mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* properly reset extracted flag on subtitles * optimize subtitle updates; extract after targeted deep scan * fix extracted text subtitle playback from media serverspull/2454/head
9 changed files with 582 additions and 404 deletions
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core; |
||||
|
||||
namespace ErsatzTV.Application.Subtitles; |
||||
|
||||
public record ExtractEmbeddedShowSubtitles(int ShowId) : IBackgroundServiceRequest, IRequest<Option<BaseError>>; |
||||
@ -0,0 +1,125 @@
@@ -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<TvContext> dbContextFactory, |
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel, |
||||
IConfigElementRepository configElementRepository, |
||||
ILocalFileSystem localFileSystem, |
||||
ILogger<ExtractEmbeddedSubtitlesHandler> logger) |
||||
: ExtractEmbeddedSubtitlesHandlerBase(localFileSystem, logger), |
||||
IRequestHandler<ExtractEmbeddedShowSubtitles, Option<BaseError>> |
||||
{ |
||||
public async Task<Option<BaseError>> Handle( |
||||
ExtractEmbeddedShowSubtitles 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, |
||||
ExtractEmbeddedShowSubtitles request, |
||||
string ffmpegPath, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
try |
||||
{ |
||||
bool useEmbeddedSubtitles = await configElementRepository |
||||
.GetValue<bool>(ConfigElementKey.FFmpegUseEmbeddedSubtitles, cancellationToken) |
||||
.IfNoneAsync(true); |
||||
|
||||
if (!useEmbeddedSubtitles) |
||||
{ |
||||
return Option<BaseError>.None; |
||||
} |
||||
|
||||
bool extractEmbeddedSubtitles = await configElementRepository |
||||
.GetValue<bool>(ConfigElementKey.FFmpegExtractEmbeddedSubtitles, cancellationToken) |
||||
.IfNoneAsync(false); |
||||
|
||||
if (!extractEmbeddedSubtitles) |
||||
{ |
||||
return Option<BaseError>.None; |
||||
} |
||||
|
||||
Option<Show> 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<int> episodeIds = await dbContext.Connection.QueryAsync<int>( |
||||
"""
|
||||
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<int> 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<BaseError>.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<BaseError>.None; |
||||
} |
||||
} |
||||
@ -0,0 +1,355 @@
@@ -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<Validation<BaseError, string>> FFmpegPathMustExist( |
||||
TvContext dbContext, |
||||
CancellationToken cancellationToken) => |
||||
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath, cancellationToken) |
||||
.FilterT(File.Exists) |
||||
.Map(maybePath => maybePath.ToValidation<BaseError>("FFmpeg path does not exist on filesystem")); |
||||
|
||||
protected 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; |
||||
} |
||||
|
||||
protected async Task ExtractSubtitles( |
||||
TvContext dbContext, |
||||
int mediaItemId, |
||||
string ffmpegPath, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId, cancellationToken)) |
||||
{ |
||||
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 || 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) |
||||
{ |
||||
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<Option<MediaItem>> 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<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 bool FileDoesntExist(int mediaItemId, Subtitle subtitle) |
||||
{ |
||||
foreach (string path in GetRelativeOutputPath(mediaItemId, subtitle)) |
||||
{ |
||||
return !localFileSystem.FileExists(path); |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
|
||||
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 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 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); |
||||
} |
||||
Loading…
Reference in new issue