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.