From a4ad12f3dca05cf3f1510bdc0023c1cb6f20ad41 Mon Sep 17 00:00:00 2001 From: Timothy Date: Mon, 20 Jul 2026 00:49:49 +0200 Subject: [PATCH] docs(489): spec + implementation plan for Jellyfin mixed-content libraries Design and bite-sized TDD plan for supporting Jellyfin libraries whose collection type is 'mixed' (currently dropped silently by JellyfinApiClient.Project), so music and standup content is ingested while staying segregated from the main Movies / TV Shows libraries. Key findings that shaped the design: - Jellyfin classifies server-side via parentId + includeItemTypes, so the three per-kind scanners see disjoint authoritative sets. No inference. - No DB migration: MediaItem is TPT keyed on LibraryPathId, so heterogeneous items under one library path are already legal. - MediaKind is dispatch + presentation only; scheduling, playout, collections and search carry zero references to it. - Scoped to Jellyfin deliberately: local mixed libraries would have the scanners claim each other's files and thrash LibraryFolder etags. Blocked by #488 (JellyfinMusicVideoLibraryScanner crashes on first item). Refs #489 --- .../2026-07-20-jellyfin-mixed-libraries.md | 888 ++++++++++++++++++ ...6-07-20-jellyfin-mixed-libraries-design.md | 213 +++++ 2 files changed, 1101 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-20-jellyfin-mixed-libraries.md create mode 100644 docs/superpowers/specs/2026-07-20-jellyfin-mixed-libraries-design.md diff --git a/docs/superpowers/plans/2026-07-20-jellyfin-mixed-libraries.md b/docs/superpowers/plans/2026-07-20-jellyfin-mixed-libraries.md new file mode 100644 index 000000000..d085948a7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-jellyfin-mixed-libraries.md @@ -0,0 +1,888 @@ +# Jellyfin Mixed-Content Library Support — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ingest Jellyfin libraries whose content type is `mixed` — currently dropped silently — into a single ErsatzTV library holding movies, shows and music videos side by side, keeping that content segregated from the main `Movies` and `TV Shows` libraries. + +**Architecture:** Add `LibraryMediaKind.Mixed`, map Jellyfin's null/`mixed` `CollectionType` onto it, and give the Jellyfin sync handler a `Mixed` arm that composes the three *existing* Jellyfin scanners in sequence against the same library. Each scanner issues its own `includeItemTypes` query, so Jellyfin does the classification server-side and returns disjoint sets. No new scanner, and no DB migration — `MediaItem` is table-per-type keyed on `LibraryPathId`, so heterogeneous contents under one library path are already legal. + +**Tech Stack:** C# / .NET 10, EF Core, MediatR CQRS, LanguageExt (`Option`/`Either`), NUnit + Shouldly + NSubstitute, React SPA (Vite + TypeScript). + +**Spec:** `docs/superpowers/specs/2026-07-20-jellyfin-mixed-libraries-design.md` +**Issue:** [#489](http://192.168.1.95:3000/timothy/ersatztv/issues/489) + +## Global Constraints + +- **BLOCKED BY [#488](http://192.168.1.95:3000/timothy/ersatztv/issues/488).** `JellyfinMusicVideoLibraryScanner` throws `ArgumentNullException` on its first item because `LibraryPath.LibraryFolders` is null on the Jellyfin sync path. The `Mixed` arm invokes that scanner, so **Task 6 (live E2E) cannot pass until #488 is fixed.** Tasks 1–5 and 7 do not depend on it and may proceed. +- Work in a **worktree off `origin/main`**. Never commit in `/Users/timothy/ersatztv` — it is a shared mutable tree, not a `main` mirror. +- **Test framework is NUnit + Shouldly + NSubstitute.** Never xUnit. Assertions are `x.ShouldBe(y)`, not `Assert.AreEqual`. +- **No `Version=` on any ``** — this repo uses Central Package Management via the root `Directory.Packages.props`. +- **Before any push touching `.cs`, check the touched set for a UTF-8 BOM** (`charset=utf-8` means no BOM): + `for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done` + Strip any hit. Note `LibraryMediaKind.cs` and `JellyfinApiClient.cs` **currently carry a BOM** (`` before `namespace`) — preserve the file as-is or strip deliberately, but do not let an editor add one where there was none. +- **Never set `ETV_UPDATE_GOLDENS`.** +- Do not add a DB migration. If you find yourself writing one, stop — the design explicitly requires none, and needing one means the approach has drifted. + +--- + +## File Structure + +| File | Responsibility | Action | +|---|---|---| +| `ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs` | the enum | Modify — add `Mixed = 8` | +| `ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs` | Jellyfin library → domain projection | Modify — map null/`mixed` | +| `ErsatzTV.Infrastructure.Tests/Jellyfin/JellyfinApiClientTests.cs` | projection tests | Modify — add cases | +| `ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs` | scanner dispatch | Modify — `Mixed` arm + fail-loud default | +| `ErsatzTV.Scanner.Tests/Application/Jellyfin/SynchronizeJellyfinLibraryByIdHandlerTests.cs` | dispatch tests | Modify — add cases | +| `ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinShowByIdHandler.cs` | targeted show scan | Modify — accept `Mixed` | +| `ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs` | local scanner dispatch | Modify — fail-loud default | +| `ErsatzTV/wwwroot/openapi/v1.json`, `docs/endpoint-index.md`, `web/src/api/generated/v1.d.ts` | generated artifacts | Regenerate — never hand-edit | +| `docs/decisions.md` | decision record | Modify — append | + +**No SPA source changes are required.** Verified: `LibrariesScreen.libraryMediaIcon` and `formatLibraryMediaKind` both have `default:` arms (`Folder` icon, raw kind string), so `Mixed` renders sensibly with no edit. `LocalLibraryEditScreen.MEDIA_KIND_OPTIONS` is an explicit allowlist of 7 entries, so `Mixed` is automatically **not** offered when creating a local library — which is the desired behaviour. Task 5 only regenerates the typed client. + +--- + +### Task 1: Add the `Mixed` media kind + +**Files:** +- Modify: `ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs` + +**Interfaces:** +- Consumes: nothing. +- Produces: `LibraryMediaKind.Mixed` (value `8`), consumed by Tasks 2, 3, 4. + +- [ ] **Step 1: Add the enum member** + +The file currently reads: + +```csharp +namespace ErsatzTV.Core.Domain; + +public enum LibraryMediaKind +{ + Movies = 1, + Shows = 2, + MusicVideos = 3, + OtherVideos = 4, + Songs = 5, + Images = 6, + RemoteStreams = 7 +} +``` + +Change it to: + +```csharp +namespace ErsatzTV.Core.Domain; + +public enum LibraryMediaKind +{ + Movies = 1, + Shows = 2, + MusicVideos = 3, + OtherVideos = 4, + Songs = 5, + Images = 6, + RemoteStreams = 7, + + /// + /// A library whose contents are heterogeneous — movies, shows and music videos together. + /// Only produced for remote (Jellyfin) libraries whose collection type is "mixed"; a local + /// library can never be Mixed, because the local folder scanners all share + /// 's video extension list and + /// would claim each other's files. + /// + Mixed = 8 +} +``` + +Explicit values matter: this is persisted as an `int` in `Library.MediaKind`. Append only — never renumber. + +- [ ] **Step 2: Verify it compiles** + +Run: `dotnet build ErsatzTV.Core/ErsatzTV.Core.csproj` +Expected: `Build succeeded`, `0 Error(s)`. + +- [ ] **Step 3: Commit** + +```bash +git add ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs +git commit -m "feat(489): add LibraryMediaKind.Mixed" +``` + +--- + +### Task 2: Project mixed Jellyfin libraries instead of dropping them + +**Files:** +- Modify: `ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs` (the `Project(JellyfinLibraryResponse)` method) +- Test: `ErsatzTV.Infrastructure.Tests/Jellyfin/JellyfinApiClientTests.cs` + +**Interfaces:** +- Consumes: `LibraryMediaKind.Mixed` from Task 1. +- Produces: `JellyfinLibrary { MediaKind = LibraryMediaKind.Mixed, ShouldSyncItems = false, Paths = [ "jellyfin://{ItemId}" ] }` for mixed libraries. Task 3 dispatches on it. + +Background: Jellyfin represents a mixed library by a `mixed.collection` marker file. In the `/Library/VirtualFolders` response this surfaces as `CollectionType` being either **absent/null** or the literal string `"mixed"` depending on server version, so both must be handled. + +- [ ] **Step 1: Write the failing tests** + +Add these two tests inside the existing `GetLibraries` fixture in `JellyfinApiClientTests.cs`, directly after `Should_Project_MusicVideo_Libraries`. They reuse the file's existing `SingleResponseHttpClientFactory` helper. + +```csharp + [Test] + public async Task Should_Project_Mixed_Libraries() + { + const string response = """ + [ + { + "Name": "Music Videos", + "CollectionType": "mixed", + "ItemId": "library-9", + "LibraryOptions": { + "PathInfos": [] + } + } + ] + """; + + var client = new JellyfinApiClient( + new MemoryCache(new MemoryCacheOptions()), + Substitute.For(), + Substitute.For(), + new SingleResponseHttpClientFactory(response), + Substitute.For>()); + + Either> result = + await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc"); + + result.IsRight.ShouldBeTrue(); + List libraries = result.RightToSeq().Single(); + libraries.Count.ShouldBe(1); + libraries[0].Name.ShouldBe("Music Videos"); + libraries[0].ItemId.ShouldBe("library-9"); + libraries[0].MediaKind.ShouldBe(LibraryMediaKind.Mixed); + libraries[0].ShouldSyncItems.ShouldBeFalse(); + libraries[0].Paths.Single().Path.ShouldBe("jellyfin://library-9"); + } + + [Test] + public async Task Should_Project_Libraries_With_No_CollectionType_As_Mixed() + { + const string response = """ + [ + { + "Name": "Standup", + "ItemId": "library-10", + "LibraryOptions": { + "PathInfos": [] + } + } + ] + """; + + var client = new JellyfinApiClient( + new MemoryCache(new MemoryCacheOptions()), + Substitute.For(), + Substitute.For(), + new SingleResponseHttpClientFactory(response), + Substitute.For>()); + + Either> result = + await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc"); + + result.IsRight.ShouldBeTrue(); + List libraries = result.RightToSeq().Single(); + libraries.Count.ShouldBe(1); + libraries[0].Name.ShouldBe("Standup"); + libraries[0].MediaKind.ShouldBe(LibraryMediaKind.Mixed); + } + + [Test] + public async Task Should_Not_Project_Unknown_CollectionTypes() + { + const string response = """ + [ + { + "Name": "Explo Discovery", + "CollectionType": "music", + "ItemId": "library-11", + "LibraryOptions": { + "PathInfos": [] + } + } + ] + """; + + var client = new JellyfinApiClient( + new MemoryCache(new MemoryCacheOptions()), + Substitute.For(), + Substitute.For(), + new SingleResponseHttpClientFactory(response), + Substitute.For>()); + + Either> result = + await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc"); + + result.IsRight.ShouldBeTrue(); + result.RightToSeq().Single().ShouldBeEmpty(); + } +``` + +The third test is the guard rail: `music` libraries must keep falling through to `None`. Without it, a careless `_ => Mixed` would sweep up audio libraries the scanners cannot handle. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj --filter "FullyQualifiedName~GetLibraries"` +Expected: `Should_Project_Mixed_Libraries` and `Should_Project_Libraries_With_No_CollectionType_As_Mixed` FAIL (0 libraries returned — currently dropped). `Should_Not_Project_Unknown_CollectionTypes` PASSES already. + +- [ ] **Step 3: Implement the mapping** + +In `JellyfinApiClient.cs`, the `Project` method currently ends: + +```csharp + // TODO: ??? for music libraries + "boxsets" => CacheCollectionLibraryId(response.ItemId), + _ => None + }; +``` + +Replace that tail with: + +```csharp + // TODO: ??? for music libraries + "boxsets" => CacheCollectionLibraryId(response.ItemId), + + // A "mixed content" library. Jellyfin reports these as either the literal "mixed" or + // with no collection type at all, depending on server version. Its items are read per + // type via includeItemTypes, so the mix is resolved authoritatively server-side. + "mixed" or null => new JellyfinLibrary + { + ItemId = response.ItemId, + Name = response.Name, + MediaKind = LibraryMediaKind.Mixed, + ShouldSyncItems = false, + Paths = new List { new() { Path = $"jellyfin://{response.ItemId}" } }, + PathInfos = GetPathInfos(response) + }, + _ => None + }; +``` + +Note the switch is over `response.CollectionType?.ToLowerInvariant()`, so the `null` pattern +matches a library with no collection type. Keep `_ => None` last so `"music"` and any future +unknown type still fall through. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj --filter "FullyQualifiedName~GetLibraries"` +Expected: all four tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs ErsatzTV.Infrastructure.Tests/Jellyfin/JellyfinApiClientTests.cs +git commit -m "feat(489): project Jellyfin mixed-content libraries instead of dropping them" +``` + +--- + +### Task 3: Dispatch a `Mixed` library to all three scanners + +**Files:** +- Modify: `ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs` (the `Synchronize` method, the `switch` at ~line 68) +- Test: `ErsatzTV.Scanner.Tests/Application/Jellyfin/SynchronizeJellyfinLibraryByIdHandlerTests.cs` + +**Interfaces:** +- Consumes: `LibraryMediaKind.Mixed` (Task 1); `JellyfinLibrary` with that kind (Task 2). +- Produces: nothing consumed downstream. This is the behavioural core. + +Design points, both load-bearing: + +1. **A failure in one arm must not skip the others.** A broken music-video scan should not stop the movies and shows in the same library from being ingested. Run all three, collect errors, and report failure only after all have run. +2. **`ScanCanceled` is not a failure and must stop the sequence.** If the user cancels mid-scan, abort immediately and propagate `ScanCanceled` — do not press on into the next scanner. + +- [ ] **Step 1: Write the failing test** + +Add to the `Handle` fixture in `SynchronizeJellyfinLibraryByIdHandlerTests.cs`, following the shape of the existing `Should_Scan_MusicVideo_Libraries` test: + +```csharp + [Test] + public async Task Should_Scan_All_Three_Kinds_For_Mixed_Libraries() + { + var scannerProxy = Substitute.For(); + var mediaSourceRepository = Substitute.For(); + var jellyfinSecretStore = Substitute.For(); + var jellyfinMovieLibraryScanner = Substitute.For(); + var jellyfinTelevisionLibraryScanner = Substitute.For(); + var jellyfinMusicVideoLibraryScanner = Substitute.For(); + var libraryRepository = Substitute.For(); + var configElementRepository = Substitute.For(); + + var library = new JellyfinLibrary + { + Id = 42, + Name = "Music Videos", + MediaKind = LibraryMediaKind.Mixed, + MediaSourceId = 7 + }; + var mediaSource = new JellyfinMediaSource + { + Id = 7, + Connections = + [ + new JellyfinConnection + { + Address = "http://jellyfin.example", + JellyfinMediaSourceId = 7 + } + ] + }; + + mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask()); + mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask()); + jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets + { + Address = "http://jellyfin.example", + ApiKey = "abc" + }); + configElementRepository.GetValue( + Arg.Is(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key), + Arg.Any()) + .Returns(Task.FromResult>(Some(0))); + + jellyfinMovieLibraryScanner.ScanLibrary( + Arg.Any(), library, true, Arg.Any()) + .Returns(Right(Unit.Default).AsTask()); + jellyfinTelevisionLibraryScanner.ScanLibrary( + Arg.Any(), library, true, Arg.Any()) + .Returns(Right(Unit.Default).AsTask()); + jellyfinMusicVideoLibraryScanner.ScanLibrary( + Arg.Any(), library, true, Arg.Any()) + .Returns(Right(Unit.Default).AsTask()); + + var handler = new SynchronizeJellyfinLibraryByIdHandler( + scannerProxy, + mediaSourceRepository, + jellyfinSecretStore, + jellyfinMovieLibraryScanner, + jellyfinTelevisionLibraryScanner, + jellyfinMusicVideoLibraryScanner, + libraryRepository, + configElementRepository, + Substitute.For>()); + + Either result = await handler.Handle( + new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true), + CancellationToken.None); + + result.LeftToSeq().ShouldBeEmpty(); + result.IsRight.ShouldBeTrue(); + result.RightToSeq().Single().ShouldBe("Music Videos"); + + await jellyfinMovieLibraryScanner.Received(1).ScanLibrary( + Arg.Any(), library, true, Arg.Any()); + await jellyfinTelevisionLibraryScanner.Received(1).ScanLibrary( + Arg.Any(), library, true, Arg.Any()); + await jellyfinMusicVideoLibraryScanner.Received(1).ScanLibrary( + Arg.Any(), library, true, Arg.Any()); + await libraryRepository.Received(1).UpdateLastScan(library); + } + + [Test] + public async Task Should_Run_Remaining_Scanners_When_One_Fails_For_Mixed_Libraries() + { + var scannerProxy = Substitute.For(); + var mediaSourceRepository = Substitute.For(); + var jellyfinSecretStore = Substitute.For(); + var jellyfinMovieLibraryScanner = Substitute.For(); + var jellyfinTelevisionLibraryScanner = Substitute.For(); + var jellyfinMusicVideoLibraryScanner = Substitute.For(); + var libraryRepository = Substitute.For(); + var configElementRepository = Substitute.For(); + + var library = new JellyfinLibrary + { + Id = 42, + Name = "Music Videos", + MediaKind = LibraryMediaKind.Mixed, + MediaSourceId = 7 + }; + var mediaSource = new JellyfinMediaSource + { + Id = 7, + Connections = + [ + new JellyfinConnection + { + Address = "http://jellyfin.example", + JellyfinMediaSourceId = 7 + } + ] + }; + + mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask()); + mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask()); + jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets + { + Address = "http://jellyfin.example", + ApiKey = "abc" + }); + configElementRepository.GetValue( + Arg.Is(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key), + Arg.Any()) + .Returns(Task.FromResult>(Some(0))); + + jellyfinMovieLibraryScanner.ScanLibrary( + Arg.Any(), library, true, Arg.Any()) + .Returns(Left(BaseError.New("movie scan blew up")).AsTask()); + jellyfinTelevisionLibraryScanner.ScanLibrary( + Arg.Any(), library, true, Arg.Any()) + .Returns(Right(Unit.Default).AsTask()); + jellyfinMusicVideoLibraryScanner.ScanLibrary( + Arg.Any(), library, true, Arg.Any()) + .Returns(Right(Unit.Default).AsTask()); + + var handler = new SynchronizeJellyfinLibraryByIdHandler( + scannerProxy, + mediaSourceRepository, + jellyfinSecretStore, + jellyfinMovieLibraryScanner, + jellyfinTelevisionLibraryScanner, + jellyfinMusicVideoLibraryScanner, + libraryRepository, + configElementRepository, + Substitute.For>()); + + Either result = await handler.Handle( + new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true), + CancellationToken.None); + + // the movie arm failed, so the overall result is a failure... + result.IsLeft.ShouldBeTrue(); + + // ...but the other two still ran + await jellyfinTelevisionLibraryScanner.Received(1).ScanLibrary( + Arg.Any(), library, true, Arg.Any()); + await jellyfinMusicVideoLibraryScanner.Received(1).ScanLibrary( + Arg.Any(), library, true, Arg.Any()); + + // and LastScan is NOT stamped, because the scan was not fully successful + await libraryRepository.DidNotReceive().UpdateLastScan(library); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj --filter "FullyQualifiedName~SynchronizeJellyfinLibraryByIdHandlerTests"` +Expected: both new tests FAIL. `Should_Scan_All_Three_Kinds_For_Mixed_Libraries` fails because no scanner is called (`Mixed` currently hits `_ => Unit.Default`, which silently reports success) — the `Received(1)` assertions fail. + +- [ ] **Step 3: Implement the `Mixed` arm** + +In `SynchronizeJellyfinLibraryByIdHandler.Synchronize`, replace the existing switch: + +```csharp + Either result = parameters.Library.MediaKind switch + { + LibraryMediaKind.Movies => + await _jellyfinMovieLibraryScanner.ScanLibrary( + parameters.ConnectionParameters, + parameters.Library, + parameters.DeepScan, + cancellationToken), + LibraryMediaKind.Shows => + await _jellyfinTelevisionLibraryScanner.ScanLibrary( + parameters.ConnectionParameters, + parameters.Library, + parameters.DeepScan, + cancellationToken), + LibraryMediaKind.MusicVideos => + await _jellyfinMusicVideoLibraryScanner.ScanLibrary( + parameters.ConnectionParameters, + parameters.Library, + parameters.DeepScan, + cancellationToken), + _ => Unit.Default + }; +``` + +with: + +```csharp + Either result = parameters.Library.MediaKind switch + { + LibraryMediaKind.Movies => + await _jellyfinMovieLibraryScanner.ScanLibrary( + parameters.ConnectionParameters, + parameters.Library, + parameters.DeepScan, + cancellationToken), + LibraryMediaKind.Shows => + await _jellyfinTelevisionLibraryScanner.ScanLibrary( + parameters.ConnectionParameters, + parameters.Library, + parameters.DeepScan, + cancellationToken), + LibraryMediaKind.MusicVideos => + await _jellyfinMusicVideoLibraryScanner.ScanLibrary( + parameters.ConnectionParameters, + parameters.Library, + parameters.DeepScan, + cancellationToken), + LibraryMediaKind.Mixed => + await ScanMixedLibrary(parameters, cancellationToken), + _ => BaseError.New( + $"Jellyfin library {parameters.Library.Name} has unsupported media kind {parameters.Library.MediaKind}") + }; +``` + +Then add this private method to the same class, directly below `Synchronize`: + +```csharp + /// + /// Scans a mixed-content library by running each per-kind scanner against it in turn. Jellyfin + /// resolves the mix server-side (each scanner queries with its own includeItemTypes), so the + /// three passes see disjoint item sets and their reconciliation passes are type-scoped and + /// cannot cross-delete. + /// + private async Task> ScanMixedLibrary( + RequestParameters parameters, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Scanning mixed-content Jellyfin library {LibraryName}", + parameters.Library.Name); + + var errors = new List(); + + foreach (Func>> scan in new Func>>[] + { + () => _jellyfinMovieLibraryScanner.ScanLibrary( + parameters.ConnectionParameters, + parameters.Library, + parameters.DeepScan, + cancellationToken), + () => _jellyfinTelevisionLibraryScanner.ScanLibrary( + parameters.ConnectionParameters, + parameters.Library, + parameters.DeepScan, + cancellationToken), + () => _jellyfinMusicVideoLibraryScanner.ScanLibrary( + parameters.ConnectionParameters, + parameters.Library, + parameters.DeepScan, + cancellationToken) + }) + { + Either result = await scan(); + + foreach (BaseError error in result.LeftToSeq()) + { + // a cancellation aborts the whole library immediately; it is not a failure of one kind + if (error is ScanCanceled) + { + return error; + } + + // one kind failing must not prevent the others from being ingested + _logger.LogWarning( + "Error scanning one media kind of mixed Jellyfin library {LibraryName}: {Error}", + parameters.Library.Name, + error.Value); + errors.Add(error); + } + } + + if (errors.Count > 0) + { + return BaseError.New( + $"Mixed library {parameters.Library.Name} had {errors.Count} scan error(s): " + + string.Join("; ", errors.Map(e => e.Value))); + } + + return Unit.Default; + } +``` + +`_logger` already exists on this class (the constructor takes `ILogger`), so no new dependency is needed. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj --filter "FullyQualifiedName~SynchronizeJellyfinLibraryByIdHandlerTests"` +Expected: all tests PASS, including the two pre-existing ones. + +Before trusting a `--no-build` result, grep the build output for `error CS` — under warnings-as-errors a failed build silently reruns the previous DLL and produces a false PASS. + +- [ ] **Step 5: Commit** + +```bash +git add ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs ErsatzTV.Scanner.Tests/Application/Jellyfin/SynchronizeJellyfinLibraryByIdHandlerTests.cs +git commit -m "feat(489): scan mixed Jellyfin libraries with all three per-kind scanners" +``` + +--- + +### Task 4: Stop reporting success for unhandled media kinds + +**Files:** +- Modify: `ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs` (the `switch` at ~line 84–143) +- Modify: `ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinShowByIdHandler.cs` (the guard at ~line 51) + +**Interfaces:** +- Consumes: `LibraryMediaKind.Mixed` (Task 1). +- Produces: nothing. + +Two independent correctness fixes that belong together because both concern kinds a dispatcher does not handle. + +- [ ] **Step 1: Make the local scanner fail loudly on an unhandled kind** + +In `ScanLocalLibraryHandler`, the dispatch switch ends with `_ => Unit.Default`. That returns **success**, so the caller stamps `LastScan` as though a scan ran. A local library can never legitimately be `Mixed`, so this arm is now reachable in a way that would silently do nothing. + +Replace the final arm: + +```csharp + _ => Unit.Default +``` + +with: + +```csharp + _ => BaseError.New( + $"Local library {localLibrary.Name} has unsupported media kind {localLibrary.MediaKind}") +``` + +- [ ] **Step 2: Let targeted show scans work on mixed libraries** + +`SynchronizeJellyfinShowByIdHandler.Synchronize` currently opens: + +```csharp + if (parameters.Library.MediaKind != LibraryMediaKind.Shows) + { + return BaseError.New($"Library {parameters.Library.Name} is not a TV show library"); + } +``` + +A mixed library legitimately contains shows, so this must accept both. Replace with: + +```csharp + if (parameters.Library.MediaKind is not (LibraryMediaKind.Shows or LibraryMediaKind.Mixed)) + { + return BaseError.New($"Library {parameters.Library.Name} does not contain TV shows"); + } +``` + +- [ ] **Step 3: Build and run the full scanner test suite** + +Run: `dotnet build ErsatzTV.sln --no-restore` +Expected: `Build succeeded`, `0 Error(s)`. + +Run: `dotnet test ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj` +Expected: all PASS. If `ScanLocalLibraryHandlerTests` has a case asserting success for an unhandled kind, it must be updated to expect the error — read it before assuming. + +- [ ] **Step 4: Commit** + +```bash +git add ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinShowByIdHandler.cs +git commit -m "fix(489): fail loudly on unhandled media kinds; allow targeted show scans on mixed libraries" +``` + +--- + +### Task 5: Verify rediscovery cannot flip a Mixed library, and regenerate the API artifacts + +**Files:** +- Read: `ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs` (~lines 182, 253, 335) +- Regenerate: `ErsatzTV/wwwroot/openapi/v1.json`, `docs/endpoint-index.md`, `web/src/api/generated/v1.d.ts` + +**Interfaces:** +- Consumes: `LibraryMediaKind.Mixed` (Task 1). +- Produces: the generated `LibraryMediaKind` enum gains `"Mixed"` in `v1.d.ts`, used by the SPA's types. + +- [ ] **Step 1: Audit the rediscovery kind-rewrite** + +`MediaSourceRepository` updates `existing.MediaKind = incoming.MediaKind` on rediscovery, but only when `ShouldSyncItems == false`. Read all three sites (~182, ~253, ~335) and confirm: + +- a `Mixed` library that is **not** synced and is rediscovered as `Mixed` stays `Mixed`; +- a `Mixed` library that **is** synced cannot be rewritten (the existing `ShouldSyncItems` guard covers this — confirm it, don't assume). + +If the guard already holds, write no code. Record the finding in the commit message. Only if it does not hold should you add a fix — and then add a test with it. + +- [ ] **Step 2: Build the app project, then regenerate** + +Order matters: `update-openapi.sh` reads the built assembly, so a stale `bin/` regenerates the old spec. + +```bash +dotnet build ErsatzTV/ErsatzTV.csproj +./scripts/update-openapi.sh +cd web && npm run generate:api && cd .. +``` + +- [ ] **Step 3: Verify the enum actually changed** + +Run: `grep -n "Mixed" web/src/api/generated/v1.d.ts | head` +Expected: `Mixed` appears in the `LibraryMediaKind` union. + +Run: `cd web && npm run check:api && cd ..` +Expected: PASS. This is the guard against a hand-merged or stale generated artifact. + +- [ ] **Step 4: Typecheck and test the SPA** + +Run: `cd web && npm run typecheck && npm run test && cd ..` +Expected: PASS. No SPA source change is expected — `libraryMediaIcon` and `formatLibraryMediaKind` both have `default:` arms, and `MEDIA_KIND_OPTIONS` is an explicit allowlist that correctly omits `Mixed`. + +If `web/node_modules` is missing, copy it from the shared checkout (`cp -R /Users/timothy/ersatztv/web/node_modules web/`) rather than a fresh install. + +- [ ] **Step 5: Commit** + +```bash +git add ErsatzTV/wwwroot/openapi/v1.json docs/endpoint-index.md web/src/api/generated/v1.d.ts +git commit -m "chore(489): regenerate OpenAPI + typed client for LibraryMediaKind.Mixed" +``` + +--- + +### Task 6: Prove no cross-type deletion, then live-E2E + +**Files:** +- Read: `ErsatzTV.Scanner/Core/Jellyfin/JellyfinMusicVideoLibraryScanner.cs` +- Test: `ErsatzTV.Scanner.Tests/Core/Metadata/` (new file if a unit test proves tractable) + +**Interfaces:** +- Consumes: everything above. +- Produces: the evidence that closes the spec's stated open risk. + +> **This task requires [#488](http://192.168.1.95:3000/timothy/ersatztv/issues/488) to be fixed.** The `Mixed` arm invokes the music-video scanner, which currently throws on its first item. + +- [ ] **Step 1: Trace the music-video scanner's reconciliation** + +This is the spec's one unresolved risk and it must be closed by reading, not assumption. + +`MediaServerMovieLibraryScanner` trashes via `movieRepository.GetExistingMovies(library)` — scoped to the `Movie` table, so it cannot touch shows or music videos. The television scanner follows the same `GetExisting*` pattern. `JellyfinMusicVideoLibraryScanner` is the odd one out: standalone, not derived from `MediaServer*LibraryScanner`, with no `ItemId`/`Etag`, identifying items by path-replaced local path. + +Read the whole file and answer explicitly: + +- Does it have a deletion, trash, or "flag missing" pass at all? +- If so, is its candidate set scoped to `MusicVideo` (and `Artist`), or does it enumerate by `LibraryPathId` alone? +- Does `_artistRepository.DeleteEmptyArtists(libraryPath)` (if called) touch anything outside the `Artist` table? + +**If any query is scoped by `LibraryPathId` alone rather than by type, STOP.** That is a cross-type deletion bug: a mixed scan would delete the movies and shows sharing that library. Report it, file it, and do not proceed to Step 3. + +Record the answer in the commit message and on #489 either way — a negative finding is the deliverable here just as much as a positive one. + +- [ ] **Step 2: Add a regression test if the trace found a type-scoping gap** + +Only if Step 1 found a gap. Fix the scoping, then pin it with a test seeding one library containing a movie, a show and a music video, running the music-video scan with an empty Jellyfin response, and asserting the movie and show are untouched. + +Prove the test is non-vacuous: temporarily widen the reconciliation query back to `LibraryPathId`-only and confirm the test **fails**. Restore, confirm it passes. Do not skip this — a concurrency/deletion test that has never been seen to fail is not evidence. + +- [ ] **Step 3: Full local test pass** + +```bash +dotnet build ErsatzTV.sln --no-restore +dotnet test ErsatzTV.sln +``` +Expected: `Build succeeded`, `0 Error(s)`, all tests PASS. + +- [ ] **Step 4: Live E2E against the running instance** + +This is a write-path scanner change, so live E2E is a stated requirement (`docs/e2e-local.md` → "When live-E2E is required"). + +1. In Jellyfin, set the `Music Videos` library's content type back to **mixed** (reverting the provisional `musicvideos` re-type made 2026-07-20 while diagnosing #474). +2. `POST /api/v1/media-sources/jellyfin/2/refresh-libraries` — confirm it appears with `mediaKind: "Mixed"`. +3. `PUT /api/v1/media-sources/jellyfin/2/libraries` — set `shouldSyncItems: true` for it. +4. `POST /api/v1/libraries/{id}/scan`. +5. Assert, by querying the DB directly: + - `Movie`, `Show`/`Episode` and `MusicVideo` rows all exist under that library's `LibraryPath`; + - **no** new rows landed under the `Movies` (id 10) or `TV Shows` (id 11) library paths — this is the segregation requirement, and it is the whole point of the feature; + - the log contains no `Unhandled exception`. +6. Scan a **second** time and assert row counts are unchanged — idempotency. + +Use `curl` for all of this. Never drive download or scan endpoints through browser tabs. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "test(489): verify mixed-library scan is type-scoped and idempotent" +``` + +--- + +### Task 7: Document the decision + +**Files:** +- Modify: `docs/decisions.md` + +**Interfaces:** +- Consumes: the design as implemented. +- Produces: the durable record. + +- [ ] **Step 1: Append the decision entry** + +`docs/decisions.md` is **append-only** — add at the end, never edit an existing entry. Note this file conflicts constantly on this repo; if a rebase hits it, re-append rather than hand-merging. + +```markdown +## 2026-07-20 (#489) — Jellyfin mixed-content libraries map to one library of many kinds + +A Jellyfin library whose collection type is `mixed` (or absent) now maps to +`LibraryMediaKind.Mixed` instead of being silently dropped by `JellyfinApiClient.Project()`. +Scanning it runs the movie, television and music-video scanners in sequence against the same +library. + +**A library is a place, not a media kind.** One physical path ↔ one Jellyfin library ↔ one +ErsatzTV library, whose contents are heterogeneous. This is what keeps music and standup content +segregated from the main `Movies` and `TV Shows` libraries, which was the goal. + +Why this is safe rather than guesswork: + +- Jellyfin classifies **server-side**. Each scanner queries `parentId` + `includeItemTypes` + (`"Movie"` / `"Series"` / `"MusicVideo"`), so the three passes see disjoint, authoritative sets. + Nothing is inferred from folder shape or NFO contents. +- **No migration.** `MediaItem` is table-per-type with no discriminator and `LibraryPathId` on the + abstract base, so heterogeneous items under one `LibraryPath` were already legal. + `MediaItemRepository.GetAllTrashedItems` already `COALESCE`s across every subclass id. +- **Reconciliation is type-scoped** (`GetExistingMovies(library)` and friends), so the passes + cannot cross-delete. + +**Deliberately scoped to Jellyfin.** Local mixed libraries are NOT supported: every local scanner +shares `LocalFolderScanner.VideoFileExtensions`, so the movie scanner would claim episode files, +and `LibraryFolder` rows are keyed by `LibraryPathId` with no kind, so two scanners over one path +would thrash each other's etags. Neither hazard exists remotely — only +`JellyfinMusicVideoLibraryScanner` touches `LibraryFolder` among the remote scanners. `Mixed` is +therefore absent from the SPA's local-library media-kind options. + +Also fixed here: `ScanLocalLibraryHandler` and `SynchronizeJellyfinLibraryByIdHandler` both ended +their dispatch switch with `_ => Unit.Default`, returning **success** for an unhandled kind and +stamping `LastScan` as though a scan had run. Both now return a `BaseError`. That silent success is +exactly how a missing `Mixed` arm would have hidden. +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/decisions.md +git commit -m "docs(489): record the mixed-library decision and Jellyfin-only scope" +``` + +--- + +## Wrap-up + +- [ ] BOM check before pushing: + `for f in $(git diff --name-only origin/main...HEAD -- '*.cs'); do head -c3 "$f" | xxd -p | grep -q "^efbbbf" && echo "BOM: $f"; done` +- [ ] Format gate (run under **bash**, not zsh — `mapfile` is bash-only and a zsh run silently checks zero files): + ```bash + bash -c 'mapfile -t files < <(git diff --name-only --diff-filter=ACM origin/main...HEAD -- "*.cs") + dotnet format ErsatzTV.sln --no-restore --verify-no-changes --include "${files[@]}"' + ``` +- [ ] Push once — batch all fixes locally first; a CI run cannot be cancelled from the agent side. +- [ ] Open the PR with `fixes #489`, and arm a CI monitor on the head sha **at PR-open**, not at the end. +- [ ] Cold-context adversarial review of the full diff, scoped "review only". This touches a scanner write path, so the review is **mandatory**, not skippable. +- [ ] Post `Review-verdict: @ ` on the PR after the review clears. +- [ ] Tick #489's `## Done-when` boxes only against evidence. + +## Post-merge follow-ups (not this plan) + +- Re-enable `shouldSyncItems` on the live music library and confirm the channels revive. +- #474 closes once music content is ingested; #487 (MCP write-path acceptance case) unblocks after it. +- Local library 14 (`Standup` → `/data/standup`) becomes retirable in favour of the real Jellyfin `Standup` library — its own issue. diff --git a/docs/superpowers/specs/2026-07-20-jellyfin-mixed-libraries-design.md b/docs/superpowers/specs/2026-07-20-jellyfin-mixed-libraries-design.md new file mode 100644 index 000000000..25f6eea96 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-jellyfin-mixed-libraries-design.md @@ -0,0 +1,213 @@ +# Jellyfin mixed-content library support — design + +**Issue:** [#489](http://192.168.1.95:3000/timothy/ersatztv/issues/489) +**Blocked by:** [#488](http://192.168.1.95:3000/timothy/ersatztv/issues/488) (`JellyfinMusicVideoLibraryScanner` crashes on its first item) +**Related:** [#474](http://192.168.1.95:3000/timothy/ersatztv/issues/474) (music channels dead — the issue this arose from) +**Date:** 2026-07-20 + +## Problem + +`JellyfinApiClient.Project()` maps a Jellyfin library's `CollectionType` onto a `LibraryMediaKind` +and returns `None` for anything it does not recognise: + +```csharp +response.CollectionType?.ToLowerInvariant() switch +{ + "tvshows" => … LibraryMediaKind.Shows …, + "movies" => … LibraryMediaKind.Movies …, + "musicvideos" => … LibraryMediaKind.MusicVideos …, + "boxsets" => CacheCollectionLibraryId(response.ItemId), + _ => None // mixed lands here, with no log line +}; +``` + +A library whose content type is **mixed** is therefore dropped silently. It never appears in +ErsatzTV and nothing explains why. On the live system that is two libraries: + +| Jellyfin library | marker | path | contents | +|---|---|---|---| +| Music Videos | `mixed.collection` | `/data/music` | shows (`Top of the Pops`, `Old Grey Whistle Test`, `Soul Train`, `The Midnight Special`), concert movies (`Concert Films`, `Kraftwerk – Minimum Maximum`, `Underworld`), and genuine single-performance music videos | +| Standup | `mixed.collection` | `/data/standup` | a mix of shows and movies | + +Both are genuinely mixed. `mixed` is the honest content type, not a mislabelling. + +The existing workaround for `Standup` is local library 14 pointed straight at `/data/standup` and +typed `Movies`. It bypasses Jellyfin, scans the content a second time, models shows as movies, and +forfeits Jellyfin's metadata. `/data/music` never received even that treatment, which is #474. + +## Goal + +Ingest mixed Jellyfin libraries while keeping their content **segregated** from the main `Movies` +and `TV Shows` libraries. + +The organising principle: **a library is a place.** One physical path ↔ one Jellyfin library ↔ one +ErsatzTV library. Its contents are heterogeneous. This replaces the implicit "a library is a media +kind" model. + +Segregation falls out of that directly: music and standup content lives in its own libraries, so it +cannot leak into `Movies` or `TV Shows`. + +## Non-goals + +- **Local (filesystem) mixed libraries.** See "Scope" below — deliberately excluded. +- **Emby and Plex mixed libraries.** Neither has music-video support at all today; adding mixed + support there is a separate, larger piece of work. +- **`Songs` and `Images` inside a mixed library.** Jellyfin's `music` collection type is already + unsupported (`// TODO: ??? for music libraries`) and out of scope here. +- Retiring local library 14 (`Standup`). That becomes possible afterwards, but is its own change. + +## Scope decision: Jellyfin-only, and why + +This is the load-bearing choice in the design. + +**Remotely, classification is authoritative.** `IJellyfinApi` already queries items by `parentId` + +`includeItemTypes`: + +```csharp +GetMovieLibraryItems(…, string includeItemTypes = "Movie", …) +GetShowLibraryItems(…, string includeItemTypes = "Series", …) +GetMusicVideoLibraryItems(…, string includeItemTypes = "MusicVideo", …) +``` + +`parentId` is the library's `ItemId`. A mixed library can therefore be queried once per type, and +Jellyfin returns disjoint, authoritative sets. `JellyfinLibraryItemResponse` also carries a per-item +`Type` field, which `ProjectToCollectionMediaItem` already switches on for boxsets. There is no +inference and no guessing. + +**Locally, the same approach is unsafe.** Every local video scanner shares +`LocalFolderScanner.VideoFileExtensions`, so pointing the movie scanner and the television scanner +at one folder tree means each claims the other's files. Worse, `LibraryFolder` rows are keyed by +`LibraryPathId` with no notion of kind, so two scanners over one path would thrash each other's +etags via `LibraryRepository.SetEtag` / `CleanEtagsForLibraryPath`, producing either perpetual full +rescans or skipped scans. + +That hazard does not exist remotely: among the remote scanners, only +`JellyfinMusicVideoLibraryScanner` touches `LibraryFolder` at all. + +Both mixed libraries on the live system are Jellyfin libraries, so this scope costs nothing against +the goal. + +There is an existing precedent worth noting for the local case: +`LocalLibraryHandlerBase.AreSubPaths` already permits an `Images` library and an `OtherVideos` +library to share one physical directory. That is two libraries over one tree — the same etag +contention described above — and it is the closest existing analogue if local mixed support is ever +revisited. + +## Enabling facts (verified, not assumed) + +1. **No DB migration is required.** `MediaItem` is table-per-type with no discriminator column. + `LibraryPathId` sits on the abstract base (`MediaItem.cs`) and every subclass inherits it; + `LibraryPath.MediaItems` is `List`. Heterogeneous items under one `LibraryPath` are + already legal. `MediaItemRepository.GetAllTrashedItems` already `COALESCE`s across + `MovieId, MusicVideoId, OtherVideoId, SongId, EpisodeId, ImageId, RemoteStreamId` for a single + `LibraryPathId`. + +2. **`MediaKind` is dispatch and presentation, not structure.** Scheduling, playout, collections, + smart collections and playlists contain zero `MediaKind` references. Search indexing keys off the + item's own subclass. The SPA's browse and collections screens use a per-item + `LibraryBrowseMediaType`, not the library's kind. `MediaKind` is persisted on the base `Library` + table only, with no unique constraint or index involving it. + +3. **Reconciliation is type-scoped.** `MediaServerMovieLibraryScanner` trashes against + `movieRepository.GetExistingMovies(library)` — scoped to the `Movie` table — and the television + scanner follows the same `GetExisting*` pattern. Several scanners over one library therefore + cannot cross-delete. + +## Design + +### Model + +Add `LibraryMediaKind.Mixed = 8`. + +`JellyfinApiClient.Project()` maps a null or `"mixed"` `CollectionType` to it, producing one +`JellyfinLibrary` exactly as the recognised types do. One Jellyfin library yields one ErsatzTV +library, preserving the path ↔ Jellyfin library ↔ ErsatzTV library correspondence. + +### Dispatch + +`SynchronizeJellyfinLibraryByIdHandler` gains a `Mixed` arm that runs the three existing Jellyfin +scanners in sequence against the same library: + +``` +LibraryMediaKind.Mixed => movie scanner, then television scanner, then music-video scanner +``` + +Each scanner issues its own `includeItemTypes` query and reconciles only its own type. **No new +scanner is written** — this is composition of three that already exist. + +Sequential rather than parallel: they share a `TvContext` factory and the `EntityLocker`, and the +ordering keeps failure attribution simple. Throughput is not a concern at this library size. + +### Error handling + +Both `ScanLocalLibraryHandler` and `SynchronizeJellyfinLibraryByIdHandler` currently end their +dispatch switch with `_ => Unit.Default`, which returns **success** for an unhandled kind and stamps +`LastScan` as though a scan had run. This is precisely how a missing `Mixed` arm would hide, and it +is fixed as part of this work: an unhandled kind logs and returns a `BaseError`. + +Within the `Mixed` arm, a failure in one scanner is reported but does not abort the remaining +scanners — a broken music-video scan should not prevent the movies and shows in the same library +from being ingested. The library's overall result is an error if any arm failed. + +### Touch points + +| Area | Change | +|---|---| +| `ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs` | add `Mixed = 8` | +| `ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs` | map null/`mixed` → `Mixed` | +| `ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs` | `Mixed` arm; remove silent-success default | +| `ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs` | remove silent-success default | +| `SynchronizeJellyfinShowByIdHandler.cs` | relax the `"is not a TV show library"` guard to accept `Mixed` | +| `ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs` | verify rediscovery cannot flip a `Mixed` library's kind | +| `web/src/screens/LibrariesScreen.tsx` | icon + label for `Mixed` | +| `web/src/screens/LocalLibraryEditScreen.tsx` | `MEDIA_KIND_OPTIONS` — `Mixed` must **not** be creatable for a local library | +| `ErsatzTV/wwwroot/openapi/v1.json`, `web/src/api/generated/v1.d.ts` | regenerate | +| `docs/decisions.md` | record the decision and the Jellyfin-only scope | + +Untouched: scheduling, playout, collections, smart collections, search, browse. + +### A note on the SPA + +`LibraryMediaKind` is a generated enum shared by local and remote libraries, so adding `Mixed` +exposes it to the local-library create screen, where it is meaningless. The media-kind select must +exclude it. `RemoteLibrariesEditScreen` keys its drafts on `(name, mediaKind)`, which continues to +work unchanged. + +## Testing + +**Unit — `JellyfinApiClientTests`.** A `mixed` `CollectionType`, and a null one, each project to a +`JellyfinLibrary` with `MediaKind = Mixed`. An unrecognised type still yields `None`. + +**Unit — dispatch.** `SynchronizeJellyfinLibraryByIdHandlerTests` (the file already exists): a +`Mixed` library invokes all three scanners; an unhandled kind returns an error rather than success. + +**Regression — #488.** Covered by that issue, but this feature depends on it: a Jellyfin +music-video scan must complete against a `LibraryPath` whose `LibraryFolders` navigation is not +eager-loaded. + +**Cross-type deletion.** The critical test. Seed a single library with a movie, a show and a music +video; run the full `Mixed` scan with one type absent from the Jellyfin response; assert only that +type is flagged missing and the others are untouched. Prove non-vacuous by temporarily widening a +reconciliation query and watching the test fail. + +**Live E2E.** Re-type the live `Music Videos` library back to `mixed`, scan, and confirm movies, +shows and music videos all land in that one library with nothing appearing in `Movies` or +`TV Shows`. This is a write-path scanner change, so live E2E is required per `docs/e2e-local.md`. + +## Open risk + +`JellyfinMusicVideoLibraryScanner`'s reconciliation has **not** been traced. It is the odd one out — +standalone, not derived from `MediaServer*LibraryScanner`, with no `ItemId`/`Etag` to key on, so it +identifies items by path-replaced local path. It is the one place cross-type deletion could still +hide, and it must be traced before the `Mixed` arm is trusted. This is a precondition of the +cross-type deletion test above, not a follow-up. + +## Rollout + +1. Fix #488 (blocking). +2. Land this feature. +3. Re-type the live `Music Videos` Jellyfin library back to `mixed`, reverting the provisional + `musicvideos` re-type made on 2026-07-20. +4. Re-enable `shouldSyncItems` on that library and scan. +5. Afterwards, local library 14 (`Standup` → `/data/standup`) becomes retirable in favour of the real + Jellyfin `Standup` library. Separate change. -- 2.47.3