Compare commits

..
Author SHA1 Message Date
timothyandClaude Opus 4.8 3d6ac883da ci: auto-bump prod chicorytv tag in server-management on v* release
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m48s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Bump prod compose tag (server-management) (pull_request) Has been skipped
Adds a bump-prod-compose job that runs only on v* tags, after build. It
clones server-management via a scoped write deploy key (SERVERMGMT_DEPLOY_KEY
secret), rewrites the pinned ersatztv image tag in
docker/bumblebee/stacks/media-servers/compose.yaml to the released version,
and pushes. The existing per-stack Gitea->Komodo webhook then redeploys prod,
and because the ersatztv service block changed, the #553 pre-deploy hook takes
an ErsatzTV PBS backup first.

Automates the documented "current practice" prod bump (docs/Docker/ErsatzTV.md)
while keeping prod pinned + backed up — deliberately NOT a floating :prod /
watchtower gate (server-management#481).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:08:07 +02:00
444 changed files with 4881 additions and 61869 deletions
+41 -65
View File
@@ -22,15 +22,11 @@ on:
tags:
- 'v*'
# Concurrency is scoped per ref (originally one global group for the single
# jazz runner; with 3 runners that serialized the whole queue). PR runs
# 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.
# Single runner on jazz: serialize all runs so the push-main-then-tag release
# flow can't collide on the shared :buildcache tag or the smoke container.
concurrency:
group: ersatztv-build-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
group: ersatztv-build
cancel-in-progress: false
env:
REGISTRY: 192.168.1.95:3000
@@ -44,22 +40,13 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
# only the test job's steps below need the working tree; git history/tags
# are only needed by the `build` job's `git describe` (ersatztv#190)
fetch-depth: 1
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
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
run: dotnet restore
@@ -115,9 +102,8 @@ jobs:
env:
MYSQL_ROOT_PASSWORD: ersatztv
MYSQL_DATABASE: ersatztv_migrations
# No host-port binding: the job reaches this service as mysql:3306 on the shared
# runner network. Publishing 3306 made concurrent runs collide ("port is already
# allocated") whenever two migrations jobs overlapped.
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -persatztv --silent"
--health-interval=5s
@@ -126,21 +112,12 @@ jobs:
steps:
- name: Checkout
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
uses: actions/setup-dotnet@v4
with:
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
run: dotnet restore
@@ -184,12 +161,7 @@ jobs:
build:
name: Build & push image (amd64)
# `small` = the dedicated small-jobs runner lane (server-management#574).
# 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
runs-on: ubuntu-latest
needs: [test, migrations]
if: github.event_name != 'pull_request'
steps:
@@ -309,35 +281,39 @@ jobs:
exit 1
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'
# On a prod release (v* tag), auto-bump the pinned ersatztv image tag in the
# server-management media-servers compose and push. The existing per-stack
# Gitea->Komodo webhook then redeploys prod, and because the ersatztv service
# block changed, the #553 pre-deploy hook takes an ErsatzTV PBS backup first.
# This automates the documented "current practice" bump (docs/Docker/ErsatzTV.md)
# while keeping prod pinned + backed up (NOT a floating :prod / watchtower gate).
bump-prod-compose:
name: Bump prod compose tag (server-management)
runs-on: ubuntu-latest
needs: [build]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Warn when a screen/route change skips the parity doc
- name: Bump ersatztv prod tag in media-servers compose
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."
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.SERVERMGMT_DEPLOY_KEY }}" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
ssh-keyscan -p 22 192.168.1.95 >> ~/.ssh/known_hosts 2>/dev/null
export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_deploy -o IdentitiesOnly=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
rm -rf /tmp/svrmgmt
git clone --depth 1 ssh://gitea@192.168.1.95:22/timothy/server-management.git /tmp/svrmgmt
cd /tmp/svrmgmt
FILE="docker/bumblebee/stacks/media-servers/compose.yaml"
sed -i -E "s|(image: 192\.168\.1\.95:3000/timothy/ersatztv:)[^[:space:]]+|\1${VERSION}|" "$FILE"
if git diff --quiet "$FILE"; then
echo "media-servers compose already pins ersatztv:${VERSION}; nothing to do"
exit 0
fi
git config user.name "ersatztv-ci"
git config user.email "ci@tblindustries.be"
git add "$FILE"
git commit -m "chore(ersatztv): bump prod chicorytv to ${VERSION} [ci auto-deploy]"
git push origin HEAD:master
echo "Pushed prod bump -> ersatztv:${VERSION}; media-servers webhook will deploy with pre-deploy backup."
-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 -12
View File
@@ -56,18 +56,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
## 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.
- **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.
- **Convention docs replace re-recon**: before API/SPA/E2E/parity work, read `docs/README.md` (index) → `docs/api-conventions.md`, `docs/spa-conventions.md`, `docs/e2e-local.md`, `docs/domain-model.md`, `docs/blazor-route-parity.md`, `docs/decisions.md`. Any PR that changes a convention, migrates a route, or reverses a decision MUST update the relevant doc in the same PR.
- Follow existing MediatR CQRS pattern for new features
- 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
@@ -55,9 +55,7 @@ public class BulkDeleteChannelsHandler(
}
}
// 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);
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return Right<BaseError, Unit>(Unit.Default);
}
@@ -54,10 +54,7 @@ public class BulkMoveChannelsToGroupHandler(
await transaction.CommitAsync(cancellationToken);
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(new RefreshChannelList(), CancellationToken.None);
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return Right<BaseError, Unit>(Unit.Default);
}
@@ -68,22 +68,19 @@ public class CreateChannelFromLineupHandler(
}
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(
new BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset),
CancellationToken.None);
cancellationToken);
// Mirror CreateClassicPlayoutHandler: on-demand playouts must be time-shifted to "now" after build.
if (prepared.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{
await workerChannel.WriteAsync(
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(
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)
{
// 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);
await dbContext.SaveChangesAsync(cancellationToken);
_searchTargets.SearchTargetsChanged();
// refresh channel list to remove channel that has no playout — post-commit side effect runs on
// CancellationToken.None so a late request cancellation can't abort it after the delete committed (#254)
await _workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
// delete channel data from channel guide cache
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
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;
}
@@ -157,23 +157,21 @@ public class UpdateChannelHandler(
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)
{
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)
{
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)
{
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), CancellationToken.None);
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), cancellationToken);
}
return ProjectToViewModel(c, c.Playouts?.Count ?? 0);
@@ -81,12 +81,10 @@ public class UpdateChannelNumbersHandler(
await transaction.CommitAsync(cancellationToken);
// update channel list and xmltv
// 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);
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
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;
@@ -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,
CancellationToken cancellationToken)
{
// This is a terminal handler with no lock handoff (unlike the Plex pin-flow handlers) —
// release unconditionally so a throw from any awaited dependency (repo delete, search-index
// commit, secret store) can't wedge the Emby lock until restart (design #202 finding 7).
try
{
List<int> ids = await _mediaSourceRepository.DeleteAllEmby();
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
await _embySecretStore.DeleteAll();
List<int> ids = await _mediaSourceRepository.DeleteAllEmby();
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
await _embySecretStore.DeleteAll();
_entityLocker.UnlockRemoteMediaSource<EmbyMediaSource>();
return Unit.Default;
}
finally
{
_entityLocker.UnlockRemoteMediaSource<EmbyMediaSource>();
}
return Unit.Default;
}
}
@@ -2,6 +2,6 @@ using ErsatzTV.Core;
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>>,
IScannerBackgroundServiceRequest;
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -12,29 +12,12 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
public UpdateEmbyPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) =>
_mediaSourceRepository = mediaSourceRepository;
public async Task<Either<BaseError, Unit>> Handle(
public Task<Either<BaseError, Unit>> Handle(
UpdateEmbyPathReplacements request,
CancellationToken cancellationToken)
{
Option<EmbyMediaSource> maybeSource =
await _mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken);
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."))));
}
CancellationToken cancellationToken) =>
Validate(request, cancellationToken)
.MapT(pms => MergePathReplacements(request, pms))
.Bind(v => v.ToEitherAsync());
private Task<Unit> MergePathReplacements(
UpdateEmbyPathReplacements request,
@@ -54,38 +37,12 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
private static EmbyPathReplacement Project(EmbyPathReplacementItem vm) =>
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
// EmbyMediaSourceId, but reject a foreign/blank/null row here too, before any write, so the
// mutation is all-or-nothing.
private static Option<BaseError> ValidateItems(
UpdateEmbyPathReplacements request,
EmbyMediaSource embyMediaSource)
{
List<EmbyPathReplacementItem> items = request.PathReplacements ?? [];
private Task<Validation<BaseError, EmbyMediaSource>> Validate(UpdateEmbyPathReplacements request, CancellationToken cancellationToken) =>
EmbyMediaSourceMustExist(request, cancellationToken);
if (items.Any(item => item is null))
{
return BaseError.New("Path replacement items must not be null.");
}
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;
}
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist(
UpdateEmbyPathReplacements request, CancellationToken cancellationToken) =>
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken)
.Map(v => v.ToValidation<BaseError>(
$"Emby media source {request.EmbyMediaSourceId} does not exist."));
}
+1 -1
View File
@@ -6,7 +6,7 @@ namespace ErsatzTV.Application.Filler;
internal static class Mapper
{
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(
@@ -1,6 +1,5 @@
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain.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)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
IQueryable<FillerPreset> query = dbContext.FillerPresets.AsNoTracking();
if (request.FillerKind is { } fillerKind)
{
query = query.Where(fp => fp.FillerKind == fillerKind);
}
List<FillerPreset> fillerPresets = await query.ToListAsync(cancellationToken);
List<FillerPreset> fillerPresets = await dbContext.FillerPresets
.AsNoTracking()
.ToListAsync(cancellationToken);
return fillerPresets.Map(ProjectToResponseModel).ToList();
}
}
@@ -30,21 +30,12 @@ public class DisconnectJellyfinHandler : IRequestHandler<DisconnectJellyfin, Eit
DisconnectJellyfin request,
CancellationToken cancellationToken)
{
// This is a terminal handler with no lock handoff (unlike the Plex pin-flow handlers) —
// release unconditionally so a throw from any awaited dependency (repo delete, search-index
// commit, secret store) can't wedge the Jellyfin lock until restart (design #202 finding 7).
try
{
List<int> ids = await _mediaSourceRepository.DeleteAllJellyfin();
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
await _jellyfinSecretStore.DeleteAll();
List<int> ids = await _mediaSourceRepository.DeleteAllJellyfin();
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
await _jellyfinSecretStore.DeleteAll();
_entityLocker.UnlockRemoteMediaSource<JellyfinMediaSource>();
return Unit.Default;
}
finally
{
_entityLocker.UnlockRemoteMediaSource<JellyfinMediaSource>();
}
return Unit.Default;
}
}
@@ -2,6 +2,6 @@ using ErsatzTV.Core;
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>>,
IScannerBackgroundServiceRequest;
@@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -12,29 +12,12 @@ public class UpdateJellyfinPathReplacementsHandler : IRequestHandler<UpdateJelly
public UpdateJellyfinPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) =>
_mediaSourceRepository = mediaSourceRepository;
public async Task<Either<BaseError, Unit>> Handle(
public Task<Either<BaseError, Unit>> Handle(
UpdateJellyfinPathReplacements request,
CancellationToken cancellationToken)
{
Option<JellyfinMediaSource> maybeSource =
await _mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId);
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."))));
}
CancellationToken cancellationToken) =>
Validate(request)
.MapT(pms => MergePathReplacements(request, pms))
.Bind(v => v.ToEitherAsync());
private Task<Unit> MergePathReplacements(
UpdateJellyfinPathReplacements request,
@@ -54,38 +37,12 @@ public class UpdateJellyfinPathReplacementsHandler : IRequestHandler<UpdateJelly
private static JellyfinPathReplacement Project(JellyfinPathReplacementItem vm) =>
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
// JellyfinMediaSourceId, but reject a foreign/blank/null row here too, before any write, so the
// mutation is all-or-nothing.
private static Option<BaseError> ValidateItems(
UpdateJellyfinPathReplacements request,
JellyfinMediaSource jellyfinMediaSource)
{
List<JellyfinPathReplacementItem> items = request.PathReplacements ?? [];
private Task<Validation<BaseError, JellyfinMediaSource>> Validate(UpdateJellyfinPathReplacements request) =>
JellyfinMediaSourceMustExist(request);
if (items.Any(item => item is null))
{
return BaseError.New("Path replacement items must not be null.");
}
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;
}
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist(
UpdateJellyfinPathReplacements request) =>
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
.Map(v => v.ToValidation<BaseError>(
$"Jellyfin media source {request.JellyfinMediaSourceId} does not exist."));
}
@@ -1,5 +1,4 @@
using System.IO.Abstractions;
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -15,18 +14,15 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
private readonly IFileSystem _fileSystem;
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
public CreateLocalLibraryHandler(
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
IEntityLocker entityLocker,
IFileSystem fileSystem,
IDbContextFactory<TvContext> dbContextFactory)
{
_scannerWorkerChannel = scannerWorkerChannel;
_entityLocker = entityLocker;
_fileSystem = fileSystem;
_dbContextFactory = dbContextFactory;
}
@@ -35,7 +31,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
CancellationToken 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));
}
@@ -48,30 +44,18 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
if (_entityLocker.LockLibrary(localLibrary.Id))
{
try
{
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;
}
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
}
return ProjectToViewModel(localLibrary);
}
private static Task<Validation<BaseError, LocalLibrary>> Validate(
IFileSystem fileSystem,
TvContext dbContext,
CreateLocalLibrary request) =>
MediaSourceMustExist(dbContext, request)
.BindT(localLibrary => NameMustBeValid(request, localLibrary))
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary))
.BindT(localLibrary => NewPathsMustExist(fileSystem, localLibrary));
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary));
private static Task<Validation<BaseError, LocalLibrary>> MediaSourceMustExist(
TvContext dbContext,
@@ -1,5 +1,4 @@
using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -15,28 +14,6 @@ public abstract class LocalLibraryHandlerBase
.Bind(_ => request.NotLongerThan(50)(c => c.Name))
.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(
TvContext dbContext,
LocalLibrary localLibrary,
@@ -79,31 +79,10 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
private static async Task<Validation<BaseError, Parameters>> Validate(
TvContext dbContext,
MoveLocalLibraryPath request,
CancellationToken cancellationToken)
{
Validation<BaseError, Parameters> parameters =
(await LibraryPathMustExist(dbContext, request, cancellationToken),
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);
CancellationToken cancellationToken) =>
(await LibraryPathMustExist(dbContext, request, cancellationToken),
await LocalLibraryMustExist(dbContext, request, cancellationToken))
.Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary));
private static Task<Validation<BaseError, LibraryPath>> LibraryPathMustExist(
TvContext dbContext,
@@ -1,11 +1,3 @@
namespace ErsatzTV.Application.Libraries;
public enum QueueLibraryScanResult
{
Queued,
NotFound,
SyncDisabled,
AlreadyScanning
}
public record QueueLibraryScanByLibraryId(int LibraryId, bool DeepScan = false) : IRequest<QueueLibraryScanResult>;
public record QueueLibraryScanByLibraryId(int LibraryId) : IRequest<bool>;
@@ -17,11 +17,9 @@ public class QueueLibraryScanByLibraryIdHandler(
IEntityLocker locker,
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorker,
ILogger<QueueLibraryScanByLibraryIdHandler> logger)
: IRequestHandler<QueueLibraryScanByLibraryId, QueueLibraryScanResult>
: IRequestHandler<QueueLibraryScanByLibraryId, bool>
{
public async Task<QueueLibraryScanResult> Handle(
QueueLibraryScanByLibraryId request,
CancellationToken cancellationToken)
public async Task<bool> Handle(QueueLibraryScanByLibraryId request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
@@ -42,17 +40,10 @@ public class QueueLibraryScanByLibraryIdHandler(
if (!shouldSyncItems)
{
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
// is already in progress and we own no release.
if (!locker.LockLibrary(library.Id))
{
return QueueLibraryScanResult.AlreadyScanning;
}
try
if (locker.LockLibrary(library.Id))
{
logger.LogDebug("Queued library scan for library id {Id}", library.Id);
@@ -66,7 +57,7 @@ public class QueueLibraryScanByLibraryIdHandler(
new SynchronizePlexLibraries(library.MediaSourceId),
cancellationToken);
await scannerWorker.WriteAsync(
new ForceSynchronizePlexLibraryById(library.Id, request.DeepScan),
new ForceSynchronizePlexLibraryById(library.Id, false),
cancellationToken);
break;
case JellyfinLibrary:
@@ -74,7 +65,7 @@ public class QueueLibraryScanByLibraryIdHandler(
new SynchronizeJellyfinLibraries(library.MediaSourceId),
cancellationToken);
await scannerWorker.WriteAsync(
new ForceSynchronizeJellyfinLibraryById(library.Id, request.DeepScan),
new ForceSynchronizeJellyfinLibraryById(library.Id, false),
cancellationToken);
break;
case EmbyLibrary:
@@ -82,23 +73,15 @@ public class QueueLibraryScanByLibraryIdHandler(
new SynchronizeEmbyLibraries(library.MediaSourceId),
cancellationToken);
await scannerWorker.WriteAsync(
new ForceSynchronizeEmbyLibraryById(library.Id, request.DeepScan),
new ForceSynchronizeEmbyLibraryById(library.Id, false),
cancellationToken);
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;
public enum QueueShowScanResult
{
Queued,
NotFound,
Unsupported,
SyncDisabled,
AlreadyScanning,
ScanFailed
}
public record QueueShowScanByLibraryId(int LibraryId, int ShowId, string ShowTitle, bool DeepScan)
: IRequest<QueueShowScanResult>;
public record QueueShowScanByLibraryId(int LibraryId, int ShowId, string ShowTitle, bool DeepScan) : IRequest<bool>;
@@ -19,9 +19,9 @@ public class QueueShowScanByLibraryIdHandler(
IMediator mediator,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
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);
@@ -42,14 +42,14 @@ public class QueueShowScanByLibraryIdHandler(
if (!shouldSyncItems)
{
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))
{
logger.LogWarning("Library {Id} is already being scanned, cannot scan individual show", library.Id);
return QueueShowScanResult.AlreadyScanning;
return false;
}
logger.LogDebug(
@@ -60,43 +60,41 @@ public class QueueShowScanByLibraryIdHandler(
try
{
QueueShowScanResult outcome;
var success = false;
switch (library)
{
case PlexLibrary:
Either<BaseError, string> plexResult = await mediator.Send(
new SynchronizePlexShowById(library.Id, request.ShowId, request.DeepScan),
cancellationToken);
outcome = plexResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
success = plexResult.IsRight;
break;
case JellyfinLibrary:
Either<BaseError, string> jellyfinResult = await mediator.Send(
new SynchronizeJellyfinShowById(library.Id, request.ShowId, request.DeepScan),
cancellationToken);
outcome = jellyfinResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
success = jellyfinResult.IsRight;
break;
case EmbyLibrary:
Either<BaseError, string> embyResult = await mediator.Send(
new SynchronizeEmbyShowById(library.Id, request.ShowId, request.DeepScan),
cancellationToken);
outcome = embyResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
success = embyResult.IsRight;
break;
case LocalLibrary:
logger.LogWarning("Single show scanning is not supported for local libraries");
outcome = QueueShowScanResult.Unsupported;
break;
default:
logger.LogWarning("Unknown library type for library {Id}", library.Id);
outcome = QueueShowScanResult.Unsupported;
break;
}
if (outcome == QueueShowScanResult.Queued && request.DeepScan)
if (success && request.DeepScan)
{
await workerChannel.WriteAsync(new ExtractEmbeddedShowSubtitles(request.ShowId), cancellationToken);
}
return outcome;
return success;
}
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 ErsatzTV.Application.MediaSources;
using ErsatzTV.Core;
@@ -18,20 +17,17 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
private readonly IFileSystem _fileSystem;
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
private readonly ISearchIndex _searchIndex;
public UpdateLocalLibraryHandler(
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
IEntityLocker entityLocker,
IFileSystem fileSystem,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
{
_scannerWorkerChannel = scannerWorkerChannel;
_entityLocker = entityLocker;
_fileSystem = fileSystem;
_searchIndex = searchIndex;
_dbContextFactory = dbContextFactory;
}
@@ -41,8 +37,7 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Parameters> validation =
await Validate(_fileSystem, dbContext, request, cancellationToken);
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters));
}
@@ -101,17 +96,7 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
if (_entityLocker.LockLibrary(existing.Id))
{
try
{
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;
}
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
}
}
@@ -119,15 +104,13 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
}
private static Task<Validation<BaseError, Parameters>> Validate(
IFileSystem fileSystem,
TvContext dbContext,
UpdateLocalLibrary request,
CancellationToken cancellationToken) =>
LocalLibraryMustExist(dbContext, request, cancellationToken)
.BindT(parameters => NameMustBeValid(request, parameters.Incoming).MapT(_ => parameters))
.BindT(parameters => PathsMustBeValid(dbContext, parameters.Incoming, parameters.Existing.Id)
.MapT(_ => parameters))
.BindT(parameters => NewPathsMustExist(fileSystem, parameters.Incoming).MapT(_ => parameters));
.MapT(_ => parameters));
private static Task<Validation<BaseError, Parameters>> LocalLibraryMustExist(
TvContext dbContext,
@@ -274,8 +274,7 @@ internal static class LibraryBrowseItemMapper
null,
em.EpisodeId,
null,
EpisodeSubtitle(em),
em.Episode.SeasonId)).ToList());
EpisodeSubtitle(em))).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetMusicVideos(
@@ -32,10 +32,6 @@ public class AddEpisodeToPlaylistHandler(IDbContextFactory<TvContext> dbContextF
};
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();
return Unit.Default;
}
@@ -81,15 +81,13 @@ public class AddItemsToCollectionHandler :
if (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(new ReindexMediaItems(toAddIds.ToArray()), CancellationToken.None);
await _searchChannel.WriteAsync(new ReindexMediaItems(toAddIds.ToArray()), cancellationToken);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.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.OtherVideo, request.OtherVideoIds },
{ CollectionType.Song, request.SongIds },
{ CollectionType.Image, request.ImageIds },
{ CollectionType.RemoteStream, request.RemoteStreamIds }
{ CollectionType.Image, request.ImageIds }
};
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);
return Unit.Default;
@@ -85,24 +81,17 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
await ValidateMovies(request),
await ValidateShows(request),
await ValidateSeasons(request),
await ValidateEpisodes(request),
await ValidateMediaItems(dbContext, request, cancellationToken))
.Apply((collection, _, _, _, _, _) => collection);
await ValidateEpisodes(request))
.Apply((collection, _, _, _, _) => collection);
private static async Task<Validation<BaseError, Playlist>> PlaylistMustExist(
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(
TvContext dbContext,
AddItemsToPlaylist request,
CancellationToken cancellationToken)
{
Option<Playlist> maybePlaylist = await dbContext.Playlists
CancellationToken cancellationToken) =>
dbContext.Playlists
.Include(c => c.Items)
.SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId, cancellationToken);
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));
}
.SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Playlist does not exist."));
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToPlaylist request) =>
_movieRepository.AllMoviesExist(request.MovieIds)
@@ -131,30 +120,4 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.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);
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
parameters.Playlist.Version++;
await dbContext.SaveChangesAsync();
return Unit.Default;
}
@@ -32,10 +32,6 @@ public class AddSeasonToPlaylistHandler(IDbContextFactory<TvContext> dbContextFa
};
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();
return Unit.Default;
}
@@ -32,10 +32,6 @@ public class AddShowToPlaylistHandler(IDbContextFactory<TvContext> dbContextFact
};
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();
return Unit.Default;
}
@@ -14,7 +14,7 @@ public class CreateRerunCollectionHandler(IDbContextFactory<TvContext> dbContext
CancellationToken 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));
}
@@ -27,20 +27,10 @@ public class CreateRerunCollectionHandler(IDbContextFactory<TvContext> dbContext
return ProjectToViewModel(collection);
}
private static async Task<Validation<BaseError, RerunCollection>> Validate(
private static Task<Validation<BaseError, RerunCollection>> Validate(
TvContext dbContext,
CreateRerunCollection request,
CancellationToken cancellationToken) =>
(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
CreateRerunCollection request) =>
ValidateName(dbContext, request).MapT(name => new RerunCollection
{
Name = name,
CollectionType = request.CollectionType,
@@ -42,10 +42,7 @@ public class CreateSmartCollectionHandler :
await dbContext.SmartCollections.AddAsync(smartCollection, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
_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 _smartCollectionCache.Refresh(CancellationToken.None);
await _smartCollectionCache.Refresh(cancellationToken);
return ProjectToViewModel(smartCollection);
}
@@ -48,10 +48,7 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
dbContext.SmartCollections.Remove(smartCollection);
await dbContext.SaveChangesAsync(cancellationToken);
_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 _smartCollectionCache.Refresh(CancellationToken.None);
await _smartCollectionCache.Refresh(cancellationToken);
return Unit.Default;
}
@@ -63,16 +63,14 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromC
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(
new ReindexMediaItems(itemsToRemove.Select(mi => mi.Id).ToArray()),
CancellationToken.None);
cancellationToken);
// refresh all playouts that use this collection
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;
public record ReplacePlaylistItems(
int PlaylistId,
string Name,
List<ReplacePlaylistItem> Items,
Option<int> ExpectedVersion = default)
public record ReplacePlaylistItems(int PlaylistId, string Name, List<ReplacePlaylistItem> Items)
: IRequest<Either<BaseError, List<PlaylistItemViewModel>>>;
@@ -15,21 +15,10 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playlist> validation = await Validate(dbContext, request, 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));
return await validation.Apply(ps => Persist(dbContext, request, ps, cancellationToken));
}
private static async Task<Either<BaseError, List<PlaylistItemViewModel>>> Persist(
private static async Task<List<PlaylistItemViewModel>> Persist(
TvContext dbContext,
ReplacePlaylistItems request,
Playlist playlist,
@@ -41,15 +30,9 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
dbContext.RemoveRange(playlist.Items);
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
// 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++;
await dbContext.SaveChangesAsync(cancellationToken);
// Save through the guard so an EF concurrency failure (a racing writer won between our load
// 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());
return playlist.Items.Map(Mapper.ProjectToViewModel).ToList();
}
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(
int CollectionId,
List<MediaItemCustomOrder> MediaItemCustomOrders,
Option<int> ExpectedVersion = default) : IRequest<Either<BaseError, Unit>>;
List<MediaItemCustomOrder> MediaItemCustomOrders) : IRequest<Either<BaseError, Unit>>;
public record MediaItemCustomOrder(int MediaItemId, int CustomIndex);
@@ -32,22 +32,13 @@ public class UpdateCollectionCustomOrderHandler : IRequestHandler<UpdateCollecti
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, 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, 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));
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<Either<BaseError, Unit>> ApplyUpdateRequest(
private async Task<Unit> ApplyUpdateRequest(
TvContext dbContext,
Collection c,
UpdateCollectionCustomOrder request,
CancellationToken cancellationToken)
UpdateCollectionCustomOrder request)
{
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).
c.Version++;
Either<BaseError, Unit> saveResult = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
if (saveResult.IsLeft)
if (await dbContext.SaveChangesAsync() > 0)
{
return saveResult;
}
// 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.
// 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);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
}
}
return Unit.Default;
@@ -59,17 +59,13 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
c.UseCustomPlaybackOrder = useCustomPlaybackOrder;
}
// Force-write past a concurrent Version bump from the custom-order editor (this name/flag writer
// 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)
if (await dbContext.SaveChangesAsync(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
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(
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(
int MultiCollectionId,
string Name,
List<UpdateMultiCollectionItem> Items,
Option<int> ExpectedVersion = default) : IRequest<Either<BaseError, Unit>>;
List<UpdateMultiCollectionItem> Items) : IRequest<Either<BaseError, Unit>>;
@@ -36,18 +36,10 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, 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));
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
}
private async Task<Either<BaseError, Unit>> ApplyUpdateRequest(
private async Task<Unit> ApplyUpdateRequest(
TvContext dbContext,
MultiCollection c,
UpdateMultiCollection request,
@@ -55,16 +47,8 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
{
c.Name = request.Name;
// Bump the version on this first save (issue #253 §7a): it rotates other clients' ETags and,
// via IsConcurrencyToken, closes the load→save race — a stale writer's WHERE Version=@orig hits
// 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;
}
// save name first so playouts don't get rebuilt for a name change
await dbContext.SaveChangesAsync(cancellationToken);
var toAdd = request.Items
.Filter(i => i.CollectionId.HasValue)
@@ -140,12 +124,10 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
_searchTargets.SearchTargetsChanged();
// 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(
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,
NamedMediaItemViewModel MediaItem,
PlaybackOrder FirstRunPlaybackOrder,
PlaybackOrder RerunPlaybackOrder,
Option<int> ExpectedVersion = default)
PlaybackOrder RerunPlaybackOrder)
: IRequest<Either<BaseError, Unit>>;
@@ -22,18 +22,10 @@ public class UpdateRerunCollectionHandler(
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, RerunCollection> validation = await Validate(dbContext, 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));
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
}
private async Task<Either<BaseError, Unit>> ApplyUpdateRequest(
private async Task<Unit> ApplyUpdateRequest(
TvContext dbContext,
RerunCollection c,
UpdateRerunCollection request,
@@ -48,23 +40,15 @@ public class UpdateRerunCollectionHandler(
c.FirstRunPlaybackOrder = request.FirstRunPlaybackOrder;
c.RerunPlaybackOrder = request.RerunPlaybackOrder;
// Unconditional bump (issue #253 §7a / M1) then guarded save, which maps a racing
// DbUpdateConcurrencyException to PreconditionFailedError (→ 412).
c.Version++;
Either<BaseError, Unit> saveResult = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
if (saveResult.IsLeft)
// rebuild playouts
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
{
return saveResult;
}
// 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
// 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);
// refresh all playouts that use this rerun collection
foreach (int playoutId in await mediaCollectionRepository.PlayoutIdsUsingRerunCollection(
request.RerunCollectionId))
{
await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
}
}
return Unit.Default;
@@ -74,17 +58,8 @@ public class UpdateRerunCollectionHandler(
TvContext dbContext,
UpdateRerunCollection request,
CancellationToken cancellationToken) =>
(await RerunCollectionMustExist(dbContext, request, cancellationToken),
await ValidateName(dbContext, request),
await RerunCollectionSelectionValidation.SelectedEntityMustExist(
dbContext,
request.CollectionType,
request.Collection?.Id,
request.MultiCollection?.Id,
request.SmartCollection?.Id,
request.MediaItem?.MediaItemId,
cancellationToken))
.Apply((collectionToUpdate, _, _) => collectionToUpdate);
(await RerunCollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
.Apply((collectionToUpdate, _) => collectionToUpdate);
private static Task<Validation<BaseError, RerunCollection>> RerunCollectionMustExist(
TvContext dbContext,
@@ -69,15 +69,12 @@ public class
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
{
_searchTargets.SearchTargetsChanged();
// 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);
await _smartCollectionCache.Refresh(cancellationToken);
// refresh all playouts that use this smart collection
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())
{
try
{
// 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;
}
await workerChannel.WriteAsync(new MatchTraktListItems(traktList.Id), cancellationToken);
}
}
else if (traktList.PlaylistId is not null)
@@ -12,16 +12,14 @@ internal static class Mapper
collection.Id,
collection.Name,
collection.UseCustomPlaybackOrder,
MediaItemState.Normal,
collection.Version);
MediaItemState.Normal);
internal static MultiCollectionViewModel ProjectToViewModel(MultiCollection multiCollection) =>
new(
multiCollection.Id,
multiCollection.Name,
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList(),
Optional(multiCollection.MultiCollectionSmartItems).Flatten().Map(ProjectToViewModel).ToList(),
multiCollection.Version);
Optional(multiCollection.MultiCollectionSmartItems).Flatten().Map(ProjectToViewModel).ToList());
internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
new(collection.Id, collection.Name, collection.Query);
@@ -51,8 +49,7 @@ internal static class Mapper
_ => null
},
collection.FirstRunPlaybackOrder,
collection.RerunPlaybackOrder,
collection.Version);
collection.RerunPlaybackOrder);
internal static TraktListViewModel ProjectToViewModel(TraktList traktList) =>
new(
@@ -92,7 +89,7 @@ internal static class Mapper
new(playlistGroup.Id, playlistGroup.Name, playlistGroup.Playlists.Count, playlistGroup.IsSystem);
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) =>
new(
@@ -8,10 +8,7 @@ public record MediaCollectionViewModel(
int Id,
string Name,
bool UseCustomPlaybackOrder,
MediaItemState State,
// 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(
MediaItemState State) : MediaCardViewModel(
Id,
Name,
string.Empty,
@@ -4,6 +4,4 @@ public record MultiCollectionViewModel(
int Id,
string Name,
List<MultiCollectionItemViewModel> Items,
List<MultiCollectionSmartItemViewModel> SmartItems,
// Optimistic-concurrency token (issue #253), header-only via ETag; 0 for selection placeholders.
int Version = 0);
List<MultiCollectionSmartItemViewModel> SmartItems);
@@ -1,3 +1,3 @@
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);
@@ -16,58 +16,32 @@ public class GetCollectionItemsHandler(IDbContextFactory<TvContext> dbContextFac
{
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
bool exists = await dbContext.Collections
.AsNoTracking()
.Where(c => c.Id == request.Id)
.Select(c => (bool?)c.UseCustomPlaybackOrder)
.SingleOrDefaultAsync(cancellationToken);
if (useCustomPlaybackOrder is null)
.AnyAsync(c => c.Id == request.Id, cancellationToken);
if (!exists)
{
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
// The collection graph is bounded, so load every member id 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.
List<int> mediaItemIds = await dbContext.CollectionItems
.AsNoTracking()
.Where(ci => ci.CollectionId == request.Id)
.Select(ci => new { ci.MediaItemId, ci.CustomIndex })
.Select(ci => ci.MediaItemId)
.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();
}
// 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.
List<LibraryBrowseItemResponseModel> 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);
@@ -12,6 +12,4 @@ public record RerunCollectionViewModel(
SmartCollectionViewModel SmartCollection,
NamedMediaItemViewModel MediaItem,
PlaybackOrder FirstRunPlaybackOrder,
PlaybackOrder RerunPlaybackOrder,
// Optimistic-concurrency token (issue #253), header-only via ETag.
int Version = 0);
PlaybackOrder RerunPlaybackOrder);
@@ -62,20 +62,9 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
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
{
await _entityLocker.LockPlayout(request.PlayoutId);
if (request.Mode is not PlayoutBuildMode.Reset)
{
// 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.SaveChangesAsync(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);
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken);
if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{
await channel.WriteAsync(
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);
}
@@ -33,18 +33,15 @@ public class CreateSequentialPlayoutHandler(
{
await dbContext.Playouts.AddAsync(playout, cancellationToken);
await dbContext.SaveChangesAsync(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);
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken);
if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{
await channel.WriteAsync(
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);
}
@@ -28,25 +28,20 @@ public class DeletePlayoutHandler(
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);
await dbContext.SaveChangesAsync(cancellationToken);
// post-commit side effects run on CancellationToken.None so a late request cancellation can't
// abort them after the delete committed (#254)
// refresh channel list to remove channel that has no playout
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
// delete channel data from channel guide cache
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{playout.Channel.Number}.xml");
if (fileSystem.File.Exists(cacheFile))
{
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
@@ -2,8 +2,5 @@ using ErsatzTV.Core;
namespace ErsatzTV.Application.Playouts;
public record ReplacePlayoutAlternateScheduleItems(
int PlayoutId,
List<ReplacePlayoutAlternateSchedule> Items,
Option<int> ExpectedVersion = default)
public record ReplacePlayoutAlternateScheduleItems(int PlayoutId, List<ReplacePlayoutAlternateSchedule> Items)
: IRequest<Either<BaseError, Unit>>;
@@ -20,13 +20,7 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
ReplacePlayoutAlternateScheduleItems request,
CancellationToken cancellationToken)
{
// The handler reads the highest-index item as the default schedule (Max() below), so an 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");
}
// TODO: validate that items is not empty
try
{
@@ -40,18 +34,6 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
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 daysToCheck = new List<DateTimeOffset>();
@@ -139,20 +121,7 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
playout.ProgramScheduleId = highest.ProgramScheduleId;
}
// Unconditional bump (issue #253 §7a / M1): EF emits the root UPDATE only when a scalar
// 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;
}
await dbContext.SaveChangesAsync(cancellationToken);
if (hasDefaultScheduleChange)
{
@@ -183,11 +152,9 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
existingValue.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(
new BuildPlayout(request.PlayoutId, PlayoutBuildMode.Refresh),
CancellationToken.None);
cancellationToken);
break;
}
@@ -1,3 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record ResetAllPlayouts : IRequest<ResetAllPlayoutsResult>;
public record ResetAllPlayouts : IRequest;
@@ -11,49 +11,33 @@ public class ResetAllPlayoutsHandler(
IEntityLocker locker,
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ResetAllPlayouts, ResetAllPlayoutsResult>
: IRequestHandler<ResetAllPlayouts>
{
public async Task<ResetAllPlayoutsResult> Handle(
ResetAllPlayouts request,
CancellationToken cancellationToken)
public async Task Handle(ResetAllPlayouts request, CancellationToken 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))
{
switch (playout.ScheduleKind)
{
case PlayoutScheduleKind.Classic:
if (locker.IsPlayoutLocked(playout.Id))
{
skippedLocked.Add(playout.Id);
}
else
if (!locker.IsPlayoutLocked(playout.Id))
{
await channel.WriteAsync(
new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh),
cancellationToken);
queued.Add(playout.Id);
}
break;
case PlayoutScheduleKind.Block:
case PlayoutScheduleKind.Sequential:
case PlayoutScheduleKind.Scripted:
if (locker.IsPlayoutLocked(playout.Id))
{
skippedLocked.Add(playout.Id);
}
else
if (!locker.IsPlayoutLocked(playout.Id))
{
await channel.WriteAsync(
new BuildPlayout(playout.Id, PlayoutBuildMode.Reset),
cancellationToken);
queued.Add(playout.Id);
}
break;
@@ -61,11 +45,8 @@ public class ResetAllPlayoutsHandler(
case PlayoutScheduleKind.None:
default:
// external json cannot be reset
skippedUnsupported.Add(playout.Id);
continue;
}
}
return new ResetAllPlayoutsResult(queued, skippedLocked, skippedUnsupported);
}
}
@@ -40,13 +40,9 @@ public class
{
playout.ScheduleFile = request.ScheduleFile;
// Force-write past a concurrent Version bump from a replace-all editor (schedule-file writer
// doesn't participate in If-Match, so an active token must not 500 a benign race) — #253/#269.
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0)
if (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 _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), CancellationToken.None);
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), cancellationToken);
}
return new PlayoutNameViewModel(
@@ -60,8 +56,7 @@ public class
playout.DailyRebuildTime,
playout.BuildStatus,
playout.DecoId,
playout.Deco?.Name,
playout.Version);
playout.Deco?.Name);
}
private static Task<Validation<BaseError, Playout>> Validate(
@@ -61,10 +61,7 @@ public class UpdateOnDemandCheckpointHandler(
playout.Channel.Name,
playout.OnDemandCheckpoint);
// Force-write past a concurrent Version bump from a replace-all editor: the on-demand
// 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);
await dbContext.SaveChangesAsync(cancellationToken);
}
}
}
@@ -35,9 +35,7 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
playout.DailyRebuildTime = dailyRebuildTime;
}
// Force-write past a concurrent Version bump from a replace-all editor (this settings writer
// doesn't participate in If-Match, so a missing token = force-write, not a 500) — #253/#269.
await dbContext.SaveChangesForcingVersion(cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return new PlayoutNameViewModel(
playout.Id,
@@ -50,8 +48,7 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
playout.DailyRebuildTime,
playout.BuildStatus,
playout.DecoId,
playout.Deco?.Name,
playout.Version);
playout.Deco?.Name);
}
private static Task<Validation<BaseError, Playout>> Validate(
@@ -35,13 +35,9 @@ public class
{
playout.ScheduleFile = request.ScheduleFile;
// Force-write past a concurrent Version bump from a replace-all editor (schedule-file writer
// doesn't participate in If-Match, so an active token must not 500 a benign race) — #253/#269.
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0)
if (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 workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), CancellationToken.None);
await workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), cancellationToken);
}
return new PlayoutNameViewModel(
@@ -55,8 +51,7 @@ public class
playout.DailyRebuildTime,
playout.BuildStatus,
playout.DecoId,
playout.Deco?.Name,
playout.Version);
playout.Deco?.Name);
}
private async Task<Validation<BaseError, Playout>> Validate(
@@ -40,13 +40,9 @@ public class
{
playout.ScheduleFile = request.ScheduleFile;
// Force-write past a concurrent Version bump from a replace-all editor (schedule-file writer
// doesn't participate in If-Match, so an active token must not 500 a benign race) — #253/#269.
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0)
if (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 _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), CancellationToken.None);
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), cancellationToken);
}
return new PlayoutNameViewModel(
@@ -60,8 +56,7 @@ public class
playout.DailyRebuildTime,
playout.BuildStatus,
playout.DecoId,
playout.Deco?.Name,
playout.Version);
playout.Deco?.Name);
}
private static Task<Validation<BaseError, Playout>> Validate(
+1 -3
View File
@@ -19,12 +19,10 @@ internal static class Mapper
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);
playout.Deco?.Name);
internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
new(
playoutItem.Id,
GetDisplayTitle(playoutItem.MediaItem, playoutItem.ChapterTitle),
playoutItem.StartOffset,
playoutItem.FinishOffset,
@@ -3,7 +3,6 @@ using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Playouts;
public record PlayoutItemViewModel(
int? Id,
string Title,
DateTimeOffset Start,
DateTimeOffset Finish,
@@ -13,8 +13,7 @@ public record PlayoutNameViewModel(
TimeSpan? DbDailyRebuildTime,
PlayoutBuildStatus BuildStatus,
int? DecoId,
string DecoName,
int Version)
string DecoName)
{
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);
TimeSpan gapDuration = gap.Finish - gap.Start;
// gaps are synthesized rows, not PlayoutItems, so they carry no row id
return new PlayoutItemViewModel(
null,
"UNSCHEDULED",
gap.StartOffset,
gap.FinishOffset,
@@ -30,7 +30,6 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
p.DailyRebuildTime,
p.BuildStatus,
p.DecoId,
p.DecoId == null ? null : p.Deco.Name,
p.Version));
p.DecoId == null ? null : p.Deco.Name));
}
}
@@ -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)
{
// Terminal handler (no lock handoff): release the Plex lock on EVERY exit via finally so a throw
// from any awaited dependency (repo delete, search-index commit, secret store) cannot wedge Plex
// locked at 409 until restart (#202 §C6 / finding 7). This is the UNCONDITIONAL-finally case —
// contrast the pin-flow handlers, which hand the lock off and must NOT use a blanket finally.
try
{
List<int> ids = await _mediaSourceRepository.DeleteAllPlex();
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
await _plexSecretStore.DeleteAll();
List<int> ids = await _mediaSourceRepository.DeleteAllPlex();
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
await _plexSecretStore.DeleteAll();
_entityLocker.UnlockPlex();
return Unit.Default;
}
finally
{
_entityLocker.UnlockPlex();
}
return Unit.Default;
}
}
@@ -2,5 +2,5 @@ using ErsatzTV.Core;
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;
@@ -7,20 +7,15 @@ public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string
int PlexLibraryId { get; }
bool ForceScan { 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 DeepScan => false;
}
public record ForceSynchronizePlexLibraryById(int PlexLibraryId, bool DeepScan, bool Unlock = true)
: ISynchronizePlexLibraryById
public record ForceSynchronizePlexLibraryById(int PlexLibraryId, bool DeepScan) : ISynchronizePlexLibraryById
{
public bool ForceScan => true;
}
@@ -2,9 +2,5 @@ using ErsatzTV.Core;
namespace ErsatzTV.Application.Plex;
// Unlock defaults true so single-message callers release the library lock as before. In the Plex
// "Shows" scheduler batch this message runs LAST and carries the single release (the preceding
// library message runs with Unlock: false).
public record SynchronizePlexNetworks(int PlexLibraryId, bool ForceScan, bool Unlock = true)
: IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;
public record SynchronizePlexNetworks(int PlexLibraryId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;
@@ -1,6 +1,5 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Plex;
namespace ErsatzTV.Application.Plex;
@@ -8,59 +7,34 @@ namespace ErsatzTV.Application.Plex;
public class TryCompletePlexPinFlowHandler : IRequestHandler<TryCompletePlexPinFlow, Either<BaseError, bool>>
{
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
private readonly IEntityLocker _entityLocker;
private readonly IPlexTvApiClient _plexTvApiClient;
public TryCompletePlexPinFlowHandler(
IPlexTvApiClient plexTvApiClient,
ChannelWriter<IPlexBackgroundServiceRequest> channel,
IEntityLocker entityLocker)
ChannelWriter<IPlexBackgroundServiceRequest> channel)
{
_plexTvApiClient = plexTvApiClient;
_channel = channel;
_entityLocker = entityLocker;
}
public async Task<Either<BaseError, bool>>
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 linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken);
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);
if (result)
{
// 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);
await _channel.WriteAsync(new SynchronizePlexMediaSources(), token);
return true;
}
// effectively unreachable (Task.Delay throws on cancellation before the loop condition is
// 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;
await Task.Delay(TimeSpan.FromSeconds(1), token);
}
return false;
}
}
@@ -35,56 +35,14 @@ public class
private static PlexPathReplacement Project(PlexPathReplacementItem vm) =>
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,
CancellationToken cancellationToken)
{
Option<PlexMediaSource> maybeSource =
await _mediaSourceRepository.GetPlex(request.PlexMediaSourceId, cancellationToken);
CancellationToken cancellationToken) =>
PlexMediaSourceMustExist(request, cancellationToken);
foreach (PlexMediaSource plexMediaSource in maybeSource)
{
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(
private Task<Validation<BaseError, PlexMediaSource>> PlexMediaSourceMustExist(
UpdatePlexPathReplacements request,
PlexMediaSource plexMediaSource)
{
List<PlexPathReplacementItem> items = request.PathReplacements;
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);
}
CancellationToken cancellationToken) =>
_mediaSourceRepository.GetPlex(request.PlexMediaSourceId, cancellationToken)
.Map(v => v.ToValidation<BaseError>($"Plex media source {request.PlexMediaSourceId} does not exist."));
}
@@ -52,28 +52,15 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
ProgramScheduleItem item = BuildItem(programSchedule, nextIndex, request);
programSchedule.Items.Add(item);
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253)
programSchedule.Version++;
await dbContext.SaveChangesAsync(cancellationToken);
// refresh any playouts that use this schedule
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
foreach (Playout playout in programSchedule.Playouts)
{
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), CancellationToken.None);
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
}
// reload with the full navigation graph before projecting: BuildItem creates the watermark/graphics
// join rows with only their foreign-key ids set, so the tracked entities have null Watermark /
// GraphicsElement navs that ProjectToViewModel dereferences (would 500 on POST — see #229).
ProgramScheduleItem persisted = await dbContext.ProgramScheduleItems
.Filter(psi => psi.Id == item.Id)
.IncludeScheduleItemDetails()
.SingleAsync(cancellationToken);
return ProjectToViewModel(persisted);
return ProjectToViewModel(item);
}
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
@@ -34,17 +34,11 @@ public class DeleteProgramScheduleItemHandler(
List<Playout> playouts = item.ProgramSchedule.Playouts;
dbContext.ProgramScheduleItems.Remove(item);
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253)
item.ProgramSchedule.Version++;
await dbContext.SaveChangesAsync(cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
foreach (Playout playout in playouts)
{
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), CancellationToken.None);
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
}
return Unit.Default;
@@ -44,8 +44,5 @@ public record ReplaceProgramScheduleItem(
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode? SubtitleMode) : IProgramScheduleItemRequest;
public record ReplaceProgramScheduleItems(
int ProgramScheduleId,
List<ReplaceProgramScheduleItem> Items,
Option<int> ExpectedVersion = default) : IRequest<
public record ReplaceProgramScheduleItems(int ProgramScheduleId, List<ReplaceProgramScheduleItem> Items) : IRequest<
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>;
@@ -26,145 +26,37 @@ public class ReplaceProgramScheduleItemsHandler(
Some: async programSchedule =>
{
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, programSchedule);
// 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).
Either<BaseError, ProgramSchedule> validated = LanguageExtensions.ToEither(validation)
.Bind(ps => ps.CheckVersion(request.ExpectedVersion));
return await validated.Match(
Right: ps => PersistItems(dbContext, request, ps, cancellationToken),
Left: error =>
Task.FromResult<Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>(error));
return await validation.Apply(ps => PersistItems(dbContext, request, ps, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>(
new NotFoundError("[ProgramScheduleId] does not exist.")));
}
private async Task<Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>> PersistItems(
private async Task<IEnumerable<ProgramScheduleItemViewModel>> PersistItems(
TvContext dbContext,
ReplaceProgramScheduleItems request,
ProgramSchedule programSchedule,
CancellationToken cancellationToken)
{
// Positional in-place reconcile (rather than delete-and-reinsert): a schedule item owns the
// persisted fill-group/shuffle enumerator state via PlayoutScheduleItemFillGroupIndex, whose
// ProgramScheduleItemId FK is OnDelete(Cascade). Removing and re-inserting every item — as the
// original handler did on every save, including a no-op PUT-back — cascade-deleted that state for
// all playouts using the schedule (#252). Reusing the existing item row for the same-typed slot
// keeps its id, so the cascade never fires and progression survives. The request DTO carries no
// stable item id, so position is the only key available here; true content-aware stable identity
// is deferred to the shared concurrency/round-trip contract in #253.
dbContext.RemoveRange(programSchedule.Items);
// reset index starting with zero
programSchedule.Items = [];
var orderedItems = request.Items.OrderBy(i => i.Index).ToList();
List<ProgramScheduleItem> existingItems = programSchedule.Items.OrderBy(i => i.Index).ToList();
// load the watermark/graphics join rows for the existing items so they can be rebuilt in place
await dbContext.Entry(programSchedule)
.Collection(ps => ps.Items)
.Query()
.Include(i => i.ProgramScheduleItemWatermarks)
.Include(i => i.ProgramScheduleItemGraphicsElements)
.LoadAsync(cancellationToken);
int commonCount = Math.Min(existingItems.Count, orderedItems.Count);
for (var i = 0; i < commonCount; i++)
{
ProgramScheduleItem existing = existingItems[i];
ProgramScheduleItem built = BuildItem(programSchedule, i, orderedItems[i]);
if (existing.GetType() == built.GetType())
{
// same TPT subtype: copy all scalar values in place (BuildItem is the single source of
// item construction, so no field is silently dropped) and rebuild the join rows, keeping
// the item's id — and with it the fill-group index that would otherwise cascade away.
built.Id = existing.Id;
dbContext.Entry(existing).CurrentValues.SetValues(built);
RebuildChildren(existing, orderedItems[i]);
}
else
{
// EF can't change a TPT row's type in place; this slot must be replaced (its fill-group
// index resets, which is acceptable — the item fundamentally changed).
dbContext.Remove(existing);
programSchedule.Items.Add(built);
}
}
// remove surplus existing items
for (int i = commonCount; i < existingItems.Count; i++)
{
dbContext.Remove(existingItems[i]);
}
// add surplus incoming items
for (int i = commonCount; i < orderedItems.Count; i++)
for (var i = 0; i < orderedItems.Count; i++)
{
programSchedule.Items.Add(BuildItem(programSchedule, i, orderedItems[i]));
}
// Unconditional bump: this handler frequently saves with only CHILD changes and no root-scalar
// change, so without an explicit bump EF would emit no root UPDATE and the concurrency token
// would never fire (nor rotate other clients' ETags). Bumping guarantees both on every save,
// including a no-op same-items PUT-back (issue #253 / api-conventions §7a).
programSchedule.Version++;
// Save through the guard so an EF concurrency failure (a racing writer won between our load and
// save) maps to 412 rather than surfacing as a 500. On failure, propagate the error WITHOUT
// running the post-save reload/enqueue below.
Either<BaseError, Unit> saved = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
if (saved.IsLeft)
{
return saved.Map(_ => (IEnumerable<ProgramScheduleItemViewModel>)[]);
}
await dbContext.SaveChangesAsync(cancellationToken);
// refresh any playouts that use this schedule
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
foreach (Playout playout in programSchedule.Playouts)
{
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), CancellationToken.None);
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
}
// reload with the full navigation graph before projecting: BuildItem creates the watermark/graphics
// join rows with only their foreign-key ids set, so the tracked entities have null Watermark /
// GraphicsElement navs that ProjectToViewModel dereferences (would 500 on PUT — see #229).
List<ProgramScheduleItem> persisted = await dbContext.ProgramScheduleItems
.Filter(psi => psi.ProgramScheduleId == programSchedule.Id)
.IncludeScheduleItemDetails()
.OrderBy(i => i.Index)
.ToListAsync(cancellationToken);
return persisted.Map(ProjectToViewModel).ToList();
}
// Rebuild the watermark/graphics join rows for an item updated in place. Clearing severs the required
// relationship so EF deletes the orphaned join rows (same net effect the delete-and-reinsert path had),
// then re-add from the request. Only the parent item row is preserved — the join rows carry no state.
private static void RebuildChildren(ProgramScheduleItem item, ReplaceProgramScheduleItem request)
{
item.ProgramScheduleItemWatermarks ??= [];
item.ProgramScheduleItemWatermarks.Clear();
foreach (int watermarkId in request.WatermarkIds)
{
item.ProgramScheduleItemWatermarks.Add(
new ProgramScheduleItemWatermark
{
ProgramScheduleItem = item,
WatermarkId = watermarkId
});
}
item.ProgramScheduleItemGraphicsElements ??= [];
item.ProgramScheduleItemGraphicsElements.Clear();
foreach (int graphicsElementId in request.GraphicsElementIds)
{
item.ProgramScheduleItemGraphicsElements.Add(
new ProgramScheduleItemGraphicsElement
{
ProgramScheduleItem = item,
GraphicsElementId = graphicsElementId
});
}
return programSchedule.Items.Map(ProjectToViewModel);
}
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
@@ -54,9 +54,6 @@ public class UpdateProgramScheduleHandler(
programSchedule.RandomStartPoint = request.RandomStartPoint;
programSchedule.FixedStartTimeBehavior = request.FixedStartTimeBehavior;
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253)
programSchedule.Version++;
await dbContext.SaveChangesAsync();
if (needToRefreshPlayout)
@@ -12,8 +12,7 @@ internal static class Mapper
programSchedule.TreatCollectionsAsShows,
programSchedule.ShuffleScheduleItems,
programSchedule.RandomStartPoint,
programSchedule.FixedStartTimeBehavior,
programSchedule.Version);
programSchedule.FixedStartTimeBehavior);
internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) =>
programScheduleItem switch
@@ -1,48 +0,0 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ProgramSchedules;
internal static class ProgramScheduleItemQueryExtensions
{
/// <summary>
/// The single source of truth for the navigation graph a <see cref="ProgramScheduleItem" /> needs before
/// it can be projected via <see cref="Mapper.ProjectToViewModel" />. The mapper dereferences the
/// <c>Watermark</c> / <c>GraphicsElement</c> navs of each join row without a null guard, so any handler
/// that projects freshly-persisted items MUST reload through this include chain (see #229 — the write
/// path used to project the tracked-but-unloaded graph and threw a NullReferenceException, surfacing as a
/// 500 on PUT/POST whenever an item carried a watermark or graphics element).
/// </summary>
public static IQueryable<ProgramScheduleItem> IncludeScheduleItemDetails(this IQueryable<ProgramScheduleItem> query) =>
query
.Include(i => i.Collection)
.Include(i => i.MultiCollection)
.Include(i => i.SmartCollection)
.Include(i => i.RerunCollection)
.Include(i => i.Playlist)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Season).SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Season).Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Show).ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Artist).ArtistMetadata)
.ThenInclude(am => am.Artwork)
.Include(i => i.PreRollFiller)
.Include(i => i.MidRollFiller)
.Include(i => i.PostRollFiller)
.Include(i => i.TailFiller)
.Include(i => i.FallbackFiller)
.Include(i => i.ProgramScheduleItemWatermarks)
.ThenInclude(i => i.Watermark)
.Include(i => i.ProgramScheduleItemGraphicsElements)
.ThenInclude(i => i.GraphicsElement);
}
@@ -9,5 +9,4 @@ public record ProgramScheduleViewModel(
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior,
int Version);
FixedStartTimeBehavior FixedStartTimeBehavior);
@@ -19,8 +19,7 @@ public class GetAllProgramSchedulesHandler(IDbContextFactory<TvContext> dbContex
ps.TreatCollectionsAsShows,
ps.ShuffleScheduleItems,
ps.RandomStartPoint,
ps.FixedStartTimeBehavior,
ps.Version))
ps.FixedStartTimeBehavior))
.ToListAsync(cancellationToken);
}
}
@@ -20,8 +20,36 @@ public class GetProgramScheduleItemsHandler(IDbContextFactory<TvContext> dbConte
return await dbContext.ProgramScheduleItems
.Filter(psi => psi.ProgramScheduleId == request.Id)
.IncludeScheduleItemDetails()
.OrderBy(i => i.Index)
.Include(i => i.Collection)
.Include(i => i.MultiCollection)
.Include(i => i.SmartCollection)
.Include(i => i.RerunCollection)
.Include(i => i.Playlist)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Season).SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Season).Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Show).ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Artist).ArtistMetadata)
.ThenInclude(am => am.Artwork)
.Include(i => i.PreRollFiller)
.Include(i => i.MidRollFiller)
.Include(i => i.PostRollFiller)
.Include(i => i.TailFiller)
.Include(i => i.FallbackFiller)
.Include(i => i.ProgramScheduleItemWatermarks)
.ThenInclude(i => i.Watermark)
.Include(i => i.ProgramScheduleItemGraphicsElements)
.ThenInclude(i => i.GraphicsElement)
.ToListAsync(cancellationToken)
.Map(programScheduleItems => programScheduleItems.Map(ProjectToViewModel)
.Map(psi => EnforceProperties(maybeProgramSchedule, psi)).ToList());
@@ -1,109 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Core.Api;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ProgramSchedules;
/// <summary>
/// Maps the polymorphic <see cref="ProgramScheduleItemViewModel" /> hierarchy to the flat,
/// fully-described <see cref="ScheduleItemResponseModel" /> API DTO (issue #126). Subtype-specific
/// fields (Multiple's mode/count, Duration's playoutDuration/tailMode/discardToFillAttempts) are
/// read by pattern-matching the concrete VM type; they are null for other subtypes.
/// </summary>
public static class ScheduleItemResponseMapper
{
public static ScheduleItemResponseModel ProjectToResponseModel(ProgramScheduleItemViewModel item)
{
MultipleMode? multipleMode = null;
string multipleCount = null;
TimeSpan? playoutDuration = null;
TailMode? tailMode = null;
int? discardToFillAttempts = null;
switch (item)
{
case ProgramScheduleItemMultipleViewModel multiple:
multipleMode = multiple.MultipleMode;
multipleCount = multiple.Count;
break;
case ProgramScheduleItemDurationViewModel duration:
playoutDuration = duration.PlayoutDuration;
tailMode = duration.TailMode;
discardToFillAttempts = duration.DiscardToFillAttempts;
break;
}
List<int> watermarkIds = item.Watermarks?.Map(w => w.Id).ToList() ?? new List<int>();
List<int> graphicsElementIds = item.GraphicsElements?.Map(g => g.Id).ToList() ?? new List<int>();
List<NamedIdResponseModel> watermarks =
item.Watermarks?.Map(w => new NamedIdResponseModel(w.Id, w.Name)).ToList()
?? new List<NamedIdResponseModel>();
List<NamedIdResponseModel> graphicsElements =
item.GraphicsElements?.Map(g => new NamedIdResponseModel(g.Id, g.Name)).ToList()
?? new List<NamedIdResponseModel>();
return new ScheduleItemResponseModel(
item.Id,
item.Index,
item.StartType,
item.StartTime,
item.FixedStartTimeBehavior,
item.PlayoutMode,
item.CollectionType,
item.Collection?.Id,
item.MultiCollection?.Id,
item.SmartCollection?.Id,
item.RerunCollection?.Id,
item.MediaItem?.MediaItemId,
item.Playlist?.Id,
item.SearchTitle,
item.SearchQuery,
item.PlaybackOrder,
item.MarathonGroupBy,
item.MarathonShuffleGroups,
item.MarathonShuffleItems,
item.MarathonBatchSize,
item.FillWithGroupMode,
multipleMode,
multipleCount,
playoutDuration,
tailMode,
discardToFillAttempts,
item.CustomTitle,
item.GuideMode,
item.PreRollFiller?.Id,
item.MidRollFiller?.Id,
item.PostRollFiller?.Id,
item.TailFiller?.Id,
item.FallbackFiller?.Id,
watermarkIds,
graphicsElementIds,
item.PreferredAudioLanguageCode,
item.PreferredAudioTitle,
item.PreferredSubtitleLanguageCode,
item.SubtitleMode,
item.Collection?.Name,
item.MultiCollection?.Name,
item.SmartCollection?.Name,
item.RerunCollection?.Name,
item.Playlist?.Name,
item.Playlist?.PlaylistGroupId,
item.MediaItem?.Name,
item.PreRollFiller?.Name,
item.MidRollFiller?.Name,
item.PostRollFiller?.Name,
item.TailFiller?.Name,
item.FallbackFiller?.Name,
watermarks,
graphicsElements,
item.Name,
item.DurationEstimate);
}
public static ScheduleItemsResponseModel ProjectToResponseModel(ProgramScheduleItemsWithDurationViewModel vm) =>
new(
vm.Items.Map(ProjectToResponseModel).ToList(),
vm.TotalDurationEstimate);
}
@@ -8,5 +8,4 @@ public record BlockViewModel(
string GroupName,
string Name,
int Minutes,
BlockStopScheduling StopScheduling,
int Version);
BlockStopScheduling StopScheduling);
@@ -9,6 +9,5 @@ public record ReplaceBlockItems(
string Name,
int Minutes,
BlockStopScheduling StopScheduling,
List<ReplaceBlockItem> Items,
Option<int> ExpectedVersion = default)
List<ReplaceBlockItem> Items)
: IRequest<Either<BaseError, Unit>>;

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