docs(489): spec + implementation plan for Jellyfin mixed-content libraries (#490)
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 5m16s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Has started running
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled

Design spec and bite-sized TDD implementation plan for #489, whose
implementation landed in #493.

Docs-only. Kept as the record of how the design was reached: that Jellyfin
classifies mixed-library items server-side via includeItemTypes (so no
inference is needed), that MediaItem is TPT keyed on LibraryPathId (so no
migration is needed), that MediaKind is dispatch + presentation only, and why
the feature is deliberately scoped to Jellyfin rather than local libraries.

Also records the open risk the plan carried -- the music-video scanner's
untraced reconciliation -- which #494 subsequently answered.

Refs #489

Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
This commit was merged in pull request #490.
This commit is contained in:
2026-07-20 16:36:02 +00:00
committed by timothy
parent 2cf90fb44f
commit 56dff2c49f
2 changed files with 1101 additions and 0 deletions
@@ -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 15 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 84143)
- 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.
@@ -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<MediaItem>`. 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.