Compare commits

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

36 tests (was 34).

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

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

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

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

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

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

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

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

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

Refs #289 #197 #58

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:08:07 +02:00
timothy d1c04030af chore: retrigger CI (MySQL service port collision with concurrent run)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-07 11:58:29 +02:00
timothy 945d108334 feat(mcp): add read-only API server foundation refs #58
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-07 11:44:44 +02:00
310 changed files with 2414 additions and 43523 deletions
-37
View File
@@ -280,40 +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
# On a prod release (v* tag), auto-bump the pinned ersatztv image tag in the
# server-management media-servers compose and push. The existing per-stack
# Gitea->Komodo webhook then redeploys prod, and because the ersatztv service
# block changed, the #553 pre-deploy hook takes an ErsatzTV PBS backup first.
# This automates the documented "current practice" bump (docs/Docker/ErsatzTV.md)
# while keeping prod pinned + backed up (NOT a floating :prod / watchtower gate).
bump-prod-compose:
name: Bump prod compose tag (server-management)
runs-on: ubuntu-latest
needs: [build]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Bump ersatztv prod tag in media-servers compose
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.SERVERMGMT_DEPLOY_KEY }}" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
ssh-keyscan -p 22 192.168.1.95 >> ~/.ssh/known_hosts 2>/dev/null
export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_deploy -o IdentitiesOnly=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
rm -rf /tmp/svrmgmt
git clone --depth 1 ssh://gitea@192.168.1.95:22/timothy/server-management.git /tmp/svrmgmt
cd /tmp/svrmgmt
FILE="docker/bumblebee/stacks/media-servers/compose.yaml"
sed -i -E "s|(image: 192\.168\.1\.95:3000/timothy/ersatztv:)[^[:space:]]+|\1${VERSION}|" "$FILE"
if git diff --quiet "$FILE"; then
echo "media-servers compose already pins ersatztv:${VERSION}; nothing to do"
exit 0
fi
git config user.name "ersatztv-ci"
git config user.email "ci@tblindustries.be"
git add "$FILE"
git commit -m "chore(ersatztv): bump prod chicorytv to ${VERSION} [ci auto-deploy]"
git push origin HEAD:master
echo "Pushed prod bump -> ersatztv:${VERSION}; media-servers webhook will deploy with pre-deploy backup."
+1 -2
View File
@@ -5,7 +5,7 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
## Architecture ## Architecture
- **Language**: C# / .NET 10 - **Language**: C# / .NET 10
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the default UI; root `/` and migrated legacy routes 302 there (`ErsatzTV/LegacyUiRedirects.cs`). The legacy Blazor Server UI (MudBlazor) still serves the remaining un-migrated admin screens — playback troubleshooting, multi/rerun collections, and playlist editing depth; Blazor home = `/system/health`, reachable via the Settings → System "Classic UI" link. Media detail pages + image folder browser landed in the SPA via #141 (PR #183); its removal is #91 phase (b), gated on #145 (playback troubleshooting) and API gaps #151/#152/#153/#155 (scheduling parity #144/#162 DONE 2026-07-07: blocks/templates/decos/deco-templates/playout editors all in the SPA; #141/#158/#161/#180 also DONE) - **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the default UI; root `/` and migrated legacy routes 302 there (`ErsatzTV/LegacyUiRedirects.cs`). The legacy Blazor Server UI (MudBlazor) still serves un-migrated admin screens (collections, media browse, trakt, filler, watermarks, ffmpeg profiles, blocks/decos/templates, playout editors, logs, troubleshooting; Blazor home = `/system/health`); its removal is #91 phase (b), gated on parity issues #140#147
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/` - **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs` - **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation - **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
@@ -56,7 +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.
- **Convention docs replace re-recon**: before API/SPA/E2E/parity work, read `docs/README.md` (index) → `docs/api-conventions.md`, `docs/spa-conventions.md`, `docs/e2e-local.md`, `docs/domain-model.md`, `docs/blazor-route-parity.md`, `docs/decisions.md`. Any PR that changes a convention, migrates a route, or reverses a decision MUST update the relevant doc in the same PR.
- Follow existing MediatR CQRS pattern for new features - Follow existing MediatR CQRS pattern for new features
- Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure` - Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure`
- Keep UI thin: the SPA talks to `/api/*` only; legacy Blazor pages delegate to MediatR handlers. New screens go in the SPA (`web/`), never in Blazor - Keep UI thin: the SPA talks to `/api/*` only; legacy Blazor pages delegate to MediatR handlers. New screens go in the SPA (`web/`), never in Blazor
+4 -4
View File
@@ -6,18 +6,18 @@
<ItemGroup> <ItemGroup>
<PackageVersion Include="AsyncFixer" Version="2.1.0" /> <PackageVersion Include="AsyncFixer" Version="2.1.0" />
<PackageVersion Include="Blazored.FluentValidation" Version="2.2.0" /> <PackageVersion Include="Blazored.FluentValidation" Version="2.2.0" />
<PackageVersion Include="BlazorSortable" Version="6.0.2" /> <PackageVersion Include="BlazorSortable" Version="5.2.1" />
<PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" /> <PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" />
<PackageVersion Include="Chronic.Core" Version="0.4.0" /> <PackageVersion Include="Chronic.Core" Version="0.4.0" />
<PackageVersion Include="CliWrap" Version="3.10.2" /> <PackageVersion Include="CliWrap" Version="3.10.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" /> <PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Dapper" Version="2.1.79" /> <PackageVersion Include="Dapper" Version="2.1.66" />
<PackageVersion Include="Destructurama.Attributed" Version="5.2.0" /> <PackageVersion Include="Destructurama.Attributed" Version="5.2.0" />
<PackageVersion Include="EFCore.BulkExtensions" Version="[9.0.2,10)" /> <PackageVersion Include="EFCore.BulkExtensions" Version="[9.0.2,10)" />
<PackageVersion Include="EFCore.BulkExtensions.MySql" Version="[9.0.2,10)" /> <PackageVersion Include="EFCore.BulkExtensions.MySql" Version="[9.0.2,10)" />
<PackageVersion Include="EFCore.BulkExtensions.Sqlite" Version="[9.0.2,10)" /> <PackageVersion Include="EFCore.BulkExtensions.Sqlite" Version="[9.0.2,10)" />
<PackageVersion Include="Elastic.Clients.Elasticsearch" Version="9.3.0" /> <PackageVersion Include="Elastic.Clients.Elasticsearch" Version="9.3.0" />
<PackageVersion Include="EntityFrameworkProfiler.Appender" Version="6.0.6053" /> <PackageVersion Include="EntityFrameworkProfiler.Appender" Version="6.0.6049" />
<PackageVersion Include="FluentValidation" Version="12.1.1" /> <PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" /> <PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageVersion Include="Flurl" Version="4.0.0" /> <PackageVersion Include="Flurl" Version="4.0.0" />
@@ -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,
-19
View File
@@ -8,25 +8,6 @@ internal static class Mapper
internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) => internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) =>
new(fillerPreset.Id, fillerPreset.Name); 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(
fillerPreset.Id, fillerPreset.Id,
@@ -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);
}
}
@@ -1,620 +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))).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)
@@ -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,56 +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);
bool exists = await dbContext.Collections
.AsNoTracking()
.AnyAsync(c => c.Id == request.Id, cancellationToken);
if (!exists)
{
return new NotFoundError($"Collection {request.Id} does not exist.");
}
// The collection graph is bounded, so load every member id and hydrate them in one shared
// pass (LibraryBrowseItemMapper), then order + page in-memory. Mixed media kinds are supported
// because MediaItem ids are globally unique across kinds.
List<int> mediaItemIds = await dbContext.CollectionItems
.AsNoTracking()
.Where(ci => ci.CollectionId == request.Id)
.Select(ci => ci.MediaItemId)
.ToListAsync(cancellationToken);
List<LibraryBrowseItemResponseModel> all =
await LibraryBrowseItemMapper.HydrateMediaItemsByIds(dbContext, mediaItemIds, cancellationToken);
// Stable title ordering mirrors the library-browse handler (which orders its rows by name),
// giving the SPA a deterministic, browsable list independent of collection insertion order.
List<LibraryBrowseItemResponseModel> ordered = all
.OrderBy(i => i.Title, StringComparer.OrdinalIgnoreCase)
.ThenBy(i => i.Id)
.ToList();
int pageNum = Math.Max(0, request.PageNum);
int pageSize = Math.Clamp(request.PageSize, 1, 100);
List<LibraryBrowseItemResponseModel> page = ordered
.Skip(pageNum * pageSize)
.Take(pageSize)
.ToList();
return new PagedLibraryBrowseItemsResponseModel(ordered.Count, page);
}
}
@@ -54,9 +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);
} }
private static Task<Validation<BaseError, Playout>> Validate( private static Task<Validation<BaseError, Playout>> Validate(
@@ -46,9 +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);
} }
private static Task<Validation<BaseError, Playout>> Validate( private static Task<Validation<BaseError, Playout>> Validate(
@@ -49,9 +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);
} }
private async Task<Validation<BaseError, Playout>> Validate( private async Task<Validation<BaseError, Playout>> Validate(
@@ -54,9 +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);
} }
private static Task<Validation<BaseError, Playout>> Validate( private static Task<Validation<BaseError, Playout>> Validate(
+1 -5
View File
@@ -15,11 +15,7 @@ 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);
internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) => internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
new( new(
@@ -11,9 +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)
{ {
public Option<TimeSpan> DailyRebuildTime => Optional(DbDailyRebuildTime); public Option<TimeSpan> DailyRebuildTime => Optional(DbDailyRebuildTime);
@@ -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,8 +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));
} }
} }
@@ -25,29 +25,13 @@ public class CreateBlockHandler(IDbContextFactory<TvContext> dbContextFactory)
return Mapper.ProjectToViewModel(block); return Mapper.ProjectToViewModel(block);
} }
private static async Task<Validation<BaseError, Block>> Validate(TvContext dbContext, CreateBlock request) private static async Task<Validation<BaseError, Block>> Validate(TvContext dbContext, CreateBlock request) =>
{ await ValidateBlockName(dbContext, request).MapT(name => new Block
Validation<BaseError, Unit> blockGroupValidation = await ValidateBlockGroupExists(dbContext, request);
Validation<BaseError, string> nameValidation = await ValidateBlockName(dbContext, request);
return (blockGroupValidation, nameValidation).Apply((_, name) => new Block
{ {
BlockGroupId = request.BlockGroupId, BlockGroupId = request.BlockGroupId,
Name = name, Name = name,
Minutes = 30 Minutes = 30
}); });
}
private static async Task<Validation<BaseError, Unit>> ValidateBlockGroupExists(
TvContext dbContext,
CreateBlock request)
{
bool blockGroupExists = await dbContext.BlockGroups.AnyAsync(bg => bg.Id == request.BlockGroupId);
return blockGroupExists
? Success<BaseError, Unit>(Unit.Default)
: BaseError.New("Block group does not exist");
}
private static async Task<Validation<BaseError, string>> ValidateBlockName( private static async Task<Validation<BaseError, string>> ValidateBlockName(
TvContext dbContext, TvContext dbContext,
@@ -25,12 +25,8 @@ public class CreateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
return Mapper.ProjectToViewModel(deco); return Mapper.ProjectToViewModel(deco);
} }
private static async Task<Validation<BaseError, Deco>> Validate(TvContext dbContext, CreateDeco request) private static async Task<Validation<BaseError, Deco>> Validate(TvContext dbContext, CreateDeco request) =>
{ await ValidateDecoName(dbContext, request).MapT(name => new Deco
Validation<BaseError, Unit> decoGroupValidation = await ValidateDecoGroupExists(dbContext, request);
Validation<BaseError, string> nameValidation = await ValidateDecoName(dbContext, request);
return (decoGroupValidation, nameValidation).Apply((_, name) => new Deco
{ {
DecoGroupId = request.DecoGroupId, DecoGroupId = request.DecoGroupId,
Name = name, Name = name,
@@ -38,18 +34,6 @@ public class CreateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
DecoWatermarks = [], DecoWatermarks = [],
DecoGraphicsElements = [] DecoGraphicsElements = []
}); });
}
private static async Task<Validation<BaseError, Unit>> ValidateDecoGroupExists(
TvContext dbContext,
CreateDeco request)
{
bool decoGroupExists = await dbContext.DecoGroups.AnyAsync(dg => dg.Id == request.DecoGroupId);
return decoGroupExists
? Success<BaseError, Unit>(Unit.Default)
: BaseError.New("Deco group does not exist");
}
private static async Task<Validation<BaseError, string>> ValidateDecoName( private static async Task<Validation<BaseError, string>> ValidateDecoName(
TvContext dbContext, TvContext dbContext,
@@ -27,30 +27,12 @@ public class CreateDecoTemplateHandler(IDbContextFactory<TvContext> dbContextFac
private static async Task<Validation<BaseError, DecoTemplate>> Validate( private static async Task<Validation<BaseError, DecoTemplate>> Validate(
TvContext dbContext, TvContext dbContext,
CreateDecoTemplate request) CreateDecoTemplate request) =>
{ await ValidateDecoTemplateName(dbContext, request).MapT(name => new DecoTemplate
Validation<BaseError, Unit> decoTemplateGroupValidation =
await ValidateDecoTemplateGroupExists(dbContext, request);
Validation<BaseError, string> nameValidation = await ValidateDecoTemplateName(dbContext, request);
return (decoTemplateGroupValidation, nameValidation).Apply((_, name) => new DecoTemplate
{ {
DecoTemplateGroupId = request.DecoTemplateGroupId, DecoTemplateGroupId = request.DecoTemplateGroupId,
Name = name Name = name
}); });
}
private static async Task<Validation<BaseError, Unit>> ValidateDecoTemplateGroupExists(
TvContext dbContext,
CreateDecoTemplate request)
{
bool decoTemplateGroupExists =
await dbContext.DecoTemplateGroups.AnyAsync(dtg => dtg.Id == request.DecoTemplateGroupId);
return decoTemplateGroupExists
? Success<BaseError, Unit>(Unit.Default)
: BaseError.New("Deco template group does not exist");
}
private static async Task<Validation<BaseError, string>> ValidateDecoTemplateName( private static async Task<Validation<BaseError, string>> ValidateDecoTemplateName(
TvContext dbContext, TvContext dbContext,
@@ -25,28 +25,12 @@ public class CreateTemplateHandler(IDbContextFactory<TvContext> dbContextFactory
return Mapper.ProjectToViewModel(template); return Mapper.ProjectToViewModel(template);
} }
private static async Task<Validation<BaseError, Template>> Validate(TvContext dbContext, CreateTemplate request) private static async Task<Validation<BaseError, Template>> Validate(TvContext dbContext, CreateTemplate request) =>
{ await ValidateTemplateName(dbContext, request).MapT(name => new Template
Validation<BaseError, Unit> templateGroupValidation = await ValidateTemplateGroupExists(dbContext, request);
Validation<BaseError, string> nameValidation = await ValidateTemplateName(dbContext, request);
return (templateGroupValidation, nameValidation).Apply((_, name) => new Template
{ {
TemplateGroupId = request.TemplateGroupId, TemplateGroupId = request.TemplateGroupId,
Name = name Name = name
}); });
}
private static async Task<Validation<BaseError, Unit>> ValidateTemplateGroupExists(
TvContext dbContext,
CreateTemplate request)
{
bool templateGroupExists = await dbContext.TemplateGroups.AnyAsync(tg => tg.Id == request.TemplateGroupId);
return templateGroupExists
? Success<BaseError, Unit>(Unit.Default)
: BaseError.New("Template group does not exist");
}
private static async Task<Validation<BaseError, string>> ValidateTemplateName( private static async Task<Validation<BaseError, string>> ValidateTemplateName(
TvContext dbContext, TvContext dbContext,
@@ -29,7 +29,11 @@ public class ReplaceDecoTemplateItemsHandler(IDbContextFactory<TvContext> dbCont
dbContext.RemoveRange(decoTemplate.Items); dbContext.RemoveRange(decoTemplate.Items);
decoTemplate.Items = request.Items.Map(i => BuildItem(decoTemplate, i)).ToList(); // drop items that are invalid
decoTemplate.Items = request.Items
.Map(i => BuildItem(decoTemplate, i))
.Filter(i => i.StartTime < i.EndTime || i.EndTime == TimeSpan.Zero)
.ToList();
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
@@ -62,78 +66,7 @@ public class ReplaceDecoTemplateItemsHandler(IDbContextFactory<TvContext> dbCont
ReplaceDecoTemplateItems request, ReplaceDecoTemplateItems request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
DecoTemplateMustExist(dbContext, request.DecoTemplateId, cancellationToken) DecoTemplateMustExist(dbContext, request.DecoTemplateId, cancellationToken)
.BindT(decoTemplate => DecoTemplateNameMustBeValid(dbContext, decoTemplate, request)) .BindT(decoTemplate => DecoTemplateNameMustBeValid(dbContext, decoTemplate, request));
.BindT(decoTemplate => DecoTemplateItemsMustBeValid(dbContext, decoTemplate, request));
// Hardening (deliberate deviation from the original handler): rather than silently filtering out
// invalid items (unknown DecoId, StartTime >= EndTime, or overlapping ranges) - the same silent-drop
// and silent-overlap bug class fixed for ReplaceTemplateItemsHandler in #144 S2 - reject the whole
// request with a 422 so the caller knows exactly what is wrong. EndTime == TimeSpan.Zero means
// "end of day" (24:00) and is treated as such for both the ordering and overlap checks.
private static async Task<Validation<BaseError, DecoTemplate>> DecoTemplateItemsMustBeValid(
TvContext dbContext,
DecoTemplate decoTemplate,
ReplaceDecoTemplateItems request)
{
var allDecoIds = request.Items.Map(i => i.DecoId).Distinct().ToList();
Dictionary<int, Deco> allDecos = await dbContext.Decos
.AsNoTracking()
.Filter(d => allDecoIds.Contains(d.Id))
.ToListAsync()
.Map(list => list.ToDictionary(d => d.Id, d => d));
var missingDecoIds = allDecoIds.Filter(id => !allDecos.ContainsKey(id)).ToList();
if (missingDecoIds.Count > 0)
{
return BaseError.New($"[DecoId] {missingDecoIds.Head()} does not exist.");
}
var itemsWithEffectiveEnd = request.Items
.Map(i => new DecoTemplateItemRange(
i.DecoId,
i.StartTime,
i.EndTime,
i.EndTime == TimeSpan.Zero ? TimeSpan.FromHours(24) : i.EndTime))
.ToList();
foreach (DecoTemplateItemRange item in itemsWithEffectiveEnd)
{
if (item.StartTime < TimeSpan.Zero || item.StartTime >= TimeSpan.FromHours(24) ||
item.EndTime < TimeSpan.Zero || item.EndTime > TimeSpan.FromHours(24))
{
return BaseError.New(
$"Deco from {item.StartTime} to {item.EndTime} must be within a single day (00:00 to 24:00)");
}
if (item.StartTime >= item.EffectiveEndTime)
{
return BaseError.New(
$"Deco from {item.StartTime} to {item.EndTime} must start before it ends");
}
}
foreach (DecoTemplateItemRange item in itemsWithEffectiveEnd)
{
foreach (DecoTemplateItemRange otherItem in itemsWithEffectiveEnd)
{
if (item == otherItem)
{
continue;
}
if (item.StartTime < otherItem.EffectiveEndTime && otherItem.StartTime < item.EffectiveEndTime)
{
return BaseError.New(
$"Deco from {item.StartTime} to {item.EndTime} intersects deco from {otherItem.StartTime} to {otherItem.EndTime}");
}
}
}
return decoTemplate;
}
private sealed record DecoTemplateItemRange(int DecoId, TimeSpan StartTime, TimeSpan EndTime, TimeSpan EffectiveEndTime);
private static Task<Validation<BaseError, DecoTemplate>> DecoTemplateMustExist( private static Task<Validation<BaseError, DecoTemplate>> DecoTemplateMustExist(
TvContext dbContext, TvContext dbContext,
@@ -76,29 +76,16 @@ public class ReplaceTemplateItemsHandler(IDbContextFactory<TvContext> dbContextF
.ToListAsync() .ToListAsync()
.Map(list => list.ToDictionary(b => b.Id, b => b)); .Map(list => list.ToDictionary(b => b.Id, b => b));
var missingBlockIds = allBlockIds.Filter(id => !allBlocks.ContainsKey(id)).ToList();
if (missingBlockIds.Count > 0)
{
return BaseError.New($"[BlockId] {missingBlockIds.Head()} does not exist.");
}
var allTemplateItems = request.Items.Map(i => var allTemplateItems = request.Items.Map(i =>
{ {
Block block = allBlocks[i.BlockId]; Block block = allBlocks[i.BlockId];
var endTime = i.StartTime + TimeSpan.FromMinutes(block.Minutes); return new BlockTemplateItem(
return new BlockTemplateItem(i.BlockId, i.StartTime, endTime); i.BlockId,
i.StartTime,
i.StartTime + TimeSpan.FromMinutes(block.Minutes));
}) })
.ToList(); .ToList();
foreach (BlockTemplateItem item in allTemplateItems)
{
if (item.EndTime > TimeSpan.FromHours(24))
{
return BaseError.New(
$"Block from {item.StartTime} to {item.EndTime} crosses midnight, which is not supported");
}
}
foreach (BlockTemplateItem item in allTemplateItems) foreach (BlockTemplateItem item in allTemplateItems)
{ {
foreach (BlockTemplateItem otherItem in allTemplateItems) foreach (BlockTemplateItem otherItem in allTemplateItems)
@@ -97,7 +97,6 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
{ {
ex.CollectionType = toUpdate.CollectionType; ex.CollectionType = toUpdate.CollectionType;
ex.CollectionId = toUpdate.CollectionId; ex.CollectionId = toUpdate.CollectionId;
ex.MediaItemId = toUpdate.MediaItemId;
ex.MultiCollectionId = toUpdate.MultiCollectionId; ex.MultiCollectionId = toUpdate.MultiCollectionId;
ex.SmartCollectionId = toUpdate.SmartCollectionId; ex.SmartCollectionId = toUpdate.SmartCollectionId;
ex.PlaylistId = toUpdate.PlaylistId; ex.PlaylistId = toUpdate.PlaylistId;
@@ -112,7 +111,6 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
DecoId = existing.Id, DecoId = existing.Id,
CollectionType = add.CollectionType, CollectionType = add.CollectionType,
CollectionId = add.CollectionId, CollectionId = add.CollectionId,
MediaItemId = add.MediaItemId,
MultiCollectionId = add.MultiCollectionId, MultiCollectionId = add.MultiCollectionId,
SmartCollectionId = add.SmartCollectionId, SmartCollectionId = add.SmartCollectionId,
PlaylistId = add.PlaylistId, PlaylistId = add.PlaylistId,
@@ -1,5 +0,0 @@
using ErsatzTV.Core.Api.Search;
namespace ErsatzTV.Application.Search;
public record GetSearchResults(string Query, int PageSize) : IRequest<SearchResultsResponseModel>;
@@ -1,65 +0,0 @@
using ErsatzTV.Application.LibraryBrowse;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Api.Search;
using MediatR;
namespace ErsatzTV.Application.Search;
// Fans out to the shared library-browse query once per media kind (mirroring the legacy Search.razor page,
// which sends one `type:{kind} AND ({query})` query per kind). Reusing GetLibraryBrowseItems keeps hydration,
// artwork resolution and the response shape identical to /api/library/browse. Raw Lucene queries pass through
// unchanged, so state filters such as `state:FileNotFound` (the Trash screen) work here too.
public class GetSearchResultsHandler(IMediator mediator)
: IRequestHandler<GetSearchResults, SearchResultsResponseModel>
{
public async Task<SearchResultsResponseModel> Handle(
GetSearchResults request,
CancellationToken cancellationToken)
{
async Task<SearchResultGroupResponseModel> ForKind(LibraryBrowseMediaType kind)
{
PagedLibraryBrowseItemsResponseModel paged = await mediator.Send(
new GetLibraryBrowseItems(request.Query, null, kind, 0, request.PageSize),
cancellationToken);
return new SearchResultGroupResponseModel(paged.TotalCount, paged.Page);
}
// Each ForKind call sends through IMediator to GetLibraryBrowseItemsHandler, which creates its
// own TvContext per invocation via IDbContextFactory<TvContext> (never shares one across calls),
// so these are safe to run concurrently.
Task<SearchResultGroupResponseModel> movies = ForKind(LibraryBrowseMediaType.Movie);
Task<SearchResultGroupResponseModel> shows = ForKind(LibraryBrowseMediaType.TelevisionShow);
Task<SearchResultGroupResponseModel> seasons = ForKind(LibraryBrowseMediaType.TelevisionSeason);
Task<SearchResultGroupResponseModel> artists = ForKind(LibraryBrowseMediaType.Artist);
Task<SearchResultGroupResponseModel> episodes = ForKind(LibraryBrowseMediaType.Episode);
Task<SearchResultGroupResponseModel> musicVideos = ForKind(LibraryBrowseMediaType.MusicVideo);
Task<SearchResultGroupResponseModel> songs = ForKind(LibraryBrowseMediaType.Song);
Task<SearchResultGroupResponseModel> otherVideos = ForKind(LibraryBrowseMediaType.OtherVideo);
Task<SearchResultGroupResponseModel> images = ForKind(LibraryBrowseMediaType.Image);
Task<SearchResultGroupResponseModel> remoteStreams = ForKind(LibraryBrowseMediaType.RemoteStream);
await Task.WhenAll(
movies,
shows,
seasons,
artists,
episodes,
musicVideos,
songs,
otherVideos,
images,
remoteStreams);
return new SearchResultsResponseModel(
movies.Result,
shows.Result,
seasons.Result,
artists.Result,
episodes.Result,
musicVideos.Result,
songs.Result,
otherVideos.Result,
images.Result,
remoteStreams.Result);
}
}
@@ -1,5 +1,8 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace ErsatzTV.Application.Troubleshooting.Queries; namespace ErsatzTV.Application.Troubleshooting.Queries;
@@ -12,10 +15,83 @@ public class DecodePlayoutHistoryHandler(IDbContextFactory<TvContext> dbContextF
{ {
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await PlayoutHistoryDecoder.Decode( var decodedKey = JsonConvert.DeserializeObject<BlockItemHistoryKey>(request.PlayoutHistory.Key);
dbContext,
request.PlayoutHistory.Key, PlaybackOrder playbackOrder = decodedKey.PlaybackOrder ?? PlaybackOrder.None;
request.PlayoutHistory.Details, CollectionType collectionType = decodedKey.CollectionType ?? CollectionType.Collection;
cancellationToken);
string name = string.Empty;
switch (collectionType)
{
case CollectionType.Collection:
name = await dbContext.Collections
.AsNoTracking()
.Where(c => c.Id == (decodedKey.CollectionId ?? 0))
.Map(c => c.Name)
.FirstOrDefaultAsync(cancellationToken);
break;
case CollectionType.SmartCollection:
name = await dbContext.SmartCollections
.AsNoTracking()
.Where(c => c.Id == (decodedKey.SmartCollectionId ?? 0))
.Map(c => c.Name)
.FirstOrDefaultAsync(cancellationToken);
break;
}
string mediaItemType = string.Empty;
string mediaItemTitle = string.Empty;
Details details = JsonConvert.DeserializeObject<Details>(request.PlayoutHistory.Details);
if (details?.MediaItemId != null)
{
Option<MediaItem> maybeMediaItem = await dbContext.MediaItems
.AsNoTracking()
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.ThenInclude(l => l.MediaSource)
.Include(i => (i as Movie).MovieMetadata)
.Include(i => (i as Episode).EpisodeMetadata)
.Include(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(i => (i as OtherVideo).OtherVideoMetadata)
.Include(i => (i as Image).ImageMetadata)
.Include(i => (i as RemoteStream).RemoteStreamMetadata)
.Include(i => (i as Song).SongMetadata)
.Include(i => (i as MusicVideo).MusicVideoMetadata)
.Include(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.SelectOneAsync(i => i.Id, i => i.Id == details.MediaItemId, cancellationToken);
foreach (var mediaItem in maybeMediaItem)
{
mediaItemType = mediaItem switch
{
Episode => "Episode",
Movie => "Movie",
MusicVideo => "Music Video",
OtherVideo => "Other Video",
Song => "Song",
Image => "Image",
RemoteStream => "Remote Stream",
_ => $"Unknown ({mediaItem.GetType().Name})"
};
mediaItemTitle = Playouts.Mapper.GetDisplayTitle(mediaItem, Option<string>.None);
}
}
return new PlayoutHistoryDetailsViewModel(playbackOrder, collectionType, name, mediaItemType, mediaItemTitle);
} }
private sealed record BlockItemHistoryKey(
int? BlockId,
PlaybackOrder? PlaybackOrder,
CollectionType? CollectionType,
int? CollectionId,
int? SmartCollectionId);
private sealed record Details(int? MediaItemId);
} }
@@ -1,5 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Troubleshooting.Queries;
public record GetPlayoutHistoryDetails(int Id) : IRequest<Either<BaseError, PlayoutHistoryDetailsViewModel>>;
@@ -1,42 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Troubleshooting.Queries;
public class GetPlayoutHistoryDetailsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetPlayoutHistoryDetails, Either<BaseError, PlayoutHistoryDetailsViewModel>>
{
public async Task<Either<BaseError, PlayoutHistoryDetailsViewModel>> Handle(
GetPlayoutHistoryDetails request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<PlayoutHistory> maybeHistory = await dbContext.PlayoutHistory
.AsNoTracking()
.SelectOneAsync(ph => ph.Id, ph => ph.Id == request.Id, cancellationToken);
foreach (PlayoutHistory history in maybeHistory)
{
try
{
return await PlayoutHistoryDecoder.Decode(
dbContext,
history.Key,
history.Details,
cancellationToken);
}
catch (Exception ex)
{
// old/corrupt rows may carry malformed Key/Details JSON; surface a 422 rather than a 500
return BaseError.New($"Unable to decode playout history: {ex.Message}");
}
}
return new NotFoundError($"Playout history {request.Id} does not exist");
}
}
@@ -1,99 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace ErsatzTV.Application.Troubleshooting.Queries;
// Shared decode logic for a single PlayoutHistory row's Key/Details JSON. Both
// DecodePlayoutHistoryHandler (Blazor) and GetPlayoutHistoryDetailsHandler (API) use this
// so the collection/media-item lookup lives in exactly one place.
internal static class PlayoutHistoryDecoder
{
public static async Task<PlayoutHistoryDetailsViewModel> Decode(
TvContext dbContext,
string key,
string details,
CancellationToken cancellationToken)
{
var decodedKey = JsonConvert.DeserializeObject<BlockItemHistoryKey>(key);
PlaybackOrder playbackOrder = decodedKey.PlaybackOrder ?? PlaybackOrder.None;
CollectionType collectionType = decodedKey.CollectionType ?? CollectionType.Collection;
string name = string.Empty;
switch (collectionType)
{
case CollectionType.Collection:
name = await dbContext.Collections
.AsNoTracking()
.Where(c => c.Id == (decodedKey.CollectionId ?? 0))
.Map(c => c.Name)
.FirstOrDefaultAsync(cancellationToken);
break;
case CollectionType.SmartCollection:
name = await dbContext.SmartCollections
.AsNoTracking()
.Where(c => c.Id == (decodedKey.SmartCollectionId ?? 0))
.Map(c => c.Name)
.FirstOrDefaultAsync(cancellationToken);
break;
}
string mediaItemType = string.Empty;
string mediaItemTitle = string.Empty;
Details decodedDetails = JsonConvert.DeserializeObject<Details>(details);
if (decodedDetails?.MediaItemId != null)
{
Option<MediaItem> maybeMediaItem = await dbContext.MediaItems
.AsNoTracking()
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.ThenInclude(l => l.MediaSource)
.Include(i => (i as Movie).MovieMetadata)
.Include(i => (i as Episode).EpisodeMetadata)
.Include(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(i => (i as OtherVideo).OtherVideoMetadata)
.Include(i => (i as Image).ImageMetadata)
.Include(i => (i as RemoteStream).RemoteStreamMetadata)
.Include(i => (i as Song).SongMetadata)
.Include(i => (i as MusicVideo).MusicVideoMetadata)
.Include(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.SelectOneAsync(i => i.Id, i => i.Id == decodedDetails.MediaItemId, cancellationToken);
foreach (var mediaItem in maybeMediaItem)
{
mediaItemType = mediaItem switch
{
Episode => "Episode",
Movie => "Movie",
MusicVideo => "Music Video",
OtherVideo => "Other Video",
Song => "Song",
Image => "Image",
RemoteStream => "Remote Stream",
_ => $"Unknown ({mediaItem.GetType().Name})"
};
mediaItemTitle = Playouts.Mapper.GetDisplayTitle(mediaItem, Option<string>.None);
}
}
return new PlayoutHistoryDetailsViewModel(playbackOrder, collectionType, name, mediaItemType, mediaItemTitle);
}
private sealed record BlockItemHistoryKey(
int? BlockId,
PlaybackOrder? PlaybackOrder,
CollectionType? CollectionType,
int? CollectionId,
int? SmartCollectionId);
private sealed record Details(int? MediaItemId);
}
@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Troubleshooting.Queries;
public record ValidateSequentialSchedule(string Yaml, bool IsImport) : IRequest<ValidateSequentialScheduleViewModel>;
@@ -1,25 +0,0 @@
using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Application.Troubleshooting.Queries;
public class ValidateSequentialScheduleHandler(ISequentialScheduleValidator validator)
: IRequestHandler<ValidateSequentialSchedule, ValidateSequentialScheduleViewModel>
{
public async Task<ValidateSequentialScheduleViewModel> Handle(
ValidateSequentialSchedule request,
CancellationToken cancellationToken)
{
try
{
// ToJson runs first and can throw on malformed YAML (GetValidationMessages catches its
// own exceptions internally); mirrors the Blazor YamlValidator ordering.
string json = validator.ToJson(request.Yaml);
IList<string> messages = await validator.GetValidationMessages(request.Yaml, request.IsImport);
return new ValidateSequentialScheduleViewModel(messages.Count == 0, messages.ToList(), json);
}
catch (Exception ex)
{
return new ValidateSequentialScheduleViewModel(false, [ex.Message], string.Empty);
}
}
}
@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Troubleshooting;
public record ValidateSequentialScheduleViewModel(bool IsValid, List<string> Messages, string Json);
@@ -1,6 +1,5 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@@ -24,14 +23,11 @@ public class DeleteWatermarkHandler : IRequestHandler<DeleteWatermark, Either<Ba
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<ChannelWatermark> maybeWatermark = await WatermarkMustExist(dbContext, request, cancellationToken); Validation<BaseError, ChannelWatermark> validation = await WatermarkMustExist(
dbContext,
// must-exist maps to a NotFoundError Either directly (not via Validation, which request,
// aggregates errors and loses the subtype the API layer maps to 404) cancellationToken);
return await maybeWatermark.Match( return await validation.Apply(p => DoDeletion(dbContext, p));
Some: watermark => DoDeletion(dbContext, watermark).Map(Right<BaseError, Unit>),
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"Watermark {request.WatermarkId} does not exist")));
} }
private async Task<Unit> DoDeletion(TvContext dbContext, ChannelWatermark watermark) private async Task<Unit> DoDeletion(TvContext dbContext, ChannelWatermark watermark)
@@ -44,10 +40,11 @@ public class DeleteWatermarkHandler : IRequestHandler<DeleteWatermark, Either<Ba
return Unit.Default; return Unit.Default;
} }
private static Task<Option<ChannelWatermark>> WatermarkMustExist( private static Task<Validation<BaseError, ChannelWatermark>> WatermarkMustExist(
TvContext dbContext, TvContext dbContext,
DeleteWatermark request, DeleteWatermark request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
dbContext.ChannelWatermarks dbContext.ChannelWatermarks
.SelectOneAsync(p => p.Id, p => p.Id == request.WatermarkId, cancellationToken); .SelectOneAsync(p => p.Id, p => p.Id == request.WatermarkId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"Watermark {request.WatermarkId} does not exist"));
} }
@@ -1,6 +1,5 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@@ -24,18 +23,8 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<ChannelWatermark> maybeWatermark = await WatermarkMustExist(dbContext, request, cancellationToken); Validation<BaseError, ChannelWatermark> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
// 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 maybeWatermark.Match(
Some: async watermark =>
{
Validation<BaseError, string> validation = await ValidateName(dbContext, request);
return await validation.Apply((string _) => ApplyUpdateRequest(dbContext, watermark, request));
},
None: () => Task.FromResult<Either<BaseError, UpdateWatermarkResult>>(
new NotFoundError("Watermark does not exist.")));
} }
private async Task<UpdateWatermarkResult> ApplyUpdateRequest( private async Task<UpdateWatermarkResult> ApplyUpdateRequest(
@@ -72,12 +61,20 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba
return new UpdateWatermarkResult(p.Id); return new UpdateWatermarkResult(p.Id);
} }
private static Task<Option<ChannelWatermark>> WatermarkMustExist( private static async Task<Validation<BaseError, ChannelWatermark>> Validate(
TvContext dbContext,
UpdateWatermark request,
CancellationToken cancellationToken) =>
(await WatermarkMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
.Apply((watermark, _) => watermark);
private static Task<Validation<BaseError, ChannelWatermark>> WatermarkMustExist(
TvContext dbContext, TvContext dbContext,
UpdateWatermark updateWatermark, UpdateWatermark updateWatermark,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
dbContext.ChannelWatermarks dbContext.ChannelWatermarks
.SelectOneAsync(p => p.Id, p => p.Id == updateWatermark.Id, cancellationToken); .SelectOneAsync(p => p.Id, p => p.Id == updateWatermark.Id, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Watermark does not exist."));
private static async Task<Validation<BaseError, string>> ValidateName( private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext, TvContext dbContext,
-20
View File
@@ -9,26 +9,6 @@ internal static class Mapper
internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) => internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) =>
new(watermark.Id, watermark.Name); new(watermark.Id, watermark.Name);
internal static WatermarkFullResponseModel ProjectToFullResponseModel(ChannelWatermark watermark) =>
new(
watermark.Id,
watermark.Name,
watermark.Mode,
watermark.ImageSource,
watermark.Image,
watermark.OriginalContentType,
watermark.Location,
watermark.Size,
watermark.WidthPercent,
watermark.HorizontalMarginPercent,
watermark.VerticalMarginPercent,
watermark.FrequencyMinutes,
watermark.DurationSeconds,
watermark.Opacity,
watermark.OpacityExpression,
watermark.ZIndex,
watermark.PlaceWithinSourceContent);
public static WatermarkViewModel ProjectToViewModel(ChannelWatermark watermark) => public static WatermarkViewModel ProjectToViewModel(ChannelWatermark watermark) =>
new( new(
watermark.Id, watermark.Id,
@@ -1,5 +0,0 @@
using ErsatzTV.Core.Api.Watermarks;
namespace ErsatzTV.Application.Watermarks;
public record GetWatermarkByIdForApi(int Id) : IRequest<Option<WatermarkFullResponseModel>>;
@@ -1,22 +0,0 @@
using ErsatzTV.Core.Api.Watermarks;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Watermarks.Mapper;
namespace ErsatzTV.Application.Watermarks;
public class GetWatermarkByIdForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetWatermarkByIdForApi, Option<WatermarkFullResponseModel>>
{
public async Task<Option<WatermarkFullResponseModel>> Handle(
GetWatermarkByIdForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.ChannelWatermarks
.AsNoTracking()
.SelectOneAsync(w => w.Id, w => w.Id == request.Id, cancellationToken)
.MapT(ProjectToFullResponseModel);
}
}
-66
View File
@@ -1,66 +0,0 @@
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using Flurl;
namespace ErsatzTV.Core.Api;
/// <summary>
/// Roots an artwork value (as produced by the Application view-model mappers) into a directly-usable
/// <c>&lt;img src&gt;</c> URL for the React SPA. The Blazor pages prefix "artwork/{folder}/" themselves and
/// resolve relative to <c>&lt;base href="/"&gt;</c>, but the SPA renders the value raw from under <c>/app/</c>,
/// so the API must root the URL itself (issue #180/#181). Mirrors the projection helper in
/// GetLibraryBrowseItemsHandler so detail endpoints stay consistent with the browse grid.
/// </summary>
public static class ApiArtwork
{
public static string Root(string? artwork, ArtworkKind artworkKind)
{
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 switch
{
ArtworkKind.Thumbnail => "thumbnails",
ArtworkKind.FanArt => "fanart",
_ => "posters"
};
// Some mappers (e.g. Artists) leave the raw jellyfin://emby:// scheme in the value; convert it here so
// the SPA gets a working proxy URL even when the source mapper didn't pre-convert. Movie/TV mappers
// already produce a relative "jellyfin/{id}?..." path, which falls through to the plain prefix below.
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}";
}
}
@@ -1,14 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Artists;
public record ArtistDetailResponseModel(
int Id,
string Name,
string? Disambiguation,
string? Biography,
string Thumbnail,
string FanArt,
List<string> Genres,
List<string> Styles,
List<string> Moods,
List<string> Languages);
@@ -1,5 +1,4 @@
#nullable enable using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Core.Api.FFmpegProfiles; namespace ErsatzTV.Core.Api.FFmpegProfiles;
@@ -8,17 +7,13 @@ public record FFmpegFullProfileResponseModel(
int Id, int Id,
string Name, string Name,
int ThreadCount, int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration, HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay, string VaapiDisplay,
VaapiDriver VaapiDriver, VaapiDriver VaapiDriver,
string VaapiDevice, string VaapiDevice,
int? QsvExtraHardwareFrames, int? QsvExtraHardwareFrames,
int ResolutionId,
string Resolution, string Resolution,
ScalingBehavior ScalingBehavior, ScalingBehavior ScalingBehavior,
FilterMode PadMode,
FFmpegProfileVideoFormat VideoFormat, FFmpegProfileVideoFormat VideoFormat,
string VideoProfile, string VideoProfile,
string VideoPreset, string VideoPreset,
@@ -31,9 +26,7 @@ public record FFmpegFullProfileResponseModel(
int AudioBitrate, int AudioBitrate,
int AudioBufferSize, int AudioBufferSize,
NormalizeLoudnessMode NormalizeLoudnessMode, NormalizeLoudnessMode NormalizeLoudnessMode,
double? TargetLoudness,
int AudioChannels, int AudioChannels,
int AudioSampleRate, int AudioSampleRate,
bool NormalizeFramerate, bool NormalizeFramerate,
bool NormalizeColors, bool? DeinterlaceVideo);
bool DeinterlaceVideo);
@@ -1,23 +0,0 @@
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Filler;
public record FillerPresetFullResponseModel(
int Id,
string Name,
FillerKind FillerKind,
FillerMode FillerMode,
TimeSpan? Duration,
int? Count,
int? PadToNearestMinute,
bool AllowWatermarks,
CollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,
int? SmartCollectionId,
int? PlaylistId,
string? Expression,
bool UseChaptersAsMediaItems);
@@ -1,10 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Images;
public record ImageFolderResponseModel(
int LibraryFolderId,
string Name,
string FullPath,
int SubfolderCount,
int ImageCount,
double? DurationSeconds);
@@ -1,4 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Images;
public record UpdateImageFolderDurationResponseModel(double? DurationSeconds);
@@ -19,5 +19,4 @@ public record LibraryBrowseItemResponseModel(
int? SmartCollectionId, int? SmartCollectionId,
int? RerunCollectionId, int? RerunCollectionId,
int? MediaItemId, int? MediaItemId,
int? PlaylistId, int? PlaylistId);
string? Subtitle = null);
@@ -11,11 +11,5 @@ public enum LibraryBrowseMediaType
SmartCollection = 6, SmartCollection = 6,
MultiCollection = 7, MultiCollection = 7,
RerunCollection = 8, RerunCollection = 8,
Playlist = 9, Playlist = 9
Episode = 10,
MusicVideo = 11,
Song = 12,
OtherVideo = 13,
Image = 14,
RemoteStream = 15
} }
@@ -1,7 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Logs;
public record LogEntryResponseModel(
DateTimeOffset Timestamp,
string Level,
string Message);
@@ -1,6 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Logs;
public record PagedLogEntriesResponseModel(
int TotalCount,
List<LogEntryResponseModel> Page);
@@ -1,8 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Media;
public record ActorResponseModel(
int Id,
string Name,
string? Role,
string Thumb);
@@ -1,20 +0,0 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaCollections;
public record MultiCollectionResponseModel(
int Id,
string Name,
List<MultiCollectionItemResponseModel> Items);
public record MultiCollectionItemResponseModel(
int? CollectionId,
int? SmartCollectionId,
string Name,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder);
public record PagedMultiCollectionsResponseModel(
int TotalCount,
List<MultiCollectionResponseModel> Page);
@@ -1,4 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaCollections;
public record PlaylistGroupResponseModel(int Id, string Name, int PlaylistCount, bool IsSystem);
@@ -1,4 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaCollections;
public record PlaylistResponseModel(int Id, int PlaylistGroupId, string Name, bool IsSystem);
@@ -1,17 +0,0 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaCollections;
public record RerunCollectionResponseModel(
int Id,
string Name,
CollectionType CollectionType,
int? SelectedId,
string? SelectedName,
PlaybackOrder FirstRunPlaybackOrder,
PlaybackOrder RerunPlaybackOrder);
public record PagedRerunCollectionsResponseModel(
int TotalCount,
List<RerunCollectionResponseModel> Page);
@@ -1,49 +0,0 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaItems;
public record MediaItemInfoResponseModel(
int Id,
string Title,
string Kind,
string LibraryKind,
string? ServerName,
string LibraryName,
MediaItemState State,
TimeSpan Duration,
string? SampleAspectRatio,
string? DisplayAspectRatio,
string? RFrameRate,
VideoScanKind VideoScanKind,
double? InterlacedRatio,
int Width,
int Height,
List<MediaItemInfoStreamResponseModel> Streams,
List<MediaItemInfoChapterResponseModel> Chapters);
public record MediaItemInfoStreamResponseModel(
int? Index,
MediaStreamKind Kind,
string? Title,
string? Codec,
string? Profile,
string? Language,
int? Channels,
bool? Default,
bool? Forced,
bool? AttachedPic,
string? PixelFormat,
string? ColorRange,
string? ColorSpace,
string? ColorTransfer,
string? ColorPrimaries,
int? BitsPerRawSample,
string? MimeType,
string? FileName,
bool? IsExtracted);
public record MediaItemInfoChapterResponseModel(
string? Title,
TimeSpan StartTime,
TimeSpan EndTime);
@@ -1,24 +0,0 @@
#nullable enable
using ErsatzTV.Core.Api.Media;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Movies;
public record MovieDetailResponseModel(
int Id,
string Title,
string? Year,
string? Plot,
List<string> Genres,
List<string> Tags,
List<string> Studios,
List<string> ContentRatings,
List<string> Languages,
List<ActorResponseModel> Actors,
List<string> Directors,
List<string> Writers,
string? Path,
string? LocalPath,
MediaItemState State,
string Poster,
string FanArt);
@@ -1,4 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Playouts;
public record PagedPlayoutHistoryResponseModel(int TotalCount, List<PlayoutHistoryResponseModel> Page);
@@ -1,17 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutAlternateScheduleResponseModel(
int Id,
int Index,
int ProgramScheduleId,
ICollection<DayOfWeek> DaysOfWeek,
ICollection<int> DaysOfMonth,
ICollection<int> MonthsOfYear,
bool LimitToDateRange,
int StartMonth,
int StartDay,
int? StartYear,
int EndMonth,
int EndDay,
int? EndYear);
@@ -1,11 +0,0 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutHistoryDetailsResponseModel(
PlaybackOrder PlaybackOrder,
CollectionType CollectionType,
string Name,
string MediaItemType,
string MediaItemTitle);
@@ -1,9 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutHistoryResponseModel(
int Id,
DateTimeOffset When,
DateTimeOffset Finish,
string Key,
string Details);
@@ -12,9 +12,7 @@ public record PlayoutResponseModel(
string ScheduleName, string ScheduleName,
string? ScheduleFile, string? ScheduleFile,
TimeSpan? DailyRebuildTime, TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus, PlayoutBuildStatusResponseModel? BuildStatus)
int? DecoId,
string? DecoName)
{ {
public static PlayoutResponseModel From( public static PlayoutResponseModel From(
int id, int id,
@@ -25,9 +23,7 @@ public record PlayoutResponseModel(
string scheduleName, string scheduleName,
string? scheduleFile, string? scheduleFile,
TimeSpan? dailyRebuildTime, TimeSpan? dailyRebuildTime,
PlayoutBuildStatusResponseModel? buildStatus, PlayoutBuildStatusResponseModel? buildStatus) =>
int? decoId,
string? decoName) =>
new( new(
id, id,
scheduleKind, scheduleKind,
@@ -37,7 +33,5 @@ public record PlayoutResponseModel(
scheduleName, scheduleName,
scheduleFile, scheduleFile,
dailyRebuildTime, dailyRebuildTime,
buildStatus, buildStatus);
decoId,
decoName);
} }
@@ -1,4 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record BlockGroupResponseModel(int Id, string Name);
@@ -1,24 +0,0 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Scheduling;
public record BlockItemResponseModel(
int Id,
int Index,
CollectionType CollectionType,
int? CollectionId,
string? CollectionName,
int? MultiCollectionId,
string? MultiCollectionName,
int? SmartCollectionId,
string? SmartCollectionName,
int? MediaItemId,
string? MediaItemName,
string SearchTitle,
string SearchQuery,
PlaybackOrder PlaybackOrder,
bool IncludeInProgramGuide,
bool DisableWatermarks,
List<int> WatermarkIds,
List<int> GraphicsElementIds);
@@ -1,8 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record BlockPreviewItemResponseModel(
string Title,
TimeSpan Start,
TimeSpan Finish,
string Duration);
@@ -1,12 +0,0 @@
#nullable enable
using ErsatzTV.Core.Domain.Scheduling;
namespace ErsatzTV.Core.Api.Scheduling;
public record BlockResponseModel(
int Id,
int GroupId,
string GroupName,
string Name,
int Minutes,
BlockStopScheduling StopScheduling);
@@ -1,13 +0,0 @@
#nullable enable
using ErsatzTV.Core.Domain.Scheduling;
namespace ErsatzTV.Core.Api.Scheduling;
public record BlockWithItemsResponseModel(
int Id,
int GroupId,
string GroupName,
string Name,
int Minutes,
BlockStopScheduling StopScheduling,
List<BlockItemResponseModel> Items);
@@ -1,17 +0,0 @@
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
namespace ErsatzTV.Core.Api.Scheduling;
public record DecoBreakContentResponseModel(
int Id,
CollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,
int? SmartCollectionId,
int? PlaylistId,
int? PlaylistGroupId,
string? SelectionName,
DecoBreakPlacement Placement);
@@ -1,4 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record DecoGroupResponseModel(int Id, string Name, int DecoCount);
@@ -1,4 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record DecoListItemResponseModel(int Id, int DecoGroupId, string DecoGroupName, string Name);
@@ -1,34 +0,0 @@
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
namespace ErsatzTV.Core.Api.Scheduling;
public record DecoResponseModel(
int Id,
int DecoGroupId,
string DecoGroupName,
string Name,
DecoMode WatermarkMode,
List<int> WatermarkIds,
bool UseWatermarkDuringFiller,
DecoMode GraphicsElementsMode,
List<int> GraphicsElementIds,
bool UseGraphicsElementsDuringFiller,
DecoMode BreakContentMode,
List<DecoBreakContentResponseModel> BreakContent,
DecoMode DefaultFillerMode,
CollectionType DefaultFillerCollectionType,
int? DefaultFillerCollectionId,
int? DefaultFillerMediaItemId,
int? DefaultFillerMultiCollectionId,
int? DefaultFillerSmartCollectionId,
string? DefaultFillerSelectionName,
bool DefaultFillerTrimToFit,
DecoMode DeadAirFallbackMode,
CollectionType DeadAirFallbackCollectionType,
int? DeadAirFallbackCollectionId,
int? DeadAirFallbackMediaItemId,
int? DeadAirFallbackMultiCollectionId,
int? DeadAirFallbackSmartCollectionId,
string? DeadAirFallbackSelectionName);
@@ -1,4 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record DecoTemplateGroupResponseModel(int Id, string Name, int DecoTemplateCount);
@@ -1,4 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record DecoTemplateItemResponseModel(int DecoId, string DecoName, TimeSpan StartTime, TimeSpan EndTime);
@@ -1,4 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record DecoTemplateResponseModel(int Id, int DecoTemplateGroupId, string GroupName, string Name);
@@ -1,9 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record DecoTemplateWithItemsResponseModel(
int Id,
int DecoTemplateGroupId,
string GroupName,
string Name,
List<DecoTemplateItemResponseModel> Items);
@@ -1,22 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record PlayoutTemplateResponseModel(
int Id,
int Index,
int TemplateId,
string TemplateName,
string TemplateGroupName,
int? DecoTemplateId,
string? DecoTemplateName,
string? DecoTemplateGroupName,
ICollection<DayOfWeek> DaysOfWeek,
ICollection<int> DaysOfMonth,
ICollection<int> MonthsOfYear,
bool LimitToDateRange,
int StartMonth,
int StartDay,
int? StartYear,
int EndMonth,
int EndDay,
int? EndYear);
@@ -1,8 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
/// <summary>
/// A lightweight {id, name} option used by the block/schedule editor pickers
/// (collection, television show, television season, smart collection search).
/// </summary>
public record SchedulingPickerOptionResponseModel(int Id, string Name);
@@ -1,4 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record TemplateGroupResponseModel(int Id, string Name, int TemplateCount);
@@ -1,4 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record TemplateItemResponseModel(int BlockId, string BlockName, int BlockMinutes, TimeSpan StartTime);
@@ -1,4 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record TemplateResponseModel(int Id, int TemplateGroupId, string GroupName, string Name);
@@ -1,9 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record TemplateWithItemsResponseModel(
int Id,
int TemplateGroupId,
string GroupName,
string Name,
List<TemplateItemResponseModel> Items);
@@ -1,8 +0,0 @@
#nullable enable
using ErsatzTV.Core.Api.LibraryBrowse;
namespace ErsatzTV.Core.Api.Search;
public record SearchResultGroupResponseModel(
int TotalCount,
List<LibraryBrowseItemResponseModel> Items);
@@ -1,14 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Search;
public record SearchResultsResponseModel(
SearchResultGroupResponseModel Movies,
SearchResultGroupResponseModel Shows,
SearchResultGroupResponseModel Seasons,
SearchResultGroupResponseModel Artists,
SearchResultGroupResponseModel Episodes,
SearchResultGroupResponseModel MusicVideos,
SearchResultGroupResponseModel Songs,
SearchResultGroupResponseModel OtherVideos,
SearchResultGroupResponseModel Images,
SearchResultGroupResponseModel RemoteStreams);
@@ -1,11 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Television;
public record SeasonDetailResponseModel(
int Id,
int ShowId,
string Title,
string? Year,
string Name,
string Poster,
string FanArt);
@@ -1,22 +0,0 @@
#nullable enable
using ErsatzTV.Core.Api.Media;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Television;
public record ShowDetailResponseModel(
int Id,
int LibraryId,
MediaSourceKind MediaSourceKind,
string Title,
string? Year,
string? Plot,
string Poster,
string FanArt,
List<string> Genres,
List<string> Tags,
List<string> Studios,
List<string> Networks,
List<string> ContentRatings,
List<string> Languages,
List<ActorResponseModel> Actors);
@@ -1,4 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Trakt;
public record PagedTraktListsResponseModel(int TotalCount, List<TraktListResponseModel> Page);
@@ -1,12 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Trakt;
public record TraktListResponseModel(
int Id,
int TraktId,
string Slug,
string Name,
int ItemCount,
int MatchCount,
bool AutoRefresh,
bool GeneratePlaylist);
@@ -1,8 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Trakt;
/// <summary>
/// HTTP-observable substitute for the Blazor <c>IEntityLocker.OnTraktChanged</c> event — the SPA polls this
/// while an add/match/delete operation is in flight (no push channel exists for the REST API).
/// </summary>
public record TraktStatusResponseModel(bool Busy);
@@ -1,14 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Troubleshooting;
// GeneralJson mirrors the JSON blob rendered on the "General" tab of the legacy Blazor
// Troubleshooting page (version, environment, cpus, video controllers, health, ffmpeg settings,
// AviSynth flags, channels, ffmpeg profiles) - kept pre-serialized so the SPA can render/copy it
// verbatim without re-deriving the same shape. The remaining fields mirror the per-platform
// capability dump tabs (only the ones relevant to the current OS/GPU are non-empty).
public record TroubleshootingInfoResponseModel(
string GeneralJson,
string? NvidiaCapabilities,
string? QsvCapabilities,
string? VaapiCapabilities,
string? VideoToolboxCapabilities);
@@ -1,4 +0,0 @@
#nullable enable
namespace ErsatzTV.Core.Api.Troubleshooting;
public record ValidateSequentialScheduleResponseModel(bool IsValid, List<string> Messages, string Json);
@@ -1,24 +0,0 @@
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
namespace ErsatzTV.Core.Api.Watermarks;
public record WatermarkFullResponseModel(
int Id,
string Name,
ChannelWatermarkMode Mode,
ChannelWatermarkImageSource ImageSource,
string? Image,
string? ImageContentType,
WatermarkLocation Location,
WatermarkSize Size,
double Width,
double HorizontalMargin,
double VerticalMargin,
int FrequencyMinutes,
int DurationSeconds,
int Opacity,
string? OpacityExpression,
int ZIndex,
bool PlaceWithinSourceContent);
@@ -0,0 +1,87 @@
using System.Text;
using ErsatzTV.Mcp;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Mcp.Tests;
[TestFixture]
public class BoundedLineReaderTests
{
[Test]
public async Task ReadLineAsync_Should_Return_Line_Without_Trailing_Newline()
{
using var reader = new StringReader("hello world\n");
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 1024);
line.EndOfStream.ShouldBeFalse();
line.Overflowed.ShouldBeFalse();
line.Text.ShouldBe("hello world");
}
[Test]
public async Task ReadLineAsync_Should_Return_Line_Of_Exactly_Cap_Length_Intact()
{
// The cap is the inclusive max: a line of exactly `cap` chars is returned, not overflowed.
using var reader = new StringReader("abcdefgh\n");
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 8);
line.Overflowed.ShouldBeFalse();
line.Text.ShouldBe("abcdefgh");
}
[Test]
public async Task ReadLineAsync_Should_Strip_Carriage_Return_In_Crlf()
{
using var reader = new StringReader("hello\r\n");
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 1024);
line.Text.ShouldBe("hello");
}
[Test]
public async Task ReadLineAsync_Should_Signal_End_Of_Stream()
{
using var reader = new StringReader(string.Empty);
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 1024);
line.EndOfStream.ShouldBeTrue();
}
[Test]
public async Task ReadLineAsync_Should_Overflow_And_Not_Buffer_Oversized_Line()
{
// A line far longer than the cap must be reported overflowed with no buffered text —
// the memory-exhaustion guard.
string oversized = new string('x', 10_000) + "\n";
using var reader = new StringReader(oversized);
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 16);
line.EndOfStream.ShouldBeFalse();
line.Overflowed.ShouldBeTrue();
line.Text.ShouldBeEmpty();
}
[Test]
public async Task ReadLineAsync_Should_Keep_Subsequent_Lines_Aligned_After_Overflow()
{
// After draining an oversized line, the next line must still be read intact.
var content = new StringBuilder()
.Append(new string('x', 100)).Append('\n')
.Append("good\n")
.ToString();
using var reader = new StringReader(content);
BoundedLineReader.Line first = await BoundedLineReader.ReadLineAsync(reader, 16);
BoundedLineReader.Line second = await BoundedLineReader.ReadLineAsync(reader, 16);
first.Overflowed.ShouldBeTrue();
second.Overflowed.ShouldBeFalse();
second.Text.ShouldBe("good");
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit.Analyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="Shouldly" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ErsatzTV.Mcp\ErsatzTV.Mcp.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,264 @@
using System.Net;
using System.Text.Json;
using ErsatzTV.Mcp;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Mcp.Tests;
[TestFixture]
public class ErsatzTvApiClientTests
{
[Test]
public async Task CallToolAsync_Should_Substitute_Path_Parameters_And_Send_Api_Key()
{
CapturingHandler handler = new("""{"id":12,"name":"Kids"}""");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost:8409/"), "secret"));
ToolCallResult result = await client.CallToolAsync(
new ToolDefinition(
"ersatztv_get_channel",
"Get channel",
HttpMethod.Get,
"/api/channels/{id}",
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
JsonDocument.Parse("""{"id":12}""").RootElement,
CancellationToken.None);
result.IsError.ShouldBeFalse();
result.Text.ShouldBe("""{"id":12,"name":"Kids"}""");
handler.RequestUri.ShouldBe(new Uri("http://localhost:8409/api/channels/12"));
handler.ApiKey.ShouldBe("secret");
}
[Test]
public async Task CallToolAsync_Should_Url_Encode_Path_Parameters()
{
CapturingHandler handler = new("""{"id":1}""");
var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
await client.CallToolAsync(
new ToolDefinition(
"ersatztv_get_resolution_by_name",
"Get resolution",
HttpMethod.Get,
"/api/ffmpeg/resolution/by-name/{name}",
ToolInputSchemas.Object(("name", "string", "Resolution name", true))),
JsonDocument.Parse("""{"name":"1920 x 1080"}""").RootElement,
CancellationToken.None);
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/ffmpeg/resolution/by-name/1920%20x%201080"));
}
[Test]
public async Task CallToolAsync_Should_Return_Error_Result_For_Non_Success_Status()
{
CapturingHandler handler = new("""{"status":404,"title":"Resource not found"}""", HttpStatusCode.NotFound);
var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
ToolCallResult result = await client.CallToolAsync(
new ToolDefinition(
"ersatztv_get_channel",
"Get channel",
HttpMethod.Get,
"/api/channels/{id}",
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
JsonDocument.Parse("""{"id":404}""").RootElement,
CancellationToken.None);
result.IsError.ShouldBeTrue();
result.Text.ShouldContain("404");
result.Text.ShouldContain("Resource not found");
}
[Test]
public async Task CallToolAsync_Should_Refuse_Non_Get_Tool_When_Read_Only()
{
CapturingHandler handler = new("{}");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
ToolCallResult result = await client.CallToolAsync(
new ToolDefinition(
"ersatztv_delete_channel",
"Delete channel",
HttpMethod.Delete,
"/api/channels/{id}",
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
JsonDocument.Parse("""{"id":12}""").RootElement,
CancellationToken.None);
result.IsError.ShouldBeTrue();
result.Text.ShouldContain("read-only");
// The request must never reach the API.
handler.RequestUri.ShouldBeNull();
}
[Test]
public async Task CallToolAsync_Should_Allow_Non_Get_Tool_When_Writes_Enabled()
{
CapturingHandler handler = new("""{"ok":true}""");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, AllowWrites: true));
ToolCallResult result = await client.CallToolAsync(
new ToolDefinition(
"ersatztv_delete_channel",
"Delete channel",
HttpMethod.Delete,
"/api/channels/{id}",
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
JsonDocument.Parse("""{"id":12}""").RootElement,
CancellationToken.None);
result.IsError.ShouldBeFalse();
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/channels/12"));
}
[Test]
public async Task CallToolAsync_Should_Truncate_Oversized_Response()
{
CapturingHandler handler = new(new string('x', 500));
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: 16));
ToolCallResult result = await client.CallToolAsync(
new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/channels", ToolInputSchemas.Empty),
JsonDocument.Parse("{}").RootElement,
CancellationToken.None);
result.Text.ShouldStartWith(new string('x', 16));
result.Text.ShouldContain("truncated");
result.Text.Length.ShouldBeLessThan(500);
}
[Test]
public async Task CallToolAsync_Should_Preserve_Reverse_Proxy_Path_Prefix()
{
CapturingHandler handler = new("""{"id":12}""");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://host/etv/"), null));
await client.CallToolAsync(
new ToolDefinition(
"ersatztv_get_channel",
"Get channel",
HttpMethod.Get,
"/api/channels/{id}",
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
JsonDocument.Parse("""{"id":12}""").RootElement,
CancellationToken.None);
handler.RequestUri.ShouldBe(new Uri("http://host/etv/api/channels/12"));
}
[Test]
public async Task CallToolAsync_Should_Reject_Unknown_Argument()
{
CapturingHandler handler = new("{}");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
await Should.ThrowAsync<ArgumentException>(async () => await client.CallToolAsync(
new ToolDefinition(
"ersatztv_get_channel",
"Get channel",
HttpMethod.Get,
"/api/channels/{id}",
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
JsonDocument.Parse("""{"id":12,"evil":"drop"}""").RootElement,
CancellationToken.None));
handler.RequestUri.ShouldBeNull();
}
[Test]
public async Task CallToolAsync_Should_Reject_Dot_Segment_Path_Parameter()
{
CapturingHandler handler = new("{}");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
// ".." would canonicalize the URL onto a different route — must be rejected pre-flight.
await Should.ThrowAsync<ArgumentException>(async () => await client.CallToolAsync(
new ToolDefinition(
"ersatztv_get_resolution_by_name",
"Get resolution",
HttpMethod.Get,
"/api/ffmpeg/resolution/by-name/{name}",
ToolInputSchemas.Object(("name", "string", "Resolution name", true))),
JsonDocument.Parse("""{"name":".."}""").RootElement,
CancellationToken.None));
handler.RequestUri.ShouldBeNull();
}
[Test]
public async Task CallToolAsync_Should_Fall_Back_To_Default_Cap_On_Overflowing_Configured_Cap()
{
CapturingHandler handler = new("""{"ok":true}""");
// int.MaxValue would overflow `cap + 1` to a negative array length; the client must
// clamp to the default instead of crashing.
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: int.MaxValue));
ToolCallResult result = await client.CallToolAsync(
new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/channels", ToolInputSchemas.Empty),
JsonDocument.Parse("{}").RootElement,
CancellationToken.None);
result.IsError.ShouldBeFalse();
result.Text.ShouldBe("""{"ok":true}""");
}
[Test]
public async Task CallToolAsync_Should_Not_Emit_Replacement_Char_When_Truncating_Mid_Codepoint()
{
// "ab😀" — the emoji is a 4-byte sequence starting at byte index 2; a 4-byte cap cuts it
// mid-sequence. The truncated text must end cleanly, not with a U+FFFD replacement char.
CapturingHandler handler = new("ab\U0001F600");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: 4));
ToolCallResult result = await client.CallToolAsync(
new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/channels", ToolInputSchemas.Empty),
JsonDocument.Parse("{}").RootElement,
CancellationToken.None);
result.Text.ShouldStartWith("ab");
result.Text.ShouldNotContain("");
result.Text.ShouldContain("truncated");
}
private sealed class CapturingHandler(string response, HttpStatusCode statusCode = HttpStatusCode.OK)
: HttpMessageHandler
{
public Uri? RequestUri { get; private set; }
public string? ApiKey { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestUri = request.RequestUri;
ApiKey = request.Headers.TryGetValues("X-Api-Key", out IEnumerable<string>? values)
? values.Single()
: null;
return Task.FromResult(new HttpResponseMessage(statusCode)
{
Content = new StringContent(response)
});
}
}
}

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