diff --git a/CHANGELOG.md b/CHANGELOG.md index 27cf436c8..6b0c12532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,7 +66,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix slow startup caused by check for overlapping playout items - Fix green line in *most* cases when overlaying content using NVIDIA acceleration and H264 output - Fix non-SRT (e.g. SSA/ASS) external subtitle playback from media servers -- Clean up some invalid external subtitle data to prevent playback failures +- Fix extracted text subtitle playback from media servers +- Fix extracted text subtitles getting into invalid state after media server deep scans + - Targeted deep scans will now extract text subtitles for the scanned show ### Changed - Filler presets: use separate text fields for `hours`, `minutes` and `seconds` duration diff --git a/ErsatzTV.Application/Libraries/Commands/QueueShowScanByLibraryIdHandler.cs b/ErsatzTV.Application/Libraries/Commands/QueueShowScanByLibraryIdHandler.cs index b96d52f3a..281606797 100644 --- a/ErsatzTV.Application/Libraries/Commands/QueueShowScanByLibraryIdHandler.cs +++ b/ErsatzTV.Application/Libraries/Commands/QueueShowScanByLibraryIdHandler.cs @@ -1,6 +1,8 @@ +using System.Threading.Channels; using ErsatzTV.Application.Emby; using ErsatzTV.Application.Jellyfin; using ErsatzTV.Application.Plex; +using ErsatzTV.Application.Subtitles; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Locking; @@ -15,6 +17,7 @@ public class QueueShowScanByLibraryIdHandler( IDbContextFactory dbContextFactory, IEntityLocker locker, IMediator mediator, + ChannelWriter workerChannel, ILogger logger) : IRequestHandler { @@ -57,30 +60,41 @@ public class QueueShowScanByLibraryIdHandler( try { + var success = false; switch (library) { case PlexLibrary: Either plexResult = await mediator.Send( new SynchronizePlexShowById(library.Id, request.ShowId, request.DeepScan), cancellationToken); - return plexResult.IsRight; + success = plexResult.IsRight; + break; case JellyfinLibrary: Either jellyfinResult = await mediator.Send( new SynchronizeJellyfinShowById(library.Id, request.ShowId, request.DeepScan), cancellationToken); - return jellyfinResult.IsRight; + success = jellyfinResult.IsRight; + break; case EmbyLibrary: Either embyResult = await mediator.Send( new SynchronizeEmbyShowById(library.Id, request.ShowId, request.DeepScan), cancellationToken); - return embyResult.IsRight; + success = embyResult.IsRight; + break; case LocalLibrary: logger.LogWarning("Single show scanning is not supported for local libraries"); - return false; + break; default: logger.LogWarning("Unknown library type for library {Id}", library.Id); - return false; + break; } + + if (success && request.DeepScan) + { + await workerChannel.WriteAsync(new ExtractEmbeddedShowSubtitles(request.ShowId), cancellationToken); + } + + return success; } finally { diff --git a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedShowSubtitles.cs b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedShowSubtitles.cs new file mode 100644 index 000000000..f94dabe01 --- /dev/null +++ b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedShowSubtitles.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.Subtitles; + +public record ExtractEmbeddedShowSubtitles(int ShowId) : IBackgroundServiceRequest, IRequest>; diff --git a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedShowSubtitlesHandler.cs b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedShowSubtitlesHandler.cs new file mode 100644 index 000000000..4cc7293d7 --- /dev/null +++ b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedShowSubtitlesHandler.cs @@ -0,0 +1,125 @@ +using System.Threading.Channels; +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Dapper; +using ErsatzTV.Application.Maintenance; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; + +namespace ErsatzTV.Application.Subtitles; + +public class ExtractEmbeddedShowSubtitlesHandler( + IDbContextFactory dbContextFactory, + ChannelWriter workerChannel, + IConfigElementRepository configElementRepository, + ILocalFileSystem localFileSystem, + ILogger logger) + : ExtractEmbeddedSubtitlesHandlerBase(localFileSystem, logger), + IRequestHandler> +{ + public async Task> Handle( + ExtractEmbeddedShowSubtitles request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + Validation validation = await FFmpegPathMustExist(dbContext, cancellationToken); + return await validation.Match( + async ffmpegPath => + { + Option result = await ExtractAll(dbContext, request, ffmpegPath, cancellationToken); + await workerChannel.WriteAsync(new ReleaseMemory(false), cancellationToken); + return result; + }, + error => Task.FromResult>(error.Join())); + } + + private async Task> ExtractAll( + TvContext dbContext, + ExtractEmbeddedShowSubtitles request, + string ffmpegPath, + CancellationToken cancellationToken) + { + try + { + bool useEmbeddedSubtitles = await configElementRepository + .GetValue(ConfigElementKey.FFmpegUseEmbeddedSubtitles, cancellationToken) + .IfNoneAsync(true); + + if (!useEmbeddedSubtitles) + { + return Option.None; + } + + bool extractEmbeddedSubtitles = await configElementRepository + .GetValue(ConfigElementKey.FFmpegExtractEmbeddedSubtitles, cancellationToken) + .IfNoneAsync(false); + + if (!extractEmbeddedSubtitles) + { + return Option.None; + } + + Option maybeShow = await dbContext.Shows + .AsNoTracking() + .Include(s => s.ShowMetadata) + .SelectOneAsync(s => s.Id, s => s.Id == request.ShowId, cancellationToken); + foreach (ShowMetadata showMetadata in maybeShow.SelectMany(s => s.ShowMetadata).HeadOrNone()) + { + logger.LogDebug("Checking show {ShowTitle} for text subtitles to extract", showMetadata.Title); + + // check for episodes with not-extracted subtitles + List episodeIds = await dbContext.Connection.QueryAsync( + """ + SELECT DISTINCT E.Id + FROM Episode E + INNER JOIN Season S ON S.Id = E.SeasonId + INNER JOIN EpisodeMetadata EM ON EM.EpisodeId = E.Id + INNER JOIN Subtitle SUB ON SUB.EpisodeMetadataId = EM.Id + WHERE S.ShowId = @ShowId AND SUB.SubtitleKind=0 AND (SUB.IsExtracted=0 OR SUB.Path IS NULL) + """, + new { request.ShowId }) + .Map(result => result.ToList()); + + // filter for items with text subtitles or font attachments + List episodeIdsWithTextSubtitles = + await GetMediaItemIdsWithTextSubtitles(dbContext, episodeIds, cancellationToken); + + logger.LogDebug( + "Show {ShowTitle} has {EpisodeCount} episodes with text subtitles to extract", + showMetadata.Title, + episodeIdsWithTextSubtitles.Count); + + for (var i = 0; i < episodeIdsWithTextSubtitles.Count; i++) + { + logger.LogDebug( + "Extracting text subtitles for show {ShowTitle} - {Current} of {Total}", + showMetadata.Title, + i + 1, + episodeIdsWithTextSubtitles.Count); + + if (cancellationToken.IsCancellationRequested) + { + return Option.None; + } + + // extract subtitles and fonts for each item and update db + int mediaItemId = episodeIdsWithTextSubtitles[i]; + await ExtractSubtitles(dbContext, mediaItemId, ffmpegPath, cancellationToken); + await ExtractFonts(dbContext, mediaItemId, ffmpegPath, cancellationToken); + } + + logger.LogDebug("Done checking show {ShowTitle} for text subtitles to extract", showMetadata.Title); + } + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + // do nothing + } + + return Option.None; + } +} diff --git a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs index 047374a35..4e39a81a2 100644 --- a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs +++ b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs @@ -1,15 +1,7 @@ -using System.Diagnostics.CodeAnalysis; -using System.Security.Cryptography; -using System.Text; -using System.Threading.Channels; -using CliWrap; -using CliWrap.Buffered; -using CliWrap.Builders; -using Dapper; +using System.Threading.Channels; using ErsatzTV.Application.Maintenance; using ErsatzTV.Core; using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; @@ -20,13 +12,12 @@ using Microsoft.Extensions.Logging; namespace ErsatzTV.Application.Subtitles; -[SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")] -public class ExtractEmbeddedSubtitlesHandler : IRequestHandler> +public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBase, + IRequestHandler> { private readonly IConfigElementRepository _configElementRepository; private readonly IDbContextFactory _dbContextFactory; private readonly IEntityLocker _entityLocker; - private readonly ILocalFileSystem _localFileSystem; private readonly ILogger _logger; private readonly ChannelWriter _workerChannel; @@ -37,9 +28,9 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler workerChannel, ILogger logger) + : base(localFileSystem, logger) { _dbContextFactory = dbContextFactory; - _localFileSystem = localFileSystem; _entityLocker = entityLocker; _configElementRepository = configElementRepository; _workerChannel = workerChannel; @@ -201,337 +192,4 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler.None; } - - private static async Task> GetMediaItemIdsWithTextSubtitles( - TvContext dbContext, - List mediaItemIds, - CancellationToken cancellationToken) - { - var result = new List(); - - try - { - List episodeIds = await dbContext.EpisodeMetadata - .AsNoTracking() - .Filter(em => mediaItemIds.Contains(em.EpisodeId)) - .Filter(em => em.Subtitles.Any(s => s.SubtitleKind == SubtitleKind.Embedded && - s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && - s.Codec != "dvdsub" && - s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs")) - .Map(em => em.EpisodeId) - .ToListAsync(cancellationToken); - result.AddRange(episodeIds); - - List movieIds = await dbContext.MovieMetadata - .AsNoTracking() - .Filter(mm => mediaItemIds.Contains(mm.MovieId)) - .Filter(mm => mm.Subtitles.Any(s => s.SubtitleKind == SubtitleKind.Embedded && - s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && - s.Codec != "dvdsub" && - s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs")) - .Map(mm => mm.MovieId) - .ToListAsync(cancellationToken); - result.AddRange(movieIds); - - List musicVideoIds = await dbContext.MusicVideoMetadata - .AsNoTracking() - .Filter(mm => mediaItemIds.Contains(mm.MusicVideoId)) - .Filter(mm => mm.Subtitles.Any(s => s.SubtitleKind == SubtitleKind.Embedded && - s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && - s.Codec != "dvdsub" && - s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs")) - .Map(mm => mm.MusicVideoId) - .ToListAsync(cancellationToken); - result.AddRange(musicVideoIds); - - List otherVideoIds = await dbContext.OtherVideoMetadata - .AsNoTracking() - .Filter(ovm => mediaItemIds.Contains(ovm.OtherVideoId)) - .Filter(ovm => ovm.Subtitles.Any(s => s.SubtitleKind == SubtitleKind.Embedded && - s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && - s.Codec != "dvdsub" && - s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs")) - .Map(ovm => ovm.OtherVideoId) - .ToListAsync(cancellationToken); - result.AddRange(otherVideoIds); - } - catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) - { - // do nothing - } - - return result; - } - - private async Task ExtractSubtitles( - TvContext dbContext, - int mediaItemId, - string ffmpegPath, - CancellationToken cancellationToken) - { - foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId, cancellationToken)) - { - foreach (List allSubtitles in GetSubtitles(mediaItem)) - { - var subtitlesToExtract = new List(); - - // find each subtitle that needs extraction - IEnumerable subtitles = allSubtitles - .Filter(s => s.SubtitleKind == SubtitleKind.Embedded) - .Filter(s => s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && s.Codec != "dvdsub" && - s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs") - .Filter(s => !s.IsExtracted || string.IsNullOrWhiteSpace(s.Path) || - FileDoesntExist(mediaItem.Id, s)); - - // find cache paths for each subtitle - foreach (Subtitle subtitle in subtitles) - { - Option maybePath = GetRelativeOutputPath(mediaItem.Id, subtitle); - foreach (string path in maybePath) - { - subtitlesToExtract.Add(new SubtitleToExtract(subtitle, path)); - } - } - - if (subtitlesToExtract.Count == 0) - { - continue; - } - - string mediaItemPath = await GetMediaItemPath(dbContext, mediaItem); - - ArgumentsBuilder args = new ArgumentsBuilder() - .Add("-nostdin") - .Add("-hide_banner") - .Add("-i").Add(mediaItemPath); - - foreach (SubtitleToExtract subtitle in subtitlesToExtract) - { - string fullOutputPath = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.OutputPath); - Directory.CreateDirectory(Path.GetDirectoryName(fullOutputPath)); - if (_localFileSystem.FileExists(fullOutputPath)) - { - File.Delete(fullOutputPath); - } - - args.Add("-map").Add($"0:{subtitle.Subtitle.StreamIndex}") - .Add("-c:s").Add(subtitle.Subtitle.Codec == "mov_text" ? "text" : "copy") - .Add(fullOutputPath); - } - - BufferedCommandResult result = await Cli.Wrap(ffmpegPath) - .WithArguments(args.Build()) - .WithValidation(CommandResultValidation.None) - .ExecuteBufferedAsync(cancellationToken); - - if (result.ExitCode == 0) - { - foreach (SubtitleToExtract subtitle in subtitlesToExtract) - { - await dbContext.Connection.ExecuteAsync( - "UPDATE `Subtitle` SET `IsExtracted` = 1, `Path` = @Path WHERE `Id` = @SubtitleId", - new { SubtitleId = subtitle.Subtitle.Id, Path = subtitle.OutputPath }); - } - - _logger.LogDebug("Successfully extracted {Count} subtitles", subtitlesToExtract.Count); - } - else - { - _logger.LogError("Failed to extract subtitles. {Error}", result.StandardError); - } - } - } - } - - private bool FileDoesntExist(int mediaItemId, Subtitle subtitle) - { - foreach (string path in GetRelativeOutputPath(mediaItemId, subtitle)) - { - return !_localFileSystem.FileExists(path); - } - - return false; - } - - private static async Task> GetMediaItem( - TvContext dbContext, - int mediaItemId, - CancellationToken cancellationToken) => - await dbContext.MediaItems - .AsNoTracking() - .Include(mi => (mi as Episode).MediaVersions) - .ThenInclude(mv => mv.MediaFiles) - .Include(mi => (mi as Episode).MediaVersions) - .ThenInclude(mv => mv.Streams) - .Include(mi => (mi as Episode).EpisodeMetadata) - .ThenInclude(em => em.Subtitles) - .Include(mi => (mi as Movie).MediaVersions) - .ThenInclude(mv => mv.MediaFiles) - .Include(mi => (mi as Movie).MediaVersions) - .ThenInclude(mv => mv.Streams) - .Include(mi => (mi as Movie).MovieMetadata) - .ThenInclude(em => em.Subtitles) - .Include(mi => (mi as MusicVideo).MediaVersions) - .ThenInclude(mv => mv.MediaFiles) - .Include(mi => (mi as MusicVideo).MediaVersions) - .ThenInclude(mv => mv.Streams) - .Include(mi => (mi as MusicVideo).MusicVideoMetadata) - .ThenInclude(em => em.Subtitles) - .Include(mi => (mi as OtherVideo).MediaVersions) - .ThenInclude(mv => mv.MediaFiles) - .Include(mi => (mi as OtherVideo).MediaVersions) - .ThenInclude(mv => mv.Streams) - .Include(mi => (mi as OtherVideo).OtherVideoMetadata) - .ThenInclude(em => em.Subtitles) - .SelectOneAsync(e => e.Id, e => e.Id == mediaItemId, cancellationToken); - - private static Option> GetSubtitles(MediaItem mediaItem) => - mediaItem switch - { - Episode e => e.EpisodeMetadata.Head().Subtitles, - Movie m => m.MovieMetadata.Head().Subtitles, - MusicVideo mv => mv.MusicVideoMetadata.Head().Subtitles, - OtherVideo ov => ov.OtherVideoMetadata.Head().Subtitles, - _ => None - }; - - private async Task ExtractFonts( - TvContext dbContext, - int mediaItemId, - string ffmpegPath, - CancellationToken cancellationToken) - { - foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId, cancellationToken)) - { - MediaVersion headVersion = mediaItem.GetHeadVersion(); - var attachments = headVersion.Streams - .Filter(s => s.MediaStreamKind == MediaStreamKind.Attachment) - .OrderBy(s => s.Index) - .ToList(); - - for (var attachmentIndex = 0; attachmentIndex < attachments.Count; attachmentIndex++) - { - MediaStream fontStream = attachments[attachmentIndex]; - - if (!(fontStream.MimeType ?? string.Empty).Contains("font") && - !(fontStream.MimeType ?? string.Empty).Contains("opentype")) - { - // not a font - continue; - } - - string fullOutputPath = Path.Combine(FileSystemLayout.FontsCacheFolder, fontStream.FileName); - if (_localFileSystem.FileExists(fullOutputPath)) - { - // already extracted - continue; - } - - string mediaItemPath = await GetMediaItemPath(dbContext, mediaItem); - - var arguments = - $"-nostdin -hide_banner -dump_attachment:t:{attachmentIndex} \"\" -i \"{mediaItemPath}\" -y"; - - BufferedCommandResult result = await Cli.Wrap(ffmpegPath) - .WithWorkingDirectory(FileSystemLayout.FontsCacheFolder) - .WithArguments(arguments) - .WithValidation(CommandResultValidation.None) - .ExecuteBufferedAsync(cancellationToken); - - // ffmpeg seems to return exit code 1 in all cases when dumping an attachment - // so ignore it and check success a different way - if (_localFileSystem.FileExists(fullOutputPath)) - { - _logger.LogDebug("Successfully extracted font {Font}", fontStream.FileName); - } - else - { - _logger.LogError( - "Failed to extract attached font {Font}. {Error}", - fontStream.FileName, - result.StandardError); - } - } - } - } - - private static Task> FFmpegPathMustExist( - TvContext dbContext, - CancellationToken cancellationToken) => - dbContext.ConfigElements.GetValue(ConfigElementKey.FFmpegPath, cancellationToken) - .FilterT(File.Exists) - .Map(maybePath => maybePath.ToValidation("FFmpeg path does not exist on filesystem")); - - private static Option GetRelativeOutputPath(int mediaItemId, Subtitle subtitle) - { - string name = GetStringHash($"{mediaItemId}_{subtitle.StreamIndex}_{subtitle.Codec}"); - string subfolder = name[..2]; - string subfolder2 = name[2..4]; - - string nameWithExtension = subtitle.Codec switch - { - "subrip" or "srt" => $"{name}.srt", - "ass" => $"{name}.ass", - "webvtt" => $"{name}.vtt", - "mov_text" => $"{name}.srt", - _ => string.Empty - }; - - if (string.IsNullOrWhiteSpace(nameWithExtension)) - { - return None; - } - - return Path.Combine(subfolder, subfolder2, nameWithExtension); - } - - private static async Task GetMediaItemPath(TvContext dbContext, MediaItem mediaItem) - { - MediaVersion version = mediaItem.GetHeadVersion(); - - MediaFile file = version.MediaFiles.Head(); - switch (file) - { - case PlexMediaFile pmf: - Option maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync( - @"SELECT PMS.Id FROM PlexMediaSource PMS - INNER JOIN Library L on PMS.Id = L.MediaSourceId - INNER JOIN LibraryPath LP on L.Id = LP.LibraryId - WHERE LP.Id = @LibraryPathId", - new { mediaItem.LibraryPathId }) - .Map(Optional); - - foreach (int plexMediaSourceId in maybeId) - { - return $"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}"; - } - - break; - } - - return mediaItem switch - { - JellyfinMovie jellyfinMovie => - $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{jellyfinMovie.ItemId}", - JellyfinEpisode jellyfinEpisode => - $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{jellyfinEpisode.ItemId}", - EmbyMovie embyMovie => $"http://localhost:{Settings.StreamingPort}/media/emby/{embyMovie.ItemId}", - EmbyEpisode embyEpisode => $"http://localhost:{Settings.StreamingPort}/media/emby/{embyEpisode.ItemId}", - _ => file.Path - }; - } - - private static string GetStringHash(string text) - { - if (string.IsNullOrEmpty(text)) - { - return string.Empty; - } - - byte[] textData = Encoding.UTF8.GetBytes(text); - byte[] hash = MD5.HashData(textData); - return Convert.ToHexString(hash); - } - - private sealed record SubtitleToExtract(Subtitle Subtitle, string OutputPath); } diff --git a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs new file mode 100644 index 000000000..2cf895b77 --- /dev/null +++ b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs @@ -0,0 +1,355 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using System.Text; +using CliWrap; +using CliWrap.Buffered; +using CliWrap.Builders; +using Dapper; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Application.Subtitles; + +[SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")] +public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem localFileSystem, ILogger logger) +{ + protected static Task> FFmpegPathMustExist( + TvContext dbContext, + CancellationToken cancellationToken) => + dbContext.ConfigElements.GetValue(ConfigElementKey.FFmpegPath, cancellationToken) + .FilterT(File.Exists) + .Map(maybePath => maybePath.ToValidation("FFmpeg path does not exist on filesystem")); + + protected static async Task> GetMediaItemIdsWithTextSubtitles( + TvContext dbContext, + List mediaItemIds, + CancellationToken cancellationToken) + { + var result = new List(); + + try + { + List episodeIds = await dbContext.EpisodeMetadata + .AsNoTracking() + .Filter(em => mediaItemIds.Contains(em.EpisodeId)) + .Filter(em => em.Subtitles.Any(s => s.SubtitleKind == SubtitleKind.Embedded && + s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && + s.Codec != "dvdsub" && + s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs")) + .Map(em => em.EpisodeId) + .ToListAsync(cancellationToken); + result.AddRange(episodeIds); + + List movieIds = await dbContext.MovieMetadata + .AsNoTracking() + .Filter(mm => mediaItemIds.Contains(mm.MovieId)) + .Filter(mm => mm.Subtitles.Any(s => s.SubtitleKind == SubtitleKind.Embedded && + s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && + s.Codec != "dvdsub" && + s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs")) + .Map(mm => mm.MovieId) + .ToListAsync(cancellationToken); + result.AddRange(movieIds); + + List musicVideoIds = await dbContext.MusicVideoMetadata + .AsNoTracking() + .Filter(mm => mediaItemIds.Contains(mm.MusicVideoId)) + .Filter(mm => mm.Subtitles.Any(s => s.SubtitleKind == SubtitleKind.Embedded && + s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && + s.Codec != "dvdsub" && + s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs")) + .Map(mm => mm.MusicVideoId) + .ToListAsync(cancellationToken); + result.AddRange(musicVideoIds); + + List otherVideoIds = await dbContext.OtherVideoMetadata + .AsNoTracking() + .Filter(ovm => mediaItemIds.Contains(ovm.OtherVideoId)) + .Filter(ovm => ovm.Subtitles.Any(s => s.SubtitleKind == SubtitleKind.Embedded && + s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && + s.Codec != "dvdsub" && + s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs")) + .Map(ovm => ovm.OtherVideoId) + .ToListAsync(cancellationToken); + result.AddRange(otherVideoIds); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + // do nothing + } + + return result; + } + + protected async Task ExtractSubtitles( + TvContext dbContext, + int mediaItemId, + string ffmpegPath, + CancellationToken cancellationToken) + { + foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId, cancellationToken)) + { + foreach (List allSubtitles in GetSubtitles(mediaItem)) + { + var subtitlesToExtract = new List(); + + // find each subtitle that needs extraction + IEnumerable subtitles = allSubtitles + .Filter(s => s.SubtitleKind == SubtitleKind.Embedded) + .Filter(s => s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle" && s.Codec != "dvdsub" && + s.Codec != "vobsub" && s.Codec != "pgssub" && s.Codec != "pgs") + .Filter(s => !s.IsExtracted || string.IsNullOrWhiteSpace(s.Path) || + FileDoesntExist(mediaItem.Id, s)); + + // find cache paths for each subtitle + foreach (Subtitle subtitle in subtitles) + { + Option maybePath = GetRelativeOutputPath(mediaItem.Id, subtitle); + foreach (string path in maybePath) + { + subtitlesToExtract.Add(new SubtitleToExtract(subtitle, path)); + } + } + + if (subtitlesToExtract.Count == 0) + { + continue; + } + + string mediaItemPath = await GetMediaItemPath(dbContext, mediaItem); + + ArgumentsBuilder args = new ArgumentsBuilder() + .Add("-nostdin") + .Add("-hide_banner") + .Add("-i").Add(mediaItemPath); + + foreach (SubtitleToExtract subtitle in subtitlesToExtract) + { + string fullOutputPath = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.OutputPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullOutputPath)); + if (localFileSystem.FileExists(fullOutputPath)) + { + File.Delete(fullOutputPath); + } + + args.Add("-map").Add($"0:{subtitle.Subtitle.StreamIndex}") + .Add("-c:s").Add(subtitle.Subtitle.Codec == "mov_text" ? "text" : "copy") + .Add(fullOutputPath); + } + + BufferedCommandResult result = await Cli.Wrap(ffmpegPath) + .WithArguments(args.Build()) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(cancellationToken); + + if (result.ExitCode == 0) + { + foreach (SubtitleToExtract subtitle in subtitlesToExtract) + { + await dbContext.Connection.ExecuteAsync( + "UPDATE `Subtitle` SET `IsExtracted` = 1, `Path` = @Path WHERE `Id` = @SubtitleId", + new { SubtitleId = subtitle.Subtitle.Id, Path = subtitle.OutputPath }); + } + + logger.LogDebug("Successfully extracted {Count} subtitles", subtitlesToExtract.Count); + } + else + { + logger.LogError("Failed to extract subtitles. {Error}", result.StandardError); + } + } + } + } + + protected async Task ExtractFonts( + TvContext dbContext, + int mediaItemId, + string ffmpegPath, + CancellationToken cancellationToken) + { + foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId, cancellationToken)) + { + MediaVersion headVersion = mediaItem.GetHeadVersion(); + var attachments = headVersion.Streams + .Filter(s => s.MediaStreamKind == MediaStreamKind.Attachment) + .OrderBy(s => s.Index) + .ToList(); + + for (var attachmentIndex = 0; attachmentIndex < attachments.Count; attachmentIndex++) + { + MediaStream fontStream = attachments[attachmentIndex]; + + if (!(fontStream.MimeType ?? string.Empty).Contains("font") && + !(fontStream.MimeType ?? string.Empty).Contains("opentype")) + { + // not a font + continue; + } + + string fullOutputPath = Path.Combine(FileSystemLayout.FontsCacheFolder, fontStream.FileName); + if (localFileSystem.FileExists(fullOutputPath)) + { + // already extracted + continue; + } + + string mediaItemPath = await GetMediaItemPath(dbContext, mediaItem); + + var arguments = + $"-nostdin -hide_banner -dump_attachment:t:{attachmentIndex} \"\" -i \"{mediaItemPath}\" -y"; + + BufferedCommandResult result = await Cli.Wrap(ffmpegPath) + .WithWorkingDirectory(FileSystemLayout.FontsCacheFolder) + .WithArguments(arguments) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(cancellationToken); + + // ffmpeg seems to return exit code 1 in all cases when dumping an attachment + // so ignore it and check success a different way + if (localFileSystem.FileExists(fullOutputPath)) + { + logger.LogDebug("Successfully extracted font {Font}", fontStream.FileName); + } + else + { + logger.LogError( + "Failed to extract attached font {Font}. {Error}", + fontStream.FileName, + result.StandardError); + } + } + } + } + + + private static async Task> GetMediaItem( + TvContext dbContext, + int mediaItemId, + CancellationToken cancellationToken) => + await dbContext.MediaItems + .AsNoTracking() + .Include(mi => (mi as Episode).MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(mi => (mi as Episode).MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(mi => (mi as Episode).EpisodeMetadata) + .ThenInclude(em => em.Subtitles) + .Include(mi => (mi as Movie).MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(mi => (mi as Movie).MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(mi => (mi as Movie).MovieMetadata) + .ThenInclude(em => em.Subtitles) + .Include(mi => (mi as MusicVideo).MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(mi => (mi as MusicVideo).MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(mi => (mi as MusicVideo).MusicVideoMetadata) + .ThenInclude(em => em.Subtitles) + .Include(mi => (mi as OtherVideo).MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(mi => (mi as OtherVideo).MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(mi => (mi as OtherVideo).OtherVideoMetadata) + .ThenInclude(em => em.Subtitles) + .SelectOneAsync(e => e.Id, e => e.Id == mediaItemId, cancellationToken); + + private static async Task GetMediaItemPath(TvContext dbContext, MediaItem mediaItem) + { + MediaVersion version = mediaItem.GetHeadVersion(); + + MediaFile file = version.MediaFiles.Head(); + switch (file) + { + case PlexMediaFile pmf: + Option maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync( + @"SELECT PMS.Id FROM PlexMediaSource PMS + INNER JOIN Library L on PMS.Id = L.MediaSourceId + INNER JOIN LibraryPath LP on L.Id = LP.LibraryId + WHERE LP.Id = @LibraryPathId", + new { mediaItem.LibraryPathId }) + .Map(Optional); + + foreach (int plexMediaSourceId in maybeId) + { + return $"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}"; + } + + break; + } + + return mediaItem switch + { + JellyfinMovie jellyfinMovie => + $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{jellyfinMovie.ItemId}", + JellyfinEpisode jellyfinEpisode => + $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{jellyfinEpisode.ItemId}", + EmbyMovie embyMovie => $"http://localhost:{Settings.StreamingPort}/media/emby/{embyMovie.ItemId}", + EmbyEpisode embyEpisode => $"http://localhost:{Settings.StreamingPort}/media/emby/{embyEpisode.ItemId}", + _ => file.Path + }; + } + + private bool FileDoesntExist(int mediaItemId, Subtitle subtitle) + { + foreach (string path in GetRelativeOutputPath(mediaItemId, subtitle)) + { + return !localFileSystem.FileExists(path); + } + + return false; + } + + private static Option> GetSubtitles(MediaItem mediaItem) => + mediaItem switch + { + Episode e => e.EpisodeMetadata.Head().Subtitles, + Movie m => m.MovieMetadata.Head().Subtitles, + MusicVideo mv => mv.MusicVideoMetadata.Head().Subtitles, + OtherVideo ov => ov.OtherVideoMetadata.Head().Subtitles, + _ => None + }; + + private static Option GetRelativeOutputPath(int mediaItemId, Subtitle subtitle) + { + string name = GetStringHash($"{mediaItemId}_{subtitle.StreamIndex}_{subtitle.Codec}"); + string subfolder = name[..2]; + string subfolder2 = name[2..4]; + + string nameWithExtension = subtitle.Codec switch + { + "subrip" or "srt" => $"{name}.srt", + "ass" => $"{name}.ass", + "webvtt" => $"{name}.vtt", + "mov_text" => $"{name}.srt", + _ => string.Empty + }; + + if (string.IsNullOrWhiteSpace(nameWithExtension)) + { + return None; + } + + return Path.Combine(subfolder, subfolder2, nameWithExtension); + } + + private static string GetStringHash(string text) + { + if (string.IsNullOrEmpty(text)) + { + return string.Empty; + } + + byte[] textData = Encoding.UTF8.GetBytes(text); + byte[] hash = MD5.HashData(textData); + return Convert.ToHexString(hash); + } + + private sealed record SubtitleToExtract(Subtitle Subtitle, string OutputPath); +} diff --git a/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs b/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs index 02a31686b..b15cd30cd 100644 --- a/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs +++ b/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs @@ -22,29 +22,28 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory dbContextFa foreach (var subtitle in maybeSubtitle) { - string path = subtitle.Path; - if (subtitle is { SubtitleKind: SubtitleKind.Embedded, IsExtracted: true }) { - path = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.Path); + string path = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.Path); + return new SubtitlePathAndCodec(path, subtitle.Codec); } foreach (string plexUrl in await GetPlexUrl(request.Id, dbContext, maybeSubtitle)) { - path = plexUrl; + return new SubtitlePathAndCodec(plexUrl, subtitle.Codec); } foreach (string jellyfinUrl in await GetJellyfinUrl(request.Id, dbContext, maybeSubtitle)) { - path = jellyfinUrl; + return new SubtitlePathAndCodec(jellyfinUrl, subtitle.Codec); } foreach (string embyUrl in await GetEmbyUrl(request.Id, dbContext, maybeSubtitle)) { - path = embyUrl; + return new SubtitlePathAndCodec(embyUrl, subtitle.Codec); } - return new SubtitlePathAndCodec(path, subtitle.Codec); + return new SubtitlePathAndCodec(subtitle.Path, subtitle.Codec); } return BaseError.New($"Unable to locate subtitle with id {request.Id}"); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs index 0e38f9aef..011c06aac 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs @@ -4,24 +4,14 @@ using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; namespace ErsatzTV.Infrastructure.Data.Repositories; -public class MetadataRepository : IMetadataRepository +public class MetadataRepository(IDbContextFactory dbContextFactory) : IMetadataRepository { - private readonly IDbContextFactory _dbContextFactory; - private readonly ILogger _logger; - - public MetadataRepository(IDbContextFactory dbContextFactory, ILogger logger) - { - _dbContextFactory = dbContextFactory; - _logger = logger; - } - public async Task RemoveActor(Actor actor) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( "DELETE FROM Actor WHERE Id = @ActorId", new { ActorId = actor.Id }) @@ -30,14 +20,14 @@ public class MetadataRepository : IMetadataRepository public async Task Update(Core.Domain.Metadata metadata) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); dbContext.Entry(metadata).State = EntityState.Modified; return await dbContext.SaveChangesAsync() > 0; } public async Task Add(Core.Domain.Metadata metadata) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); dbContext.Entry(metadata).State = EntityState.Added; foreach (Genre genre in Optional(metadata.Genres).Flatten()) @@ -126,7 +116,7 @@ public class MetadataRepository : IMetadataRepository { int mediaVersionId = mediaItem.GetHeadVersion().Id; - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); Option maybeVersion = await dbContext.MediaVersions .Include(v => v.Streams) .Include(v => v.Chapters) @@ -247,7 +237,7 @@ public class MetadataRepository : IMetadataRepository public async Task UpdateArtworkPath(Artwork artwork) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( "UPDATE Artwork SET Path = @Path, SourcePath = @SourcePath, DateUpdated = @DateUpdated, BlurHash43 = @BlurHash43, BlurHash54 = @BlurHash54, BlurHash64 = @BlurHash64 WHERE Id = @Id", new @@ -259,7 +249,7 @@ public class MetadataRepository : IMetadataRepository public async Task AddArtwork(Core.Domain.Metadata metadata, Artwork artwork) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); var parameters = new { @@ -315,7 +305,7 @@ public class MetadataRepository : IMetadataRepository public async Task RemoveArtwork(Core.Domain.Metadata metadata, ArtworkKind artworkKind) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( @"DELETE FROM Artwork WHERE ArtworkKind = @ArtworkKind AND (MovieMetadataId = @Id OR ShowMetadataId = @Id OR SeasonMetadataId = @Id OR EpisodeMetadataId = @Id OR OtherVideoMetadataId = @Id)", @@ -329,7 +319,7 @@ public class MetadataRepository : IMetadataRepository string sourcePath, DateTime lastWriteTime) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); Option maybeExisting = await dbContext.Artwork .AsNoTracking() .Filter(a => a.SourcePath == sourcePath && a.ArtworkKind == artworkKind && a.DateUpdated == lastWriteTime) @@ -369,7 +359,7 @@ public class MetadataRepository : IMetadataRepository public async Task MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( @"UPDATE ShowMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id", new { DateUpdated = dateUpdated, metadata.Id }).ToUnit(); @@ -377,7 +367,7 @@ public class MetadataRepository : IMetadataRepository public async Task MarkAsUpdated(SeasonMetadata metadata, DateTime dateUpdated) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( @"UPDATE SeasonMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id", new { DateUpdated = dateUpdated, metadata.Id }).ToUnit(); @@ -385,7 +375,7 @@ public class MetadataRepository : IMetadataRepository public async Task MarkAsUpdated(MovieMetadata metadata, DateTime dateUpdated) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( @"UPDATE MovieMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id", new { DateUpdated = dateUpdated, metadata.Id }).ToUnit(); @@ -393,7 +383,7 @@ public class MetadataRepository : IMetadataRepository public async Task MarkAsUpdated(EpisodeMetadata metadata, DateTime dateUpdated) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( @"UPDATE EpisodeMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id", new { DateUpdated = dateUpdated, metadata.Id }).ToUnit(); @@ -401,7 +391,7 @@ public class MetadataRepository : IMetadataRepository public async Task MarkAsExternal(ShowMetadata metadata) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( @"UPDATE ShowMetadata SET MetadataKind = @Kind WHERE Id = @Id", new { metadata.Id, Kind = (int)MetadataKind.External }).ToUnit(); @@ -409,7 +399,7 @@ public class MetadataRepository : IMetadataRepository public async Task SetContentRating(ShowMetadata metadata, string contentRating) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( @"UPDATE ShowMetadata SET ContentRating = @ContentRating WHERE Id = @Id", new { metadata.Id, ContentRating = contentRating }).ToUnit(); @@ -417,7 +407,7 @@ public class MetadataRepository : IMetadataRepository public async Task MarkAsExternal(MovieMetadata metadata) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( @"UPDATE MovieMetadata SET MetadataKind = @Kind WHERE Id = @Id", new { metadata.Id, Kind = (int)MetadataKind.External }).ToUnit(); @@ -425,7 +415,7 @@ public class MetadataRepository : IMetadataRepository public async Task SetContentRating(MovieMetadata metadata, string contentRating) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( @"UPDATE MovieMetadata SET ContentRating = @ContentRating WHERE Id = @Id", new { metadata.Id, ContentRating = contentRating }).ToUnit(); @@ -433,7 +423,7 @@ public class MetadataRepository : IMetadataRepository public async Task MarkAsUpdated(OtherVideoMetadata metadata, DateTime dateUpdated) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( @"UPDATE OtherVideoMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id", new { DateUpdated = dateUpdated, metadata.Id }).ToUnit(); @@ -441,7 +431,7 @@ public class MetadataRepository : IMetadataRepository public async Task MarkAsExternal(OtherVideoMetadata metadata) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( @"UPDATE OtherVideoMetadata SET MetadataKind = @Kind WHERE Id = @Id", new { metadata.Id, Kind = (int)MetadataKind.External }).ToUnit(); @@ -449,7 +439,7 @@ public class MetadataRepository : IMetadataRepository public async Task SetContentRating(OtherVideoMetadata metadata, string contentRating) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( @"UPDATE OtherVideoMetadata SET ContentRating = @ContentRating WHERE Id = @Id", new { metadata.Id, ContentRating = contentRating }).ToUnit(); @@ -457,7 +447,7 @@ public class MetadataRepository : IMetadataRepository public async Task SetPlot(MovieMetadata metadata, string plot) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( @"UPDATE MovieMetadata SET Plot = @Plot WHERE Id = @Id", new { metadata.Id, Plot = plot }).ToUnit(); @@ -465,7 +455,7 @@ public class MetadataRepository : IMetadataRepository public async Task SetPlot(OtherVideoMetadata metadata, string plot) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( @"UPDATE OtherVideoMetadata SET Plot = @Plot WHERE Id = @Id", new { metadata.Id, Plot = plot }).ToUnit(); @@ -473,7 +463,7 @@ public class MetadataRepository : IMetadataRepository public async Task RemoveGuid(MetadataGuid guid) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( "DELETE FROM MetadataGuid WHERE Id = @GuidId", new { GuidId = guid.Id }) @@ -482,7 +472,7 @@ public class MetadataRepository : IMetadataRepository public async Task AddGuid(Core.Domain.Metadata metadata, MetadataGuid guid) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return metadata switch { MovieMetadata => @@ -515,7 +505,7 @@ public class MetadataRepository : IMetadataRepository public async Task RemoveDirector(Director director) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( "DELETE FROM Director WHERE Id = @DirectorId", new { DirectorId = director.Id }) @@ -524,7 +514,7 @@ public class MetadataRepository : IMetadataRepository public async Task RemoveWriter(Writer writer) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( "DELETE FROM Writer WHERE Id = @WriterId", new { WriterId = writer.Id }) @@ -536,7 +526,7 @@ public class MetadataRepository : IMetadataRepository List subtitles, CancellationToken cancellationToken) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); return await UpdateSubtitles(dbContext, metadata, subtitles, cancellationToken); } @@ -545,7 +535,7 @@ public class MetadataRepository : IMetadataRepository List chapters, CancellationToken cancellationToken) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); Option maybeExisting = await dbContext.MediaVersions .Include(mv => mv.Chapters) @@ -586,7 +576,7 @@ public class MetadataRepository : IMetadataRepository public async Task RemoveGenre(Genre genre) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( "DELETE FROM Genre WHERE Id = @GenreId", new { GenreId = genre.Id }) @@ -595,7 +585,7 @@ public class MetadataRepository : IMetadataRepository public async Task RemoveTag(Tag tag) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( "DELETE FROM Tag WHERE Id = @TagId AND ExternalCollectionId = @ExternalCollectionId", new { TagId = tag.Id, tag.ExternalCollectionId }) @@ -604,7 +594,7 @@ public class MetadataRepository : IMetadataRepository public async Task RemoveStudio(Studio studio) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( "DELETE FROM Studio WHERE Id = @StudioId", new { StudioId = studio.Id }) @@ -613,7 +603,7 @@ public class MetadataRepository : IMetadataRepository public async Task RemoveStyle(Style style) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync( "DELETE FROM Style WHERE Id = @StyleId", new { StyleId = style.Id }) @@ -622,7 +612,7 @@ public class MetadataRepository : IMetadataRepository public async Task RemoveMood(Mood mood) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.ExecuteAsync("DELETE FROM Mood WHERE Id = @MoodId", new { MoodId = mood.Id }) .Map(result => result > 0); } @@ -692,14 +682,41 @@ public class MetadataRepository : IMetadataRepository Subtitle existingSubtitle = existing.Subtitles.First(s => s.StreamIndex == incomingSubtitle.StreamIndex); - existingSubtitle.Codec = incomingSubtitle.Codec; + // when the kind, codec, language, or flags change we need to extract again + if (existingSubtitle.IsExtracted) + { + bool differentKind = existingSubtitle.SubtitleKind != incomingSubtitle.SubtitleKind; + bool differentCodec = existingSubtitle.Codec != incomingSubtitle.Codec; + bool differentLanguage = existingSubtitle.Language != incomingSubtitle.Language; + bool differentFlags = existingSubtitle.Default != incomingSubtitle.Default || + existingSubtitle.Forced != incomingSubtitle.Forced || + existingSubtitle.SDH != incomingSubtitle.SDH; + + if (differentKind || differentCodec || differentLanguage || differentFlags) + { + existingSubtitle.IsExtracted = false; + existingSubtitle.Path = incomingSubtitle.Path; + } + else + { + // only re-extract if path changed + // this probably won't ever happen? + bool differentPath = existingSubtitle.Path != incomingSubtitle.Path; + if (!string.IsNullOrEmpty(incomingSubtitle.Path) && differentPath) + { + existingSubtitle.IsExtracted = false; + existingSubtitle.Path = incomingSubtitle.Path; + } + } + } + existingSubtitle.Default = incomingSubtitle.Default; existingSubtitle.Forced = incomingSubtitle.Forced; existingSubtitle.SDH = incomingSubtitle.SDH; existingSubtitle.Language = incomingSubtitle.Language; existingSubtitle.SubtitleKind = incomingSubtitle.SubtitleKind; + existingSubtitle.Codec = incomingSubtitle.Codec; existingSubtitle.DateUpdated = incomingSubtitle.DateUpdated; - existingSubtitle.Path = incomingSubtitle.Path; dbContext.Entry(existingSubtitle).State = EntityState.Modified; } diff --git a/ErsatzTV/Services/WorkerService.cs b/ErsatzTV/Services/WorkerService.cs index 770e0f3f2..1b942838f 100644 --- a/ErsatzTV/Services/WorkerService.cs +++ b/ErsatzTV/Services/WorkerService.cs @@ -117,6 +117,9 @@ public class WorkerService : BackgroundService case ExtractEmbeddedSubtitles extractEmbeddedSubtitles: await mediator.Send(extractEmbeddedSubtitles, stoppingToken); break; + case ExtractEmbeddedShowSubtitles extractEmbeddedShowSubtitles: + await mediator.Send(extractEmbeddedShowSubtitles, stoppingToken); + break; #endif case ReleaseMemory aggressivelyReleaseMemory: await mediator.Send(aggressivelyReleaseMemory, stoppingToken);