Compare commits

..
Author SHA1 Message Date
timothy 65d88b5167 test(#289): pin BoundedLineReader cap/CRLF boundaries + document per-segment path assumption
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Non-blocking polish from the fix-commit re-review (verdict MERGEABLE):
- BuildPath: comment the assumption that each {param} is its own path segment
  (revalidate the whole path if a template ever concatenates adjacent params).
- Two additive BoundedLineReader tests: exactly-at-cap line returned intact
  (inclusive max), and \r\n carriage-return stripping. No logic change.

36 tests (was 34).

Refs #289
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:24:35 +02:00
timothy 4f68805d9a security(#289): review fixes — body-read timeout, transport-error response, bounded stdin, env clamps
Folds in findings from the independent reviews of the initial hardening diff
(a cold adversarial pass + a Codex pass — complementary catches).

Fork (HIGH/MED):
- Body-read timeout regression: ResponseHeadersRead moved the body read outside
  HttpClient.Timeout and it used CancellationToken.None, so a slow-drip upstream
  hung the single-threaded session. Now a per-request linked CTS (options
  .EffectiveRequestTimeout) covers headers + body; HttpClient.Timeout set to
  Infinite so one timer owns it. Verified live against a black-hole upstream:
  1s timeout → -32603 in ~1s, no hang.
- Transport/timeout exceptions escaped HandleAsync's catch filter → no response
  for the id → client hangs. Added a broad final catch → -32603 when hasId.

Codex (env/input robustness):
- ERSATZTV_REQUEST_TIMEOUT_SECONDS / ERSATZTV_MAX_RESPONSE_BYTES at int.MaxValue
  crashed at startup / overflowed `cap+1` to a negative alloc. ParseInt now
  clamps to [min,max]; the client also hard-ceils the cap (MaxAllowedResponseBytes).
- A ".."/"." path param collapsed the URL onto another route under Uri
  canonicalization — rejected in BuildPath.

Both reviewers (stdin OOM): ReadLineAsync buffered an unbounded line before any
guard. New BoundedLineReader drains+drops oversized lines (memory-bounded),
keeping subsequent lines aligned.

Nits: case-insensitive ParseBool fallback; UTF-8 boundary backoff so a mid-
codepoint truncation doesn't emit U+FFFD; renamed a misleading test.

34 tests (was 26); +8 covering the transport-error path, dot-segment rejection,
cap-overflow fallback, UTF-8 seam, and the bounded reader.

Refs #289 #197
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:20:10 +02:00
timothyandClaude Opus 4.8 55fc210385 security(#289): harden MCP server (read-only posture, JSON-DoS, response cap, arg validation)
From the #197 cold review's MCP-consumer pass. Hardens `ErsatzTV.Mcp` (PR #76)
before it can go live against a soon-remote-exposed API.

- HIGH verb-guard: runtime read-only *posture* (ERSATZTV_ALLOW_WRITES, default
  false) enforced in the executor — a wrong catalog entry can't mutate/delete,
  and future write tools (#58) slot in behind the opt-in. Not a hardcoded
  GET-only clamp, so the non-read-only final design stays reachable.
- HIGH JSON-crash DoS: guard JsonDocument.Parse in HandleAsync (-32700, id null),
  handle missing/non-string method (-32600) instead of throwing, and wrap the
  Program.Main read loop so one bad stdin line can never kill the session.
- MED unbounded response / LOW no-timeout: cap the buffered body
  (ERSATZTV_MAX_RESPONSE_BYTES, truncation marker) + explicit HttpClient.Timeout.
- MED args unvalidated: ToolArgumentValidator checks caller args against each
  tool's InputSchema (required present, no unknown args, basic types) before a
  request is built.
- MED prompt injection: documented untrusted-data posture in docs/mcp.md.
- LOW base-URL prefix: preserve a reverse-proxy path prefix in the URL join.

Also documents that ERSATZTV_API_KEY is now effectively required (Bundle A gates
all /api reads behind X-Api-Key). 14 new tests (26 total); live stdin smoke
confirms malformed/no-method/non-object lines no longer crash the loop.

Refs #289 #197 #58

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:08:07 +02:00
timothy d1c04030af chore: retrigger CI (MySQL service port collision with concurrent run)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-07 11:58:29 +02:00
timothy 945d108334 feat(mcp): add read-only API server foundation refs #58
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-07 11:44:44 +02:00
638 changed files with 6434 additions and 104531 deletions
+8 -69
View File
@@ -22,15 +22,11 @@ on:
tags: tags:
- 'v*' - 'v*'
# Concurrency is scoped per ref (originally one global group for the single # Single runner on jazz: serialize all runs so the push-main-then-tag release
# jazz runner; with 3 runners that serialized the whole queue). PR runs # flow can't collide on the shared :buildcache tag or the smoke container.
# parallelize across PRs and a new sync auto-cancels its superseded run.
# Real image builds (main / v* tags) still serialize within their own ref;
# don't push main and a v* tag simultaneously — they share :buildcache and
# the smoke container name.
concurrency: concurrency:
group: ersatztv-build-${{ github.event_name }}-${{ github.ref }} group: ersatztv-build
cancel-in-progress: ${{ github.event_name == 'pull_request' }} cancel-in-progress: false
env: env:
REGISTRY: 192.168.1.95:3000 REGISTRY: 192.168.1.95:3000
@@ -44,22 +40,13 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
# only the test job's steps below need the working tree; git history/tags fetch-depth: 0
# are only needed by the `build` job's `git describe` (ersatztv#190)
fetch-depth: 1
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }}
restore-keys: nuget-${{ runner.os }}-
- name: Restore - name: Restore
run: dotnet restore run: dotnet restore
@@ -115,9 +102,8 @@ jobs:
env: env:
MYSQL_ROOT_PASSWORD: ersatztv MYSQL_ROOT_PASSWORD: ersatztv
MYSQL_DATABASE: ersatztv_migrations MYSQL_DATABASE: ersatztv_migrations
# No host-port binding: the job reaches this service as mysql:3306 on the shared ports:
# runner network. Publishing 3306 made concurrent runs collide ("port is already - 3306:3306
# allocated") whenever two migrations jobs overlapped.
options: >- options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -persatztv --silent" --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -persatztv --silent"
--health-interval=5s --health-interval=5s
@@ -126,21 +112,12 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# default fetch-depth: 1 -- this job never runs git describe/log, only
# actions/checkout@v4's default (shallow) history is needed (ersatztv#190)
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }}
restore-keys: nuget-${{ runner.os }}-
- name: Restore - name: Restore
run: dotnet restore run: dotnet restore
@@ -184,12 +161,7 @@ jobs:
build: build:
name: Build & push image (amd64) name: Build & push image (amd64)
# `small` = the dedicated small-jobs runner lane (server-management#574). runs-on: ubuntu-latest
# On PR runs this job only resolves its skip, but Gitea still dispatches it
# as a task — on the ubuntu-latest runners that skip queued behind long
# builds (observed 31 min). Real builds (main/tags) run on bumblebee,
# capped at 4 CPUs / 10g.
runs-on: small
needs: [test, migrations] needs: [test, migrations]
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
steps: steps:
@@ -308,36 +280,3 @@ jobs:
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true
exit 1 exit 1
fi fi
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
# cache-save issues seen on the relocated runner (server-management#570).
docs-reminder:
name: Docs update reminder
runs-on: small # seconds-long git diff; keep it off the build runners
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Warn when a screen/route change skips the parity doc
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
echo "Changed files in this PR:"; printf '%s\n' "$changed"
screen_or_route=no
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
screen_or_route=yes
fi
parity=no
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
parity=yes
fi
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
else
echo "Parity-doc reminder: nothing to flag."
fi
-10
View File
@@ -1,10 +0,0 @@
# Enforce the CLAUDE.md protocol: every commit message must carry a Co-Authored-By
# trailer. Merge commits are exempt (their MERGE_MSG has no trailer and shouldn't be
# rewritten).
if git rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then
exit 0
fi
grep -q '^Co-Authored-By:' "$1" || {
echo 'husky - commit message missing Co-Authored-By trailer'
exit 1
}
-15
View File
@@ -1,15 +0,0 @@
cd web && npx lint-staged || exit 1
cd ..
# dotnet format on staged .cs files (repo root). Scoped to the staged files so we
# don't pay the full-tree cost; skip entirely when no .cs is staged (avoids the
# ~20-40s sln load for web-only commits).
cs_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.cs')
if [ -n "$cs_files" ]; then
echo "husky - dotnet format (verify) on staged .cs files"
# shellcheck disable=SC2086
dotnet format ErsatzTV.sln --verify-no-changes --include $cs_files || {
echo "husky - dotnet format found issues in staged .cs files; run 'dotnet format ErsatzTV.sln --include <files>' to fix"
exit 1
}
fi
-11
View File
@@ -1,11 +0,0 @@
# Git exports GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE while running hooks. In a worktree
# (or any subdir), an explicit GIT_DIR makes nested `git` commands mislocate the working
# tree — notably `check:api`'s `git diff --exit-code` (run from web/) silently reports "no
# diff" and lets drift through. Unset them so nested git rediscovers the repo normally.
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
# CI-parity checks: catch "green locally, red in CI" before the push leaves the machine.
# check:api guards the generated OpenAPI types (v1.json / v1.d.ts drift); the full
# lint/typecheck/build catch a staged change that breaks an UNstaged file (lint-staged
# only sees staged files).
cd web && npm run check:api && npm run lint && npm run typecheck && npm run build
+1 -13
View File
@@ -5,7 +5,7 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
## Architecture ## Architecture
- **Language**: C# / .NET 10 - **Language**: C# / .NET 10
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the default UI; root `/` and migrated legacy routes 302 there (`ErsatzTV/LegacyUiRedirects.cs`). The legacy Blazor Server UI (MudBlazor) still serves the remaining un-migrated admin screens — playback troubleshooting, multi/rerun collections, and playlist editing depth; Blazor home = `/system/health`, reachable via the Settings → System "Classic UI" link. Media detail pages + image folder browser landed in the SPA via #141 (PR #183); its removal is #91 phase (b), gated on #145 (playback troubleshooting) and API gaps #151/#152/#153/#155 (scheduling parity #144/#162 DONE 2026-07-07: blocks/templates/decos/deco-templates/playout editors all in the SPA; #141/#158/#161/#180 also DONE) - **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the default UI; root `/` and migrated legacy routes 302 there (`ErsatzTV/LegacyUiRedirects.cs`). The legacy Blazor Server UI (MudBlazor) still serves un-migrated admin screens (collections, media browse, trakt, filler, watermarks, ffmpeg profiles, blocks/decos/templates, playout editors, logs, troubleshooting; Blazor home = `/system/health`); its removal is #91 phase (b), gated on parity issues #140#147
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/` - **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs` - **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation - **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
@@ -56,18 +56,6 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
## Conventions ## Conventions
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, Blazor/MudBlazor, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason. - **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, Blazor/MudBlazor, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
- **Docs-first is a HARD RULE — read before you explore**: before ANY API / SPA / E2E / parity / scheduling work, read `docs/README.md` (index) → the convention docs (`api-conventions`, `spa-conventions`, `e2e-local`, `domain-model`, `blazor-route-parity`, `decisions`). **Do NOT reverse-engineer conventions from source (Grep/Read) before reading these** — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). **This applies to delegated subagents too**: tell each agent which doc section to read; never let one re-derive conventions from code.
- **Docs-update is part of "done" — same PR, never a follow-up**: any PR that changes a convention, adds/migrates/redirects a route, adds/changes a `/api/*` endpoint, or reverses a decision MUST update the relevant doc in that same PR:
| Change | Update in the same PR |
|---|---|
| Migrate / add / redirect a route (new `web/src/screens/*.tsx`, `LegacyUiRedirects.cs`) | `docs/blazor-route-parity.md` + `docs/domain-model.md` |
| Add / change a `/api/*` endpoint | `docs/api-conventions.md` checklist, then regenerate `v1.json` + `endpoint-index.md` via `./scripts/update-openapi.sh` |
| Change a SPA screen convention | `docs/spa-conventions.md` |
| Establish / reverse a convention or decision | `docs/decisions.md` (append-only) + the affected doc |
| Add / remove / retitle a doc | `docs/README.md` index |
The `docs-reminder` CI job flags a screen/route change that skips `blazor-route-parity.md`, but it's a **non-blocking** nudge — the rule is on you, not the check.
- Follow existing MediatR CQRS pattern for new features - Follow existing MediatR CQRS pattern for new features
- Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure` - Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure`
- Keep UI thin: the SPA talks to `/api/*` only; legacy Blazor pages delegate to MediatR handlers. New screens go in the SPA (`web/`), never in Blazor - Keep UI thin: the SPA talks to `/api/*` only; legacy Blazor pages delegate to MediatR handlers. New screens go in the SPA (`web/`), never in Blazor
+4 -4
View File
@@ -6,18 +6,18 @@
<ItemGroup> <ItemGroup>
<PackageVersion Include="AsyncFixer" Version="2.1.0" /> <PackageVersion Include="AsyncFixer" Version="2.1.0" />
<PackageVersion Include="Blazored.FluentValidation" Version="2.2.0" /> <PackageVersion Include="Blazored.FluentValidation" Version="2.2.0" />
<PackageVersion Include="BlazorSortable" Version="6.0.2" /> <PackageVersion Include="BlazorSortable" Version="5.2.1" />
<PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" /> <PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" />
<PackageVersion Include="Chronic.Core" Version="0.4.0" /> <PackageVersion Include="Chronic.Core" Version="0.4.0" />
<PackageVersion Include="CliWrap" Version="3.10.2" /> <PackageVersion Include="CliWrap" Version="3.10.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" /> <PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Dapper" Version="2.1.79" /> <PackageVersion Include="Dapper" Version="2.1.66" />
<PackageVersion Include="Destructurama.Attributed" Version="5.2.0" /> <PackageVersion Include="Destructurama.Attributed" Version="5.2.0" />
<PackageVersion Include="EFCore.BulkExtensions" Version="[9.0.2,10)" /> <PackageVersion Include="EFCore.BulkExtensions" Version="[9.0.2,10)" />
<PackageVersion Include="EFCore.BulkExtensions.MySql" Version="[9.0.2,10)" /> <PackageVersion Include="EFCore.BulkExtensions.MySql" Version="[9.0.2,10)" />
<PackageVersion Include="EFCore.BulkExtensions.Sqlite" Version="[9.0.2,10)" /> <PackageVersion Include="EFCore.BulkExtensions.Sqlite" Version="[9.0.2,10)" />
<PackageVersion Include="Elastic.Clients.Elasticsearch" Version="9.3.0" /> <PackageVersion Include="Elastic.Clients.Elasticsearch" Version="9.3.0" />
<PackageVersion Include="EntityFrameworkProfiler.Appender" Version="6.0.6053" /> <PackageVersion Include="EntityFrameworkProfiler.Appender" Version="6.0.6049" />
<PackageVersion Include="FluentValidation" Version="12.1.1" /> <PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" /> <PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageVersion Include="Flurl" Version="4.0.0" /> <PackageVersion Include="Flurl" Version="4.0.0" />
@@ -55,9 +55,7 @@ public class BulkDeleteChannelsHandler(
} }
} }
// post-commit side effect runs on CancellationToken.None so a late request cancellation await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
// can't abort it after the commit landed (#254)
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
return Right<BaseError, Unit>(Unit.Default); return Right<BaseError, Unit>(Unit.Default);
} }
@@ -54,10 +54,7 @@ public class BulkMoveChannelsToGroupHandler(
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
searchTargets.SearchTargetsChanged(); searchTargets.SearchTargetsChanged();
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
return Right<BaseError, Unit>(Unit.Default); return Right<BaseError, Unit>(Unit.Default);
} }
@@ -68,22 +68,19 @@ public class CreateChannelFromLineupHandler(
} }
searchTargets.SearchTargetsChanged(); searchTargets.SearchTargetsChanged();
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await workerChannel.WriteAsync( await workerChannel.WriteAsync(
new BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset), new BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset),
CancellationToken.None); cancellationToken);
// Mirror CreateClassicPlayoutHandler: on-demand playouts must be time-shifted to "now" after build. // Mirror CreateClassicPlayoutHandler: on-demand playouts must be time-shifted to "now" after build.
if (prepared.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand) if (prepared.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{ {
await workerChannel.WriteAsync( await workerChannel.WriteAsync(
new TimeShiftOnDemandPlayout(prepared.Playout.Id, DateTimeOffset.Now, false), new TimeShiftOnDemandPlayout(prepared.Playout.Id, DateTimeOffset.Now, false),
CancellationToken.None); cancellationToken);
} }
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None); await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return new CreateChannelFromLineupResponseModel( return new CreateChannelFromLineupResponseModel(
prepared.Channel.Id, prepared.Channel.Id,
@@ -47,24 +47,20 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken) private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
{ {
// Delete the guide cache file through the filesystem abstraction (so it's observable under a
// MockFileSystem) and BEFORE the commit: deleting after commit orphans {number}.xml if the
// process crashes in between (nothing reaps it, and GetChannelGuideHandler serves everything
// in the cache folder). The guide xml is regenerable on demand, so losing it pre-commit is safe (#254).
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
if (_fileSystem.File.Exists(cacheFile))
{
_fileSystem.File.Delete(cacheFile);
}
dbContext.Channels.Remove(channel); dbContext.Channels.Remove(channel);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
_searchTargets.SearchTargetsChanged(); _searchTargets.SearchTargetsChanged();
// refresh channel list to remove channel that has no playout — post-commit side effect runs on // delete channel data from channel guide cache
// CancellationToken.None so a late request cancellation can't abort it after the delete committed (#254) string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
await _workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None); if (_fileSystem.File.Exists(cacheFile))
{
File.Delete(cacheFile);
}
// refresh channel list to remove channel that has no playout
await _workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return Unit.Default; return Unit.Default;
} }
@@ -157,23 +157,21 @@ public class UpdateChannelHandler(
searchTargets.SearchTargetsChanged(); searchTargets.SearchTargetsChanged();
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
if (c.SubtitleMode != ChannelSubtitleMode.None) if (c.SubtitleMode != ChannelSubtitleMode.None)
{ {
Option<Playout> maybePlayout = await dbContext.Playouts Option<Playout> maybePlayout = await dbContext.Playouts
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id, CancellationToken.None); .SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id, cancellationToken);
foreach (Playout playout in maybePlayout) foreach (Playout playout in maybePlayout)
{ {
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), CancellationToken.None); await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), cancellationToken);
} }
} }
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None); await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
if (hasEpgChange) if (hasEpgChange)
{ {
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), CancellationToken.None); await workerChannel.WriteAsync(new RefreshChannelData(c.Number), cancellationToken);
} }
return ProjectToViewModel(c, c.Playouts?.Count ?? 0); return ProjectToViewModel(c, c.Playouts?.Count ?? 0);
@@ -81,12 +81,10 @@ public class UpdateChannelNumbersHandler(
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
// update channel list and xmltv // update channel list and xmltv
// post-commit side effect runs on CancellationToken.None so a late request cancellation await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
// can't abort it after the commit landed (#254)
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
foreach (var channel in channelsToUpdate) foreach (var channel in channelsToUpdate)
{ {
await workerChannel.WriteAsync(new RefreshChannelData(channel.Number), CancellationToken.None); await workerChannel.WriteAsync(new RefreshChannelData(channel.Number), cancellationToken);
} }
return Option<BaseError>.None; return Option<BaseError>.None;
@@ -1,89 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace ErsatzTV.Application;
public static class ConcurrencyExtensions
{
/// <summary>
/// Persist changes that touch a versioned root but do <b>not</b> participate in the If-Match
/// contract (e.g. a playout's settings/schedule-file/on-demand-checkpoint writer, a collection's
/// name edit). Because the root's <c>Version</c> is an <c>IsConcurrencyToken</c>, EF guards every
/// UPDATE of that row with <c>WHERE Version=@orig</c>, so a concurrent bump from a replace-all
/// editor would otherwise surface as an unhandled <see cref="DbUpdateConcurrencyException" /> →
/// 500 (issue #253 / #269). Phase-1 semantics for a missing <c>If-Match</c> is <b>force-write</b>,
/// so on a concurrency failure we adopt the stored token as both original (the retry's WHERE then
/// matches) and current (so we don't revert the concurrent bump) and retry — a client-wins merge
/// scoped to the token; our own modified scalars still win. Bounded to avoid a livelock; if the row
/// was deleted out from under us, that's a genuine conflict and rethrows.
/// </summary>
public static async Task<int> SaveChangesForcingVersion(
this DbContext dbContext,
CancellationToken cancellationToken)
{
for (var attempt = 0; ; attempt++)
{
try
{
return await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException ex) when (attempt < 5)
{
var resolvedAny = false;
foreach (EntityEntry entry in ex.Entries)
{
if (entry.Entity is not IVersionedAggregate)
{
continue;
}
PropertyValues databaseValues = await entry.GetDatabaseValuesAsync(cancellationToken);
if (databaseValues is null)
{
// The row was deleted out from under us — a genuine conflict, not a token race.
throw;
}
PropertyEntry version = entry.Property(nameof(IVersionedAggregate.Version));
object currentVersion = databaseValues[nameof(IVersionedAggregate.Version)]!;
version.OriginalValue = currentVersion;
version.CurrentValue = currentVersion;
resolvedAny = true;
}
if (!resolvedAny)
{
throw;
}
}
}
}
/// <summary>
/// Persist pending changes, mapping the EF optimistic-concurrency failure to
/// <see cref="PreconditionFailedError" /> (→ 412). When a versioned root carries an
/// <c>IsConcurrencyToken</c> column and its <c>Version</c> is bumped before saving, EF emits
/// <c>UPDATE … WHERE Id=@id AND Version=@original</c>; a zero-row result (another writer won
/// the race between our load and save) throws <see cref="DbUpdateConcurrencyException" />.
/// This is the backstop that closes the load→save TOCTOU the handler pre-check cannot.
/// Issue #253.
/// </summary>
public static async Task<Either<BaseError, Unit>> SaveChangesWithConcurrencyGuard(
this DbContext dbContext,
CancellationToken cancellationToken)
{
try
{
await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default;
}
catch (DbUpdateConcurrencyException)
{
return new PreconditionFailedError(
"The resource was modified by another request. Reload and try again.");
}
}
}
@@ -30,21 +30,12 @@ public class DisconnectEmbyHandler : IRequestHandler<DisconnectEmby, Either<Base
DisconnectEmby request, DisconnectEmby request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// This is a terminal handler with no lock handoff (unlike the Plex pin-flow handlers) — List<int> ids = await _mediaSourceRepository.DeleteAllEmby();
// release unconditionally so a throw from any awaited dependency (repo delete, search-index await _searchIndex.RemoveItems(ids);
// commit, secret store) can't wedge the Emby lock until restart (design #202 finding 7). _searchIndex.Commit();
try await _embySecretStore.DeleteAll();
{ _entityLocker.UnlockRemoteMediaSource<EmbyMediaSource>();
List<int> ids = await _mediaSourceRepository.DeleteAllEmby();
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
await _embySecretStore.DeleteAll();
return Unit.Default; return Unit.Default;
}
finally
{
_entityLocker.UnlockRemoteMediaSource<EmbyMediaSource>();
}
} }
} }
@@ -2,6 +2,6 @@ using ErsatzTV.Core;
namespace ErsatzTV.Application.Emby; namespace ErsatzTV.Application.Emby;
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan, bool DeepScan, bool Unlock = true) public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan, bool DeepScan)
: IRequest<Either<BaseError, Unit>>, : IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest; IScannerBackgroundServiceRequest;
@@ -1,4 +1,4 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
@@ -12,29 +12,12 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
public UpdateEmbyPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) => public UpdateEmbyPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) =>
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
public async Task<Either<BaseError, Unit>> Handle( public Task<Either<BaseError, Unit>> Handle(
UpdateEmbyPathReplacements request, UpdateEmbyPathReplacements request,
CancellationToken cancellationToken) CancellationToken cancellationToken) =>
{ Validate(request, cancellationToken)
Option<EmbyMediaSource> maybeSource = .MapT(pms => MergePathReplacements(request, pms))
await _mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken); .Bind(v => v.ToEitherAsync());
return await maybeSource.Match(
Some: async embyMediaSource =>
{
Option<BaseError> maybeError = ValidateItems(request, embyMediaSource);
return await maybeError.Match(
Some: error => Task.FromResult(Left<BaseError, Unit>(error)),
None: async () =>
{
await MergePathReplacements(request, embyMediaSource);
return Right<BaseError, Unit>(Unit.Default);
});
},
None: () => Task.FromResult(
Left<BaseError, Unit>(
BaseError.New($"Emby media source {request.EmbyMediaSourceId} does not exist."))));
}
private Task<Unit> MergePathReplacements( private Task<Unit> MergePathReplacements(
UpdateEmbyPathReplacements request, UpdateEmbyPathReplacements request,
@@ -54,38 +37,12 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
private static EmbyPathReplacement Project(EmbyPathReplacementItem vm) => private static EmbyPathReplacement Project(EmbyPathReplacementItem vm) =>
new() { Id = vm.Id, EmbyPath = vm.EmbyPath, LocalPath = vm.LocalPath }; new() { Id = vm.Id, EmbyPath = vm.EmbyPath, LocalPath = vm.LocalPath };
// Defense-in-depth for design #202 findings 2c/8 — the repo UPDATE is scoped by private Task<Validation<BaseError, EmbyMediaSource>> Validate(UpdateEmbyPathReplacements request, CancellationToken cancellationToken) =>
// EmbyMediaSourceId, but reject a foreign/blank/null row here too, before any write, so the EmbyMediaSourceMustExist(request, cancellationToken);
// mutation is all-or-nothing.
private static Option<BaseError> ValidateItems(
UpdateEmbyPathReplacements request,
EmbyMediaSource embyMediaSource)
{
List<EmbyPathReplacementItem> items = request.PathReplacements ?? [];
if (items.Any(item => item is null)) private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist(
{ UpdateEmbyPathReplacements request, CancellationToken cancellationToken) =>
return BaseError.New("Path replacement items must not be null."); _mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken)
} .Map(v => v.ToValidation<BaseError>(
$"Emby media source {request.EmbyMediaSourceId} does not exist."));
if (items.Any(item => string.IsNullOrWhiteSpace(item.EmbyPath) || string.IsNullOrWhiteSpace(item.LocalPath)))
{
return BaseError.New("Each path replacement requires a non-blank Emby path and local path.");
}
var existingIds = (embyMediaSource.PathReplacements ?? new List<EmbyPathReplacement>())
.Map(pr => pr.Id)
.ToList();
var foreignIds = items.Filter(item => item.Id > 0 && !existingIds.Contains(item.Id))
.Map(item => item.Id)
.ToList();
if (foreignIds.Count > 0)
{
return BaseError.New(
$"Path replacement id(s) {string.Join(", ", foreignIds)} do not belong to Emby media source " +
$"{request.EmbyMediaSourceId}.");
}
return Option<BaseError>.None;
}
} }
@@ -52,17 +52,13 @@ internal static class Mapper
ffmpegProfile.Id, ffmpegProfile.Id,
ffmpegProfile.Name, ffmpegProfile.Name,
ffmpegProfile.ThreadCount, ffmpegProfile.ThreadCount,
ffmpegProfile.NormalizeAudio,
ffmpegProfile.NormalizeVideo,
ffmpegProfile.HardwareAcceleration, ffmpegProfile.HardwareAcceleration,
ffmpegProfile.VaapiDisplay, ffmpegProfile.VaapiDisplay,
ffmpegProfile.VaapiDriver, ffmpegProfile.VaapiDriver,
ffmpegProfile.VaapiDevice, ffmpegProfile.VaapiDevice,
ffmpegProfile.QsvExtraHardwareFrames, ffmpegProfile.QsvExtraHardwareFrames,
ffmpegProfile.ResolutionId,
ffmpegProfile.Resolution.Name, ffmpegProfile.Resolution.Name,
ffmpegProfile.ScalingBehavior, ffmpegProfile.ScalingBehavior,
ffmpegProfile.PadMode,
ffmpegProfile.VideoFormat, ffmpegProfile.VideoFormat,
ffmpegProfile.VideoProfile, ffmpegProfile.VideoProfile,
ffmpegProfile.VideoPreset, ffmpegProfile.VideoPreset,
@@ -75,10 +71,8 @@ internal static class Mapper
ffmpegProfile.AudioBitrate, ffmpegProfile.AudioBitrate,
ffmpegProfile.AudioBufferSize, ffmpegProfile.AudioBufferSize,
ffmpegProfile.NormalizeLoudnessMode, ffmpegProfile.NormalizeLoudnessMode,
ffmpegProfile.TargetLoudness,
ffmpegProfile.AudioChannels, ffmpegProfile.AudioChannels,
ffmpegProfile.AudioSampleRate, ffmpegProfile.AudioSampleRate,
ffmpegProfile.NormalizeFramerate, ffmpegProfile.NormalizeFramerate,
ffmpegProfile.NormalizeColors, ffmpegProfile.DeinterlaceVideo);
ffmpegProfile.DeinterlaceVideo == true);
} }
@@ -20,6 +20,4 @@ public record CreateFillerPreset(
int? PlaylistId, int? PlaylistId,
string Expression, string Expression,
bool UseChaptersAsMediaItems bool UseChaptersAsMediaItems
) : IRequest<Either<BaseError, CreateFillerPresetResult>>; ) : IRequest<Either<BaseError, Unit>>;
public record CreateFillerPresetResult(int FillerPresetId) : EntityIdResult(FillerPresetId);
@@ -6,25 +6,23 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Filler; namespace ErsatzTV.Application.Filler;
public class CreateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFactory) public class CreateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<CreateFillerPreset, Either<BaseError, CreateFillerPresetResult>> : IRequestHandler<CreateFillerPreset, Either<BaseError, Unit>>
{ {
public async Task<Either<BaseError, CreateFillerPresetResult>> Handle( public async Task<Either<BaseError, Unit>> Handle(CreateFillerPreset request, CancellationToken cancellationToken)
CreateFillerPreset request,
CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FillerPreset> validation = await Validate(dbContext, request); Validation<BaseError, FillerPreset> validation = await Validate(dbContext, request);
return await validation.Apply(fp => Persist(dbContext, fp, cancellationToken)); return await validation.Apply(fp => Persist(dbContext, fp, cancellationToken));
} }
private static async Task<CreateFillerPresetResult> Persist( private static async Task<Unit> Persist(
TvContext dbContext, TvContext dbContext,
FillerPreset fillerPreset, FillerPreset fillerPreset,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await dbContext.FillerPresets.AddAsync(fillerPreset, cancellationToken); await dbContext.FillerPresets.AddAsync(fillerPreset, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
return new CreateFillerPresetResult(fillerPreset.Id); return Unit.Default;
} }
private static Task<Validation<BaseError, FillerPreset>> Validate( private static Task<Validation<BaseError, FillerPreset>> Validate(
@@ -1,6 +1,5 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -19,14 +18,8 @@ public class DeleteFillerPresetHandler : IRequestHandler<DeleteFillerPreset, Eit
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<FillerPreset> maybeFillerPreset = await FillerPresetMustExist(dbContext, request, cancellationToken); Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request, cancellationToken);
return await validation.Apply(ps => DoDeletion(dbContext, ps));
// must-exist maps to a NotFoundError Either directly (not via Validation, which
// aggregates errors and loses the subtype the API layer maps to 404)
return await maybeFillerPreset.Match(
Some: fillerPreset => DoDeletion(dbContext, fillerPreset).Map(Right<BaseError, Unit>),
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"FillerPreset {request.FillerPresetId} does not exist.")));
} }
private static Task<Unit> DoDeletion(TvContext dbContext, FillerPreset fillerPreset) private static Task<Unit> DoDeletion(TvContext dbContext, FillerPreset fillerPreset)
@@ -35,10 +28,11 @@ public class DeleteFillerPresetHandler : IRequestHandler<DeleteFillerPreset, Eit
return dbContext.SaveChangesAsync().ToUnit(); return dbContext.SaveChangesAsync().ToUnit();
} }
private static Task<Option<FillerPreset>> FillerPresetMustExist( private static Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
TvContext dbContext, TvContext dbContext,
DeleteFillerPreset request, DeleteFillerPreset request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
dbContext.FillerPresets dbContext.FillerPresets
.SelectOneAsync(fp => fp.Id, ps => ps.Id == request.FillerPresetId, cancellationToken); .SelectOneAsync(fp => fp.Id, ps => ps.Id == request.FillerPresetId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"FillerPreset {request.FillerPresetId} does not exist."));
} }
@@ -1,6 +1,5 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -13,19 +12,8 @@ public class UpdateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFac
public async Task<Either<BaseError, Unit>> Handle(UpdateFillerPreset request, CancellationToken cancellationToken) public async Task<Either<BaseError, Unit>> Handle(UpdateFillerPreset request, CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<FillerPreset> maybeFillerPreset = await FillerPresetMustExist(dbContext, request, cancellationToken); Validation<BaseError, FillerPreset> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request, cancellationToken));
// must-exist maps to a NotFoundError Either directly (not via Validation, which
// aggregates errors and loses the subtype the API layer maps to 404)
return await maybeFillerPreset.Match(
Some: async fillerPreset =>
{
Validation<BaseError, string> validation = await ValidateName(dbContext, request);
return await validation.Apply((string _) =>
ApplyUpdateRequest(dbContext, fillerPreset, request, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"FillerPreset {request.Id} does not exist.")));
} }
private static async Task<Unit> ApplyUpdateRequest( private static async Task<Unit> ApplyUpdateRequest(
@@ -56,12 +44,20 @@ public class UpdateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFac
return Unit.Default; return Unit.Default;
} }
private static Task<Option<FillerPreset>> FillerPresetMustExist( private static async Task<Validation<BaseError, FillerPreset>> Validate(
TvContext dbContext,
UpdateFillerPreset request,
CancellationToken cancellationToken) =>
(await FillerPresetMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
.Apply((collectionToUpdate, _) => collectionToUpdate);
private static Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
TvContext dbContext, TvContext dbContext,
UpdateFillerPreset request, UpdateFillerPreset request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
dbContext.FillerPresets dbContext.FillerPresets
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.Id, cancellationToken); .SelectOneAsync(ps => ps.Id, ps => ps.Id == request.Id, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Filler preset does not exist"));
private static async Task<Validation<BaseError, string>> ValidateName( private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext, TvContext dbContext,
+1 -20
View File
@@ -6,26 +6,7 @@ namespace ErsatzTV.Application.Filler;
internal static class Mapper internal static class Mapper
{ {
internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) => internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) =>
new(fillerPreset.Id, fillerPreset.Name, fillerPreset.FillerKind); new(fillerPreset.Id, fillerPreset.Name);
internal static FillerPresetFullResponseModel ProjectToFullResponseModel(FillerPreset fillerPreset) =>
new(
fillerPreset.Id,
fillerPreset.Name,
fillerPreset.FillerKind,
fillerPreset.FillerMode,
fillerPreset.Duration,
fillerPreset.Count,
fillerPreset.PadToNearestMinute,
fillerPreset.AllowWatermarks,
fillerPreset.CollectionType,
fillerPreset.CollectionId,
fillerPreset.MediaItemId,
fillerPreset.MultiCollectionId,
fillerPreset.SmartCollectionId,
fillerPreset.PlaylistId,
fillerPreset.Expression,
fillerPreset.UseChaptersAsMediaItems);
internal static FillerPresetViewModel ProjectToViewModel(FillerPreset fillerPreset) => internal static FillerPresetViewModel ProjectToViewModel(FillerPreset fillerPreset) =>
new( new(
@@ -1,6 +1,5 @@
using ErsatzTV.Core.Api.Filler; using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Filler; namespace ErsatzTV.Application.Filler;
public record GetAllFillerPresetsForApi(FillerKind? FillerKind = null) : IRequest<List<FillerPresetResponseModel>>; public record GetAllFillerPresetsForApi : IRequest<List<FillerPresetResponseModel>>;
@@ -14,13 +14,9 @@ public class GetAllFillerPresetsForApiHandler(IDbContextFactory<TvContext> dbCon
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
IQueryable<FillerPreset> query = dbContext.FillerPresets.AsNoTracking(); List<FillerPreset> fillerPresets = await dbContext.FillerPresets
if (request.FillerKind is { } fillerKind) .AsNoTracking()
{ .ToListAsync(cancellationToken);
query = query.Where(fp => fp.FillerKind == fillerKind);
}
List<FillerPreset> fillerPresets = await query.ToListAsync(cancellationToken);
return fillerPresets.Map(ProjectToResponseModel).ToList(); return fillerPresets.Map(ProjectToResponseModel).ToList();
} }
} }
@@ -1,5 +0,0 @@
using ErsatzTV.Core.Api.Filler;
namespace ErsatzTV.Application.Filler;
public record GetFillerPresetByIdForApi(int Id) : IRequest<Option<FillerPresetFullResponseModel>>;
@@ -1,22 +0,0 @@
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Filler.Mapper;
namespace ErsatzTV.Application.Filler;
public class GetFillerPresetByIdForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetFillerPresetByIdForApi, Option<FillerPresetFullResponseModel>>
{
public async Task<Option<FillerPresetFullResponseModel>> Handle(
GetFillerPresetByIdForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.FillerPresets
.AsNoTracking()
.SelectOneAsync(fp => fp.Id, fp => fp.Id == request.Id, cancellationToken)
.MapT(ProjectToFullResponseModel);
}
}
@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Images;
public record ImageFolderExists(int LibraryFolderId) : IRequest<bool>;
@@ -1,21 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Images;
public class ImageFolderExistsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ImageFolderExists, bool>
{
public async Task<bool> Handle(ImageFolderExists request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.LibraryFolders
.AsNoTracking()
.AnyAsync(
lf => lf.Id == request.LibraryFolderId
&& lf.LibraryPath.Library.MediaKind == LibraryMediaKind.Images,
cancellationToken);
}
}
@@ -30,21 +30,12 @@ public class DisconnectJellyfinHandler : IRequestHandler<DisconnectJellyfin, Eit
DisconnectJellyfin request, DisconnectJellyfin request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// This is a terminal handler with no lock handoff (unlike the Plex pin-flow handlers) — List<int> ids = await _mediaSourceRepository.DeleteAllJellyfin();
// release unconditionally so a throw from any awaited dependency (repo delete, search-index await _searchIndex.RemoveItems(ids);
// commit, secret store) can't wedge the Jellyfin lock until restart (design #202 finding 7). _searchIndex.Commit();
try await _jellyfinSecretStore.DeleteAll();
{ _entityLocker.UnlockRemoteMediaSource<JellyfinMediaSource>();
List<int> ids = await _mediaSourceRepository.DeleteAllJellyfin();
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
await _jellyfinSecretStore.DeleteAll();
return Unit.Default; return Unit.Default;
}
finally
{
_entityLocker.UnlockRemoteMediaSource<JellyfinMediaSource>();
}
} }
} }
@@ -2,6 +2,6 @@ using ErsatzTV.Core;
namespace ErsatzTV.Application.Jellyfin; namespace ErsatzTV.Application.Jellyfin;
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan, bool DeepScan, bool Unlock = true) : public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan, bool DeepScan) :
IRequest<Either<BaseError, Unit>>, IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest; IScannerBackgroundServiceRequest;
@@ -1,4 +1,4 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
@@ -12,29 +12,12 @@ public class UpdateJellyfinPathReplacementsHandler : IRequestHandler<UpdateJelly
public UpdateJellyfinPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) => public UpdateJellyfinPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) =>
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
public async Task<Either<BaseError, Unit>> Handle( public Task<Either<BaseError, Unit>> Handle(
UpdateJellyfinPathReplacements request, UpdateJellyfinPathReplacements request,
CancellationToken cancellationToken) CancellationToken cancellationToken) =>
{ Validate(request)
Option<JellyfinMediaSource> maybeSource = .MapT(pms => MergePathReplacements(request, pms))
await _mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId); .Bind(v => v.ToEitherAsync());
return await maybeSource.Match(
Some: async jellyfinMediaSource =>
{
Option<BaseError> maybeError = ValidateItems(request, jellyfinMediaSource);
return await maybeError.Match(
Some: error => Task.FromResult(Left<BaseError, Unit>(error)),
None: async () =>
{
await MergePathReplacements(request, jellyfinMediaSource);
return Right<BaseError, Unit>(Unit.Default);
});
},
None: () => Task.FromResult(
Left<BaseError, Unit>(
BaseError.New($"Jellyfin media source {request.JellyfinMediaSourceId} does not exist."))));
}
private Task<Unit> MergePathReplacements( private Task<Unit> MergePathReplacements(
UpdateJellyfinPathReplacements request, UpdateJellyfinPathReplacements request,
@@ -54,38 +37,12 @@ public class UpdateJellyfinPathReplacementsHandler : IRequestHandler<UpdateJelly
private static JellyfinPathReplacement Project(JellyfinPathReplacementItem vm) => private static JellyfinPathReplacement Project(JellyfinPathReplacementItem vm) =>
new() { Id = vm.Id, JellyfinPath = vm.JellyfinPath, LocalPath = vm.LocalPath }; new() { Id = vm.Id, JellyfinPath = vm.JellyfinPath, LocalPath = vm.LocalPath };
// Defense-in-depth for design #202 findings 2c/8 — the repo UPDATE is scoped by private Task<Validation<BaseError, JellyfinMediaSource>> Validate(UpdateJellyfinPathReplacements request) =>
// JellyfinMediaSourceId, but reject a foreign/blank/null row here too, before any write, so the JellyfinMediaSourceMustExist(request);
// mutation is all-or-nothing.
private static Option<BaseError> ValidateItems(
UpdateJellyfinPathReplacements request,
JellyfinMediaSource jellyfinMediaSource)
{
List<JellyfinPathReplacementItem> items = request.PathReplacements ?? [];
if (items.Any(item => item is null)) private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist(
{ UpdateJellyfinPathReplacements request) =>
return BaseError.New("Path replacement items must not be null."); _mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
} .Map(v => v.ToValidation<BaseError>(
$"Jellyfin media source {request.JellyfinMediaSourceId} does not exist."));
if (items.Any(item => string.IsNullOrWhiteSpace(item.JellyfinPath) || string.IsNullOrWhiteSpace(item.LocalPath)))
{
return BaseError.New("Each path replacement requires a non-blank Jellyfin path and local path.");
}
var existingIds = (jellyfinMediaSource.PathReplacements ?? new List<JellyfinPathReplacement>())
.Map(pr => pr.Id)
.ToList();
var foreignIds = items.Filter(item => item.Id > 0 && !existingIds.Contains(item.Id))
.Map(item => item.Id)
.ToList();
if (foreignIds.Count > 0)
{
return BaseError.New(
$"Path replacement id(s) {string.Join(", ", foreignIds)} do not belong to Jellyfin media source " +
$"{request.JellyfinMediaSourceId}.");
}
return Option<BaseError>.None;
}
} }
@@ -1,5 +1,4 @@
using System.IO.Abstractions; using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.MediaSources; using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
@@ -15,18 +14,15 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker; private readonly IEntityLocker _entityLocker;
private readonly IFileSystem _fileSystem;
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel; private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
public CreateLocalLibraryHandler( public CreateLocalLibraryHandler(
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel, ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
IEntityLocker entityLocker, IEntityLocker entityLocker,
IFileSystem fileSystem,
IDbContextFactory<TvContext> dbContextFactory) IDbContextFactory<TvContext> dbContextFactory)
{ {
_scannerWorkerChannel = scannerWorkerChannel; _scannerWorkerChannel = scannerWorkerChannel;
_entityLocker = entityLocker; _entityLocker = entityLocker;
_fileSystem = fileSystem;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
} }
@@ -35,7 +31,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, LocalLibrary> validation = await Validate(_fileSystem, dbContext, request); Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary)); return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
} }
@@ -48,30 +44,18 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
if (_entityLocker.LockLibrary(localLibrary.Id)) if (_entityLocker.LockLibrary(localLibrary.Id))
{ {
try await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
{
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
}
catch
{
// the scanner only unlocks when it receives the message; if the enqueue fails
// after we acquired the lock, release it here or it is held forever.
_entityLocker.UnlockLibrary(localLibrary.Id);
throw;
}
} }
return ProjectToViewModel(localLibrary); return ProjectToViewModel(localLibrary);
} }
private static Task<Validation<BaseError, LocalLibrary>> Validate( private static Task<Validation<BaseError, LocalLibrary>> Validate(
IFileSystem fileSystem,
TvContext dbContext, TvContext dbContext,
CreateLocalLibrary request) => CreateLocalLibrary request) =>
MediaSourceMustExist(dbContext, request) MediaSourceMustExist(dbContext, request)
.BindT(localLibrary => NameMustBeValid(request, localLibrary)) .BindT(localLibrary => NameMustBeValid(request, localLibrary))
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary)) .BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary));
.BindT(localLibrary => NewPathsMustExist(fileSystem, localLibrary));
private static Task<Validation<BaseError, LocalLibrary>> MediaSourceMustExist( private static Task<Validation<BaseError, LocalLibrary>> MediaSourceMustExist(
TvContext dbContext, TvContext dbContext,
@@ -1,5 +1,4 @@
using System.IO.Abstractions; using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -15,28 +14,6 @@ public abstract class LocalLibraryHandlerBase
.Bind(_ => request.NotLongerThan(50)(c => c.Name)) .Bind(_ => request.NotLongerThan(50)(c => c.Name))
.Map(_ => localLibrary).AsTask(); .Map(_ => localLibrary).AsTask();
/// <summary>
/// Validates that every NEW path (<c>Id &lt; 1</c> — see design #202 §C2) exists on the
/// filesystem. Existing rows are exempt: an unmounted share must not block saving a rename,
/// matching Blazor's behavior of only checking existence when a path is added.
/// </summary>
protected static Task<Validation<BaseError, LocalLibrary>> NewPathsMustExist(
IFileSystem fileSystem,
LocalLibrary localLibrary)
{
List<string> missing = localLibrary.Paths
.Filter(p => p.Id < 1)
.Filter(p => !fileSystem.Directory.Exists(p.Path))
.Map(p => p.Path)
.ToList();
Validation<BaseError, LocalLibrary> result = missing.Count == 0
? Success<BaseError, LocalLibrary>(localLibrary)
: Fail<BaseError, LocalLibrary>($"Path(s) do not exist on the filesystem: {string.Join(", ", missing)}");
return result.AsTask();
}
protected static async Task<Validation<BaseError, LocalLibrary>> PathsMustBeValid( protected static async Task<Validation<BaseError, LocalLibrary>> PathsMustBeValid(
TvContext dbContext, TvContext dbContext,
LocalLibrary localLibrary, LocalLibrary localLibrary,
@@ -79,31 +79,10 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
private static async Task<Validation<BaseError, Parameters>> Validate( private static async Task<Validation<BaseError, Parameters>> Validate(
TvContext dbContext, TvContext dbContext,
MoveLocalLibraryPath request, MoveLocalLibraryPath request,
CancellationToken cancellationToken) CancellationToken cancellationToken) =>
{ (await LibraryPathMustExist(dbContext, request, cancellationToken),
Validation<BaseError, Parameters> parameters = await LocalLibraryMustExist(dbContext, request, cancellationToken))
(await LibraryPathMustExist(dbContext, request, cancellationToken), .Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary));
await LocalLibraryMustExist(dbContext, request, cancellationToken))
.Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary));
return parameters
.Bind(TargetLibraryMustDiffer)
.Bind(TargetLibraryMustMatchMediaKind);
}
// Blazor's move dialog filters the target-library picker to same-kind, source-excluded
// libraries only (MoveLocalLibraryPathDialog.razor:82); an API/MCP client bypasses that
// client-side filter today, so #202 moves both invariants into the handler (design #202 §C5,
// finding 3).
private static Validation<BaseError, Parameters> TargetLibraryMustDiffer(Parameters parameters) =>
parameters.LibraryPath.LibraryId == parameters.Library.Id
? Fail<BaseError, Parameters>("Target library must be different from the source path's current library")
: Success<BaseError, Parameters>(parameters);
private static Validation<BaseError, Parameters> TargetLibraryMustMatchMediaKind(Parameters parameters) =>
parameters.LibraryPath.Library.MediaKind != parameters.Library.MediaKind
? Fail<BaseError, Parameters>("Target library must have the same media kind as the source path's library")
: Success<BaseError, Parameters>(parameters);
private static Task<Validation<BaseError, LibraryPath>> LibraryPathMustExist( private static Task<Validation<BaseError, LibraryPath>> LibraryPathMustExist(
TvContext dbContext, TvContext dbContext,
@@ -1,11 +1,3 @@
namespace ErsatzTV.Application.Libraries; namespace ErsatzTV.Application.Libraries;
public enum QueueLibraryScanResult public record QueueLibraryScanByLibraryId(int LibraryId) : IRequest<bool>;
{
Queued,
NotFound,
SyncDisabled,
AlreadyScanning
}
public record QueueLibraryScanByLibraryId(int LibraryId, bool DeepScan = false) : IRequest<QueueLibraryScanResult>;
@@ -17,11 +17,9 @@ public class QueueLibraryScanByLibraryIdHandler(
IEntityLocker locker, IEntityLocker locker,
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorker, ChannelWriter<IScannerBackgroundServiceRequest> scannerWorker,
ILogger<QueueLibraryScanByLibraryIdHandler> logger) ILogger<QueueLibraryScanByLibraryIdHandler> logger)
: IRequestHandler<QueueLibraryScanByLibraryId, QueueLibraryScanResult> : IRequestHandler<QueueLibraryScanByLibraryId, bool>
{ {
public async Task<QueueLibraryScanResult> Handle( public async Task<bool> Handle(QueueLibraryScanByLibraryId request, CancellationToken cancellationToken)
QueueLibraryScanByLibraryId request,
CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
@@ -42,17 +40,10 @@ public class QueueLibraryScanByLibraryIdHandler(
if (!shouldSyncItems) if (!shouldSyncItems)
{ {
logger.LogWarning("Library sync is disabled for library id {Id}", library.Id); logger.LogWarning("Library sync is disabled for library id {Id}", library.Id);
return QueueLibraryScanResult.SyncDisabled; return false;
} }
// A true from LockLibrary confers ownership of exactly one release; a false means a scan if (locker.LockLibrary(library.Id))
// is already in progress and we own no release.
if (!locker.LockLibrary(library.Id))
{
return QueueLibraryScanResult.AlreadyScanning;
}
try
{ {
logger.LogDebug("Queued library scan for library id {Id}", library.Id); logger.LogDebug("Queued library scan for library id {Id}", library.Id);
@@ -66,7 +57,7 @@ public class QueueLibraryScanByLibraryIdHandler(
new SynchronizePlexLibraries(library.MediaSourceId), new SynchronizePlexLibraries(library.MediaSourceId),
cancellationToken); cancellationToken);
await scannerWorker.WriteAsync( await scannerWorker.WriteAsync(
new ForceSynchronizePlexLibraryById(library.Id, request.DeepScan), new ForceSynchronizePlexLibraryById(library.Id, false),
cancellationToken); cancellationToken);
break; break;
case JellyfinLibrary: case JellyfinLibrary:
@@ -74,7 +65,7 @@ public class QueueLibraryScanByLibraryIdHandler(
new SynchronizeJellyfinLibraries(library.MediaSourceId), new SynchronizeJellyfinLibraries(library.MediaSourceId),
cancellationToken); cancellationToken);
await scannerWorker.WriteAsync( await scannerWorker.WriteAsync(
new ForceSynchronizeJellyfinLibraryById(library.Id, request.DeepScan), new ForceSynchronizeJellyfinLibraryById(library.Id, false),
cancellationToken); cancellationToken);
break; break;
case EmbyLibrary: case EmbyLibrary:
@@ -82,23 +73,15 @@ public class QueueLibraryScanByLibraryIdHandler(
new SynchronizeEmbyLibraries(library.MediaSourceId), new SynchronizeEmbyLibraries(library.MediaSourceId),
cancellationToken); cancellationToken);
await scannerWorker.WriteAsync( await scannerWorker.WriteAsync(
new ForceSynchronizeEmbyLibraryById(library.Id, request.DeepScan), new ForceSynchronizeEmbyLibraryById(library.Id, false),
cancellationToken); cancellationToken);
break; break;
} }
} }
catch
{
// the scanner only unlocks when it receives the message; if enqueueing fails
// (e.g. request aborted / channel completed) after we acquired the lock, release
// it here or it is held forever (EnqueueWithTraktLock pattern).
locker.UnlockLibrary(library.Id);
throw;
}
return QueueLibraryScanResult.Queued; return true;
} }
return QueueLibraryScanResult.NotFound; return false;
} }
} }
@@ -1,14 +1,3 @@
namespace ErsatzTV.Application.Libraries; namespace ErsatzTV.Application.Libraries;
public enum QueueShowScanResult public record QueueShowScanByLibraryId(int LibraryId, int ShowId, string ShowTitle, bool DeepScan) : IRequest<bool>;
{
Queued,
NotFound,
Unsupported,
SyncDisabled,
AlreadyScanning,
ScanFailed
}
public record QueueShowScanByLibraryId(int LibraryId, int ShowId, string ShowTitle, bool DeepScan)
: IRequest<QueueShowScanResult>;
@@ -19,9 +19,9 @@ public class QueueShowScanByLibraryIdHandler(
IMediator mediator, IMediator mediator,
ChannelWriter<IBackgroundServiceRequest> workerChannel, ChannelWriter<IBackgroundServiceRequest> workerChannel,
ILogger<QueueShowScanByLibraryIdHandler> logger) ILogger<QueueShowScanByLibraryIdHandler> logger)
: IRequestHandler<QueueShowScanByLibraryId, QueueShowScanResult> : IRequestHandler<QueueShowScanByLibraryId, bool>
{ {
public async Task<QueueShowScanResult> Handle(QueueShowScanByLibraryId request, CancellationToken cancellationToken) public async Task<bool> Handle(QueueShowScanByLibraryId request, CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
@@ -42,14 +42,14 @@ public class QueueShowScanByLibraryIdHandler(
if (!shouldSyncItems) if (!shouldSyncItems)
{ {
logger.LogWarning("Library sync is disabled for library id {Id}", library.Id); logger.LogWarning("Library sync is disabled for library id {Id}", library.Id);
return QueueShowScanResult.SyncDisabled; return false;
} }
// A false from LockLibrary means a scan is already in progress; we own no release. // Check if library is already being scanned - return false if locked
if (!locker.LockLibrary(library.Id)) if (!locker.LockLibrary(library.Id))
{ {
logger.LogWarning("Library {Id} is already being scanned, cannot scan individual show", library.Id); logger.LogWarning("Library {Id} is already being scanned, cannot scan individual show", library.Id);
return QueueShowScanResult.AlreadyScanning; return false;
} }
logger.LogDebug( logger.LogDebug(
@@ -60,43 +60,41 @@ public class QueueShowScanByLibraryIdHandler(
try try
{ {
QueueShowScanResult outcome; var success = false;
switch (library) switch (library)
{ {
case PlexLibrary: case PlexLibrary:
Either<BaseError, string> plexResult = await mediator.Send( Either<BaseError, string> plexResult = await mediator.Send(
new SynchronizePlexShowById(library.Id, request.ShowId, request.DeepScan), new SynchronizePlexShowById(library.Id, request.ShowId, request.DeepScan),
cancellationToken); cancellationToken);
outcome = plexResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed; success = plexResult.IsRight;
break; break;
case JellyfinLibrary: case JellyfinLibrary:
Either<BaseError, string> jellyfinResult = await mediator.Send( Either<BaseError, string> jellyfinResult = await mediator.Send(
new SynchronizeJellyfinShowById(library.Id, request.ShowId, request.DeepScan), new SynchronizeJellyfinShowById(library.Id, request.ShowId, request.DeepScan),
cancellationToken); cancellationToken);
outcome = jellyfinResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed; success = jellyfinResult.IsRight;
break; break;
case EmbyLibrary: case EmbyLibrary:
Either<BaseError, string> embyResult = await mediator.Send( Either<BaseError, string> embyResult = await mediator.Send(
new SynchronizeEmbyShowById(library.Id, request.ShowId, request.DeepScan), new SynchronizeEmbyShowById(library.Id, request.ShowId, request.DeepScan),
cancellationToken); cancellationToken);
outcome = embyResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed; success = embyResult.IsRight;
break; break;
case LocalLibrary: case LocalLibrary:
logger.LogWarning("Single show scanning is not supported for local libraries"); logger.LogWarning("Single show scanning is not supported for local libraries");
outcome = QueueShowScanResult.Unsupported;
break; break;
default: default:
logger.LogWarning("Unknown library type for library {Id}", library.Id); logger.LogWarning("Unknown library type for library {Id}", library.Id);
outcome = QueueShowScanResult.Unsupported;
break; break;
} }
if (outcome == QueueShowScanResult.Queued && request.DeepScan) if (success && request.DeepScan)
{ {
await workerChannel.WriteAsync(new ExtractEmbeddedShowSubtitles(request.ShowId), cancellationToken); await workerChannel.WriteAsync(new ExtractEmbeddedShowSubtitles(request.ShowId), cancellationToken);
} }
return outcome; return success;
} }
finally finally
{ {
@@ -105,6 +103,6 @@ public class QueueShowScanByLibraryIdHandler(
} }
} }
return QueueShowScanResult.NotFound; return false;
} }
} }
@@ -1,5 +1,4 @@
using System.IO.Abstractions; using System.Threading.Channels;
using System.Threading.Channels;
using Dapper; using Dapper;
using ErsatzTV.Application.MediaSources; using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core; using ErsatzTV.Core;
@@ -18,20 +17,17 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker; private readonly IEntityLocker _entityLocker;
private readonly IFileSystem _fileSystem;
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel; private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
private readonly ISearchIndex _searchIndex; private readonly ISearchIndex _searchIndex;
public UpdateLocalLibraryHandler( public UpdateLocalLibraryHandler(
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel, ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
IEntityLocker entityLocker, IEntityLocker entityLocker,
IFileSystem fileSystem,
ISearchIndex searchIndex, ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory) IDbContextFactory<TvContext> dbContextFactory)
{ {
_scannerWorkerChannel = scannerWorkerChannel; _scannerWorkerChannel = scannerWorkerChannel;
_entityLocker = entityLocker; _entityLocker = entityLocker;
_fileSystem = fileSystem;
_searchIndex = searchIndex; _searchIndex = searchIndex;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
} }
@@ -41,8 +37,7 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation = Validation<BaseError, Parameters> validation = await Validate(dbContext, request, cancellationToken);
await Validate(_fileSystem, dbContext, request, cancellationToken);
return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters)); return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters));
} }
@@ -101,17 +96,7 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
if (_entityLocker.LockLibrary(existing.Id)) if (_entityLocker.LockLibrary(existing.Id))
{ {
try await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
{
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
}
catch
{
// the scanner only unlocks when it receives the message; if the enqueue fails
// after we acquired the lock, release it here or it is held forever.
_entityLocker.UnlockLibrary(existing.Id);
throw;
}
} }
} }
@@ -119,15 +104,13 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
} }
private static Task<Validation<BaseError, Parameters>> Validate( private static Task<Validation<BaseError, Parameters>> Validate(
IFileSystem fileSystem,
TvContext dbContext, TvContext dbContext,
UpdateLocalLibrary request, UpdateLocalLibrary request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
LocalLibraryMustExist(dbContext, request, cancellationToken) LocalLibraryMustExist(dbContext, request, cancellationToken)
.BindT(parameters => NameMustBeValid(request, parameters.Incoming).MapT(_ => parameters)) .BindT(parameters => NameMustBeValid(request, parameters.Incoming).MapT(_ => parameters))
.BindT(parameters => PathsMustBeValid(dbContext, parameters.Incoming, parameters.Existing.Id) .BindT(parameters => PathsMustBeValid(dbContext, parameters.Incoming, parameters.Existing.Id)
.MapT(_ => parameters)) .MapT(_ => parameters));
.BindT(parameters => NewPathsMustExist(fileSystem, parameters.Incoming).MapT(_ => parameters));
private static Task<Validation<BaseError, Parameters>> LocalLibraryMustExist( private static Task<Validation<BaseError, Parameters>> LocalLibraryMustExist(
TvContext dbContext, TvContext dbContext,
@@ -1,621 +0,0 @@
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Infrastructure.Data;
using Flurl;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.LibraryBrowse;
// Shared MediaItem -> LibraryBrowseItemResponseModel projection used by both the library-browse
// search handler and the collection-items handler (#155). Keeping the per-kind hydration and the
// rooted-artwork logic in one place avoids duplicating the Blazor-vs-SPA artwork rooting rules
// (see the Artwork helper below and docs/api-conventions.md §4).
internal static class LibraryBrowseItemMapper
{
// Hydrates an arbitrary set of media item ids (any kinds mixed) into response models. MediaItem
// ids are globally unique across kinds, so passing the full id list to every per-kind query is
// safe: each query only matches its own kind. Callers order/page the result themselves.
public static async Task<List<LibraryBrowseItemResponseModel>> HydrateMediaItemsByIds(
TvContext dbContext,
IReadOnlyList<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
var idList = ids.Distinct().ToList();
var results = new List<LibraryBrowseItemResponseModel>();
results.AddRange(await GetMovies(dbContext, idList, cancellationToken));
results.AddRange(await GetShows(dbContext, idList, cancellationToken));
results.AddRange(await GetSeasons(dbContext, idList, cancellationToken));
results.AddRange(await GetArtists(dbContext, idList, cancellationToken));
results.AddRange(await GetEpisodes(dbContext, idList, cancellationToken));
results.AddRange(await GetMusicVideos(dbContext, idList, cancellationToken));
results.AddRange(await GetSongs(dbContext, idList, cancellationToken));
results.AddRange(await GetOtherVideos(dbContext, idList, cancellationToken));
results.AddRange(await GetImages(dbContext, idList, cancellationToken));
results.AddRange(await GetRemoteStreams(dbContext, idList, cancellationToken));
return results;
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetMovies(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.MovieMetadata
.AsNoTracking()
.Where(mm => ids.Contains(mm.MovieId))
.Include(mm => mm.Artwork)
.Include(mm => mm.Movie)
.ThenInclude(m => m.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(mm => mm.Movie)
.ThenInclude(m => m.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(mm => mm.MovieId)
.Select(g => g.OrderBy(mm => mm.Id).First())
.Map(mm => new LibraryBrowseItemResponseModel(
mm.MovieId,
LibraryBrowseMediaType.Movie,
mm.Title ?? string.Empty,
mm.Movie.LibraryPath.LibraryId,
mm.Movie.LibraryPath.Library.Name,
Artwork(mm, ArtworkKind.Poster),
BestDuration(mm.Movie.MediaVersions),
1,
null,
CollectionType.Movie,
null,
null,
null,
null,
mm.MovieId,
null)).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetShows(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
Dictionary<int, int> counts = await dbContext.Episodes
.AsNoTracking()
.Where(e => ids.Contains(e.Season.ShowId))
.GroupBy(e => e.Season.ShowId)
.Select(g => new { ShowId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.ShowId, g => g.Count, cancellationToken);
return await dbContext.ShowMetadata
.AsNoTracking()
.Where(sm => ids.Contains(sm.ShowId))
.Include(sm => sm.Artwork)
.Include(sm => sm.Show)
.ThenInclude(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(sm => sm.ShowId)
.Select(g => g.OrderBy(sm => sm.Id).First())
.Map(sm => new LibraryBrowseItemResponseModel(
sm.ShowId,
LibraryBrowseMediaType.TelevisionShow,
sm.Title ?? string.Empty,
sm.Show.LibraryPath.LibraryId,
sm.Show.LibraryPath.Library.Name,
Artwork(sm, ArtworkKind.Poster),
null,
counts.TryGetValue(sm.ShowId, out int count) ? count : 0,
null,
CollectionType.TelevisionShow,
null,
null,
null,
null,
sm.ShowId,
null)).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetSeasons(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
Dictionary<int, int> counts = await dbContext.Episodes
.AsNoTracking()
.Where(e => ids.Contains(e.SeasonId))
.GroupBy(e => e.SeasonId)
.Select(g => new { SeasonId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.SeasonId, g => g.Count, cancellationToken);
return await dbContext.SeasonMetadata
.AsNoTracking()
.Where(sm => ids.Contains(sm.SeasonId))
.Include(sm => sm.Artwork)
.Include(sm => sm.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(shm => shm.Artwork)
.Include(sm => sm.Season)
.ThenInclude(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(sm => sm.SeasonId)
.Select(g => g.OrderBy(sm => sm.Id).First())
.Map(sm => new LibraryBrowseItemResponseModel(
sm.SeasonId,
LibraryBrowseMediaType.TelevisionSeason,
SeasonTitle(sm),
sm.Season.LibraryPath.LibraryId,
sm.Season.LibraryPath.Library.Name,
SeasonArtwork(sm),
null,
counts.TryGetValue(sm.SeasonId, out int count) ? count : 0,
null,
CollectionType.TelevisionSeason,
null,
null,
null,
null,
sm.SeasonId,
null)).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetArtists(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
Dictionary<int, int> counts = await dbContext.MusicVideos
.AsNoTracking()
.Where(mv => ids.Contains(mv.ArtistId))
.GroupBy(mv => mv.ArtistId)
.Select(g => new { ArtistId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.ArtistId, g => g.Count, cancellationToken);
return await dbContext.ArtistMetadata
.AsNoTracking()
.Where(am => ids.Contains(am.ArtistId))
.Include(am => am.Artwork)
.Include(am => am.Artist)
.ThenInclude(a => a.LibraryPath)
.ThenInclude(lp => lp.Library)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(am => am.ArtistId)
.Select(g => g.OrderBy(am => am.Id).First())
.Map(am => new LibraryBrowseItemResponseModel(
am.ArtistId,
LibraryBrowseMediaType.Artist,
am.Title ?? string.Empty,
am.Artist.LibraryPath.LibraryId,
am.Artist.LibraryPath.Library.Name,
Artwork(am, ArtworkKind.Thumbnail),
null,
counts.TryGetValue(am.ArtistId, out int count) ? count : 0,
null,
CollectionType.Artist,
null,
null,
null,
null,
am.ArtistId,
null)).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetEpisodes(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.EpisodeMetadata
.AsNoTracking()
.Where(em => ids.Contains(em.EpisodeId))
.Include(em => em.Artwork)
.Include(em => em.Episode)
.ThenInclude(e => e.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(em => em.Episode)
.ThenInclude(e => e.MediaVersions)
.Include(em => em.Episode)
.ThenInclude(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(sh => sh.ShowMetadata)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(em => em.EpisodeId)
.Select(g => g.OrderBy(em => em.Id).First())
.Map(em => new LibraryBrowseItemResponseModel(
em.EpisodeId,
LibraryBrowseMediaType.Episode,
em.Title ?? string.Empty,
em.Episode.LibraryPath.LibraryId,
em.Episode.LibraryPath.Library.Name,
ArtworkWithFallback(em, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(em.Episode.MediaVersions),
1,
null,
CollectionType.Episode,
null,
null,
null,
null,
em.EpisodeId,
null,
EpisodeSubtitle(em),
em.Episode.SeasonId)).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetMusicVideos(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.MusicVideoMetadata
.AsNoTracking()
.Where(mvm => ids.Contains(mvm.MusicVideoId))
.Include(mvm => mvm.Artwork)
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.MediaVersions)
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(mvm => mvm.MusicVideoId)
.Select(g => g.OrderBy(mvm => mvm.Id).First())
.Map(mvm => new LibraryBrowseItemResponseModel(
mvm.MusicVideoId,
LibraryBrowseMediaType.MusicVideo,
mvm.Title ?? string.Empty,
mvm.MusicVideo.LibraryPath.LibraryId,
mvm.MusicVideo.LibraryPath.Library.Name,
ArtworkWithFallback(mvm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(mvm.MusicVideo.MediaVersions),
1,
null,
CollectionType.MusicVideo,
null,
null,
null,
null,
mvm.MusicVideoId,
null,
MusicVideoSubtitle(mvm))).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetSongs(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.SongMetadata
.AsNoTracking()
.Where(sm => ids.Contains(sm.SongId))
.Include(sm => sm.Artwork)
.Include(sm => sm.Song)
.ThenInclude(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(sm => sm.Song)
.ThenInclude(s => s.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(sm => sm.SongId)
.Select(g => g.OrderBy(sm => sm.Id).First())
.Map(sm => new LibraryBrowseItemResponseModel(
sm.SongId,
LibraryBrowseMediaType.Song,
sm.Title ?? string.Empty,
sm.Song.LibraryPath.LibraryId,
sm.Song.LibraryPath.Library.Name,
ArtworkWithFallback(sm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(sm.Song.MediaVersions),
1,
null,
CollectionType.Song,
null,
null,
null,
null,
sm.SongId,
null,
SongSubtitle(sm))).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetOtherVideos(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.OtherVideoMetadata
.AsNoTracking()
.Where(ovm => ids.Contains(ovm.OtherVideoId))
.Include(ovm => ovm.Artwork)
.Include(ovm => ovm.OtherVideo)
.ThenInclude(ov => ov.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(ovm => ovm.OtherVideo)
.ThenInclude(ov => ov.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(ovm => ovm.OtherVideoId)
.Select(g => g.OrderBy(ovm => ovm.Id).First())
.Map(ovm => new LibraryBrowseItemResponseModel(
ovm.OtherVideoId,
LibraryBrowseMediaType.OtherVideo,
ovm.Title ?? string.Empty,
ovm.OtherVideo.LibraryPath.LibraryId,
ovm.OtherVideo.LibraryPath.Library.Name,
ArtworkWithFallback(ovm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(ovm.OtherVideo.MediaVersions),
1,
null,
CollectionType.OtherVideo,
null,
null,
null,
null,
ovm.OtherVideoId,
null,
string.IsNullOrWhiteSpace(ovm.OriginalTitle) ? null : ovm.OriginalTitle)).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetImages(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.ImageMetadata
.AsNoTracking()
.Where(im => ids.Contains(im.ImageId))
.Include(im => im.Artwork)
.Include(im => im.Image)
.ThenInclude(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(im => im.Image)
.ThenInclude(i => i.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(im => im.ImageId)
.Select(g => g.OrderBy(im => im.Id).First())
.Map(im => new LibraryBrowseItemResponseModel(
im.ImageId,
LibraryBrowseMediaType.Image,
im.Title ?? string.Empty,
im.Image.LibraryPath.LibraryId,
im.Image.LibraryPath.Library.Name,
ArtworkWithFallback(im, ArtworkKind.Poster, ArtworkKind.Thumbnail),
BestDuration(im.Image.MediaVersions),
1,
null,
CollectionType.Image,
null,
null,
null,
null,
im.ImageId,
null,
string.IsNullOrWhiteSpace(im.OriginalTitle) ? null : im.OriginalTitle)).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetRemoteStreams(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.RemoteStreamMetadata
.AsNoTracking()
.Where(rsm => ids.Contains(rsm.RemoteStreamId))
.Include(rsm => rsm.Artwork)
.Include(rsm => rsm.RemoteStream)
.ThenInclude(rs => rs.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(rsm => rsm.RemoteStream)
.ThenInclude(rs => rs.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(rsm => rsm.RemoteStreamId)
.Select(g => g.OrderBy(rsm => rsm.Id).First())
.Map(rsm => new LibraryBrowseItemResponseModel(
rsm.RemoteStreamId,
LibraryBrowseMediaType.RemoteStream,
rsm.Title ?? string.Empty,
rsm.RemoteStream.LibraryPath.LibraryId,
rsm.RemoteStream.LibraryPath.Library.Name,
ArtworkWithFallback(rsm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(rsm.RemoteStream.MediaVersions),
1,
null,
CollectionType.RemoteStream,
null,
null,
null,
null,
rsm.RemoteStreamId,
null,
string.IsNullOrWhiteSpace(rsm.OriginalTitle) ? null : rsm.OriginalTitle)).ToList());
}
public static TimeSpan? BestDuration(IEnumerable<MediaVersion> versions)
{
TimeSpan duration = versions
.Select(v => v.Duration)
.Where(d => d > TimeSpan.Zero)
.DefaultIfEmpty()
.Max();
return duration > TimeSpan.Zero ? duration : null;
}
// Returns a rooted, directly-usable artwork URL for the SPA's <img src>. Blazor pages rely on
// GetPosterUrl to prefix "artwork/posters/" and resolve relative to <base href="/">, but the SPA
// renders the value raw from under /app/, so the API must root the URL itself (issue #180).
public static string Artwork(Metadata metadata, ArtworkKind artworkKind)
{
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
if (string.IsNullOrWhiteSpace(artwork))
{
return string.Empty;
}
// Absolute URLs are already usable as-is (matches Blazor's GetPosterUrl guard).
if (artwork.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
artwork.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return artwork;
}
string folder = artworkKind is ArtworkKind.Thumbnail ? "thumbnails" : "posters";
if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
{
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("fillHeight", 440);
}
return $"/artwork/{folder}/{url}";
}
if (artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase))
{
Url url = EmbyUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("maxHeight", 440);
}
return $"/artwork/{folder}/{url}";
}
return $"/artwork/{folder}/{artwork}";
}
private static string ArtworkWithFallback(Metadata metadata, ArtworkKind primary, ArtworkKind fallback)
{
string artwork = Artwork(metadata, primary);
return string.IsNullOrWhiteSpace(artwork) ? Artwork(metadata, fallback) : artwork;
}
private static string SeasonTitle(SeasonMetadata metadata)
{
string showTitle = metadata.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => sm.Title ?? string.Empty)
.IfNone(string.Empty);
string seasonTitle = metadata.Season.SeasonNumber == 0
? "Specials"
: $"Season {metadata.Season.SeasonNumber}";
return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}";
}
// Seasons often have no poster of their own; fall back to the parent show's poster (issue #180).
private static string SeasonArtwork(SeasonMetadata metadata)
{
string artwork = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(artwork))
{
return artwork;
}
return metadata.Season.Show.ShowMetadata.HeadOrNone()
.Match(sm => Artwork(sm, ArtworkKind.Poster), string.Empty);
}
private static string EpisodeSubtitle(EpisodeMetadata metadata)
{
string showTitle = metadata.Episode.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => sm.Title ?? string.Empty)
.IfNone(string.Empty);
int seasonNumber = metadata.Episode.Season.SeasonNumber;
string suffix = $"S{seasonNumber}E{metadata.EpisodeNumber}";
return string.IsNullOrWhiteSpace(showTitle) ? suffix : $"{showTitle} - {suffix}";
}
private static string MusicVideoSubtitle(MusicVideoMetadata metadata)
{
string artist = metadata.MusicVideo.Artist.ArtistMetadata.HeadOrNone()
.Map(am => am.Title ?? string.Empty)
.IfNone(string.Empty);
string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album;
if (!string.IsNullOrWhiteSpace(artist) && !string.IsNullOrWhiteSpace(album))
{
return $"{artist} - {album}";
}
return string.IsNullOrWhiteSpace(artist) ? album : artist;
}
private static string SongSubtitle(SongMetadata metadata)
{
string artists = string.Join(", ", metadata.Artists ?? []);
string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album;
if (!string.IsNullOrWhiteSpace(artists) && !string.IsNullOrWhiteSpace(album))
{
return $"{artists} - {album}";
}
return string.IsNullOrWhiteSpace(artists) ? album : artists;
}
}
@@ -7,5 +7,4 @@ public record GetLibraryBrowseItems(
int? LibraryId, int? LibraryId,
LibraryBrowseMediaType? MediaType, LibraryBrowseMediaType? MediaType,
int PageNum, int PageNum,
int PageSize, int PageSize) : IRequest<PagedLibraryBrowseItemsResponseModel>;
int? ParentId = null) : IRequest<PagedLibraryBrowseItemsResponseModel>;
@@ -1,9 +1,12 @@
using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Search; using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search; using ErsatzTV.Infrastructure.Search;
using Flurl;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.LibraryBrowse; namespace ErsatzTV.Application.LibraryBrowse;
@@ -19,34 +22,6 @@ public class GetLibraryBrowseItemsHandler(
GetLibraryBrowseItems request, GetLibraryBrowseItems request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// Drill-in for detail pages: read a parent's children directly (bypassing Lucene) so the SPA can
// expand a show into its seasons (#180), a season into its episodes, or an artist into its music
// videos (#141/#161). Each reads in the natural display order for that kind.
if (request.ParentId.HasValue)
{
switch (request.MediaType)
{
case LibraryBrowseMediaType.TelevisionSeason:
{
await using TvContext seasonContext =
await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await BrowseSeasonsForShow(seasonContext, request, cancellationToken);
}
case LibraryBrowseMediaType.Episode:
{
await using TvContext episodeContext =
await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await BrowseEpisodesForSeason(episodeContext, request, cancellationToken);
}
case LibraryBrowseMediaType.MusicVideo:
{
await using TvContext musicVideoContext =
await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await BrowseMusicVideosForArtist(musicVideoContext, request, cancellationToken);
}
}
}
int offset = request.PageNum * request.PageSize; int offset = request.PageNum * request.PageSize;
SearchResult mediaResult = await SearchMedia(request, offset, request.PageSize, cancellationToken); SearchResult mediaResult = await SearchMedia(request, offset, request.PageSize, cancellationToken);
@@ -116,24 +91,12 @@ public class GetLibraryBrowseItemsHandler(
LibraryBrowseMediaType.TelevisionShow => [LuceneSearchIndex.ShowType], LibraryBrowseMediaType.TelevisionShow => [LuceneSearchIndex.ShowType],
LibraryBrowseMediaType.TelevisionSeason => [LuceneSearchIndex.SeasonType], LibraryBrowseMediaType.TelevisionSeason => [LuceneSearchIndex.SeasonType],
LibraryBrowseMediaType.Artist => [LuceneSearchIndex.ArtistType], LibraryBrowseMediaType.Artist => [LuceneSearchIndex.ArtistType],
LibraryBrowseMediaType.Episode => [LuceneSearchIndex.EpisodeType],
LibraryBrowseMediaType.MusicVideo => [LuceneSearchIndex.MusicVideoType],
LibraryBrowseMediaType.Song => [LuceneSearchIndex.SongType],
LibraryBrowseMediaType.OtherVideo => [LuceneSearchIndex.OtherVideoType],
LibraryBrowseMediaType.Image => [LuceneSearchIndex.ImageType],
LibraryBrowseMediaType.RemoteStream => [LuceneSearchIndex.RemoteStreamType],
null => null =>
[ [
LuceneSearchIndex.MovieType, LuceneSearchIndex.MovieType,
LuceneSearchIndex.ShowType, LuceneSearchIndex.ShowType,
LuceneSearchIndex.SeasonType, LuceneSearchIndex.SeasonType,
LuceneSearchIndex.ArtistType, LuceneSearchIndex.ArtistType
LuceneSearchIndex.EpisodeType,
LuceneSearchIndex.MusicVideoType,
LuceneSearchIndex.SongType,
LuceneSearchIndex.OtherVideoType,
LuceneSearchIndex.ImageType,
LuceneSearchIndex.RemoteStreamType
], ],
_ => [] _ => []
}; };
@@ -152,184 +115,219 @@ public class GetLibraryBrowseItemsHandler(
List<int> showIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ShowType).Select(i => i.Id).ToList(); List<int> showIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ShowType).Select(i => i.Id).ToList();
List<int> seasonIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SeasonType).Select(i => i.Id).ToList(); List<int> seasonIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SeasonType).Select(i => i.Id).ToList();
List<int> artistIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ArtistType).Select(i => i.Id).ToList(); List<int> artistIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ArtistType).Select(i => i.Id).ToList();
List<int> episodeIds = searchItems.Where(i => i.Type == LuceneSearchIndex.EpisodeType).Select(i => i.Id).ToList();
List<int> musicVideoIds =
searchItems.Where(i => i.Type == LuceneSearchIndex.MusicVideoType).Select(i => i.Id).ToList();
List<int> songIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SongType).Select(i => i.Id).ToList();
List<int> otherVideoIds =
searchItems.Where(i => i.Type == LuceneSearchIndex.OtherVideoType).Select(i => i.Id).ToList();
List<int> imageIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ImageType).Select(i => i.Id).ToList();
List<int> remoteStreamIds =
searchItems.Where(i => i.Type == LuceneSearchIndex.RemoteStreamType).Select(i => i.Id).ToList();
Dictionary<(string Type, int Id), LibraryBrowseItemResponseModel> hydrated = []; Dictionary<(string Type, int Id), LibraryBrowseItemResponseModel> hydrated = [];
foreach (LibraryBrowseItemResponseModel item in foreach (LibraryBrowseItemResponseModel item in await GetMovies(dbContext, movieIds, cancellationToken))
await LibraryBrowseItemMapper.GetMovies(dbContext, movieIds, cancellationToken))
{ {
hydrated[(LuceneSearchIndex.MovieType, item.Id)] = item; hydrated[(LuceneSearchIndex.MovieType, item.Id)] = item;
} }
foreach (LibraryBrowseItemResponseModel item in foreach (LibraryBrowseItemResponseModel item in await GetShows(dbContext, showIds, cancellationToken))
await LibraryBrowseItemMapper.GetShows(dbContext, showIds, cancellationToken))
{ {
hydrated[(LuceneSearchIndex.ShowType, item.Id)] = item; hydrated[(LuceneSearchIndex.ShowType, item.Id)] = item;
} }
foreach (LibraryBrowseItemResponseModel item in foreach (LibraryBrowseItemResponseModel item in await GetSeasons(dbContext, seasonIds, cancellationToken))
await LibraryBrowseItemMapper.GetSeasons(dbContext, seasonIds, cancellationToken))
{ {
hydrated[(LuceneSearchIndex.SeasonType, item.Id)] = item; hydrated[(LuceneSearchIndex.SeasonType, item.Id)] = item;
} }
foreach (LibraryBrowseItemResponseModel item in foreach (LibraryBrowseItemResponseModel item in await GetArtists(dbContext, artistIds, cancellationToken))
await LibraryBrowseItemMapper.GetArtists(dbContext, artistIds, cancellationToken))
{ {
hydrated[(LuceneSearchIndex.ArtistType, item.Id)] = item; hydrated[(LuceneSearchIndex.ArtistType, item.Id)] = item;
} }
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetEpisodes(dbContext, episodeIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.EpisodeType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetMusicVideos(dbContext, musicVideoIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.MusicVideoType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetSongs(dbContext, songIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.SongType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetOtherVideos(dbContext, otherVideoIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.OtherVideoType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetImages(dbContext, imageIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.ImageType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetRemoteStreams(dbContext, remoteStreamIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.RemoteStreamType, item.Id)] = item;
}
return searchItems return searchItems
.Where(i => hydrated.ContainsKey((i.Type, i.Id))) .Where(i => hydrated.ContainsKey((i.Type, i.Id)))
.Select(i => hydrated[(i.Type, i.Id)]) .Select(i => hydrated[(i.Type, i.Id)])
.ToList(); .ToList();
} }
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseSeasonsForShow( private static async Task<List<LibraryBrowseItemResponseModel>> GetMovies(
TvContext dbContext, TvContext dbContext,
GetLibraryBrowseItems request, List<int> ids,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
List<int> allSeasonIds = await dbContext.Seasons if (ids.Count == 0)
{
return [];
}
return await dbContext.MovieMetadata
.AsNoTracking() .AsNoTracking()
.Where(s => s.ShowId == request.ParentId.Value) .Where(mm => ids.Contains(mm.MovieId))
.OrderBy(s => s.SeasonNumber) .Include(mm => mm.Artwork)
.Select(s => s.Id) .Include(mm => mm.Movie)
.ToListAsync(cancellationToken); .ThenInclude(m => m.LibraryPath)
.ThenInclude(lp => lp.Library)
int total = allSeasonIds.Count; .Include(mm => mm.Movie)
List<int> pageIds = allSeasonIds .ThenInclude(m => m.MediaVersions)
.Skip(request.PageNum * request.PageSize) .ToListAsync(cancellationToken)
.Take(request.PageSize) .Map(list => list
.ToList(); .GroupBy(mm => mm.MovieId)
.Select(g => g.OrderBy(mm => mm.Id).First())
List<LibraryBrowseItemResponseModel> seasons = .Map(mm => new LibraryBrowseItemResponseModel(
await LibraryBrowseItemMapper.GetSeasons(dbContext, pageIds, cancellationToken); mm.MovieId,
LibraryBrowseMediaType.Movie,
// GetSeasons groups by season id, so restore the requested season-number order. mm.Title ?? string.Empty,
Dictionary<int, LibraryBrowseItemResponseModel> byId = seasons.ToDictionary(s => s.Id); mm.Movie.LibraryPath.LibraryId,
List<LibraryBrowseItemResponseModel> ordered = pageIds mm.Movie.LibraryPath.Library.Name,
.Where(byId.ContainsKey) Artwork(mm, ArtworkKind.Poster),
.Select(id => byId[id]) BestDuration(mm.Movie.MediaVersions),
.ToList(); 1,
null,
return new PagedLibraryBrowseItemsResponseModel(total, ordered); CollectionType.Movie,
null,
null,
null,
null,
mm.MovieId,
null)).ToList());
} }
// Drill-in: episodes of a specific season, in episode-number order (#141/#161). private static async Task<List<LibraryBrowseItemResponseModel>> GetShows(
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseEpisodesForSeason(
TvContext dbContext, TvContext dbContext,
GetLibraryBrowseItems request, List<int> ids,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
List<int> allEpisodeIds = await dbContext.EpisodeMetadata if (ids.Count == 0)
{
return [];
}
Dictionary<int, int> counts = await dbContext.Episodes
.AsNoTracking() .AsNoTracking()
.Where(em => em.Episode.SeasonId == request.ParentId.Value) .Where(e => ids.Contains(e.Season.ShowId))
.OrderBy(em => em.EpisodeNumber) .GroupBy(e => e.Season.ShowId)
.ThenBy(em => em.EpisodeId) .Select(g => new { ShowId = g.Key, Count = g.Count() })
.Select(em => em.EpisodeId) .ToDictionaryAsync(g => g.ShowId, g => g.Count, cancellationToken);
.ToListAsync(cancellationToken);
// Distinct preserves order (LINQ-to-Objects) for episodes with multiple metadata rows. return await dbContext.ShowMetadata
allEpisodeIds = allEpisodeIds.Distinct().ToList(); .AsNoTracking()
.Where(sm => ids.Contains(sm.ShowId))
int total = allEpisodeIds.Count; .Include(sm => sm.Artwork)
List<int> pageIds = allEpisodeIds .Include(sm => sm.Show)
.Skip(request.PageNum * request.PageSize) .ThenInclude(s => s.LibraryPath)
.Take(request.PageSize) .ThenInclude(lp => lp.Library)
.ToList(); .ToListAsync(cancellationToken)
.Map(list => list
List<LibraryBrowseItemResponseModel> episodes = .GroupBy(sm => sm.ShowId)
await LibraryBrowseItemMapper.GetEpisodes(dbContext, pageIds, cancellationToken); .Select(g => g.OrderBy(sm => sm.Id).First())
.Map(sm => new LibraryBrowseItemResponseModel(
Dictionary<int, LibraryBrowseItemResponseModel> byId = episodes.ToDictionary(e => e.Id); sm.ShowId,
List<LibraryBrowseItemResponseModel> ordered = pageIds LibraryBrowseMediaType.TelevisionShow,
.Where(byId.ContainsKey) sm.Title ?? string.Empty,
.Select(id => byId[id]) sm.Show.LibraryPath.LibraryId,
.ToList(); sm.Show.LibraryPath.Library.Name,
Artwork(sm, ArtworkKind.Poster),
return new PagedLibraryBrowseItemsResponseModel(total, ordered); null,
counts.TryGetValue(sm.ShowId, out int count) ? count : 0,
null,
CollectionType.TelevisionShow,
null,
null,
null,
null,
sm.ShowId,
null)).ToList());
} }
// Drill-in: music videos of a specific artist, in album/track/title order (#141/#161). private static async Task<List<LibraryBrowseItemResponseModel>> GetSeasons(
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseMusicVideosForArtist(
TvContext dbContext, TvContext dbContext,
GetLibraryBrowseItems request, List<int> ids,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
List<int> allMusicVideoIds = await dbContext.MusicVideoMetadata if (ids.Count == 0)
{
return [];
}
Dictionary<int, int> counts = await dbContext.Episodes
.AsNoTracking() .AsNoTracking()
.Where(mvm => mvm.MusicVideo.ArtistId == request.ParentId.Value) .Where(e => ids.Contains(e.SeasonId))
.OrderBy(mvm => mvm.Album) .GroupBy(e => e.SeasonId)
.ThenBy(mvm => mvm.Track) .Select(g => new { SeasonId = g.Key, Count = g.Count() })
.ThenBy(mvm => mvm.Title) .ToDictionaryAsync(g => g.SeasonId, g => g.Count, cancellationToken);
.ThenBy(mvm => mvm.MusicVideoId)
.Select(mvm => mvm.MusicVideoId)
.ToListAsync(cancellationToken);
allMusicVideoIds = allMusicVideoIds.Distinct().ToList(); return await dbContext.SeasonMetadata
.AsNoTracking()
.Where(sm => ids.Contains(sm.SeasonId))
.Include(sm => sm.Artwork)
.Include(sm => sm.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(sm => sm.Season)
.ThenInclude(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(sm => sm.SeasonId)
.Select(g => g.OrderBy(sm => sm.Id).First())
.Map(sm => new LibraryBrowseItemResponseModel(
sm.SeasonId,
LibraryBrowseMediaType.TelevisionSeason,
SeasonTitle(sm),
sm.Season.LibraryPath.LibraryId,
sm.Season.LibraryPath.Library.Name,
Artwork(sm, ArtworkKind.Poster),
null,
counts.TryGetValue(sm.SeasonId, out int count) ? count : 0,
null,
CollectionType.TelevisionSeason,
null,
null,
null,
null,
sm.SeasonId,
null)).ToList());
}
int total = allMusicVideoIds.Count; private static async Task<List<LibraryBrowseItemResponseModel>> GetArtists(
List<int> pageIds = allMusicVideoIds TvContext dbContext,
.Skip(request.PageNum * request.PageSize) List<int> ids,
.Take(request.PageSize) CancellationToken cancellationToken)
.ToList(); {
if (ids.Count == 0)
{
return [];
}
List<LibraryBrowseItemResponseModel> musicVideos = Dictionary<int, int> counts = await dbContext.MusicVideos
await LibraryBrowseItemMapper.GetMusicVideos(dbContext, pageIds, cancellationToken); .AsNoTracking()
.Where(mv => ids.Contains(mv.ArtistId))
.GroupBy(mv => mv.ArtistId)
.Select(g => new { ArtistId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.ArtistId, g => g.Count, cancellationToken);
Dictionary<int, LibraryBrowseItemResponseModel> byId = musicVideos.ToDictionary(mv => mv.Id); return await dbContext.ArtistMetadata
List<LibraryBrowseItemResponseModel> ordered = pageIds .AsNoTracking()
.Where(byId.ContainsKey) .Where(am => ids.Contains(am.ArtistId))
.Select(id => byId[id]) .Include(am => am.Artwork)
.ToList(); .Include(am => am.Artist)
.ThenInclude(a => a.LibraryPath)
return new PagedLibraryBrowseItemsResponseModel(total, ordered); .ThenInclude(lp => lp.Library)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(am => am.ArtistId)
.Select(g => g.OrderBy(am => am.Id).First())
.Map(am => new LibraryBrowseItemResponseModel(
am.ArtistId,
LibraryBrowseMediaType.Artist,
am.Title ?? string.Empty,
am.Artist.LibraryPath.LibraryId,
am.Artist.LibraryPath.Library.Name,
Artwork(am, ArtworkKind.Thumbnail),
null,
counts.TryGetValue(am.ArtistId, out int count) ? count : 0,
null,
CollectionType.Artist,
null,
null,
null,
null,
am.ArtistId,
null)).ToList());
} }
private static async Task<int> CountCollections( private static async Task<int> CountCollections(
@@ -661,6 +659,16 @@ public class GetLibraryBrowseItemsHandler(
private static bool ShouldInclude(LibraryBrowseMediaType? requestType, LibraryBrowseMediaType itemType) => private static bool ShouldInclude(LibraryBrowseMediaType? requestType, LibraryBrowseMediaType itemType) =>
requestType is null || requestType == itemType; requestType is null || requestType == itemType;
private static TimeSpan? BestDuration(IEnumerable<MediaVersion> versions)
{
TimeSpan duration = versions
.Select(v => v.Duration)
.Where(d => d > TimeSpan.Zero)
.DefaultIfEmpty()
.Max();
return duration > TimeSpan.Zero ? duration : null;
}
private static async Task<Dictionary<int, TimeSpan?>> GetManualCollectionDurations( private static async Task<Dictionary<int, TimeSpan?>> GetManualCollectionDurations(
TvContext dbContext, TvContext dbContext,
List<int> collectionIds, List<int> collectionIds,
@@ -676,7 +684,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(m => m.MediaVersions) .Include(m => m.MediaVersions)
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
{ {
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(movie.MediaVersions); TimeSpan? duration = BestDuration(movie.MediaVersions);
if (duration.HasValue) if (duration.HasValue)
{ {
mediaItemDurations[movie.Id] = duration.Value; mediaItemDurations[movie.Id] = duration.Value;
@@ -689,7 +697,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(e => e.MediaVersions) .Include(e => e.MediaVersions)
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
{ {
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(episode.MediaVersions); TimeSpan? duration = BestDuration(episode.MediaVersions);
if (duration.HasValue) if (duration.HasValue)
{ {
mediaItemDurations[episode.Id] = duration.Value; mediaItemDurations[episode.Id] = duration.Value;
@@ -702,7 +710,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(mv => mv.MediaVersions) .Include(mv => mv.MediaVersions)
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
{ {
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(musicVideo.MediaVersions); TimeSpan? duration = BestDuration(musicVideo.MediaVersions);
if (duration.HasValue) if (duration.HasValue)
{ {
mediaItemDurations[musicVideo.Id] = duration.Value; mediaItemDurations[musicVideo.Id] = duration.Value;
@@ -715,7 +723,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(ov => ov.MediaVersions) .Include(ov => ov.MediaVersions)
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
{ {
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(otherVideo.MediaVersions); TimeSpan? duration = BestDuration(otherVideo.MediaVersions);
if (duration.HasValue) if (duration.HasValue)
{ {
mediaItemDurations[otherVideo.Id] = duration.Value; mediaItemDurations[otherVideo.Id] = duration.Value;
@@ -728,7 +736,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(s => s.MediaVersions) .Include(s => s.MediaVersions)
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
{ {
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(song.MediaVersions); TimeSpan? duration = BestDuration(song.MediaVersions);
if (duration.HasValue) if (duration.HasValue)
{ {
mediaItemDurations[song.Id] = duration.Value; mediaItemDurations[song.Id] = duration.Value;
@@ -741,7 +749,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(i => i.MediaVersions) .Include(i => i.MediaVersions)
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
{ {
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(image.MediaVersions); TimeSpan? duration = BestDuration(image.MediaVersions);
if (duration.HasValue) if (duration.HasValue)
{ {
mediaItemDurations[image.Id] = duration.Value; mediaItemDurations[image.Id] = duration.Value;
@@ -754,7 +762,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(rs => rs.MediaVersions) .Include(rs => rs.MediaVersions)
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
{ {
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(remoteStream.MediaVersions); TimeSpan? duration = BestDuration(remoteStream.MediaVersions);
if (duration.HasValue) if (duration.HasValue)
{ {
mediaItemDurations[remoteStream.Id] = duration.Value; mediaItemDurations[remoteStream.Id] = duration.Value;
@@ -785,7 +793,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(mm => mm.Id) .OrderBy(mm => mm.Id)
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
{ {
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.MovieId)) if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.MovieId))
{ {
mediaItemArtwork[metadata.MovieId] = poster; mediaItemArtwork[metadata.MovieId] = poster;
@@ -799,7 +807,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(sm => sm.Id) .OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
{ {
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ShowId)) if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ShowId))
{ {
mediaItemArtwork[metadata.ShowId] = poster; mediaItemArtwork[metadata.ShowId] = poster;
@@ -813,7 +821,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(sm => sm.Id) .OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
{ {
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SeasonId)) if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SeasonId))
{ {
mediaItemArtwork[metadata.SeasonId] = poster; mediaItemArtwork[metadata.SeasonId] = poster;
@@ -827,7 +835,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(ovm => ovm.Id) .OrderBy(ovm => ovm.Id)
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
{ {
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.OtherVideoId)) if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.OtherVideoId))
{ {
mediaItemArtwork[metadata.OtherVideoId] = poster; mediaItemArtwork[metadata.OtherVideoId] = poster;
@@ -841,7 +849,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(sm => sm.Id) .OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
{ {
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SongId)) if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SongId))
{ {
mediaItemArtwork[metadata.SongId] = poster; mediaItemArtwork[metadata.SongId] = poster;
@@ -855,7 +863,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(im => im.Id) .OrderBy(im => im.Id)
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
{ {
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ImageId)) if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ImageId))
{ {
mediaItemArtwork[metadata.ImageId] = poster; mediaItemArtwork[metadata.ImageId] = poster;
@@ -869,7 +877,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(rsm => rsm.Id) .OrderBy(rsm => rsm.Id)
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
{ {
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.RemoteStreamId)) if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.RemoteStreamId))
{ {
mediaItemArtwork[metadata.RemoteStreamId] = poster; mediaItemArtwork[metadata.RemoteStreamId] = poster;
@@ -894,6 +902,47 @@ public class GetLibraryBrowseItemsHandler(
.Select(ci => new CollectionMediaItem(ci.CollectionId, ci.MediaItemId, ci.CustomIndex)) .Select(ci => new CollectionMediaItem(ci.CollectionId, ci.MediaItemId, ci.CustomIndex))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
private static string SeasonTitle(SeasonMetadata metadata)
{
string showTitle = metadata.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => sm.Title ?? string.Empty)
.IfNone(string.Empty);
string seasonTitle = metadata.Season.SeasonNumber == 0
? "Specials"
: $"Season {metadata.Season.SeasonNumber}";
return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}";
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind)
{
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
{
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("fillHeight", 440);
}
return url;
}
if (artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase))
{
Url url = EmbyUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("maxHeight", 440);
}
return url;
}
return artwork;
}
private static string EscapeLike(string searchQuery) => private static string EscapeLike(string searchQuery) =>
searchQuery searchQuery
.Replace("\\", "\\\\", StringComparison.Ordinal) .Replace("\\", "\\\\", StringComparison.Ordinal)
@@ -32,10 +32,6 @@ public class AddEpisodeToPlaylistHandler(IDbContextFactory<TvContext> dbContextF
}; };
parameters.Playlist.Items.Add(playlistItem); parameters.Playlist.Items.Add(playlistItem);
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
parameters.Playlist.Version++;
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return Unit.Default; return Unit.Default;
} }
@@ -81,15 +81,13 @@ public class AddItemsToCollectionHandler :
if (await dbContext.SaveChangesAsync(cancellationToken) > 0) if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
{ {
// post-commit side effect runs on CancellationToken.None so a late request cancellation await _searchChannel.WriteAsync(new ReindexMediaItems(toAddIds.ToArray()), cancellationToken);
// can't abort it after the commit landed (#254)
await _searchChannel.WriteAsync(new ReindexMediaItems(toAddIds.ToArray()), CancellationToken.None);
// refresh all playouts that use this collection // refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId)) .PlayoutIdsUsingCollection(request.CollectionId))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
} }
} }
@@ -46,8 +46,7 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
{ CollectionType.MusicVideo, request.MusicVideoIds }, { CollectionType.MusicVideo, request.MusicVideoIds },
{ CollectionType.OtherVideo, request.OtherVideoIds }, { CollectionType.OtherVideo, request.OtherVideoIds },
{ CollectionType.Song, request.SongIds }, { CollectionType.Song, request.SongIds },
{ CollectionType.Image, request.ImageIds }, { CollectionType.Image, request.ImageIds }
{ CollectionType.RemoteStream, request.RemoteStreamIds }
}; };
int index = playlist.Items.Count > 0 ? playlist.Items.Max(i => i.Index) + 1 : 0; int index = playlist.Items.Count > 0 ? playlist.Items.Max(i => i.Index) + 1 : 0;
@@ -69,9 +68,6 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
} }
} }
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
playlist.Version++;
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default; return Unit.Default;
@@ -85,24 +81,17 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
await ValidateMovies(request), await ValidateMovies(request),
await ValidateShows(request), await ValidateShows(request),
await ValidateSeasons(request), await ValidateSeasons(request),
await ValidateEpisodes(request), await ValidateEpisodes(request))
await ValidateMediaItems(dbContext, request, cancellationToken)) .Apply((collection, _, _, _, _) => collection);
.Apply((collection, _, _, _, _, _) => collection);
private static async Task<Validation<BaseError, Playlist>> PlaylistMustExist( private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(
TvContext dbContext, TvContext dbContext,
AddItemsToPlaylist request, AddItemsToPlaylist request,
CancellationToken cancellationToken) CancellationToken cancellationToken) =>
{ dbContext.Playlists
Option<Playlist> maybePlaylist = await dbContext.Playlists
.Include(c => c.Items) .Include(c => c.Items)
.SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId, cancellationToken); .SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Playlist does not exist."));
return maybePlaylist.ToValidation<BaseError>("Playlist does not exist.")
.Bind(playlist => playlist.IsSystem
? BaseError.New("Cannot add items to system (generated) playlist")
: Success<BaseError, Playlist>(playlist));
}
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToPlaylist request) => private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToPlaylist request) =>
_movieRepository.AllMoviesExist(request.MovieIds) _movieRepository.AllMoviesExist(request.MovieIds)
@@ -131,30 +120,4 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
.Filter(v => v == true) .Filter(v => v == true)
.MapT(_ => Unit.Default) .MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Episode does not exist")); .Map(v => v.ToValidation<BaseError>("Episode does not exist"));
private static async Task<Validation<BaseError, Unit>> ValidateMediaItems(
TvContext dbContext,
AddItemsToPlaylist request,
CancellationToken cancellationToken)
{
List<int> ids = GetRequestedMediaItemIds(request).Distinct().ToList();
int existingCount = await dbContext.MediaItems
.CountAsync(mi => ids.Contains(mi.Id), cancellationToken);
return existingCount == ids.Count
? Unit.Default
: BaseError.New("Media item does not exist");
}
private static IEnumerable<int> GetRequestedMediaItemIds(AddItemsToPlaylist request) =>
request.MovieIds
.Append(request.ShowIds)
.Append(request.SeasonIds)
.Append(request.EpisodeIds)
.Append(request.ArtistIds)
.Append(request.MusicVideoIds)
.Append(request.OtherVideoIds)
.Append(request.SongIds)
.Append(request.ImageIds)
.Append(request.RemoteStreamIds);
} }
@@ -32,10 +32,6 @@ public class AddMovieToPlaylistHandler(IDbContextFactory<TvContext> dbContextFac
}; };
parameters.Playlist.Items.Add(playlistItem); parameters.Playlist.Items.Add(playlistItem);
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
parameters.Playlist.Version++;
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return Unit.Default; return Unit.Default;
} }
@@ -32,10 +32,6 @@ public class AddSeasonToPlaylistHandler(IDbContextFactory<TvContext> dbContextFa
}; };
parameters.Playlist.Items.Add(playlistItem); parameters.Playlist.Items.Add(playlistItem);
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
parameters.Playlist.Version++;
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return Unit.Default; return Unit.Default;
} }
@@ -32,10 +32,6 @@ public class AddShowToPlaylistHandler(IDbContextFactory<TvContext> dbContextFact
}; };
parameters.Playlist.Items.Add(playlistItem); parameters.Playlist.Items.Add(playlistItem);
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
parameters.Playlist.Version++;
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return Unit.Default; return Unit.Default;
} }
@@ -14,7 +14,7 @@ public class CreateRerunCollectionHandler(IDbContextFactory<TvContext> dbContext
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, RerunCollection> validation = await Validate(dbContext, request, cancellationToken); Validation<BaseError, RerunCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistCollection(dbContext, c)); return await validation.Apply(c => PersistCollection(dbContext, c));
} }
@@ -27,20 +27,10 @@ public class CreateRerunCollectionHandler(IDbContextFactory<TvContext> dbContext
return ProjectToViewModel(collection); return ProjectToViewModel(collection);
} }
private static async Task<Validation<BaseError, RerunCollection>> Validate( private static Task<Validation<BaseError, RerunCollection>> Validate(
TvContext dbContext, TvContext dbContext,
CreateRerunCollection request, CreateRerunCollection request) =>
CancellationToken cancellationToken) => ValidateName(dbContext, request).MapT(name => new RerunCollection
(await ValidateName(dbContext, request),
await RerunCollectionSelectionValidation.SelectedEntityMustExist(
dbContext,
request.CollectionType,
request.Collection?.Id,
request.MultiCollection?.Id,
request.SmartCollection?.Id,
request.MediaItem?.MediaItemId,
cancellationToken))
.Apply((name, _) => new RerunCollection
{ {
Name = name, Name = name,
CollectionType = request.CollectionType, CollectionType = request.CollectionType,
@@ -42,10 +42,7 @@ public class CreateSmartCollectionHandler :
await dbContext.SmartCollections.AddAsync(smartCollection, cancellationToken); await dbContext.SmartCollections.AddAsync(smartCollection, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
_searchTargets.SearchTargetsChanged(); _searchTargets.SearchTargetsChanged();
await _smartCollectionCache.Refresh(cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await _smartCollectionCache.Refresh(CancellationToken.None);
return ProjectToViewModel(smartCollection); return ProjectToViewModel(smartCollection);
} }
@@ -48,10 +48,7 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
dbContext.SmartCollections.Remove(smartCollection); dbContext.SmartCollections.Remove(smartCollection);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
_searchTargets.SearchTargetsChanged(); _searchTargets.SearchTargetsChanged();
await _smartCollectionCache.Refresh(cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await _smartCollectionCache.Refresh(CancellationToken.None);
return Unit.Default; return Unit.Default;
} }
@@ -63,16 +63,14 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromC
if (itemsToRemove.Count != 0 && await dbContext.SaveChangesAsync(cancellationToken) > 0) if (itemsToRemove.Count != 0 && await dbContext.SaveChangesAsync(cancellationToken) > 0)
{ {
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await _searchChannel.WriteAsync( await _searchChannel.WriteAsync(
new ReindexMediaItems(itemsToRemove.Select(mi => mi.Id).ToArray()), new ReindexMediaItems(itemsToRemove.Select(mi => mi.Id).ToArray()),
CancellationToken.None); cancellationToken);
// refresh all playouts that use this collection // refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(collection.Id)) foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(collection.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
} }
} }
@@ -1,6 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.MediaCollections;
public record RenamePlaylistGroup(int PlaylistGroupId, string Name)
: IRequest<Either<BaseError, PlaylistGroupViewModel>>;
@@ -1,60 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class RenamePlaylistGroupHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<RenamePlaylistGroup, Either<BaseError, PlaylistGroupViewModel>>
{
public async Task<Either<BaseError, PlaylistGroupViewModel>> Handle(
RenamePlaylistGroup request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, PlaylistGroup> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(playlistGroup => Persist(dbContext, request, playlistGroup));
}
private static async Task<PlaylistGroupViewModel> Persist(
TvContext dbContext,
RenamePlaylistGroup request,
PlaylistGroup playlistGroup)
{
playlistGroup.Name = request.Name;
await dbContext.SaveChangesAsync();
return Mapper.ProjectToViewModel(playlistGroup);
}
private static Task<Validation<BaseError, PlaylistGroup>> Validate(
TvContext dbContext,
RenamePlaylistGroup request,
CancellationToken cancellationToken) =>
PlaylistGroupMustExist(dbContext, request, cancellationToken)
.BindT(PlaylistGroupMustNotBeSystem)
.BindT(playlistGroup => ValidateName(request).Map(_ => playlistGroup));
private static Task<Validation<BaseError, PlaylistGroup>> PlaylistGroupMustExist(
TvContext dbContext,
RenamePlaylistGroup request,
CancellationToken cancellationToken) =>
dbContext.PlaylistGroups
.Include(pg => pg.Playlists)
.SelectOneAsync(pg => pg.Id, pg => pg.Id == request.PlaylistGroupId, cancellationToken)
.Map(o => o.ToValidation<BaseError>(
new NotFoundError($"PlaylistGroup {request.PlaylistGroupId} does not exist.")));
// Plain BaseError (NOT NotFoundError) so it maps to 422, mirroring DeletePlaylistGroupHandler's
// system-group guard. A missing group still surfaces as NotFoundError (404) from PlaylistGroupMustExist.
private static Validation<BaseError, PlaylistGroup> PlaylistGroupMustNotBeSystem(PlaylistGroup playlistGroup) =>
playlistGroup.IsSystem
? BaseError.New("Cannot rename system playlist group")
: playlistGroup;
private static Validation<BaseError, string> ValidateName(RenamePlaylistGroup request) =>
request.NotEmpty(x => x.Name)
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
}
@@ -2,9 +2,5 @@ using ErsatzTV.Core;
namespace ErsatzTV.Application.MediaCollections; namespace ErsatzTV.Application.MediaCollections;
public record ReplacePlaylistItems( public record ReplacePlaylistItems(int PlaylistId, string Name, List<ReplacePlaylistItem> Items)
int PlaylistId,
string Name,
List<ReplacePlaylistItem> Items,
Option<int> ExpectedVersion = default)
: IRequest<Either<BaseError, List<PlaylistItemViewModel>>>; : IRequest<Either<BaseError, List<PlaylistItemViewModel>>>;
@@ -15,21 +15,10 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
{ {
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playlist> validation = await Validate(dbContext, request, cancellationToken); Validation<BaseError, Playlist> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(ps => Persist(dbContext, request, ps, cancellationToken));
// Introduce the optimistic-concurrency check as a standalone Either AFTER the validation
// pipeline (never via Apply) so a PreconditionFailedError survives to a 412 and is not
// flattened to a generic 422 by Join() (issue #253 / api-conventions §7a).
// LanguageExtensions.ToEither joins the Seq<BaseError> to a single BaseError (the native
// Validation.ToEither() would keep Seq and is called explicitly here to avoid that shadow).
Either<BaseError, Playlist> validated = LanguageExtensions.ToEither(validation)
.Bind(playlist => playlist.CheckVersion(request.ExpectedVersion));
return await validated.Match(
Right: playlist => Persist(dbContext, request, playlist, cancellationToken),
Left: error => Task.FromResult<Either<BaseError, List<PlaylistItemViewModel>>>(error));
} }
private static async Task<Either<BaseError, List<PlaylistItemViewModel>>> Persist( private static async Task<List<PlaylistItemViewModel>> Persist(
TvContext dbContext, TvContext dbContext,
ReplacePlaylistItems request, ReplacePlaylistItems request,
Playlist playlist, Playlist playlist,
@@ -41,15 +30,9 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
dbContext.RemoveRange(playlist.Items); dbContext.RemoveRange(playlist.Items);
playlist.Items = request.Items.Map(i => BuildItem(playlist, i.Index, i)).ToList(); playlist.Items = request.Items.Map(i => BuildItem(playlist, i.Index, i)).ToList();
// Unconditional bump: EF emits the root UPDATE only when a scalar actually differs, so a await dbContext.SaveChangesAsync(cancellationToken);
// same-value/no-op save would otherwise write no root row and neither fire the concurrency
// token nor rotate other clients' ETags. Bumping guarantees both on every save (issue #253).
playlist.Version++;
// Save through the guard so an EF concurrency failure (a racing writer won between our load return playlist.Items.Map(Mapper.ProjectToViewModel).ToList();
// and save) maps to 412 rather than surfacing as a 500.
Either<BaseError, Unit> saved = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
return saved.Map(_ => playlist.Items.Map(Mapper.ProjectToViewModel).ToList());
} }
private static PlaylistItem BuildItem(Playlist playlist, int index, ReplacePlaylistItem item) => private static PlaylistItem BuildItem(Playlist playlist, int index, ReplacePlaylistItem item) =>
@@ -1,112 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
/// <summary>
/// Shared existence check for the id a rerun collection selects via <see cref="CollectionType" />
/// (see <c>RerunCollectionRequestMapping.ResolveSelection</c> in the API layer). Used by both
/// <see cref="CreateRerunCollectionHandler" /> and <see cref="UpdateRerunCollectionHandler" /> so
/// a bad/stale id fails validation (422) instead of persisting a dangling FK or 500ing at
/// SaveChanges.
/// </summary>
internal static class RerunCollectionSelectionValidation
{
public static Task<Validation<BaseError, Unit>> SelectedEntityMustExist(
TvContext dbContext,
CollectionType collectionType,
int? collectionId,
int? multiCollectionId,
int? smartCollectionId,
int? mediaItemId,
CancellationToken cancellationToken) =>
collectionType switch
{
CollectionType.Collection => Exists(
dbContext.Collections,
collectionId,
"Collection does not exist.",
cancellationToken),
CollectionType.MultiCollection => Exists(
dbContext.MultiCollections,
multiCollectionId,
"Multi collection does not exist.",
cancellationToken),
CollectionType.SmartCollection => Exists(
dbContext.SmartCollections,
smartCollectionId,
"Smart collection does not exist.",
cancellationToken),
CollectionType.TelevisionShow => Exists(
dbContext.Shows,
mediaItemId,
"Show does not exist.",
cancellationToken),
CollectionType.TelevisionSeason => Exists(
dbContext.Seasons,
mediaItemId,
"Season does not exist.",
cancellationToken),
CollectionType.Artist => Exists(
dbContext.Artists,
mediaItemId,
"Artist does not exist.",
cancellationToken),
CollectionType.Movie => Exists(
dbContext.Movies,
mediaItemId,
"Movie does not exist.",
cancellationToken),
CollectionType.Episode => Exists(
dbContext.Episodes,
mediaItemId,
"Episode does not exist.",
cancellationToken),
CollectionType.MusicVideo => Exists(
dbContext.MusicVideos,
mediaItemId,
"Music video does not exist.",
cancellationToken),
CollectionType.OtherVideo => Exists(
dbContext.OtherVideos,
mediaItemId,
"Other video does not exist.",
cancellationToken),
CollectionType.Song => Exists(
dbContext.Songs,
mediaItemId,
"Song does not exist.",
cancellationToken),
CollectionType.Image => Exists(
dbContext.Images,
mediaItemId,
"Image does not exist.",
cancellationToken),
CollectionType.RemoteStream => Exists(
dbContext.RemoteStreams,
mediaItemId,
"Remote stream does not exist.",
cancellationToken),
_ => Task.FromResult(
Fail<BaseError, Unit>($"Unsupported collection type '{collectionType}' for a rerun collection."))
};
private static async Task<Validation<BaseError, Unit>> Exists<T>(
DbSet<T> set,
int? id,
string errorMessage,
CancellationToken cancellationToken)
where T : class
{
// pure existence check — no tracking, nothing materialized into the save-path context
if (id is null ||
!await set.AnyAsync(e => EF.Property<int>(e, "Id") == id.Value, cancellationToken))
{
return Fail<BaseError, Unit>(errorMessage);
}
return Success<BaseError, Unit>(Unit.Default);
}
}
@@ -4,7 +4,6 @@ namespace ErsatzTV.Application.MediaCollections;
public record UpdateCollectionCustomOrder( public record UpdateCollectionCustomOrder(
int CollectionId, int CollectionId,
List<MediaItemCustomOrder> MediaItemCustomOrders, List<MediaItemCustomOrder> MediaItemCustomOrders) : IRequest<Either<BaseError, Unit>>;
Option<int> ExpectedVersion = default) : IRequest<Either<BaseError, Unit>>;
public record MediaItemCustomOrder(int MediaItemId, int CustomIndex); public record MediaItemCustomOrder(int MediaItemId, int CustomIndex);
@@ -32,22 +32,13 @@ public class UpdateCollectionCustomOrderHandler : IRequestHandler<UpdateCollecti
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken); Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
// Optimistic-concurrency check as a standalone Either AFTER validation, never via Apply (which
// Join()s the error Seq and would flatten PreconditionFailedError to a 422) — issue #253 §7a.
Either<BaseError, Collection> validated = LanguageExtensions.ToEither(validation)
.Bind(c => c.CheckVersion(request.ExpectedVersion));
return await validated.Match(
Right: c => ApplyUpdateRequest(dbContext, c, request, cancellationToken),
Left: error => Task.FromResult<Either<BaseError, Unit>>(error));
} }
private async Task<Either<BaseError, Unit>> ApplyUpdateRequest( private async Task<Unit> ApplyUpdateRequest(
TvContext dbContext, TvContext dbContext,
Collection c, Collection c,
UpdateCollectionCustomOrder request, UpdateCollectionCustomOrder request)
CancellationToken cancellationToken)
{ {
foreach (MediaItemCustomOrder updateItem in request.MediaItemCustomOrders) foreach (MediaItemCustomOrder updateItem in request.MediaItemCustomOrders)
{ {
@@ -60,24 +51,14 @@ public class UpdateCollectionCustomOrderHandler : IRequestHandler<UpdateCollecti
} }
} }
// Unconditional bump (issue #253 §7a / M1) then guarded save (→ 412 on a lost race). if (await dbContext.SaveChangesAsync() > 0)
c.Version++;
Either<BaseError, Unit> saveResult = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
if (saveResult.IsLeft)
{ {
return saveResult; // refresh all playouts that use this collection
} foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
// Refresh all playouts that use this collection. The old `SaveChangesAsync() > 0` gate is always {
// true once the version bumps unconditionally (M2), so run the refresh on any successful save. await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
// Post-commit enqueue on CancellationToken.None so a late cancellation can't drop the rebuild }
// after the commit landed (#254 / §7b).
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(
new BuildPlayout(playoutId, PlayoutBuildMode.Refresh),
CancellationToken.None);
} }
return Unit.Default; return Unit.Default;
@@ -59,17 +59,13 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
c.UseCustomPlaybackOrder = useCustomPlaybackOrder; c.UseCustomPlaybackOrder = useCustomPlaybackOrder;
} }
// Force-write past a concurrent Version bump from the custom-order editor (this name/flag writer if (await dbContext.SaveChangesAsync(cancellationToken) > 0 && request.UseCustomPlaybackOrder.IsSome)
// doesn't participate in If-Match, so the active token must not 500 a benign race) — #253/#269.
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0 && request.UseCustomPlaybackOrder.IsSome)
{ {
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
// refresh all playouts that use this collection // refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection( foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(
request.CollectionId)) request.CollectionId))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
} }
} }
@@ -12,5 +12,4 @@ public record UpdateMultiCollectionItem(
public record UpdateMultiCollection( public record UpdateMultiCollection(
int MultiCollectionId, int MultiCollectionId,
string Name, string Name,
List<UpdateMultiCollectionItem> Items, List<UpdateMultiCollectionItem> Items) : IRequest<Either<BaseError, Unit>>;
Option<int> ExpectedVersion = default) : IRequest<Either<BaseError, Unit>>;
@@ -36,18 +36,10 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request, cancellationToken); Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
// Optimistic-concurrency check as a standalone Either AFTER validation, never via Apply (which
// Join()s the error Seq and would flatten PreconditionFailedError to a 422) — issue #253 §7a.
Either<BaseError, MultiCollection> validated = LanguageExtensions.ToEither(validation)
.Bind(c => c.CheckVersion(request.ExpectedVersion));
return await validated.Match(
Right: c => ApplyUpdateRequest(dbContext, c, request, cancellationToken),
Left: error => Task.FromResult<Either<BaseError, Unit>>(error));
} }
private async Task<Either<BaseError, Unit>> ApplyUpdateRequest( private async Task<Unit> ApplyUpdateRequest(
TvContext dbContext, TvContext dbContext,
MultiCollection c, MultiCollection c,
UpdateMultiCollection request, UpdateMultiCollection request,
@@ -55,16 +47,8 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
{ {
c.Name = request.Name; c.Name = request.Name;
// Bump the version on this first save (issue #253 §7a): it rotates other clients' ETags and, // save name first so playouts don't get rebuilt for a name change
// via IsConcurrencyToken, closes the load→save race — a stale writer's WHERE Version=@orig hits await dbContext.SaveChangesAsync(cancellationToken);
// 0 rows → PreconditionFailedError (412). Saving the name first also keeps a name-only change
// from triggering a playout rebuild (the item save below stays gated on real item changes).
c.Version++;
Either<BaseError, Unit> nameSave = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
if (nameSave.IsLeft)
{
return nameSave;
}
var toAdd = request.Items var toAdd = request.Items
.Filter(i => i.CollectionId.HasValue) .Filter(i => i.CollectionId.HasValue)
@@ -140,12 +124,10 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
_searchTargets.SearchTargetsChanged(); _searchTargets.SearchTargetsChanged();
// refresh all playouts that use this collection // refresh all playouts that use this collection
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingMultiCollection( foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingMultiCollection(
request.MultiCollectionId)) request.MultiCollectionId))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
} }
} }
@@ -13,6 +13,5 @@ public record UpdateRerunCollection(
SmartCollectionViewModel SmartCollection, SmartCollectionViewModel SmartCollection,
NamedMediaItemViewModel MediaItem, NamedMediaItemViewModel MediaItem,
PlaybackOrder FirstRunPlaybackOrder, PlaybackOrder FirstRunPlaybackOrder,
PlaybackOrder RerunPlaybackOrder, PlaybackOrder RerunPlaybackOrder)
Option<int> ExpectedVersion = default)
: IRequest<Either<BaseError, Unit>>; : IRequest<Either<BaseError, Unit>>;
@@ -22,18 +22,10 @@ public class UpdateRerunCollectionHandler(
{ {
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, RerunCollection> validation = await Validate(dbContext, request, cancellationToken); Validation<BaseError, RerunCollection> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
// Optimistic-concurrency check as a standalone Either AFTER validation, never via Apply (which
// Join()s the error Seq and would flatten PreconditionFailedError to a 422) — issue #253 §7a.
Either<BaseError, RerunCollection> validated = LanguageExtensions.ToEither(validation)
.Bind(c => c.CheckVersion(request.ExpectedVersion));
return await validated.Match(
Right: c => ApplyUpdateRequest(dbContext, c, request, cancellationToken),
Left: error => Task.FromResult<Either<BaseError, Unit>>(error));
} }
private async Task<Either<BaseError, Unit>> ApplyUpdateRequest( private async Task<Unit> ApplyUpdateRequest(
TvContext dbContext, TvContext dbContext,
RerunCollection c, RerunCollection c,
UpdateRerunCollection request, UpdateRerunCollection request,
@@ -48,23 +40,15 @@ public class UpdateRerunCollectionHandler(
c.FirstRunPlaybackOrder = request.FirstRunPlaybackOrder; c.FirstRunPlaybackOrder = request.FirstRunPlaybackOrder;
c.RerunPlaybackOrder = request.RerunPlaybackOrder; c.RerunPlaybackOrder = request.RerunPlaybackOrder;
// Unconditional bump (issue #253 §7a / M1) then guarded save, which maps a racing // rebuild playouts
// DbUpdateConcurrencyException to PreconditionFailedError (→ 412). if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
c.Version++;
Either<BaseError, Unit> saveResult = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
if (saveResult.IsLeft)
{ {
return saveResult; // refresh all playouts that use this rerun collection
} foreach (int playoutId in await mediaCollectionRepository.PlayoutIdsUsingRerunCollection(
request.RerunCollectionId))
// Refresh all playouts that use this rerun collection. The old `SaveChangesAsync() > 0` gate is {
// always true once the version bumps unconditionally (M2), so run the refresh on any successful await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
// save. Post-commit enqueue on CancellationToken.None so a late cancellation can't drop the }
// rebuild after the commit landed (#254).
foreach (int playoutId in await mediaCollectionRepository.PlayoutIdsUsingRerunCollection(
request.RerunCollectionId))
{
await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
} }
return Unit.Default; return Unit.Default;
@@ -74,17 +58,8 @@ public class UpdateRerunCollectionHandler(
TvContext dbContext, TvContext dbContext,
UpdateRerunCollection request, UpdateRerunCollection request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
(await RerunCollectionMustExist(dbContext, request, cancellationToken), (await RerunCollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
await ValidateName(dbContext, request), .Apply((collectionToUpdate, _) => collectionToUpdate);
await RerunCollectionSelectionValidation.SelectedEntityMustExist(
dbContext,
request.CollectionType,
request.Collection?.Id,
request.MultiCollection?.Id,
request.SmartCollection?.Id,
request.MediaItem?.MediaItemId,
cancellationToken))
.Apply((collectionToUpdate, _, _) => collectionToUpdate);
private static Task<Validation<BaseError, RerunCollection>> RerunCollectionMustExist( private static Task<Validation<BaseError, RerunCollection>> RerunCollectionMustExist(
TvContext dbContext, TvContext dbContext,
@@ -69,15 +69,12 @@ public class
if (await dbContext.SaveChangesAsync(cancellationToken) > 0) if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
{ {
_searchTargets.SearchTargetsChanged(); _searchTargets.SearchTargetsChanged();
await _smartCollectionCache.Refresh(cancellationToken);
// post-commit side effects run on CancellationToken.None so a late request cancellation
// can't abort them after the commit landed (#254)
await _smartCollectionCache.Refresh(CancellationToken.None);
// refresh all playouts that use this smart collection // refresh all playouts that use this smart collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingSmartCollection(request.Id)) foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingSmartCollection(request.Id))
{ {
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None); await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
} }
} }
@@ -55,19 +55,7 @@ public class UpdateTraktListHandler(
if (entityLocker.LockTrakt()) if (entityLocker.LockTrakt())
{ {
try await workerChannel.WriteAsync(new MatchTraktListItems(traktList.Id), cancellationToken);
{
// post-commit side effect runs on CancellationToken.None so a late request
// cancellation can't abort it after the commit landed (#254)
await workerChannel.WriteAsync(new MatchTraktListItems(traktList.Id), CancellationToken.None);
}
catch
{
// the background handler only unlocks when it receives the message; if the
// enqueue fails after we acquired the lock, release it here or it is held forever.
entityLocker.UnlockTrakt();
throw;
}
} }
} }
else if (traktList.PlaylistId is not null) else if (traktList.PlaylistId is not null)
@@ -12,16 +12,14 @@ internal static class Mapper
collection.Id, collection.Id,
collection.Name, collection.Name,
collection.UseCustomPlaybackOrder, collection.UseCustomPlaybackOrder,
MediaItemState.Normal, MediaItemState.Normal);
collection.Version);
internal static MultiCollectionViewModel ProjectToViewModel(MultiCollection multiCollection) => internal static MultiCollectionViewModel ProjectToViewModel(MultiCollection multiCollection) =>
new( new(
multiCollection.Id, multiCollection.Id,
multiCollection.Name, multiCollection.Name,
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList(), Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList(),
Optional(multiCollection.MultiCollectionSmartItems).Flatten().Map(ProjectToViewModel).ToList(), Optional(multiCollection.MultiCollectionSmartItems).Flatten().Map(ProjectToViewModel).ToList());
multiCollection.Version);
internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) => internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
new(collection.Id, collection.Name, collection.Query); new(collection.Id, collection.Name, collection.Query);
@@ -51,8 +49,7 @@ internal static class Mapper
_ => null _ => null
}, },
collection.FirstRunPlaybackOrder, collection.FirstRunPlaybackOrder,
collection.RerunPlaybackOrder, collection.RerunPlaybackOrder);
collection.Version);
internal static TraktListViewModel ProjectToViewModel(TraktList traktList) => internal static TraktListViewModel ProjectToViewModel(TraktList traktList) =>
new( new(
@@ -92,7 +89,7 @@ internal static class Mapper
new(playlistGroup.Id, playlistGroup.Name, playlistGroup.Playlists.Count, playlistGroup.IsSystem); new(playlistGroup.Id, playlistGroup.Name, playlistGroup.Playlists.Count, playlistGroup.IsSystem);
internal static PlaylistViewModel ProjectToViewModel(Playlist playlist) => internal static PlaylistViewModel ProjectToViewModel(Playlist playlist) =>
new(playlist.Id, playlist.PlaylistGroupId, playlist.Name, playlist.IsSystem, playlist.Version); new(playlist.Id, playlist.PlaylistGroupId, playlist.Name, playlist.IsSystem);
internal static PlaylistItemViewModel ProjectToViewModel(PlaylistItem playlistItem) => internal static PlaylistItemViewModel ProjectToViewModel(PlaylistItem playlistItem) =>
new( new(
@@ -8,10 +8,7 @@ public record MediaCollectionViewModel(
int Id, int Id,
string Name, string Name,
bool UseCustomPlaybackOrder, bool UseCustomPlaybackOrder,
MediaItemState State, MediaItemState State) : MediaCardViewModel(
// Optimistic-concurrency token (issue #253), header-only via ETag; 0 for the selection/placeholder
// constructions that don't read a real collection. Set from the entity by the Mapper projection.
int Version = 0) : MediaCardViewModel(
Id, Id,
Name, Name,
string.Empty, string.Empty,
@@ -4,6 +4,4 @@ public record MultiCollectionViewModel(
int Id, int Id,
string Name, string Name,
List<MultiCollectionItemViewModel> Items, List<MultiCollectionItemViewModel> Items,
List<MultiCollectionSmartItemViewModel> SmartItems, List<MultiCollectionSmartItemViewModel> SmartItems);
// Optimistic-concurrency token (issue #253), header-only via ETag; 0 for selection placeholders.
int Version = 0);
@@ -1,3 +1,3 @@
namespace ErsatzTV.Application.MediaCollections; namespace ErsatzTV.Application.MediaCollections;
public record PlaylistViewModel(int Id, int PlaylistGroupId, string Name, bool IsSystem, int Version); public record PlaylistViewModel(int Id, int PlaylistGroupId, string Name, bool IsSystem);
@@ -1,7 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.LibraryBrowse;
namespace ErsatzTV.Application.MediaCollections;
public record GetCollectionItems(int Id, int PageNum, int PageSize)
: IRequest<Either<BaseError, PagedLibraryBrowseItemsResponseModel>>;
@@ -1,82 +0,0 @@
using ErsatzTV.Application.LibraryBrowse;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class GetCollectionItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetCollectionItems, Either<BaseError, PagedLibraryBrowseItemsResponseModel>>
{
public async Task<Either<BaseError, PagedLibraryBrowseItemsResponseModel>> Handle(
GetCollectionItems request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
// A null flag here means the collection row does not exist (the projection yields no row),
// which lets a single query serve both the existence check and the custom-order flag.
bool? useCustomPlaybackOrder = await dbContext.Collections
.AsNoTracking()
.Where(c => c.Id == request.Id)
.Select(c => (bool?)c.UseCustomPlaybackOrder)
.SingleOrDefaultAsync(cancellationToken);
if (useCustomPlaybackOrder is null)
{
return new NotFoundError($"Collection {request.Id} does not exist.");
}
// The collection graph is bounded, so load every member (with its CustomIndex) and hydrate
// them in one shared pass (LibraryBrowseItemMapper), then order + page in-memory. Mixed media
// kinds are supported because MediaItem ids are globally unique across kinds.
var collectionItems = await dbContext.CollectionItems
.AsNoTracking()
.Where(ci => ci.CollectionId == request.Id)
.Select(ci => new { ci.MediaItemId, ci.CustomIndex })
.ToListAsync(cancellationToken);
List<int> mediaItemIds = collectionItems.Select(ci => ci.MediaItemId).ToList();
List<LibraryBrowseItemResponseModel> all =
await LibraryBrowseItemMapper.HydrateMediaItemsByIds(dbContext, mediaItemIds, cancellationToken);
List<LibraryBrowseItemResponseModel> ordered;
if (useCustomPlaybackOrder.Value)
{
// Custom order: sort by CustomIndex (items without one sort last), then title/id as a
// stable tiebreak.
var customIndexByMediaItemId = collectionItems
.GroupBy(ci => ci.MediaItemId)
.ToDictionary(g => g.Key, g => g.Select(ci => ci.CustomIndex).FirstOrDefault());
ordered = all
.OrderBy(i => customIndexByMediaItemId.TryGetValue(i.Id, out int? customIndex) && customIndex.HasValue
? customIndex.Value
: int.MaxValue)
.ThenBy(i => i.Title, StringComparer.OrdinalIgnoreCase)
.ThenBy(i => i.Id)
.ToList();
}
else
{
// Stable title ordering mirrors the library-browse handler (which orders its rows by name),
// giving the SPA a deterministic, browsable list independent of collection insertion order.
ordered = all
.OrderBy(i => i.Title, StringComparer.OrdinalIgnoreCase)
.ThenBy(i => i.Id)
.ToList();
}
int pageNum = Math.Max(0, request.PageNum);
int pageSize = Math.Clamp(request.PageSize, 1, 100);
List<LibraryBrowseItemResponseModel> page = ordered
.Skip(pageNum * pageSize)
.Take(pageSize)
.ToList();
return new PagedLibraryBrowseItemsResponseModel(ordered.Count, page);
}
}
@@ -12,6 +12,4 @@ public record RerunCollectionViewModel(
SmartCollectionViewModel SmartCollection, SmartCollectionViewModel SmartCollection,
NamedMediaItemViewModel MediaItem, NamedMediaItemViewModel MediaItem,
PlaybackOrder FirstRunPlaybackOrder, PlaybackOrder FirstRunPlaybackOrder,
PlaybackOrder RerunPlaybackOrder, PlaybackOrder RerunPlaybackOrder);
// Optimistic-concurrency token (issue #253), header-only via ETag.
int Version = 0);
@@ -62,20 +62,9 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
public async Task<Either<BaseError, Unit>> Handle(BuildPlayout request, CancellationToken cancellationToken) public async Task<Either<BaseError, Unit>> Handle(BuildPlayout request, CancellationToken cancellationToken)
{ {
// respect the EntityLocker ownership contract: LockPlayout returns true only for the caller
// that performed the 0->1 transition. If another operation already holds this playout's lock
// (a concurrent build, or a subtitle extraction), skip rather than build unlocked and then
// cross-release the other owner's lock in the finally.
if (!await _entityLocker.LockPlayout(request.PlayoutId))
{
_logger.LogDebug(
"Playout {PlayoutId} is already locked; skipping this build request",
request.PlayoutId);
return Unit.Default;
}
try try
{ {
await _entityLocker.LockPlayout(request.PlayoutId);
if (request.Mode is not PlayoutBuildMode.Reset) if (request.Mode is not PlayoutBuildMode.Reset)
{ {
// this needs to happen before we load the playout in this handler because it modifies items, etc // this needs to happen before we load the playout in this handler because it modifies items, etc
@@ -35,18 +35,15 @@ public class CreateScriptedPlayoutHandler(
{ {
await dbContext.Playouts.AddAsync(playout, cancellationToken); await dbContext.Playouts.AddAsync(playout, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), CancellationToken.None);
if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand) if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{ {
await channel.WriteAsync( await channel.WriteAsync(
new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false), new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false),
CancellationToken.None); cancellationToken);
} }
await channel.WriteAsync(new RefreshChannelList(), CancellationToken.None); await channel.WriteAsync(new RefreshChannelList(), cancellationToken);
return new CreatePlayoutResponse(playout.Id); return new CreatePlayoutResponse(playout.Id);
} }
@@ -33,18 +33,15 @@ public class CreateSequentialPlayoutHandler(
{ {
await dbContext.Playouts.AddAsync(playout, cancellationToken); await dbContext.Playouts.AddAsync(playout, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), CancellationToken.None);
if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand) if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{ {
await channel.WriteAsync( await channel.WriteAsync(
new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false), new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false),
CancellationToken.None); cancellationToken);
} }
await channel.WriteAsync(new RefreshChannelList(), CancellationToken.None); await channel.WriteAsync(new RefreshChannelList(), cancellationToken);
return new CreatePlayoutResponse(playout.Id); return new CreatePlayoutResponse(playout.Id);
} }
@@ -28,25 +28,20 @@ public class DeletePlayoutHandler(
foreach (Playout playout in maybePlayout) foreach (Playout playout in maybePlayout)
{ {
// Delete the guide cache file through the filesystem abstraction (observable under a
// MockFileSystem) and BEFORE the commit: deleting after commit orphans {number}.xml if the
// process crashes in between. The guide xml is regenerable on demand, so losing it pre-commit
// is safe (#254).
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{playout.Channel.Number}.xml");
if (fileSystem.File.Exists(cacheFile))
{
fileSystem.File.Delete(cacheFile);
}
dbContext.Playouts.Remove(playout); dbContext.Playouts.Remove(playout);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
// post-commit side effects run on CancellationToken.None so a late request cancellation can't // delete channel data from channel guide cache
// abort them after the delete committed (#254) string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{playout.Channel.Number}.xml");
// refresh channel list to remove channel that has no playout if (fileSystem.File.Exists(cacheFile))
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None); {
File.Delete(cacheFile);
}
await mediator.Publish(new PlayoutUpdatedNotification(playout.Id, false), CancellationToken.None); // refresh channel list to remove channel that has no playout
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
await mediator.Publish(new PlayoutUpdatedNotification(playout.Id, false), cancellationToken);
} }
return maybePlayout return maybePlayout
@@ -2,8 +2,5 @@ using ErsatzTV.Core;
namespace ErsatzTV.Application.Playouts; namespace ErsatzTV.Application.Playouts;
public record ReplacePlayoutAlternateScheduleItems( public record ReplacePlayoutAlternateScheduleItems(int PlayoutId, List<ReplacePlayoutAlternateSchedule> Items)
int PlayoutId,
List<ReplacePlayoutAlternateSchedule> Items,
Option<int> ExpectedVersion = default)
: IRequest<Either<BaseError, Unit>>; : IRequest<Either<BaseError, Unit>>;
@@ -20,13 +20,7 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
ReplacePlayoutAlternateScheduleItems request, ReplacePlayoutAlternateScheduleItems request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// The handler reads the highest-index item as the default schedule (Max() below), so an empty // TODO: validate that items is not empty
// list is invalid — reject it here rather than letting Max() throw. The controller pre-guards
// too, but a direct handler caller (MCP, test, reuse) must get a clean 422, not a raw crash (#254).
if (request.Items.Count == 0)
{
return BaseError.New("Playout alternate schedule items must not be empty");
}
try try
{ {
@@ -40,18 +34,6 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
foreach (Playout playout in maybePlayout) foreach (Playout playout in maybePlayout)
{ {
// Optimistic-concurrency pre-check (issue #253 §7a): reject a stale If-Match with a
// PreconditionFailedError (→ 412) before any mutation. Introduced as a standalone Either,
// and returned directly (a value, not a throw) so it escapes the catch(Exception) below
// rather than being reshaped into a bare 422 (§9/H1).
Either<BaseError, Playout> versionCheck = playout.CheckVersion(request.ExpectedVersion);
if (versionCheck.IsLeft)
{
return versionCheck.Match<Either<BaseError, Unit>>(
Left: error => error,
Right: _ => Unit.Default);
}
var existingScheduleMap = new Dictionary<DateTimeOffset, ProgramSchedule>(); var existingScheduleMap = new Dictionary<DateTimeOffset, ProgramSchedule>();
var daysToCheck = new List<DateTimeOffset>(); var daysToCheck = new List<DateTimeOffset>();
@@ -139,20 +121,7 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
playout.ProgramScheduleId = highest.ProgramScheduleId; playout.ProgramScheduleId = highest.ProgramScheduleId;
} }
// Unconditional bump (issue #253 §7a / M1): EF emits the root UPDATE only when a scalar await dbContext.SaveChangesAsync(cancellationToken);
// actually differs, so a no-op PUT-back would otherwise neither fire the concurrency token
// nor rotate other clients' ETags.
playout.Version++;
// Guarded save maps a racing DbUpdateConcurrencyException to PreconditionFailedError (→ 412)
// as a return value, so a lost race short-circuits here before the post-commit refresh block
// and escapes the catch(Exception) below as a 412, not a 422 (§9/H1).
Either<BaseError, Unit> saveResult =
await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
if (saveResult.IsLeft)
{
return saveResult;
}
if (hasDefaultScheduleChange) if (hasDefaultScheduleChange)
{ {
@@ -183,11 +152,9 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
existingValue.Name, existingValue.Name,
schedule.Name); schedule.Name);
// post-commit enqueue runs on CancellationToken.None: the schedule change is
// already committed, so a late cancellation must not drop the rebuild (#254)
await channel.WriteAsync( await channel.WriteAsync(
new BuildPlayout(request.PlayoutId, PlayoutBuildMode.Refresh), new BuildPlayout(request.PlayoutId, PlayoutBuildMode.Refresh),
CancellationToken.None); cancellationToken);
break; break;
} }
@@ -1,3 +1,3 @@
namespace ErsatzTV.Application.Playouts; namespace ErsatzTV.Application.Playouts;
public record ResetAllPlayouts : IRequest<ResetAllPlayoutsResult>; public record ResetAllPlayouts : IRequest;
@@ -11,49 +11,33 @@ public class ResetAllPlayoutsHandler(
IEntityLocker locker, IEntityLocker locker,
ChannelWriter<IBackgroundServiceRequest> channel, ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory) IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ResetAllPlayouts, ResetAllPlayoutsResult> : IRequestHandler<ResetAllPlayouts>
{ {
public async Task<ResetAllPlayoutsResult> Handle( public async Task Handle(ResetAllPlayouts request, CancellationToken cancellationToken)
ResetAllPlayouts request,
CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var queued = new List<int>();
var skippedLocked = new List<int>();
var skippedUnsupported = new List<int>();
foreach (Playout playout in await dbContext.Playouts.ToListAsync(cancellationToken)) foreach (Playout playout in await dbContext.Playouts.ToListAsync(cancellationToken))
{ {
switch (playout.ScheduleKind) switch (playout.ScheduleKind)
{ {
case PlayoutScheduleKind.Classic: case PlayoutScheduleKind.Classic:
if (locker.IsPlayoutLocked(playout.Id)) if (!locker.IsPlayoutLocked(playout.Id))
{
skippedLocked.Add(playout.Id);
}
else
{ {
await channel.WriteAsync( await channel.WriteAsync(
new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh),
cancellationToken); cancellationToken);
queued.Add(playout.Id);
} }
break; break;
case PlayoutScheduleKind.Block: case PlayoutScheduleKind.Block:
case PlayoutScheduleKind.Sequential: case PlayoutScheduleKind.Sequential:
case PlayoutScheduleKind.Scripted: case PlayoutScheduleKind.Scripted:
if (locker.IsPlayoutLocked(playout.Id)) if (!locker.IsPlayoutLocked(playout.Id))
{
skippedLocked.Add(playout.Id);
}
else
{ {
await channel.WriteAsync( await channel.WriteAsync(
new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), new BuildPlayout(playout.Id, PlayoutBuildMode.Reset),
cancellationToken); cancellationToken);
queued.Add(playout.Id);
} }
break; break;
@@ -61,11 +45,8 @@ public class ResetAllPlayoutsHandler(
case PlayoutScheduleKind.None: case PlayoutScheduleKind.None:
default: default:
// external json cannot be reset // external json cannot be reset
skippedUnsupported.Add(playout.Id);
continue; continue;
} }
} }
return new ResetAllPlayoutsResult(queued, skippedLocked, skippedUnsupported);
} }
} }
@@ -40,13 +40,9 @@ public class
{ {
playout.ScheduleFile = request.ScheduleFile; playout.ScheduleFile = request.ScheduleFile;
// Force-write past a concurrent Version bump from a replace-all editor (schedule-file writer if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
// doesn't participate in If-Match, so an active token must not 500 a benign race) — #253/#269.
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0)
{ {
// post-commit side effect runs on CancellationToken.None so a late request cancellation await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), cancellationToken);
// can't abort it after the commit landed (#254)
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), CancellationToken.None);
} }
return new PlayoutNameViewModel( return new PlayoutNameViewModel(
@@ -58,10 +54,7 @@ public class
playout.ProgramSchedule?.Name ?? string.Empty, playout.ProgramSchedule?.Name ?? string.Empty,
playout.ScheduleFile, playout.ScheduleFile,
playout.DailyRebuildTime, playout.DailyRebuildTime,
playout.BuildStatus, playout.BuildStatus);
playout.DecoId,
playout.Deco?.Name,
playout.Version);
} }
private static Task<Validation<BaseError, Playout>> Validate( private static Task<Validation<BaseError, Playout>> Validate(
@@ -61,10 +61,7 @@ public class UpdateOnDemandCheckpointHandler(
playout.Channel.Name, playout.Channel.Name,
playout.OnDemandCheckpoint); playout.OnDemandCheckpoint);
// Force-write past a concurrent Version bump from a replace-all editor: the on-demand await dbContext.SaveChangesAsync(cancellationToken);
// checkpoint writer touches the Playout root but doesn't participate in If-Match, so the
// active concurrency token must not turn a benign race into a 500 (#253/#269).
await dbContext.SaveChangesForcingVersion(cancellationToken);
} }
} }
} }
@@ -35,9 +35,7 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
playout.DailyRebuildTime = dailyRebuildTime; playout.DailyRebuildTime = dailyRebuildTime;
} }
// Force-write past a concurrent Version bump from a replace-all editor (this settings writer await dbContext.SaveChangesAsync(cancellationToken);
// doesn't participate in If-Match, so a missing token = force-write, not a 500) — #253/#269.
await dbContext.SaveChangesForcingVersion(cancellationToken);
return new PlayoutNameViewModel( return new PlayoutNameViewModel(
playout.Id, playout.Id,
@@ -48,10 +46,7 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
playout.ProgramSchedule?.Name ?? string.Empty, playout.ProgramSchedule?.Name ?? string.Empty,
playout.ScheduleFile, playout.ScheduleFile,
playout.DailyRebuildTime, playout.DailyRebuildTime,
playout.BuildStatus, playout.BuildStatus);
playout.DecoId,
playout.Deco?.Name,
playout.Version);
} }
private static Task<Validation<BaseError, Playout>> Validate( private static Task<Validation<BaseError, Playout>> Validate(
@@ -35,13 +35,9 @@ public class
{ {
playout.ScheduleFile = request.ScheduleFile; playout.ScheduleFile = request.ScheduleFile;
// Force-write past a concurrent Version bump from a replace-all editor (schedule-file writer if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
// doesn't participate in If-Match, so an active token must not 500 a benign race) — #253/#269.
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0)
{ {
// post-commit side effect runs on CancellationToken.None so a late request cancellation await workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), cancellationToken);
// can't abort it after the commit landed (#254)
await workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), CancellationToken.None);
} }
return new PlayoutNameViewModel( return new PlayoutNameViewModel(
@@ -53,10 +49,7 @@ public class
playout.ProgramSchedule?.Name ?? string.Empty, playout.ProgramSchedule?.Name ?? string.Empty,
playout.ScheduleFile, playout.ScheduleFile,
playout.DailyRebuildTime, playout.DailyRebuildTime,
playout.BuildStatus, playout.BuildStatus);
playout.DecoId,
playout.Deco?.Name,
playout.Version);
} }
private async Task<Validation<BaseError, Playout>> Validate( private async Task<Validation<BaseError, Playout>> Validate(
@@ -40,13 +40,9 @@ public class
{ {
playout.ScheduleFile = request.ScheduleFile; playout.ScheduleFile = request.ScheduleFile;
// Force-write past a concurrent Version bump from a replace-all editor (schedule-file writer if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
// doesn't participate in If-Match, so an active token must not 500 a benign race) — #253/#269.
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0)
{ {
// post-commit side effect runs on CancellationToken.None so a late request cancellation await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), cancellationToken);
// can't abort it after the commit landed (#254)
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), CancellationToken.None);
} }
return new PlayoutNameViewModel( return new PlayoutNameViewModel(
@@ -58,10 +54,7 @@ public class
playout.ProgramSchedule?.Name ?? string.Empty, playout.ProgramSchedule?.Name ?? string.Empty,
playout.ScheduleFile, playout.ScheduleFile,
playout.DailyRebuildTime, playout.DailyRebuildTime,
playout.BuildStatus, playout.BuildStatus);
playout.DecoId,
playout.Deco?.Name,
playout.Version);
} }
private static Task<Validation<BaseError, Playout>> Validate( private static Task<Validation<BaseError, Playout>> Validate(
+1 -7
View File
@@ -15,16 +15,10 @@ internal static class Mapper
playout.ProgramScheduleId == null ? string.Empty : playout.ProgramSchedule.Name, playout.ProgramScheduleId == null ? string.Empty : playout.ProgramSchedule.Name,
playout.ScheduleFile, playout.ScheduleFile,
playout.DailyRebuildTime, playout.DailyRebuildTime,
playout.BuildStatus, playout.BuildStatus);
playout.DecoId,
// the paged-playouts query does not eager-load Deco (the list response does not surface
// the default deco); GetPlayoutById includes it for the detail response
playout.Deco?.Name,
playout.Version);
internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) => internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
new( new(
playoutItem.Id,
GetDisplayTitle(playoutItem.MediaItem, playoutItem.ChapterTitle), GetDisplayTitle(playoutItem.MediaItem, playoutItem.ChapterTitle),
playoutItem.StartOffset, playoutItem.StartOffset,
playoutItem.FinishOffset, playoutItem.FinishOffset,
@@ -3,7 +3,6 @@ using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Playouts; namespace ErsatzTV.Application.Playouts;
public record PlayoutItemViewModel( public record PlayoutItemViewModel(
int? Id,
string Title, string Title,
DateTimeOffset Start, DateTimeOffset Start,
DateTimeOffset Finish, DateTimeOffset Finish,
@@ -11,10 +11,7 @@ public record PlayoutNameViewModel(
string ScheduleName, string ScheduleName,
string ScheduleFile, string ScheduleFile,
TimeSpan? DbDailyRebuildTime, TimeSpan? DbDailyRebuildTime,
PlayoutBuildStatus BuildStatus, PlayoutBuildStatus BuildStatus)
int? DecoId,
string DecoName,
int Version)
{ {
public Option<TimeSpan> DailyRebuildTime => Optional(DbDailyRebuildTime); public Option<TimeSpan> DailyRebuildTime => Optional(DbDailyRebuildTime);
@@ -106,9 +106,7 @@ public class GetFuturePlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbCon
var gap = playoutGaps.Single(g => g.Id == c.Id); var gap = playoutGaps.Single(g => g.Id == c.Id);
TimeSpan gapDuration = gap.Finish - gap.Start; TimeSpan gapDuration = gap.Finish - gap.Start;
// gaps are synthesized rows, not PlayoutItems, so they carry no row id
return new PlayoutItemViewModel( return new PlayoutItemViewModel(
null,
"UNSCHEDULED", "UNSCHEDULED",
gap.StartOffset, gap.StartOffset,
gap.FinishOffset, gap.FinishOffset,
@@ -17,7 +17,6 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
.Include(p => p.ProgramSchedule) .Include(p => p.ProgramSchedule)
.Include(p => p.Channel) .Include(p => p.Channel)
.Include(p => p.BuildStatus) .Include(p => p.BuildStatus)
.Include(p => p.Deco)
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken) .SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken)
.MapT(p => new PlayoutNameViewModel( .MapT(p => new PlayoutNameViewModel(
p.Id, p.Id,
@@ -28,9 +27,6 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
p.ProgramScheduleId == null ? string.Empty : p.ProgramSchedule.Name, p.ProgramScheduleId == null ? string.Empty : p.ProgramSchedule.Name,
p.ScheduleFile, p.ScheduleFile,
p.DailyRebuildTime, p.DailyRebuildTime,
p.BuildStatus, p.BuildStatus));
p.DecoId,
p.DecoId == null ? null : p.Deco.Name,
p.Version));
} }
} }
@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Playouts;
public record GetPlayoutItemSchedulingContext(int PlayoutItemId) : IRequest<Option<string>>;
@@ -1,33 +0,0 @@
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts;
public class GetPlayoutItemSchedulingContextHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediator mediator)
: IRequestHandler<GetPlayoutItemSchedulingContext, Option<string>>
{
public async Task<Option<string>> Handle(
GetPlayoutItemSchedulingContext request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
string serializedContext = await dbContext.PlayoutItems
.AsNoTracking()
.Where(pi => pi.Id == request.PlayoutItemId)
.Select(pi => pi.SchedulingContext)
.SingleOrDefaultAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(serializedContext))
{
return Option<string>.None;
}
// Decode/enrich exactly the way the troubleshooting decode path does, reusing the
// single ProcessSchedulingContext handler so any future format change stays in one place.
return await mediator.Send(new ProcessSchedulingContext(serializedContext), cancellationToken);
}
}
@@ -1,6 +0,0 @@
namespace ErsatzTV.Application.Playouts;
public record ResetAllPlayoutsResult(
List<int> QueuedPlayoutIds,
List<int> SkippedLocked,
List<int> SkippedUnsupported);
@@ -27,22 +27,12 @@ public class SignOutOfPlexHandler : IRequestHandler<SignOutOfPlex, Either<BaseEr
public async Task<Either<BaseError, Unit>> Handle(SignOutOfPlex request, CancellationToken cancellationToken) public async Task<Either<BaseError, Unit>> Handle(SignOutOfPlex request, CancellationToken cancellationToken)
{ {
// Terminal handler (no lock handoff): release the Plex lock on EVERY exit via finally so a throw List<int> ids = await _mediaSourceRepository.DeleteAllPlex();
// from any awaited dependency (repo delete, search-index commit, secret store) cannot wedge Plex await _searchIndex.RemoveItems(ids);
// locked at 409 until restart (#202 §C6 / finding 7). This is the UNCONDITIONAL-finally case — _searchIndex.Commit();
// contrast the pin-flow handlers, which hand the lock off and must NOT use a blanket finally. await _plexSecretStore.DeleteAll();
try _entityLocker.UnlockPlex();
{
List<int> ids = await _mediaSourceRepository.DeleteAllPlex();
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
await _plexSecretStore.DeleteAll();
return Unit.Default; return Unit.Default;
}
finally
{
_entityLocker.UnlockPlex();
}
} }
} }
@@ -2,5 +2,5 @@ using ErsatzTV.Core;
namespace ErsatzTV.Application.Plex; namespace ErsatzTV.Application.Plex;
public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan, bool DeepScan, bool Unlock = true) public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan, bool DeepScan)
: IRequest<Either<BaseError, Unit>>, IScannerBackgroundServiceRequest; : IRequest<Either<BaseError, Unit>>, IScannerBackgroundServiceRequest;
@@ -7,20 +7,15 @@ public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string
int PlexLibraryId { get; } int PlexLibraryId { get; }
bool ForceScan { get; } bool ForceScan { get; }
bool DeepScan { get; } bool DeepScan { get; }
// When false, the ScannerService keeps the library lock held after this message so a later
// message in the same batch (e.g. SynchronizePlexNetworks) carries the single release.
bool Unlock { get; }
} }
public record SynchronizePlexLibraryByIdIfNeeded(int PlexLibraryId, bool Unlock = true) : ISynchronizePlexLibraryById public record SynchronizePlexLibraryByIdIfNeeded(int PlexLibraryId) : ISynchronizePlexLibraryById
{ {
public bool ForceScan => false; public bool ForceScan => false;
public bool DeepScan => false; public bool DeepScan => false;
} }
public record ForceSynchronizePlexLibraryById(int PlexLibraryId, bool DeepScan, bool Unlock = true) public record ForceSynchronizePlexLibraryById(int PlexLibraryId, bool DeepScan) : ISynchronizePlexLibraryById
: ISynchronizePlexLibraryById
{ {
public bool ForceScan => true; public bool ForceScan => true;
} }
@@ -2,9 +2,5 @@ using ErsatzTV.Core;
namespace ErsatzTV.Application.Plex; namespace ErsatzTV.Application.Plex;
// Unlock defaults true so single-message callers release the library lock as before. In the Plex public record SynchronizePlexNetworks(int PlexLibraryId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
// "Shows" scheduler batch this message runs LAST and carries the single release (the preceding IScannerBackgroundServiceRequest;
// library message runs with Unlock: false).
public record SynchronizePlexNetworks(int PlexLibraryId, bool ForceScan, bool Unlock = true)
: IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;
@@ -1,6 +1,5 @@
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
namespace ErsatzTV.Application.Plex; namespace ErsatzTV.Application.Plex;
@@ -8,59 +7,34 @@ namespace ErsatzTV.Application.Plex;
public class TryCompletePlexPinFlowHandler : IRequestHandler<TryCompletePlexPinFlow, Either<BaseError, bool>> public class TryCompletePlexPinFlowHandler : IRequestHandler<TryCompletePlexPinFlow, Either<BaseError, bool>>
{ {
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel; private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
private readonly IEntityLocker _entityLocker;
private readonly IPlexTvApiClient _plexTvApiClient; private readonly IPlexTvApiClient _plexTvApiClient;
public TryCompletePlexPinFlowHandler( public TryCompletePlexPinFlowHandler(
IPlexTvApiClient plexTvApiClient, IPlexTvApiClient plexTvApiClient,
ChannelWriter<IPlexBackgroundServiceRequest> channel, ChannelWriter<IPlexBackgroundServiceRequest> channel)
IEntityLocker entityLocker)
{ {
_plexTvApiClient = plexTvApiClient; _plexTvApiClient = plexTvApiClient;
_channel = channel; _channel = channel;
_entityLocker = entityLocker;
} }
public async Task<Either<BaseError, bool>> public async Task<Either<BaseError, bool>>
Handle(TryCompletePlexPinFlow request, CancellationToken cancellationToken) Handle(TryCompletePlexPinFlow request, CancellationToken cancellationToken)
{ {
// Lock-release discipline (#202 §C1.6 / §C6): the Plex lock this pin flow holds is released
// ONLY on non-handoff exits — the 2-minute timeout (Task.Delay throws
// OperationCanceledException), a poll exception, a failed enqueue, or the (effectively dead)
// return-false at loop entry. On SUCCESS the lock is HANDED OFF to SynchronizePlexMediaSources,
// whose handler is the sole releaser after server discovery (SynchronizePlexMediaSourcesHandler).
// This is deliberately NOT an unconditional finally: a blanket release here would double-release
// AND release before discovery, re-opening the finding-5 race (an empty server list reading as
// success). Contrast the terminal SignOutOfPlexHandler, which DOES use finally (it has no handoff).
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken); using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken);
CancellationToken token = linkedTokenSource.Token; CancellationToken token = linkedTokenSource.Token;
try while (!token.IsCancellationRequested)
{ {
while (!token.IsCancellationRequested) bool result = await _plexTvApiClient.TryCompletePinFlow(request.AuthPin);
if (result)
{ {
bool result = await _plexTvApiClient.TryCompletePinFlow(request.AuthPin); await _channel.WriteAsync(new SynchronizePlexMediaSources(), token);
if (result) return true;
{
// hand the lock off to the sync handler — do NOT release on this success path
await _channel.WriteAsync(new SynchronizePlexMediaSources(), token);
return true;
}
await Task.Delay(TimeSpan.FromSeconds(1), token);
} }
// effectively unreachable (Task.Delay throws on cancellation before the loop condition is await Task.Delay(TimeSpan.FromSeconds(1), token);
// re-evaluated) but if the flow ever ends here it abandoned without auth → release
_entityLocker.UnlockPlex();
return false;
}
catch (Exception)
{
// non-handoff exit: timeout-throw, poll exception, or failed enqueue — release the lock so an
// abandoned flow does not wedge Plex locked, then rethrow (PlexService logs it as before)
_entityLocker.UnlockPlex();
throw;
} }
return false;
} }
} }
@@ -35,56 +35,14 @@ public class
private static PlexPathReplacement Project(PlexPathReplacementItem vm) => private static PlexPathReplacement Project(PlexPathReplacementItem vm) =>
new() { Id = vm.Id, PlexPath = vm.PlexPath, LocalPath = vm.LocalPath }; new() { Id = vm.Id, PlexPath = vm.PlexPath, LocalPath = vm.LocalPath };
private async Task<Validation<BaseError, PlexMediaSource>> Validate( private Task<Validation<BaseError, PlexMediaSource>> Validate(
UpdatePlexPathReplacements request, UpdatePlexPathReplacements request,
CancellationToken cancellationToken) CancellationToken cancellationToken) =>
{ PlexMediaSourceMustExist(request, cancellationToken);
Option<PlexMediaSource> maybeSource =
await _mediaSourceRepository.GetPlex(request.PlexMediaSourceId, cancellationToken);
foreach (PlexMediaSource plexMediaSource in maybeSource) private Task<Validation<BaseError, PlexMediaSource>> PlexMediaSourceMustExist(
{
return ValidatePathReplacements(request, plexMediaSource);
}
return Fail<BaseError, PlexMediaSource>(
BaseError.New($"Plex media source {request.PlexMediaSourceId} does not exist."));
}
// Programmatic clients now reach this handler directly (#202), so validate what the Blazor form
// enforced plus the cross-source ownership hole (finding 2c/8): reject a null list, null items,
// blank RemotePath/LocalPath, and any positive Id NOT owned by this source — all before any write,
// so there is no partial mutation.
private static Validation<BaseError, PlexMediaSource> ValidatePathReplacements(
UpdatePlexPathReplacements request, UpdatePlexPathReplacements request,
PlexMediaSource plexMediaSource) CancellationToken cancellationToken) =>
{ _mediaSourceRepository.GetPlex(request.PlexMediaSourceId, cancellationToken)
List<PlexPathReplacementItem> items = request.PathReplacements; .Map(v => v.ToValidation<BaseError>($"Plex media source {request.PlexMediaSourceId} does not exist."));
if (items is null)
{
return Fail<BaseError, PlexMediaSource>(BaseError.New("[PathReplacements] is required"));
}
if (items.Any(i => i is null))
{
return Fail<BaseError, PlexMediaSource>(BaseError.New("[PathReplacements] must not contain null items"));
}
if (items.Any(i => string.IsNullOrWhiteSpace(i.PlexPath) || string.IsNullOrWhiteSpace(i.LocalPath)))
{
return Fail<BaseError, PlexMediaSource>(
BaseError.New("Each path replacement requires a non-empty RemotePath and LocalPath"));
}
var ownedIds = Optional(plexMediaSource.PathReplacements).Flatten().Map(pr => pr.Id).ToHashSet();
List<int> foreignIds = items.Filter(i => i.Id > 0 && !ownedIds.Contains(i.Id)).Map(i => i.Id).ToList();
if (foreignIds.Count > 0)
{
return Fail<BaseError, PlexMediaSource>(
BaseError.New(
$"Path replacement {foreignIds[0]} does not belong to Plex media source {request.PlexMediaSourceId}"));
}
return Success<BaseError, PlexMediaSource>(plexMediaSource);
}
} }

Some files were not shown because too many files have changed in this diff Show More