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
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:
@@ -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 `<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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user