Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb310f6bc7 | ||
|
|
7ffc12b213 | ||
|
|
2d0935b651 | ||
|
|
6c9b998d7d | ||
|
|
22440cc1e6 | ||
|
|
550d7ac984 | ||
|
|
f59f2362e2 | ||
|
|
c93266b6fb | ||
|
|
e61f58cd99 | ||
|
|
f27f458ff5 | ||
|
|
bb7b189929 | ||
|
|
c40ffefa99 | ||
|
|
7bc84fa369 | ||
|
|
1a24298105 | ||
|
|
3981abc7f9 | ||
|
|
4aeabada3b | ||
|
|
b063bc45c0 | ||
|
|
589c35d357 | ||
|
|
b5c900dfbf | ||
|
|
832d482f2c | ||
|
|
6046bc622e | ||
|
|
7b78a393cf | ||
|
|
787058d18c | ||
|
|
611924c0ee | ||
|
|
5c9f04fdec | ||
|
|
1a8c0f60de | ||
|
|
3efb2ac4e1 | ||
|
|
13cd00c8fe | ||
|
|
d32ca976f7 | ||
|
|
2de091ea4f | ||
|
|
a27cfc475e | ||
|
|
61c8556ec7 | ||
|
|
321a34c748 | ||
|
|
b39f398df7 | ||
|
|
628c9d7228 | ||
|
|
9b73b62527 | ||
|
|
d02f953922 | ||
|
|
5e5f0af684 | ||
|
|
6055bd3687 | ||
|
|
a1bd303cce | ||
|
|
8e2f995492 | ||
|
|
7d106bf810 | ||
|
|
6ed36b4bac | ||
|
|
b285e33747 | ||
|
|
8878bf9e11 | ||
|
|
9b83be57f5 | ||
|
|
ee39effe0b | ||
|
|
7608cccebd | ||
|
|
9a1ddec71a | ||
|
|
94ebf34ccd | ||
|
|
495e450e2c | ||
|
|
b9a2fdec50 | ||
|
|
12cbff01f9 | ||
|
|
8a2238b62e | ||
|
|
7cddbb4bf8 | ||
|
|
edbecc01d7 | ||
|
|
ff72eb4d5f | ||
|
|
6ce448d265 | ||
|
|
c6ca87c06e | ||
|
|
ee104164db | ||
|
|
c617a01e83 | ||
|
|
26205b8ccd | ||
|
|
fe695166f6 | ||
|
|
f5cf23c952 | ||
|
|
4852c36268 | ||
|
|
8ee709d815 | ||
|
|
99fab8e0bb | ||
|
|
06c877b5fc | ||
|
|
87060aa3de | ||
|
|
295004ab12 | ||
|
|
51ac11e5aa | ||
|
|
64473b136d | ||
|
|
d93bca653a | ||
|
|
77272ed5a7 | ||
|
|
8cdc963636 | ||
|
|
084d4c4ca1 | ||
|
|
533c6c1e9f |
Executable
+10
@@ -0,0 +1,10 @@
|
||||
# Enforce the CLAUDE.md protocol: every commit message must carry a Co-Authored-By
|
||||
# trailer. Merge commits are exempt (their MERGE_MSG has no trailer and shouldn't be
|
||||
# rewritten).
|
||||
if git rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
grep -q '^Co-Authored-By:' "$1" || {
|
||||
echo 'husky - commit message missing Co-Authored-By trailer'
|
||||
exit 1
|
||||
}
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
cd web && npx lint-staged || exit 1
|
||||
cd ..
|
||||
|
||||
# dotnet format on staged .cs files (repo root). Scoped to the staged files so we
|
||||
# don't pay the full-tree cost; skip entirely when no .cs is staged (avoids the
|
||||
# ~20-40s sln load for web-only commits).
|
||||
cs_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.cs')
|
||||
if [ -n "$cs_files" ]; then
|
||||
echo "husky - dotnet format (verify) on staged .cs files"
|
||||
# shellcheck disable=SC2086
|
||||
dotnet format ErsatzTV.sln --verify-no-changes --include $cs_files || {
|
||||
echo "husky - dotnet format found issues in staged .cs files; run 'dotnet format ErsatzTV.sln --include <files>' to fix"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
# Git exports GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE while running hooks. In a worktree
|
||||
# (or any subdir), an explicit GIT_DIR makes nested `git` commands mislocate the working
|
||||
# tree — notably `check:api`'s `git diff --exit-code` (run from web/) silently reports "no
|
||||
# diff" and lets drift through. Unset them so nested git rediscovers the repo normally.
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
|
||||
|
||||
# CI-parity checks: catch "green locally, red in CI" before the push leaves the machine.
|
||||
# check:api guards the generated OpenAPI types (v1.json / v1.d.ts drift); the full
|
||||
# lint/typecheck/build catch a staged change that breaks an UNstaged file (lint-staged
|
||||
# only sees staged files).
|
||||
cd web && npm run check:api && npm run lint && npm run typecheck && npm run build
|
||||
@@ -55,7 +55,9 @@ public class BulkDeleteChannelsHandler(
|
||||
}
|
||||
}
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
|
||||
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,10 @@ public class BulkMoveChannelsToGroupHandler(
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
|
||||
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
|
||||
@@ -68,19 +68,22 @@ public class CreateChannelFromLineupHandler(
|
||||
}
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
await workerChannel.WriteAsync(
|
||||
new BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset),
|
||||
cancellationToken);
|
||||
CancellationToken.None);
|
||||
|
||||
// Mirror CreateClassicPlayoutHandler: on-demand playouts must be time-shifted to "now" after build.
|
||||
if (prepared.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
|
||||
{
|
||||
await workerChannel.WriteAsync(
|
||||
new TimeShiftOnDemandPlayout(prepared.Playout.Id, DateTimeOffset.Now, false),
|
||||
cancellationToken);
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
|
||||
|
||||
return new CreateChannelFromLineupResponseModel(
|
||||
prepared.Channel.Id,
|
||||
|
||||
@@ -47,20 +47,24 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
|
||||
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
// Delete the guide cache file through the filesystem abstraction (so it's observable under a
|
||||
// MockFileSystem) and BEFORE the commit: deleting after commit orphans {number}.xml if the
|
||||
// process crashes in between (nothing reaps it, and GetChannelGuideHandler serves everything
|
||||
// in the cache folder). The guide xml is regenerable on demand, so losing it pre-commit is safe (#254).
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
|
||||
if (_fileSystem.File.Exists(cacheFile))
|
||||
{
|
||||
_fileSystem.File.Delete(cacheFile);
|
||||
}
|
||||
|
||||
dbContext.Channels.Remove(channel);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
|
||||
// delete channel data from channel guide cache
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
|
||||
if (_fileSystem.File.Exists(cacheFile))
|
||||
{
|
||||
File.Delete(cacheFile);
|
||||
}
|
||||
|
||||
// refresh channel list to remove channel that has no playout
|
||||
await _workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
// refresh channel list to remove channel that has no playout — post-commit side effect runs on
|
||||
// CancellationToken.None so a late request cancellation can't abort it after the delete committed (#254)
|
||||
await _workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -157,21 +157,23 @@ public class UpdateChannelHandler(
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
if (c.SubtitleMode != ChannelSubtitleMode.None)
|
||||
{
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id, cancellationToken);
|
||||
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id, CancellationToken.None);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), cancellationToken);
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
|
||||
if (hasEpgChange)
|
||||
{
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), cancellationToken);
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), CancellationToken.None);
|
||||
}
|
||||
|
||||
return ProjectToViewModel(c, c.Playouts?.Count ?? 0);
|
||||
|
||||
@@ -81,10 +81,12 @@ public class UpdateChannelNumbersHandler(
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
// update channel list and xmltv
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
|
||||
foreach (var channel in channelsToUpdate)
|
||||
{
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(channel.Number), cancellationToken);
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(channel.Number), CancellationToken.None);
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
|
||||
namespace ErsatzTV.Application;
|
||||
|
||||
public static class ConcurrencyExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Persist changes that touch a versioned root but do <b>not</b> participate in the If-Match
|
||||
/// contract (e.g. a playout's settings/schedule-file/on-demand-checkpoint writer, a collection's
|
||||
/// name edit). Because the root's <c>Version</c> is an <c>IsConcurrencyToken</c>, EF guards every
|
||||
/// UPDATE of that row with <c>WHERE Version=@orig</c>, so a concurrent bump from a replace-all
|
||||
/// editor would otherwise surface as an unhandled <see cref="DbUpdateConcurrencyException" /> →
|
||||
/// 500 (issue #253 / #269). Phase-1 semantics for a missing <c>If-Match</c> is <b>force-write</b>,
|
||||
/// so on a concurrency failure we adopt the stored token as both original (the retry's WHERE then
|
||||
/// matches) and current (so we don't revert the concurrent bump) and retry — a client-wins merge
|
||||
/// scoped to the token; our own modified scalars still win. Bounded to avoid a livelock; if the row
|
||||
/// was deleted out from under us, that's a genuine conflict and rethrows.
|
||||
/// </summary>
|
||||
public static async Task<int> SaveChangesForcingVersion(
|
||||
this DbContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException ex) when (attempt < 5)
|
||||
{
|
||||
var resolvedAny = false;
|
||||
foreach (EntityEntry entry in ex.Entries)
|
||||
{
|
||||
if (entry.Entity is not IVersionedAggregate)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
PropertyValues databaseValues = await entry.GetDatabaseValuesAsync(cancellationToken);
|
||||
if (databaseValues is null)
|
||||
{
|
||||
// The row was deleted out from under us — a genuine conflict, not a token race.
|
||||
throw;
|
||||
}
|
||||
|
||||
PropertyEntry version = entry.Property(nameof(IVersionedAggregate.Version));
|
||||
object currentVersion = databaseValues[nameof(IVersionedAggregate.Version)]!;
|
||||
version.OriginalValue = currentVersion;
|
||||
version.CurrentValue = currentVersion;
|
||||
resolvedAny = true;
|
||||
}
|
||||
|
||||
if (!resolvedAny)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persist pending changes, mapping the EF optimistic-concurrency failure to
|
||||
/// <see cref="PreconditionFailedError" /> (→ 412). When a versioned root carries an
|
||||
/// <c>IsConcurrencyToken</c> column and its <c>Version</c> is bumped before saving, EF emits
|
||||
/// <c>UPDATE … WHERE Id=@id AND Version=@original</c>; a zero-row result (another writer won
|
||||
/// the race between our load and save) throws <see cref="DbUpdateConcurrencyException" />.
|
||||
/// This is the backstop that closes the load→save TOCTOU the handler pre-check cannot.
|
||||
/// Issue #253.
|
||||
/// </summary>
|
||||
public static async Task<Either<BaseError, Unit>> SaveChangesWithConcurrencyGuard(
|
||||
this DbContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new PreconditionFailedError(
|
||||
"The resource was modified by another request. Reload and try again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,12 +30,21 @@ public class DisconnectEmbyHandler : IRequestHandler<DisconnectEmby, Either<Base
|
||||
DisconnectEmby request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllEmby();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _embySecretStore.DeleteAll();
|
||||
_entityLocker.UnlockRemoteMediaSource<EmbyMediaSource>();
|
||||
// This is a terminal handler with no lock handoff (unlike the Plex pin-flow handlers) —
|
||||
// release unconditionally so a throw from any awaited dependency (repo delete, search-index
|
||||
// commit, secret store) can't wedge the Emby lock until restart (design #202 finding 7).
|
||||
try
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllEmby();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _embySecretStore.DeleteAll();
|
||||
|
||||
return Unit.Default;
|
||||
return Unit.Default;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_entityLocker.UnlockRemoteMediaSource<EmbyMediaSource>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan, bool DeepScan)
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId, bool ForceScan, bool DeepScan, bool Unlock = true)
|
||||
: IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
@@ -12,12 +12,29 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
|
||||
public UpdateEmbyPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateEmbyPathReplacements request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request, cancellationToken)
|
||||
.MapT(pms => MergePathReplacements(request, pms))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<EmbyMediaSource> maybeSource =
|
||||
await _mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken);
|
||||
|
||||
return await maybeSource.Match(
|
||||
Some: async embyMediaSource =>
|
||||
{
|
||||
Option<BaseError> maybeError = ValidateItems(request, embyMediaSource);
|
||||
return await maybeError.Match(
|
||||
Some: error => Task.FromResult(Left<BaseError, Unit>(error)),
|
||||
None: async () =>
|
||||
{
|
||||
await MergePathReplacements(request, embyMediaSource);
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
});
|
||||
},
|
||||
None: () => Task.FromResult(
|
||||
Left<BaseError, Unit>(
|
||||
BaseError.New($"Emby media source {request.EmbyMediaSourceId} does not exist."))));
|
||||
}
|
||||
|
||||
private Task<Unit> MergePathReplacements(
|
||||
UpdateEmbyPathReplacements request,
|
||||
@@ -37,12 +54,38 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
|
||||
private static EmbyPathReplacement Project(EmbyPathReplacementItem vm) =>
|
||||
new() { Id = vm.Id, EmbyPath = vm.EmbyPath, LocalPath = vm.LocalPath };
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> Validate(UpdateEmbyPathReplacements request, CancellationToken cancellationToken) =>
|
||||
EmbyMediaSourceMustExist(request, cancellationToken);
|
||||
// Defense-in-depth for design #202 findings 2c/8 — the repo UPDATE is scoped by
|
||||
// EmbyMediaSourceId, but reject a foreign/blank/null row here too, before any write, so the
|
||||
// mutation is all-or-nothing.
|
||||
private static Option<BaseError> ValidateItems(
|
||||
UpdateEmbyPathReplacements request,
|
||||
EmbyMediaSource embyMediaSource)
|
||||
{
|
||||
List<EmbyPathReplacementItem> items = request.PathReplacements ?? [];
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist(
|
||||
UpdateEmbyPathReplacements request, CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken)
|
||||
.Map(v => v.ToValidation<BaseError>(
|
||||
$"Emby media source {request.EmbyMediaSourceId} does not exist."));
|
||||
if (items.Any(item => item is null))
|
||||
{
|
||||
return BaseError.New("Path replacement items must not be null.");
|
||||
}
|
||||
|
||||
if (items.Any(item => string.IsNullOrWhiteSpace(item.EmbyPath) || string.IsNullOrWhiteSpace(item.LocalPath)))
|
||||
{
|
||||
return BaseError.New("Each path replacement requires a non-blank Emby path and local path.");
|
||||
}
|
||||
|
||||
var existingIds = (embyMediaSource.PathReplacements ?? new List<EmbyPathReplacement>())
|
||||
.Map(pr => pr.Id)
|
||||
.ToList();
|
||||
var foreignIds = items.Filter(item => item.Id > 0 && !existingIds.Contains(item.Id))
|
||||
.Map(item => item.Id)
|
||||
.ToList();
|
||||
if (foreignIds.Count > 0)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Path replacement id(s) {string.Join(", ", foreignIds)} do not belong to Emby media source " +
|
||||
$"{request.EmbyMediaSourceId}.");
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,21 @@ public class DisconnectJellyfinHandler : IRequestHandler<DisconnectJellyfin, Eit
|
||||
DisconnectJellyfin request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllJellyfin();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _jellyfinSecretStore.DeleteAll();
|
||||
_entityLocker.UnlockRemoteMediaSource<JellyfinMediaSource>();
|
||||
// This is a terminal handler with no lock handoff (unlike the Plex pin-flow handlers) —
|
||||
// release unconditionally so a throw from any awaited dependency (repo delete, search-index
|
||||
// commit, secret store) can't wedge the Jellyfin lock until restart (design #202 finding 7).
|
||||
try
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllJellyfin();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _jellyfinSecretStore.DeleteAll();
|
||||
|
||||
return Unit.Default;
|
||||
return Unit.Default;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_entityLocker.UnlockRemoteMediaSource<JellyfinMediaSource>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan, bool DeepScan) :
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan, bool DeepScan, bool Unlock = true) :
|
||||
IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
@@ -12,12 +12,29 @@ public class UpdateJellyfinPathReplacementsHandler : IRequestHandler<UpdateJelly
|
||||
public UpdateJellyfinPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateJellyfinPathReplacements request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(pms => MergePathReplacements(request, pms))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<JellyfinMediaSource> maybeSource =
|
||||
await _mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId);
|
||||
|
||||
return await maybeSource.Match(
|
||||
Some: async jellyfinMediaSource =>
|
||||
{
|
||||
Option<BaseError> maybeError = ValidateItems(request, jellyfinMediaSource);
|
||||
return await maybeError.Match(
|
||||
Some: error => Task.FromResult(Left<BaseError, Unit>(error)),
|
||||
None: async () =>
|
||||
{
|
||||
await MergePathReplacements(request, jellyfinMediaSource);
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
});
|
||||
},
|
||||
None: () => Task.FromResult(
|
||||
Left<BaseError, Unit>(
|
||||
BaseError.New($"Jellyfin media source {request.JellyfinMediaSourceId} does not exist."))));
|
||||
}
|
||||
|
||||
private Task<Unit> MergePathReplacements(
|
||||
UpdateJellyfinPathReplacements request,
|
||||
@@ -37,12 +54,38 @@ public class UpdateJellyfinPathReplacementsHandler : IRequestHandler<UpdateJelly
|
||||
private static JellyfinPathReplacement Project(JellyfinPathReplacementItem vm) =>
|
||||
new() { Id = vm.Id, JellyfinPath = vm.JellyfinPath, LocalPath = vm.LocalPath };
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> Validate(UpdateJellyfinPathReplacements request) =>
|
||||
JellyfinMediaSourceMustExist(request);
|
||||
// Defense-in-depth for design #202 findings 2c/8 — the repo UPDATE is scoped by
|
||||
// JellyfinMediaSourceId, but reject a foreign/blank/null row here too, before any write, so the
|
||||
// mutation is all-or-nothing.
|
||||
private static Option<BaseError> ValidateItems(
|
||||
UpdateJellyfinPathReplacements request,
|
||||
JellyfinMediaSource jellyfinMediaSource)
|
||||
{
|
||||
List<JellyfinPathReplacementItem> items = request.PathReplacements ?? [];
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist(
|
||||
UpdateJellyfinPathReplacements request) =>
|
||||
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
|
||||
.Map(v => v.ToValidation<BaseError>(
|
||||
$"Jellyfin media source {request.JellyfinMediaSourceId} does not exist."));
|
||||
if (items.Any(item => item is null))
|
||||
{
|
||||
return BaseError.New("Path replacement items must not be null.");
|
||||
}
|
||||
|
||||
if (items.Any(item => string.IsNullOrWhiteSpace(item.JellyfinPath) || string.IsNullOrWhiteSpace(item.LocalPath)))
|
||||
{
|
||||
return BaseError.New("Each path replacement requires a non-blank Jellyfin path and local path.");
|
||||
}
|
||||
|
||||
var existingIds = (jellyfinMediaSource.PathReplacements ?? new List<JellyfinPathReplacement>())
|
||||
.Map(pr => pr.Id)
|
||||
.ToList();
|
||||
var foreignIds = items.Filter(item => item.Id > 0 && !existingIds.Contains(item.Id))
|
||||
.Map(item => item.Id)
|
||||
.ToList();
|
||||
if (foreignIds.Count > 0)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Path replacement id(s) {string.Join(", ", foreignIds)} do not belong to Jellyfin media source " +
|
||||
$"{request.JellyfinMediaSourceId}.");
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Channels;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -14,15 +15,18 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
|
||||
public CreateLocalLibraryHandler(
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
|
||||
IEntityLocker entityLocker,
|
||||
IFileSystem fileSystem,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
_entityLocker = entityLocker;
|
||||
_fileSystem = fileSystem;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
@@ -31,7 +35,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
|
||||
Validation<BaseError, LocalLibrary> validation = await Validate(_fileSystem, dbContext, request);
|
||||
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
|
||||
}
|
||||
|
||||
@@ -61,11 +65,13 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, LocalLibrary>> Validate(
|
||||
IFileSystem fileSystem,
|
||||
TvContext dbContext,
|
||||
CreateLocalLibrary request) =>
|
||||
MediaSourceMustExist(dbContext, request)
|
||||
.BindT(localLibrary => NameMustBeValid(request, localLibrary))
|
||||
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary));
|
||||
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary))
|
||||
.BindT(localLibrary => NewPathsMustExist(fileSystem, localLibrary));
|
||||
|
||||
private static Task<Validation<BaseError, LocalLibrary>> MediaSourceMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -14,6 +15,28 @@ public abstract class LocalLibraryHandlerBase
|
||||
.Bind(_ => request.NotLongerThan(50)(c => c.Name))
|
||||
.Map(_ => localLibrary).AsTask();
|
||||
|
||||
/// <summary>
|
||||
/// Validates that every NEW path (<c>Id < 1</c> — see design #202 §C2) exists on the
|
||||
/// filesystem. Existing rows are exempt: an unmounted share must not block saving a rename,
|
||||
/// matching Blazor's behavior of only checking existence when a path is added.
|
||||
/// </summary>
|
||||
protected static Task<Validation<BaseError, LocalLibrary>> NewPathsMustExist(
|
||||
IFileSystem fileSystem,
|
||||
LocalLibrary localLibrary)
|
||||
{
|
||||
List<string> missing = localLibrary.Paths
|
||||
.Filter(p => p.Id < 1)
|
||||
.Filter(p => !fileSystem.Directory.Exists(p.Path))
|
||||
.Map(p => p.Path)
|
||||
.ToList();
|
||||
|
||||
Validation<BaseError, LocalLibrary> result = missing.Count == 0
|
||||
? Success<BaseError, LocalLibrary>(localLibrary)
|
||||
: Fail<BaseError, LocalLibrary>($"Path(s) do not exist on the filesystem: {string.Join(", ", missing)}");
|
||||
|
||||
return result.AsTask();
|
||||
}
|
||||
|
||||
protected static async Task<Validation<BaseError, LocalLibrary>> PathsMustBeValid(
|
||||
TvContext dbContext,
|
||||
LocalLibrary localLibrary,
|
||||
|
||||
@@ -79,10 +79,31 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
private static async Task<Validation<BaseError, Parameters>> Validate(
|
||||
TvContext dbContext,
|
||||
MoveLocalLibraryPath request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await LibraryPathMustExist(dbContext, request, cancellationToken),
|
||||
await LocalLibraryMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary));
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, Parameters> parameters =
|
||||
(await LibraryPathMustExist(dbContext, request, cancellationToken),
|
||||
await LocalLibraryMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary));
|
||||
|
||||
return parameters
|
||||
.Bind(TargetLibraryMustDiffer)
|
||||
.Bind(TargetLibraryMustMatchMediaKind);
|
||||
}
|
||||
|
||||
// Blazor's move dialog filters the target-library picker to same-kind, source-excluded
|
||||
// libraries only (MoveLocalLibraryPathDialog.razor:82); an API/MCP client bypasses that
|
||||
// client-side filter today, so #202 moves both invariants into the handler (design #202 §C5,
|
||||
// finding 3).
|
||||
private static Validation<BaseError, Parameters> TargetLibraryMustDiffer(Parameters parameters) =>
|
||||
parameters.LibraryPath.LibraryId == parameters.Library.Id
|
||||
? Fail<BaseError, Parameters>("Target library must be different from the source path's current library")
|
||||
: Success<BaseError, Parameters>(parameters);
|
||||
|
||||
private static Validation<BaseError, Parameters> TargetLibraryMustMatchMediaKind(Parameters parameters) =>
|
||||
parameters.LibraryPath.Library.MediaKind != parameters.Library.MediaKind
|
||||
? Fail<BaseError, Parameters>("Target library must have the same media kind as the source path's library")
|
||||
: Success<BaseError, Parameters>(parameters);
|
||||
|
||||
private static Task<Validation<BaseError, LibraryPath>> LibraryPathMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -8,4 +8,4 @@ public enum QueueLibraryScanResult
|
||||
AlreadyScanning
|
||||
}
|
||||
|
||||
public record QueueLibraryScanByLibraryId(int LibraryId) : IRequest<QueueLibraryScanResult>;
|
||||
public record QueueLibraryScanByLibraryId(int LibraryId, bool DeepScan = false) : IRequest<QueueLibraryScanResult>;
|
||||
|
||||
@@ -66,7 +66,7 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
new SynchronizePlexLibraries(library.MediaSourceId),
|
||||
cancellationToken);
|
||||
await scannerWorker.WriteAsync(
|
||||
new ForceSynchronizePlexLibraryById(library.Id, false),
|
||||
new ForceSynchronizePlexLibraryById(library.Id, request.DeepScan),
|
||||
cancellationToken);
|
||||
break;
|
||||
case JellyfinLibrary:
|
||||
@@ -74,7 +74,7 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
new SynchronizeJellyfinLibraries(library.MediaSourceId),
|
||||
cancellationToken);
|
||||
await scannerWorker.WriteAsync(
|
||||
new ForceSynchronizeJellyfinLibraryById(library.Id, false),
|
||||
new ForceSynchronizeJellyfinLibraryById(library.Id, request.DeepScan),
|
||||
cancellationToken);
|
||||
break;
|
||||
case EmbyLibrary:
|
||||
@@ -82,7 +82,7 @@ public class QueueLibraryScanByLibraryIdHandler(
|
||||
new SynchronizeEmbyLibraries(library.MediaSourceId),
|
||||
cancellationToken);
|
||||
await scannerWorker.WriteAsync(
|
||||
new ForceSynchronizeEmbyLibraryById(library.Id, false),
|
||||
new ForceSynchronizeEmbyLibraryById(library.Id, request.DeepScan),
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public record QueueShowScanByLibraryId(int LibraryId, int ShowId, string ShowTitle, bool DeepScan) : IRequest<bool>;
|
||||
public enum QueueShowScanResult
|
||||
{
|
||||
Queued,
|
||||
NotFound,
|
||||
Unsupported,
|
||||
SyncDisabled,
|
||||
AlreadyScanning,
|
||||
ScanFailed
|
||||
}
|
||||
|
||||
public record QueueShowScanByLibraryId(int LibraryId, int ShowId, string ShowTitle, bool DeepScan)
|
||||
: IRequest<QueueShowScanResult>;
|
||||
|
||||
@@ -19,9 +19,9 @@ public class QueueShowScanByLibraryIdHandler(
|
||||
IMediator mediator,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ILogger<QueueShowScanByLibraryIdHandler> logger)
|
||||
: IRequestHandler<QueueShowScanByLibraryId, bool>
|
||||
: IRequestHandler<QueueShowScanByLibraryId, QueueShowScanResult>
|
||||
{
|
||||
public async Task<bool> Handle(QueueShowScanByLibraryId request, CancellationToken cancellationToken)
|
||||
public async Task<QueueShowScanResult> Handle(QueueShowScanByLibraryId request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
@@ -42,14 +42,14 @@ public class QueueShowScanByLibraryIdHandler(
|
||||
if (!shouldSyncItems)
|
||||
{
|
||||
logger.LogWarning("Library sync is disabled for library id {Id}", library.Id);
|
||||
return false;
|
||||
return QueueShowScanResult.SyncDisabled;
|
||||
}
|
||||
|
||||
// Check if library is already being scanned - return false if locked
|
||||
// A false from LockLibrary means a scan is already in progress; we own no release.
|
||||
if (!locker.LockLibrary(library.Id))
|
||||
{
|
||||
logger.LogWarning("Library {Id} is already being scanned, cannot scan individual show", library.Id);
|
||||
return false;
|
||||
return QueueShowScanResult.AlreadyScanning;
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
@@ -60,41 +60,43 @@ public class QueueShowScanByLibraryIdHandler(
|
||||
|
||||
try
|
||||
{
|
||||
var success = false;
|
||||
QueueShowScanResult outcome;
|
||||
switch (library)
|
||||
{
|
||||
case PlexLibrary:
|
||||
Either<BaseError, string> plexResult = await mediator.Send(
|
||||
new SynchronizePlexShowById(library.Id, request.ShowId, request.DeepScan),
|
||||
cancellationToken);
|
||||
success = plexResult.IsRight;
|
||||
outcome = plexResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
|
||||
break;
|
||||
case JellyfinLibrary:
|
||||
Either<BaseError, string> jellyfinResult = await mediator.Send(
|
||||
new SynchronizeJellyfinShowById(library.Id, request.ShowId, request.DeepScan),
|
||||
cancellationToken);
|
||||
success = jellyfinResult.IsRight;
|
||||
outcome = jellyfinResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
|
||||
break;
|
||||
case EmbyLibrary:
|
||||
Either<BaseError, string> embyResult = await mediator.Send(
|
||||
new SynchronizeEmbyShowById(library.Id, request.ShowId, request.DeepScan),
|
||||
cancellationToken);
|
||||
success = embyResult.IsRight;
|
||||
outcome = embyResult.IsRight ? QueueShowScanResult.Queued : QueueShowScanResult.ScanFailed;
|
||||
break;
|
||||
case LocalLibrary:
|
||||
logger.LogWarning("Single show scanning is not supported for local libraries");
|
||||
outcome = QueueShowScanResult.Unsupported;
|
||||
break;
|
||||
default:
|
||||
logger.LogWarning("Unknown library type for library {Id}", library.Id);
|
||||
outcome = QueueShowScanResult.Unsupported;
|
||||
break;
|
||||
}
|
||||
|
||||
if (success && request.DeepScan)
|
||||
if (outcome == QueueShowScanResult.Queued && request.DeepScan)
|
||||
{
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedShowSubtitles(request.ShowId), cancellationToken);
|
||||
}
|
||||
|
||||
return success;
|
||||
return outcome;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -103,6 +105,6 @@ public class QueueShowScanByLibraryIdHandler(
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return QueueShowScanResult.NotFound;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Channels;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using Dapper;
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Core;
|
||||
@@ -17,17 +18,20 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public UpdateLocalLibraryHandler(
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
|
||||
IEntityLocker entityLocker,
|
||||
IFileSystem fileSystem,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
_entityLocker = entityLocker;
|
||||
_fileSystem = fileSystem;
|
||||
_searchIndex = searchIndex;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
@@ -37,7 +41,8 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, cancellationToken);
|
||||
Validation<BaseError, Parameters> validation =
|
||||
await Validate(_fileSystem, dbContext, request, cancellationToken);
|
||||
return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters));
|
||||
}
|
||||
|
||||
@@ -114,13 +119,15 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Parameters>> Validate(
|
||||
IFileSystem fileSystem,
|
||||
TvContext dbContext,
|
||||
UpdateLocalLibrary request,
|
||||
CancellationToken cancellationToken) =>
|
||||
LocalLibraryMustExist(dbContext, request, cancellationToken)
|
||||
.BindT(parameters => NameMustBeValid(request, parameters.Incoming).MapT(_ => parameters))
|
||||
.BindT(parameters => PathsMustBeValid(dbContext, parameters.Incoming, parameters.Existing.Id)
|
||||
.MapT(_ => parameters));
|
||||
.MapT(_ => parameters))
|
||||
.BindT(parameters => NewPathsMustExist(fileSystem, parameters.Incoming).MapT(_ => parameters));
|
||||
|
||||
private static Task<Validation<BaseError, Parameters>> LocalLibraryMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -32,6 +32,10 @@ public class AddEpisodeToPlaylistHandler(IDbContextFactory<TvContext> dbContextF
|
||||
};
|
||||
|
||||
parameters.Playlist.Items.Add(playlistItem);
|
||||
|
||||
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
|
||||
parameters.Playlist.Version++;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -81,13 +81,15 @@ public class AddItemsToCollectionHandler :
|
||||
|
||||
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
|
||||
{
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems(toAddIds.ToArray()), cancellationToken);
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
await _searchChannel.WriteAsync(new ReindexMediaItems(toAddIds.ToArray()), CancellationToken.None);
|
||||
|
||||
// refresh all playouts that use this collection
|
||||
foreach (int playoutId in await _mediaCollectionRepository
|
||||
.PlayoutIdsUsingCollection(request.CollectionId))
|
||||
{
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,9 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
|
||||
}
|
||||
}
|
||||
|
||||
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
|
||||
playlist.Version++;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -32,6 +32,10 @@ public class AddMovieToPlaylistHandler(IDbContextFactory<TvContext> dbContextFac
|
||||
};
|
||||
|
||||
parameters.Playlist.Items.Add(playlistItem);
|
||||
|
||||
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
|
||||
parameters.Playlist.Version++;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ public class AddSeasonToPlaylistHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
};
|
||||
|
||||
parameters.Playlist.Items.Add(playlistItem);
|
||||
|
||||
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
|
||||
parameters.Playlist.Version++;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ public class AddShowToPlaylistHandler(IDbContextFactory<TvContext> dbContextFact
|
||||
};
|
||||
|
||||
parameters.Playlist.Items.Add(playlistItem);
|
||||
|
||||
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
|
||||
parameters.Playlist.Version++;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ public class CreateSmartCollectionHandler :
|
||||
await dbContext.SmartCollections.AddAsync(smartCollection, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
await _smartCollectionCache.Refresh(cancellationToken);
|
||||
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
await _smartCollectionCache.Refresh(CancellationToken.None);
|
||||
return ProjectToViewModel(smartCollection);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,10 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
|
||||
dbContext.SmartCollections.Remove(smartCollection);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
await _smartCollectionCache.Refresh(cancellationToken);
|
||||
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
await _smartCollectionCache.Refresh(CancellationToken.None);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,14 +63,16 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromC
|
||||
|
||||
if (itemsToRemove.Count != 0 && await dbContext.SaveChangesAsync(cancellationToken) > 0)
|
||||
{
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
await _searchChannel.WriteAsync(
|
||||
new ReindexMediaItems(itemsToRemove.Select(mi => mi.Id).ToArray()),
|
||||
cancellationToken);
|
||||
CancellationToken.None);
|
||||
|
||||
// refresh all playouts that use this collection
|
||||
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(collection.Id))
|
||||
{
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,9 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record ReplacePlaylistItems(int PlaylistId, string Name, List<ReplacePlaylistItem> Items)
|
||||
public record ReplacePlaylistItems(
|
||||
int PlaylistId,
|
||||
string Name,
|
||||
List<ReplacePlaylistItem> Items,
|
||||
Option<int> ExpectedVersion = default)
|
||||
: IRequest<Either<BaseError, List<PlaylistItemViewModel>>>;
|
||||
|
||||
@@ -15,10 +15,21 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Playlist> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => Persist(dbContext, request, ps, cancellationToken));
|
||||
|
||||
// Introduce the optimistic-concurrency check as a standalone Either AFTER the validation
|
||||
// pipeline (never via Apply) so a PreconditionFailedError survives to a 412 and is not
|
||||
// flattened to a generic 422 by Join() (issue #253 / api-conventions §7a).
|
||||
// LanguageExtensions.ToEither joins the Seq<BaseError> to a single BaseError (the native
|
||||
// Validation.ToEither() would keep Seq and is called explicitly here to avoid that shadow).
|
||||
Either<BaseError, Playlist> validated = LanguageExtensions.ToEither(validation)
|
||||
.Bind(playlist => playlist.CheckVersion(request.ExpectedVersion));
|
||||
|
||||
return await validated.Match(
|
||||
Right: playlist => Persist(dbContext, request, playlist, cancellationToken),
|
||||
Left: error => Task.FromResult<Either<BaseError, List<PlaylistItemViewModel>>>(error));
|
||||
}
|
||||
|
||||
private static async Task<List<PlaylistItemViewModel>> Persist(
|
||||
private static async Task<Either<BaseError, List<PlaylistItemViewModel>>> Persist(
|
||||
TvContext dbContext,
|
||||
ReplacePlaylistItems request,
|
||||
Playlist playlist,
|
||||
@@ -30,9 +41,15 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
dbContext.RemoveRange(playlist.Items);
|
||||
playlist.Items = request.Items.Map(i => BuildItem(playlist, i.Index, i)).ToList();
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
// Unconditional bump: EF emits the root UPDATE only when a scalar actually differs, so a
|
||||
// same-value/no-op save would otherwise write no root row and neither fire the concurrency
|
||||
// token nor rotate other clients' ETags. Bumping guarantees both on every save (issue #253).
|
||||
playlist.Version++;
|
||||
|
||||
return playlist.Items.Map(Mapper.ProjectToViewModel).ToList();
|
||||
// Save through the guard so an EF concurrency failure (a racing writer won between our load
|
||||
// and save) maps to 412 rather than surfacing as a 500.
|
||||
Either<BaseError, Unit> saved = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
|
||||
return saved.Map(_ => playlist.Items.Map(Mapper.ProjectToViewModel).ToList());
|
||||
}
|
||||
|
||||
private static PlaylistItem BuildItem(Playlist playlist, int index, ReplacePlaylistItem item) =>
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record UpdateCollectionCustomOrder(
|
||||
int CollectionId,
|
||||
List<MediaItemCustomOrder> MediaItemCustomOrders) : IRequest<Either<BaseError, Unit>>;
|
||||
List<MediaItemCustomOrder> MediaItemCustomOrders,
|
||||
Option<int> ExpectedVersion = default) : IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
public record MediaItemCustomOrder(int MediaItemId, int CustomIndex);
|
||||
|
||||
+29
-10
@@ -32,13 +32,22 @@ public class UpdateCollectionCustomOrderHandler : IRequestHandler<UpdateCollecti
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
|
||||
|
||||
// Optimistic-concurrency check as a standalone Either AFTER validation, never via Apply (which
|
||||
// Join()s the error Seq and would flatten PreconditionFailedError to a 422) — issue #253 §7a.
|
||||
Either<BaseError, Collection> validated = LanguageExtensions.ToEither(validation)
|
||||
.Bind(c => c.CheckVersion(request.ExpectedVersion));
|
||||
|
||||
return await validated.Match(
|
||||
Right: c => ApplyUpdateRequest(dbContext, c, request, cancellationToken),
|
||||
Left: error => Task.FromResult<Either<BaseError, Unit>>(error));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(
|
||||
private async Task<Either<BaseError, Unit>> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
Collection c,
|
||||
UpdateCollectionCustomOrder request)
|
||||
UpdateCollectionCustomOrder request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (MediaItemCustomOrder updateItem in request.MediaItemCustomOrders)
|
||||
{
|
||||
@@ -51,14 +60,24 @@ public class UpdateCollectionCustomOrderHandler : IRequestHandler<UpdateCollecti
|
||||
}
|
||||
}
|
||||
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
// Unconditional bump (issue #253 §7a / M1) then guarded save (→ 412 on a lost race).
|
||||
c.Version++;
|
||||
Either<BaseError, Unit> saveResult = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
|
||||
if (saveResult.IsLeft)
|
||||
{
|
||||
// refresh all playouts that use this collection
|
||||
foreach (int playoutId in await _mediaCollectionRepository
|
||||
.PlayoutIdsUsingCollection(request.CollectionId))
|
||||
{
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh));
|
||||
}
|
||||
return saveResult;
|
||||
}
|
||||
|
||||
// Refresh all playouts that use this collection. The old `SaveChangesAsync() > 0` gate is always
|
||||
// true once the version bumps unconditionally (M2), so run the refresh on any successful save.
|
||||
// Post-commit enqueue on CancellationToken.None so a late cancellation can't drop the rebuild
|
||||
// after the commit landed (#254 / §7b).
|
||||
foreach (int playoutId in await _mediaCollectionRepository
|
||||
.PlayoutIdsUsingCollection(request.CollectionId))
|
||||
{
|
||||
await _channel.WriteAsync(
|
||||
new BuildPlayout(playoutId, PlayoutBuildMode.Refresh),
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -59,13 +59,17 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
|
||||
c.UseCustomPlaybackOrder = useCustomPlaybackOrder;
|
||||
}
|
||||
|
||||
if (await dbContext.SaveChangesAsync(cancellationToken) > 0 && request.UseCustomPlaybackOrder.IsSome)
|
||||
// Force-write past a concurrent Version bump from the custom-order editor (this name/flag writer
|
||||
// doesn't participate in If-Match, so the active token must not 500 a benign race) — #253/#269.
|
||||
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0 && request.UseCustomPlaybackOrder.IsSome)
|
||||
{
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
// refresh all playouts that use this collection
|
||||
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(
|
||||
request.CollectionId))
|
||||
{
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,4 +12,5 @@ public record UpdateMultiCollectionItem(
|
||||
public record UpdateMultiCollection(
|
||||
int MultiCollectionId,
|
||||
string Name,
|
||||
List<UpdateMultiCollectionItem> Items) : IRequest<Either<BaseError, Unit>>;
|
||||
List<UpdateMultiCollectionItem> Items,
|
||||
Option<int> ExpectedVersion = default) : IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
@@ -36,10 +36,18 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
|
||||
// Optimistic-concurrency check as a standalone Either AFTER validation, never via Apply (which
|
||||
// Join()s the error Seq and would flatten PreconditionFailedError to a 422) — issue #253 §7a.
|
||||
Either<BaseError, MultiCollection> validated = LanguageExtensions.ToEither(validation)
|
||||
.Bind(c => c.CheckVersion(request.ExpectedVersion));
|
||||
|
||||
return await validated.Match(
|
||||
Right: c => ApplyUpdateRequest(dbContext, c, request, cancellationToken),
|
||||
Left: error => Task.FromResult<Either<BaseError, Unit>>(error));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(
|
||||
private async Task<Either<BaseError, Unit>> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
MultiCollection c,
|
||||
UpdateMultiCollection request,
|
||||
@@ -47,8 +55,16 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
{
|
||||
c.Name = request.Name;
|
||||
|
||||
// save name first so playouts don't get rebuilt for a name change
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
// Bump the version on this first save (issue #253 §7a): it rotates other clients' ETags and,
|
||||
// via IsConcurrencyToken, closes the load→save race — a stale writer's WHERE Version=@orig hits
|
||||
// 0 rows → PreconditionFailedError (412). Saving the name first also keeps a name-only change
|
||||
// from triggering a playout rebuild (the item save below stays gated on real item changes).
|
||||
c.Version++;
|
||||
Either<BaseError, Unit> nameSave = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
|
||||
if (nameSave.IsLeft)
|
||||
{
|
||||
return nameSave;
|
||||
}
|
||||
|
||||
var toAdd = request.Items
|
||||
.Filter(i => i.CollectionId.HasValue)
|
||||
@@ -124,10 +140,12 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
|
||||
// refresh all playouts that use this collection
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingMultiCollection(
|
||||
request.MultiCollectionId))
|
||||
{
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,5 +13,6 @@ public record UpdateRerunCollection(
|
||||
SmartCollectionViewModel SmartCollection,
|
||||
NamedMediaItemViewModel MediaItem,
|
||||
PlaybackOrder FirstRunPlaybackOrder,
|
||||
PlaybackOrder RerunPlaybackOrder)
|
||||
PlaybackOrder RerunPlaybackOrder,
|
||||
Option<int> ExpectedVersion = default)
|
||||
: IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
@@ -22,10 +22,18 @@ public class UpdateRerunCollectionHandler(
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, RerunCollection> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
|
||||
// Optimistic-concurrency check as a standalone Either AFTER validation, never via Apply (which
|
||||
// Join()s the error Seq and would flatten PreconditionFailedError to a 422) — issue #253 §7a.
|
||||
Either<BaseError, RerunCollection> validated = LanguageExtensions.ToEither(validation)
|
||||
.Bind(c => c.CheckVersion(request.ExpectedVersion));
|
||||
|
||||
return await validated.Match(
|
||||
Right: c => ApplyUpdateRequest(dbContext, c, request, cancellationToken),
|
||||
Left: error => Task.FromResult<Either<BaseError, Unit>>(error));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(
|
||||
private async Task<Either<BaseError, Unit>> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
RerunCollection c,
|
||||
UpdateRerunCollection request,
|
||||
@@ -40,15 +48,23 @@ public class UpdateRerunCollectionHandler(
|
||||
c.FirstRunPlaybackOrder = request.FirstRunPlaybackOrder;
|
||||
c.RerunPlaybackOrder = request.RerunPlaybackOrder;
|
||||
|
||||
// rebuild playouts
|
||||
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
|
||||
// Unconditional bump (issue #253 §7a / M1) then guarded save, which maps a racing
|
||||
// DbUpdateConcurrencyException to PreconditionFailedError (→ 412).
|
||||
c.Version++;
|
||||
Either<BaseError, Unit> saveResult = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
|
||||
if (saveResult.IsLeft)
|
||||
{
|
||||
// refresh all playouts that use this rerun collection
|
||||
foreach (int playoutId in await mediaCollectionRepository.PlayoutIdsUsingRerunCollection(
|
||||
request.RerunCollectionId))
|
||||
{
|
||||
await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
|
||||
}
|
||||
return saveResult;
|
||||
}
|
||||
|
||||
// Refresh all playouts that use this rerun collection. The old `SaveChangesAsync() > 0` gate is
|
||||
// always true once the version bumps unconditionally (M2), so run the refresh on any successful
|
||||
// save. Post-commit enqueue on CancellationToken.None so a late cancellation can't drop the
|
||||
// rebuild after the commit landed (#254).
|
||||
foreach (int playoutId in await mediaCollectionRepository.PlayoutIdsUsingRerunCollection(
|
||||
request.RerunCollectionId))
|
||||
{
|
||||
await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -69,12 +69,15 @@ public class
|
||||
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
|
||||
{
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
await _smartCollectionCache.Refresh(cancellationToken);
|
||||
|
||||
// post-commit side effects run on CancellationToken.None so a late request cancellation
|
||||
// can't abort them after the commit landed (#254)
|
||||
await _smartCollectionCache.Refresh(CancellationToken.None);
|
||||
|
||||
// refresh all playouts that use this smart collection
|
||||
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingSmartCollection(request.Id))
|
||||
{
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), cancellationToken);
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,9 @@ public class UpdateTraktListHandler(
|
||||
{
|
||||
try
|
||||
{
|
||||
await workerChannel.WriteAsync(new MatchTraktListItems(traktList.Id), cancellationToken);
|
||||
// post-commit side effect runs on CancellationToken.None so a late request
|
||||
// cancellation can't abort it after the commit landed (#254)
|
||||
await workerChannel.WriteAsync(new MatchTraktListItems(traktList.Id), CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -12,14 +12,16 @@ internal static class Mapper
|
||||
collection.Id,
|
||||
collection.Name,
|
||||
collection.UseCustomPlaybackOrder,
|
||||
MediaItemState.Normal);
|
||||
MediaItemState.Normal,
|
||||
collection.Version);
|
||||
|
||||
internal static MultiCollectionViewModel ProjectToViewModel(MultiCollection multiCollection) =>
|
||||
new(
|
||||
multiCollection.Id,
|
||||
multiCollection.Name,
|
||||
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList(),
|
||||
Optional(multiCollection.MultiCollectionSmartItems).Flatten().Map(ProjectToViewModel).ToList());
|
||||
Optional(multiCollection.MultiCollectionSmartItems).Flatten().Map(ProjectToViewModel).ToList(),
|
||||
multiCollection.Version);
|
||||
|
||||
internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
|
||||
new(collection.Id, collection.Name, collection.Query);
|
||||
@@ -49,7 +51,8 @@ internal static class Mapper
|
||||
_ => null
|
||||
},
|
||||
collection.FirstRunPlaybackOrder,
|
||||
collection.RerunPlaybackOrder);
|
||||
collection.RerunPlaybackOrder,
|
||||
collection.Version);
|
||||
|
||||
internal static TraktListViewModel ProjectToViewModel(TraktList traktList) =>
|
||||
new(
|
||||
@@ -89,7 +92,7 @@ internal static class Mapper
|
||||
new(playlistGroup.Id, playlistGroup.Name, playlistGroup.Playlists.Count, playlistGroup.IsSystem);
|
||||
|
||||
internal static PlaylistViewModel ProjectToViewModel(Playlist playlist) =>
|
||||
new(playlist.Id, playlist.PlaylistGroupId, playlist.Name, playlist.IsSystem);
|
||||
new(playlist.Id, playlist.PlaylistGroupId, playlist.Name, playlist.IsSystem, playlist.Version);
|
||||
|
||||
internal static PlaylistItemViewModel ProjectToViewModel(PlaylistItem playlistItem) =>
|
||||
new(
|
||||
|
||||
@@ -8,7 +8,10 @@ public record MediaCollectionViewModel(
|
||||
int Id,
|
||||
string Name,
|
||||
bool UseCustomPlaybackOrder,
|
||||
MediaItemState State) : MediaCardViewModel(
|
||||
MediaItemState State,
|
||||
// Optimistic-concurrency token (issue #253), header-only via ETag; 0 for the selection/placeholder
|
||||
// constructions that don't read a real collection. Set from the entity by the Mapper projection.
|
||||
int Version = 0) : MediaCardViewModel(
|
||||
Id,
|
||||
Name,
|
||||
string.Empty,
|
||||
|
||||
@@ -4,4 +4,6 @@ public record MultiCollectionViewModel(
|
||||
int Id,
|
||||
string Name,
|
||||
List<MultiCollectionItemViewModel> Items,
|
||||
List<MultiCollectionSmartItemViewModel> SmartItems);
|
||||
List<MultiCollectionSmartItemViewModel> SmartItems,
|
||||
// Optimistic-concurrency token (issue #253), header-only via ETag; 0 for selection placeholders.
|
||||
int Version = 0);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record PlaylistViewModel(int Id, int PlaylistGroupId, string Name, bool IsSystem);
|
||||
public record PlaylistViewModel(int Id, int PlaylistGroupId, string Name, bool IsSystem, int Version);
|
||||
|
||||
@@ -12,4 +12,6 @@ public record RerunCollectionViewModel(
|
||||
SmartCollectionViewModel SmartCollection,
|
||||
NamedMediaItemViewModel MediaItem,
|
||||
PlaybackOrder FirstRunPlaybackOrder,
|
||||
PlaybackOrder RerunPlaybackOrder);
|
||||
PlaybackOrder RerunPlaybackOrder,
|
||||
// Optimistic-concurrency token (issue #253), header-only via ETag.
|
||||
int Version = 0);
|
||||
|
||||
@@ -62,9 +62,20 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(BuildPlayout request, CancellationToken cancellationToken)
|
||||
{
|
||||
// respect the EntityLocker ownership contract: LockPlayout returns true only for the caller
|
||||
// that performed the 0->1 transition. If another operation already holds this playout's lock
|
||||
// (a concurrent build, or a subtitle extraction), skip rather than build unlocked and then
|
||||
// cross-release the other owner's lock in the finally.
|
||||
if (!await _entityLocker.LockPlayout(request.PlayoutId))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Playout {PlayoutId} is already locked; skipping this build request",
|
||||
request.PlayoutId);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _entityLocker.LockPlayout(request.PlayoutId);
|
||||
if (request.Mode is not PlayoutBuildMode.Reset)
|
||||
{
|
||||
// this needs to happen before we load the playout in this handler because it modifies items, etc
|
||||
|
||||
@@ -35,15 +35,18 @@ public class CreateScriptedPlayoutHandler(
|
||||
{
|
||||
await dbContext.Playouts.AddAsync(playout, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken);
|
||||
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), CancellationToken.None);
|
||||
if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
|
||||
{
|
||||
await channel.WriteAsync(
|
||||
new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false),
|
||||
cancellationToken);
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
await channel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
await channel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
|
||||
return new CreatePlayoutResponse(playout.Id);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,15 +33,18 @@ public class CreateSequentialPlayoutHandler(
|
||||
{
|
||||
await dbContext.Playouts.AddAsync(playout, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken);
|
||||
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), CancellationToken.None);
|
||||
if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
|
||||
{
|
||||
await channel.WriteAsync(
|
||||
new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false),
|
||||
cancellationToken);
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
await channel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
await channel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
|
||||
return new CreatePlayoutResponse(playout.Id);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,20 +28,25 @@ public class DeletePlayoutHandler(
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
dbContext.Playouts.Remove(playout);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// delete channel data from channel guide cache
|
||||
// Delete the guide cache file through the filesystem abstraction (observable under a
|
||||
// MockFileSystem) and BEFORE the commit: deleting after commit orphans {number}.xml if the
|
||||
// process crashes in between. The guide xml is regenerable on demand, so losing it pre-commit
|
||||
// is safe (#254).
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{playout.Channel.Number}.xml");
|
||||
if (fileSystem.File.Exists(cacheFile))
|
||||
{
|
||||
File.Delete(cacheFile);
|
||||
fileSystem.File.Delete(cacheFile);
|
||||
}
|
||||
|
||||
// refresh channel list to remove channel that has no playout
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
dbContext.Playouts.Remove(playout);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await mediator.Publish(new PlayoutUpdatedNotification(playout.Id, false), cancellationToken);
|
||||
// post-commit side effects run on CancellationToken.None so a late request cancellation can't
|
||||
// abort them after the delete committed (#254)
|
||||
// refresh channel list to remove channel that has no playout
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
|
||||
|
||||
await mediator.Publish(new PlayoutUpdatedNotification(playout.Id, false), CancellationToken.None);
|
||||
}
|
||||
|
||||
return maybePlayout
|
||||
|
||||
@@ -2,5 +2,8 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record ReplacePlayoutAlternateScheduleItems(int PlayoutId, List<ReplacePlayoutAlternateSchedule> Items)
|
||||
public record ReplacePlayoutAlternateScheduleItems(
|
||||
int PlayoutId,
|
||||
List<ReplacePlayoutAlternateSchedule> Items,
|
||||
Option<int> ExpectedVersion = default)
|
||||
: IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
+36
-3
@@ -20,7 +20,13 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
|
||||
ReplacePlayoutAlternateScheduleItems request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: validate that items is not empty
|
||||
// The handler reads the highest-index item as the default schedule (Max() below), so an empty
|
||||
// list is invalid — reject it here rather than letting Max() throw. The controller pre-guards
|
||||
// too, but a direct handler caller (MCP, test, reuse) must get a clean 422, not a raw crash (#254).
|
||||
if (request.Items.Count == 0)
|
||||
{
|
||||
return BaseError.New("Playout alternate schedule items must not be empty");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -34,6 +40,18 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
// Optimistic-concurrency pre-check (issue #253 §7a): reject a stale If-Match with a
|
||||
// PreconditionFailedError (→ 412) before any mutation. Introduced as a standalone Either,
|
||||
// and returned directly (a value, not a throw) so it escapes the catch(Exception) below
|
||||
// rather than being reshaped into a bare 422 (§9/H1).
|
||||
Either<BaseError, Playout> versionCheck = playout.CheckVersion(request.ExpectedVersion);
|
||||
if (versionCheck.IsLeft)
|
||||
{
|
||||
return versionCheck.Match<Either<BaseError, Unit>>(
|
||||
Left: error => error,
|
||||
Right: _ => Unit.Default);
|
||||
}
|
||||
|
||||
var existingScheduleMap = new Dictionary<DateTimeOffset, ProgramSchedule>();
|
||||
var daysToCheck = new List<DateTimeOffset>();
|
||||
|
||||
@@ -121,7 +139,20 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
|
||||
playout.ProgramScheduleId = highest.ProgramScheduleId;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
// Unconditional bump (issue #253 §7a / M1): EF emits the root UPDATE only when a scalar
|
||||
// actually differs, so a no-op PUT-back would otherwise neither fire the concurrency token
|
||||
// nor rotate other clients' ETags.
|
||||
playout.Version++;
|
||||
|
||||
// Guarded save maps a racing DbUpdateConcurrencyException to PreconditionFailedError (→ 412)
|
||||
// as a return value, so a lost race short-circuits here before the post-commit refresh block
|
||||
// and escapes the catch(Exception) below as a 412, not a 422 (§9/H1).
|
||||
Either<BaseError, Unit> saveResult =
|
||||
await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
|
||||
if (saveResult.IsLeft)
|
||||
{
|
||||
return saveResult;
|
||||
}
|
||||
|
||||
if (hasDefaultScheduleChange)
|
||||
{
|
||||
@@ -152,9 +183,11 @@ public class ReplacePlayoutAlternateScheduleItemsHandler(
|
||||
existingValue.Name,
|
||||
schedule.Name);
|
||||
|
||||
// post-commit enqueue runs on CancellationToken.None: the schedule change is
|
||||
// already committed, so a late cancellation must not drop the rebuild (#254)
|
||||
await channel.WriteAsync(
|
||||
new BuildPlayout(request.PlayoutId, PlayoutBuildMode.Refresh),
|
||||
cancellationToken);
|
||||
CancellationToken.None);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record ResetAllPlayouts : IRequest;
|
||||
public record ResetAllPlayouts : IRequest<ResetAllPlayoutsResult>;
|
||||
|
||||
@@ -11,33 +11,49 @@ public class ResetAllPlayoutsHandler(
|
||||
IEntityLocker locker,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<ResetAllPlayouts>
|
||||
: IRequestHandler<ResetAllPlayouts, ResetAllPlayoutsResult>
|
||||
{
|
||||
public async Task Handle(ResetAllPlayouts request, CancellationToken cancellationToken)
|
||||
public async Task<ResetAllPlayoutsResult> Handle(
|
||||
ResetAllPlayouts request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var queued = new List<int>();
|
||||
var skippedLocked = new List<int>();
|
||||
var skippedUnsupported = new List<int>();
|
||||
|
||||
foreach (Playout playout in await dbContext.Playouts.ToListAsync(cancellationToken))
|
||||
{
|
||||
switch (playout.ScheduleKind)
|
||||
{
|
||||
case PlayoutScheduleKind.Classic:
|
||||
if (!locker.IsPlayoutLocked(playout.Id))
|
||||
if (locker.IsPlayoutLocked(playout.Id))
|
||||
{
|
||||
skippedLocked.Add(playout.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
await channel.WriteAsync(
|
||||
new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh),
|
||||
cancellationToken);
|
||||
queued.Add(playout.Id);
|
||||
}
|
||||
|
||||
break;
|
||||
case PlayoutScheduleKind.Block:
|
||||
case PlayoutScheduleKind.Sequential:
|
||||
case PlayoutScheduleKind.Scripted:
|
||||
if (!locker.IsPlayoutLocked(playout.Id))
|
||||
if (locker.IsPlayoutLocked(playout.Id))
|
||||
{
|
||||
skippedLocked.Add(playout.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
await channel.WriteAsync(
|
||||
new BuildPlayout(playout.Id, PlayoutBuildMode.Reset),
|
||||
cancellationToken);
|
||||
queued.Add(playout.Id);
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -45,8 +61,11 @@ public class ResetAllPlayoutsHandler(
|
||||
case PlayoutScheduleKind.None:
|
||||
default:
|
||||
// external json cannot be reset
|
||||
skippedUnsupported.Add(playout.Id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return new ResetAllPlayoutsResult(queued, skippedLocked, skippedUnsupported);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,13 @@ public class
|
||||
{
|
||||
playout.ScheduleFile = request.ScheduleFile;
|
||||
|
||||
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
|
||||
// Force-write past a concurrent Version bump from a replace-all editor (schedule-file writer
|
||||
// doesn't participate in If-Match, so an active token must not 500 a benign race) — #253/#269.
|
||||
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0)
|
||||
{
|
||||
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), cancellationToken);
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), CancellationToken.None);
|
||||
}
|
||||
|
||||
return new PlayoutNameViewModel(
|
||||
@@ -56,7 +60,8 @@ public class
|
||||
playout.DailyRebuildTime,
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name);
|
||||
playout.Deco?.Name,
|
||||
playout.Version);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -61,7 +61,10 @@ public class UpdateOnDemandCheckpointHandler(
|
||||
playout.Channel.Name,
|
||||
playout.OnDemandCheckpoint);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
// Force-write past a concurrent Version bump from a replace-all editor: the on-demand
|
||||
// checkpoint writer touches the Playout root but doesn't participate in If-Match, so the
|
||||
// active concurrency token must not turn a benign race into a 500 (#253/#269).
|
||||
await dbContext.SaveChangesForcingVersion(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
|
||||
playout.DailyRebuildTime = dailyRebuildTime;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
// Force-write past a concurrent Version bump from a replace-all editor (this settings writer
|
||||
// doesn't participate in If-Match, so a missing token = force-write, not a 500) — #253/#269.
|
||||
await dbContext.SaveChangesForcingVersion(cancellationToken);
|
||||
|
||||
return new PlayoutNameViewModel(
|
||||
playout.Id,
|
||||
@@ -48,7 +50,8 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
|
||||
playout.DailyRebuildTime,
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name);
|
||||
playout.Deco?.Name,
|
||||
playout.Version);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -35,9 +35,13 @@ public class
|
||||
{
|
||||
playout.ScheduleFile = request.ScheduleFile;
|
||||
|
||||
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
|
||||
// Force-write past a concurrent Version bump from a replace-all editor (schedule-file writer
|
||||
// doesn't participate in If-Match, so an active token must not 500 a benign race) — #253/#269.
|
||||
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0)
|
||||
{
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), cancellationToken);
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), CancellationToken.None);
|
||||
}
|
||||
|
||||
return new PlayoutNameViewModel(
|
||||
@@ -51,7 +55,8 @@ public class
|
||||
playout.DailyRebuildTime,
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name);
|
||||
playout.Deco?.Name,
|
||||
playout.Version);
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -40,9 +40,13 @@ public class
|
||||
{
|
||||
playout.ScheduleFile = request.ScheduleFile;
|
||||
|
||||
if (await dbContext.SaveChangesAsync(cancellationToken) > 0)
|
||||
// Force-write past a concurrent Version bump from a replace-all editor (schedule-file writer
|
||||
// doesn't participate in If-Match, so an active token must not 500 a benign race) — #253/#269.
|
||||
if (await dbContext.SaveChangesForcingVersion(cancellationToken) > 0)
|
||||
{
|
||||
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), cancellationToken);
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number), CancellationToken.None);
|
||||
}
|
||||
|
||||
return new PlayoutNameViewModel(
|
||||
@@ -56,7 +60,8 @@ public class
|
||||
playout.DailyRebuildTime,
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name);
|
||||
playout.Deco?.Name,
|
||||
playout.Version);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -19,7 +19,8 @@ internal static class Mapper
|
||||
playout.DecoId,
|
||||
// the paged-playouts query does not eager-load Deco (the list response does not surface
|
||||
// the default deco); GetPlayoutById includes it for the detail response
|
||||
playout.Deco?.Name);
|
||||
playout.Deco?.Name,
|
||||
playout.Version);
|
||||
|
||||
internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
|
||||
new(
|
||||
|
||||
@@ -13,7 +13,8 @@ public record PlayoutNameViewModel(
|
||||
TimeSpan? DbDailyRebuildTime,
|
||||
PlayoutBuildStatus BuildStatus,
|
||||
int? DecoId,
|
||||
string DecoName)
|
||||
string DecoName,
|
||||
int Version)
|
||||
{
|
||||
public Option<TimeSpan> DailyRebuildTime => Optional(DbDailyRebuildTime);
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
|
||||
p.DailyRebuildTime,
|
||||
p.BuildStatus,
|
||||
p.DecoId,
|
||||
p.DecoId == null ? null : p.Deco.Name));
|
||||
p.DecoId == null ? null : p.Deco.Name,
|
||||
p.Version));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record ResetAllPlayoutsResult(
|
||||
List<int> QueuedPlayoutIds,
|
||||
List<int> SkippedLocked,
|
||||
List<int> SkippedUnsupported);
|
||||
@@ -27,12 +27,22 @@ public class SignOutOfPlexHandler : IRequestHandler<SignOutOfPlex, Either<BaseEr
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(SignOutOfPlex request, CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllPlex();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _plexSecretStore.DeleteAll();
|
||||
_entityLocker.UnlockPlex();
|
||||
// Terminal handler (no lock handoff): release the Plex lock on EVERY exit via finally so a throw
|
||||
// from any awaited dependency (repo delete, search-index commit, secret store) cannot wedge Plex
|
||||
// locked at 409 until restart (#202 §C6 / finding 7). This is the UNCONDITIONAL-finally case —
|
||||
// contrast the pin-flow handlers, which hand the lock off and must NOT use a blanket finally.
|
||||
try
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllPlex();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _plexSecretStore.DeleteAll();
|
||||
|
||||
return Unit.Default;
|
||||
return Unit.Default;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_entityLocker.UnlockPlex();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan, bool DeepScan)
|
||||
public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan, bool DeepScan, bool Unlock = true)
|
||||
: IRequest<Either<BaseError, Unit>>, IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
@@ -7,34 +8,59 @@ namespace ErsatzTV.Application.Plex;
|
||||
public class TryCompletePlexPinFlowHandler : IRequestHandler<TryCompletePlexPinFlow, Either<BaseError, bool>>
|
||||
{
|
||||
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IPlexTvApiClient _plexTvApiClient;
|
||||
|
||||
public TryCompletePlexPinFlowHandler(
|
||||
IPlexTvApiClient plexTvApiClient,
|
||||
ChannelWriter<IPlexBackgroundServiceRequest> channel)
|
||||
ChannelWriter<IPlexBackgroundServiceRequest> channel,
|
||||
IEntityLocker entityLocker)
|
||||
{
|
||||
_plexTvApiClient = plexTvApiClient;
|
||||
_channel = channel;
|
||||
_entityLocker = entityLocker;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, bool>>
|
||||
Handle(TryCompletePlexPinFlow request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Lock-release discipline (#202 §C1.6 / §C6): the Plex lock this pin flow holds is released
|
||||
// ONLY on non-handoff exits — the 2-minute timeout (Task.Delay throws
|
||||
// OperationCanceledException), a poll exception, a failed enqueue, or the (effectively dead)
|
||||
// return-false at loop entry. On SUCCESS the lock is HANDED OFF to SynchronizePlexMediaSources,
|
||||
// whose handler is the sole releaser after server discovery (SynchronizePlexMediaSourcesHandler).
|
||||
// This is deliberately NOT an unconditional finally: a blanket release here would double-release
|
||||
// AND release before discovery, re-opening the finding-5 race (an empty server list reading as
|
||||
// success). Contrast the terminal SignOutOfPlexHandler, which DOES use finally (it has no handoff).
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
|
||||
using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken);
|
||||
CancellationToken token = linkedTokenSource.Token;
|
||||
while (!token.IsCancellationRequested)
|
||||
try
|
||||
{
|
||||
bool result = await _plexTvApiClient.TryCompletePinFlow(request.AuthPin);
|
||||
if (result)
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
await _channel.WriteAsync(new SynchronizePlexMediaSources(), token);
|
||||
return true;
|
||||
bool result = await _plexTvApiClient.TryCompletePinFlow(request.AuthPin);
|
||||
if (result)
|
||||
{
|
||||
// hand the lock off to the sync handler — do NOT release on this success path
|
||||
await _channel.WriteAsync(new SynchronizePlexMediaSources(), token);
|
||||
return true;
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), token);
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), token);
|
||||
// effectively unreachable (Task.Delay throws on cancellation before the loop condition is
|
||||
// re-evaluated) but if the flow ever ends here it abandoned without auth → release
|
||||
_entityLocker.UnlockPlex();
|
||||
return false;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// non-handoff exit: timeout-throw, poll exception, or failed enqueue — release the lock so an
|
||||
// abandoned flow does not wedge Plex locked, then rethrow (PlexService logs it as before)
|
||||
_entityLocker.UnlockPlex();
|
||||
throw;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,14 +35,56 @@ public class
|
||||
private static PlexPathReplacement Project(PlexPathReplacementItem vm) =>
|
||||
new() { Id = vm.Id, PlexPath = vm.PlexPath, LocalPath = vm.LocalPath };
|
||||
|
||||
private Task<Validation<BaseError, PlexMediaSource>> Validate(
|
||||
private async Task<Validation<BaseError, PlexMediaSource>> Validate(
|
||||
UpdatePlexPathReplacements request,
|
||||
CancellationToken cancellationToken) =>
|
||||
PlexMediaSourceMustExist(request, cancellationToken);
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlexMediaSource> maybeSource =
|
||||
await _mediaSourceRepository.GetPlex(request.PlexMediaSourceId, cancellationToken);
|
||||
|
||||
private Task<Validation<BaseError, PlexMediaSource>> PlexMediaSourceMustExist(
|
||||
foreach (PlexMediaSource plexMediaSource in maybeSource)
|
||||
{
|
||||
return ValidatePathReplacements(request, plexMediaSource);
|
||||
}
|
||||
|
||||
return Fail<BaseError, PlexMediaSource>(
|
||||
BaseError.New($"Plex media source {request.PlexMediaSourceId} does not exist."));
|
||||
}
|
||||
|
||||
// Programmatic clients now reach this handler directly (#202), so validate what the Blazor form
|
||||
// enforced plus the cross-source ownership hole (finding 2c/8): reject a null list, null items,
|
||||
// blank RemotePath/LocalPath, and any positive Id NOT owned by this source — all before any write,
|
||||
// so there is no partial mutation.
|
||||
private static Validation<BaseError, PlexMediaSource> ValidatePathReplacements(
|
||||
UpdatePlexPathReplacements request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetPlex(request.PlexMediaSourceId, cancellationToken)
|
||||
.Map(v => v.ToValidation<BaseError>($"Plex media source {request.PlexMediaSourceId} does not exist."));
|
||||
PlexMediaSource plexMediaSource)
|
||||
{
|
||||
List<PlexPathReplacementItem> items = request.PathReplacements;
|
||||
if (items is null)
|
||||
{
|
||||
return Fail<BaseError, PlexMediaSource>(BaseError.New("[PathReplacements] is required"));
|
||||
}
|
||||
|
||||
if (items.Any(i => i is null))
|
||||
{
|
||||
return Fail<BaseError, PlexMediaSource>(BaseError.New("[PathReplacements] must not contain null items"));
|
||||
}
|
||||
|
||||
if (items.Any(i => string.IsNullOrWhiteSpace(i.PlexPath) || string.IsNullOrWhiteSpace(i.LocalPath)))
|
||||
{
|
||||
return Fail<BaseError, PlexMediaSource>(
|
||||
BaseError.New("Each path replacement requires a non-empty RemotePath and LocalPath"));
|
||||
}
|
||||
|
||||
var ownedIds = Optional(plexMediaSource.PathReplacements).Flatten().Map(pr => pr.Id).ToHashSet();
|
||||
List<int> foreignIds = items.Filter(i => i.Id > 0 && !ownedIds.Contains(i.Id)).Map(i => i.Id).ToList();
|
||||
if (foreignIds.Count > 0)
|
||||
{
|
||||
return Fail<BaseError, PlexMediaSource>(
|
||||
BaseError.New(
|
||||
$"Path replacement {foreignIds[0]} does not belong to Plex media source {request.PlexMediaSourceId}"));
|
||||
}
|
||||
|
||||
return Success<BaseError, PlexMediaSource>(plexMediaSource);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,12 +52,17 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
|
||||
ProgramScheduleItem item = BuildItem(programSchedule, nextIndex, request);
|
||||
programSchedule.Items.Add(item);
|
||||
|
||||
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253)
|
||||
programSchedule.Version++;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// refresh any playouts that use this schedule
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
foreach (Playout playout in programSchedule.Playouts)
|
||||
{
|
||||
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
|
||||
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), CancellationToken.None);
|
||||
}
|
||||
|
||||
// reload with the full navigation graph before projecting: BuildItem creates the watermark/graphics
|
||||
|
||||
@@ -34,11 +34,17 @@ public class DeleteProgramScheduleItemHandler(
|
||||
|
||||
List<Playout> playouts = item.ProgramSchedule.Playouts;
|
||||
dbContext.ProgramScheduleItems.Remove(item);
|
||||
|
||||
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253)
|
||||
item.ProgramSchedule.Version++;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
foreach (Playout playout in playouts)
|
||||
{
|
||||
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
|
||||
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), CancellationToken.None);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -44,5 +44,8 @@ public record ReplaceProgramScheduleItem(
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode? SubtitleMode) : IProgramScheduleItemRequest;
|
||||
|
||||
public record ReplaceProgramScheduleItems(int ProgramScheduleId, List<ReplaceProgramScheduleItem> Items) : IRequest<
|
||||
public record ReplaceProgramScheduleItems(
|
||||
int ProgramScheduleId,
|
||||
List<ReplaceProgramScheduleItem> Items,
|
||||
Option<int> ExpectedVersion = default) : IRequest<
|
||||
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>;
|
||||
|
||||
+108
-9
@@ -26,34 +26,103 @@ public class ReplaceProgramScheduleItemsHandler(
|
||||
Some: async programSchedule =>
|
||||
{
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, programSchedule);
|
||||
return await validation.Apply(ps => PersistItems(dbContext, request, ps, cancellationToken));
|
||||
|
||||
// Introduce the optimistic-concurrency check as a standalone Either AFTER the validation
|
||||
// pipeline (never via Apply) so a PreconditionFailedError survives to a 412 and is not
|
||||
// flattened to a generic 422 by Join() (issue #253 / api-conventions §7a).
|
||||
Either<BaseError, ProgramSchedule> validated = LanguageExtensions.ToEither(validation)
|
||||
.Bind(ps => ps.CheckVersion(request.ExpectedVersion));
|
||||
|
||||
return await validated.Match(
|
||||
Right: ps => PersistItems(dbContext, request, ps, cancellationToken),
|
||||
Left: error =>
|
||||
Task.FromResult<Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>(error));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>(
|
||||
new NotFoundError("[ProgramScheduleId] does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<ProgramScheduleItemViewModel>> PersistItems(
|
||||
private async Task<Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>> PersistItems(
|
||||
TvContext dbContext,
|
||||
ReplaceProgramScheduleItems request,
|
||||
ProgramSchedule programSchedule,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.RemoveRange(programSchedule.Items);
|
||||
|
||||
// reset index starting with zero
|
||||
programSchedule.Items = [];
|
||||
// Positional in-place reconcile (rather than delete-and-reinsert): a schedule item owns the
|
||||
// persisted fill-group/shuffle enumerator state via PlayoutScheduleItemFillGroupIndex, whose
|
||||
// ProgramScheduleItemId FK is OnDelete(Cascade). Removing and re-inserting every item — as the
|
||||
// original handler did on every save, including a no-op PUT-back — cascade-deleted that state for
|
||||
// all playouts using the schedule (#252). Reusing the existing item row for the same-typed slot
|
||||
// keeps its id, so the cascade never fires and progression survives. The request DTO carries no
|
||||
// stable item id, so position is the only key available here; true content-aware stable identity
|
||||
// is deferred to the shared concurrency/round-trip contract in #253.
|
||||
var orderedItems = request.Items.OrderBy(i => i.Index).ToList();
|
||||
for (var i = 0; i < orderedItems.Count; i++)
|
||||
List<ProgramScheduleItem> existingItems = programSchedule.Items.OrderBy(i => i.Index).ToList();
|
||||
|
||||
// load the watermark/graphics join rows for the existing items so they can be rebuilt in place
|
||||
await dbContext.Entry(programSchedule)
|
||||
.Collection(ps => ps.Items)
|
||||
.Query()
|
||||
.Include(i => i.ProgramScheduleItemWatermarks)
|
||||
.Include(i => i.ProgramScheduleItemGraphicsElements)
|
||||
.LoadAsync(cancellationToken);
|
||||
|
||||
int commonCount = Math.Min(existingItems.Count, orderedItems.Count);
|
||||
for (var i = 0; i < commonCount; i++)
|
||||
{
|
||||
ProgramScheduleItem existing = existingItems[i];
|
||||
ProgramScheduleItem built = BuildItem(programSchedule, i, orderedItems[i]);
|
||||
if (existing.GetType() == built.GetType())
|
||||
{
|
||||
// same TPT subtype: copy all scalar values in place (BuildItem is the single source of
|
||||
// item construction, so no field is silently dropped) and rebuild the join rows, keeping
|
||||
// the item's id — and with it the fill-group index that would otherwise cascade away.
|
||||
built.Id = existing.Id;
|
||||
dbContext.Entry(existing).CurrentValues.SetValues(built);
|
||||
RebuildChildren(existing, orderedItems[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
// EF can't change a TPT row's type in place; this slot must be replaced (its fill-group
|
||||
// index resets, which is acceptable — the item fundamentally changed).
|
||||
dbContext.Remove(existing);
|
||||
programSchedule.Items.Add(built);
|
||||
}
|
||||
}
|
||||
|
||||
// remove surplus existing items
|
||||
for (int i = commonCount; i < existingItems.Count; i++)
|
||||
{
|
||||
dbContext.Remove(existingItems[i]);
|
||||
}
|
||||
|
||||
// add surplus incoming items
|
||||
for (int i = commonCount; i < orderedItems.Count; i++)
|
||||
{
|
||||
programSchedule.Items.Add(BuildItem(programSchedule, i, orderedItems[i]));
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
// Unconditional bump: this handler frequently saves with only CHILD changes and no root-scalar
|
||||
// change, so without an explicit bump EF would emit no root UPDATE and the concurrency token
|
||||
// would never fire (nor rotate other clients' ETags). Bumping guarantees both on every save,
|
||||
// including a no-op same-items PUT-back (issue #253 / api-conventions §7a).
|
||||
programSchedule.Version++;
|
||||
|
||||
// Save through the guard so an EF concurrency failure (a racing writer won between our load and
|
||||
// save) maps to 412 rather than surfacing as a 500. On failure, propagate the error WITHOUT
|
||||
// running the post-save reload/enqueue below.
|
||||
Either<BaseError, Unit> saved = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
|
||||
if (saved.IsLeft)
|
||||
{
|
||||
return saved.Map(_ => (IEnumerable<ProgramScheduleItemViewModel>)[]);
|
||||
}
|
||||
|
||||
// refresh any playouts that use this schedule
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
// can't abort it after the commit landed (#254)
|
||||
foreach (Playout playout in programSchedule.Playouts)
|
||||
{
|
||||
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
|
||||
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), CancellationToken.None);
|
||||
}
|
||||
|
||||
// reload with the full navigation graph before projecting: BuildItem creates the watermark/graphics
|
||||
@@ -68,6 +137,36 @@ public class ReplaceProgramScheduleItemsHandler(
|
||||
return persisted.Map(ProjectToViewModel).ToList();
|
||||
}
|
||||
|
||||
// Rebuild the watermark/graphics join rows for an item updated in place. Clearing severs the required
|
||||
// relationship so EF deletes the orphaned join rows (same net effect the delete-and-reinsert path had),
|
||||
// then re-add from the request. Only the parent item row is preserved — the join rows carry no state.
|
||||
private static void RebuildChildren(ProgramScheduleItem item, ReplaceProgramScheduleItem request)
|
||||
{
|
||||
item.ProgramScheduleItemWatermarks ??= [];
|
||||
item.ProgramScheduleItemWatermarks.Clear();
|
||||
foreach (int watermarkId in request.WatermarkIds)
|
||||
{
|
||||
item.ProgramScheduleItemWatermarks.Add(
|
||||
new ProgramScheduleItemWatermark
|
||||
{
|
||||
ProgramScheduleItem = item,
|
||||
WatermarkId = watermarkId
|
||||
});
|
||||
}
|
||||
|
||||
item.ProgramScheduleItemGraphicsElements ??= [];
|
||||
item.ProgramScheduleItemGraphicsElements.Clear();
|
||||
foreach (int graphicsElementId in request.GraphicsElementIds)
|
||||
{
|
||||
item.ProgramScheduleItemGraphicsElements.Add(
|
||||
new ProgramScheduleItemGraphicsElement
|
||||
{
|
||||
ProgramScheduleItem = item,
|
||||
GraphicsElementId = graphicsElementId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
TvContext dbContext,
|
||||
ReplaceProgramScheduleItems request,
|
||||
|
||||
@@ -54,6 +54,9 @@ public class UpdateProgramScheduleHandler(
|
||||
programSchedule.RandomStartPoint = request.RandomStartPoint;
|
||||
programSchedule.FixedStartTimeBehavior = request.FixedStartTimeBehavior;
|
||||
|
||||
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253)
|
||||
programSchedule.Version++;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
if (needToRefreshPlayout)
|
||||
|
||||
@@ -12,7 +12,8 @@ internal static class Mapper
|
||||
programSchedule.TreatCollectionsAsShows,
|
||||
programSchedule.ShuffleScheduleItems,
|
||||
programSchedule.RandomStartPoint,
|
||||
programSchedule.FixedStartTimeBehavior);
|
||||
programSchedule.FixedStartTimeBehavior,
|
||||
programSchedule.Version);
|
||||
|
||||
internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) =>
|
||||
programScheduleItem switch
|
||||
|
||||
@@ -9,4 +9,5 @@ public record ProgramScheduleViewModel(
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior);
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int Version);
|
||||
|
||||
@@ -19,7 +19,8 @@ public class GetAllProgramSchedulesHandler(IDbContextFactory<TvContext> dbContex
|
||||
ps.TreatCollectionsAsShows,
|
||||
ps.ShuffleScheduleItems,
|
||||
ps.RandomStartPoint,
|
||||
ps.FixedStartTimeBehavior))
|
||||
ps.FixedStartTimeBehavior,
|
||||
ps.Version))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ public record BlockViewModel(
|
||||
string GroupName,
|
||||
string Name,
|
||||
int Minutes,
|
||||
BlockStopScheduling StopScheduling);
|
||||
BlockStopScheduling StopScheduling,
|
||||
int Version);
|
||||
|
||||
@@ -9,5 +9,6 @@ public record ReplaceBlockItems(
|
||||
string Name,
|
||||
int Minutes,
|
||||
BlockStopScheduling StopScheduling,
|
||||
List<ReplaceBlockItem> Items)
|
||||
List<ReplaceBlockItem> Items,
|
||||
Option<int> ExpectedVersion = default)
|
||||
: IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
@@ -16,10 +16,21 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Block> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => Persist(dbContext, request, ps, cancellationToken));
|
||||
|
||||
// Introduce the optimistic-concurrency check as a standalone Either AFTER the validation
|
||||
// pipeline (never via Apply) so a PreconditionFailedError survives to a 412 and is not
|
||||
// flattened to a generic 422 by Join() (issue #253 / api-conventions §7a).
|
||||
// LanguageExtensions.ToEither joins the Seq<BaseError> to a single BaseError (the native
|
||||
// Validation.ToEither() would keep Seq and is called explicitly here to avoid that shadow).
|
||||
Either<BaseError, Block> validated = LanguageExtensions.ToEither(validation)
|
||||
.Bind(block => block.CheckVersion(request.ExpectedVersion));
|
||||
|
||||
return await validated.Match(
|
||||
Right: block => Persist(dbContext, request, block, cancellationToken),
|
||||
Left: error => Task.FromResult<Either<BaseError, Unit>>(error));
|
||||
}
|
||||
|
||||
private static async Task<Unit> Persist(
|
||||
private static async Task<Either<BaseError, Unit>> Persist(
|
||||
TvContext dbContext,
|
||||
ReplaceBlockItems request,
|
||||
Block block,
|
||||
@@ -33,7 +44,10 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact
|
||||
dbContext.RemoveRange(block.Items);
|
||||
block.Items = request.Items.Map(i => BuildItem(block, i.Index, i)).ToList();
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
// Unconditional bump: EF emits the root UPDATE only when a scalar actually differs, so a
|
||||
// same-value/no-op save would otherwise write no root row and neither fire the concurrency
|
||||
// token nor rotate other clients' ETags. Bumping guarantees both on every save (issue #253).
|
||||
block.Version++;
|
||||
|
||||
// TODO: refresh any playouts that use this schedule
|
||||
// foreach (Playout playout in programSchedule.Playouts)
|
||||
@@ -41,7 +55,9 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact
|
||||
// await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh));
|
||||
// }
|
||||
|
||||
return Unit.Default;
|
||||
// Save through the guard so an EF concurrency failure (a racing writer won between our load
|
||||
// and save) maps to 412 rather than surfacing as a 500.
|
||||
return await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
|
||||
}
|
||||
|
||||
private static BlockItem BuildItem(Block block, int index, ReplaceBlockItem item)
|
||||
|
||||
@@ -6,5 +6,6 @@ public record ReplaceDecoTemplateItems(
|
||||
int DecoTemplateId,
|
||||
int DecoTemplateGroupId,
|
||||
string Name,
|
||||
List<ReplaceDecoTemplateItem> Items)
|
||||
List<ReplaceDecoTemplateItem> Items,
|
||||
Option<int> ExpectedVersion = default)
|
||||
: IRequest<Either<BaseError, List<DecoTemplateItemViewModel>>>;
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public class ReplaceDecoTemplateItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
public class ReplaceDecoTemplateItemsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel)
|
||||
: IRequestHandler<ReplaceDecoTemplateItems, Either<BaseError, List<DecoTemplateItemViewModel>>>
|
||||
{
|
||||
public async Task<Either<BaseError, List<DecoTemplateItemViewModel>>> Handle(
|
||||
@@ -15,10 +20,19 @@ public class ReplaceDecoTemplateItemsHandler(IDbContextFactory<TvContext> dbCont
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, DecoTemplate> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => Persist(dbContext, request, ps, cancellationToken));
|
||||
|
||||
// Introduce the optimistic-concurrency check as a standalone Either AFTER the validation
|
||||
// pipeline (never via Apply) so a PreconditionFailedError survives to a 412 and is not
|
||||
// flattened to a generic 422 by Join() (issue #253 / api-conventions §7a).
|
||||
Either<BaseError, DecoTemplate> validated = LanguageExtensions.ToEither(validation)
|
||||
.Bind(decoTemplate => decoTemplate.CheckVersion(request.ExpectedVersion));
|
||||
|
||||
return await validated.Match(
|
||||
Right: decoTemplate => Persist(dbContext, request, decoTemplate, cancellationToken),
|
||||
Left: error => Task.FromResult<Either<BaseError, List<DecoTemplateItemViewModel>>>(error));
|
||||
}
|
||||
|
||||
private static async Task<List<DecoTemplateItemViewModel>> Persist(
|
||||
private async Task<Either<BaseError, List<DecoTemplateItemViewModel>>> Persist(
|
||||
TvContext dbContext,
|
||||
ReplaceDecoTemplateItems request,
|
||||
DecoTemplate decoTemplate,
|
||||
@@ -31,21 +45,49 @@ public class ReplaceDecoTemplateItemsHandler(IDbContextFactory<TvContext> dbCont
|
||||
|
||||
decoTemplate.Items = request.Items.Map(i => BuildItem(decoTemplate, i)).ToList();
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
// Unconditional bump: EF emits the root UPDATE only when a scalar actually differs, so a
|
||||
// same-value/no-op save would otherwise write no root row and neither fire the concurrency
|
||||
// token nor rotate other clients' ETags. Bumping guarantees both on every save (issue #253).
|
||||
decoTemplate.Version++;
|
||||
|
||||
// TODO: refresh any playouts that use this schedule
|
||||
// foreach (Playout playout in programSchedule.Playouts)
|
||||
// {
|
||||
// await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh));
|
||||
// }
|
||||
// Save through the guard so an EF concurrency failure (a racing writer won between our load
|
||||
// and save) maps to 412 rather than surfacing as a 500.
|
||||
Either<BaseError, Unit> saved = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
|
||||
return await saved.Match(
|
||||
Right: async _ =>
|
||||
{
|
||||
// Deco/break/default-filler content is only (re)applied during a Reset build (a Continue keeps
|
||||
// the frozen DecoDefault filler items), and BlockKey change-detection has no deco dimension, so
|
||||
// nothing self-heals a deco-template edit — the editor returned 200 but built filler stayed
|
||||
// stale until a manual Reset (#251). Enqueue a Reset for every playout that references this
|
||||
// deco template. This whole post-commit invalidation runs with CancellationToken.None (audit
|
||||
// #22 policy): once the edit is committed, a late request cancellation must not be able to
|
||||
// abort the affected-playout query OR the enqueue and leave content stale. Only runs after a
|
||||
// successful save (issue #253) — a 412/422 must not enqueue a Reset for content that was never
|
||||
// persisted.
|
||||
List<int> playoutIds = await dbContext.PlayoutTemplates
|
||||
.Where(pt => pt.DecoTemplateId == decoTemplate.Id)
|
||||
.Select(pt => pt.PlayoutId)
|
||||
.Distinct()
|
||||
.ToListAsync(CancellationToken.None);
|
||||
|
||||
await dbContext.Entry(decoTemplate)
|
||||
.Collection(t => t.Items)
|
||||
.Query()
|
||||
.Include(i => i.Deco)
|
||||
.LoadAsync(cancellationToken);
|
||||
foreach (int playoutId in playoutIds)
|
||||
{
|
||||
await channel.WriteAsync(
|
||||
new BuildPlayout(playoutId, PlayoutBuildMode.Reset),
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
return decoTemplate.Items.Map(Mapper.ProjectToViewModel).ToList();
|
||||
await dbContext.Entry(decoTemplate)
|
||||
.Collection(t => t.Items)
|
||||
.Query()
|
||||
.Include(i => i.Deco)
|
||||
.LoadAsync(cancellationToken);
|
||||
|
||||
return Right<BaseError, List<DecoTemplateItemViewModel>>(
|
||||
decoTemplate.Items.Map(Mapper.ProjectToViewModel).ToList());
|
||||
},
|
||||
Left: error => Task.FromResult(Left<BaseError, List<DecoTemplateItemViewModel>>(error)));
|
||||
}
|
||||
|
||||
private static DecoTemplateItem BuildItem(DecoTemplate decoTemplate, ReplaceDecoTemplateItem item) =>
|
||||
|
||||
@@ -2,5 +2,8 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public record ReplacePlayoutTemplateItems(int PlayoutId, List<ReplacePlayoutTemplate> Items)
|
||||
public record ReplacePlayoutTemplateItems(
|
||||
int PlayoutId,
|
||||
List<ReplacePlayoutTemplate> Items,
|
||||
Option<int> ExpectedVersion = default)
|
||||
: IRequest<Option<BaseError>>;
|
||||
|
||||
@@ -29,6 +29,17 @@ public class ReplacePlayoutTemplateItemsHandler(
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
// Optimistic-concurrency pre-check (issue #253 §7a): reject a stale If-Match with a
|
||||
// PreconditionFailedError (→ 412) before any mutation. Returned directly so it escapes the
|
||||
// catch(Exception) below as a 412 rather than being reshaped into a bare 422 (§9/H1).
|
||||
Either<BaseError, Playout> versionCheck = playout.CheckVersion(request.ExpectedVersion);
|
||||
if (versionCheck.IsLeft)
|
||||
{
|
||||
return versionCheck.Match<Option<BaseError>>(
|
||||
Left: error => error,
|
||||
Right: _ => Option<BaseError>.None);
|
||||
}
|
||||
|
||||
PlayoutTemplate[] existing = playout.Templates.ToArray();
|
||||
|
||||
List<ReplacePlayoutTemplate> incoming = request.Items;
|
||||
@@ -88,7 +99,20 @@ public class ReplacePlayoutTemplateItemsHandler(
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
// Unconditional bump (issue #253 §7a / M1): #8 writes no root scalar at all (only the
|
||||
// Templates child collection), so without this the root UPDATE never fires and the token
|
||||
// never rotates. Bump then save through the guard, which maps a racing
|
||||
// DbUpdateConcurrencyException to PreconditionFailedError (→ 412) as a return value.
|
||||
playout.Version++;
|
||||
|
||||
Either<BaseError, Unit> saveResult =
|
||||
await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
|
||||
if (saveResult.IsLeft)
|
||||
{
|
||||
return saveResult.Match<Option<BaseError>>(
|
||||
Left: error => error,
|
||||
Right: _ => Option<BaseError>.None);
|
||||
}
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
|
||||
@@ -2,5 +2,10 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public record ReplaceTemplateItems(int TemplateGroupId, int TemplateId, string Name, List<ReplaceTemplateItem> Items)
|
||||
public record ReplaceTemplateItems(
|
||||
int TemplateGroupId,
|
||||
int TemplateId,
|
||||
string Name,
|
||||
List<ReplaceTemplateItem> Items,
|
||||
Option<int> ExpectedVersion = default)
|
||||
: IRequest<Either<BaseError, List<TemplateItemViewModel>>>;
|
||||
|
||||
@@ -15,10 +15,19 @@ public class ReplaceTemplateItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Template> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => Persist(dbContext, request, ps, cancellationToken));
|
||||
|
||||
// Introduce the optimistic-concurrency check as a standalone Either AFTER the validation
|
||||
// pipeline (never via Apply) so a PreconditionFailedError survives to a 412 and is not
|
||||
// flattened to a generic 422 by Join() (issue #253 / api-conventions §7a).
|
||||
Either<BaseError, Template> validated = LanguageExtensions.ToEither(validation)
|
||||
.Bind(template => template.CheckVersion(request.ExpectedVersion));
|
||||
|
||||
return await validated.Match(
|
||||
Right: template => Persist(dbContext, request, template, cancellationToken),
|
||||
Left: error => Task.FromResult<Either<BaseError, List<TemplateItemViewModel>>>(error));
|
||||
}
|
||||
|
||||
private static async Task<List<TemplateItemViewModel>> Persist(
|
||||
private static async Task<Either<BaseError, List<TemplateItemViewModel>>> Persist(
|
||||
TvContext dbContext,
|
||||
ReplaceTemplateItems request,
|
||||
Template template,
|
||||
@@ -30,7 +39,10 @@ public class ReplaceTemplateItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
dbContext.RemoveRange(template.Items);
|
||||
template.Items = request.Items.Map(i => BuildItem(template, i)).ToList();
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
// Unconditional bump: EF emits the root UPDATE only when a scalar actually differs, so a
|
||||
// same-value/no-op save would otherwise write no root row and neither fire the concurrency
|
||||
// token nor rotate other clients' ETags. Bumping guarantees both on every save (issue #253).
|
||||
template.Version++;
|
||||
|
||||
// TODO: refresh any playouts that use this schedule
|
||||
// foreach (Playout playout in programSchedule.Playouts)
|
||||
@@ -38,13 +50,22 @@ public class ReplaceTemplateItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
// await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh));
|
||||
// }
|
||||
|
||||
await dbContext.Entry(template)
|
||||
.Collection(t => t.Items)
|
||||
.Query()
|
||||
.Include(i => i.Block)
|
||||
.LoadAsync(cancellationToken);
|
||||
// Save through the guard so an EF concurrency failure (a racing writer won between our load
|
||||
// and save) maps to 412 rather than surfacing as a 500.
|
||||
Either<BaseError, Unit> saved = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
|
||||
return await saved.Match(
|
||||
Right: async _ =>
|
||||
{
|
||||
await dbContext.Entry(template)
|
||||
.Collection(t => t.Items)
|
||||
.Query()
|
||||
.Include(i => i.Block)
|
||||
.LoadAsync(cancellationToken);
|
||||
|
||||
return template.Items.Map(Mapper.ProjectToViewModel).ToList();
|
||||
return Right<BaseError, List<TemplateItemViewModel>>(
|
||||
template.Items.Map(Mapper.ProjectToViewModel).ToList());
|
||||
},
|
||||
Left: error => Task.FromResult(Left<BaseError, List<TemplateItemViewModel>>(error)));
|
||||
}
|
||||
|
||||
private static TemplateItem BuildItem(Template template, ReplaceTemplateItem item) =>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
public class UpdateDecoHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel)
|
||||
: IRequestHandler<UpdateDeco, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(UpdateDeco request, CancellationToken cancellationToken)
|
||||
@@ -17,7 +22,7 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request, cancellationToken));
|
||||
}
|
||||
|
||||
private static async Task<Unit> ApplyUpdateRequest(
|
||||
private async Task<Unit> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
Deco existing,
|
||||
UpdateDeco request,
|
||||
@@ -177,6 +182,34 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Deco content (break/default filler/etc.) is only (re)applied during a Reset build, and BlockKey
|
||||
// change-detection has no deco dimension, so editing a deco left already-built filler/break content
|
||||
// stale until a manual Reset (#251). Enqueue a Reset for every playout that references this deco —
|
||||
// directly (Playout.DecoId) or through a deco template that includes it. This whole post-commit
|
||||
// invalidation runs with CancellationToken.None (audit #22 policy): once the edit is committed, a
|
||||
// late request cancellation must not be able to abort either the affected-playout queries OR the
|
||||
// enqueue and leave content stale — the entire side effect has to complete.
|
||||
List<int> directPlayoutIds = await dbContext.Playouts
|
||||
.Where(p => p.DecoId == request.DecoId)
|
||||
.Select(p => p.Id)
|
||||
.ToListAsync(CancellationToken.None);
|
||||
|
||||
List<int> decoTemplateIds = await dbContext.DecoTemplateItems
|
||||
.Where(i => i.DecoId == request.DecoId)
|
||||
.Select(i => i.DecoTemplateId)
|
||||
.Distinct()
|
||||
.ToListAsync(CancellationToken.None);
|
||||
|
||||
List<int> templatePlayoutIds = await dbContext.PlayoutTemplates
|
||||
.Where(pt => pt.DecoTemplateId.HasValue && decoTemplateIds.Contains(pt.DecoTemplateId.Value))
|
||||
.Select(pt => pt.PlayoutId)
|
||||
.ToListAsync(CancellationToken.None);
|
||||
|
||||
foreach (int playoutId in directPlayoutIds.Concat(templatePlayoutIds).Distinct())
|
||||
{
|
||||
await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Reset), CancellationToken.None);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public class UpdateDefaultDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
public class UpdateDefaultDecoHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel)
|
||||
: IRequestHandler<UpdateDefaultDeco, Option<BaseError>>
|
||||
{
|
||||
public async Task<Option<BaseError>> Handle(UpdateDefaultDeco request, CancellationToken cancellationToken)
|
||||
@@ -13,9 +18,29 @@ public class UpdateDefaultDecoHandler(IDbContextFactory<TvContext> dbContextFact
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
await dbContext.Playouts
|
||||
int updated = await dbContext.Playouts
|
||||
.Where(p => p.Id == request.PlayoutId)
|
||||
.ExecuteUpdateAsync(u => u.SetProperty(p => p.DecoId, p => request.DecoId), cancellationToken);
|
||||
.ExecuteUpdateAsync(
|
||||
u => u
|
||||
.SetProperty(p => p.DecoId, p => request.DecoId)
|
||||
// Bump the shared Playout.Version so any open alternate-schedule/template editor tab
|
||||
// (which round-trips this same token) detects the deco change and reloads (#253 §7a).
|
||||
// Bulk ExecuteUpdate bypasses change-tracking, so bump via SetProperty, not Version++.
|
||||
.SetProperty(p => p.Version, p => p.Version + 1),
|
||||
cancellationToken);
|
||||
|
||||
// Changing a playout's default deco only takes effect on a Reset build (deco content is applied
|
||||
// during Reset, and BlockKey has no deco dimension to self-heal) — same stale-until-Reset class
|
||||
// as #251. Enqueue a Reset for the affected playout, but only if a row was actually updated: a
|
||||
// request for a nonexistent playout must not queue a build for an id that isn't there (keeps the
|
||||
// enqueued set equal to the affected set). CancellationToken.None so the rebuild isn't dropped
|
||||
// after the assignment has already been persisted.
|
||||
if (updated > 0)
|
||||
{
|
||||
await channel.WriteAsync(
|
||||
new BuildPlayout(request.PlayoutId, PlayoutBuildMode.Reset),
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public record DecoTemplateViewModel(int Id, int DecoTemplateGroupId, string GroupName, string Name);
|
||||
public record DecoTemplateViewModel(int Id, int DecoTemplateGroupId, string GroupName, string Name, int Version);
|
||||
|
||||
@@ -32,7 +32,7 @@ internal static class Mapper
|
||||
new(blockGroup.Id, blockGroup.Name);
|
||||
|
||||
internal static BlockViewModel ProjectToViewModel(Block block) =>
|
||||
new(block.Id, block.BlockGroupId, block.BlockGroup.Name, block.Name, block.Minutes, block.StopScheduling);
|
||||
new(block.Id, block.BlockGroupId, block.BlockGroup.Name, block.Name, block.Minutes, block.StopScheduling, block.Version);
|
||||
|
||||
internal static BlockItemViewModel ProjectToViewModel(BlockItem blockItem) =>
|
||||
new(
|
||||
@@ -65,7 +65,7 @@ internal static class Mapper
|
||||
new(templateGroup.Id, templateGroup.Name, templateGroup.Templates.Count);
|
||||
|
||||
internal static TemplateViewModel ProjectToViewModel(Template template) =>
|
||||
new(template.Id, template.TemplateGroupId, template.TemplateGroup.Name, template.Name);
|
||||
new(template.Id, template.TemplateGroupId, template.TemplateGroup.Name, template.Name, template.Version);
|
||||
|
||||
internal static TemplateItemViewModel ProjectToViewModel(TemplateItem templateItem)
|
||||
{
|
||||
@@ -168,7 +168,8 @@ internal static class Mapper
|
||||
decoTemplate.Id,
|
||||
decoTemplate.DecoTemplateGroupId,
|
||||
decoTemplate.DecoTemplateGroup.Name,
|
||||
decoTemplate.Name);
|
||||
decoTemplate.Name,
|
||||
decoTemplate.Version);
|
||||
}
|
||||
|
||||
internal static DecoTemplateItemViewModel ProjectToViewModel(DecoTemplateItem decoTemplateItem)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public record TemplateViewModel(int Id, int TemplateGroupId, string GroupName, string Name);
|
||||
public record TemplateViewModel(int Id, int TemplateGroupId, string GroupName, string Name, int Version);
|
||||
|
||||
@@ -59,6 +59,9 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// track exactly the playouts this handler locked so the finally releases only those,
|
||||
// on every terminal path (cancellation early-return, swallowed cancellation, any exception).
|
||||
var lockedPlayoutIds = new List<int>();
|
||||
try
|
||||
{
|
||||
bool useEmbeddedSubtitles = await _configElementRepository
|
||||
@@ -124,7 +127,10 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa
|
||||
|
||||
foreach (int playoutId in playoutIdsToCheck)
|
||||
{
|
||||
await _entityLocker.LockPlayout(playoutId);
|
||||
if (await _entityLocker.LockPlayout(playoutId))
|
||||
{
|
||||
lockedPlayoutIds.Add(playoutId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck);
|
||||
@@ -179,16 +185,18 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa
|
||||
}
|
||||
|
||||
_logger.LogDebug("Done checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck);
|
||||
|
||||
foreach (int playoutId in playoutIdsToCheck)
|
||||
{
|
||||
await _entityLocker.UnlockPlayout(playoutId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (int playoutId in lockedPlayoutIds)
|
||||
{
|
||||
await _entityLocker.UnlockPlayout(playoutId);
|
||||
}
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
|
||||
+49
-17
@@ -52,6 +52,10 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
var currentStreamingLevel = loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel;
|
||||
loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = LogEventLevel.Debug;
|
||||
|
||||
// tracks whether THIS invocation owns the troubleshooting lock, so the catch/failure paths
|
||||
// never release a lock held by another session (e.g. an exception before we acquire)
|
||||
var lockAcquired = false;
|
||||
|
||||
try
|
||||
{
|
||||
using var logContext = LogContext.PushProperty(InMemoryLogService.CorrelationIdKey, request.SessionId);
|
||||
@@ -64,12 +68,14 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
return BaseError.New("Channel start is required");
|
||||
}
|
||||
|
||||
if (entityLocker.IsTroubleshootingPlaybackLocked())
|
||||
// acquire atomically: LockTroubleshootingPlayback returns false if another session
|
||||
// already holds it (no check-then-set race, no double-owner)
|
||||
if (!entityLocker.LockTroubleshootingPlayback())
|
||||
{
|
||||
return BaseError.New("Troubleshooting playback is locked");
|
||||
}
|
||||
|
||||
entityLocker.LockTroubleshootingPlayback();
|
||||
lockAcquired = true;
|
||||
statusStore.Reset();
|
||||
|
||||
localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder);
|
||||
@@ -108,6 +114,7 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
Option<double>.None),
|
||||
cancellationToken);
|
||||
entityLocker.UnlockTroubleshootingPlayback();
|
||||
lockAcquired = false;
|
||||
}
|
||||
|
||||
return result.Map(model => new PlayoutItemResult(
|
||||
@@ -119,6 +126,7 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
if (maybeChannel.IsNone)
|
||||
{
|
||||
entityLocker.UnlockTroubleshootingPlayback();
|
||||
lockAcquired = false;
|
||||
return BaseError.New($"Channel {request.ChannelId} does not exist");
|
||||
}
|
||||
}
|
||||
@@ -129,19 +137,46 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
request,
|
||||
cancellationToken);
|
||||
return await validation.Match(
|
||||
tuple => GetProcess(
|
||||
dbContext,
|
||||
request,
|
||||
tuple.Item1,
|
||||
tuple.Item2,
|
||||
tuple.Item3,
|
||||
tuple.Item4,
|
||||
cancellationToken),
|
||||
async tuple =>
|
||||
{
|
||||
// acquire only after validation succeeds; if another session already holds the lock,
|
||||
// report the conflict. Ownership is tracked so the catch never cross-releases.
|
||||
if (!entityLocker.LockTroubleshootingPlayback())
|
||||
{
|
||||
return BaseError.New("Troubleshooting playback is locked");
|
||||
}
|
||||
|
||||
lockAcquired = true;
|
||||
|
||||
Either<BaseError, PlayoutItemResult> processResult = await GetProcess(
|
||||
dbContext,
|
||||
request,
|
||||
tuple.Item1,
|
||||
tuple.Item2,
|
||||
tuple.Item3,
|
||||
tuple.Item4,
|
||||
cancellationToken);
|
||||
|
||||
// success hands the lock off to StartTroubleshootingPlayback; any Left releases it now
|
||||
if (processResult.IsLeft)
|
||||
{
|
||||
entityLocker.UnlockTroubleshootingPlayback();
|
||||
lockAcquired = false;
|
||||
}
|
||||
|
||||
return processResult;
|
||||
},
|
||||
error => Task.FromResult<Either<BaseError, PlayoutItemResult>>(error.Join()));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
entityLocker.UnlockTroubleshootingPlayback();
|
||||
// only release if THIS invocation still owns the lock; never release a lock held by another
|
||||
// session when the failure happened before we acquired (e.g. cancellation during validation)
|
||||
if (lockAcquired)
|
||||
{
|
||||
entityLocker.UnlockTroubleshootingPlayback();
|
||||
}
|
||||
|
||||
await mediator.Publish(
|
||||
new PlaybackTroubleshootingCompletedNotification(-1, ex, Option<double>.None),
|
||||
cancellationToken);
|
||||
@@ -163,12 +198,7 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
FFmpegProfile ffmpegProfile,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsTroubleshootingPlaybackLocked())
|
||||
{
|
||||
return BaseError.New("Troubleshooting playback is locked");
|
||||
}
|
||||
|
||||
entityLocker.LockTroubleshootingPlayback();
|
||||
// the troubleshooting lock is acquired and released by Handle; GetProcess only builds the process
|
||||
statusStore.Reset();
|
||||
|
||||
localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder);
|
||||
@@ -181,6 +211,8 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
string mediaPath = await GetMediaItemPath(dbContext, mediaItem, cancellationToken);
|
||||
if (string.IsNullOrEmpty(mediaPath))
|
||||
{
|
||||
// Handle releases the lock on this Left return, so troubleshooting isn't wedged
|
||||
// "running" forever for a file that's gone from disk
|
||||
logger.LogWarning("Media item {MediaItemId} does not exist on disk; cannot troubleshoot.", mediaItem.Id);
|
||||
return BaseError.New("Media item does not exist on disk");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record LocalLibraryDetailResponseModel(
|
||||
int Id,
|
||||
string Name,
|
||||
LibraryMediaKind MediaKind,
|
||||
bool IsLocked,
|
||||
int MediaItemCount,
|
||||
List<LocalLibraryPathResponseModel> Paths);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record LocalLibraryPathResponseModel(int Id, string Path, int MediaItemCount);
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record LocalLibraryResponseModel(int Id, string Name, LibraryMediaKind MediaKind, bool IsLocked);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record LocalPathCheckResponseModel(bool Exists);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record PathReplacementResponseModel(int Id, string RemotePath, string LocalPath);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record PlexPinFlowResponseModel(string AuthUrl);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user