Files
wjones e1f7a4bee9 Refactor database configuration and migrations for PostgreSQL support
- 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>
2026-04-30 18:47:48 -04:00

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);
}
}