Add JSON config, DB-backed library options, and docs
- Add JSON-based config loading with XML fallback for DB and library options - Implement LibraryOptionsRepository with EF Core, migrations, and entity - Update CollectionFolder to use DB-backed options with XML fallback/backfill - Register repository in DI and initialize at startup - Use EF execution strategy for transactional DB operations - Suppress code analysis warnings in .csproj and test files - Add DATABASE_MIGRATION.md, LIBRARY_OPTIONS_DB_DESIGN.md, and WEBSOCKET_AUTHENTICATION.md - Add database.json.example and improve migration docs - Add tests for JSON config loader and update test naming warnings
This commit is contained in:
@@ -0,0 +1,710 @@
|
||||
# Library Options DB Design
|
||||
|
||||
This document turns the earlier storage discussion into concrete PostgreSQL DDL and C# repository sketches that fit the patterns already used in this repository.
|
||||
|
||||
Relevant existing patterns:
|
||||
|
||||
- PostgreSQL migrations use `MigrationBuilder.Sql(...)` in provider-specific migrations under `src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/Migrations/`.
|
||||
- EF entities live under `src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/`.
|
||||
- `DbSet<>` registrations live in `src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs`.
|
||||
- PostgreSQL table mapping lives in `src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/PostgresDatabaseProvider.cs`.
|
||||
- Runtime repositories typically use `IDbContextFactory<JellyfinDbContext>` and `await using var context = _dbProvider.CreateDbContext();` as seen in `Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs`.
|
||||
- Virtual path storage is already handled by `IServerApplicationHost.ReverseVirtualPath(...)` and `ExpandVirtualPath(...)`.
|
||||
|
||||
## Option 1: Minimal-Change JSON-In-DB
|
||||
|
||||
### Goal
|
||||
|
||||
Persist one `LibraryOptions` document per collection folder in PostgreSQL with minimal change to the existing `CollectionFolder.GetLibraryOptions(...)` and `SaveLibraryOptions(...)` flow.
|
||||
|
||||
### Table Design
|
||||
|
||||
```sql
|
||||
CREATE TABLE library."LibraryOptions" (
|
||||
"LibraryId" uuid NOT NULL,
|
||||
"LibraryPath" text NOT NULL,
|
||||
"OptionsJson" jsonb NOT NULL,
|
||||
"Version" integer NOT NULL DEFAULT 1,
|
||||
"DateCreated" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"DateModified" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_LibraryOptions" PRIMARY KEY ("LibraryId")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "IX_LibraryOptions_LibraryPath"
|
||||
ON library."LibraryOptions" USING btree ("LibraryPath");
|
||||
|
||||
CREATE INDEX "IX_LibraryOptions_DateModified"
|
||||
ON library."LibraryOptions" USING btree ("DateModified" DESC);
|
||||
```
|
||||
|
||||
### Suggested PostgreSQL Migration
|
||||
|
||||
```csharp
|
||||
// <copyright file="20260501000000_AddLibraryOptionsJsonStore.cs" company="PlaceholderCompany">
|
||||
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jellyfin.Database.Providers.Postgres.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLibraryOptionsJsonStore : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE TABLE IF NOT EXISTS library.""LibraryOptions"" (
|
||||
""LibraryId"" uuid NOT NULL,
|
||||
""LibraryPath"" text NOT NULL,
|
||||
""OptionsJson"" jsonb NOT NULL,
|
||||
""Version"" integer NOT NULL DEFAULT 1,
|
||||
""DateCreated"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""DateModified"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT ""PK_LibraryOptions"" PRIMARY KEY (""LibraryId"")
|
||||
);
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_LibraryOptions_LibraryPath""
|
||||
ON library.""LibraryOptions"" (""LibraryPath"");
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE INDEX IF NOT EXISTS ""IX_LibraryOptions_DateModified""
|
||||
ON library.""LibraryOptions"" (""DateModified"" DESC);
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"DROP TABLE IF EXISTS library.""LibraryOptions"" CASCADE;");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Suggested Entity
|
||||
|
||||
```csharp
|
||||
// <copyright file="LibraryOptionsEntity.cs" company="PlaceholderCompany">
|
||||
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||
// </copyright>
|
||||
|
||||
namespace Jellyfin.Database.Implementations.Entities;
|
||||
|
||||
using System;
|
||||
|
||||
public class LibraryOptionsEntity
|
||||
{
|
||||
public Guid LibraryId { get; set; }
|
||||
|
||||
public required string LibraryPath { get; set; }
|
||||
|
||||
public required string OptionsJson { get; set; }
|
||||
|
||||
public int Version { get; set; }
|
||||
|
||||
public DateTime DateCreated { get; set; }
|
||||
|
||||
public DateTime DateModified { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### DbContext / Provider Mapping
|
||||
|
||||
Add to `JellyfinDbContext`:
|
||||
|
||||
```csharp
|
||||
public DbSet<LibraryOptionsEntity> LibraryOptions => this.Set<LibraryOptionsEntity>();
|
||||
```
|
||||
|
||||
Add to `PostgresDatabaseProvider.ConfigureEntities(...)`:
|
||||
|
||||
```csharp
|
||||
modelBuilder.Entity<LibraryOptionsEntity>().ToTable("LibraryOptions", Schemas.Library);
|
||||
```
|
||||
|
||||
Recommended extra configuration:
|
||||
|
||||
```csharp
|
||||
modelBuilder.Entity<LibraryOptionsEntity>()
|
||||
.HasIndex(e => e.LibraryPath)
|
||||
.IsUnique();
|
||||
```
|
||||
|
||||
### Repository Sketch
|
||||
|
||||
This sketch follows the `MediaStreamRepository` and `BaseItemRepository` conventions already used in the codebase.
|
||||
|
||||
```csharp
|
||||
// <copyright file="LibraryOptionsRepository.cs" company="PlaceholderCompany">
|
||||
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||
// </copyright>
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Library;
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class LibraryOptionsRepository
|
||||
{
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
|
||||
public LibraryOptionsRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IServerApplicationHost appHost)
|
||||
{
|
||||
_dbProvider = dbProvider;
|
||||
_appHost = appHost;
|
||||
}
|
||||
|
||||
public async Task<LibraryOptions?> GetLibraryOptionsAsync(Guid libraryId, string libraryPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var context = _dbProvider.CreateDbContext();
|
||||
|
||||
var entity = await context.LibraryOptions
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(e => e.LibraryId == libraryId || e.LibraryPath == libraryPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var options = JsonSerializer.Deserialize<LibraryOptions>(entity.OptionsJson, JsonDefaults.Options) ?? new LibraryOptions();
|
||||
|
||||
foreach (var mediaPath in options.PathInfos)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(mediaPath.Path))
|
||||
{
|
||||
mediaPath.Path = _appHost.ExpandVirtualPath(mediaPath.Path);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public async Task SaveLibraryOptionsAsync(Guid libraryId, string libraryPath, LibraryOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var context = _dbProvider.CreateDbContext();
|
||||
|
||||
var executionStrategy = context.Database.CreateExecutionStrategy();
|
||||
await executionStrategy.ExecuteAsync(async () =>
|
||||
{
|
||||
var clone = JsonSerializer.Deserialize<LibraryOptions>(JsonSerializer.SerializeToUtf8Bytes(options, JsonDefaults.Options), JsonDefaults.Options)
|
||||
?? new LibraryOptions();
|
||||
|
||||
foreach (var mediaPath in clone.PathInfos)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(mediaPath.Path))
|
||||
{
|
||||
mediaPath.Path = _appHost.ReverseVirtualPath(mediaPath.Path);
|
||||
}
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(clone, JsonDefaults.Options);
|
||||
var entity = await context.LibraryOptions
|
||||
.SingleOrDefaultAsync(e => e.LibraryId == libraryId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
entity = new LibraryOptionsEntity
|
||||
{
|
||||
LibraryId = libraryId,
|
||||
LibraryPath = libraryPath,
|
||||
OptionsJson = json,
|
||||
Version = 1,
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await context.LibraryOptions.AddAsync(entity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.LibraryPath = libraryPath;
|
||||
entity.OptionsJson = json;
|
||||
entity.DateModified = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CollectionFolder Integration Sketch
|
||||
|
||||
The minimal integration strategy is:
|
||||
|
||||
1. Change `CollectionFolder.LoadLibraryOptions(...)` to try the repository first.
|
||||
2. If no database row exists, fall back to `options.xml`.
|
||||
3. After a successful XML read, persist the value into `library."LibraryOptions"`.
|
||||
4. Keep the current static cache keyed by library path.
|
||||
|
||||
Pseudo-flow:
|
||||
|
||||
```csharp
|
||||
var fromDb = await _libraryOptionsRepository.GetLibraryOptionsAsync(libraryId, path, cancellationToken);
|
||||
if (fromDb is not null)
|
||||
{
|
||||
return fromDb;
|
||||
}
|
||||
|
||||
var fromXml = LoadLibraryOptionsFromXml(path);
|
||||
await _libraryOptionsRepository.SaveLibraryOptionsAsync(libraryId, path, fromXml, cancellationToken);
|
||||
return fromXml;
|
||||
```
|
||||
|
||||
### Tradeoffs
|
||||
|
||||
- Lowest risk.
|
||||
- No relational visibility into individual media roots.
|
||||
- Best fit if the goal is only to stop using `options.xml` as the persistence backend.
|
||||
|
||||
## Option 2: Hybrid JSON + Queryable Media Paths
|
||||
|
||||
### Goal
|
||||
|
||||
Keep the full `LibraryOptions` object as JSON for compatibility, but project `PathInfos` into a separate relational table so path-level reporting and joins are cheap and reliable.
|
||||
|
||||
### Table Design
|
||||
|
||||
```sql
|
||||
CREATE TABLE library."LibraryOptions" (
|
||||
"LibraryId" uuid NOT NULL,
|
||||
"LibraryPath" text NOT NULL,
|
||||
"OptionsJson" jsonb NOT NULL,
|
||||
"Version" integer NOT NULL DEFAULT 1,
|
||||
"DateCreated" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"DateModified" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_LibraryOptions" PRIMARY KEY ("LibraryId")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "IX_LibraryOptions_LibraryPath"
|
||||
ON library."LibraryOptions" USING btree ("LibraryPath");
|
||||
|
||||
CREATE TABLE library."LibraryMediaPaths" (
|
||||
"Id" uuid NOT NULL,
|
||||
"LibraryId" uuid NOT NULL,
|
||||
"Path" text NOT NULL,
|
||||
"PathVirtual" text NOT NULL,
|
||||
"Position" integer NOT NULL,
|
||||
"DateCreated" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"DateModified" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_LibraryMediaPaths" PRIMARY KEY ("Id"),
|
||||
CONSTRAINT "FK_LibraryMediaPaths_LibraryOptions_LibraryId"
|
||||
FOREIGN KEY ("LibraryId") REFERENCES library."LibraryOptions" ("LibraryId") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "IX_LibraryMediaPaths_LibraryId_Position"
|
||||
ON library."LibraryMediaPaths" USING btree ("LibraryId", "Position");
|
||||
|
||||
CREATE INDEX "IX_LibraryMediaPaths_Path"
|
||||
ON library."LibraryMediaPaths" USING btree ("Path");
|
||||
|
||||
CREATE INDEX "IX_LibraryMediaPaths_PathVirtual"
|
||||
ON library."LibraryMediaPaths" USING btree ("PathVirtual");
|
||||
|
||||
CREATE INDEX "IX_LibraryMediaPaths_LibraryId"
|
||||
ON library."LibraryMediaPaths" USING btree ("LibraryId");
|
||||
```
|
||||
|
||||
### Suggested PostgreSQL Migration
|
||||
|
||||
```csharp
|
||||
// <copyright file="20260501001000_AddLibraryOptionsHybridStore.cs" company="PlaceholderCompany">
|
||||
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jellyfin.Database.Providers.Postgres.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLibraryOptionsHybridStore : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE TABLE IF NOT EXISTS library.""LibraryOptions"" (
|
||||
""LibraryId"" uuid NOT NULL,
|
||||
""LibraryPath"" text NOT NULL,
|
||||
""OptionsJson"" jsonb NOT NULL,
|
||||
""Version"" integer NOT NULL DEFAULT 1,
|
||||
""DateCreated"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""DateModified"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT ""PK_LibraryOptions"" PRIMARY KEY (""LibraryId"")
|
||||
);
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_LibraryOptions_LibraryPath""
|
||||
ON library.""LibraryOptions"" (""LibraryPath"");
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE TABLE IF NOT EXISTS library.""LibraryMediaPaths"" (
|
||||
""Id"" uuid NOT NULL,
|
||||
""LibraryId"" uuid NOT NULL,
|
||||
""Path"" text NOT NULL,
|
||||
""PathVirtual"" text NOT NULL,
|
||||
""Position"" integer NOT NULL,
|
||||
""DateCreated"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
""DateModified"" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT ""PK_LibraryMediaPaths"" PRIMARY KEY (""Id""),
|
||||
CONSTRAINT ""FK_LibraryMediaPaths_LibraryOptions_LibraryId""
|
||||
FOREIGN KEY (""LibraryId"") REFERENCES library.""LibraryOptions"" (""LibraryId"") ON DELETE CASCADE
|
||||
);
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_LibraryMediaPaths_LibraryId_Position""
|
||||
ON library.""LibraryMediaPaths"" (""LibraryId"", ""Position"");
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE INDEX IF NOT EXISTS ""IX_LibraryMediaPaths_Path""
|
||||
ON library.""LibraryMediaPaths"" (""Path"");
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE INDEX IF NOT EXISTS ""IX_LibraryMediaPaths_PathVirtual""
|
||||
ON library.""LibraryMediaPaths"" (""PathVirtual"");
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE INDEX IF NOT EXISTS ""IX_LibraryMediaPaths_LibraryId""
|
||||
ON library.""LibraryMediaPaths"" (""LibraryId"");
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"DROP TABLE IF EXISTS library.""LibraryMediaPaths"" CASCADE;");
|
||||
migrationBuilder.Sql(@"DROP TABLE IF EXISTS library.""LibraryOptions"" CASCADE;");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Suggested Entities
|
||||
|
||||
```csharp
|
||||
// <copyright file="LibraryOptionsEntity.cs" company="PlaceholderCompany">
|
||||
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||
// </copyright>
|
||||
|
||||
namespace Jellyfin.Database.Implementations.Entities;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
public class LibraryOptionsEntity
|
||||
{
|
||||
public Guid LibraryId { get; set; }
|
||||
|
||||
public required string LibraryPath { get; set; }
|
||||
|
||||
public required string OptionsJson { get; set; }
|
||||
|
||||
public int Version { get; set; }
|
||||
|
||||
public DateTime DateCreated { get; set; }
|
||||
|
||||
public DateTime DateModified { get; set; }
|
||||
|
||||
public ICollection<LibraryMediaPathEntity> MediaPaths { get; set; } = new List<LibraryMediaPathEntity>();
|
||||
}
|
||||
```
|
||||
|
||||
```csharp
|
||||
// <copyright file="LibraryMediaPathEntity.cs" company="PlaceholderCompany">
|
||||
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||
// </copyright>
|
||||
|
||||
namespace Jellyfin.Database.Implementations.Entities;
|
||||
|
||||
using System;
|
||||
|
||||
public class LibraryMediaPathEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid LibraryId { get; set; }
|
||||
|
||||
public required string Path { get; set; }
|
||||
|
||||
public required string PathVirtual { get; set; }
|
||||
|
||||
public int Position { get; set; }
|
||||
|
||||
public DateTime DateCreated { get; set; }
|
||||
|
||||
public DateTime DateModified { get; set; }
|
||||
|
||||
public LibraryOptionsEntity LibraryOptions { get; set; } = null!;
|
||||
}
|
||||
```
|
||||
|
||||
### DbContext / Provider Mapping
|
||||
|
||||
Add to `JellyfinDbContext`:
|
||||
|
||||
```csharp
|
||||
public DbSet<LibraryOptionsEntity> LibraryOptions => this.Set<LibraryOptionsEntity>();
|
||||
|
||||
public DbSet<LibraryMediaPathEntity> LibraryMediaPaths => this.Set<LibraryMediaPathEntity>();
|
||||
```
|
||||
|
||||
Add to `PostgresDatabaseProvider.ConfigureEntities(...)`:
|
||||
|
||||
```csharp
|
||||
modelBuilder.Entity<LibraryOptionsEntity>().ToTable("LibraryOptions", Schemas.Library);
|
||||
modelBuilder.Entity<LibraryMediaPathEntity>().ToTable("LibraryMediaPaths", Schemas.Library);
|
||||
|
||||
modelBuilder.Entity<LibraryOptionsEntity>()
|
||||
.HasMany(e => e.MediaPaths)
|
||||
.WithOne(e => e.LibraryOptions)
|
||||
.HasForeignKey(e => e.LibraryId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<LibraryOptionsEntity>()
|
||||
.HasIndex(e => e.LibraryPath)
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<LibraryMediaPathEntity>()
|
||||
.HasIndex(e => new { e.LibraryId, e.Position })
|
||||
.IsUnique();
|
||||
```
|
||||
|
||||
### Repository Sketch
|
||||
|
||||
```csharp
|
||||
// <copyright file="LibraryOptionsRepository.cs" company="PlaceholderCompany">
|
||||
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||
// </copyright>
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Library;
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
public class LibraryOptionsRepository
|
||||
{
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
|
||||
public LibraryOptionsRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IServerApplicationHost appHost)
|
||||
{
|
||||
_dbProvider = dbProvider;
|
||||
_appHost = appHost;
|
||||
}
|
||||
|
||||
public async Task<LibraryOptions?> GetLibraryOptionsAsync(Guid libraryId, string libraryPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var context = _dbProvider.CreateDbContext();
|
||||
|
||||
var entity = await context.LibraryOptions
|
||||
.AsNoTracking()
|
||||
.Include(e => e.MediaPaths.OrderBy(p => p.Position))
|
||||
.SingleOrDefaultAsync(e => e.LibraryId == libraryId || e.LibraryPath == libraryPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var options = JsonSerializer.Deserialize<LibraryOptions>(entity.OptionsJson, JsonDefaults.Options) ?? new LibraryOptions();
|
||||
|
||||
foreach (var mediaPath in options.PathInfos)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(mediaPath.Path))
|
||||
{
|
||||
mediaPath.Path = _appHost.ExpandVirtualPath(mediaPath.Path);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public async Task SaveLibraryOptionsAsync(Guid libraryId, string libraryPath, LibraryOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var context = _dbProvider.CreateDbContext();
|
||||
|
||||
var executionStrategy = context.Database.CreateExecutionStrategy();
|
||||
await executionStrategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var clone = JsonSerializer.Deserialize<LibraryOptions>(JsonSerializer.SerializeToUtf8Bytes(options, JsonDefaults.Options), JsonDefaults.Options)
|
||||
?? new LibraryOptions();
|
||||
|
||||
foreach (var mediaPath in clone.PathInfos)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(mediaPath.Path))
|
||||
{
|
||||
mediaPath.Path = _appHost.ReverseVirtualPath(mediaPath.Path);
|
||||
}
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(clone, JsonDefaults.Options);
|
||||
var utcNow = DateTime.UtcNow;
|
||||
|
||||
var entity = await context.LibraryOptions
|
||||
.Include(e => e.MediaPaths)
|
||||
.SingleOrDefaultAsync(e => e.LibraryId == libraryId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
entity = new LibraryOptionsEntity
|
||||
{
|
||||
LibraryId = libraryId,
|
||||
LibraryPath = libraryPath,
|
||||
OptionsJson = json,
|
||||
Version = 1,
|
||||
DateCreated = utcNow,
|
||||
DateModified = utcNow
|
||||
};
|
||||
|
||||
await context.LibraryOptions.AddAsync(entity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.LibraryPath = libraryPath;
|
||||
entity.OptionsJson = json;
|
||||
entity.DateModified = utcNow;
|
||||
}
|
||||
|
||||
if (entity.MediaPaths.Count > 0)
|
||||
{
|
||||
context.LibraryMediaPaths.RemoveRange(entity.MediaPaths);
|
||||
}
|
||||
|
||||
entity.MediaPaths = clone.PathInfos
|
||||
.Select((pathInfo, index) => new LibraryMediaPathEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
LibraryId = libraryId,
|
||||
Path = _appHost.ExpandVirtualPath(pathInfo.Path),
|
||||
PathVirtual = pathInfo.Path,
|
||||
Position = index,
|
||||
DateCreated = utcNow,
|
||||
DateModified = utcNow
|
||||
})
|
||||
.ToList();
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<Guid[]> GetLibrariesByMediaPathAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var context = _dbProvider.CreateDbContext();
|
||||
|
||||
return await context.LibraryMediaPaths
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Path == path || e.PathVirtual == path)
|
||||
.OrderBy(e => e.Position)
|
||||
.Select(e => e.LibraryId)
|
||||
.Distinct()
|
||||
.ToArrayAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Operational Queries Enabled By The Hybrid Model
|
||||
|
||||
```sql
|
||||
SELECT lmp."LibraryId", lmp."Path", lmp."PathVirtual"
|
||||
FROM library."LibraryMediaPaths" AS lmp
|
||||
WHERE lmp."Path" = '/media/main_bridge/media/movies'
|
||||
OR lmp."PathVirtual" = '/media/main_bridge/media/movies';
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT lmp."Path", count(*) AS "LibraryCount"
|
||||
FROM library."LibraryMediaPaths" AS lmp
|
||||
GROUP BY lmp."Path"
|
||||
HAVING count(*) > 1
|
||||
ORDER BY "LibraryCount" DESC, lmp."Path";
|
||||
```
|
||||
|
||||
### Tradeoffs
|
||||
|
||||
- Slightly more implementation work than the JSON-only option.
|
||||
- Keeps the current `LibraryOptions` object model intact.
|
||||
- Makes media roots queryable, indexable, and auditable.
|
||||
- Requires a transactional write path so JSON and projected rows do not drift.
|
||||
|
||||
## Recommended Touch Points In This Repo
|
||||
|
||||
Regardless of which option is chosen, the same set of integration points will need to move:
|
||||
|
||||
- `MediaBrowser.Controller/Entities/CollectionFolder.cs`
|
||||
- Replace raw XML read/write with repository-backed read/write.
|
||||
- Keep XML fallback for a transition period if rollback safety matters.
|
||||
- `src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs`
|
||||
- Add `DbSet<>` properties.
|
||||
- `src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/`
|
||||
- Add `LibraryOptionsEntity` and, for the hybrid approach, `LibraryMediaPathEntity`.
|
||||
- `src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/PostgresDatabaseProvider.cs`
|
||||
- Add table mapping and relationship/index configuration.
|
||||
- `src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/Migrations/`
|
||||
- Add provider-specific migration(s).
|
||||
- `Jellyfin.Server.Implementations/Library/`
|
||||
- Add a repository class and register it in DI.
|
||||
|
||||
## Recommended Rollout
|
||||
|
||||
### JSON-In-DB
|
||||
|
||||
1. Add the table, entity, mapping, and repository.
|
||||
2. Read DB first, then fall back to XML.
|
||||
3. Backfill on demand when XML is encountered.
|
||||
4. Keep XML writes optional for one release if rollback support is needed.
|
||||
|
||||
### Hybrid
|
||||
|
||||
1. Add both tables, entities, mapping, and repository.
|
||||
2. Save JSON and `PathInfos` projection in one transaction.
|
||||
3. Read DB first, then fall back to XML.
|
||||
4. Add a small admin or diagnostic query surface over `LibraryMediaPaths`.
|
||||
5. Remove XML writes after migration confidence is high.
|
||||
|
||||
## Recommendation
|
||||
|
||||
If the immediate goal is only to stop relying on `options.xml`, choose the JSON-only design.
|
||||
|
||||
If the goal includes operational visibility into library roots, duplicate path detection, path remapping support, or future query-driven tooling, choose the hybrid design. The hybrid design is the better long-term fit for PostgreSQL while still preserving the current `LibraryOptions` object graph and call sites.
|
||||
Reference in New Issue
Block a user