Compare commits
148
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8390bb008 | ||
|
|
ea0eb0ab76 | ||
|
|
eba22ce679 | ||
|
|
490b6d2ef0 | ||
|
|
b26045676b | ||
|
|
c42928e891 | ||
|
|
6f6fcf6c63 | ||
|
|
702b0121cd | ||
|
|
78e4a3c738 | ||
|
|
11b43d5e06 | ||
|
|
f09045e135 | ||
|
|
d3d50f60c4 | ||
|
|
088644e8c0 | ||
|
|
275908ec11 | ||
|
|
554f658989 | ||
|
|
dd9230ac7d | ||
|
|
6bbf669403 | ||
|
|
c4b7babbe8 | ||
|
|
3c7ed5c2f8 | ||
|
|
67547d998a | ||
|
|
9467a373b8 | ||
|
|
b82ae0c906 | ||
|
|
5e2b428876 | ||
|
|
1bef9c9076 | ||
|
|
c77ed43585 | ||
|
|
f0ada7e993 | ||
|
|
6dc9c12d8c | ||
|
|
cf81f28118 | ||
|
|
aee96a1788 | ||
|
|
ef4b64d57c | ||
|
|
c846593aa3 | ||
|
|
a039d5230c | ||
|
|
245b1a5889 | ||
|
|
e5189f2921 | ||
|
|
7f248fd553 | ||
|
|
3d35aa5348 | ||
|
|
794bb0d4bb | ||
|
|
dfd2134cb4 | ||
|
|
ef615f8c69 | ||
|
|
5918e947d9 | ||
|
|
4bc43893c1 | ||
|
|
8feb9a1367 | ||
|
|
0120d3ce5b | ||
|
|
ceb45e4926 | ||
|
|
5054cd8228 | ||
|
|
2687d33637 | ||
|
|
d0e431beff | ||
|
|
086319f200 | ||
|
|
b70713fde8 | ||
|
|
83455fa42e | ||
|
|
9da582924a | ||
|
|
6b9d76f948 | ||
|
|
7a28e6ae9c | ||
|
|
846452e28c | ||
|
|
f459e74645 | ||
|
|
5d0ad8b0d7 | ||
|
|
0a8c7b691b | ||
|
|
5816c31136 | ||
|
|
ef8915f1ca | ||
|
|
caeabf2a2b | ||
|
|
35206b2199 | ||
|
|
6823b42dbc | ||
|
|
223797a6cb | ||
|
|
0156077e18 | ||
|
|
b13fb0ffc7 | ||
|
|
c5ebe29ce6 | ||
|
|
d7782dccac | ||
|
|
dbc47f4cae | ||
|
|
5b945c308d | ||
|
|
c038e13a99 | ||
|
|
28910ff557 | ||
|
|
8ddf0ae169 | ||
|
|
fc2c054b44 | ||
|
|
81ddd4e5ca | ||
|
|
7129d9c5b5 | ||
|
|
8b77d5e739 | ||
|
|
c63cadb834 | ||
|
|
9e6ea3567c | ||
|
|
279052ccd4 | ||
|
|
50911eb456 | ||
|
|
a2ed834ef6 | ||
|
|
9ab851bc6d | ||
|
|
acc3188a93 | ||
|
|
04cb214ec1 | ||
|
|
6426c01efa | ||
|
|
4c6b10a751 | ||
|
|
584ad095d9 | ||
|
|
1e72faa60f | ||
|
|
5493065867 | ||
|
|
819e751cab | ||
|
|
cfabd6f33f | ||
|
|
4f8079986f | ||
|
|
3528f689d6 | ||
|
|
f3d8ac559f | ||
|
|
8ba261ded4 | ||
|
|
3bccb568ad | ||
|
|
606d50bb8d | ||
|
|
91e23bcd57 | ||
|
|
e66b926206 | ||
|
|
f1b3879d51 | ||
|
|
eb6e712c38 | ||
|
|
688cf10fee | ||
|
|
2b91837174 | ||
|
|
8a263e7c93 | ||
|
|
e0d30c9b7b | ||
|
|
d9faa8be7b | ||
|
|
7ffde6225f | ||
|
|
f26790aa35 | ||
|
|
6dac2d724c | ||
|
|
ad7e151565 | ||
|
|
415da214ec | ||
|
|
af17dafcab | ||
|
|
63983244ab | ||
|
|
1ae8facaab | ||
|
|
284b65a936 | ||
|
|
58a3bb1a2b | ||
|
|
6e2e0e1858 | ||
|
|
add5d4c11d | ||
|
|
6202886b8f | ||
|
|
40299f5ee6 | ||
|
|
6d9619a3bc | ||
|
|
8ada776324 | ||
|
|
5815e4b437 | ||
|
|
cf33af4572 | ||
|
|
2998d1c2b9 | ||
|
|
4c58e19b04 | ||
|
|
6bdb7dae1c | ||
|
|
3531211984 | ||
|
|
81bcfec8ad | ||
|
|
d15102e14e | ||
|
|
a6064eccb2 | ||
|
|
59edec1e85 | ||
|
|
79bdd86ef1 | ||
|
|
35e5799cd6 | ||
|
|
7d5850214e | ||
|
|
636b680a29 | ||
|
|
3a260e67f8 | ||
|
|
72c57be6a2 | ||
|
|
c791b78825 | ||
|
|
b37c2298b5 | ||
|
|
e68aa5834c | ||
|
|
d20b240a1a | ||
|
|
1c035cc94f | ||
|
|
282f59ea60 | ||
|
|
ca1265214e | ||
|
|
dc459c6cd5 | ||
|
|
16c8789638 | ||
|
|
a496dad9c9 |
@@ -22,11 +22,15 @@ on:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
# Single runner on jazz: serialize all runs so the push-main-then-tag release
|
||||
# flow can't collide on the shared :buildcache tag or the smoke container.
|
||||
# Concurrency is scoped per ref (originally one global group for the single
|
||||
# jazz runner; with 3 runners that serialized the whole queue). PR runs
|
||||
# parallelize across PRs and a new sync auto-cancels its superseded run.
|
||||
# Real image builds (main / v* tags) still serialize within their own ref;
|
||||
# don't push main and a v* tag simultaneously — they share :buildcache and
|
||||
# the smoke container name.
|
||||
concurrency:
|
||||
group: ersatztv-build
|
||||
cancel-in-progress: false
|
||||
group: ersatztv-build-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
REGISTRY: 192.168.1.95:3000
|
||||
@@ -40,13 +44,22 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# only the test job's steps below need the working tree; git history/tags
|
||||
# are only needed by the `build` job's `git describe` (ersatztv#190)
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }}
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
|
||||
@@ -102,8 +115,9 @@ jobs:
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: ersatztv
|
||||
MYSQL_DATABASE: ersatztv_migrations
|
||||
ports:
|
||||
- 3306:3306
|
||||
# No host-port binding: the job reaches this service as mysql:3306 on the shared
|
||||
# runner network. Publishing 3306 made concurrent runs collide ("port is already
|
||||
# allocated") whenever two migrations jobs overlapped.
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -persatztv --silent"
|
||||
--health-interval=5s
|
||||
@@ -112,12 +126,21 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# default fetch-depth: 1 -- this job never runs git describe/log, only
|
||||
# actions/checkout@v4's default (shallow) history is needed (ersatztv#190)
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }}
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
|
||||
@@ -161,7 +184,12 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build & push image (amd64)
|
||||
runs-on: ubuntu-latest
|
||||
# `small` = the dedicated small-jobs runner lane (server-management#574).
|
||||
# On PR runs this job only resolves its skip, but Gitea still dispatches it
|
||||
# as a task — on the ubuntu-latest runners that skip queued behind long
|
||||
# builds (observed 31 min). Real builds (main/tags) run on bumblebee,
|
||||
# capped at 4 CPUs / 10g.
|
||||
runs-on: small
|
||||
needs: [test, migrations]
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
@@ -280,3 +308,36 @@ jobs:
|
||||
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
|
||||
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
|
||||
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
|
||||
# cache-save issues seen on the relocated runner (server-management#570).
|
||||
docs-reminder:
|
||||
name: Docs update reminder
|
||||
runs-on: small # seconds-long git diff; keep it off the build runners
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Warn when a screen/route change skips the parity doc
|
||||
run: |
|
||||
base_ref="${{ github.base_ref }}"
|
||||
git fetch --no-tags --depth=100 origin "$base_ref" || true
|
||||
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
|
||||
echo "Changed files in this PR:"; printf '%s\n' "$changed"
|
||||
screen_or_route=no
|
||||
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
|
||||
screen_or_route=yes
|
||||
fi
|
||||
parity=no
|
||||
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
|
||||
parity=yes
|
||||
fi
|
||||
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
|
||||
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
|
||||
else
|
||||
echo "Parity-doc reminder: nothing to flag."
|
||||
fi
|
||||
|
||||
@@ -56,7 +56,18 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
|
||||
## Conventions
|
||||
|
||||
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, Blazor/MudBlazor, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
|
||||
- **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.
|
||||
- **Docs-first is a HARD RULE — read before you explore**: before ANY API / SPA / E2E / parity / scheduling work, read `docs/README.md` (index) → the convention docs (`api-conventions`, `spa-conventions`, `e2e-local`, `domain-model`, `blazor-route-parity`, `decisions`). **Do NOT reverse-engineer conventions from source (Grep/Read) before reading these** — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). **This applies to delegated subagents too**: tell each agent which doc section to read; never let one re-derive conventions from code.
|
||||
- **Docs-update is part of "done" — same PR, never a follow-up**: any PR that changes a convention, adds/migrates/redirects a route, adds/changes a `/api/*` endpoint, or reverses a decision MUST update the relevant doc in that same PR:
|
||||
|
||||
| Change | Update in the same PR |
|
||||
|---|---|
|
||||
| Migrate / add / redirect a route (new `web/src/screens/*.tsx`, `LegacyUiRedirects.cs`) | `docs/blazor-route-parity.md` + `docs/domain-model.md` |
|
||||
| Add / change a `/api/*` endpoint | `docs/api-conventions.md` checklist, then regenerate `v1.json` + `endpoint-index.md` via `./scripts/update-openapi.sh` |
|
||||
| Change a SPA screen convention | `docs/spa-conventions.md` |
|
||||
| Establish / reverse a convention or decision | `docs/decisions.md` (append-only) + the affected doc |
|
||||
| Add / remove / retitle a doc | `docs/README.md` index |
|
||||
|
||||
The `docs-reminder` CI job flags a screen/route change that skips `blazor-route-parity.md`, but it's a **non-blocking** nudge — the rule is on you, not the check.
|
||||
- Follow existing MediatR CQRS pattern for new features
|
||||
- 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
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ErsatzTV.Application.Filler;
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) =>
|
||||
new(fillerPreset.Id, fillerPreset.Name);
|
||||
new(fillerPreset.Id, fillerPreset.Name, fillerPreset.FillerKind);
|
||||
|
||||
internal static FillerPresetFullResponseModel ProjectToFullResponseModel(FillerPreset fillerPreset) =>
|
||||
new(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
|
||||
public record GetAllFillerPresetsForApi : IRequest<List<FillerPresetResponseModel>>;
|
||||
public record GetAllFillerPresetsForApi(FillerKind? FillerKind = null) : IRequest<List<FillerPresetResponseModel>>;
|
||||
|
||||
@@ -14,9 +14,13 @@ public class GetAllFillerPresetsForApiHandler(IDbContextFactory<TvContext> dbCon
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<FillerPreset> fillerPresets = await dbContext.FillerPresets
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
IQueryable<FillerPreset> query = dbContext.FillerPresets.AsNoTracking();
|
||||
if (request.FillerKind is { } fillerKind)
|
||||
{
|
||||
query = query.Where(fp => fp.FillerKind == fillerKind);
|
||||
}
|
||||
|
||||
List<FillerPreset> fillerPresets = await query.ToListAsync(cancellationToken);
|
||||
return fillerPresets.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,17 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
|
||||
if (_entityLocker.LockLibrary(localLibrary.Id))
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
|
||||
try
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the scanner only unlocks when it receives the message; if the enqueue fails
|
||||
// after we acquired the lock, release it here or it is held forever.
|
||||
_entityLocker.UnlockLibrary(localLibrary.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
return ProjectToViewModel(localLibrary);
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public record QueueLibraryScanByLibraryId(int LibraryId) : IRequest<bool>;
|
||||
public enum QueueLibraryScanResult
|
||||
{
|
||||
Queued,
|
||||
NotFound,
|
||||
SyncDisabled,
|
||||
AlreadyScanning
|
||||
}
|
||||
|
||||
public record QueueLibraryScanByLibraryId(int LibraryId) : IRequest<QueueLibraryScanResult>;
|
||||
|
||||
@@ -17,9 +17,11 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
IEntityLocker locker,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorker,
|
||||
ILogger<QueueLibraryScanByLibraryIdHandler> logger)
|
||||
: IRequestHandler<QueueLibraryScanByLibraryId, bool>
|
||||
: IRequestHandler<QueueLibraryScanByLibraryId, QueueLibraryScanResult>
|
||||
{
|
||||
public async Task<bool> Handle(QueueLibraryScanByLibraryId request, CancellationToken cancellationToken)
|
||||
public async Task<QueueLibraryScanResult> Handle(
|
||||
QueueLibraryScanByLibraryId request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
@@ -40,10 +42,17 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
if (!shouldSyncItems)
|
||||
{
|
||||
logger.LogWarning("Library sync is disabled for library id {Id}", library.Id);
|
||||
return false;
|
||||
return QueueLibraryScanResult.SyncDisabled;
|
||||
}
|
||||
|
||||
if (locker.LockLibrary(library.Id))
|
||||
// A true from LockLibrary confers ownership of exactly one release; a false means a scan
|
||||
// is already in progress and we own no release.
|
||||
if (!locker.LockLibrary(library.Id))
|
||||
{
|
||||
return QueueLibraryScanResult.AlreadyScanning;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
logger.LogDebug("Queued library scan for library id {Id}", library.Id);
|
||||
|
||||
@@ -78,10 +87,18 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the scanner only unlocks when it receives the message; if enqueueing fails
|
||||
// (e.g. request aborted / channel completed) after we acquired the lock, release
|
||||
// it here or it is held forever (EnqueueWithTraktLock pattern).
|
||||
locker.UnlockLibrary(library.Id);
|
||||
throw;
|
||||
}
|
||||
|
||||
return true;
|
||||
return QueueLibraryScanResult.Queued;
|
||||
}
|
||||
|
||||
return false;
|
||||
return QueueLibraryScanResult.NotFound;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,17 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
|
||||
if (_entityLocker.LockLibrary(existing.Id))
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
try
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the scanner only unlocks when it receives the message; if the enqueue fails
|
||||
// after we acquired the lock, release it here or it is held forever.
|
||||
_entityLocker.UnlockLibrary(existing.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -274,7 +274,8 @@ internal static class LibraryBrowseItemMapper
|
||||
null,
|
||||
em.EpisodeId,
|
||||
null,
|
||||
EpisodeSubtitle(em))).ToList());
|
||||
EpisodeSubtitle(em),
|
||||
em.Episode.SeasonId)).ToList());
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBrowseItemResponseModel>> GetMusicVideos(
|
||||
|
||||
@@ -46,7 +46,8 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
|
||||
{ CollectionType.MusicVideo, request.MusicVideoIds },
|
||||
{ CollectionType.OtherVideo, request.OtherVideoIds },
|
||||
{ CollectionType.Song, request.SongIds },
|
||||
{ CollectionType.Image, request.ImageIds }
|
||||
{ CollectionType.Image, request.ImageIds },
|
||||
{ CollectionType.RemoteStream, request.RemoteStreamIds }
|
||||
};
|
||||
|
||||
int index = playlist.Items.Count > 0 ? playlist.Items.Max(i => i.Index) + 1 : 0;
|
||||
@@ -81,17 +82,24 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
|
||||
await ValidateMovies(request),
|
||||
await ValidateShows(request),
|
||||
await ValidateSeasons(request),
|
||||
await ValidateEpisodes(request))
|
||||
.Apply((collection, _, _, _, _) => collection);
|
||||
await ValidateEpisodes(request),
|
||||
await ValidateMediaItems(dbContext, request, cancellationToken))
|
||||
.Apply((collection, _, _, _, _, _) => collection);
|
||||
|
||||
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(
|
||||
private static async Task<Validation<BaseError, Playlist>> PlaylistMustExist(
|
||||
TvContext dbContext,
|
||||
AddItemsToPlaylist request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Playlists
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Playlist> maybePlaylist = await dbContext.Playlists
|
||||
.Include(c => c.Items)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Playlist does not exist."));
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId, cancellationToken);
|
||||
|
||||
return maybePlaylist.ToValidation<BaseError>("Playlist does not exist.")
|
||||
.Bind(playlist => playlist.IsSystem
|
||||
? BaseError.New("Cannot add items to system (generated) playlist")
|
||||
: Success<BaseError, Playlist>(playlist));
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToPlaylist request) =>
|
||||
_movieRepository.AllMoviesExist(request.MovieIds)
|
||||
@@ -120,4 +128,30 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
|
||||
.Filter(v => v == true)
|
||||
.MapT(_ => Unit.Default)
|
||||
.Map(v => v.ToValidation<BaseError>("Episode does not exist"));
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> ValidateMediaItems(
|
||||
TvContext dbContext,
|
||||
AddItemsToPlaylist request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> ids = GetRequestedMediaItemIds(request).Distinct().ToList();
|
||||
int existingCount = await dbContext.MediaItems
|
||||
.CountAsync(mi => ids.Contains(mi.Id), cancellationToken);
|
||||
|
||||
return existingCount == ids.Count
|
||||
? Unit.Default
|
||||
: BaseError.New("Media item does not exist");
|
||||
}
|
||||
|
||||
private static IEnumerable<int> GetRequestedMediaItemIds(AddItemsToPlaylist request) =>
|
||||
request.MovieIds
|
||||
.Append(request.ShowIds)
|
||||
.Append(request.SeasonIds)
|
||||
.Append(request.EpisodeIds)
|
||||
.Append(request.ArtistIds)
|
||||
.Append(request.MusicVideoIds)
|
||||
.Append(request.OtherVideoIds)
|
||||
.Append(request.SongIds)
|
||||
.Append(request.ImageIds)
|
||||
.Append(request.RemoteStreamIds);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ public class CreateRerunCollectionHandler(IDbContextFactory<TvContext> dbContext
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, RerunCollection> validation = await Validate(dbContext, request);
|
||||
Validation<BaseError, RerunCollection> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => PersistCollection(dbContext, c));
|
||||
}
|
||||
|
||||
@@ -27,10 +27,20 @@ public class CreateRerunCollectionHandler(IDbContextFactory<TvContext> dbContext
|
||||
return ProjectToViewModel(collection);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, RerunCollection>> Validate(
|
||||
private static async Task<Validation<BaseError, RerunCollection>> Validate(
|
||||
TvContext dbContext,
|
||||
CreateRerunCollection request) =>
|
||||
ValidateName(dbContext, request).MapT(name => new RerunCollection
|
||||
CreateRerunCollection request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await ValidateName(dbContext, request),
|
||||
await RerunCollectionSelectionValidation.SelectedEntityMustExist(
|
||||
dbContext,
|
||||
request.CollectionType,
|
||||
request.Collection?.Id,
|
||||
request.MultiCollection?.Id,
|
||||
request.SmartCollection?.Id,
|
||||
request.MediaItem?.MediaItemId,
|
||||
cancellationToken))
|
||||
.Apply((name, _) => new RerunCollection
|
||||
{
|
||||
Name = name,
|
||||
CollectionType = request.CollectionType,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record RenamePlaylistGroup(int PlaylistGroupId, string Name)
|
||||
: IRequest<Either<BaseError, PlaylistGroupViewModel>>;
|
||||
@@ -0,0 +1,60 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class RenamePlaylistGroupHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<RenamePlaylistGroup, Either<BaseError, PlaylistGroupViewModel>>
|
||||
{
|
||||
public async Task<Either<BaseError, PlaylistGroupViewModel>> Handle(
|
||||
RenamePlaylistGroup request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, PlaylistGroup> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(playlistGroup => Persist(dbContext, request, playlistGroup));
|
||||
}
|
||||
|
||||
private static async Task<PlaylistGroupViewModel> Persist(
|
||||
TvContext dbContext,
|
||||
RenamePlaylistGroup request,
|
||||
PlaylistGroup playlistGroup)
|
||||
{
|
||||
playlistGroup.Name = request.Name;
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Mapper.ProjectToViewModel(playlistGroup);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, PlaylistGroup>> Validate(
|
||||
TvContext dbContext,
|
||||
RenamePlaylistGroup request,
|
||||
CancellationToken cancellationToken) =>
|
||||
PlaylistGroupMustExist(dbContext, request, cancellationToken)
|
||||
.BindT(PlaylistGroupMustNotBeSystem)
|
||||
.BindT(playlistGroup => ValidateName(request).Map(_ => playlistGroup));
|
||||
|
||||
private static Task<Validation<BaseError, PlaylistGroup>> PlaylistGroupMustExist(
|
||||
TvContext dbContext,
|
||||
RenamePlaylistGroup request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.PlaylistGroups
|
||||
.Include(pg => pg.Playlists)
|
||||
.SelectOneAsync(pg => pg.Id, pg => pg.Id == request.PlaylistGroupId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>(
|
||||
new NotFoundError($"PlaylistGroup {request.PlaylistGroupId} does not exist.")));
|
||||
|
||||
// Plain BaseError (NOT NotFoundError) so it maps to 422, mirroring DeletePlaylistGroupHandler's
|
||||
// system-group guard. A missing group still surfaces as NotFoundError (404) from PlaylistGroupMustExist.
|
||||
private static Validation<BaseError, PlaylistGroup> PlaylistGroupMustNotBeSystem(PlaylistGroup playlistGroup) =>
|
||||
playlistGroup.IsSystem
|
||||
? BaseError.New("Cannot rename system playlist group")
|
||||
: playlistGroup;
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(RenamePlaylistGroup request) =>
|
||||
request.NotEmpty(x => x.Name)
|
||||
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
/// <summary>
|
||||
/// Shared existence check for the id a rerun collection selects via <see cref="CollectionType" />
|
||||
/// (see <c>RerunCollectionRequestMapping.ResolveSelection</c> in the API layer). Used by both
|
||||
/// <see cref="CreateRerunCollectionHandler" /> and <see cref="UpdateRerunCollectionHandler" /> so
|
||||
/// a bad/stale id fails validation (422) instead of persisting a dangling FK or 500ing at
|
||||
/// SaveChanges.
|
||||
/// </summary>
|
||||
internal static class RerunCollectionSelectionValidation
|
||||
{
|
||||
public static Task<Validation<BaseError, Unit>> SelectedEntityMustExist(
|
||||
TvContext dbContext,
|
||||
CollectionType collectionType,
|
||||
int? collectionId,
|
||||
int? multiCollectionId,
|
||||
int? smartCollectionId,
|
||||
int? mediaItemId,
|
||||
CancellationToken cancellationToken) =>
|
||||
collectionType switch
|
||||
{
|
||||
CollectionType.Collection => Exists(
|
||||
dbContext.Collections,
|
||||
collectionId,
|
||||
"Collection does not exist.",
|
||||
cancellationToken),
|
||||
CollectionType.MultiCollection => Exists(
|
||||
dbContext.MultiCollections,
|
||||
multiCollectionId,
|
||||
"Multi collection does not exist.",
|
||||
cancellationToken),
|
||||
CollectionType.SmartCollection => Exists(
|
||||
dbContext.SmartCollections,
|
||||
smartCollectionId,
|
||||
"Smart collection does not exist.",
|
||||
cancellationToken),
|
||||
CollectionType.TelevisionShow => Exists(
|
||||
dbContext.Shows,
|
||||
mediaItemId,
|
||||
"Show does not exist.",
|
||||
cancellationToken),
|
||||
CollectionType.TelevisionSeason => Exists(
|
||||
dbContext.Seasons,
|
||||
mediaItemId,
|
||||
"Season does not exist.",
|
||||
cancellationToken),
|
||||
CollectionType.Artist => Exists(
|
||||
dbContext.Artists,
|
||||
mediaItemId,
|
||||
"Artist does not exist.",
|
||||
cancellationToken),
|
||||
CollectionType.Movie => Exists(
|
||||
dbContext.Movies,
|
||||
mediaItemId,
|
||||
"Movie does not exist.",
|
||||
cancellationToken),
|
||||
CollectionType.Episode => Exists(
|
||||
dbContext.Episodes,
|
||||
mediaItemId,
|
||||
"Episode does not exist.",
|
||||
cancellationToken),
|
||||
CollectionType.MusicVideo => Exists(
|
||||
dbContext.MusicVideos,
|
||||
mediaItemId,
|
||||
"Music video does not exist.",
|
||||
cancellationToken),
|
||||
CollectionType.OtherVideo => Exists(
|
||||
dbContext.OtherVideos,
|
||||
mediaItemId,
|
||||
"Other video does not exist.",
|
||||
cancellationToken),
|
||||
CollectionType.Song => Exists(
|
||||
dbContext.Songs,
|
||||
mediaItemId,
|
||||
"Song does not exist.",
|
||||
cancellationToken),
|
||||
CollectionType.Image => Exists(
|
||||
dbContext.Images,
|
||||
mediaItemId,
|
||||
"Image does not exist.",
|
||||
cancellationToken),
|
||||
CollectionType.RemoteStream => Exists(
|
||||
dbContext.RemoteStreams,
|
||||
mediaItemId,
|
||||
"Remote stream does not exist.",
|
||||
cancellationToken),
|
||||
_ => Task.FromResult(
|
||||
Fail<BaseError, Unit>($"Unsupported collection type '{collectionType}' for a rerun collection."))
|
||||
};
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> Exists<T>(
|
||||
DbSet<T> set,
|
||||
int? id,
|
||||
string errorMessage,
|
||||
CancellationToken cancellationToken)
|
||||
where T : class
|
||||
{
|
||||
// pure existence check — no tracking, nothing materialized into the save-path context
|
||||
if (id is null ||
|
||||
!await set.AnyAsync(e => EF.Property<int>(e, "Id") == id.Value, cancellationToken))
|
||||
{
|
||||
return Fail<BaseError, Unit>(errorMessage);
|
||||
}
|
||||
|
||||
return Success<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,17 @@ public class UpdateRerunCollectionHandler(
|
||||
TvContext dbContext,
|
||||
UpdateRerunCollection request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await RerunCollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
|
||||
.Apply((collectionToUpdate, _) => collectionToUpdate);
|
||||
(await RerunCollectionMustExist(dbContext, request, cancellationToken),
|
||||
await ValidateName(dbContext, request),
|
||||
await RerunCollectionSelectionValidation.SelectedEntityMustExist(
|
||||
dbContext,
|
||||
request.CollectionType,
|
||||
request.Collection?.Id,
|
||||
request.MultiCollection?.Id,
|
||||
request.SmartCollection?.Id,
|
||||
request.MediaItem?.MediaItemId,
|
||||
cancellationToken))
|
||||
.Apply((collectionToUpdate, _, _) => collectionToUpdate);
|
||||
|
||||
private static Task<Validation<BaseError, RerunCollection>> RerunCollectionMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -55,7 +55,17 @@ public class UpdateTraktListHandler(
|
||||
|
||||
if (entityLocker.LockTrakt())
|
||||
{
|
||||
await workerChannel.WriteAsync(new MatchTraktListItems(traktList.Id), cancellationToken);
|
||||
try
|
||||
{
|
||||
await workerChannel.WriteAsync(new MatchTraktListItems(traktList.Id), cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the background handler only unlocks when it receives the message; if the
|
||||
// enqueue fails after we acquired the lock, release it here or it is held forever.
|
||||
entityLocker.UnlockTrakt();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (traktList.PlaylistId is not null)
|
||||
|
||||
@@ -16,32 +16,58 @@ public class GetCollectionItemsHandler(IDbContextFactory<TvContext> dbContextFac
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
bool exists = await dbContext.Collections
|
||||
// A null flag here means the collection row does not exist (the projection yields no row),
|
||||
// which lets a single query serve both the existence check and the custom-order flag.
|
||||
bool? useCustomPlaybackOrder = await dbContext.Collections
|
||||
.AsNoTracking()
|
||||
.AnyAsync(c => c.Id == request.Id, cancellationToken);
|
||||
if (!exists)
|
||||
.Where(c => c.Id == request.Id)
|
||||
.Select(c => (bool?)c.UseCustomPlaybackOrder)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
if (useCustomPlaybackOrder is null)
|
||||
{
|
||||
return new NotFoundError($"Collection {request.Id} does not exist.");
|
||||
}
|
||||
|
||||
// The collection graph is bounded, so load every member 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
|
||||
// The collection graph is bounded, so load every member (with its CustomIndex) and hydrate
|
||||
// them in one shared pass (LibraryBrowseItemMapper), then order + page in-memory. Mixed media
|
||||
// kinds are supported because MediaItem ids are globally unique across kinds.
|
||||
var collectionItems = await dbContext.CollectionItems
|
||||
.AsNoTracking()
|
||||
.Where(ci => ci.CollectionId == request.Id)
|
||||
.Select(ci => ci.MediaItemId)
|
||||
.Select(ci => new { ci.MediaItemId, ci.CustomIndex })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
List<int> mediaItemIds = collectionItems.Select(ci => ci.MediaItemId).ToList();
|
||||
|
||||
List<LibraryBrowseItemResponseModel> all =
|
||||
await LibraryBrowseItemMapper.HydrateMediaItemsByIds(dbContext, mediaItemIds, cancellationToken);
|
||||
|
||||
// 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();
|
||||
List<LibraryBrowseItemResponseModel> ordered;
|
||||
if (useCustomPlaybackOrder.Value)
|
||||
{
|
||||
// Custom order: sort by CustomIndex (items without one sort last), then title/id as a
|
||||
// stable tiebreak.
|
||||
var customIndexByMediaItemId = collectionItems
|
||||
.GroupBy(ci => ci.MediaItemId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(ci => ci.CustomIndex).FirstOrDefault());
|
||||
|
||||
ordered = all
|
||||
.OrderBy(i => customIndexByMediaItemId.TryGetValue(i.Id, out int? customIndex) && customIndex.HasValue
|
||||
? customIndex.Value
|
||||
: int.MaxValue)
|
||||
.ThenBy(i => i.Title, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(i => i.Id)
|
||||
.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Stable title ordering mirrors the library-browse handler (which orders its rows by name),
|
||||
// giving the SPA a deterministic, browsable list independent of collection insertion order.
|
||||
ordered = all
|
||||
.OrderBy(i => i.Title, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(i => i.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
int pageNum = Math.Max(0, request.PageNum);
|
||||
int pageSize = Math.Clamp(request.PageSize, 1, 100);
|
||||
|
||||
@@ -23,6 +23,7 @@ internal static class Mapper
|
||||
|
||||
internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
|
||||
new(
|
||||
playoutItem.Id,
|
||||
GetDisplayTitle(playoutItem.MediaItem, playoutItem.ChapterTitle),
|
||||
playoutItem.StartOffset,
|
||||
playoutItem.FinishOffset,
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain.Filler;
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record PlayoutItemViewModel(
|
||||
int? Id,
|
||||
string Title,
|
||||
DateTimeOffset Start,
|
||||
DateTimeOffset Finish,
|
||||
|
||||
@@ -106,7 +106,9 @@ public class GetFuturePlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbCon
|
||||
|
||||
var gap = playoutGaps.Single(g => g.Id == c.Id);
|
||||
TimeSpan gapDuration = gap.Finish - gap.Start;
|
||||
// gaps are synthesized rows, not PlayoutItems, so they carry no row id
|
||||
return new PlayoutItemViewModel(
|
||||
null,
|
||||
"UNSCHEDULED",
|
||||
gap.StartOffset,
|
||||
gap.FinishOffset,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record GetPlayoutItemSchedulingContext(int PlayoutItemId) : IRequest<Option<string>>;
|
||||
@@ -0,0 +1,33 @@
|
||||
using ErsatzTV.Application.ProgramSchedules;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class GetPlayoutItemSchedulingContextHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IMediator mediator)
|
||||
: IRequestHandler<GetPlayoutItemSchedulingContext, Option<string>>
|
||||
{
|
||||
public async Task<Option<string>> Handle(
|
||||
GetPlayoutItemSchedulingContext request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
string serializedContext = await dbContext.PlayoutItems
|
||||
.AsNoTracking()
|
||||
.Where(pi => pi.Id == request.PlayoutItemId)
|
||||
.Select(pi => pi.SchedulingContext)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(serializedContext))
|
||||
{
|
||||
return Option<string>.None;
|
||||
}
|
||||
|
||||
// Decode/enrich exactly the way the troubleshooting decode path does, reusing the
|
||||
// single ProcessSchedulingContext handler so any future format change stays in one place.
|
||||
return await mediator.Send(new ProcessSchedulingContext(serializedContext), cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,20 @@ public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string
|
||||
int PlexLibraryId { get; }
|
||||
bool ForceScan { get; }
|
||||
bool DeepScan { get; }
|
||||
|
||||
// When false, the ScannerService keeps the library lock held after this message so a later
|
||||
// message in the same batch (e.g. SynchronizePlexNetworks) carries the single release.
|
||||
bool Unlock { get; }
|
||||
}
|
||||
|
||||
public record SynchronizePlexLibraryByIdIfNeeded(int PlexLibraryId) : ISynchronizePlexLibraryById
|
||||
public record SynchronizePlexLibraryByIdIfNeeded(int PlexLibraryId, bool Unlock = true) : ISynchronizePlexLibraryById
|
||||
{
|
||||
public bool ForceScan => false;
|
||||
public bool DeepScan => false;
|
||||
}
|
||||
|
||||
public record ForceSynchronizePlexLibraryById(int PlexLibraryId, bool DeepScan) : ISynchronizePlexLibraryById
|
||||
public record ForceSynchronizePlexLibraryById(int PlexLibraryId, bool DeepScan, bool Unlock = true)
|
||||
: ISynchronizePlexLibraryById
|
||||
{
|
||||
public bool ForceScan => true;
|
||||
}
|
||||
|
||||
@@ -2,5 +2,9 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public record SynchronizePlexNetworks(int PlexLibraryId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
// Unlock defaults true so single-message callers release the library lock as before. In the Plex
|
||||
// "Shows" scheduler batch this message runs LAST and carries the single release (the preceding
|
||||
// library message runs with Unlock: false).
|
||||
public record SynchronizePlexNetworks(int PlexLibraryId, bool ForceScan, bool Unlock = true)
|
||||
: IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -60,7 +60,15 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
|
||||
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
|
||||
}
|
||||
|
||||
return ProjectToViewModel(item);
|
||||
// reload with the full navigation graph before projecting: BuildItem creates the watermark/graphics
|
||||
// join rows with only their foreign-key ids set, so the tracked entities have null Watermark /
|
||||
// GraphicsElement navs that ProjectToViewModel dereferences (would 500 on POST — see #229).
|
||||
ProgramScheduleItem persisted = await dbContext.ProgramScheduleItems
|
||||
.Filter(psi => psi.Id == item.Id)
|
||||
.IncludeScheduleItemDetails()
|
||||
.SingleAsync(cancellationToken);
|
||||
|
||||
return ProjectToViewModel(persisted);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
|
||||
+10
-1
@@ -56,7 +56,16 @@ public class ReplaceProgramScheduleItemsHandler(
|
||||
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
|
||||
}
|
||||
|
||||
return programSchedule.Items.Map(ProjectToViewModel);
|
||||
// reload with the full navigation graph before projecting: BuildItem creates the watermark/graphics
|
||||
// join rows with only their foreign-key ids set, so the tracked entities have null Watermark /
|
||||
// GraphicsElement navs that ProjectToViewModel dereferences (would 500 on PUT — see #229).
|
||||
List<ProgramScheduleItem> persisted = await dbContext.ProgramScheduleItems
|
||||
.Filter(psi => psi.ProgramScheduleId == programSchedule.Id)
|
||||
.IncludeScheduleItemDetails()
|
||||
.OrderBy(i => i.Index)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return persisted.Map(ProjectToViewModel).ToList();
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
internal static class ProgramScheduleItemQueryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The single source of truth for the navigation graph a <see cref="ProgramScheduleItem" /> needs before
|
||||
/// it can be projected via <see cref="Mapper.ProjectToViewModel" />. The mapper dereferences the
|
||||
/// <c>Watermark</c> / <c>GraphicsElement</c> navs of each join row without a null guard, so any handler
|
||||
/// that projects freshly-persisted items MUST reload through this include chain (see #229 — the write
|
||||
/// path used to project the tracked-but-unloaded graph and threw a NullReferenceException, surfacing as a
|
||||
/// 500 on PUT/POST whenever an item carried a watermark or graphics element).
|
||||
/// </summary>
|
||||
public static IQueryable<ProgramScheduleItem> IncludeScheduleItemDetails(this IQueryable<ProgramScheduleItem> query) =>
|
||||
query
|
||||
.Include(i => i.Collection)
|
||||
.Include(i => i.MultiCollection)
|
||||
.Include(i => i.SmartCollection)
|
||||
.Include(i => i.RerunCollection)
|
||||
.Include(i => i.Playlist)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Season).SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Season).Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Show).ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Artist).ArtistMetadata)
|
||||
.ThenInclude(am => am.Artwork)
|
||||
.Include(i => i.PreRollFiller)
|
||||
.Include(i => i.MidRollFiller)
|
||||
.Include(i => i.PostRollFiller)
|
||||
.Include(i => i.TailFiller)
|
||||
.Include(i => i.FallbackFiller)
|
||||
.Include(i => i.ProgramScheduleItemWatermarks)
|
||||
.ThenInclude(i => i.Watermark)
|
||||
.Include(i => i.ProgramScheduleItemGraphicsElements)
|
||||
.ThenInclude(i => i.GraphicsElement);
|
||||
}
|
||||
@@ -20,36 +20,8 @@ public class GetProgramScheduleItemsHandler(IDbContextFactory<TvContext> dbConte
|
||||
|
||||
return await dbContext.ProgramScheduleItems
|
||||
.Filter(psi => psi.ProgramScheduleId == request.Id)
|
||||
.Include(i => i.Collection)
|
||||
.Include(i => i.MultiCollection)
|
||||
.Include(i => i.SmartCollection)
|
||||
.Include(i => i.RerunCollection)
|
||||
.Include(i => i.Playlist)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Season).SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Season).Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Show).ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Artist).ArtistMetadata)
|
||||
.ThenInclude(am => am.Artwork)
|
||||
.Include(i => i.PreRollFiller)
|
||||
.Include(i => i.MidRollFiller)
|
||||
.Include(i => i.PostRollFiller)
|
||||
.Include(i => i.TailFiller)
|
||||
.Include(i => i.FallbackFiller)
|
||||
.Include(i => i.ProgramScheduleItemWatermarks)
|
||||
.ThenInclude(i => i.Watermark)
|
||||
.Include(i => i.ProgramScheduleItemGraphicsElements)
|
||||
.ThenInclude(i => i.GraphicsElement)
|
||||
.IncludeScheduleItemDetails()
|
||||
.OrderBy(i => i.Index)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(programScheduleItems => programScheduleItems.Map(ProjectToViewModel)
|
||||
.Map(psi => EnforceProperties(maybeProgramSchedule, psi)).ToList());
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Api;
|
||||
using ErsatzTV.Core.Api.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the polymorphic <see cref="ProgramScheduleItemViewModel" /> hierarchy to the flat,
|
||||
/// fully-described <see cref="ScheduleItemResponseModel" /> API DTO (issue #126). Subtype-specific
|
||||
/// fields (Multiple's mode/count, Duration's playoutDuration/tailMode/discardToFillAttempts) are
|
||||
/// read by pattern-matching the concrete VM type; they are null for other subtypes.
|
||||
/// </summary>
|
||||
public static class ScheduleItemResponseMapper
|
||||
{
|
||||
public static ScheduleItemResponseModel ProjectToResponseModel(ProgramScheduleItemViewModel item)
|
||||
{
|
||||
MultipleMode? multipleMode = null;
|
||||
string multipleCount = null;
|
||||
TimeSpan? playoutDuration = null;
|
||||
TailMode? tailMode = null;
|
||||
int? discardToFillAttempts = null;
|
||||
|
||||
switch (item)
|
||||
{
|
||||
case ProgramScheduleItemMultipleViewModel multiple:
|
||||
multipleMode = multiple.MultipleMode;
|
||||
multipleCount = multiple.Count;
|
||||
break;
|
||||
case ProgramScheduleItemDurationViewModel duration:
|
||||
playoutDuration = duration.PlayoutDuration;
|
||||
tailMode = duration.TailMode;
|
||||
discardToFillAttempts = duration.DiscardToFillAttempts;
|
||||
break;
|
||||
}
|
||||
|
||||
List<int> watermarkIds = item.Watermarks?.Map(w => w.Id).ToList() ?? new List<int>();
|
||||
List<int> graphicsElementIds = item.GraphicsElements?.Map(g => g.Id).ToList() ?? new List<int>();
|
||||
List<NamedIdResponseModel> watermarks =
|
||||
item.Watermarks?.Map(w => new NamedIdResponseModel(w.Id, w.Name)).ToList()
|
||||
?? new List<NamedIdResponseModel>();
|
||||
List<NamedIdResponseModel> graphicsElements =
|
||||
item.GraphicsElements?.Map(g => new NamedIdResponseModel(g.Id, g.Name)).ToList()
|
||||
?? new List<NamedIdResponseModel>();
|
||||
|
||||
return new ScheduleItemResponseModel(
|
||||
item.Id,
|
||||
item.Index,
|
||||
item.StartType,
|
||||
item.StartTime,
|
||||
item.FixedStartTimeBehavior,
|
||||
item.PlayoutMode,
|
||||
item.CollectionType,
|
||||
item.Collection?.Id,
|
||||
item.MultiCollection?.Id,
|
||||
item.SmartCollection?.Id,
|
||||
item.RerunCollection?.Id,
|
||||
item.MediaItem?.MediaItemId,
|
||||
item.Playlist?.Id,
|
||||
item.SearchTitle,
|
||||
item.SearchQuery,
|
||||
item.PlaybackOrder,
|
||||
item.MarathonGroupBy,
|
||||
item.MarathonShuffleGroups,
|
||||
item.MarathonShuffleItems,
|
||||
item.MarathonBatchSize,
|
||||
item.FillWithGroupMode,
|
||||
multipleMode,
|
||||
multipleCount,
|
||||
playoutDuration,
|
||||
tailMode,
|
||||
discardToFillAttempts,
|
||||
item.CustomTitle,
|
||||
item.GuideMode,
|
||||
item.PreRollFiller?.Id,
|
||||
item.MidRollFiller?.Id,
|
||||
item.PostRollFiller?.Id,
|
||||
item.TailFiller?.Id,
|
||||
item.FallbackFiller?.Id,
|
||||
watermarkIds,
|
||||
graphicsElementIds,
|
||||
item.PreferredAudioLanguageCode,
|
||||
item.PreferredAudioTitle,
|
||||
item.PreferredSubtitleLanguageCode,
|
||||
item.SubtitleMode,
|
||||
item.Collection?.Name,
|
||||
item.MultiCollection?.Name,
|
||||
item.SmartCollection?.Name,
|
||||
item.RerunCollection?.Name,
|
||||
item.Playlist?.Name,
|
||||
item.Playlist?.PlaylistGroupId,
|
||||
item.MediaItem?.Name,
|
||||
item.PreRollFiller?.Name,
|
||||
item.MidRollFiller?.Name,
|
||||
item.PostRollFiller?.Name,
|
||||
item.TailFiller?.Name,
|
||||
item.FallbackFiller?.Name,
|
||||
watermarks,
|
||||
graphicsElements,
|
||||
item.Name,
|
||||
item.DurationEstimate);
|
||||
}
|
||||
|
||||
public static ScheduleItemsResponseModel ProjectToResponseModel(ProgramScheduleItemsWithDurationViewModel vm) =>
|
||||
new(
|
||||
vm.Items.Map(ProjectToResponseModel).ToList(),
|
||||
vm.TotalDurationEstimate);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Troubleshooting;
|
||||
using ErsatzTV.Core.Notifications;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
@@ -34,6 +35,7 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
ISongVideoGenerator songVideoGenerator,
|
||||
IWatermarkSelector watermarkSelector,
|
||||
IEntityLocker entityLocker,
|
||||
ITroubleshootingPlaybackStatusStore statusStore,
|
||||
IMediator mediator,
|
||||
LoggingLevelSwitches loggingLevelSwitches,
|
||||
ILogger<PrepareTroubleshootingPlaybackHandler> logger)
|
||||
@@ -68,6 +70,7 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
}
|
||||
|
||||
entityLocker.LockTroubleshootingPlayback();
|
||||
statusStore.Reset();
|
||||
|
||||
localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder);
|
||||
localFileSystem.EmptyFolder(FileSystemLayout.TranscodeTroubleshootingFolder);
|
||||
@@ -166,6 +169,7 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
}
|
||||
|
||||
entityLocker.LockTroubleshootingPlayback();
|
||||
statusStore.Reset();
|
||||
|
||||
localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder);
|
||||
localFileSystem.EmptyFolder(FileSystemLayout.TranscodeTroubleshootingFolder);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using ErsatzTV.Core.Interfaces.Troubleshooting;
|
||||
using ErsatzTV.Core.Notifications;
|
||||
|
||||
namespace ErsatzTV.Application.Troubleshooting;
|
||||
|
||||
public class RecordTroubleshootingPlaybackStatusHandler(ITroubleshootingPlaybackStatusStore statusStore)
|
||||
: INotificationHandler<PlaybackTroubleshootingCompletedNotification>
|
||||
{
|
||||
public Task Handle(PlaybackTroubleshootingCompletedNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
statusStore.RecordCompletion(notification.ExitCode, notification.MaybeSpeed);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ public class FakeTelevisionRepository : ITelevisionRepository
|
||||
public Task<List<Show>> GetAllShows() => throw new NotSupportedException();
|
||||
|
||||
public Task<Option<Show>> GetShow(int showId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<Option<int>> GetShowIdByTitle(int libraryId, string title) => throw new NotSupportedException();
|
||||
public Task<Option<string>> GetShowTitle(int libraryId, int showId) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
|
||||
public Task<List<int>> GetEpisodeIdsForShow(int showId) => throw new NotSupportedException();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Filler;
|
||||
|
||||
public record FillerPresetResponseModel(int Id, string Name);
|
||||
public record FillerPresetResponseModel(int Id, string Name, FillerKind FillerKind);
|
||||
|
||||
@@ -20,4 +20,5 @@ public record LibraryBrowseItemResponseModel(
|
||||
int? RerunCollectionId,
|
||||
int? MediaItemId,
|
||||
int? PlaylistId,
|
||||
string? Subtitle = null);
|
||||
string? Subtitle = null,
|
||||
int? SeasonId = null);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#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);
|
||||
@@ -0,0 +1,21 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.MediaCollections;
|
||||
|
||||
public record PlaylistItemResponseModel(
|
||||
int Id,
|
||||
int Index,
|
||||
CollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
string? CollectionName,
|
||||
int? MultiCollectionId,
|
||||
string? MultiCollectionName,
|
||||
int? SmartCollectionId,
|
||||
string? SmartCollectionName,
|
||||
int? MediaItemId,
|
||||
string? MediaItemName,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int? Count,
|
||||
bool PlayAll,
|
||||
bool IncludeInProgramGuide);
|
||||
@@ -0,0 +1,4 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.MediaCollections;
|
||||
|
||||
public record PlaylistPreviewItemResponseModel(string Title, string Start, string Finish, string Duration);
|
||||
@@ -0,0 +1,17 @@
|
||||
#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);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Api.MediaItems;
|
||||
|
||||
public record LanguageCodeResponseModel(string Code, string EnglishName);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Api;
|
||||
|
||||
/// <summary>
|
||||
/// A lightweight {id, name} pair used when an API response needs to embed a reference to a
|
||||
/// named entity (e.g. a schedule item's watermarks or graphics elements) without projecting
|
||||
/// the full entity DTO.
|
||||
/// </summary>
|
||||
public record NamedIdResponseModel(int Id, string Name);
|
||||
@@ -1,10 +1,13 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Playouts;
|
||||
|
||||
public record PlayoutItemResponseModel(
|
||||
int? Id,
|
||||
string Title,
|
||||
DateTimeOffset Start,
|
||||
DateTimeOffset Finish,
|
||||
string Duration,
|
||||
FillerKind? FillerKind);
|
||||
FillerKind? FillerKind,
|
||||
bool HasSchedulingContext);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Playouts;
|
||||
|
||||
public record PlayoutItemSchedulingContextResponseModel(string Context);
|
||||
@@ -10,4 +10,6 @@ public record PlayoutListItemResponseModel(
|
||||
PlayoutScheduleKind ScheduleKind,
|
||||
string ScheduleName,
|
||||
TimeSpan? DailyRebuildTime,
|
||||
PlayoutBuildStatusResponseModel? BuildStatus);
|
||||
PlayoutBuildStatusResponseModel? BuildStatus,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
bool IsLocked);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// A flat, non-polymorphic projection of a program schedule item (issue #126). Unlike the
|
||||
/// Application-layer <c>ProgramScheduleItemViewModel</c> hierarchy (One/Flood/Multiple/Duration
|
||||
/// subtypes), every subtype-specific field is a nullable member here so the OpenAPI schema fully
|
||||
/// describes the shape. The mutation fields are named 1:1 with
|
||||
/// <c>ScheduleItemRequest</c> so a GET can be mapped losslessly back to a PUT/POST request.
|
||||
/// </summary>
|
||||
public record ScheduleItemResponseModel(
|
||||
int Id,
|
||||
int Index,
|
||||
StartType StartType,
|
||||
TimeSpan? StartTime,
|
||||
FixedStartTimeBehavior? FixedStartTimeBehavior,
|
||||
PlayoutMode PlayoutMode,
|
||||
CollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MultiCollectionId,
|
||||
int? SmartCollectionId,
|
||||
int? RerunCollectionId,
|
||||
int? MediaItemId,
|
||||
int? PlaylistId,
|
||||
string? SearchTitle,
|
||||
string? SearchQuery,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
MarathonGroupBy MarathonGroupBy,
|
||||
bool MarathonShuffleGroups,
|
||||
bool MarathonShuffleItems,
|
||||
int? MarathonBatchSize,
|
||||
FillWithGroupMode FillWithGroupMode,
|
||||
MultipleMode? MultipleMode,
|
||||
string? MultipleCount,
|
||||
TimeSpan? PlayoutDuration,
|
||||
TailMode? TailMode,
|
||||
int? DiscardToFillAttempts,
|
||||
string? CustomTitle,
|
||||
GuideMode GuideMode,
|
||||
int? PreRollFillerId,
|
||||
int? MidRollFillerId,
|
||||
int? PostRollFillerId,
|
||||
int? TailFillerId,
|
||||
int? FallbackFillerId,
|
||||
List<int> WatermarkIds,
|
||||
List<int> GraphicsElementIds,
|
||||
string? PreferredAudioLanguageCode,
|
||||
string? PreferredAudioTitle,
|
||||
string? PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode? SubtitleMode,
|
||||
string? CollectionName,
|
||||
string? MultiCollectionName,
|
||||
string? SmartCollectionName,
|
||||
string? RerunCollectionName,
|
||||
string? PlaylistName,
|
||||
int? PlaylistGroupId,
|
||||
string? MediaItemName,
|
||||
string? PreRollFillerName,
|
||||
string? MidRollFillerName,
|
||||
string? PostRollFillerName,
|
||||
string? TailFillerName,
|
||||
string? FallbackFillerName,
|
||||
List<NamedIdResponseModel> Watermarks,
|
||||
List<NamedIdResponseModel> GraphicsElements,
|
||||
string? Name,
|
||||
TimeSpan? DurationEstimate);
|
||||
@@ -0,0 +1,12 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// GET envelope for a schedule's items. Carries the flat <see cref="ScheduleItemResponseModel" />
|
||||
/// list plus a best-effort total runtime estimate (null when unbounded/unknown).
|
||||
/// </summary>
|
||||
public record ScheduleItemsResponseModel(
|
||||
List<ScheduleItemResponseModel> Items,
|
||||
TimeSpan? TotalDurationEstimate);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ErsatzTV.Core.Api.Search;
|
||||
|
||||
public record SearchResultAllItemsResponseModel(
|
||||
List<int> MovieIds,
|
||||
List<int> ShowIds,
|
||||
List<int> SeasonIds,
|
||||
List<int> EpisodeIds,
|
||||
List<int> ArtistIds,
|
||||
List<int> MusicVideoIds,
|
||||
List<int> OtherVideoIds,
|
||||
List<int> SongIds,
|
||||
List<int> ImageIds,
|
||||
List<int> RemoteStreamIds);
|
||||
@@ -0,0 +1,15 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Troubleshooting;
|
||||
|
||||
// State machine for the SPA's playback-status poll:
|
||||
// "idle" - no session has run since startup / reset
|
||||
// "running" - a session is in flight (IEntityLocker.IsTroubleshootingPlaybackLocked())
|
||||
// "completed" - the last session finished with exit code 0
|
||||
// "failed" - the last session finished with a non-zero exit code
|
||||
// ExitCode/Speed are populated once a result has been recorded; Logs is the tail (last 500 lines)
|
||||
// of the session's logs.txt when present.
|
||||
public record TroubleshootingPlaybackStatusResponseModel(
|
||||
string State,
|
||||
int? ExitCode,
|
||||
double? Speed,
|
||||
string? Logs);
|
||||
@@ -0,0 +1,11 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Troubleshooting;
|
||||
|
||||
// Mirrors ErsatzTV.Application.Troubleshooting.SubtitleViewModel. Id is the value the SPA passes
|
||||
// back as the m3u8 endpoint's subtitleId query parameter; the remaining fields are display-only.
|
||||
// Language/Title/Codec are nullable because embedded/sidecar subtitles frequently lack them.
|
||||
public record TroubleshootingSubtitleResponseModel(
|
||||
int Id,
|
||||
string? Language,
|
||||
string? Title,
|
||||
string? Codec);
|
||||
@@ -1,5 +1,19 @@
|
||||
namespace ErsatzTV.Core.Interfaces.Locking;
|
||||
|
||||
/// <summary>
|
||||
/// Advisory "an operation on entity X is in progress" mutex layer, registered as a process-wide singleton.
|
||||
/// Each lock kind is a single exclusive slot (not reentrant or counted): a second acquire while held returns false.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ownership contract (see docs/decisions.md 2026-07-11, issue #231): every <c>Lock*</c> call returns
|
||||
/// <c>true</c> iff it transitioned the slot to locked. A <c>true</c> confers ownership of exactly one release —
|
||||
/// performed either in the acquiring scope (release in a <c>finally</c> gated on the captured return value) or by
|
||||
/// the single designated releaser it hands off to (the consumer of a message enqueued while holding the lock, with
|
||||
/// a compensating unlock if the enqueue itself throws). Callers must never release a lock they did not acquire.
|
||||
/// <c>Unlock*</c> on an already-unlocked slot is a no-op that returns <c>false</c>, raises no event, and logs a
|
||||
/// warning — the tripwire for double-release / non-owner-release bugs; it deliberately does not throw, so the
|
||||
/// primitive stays safe to call from <c>finally</c> paths.
|
||||
/// </remarks>
|
||||
public interface IEntityLocker
|
||||
{
|
||||
event EventHandler OnLibraryChanged;
|
||||
|
||||
@@ -10,7 +10,7 @@ public interface ITelevisionRepository
|
||||
Task<bool> AllEpisodesExist(List<int> episodeIds);
|
||||
Task<List<Show>> GetAllShows();
|
||||
Task<Option<Show>> GetShow(int showId, CancellationToken cancellationToken);
|
||||
Task<Option<int>> GetShowIdByTitle(int libraryId, string title);
|
||||
Task<Option<string>> GetShowTitle(int libraryId, int showId);
|
||||
Task<List<Episode>> GetShowItems(int showId);
|
||||
Task<List<int>> GetEpisodeIdsForShow(int showId);
|
||||
Task<List<Season>> GetAllSeasons();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using ErsatzTV.Core.Troubleshooting;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Troubleshooting;
|
||||
|
||||
// Records the outcome of the most recent troubleshooting playback session so the SPA can poll
|
||||
// for completion (the legacy Blazor page instead subscribed to an in-process notification). The
|
||||
// store is reset when a new session starts (see PrepareTroubleshootingPlaybackHandler) and written
|
||||
// by the PlaybackTroubleshootingCompletedNotification handler.
|
||||
public interface ITroubleshootingPlaybackStatusStore
|
||||
{
|
||||
Option<TroubleshootingPlaybackResult> CurrentResult { get; }
|
||||
|
||||
void Reset();
|
||||
|
||||
void RecordCompletion(int exitCode, Option<double> speed);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Troubleshooting;
|
||||
|
||||
public record TroubleshootingPlaybackResult(int ExitCode, Option<double> Speed);
|
||||
@@ -0,0 +1,36 @@
|
||||
using ErsatzTV.Core.Interfaces.Troubleshooting;
|
||||
|
||||
namespace ErsatzTV.Core.Troubleshooting;
|
||||
|
||||
public class TroubleshootingPlaybackStatusStore : ITroubleshootingPlaybackStatusStore
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private Option<TroubleshootingPlaybackResult> _result = Option<TroubleshootingPlaybackResult>.None;
|
||||
|
||||
public Option<TroubleshootingPlaybackResult> CurrentResult
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_result = Option<TroubleshootingPlaybackResult>.None;
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordCompletion(int exitCode, Option<double> speed)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_result = new TroubleshootingPlaybackResult(exitCode, speed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Notifications;
|
||||
using ErsatzTV.Infrastructure.Locking;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Tests.Locking;
|
||||
|
||||
// Verifies the atomicity + single-owner release contract from docs/decisions.md 2026-07-11 (issue #231).
|
||||
// These run against the real EntityLocker (not a mock) so the Interlocked/ConcurrentDictionary state is exercised.
|
||||
[TestFixture]
|
||||
public class EntityLockerTests
|
||||
{
|
||||
private const int ParallelCallers = 64;
|
||||
|
||||
private static EntityLocker CreateLocker() =>
|
||||
new(Substitute.For<IMediator>(), NullLogger<EntityLocker>.Instance);
|
||||
|
||||
// Every exclusive single-slot kind, adapted to a uniform bool lock/unlock/is-locked shape.
|
||||
// Library bakes in a fixed id; RemoteMediaSource a concrete media-source type. Playout is async
|
||||
// (tested separately) and so is not in this source.
|
||||
private static IEnumerable<LockKind> ExclusiveWinnerKinds()
|
||||
{
|
||||
foreach (LockKind kind in EventCountingKinds())
|
||||
{
|
||||
yield return kind;
|
||||
}
|
||||
|
||||
yield return new LockKind
|
||||
{
|
||||
Name = "RemoteMediaSource",
|
||||
Lock = l => l.LockRemoteMediaSource<PlexMediaSource>(),
|
||||
Unlock = l => l.UnlockRemoteMediaSource<PlexMediaSource>(),
|
||||
IsLocked = l => l.IsRemoteMediaSourceLocked<PlexMediaSource>()
|
||||
};
|
||||
}
|
||||
|
||||
// The kinds whose change event is a plain EventHandler, so a subscriber can count transitions.
|
||||
private static IEnumerable<LockKind> EventCountingKinds()
|
||||
{
|
||||
yield return new LockKind
|
||||
{
|
||||
Name = "Plex",
|
||||
Lock = l => l.LockPlex(),
|
||||
Unlock = l => l.UnlockPlex(),
|
||||
IsLocked = l => l.IsPlexLocked(),
|
||||
Subscribe = (l, h) => l.OnPlexChanged += h
|
||||
};
|
||||
yield return new LockKind
|
||||
{
|
||||
Name = "Trakt",
|
||||
Lock = l => l.LockTrakt(),
|
||||
Unlock = l => l.UnlockTrakt(),
|
||||
IsLocked = l => l.IsTraktLocked(),
|
||||
Subscribe = (l, h) => l.OnTraktChanged += h
|
||||
};
|
||||
yield return new LockKind
|
||||
{
|
||||
Name = "EmbyCollections",
|
||||
Lock = l => l.LockEmbyCollections(),
|
||||
Unlock = l => l.UnlockEmbyCollections(),
|
||||
IsLocked = l => l.AreEmbyCollectionsLocked(),
|
||||
Subscribe = (l, h) => l.OnEmbyCollectionsChanged += h
|
||||
};
|
||||
yield return new LockKind
|
||||
{
|
||||
Name = "JellyfinCollections",
|
||||
Lock = l => l.LockJellyfinCollections(),
|
||||
Unlock = l => l.UnlockJellyfinCollections(),
|
||||
IsLocked = l => l.AreJellyfinCollectionsLocked(),
|
||||
Subscribe = (l, h) => l.OnJellyfinCollectionsChanged += h
|
||||
};
|
||||
yield return new LockKind
|
||||
{
|
||||
Name = "PlexCollections",
|
||||
Lock = l => l.LockPlexCollections(),
|
||||
Unlock = l => l.UnlockPlexCollections(),
|
||||
IsLocked = l => l.ArePlexCollectionsLocked(),
|
||||
Subscribe = (l, h) => l.OnPlexCollectionsChanged += h
|
||||
};
|
||||
yield return new LockKind
|
||||
{
|
||||
Name = "TroubleshootingPlayback",
|
||||
Lock = l => l.LockTroubleshootingPlayback(),
|
||||
Unlock = l => l.UnlockTroubleshootingPlayback(),
|
||||
IsLocked = l => l.IsTroubleshootingPlaybackLocked(),
|
||||
Subscribe = (l, h) => l.OnTroubleshootingPlaybackChanged += h
|
||||
};
|
||||
yield return new LockKind
|
||||
{
|
||||
Name = "Library",
|
||||
Lock = l => l.LockLibrary(1),
|
||||
Unlock = l => l.UnlockLibrary(1),
|
||||
IsLocked = l => l.IsLibraryLocked(1),
|
||||
Subscribe = (l, h) => l.OnLibraryChanged += h
|
||||
};
|
||||
}
|
||||
|
||||
// Stress the acquire race over many rounds: a single-round 64-thread barrier does NOT reliably
|
||||
// collide the non-atomic check-then-set window on fast hardware (verified: it green-lit a
|
||||
// deliberately broken flag), so this hammers Lock* for thousands of rounds — each round every
|
||||
// thread races one acquire, exactly one must win, then the winner releases to reset. Any round
|
||||
// with a winner count != 1 fails the whole test. Against the atomic CAS this is invariant;
|
||||
// against a non-atomic flag some round double-acquires.
|
||||
[Test]
|
||||
[TestCaseSource(nameof(ExclusiveWinnerKinds))]
|
||||
public void Lock_ParallelCallers_ExactlyOneWinner(LockKind kind)
|
||||
{
|
||||
const int threads = 8;
|
||||
const int rounds = 20_000;
|
||||
|
||||
EntityLocker locker = CreateLocker();
|
||||
var winners = 0;
|
||||
var badRounds = 0;
|
||||
|
||||
using var startRound = new Barrier(threads);
|
||||
using var endRound = new Barrier(
|
||||
threads,
|
||||
_ =>
|
||||
{
|
||||
if (Volatile.Read(ref winners) != 1)
|
||||
{
|
||||
Interlocked.Increment(ref badRounds);
|
||||
}
|
||||
|
||||
// reset for the next round: release the (single) held lock and clear the counter
|
||||
kind.Unlock(locker);
|
||||
Volatile.Write(ref winners, 0);
|
||||
});
|
||||
|
||||
var workers = new Thread[threads];
|
||||
for (var t = 0; t < threads; t++)
|
||||
{
|
||||
workers[t] = new Thread(() =>
|
||||
{
|
||||
for (var r = 0; r < rounds; r++)
|
||||
{
|
||||
startRound.SignalAndWait();
|
||||
if (kind.Lock(locker))
|
||||
{
|
||||
Interlocked.Increment(ref winners);
|
||||
}
|
||||
|
||||
endRound.SignalAndWait();
|
||||
}
|
||||
});
|
||||
workers[t].Start();
|
||||
}
|
||||
|
||||
foreach (Thread worker in workers)
|
||||
{
|
||||
worker.Join();
|
||||
}
|
||||
|
||||
badRounds.ShouldBe(0);
|
||||
kind.IsLocked(locker).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LockPlayout_ParallelCallers_ExactlyOneWinner_PublishesOnce()
|
||||
{
|
||||
var mediator = Substitute.For<IMediator>();
|
||||
var locker = new EntityLocker(mediator, NullLogger<EntityLocker>.Instance);
|
||||
const int playoutId = 7;
|
||||
using var barrier = new Barrier(ParallelCallers);
|
||||
|
||||
Task<bool>[] tasks = Enumerable.Range(0, ParallelCallers)
|
||||
.Select(_ => Task.Run(async () =>
|
||||
{
|
||||
barrier.SignalAndWait();
|
||||
return await locker.LockPlayout(playoutId);
|
||||
}))
|
||||
.ToArray();
|
||||
|
||||
bool[] results = await Task.WhenAll(tasks);
|
||||
|
||||
results.Count(r => r).ShouldBe(1);
|
||||
locker.IsPlayoutLocked(playoutId).ShouldBeTrue();
|
||||
await mediator.Received(1).Publish(
|
||||
Arg.Is<PlayoutUpdatedNotification>(n => n.PlayoutId == playoutId && n.IsLocked),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCaseSource(nameof(EventCountingKinds))]
|
||||
public void Lock_WhenAlreadyLocked_ReturnsFalse_AndFiresNoSecondEvent(LockKind kind)
|
||||
{
|
||||
EntityLocker locker = CreateLocker();
|
||||
var eventCount = 0;
|
||||
kind.Subscribe!(locker, (_, _) => eventCount++);
|
||||
|
||||
kind.Lock(locker).ShouldBeTrue();
|
||||
kind.Lock(locker).ShouldBeFalse();
|
||||
|
||||
kind.IsLocked(locker).ShouldBeTrue();
|
||||
eventCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCaseSource(nameof(EventCountingKinds))]
|
||||
public void Unlock_WhenNotLocked_ReturnsFalse_AndFiresNoEvent(LockKind kind)
|
||||
{
|
||||
EntityLocker locker = CreateLocker();
|
||||
var eventCount = 0;
|
||||
kind.Subscribe!(locker, (_, _) => eventCount++);
|
||||
|
||||
kind.Unlock(locker).ShouldBeFalse();
|
||||
|
||||
eventCount.ShouldBe(0);
|
||||
kind.IsLocked(locker).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCaseSource(nameof(EventCountingKinds))]
|
||||
public void Unlock_AfterLock_ReturnsTrue_ThenSecondUnlockReturnsFalse(LockKind kind)
|
||||
{
|
||||
EntityLocker locker = CreateLocker();
|
||||
var eventCount = 0;
|
||||
kind.Subscribe!(locker, (_, _) => eventCount++);
|
||||
|
||||
kind.Lock(locker).ShouldBeTrue();
|
||||
kind.Unlock(locker).ShouldBeTrue();
|
||||
kind.Unlock(locker).ShouldBeFalse();
|
||||
|
||||
kind.IsLocked(locker).ShouldBeFalse();
|
||||
eventCount.ShouldBe(2); // one lock transition + one unlock transition, the no-op unlock fires nothing
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCaseSource(nameof(EventCountingKinds))]
|
||||
public async Task ParallelUnlock_AfterSingleLock_ExactlyOneReleaser(LockKind kind)
|
||||
{
|
||||
EntityLocker locker = CreateLocker();
|
||||
kind.Lock(locker).ShouldBeTrue();
|
||||
|
||||
var unlockEvents = 0;
|
||||
kind.Subscribe!(locker, (_, _) => Interlocked.Increment(ref unlockEvents));
|
||||
using var barrier = new Barrier(ParallelCallers);
|
||||
|
||||
Task<bool>[] tasks = Enumerable.Range(0, ParallelCallers)
|
||||
.Select(_ => Task.Run(() =>
|
||||
{
|
||||
barrier.SignalAndWait();
|
||||
return kind.Unlock(locker);
|
||||
}))
|
||||
.ToArray();
|
||||
|
||||
bool[] results = await Task.WhenAll(tasks);
|
||||
|
||||
results.Count(r => r).ShouldBe(1);
|
||||
kind.IsLocked(locker).ShouldBeFalse();
|
||||
unlockEvents.ShouldBe(1);
|
||||
}
|
||||
|
||||
public sealed class LockKind
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required Func<EntityLocker, bool> Lock { get; init; }
|
||||
public required Func<EntityLocker, bool> Unlock { get; init; }
|
||||
public required Func<EntityLocker, bool> IsLocked { get; init; }
|
||||
|
||||
// null for kinds whose change event is not a plain EventHandler (RemoteMediaSource is EventHandler<Type>).
|
||||
public Action<EntityLocker, EventHandler>? Subscribe { get; init; }
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
}
|
||||
@@ -80,16 +80,16 @@ public class TelevisionRepository : ITelevisionRepository
|
||||
.SelectOneAsync(s => s.Id, s => s.Id == showId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Option<int>> GetShowIdByTitle(int libraryId, string title)
|
||||
public async Task<Option<string>> GetShowTitle(int libraryId, int showId)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.ShowMetadata
|
||||
.AsNoTracking()
|
||||
.Where(sm => sm.ShowId == showId)
|
||||
.Where(sm => sm.Show.LibraryPath.LibraryId == libraryId)
|
||||
.Where(sm => EF.Functions.Like(sm.Title, $"%{title}%"))
|
||||
.Map(sm => sm.ShowId)
|
||||
.Map(sm => sm.Title)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(showId => showId > 0 ? Option<int>.Some(showId) : Option<int>.None);
|
||||
.Map(Optional);
|
||||
}
|
||||
|
||||
public async Task<List<int>> GetEpisodeIdsForShow(int showId)
|
||||
|
||||
@@ -2,20 +2,25 @@
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Notifications;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Locking;
|
||||
|
||||
public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
public class EntityLocker(IMediator mediator, ILogger<EntityLocker> logger) : IEntityLocker
|
||||
{
|
||||
private readonly ConcurrentDictionary<int, byte> _lockedLibraries = new();
|
||||
private readonly ConcurrentDictionary<int, byte> _lockedPlayouts = new();
|
||||
private readonly ConcurrentDictionary<Type, byte> _lockedRemoteMediaSourceTypes = new();
|
||||
private bool _embyCollections;
|
||||
private bool _jellyfinCollections;
|
||||
private bool _plex;
|
||||
private bool _plexCollections;
|
||||
private bool _trakt;
|
||||
private bool _troubleshootingPlayback;
|
||||
|
||||
// 0 = unlocked, 1 = locked. Mutated only via Interlocked.CompareExchange so the caller that wins
|
||||
// the 0 -> 1 transition is the sole owner and the only one that fires the change event
|
||||
// (see docs/decisions.md 2026-07-11, issue #231).
|
||||
private int _embyCollections;
|
||||
private int _jellyfinCollections;
|
||||
private int _plex;
|
||||
private int _plexCollections;
|
||||
private int _trakt;
|
||||
private int _troubleshootingPlayback;
|
||||
|
||||
public event EventHandler OnLibraryChanged;
|
||||
public event EventHandler OnPlexChanged;
|
||||
@@ -28,7 +33,7 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool LockLibrary(int libraryId)
|
||||
{
|
||||
if (!_lockedLibraries.ContainsKey(libraryId) && _lockedLibraries.TryAdd(libraryId, 0))
|
||||
if (_lockedLibraries.TryAdd(libraryId, 0))
|
||||
{
|
||||
OnLibraryChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
@@ -45,6 +50,9 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"UnlockLibrary called for library {LibraryId} that was not locked (double-release or non-owner release)",
|
||||
libraryId);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -53,9 +61,8 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool LockPlex()
|
||||
{
|
||||
if (!_plex)
|
||||
if (Interlocked.CompareExchange(ref _plex, 1, 0) == 0)
|
||||
{
|
||||
_plex = true;
|
||||
OnPlexChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
@@ -65,17 +72,17 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool UnlockPlex()
|
||||
{
|
||||
if (_plex)
|
||||
if (Interlocked.CompareExchange(ref _plex, 0, 1) == 1)
|
||||
{
|
||||
_plex = false;
|
||||
OnPlexChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning("UnlockPlex called but Plex was not locked (double-release or non-owner release)");
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool IsPlexLocked() => _plex;
|
||||
public bool IsPlexLocked() => Volatile.Read(ref _plex) == 1;
|
||||
|
||||
public bool IsRemoteMediaSourceLocked<TMediaSource>() =>
|
||||
_lockedRemoteMediaSourceTypes.ContainsKey(typeof(TMediaSource));
|
||||
@@ -84,8 +91,7 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
{
|
||||
Type mediaSourceType = typeof(TMediaSource);
|
||||
|
||||
if (!_lockedRemoteMediaSourceTypes.ContainsKey(mediaSourceType) &&
|
||||
_lockedRemoteMediaSourceTypes.TryAdd(mediaSourceType, 0))
|
||||
if (_lockedRemoteMediaSourceTypes.TryAdd(mediaSourceType, 0))
|
||||
{
|
||||
OnRemoteMediaSourceChanged?.Invoke(this, mediaSourceType);
|
||||
return true;
|
||||
@@ -104,14 +110,16 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"UnlockRemoteMediaSource called for {MediaSourceType} that was not locked (double-release or non-owner release)",
|
||||
mediaSourceType.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool LockTrakt()
|
||||
{
|
||||
if (!_trakt)
|
||||
if (Interlocked.CompareExchange(ref _trakt, 1, 0) == 0)
|
||||
{
|
||||
_trakt = true;
|
||||
OnTraktChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
@@ -121,23 +129,22 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool UnlockTrakt()
|
||||
{
|
||||
if (_trakt)
|
||||
if (Interlocked.CompareExchange(ref _trakt, 0, 1) == 1)
|
||||
{
|
||||
_trakt = false;
|
||||
OnTraktChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning("UnlockTrakt called but Trakt was not locked (double-release or non-owner release)");
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool IsTraktLocked() => _trakt;
|
||||
public bool IsTraktLocked() => Volatile.Read(ref _trakt) == 1;
|
||||
|
||||
public bool LockEmbyCollections()
|
||||
{
|
||||
if (!_embyCollections)
|
||||
if (Interlocked.CompareExchange(ref _embyCollections, 1, 0) == 0)
|
||||
{
|
||||
_embyCollections = true;
|
||||
OnEmbyCollectionsChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
@@ -147,23 +154,23 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool UnlockEmbyCollections()
|
||||
{
|
||||
if (_embyCollections)
|
||||
if (Interlocked.CompareExchange(ref _embyCollections, 0, 1) == 1)
|
||||
{
|
||||
_embyCollections = false;
|
||||
OnEmbyCollectionsChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"UnlockEmbyCollections called but Emby collections were not locked (double-release or non-owner release)");
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool AreEmbyCollectionsLocked() => _embyCollections;
|
||||
public bool AreEmbyCollectionsLocked() => Volatile.Read(ref _embyCollections) == 1;
|
||||
|
||||
public bool LockJellyfinCollections()
|
||||
{
|
||||
if (!_jellyfinCollections)
|
||||
if (Interlocked.CompareExchange(ref _jellyfinCollections, 1, 0) == 0)
|
||||
{
|
||||
_jellyfinCollections = true;
|
||||
OnJellyfinCollectionsChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
@@ -173,23 +180,23 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool UnlockJellyfinCollections()
|
||||
{
|
||||
if (_jellyfinCollections)
|
||||
if (Interlocked.CompareExchange(ref _jellyfinCollections, 0, 1) == 1)
|
||||
{
|
||||
_jellyfinCollections = false;
|
||||
OnJellyfinCollectionsChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"UnlockJellyfinCollections called but Jellyfin collections were not locked (double-release or non-owner release)");
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool AreJellyfinCollectionsLocked() => _jellyfinCollections;
|
||||
public bool AreJellyfinCollectionsLocked() => Volatile.Read(ref _jellyfinCollections) == 1;
|
||||
|
||||
public bool LockPlexCollections()
|
||||
{
|
||||
if (!_plexCollections)
|
||||
if (Interlocked.CompareExchange(ref _plexCollections, 1, 0) == 0)
|
||||
{
|
||||
_plexCollections = true;
|
||||
OnPlexCollectionsChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
@@ -199,21 +206,22 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool UnlockPlexCollections()
|
||||
{
|
||||
if (_plexCollections)
|
||||
if (Interlocked.CompareExchange(ref _plexCollections, 0, 1) == 1)
|
||||
{
|
||||
_plexCollections = false;
|
||||
OnPlexCollectionsChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"UnlockPlexCollections called but Plex collections were not locked (double-release or non-owner release)");
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ArePlexCollectionsLocked() => _plexCollections;
|
||||
public bool ArePlexCollectionsLocked() => Volatile.Read(ref _plexCollections) == 1;
|
||||
|
||||
public async Task<bool> LockPlayout(int playoutId)
|
||||
{
|
||||
if (!_lockedPlayouts.ContainsKey(playoutId) && _lockedPlayouts.TryAdd(playoutId, 0))
|
||||
if (_lockedPlayouts.TryAdd(playoutId, 0))
|
||||
{
|
||||
await mediator.Publish(new PlayoutUpdatedNotification(playoutId, true));
|
||||
return true;
|
||||
@@ -230,6 +238,9 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"UnlockPlayout called for playout {PlayoutId} that was not locked (double-release or non-owner release)",
|
||||
playoutId);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -237,9 +248,8 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool LockTroubleshootingPlayback()
|
||||
{
|
||||
if (!_troubleshootingPlayback)
|
||||
if (Interlocked.CompareExchange(ref _troubleshootingPlayback, 1, 0) == 0)
|
||||
{
|
||||
_troubleshootingPlayback = true;
|
||||
OnTroubleshootingPlaybackChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
@@ -249,15 +259,16 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
|
||||
|
||||
public bool UnlockTroubleshootingPlayback()
|
||||
{
|
||||
if (_troubleshootingPlayback)
|
||||
if (Interlocked.CompareExchange(ref _troubleshootingPlayback, 0, 1) == 1)
|
||||
{
|
||||
_troubleshootingPlayback = false;
|
||||
OnTroubleshootingPlaybackChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"UnlockTroubleshootingPlayback called but troubleshooting playback was not locked (double-release or non-owner release)");
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool IsTroubleshootingPlaybackLocked() => _troubleshootingPlayback;
|
||||
public bool IsTroubleshootingPlaybackLocked() => Volatile.Read(ref _troubleshootingPlayback) == 1;
|
||||
}
|
||||
|
||||
@@ -32,8 +32,24 @@ public class FillerPresetHandlerTests
|
||||
await handler.Handle(new GetAllFillerPresetsForApi(), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain(new FillerPresetResponseModel(1, "Intro"));
|
||||
result.ShouldContain(new FillerPresetResponseModel(2, "Outro"));
|
||||
result.ShouldContain(new FillerPresetResponseModel(1, "Intro", FillerKind.PreRoll));
|
||||
result.ShouldContain(new FillerPresetResponseModel(2, "Outro", FillerKind.PreRoll));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAllFillerPresetsForApi_Should_Filter_By_FillerKind()
|
||||
{
|
||||
await SeedPreset(1, "Intro", FillerKind.PreRoll);
|
||||
await SeedPreset(2, "Mid", FillerKind.MidRoll);
|
||||
await SeedPreset(3, "Outro", FillerKind.PostRoll);
|
||||
|
||||
var handler = new GetAllFillerPresetsForApiHandler(_db.Factory);
|
||||
|
||||
List<FillerPresetResponseModel> result =
|
||||
await handler.Handle(new GetAllFillerPresetsForApi(FillerKind.MidRoll), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result.ShouldContain(new FillerPresetResponseModel(2, "Mid", FillerKind.MidRoll));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -47,14 +63,14 @@ public class FillerPresetHandlerTests
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private async Task SeedPreset(int id, string name)
|
||||
private async Task SeedPreset(int id, string name, FillerKind fillerKind = FillerKind.PreRoll)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.FillerPresets.Add(new FillerPreset
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
FillerKind = FillerKind.PreRoll,
|
||||
FillerKind = fillerKind,
|
||||
FillerMode = FillerMode.Duration,
|
||||
CollectionType = CollectionType.Collection
|
||||
});
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using ThreadingChannel = System.Threading.Channels.Channel;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Libraries;
|
||||
|
||||
[TestFixture]
|
||||
public class QueueLibraryScanByLibraryIdHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_NotFound_When_Library_Missing()
|
||||
{
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
||||
|
||||
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
||||
|
||||
QueueLibraryScanResult result = await handler.Handle(
|
||||
new QueueLibraryScanByLibraryId(9999),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(QueueLibraryScanResult.NotFound);
|
||||
locker.DidNotReceive().LockLibrary(Arg.Any<int>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_SyncDisabled_When_Item_Sync_Off()
|
||||
{
|
||||
int libraryId = await SeedSyncDisabledPlexLibrary();
|
||||
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
||||
|
||||
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
||||
|
||||
QueueLibraryScanResult result = await handler.Handle(
|
||||
new QueueLibraryScanByLibraryId(libraryId),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(QueueLibraryScanResult.SyncDisabled);
|
||||
locker.DidNotReceive().LockLibrary(Arg.Any<int>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Queue_And_Enqueue_When_Lock_Acquired()
|
||||
{
|
||||
int libraryId = await SeedLocalLibrary();
|
||||
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
locker.LockLibrary(libraryId).Returns(true);
|
||||
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
||||
|
||||
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
||||
|
||||
QueueLibraryScanResult result = await handler.Handle(
|
||||
new QueueLibraryScanByLibraryId(libraryId),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(QueueLibraryScanResult.Queued);
|
||||
locker.Received(1).LockLibrary(libraryId);
|
||||
locker.DidNotReceive().UnlockLibrary(Arg.Any<int>());
|
||||
channel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue();
|
||||
request.ShouldBeOfType<ForceScanLocalLibrary>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_AlreadyScanning_When_Lock_Not_Acquired()
|
||||
{
|
||||
int libraryId = await SeedLocalLibrary();
|
||||
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
locker.LockLibrary(libraryId).Returns(false);
|
||||
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
||||
|
||||
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
||||
|
||||
QueueLibraryScanResult result = await handler.Handle(
|
||||
new QueueLibraryScanByLibraryId(libraryId),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(QueueLibraryScanResult.AlreadyScanning);
|
||||
channel.Reader.TryRead(out _).ShouldBeFalse();
|
||||
locker.DidNotReceive().UnlockLibrary(Arg.Any<int>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Two_Concurrent_Scans_Should_Be_Queued_Then_AlreadyScanning()
|
||||
{
|
||||
int libraryId = await SeedLocalLibrary();
|
||||
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
// first acquire wins, second loses (a scan is already in progress)
|
||||
locker.LockLibrary(libraryId).Returns(true, false);
|
||||
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
||||
|
||||
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
||||
|
||||
QueueLibraryScanResult first = await handler.Handle(
|
||||
new QueueLibraryScanByLibraryId(libraryId),
|
||||
CancellationToken.None);
|
||||
QueueLibraryScanResult second = await handler.Handle(
|
||||
new QueueLibraryScanByLibraryId(libraryId),
|
||||
CancellationToken.None);
|
||||
|
||||
first.ShouldBe(QueueLibraryScanResult.Queued);
|
||||
second.ShouldBe(QueueLibraryScanResult.AlreadyScanning);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Unlock_And_Rethrow_When_Enqueue_Throws()
|
||||
{
|
||||
int libraryId = await SeedLocalLibrary();
|
||||
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
locker.LockLibrary(libraryId).Returns(true);
|
||||
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
||||
// a completed writer makes WriteAsync throw ChannelClosedException, simulating a failed enqueue
|
||||
channel.Writer.Complete();
|
||||
|
||||
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
|
||||
|
||||
await Should.ThrowAsync<ChannelClosedException>(() =>
|
||||
handler.Handle(new QueueLibraryScanByLibraryId(libraryId), CancellationToken.None));
|
||||
|
||||
locker.Received(1).UnlockLibrary(libraryId);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Unlock_When_Enqueue_Canceled()
|
||||
{
|
||||
int libraryId = await SeedLocalLibrary();
|
||||
|
||||
var locker = Substitute.For<IEntityLocker>();
|
||||
locker.LockLibrary(libraryId).Returns(true);
|
||||
|
||||
// a writer whose enqueue is aborted (e.g. request cancelled after the lock was taken)
|
||||
var writer = Substitute.For<ChannelWriter<IScannerBackgroundServiceRequest>>();
|
||||
writer.WriteAsync(Arg.Any<IScannerBackgroundServiceRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ValueTask.FromException(new OperationCanceledException()));
|
||||
|
||||
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, writer);
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(() =>
|
||||
handler.Handle(new QueueLibraryScanByLibraryId(libraryId), CancellationToken.None));
|
||||
|
||||
locker.Received(1).UnlockLibrary(libraryId);
|
||||
}
|
||||
|
||||
private QueueLibraryScanByLibraryIdHandler CreateHandler(
|
||||
IEntityLocker locker,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> writer) =>
|
||||
new(
|
||||
_db.Factory,
|
||||
locker,
|
||||
writer,
|
||||
NullLogger<QueueLibraryScanByLibraryIdHandler>.Instance);
|
||||
|
||||
private async Task<int> SeedLocalLibrary()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var source = new LocalMediaSource
|
||||
{
|
||||
Libraries =
|
||||
[
|
||||
new LocalLibrary
|
||||
{
|
||||
Name = "Local Movies",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = []
|
||||
}
|
||||
]
|
||||
};
|
||||
await context.LocalMediaSources.AddAsync(source);
|
||||
await context.SaveChangesAsync();
|
||||
return source.Libraries[0].Id;
|
||||
}
|
||||
|
||||
private async Task<int> SeedSyncDisabledPlexLibrary()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var source = new PlexMediaSource
|
||||
{
|
||||
ServerName = "Plex Server",
|
||||
ProductVersion = "1",
|
||||
Platform = "Linux",
|
||||
PlatformVersion = "1",
|
||||
ClientIdentifier = "plex",
|
||||
Connections = [],
|
||||
PathReplacements = [],
|
||||
Libraries =
|
||||
[
|
||||
new PlexLibrary
|
||||
{
|
||||
Name = "Plex Movies",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Key = "1",
|
||||
ShouldSyncItems = false,
|
||||
Paths = []
|
||||
}
|
||||
]
|
||||
};
|
||||
await context.PlexMediaSources.AddAsync(source);
|
||||
await context.SaveChangesAsync();
|
||||
return source.Libraries[0].Id;
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,10 @@ public class GetLibraryBrowseItemsHandlerTests
|
||||
result.Page[1].MediaItemId.ShouldBe(711);
|
||||
result.Page[2].MediaItemId.ShouldBe(713);
|
||||
|
||||
// #220: every episode carries its parent season id so the SPA can route to the season
|
||||
// detail page and anchor to the episode (`/app/media/seasons/{seasonId}#episode-{id}`).
|
||||
result.Page.ShouldAllBe(p => p.SeasonId == 701);
|
||||
|
||||
await _searchIndex.DidNotReceive().Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
@@ -297,6 +301,32 @@ public class GetLibraryBrowseItemsHandlerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Leave_SeasonId_Null_For_Non_Episode_Kinds()
|
||||
{
|
||||
await SeedLibraryGraph();
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResult(
|
||||
[
|
||||
new SearchItem(LuceneSearchIndex.ShowType, 20),
|
||||
new SearchItem(LuceneSearchIndex.MovieType, 10)
|
||||
],
|
||||
2));
|
||||
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
|
||||
new GetLibraryBrowseItems("", null, null, 0, 10),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Page.ShouldAllBe(p => p.SeasonId == null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Browse_Music_Videos_For_A_Specific_Artist_By_ParentId()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.MediaCollections;
|
||||
|
||||
[TestFixture]
|
||||
public class GetCollectionItemsHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_NotFound_For_Missing_Collection()
|
||||
{
|
||||
var handler = new GetCollectionItemsHandler(_db.Factory);
|
||||
|
||||
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
|
||||
await handler.Handle(new GetCollectionItems(999, 0, 100), CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Order_By_Title_When_Custom_Order_Disabled()
|
||||
{
|
||||
await SeedCollectionGraph(useCustomPlaybackOrder: false);
|
||||
var handler = new GetCollectionItemsHandler(_db.Factory);
|
||||
|
||||
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
|
||||
await handler.Handle(new GetCollectionItems(10, 0, 100), CancellationToken.None);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel page = result.RightToSeq().Single();
|
||||
page.TotalCount.ShouldBe(3);
|
||||
page.Page.Select(i => i.Title).ShouldBe(["Alpha", "Beta", "Zeta"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Order_By_CustomIndex_With_Nulls_Last_When_Custom_Order_Enabled()
|
||||
{
|
||||
await SeedCollectionGraph(useCustomPlaybackOrder: true);
|
||||
var handler = new GetCollectionItemsHandler(_db.Factory);
|
||||
|
||||
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
|
||||
await handler.Handle(new GetCollectionItems(10, 0, 100), CancellationToken.None);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel page = result.RightToSeq().Single();
|
||||
|
||||
// Zeta has CustomIndex 0, Alpha has CustomIndex 1, Beta has no CustomIndex (sorts last).
|
||||
page.Page.Select(i => i.Title).ShouldBe(["Zeta", "Alpha", "Beta"]);
|
||||
}
|
||||
|
||||
private async Task SeedCollectionGraph(bool useCustomPlaybackOrder)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Library",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = []
|
||||
};
|
||||
var path = new LibraryPath
|
||||
{
|
||||
Id = 1,
|
||||
Path = "/media",
|
||||
Library = library,
|
||||
LibraryFolders = [],
|
||||
MediaItems = []
|
||||
};
|
||||
library.Paths.Add(path);
|
||||
|
||||
Movie alpha = MakeMovie(101, path, "Alpha");
|
||||
Movie beta = MakeMovie(102, path, "Beta");
|
||||
Movie zeta = MakeMovie(103, path, "Zeta");
|
||||
|
||||
var collection = new Collection
|
||||
{
|
||||
Id = 10,
|
||||
Name = "Manual",
|
||||
UseCustomPlaybackOrder = useCustomPlaybackOrder,
|
||||
MediaItems = [],
|
||||
CollectionItems =
|
||||
[
|
||||
new CollectionItem { MediaItemId = 103, CustomIndex = 0 },
|
||||
new CollectionItem { MediaItemId = 101, CustomIndex = 1 },
|
||||
new CollectionItem { MediaItemId = 102, CustomIndex = null }
|
||||
],
|
||||
MultiCollections = [],
|
||||
MultiCollectionItems = []
|
||||
};
|
||||
|
||||
context.LocalLibraries.Add(library);
|
||||
context.Movies.AddRange(alpha, beta, zeta);
|
||||
context.Collections.Add(collection);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static Movie MakeMovie(int id, LibraryPath path, string title) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
LibraryPath = path,
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = [],
|
||||
MovieMetadata =
|
||||
[
|
||||
new MovieMetadata
|
||||
{
|
||||
Title = title,
|
||||
SortTitle = title,
|
||||
Artwork = [],
|
||||
Genres = [],
|
||||
Tags = [],
|
||||
Studios = [],
|
||||
Actors = [],
|
||||
Guids = [],
|
||||
Subtitles = [],
|
||||
Directors = [],
|
||||
Writers = []
|
||||
}
|
||||
],
|
||||
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(30) }]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.MediaCollections;
|
||||
|
||||
[TestFixture]
|
||||
public class PlaylistGroupHandlerTests : MediaCollectionHandlerTestBase
|
||||
{
|
||||
[Test]
|
||||
public async Task Rename_Should_Return_Error_When_Group_Missing()
|
||||
{
|
||||
var handler = new RenamePlaylistGroupHandler(Db.Factory);
|
||||
|
||||
Either<BaseError, PlaylistGroupViewModel> result =
|
||||
await handler.Handle(new RenamePlaylistGroup(999, "Updated"), CancellationToken.None);
|
||||
|
||||
// The handler rejects a missing group; the API surfaces 404 for this case via the
|
||||
// controller's existence pre-check (LanguageExtensions.Apply collapses the handler's
|
||||
// NotFoundError to a plain BaseError, so the type is not asserted here).
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Rename_Should_Return_BaseError_When_Group_Is_System()
|
||||
{
|
||||
await SeedPlaylistGroup(5, "System", isSystem: true);
|
||||
|
||||
var handler = new RenamePlaylistGroupHandler(Db.Factory);
|
||||
|
||||
Either<BaseError, PlaylistGroupViewModel> result =
|
||||
await handler.Handle(new RenamePlaylistGroup(5, "Renamed"), CancellationToken.None);
|
||||
|
||||
// Plain BaseError (not NotFoundError) so ApiResults maps it to 422, not 404.
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldBe("Cannot rename system playlist group");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Rename_Should_Succeed_For_Non_System_Group()
|
||||
{
|
||||
await SeedPlaylistGroup(6, "Kids", isSystem: false);
|
||||
|
||||
var handler = new RenamePlaylistGroupHandler(Db.Factory);
|
||||
|
||||
Either<BaseError, PlaylistGroupViewModel> result =
|
||||
await handler.Handle(new RenamePlaylistGroup(6, "Family"), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
result.Match(Left: _ => "", Right: vm => vm.Name).ShouldBe("Family");
|
||||
}
|
||||
|
||||
private async Task SeedPlaylistGroup(int id, string name, bool isSystem)
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
context.PlaylistGroups.Add(new PlaylistGroup
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
IsSystem = isSystem,
|
||||
Playlists = []
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.MediaCollections;
|
||||
|
||||
[TestFixture]
|
||||
public class PlaylistHandlerTests : MediaCollectionHandlerTestBase
|
||||
{
|
||||
[TestCase(CollectionType.Artist)]
|
||||
[TestCase(CollectionType.MusicVideo)]
|
||||
[TestCase(CollectionType.OtherVideo)]
|
||||
[TestCase(CollectionType.Song)]
|
||||
[TestCase(CollectionType.Image)]
|
||||
[TestCase(CollectionType.RemoteStream)]
|
||||
public async Task AddItems_Should_Return_ValidationError_When_Previously_Unvalidated_Kind_Missing(
|
||||
CollectionType collectionType)
|
||||
{
|
||||
await SeedPlaylist(1);
|
||||
var handler = MakeHandler();
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(MakeAddItems(1, collectionType, [999]), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("Media item does not exist");
|
||||
}
|
||||
|
||||
[TestCase(CollectionType.Movie)]
|
||||
[TestCase(CollectionType.TelevisionShow)]
|
||||
[TestCase(CollectionType.TelevisionSeason)]
|
||||
[TestCase(CollectionType.Episode)]
|
||||
public async Task AddItems_Should_Return_ValidationError_When_Existing_Validated_Kind_Missing(
|
||||
CollectionType collectionType)
|
||||
{
|
||||
await SeedPlaylist(1);
|
||||
var handler = MakeHandler();
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(MakeAddItems(1, collectionType, [999]), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddItems_Should_Add_Valid_RemoteStream_Item_To_Playlist()
|
||||
{
|
||||
await SeedPlaylist(1);
|
||||
await SeedRemoteStream(50);
|
||||
var handler = MakeHandler();
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(MakeAddItems(1, CollectionType.RemoteStream, [50]), CancellationToken.None);
|
||||
|
||||
RightOf(result);
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
Playlist playlist = await context.Playlists.FindAsync(1);
|
||||
List<PlaylistItem> items = context.Entry(playlist!).Collection(p => p.Items).Query().ToList();
|
||||
|
||||
items.ShouldHaveSingleItem();
|
||||
items[0].CollectionType.ShouldBe(CollectionType.RemoteStream);
|
||||
items[0].MediaItemId.ShouldBe(50);
|
||||
}
|
||||
|
||||
private AddItemsToPlaylistHandler MakeHandler()
|
||||
{
|
||||
IMovieRepository movieRepository = Substitute.For<IMovieRepository>();
|
||||
movieRepository.AllMoviesExist(Arg.Any<List<int>>()).Returns(true);
|
||||
ITelevisionRepository televisionRepository = Substitute.For<ITelevisionRepository>();
|
||||
televisionRepository.AllShowsExist(Arg.Any<List<int>>()).Returns(true);
|
||||
televisionRepository.AllSeasonsExist(Arg.Any<List<int>>()).Returns(true);
|
||||
televisionRepository.AllEpisodesExist(Arg.Any<List<int>>()).Returns(true);
|
||||
|
||||
return new AddItemsToPlaylistHandler(Db.Factory, movieRepository, televisionRepository);
|
||||
}
|
||||
|
||||
private async Task SeedPlaylist(int id, string name = "Playlist")
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
context.Playlists.Add(new Playlist { Id = id, Name = name, Items = [] });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedRemoteStream(int id)
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
context.RemoteStreams.Add(new RemoteStream
|
||||
{
|
||||
Id = id,
|
||||
RemoteStreamMetadata = []
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static AddItemsToPlaylist MakeAddItems(int playlistId, CollectionType collectionType, List<int> ids)
|
||||
{
|
||||
List<int> movieIds = collectionType == CollectionType.Movie ? ids : [];
|
||||
List<int> showIds = collectionType == CollectionType.TelevisionShow ? ids : [];
|
||||
List<int> seasonIds = collectionType == CollectionType.TelevisionSeason ? ids : [];
|
||||
List<int> episodeIds = collectionType == CollectionType.Episode ? ids : [];
|
||||
List<int> artistIds = collectionType == CollectionType.Artist ? ids : [];
|
||||
List<int> musicVideoIds = collectionType == CollectionType.MusicVideo ? ids : [];
|
||||
List<int> otherVideoIds = collectionType == CollectionType.OtherVideo ? ids : [];
|
||||
List<int> songIds = collectionType == CollectionType.Song ? ids : [];
|
||||
List<int> imageIds = collectionType == CollectionType.Image ? ids : [];
|
||||
List<int> remoteStreamIds = collectionType == CollectionType.RemoteStream ? ids : [];
|
||||
|
||||
return new AddItemsToPlaylist(
|
||||
playlistId,
|
||||
movieIds,
|
||||
showIds,
|
||||
seasonIds,
|
||||
episodeIds,
|
||||
artistIds,
|
||||
musicVideoIds,
|
||||
otherVideoIds,
|
||||
songIds,
|
||||
imageIds,
|
||||
remoteStreamIds);
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
|
||||
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => throw new AssertionException($"Expected a Right result, got: {e.Value}"), Right: v => v);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.MediaCollections;
|
||||
|
||||
[TestFixture]
|
||||
public class RerunCollectionHandlerTests : MediaCollectionHandlerTestBase
|
||||
{
|
||||
[Test]
|
||||
public async Task Create_Should_Return_Error_When_Collection_Missing()
|
||||
{
|
||||
var handler = new CreateRerunCollectionHandler(Db.Factory);
|
||||
|
||||
Either<BaseError, RerunCollectionViewModel> result = await handler.Handle(
|
||||
MakeCreate(CollectionType.Collection, collection: new MediaCollectionViewModel(
|
||||
CollectionType.Collection,
|
||||
999,
|
||||
string.Empty,
|
||||
false,
|
||||
MediaItemState.Normal)),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
// deliberate 422-not-404: the missing entity is a request-body FK, not the route resource
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("Collection does not exist");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_Error_When_CollectionType_Unsupported()
|
||||
{
|
||||
var handler = new CreateRerunCollectionHandler(Db.Factory);
|
||||
|
||||
// reachable via the legacy Blazor editor (no IsSupportedSelectionType pre-check)
|
||||
Either<BaseError, RerunCollectionViewModel> result = await handler.Handle(
|
||||
MakeCreate(CollectionType.Playlist),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.Value.ShouldContain("Unsupported collection type");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_Error_When_MediaItem_Missing()
|
||||
{
|
||||
var handler = new CreateRerunCollectionHandler(Db.Factory);
|
||||
|
||||
Either<BaseError, RerunCollectionViewModel> result = await handler.Handle(
|
||||
MakeCreate(CollectionType.Movie, mediaItem: new NamedMediaItemViewModel(999, string.Empty)),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.Value.ShouldContain("Movie does not exist");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Succeed_When_Collection_Exists()
|
||||
{
|
||||
await SeedCollection(1);
|
||||
var handler = new CreateRerunCollectionHandler(Db.Factory);
|
||||
|
||||
Either<BaseError, RerunCollectionViewModel> result = await handler.Handle(
|
||||
MakeCreate(CollectionType.Collection, collection: new MediaCollectionViewModel(
|
||||
CollectionType.Collection,
|
||||
1,
|
||||
string.Empty,
|
||||
false,
|
||||
MediaItemState.Normal)),
|
||||
CancellationToken.None);
|
||||
|
||||
RerunCollectionViewModel vm = RightOf(result);
|
||||
vm.CollectionType.ShouldBe(CollectionType.Collection);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_Error_When_MediaItem_Type_Mismatch()
|
||||
{
|
||||
await SeedShow(1);
|
||||
var handler = new CreateRerunCollectionHandler(Db.Factory);
|
||||
|
||||
// request claims Movie, but the seeded id (1) is actually a Show
|
||||
Either<BaseError, RerunCollectionViewModel> result = await handler.Handle(
|
||||
MakeCreate(CollectionType.Movie, mediaItem: new NamedMediaItemViewModel(1, string.Empty)),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.Value.ShouldContain("Movie does not exist");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_Error_When_SmartCollection_Missing()
|
||||
{
|
||||
await SeedRerunCollection(1, CollectionType.SmartCollection);
|
||||
IMediaCollectionRepository mediaCollectionRepository = Substitute.For<IMediaCollectionRepository>();
|
||||
mediaCollectionRepository.PlayoutIdsUsingRerunCollection(Arg.Any<int>()).Returns([]);
|
||||
var handler = new UpdateRerunCollectionHandler(
|
||||
Db.Factory,
|
||||
mediaCollectionRepository,
|
||||
Worker);
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(
|
||||
1,
|
||||
CollectionType.SmartCollection,
|
||||
smartCollection: new SmartCollectionViewModel(999, string.Empty, string.Empty)),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.Value.ShouldContain("Smart collection does not exist");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Succeed_When_Selection_Exists()
|
||||
{
|
||||
await SeedRerunCollection(1, CollectionType.SmartCollection);
|
||||
await SeedSmartCollection(2);
|
||||
IMediaCollectionRepository mediaCollectionRepository = Substitute.For<IMediaCollectionRepository>();
|
||||
mediaCollectionRepository.PlayoutIdsUsingRerunCollection(Arg.Any<int>()).Returns([]);
|
||||
var handler = new UpdateRerunCollectionHandler(
|
||||
Db.Factory,
|
||||
mediaCollectionRepository,
|
||||
Worker);
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
MakeUpdate(
|
||||
1,
|
||||
CollectionType.SmartCollection,
|
||||
smartCollection: new SmartCollectionViewModel(2, string.Empty, string.Empty)),
|
||||
CancellationToken.None);
|
||||
|
||||
RightOf(result);
|
||||
}
|
||||
|
||||
private async Task SeedShow(int id)
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
context.Shows.Add(new Show { Id = id });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedRerunCollection(int id, CollectionType collectionType, string name = "Rerun")
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
context.RerunCollections.Add(new RerunCollection
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
CollectionType = collectionType,
|
||||
FirstRunPlaybackOrder = PlaybackOrder.Chronological,
|
||||
RerunPlaybackOrder = PlaybackOrder.Chronological
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static CreateRerunCollection MakeCreate(
|
||||
CollectionType collectionType,
|
||||
MediaCollectionViewModel collection = null,
|
||||
MultiCollectionViewModel multiCollection = null,
|
||||
SmartCollectionViewModel smartCollection = null,
|
||||
NamedMediaItemViewModel mediaItem = null) =>
|
||||
new(
|
||||
"Rerun " + Guid.NewGuid(),
|
||||
collectionType,
|
||||
collection,
|
||||
multiCollection,
|
||||
smartCollection,
|
||||
mediaItem,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Chronological);
|
||||
|
||||
private static UpdateRerunCollection MakeUpdate(
|
||||
int rerunCollectionId,
|
||||
CollectionType collectionType,
|
||||
MediaCollectionViewModel collection = null,
|
||||
MultiCollectionViewModel multiCollection = null,
|
||||
SmartCollectionViewModel smartCollection = null,
|
||||
NamedMediaItemViewModel mediaItem = null) =>
|
||||
new(
|
||||
rerunCollectionId,
|
||||
"Rerun " + Guid.NewGuid(),
|
||||
collectionType,
|
||||
collection,
|
||||
multiCollection,
|
||||
smartCollection,
|
||||
mediaItem,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Chronological);
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
|
||||
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => throw new AssertionException($"Expected a Right result, got: {e.Value}"), Right: v => v);
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.ProgramSchedules;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.ProgramSchedules;
|
||||
|
||||
/// <summary>
|
||||
/// Release gate for issue #126: proves the flat <see cref="ScheduleItemResponseModel" /> produced by
|
||||
/// the GET endpoint carries enough information to reconstruct an identical PUT (ReplaceProgramScheduleItems)
|
||||
/// with no loss — a GET → map → PUT → GET fixed point across every subtype and field family. Also documents
|
||||
/// the deliberate <c>EnforceProperties</c> normalization (shuffle → Dynamic/One/None).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ScheduleItemResponseRoundTripTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Get_Map_Replace_Get_Should_Be_Lossless_Across_All_Subtypes()
|
||||
{
|
||||
int scheduleId = await SeedScheduleAndReferences(shuffleScheduleItems: false);
|
||||
|
||||
var replaceHandler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
|
||||
|
||||
// Arrange: establish the initial items through the real write path.
|
||||
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> seeded =
|
||||
await replaceHandler.Handle(new ReplaceProgramScheduleItems(scheduleId, BuildSeedItems()), CancellationToken.None);
|
||||
seeded.IsRight.ShouldBeTrue(seeded.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
|
||||
|
||||
// Act: GET (with durations) → flat DTO envelope A
|
||||
ScheduleItemsResponseModel envelopeA = await GetItemsEnvelope(scheduleId);
|
||||
envelopeA.Items.Count.ShouldBe(6);
|
||||
|
||||
// Map every response item back into a Replace command exactly as the request mapping would, then PUT.
|
||||
// Reconstruct in the item's own (stable) Index order, mirroring how the SPA re-submits the ordered list.
|
||||
List<ReplaceProgramScheduleItem> reconstructed = envelopeA.Items
|
||||
.OrderBy(item => item.Index)
|
||||
.Select((item, index) => ToReplaceCommand(item, index))
|
||||
.ToList();
|
||||
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> replaced =
|
||||
await replaceHandler.Handle(new ReplaceProgramScheduleItems(scheduleId, reconstructed), CancellationToken.None);
|
||||
replaced.IsRight.ShouldBeTrue(replaced.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
|
||||
|
||||
// GET again → envelope B; A and B must be semantically identical (ignoring regenerated row ids).
|
||||
ScheduleItemsResponseModel envelopeB = await GetItemsEnvelope(scheduleId);
|
||||
|
||||
envelopeB.Items.Count.ShouldBe(envelopeA.Items.Count);
|
||||
envelopeB.TotalDurationEstimate.ShouldBe(envelopeA.TotalDurationEstimate);
|
||||
|
||||
// The GET does not guarantee row order, so pair items up by their (stable) Index before comparing.
|
||||
List<ScheduleItemResponseModel> orderedA = envelopeA.Items.OrderBy(i => i.Index).ToList();
|
||||
List<ScheduleItemResponseModel> orderedB = envelopeB.Items.OrderBy(i => i.Index).ToList();
|
||||
for (var i = 0; i < orderedA.Count; i++)
|
||||
{
|
||||
AssertSemanticallyEqual(orderedA[i], orderedB[i]);
|
||||
}
|
||||
|
||||
// Spot-check that the display/hydration fields actually populated (proves the mapper is non-vacuous).
|
||||
ScheduleItemResponseModel multiple = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Multiple);
|
||||
multiple.CollectionName.ShouldBe("Prime Collection");
|
||||
multiple.MultipleMode.ShouldBe(MultipleMode.Count);
|
||||
multiple.MultipleCount.ShouldBe("2 + 1");
|
||||
multiple.FillWithGroupMode.ShouldBe(FillWithGroupMode.FillWithShuffledGroups);
|
||||
multiple.PreRollFillerId.ShouldBe(1);
|
||||
multiple.PreRollFillerName.ShouldBe("PreRoll");
|
||||
multiple.FallbackFillerId.ShouldBe(5);
|
||||
multiple.WatermarkIds.ShouldBe([1, 2]);
|
||||
multiple.Watermarks.Select(w => w.Name).ShouldBe(["WM One", "WM Two"]);
|
||||
multiple.GraphicsElementIds.ShouldBe([1, 2]);
|
||||
multiple.GraphicsElements.Select(g => g.Id).ShouldBe([1, 2]);
|
||||
multiple.GraphicsElements.Select(g => g.Name).ShouldAllBe(n => !string.IsNullOrWhiteSpace(n));
|
||||
|
||||
ScheduleItemResponseModel one = envelopeA.Items.Single(i =>
|
||||
i.PlayoutMode == PlayoutMode.One && i.CollectionType == CollectionType.Collection);
|
||||
one.StartType.ShouldBe(StartType.Fixed);
|
||||
one.StartTime.ShouldBe(TimeSpan.FromHours(20));
|
||||
one.FixedStartTimeBehavior.ShouldBe(FixedStartTimeBehavior.Flexible);
|
||||
one.CustomTitle.ShouldBe("News Hour");
|
||||
one.PreferredAudioLanguageCode.ShouldBe("eng");
|
||||
one.PreferredAudioTitle.ShouldBe("Director Commentary");
|
||||
one.PreferredSubtitleLanguageCode.ShouldBe("fra");
|
||||
one.SubtitleMode.ShouldBe(ChannelSubtitleMode.Any);
|
||||
|
||||
ScheduleItemResponseModel duration = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Duration);
|
||||
duration.SmartCollectionName.ShouldBe("Smart Picks");
|
||||
duration.PlayoutDuration.ShouldBe(TimeSpan.FromMinutes(45));
|
||||
duration.TailMode.ShouldBe(TailMode.Filler);
|
||||
duration.TailFillerId.ShouldBe(4);
|
||||
duration.PlaybackOrder.ShouldBe(PlaybackOrder.Marathon);
|
||||
duration.MarathonGroupBy.ShouldBe(MarathonGroupBy.Show);
|
||||
duration.MarathonBatchSize.ShouldBe(4);
|
||||
// Seeded with DiscardToFillAttempts = 5, but the write path's FixDiscardToFillAttempts zeroes
|
||||
// it for any order other than Random/Shuffle (Marathon here) — a deliberate server-side
|
||||
// normalization. Pinning it here keeps the lossless round-trip honest: A already holds 0, so
|
||||
// the re-submit maps 0 → 0 and B stays equal.
|
||||
duration.DiscardToFillAttempts.ShouldBe(0);
|
||||
|
||||
ScheduleItemResponseModel flood = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Flood);
|
||||
flood.PlaylistName.ShouldBe("My Playlist");
|
||||
flood.PlaylistGroupId.ShouldBe(9);
|
||||
flood.MarathonShuffleGroups.ShouldBeTrue();
|
||||
|
||||
ScheduleItemResponseModel rerun = envelopeA.Items.Single(i => i.CollectionType == CollectionType.RerunFirstRun);
|
||||
rerun.RerunCollectionName.ShouldBe("Rerun Bin");
|
||||
|
||||
ScheduleItemResponseModel search = envelopeA.Items.Single(i => i.CollectionType == CollectionType.SearchQuery);
|
||||
search.SearchQuery.ShouldBe("genre:comedy");
|
||||
search.Name.ShouldBe("Comedy Search");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Get_Should_Apply_EnforceProperties_Normalization_When_Shuffle_Enabled()
|
||||
{
|
||||
int scheduleId = await SeedScheduleAndReferences(shuffleScheduleItems: true);
|
||||
|
||||
var replaceHandler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
|
||||
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> seeded = await replaceHandler.Handle(
|
||||
new ReplaceProgramScheduleItems(
|
||||
scheduleId,
|
||||
[
|
||||
// Fixed-start Flood item on a plain collection.
|
||||
MakeReplace(0, PlayoutMode.Flood, CollectionType.Collection) with
|
||||
{
|
||||
StartTime = TimeSpan.FromHours(6),
|
||||
CollectionId = 1,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
// Playlist item carrying a playback order that must be normalized away.
|
||||
MakeReplace(1, PlayoutMode.One, CollectionType.Playlist) with
|
||||
{
|
||||
PlaylistId = 1,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
]),
|
||||
CancellationToken.None);
|
||||
seeded.IsRight.ShouldBeTrue();
|
||||
|
||||
List<ScheduleItemResponseModel> items = (await GetItemsEnvelope(scheduleId)).Items;
|
||||
|
||||
ScheduleItemResponseModel floodItem = items.Single(i => i.CollectionType == CollectionType.Collection);
|
||||
// Flood → One and Fixed → Dynamic are the documented, deliberate rewrites when ShuffleScheduleItems is on.
|
||||
floodItem.PlayoutMode.ShouldBe(PlayoutMode.One);
|
||||
floodItem.StartType.ShouldBe(StartType.Dynamic);
|
||||
|
||||
ScheduleItemResponseModel playlistItem = items.Single(i => i.CollectionType == CollectionType.Playlist);
|
||||
playlistItem.PlaybackOrder.ShouldBe(PlaybackOrder.None);
|
||||
}
|
||||
|
||||
private async Task<ScheduleItemsResponseModel> GetItemsEnvelope(int scheduleId)
|
||||
{
|
||||
var itemsHandler = new GetProgramScheduleItemsHandler(_db.Factory);
|
||||
IMediator mediator = Substitute.For<IMediator>();
|
||||
mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => itemsHandler.Handle((GetProgramScheduleItems)ci[0], (CancellationToken)ci[1]));
|
||||
|
||||
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
|
||||
repo.GetItems(Arg.Any<int>()).Returns(new List<MediaItem>());
|
||||
|
||||
var withDurations = new GetProgramScheduleItemsWithDurationsHandler(mediator, repo);
|
||||
ProgramScheduleItemsWithDurationViewModel vm =
|
||||
await withDurations.Handle(new GetProgramScheduleItemsWithDurations(scheduleId), CancellationToken.None);
|
||||
return ScheduleItemResponseMapper.ProjectToResponseModel(vm);
|
||||
}
|
||||
|
||||
private static List<ReplaceProgramScheduleItem> BuildSeedItems() =>
|
||||
[
|
||||
// 0: Fixed-start One on a plain collection, full preferred-audio/subtitle + custom title.
|
||||
MakeReplace(0, PlayoutMode.One, CollectionType.Collection) with
|
||||
{
|
||||
StartType = StartType.Fixed,
|
||||
StartTime = TimeSpan.FromHours(20),
|
||||
FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible,
|
||||
CollectionId = 1,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
CustomTitle = "News Hour",
|
||||
PreferredAudioLanguageCode = "eng",
|
||||
PreferredAudioTitle = "Director Commentary",
|
||||
PreferredSubtitleLanguageCode = "fra",
|
||||
SubtitleMode = ChannelSubtitleMode.Any
|
||||
},
|
||||
// 1: Multiple (Count, expression string) on a different collection, group mode + all 5 fillers + 2 wm + 2 ge.
|
||||
MakeReplace(1, PlayoutMode.Multiple, CollectionType.Collection) with
|
||||
{
|
||||
CollectionId = 2,
|
||||
PlaybackOrder = PlaybackOrder.Shuffle,
|
||||
FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups,
|
||||
MultipleMode = MultipleMode.Count,
|
||||
MultipleCount = "2 + 1",
|
||||
PreRollFillerId = 1,
|
||||
MidRollFillerId = 2,
|
||||
PostRollFillerId = 3,
|
||||
TailFillerId = 4,
|
||||
FallbackFillerId = 5,
|
||||
WatermarkIds = [1, 2],
|
||||
GraphicsElementIds = [1, 2]
|
||||
},
|
||||
// 2: Duration on a smart collection, Marathon order + tail filler.
|
||||
MakeReplace(2, PlayoutMode.Duration, CollectionType.SmartCollection) with
|
||||
{
|
||||
SmartCollectionId = 1,
|
||||
PlayoutDuration = TimeSpan.FromMinutes(45),
|
||||
TailMode = TailMode.Filler,
|
||||
TailFillerId = 4,
|
||||
DiscardToFillAttempts = 5,
|
||||
PlaybackOrder = PlaybackOrder.Marathon,
|
||||
MarathonGroupBy = MarathonGroupBy.Show,
|
||||
MarathonShuffleGroups = true,
|
||||
MarathonShuffleItems = true,
|
||||
MarathonBatchSize = 4
|
||||
},
|
||||
// 3: Flood on a playlist (playback order None; MarathonShuffleGroups doubles as shuffle-playlist-items).
|
||||
MakeReplace(3, PlayoutMode.Flood, CollectionType.Playlist) with
|
||||
{
|
||||
PlaylistId = 1,
|
||||
PlaybackOrder = PlaybackOrder.None,
|
||||
MarathonShuffleGroups = true
|
||||
},
|
||||
// 4: Rerun-first-run One item.
|
||||
MakeReplace(4, PlayoutMode.One, CollectionType.RerunFirstRun) with
|
||||
{
|
||||
RerunCollectionId = 1,
|
||||
PlaybackOrder = PlaybackOrder.None
|
||||
},
|
||||
// 5: SearchQuery One item.
|
||||
MakeReplace(5, PlayoutMode.One, CollectionType.SearchQuery) with
|
||||
{
|
||||
SearchTitle = "Comedy Search",
|
||||
SearchQuery = "genre:comedy",
|
||||
PlaybackOrder = PlaybackOrder.Shuffle
|
||||
}
|
||||
];
|
||||
|
||||
private static ReplaceProgramScheduleItem MakeReplace(int index, PlayoutMode playoutMode, CollectionType collectionType) =>
|
||||
new(
|
||||
index,
|
||||
StartType.Dynamic,
|
||||
StartTime: null,
|
||||
FixedStartTimeBehavior: null,
|
||||
playoutMode,
|
||||
collectionType,
|
||||
CollectionId: null,
|
||||
MultiCollectionId: null,
|
||||
SmartCollectionId: null,
|
||||
RerunCollectionId: null,
|
||||
MediaItemId: null,
|
||||
PlaylistId: null,
|
||||
SearchTitle: null,
|
||||
SearchQuery: null,
|
||||
PlaybackOrder: PlaybackOrder.Shuffle,
|
||||
MarathonGroupBy: MarathonGroupBy.None,
|
||||
MarathonShuffleGroups: false,
|
||||
MarathonShuffleItems: false,
|
||||
MarathonBatchSize: null,
|
||||
FillWithGroupMode: FillWithGroupMode.None,
|
||||
MultipleMode: MultipleMode.Count,
|
||||
MultipleCount: null,
|
||||
PlayoutDuration: null,
|
||||
TailMode: TailMode.None,
|
||||
DiscardToFillAttempts: null,
|
||||
CustomTitle: null,
|
||||
GuideMode: GuideMode.Normal,
|
||||
PreRollFillerId: null,
|
||||
MidRollFillerId: null,
|
||||
PostRollFillerId: null,
|
||||
TailFillerId: null,
|
||||
FallbackFillerId: null,
|
||||
WatermarkIds: [],
|
||||
GraphicsElementIds: [],
|
||||
PreferredAudioLanguageCode: null,
|
||||
PreferredAudioTitle: null,
|
||||
PreferredSubtitleLanguageCode: null,
|
||||
SubtitleMode: null);
|
||||
|
||||
// Mirrors the ScheduleItemRequest → ReplaceProgramScheduleItem controller mapping, but sourced from a
|
||||
// response DTO (the SPA does this same field-for-field copy in TypeScript before a PUT).
|
||||
private static ReplaceProgramScheduleItem ToReplaceCommand(ScheduleItemResponseModel r, int index) =>
|
||||
new(
|
||||
index,
|
||||
r.StartType,
|
||||
r.StartTime,
|
||||
r.FixedStartTimeBehavior,
|
||||
r.PlayoutMode,
|
||||
r.CollectionType,
|
||||
r.CollectionId,
|
||||
r.MultiCollectionId,
|
||||
r.SmartCollectionId,
|
||||
r.RerunCollectionId,
|
||||
r.MediaItemId,
|
||||
r.PlaylistId,
|
||||
r.SearchTitle,
|
||||
r.SearchQuery,
|
||||
r.PlaybackOrder,
|
||||
r.MarathonGroupBy,
|
||||
r.MarathonShuffleGroups,
|
||||
r.MarathonShuffleItems,
|
||||
r.MarathonBatchSize,
|
||||
r.FillWithGroupMode,
|
||||
r.MultipleMode ?? MultipleMode.Count,
|
||||
r.MultipleCount,
|
||||
r.PlayoutDuration,
|
||||
r.TailMode ?? TailMode.None,
|
||||
r.DiscardToFillAttempts,
|
||||
r.CustomTitle,
|
||||
r.GuideMode,
|
||||
r.PreRollFillerId,
|
||||
r.MidRollFillerId,
|
||||
r.PostRollFillerId,
|
||||
r.TailFillerId,
|
||||
r.FallbackFillerId,
|
||||
r.WatermarkIds,
|
||||
r.GraphicsElementIds,
|
||||
r.PreferredAudioLanguageCode,
|
||||
r.PreferredAudioTitle,
|
||||
r.PreferredSubtitleLanguageCode,
|
||||
r.SubtitleMode);
|
||||
|
||||
private static void AssertSemanticallyEqual(ScheduleItemResponseModel a, ScheduleItemResponseModel b)
|
||||
{
|
||||
b.Index.ShouldBe(a.Index);
|
||||
b.StartType.ShouldBe(a.StartType);
|
||||
b.StartTime.ShouldBe(a.StartTime);
|
||||
b.FixedStartTimeBehavior.ShouldBe(a.FixedStartTimeBehavior);
|
||||
b.PlayoutMode.ShouldBe(a.PlayoutMode);
|
||||
b.CollectionType.ShouldBe(a.CollectionType);
|
||||
b.CollectionId.ShouldBe(a.CollectionId);
|
||||
b.MultiCollectionId.ShouldBe(a.MultiCollectionId);
|
||||
b.SmartCollectionId.ShouldBe(a.SmartCollectionId);
|
||||
b.RerunCollectionId.ShouldBe(a.RerunCollectionId);
|
||||
b.MediaItemId.ShouldBe(a.MediaItemId);
|
||||
b.PlaylistId.ShouldBe(a.PlaylistId);
|
||||
b.SearchTitle.ShouldBe(a.SearchTitle);
|
||||
b.SearchQuery.ShouldBe(a.SearchQuery);
|
||||
b.PlaybackOrder.ShouldBe(a.PlaybackOrder);
|
||||
b.MarathonGroupBy.ShouldBe(a.MarathonGroupBy);
|
||||
b.MarathonShuffleGroups.ShouldBe(a.MarathonShuffleGroups);
|
||||
b.MarathonShuffleItems.ShouldBe(a.MarathonShuffleItems);
|
||||
b.MarathonBatchSize.ShouldBe(a.MarathonBatchSize);
|
||||
b.FillWithGroupMode.ShouldBe(a.FillWithGroupMode);
|
||||
b.MultipleMode.ShouldBe(a.MultipleMode);
|
||||
b.MultipleCount.ShouldBe(a.MultipleCount);
|
||||
b.PlayoutDuration.ShouldBe(a.PlayoutDuration);
|
||||
b.TailMode.ShouldBe(a.TailMode);
|
||||
b.DiscardToFillAttempts.ShouldBe(a.DiscardToFillAttempts);
|
||||
b.CustomTitle.ShouldBe(a.CustomTitle);
|
||||
b.GuideMode.ShouldBe(a.GuideMode);
|
||||
b.PreRollFillerId.ShouldBe(a.PreRollFillerId);
|
||||
b.MidRollFillerId.ShouldBe(a.MidRollFillerId);
|
||||
b.PostRollFillerId.ShouldBe(a.PostRollFillerId);
|
||||
b.TailFillerId.ShouldBe(a.TailFillerId);
|
||||
b.FallbackFillerId.ShouldBe(a.FallbackFillerId);
|
||||
b.WatermarkIds.ShouldBe(a.WatermarkIds);
|
||||
b.GraphicsElementIds.ShouldBe(a.GraphicsElementIds);
|
||||
b.PreferredAudioLanguageCode.ShouldBe(a.PreferredAudioLanguageCode);
|
||||
b.PreferredAudioTitle.ShouldBe(a.PreferredAudioTitle);
|
||||
b.PreferredSubtitleLanguageCode.ShouldBe(a.PreferredSubtitleLanguageCode);
|
||||
b.SubtitleMode.ShouldBe(a.SubtitleMode);
|
||||
b.CollectionName.ShouldBe(a.CollectionName);
|
||||
b.MultiCollectionName.ShouldBe(a.MultiCollectionName);
|
||||
b.SmartCollectionName.ShouldBe(a.SmartCollectionName);
|
||||
b.RerunCollectionName.ShouldBe(a.RerunCollectionName);
|
||||
b.PlaylistName.ShouldBe(a.PlaylistName);
|
||||
b.PlaylistGroupId.ShouldBe(a.PlaylistGroupId);
|
||||
b.MediaItemName.ShouldBe(a.MediaItemName);
|
||||
b.PreRollFillerName.ShouldBe(a.PreRollFillerName);
|
||||
b.MidRollFillerName.ShouldBe(a.MidRollFillerName);
|
||||
b.PostRollFillerName.ShouldBe(a.PostRollFillerName);
|
||||
b.TailFillerName.ShouldBe(a.TailFillerName);
|
||||
b.FallbackFillerName.ShouldBe(a.FallbackFillerName);
|
||||
b.Watermarks.Select(w => (w.Id, w.Name)).ShouldBe(a.Watermarks.Select(w => (w.Id, w.Name)));
|
||||
b.GraphicsElements.Select(g => (g.Id, g.Name)).ShouldBe(a.GraphicsElements.Select(g => (g.Id, g.Name)));
|
||||
b.Name.ShouldBe(a.Name);
|
||||
b.DurationEstimate.ShouldBe(a.DurationEstimate);
|
||||
}
|
||||
|
||||
private async Task<int> SeedScheduleAndReferences(bool shuffleScheduleItems)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
context.Collections.Add(new Collection { Id = 1, Name = "First Collection" });
|
||||
context.Collections.Add(new Collection { Id = 2, Name = "Prime Collection" });
|
||||
context.SmartCollections.Add(new SmartCollection { Id = 1, Name = "Smart Picks", Query = "*" });
|
||||
context.RerunCollections.Add(new RerunCollection
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Rerun Bin",
|
||||
CollectionType = CollectionType.Collection
|
||||
});
|
||||
|
||||
context.PlaylistGroups.Add(new PlaylistGroup { Id = 9, Name = "Group" });
|
||||
context.Playlists.Add(new Playlist { Id = 1, PlaylistGroupId = 9, Name = "My Playlist" });
|
||||
|
||||
context.ChannelWatermarks.Add(new ChannelWatermark { Id = 1, Name = "WM One" });
|
||||
context.ChannelWatermarks.Add(new ChannelWatermark { Id = 2, Name = "WM Two" });
|
||||
context.GraphicsElements.Add(new GraphicsElement { Id = 1, Name = "GE One", Path = "/ge/1", Kind = GraphicsElementKind.Image });
|
||||
context.GraphicsElements.Add(new GraphicsElement { Id = 2, Name = "GE Two", Path = "/ge/2", Kind = GraphicsElementKind.Image });
|
||||
|
||||
AddFiller(context, 1, "PreRoll", FillerKind.PreRoll);
|
||||
AddFiller(context, 2, "MidRoll", FillerKind.MidRoll);
|
||||
AddFiller(context, 3, "PostRoll", FillerKind.PostRoll);
|
||||
AddFiller(context, 4, "Tail", FillerKind.Tail);
|
||||
AddFiller(context, 5, "Fallback", FillerKind.Fallback);
|
||||
|
||||
var schedule = new ProgramSchedule
|
||||
{
|
||||
Name = "Round Trip",
|
||||
ShuffleScheduleItems = shuffleScheduleItems,
|
||||
Items = [],
|
||||
Playouts = [],
|
||||
ProgramScheduleAlternates = []
|
||||
};
|
||||
context.ProgramSchedules.Add(schedule);
|
||||
await context.SaveChangesAsync();
|
||||
return schedule.Id;
|
||||
}
|
||||
|
||||
private static void AddFiller(TvContext context, int id, string name, FillerKind kind) =>
|
||||
context.FillerPresets.Add(new FillerPreset
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
FillerKind = kind,
|
||||
FillerMode = FillerMode.Count,
|
||||
Count = 1,
|
||||
CollectionType = CollectionType.Collection
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.ProgramSchedules;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.ProgramSchedules;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for #229. The write path (Replace = PUT, Add = POST) used to project freshly-persisted
|
||||
/// items whose watermark/graphics join rows only carry foreign-key ids — the Watermark / GraphicsElement navs
|
||||
/// are null, and <c>Mapper.ProjectToViewModel</c> dereferences them without a guard, throwing an NRE that
|
||||
/// surfaced as a 500. The pre-existing round-trip test masked this because the Replace handler returned a
|
||||
/// <b>lazy</b> LanguageExt <c>Map</c> sequence and that test only checked <c>.IsRight</c> — it never enumerated
|
||||
/// the projected items, so the deferred NRE never fired. These tests force enumeration of the write-path
|
||||
/// response (exactly as the controller's <c>.ToList()</c> / single-item serialization does) and seed the
|
||||
/// watermark/graphics entities through a <b>separate</b> context so the handler's fresh factory context has
|
||||
/// nothing pre-tracked (no accidental change-tracker fix-up).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ScheduleItemWriteProjectionTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Replace_PutResponse_Should_Carry_Watermark_And_Graphics_Names()
|
||||
{
|
||||
int scheduleId = await SeedReferences();
|
||||
|
||||
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
|
||||
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result = await handler.Handle(
|
||||
new ReplaceProgramScheduleItems(scheduleId, [ItemWithGraphics(0)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue(result.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
|
||||
|
||||
// Force enumeration exactly like the controller (`items.Select(...).ToList()`) — this is what threw the NRE.
|
||||
List<ProgramScheduleItemViewModel> items = result.RightToSeq().Head().ToList();
|
||||
ProgramScheduleItemViewModel item = items.Single();
|
||||
item.Watermarks.Select(w => w.Name).ShouldBe(["WM One", "WM Two"]);
|
||||
item.GraphicsElements.Count.ShouldBe(2);
|
||||
item.GraphicsElements.Select(g => g.Name).ShouldAllBe(n => !string.IsNullOrWhiteSpace(n));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Add_PostResponse_Should_Carry_Watermark_And_Graphics_Names()
|
||||
{
|
||||
int scheduleId = await SeedReferences();
|
||||
|
||||
var handler = new AddProgramScheduleItemHandler(_db.Factory, _worker);
|
||||
Either<BaseError, ProgramScheduleItemViewModel> result = await handler.Handle(
|
||||
AddItemWithGraphics(scheduleId),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue(result.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
|
||||
|
||||
ProgramScheduleItemViewModel item = result.RightToSeq().Head();
|
||||
item.Watermarks.Select(w => w.Name).ShouldBe(["WM One", "WM Two"]);
|
||||
item.GraphicsElements.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Get_Should_Return_Items_In_Index_Order_Regardless_Of_Id_Order()
|
||||
{
|
||||
int scheduleId;
|
||||
await using (TvContext ctx = _db.CreateContext())
|
||||
{
|
||||
var schedule = new ProgramSchedule
|
||||
{
|
||||
Name = "Shuffled", Items = [], Playouts = [], ProgramScheduleAlternates = []
|
||||
};
|
||||
ctx.ProgramSchedules.Add(schedule);
|
||||
await ctx.SaveChangesAsync();
|
||||
scheduleId = schedule.Id;
|
||||
|
||||
// Insert with ids deliberately out of Index order: id 10 -> Index 2, id 11 -> Index 0, id 12 -> Index 1.
|
||||
// Before the fix the GET returned id order (2, 0, 1); after, it must return Index order (0, 1, 2).
|
||||
ctx.ProgramScheduleItems.Add(OneItemRow(10, scheduleId, index: 2));
|
||||
ctx.ProgramScheduleItems.Add(OneItemRow(11, scheduleId, index: 0));
|
||||
ctx.ProgramScheduleItems.Add(OneItemRow(12, scheduleId, index: 1));
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var getHandler = new GetProgramScheduleItemsHandler(_db.Factory);
|
||||
List<ProgramScheduleItemViewModel> items =
|
||||
await getHandler.Handle(new GetProgramScheduleItems(scheduleId), CancellationToken.None);
|
||||
|
||||
items.Select(i => i.Index).ShouldBe([0, 1, 2]);
|
||||
}
|
||||
|
||||
private static ProgramScheduleItemOne OneItemRow(int id, int scheduleId, int index) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
ProgramScheduleId = scheduleId,
|
||||
Index = index,
|
||||
CollectionType = CollectionType.Collection,
|
||||
CollectionId = 1,
|
||||
PlaybackOrder = PlaybackOrder.Shuffle,
|
||||
ProgramScheduleItemWatermarks = [],
|
||||
ProgramScheduleItemGraphicsElements = []
|
||||
};
|
||||
|
||||
private async Task<int> SeedReferences()
|
||||
{
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
ctx.Collections.Add(new Collection { Id = 1, Name = "First Collection" });
|
||||
ctx.ChannelWatermarks.Add(new ChannelWatermark { Id = 1, Name = "WM One" });
|
||||
ctx.ChannelWatermarks.Add(new ChannelWatermark { Id = 2, Name = "WM Two" });
|
||||
ctx.GraphicsElements.Add(new GraphicsElement { Id = 1, Name = "GE One", Path = "/ge/1", Kind = GraphicsElementKind.Image });
|
||||
ctx.GraphicsElements.Add(new GraphicsElement { Id = 2, Name = "GE Two", Path = "/ge/2", Kind = GraphicsElementKind.Image });
|
||||
|
||||
var schedule = new ProgramSchedule
|
||||
{
|
||||
Name = "WM Schedule", Items = [], Playouts = [], ProgramScheduleAlternates = []
|
||||
};
|
||||
ctx.ProgramSchedules.Add(schedule);
|
||||
await ctx.SaveChangesAsync();
|
||||
return schedule.Id;
|
||||
}
|
||||
|
||||
private static ReplaceProgramScheduleItem ItemWithGraphics(int index) =>
|
||||
new(
|
||||
index, StartType.Dynamic, StartTime: null, FixedStartTimeBehavior: null, PlayoutMode.One,
|
||||
CollectionType.Collection, CollectionId: 1, MultiCollectionId: null, SmartCollectionId: null,
|
||||
RerunCollectionId: null, MediaItemId: null, PlaylistId: null, SearchTitle: null, SearchQuery: null,
|
||||
PlaybackOrder: PlaybackOrder.Shuffle, MarathonGroupBy: MarathonGroupBy.None,
|
||||
MarathonShuffleGroups: false, MarathonShuffleItems: false, MarathonBatchSize: null,
|
||||
FillWithGroupMode: FillWithGroupMode.None, MultipleMode: MultipleMode.Count, MultipleCount: null,
|
||||
PlayoutDuration: null, TailMode: TailMode.None, DiscardToFillAttempts: null, CustomTitle: null,
|
||||
GuideMode: GuideMode.Normal, PreRollFillerId: null, MidRollFillerId: null, PostRollFillerId: null,
|
||||
TailFillerId: null, FallbackFillerId: null, WatermarkIds: [1, 2], GraphicsElementIds: [1, 2],
|
||||
PreferredAudioLanguageCode: null, PreferredAudioTitle: null, PreferredSubtitleLanguageCode: null,
|
||||
SubtitleMode: null);
|
||||
|
||||
private static AddProgramScheduleItem AddItemWithGraphics(int scheduleId) =>
|
||||
new(
|
||||
scheduleId, StartType.Dynamic, StartTime: null, FixedStartTimeBehavior: null, PlayoutMode.One,
|
||||
CollectionType.Collection, CollectionId: 1, MultiCollectionId: null, SmartCollectionId: null,
|
||||
RerunCollectionId: null, MediaItemId: null, PlaylistId: null, SearchTitle: null, SearchQuery: null,
|
||||
PlaybackOrder: PlaybackOrder.Shuffle, MarathonGroupBy: MarathonGroupBy.None,
|
||||
MarathonShuffleGroups: false, MarathonShuffleItems: false, MarathonBatchSize: null,
|
||||
FillWithGroupMode: FillWithGroupMode.None, MultipleMode: MultipleMode.Count, MultipleCount: null,
|
||||
PlayoutDuration: null, TailMode: TailMode.None, DiscardToFillAttempts: null, CustomTitle: null,
|
||||
GuideMode: GuideMode.Normal, PreRollFillerId: null, MidRollFillerId: null, PostRollFillerId: null,
|
||||
TailFillerId: null, FallbackFillerId: null, WatermarkIds: [1, 2], GraphicsElementIds: [1, 2],
|
||||
PreferredAudioLanguageCode: null, PreferredAudioTitle: null, PreferredSubtitleLanguageCode: null,
|
||||
SubtitleMode: null);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public class ApiErrorResponseMetadataTests
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status409Conflict)]
|
||||
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.GetDefault), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status422UnprocessableEntity)]
|
||||
@@ -113,22 +114,33 @@ public class ApiErrorResponseMetadataTests
|
||||
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status409Conflict)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status409Conflict)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status409Conflict)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status409Conflict)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status409Conflict)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status409Conflict)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), StatusCodes.Status409Conflict)]
|
||||
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.GetById), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status401Unauthorized)]
|
||||
@@ -139,6 +151,7 @@ public class ApiErrorResponseMetadataTests
|
||||
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status401Unauthorized)]
|
||||
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(LibrariesController), nameof(LibrariesController.ScanShow), StatusCodes.Status404NotFound)]
|
||||
public void Api_Error_Response_Metadata_Should_Document_ProblemDetails(
|
||||
Type controllerType,
|
||||
string actionName,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using System.Reflection;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Templates;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
@@ -10,6 +13,7 @@ using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
@@ -27,6 +31,7 @@ public class ChannelControllerTests
|
||||
{
|
||||
private IMediator _mediator = null!;
|
||||
private Channel<IBackgroundServiceRequest> _workerChannel = null!;
|
||||
private IEntityLocker _entityLocker = null!;
|
||||
private ChannelController _controller = null!;
|
||||
|
||||
[SetUp]
|
||||
@@ -34,7 +39,8 @@ public class ChannelControllerTests
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_workerChannel = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
||||
_controller = new ChannelController(_workerChannel.Writer, _mediator);
|
||||
_entityLocker = Substitute.For<IEntityLocker>();
|
||||
_controller = new ChannelController(_workerChannel.Writer, _mediator, _entityLocker);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -56,6 +62,40 @@ public class ChannelControllerTests
|
||||
result.ShouldBe(new List<ChannelStateResponseModel> { state });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MusicVideoCreditsTemplates_And_StreamSelectors_Routes_Are_Literal_And_Precede_Id()
|
||||
{
|
||||
MethodInfo mvct = typeof(ChannelController).GetMethod(nameof(ChannelController.GetMusicVideoCreditsTemplates))!;
|
||||
mvct.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single().Template
|
||||
.ShouldBe("/api/channels/music-video-credits-templates");
|
||||
|
||||
MethodInfo selectors = typeof(ChannelController).GetMethod(nameof(ChannelController.GetStreamSelectors))!;
|
||||
selectors.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single().Template
|
||||
.ShouldBe("/api/channels/stream-selectors");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetMusicVideoCreditsTemplates_Should_Return_Names()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetMusicVideoCreditTemplates>(), Arg.Any<CancellationToken>())
|
||||
.Returns(["Default", "Scroll"]);
|
||||
|
||||
List<string> result = await _controller.GetMusicVideoCreditsTemplates(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(["Default", "Scroll"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetStreamSelectors_Should_Return_Names()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetChannelStreamSelectors>(), Arg.Any<CancellationToken>())
|
||||
.Returns(["selector-a.yml"]);
|
||||
|
||||
List<string> result = await _controller.GetStreamSelectors(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(["selector-a.yml"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_With_Location_And_Body()
|
||||
{
|
||||
@@ -374,6 +414,20 @@ public class ChannelControllerTests
|
||||
buildPlayout.Mode.ShouldBe(expectedMode);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ResetPlayout_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.Some(9));
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
|
||||
|
||||
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
||||
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
_workerChannel.Reader.TryRead(out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ResetPlayout_Should_Honor_Explicit_Mode()
|
||||
{
|
||||
|
||||
@@ -45,6 +45,66 @@ public class CollectionControllerTests
|
||||
nameof(CollectionController.RemoveItem),
|
||||
"DELETE",
|
||||
"/api/collections/{id:int}/items/{mediaItemId:int}");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(CollectionController.UpdateCustomOrder),
|
||||
"PUT",
|
||||
"/api/collections/{id:int}/custom-order");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateCustomOrder_Should_Return_404_When_Collection_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MediaCollectionViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.UpdateCustomOrder(
|
||||
99,
|
||||
new UpdateCollectionCustomOrderRequest([10, 20]),
|
||||
CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdateCollectionCustomOrder>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateCustomOrder_Should_Return_204_And_Assign_Index_From_Order()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MediaCollectionViewModel>.Some(MakeVm(7, "Movies")));
|
||||
_mediator.Send(Arg.Any<UpdateCollectionCustomOrder>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.UpdateCustomOrder(
|
||||
7,
|
||||
new UpdateCollectionCustomOrderRequest([30, 10, 20]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateCollectionCustomOrder>(c =>
|
||||
c.CollectionId == 7 &&
|
||||
c.MediaItemCustomOrders.Count == 3 &&
|
||||
c.MediaItemCustomOrders[0].MediaItemId == 30 && c.MediaItemCustomOrders[0].CustomIndex == 0 &&
|
||||
c.MediaItemCustomOrders[1].MediaItemId == 10 && c.MediaItemCustomOrders[1].CustomIndex == 1 &&
|
||||
c.MediaItemCustomOrders[2].MediaItemId == 20 && c.MediaItemCustomOrders[2].CustomIndex == 2),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateCustomOrder_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MediaCollectionViewModel>.Some(MakeVm(7, "Movies")));
|
||||
_mediator.Send(Arg.Any<UpdateCollectionCustomOrder>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.UpdateCustomOrder(
|
||||
7,
|
||||
new UpdateCollectionCustomOrderRequest([10]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -63,17 +63,30 @@ public class FillerPresetControllerTests
|
||||
{
|
||||
List<FillerPresetResponseModel> models =
|
||||
[
|
||||
new FillerPresetResponseModel(1, "Intro"),
|
||||
new FillerPresetResponseModel(2, "Outro")
|
||||
new FillerPresetResponseModel(1, "Intro", FillerKind.PreRoll),
|
||||
new FillerPresetResponseModel(2, "Outro", FillerKind.PostRoll)
|
||||
];
|
||||
_mediator.Send(Arg.Any<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(models);
|
||||
|
||||
List<FillerPresetResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
List<FillerPresetResponseModel> result = await _controller.GetAll(null, CancellationToken.None);
|
||||
|
||||
result.ShouldBe(models);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Forward_FillerKind_Filter()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
await _controller.GetAll(FillerKind.MidRoll, CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetAllFillerPresetsForApi>(q => q.FillerKind == FillerKind.MidRoll),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
{
|
||||
|
||||
@@ -45,7 +45,7 @@ public class GraphicsElementControllerTests
|
||||
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(models);
|
||||
|
||||
List<GraphicsElementResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
List<GraphicsElementResponseModel> result = await _controller.GetAll(refresh: false, CancellationToken.None);
|
||||
|
||||
result.ShouldBe(models);
|
||||
}
|
||||
@@ -56,8 +56,34 @@ public class GraphicsElementControllerTests
|
||||
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
List<GraphicsElementResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
List<GraphicsElementResponseModel> result = await _controller.GetAll(refresh: false, CancellationToken.None);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Not_Refresh_When_Refresh_Is_False()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
await _controller.GetAll(refresh: false, CancellationToken.None);
|
||||
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<RefreshGraphicsElements>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Refresh_Before_Listing_When_Refresh_Is_True()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
await _controller.GetAll(refresh: true, CancellationToken.None);
|
||||
|
||||
Received.InOrder(() =>
|
||||
{
|
||||
_mediator.Send(Arg.Any<RefreshGraphicsElements>(), Arg.Any<CancellationToken>());
|
||||
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core.Api.MediaItems;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class LanguagesControllerTests
|
||||
{
|
||||
private LanguagesController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new LanguagesController(_mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route()
|
||||
{
|
||||
MethodInfo action = typeof(LanguagesController).GetMethod(nameof(LanguagesController.GetLanguages))!;
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/languages");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLanguages_Should_Map_Vm_To_ResponseModel()
|
||||
{
|
||||
List<LanguageCodeViewModel> vms =
|
||||
[
|
||||
new LanguageCodeViewModel("eng", "English"),
|
||||
new LanguageCodeViewModel("fra", "French")
|
||||
];
|
||||
_mediator.Send(Arg.Any<GetAllLanguageCodes>(), Arg.Any<CancellationToken>())
|
||||
.Returns(vms);
|
||||
|
||||
List<LanguageCodeResponseModel> result = await _controller.GetLanguages(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(
|
||||
[
|
||||
new LanguageCodeResponseModel("eng", "English"),
|
||||
new LanguageCodeResponseModel("fra", "French")
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@ using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core.Api.Libraries;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
@@ -16,12 +19,14 @@ public class LibrariesControllerTests
|
||||
{
|
||||
private LibrariesController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
private ITelevisionRepository _televisionRepository = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new LibrariesController(Substitute.For<ITelevisionRepository>(), _mediator);
|
||||
_televisionRepository = Substitute.For<ITelevisionRepository>();
|
||||
_controller = new LibrariesController(_televisionRepository, _mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -52,4 +57,92 @@ public class LibrariesControllerTests
|
||||
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanShow_Should_Return_NotFoundProblem_When_Show_Not_In_Library()
|
||||
{
|
||||
_televisionRepository.GetShowTitle(3, 999).Returns(Option<string>.None);
|
||||
|
||||
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(999));
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status404NotFound);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanShow_Should_Queue_Scan_By_Show_Id_When_Show_Belongs_To_Library()
|
||||
{
|
||||
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
|
||||
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>()).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42, DeepScan: true));
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<QueueShowScanByLibraryId>(r =>
|
||||
r.LibraryId == 3 && r.ShowId == 42 && r.ShowTitle == "The Office" && r.DeepScan),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanShow_Should_Return_BadRequest_When_Mediator_Fails_To_Queue()
|
||||
{
|
||||
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
|
||||
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>()).Returns(false);
|
||||
|
||||
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42));
|
||||
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanLibrary_Should_Return_202_When_Queued()
|
||||
{
|
||||
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueLibraryScanResult.Queued);
|
||||
|
||||
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<QueueLibraryScanByLibraryId>(r => r.LibraryId == 7),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanLibrary_Should_Return_404_When_NotFound()
|
||||
{
|
||||
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueLibraryScanResult.NotFound);
|
||||
|
||||
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanLibrary_Should_Return_409_When_AlreadyScanning()
|
||||
{
|
||||
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueLibraryScanResult.AlreadyScanning);
|
||||
|
||||
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
|
||||
|
||||
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
||||
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ScanLibrary_Should_Return_422_When_SyncDisabled()
|
||||
{
|
||||
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(QueueLibraryScanResult.SyncDisabled);
|
||||
|
||||
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
|
||||
|
||||
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status422UnprocessableEntity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ public class LogsControllerTests
|
||||
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedLogEntriesViewModel(0, []));
|
||||
|
||||
await _controller.GetLogs(-1, 500, "boom", CancellationToken.None);
|
||||
await _controller.GetLogs(-1, 500, "boom", cancellationToken: CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetRecentLogEntries>(q =>
|
||||
@@ -52,6 +52,74 @@ public class LogsControllerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLogs_Should_Default_To_Timestamp_Descending()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedLogEntriesViewModel(0, []));
|
||||
|
||||
await _controller.GetLogs(cancellationToken: CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetRecentLogEntries>(q =>
|
||||
q.SortDescending == true &&
|
||||
SelectsTimestamp(q.SortExpression)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLogs_Should_Sort_By_Level_Ascending_When_Requested()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedLogEntriesViewModel(0, []));
|
||||
|
||||
await _controller.GetLogs(sortField: "level", sortDirection: "asc", cancellationToken: CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetRecentLogEntries>(q =>
|
||||
q.SortDescending == false &&
|
||||
SelectsLevel(q.SortExpression)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLogs_Should_Reject_Unknown_Sort_Field_And_Fall_Back_To_Timestamp()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedLogEntriesViewModel(0, []));
|
||||
|
||||
await _controller.GetLogs(sortField: "message; DROP TABLE", cancellationToken: CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetRecentLogEntries>(q => SelectsTimestamp(q.SortExpression)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLogs_Should_Fall_Back_To_Descending_For_Unknown_Direction()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedLogEntriesViewModel(0, []));
|
||||
|
||||
await _controller.GetLogs(sortDirection: "sideways", cancellationToken: CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetRecentLogEntries>(q => q.SortDescending == true),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private static bool SelectsTimestamp(System.Linq.Expressions.Expression<Func<LogEntryViewModel, object>> expr)
|
||||
{
|
||||
var sample = new LogEntryViewModel(DateTimeOffset.UnixEpoch.AddDays(1), LogEventLevel.Error, "m");
|
||||
return Equals(expr.Compile()(sample), sample.Timestamp);
|
||||
}
|
||||
|
||||
private static bool SelectsLevel(System.Linq.Expressions.Expression<Func<LogEntryViewModel, object>> expr)
|
||||
{
|
||||
var sample = new LogEntryViewModel(DateTimeOffset.UnixEpoch.AddDays(1), LogEventLevel.Error, "m");
|
||||
return Equals(expr.Compile()(sample), sample.Level);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLogs_Should_Map_Entries_To_Response_Model()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class MultiCollectionControllerTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new MultiCollectionController(_mediator);
|
||||
}
|
||||
|
||||
private MultiCollectionController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.GetAll), "GET", "/api/multi-collections");
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.GetById), "GET", "/api/multi-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.Create), "POST", "/api/multi-collections");
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.Update), "PUT", "/api/multi-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(MultiCollectionController.Delete), "DELETE", "/api/multi-collections/{id:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Clamp_Paging_And_Map_Response()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPagedMultiCollections>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedMultiCollectionsViewModel(1, new List<MultiCollectionViewModel> { MakeMulti(3, "Kids") }));
|
||||
|
||||
PagedMultiCollectionsResponseModel result =
|
||||
await _controller.GetAll("k", -5, 9999, CancellationToken.None);
|
||||
|
||||
result.TotalCount.ShouldBe(1);
|
||||
result.Page.Count.ShouldBe(1);
|
||||
result.Page[0].Id.ShouldBe(3);
|
||||
result.Page[0].Items.Count.ShouldBe(2);
|
||||
result.Page[0].Items[0].CollectionId.ShouldBe(10);
|
||||
result.Page[0].Items[1].SmartCollectionId.ShouldBe(20);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetPagedMultiCollections>(q => q.Query == "k" && q.PageNum == 0 && q.PageSize == 100),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetMultiCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MultiCollectionViewModel>.Some(MakeMulti(4, "Kids")));
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<MultiCollectionResponseModel>().Id.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_404_For_None()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetMultiCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MultiCollectionViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, MultiCollectionViewModel>(MakeMulti(8, "Kids")));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateMultiCollectionRequest(
|
||||
"Kids",
|
||||
new List<MultiCollectionItemRequest>
|
||||
{
|
||||
new(10, null, true, PlaybackOrder.Chronological),
|
||||
new(null, 20, false, PlaybackOrder.Shuffle)
|
||||
}),
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/multi-collections/8");
|
||||
created.Value.ShouldBeOfType<MultiCollectionResponseModel>().Name.ShouldBe("Kids");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateMultiCollection>(c =>
|
||||
c.Name == "Kids" &&
|
||||
c.Items.Count == 2 &&
|
||||
c.Items[0].CollectionId == 10 &&
|
||||
c.Items[0].ScheduleAsGroup &&
|
||||
c.Items[0].PlaybackOrder == PlaybackOrder.Chronological &&
|
||||
c.Items[1].SmartCollectionId == 20),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, MultiCollectionViewModel>(BaseError.New("dupe")));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateMultiCollectionRequest("Kids", new List<MultiCollectionItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_200_With_Refreshed_Body_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetMultiCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MultiCollectionViewModel>.Some(MakeMulti(4, "Kids")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new UpdateMultiCollectionRequest(
|
||||
"Kids",
|
||||
new List<MultiCollectionItemRequest> { new(10, null, true, PlaybackOrder.Random) }),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<MultiCollectionResponseModel>().Id.ShouldBe(4);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateMultiCollection>(c =>
|
||||
c.MultiCollectionId == 4 &&
|
||||
c.Name == "Kids" &&
|
||||
c.Items.Count == 1 &&
|
||||
c.Items[0].CollectionId == 10 &&
|
||||
c.Items[0].PlaybackOrder == PlaybackOrder.Random),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new UpdateMultiCollectionRequest("Kids", new List<MultiCollectionItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetMultiCollectionById>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_204_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<DeleteMultiCollection>(c => c.MultiCollectionId == 4),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_404_On_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteMultiCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new ErsatzTV.Core.Errors.NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
private static MultiCollectionViewModel MakeMulti(int id, string name) =>
|
||||
new(
|
||||
id,
|
||||
name,
|
||||
new List<MultiCollectionItemViewModel>
|
||||
{
|
||||
new(
|
||||
id,
|
||||
new MediaCollectionViewModel(CollectionType.Collection, 10, "Movies", false, MediaItemState.Normal),
|
||||
true,
|
||||
PlaybackOrder.Chronological)
|
||||
},
|
||||
new List<MultiCollectionSmartItemViewModel>
|
||||
{
|
||||
new(id, new SmartCollectionViewModel(20, "Smart", "query"), false, PlaybackOrder.Shuffle)
|
||||
});
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
MethodInfo action = typeof(MultiCollectionController).GetMethod(actionName)
|
||||
?? throw new AssertionException($"Missing action {actionName}");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain(httpMethod);
|
||||
attribute.Template.ShouldBe(route);
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,7 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/channels/bulk/delete", "post", "404")]
|
||||
[TestCase("/api/channels/bulk/delete", "post", "422")]
|
||||
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")]
|
||||
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "409")]
|
||||
[TestCase("/api/channel-templates/default", "get", "404")]
|
||||
[TestCase("/api/channel-templates/default/{id}", "put", "404")]
|
||||
[TestCase("/api/channel-templates/default/{id}", "put", "422")]
|
||||
@@ -144,6 +145,33 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/smart-collections/{id}", "put", "422")]
|
||||
[TestCase("/api/smart-collections/{id}", "delete", "404")]
|
||||
[TestCase("/api/smart-collections/{id}", "delete", "422")]
|
||||
[TestCase("/api/multi-collections/{id}", "get", "404")]
|
||||
[TestCase("/api/multi-collections", "post", "404")]
|
||||
[TestCase("/api/multi-collections", "post", "422")]
|
||||
[TestCase("/api/multi-collections/{id}", "put", "404")]
|
||||
[TestCase("/api/multi-collections/{id}", "put", "422")]
|
||||
[TestCase("/api/multi-collections/{id}", "delete", "404")]
|
||||
[TestCase("/api/multi-collections/{id}", "delete", "422")]
|
||||
[TestCase("/api/playlists/groups", "post", "422")]
|
||||
[TestCase("/api/playlists/groups/{id}", "put", "404")]
|
||||
[TestCase("/api/playlists/groups/{id}", "put", "422")]
|
||||
[TestCase("/api/playlists/groups/{id}", "delete", "404")]
|
||||
[TestCase("/api/playlists/groups/{id}", "delete", "422")]
|
||||
[TestCase("/api/playlists/{id}", "get", "404")]
|
||||
[TestCase("/api/playlists/{id}/items", "get", "404")]
|
||||
[TestCase("/api/playlists", "post", "422")]
|
||||
[TestCase("/api/playlists/{id}", "put", "404")]
|
||||
[TestCase("/api/playlists/{id}", "put", "422")]
|
||||
[TestCase("/api/playlists/{id}", "delete", "404")]
|
||||
[TestCase("/api/playlists/{id}", "delete", "422")]
|
||||
[TestCase("/api/playlists/preview", "post", "422")]
|
||||
[TestCase("/api/rerun-collections/{id}", "get", "404")]
|
||||
[TestCase("/api/rerun-collections", "post", "404")]
|
||||
[TestCase("/api/rerun-collections", "post", "422")]
|
||||
[TestCase("/api/rerun-collections/{id}", "put", "404")]
|
||||
[TestCase("/api/rerun-collections/{id}", "put", "422")]
|
||||
[TestCase("/api/rerun-collections/{id}", "delete", "404")]
|
||||
[TestCase("/api/rerun-collections/{id}", "delete", "422")]
|
||||
[TestCase("/api/schedules/{id}", "get", "404")]
|
||||
[TestCase("/api/schedules", "post", "404")]
|
||||
[TestCase("/api/schedules", "post", "422")]
|
||||
@@ -161,9 +189,25 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/playouts/{id}", "get", "404")]
|
||||
[TestCase("/api/playouts", "post", "404")]
|
||||
[TestCase("/api/playouts", "post", "422")]
|
||||
[TestCase("/api/playouts/{id}", "put", "404")]
|
||||
[TestCase("/api/playouts/{id}", "put", "409")]
|
||||
[TestCase("/api/playouts/{id}", "put", "422")]
|
||||
[TestCase("/api/playouts/{id}", "delete", "404")]
|
||||
[TestCase("/api/playouts/{id}", "delete", "409")]
|
||||
[TestCase("/api/playouts/{id}", "delete", "422")]
|
||||
[TestCase("/api/playouts/{id}/deco", "put", "409")]
|
||||
[TestCase("/api/playouts/{id}/alternate-schedules", "put", "409")]
|
||||
[TestCase("/api/playouts/{id}/templates", "put", "409")]
|
||||
[TestCase("/api/playouts/{id}/items", "get", "404")]
|
||||
[TestCase("/api/playouts/{id}/erase-items", "post", "404")]
|
||||
[TestCase("/api/playouts/{id}/erase-items", "post", "409")]
|
||||
[TestCase("/api/playouts/{id}/erase-items", "post", "422")]
|
||||
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "404")]
|
||||
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "409")]
|
||||
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "422")]
|
||||
[TestCase("/api/playouts/items/{id}/scheduling-context", "get", "404")]
|
||||
[TestCase("/api/collections/{id}/custom-order", "put", "404")]
|
||||
[TestCase("/api/collections/{id}/custom-order", "put", "422")]
|
||||
[TestCase("/api/artwork/uploads", "post", "422")]
|
||||
[TestCase("/api/ffmpeg/profiles/{id}", "get", "404")]
|
||||
[TestCase("/api/ffmpeg/profiles", "post", "404")]
|
||||
@@ -225,6 +269,11 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/trakt/lists/{id}", "delete", "409")]
|
||||
[TestCase("/api/trakt/lists/{id}", "put", "404")]
|
||||
[TestCase("/api/trakt/lists/{id}", "put", "422")]
|
||||
[TestCase("/api/troubleshoot/playback/subtitles/{mediaItemId}", "get", "404")]
|
||||
[TestCase("/api/libraries/{id}/scan-show", "post", "404")]
|
||||
[TestCase("/api/libraries/{id}/scan", "post", "404")]
|
||||
[TestCase("/api/libraries/{id}/scan", "post", "409")]
|
||||
[TestCase("/api/libraries/{id}/scan", "post", "422")]
|
||||
public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
|
||||
string path,
|
||||
string method,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Text.Json;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Api.MediaItems;
|
||||
using ErsatzTV.Core.Api.Settings;
|
||||
using ErsatzTV.Serialization;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Guards that the generated OpenAPI document's property names for a set of DTOs exactly match the
|
||||
/// JSON keys the runtime MVC serializer (Newtonsoft, via <see cref="CustomContractResolver" /> +
|
||||
/// <see cref="StringEnumConverter" />) actually emits. The spec is generated from System.Text.Json
|
||||
/// metadata, whose camelCase can drift from Newtonsoft's (e.g. "ffmpegProfileId" special case, or a
|
||||
/// [JsonProperty] override). See issue #198 — a schema transformer now mirrors the runtime resolver,
|
||||
/// and this test fails if that mirroring is ever removed or broken.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class OpenApiSerializerContractTests
|
||||
{
|
||||
// Mirrors Startup.ConfigureServices -> AddNewtonsoftJson exactly.
|
||||
private static readonly JsonSerializerSettings RuntimeSettings = new()
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
||||
ContractResolver = new CustomContractResolver(),
|
||||
Converters = { new StringEnumConverter() }
|
||||
};
|
||||
|
||||
private static IEnumerable<TestCaseData> Cases()
|
||||
{
|
||||
yield return new TestCaseData(FullyPopulatedChannel(), "ChannelViewModel").SetName("ChannelViewModel");
|
||||
yield return new TestCaseData(FullyPopulatedFFmpegSettings(), "FFmpegSettingsResponseModel")
|
||||
.SetName("FFmpegSettingsResponseModel");
|
||||
// WatermarkViewModel was dropped from the API surface by #126: schedule-item endpoints no longer
|
||||
// expose the polymorphic ProgramScheduleItemViewModel (which transitively pulled in WatermarkViewModel
|
||||
// and the other collection VMs) — they return the flat ScheduleItemResponseModel, embedding watermarks
|
||||
// as NamedIdResponseModel. ChannelViewModel below still exercises the VM path of the schema transformer.
|
||||
yield return new TestCaseData(FullyPopulatedMediaItemInfo(), "MediaItemInfoResponseModel")
|
||||
.SetName("MediaItemInfoResponseModel");
|
||||
|
||||
// The only DTO with a [JsonProperty] override (FFmpegProfile -> "ffmpegProfile") — covers
|
||||
// the attribute path of NewtonsoftSchemaNamingTransformer, which the cases above don't.
|
||||
yield return new TestCaseData(FullyPopulatedChannelSummary(), "ChannelResponseModel")
|
||||
.SetName("ChannelResponseModel");
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(Cases))]
|
||||
public void Runtime_Serialized_Keys_Should_Match_OpenApi_Schema_Properties(object dto, string schemaName)
|
||||
{
|
||||
// Every member of dto is non-null, so NullValueHandling.Ignore drops nothing: the emitted
|
||||
// top-level keys are the complete runtime property set for this type.
|
||||
var serialized = JObject.Parse(JsonConvert.SerializeObject(dto, RuntimeSettings));
|
||||
List<string> runtimeKeys = serialized.Properties().Select(p => p.Name).OrderBy(n => n).ToList();
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument()));
|
||||
JsonElement properties = document.RootElement
|
||||
.GetProperty("components")
|
||||
.GetProperty("schemas")
|
||||
.GetProperty(schemaName)
|
||||
.GetProperty("properties");
|
||||
|
||||
List<string> schemaKeys = properties.EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToList();
|
||||
|
||||
runtimeKeys.ShouldBe(
|
||||
schemaKeys,
|
||||
$"OpenAPI schema '{schemaName}' property names must match the runtime Newtonsoft JSON keys.");
|
||||
}
|
||||
|
||||
private static ChannelViewModel FullyPopulatedChannel() => new(
|
||||
1,
|
||||
"1",
|
||||
"Name",
|
||||
"Group",
|
||||
"Categories",
|
||||
1,
|
||||
1.0,
|
||||
new ArtworkContentTypeModel("path", "image/png"),
|
||||
default,
|
||||
"selector",
|
||||
"en",
|
||||
"Audio Title",
|
||||
default,
|
||||
default,
|
||||
1,
|
||||
TimeSpan.Zero,
|
||||
default,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
"en",
|
||||
default,
|
||||
default,
|
||||
"template",
|
||||
default,
|
||||
default,
|
||||
default,
|
||||
true,
|
||||
true);
|
||||
|
||||
private static FFmpegSettingsResponseModel FullyPopulatedFFmpegSettings() => new(
|
||||
"/usr/bin/ffmpeg",
|
||||
"/usr/bin/ffprobe",
|
||||
1,
|
||||
"en",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
default,
|
||||
"script");
|
||||
|
||||
private static MediaItemInfoResponseModel FullyPopulatedMediaItemInfo() => new(
|
||||
1,
|
||||
"Title",
|
||||
"Movie",
|
||||
"Local",
|
||||
"Server",
|
||||
"Library",
|
||||
default,
|
||||
TimeSpan.Zero,
|
||||
"1:1",
|
||||
"16:9",
|
||||
"30/1",
|
||||
default,
|
||||
1.0,
|
||||
1,
|
||||
1,
|
||||
[],
|
||||
[]);
|
||||
|
||||
private static ChannelResponseModel FullyPopulatedChannelSummary() => new(
|
||||
1,
|
||||
"1",
|
||||
1.0,
|
||||
"Name",
|
||||
"Group",
|
||||
"Categories",
|
||||
"1080p H.264",
|
||||
"en",
|
||||
"TransportStream",
|
||||
true,
|
||||
true);
|
||||
|
||||
private static string FindOpenApiDocument()
|
||||
{
|
||||
DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
string candidate = Path.Combine(directory.FullName, "ErsatzTV", "wwwroot", "openapi", "v1.json");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException("Could not find ErsatzTV/wwwroot/openapi/v1.json");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class PlaylistControllerTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new PlaylistController(_mediator);
|
||||
}
|
||||
|
||||
private PlaylistController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.GetGroups), "GET", "/api/playlists/groups");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.CreateGroup), "POST", "/api/playlists/groups");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.UpdateGroup), "PUT", "/api/playlists/groups/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.DeleteGroup), "DELETE", "/api/playlists/groups/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.GetAll), "GET", "/api/playlists");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.GetById), "GET", "/api/playlists/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.GetItems), "GET", "/api/playlists/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.Create), "POST", "/api/playlists");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.Update), "PUT", "/api/playlists/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.Delete), "DELETE", "/api/playlists/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.AddItems), "POST", "/api/playlists/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(PlaylistController.Preview), "POST", "/api/playlists/preview");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetGroups_Should_Map_Response()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistGroupViewModel> { new(3, "Kids", 2, false) });
|
||||
|
||||
List<PlaylistGroupResponseModel> result = await _controller.GetGroups(CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].Id.ShouldBe(3);
|
||||
result[0].PlaylistCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CreateGroup_Should_Return_201_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreatePlaylistGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, PlaylistGroupViewModel>(new PlaylistGroupViewModel(8, "Kids", 0, false)));
|
||||
|
||||
IActionResult result = await _controller.CreateGroup(
|
||||
new CreatePlaylistGroupRequest("Kids"),
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/playlists/groups/8");
|
||||
created.Value.ShouldBeOfType<PlaylistGroupResponseModel>().Name.ShouldBe("Kids");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreatePlaylistGroup>(c => c.Name == "Kids"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CreateGroup_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreatePlaylistGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, PlaylistGroupViewModel>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.CreateGroup(
|
||||
new CreatePlaylistGroupRequest("Kids"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateGroup_Should_Return_200_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistGroupViewModel> { new(4, "Kids", 1, false) });
|
||||
_mediator.Send(Arg.Any<RenamePlaylistGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, PlaylistGroupViewModel>(new PlaylistGroupViewModel(4, "New Name", 1, false)));
|
||||
|
||||
IActionResult result = await _controller.UpdateGroup(
|
||||
4,
|
||||
new UpdatePlaylistGroupRequest("New Name"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PlaylistGroupResponseModel>().Name
|
||||
.ShouldBe("New Name");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<RenamePlaylistGroup>(c => c.PlaylistGroupId == 4 && c.Name == "New Name"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateGroup_Should_Return_404_When_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistGroupViewModel>());
|
||||
|
||||
IActionResult result = await _controller.UpdateGroup(
|
||||
4,
|
||||
new UpdatePlaylistGroupRequest("New Name"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<RenamePlaylistGroup>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateGroup_Should_Return_422_On_System_Group_And_Not_Rename()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistGroupViewModel> { new(4, "System", 0, true) });
|
||||
|
||||
IActionResult result = await _controller.UpdateGroup(
|
||||
4,
|
||||
new UpdatePlaylistGroupRequest("New Name"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<RenamePlaylistGroup>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateGroup_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistGroupViewModel> { new(4, "Kids", 1, false) });
|
||||
_mediator.Send(Arg.Any<RenamePlaylistGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, PlaylistGroupViewModel>(BaseError.New("too long")));
|
||||
|
||||
IActionResult result = await _controller.UpdateGroup(
|
||||
4,
|
||||
new UpdatePlaylistGroupRequest("New Name"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DeleteGroup_Should_Return_204_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistGroupViewModel> { new(4, "Kids", 0, false) });
|
||||
_mediator.Send(Arg.Any<DeletePlaylistGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.None);
|
||||
|
||||
IActionResult result = await _controller.DeleteGroup(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<DeletePlaylistGroup>(c => c.PlaylistGroupId == 4),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DeleteGroup_Should_Return_404_When_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistGroupViewModel>());
|
||||
|
||||
IActionResult result = await _controller.DeleteGroup(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<DeletePlaylistGroup>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DeleteGroup_Should_Return_422_On_System_Group()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistGroupViewModel> { new(4, "System", 0, true) });
|
||||
_mediator.Send(Arg.Any<DeletePlaylistGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.Some(BaseError.New("Cannot delete system playlist group")));
|
||||
|
||||
IActionResult result = await _controller.DeleteGroup(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PlaylistResponseModel>().Id.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_404_For_None()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItems_Should_Return_200_And_Flatten_Names()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
_mediator.Send(Arg.Any<GetPlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistItemViewModel>
|
||||
{
|
||||
new(
|
||||
100,
|
||||
0,
|
||||
CollectionType.Collection,
|
||||
new MediaCollectionViewModel(CollectionType.Collection, 10, "Movies", false, MediaItemState.Normal),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
PlaybackOrder.Chronological,
|
||||
null,
|
||||
true,
|
||||
false),
|
||||
new(
|
||||
101,
|
||||
1,
|
||||
CollectionType.Movie,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new NamedMediaItemViewModel(55, "The Movie"),
|
||||
PlaybackOrder.Shuffle,
|
||||
3,
|
||||
false,
|
||||
true)
|
||||
});
|
||||
|
||||
IActionResult result = await _controller.GetItems(4, CancellationToken.None);
|
||||
|
||||
var items = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<List<PlaylistItemResponseModel>>();
|
||||
items.Count.ShouldBe(2);
|
||||
items[0].CollectionId.ShouldBe(10);
|
||||
items[0].CollectionName.ShouldBe("Movies");
|
||||
items[0].MediaItemId.ShouldBeNull();
|
||||
items[1].MediaItemId.ShouldBe(55);
|
||||
items[1].MediaItemName.ShouldBe("The Movie");
|
||||
items[1].CollectionName.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItems_Should_Return_404_When_Playlist_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetItems(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlaylistItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreatePlaylist>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, PlaylistViewModel>(new PlaylistViewModel(9, 1, "Kids", false)));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreatePlaylistRequest(1, "Kids"),
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/playlists/9");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreatePlaylist>(c => c.PlaylistGroupId == 1 && c.Name == "Kids"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreatePlaylist>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, PlaylistViewModel>(BaseError.New("dupe")));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreatePlaylistRequest(1, "Kids"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_200_With_Items_And_Map_Request_By_Array_Order()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
_mediator.Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, List<PlaylistItemViewModel>>(new List<PlaylistItemViewModel>
|
||||
{
|
||||
new(
|
||||
100,
|
||||
0,
|
||||
CollectionType.Movie,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new NamedMediaItemViewModel(55, "The Movie"),
|
||||
PlaybackOrder.Shuffle,
|
||||
null,
|
||||
false,
|
||||
true)
|
||||
}));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new ReplacePlaylistRequest(
|
||||
"Kids",
|
||||
new List<PlaylistItemRequest>
|
||||
{
|
||||
new(99, CollectionType.Movie, null, null, null, 55, PlaybackOrder.Shuffle, null, false, true),
|
||||
new(99, CollectionType.Collection, 10, null, null, null, PlaybackOrder.Chronological, null, true,
|
||||
false)
|
||||
}),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<List<PlaylistItemResponseModel>>().Count
|
||||
.ShouldBe(1);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplacePlaylistItems>(c =>
|
||||
c.PlaylistId == 4 &&
|
||||
c.Name == "Kids" &&
|
||||
c.Items.Count == 2 &&
|
||||
c.Items[0].Index == 0 &&
|
||||
c.Items[0].MediaItemId == 55 &&
|
||||
c.Items[1].Index == 1 &&
|
||||
c.Items[1].CollectionId == 10),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_404_When_Playlist_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new ReplacePlaylistRequest("Kids", new List<PlaylistItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
_mediator.Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, List<PlaylistItemViewModel>>(BaseError.New("bad item")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new ReplacePlaylistRequest("Kids", new List<PlaylistItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_422_On_System_Playlist_And_Not_Replace_Items()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "System", true)));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new ReplacePlaylistRequest(
|
||||
"Renamed",
|
||||
new List<PlaylistItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_204_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
_mediator.Send(Arg.Any<DeletePlaylist>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.None);
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<DeletePlaylist>(c => c.PlaylistId == 4),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_404_When_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<DeletePlaylist>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_422_On_System_Playlist()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "System", true)));
|
||||
_mediator.Send(Arg.Any<DeletePlaylist>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.Some(BaseError.New("Cannot delete system (generated) playlist")));
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddItems_Should_Return_204_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
_mediator.Send(Arg.Any<AddItemsToPlaylist>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.AddItems(
|
||||
4,
|
||||
new AddItemsToPlaylistRequest([1, 2], [3], [], [], [], [], [], [], [], []),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<AddItemsToPlaylist>(c =>
|
||||
c.PlaylistId == 4 &&
|
||||
c.MovieIds.SequenceEqual(new List<int> { 1, 2 }) &&
|
||||
c.ShowIds.SequenceEqual(new List<int> { 3 })),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddItems_Should_Return_404_When_Playlist_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.AddItems(
|
||||
4,
|
||||
new AddItemsToPlaylistRequest([1], [], [], [], [], [], [], [], [], []),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<AddItemsToPlaylist>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddItems_Should_Return_422_On_System_Playlist()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "System", true)));
|
||||
_mediator.Send(Arg.Any<AddItemsToPlaylist>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("Cannot add items to system (generated) playlist")));
|
||||
|
||||
IActionResult result = await _controller.AddItems(
|
||||
4,
|
||||
new AddItemsToPlaylistRequest([1], [], [], [], [], [], [], [], [], []),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddItems_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
_mediator.Send(Arg.Any<AddItemsToPlaylist>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("Movie does not exist")));
|
||||
|
||||
IActionResult result = await _controller.AddItems(
|
||||
4,
|
||||
new AddItemsToPlaylistRequest([999], [], [], [], [], [], [], [], [], []),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Preview_Should_Return_200_And_Format_Times()
|
||||
{
|
||||
_mediator.Send(Arg.Any<PreviewPlaylistPlayout>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, List<PlayoutItemPreviewViewModel>>(new List<PlayoutItemPreviewViewModel>
|
||||
{
|
||||
new("The Movie", new TimeSpan(1, 2, 3), new TimeSpan(2, 4, 6), "1:02:03")
|
||||
}));
|
||||
|
||||
IActionResult result = await _controller.Preview(
|
||||
new ReplacePlaylistRequest(
|
||||
"Draft",
|
||||
new List<PlaylistItemRequest>
|
||||
{
|
||||
new(0, CollectionType.Movie, null, null, null, 55, PlaybackOrder.Shuffle, null, false, true)
|
||||
}),
|
||||
CancellationToken.None);
|
||||
|
||||
var items = result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<List<PlaylistPreviewItemResponseModel>>();
|
||||
items.Count.ShouldBe(1);
|
||||
items[0].Title.ShouldBe("The Movie");
|
||||
items[0].Start.ShouldBe("01:02:03");
|
||||
items[0].Finish.ShouldBe("02:04:06");
|
||||
items[0].Duration.ShouldBe("1:02:03");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<PreviewPlaylistPlayout>(c => c.Data.PlaylistId == 0 && c.Data.Items.Count == 1),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Preview_Should_Return_422_On_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<PreviewPlaylistPlayout>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, List<PlayoutItemPreviewViewModel>>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.Preview(
|
||||
new ReplacePlaylistRequest("Draft", new List<PlaylistItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Preview_Should_Return_422_When_Item_Missing_Required_Id()
|
||||
{
|
||||
IActionResult result = await _controller.Preview(
|
||||
new ReplacePlaylistRequest(
|
||||
"Draft",
|
||||
new List<PlaylistItemRequest>
|
||||
{
|
||||
// Collection-type item with a null collectionId is invalid.
|
||||
new(0, CollectionType.Collection, null, null, null, null, PlaybackOrder.Chronological, null, true,
|
||||
false)
|
||||
}),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<PreviewPlaylistPlayout>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
MethodInfo action = typeof(PlaylistController).GetMethod(actionName)
|
||||
?? throw new AssertionException($"Missing action {actionName}");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain(httpMethod);
|
||||
attribute.Template.ShouldBe(route);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
@@ -31,12 +32,14 @@ public class PlayoutControllerTests
|
||||
{
|
||||
private PlayoutController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
private IEntityLocker _entityLocker = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new PlayoutController(_mediator);
|
||||
_entityLocker = Substitute.For<IEntityLocker>();
|
||||
_controller = new PlayoutController(_mediator, _entityLocker);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -57,6 +60,206 @@ public class PlayoutControllerTests
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetBlocks), "GET", "/api/playouts/{id:int}/blocks");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetBlockHistory), "GET", "/api/playouts/{id:int}/blocks/{blockId:int}/history");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetHistoryDetails), "GET", "/api/playouts/history/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.EraseItems), "POST", "/api/playouts/{id:int}/erase-items");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlayoutController.EraseItemsAndHistory),
|
||||
"POST",
|
||||
"/api/playouts/{id:int}/erase-items-and-history");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlayoutController.GetItemSchedulingContext),
|
||||
"GET",
|
||||
"/api/playouts/items/{id:int}/scheduling-context");
|
||||
}
|
||||
|
||||
// ----- Build-lock guard (#215): id-keyed mutations return 409 while the build lock is held -----
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.Delete(9, CancellationToken.None);
|
||||
|
||||
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
||||
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<DeletePlayout>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EraseItems_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.EraseItems(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EraseItemsAndHistory_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
9,
|
||||
new UpdatePlayoutDetailsRequest(TimeSpan.FromHours(4), null),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlayout>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateDefaultDeco_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.UpdateDefaultDeco(
|
||||
9,
|
||||
new UpdateDefaultDecoRequest(null),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Stamp_IsLocked_From_Locker()
|
||||
{
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedPlayoutsViewModel(1, [MakePlayout(9)]));
|
||||
|
||||
PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);
|
||||
|
||||
result.Page.Single().IsLocked.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ----- Erase items / history -----
|
||||
|
||||
[Test]
|
||||
public async Task EraseItems_Should_Return_404_When_Playout_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.EraseItems(404, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[TestCase(PlayoutScheduleKind.Classic)]
|
||||
[TestCase(PlayoutScheduleKind.ExternalJson)]
|
||||
public async Task EraseItems_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind)
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
|
||||
|
||||
IActionResult result = await _controller.EraseItems(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[TestCase(PlayoutScheduleKind.Block)]
|
||||
[TestCase(PlayoutScheduleKind.Sequential)]
|
||||
[TestCase(PlayoutScheduleKind.Scripted)]
|
||||
public async Task EraseItems_Should_Return_204_And_Send_Command_For_Supported_Kind(PlayoutScheduleKind kind)
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
|
||||
|
||||
IActionResult result = await _controller.EraseItems(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ErasePlayoutItems>(c => c.PlayoutId == 9),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EraseItemsAndHistory_Should_Return_404_When_Playout_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.EraseItemsAndHistory(404, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EraseItemsAndHistory_Should_Return_422_For_Unsupported_Kind()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.ExternalJson }));
|
||||
|
||||
IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[TestCase(PlayoutScheduleKind.Classic)]
|
||||
[TestCase(PlayoutScheduleKind.Block)]
|
||||
[TestCase(PlayoutScheduleKind.Sequential)]
|
||||
[TestCase(PlayoutScheduleKind.Scripted)]
|
||||
public async Task EraseItemsAndHistory_Should_Return_204_And_Send_Command_For_Supported_Kind(
|
||||
PlayoutScheduleKind kind)
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
|
||||
|
||||
IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ErasePlayoutHistory>(c => c.PlayoutId == 9),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ----- Playout item scheduling context -----
|
||||
|
||||
[Test]
|
||||
public async Task GetItemSchedulingContext_Should_Return_200_With_Decoded_Context()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutItemSchedulingContext>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<string>.Some("{ \"decoded\": true }"));
|
||||
|
||||
IActionResult result = await _controller.GetItemSchedulingContext(42, CancellationToken.None);
|
||||
|
||||
var context = result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<PlayoutItemSchedulingContextResponseModel>();
|
||||
context.Context.ShouldBe("{ \"decoded\": true }");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetPlayoutItemSchedulingContext>(q => q.PlayoutItemId == 42),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItemSchedulingContext_Should_Return_404_For_None()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutItemSchedulingContext>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<string>.None);
|
||||
|
||||
IActionResult result = await _controller.GetItemSchedulingContext(404, CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -364,6 +567,7 @@ public class PlayoutControllerTests
|
||||
item.ChannelName.ShouldBe("Channel");
|
||||
item.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic);
|
||||
item.ScheduleName.ShouldBe("Schedule");
|
||||
item.PlayoutMode.ShouldBe(ChannelPlayoutMode.Continuous);
|
||||
item.DailyRebuildTime.ShouldBe(TimeSpan.FromHours(4));
|
||||
item.BuildStatus.ShouldNotBeNull();
|
||||
item.BuildStatus.Success.ShouldBeFalse();
|
||||
@@ -394,6 +598,7 @@ public class PlayoutControllerTests
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
|
||||
var item = new PlayoutItemViewModel(
|
||||
77,
|
||||
"Movie",
|
||||
new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
|
||||
@@ -401,6 +606,7 @@ public class PlayoutControllerTests
|
||||
string.Empty,
|
||||
Some(FillerKind.MidRoll));
|
||||
var gap = new PlayoutItemViewModel(
|
||||
null,
|
||||
"UNSCHEDULED",
|
||||
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 7, 2, 13, 30, 0, TimeSpan.Zero),
|
||||
@@ -414,9 +620,11 @@ public class PlayoutControllerTests
|
||||
|
||||
var result = actionResult.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PagedPlayoutItemsResponseModel>();
|
||||
result.TotalCount.ShouldBe(2);
|
||||
result.Page[0].Id.ShouldBe(77);
|
||||
result.Page[0].Title.ShouldBe("Movie");
|
||||
result.Page[0].Duration.ShouldBe("1:00:00");
|
||||
result.Page[0].FillerKind.ShouldBe(FillerKind.MidRoll);
|
||||
result.Page[1].Id.ShouldBeNull();
|
||||
result.Page[1].Title.ShouldBe("UNSCHEDULED");
|
||||
result.Page[1].FillerKind.ShouldBeNull();
|
||||
|
||||
@@ -426,6 +634,35 @@ public class PlayoutControllerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItems_Should_Flag_HasSchedulingContext_Without_Exposing_Raw_Json()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
|
||||
var withContext = new PlayoutItemViewModel(
|
||||
42,
|
||||
"Movie",
|
||||
new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
|
||||
"1:00:00",
|
||||
"{ \"ScheduleId\": 1 }",
|
||||
Option<FillerKind>.None);
|
||||
var withoutContext = withContext with { SchedulingContext = " " };
|
||||
_mediator.Send(Arg.Any<GetFuturePlayoutItemsById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedPlayoutItemsViewModel(2, [withContext, withoutContext]));
|
||||
|
||||
IActionResult actionResult = await _controller.GetItems(9, showFiller: false, 0, 100, CancellationToken.None);
|
||||
|
||||
var result = actionResult.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<PagedPlayoutItemsResponseModel>();
|
||||
result.Page[0].HasSchedulingContext.ShouldBeTrue();
|
||||
result.Page[1].HasSchedulingContext.ShouldBeFalse();
|
||||
|
||||
// The raw JSON blob must never appear on the list DTO.
|
||||
typeof(PlayoutItemResponseModel).GetProperty("SchedulingContext").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItems_Should_Return_404_For_Unknown_Playout_With_ProblemDetails()
|
||||
{
|
||||
@@ -507,6 +744,22 @@ public class PlayoutControllerTests
|
||||
list[1].ProgramScheduleId.ShouldBe(8);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceAlternateSchedules_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.ReplaceAlternateSchedules(
|
||||
9,
|
||||
new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7)]),
|
||||
CancellationToken.None);
|
||||
|
||||
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
||||
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _mediator.DidNotReceive()
|
||||
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceAlternateSchedules_Should_Return_404_When_Playout_Missing()
|
||||
{
|
||||
@@ -717,6 +970,21 @@ public class PlayoutControllerTests
|
||||
list[1].DecoTemplateId.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceTemplates_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.ReplaceTemplates(
|
||||
9,
|
||||
new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, null)]),
|
||||
CancellationToken.None);
|
||||
|
||||
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
||||
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceTemplates_Should_Return_404_When_Playout_Missing()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class RerunCollectionControllerTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new RerunCollectionController(_mediator);
|
||||
}
|
||||
|
||||
private RerunCollectionController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.GetAll), "GET", "/api/rerun-collections");
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.GetById), "GET", "/api/rerun-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.Create), "POST", "/api/rerun-collections");
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.Update), "PUT", "/api/rerun-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(RerunCollectionController.Delete), "DELETE", "/api/rerun-collections/{id:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Clamp_Paging_And_Map_Response()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPagedRerunCollections>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedRerunCollectionsViewModel(
|
||||
1,
|
||||
new List<RerunCollectionViewModel> { MakeRerunFromSmart(3, "Nightly") }));
|
||||
|
||||
PagedRerunCollectionsResponseModel result =
|
||||
await _controller.GetAll("n", -5, 9999, CancellationToken.None);
|
||||
|
||||
result.TotalCount.ShouldBe(1);
|
||||
result.Page.Count.ShouldBe(1);
|
||||
result.Page[0].Id.ShouldBe(3);
|
||||
result.Page[0].CollectionType.ShouldBe(CollectionType.SmartCollection);
|
||||
result.Page[0].SelectedId.ShouldBe(20);
|
||||
result.Page[0].SelectedName.ShouldBe("Smart");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetPagedRerunCollections>(q => q.Query == "n" && q.PageNum == 0 && q.PageSize == 100),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetRerunCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<RerunCollectionViewModel>.Some(MakeRerunFromSmart(4, "Nightly")));
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<RerunCollectionResponseModel>().Id.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_404_For_None()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetRerunCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<RerunCollectionViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_And_Resolve_Selected_Id_Into_Collection_Vm()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, RerunCollectionViewModel>(MakeRerunFromSmart(8, "Nightly")));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.Collection,
|
||||
42,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Shuffle),
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/rerun-collections/8");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateRerunCollection>(c =>
|
||||
c.Name == "Nightly" &&
|
||||
c.CollectionType == CollectionType.Collection &&
|
||||
c.Collection != null && c.Collection.Id == 42 &&
|
||||
c.MultiCollection == null &&
|
||||
c.SmartCollection == null &&
|
||||
c.MediaItem == null &&
|
||||
c.FirstRunPlaybackOrder == PlaybackOrder.Chronological &&
|
||||
c.RerunPlaybackOrder == PlaybackOrder.Shuffle),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Resolve_Media_Item_Selection_For_Show_Type()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, RerunCollectionViewModel>(MakeRerunFromSmart(9, "Nightly")));
|
||||
|
||||
await _controller.Create(
|
||||
new CreateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.TelevisionShow,
|
||||
77,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Chronological),
|
||||
CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateRerunCollection>(c =>
|
||||
c.CollectionType == CollectionType.TelevisionShow &&
|
||||
c.Collection == null &&
|
||||
c.MediaItem != null && c.MediaItem.MediaItemId == 77),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, RerunCollectionViewModel>(BaseError.New("dupe")));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.SmartCollection,
|
||||
1,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Chronological),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_422_For_Unsupported_Collection_Type()
|
||||
{
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.Playlist,
|
||||
1,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Chronological),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<CreateRerunCollection>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_200_With_Refreshed_Body_And_Resolve_Selection()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetRerunCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<RerunCollectionViewModel>.Some(MakeRerunFromSmart(4, "Nightly")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new UpdateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.MultiCollection,
|
||||
55,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Shuffle),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<RerunCollectionResponseModel>().Id.ShouldBe(4);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateRerunCollection>(c =>
|
||||
c.RerunCollectionId == 4 &&
|
||||
c.Name == "Nightly" &&
|
||||
c.CollectionType == CollectionType.MultiCollection &&
|
||||
c.MultiCollection != null && c.MultiCollection.Id == 55 &&
|
||||
c.Collection == null &&
|
||||
c.SmartCollection == null &&
|
||||
c.MediaItem == null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new UpdateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.SmartCollection,
|
||||
1,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Chronological),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetRerunCollectionById>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_422_For_Unsupported_Collection_Type()
|
||||
{
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new UpdateRerunCollectionRequest(
|
||||
"Nightly",
|
||||
CollectionType.RerunFirstRun,
|
||||
1,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Chronological),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdateRerunCollection>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_204_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<DeleteRerunCollection>(c => c.RerunCollectionId == 4),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_404_On_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteRerunCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new ErsatzTV.Core.Errors.NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.Delete(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
private static RerunCollectionViewModel MakeRerunFromSmart(int id, string name) =>
|
||||
new(
|
||||
id,
|
||||
name,
|
||||
CollectionType.SmartCollection,
|
||||
null,
|
||||
null,
|
||||
new SmartCollectionViewModel(20, "Smart", "query"),
|
||||
null,
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.Shuffle);
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
MethodInfo action = typeof(RerunCollectionController).GetMethod(actionName)
|
||||
?? throw new AssertionException($"Missing action {actionName}");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain(httpMethod);
|
||||
attribute.Template.ShouldBe(route);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.ProgramSchedules;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
@@ -197,7 +199,10 @@ public class ScheduleControllerTests
|
||||
|
||||
IActionResult result = await _controller.GetItems(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(response);
|
||||
var envelope = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<ScheduleItemsResponseModel>();
|
||||
envelope.TotalDurationEstimate.ShouldBe(TimeSpan.FromMinutes(25));
|
||||
envelope.Items.Count.ShouldBe(1);
|
||||
envelope.Items[0].Id.ShouldBe(11);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetProgramScheduleItemsWithDurations>(q => q.Id == 4),
|
||||
Arg.Any<CancellationToken>());
|
||||
@@ -225,7 +230,7 @@ public class ScheduleControllerTests
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/schedules/4/items/12");
|
||||
created.Value.ShouldBe(item);
|
||||
created.Value.ShouldBeOfType<ScheduleItemResponseModel>().Id.ShouldBe(12);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<AddProgramScheduleItem>(c => c.ProgramScheduleId == 4 && c.PlayoutMode == PlayoutMode.One),
|
||||
Arg.Any<CancellationToken>());
|
||||
@@ -254,7 +259,9 @@ public class ScheduleControllerTests
|
||||
new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One), MakeItemRequest(PlayoutMode.Multiple)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(items);
|
||||
var replaced = result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<List<ScheduleItemResponseModel>>();
|
||||
replaced.Select(i => i.Id).ShouldBe([21, 22]);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplaceProgramScheduleItems>(c =>
|
||||
c.ProgramScheduleId == 4 &&
|
||||
|
||||
@@ -75,6 +75,54 @@ public class SearchControllerTests
|
||||
ok.Value.ShouldBe(results);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_SearchAllItems_Route_With_Stable_Operation_Name()
|
||||
{
|
||||
MethodInfo action = typeof(SearchController).GetMethod(nameof(SearchController.SearchAllItems))
|
||||
?? throw new AssertionException($"Missing action {nameof(SearchController.SearchAllItems)}");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
|
||||
attribute.Template.ShouldBe("/api/search/all-items");
|
||||
attribute.Name.ShouldBe("SearchAllItems");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SearchAllItems_Should_Return_422_For_Empty_Query()
|
||||
{
|
||||
IActionResult result = await _controller.SearchAllItems(" ", CancellationToken.None);
|
||||
|
||||
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
unprocessable.StatusCode.ShouldBe(422);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<QuerySearchIndexAllItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SearchAllItems_Should_Map_Id_Lists()
|
||||
{
|
||||
_mediator.Send(Arg.Any<QuerySearchIndexAllItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new SearchResultAllItemsViewModel(
|
||||
[1, 2],
|
||||
[3],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[]));
|
||||
|
||||
IActionResult result = await _controller.SearchAllItems("star", CancellationToken.None);
|
||||
|
||||
var body = result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<SearchResultAllItemsResponseModel>();
|
||||
body.MovieIds.ShouldBe(new List<int> { 1, 2 });
|
||||
body.ShowIds.ShouldBe(new List<int> { 3 });
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<QuerySearchIndexAllItems>(q => q.Query == "star"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SearchCollections_Should_Map_To_Picker_Options()
|
||||
{
|
||||
|
||||
@@ -3,13 +3,21 @@ using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Application.Troubleshooting;
|
||||
using ErsatzTV.Application.Troubleshooting.Queries;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Troubleshooting;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Troubleshooting;
|
||||
using ErsatzTV.Core.Notifications;
|
||||
using ErsatzTV.Core.Troubleshooting;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NSubstitute;
|
||||
@@ -23,19 +31,45 @@ public class TroubleshootControllerTests
|
||||
{
|
||||
private TroubleshootController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
private IEntityLocker _entityLocker = null!;
|
||||
private TroubleshootingPlaybackStatusStore _statusStore = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_entityLocker = Substitute.For<IEntityLocker>();
|
||||
_statusStore = new TroubleshootingPlaybackStatusStore();
|
||||
_controller = new TroubleshootController(
|
||||
Channel.CreateUnbounded<IFFmpegWorkerRequest>().Writer,
|
||||
System.Threading.Channels.Channel.CreateUnbounded<IFFmpegWorkerRequest>().Writer,
|
||||
Substitute.For<IFileSystem>(),
|
||||
Substitute.For<IConfigElementRepository>(),
|
||||
Substitute.For<ITroubleshootingNotifier>(),
|
||||
_entityLocker,
|
||||
_statusStore,
|
||||
_mediator);
|
||||
}
|
||||
|
||||
private static MediaItemInfo FakeMediaItemInfo() =>
|
||||
new(
|
||||
1,
|
||||
"Title",
|
||||
"Movie",
|
||||
"LocalLibrary",
|
||||
null,
|
||||
"Movies",
|
||||
MediaItemState.Normal,
|
||||
TimeSpan.FromMinutes(90),
|
||||
"1:1",
|
||||
"16:9",
|
||||
"24000/1001",
|
||||
VideoScanKind.Progressive,
|
||||
null,
|
||||
1920,
|
||||
1080,
|
||||
[],
|
||||
[]);
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_Info()
|
||||
{
|
||||
@@ -136,4 +170,155 @@ public class TroubleshootControllerTests
|
||||
Arg.Is<ValidateSequentialSchedule>(q => q.Yaml == "content: []" && q.IsImport),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_StreamSelectors()
|
||||
{
|
||||
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetStreamSelectors))
|
||||
?? throw new AssertionException("Missing action GetStreamSelectors");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
|
||||
attribute.Template.ShouldBe("api/troubleshoot/playback/stream-selectors");
|
||||
attribute.Name.ShouldBe("GetTroubleshootingStreamSelectors");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetStreamSelectors_Should_Return_Selector_Names()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetChannelStreamSelectors>(), Arg.Any<CancellationToken>())
|
||||
.Returns(["a.yml", "b.yml"]);
|
||||
|
||||
List<string> result = await _controller.GetStreamSelectors(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(["a.yml", "b.yml"]);
|
||||
await _mediator.Received(1).Send(Arg.Any<GetChannelStreamSelectors>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_Subtitles()
|
||||
{
|
||||
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetSubtitles))
|
||||
?? throw new AssertionException("Missing action GetSubtitles");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
|
||||
attribute.Template.ShouldBe("api/troubleshoot/playback/subtitles/{mediaItemId:int}");
|
||||
attribute.Name.ShouldBe("GetTroubleshootingSubtitles");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetSubtitles_Should_Return_404_For_Unknown_Media_Item()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Either<BaseError, MediaItemInfo>.Left(BaseError.New("nope")));
|
||||
|
||||
IActionResult result = await _controller.GetSubtitles(999, CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetTroubleshootingSubtitles>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetSubtitles_Should_Map_ViewModels_To_Response()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Either<BaseError, MediaItemInfo>.Right(FakeMediaItemInfo()));
|
||||
_mediator.Send(Arg.Any<GetTroubleshootingSubtitles>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<SubtitleViewModel> { new(7, "eng", "English", "subrip") });
|
||||
|
||||
IActionResult result = await _controller.GetSubtitles(1, CancellationToken.None);
|
||||
|
||||
var subtitles = result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<List<TroubleshootingSubtitleResponseModel>>();
|
||||
subtitles.Count.ShouldBe(1);
|
||||
subtitles[0].Id.ShouldBe(7);
|
||||
subtitles[0].Language.ShouldBe("eng");
|
||||
subtitles[0].Title.ShouldBe("English");
|
||||
subtitles[0].Codec.ShouldBe("subrip");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_PlaybackStatus()
|
||||
{
|
||||
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetPlaybackStatus))
|
||||
?? throw new AssertionException("Missing action GetPlaybackStatus");
|
||||
|
||||
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
|
||||
attribute.Template.ShouldBe("api/troubleshoot/playback/status");
|
||||
attribute.Name.ShouldBe("GetTroubleshootingPlaybackStatus");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPlaybackStatus_Should_Report_Idle_When_No_Result_And_Unlocked()
|
||||
{
|
||||
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
|
||||
|
||||
TroubleshootingPlaybackStatusResponseModel result =
|
||||
await _controller.GetPlaybackStatus(CancellationToken.None);
|
||||
|
||||
result.State.ShouldBe("idle");
|
||||
result.ExitCode.ShouldBeNull();
|
||||
result.Speed.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPlaybackStatus_Should_Report_Running_When_Locked()
|
||||
{
|
||||
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(true);
|
||||
|
||||
TroubleshootingPlaybackStatusResponseModel result =
|
||||
await _controller.GetPlaybackStatus(CancellationToken.None);
|
||||
|
||||
result.State.ShouldBe("running");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPlaybackStatus_Should_Report_Completed_With_ExitCode_And_Speed()
|
||||
{
|
||||
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
|
||||
_statusStore.RecordCompletion(0, 1.5);
|
||||
|
||||
TroubleshootingPlaybackStatusResponseModel result =
|
||||
await _controller.GetPlaybackStatus(CancellationToken.None);
|
||||
|
||||
result.State.ShouldBe("completed");
|
||||
result.ExitCode.ShouldBe(0);
|
||||
result.Speed.ShouldBe(1.5);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPlaybackStatus_Should_Report_Failed_For_NonZero_ExitCode()
|
||||
{
|
||||
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
|
||||
_statusStore.RecordCompletion(1, Option<double>.None);
|
||||
|
||||
TroubleshootingPlaybackStatusResponseModel result =
|
||||
await _controller.GetPlaybackStatus(CancellationToken.None);
|
||||
|
||||
result.State.ShouldBe("failed");
|
||||
result.ExitCode.ShouldBe(1);
|
||||
result.Speed.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RecordTroubleshootingPlaybackStatusHandler_Should_Record_Result_In_Store()
|
||||
{
|
||||
var store = new TroubleshootingPlaybackStatusStore();
|
||||
var handler = new RecordTroubleshootingPlaybackStatusHandler(store);
|
||||
|
||||
store.CurrentResult.IsNone.ShouldBeTrue();
|
||||
|
||||
await handler.Handle(
|
||||
new PlaybackTroubleshootingCompletedNotification(0, Option<Exception>.None, 2.0),
|
||||
CancellationToken.None);
|
||||
|
||||
store.CurrentResult.IsSome.ShouldBeTrue();
|
||||
TroubleshootingPlaybackResult recorded =
|
||||
store.CurrentResult.IfNone(() => throw new AssertionException("Expected a recorded result"));
|
||||
recorded.ExitCode.ShouldBe(0);
|
||||
recorded.Speed.IfNone(-1).ShouldBe(2.0);
|
||||
|
||||
store.Reset();
|
||||
store.CurrentResult.IsNone.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Templates;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
@@ -16,7 +18,10 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerChannel, IMediator mediator)
|
||||
public class ChannelController(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IMediator mediator,
|
||||
IEntityLocker entityLocker)
|
||||
{
|
||||
[HttpGet("/api/channels")]
|
||||
[EndpointGroupName("general")]
|
||||
@@ -43,6 +48,22 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
||||
CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelGuideData(start, end), cancellationToken);
|
||||
|
||||
[HttpGet("/api/channels/music-video-credits-templates", Name = "GetMusicVideoCreditsTemplates")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get available music video credits template names")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<string>), StatusCodes.Status200OK)]
|
||||
public async Task<List<string>> GetMusicVideoCreditsTemplates(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetMusicVideoCreditTemplates(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/channels/stream-selectors", Name = "GetChannelStreamSelectors")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get available channel stream selector names")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<string>), StatusCodes.Status200OK)]
|
||||
public async Task<List<string>> GetStreamSelectors(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelStreamSelectors(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get a channel by id")]
|
||||
@@ -192,6 +213,7 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> ResetPlayout(
|
||||
string channelNumber,
|
||||
[FromQuery] PlayoutBuildMode? mode,
|
||||
@@ -201,6 +223,14 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
||||
await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken);
|
||||
foreach (int playoutId in maybePlayoutId)
|
||||
{
|
||||
// Mirror Blazor's EntityLocker gating: don't enqueue a rebuild while one is already in flight.
|
||||
if (entityLocker.IsPlayoutLocked(playoutId))
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Playout build in progress",
|
||||
"A build for this playout is currently in progress; try again once it completes.");
|
||||
}
|
||||
|
||||
PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken);
|
||||
await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken);
|
||||
return new OkResult();
|
||||
|
||||
@@ -96,6 +96,33 @@ public class CollectionController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("/api/collections/{id:int}/custom-order")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Set a collection's custom playback order")]
|
||||
[EndpointDescription(
|
||||
"Replaces the custom playback order of a manual collection. CustomIndex is assigned from array position " +
|
||||
"(the request body has no index field); ids that are not members of the collection are ignored. Set " +
|
||||
"UseCustomPlaybackOrder on the collection for this order to take effect.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> UpdateCustomOrder(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateCollectionCustomOrderRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<MediaCollectionViewModel> maybeCollection =
|
||||
await mediator.Send(new GetCollectionById(id), cancellationToken);
|
||||
if (maybeCollection.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpDelete("/api/collections/{id:int}")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Delete a collection")]
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Filler;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -16,10 +17,13 @@ public class FillerPresetController(IMediator mediator) : ControllerBase
|
||||
[HttpGet("/api/filler-presets", Name = "GetFillerPresets")]
|
||||
[Tags("Filler Presets")]
|
||||
[EndpointSummary("Get all filler presets")]
|
||||
[EndpointDescription("Optionally filter to a single filler kind via the fillerKind query parameter.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<FillerPresetResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<FillerPresetResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllFillerPresetsForApi(), cancellationToken);
|
||||
public async Task<List<FillerPresetResponseModel>> GetAll(
|
||||
[FromQuery] FillerKind? fillerKind,
|
||||
CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllFillerPresetsForApi(fillerKind), cancellationToken);
|
||||
|
||||
[HttpGet("/api/filler-presets/{id:int}", Name = "GetFillerPresetById")]
|
||||
[Tags("Filler Presets")]
|
||||
|
||||
@@ -12,8 +12,20 @@ public class GraphicsElementController(IMediator mediator) : ControllerBase
|
||||
[HttpGet("/api/graphics-elements", Name = "GetGraphicsElements")]
|
||||
[Tags("Graphics Elements")]
|
||||
[EndpointSummary("Get all graphics elements")]
|
||||
[EndpointDescription(
|
||||
"Returns all graphics elements. Pass refresh=true to first re-sync the on-disk graphics element " +
|
||||
"definitions into the database (matching the legacy Blazor behavior) so newly added files appear.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<GraphicsElementResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<GraphicsElementResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllGraphicsElementsForApi(), cancellationToken);
|
||||
public async Task<List<GraphicsElementResponseModel>> GetAll(
|
||||
[FromQuery] bool refresh,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (refresh)
|
||||
{
|
||||
await mediator.Send(new RefreshGraphicsElements(), cancellationToken);
|
||||
}
|
||||
|
||||
return await mediator.Send(new GetAllGraphicsElementsForApi(), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Linq;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Api.MediaItems;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class LanguagesController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/languages", Name = "GetLanguages")]
|
||||
[Tags("Languages")]
|
||||
[EndpointSummary("Get all available language codes")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<LanguageCodeResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<LanguageCodeResponseModel>> GetLanguages(CancellationToken cancellationToken)
|
||||
{
|
||||
List<LanguageCodeViewModel> languages = await mediator.Send(new GetAllLanguageCodes(), cancellationToken);
|
||||
return languages
|
||||
.Select(l => new LanguageCodeResponseModel(l.ThreeLetterISOLanguageName, l.EnglishName))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core.Api.Libraries;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -21,35 +22,50 @@ public class LibrariesController(ITelevisionRepository televisionRepository, IMe
|
||||
[HttpPost("/api/libraries/{id:int}/scan")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Scan library")]
|
||||
public async Task<IActionResult> ScanLibrary(int id) =>
|
||||
await mediator.Send(new QueueLibraryScanByLibraryId(id))
|
||||
? new OkResult()
|
||||
: new NotFoundResult();
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ScanLibrary(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
QueueLibraryScanResult result = await mediator.Send(new QueueLibraryScanByLibraryId(id), cancellationToken);
|
||||
return result switch
|
||||
{
|
||||
QueueLibraryScanResult.Queued => new AcceptedResult(),
|
||||
QueueLibraryScanResult.AlreadyScanning => ApiResults.ConflictProblem(
|
||||
"Library scan in progress",
|
||||
$"A scan for library {id} is already in progress."),
|
||||
QueueLibraryScanResult.SyncDisabled => new UnprocessableEntityObjectResult(
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status422UnprocessableEntity,
|
||||
Title = "Library sync is disabled",
|
||||
Detail = $"Item sync is disabled for library {id}."
|
||||
}),
|
||||
_ => ApiResults.NotFoundProblem($"Library {id} does not exist.")
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("/api/libraries/{id:int}/scan-show")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Scan show")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> ScanShow(int id, [FromBody] ScanShowRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ShowTitle))
|
||||
Option<string> maybeTitle = await televisionRepository.GetShowTitle(id, request.ShowId);
|
||||
foreach (string title in maybeTitle)
|
||||
{
|
||||
return new BadRequestObjectResult(new { error = "ShowTitle is required" });
|
||||
}
|
||||
|
||||
string trimmedTitle = request.ShowTitle.Trim();
|
||||
Option<int> maybeShowId = await televisionRepository.GetShowIdByTitle(id, trimmedTitle);
|
||||
foreach (int showId in maybeShowId)
|
||||
{
|
||||
bool result = await mediator.Send(new QueueShowScanByLibraryId(id, showId, trimmedTitle, request.DeepScan));
|
||||
bool result = await mediator.Send(new QueueShowScanByLibraryId(id, request.ShowId, title, request.DeepScan));
|
||||
|
||||
return result
|
||||
? new OkResult()
|
||||
: new BadRequestObjectResult(new { error = "Unable to queue show scan. Library may not exist, may not support single show scanning, or may already be scanning." });
|
||||
}
|
||||
|
||||
return new BadRequestObjectResult(
|
||||
new { error = $"Unable to locate show with title {request.ShowTitle} in library {id}" });
|
||||
return ApiResults.NotFoundProblem($"Show {request.ShowId} does not exist in library {id}.");
|
||||
}
|
||||
}
|
||||
|
||||
public record ScanShowRequest(string ShowTitle, bool DeepScan = false);
|
||||
public record ScanShowRequest(int ShowId, bool DeepScan = false);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Linq.Expressions;
|
||||
using ErsatzTV.Application.Logs;
|
||||
using ErsatzTV.Core.Api.Logs;
|
||||
using MediatR;
|
||||
@@ -11,22 +12,48 @@ public class LogsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
// Mirrors the sortable columns from the legacy Blazor Logs.razor (MudTableSortLabel on
|
||||
// Timestamp/Level; Message was never sortable there either).
|
||||
private static readonly System.Collections.Generic.HashSet<string> AllowedSortFields =
|
||||
new(StringComparer.OrdinalIgnoreCase) { "timestamp", "level" };
|
||||
|
||||
[HttpGet("/api/logs", Name = "GetLogs")]
|
||||
[Tags("Logs")]
|
||||
[EndpointSummary("Get recent log entries")]
|
||||
[EndpointDescription(
|
||||
"sortField is validated against an allow-list (timestamp, level); an unrecognized value " +
|
||||
"falls back to timestamp. sortDirection accepts asc/desc and falls back to desc (the " +
|
||||
"pre-existing default, newest first).")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PagedLogEntriesResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<PagedLogEntriesResponseModel> GetLogs(
|
||||
[FromQuery] int pageNum = 0,
|
||||
[FromQuery] int pageSize = 100,
|
||||
[FromQuery] string filter = "",
|
||||
[FromQuery] string sortField = "timestamp",
|
||||
[FromQuery] string sortDirection = "desc",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
int clampedPageNum = Math.Max(0, pageNum);
|
||||
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
|
||||
|
||||
string normalizedSortField = AllowedSortFields.Contains(sortField ?? string.Empty)
|
||||
? sortField!.ToLowerInvariant()
|
||||
: "timestamp";
|
||||
bool descending = !string.Equals(sortDirection, "asc", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Expression<Func<LogEntryViewModel, object>> sortExpression = normalizedSortField switch
|
||||
{
|
||||
"level" => le => le.Level,
|
||||
_ => le => le.Timestamp
|
||||
};
|
||||
|
||||
PagedLogEntriesViewModel result = await mediator.Send(
|
||||
new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty),
|
||||
new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty)
|
||||
{
|
||||
SortExpression = sortExpression,
|
||||
SortDescending = descending
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return new PagedLogEntriesResponseModel(
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaCollections;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class MultiCollectionController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
[HttpGet("/api/multi-collections", Name = "GetMultiCollections")]
|
||||
[Tags("Multi Collections")]
|
||||
[EndpointSummary("Get all multi collections (paged)")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PagedMultiCollectionsResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<PagedMultiCollectionsResponseModel> GetAll(
|
||||
[FromQuery] string query = "",
|
||||
[FromQuery] int pageNum = 0,
|
||||
[FromQuery] int pageSize = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
int clampedPageNum = Math.Max(0, pageNum);
|
||||
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
|
||||
|
||||
PagedMultiCollectionsViewModel result = await mediator.Send(
|
||||
new GetPagedMultiCollections(query ?? string.Empty, clampedPageNum, clampedPageSize),
|
||||
cancellationToken);
|
||||
|
||||
return new PagedMultiCollectionsResponseModel(
|
||||
result.TotalCount,
|
||||
result.Page.Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/multi-collections/{id:int}", Name = "GetMultiCollectionById")]
|
||||
[Tags("Multi Collections")]
|
||||
[EndpointSummary("Get a multi collection by id")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(MultiCollectionResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<MultiCollectionViewModel> result =
|
||||
await mediator.Send(new GetMultiCollectionById(id), cancellationToken);
|
||||
return result.Map(ProjectToResponseModel).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/multi-collections")]
|
||||
[Tags("Multi Collections")]
|
||||
[EndpointSummary("Create a multi collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(MultiCollectionResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateMultiCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, MultiCollectionViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
vm => $"/api/multi-collections/{vm.Id}",
|
||||
ProjectToResponseModel);
|
||||
}
|
||||
|
||||
[HttpPut("/api/multi-collections/{id:int}")]
|
||||
[Tags("Multi Collections")]
|
||||
[EndpointSummary("Update a multi collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(MultiCollectionResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateMultiCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
Option<MultiCollectionViewModel> multiCollection =
|
||||
await mediator.Send(new GetMultiCollectionById(id), cancellationToken);
|
||||
return multiCollection.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/multi-collections/{id:int}")]
|
||||
[Tags("Multi Collections")]
|
||||
[EndpointSummary("Delete a multi collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DeleteMultiCollection(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
private static MultiCollectionResponseModel ProjectToResponseModel(MultiCollectionViewModel vm)
|
||||
{
|
||||
List<MultiCollectionItemResponseModel> items = vm.Items
|
||||
.Map(i => new MultiCollectionItemResponseModel(
|
||||
i.Collection.Id,
|
||||
null,
|
||||
i.Collection.Name,
|
||||
i.ScheduleAsGroup,
|
||||
i.PlaybackOrder))
|
||||
.Concat(vm.SmartItems.Map(i => new MultiCollectionItemResponseModel(
|
||||
null,
|
||||
i.SmartCollection.Id,
|
||||
i.SmartCollection.Name,
|
||||
i.ScheduleAsGroup,
|
||||
i.PlaybackOrder)))
|
||||
.ToList();
|
||||
|
||||
return new MultiCollectionResponseModel(vm.Id, vm.Name, items);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaCollections;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -17,7 +23,85 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
public async Task<List<PlaylistGroupResponseModel>> GetGroups(CancellationToken cancellationToken)
|
||||
{
|
||||
List<PlaylistGroupViewModel> groups = await mediator.Send(new GetAllPlaylistGroups(), cancellationToken);
|
||||
return groups.Map(g => new PlaylistGroupResponseModel(g.Id, g.Name, g.PlaylistCount, g.IsSystem)).ToList();
|
||||
return groups.Map(ProjectToGroupResponse).ToList();
|
||||
}
|
||||
|
||||
[HttpPost("/api/playlists/groups", Name = "CreatePlaylistGroup")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Create a playlist group")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PlaylistGroupResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> CreateGroup(
|
||||
[Required] [FromBody] CreatePlaylistGroupRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, PlaylistGroupViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
g => $"/api/playlists/groups/{g.Id}",
|
||||
ProjectToGroupResponse);
|
||||
}
|
||||
|
||||
[HttpPut("/api/playlists/groups/{id:int}", Name = "UpdatePlaylistGroup")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Rename a playlist group")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PlaylistGroupResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> UpdateGroup(
|
||||
int id,
|
||||
[Required] [FromBody] UpdatePlaylistGroupRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Existence pre-check (404) mirrors DeleteGroup. Required controller-side because the
|
||||
// handler's NotFoundError is collapsed to a plain BaseError by LanguageExtensions.Apply
|
||||
// (error.Join()), so a missing group would otherwise map to 422 instead of 404.
|
||||
List<PlaylistGroupViewModel> groups = await mediator.Send(new GetAllPlaylistGroups(), cancellationToken);
|
||||
Option<PlaylistGroupViewModel> maybeGroup = groups.Find(g => g.Id == id);
|
||||
if (maybeGroup.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// System (generated) groups must not be renamed. The RenamePlaylistGroupHandler also
|
||||
// enforces this (defense-in-depth for the Blazor path); catching it here keeps the API's
|
||||
// 422 independent of the handler's error-collapsing.
|
||||
foreach (PlaylistGroupViewModel group in maybeGroup)
|
||||
{
|
||||
if (group.IsSystem)
|
||||
{
|
||||
return BaseError.New("Cannot rename system playlist group").ToErrorResult();
|
||||
}
|
||||
}
|
||||
|
||||
Either<BaseError, PlaylistGroupViewModel> result =
|
||||
await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result.Match(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: vm => (IActionResult)new OkObjectResult(ProjectToGroupResponse(vm)));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/playlists/groups/{id:int}", Name = "DeletePlaylistGroup")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Delete a playlist group")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> DeleteGroup(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
List<PlaylistGroupViewModel> groups = await mediator.Send(new GetAllPlaylistGroups(), cancellationToken);
|
||||
if (groups.All(g => g.Id != id))
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
Option<BaseError> result = await mediator.Send(new DeletePlaylistGroup(id), cancellationToken);
|
||||
return result.Match<IActionResult>(
|
||||
Some: error => error.ToErrorResult(),
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpGet("/api/playlists", Name = "GetPlaylists")]
|
||||
@@ -32,6 +116,195 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
List<PlaylistViewModel> playlists =
|
||||
await mediator.Send(new GetPlaylistsByPlaylistGroupId(playlistGroupId), cancellationToken);
|
||||
return playlists.Map(p => new PlaylistResponseModel(p.Id, p.PlaylistGroupId, p.Name, p.IsSystem)).ToList();
|
||||
return playlists.Map(ProjectToPlaylistResponse).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/playlists/{id:int}", Name = "GetPlaylistById")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Get a playlist by id")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PlaylistResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlaylistViewModel> result = await mediator.Send(new GetPlaylistById(id), cancellationToken);
|
||||
return result.Map(ProjectToPlaylistResponse).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/playlists/{id:int}/items", Name = "GetPlaylistItems")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Get the items in a playlist")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlaylistItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetItems(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlaylistViewModel> maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken);
|
||||
if (maybePlaylist.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<PlaylistItemViewModel> items = await mediator.Send(new GetPlaylistItems(id), cancellationToken);
|
||||
return new OkObjectResult(items.Map(ProjectToItemResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("/api/playlists", Name = "CreatePlaylistInGroup")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Create a playlist")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PlaylistResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreatePlaylistRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, PlaylistViewModel> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
p => $"/api/playlists/{p.Id}",
|
||||
ProjectToPlaylistResponse);
|
||||
}
|
||||
|
||||
[HttpPut("/api/playlists/{id:int}", Name = "UpdatePlaylist")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Update a playlist (rename and replace its items)")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlaylistItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] ReplacePlaylistRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlaylistViewModel> maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken);
|
||||
if (maybePlaylist.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// System (generated) playlists must not be renamed or have their items replaced.
|
||||
// Mirrors the DeletePlaylist system-guard (422); done controller-side so item
|
||||
// replacement never reaches ReplacePlaylistItemsHandler for a generated playlist.
|
||||
foreach (PlaylistViewModel playlist in maybePlaylist)
|
||||
{
|
||||
if (playlist.IsSystem)
|
||||
{
|
||||
return BaseError.New("Cannot modify system (generated) playlist").ToErrorResult();
|
||||
}
|
||||
}
|
||||
|
||||
Either<BaseError, List<PlaylistItemViewModel>> result =
|
||||
await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result.Match(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: items => (IActionResult)new OkObjectResult(items.Map(ProjectToItemResponse).ToList()));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/playlists/{id:int}", Name = "DeletePlaylist")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Delete a playlist")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlaylistViewModel> maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken);
|
||||
if (maybePlaylist.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
Option<BaseError> result = await mediator.Send(new DeletePlaylist(id), cancellationToken);
|
||||
return result.Match<IActionResult>(
|
||||
Some: error => error.ToErrorResult(),
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpPost("/api/playlists/{id:int}/items", Name = "AddItemsToPlaylist")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Add items to a playlist")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> AddItems(
|
||||
int id,
|
||||
[Required] [FromBody] AddItemsToPlaylistRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlaylistViewModel> maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken);
|
||||
if (maybePlaylist.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// System (generated) playlists must not have items added. The AddItemsToPlaylistHandler
|
||||
// also enforces this (defense-in-depth for the Blazor path, which calls the same command
|
||||
// from MultiSelectBase.AddItemsToPlaylist).
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/playlists/preview", Name = "PreviewPlaylist")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Preview the playout of a draft playlist")]
|
||||
[EndpointDescription("Builds a preview playout from the posted draft playlist items (no persistence).")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlaylistPreviewItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Preview(
|
||||
[Required] [FromBody] ReplacePlaylistRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// The preview path does not run the handler's CollectionTypesMustBeValid check
|
||||
// (PreviewPlaylistPlayoutHandler is shared with Blazor and left unchanged), so
|
||||
// validate the draft at the controller boundary to avoid a 500 in the playout
|
||||
// builder on an item missing the id required for its collection type.
|
||||
List<int> invalidItems = request.ItemsMissingRequiredId();
|
||||
if (invalidItems.Count > 0)
|
||||
{
|
||||
return BaseError
|
||||
.New($"Invalid playlist item(s) at index(es): {string.Join(", ", invalidItems)}")
|
||||
.ToErrorResult();
|
||||
}
|
||||
|
||||
Either<BaseError, List<PlayoutItemPreviewViewModel>> result =
|
||||
await mediator.Send(new PreviewPlaylistPlayout(request.ToReplaceCommand()), cancellationToken);
|
||||
return result.Match(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: items => (IActionResult)new OkObjectResult(items.Map(ProjectToPreviewResponse).ToList()));
|
||||
}
|
||||
|
||||
private static PlaylistGroupResponseModel ProjectToGroupResponse(PlaylistGroupViewModel vm) =>
|
||||
new(vm.Id, vm.Name, vm.PlaylistCount, vm.IsSystem);
|
||||
|
||||
private static PlaylistResponseModel ProjectToPlaylistResponse(PlaylistViewModel vm) =>
|
||||
new(vm.Id, vm.PlaylistGroupId, vm.Name, vm.IsSystem);
|
||||
|
||||
private static PlaylistItemResponseModel ProjectToItemResponse(PlaylistItemViewModel vm) =>
|
||||
new(
|
||||
vm.Id,
|
||||
vm.Index,
|
||||
vm.CollectionType,
|
||||
vm.Collection?.Id,
|
||||
vm.Collection?.Name,
|
||||
vm.MultiCollection?.Id,
|
||||
vm.MultiCollection?.Name,
|
||||
vm.SmartCollection?.Id,
|
||||
vm.SmartCollection?.Name,
|
||||
vm.MediaItem?.MediaItemId,
|
||||
vm.MediaItem?.Name,
|
||||
vm.PlaybackOrder,
|
||||
vm.Count,
|
||||
vm.PlayAll,
|
||||
vm.IncludeInProgramGuide);
|
||||
|
||||
private static PlaylistPreviewItemResponseModel ProjectToPreviewResponse(PlayoutItemPreviewViewModel vm) =>
|
||||
new(
|
||||
vm.Title,
|
||||
vm.Start.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture),
|
||||
vm.Finish.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture),
|
||||
vm.Duration);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ using ErsatzTV.Core.Api.Playouts;
|
||||
using ErsatzTV.Core.Api.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -18,10 +19,21 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) : ControllerBase
|
||||
{
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
// Blazor disables per-playout Reset/Erase/Delete/Edit while a build is in flight
|
||||
// (EntityLocker.IsPlayoutLocked); the API mirrors that invariant by rejecting any
|
||||
// id-keyed mutation with 409 while the build lock is held. See docs/decisions.md 2026-07-10.
|
||||
private const string BuildInProgressTitle = "Playout build in progress";
|
||||
|
||||
private const string BuildInProgressDetail =
|
||||
"A build for this playout is currently in progress; try again once it completes.";
|
||||
|
||||
private static IActionResult PlayoutLockedProblem() =>
|
||||
ApiResults.ConflictProblem(BuildInProgressTitle, BuildInProgressDetail);
|
||||
|
||||
[HttpGet("/api/playouts", Name = "GetPlayouts")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("List playouts")]
|
||||
@@ -37,7 +49,7 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
await mediator.Send(new GetPagedPlayouts(query, pageNum, pageSize), cancellationToken);
|
||||
return new PagedPlayoutsResponseModel(
|
||||
result.TotalCount,
|
||||
result.Page.Map(ToListItemResponse).ToList());
|
||||
result.Page.Map(vm => ToListItemResponse(vm, entityLocker.IsPlayoutLocked(vm.PlayoutId))).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/warnings/count", Name = "GetPlayoutWarningsCount")]
|
||||
@@ -131,12 +143,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdatePlayoutDetailsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
}
|
||||
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
@@ -204,12 +222,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> UpdateDefaultDeco(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateDefaultDecoRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
}
|
||||
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
@@ -284,12 +308,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlayoutAlternateScheduleResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplaceAlternateSchedules(
|
||||
int id,
|
||||
[Required] [FromBody] ReplacePlayoutAlternateSchedulesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
}
|
||||
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
@@ -376,12 +406,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlayoutTemplateResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplaceTemplates(
|
||||
int id,
|
||||
[Required] [FromBody] ReplacePlayoutTemplatesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
}
|
||||
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
@@ -515,21 +551,123 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
[EndpointSummary("Reset all playouts")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
// No 409 lock guard here (unlike the id-keyed mutations): ResetAllPlayoutsHandler already
|
||||
// skips any playout whose build lock is held, matching Blazor. It is a fire-and-forget
|
||||
// bulk enqueue, so it always accepts. See docs/decisions.md 2026-07-10.
|
||||
public async Task<IActionResult> ResetAll(CancellationToken cancellationToken)
|
||||
{
|
||||
await mediator.Send(new ResetAllPlayouts(), cancellationToken);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
[HttpPost("/api/playouts/{id:int}/erase-items", Name = "ErasePlayoutItems")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Erase a playout's items")]
|
||||
[EndpointDescription(
|
||||
"Deletes the built items (plus gaps and build status) for a Block, Sequential, or Scripted playout, " +
|
||||
"preserving history that precedes the currently-airing item. Only valid for those kinds; other kinds " +
|
||||
"return 422.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> EraseItems(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
}
|
||||
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
foreach (PlayoutNameViewModel playout in maybePlayout)
|
||||
{
|
||||
if (playout.ScheduleKind is not (PlayoutScheduleKind.Block or PlayoutScheduleKind.Sequential
|
||||
or PlayoutScheduleKind.Scripted))
|
||||
{
|
||||
return BaseError.New("[EraseItems] is only valid for Block, Sequential, or Scripted playouts")
|
||||
.ToErrorResult();
|
||||
}
|
||||
}
|
||||
|
||||
await mediator.Send(new ErasePlayoutItems(id), cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("/api/playouts/{id:int}/erase-items-and-history", Name = "ErasePlayoutItemsAndHistory")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Erase a playout's items and history")]
|
||||
[EndpointDescription(
|
||||
"Deletes all built items, history, anchors, and build status for a Classic, Block, Sequential, or " +
|
||||
"Scripted playout, and reseeds it. Only valid for those kinds; other kinds return 422.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> EraseItemsAndHistory(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
}
|
||||
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
foreach (PlayoutNameViewModel playout in maybePlayout)
|
||||
{
|
||||
if (playout.ScheduleKind is not (PlayoutScheduleKind.Classic or PlayoutScheduleKind.Block
|
||||
or PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted))
|
||||
{
|
||||
return BaseError.New(
|
||||
"[EraseItemsAndHistory] is only valid for Classic, Block, Sequential, or Scripted playouts")
|
||||
.ToErrorResult();
|
||||
}
|
||||
}
|
||||
|
||||
await mediator.Send(new ErasePlayoutHistory(id), cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/items/{id:int}/scheduling-context", Name = "GetPlayoutItemSchedulingContext")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Decode a playout item's scheduling context")]
|
||||
[EndpointDescription(
|
||||
"Decodes the stored scheduling context for a single playout item (by its row id) into readable, enriched " +
|
||||
"JSON. Returns 404 when the item is missing or has no scheduling context.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PlayoutItemSchedulingContextResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetItemSchedulingContext(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string> result = await mediator.Send(new GetPlayoutItemSchedulingContext(id), cancellationToken);
|
||||
return result.Map(context => new PlayoutItemSchedulingContextResponseModel(context)).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpDelete("/api/playouts/{id:int}")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Delete a playout")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DeletePlayout(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
@@ -641,7 +779,7 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
vm.EndDay,
|
||||
vm.EndYear);
|
||||
|
||||
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm) =>
|
||||
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm, bool isLocked) =>
|
||||
new(
|
||||
vm.PlayoutId,
|
||||
vm.ChannelNumber,
|
||||
@@ -649,7 +787,9 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
vm.ScheduleKind,
|
||||
vm.ScheduleName,
|
||||
vm.DbDailyRebuildTime,
|
||||
ToBuildStatus(vm.BuildStatus));
|
||||
ToBuildStatus(vm.BuildStatus),
|
||||
vm.PlayoutMode,
|
||||
isLocked);
|
||||
|
||||
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
|
||||
buildStatus is null
|
||||
@@ -661,9 +801,11 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
|
||||
private static PlayoutItemResponseModel ToItemResponse(PlayoutItemViewModel vm) =>
|
||||
new(
|
||||
vm.Id,
|
||||
vm.Title,
|
||||
vm.Start,
|
||||
vm.Finish,
|
||||
vm.Duration,
|
||||
vm.FillerKind.MatchUnsafe(fk => (FillerKind?)fk, () => null));
|
||||
vm.FillerKind.MatchUnsafe(fk => (FillerKind?)fk, () => null),
|
||||
!string.IsNullOrWhiteSpace(vm.SchedulingContext));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record AddItemsToPlaylistRequest(
|
||||
List<int> MovieIds,
|
||||
List<int> ShowIds,
|
||||
List<int> SeasonIds,
|
||||
List<int> EpisodeIds,
|
||||
List<int> ArtistIds,
|
||||
List<int> MusicVideoIds,
|
||||
List<int> OtherVideoIds,
|
||||
List<int> SongIds,
|
||||
List<int> ImageIds,
|
||||
List<int> RemoteStreamIds)
|
||||
{
|
||||
public AddItemsToPlaylist ToCommand(int playlistId) =>
|
||||
new(
|
||||
playlistId,
|
||||
MovieIds ?? [],
|
||||
ShowIds ?? [],
|
||||
SeasonIds ?? [],
|
||||
EpisodeIds ?? [],
|
||||
ArtistIds ?? [],
|
||||
MusicVideoIds ?? [],
|
||||
OtherVideoIds ?? [],
|
||||
SongIds ?? [],
|
||||
ImageIds ?? [],
|
||||
RemoteStreamIds ?? []);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record MultiCollectionItemRequest(
|
||||
int? CollectionId,
|
||||
int? SmartCollectionId,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder);
|
||||
|
||||
public record CreateMultiCollectionRequest(string Name, List<MultiCollectionItemRequest> Items)
|
||||
{
|
||||
public CreateMultiCollection ToCommand() =>
|
||||
new(
|
||||
Name,
|
||||
(Items ?? new List<MultiCollectionItemRequest>())
|
||||
.Select(i => new CreateMultiCollectionItem(
|
||||
i.CollectionId,
|
||||
i.SmartCollectionId,
|
||||
i.ScheduleAsGroup,
|
||||
i.PlaybackOrder))
|
||||
.ToList());
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record CreatePlaylistGroupRequest(string? Name)
|
||||
{
|
||||
public CreatePlaylistGroup ToCommand() => new(Name ?? string.Empty);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record CreatePlaylistRequest(int PlaylistGroupId, string? Name)
|
||||
{
|
||||
public CreatePlaylist ToCommand() => new(PlaylistGroupId, Name ?? string.Empty);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record CreateRerunCollectionRequest(
|
||||
string Name,
|
||||
CollectionType CollectionType,
|
||||
int SelectedId,
|
||||
PlaybackOrder FirstRunPlaybackOrder,
|
||||
PlaybackOrder RerunPlaybackOrder)
|
||||
{
|
||||
public CreateRerunCollection ToCommand()
|
||||
{
|
||||
(MediaCollectionViewModel collection,
|
||||
MultiCollectionViewModel multiCollection,
|
||||
SmartCollectionViewModel smartCollection,
|
||||
NamedMediaItemViewModel mediaItem) =
|
||||
RerunCollectionRequestMapping.ResolveSelection(CollectionType, SelectedId);
|
||||
|
||||
return new CreateRerunCollection(
|
||||
Name,
|
||||
CollectionType,
|
||||
collection,
|
||||
multiCollection,
|
||||
smartCollection,
|
||||
mediaItem,
|
||||
FirstRunPlaybackOrder,
|
||||
RerunPlaybackOrder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record PlaylistItemRequest(
|
||||
int Index,
|
||||
CollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MultiCollectionId,
|
||||
int? SmartCollectionId,
|
||||
int? MediaItemId,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int? Count,
|
||||
bool PlayAll,
|
||||
bool IncludeInProgramGuide)
|
||||
{
|
||||
public ReplacePlaylistItem ToReplaceCommand(int index) =>
|
||||
new(
|
||||
index,
|
||||
CollectionType,
|
||||
CollectionId,
|
||||
MultiCollectionId,
|
||||
SmartCollectionId,
|
||||
MediaItemId,
|
||||
PlaybackOrder,
|
||||
Count,
|
||||
PlayAll,
|
||||
IncludeInProgramGuide);
|
||||
|
||||
// Mirrors ReplacePlaylistItemsHandler.CollectionTypeMustBeValid: the id required for the
|
||||
// item's collection type must be present. Kept in sync with that handler so the PUT and
|
||||
// preview endpoints enforce the same rule (the handler operates on the command type in the
|
||||
// Application layer and cannot reference this request DTO, hence the parallel logic).
|
||||
public bool HasRequiredId() =>
|
||||
CollectionType switch
|
||||
{
|
||||
CollectionType.Collection => CollectionId is not null,
|
||||
CollectionType.MultiCollection => MultiCollectionId is not null,
|
||||
CollectionType.SmartCollection => SmartCollectionId is not null,
|
||||
CollectionType.TelevisionShow
|
||||
or CollectionType.TelevisionSeason
|
||||
or CollectionType.Artist
|
||||
or CollectionType.Movie
|
||||
or CollectionType.Episode
|
||||
or CollectionType.MusicVideo
|
||||
or CollectionType.OtherVideo
|
||||
or CollectionType.Song
|
||||
or CollectionType.Image => MediaItemId is not null,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public record ReplacePlaylistRequest(string? Name, List<PlaylistItemRequest>? Items)
|
||||
{
|
||||
public ReplacePlaylistItems ToCommand(int id) =>
|
||||
new(id, Name ?? string.Empty, BuildItems());
|
||||
|
||||
// Preview operates on the posted draft, so there is no persisted playlist id (0).
|
||||
public ReplacePlaylistItems ToReplaceCommand() =>
|
||||
new(0, Name ?? string.Empty, BuildItems());
|
||||
|
||||
// Array indexes of draft items missing the id required for their collection type.
|
||||
public List<int> ItemsMissingRequiredId()
|
||||
{
|
||||
List<PlaylistItemRequest> items = Items ?? new List<PlaylistItemRequest>();
|
||||
var invalid = new List<int>();
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
{
|
||||
if (!items[i].HasRequiredId())
|
||||
{
|
||||
invalid.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
return invalid;
|
||||
}
|
||||
|
||||
private List<ReplacePlaylistItem> BuildItems() =>
|
||||
(Items ?? new List<PlaylistItemRequest>())
|
||||
.Select((item, index) => item.ToReplaceCommand(index))
|
||||
.ToList();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
// A rerun collection is a tagged union: CollectionType selects which of Collection /
|
||||
// MultiCollection / SmartCollection / MediaItem is populated. The Create/Update handlers
|
||||
// read ONLY the `.Id` (or `.MediaItemId`) off the chosen VM — verified in
|
||||
// CreateRerunCollectionHandler.Validate / UpdateRerunCollectionHandler.ApplyUpdateRequest,
|
||||
// both of which do `request.Collection?.Id`, `request.MultiCollection?.Id`,
|
||||
// `request.SmartCollection?.Id`, `request.MediaItem?.MediaItemId` and nothing else. So we
|
||||
// construct a minimal VM carrying just the selected id (defaults elsewhere) rather than
|
||||
// doing extra lookup queries. The remaining VMs are left null (the handler expects that).
|
||||
internal static class RerunCollectionRequestMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// The only <see cref="CollectionType" /> values valid as a rerun collection's selected
|
||||
/// source. Excludes Playlist / RerunFirstRun / RerunRerun / SearchQuery / Fake* — those
|
||||
/// are not media-item-backed collection types and must not be resolved via
|
||||
/// <see cref="ResolveSelection" />. Callers (the controller) must check this first.
|
||||
/// </summary>
|
||||
public static bool IsSupportedSelectionType(CollectionType collectionType) =>
|
||||
collectionType is CollectionType.Collection
|
||||
or CollectionType.MultiCollection
|
||||
or CollectionType.SmartCollection
|
||||
or CollectionType.TelevisionShow
|
||||
or CollectionType.TelevisionSeason
|
||||
or CollectionType.Artist
|
||||
or CollectionType.Movie
|
||||
or CollectionType.Episode
|
||||
or CollectionType.MusicVideo
|
||||
or CollectionType.OtherVideo
|
||||
or CollectionType.Song
|
||||
or CollectionType.Image
|
||||
or CollectionType.RemoteStream;
|
||||
|
||||
public static (MediaCollectionViewModel Collection,
|
||||
MultiCollectionViewModel MultiCollection,
|
||||
SmartCollectionViewModel SmartCollection,
|
||||
NamedMediaItemViewModel MediaItem) ResolveSelection(CollectionType collectionType, int selectedId) =>
|
||||
collectionType switch
|
||||
{
|
||||
CollectionType.Collection => (
|
||||
new MediaCollectionViewModel(
|
||||
CollectionType.Collection,
|
||||
selectedId,
|
||||
string.Empty,
|
||||
false,
|
||||
MediaItemState.Normal),
|
||||
null,
|
||||
null,
|
||||
null),
|
||||
CollectionType.MultiCollection => (
|
||||
null,
|
||||
new MultiCollectionViewModel(selectedId, string.Empty, [], []),
|
||||
null,
|
||||
null),
|
||||
CollectionType.SmartCollection => (
|
||||
null,
|
||||
null,
|
||||
new SmartCollectionViewModel(selectedId, string.Empty, string.Empty),
|
||||
null),
|
||||
|
||||
// Any media-item-backed collection type (TelevisionShow, TelevisionSeason,
|
||||
// Artist, Movie, Episode, MusicVideo, OtherVideo, Song, Image, ...) resolves to
|
||||
// the MediaItem VM keyed by media item id. The caller (RerunCollectionController)
|
||||
// has already validated CollectionType via IsSupportedSelectionType, so this
|
||||
// catch-all only ever receives a real media-item type.
|
||||
_ => (
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new NamedMediaItemViewModel(selectedId, string.Empty))
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user