# 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 `<PackageReference>`** — 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,

    /// <summary>
    /// 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
    /// <see cref="ErsatzTV.Scanner.Core.Metadata.LocalFolderScanner"/>'s video extension list and
    /// would claim each other's files.
    /// </summary>
    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<IJellyfinPathReplacementService>(),
                Substitute.For<IFallbackMetadataProvider>(),
                new SingleResponseHttpClientFactory(response),
                Substitute.For<ILogger<JellyfinApiClient>>());

            Either<BaseError, List<JellyfinLibrary>> result =
                await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");

            result.IsRight.ShouldBeTrue();
            List<JellyfinLibrary> 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<IJellyfinPathReplacementService>(),
                Substitute.For<IFallbackMetadataProvider>(),
                new SingleResponseHttpClientFactory(response),
                Substitute.For<ILogger<JellyfinApiClient>>());

            Either<BaseError, List<JellyfinLibrary>> result =
                await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");

            result.IsRight.ShouldBeTrue();
            List<JellyfinLibrary> 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<IJellyfinPathReplacementService>(),
                Substitute.For<IFallbackMetadataProvider>(),
                new SingleResponseHttpClientFactory(response),
                Substitute.For<ILogger<JellyfinApiClient>>());

            Either<BaseError, List<JellyfinLibrary>> 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<LibraryPath> { 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<IScannerProxy>();
            var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
            var jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
            var jellyfinMovieLibraryScanner = Substitute.For<IJellyfinMovieLibraryScanner>();
            var jellyfinTelevisionLibraryScanner = Substitute.For<IJellyfinTelevisionLibraryScanner>();
            var jellyfinMusicVideoLibraryScanner = Substitute.For<IJellyfinMusicVideoLibraryScanner>();
            var libraryRepository = Substitute.For<ILibraryRepository>();
            var configElementRepository = Substitute.For<IConfigElementRepository>();

            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<int>(
                    Arg.Is<ConfigElementKey>(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
                    Arg.Any<CancellationToken>())
                .Returns(Task.FromResult<Option<int>>(Some(0)));

            jellyfinMovieLibraryScanner.ScanLibrary(
                    Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
                .Returns(Right<BaseError, Unit>(Unit.Default).AsTask());
            jellyfinTelevisionLibraryScanner.ScanLibrary(
                    Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
                .Returns(Right<BaseError, Unit>(Unit.Default).AsTask());
            jellyfinMusicVideoLibraryScanner.ScanLibrary(
                    Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
                .Returns(Right<BaseError, Unit>(Unit.Default).AsTask());

            var handler = new SynchronizeJellyfinLibraryByIdHandler(
                scannerProxy,
                mediaSourceRepository,
                jellyfinSecretStore,
                jellyfinMovieLibraryScanner,
                jellyfinTelevisionLibraryScanner,
                jellyfinMusicVideoLibraryScanner,
                libraryRepository,
                configElementRepository,
                Substitute.For<ILogger<SynchronizeJellyfinLibraryByIdHandler>>());

            Either<BaseError, string> 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<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>());
            await jellyfinTelevisionLibraryScanner.Received(1).ScanLibrary(
                Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>());
            await jellyfinMusicVideoLibraryScanner.Received(1).ScanLibrary(
                Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>());
            await libraryRepository.Received(1).UpdateLastScan(library);
        }

        [Test]
        public async Task Should_Run_Remaining_Scanners_When_One_Fails_For_Mixed_Libraries()
        {
            var scannerProxy = Substitute.For<IScannerProxy>();
            var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
            var jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
            var jellyfinMovieLibraryScanner = Substitute.For<IJellyfinMovieLibraryScanner>();
            var jellyfinTelevisionLibraryScanner = Substitute.For<IJellyfinTelevisionLibraryScanner>();
            var jellyfinMusicVideoLibraryScanner = Substitute.For<IJellyfinMusicVideoLibraryScanner>();
            var libraryRepository = Substitute.For<ILibraryRepository>();
            var configElementRepository = Substitute.For<IConfigElementRepository>();

            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<int>(
                    Arg.Is<ConfigElementKey>(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
                    Arg.Any<CancellationToken>())
                .Returns(Task.FromResult<Option<int>>(Some(0)));

            jellyfinMovieLibraryScanner.ScanLibrary(
                    Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
                .Returns(Left<BaseError, Unit>(BaseError.New("movie scan blew up")).AsTask());
            jellyfinTelevisionLibraryScanner.ScanLibrary(
                    Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
                .Returns(Right<BaseError, Unit>(Unit.Default).AsTask());
            jellyfinMusicVideoLibraryScanner.ScanLibrary(
                    Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>())
                .Returns(Right<BaseError, Unit>(Unit.Default).AsTask());

            var handler = new SynchronizeJellyfinLibraryByIdHandler(
                scannerProxy,
                mediaSourceRepository,
                jellyfinSecretStore,
                jellyfinMovieLibraryScanner,
                jellyfinTelevisionLibraryScanner,
                jellyfinMusicVideoLibraryScanner,
                libraryRepository,
                configElementRepository,
                Substitute.For<ILogger<SynchronizeJellyfinLibraryByIdHandler>>());

            Either<BaseError, string> 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<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>());
            await jellyfinMusicVideoLibraryScanner.Received(1).ScanLibrary(
                Arg.Any<JellyfinConnectionParameters>(), library, true, Arg.Any<CancellationToken>());

            // 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<BaseError, Unit> 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<BaseError, Unit> 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
    /// <summary>
    /// 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.
    /// </summary>
    private async Task<Either<BaseError, Unit>> ScanMixedLibrary(
        RequestParameters parameters,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation(
            "Scanning mixed-content Jellyfin library {LibraryName}",
            parameters.Library.Name);

        var errors = new List<BaseError>();

        foreach (Func<Task<Either<BaseError, Unit>>> scan in new Func<Task<Either<BaseError, Unit>>>[]
                 {
                     () => _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<BaseError, Unit> 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<SynchronizeJellyfinLibraryByIdHandler>`), 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: <VERDICT> @ <head-sha>` 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.
