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
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>
204 lines
8.3 KiB
C#
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;
|
|
}
|
|
}
|