Files
ersatztv/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs
T
timothyandClaude Opus 4.8 084d4c4ca1
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 8m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(locking): release troubleshooting + playout locks on all terminal paths (#233, #234)
Consume the EntityLocker ownership contract (#231/#241: Lock* returns true iff
this caller won the slot) at three lock-leak sites surfaced by adversarial-reviewer#20.

#233 (F3) — troubleshooting playback:
- PrepareTroubleshootingPlaybackHandler: both lock sites now acquire via
  `if (!LockTroubleshootingPlayback())` (kills the check-then-set TOCTOU) and the
  empty-media-path Left return releases the lock it acquired — previously it leaked,
  wedging the status endpoint at "running" forever for a file gone from disk.
- TroubleshootController.TroubleshootPlayback: lock conflict is now 409 ProblemDetails
  (was a bare 404, indistinguishable from a bad id); the Prepare-success -> enqueue
  window releases the lock if we never hand off to StartTroubleshootingPlayback.

#234 (F4 + F5.2) — playout builds:
- ExtractEmbeddedSubtitlesHandler: try/finally releases exactly the playouts it
  locked, on every terminal path (cancellation early-return, swallowed cancellation,
  any exception) — no more permanent leaks after cancelled mid-extraction, and no
  cross-release of playouts held by someone else.
- BuildPlayoutHandler: skips (logs, returns Right) when LockPlayout returns false
  instead of building unlocked and cross-releasing the other owner's lock in finally.

Tests: handler-level release-discipline tests (Prepare empty-path, Extract
cancellation + no-cross-release, BuildPlayout skip + finally-release) via the
InMemoryTvContext harness, a TroubleshootController 409 test, and OpenApi contract
cases for the m3u8 endpoint's 409. OpenAPI regenerated. All non-vacuous (F3 verified
against a negative control).

Fixes #233, #234

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:44:47 +02:00

204 lines
8.3 KiB
C#

using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Application.Maintenance;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Subtitles;
public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBase,
IRequestHandler<ExtractEmbeddedSubtitles, Option<BaseError>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
private readonly ILogger<ExtractEmbeddedSubtitlesHandler> _logger;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public ExtractEmbeddedSubtitlesHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
IEntityLocker entityLocker,
IConfigElementRepository configElementRepository,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
ILogger<ExtractEmbeddedSubtitlesHandler> logger)
: base(fileSystem, logger)
{
_dbContextFactory = dbContextFactory;
_entityLocker = entityLocker;
_configElementRepository = configElementRepository;
_workerChannel = workerChannel;
_logger = logger;
}
public async Task<Option<BaseError>> Handle(
ExtractEmbeddedSubtitles request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, string> validation = await FFmpegPathMustExist(dbContext, cancellationToken);
return await validation.Match(
async ffmpegPath =>
{
Option<BaseError> result = await ExtractAll(dbContext, request, ffmpegPath, cancellationToken);
await _workerChannel.WriteAsync(new ReleaseMemory(false), cancellationToken);
return result;
},
error => Task.FromResult<Option<BaseError>>(error.Join()));
}
private async Task<Option<BaseError>> ExtractAll(
TvContext dbContext,
ExtractEmbeddedSubtitles request,
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
.GetValue<bool>(ConfigElementKey.FFmpegUseEmbeddedSubtitles, cancellationToken)
.IfNoneAsync(true);
if (!useEmbeddedSubtitles)
{
_logger.LogDebug("Embedded subtitles are NOT enabled; nothing to extract");
return Option<BaseError>.None;
}
bool extractEmbeddedSubtitles = await _configElementRepository
.GetValue<bool>(ConfigElementKey.FFmpegExtractEmbeddedSubtitles, cancellationToken)
.IfNoneAsync(false);
if (!extractEmbeddedSubtitles)
{
_logger.LogDebug("Embedded subtitle extraction is NOT enabled");
return Option<BaseError>.None;
}
DateTime now = DateTime.UtcNow;
DateTime until = now.AddHours(1);
var playoutIdsToCheck = new List<int>();
// only check the requested playout if subtitles are enabled
Option<Playout> requestedPlayout = await dbContext.Playouts
.AsNoTracking()
.Filter(p => p.Channel.SubtitleMode != ChannelSubtitleMode.None ||
p.ProgramSchedule.Items.Any(psi =>
psi.SubtitleMode != null && psi.SubtitleMode != ChannelSubtitleMode.None))
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId.IfNone(-1), cancellationToken);
playoutIdsToCheck.AddRange(requestedPlayout.Map(p => p.Id));
// check all playouts (that have subtitles enabled) if none were passed
if (request.PlayoutId.IsNone)
{
playoutIdsToCheck = dbContext.Playouts
.AsNoTracking()
.Filter(p => p.Channel.SubtitleMode != ChannelSubtitleMode.None ||
p.ProgramSchedule.Items.Any(psi =>
psi.SubtitleMode != null && psi.SubtitleMode != ChannelSubtitleMode.None))
.Map(p => p.Id)
.ToList();
}
if (playoutIdsToCheck.Count == 0)
{
foreach (int playoutId in request.PlayoutId)
{
_logger.LogDebug(
"Playout {PlayoutId} does not have subtitles enabled; nothing to extract",
playoutId);
return Option<BaseError>.None;
}
_logger.LogDebug("No playouts have subtitles enabled; nothing to extract");
return Option<BaseError>.None;
}
foreach (int playoutId in playoutIdsToCheck)
{
if (await _entityLocker.LockPlayout(playoutId))
{
lockedPlayoutIds.Add(playoutId);
}
}
_logger.LogDebug("Checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck);
// find all playout items in the next hour
List<PlayoutItem> playoutItems = await dbContext.PlayoutItems
.AsNoTracking()
.Filter(pi => playoutIdsToCheck.Contains(pi.PlayoutId))
.Filter(pi => pi.Finish >= DateTime.UtcNow)
.Filter(pi => pi.Start <= until)
.ToListAsync(cancellationToken);
var mediaItemIds = playoutItems.Map(pi => pi.MediaItemId).ToList();
// filter for items with text subtitles or font attachments
List<int> mediaItemIdsWithTextSubtitles =
await GetMediaItemIdsWithTextSubtitles(dbContext, mediaItemIds, cancellationToken);
if (mediaItemIdsWithTextSubtitles.Count != 0)
{
_logger.LogDebug(
"Checking media items {MediaItemIds} for text subtitles or fonts to extract for playouts {PlayoutIds}",
mediaItemIdsWithTextSubtitles,
playoutIdsToCheck);
}
else
{
_logger.LogDebug(
"Found no text subtitles or fonts to extract for playouts {PlayoutIds}",
playoutIdsToCheck);
}
// sort by start time
var toUpdate = playoutItems
.Filter(pi => pi.Finish >= DateTime.UtcNow)
.DistinctBy(pi => pi.MediaItemId)
.Filter(pi => mediaItemIdsWithTextSubtitles.Contains(pi.MediaItemId))
.OrderBy(pi => pi.StartOffset)
.Map(pi => pi.MediaItemId)
.ToList();
foreach (int mediaItemId in toUpdate)
{
if (cancellationToken.IsCancellationRequested)
{
return Option<BaseError>.None;
}
// extract subtitles and fonts for each item and update db
await ExtractSubtitles(dbContext, mediaItemId, ffmpegPath, cancellationToken);
await ExtractFonts(dbContext, mediaItemId, ffmpegPath, cancellationToken);
}
_logger.LogDebug("Done checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck);
}
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;
}
}