mirror of https://github.com/ErsatzTV/ErsatzTV.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
539 lines
22 KiB
539 lines
22 KiB
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 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; |
|
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 class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSubtitles, Option<BaseError>> |
|
{ |
|
private readonly IConfigElementRepository _configElementRepository; |
|
private readonly IDbContextFactory<TvContext> _dbContextFactory; |
|
private readonly IEntityLocker _entityLocker; |
|
private readonly ILocalFileSystem _localFileSystem; |
|
private readonly ILogger<ExtractEmbeddedSubtitlesHandler> _logger; |
|
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel; |
|
|
|
public ExtractEmbeddedSubtitlesHandler( |
|
IDbContextFactory<TvContext> dbContextFactory, |
|
ILocalFileSystem localFileSystem, |
|
IEntityLocker entityLocker, |
|
IConfigElementRepository configElementRepository, |
|
ChannelWriter<IBackgroundServiceRequest> workerChannel, |
|
ILogger<ExtractEmbeddedSubtitlesHandler> logger) |
|
{ |
|
_dbContextFactory = dbContextFactory; |
|
_localFileSystem = localFileSystem; |
|
_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); |
|
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) |
|
{ |
|
try |
|
{ |
|
bool useEmbeddedSubtitles = await _configElementRepository |
|
.GetValue<bool>(ConfigElementKey.FFmpegUseEmbeddedSubtitles) |
|
.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) |
|
.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)); |
|
|
|
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) |
|
{ |
|
await _entityLocker.LockPlayout(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); |
|
|
|
foreach (int playoutId in playoutIdsToCheck) |
|
{ |
|
await _entityLocker.UnlockPlayout(playoutId); |
|
} |
|
} |
|
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) |
|
{ |
|
// do nothing |
|
} |
|
|
|
return Option<BaseError>.None; |
|
} |
|
|
|
private static async Task<List<int>> GetMediaItemIdsWithTextSubtitles( |
|
TvContext dbContext, |
|
List<int> mediaItemIds, |
|
CancellationToken cancellationToken) |
|
{ |
|
var result = new List<int>(); |
|
|
|
try |
|
{ |
|
List<int> 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<int> 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<int> 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<int> 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)) |
|
{ |
|
foreach (List<Subtitle> allSubtitles in GetSubtitles(mediaItem)) |
|
{ |
|
var subtitlesToExtract = new List<SubtitleToExtract>(); |
|
|
|
// find each subtitle that needs extraction |
|
IEnumerable<Subtitle> 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 == false || string.IsNullOrWhiteSpace(s.Path) || |
|
FileDoesntExist(mediaItem.Id, s)); |
|
|
|
// find cache paths for each subtitle |
|
foreach (Subtitle subtitle in subtitles) |
|
{ |
|
Option<string> 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) |
|
{ |
|
subtitle.Subtitle.IsExtracted = true; |
|
subtitle.Subtitle.Path = subtitle.OutputPath; |
|
} |
|
|
|
await dbContext.SaveChangesAsync(cancellationToken); |
|
_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) == false; |
|
} |
|
|
|
return false; |
|
} |
|
|
|
private static async Task<Option<MediaItem>> GetMediaItem(TvContext dbContext, int mediaItemId) => |
|
await dbContext.MediaItems |
|
.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); |
|
|
|
private static Option<List<Subtitle>> 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)) |
|
{ |
|
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<Validation<BaseError, string>> FFmpegPathMustExist(TvContext dbContext) => |
|
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath) |
|
.FilterT(File.Exists) |
|
.Map(maybePath => maybePath.ToValidation<BaseError>("FFmpeg path does not exist on filesystem")); |
|
|
|
private static Option<string> 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<string> GetMediaItemPath(TvContext dbContext, MediaItem mediaItem) |
|
{ |
|
MediaVersion version = mediaItem.GetHeadVersion(); |
|
|
|
MediaFile file = version.MediaFiles.Head(); |
|
switch (file) |
|
{ |
|
case PlexMediaFile pmf: |
|
Option<int> maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync<int>( |
|
@"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 BitConverter.ToString(hash).Replace("-", string.Empty); |
|
} |
|
|
|
private sealed record SubtitleToExtract(Subtitle Subtitle, string OutputPath); |
|
}
|
|
|