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