e1f7a4bee9
- Updated default database configuration to use PostgreSQL instead of SQLite when no database.xml exists. - Removed obsolete migration routines: MigrateUserDb, RemoveDuplicateExtras, ReseedFolderFlag. - Introduced new read-only view projections for PostgreSQL: ItemPersonView, ItemProviderView, LibrarySummaryView, MediaStreamInfoView, UserPlaybackHistoryView, VideoItemView. - Registered view mappings in PostgresDatabaseProvider to ensure proper handling of read-only views. - Cleaned up unused SQLite cache size configuration in ConfigurationExtensions. Co-authored-by: Copilot <copilot@github.com>
303 lines
11 KiB
C#
303 lines
11 KiB
C#
// <copyright file="DatabaseViewsController.cs" company="PlaceholderCompany">
|
|
// Copyright (c) PlaceholderCompany. All rights reserved.
|
|
// </copyright>
|
|
|
|
namespace Jellyfin.Api.Controllers;
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Database.Implementations;
|
|
using Jellyfin.Database.Implementations.Entities.Views;
|
|
using MediaBrowser.Common.Api;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
/// <summary>
|
|
/// Exposes the PostgreSQL reporting views as read-only API endpoints.
|
|
/// All endpoints require administrator privileges.
|
|
/// </summary>
|
|
[Route("DatabaseViews")]
|
|
[Authorize(Policy = Policies.RequiresElevation)]
|
|
public class DatabaseViewsController : BaseJellyfinApiController
|
|
{
|
|
private readonly IDbContextFactory<JellyfinDbContext> _dbFactory;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="DatabaseViewsController"/> class.
|
|
/// </summary>
|
|
/// <param name="dbFactory">The EF Core context factory.</param>
|
|
public DatabaseViewsController(IDbContextFactory<JellyfinDbContext> dbFactory)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a flat list of all media streams (audio + video columns merged).
|
|
/// Maps to library."MediaStreamInfos_v".
|
|
/// </summary>
|
|
/// <param name="itemId">Optional item ID filter.</param>
|
|
/// <param name="streamType">Optional stream type filter (0=Audio, 1=Video, 2=Subtitle).</param>
|
|
/// <param name="startIndex">Record index to start at.</param>
|
|
/// <param name="limit">Maximum records to return (1-1000, default 100).</param>
|
|
/// <returns>List of media stream rows.</returns>
|
|
[HttpGet("MediaStreamInfos")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public async Task<ActionResult<IEnumerable<MediaStreamInfoView>>> GetMediaStreamInfos(
|
|
[FromQuery] Guid? itemId = null,
|
|
[FromQuery] int? streamType = null,
|
|
[FromQuery] int startIndex = 0,
|
|
[FromQuery] int limit = 100)
|
|
{
|
|
limit = Math.Clamp(limit, 1, 1000);
|
|
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
|
|
|
|
var query = db.MediaStreamInfoViews.AsNoTracking();
|
|
if (itemId.HasValue)
|
|
{
|
|
var id = itemId.Value;
|
|
query = query.Where(x => x.ItemId.HasValue && x.ItemId.Value.Equals(id));
|
|
}
|
|
|
|
if (streamType.HasValue)
|
|
{
|
|
query = query.Where(x => x.StreamType == streamType.Value);
|
|
}
|
|
|
|
IEnumerable<MediaStreamInfoView> results = await query
|
|
.OrderBy(x => x.ItemId)
|
|
.ThenBy(x => x.StreamIndex)
|
|
.Skip(startIndex)
|
|
.Take(limit)
|
|
.ToListAsync()
|
|
.ConfigureAwait(false);
|
|
|
|
return Ok(results);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets video items joined with primary stream technical details.
|
|
/// Maps to library."VideoItems_v".
|
|
/// </summary>
|
|
/// <param name="type">Optional partial type filter (e.g. "Movie", "Episode").</param>
|
|
/// <param name="minHeight">Optional minimum video height (e.g. 2160 for 4K).</param>
|
|
/// <param name="hdrFormat">Optional HDR format ("Dolby Vision", "HDR10+", "HDR10", "HLG", "SDR").</param>
|
|
/// <param name="startIndex">Record index to start at.</param>
|
|
/// <param name="limit">Maximum records to return (1-1000, default 100).</param>
|
|
/// <returns>List of video item rows.</returns>
|
|
[HttpGet("VideoItems")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public async Task<ActionResult<IEnumerable<VideoItemView>>> GetVideoItems(
|
|
[FromQuery] string? type = null,
|
|
[FromQuery] int? minHeight = null,
|
|
[FromQuery] string? hdrFormat = null,
|
|
[FromQuery] int startIndex = 0,
|
|
[FromQuery] int limit = 100)
|
|
{
|
|
limit = Math.Clamp(limit, 1, 1000);
|
|
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
|
|
|
|
var query = db.VideoItemViews.AsNoTracking();
|
|
if (!string.IsNullOrEmpty(type))
|
|
{
|
|
query = query.Where(x => x.Type != null && x.Type.Contains(type));
|
|
}
|
|
|
|
if (minHeight.HasValue)
|
|
{
|
|
query = query.Where(x => x.Height >= minHeight.Value);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(hdrFormat))
|
|
{
|
|
query = query.Where(x => x.HDRFormat == hdrFormat);
|
|
}
|
|
|
|
IEnumerable<VideoItemView> results = await query
|
|
.OrderBy(x => x.Name)
|
|
.Skip(startIndex)
|
|
.Take(limit)
|
|
.ToListAsync()
|
|
.ConfigureAwait(false);
|
|
|
|
return Ok(results);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets per-user playback history with calculated progress percentages.
|
|
/// Maps to library."UserPlaybackHistory_v".
|
|
/// </summary>
|
|
/// <param name="username">Optional exact username filter.</param>
|
|
/// <param name="itemId">Optional item ID filter.</param>
|
|
/// <param name="inProgressOnly">When true, returns only items started but not fully played.</param>
|
|
/// <param name="startIndex">Record index to start at.</param>
|
|
/// <param name="limit">Maximum records to return (1-1000, default 100).</param>
|
|
/// <returns>List of playback history rows.</returns>
|
|
[HttpGet("PlaybackHistory")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public async Task<ActionResult<IEnumerable<UserPlaybackHistoryView>>> GetPlaybackHistory(
|
|
[FromQuery] string? username = null,
|
|
[FromQuery] Guid? itemId = null,
|
|
[FromQuery] bool inProgressOnly = false,
|
|
[FromQuery] int startIndex = 0,
|
|
[FromQuery] int limit = 100)
|
|
{
|
|
limit = Math.Clamp(limit, 1, 1000);
|
|
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
|
|
|
|
var query = db.UserPlaybackHistoryViews.AsNoTracking();
|
|
if (!string.IsNullOrEmpty(username))
|
|
{
|
|
query = query.Where(x => x.Username == username);
|
|
}
|
|
|
|
if (itemId.HasValue)
|
|
{
|
|
var id = itemId.Value;
|
|
query = query.Where(x => x.ItemId.HasValue && x.ItemId.Value.Equals(id));
|
|
}
|
|
|
|
if (inProgressOnly)
|
|
{
|
|
query = query.Where(x => x.Played != true && x.ProgressPct > 0);
|
|
}
|
|
|
|
IEnumerable<UserPlaybackHistoryView> results = await query
|
|
.OrderByDescending(x => x.LastPlayedDate)
|
|
.Skip(startIndex)
|
|
.Take(limit)
|
|
.ToListAsync()
|
|
.ConfigureAwait(false);
|
|
|
|
return Ok(results);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets items with their external provider IDs (IMDb, TMDB, TVDB, etc.) in named columns.
|
|
/// Maps to library."ItemProviders_v".
|
|
/// </summary>
|
|
/// <param name="type">Optional partial type filter.</param>
|
|
/// <param name="imdbId">Optional exact IMDb ID filter.</param>
|
|
/// <param name="tmdbId">Optional exact TMDB ID filter.</param>
|
|
/// <param name="tvdbId">Optional exact TVDB ID filter.</param>
|
|
/// <param name="startIndex">Record index to start at.</param>
|
|
/// <param name="limit">Maximum records to return (1-1000, default 100).</param>
|
|
/// <returns>List of item provider rows.</returns>
|
|
[HttpGet("ItemProviders")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public async Task<ActionResult<IEnumerable<ItemProviderView>>> GetItemProviders(
|
|
[FromQuery] string? type = null,
|
|
[FromQuery] string? imdbId = null,
|
|
[FromQuery] string? tmdbId = null,
|
|
[FromQuery] string? tvdbId = null,
|
|
[FromQuery] int startIndex = 0,
|
|
[FromQuery] int limit = 100)
|
|
{
|
|
limit = Math.Clamp(limit, 1, 1000);
|
|
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
|
|
|
|
var query = db.ItemProviderViews.AsNoTracking();
|
|
if (!string.IsNullOrEmpty(type))
|
|
{
|
|
query = query.Where(x => x.Type != null && x.Type.Contains(type));
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(imdbId))
|
|
{
|
|
query = query.Where(x => x.ImdbId == imdbId);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(tmdbId))
|
|
{
|
|
query = query.Where(x => x.TmdbId == tmdbId);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(tvdbId))
|
|
{
|
|
query = query.Where(x => x.TvdbId == tvdbId);
|
|
}
|
|
|
|
IEnumerable<ItemProviderView> results = await query
|
|
.OrderBy(x => x.Name)
|
|
.Skip(startIndex)
|
|
.Take(limit)
|
|
.ToListAsync()
|
|
.ConfigureAwait(false);
|
|
|
|
return Ok(results);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets cast and crew entries linked to the items they appear in.
|
|
/// Maps to library."ItemPeople_v".
|
|
/// </summary>
|
|
/// <param name="personName">Optional exact person name filter.</param>
|
|
/// <param name="personType">Optional person type filter (e.g. "Actor", "Director").</param>
|
|
/// <param name="itemId">Optional item ID filter.</param>
|
|
/// <param name="startIndex">Record index to start at.</param>
|
|
/// <param name="limit">Maximum records to return (1-1000, default 100).</param>
|
|
/// <returns>List of item person rows.</returns>
|
|
[HttpGet("ItemPeople")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public async Task<ActionResult<IEnumerable<ItemPersonView>>> GetItemPeople(
|
|
[FromQuery] string? personName = null,
|
|
[FromQuery] string? personType = null,
|
|
[FromQuery] Guid? itemId = null,
|
|
[FromQuery] int startIndex = 0,
|
|
[FromQuery] int limit = 100)
|
|
{
|
|
limit = Math.Clamp(limit, 1, 1000);
|
|
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
|
|
|
|
var query = db.ItemPersonViews.AsNoTracking();
|
|
if (!string.IsNullOrEmpty(personName))
|
|
{
|
|
query = query.Where(x => x.PersonName == personName);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(personType))
|
|
{
|
|
query = query.Where(x => x.PersonType == personType);
|
|
}
|
|
|
|
if (itemId.HasValue)
|
|
{
|
|
var id = itemId.Value;
|
|
query = query.Where(x => x.ItemId.HasValue && x.ItemId.Value.Equals(id));
|
|
}
|
|
|
|
IEnumerable<ItemPersonView> results = await query
|
|
.OrderBy(x => x.PersonName)
|
|
.ThenBy(x => x.SortOrder)
|
|
.Skip(startIndex)
|
|
.Take(limit)
|
|
.ToListAsync()
|
|
.ConfigureAwait(false);
|
|
|
|
return Ok(results);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets aggregate library statistics - one row per item type.
|
|
/// Maps to library."LibrarySummary_v".
|
|
/// </summary>
|
|
/// <returns>List of library summary rows, one per item type.</returns>
|
|
[HttpGet("LibrarySummary")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public async Task<ActionResult<IEnumerable<LibrarySummaryView>>> GetLibrarySummary()
|
|
{
|
|
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
|
|
|
|
IEnumerable<LibrarySummaryView> results = await db.LibrarySummaryViews
|
|
.AsNoTracking()
|
|
.OrderBy(x => x.Type)
|
|
.ToListAsync()
|
|
.ConfigureAwait(false);
|
|
|
|
return Ok(results);
|
|
}
|
|
}
|