Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65d88b5167 | ||
|
|
4f68805d9a | ||
|
|
55fc210385 | ||
|
|
d1c04030af | ||
|
|
945d108334 |
@@ -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."
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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><img src></c> URL for the React SPA. The Blazor pages prefix "artwork/{folder}/" themselves and
|
|
||||||
/// resolve relative to <c><base href="/"></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
Reference in New Issue
Block a user