diff --git a/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs index 95c14f813..7d43a83e9 100644 --- a/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs @@ -62,9 +62,20 @@ public class BuildPlayoutHandler : IRequestHandler> 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 diff --git a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs index 163bab787..857afd4d4 100644 --- a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs +++ b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs @@ -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(); 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.None; } diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index 94daedf0d..9bb32e29f 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -64,12 +64,13 @@ 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(); statusStore.Reset(); localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder); @@ -163,12 +164,11 @@ public class PrepareTroubleshootingPlaybackHandler( FFmpegProfile ffmpegProfile, CancellationToken cancellationToken) { - if (entityLocker.IsTroubleshootingPlaybackLocked()) + if (!entityLocker.LockTroubleshootingPlayback()) { return BaseError.New("Troubleshooting playback is locked"); } - entityLocker.LockTroubleshootingPlayback(); statusStore.Reset(); localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder); @@ -181,6 +181,9 @@ public class PrepareTroubleshootingPlaybackHandler( string mediaPath = await GetMediaItemPath(dbContext, mediaItem, cancellationToken); if (string.IsNullOrEmpty(mediaPath)) { + // this Left return bypasses the Handle-level catch; release the lock we just acquired + // so troubleshooting isn't wedged "running" forever for a file that's gone from disk + entityLocker.UnlockTroubleshootingPlayback(); logger.LogWarning("Media item {MediaItemId} does not exist on disk; cannot troubleshoot.", mediaItem.Id); return BaseError.New("Media item does not exist on disk"); } diff --git a/ErsatzTV.Tests/Application/Playouts/BuildPlayoutHandlerTests.cs b/ErsatzTV.Tests/Application/Playouts/BuildPlayoutHandlerTests.cs new file mode 100644 index 000000000..214f1e330 --- /dev/null +++ b/ErsatzTV.Tests/Application/Playouts/BuildPlayoutHandlerTests.cs @@ -0,0 +1,90 @@ +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Playouts; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Scheduling; +using ErsatzTV.Tests.Support; +using LanguageExt; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Channel = System.Threading.Channels.Channel; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.Playouts; + +[TestFixture] +public class BuildPlayoutHandlerTests +{ + private InMemoryTvContext _db = null!; + private ChannelWriter _worker = null!; + private IEntityLocker _entityLocker = null!; + private IPlayoutTimeShifter _timeShifter = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _worker = Channel.CreateUnbounded().Writer; + _entityLocker = Substitute.For(); + _timeShifter = Substitute.For(); + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + private BuildPlayoutHandler CreateHandler() => + new( + _db.Factory, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + _entityLocker, + _timeShifter, + _worker, + NullLogger.Instance); + + [Test] + public async Task Build_Should_Skip_And_Not_Cross_Release_When_Playout_Already_Locked() + { + // another operation (a concurrent build or the subtitle extractor) already holds the lock + _entityLocker.LockPlayout(5).Returns(false); + BuildPlayoutHandler handler = CreateHandler(); + + Either result = + await handler.Handle(new BuildPlayout(5, PlayoutBuildMode.Continue), CancellationToken.None); + + // skipped cleanly (no error surfaced to the caller) + result.IsRight.ShouldBeTrue(); + + // did not proceed to build the playout... + await _timeShifter.DidNotReceive() + .TimeShift(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + + // ...and crucially did not release the other owner's lock (no cross-release) + await _entityLocker.DidNotReceive().UnlockPlayout(5); + } + + [Test] + public async Task Build_Should_Release_Lock_When_Owned_Even_If_Validation_Fails() + { + // this caller wins the lock + _entityLocker.LockPlayout(999).Returns(true); + BuildPlayoutHandler handler = CreateHandler(); + + // playout 999 does not exist -> validation fails -> Left, but the finally must still release + Either result = + await handler.Handle(new BuildPlayout(999, PlayoutBuildMode.Reset), CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await _entityLocker.Received(1).UnlockPlayout(999); + } +} diff --git a/ErsatzTV.Tests/Application/Subtitles/ExtractEmbeddedSubtitlesHandlerTests.cs b/ErsatzTV.Tests/Application/Subtitles/ExtractEmbeddedSubtitlesHandlerTests.cs new file mode 100644 index 000000000..7696fb696 --- /dev/null +++ b/ErsatzTV.Tests/Application/Subtitles/ExtractEmbeddedSubtitlesHandlerTests.cs @@ -0,0 +1,155 @@ +using System.IO.Abstractions; +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Subtitles; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Channel = System.Threading.Channels.Channel; + +namespace ErsatzTV.Tests.Application.Subtitles; + +[TestFixture] +public class ExtractEmbeddedSubtitlesHandlerTests +{ + private InMemoryTvContext _db = null!; + private ChannelWriter _worker = null!; + private IEntityLocker _entityLocker = null!; + private IConfigElementRepository _configRepo = null!; + private readonly List _tempFiles = []; + private int _channelNumber = 100; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _worker = Channel.CreateUnbounded().Writer; + _entityLocker = Substitute.For(); + _configRepo = Substitute.For(); + + // embedded-subtitle extraction enabled (both feature flags on) + _configRepo.GetValue(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Option.Some(true))); + } + + [TearDown] + public async Task TearDown() + { + await _db.DisposeAsync(); + foreach (string tempFile in _tempFiles) + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + private ExtractEmbeddedSubtitlesHandler CreateHandler() => + new( + _db.Factory, + Substitute.For(), + _entityLocker, + _configRepo, + _worker, + NullLogger.Instance); + + // FFmpegPathMustExist reads ConfigElements + static File.Exists, so point ffmpeg at a real temp file + private async Task SeedFFmpegPath() + { + string tempFile = Path.GetTempFileName(); + _tempFiles.Add(tempFile); + await using TvContext context = _db.CreateContext(); + context.ConfigElements.Add(new ConfigElement { Key = ConfigElementKey.FFmpegPath.Key, Value = tempFile }); + await context.SaveChangesAsync(); + } + + private async Task SeedSubtitledPlayout() + { + int number = ++_channelNumber; + await using TvContext context = _db.CreateContext(); + var channel = new ErsatzTV.Core.Domain.Channel(Guid.NewGuid()) + { + Number = number.ToString(), + SortNumber = number, + Name = "Subs", + Group = string.Empty, + Categories = string.Empty, + StreamingMode = StreamingMode.HttpLiveStreamingSegmenter, + SubtitleMode = ChannelSubtitleMode.Any, + Playouts = [], + Artwork = [], + StreamSelector = string.Empty, + PreferredAudioLanguageCode = string.Empty, + PreferredAudioTitle = string.Empty, + PreferredSubtitleLanguageCode = string.Empty, + MusicVideoCreditsTemplate = string.Empty, + PlayoutSource = ChannelPlayoutSource.Generated, + PlayoutMode = ChannelPlayoutMode.Continuous, + IsEnabled = true, + ShowInEpg = true + }; + var playout = new Playout { Channel = channel }; + context.Playouts.Add(playout); + await context.SaveChangesAsync(); + return playout.Id; + } + + [Test] + public async Task Extract_Should_Release_All_Locks_On_Cancellation() + { + await SeedFFmpegPath(); + int playoutId = await SeedSubtitledPlayout(); + using var cts = new CancellationTokenSource(); + + // win the lock, but cancel as we do it so the next cancellable DB call throws mid-run — + // exercising the terminal path that used to skip the unlock loop + _entityLocker.LockPlayout(Arg.Any()).Returns(_ => + { + cts.Cancel(); + return Task.FromResult(true); + }); + + ExtractEmbeddedSubtitlesHandler handler = CreateHandler(); + + try + { + await handler.Handle(new ExtractEmbeddedSubtitles(Option.None), cts.Token); + } + catch (OperationCanceledException) + { + // expected: the post-extract ReleaseMemory write observes the cancelled token AFTER + // ExtractAll's finally has already released the locks (which is what we assert below) + } + + await _entityLocker.Received(1).UnlockPlayout(playoutId); + } + + [Test] + public async Task Extract_Should_Release_Only_Locks_It_Acquired() + { + await SeedFFmpegPath(); + int ownedPlayoutId = await SeedSubtitledPlayout(); + int otherOwnersPlayoutId = await SeedSubtitledPlayout(); + + // we win one lock; the other is already held by someone else (LockPlayout -> false) + _entityLocker.LockPlayout(ownedPlayoutId).Returns(true); + _entityLocker.LockPlayout(otherOwnersPlayoutId).Returns(false); + + ExtractEmbeddedSubtitlesHandler handler = CreateHandler(); + + // no playout items seeded -> nothing to extract -> normal completion through the finally + await handler.Handle(new ExtractEmbeddedSubtitles(Option.None), CancellationToken.None); + + await _entityLocker.Received(1).UnlockPlayout(ownedPlayoutId); + // must NOT release the lock we never acquired (no cross-release) + await _entityLocker.DidNotReceive().UnlockPlayout(otherOwnersPlayoutId); + } +} diff --git a/ErsatzTV.Tests/Application/Troubleshooting/PrepareTroubleshootingPlaybackHandlerTests.cs b/ErsatzTV.Tests/Application/Troubleshooting/PrepareTroubleshootingPlaybackHandlerTests.cs new file mode 100644 index 000000000..2ca1016e6 --- /dev/null +++ b/ErsatzTV.Tests/Application/Troubleshooting/PrepareTroubleshootingPlaybackHandlerTests.cs @@ -0,0 +1,155 @@ +using System.IO.Abstractions; +using ErsatzTV.Application.Troubleshooting; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.Core.Interfaces.Jellyfin; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Core.Troubleshooting; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using MediatR; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.Troubleshooting; + +[TestFixture] +public class PrepareTroubleshootingPlaybackHandlerTests +{ + private InMemoryTvContext _db = null!; + private IEntityLocker _entityLocker = null!; + private readonly List _tempFiles = []; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _entityLocker = Substitute.For(); + } + + [TearDown] + public async Task TearDown() + { + await _db.DisposeAsync(); + foreach (string tempFile in _tempFiles) + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Test] + public async Task GetProcess_Should_Release_Lock_When_Media_Not_On_Disk() + { + await SeedFFmpegConfig(); + int profileId = await SeedFFmpegProfile(); + // media item whose file is gone from disk -> GetMediaItemPath returns null + int movieId = await SeedMovieWithMissingFile(); + + // this caller wins the troubleshooting lock + _entityLocker.LockTroubleshootingPlayback().Returns(true); + + PrepareTroubleshootingPlaybackHandler handler = CreateHandler(); + + var request = new PrepareTroubleshootingPlayback( + Guid.NewGuid(), + StreamingMode.HttpLiveStreamingSegmenter, + movieId, + ChannelId: 0, + profileId, + StreamSelector: string.Empty, + WatermarkIds: [], + GraphicsElementIds: [], + SubtitleId: null, + SeekSeconds: Option.None, + Start: Option.None); + + Either result = await handler.Handle(request, CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + // the lock acquired at the top of GetProcess must be released on this early Left return, + // otherwise troubleshooting is wedged "running" forever (reviewer#20 F3) + _entityLocker.Received(1).UnlockTroubleshootingPlayback(); + } + + private PrepareTroubleshootingPlaybackHandler CreateHandler() + { + var fileSystem = Substitute.For(); + fileSystem.File.Exists(Arg.Any()).Returns(false); + + return new PrepareTroubleshootingPlaybackHandler( + _db.Factory, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + fileSystem, + Substitute.For(), + Substitute.For(), + Substitute.For(), + _entityLocker, + new TroubleshootingPlaybackStatusStore(), + Substitute.For(), + new LoggingLevelSwitches(), + NullLogger.Instance); + } + + private async Task SeedFFmpegConfig() + { + string ffmpeg = Path.GetTempFileName(); + string ffprobe = Path.GetTempFileName(); + _tempFiles.Add(ffmpeg); + _tempFiles.Add(ffprobe); + await using TvContext context = _db.CreateContext(); + context.ConfigElements.Add(new ConfigElement { Key = ConfigElementKey.FFmpegPath.Key, Value = ffmpeg }); + context.ConfigElements.Add(new ConfigElement { Key = ConfigElementKey.FFprobePath.Key, Value = ffprobe }); + await context.SaveChangesAsync(); + } + + private async Task SeedFFmpegProfile() + { + await using TvContext context = _db.CreateContext(); + var profile = new FFmpegProfile + { + Name = "Test", + Resolution = new Resolution { Name = "1080p", Width = 1920, Height = 1080 } + }; + context.FFmpegProfiles.Add(profile); + await context.SaveChangesAsync(); + return profile.Id; + } + + private async Task SeedMovieWithMissingFile() + { + await using TvContext context = _db.CreateContext(); + var movie = new Movie + { + MediaVersions = + [ + new MediaVersion + { + Name = "Main", + Duration = TimeSpan.FromMinutes(42), + MediaFiles = [new MediaFile { Path = "/gone/movie.mkv", PathHash = "gone" }], + Streams = [] + } + ], + MovieMetadata = + [ + new MovieMetadata { Title = "Gone", SortTitle = "Gone", Subtitles = [] } + ] + }; + context.Movies.Add(movie); + await context.SaveChangesAsync(); + return movie.Id; + } +} diff --git a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs index f4de94901..2e0c8638a 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs @@ -270,6 +270,8 @@ public class OpenApiErrorResponseContractTests [TestCase("/api/trakt/lists/{id}", "put", "404")] [TestCase("/api/trakt/lists/{id}", "put", "422")] [TestCase("/api/troubleshoot/playback/subtitles/{mediaItemId}", "get", "404")] + [TestCase("/api/troubleshoot/playback.m3u8", "get", "409")] + [TestCase("/api/troubleshoot/playback.m3u8", "head", "409")] [TestCase("/api/libraries/{id}/scan-show", "post", "404")] [TestCase("/api/libraries/{id}/scan", "post", "404")] [TestCase("/api/libraries/{id}/scan", "post", "409")] diff --git a/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs b/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs index 305a0e1b1..e04ac59cf 100644 --- a/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/TroubleshootControllerTests.cs @@ -248,6 +248,32 @@ public class TroubleshootControllerTests attribute.Name.ShouldBe("GetTroubleshootingPlaybackStatus"); } + [Test] + public async Task TroubleshootPlayback_Should_Return_409_When_Already_Locked() + { + _entityLocker.IsTroubleshootingPlaybackLocked().Returns(true); + + IActionResult result = await _controller.TroubleshootPlayback( + mediaItem: 1, + channel: 0, + ffmpegProfile: 1, + StreamingMode.HttpLiveStreamingSegmenter, + watermark: [], + graphicsElement: [], + streamSelector: string.Empty, + subtitleId: null, + seekSeconds: 0, + start: null, + CancellationToken.None); + + ConflictObjectResult conflict = result.ShouldBeOfType(); + conflict.Value.ShouldBeOfType().Status.ShouldBe(409); + + // pre-check short-circuits before dispatching any work + await _mediator.DidNotReceive() + .Send(Arg.Any(), Arg.Any()); + } + [Test] public async Task GetPlaybackStatus_Should_Report_Idle_When_No_Result_And_Unlocked() { diff --git a/ErsatzTV/Controllers/Api/TroubleshootController.cs b/ErsatzTV/Controllers/Api/TroubleshootController.cs index 61de653eb..129c077f5 100644 --- a/ErsatzTV/Controllers/Api/TroubleshootController.cs +++ b/ErsatzTV/Controllers/Api/TroubleshootController.cs @@ -114,6 +114,7 @@ public class TroubleshootController( [Tags("Troubleshooting")] [EndpointSummary("Start a troubleshooting playback session")] [EndpointGroupName("general")] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)] public async Task TroubleshootPlayback( [FromQuery] int mediaItem, @@ -140,8 +141,22 @@ public class TroubleshootController( var sessionId = Guid.NewGuid(); using var logContext = LogContext.PushProperty(InMemoryLogService.CorrelationIdKey, sessionId); + // acquiredLock: the handler took the troubleshooting lock on a successful Prepare. + // startEnqueued: we handed off to StartTroubleshootingPlayback, which becomes the lock's releaser. + var acquiredLock = false; + var startEnqueued = false; + try { + // fast lock-conflict signal so the client gets a 409 (not a 404) while another + // troubleshooting session is active; the atomic acquire in the handler is the real guard + if (entityLocker.IsTroubleshootingPlaybackLocked()) + { + return ApiResults.ConflictProblem( + "Troubleshooting playback in progress", + "Another troubleshooting playback session is currently running. Try again once it completes."); + } + Option ss = seekSeconds > 0 ? seekSeconds : Option.None; Either result = await mediator.Send( @@ -164,6 +179,9 @@ public class TroubleshootController( return NotFound(); } + // Prepare returned a process, so the handler holds the troubleshooting lock now + acquiredLock = true; + foreach (PlayoutItemResult playoutItemResult in result.RightToSeq()) { Either maybeMediaInfo = @@ -192,6 +210,9 @@ public class TroubleshootController( troubleshootingInfo), cancellationToken); + // StartTroubleshootingPlayback is now responsible for releasing the lock in its finally + startEnqueued = true; + string playlistFile = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "live.m3u8"); while (!fileSystem.File.Exists(playlistFile)) { @@ -242,6 +263,15 @@ public class TroubleshootController( { // do nothing } + finally + { + // the handler acquired the lock but we never handed off to the worker + // (cancellation/exception in the window before enqueue) -> release it so it doesn't leak + if (acquiredLock && !startEnqueued) + { + entityLocker.UnlockTroubleshootingPlayback(); + } + } return NotFound(); } diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 6657b3bf5..9cdc5a6ca 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -13483,8 +13483,25 @@ } ], "responses": { - "200": { - "description": "OK" + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } }, @@ -13580,8 +13597,25 @@ } ], "responses": { - "200": { - "description": "OK" + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } }