diff --git a/ErsatzTV/Controllers/Api/TraktController.cs b/ErsatzTV/Controllers/Api/TraktController.cs index bd614afdb..ae919a4d1 100644 --- a/ErsatzTV/Controllers/Api/TraktController.cs +++ b/ErsatzTV/Controllers/Api/TraktController.cs @@ -76,13 +76,7 @@ public partial class TraktController( CreateProblemDetails(422, "Validation failed", "Invalid Trakt list url")); } - if (!entityLocker.LockTrakt()) - { - return ConflictProblem(); - } - - await workerChannel.WriteAsync(AddTraktList.FromUrl(request.Url), cancellationToken); - return new AcceptedResult(); + return await EnqueueWithTraktLock(AddTraktList.FromUrl(request.Url), cancellationToken); } [HttpPost("/api/trakt/lists/{id:int}/match")] @@ -100,13 +94,7 @@ public partial class TraktController( return ApiResults.NotFoundProblem(); } - if (!entityLocker.LockTrakt()) - { - return ConflictProblem(); - } - - await workerChannel.WriteAsync(new MatchTraktListItems(id), cancellationToken); - return new AcceptedResult(); + return await EnqueueWithTraktLock(new MatchTraktListItems(id), cancellationToken); } [HttpDelete("/api/trakt/lists/{id:int}")] @@ -124,13 +112,7 @@ public partial class TraktController( return ApiResults.NotFoundProblem(); } - if (!entityLocker.LockTrakt()) - { - return ConflictProblem(); - } - - await workerChannel.WriteAsync(new DeleteTraktList(id), cancellationToken); - return new AcceptedResult(); + return await EnqueueWithTraktLock(new DeleteTraktList(id), cancellationToken); } [HttpPut("/api/trakt/lists/{id:int}")] @@ -185,6 +167,30 @@ public partial class TraktController( viewModel.AutoRefresh, viewModel.GeneratePlaylist); + private async Task EnqueueWithTraktLock( + IBackgroundServiceRequest request, + CancellationToken cancellationToken) + { + if (!entityLocker.LockTrakt()) + { + return ConflictProblem(); + } + + try + { + await workerChannel.WriteAsync(request, cancellationToken); + } + catch + { + // the background handler only unlocks when it receives the message; + // if enqueueing fails (e.g. request aborted), release the lock here or it is held forever + entityLocker.UnlockTrakt(); + throw; + } + + return new AcceptedResult(); + } + private static ConflictObjectResult ConflictProblem() => new ConflictObjectResult( CreateProblemDetails( diff --git a/web/src/screens/TraktListsScreen.tsx b/web/src/screens/TraktListsScreen.tsx index 627c397cf..cae1a38c9 100644 --- a/web/src/screens/TraktListsScreen.tsx +++ b/web/src/screens/TraktListsScreen.tsx @@ -143,7 +143,15 @@ function AddTraktListDialog({ /* ---------- sub-path editor ---------- */ -function TraktListEditor({ id, onBack }: { id: number; onBack: () => void }) { +function TraktListEditor({ + id, + onBack, + onBackgroundMatch, +}: { + id: number; + onBack: () => void; + onBackgroundMatch: () => void; +}) { const [list, setList] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -191,6 +199,10 @@ function TraktListEditor({ id, onBack }: { id: number; onBack: () => void }) { const updated = await updateTraktList(id, { autoRefresh, generatePlaylist }); setList(updated); setSaved(true); + if (generatePlaylist) { + // saving with generatePlaylist enqueues a background match server-side + onBackgroundMatch(); + } } catch (updateError) { setSaveError(messageFromTraktError(updateError, 'Unable to save Trakt list')); } finally { @@ -309,7 +321,13 @@ export function TraktListsScreen() { const { busy, markBusy } = useTraktBusyPoll(load); if (editingId !== null) { - return navigateToPath(TRAKT_BASE_PATH)} />; + return ( + navigateToPath(TRAKT_BASE_PATH)} + onBackgroundMatch={markBusy} + /> + ); } const submitAdd = async (url: string) => {