mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* first pass at text subtitle support * support text subtitles from movies, music videos and other videos * fixes * qsv fixes * vaapi fixes * update changelogpull/744/head
68 changed files with 22005 additions and 152 deletions
@ -0,0 +1,5 @@ |
|||||||
|
namespace ErsatzTV.Application; |
||||||
|
|
||||||
|
public interface ISubtitleWorkerRequest |
||||||
|
{ |
||||||
|
} |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
using ErsatzTV.Core; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Subtitles; |
||||||
|
|
||||||
|
public record ExtractEmbeddedSubtitles(Option<int> PlayoutId) : IRequest<Either<BaseError, Unit>>, |
||||||
|
ISubtitleWorkerRequest; |
||||||
@ -0,0 +1,398 @@ |
|||||||
|
using System.Security.Cryptography; |
||||||
|
using System.Text; |
||||||
|
using CliWrap; |
||||||
|
using CliWrap.Buffered; |
||||||
|
using CliWrap.Builders; |
||||||
|
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; |
||||||
|
|
||||||
|
public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSubtitles, Either<BaseError, Unit>> |
||||||
|
{ |
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory; |
||||||
|
private readonly ILocalFileSystem _localFileSystem; |
||||||
|
private readonly ILogger<ExtractEmbeddedSubtitlesHandler> _logger; |
||||||
|
|
||||||
|
public ExtractEmbeddedSubtitlesHandler( |
||||||
|
IDbContextFactory<TvContext> dbContextFactory, |
||||||
|
ILocalFileSystem localFileSystem, |
||||||
|
ILogger<ExtractEmbeddedSubtitlesHandler> logger) |
||||||
|
{ |
||||||
|
_dbContextFactory = dbContextFactory; |
||||||
|
_localFileSystem = localFileSystem; |
||||||
|
_logger = logger; |
||||||
|
} |
||||||
|
|
||||||
|
public async Task<Either<BaseError, Unit>> Handle( |
||||||
|
ExtractEmbeddedSubtitles request, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); |
||||||
|
Validation<BaseError, string> validation = await FFmpegPathMustExist(dbContext); |
||||||
|
return await validation.Match( |
||||||
|
ffmpegPath => ExtractAll(dbContext, request, ffmpegPath, cancellationToken), |
||||||
|
error => Task.FromResult<Either<BaseError, Unit>>(error.Join())); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<Either<BaseError, Unit>> ExtractAll( |
||||||
|
TvContext dbContext, |
||||||
|
ExtractEmbeddedSubtitles request, |
||||||
|
string ffmpegPath, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
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 |
||||||
|
.Filter(p => p.Channel.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 |
||||||
|
.Filter(p => p.Channel.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 Unit.Default; |
||||||
|
} |
||||||
|
|
||||||
|
_logger.LogDebug("No playouts have subtitles enabled; nothing to extract"); |
||||||
|
return Unit.Default; |
||||||
|
} |
||||||
|
|
||||||
|
_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 |
||||||
|
.Filter(pi => playoutIdsToCheck.Contains(pi.PlayoutId)) |
||||||
|
.Filter(pi => pi.Finish >= DateTime.UtcNow) |
||||||
|
.Filter(pi => pi.Start <= until) |
||||||
|
.ToListAsync(cancellationToken); |
||||||
|
|
||||||
|
// TODO: support other media kinds (movies, other videos, etc)
|
||||||
|
|
||||||
|
var mediaItemIds = playoutItems.Map(pi => pi.MediaItemId).ToList(); |
||||||
|
|
||||||
|
// filter for subtitles that need extraction
|
||||||
|
List<int> unextractedMediaItemIds = |
||||||
|
await GetUnextractedMediaItemIds(dbContext, mediaItemIds, cancellationToken); |
||||||
|
|
||||||
|
if (unextractedMediaItemIds.Any()) |
||||||
|
{ |
||||||
|
_logger.LogDebug( |
||||||
|
"Found media items {MediaItemIds} with text subtitles to extract for playouts {PlayoutIds}", |
||||||
|
unextractedMediaItemIds, |
||||||
|
playoutIdsToCheck); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
_logger.LogDebug("Found no text subtitles 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 => unextractedMediaItemIds.Contains(pi.MediaItemId)) |
||||||
|
.OrderBy(pi => pi.StartOffset) |
||||||
|
.Map(pi => pi.MediaItemId) |
||||||
|
.ToList(); |
||||||
|
|
||||||
|
foreach (int mediaItemId in toUpdate) |
||||||
|
{ |
||||||
|
if (cancellationToken.IsCancellationRequested) |
||||||
|
{ |
||||||
|
return Unit.Default; |
||||||
|
} |
||||||
|
|
||||||
|
PlayoutItem pi = playoutItems.Find(pi => pi.MediaItemId == mediaItemId); |
||||||
|
_logger.LogDebug("Extracting subtitles for item with start time {StartTime}", pi?.StartOffset); |
||||||
|
|
||||||
|
// extract subtitles and fonts for each item and update db
|
||||||
|
await ExtractSubtitles(dbContext, mediaItemId, ffmpegPath, cancellationToken); |
||||||
|
// await ExtractFonts(dbContext, episodeId, ffmpegPath, cancellationToken);
|
||||||
|
} |
||||||
|
|
||||||
|
return Unit.Default; |
||||||
|
} |
||||||
|
catch (TaskCanceledException) |
||||||
|
{ |
||||||
|
return Unit.Default; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<List<int>> GetUnextractedMediaItemIds( |
||||||
|
TvContext dbContext, |
||||||
|
List<int> mediaItemIds, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
var result = new List<int>(); |
||||||
|
|
||||||
|
try |
||||||
|
{ |
||||||
|
List<int> episodeIds = await dbContext.EpisodeMetadata |
||||||
|
.Filter(em => mediaItemIds.Contains(em.EpisodeId)) |
||||||
|
.Filter( |
||||||
|
em => em.Subtitles.Any( |
||||||
|
s => s.SubtitleKind == SubtitleKind.Embedded && s.IsExtracted == false && |
||||||
|
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle")) |
||||||
|
.Map(em => em.EpisodeId) |
||||||
|
.ToListAsync(cancellationToken); |
||||||
|
result.AddRange(episodeIds); |
||||||
|
|
||||||
|
List<int> movieIds = await dbContext.MovieMetadata |
||||||
|
.Filter(mm => mediaItemIds.Contains(mm.MovieId)) |
||||||
|
.Filter( |
||||||
|
mm => mm.Subtitles.Any( |
||||||
|
s => s.SubtitleKind == SubtitleKind.Embedded && s.IsExtracted == false && |
||||||
|
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle")) |
||||||
|
.Map(mm => mm.MovieId) |
||||||
|
.ToListAsync(cancellationToken); |
||||||
|
result.AddRange(movieIds); |
||||||
|
|
||||||
|
List<int> musicVideoIds = await dbContext.MusicVideoMetadata |
||||||
|
.Filter(mm => mediaItemIds.Contains(mm.MusicVideoId)) |
||||||
|
.Filter( |
||||||
|
mm => mm.Subtitles.Any( |
||||||
|
s => s.SubtitleKind == SubtitleKind.Embedded && s.IsExtracted == false && |
||||||
|
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle")) |
||||||
|
.Map(mm => mm.MusicVideoId) |
||||||
|
.ToListAsync(cancellationToken); |
||||||
|
result.AddRange(musicVideoIds); |
||||||
|
|
||||||
|
List<int> otherVideoIds = await dbContext.OtherVideoMetadata |
||||||
|
.Filter(ovm => mediaItemIds.Contains(ovm.OtherVideoId)) |
||||||
|
.Filter( |
||||||
|
ovm => ovm.Subtitles.Any( |
||||||
|
s => s.SubtitleKind == SubtitleKind.Embedded && s.IsExtracted == false && |
||||||
|
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle")) |
||||||
|
.Map(ovm => ovm.OtherVideoId) |
||||||
|
.ToListAsync(cancellationToken); |
||||||
|
result.AddRange(otherVideoIds); |
||||||
|
} |
||||||
|
catch (TaskCanceledException) |
||||||
|
{ |
||||||
|
// do nothing
|
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<Unit> ExtractSubtitles( |
||||||
|
TvContext dbContext, |
||||||
|
int mediaItemId, |
||||||
|
string ffmpegPath, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
Option<MediaItem> maybeMediaItem = 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); |
||||||
|
|
||||||
|
foreach (MediaItem mediaItem in maybeMediaItem) |
||||||
|
{ |
||||||
|
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 && s.IsExtracted == false && |
||||||
|
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle"); |
||||||
|
|
||||||
|
// 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)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
string mediaItemPath = mediaItem.GetHeadVersion().MediaFiles.Head().Path; |
||||||
|
|
||||||
|
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").Add("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; |
||||||
|
} |
||||||
|
|
||||||
|
int count = await dbContext.SaveChangesAsync(cancellationToken); |
||||||
|
_logger.LogDebug("Successfully extracted {Count} subtitles", count); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
_logger.LogError("Failed to extract subtitles. {Error}", result.StandardError); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return Unit.Default; |
||||||
|
} |
||||||
|
|
||||||
|
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<Unit> ExtractFonts( |
||||||
|
TvContext dbContext, |
||||||
|
int mediaItemId, |
||||||
|
string ffmpegPath, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
Option<Episode> maybeEpisode = await dbContext.Episodes |
||||||
|
.Include(e => e.MediaVersions) |
||||||
|
.ThenInclude(mv => mv.MediaFiles) |
||||||
|
.Include(e => e.MediaVersions) |
||||||
|
.ThenInclude(mv => mv.Streams) |
||||||
|
.Include(e => e.EpisodeMetadata) |
||||||
|
.ThenInclude(em => em.Subtitles) |
||||||
|
.SelectOneAsync(e => e.Id, e => e.Id == mediaItemId); |
||||||
|
|
||||||
|
foreach (Episode episode in maybeEpisode) |
||||||
|
{ |
||||||
|
string mediaItemPath = episode.GetHeadVersion().MediaFiles.Head().Path; |
||||||
|
|
||||||
|
var arguments = $"-nostdin -hide_banner -dump_attachment:t \"\" -i \"{mediaItemPath}\" -y"; |
||||||
|
|
||||||
|
BufferedCommandResult result = await Cli.Wrap(ffmpegPath) |
||||||
|
.WithWorkingDirectory(FileSystemLayout.FontsCacheFolder) |
||||||
|
.WithArguments(arguments) |
||||||
|
.WithValidation(CommandResultValidation.None) |
||||||
|
.ExecuteBufferedAsync(cancellationToken); |
||||||
|
|
||||||
|
// if (result.ExitCode == 0)
|
||||||
|
// {
|
||||||
|
// _logger.LogDebug("Successfully extracted attached fonts");
|
||||||
|
// }
|
||||||
|
// else
|
||||||
|
// {
|
||||||
|
// _logger.LogError("Failed to extract attached fonts. {Error}", result.StandardError);
|
||||||
|
// }
|
||||||
|
} |
||||||
|
|
||||||
|
return Unit.Default; |
||||||
|
} |
||||||
|
|
||||||
|
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" => $"{name}.srt", |
||||||
|
"ass" => $"{name}.ass", |
||||||
|
"webvtt" => $"{name}.vtt", |
||||||
|
_ => 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; |
||||||
|
} |
||||||
|
|
||||||
|
using var md5 = MD5.Create(); |
||||||
|
byte[] textData = Encoding.UTF8.GetBytes(text); |
||||||
|
byte[] hash = md5.ComputeHash(textData); |
||||||
|
return BitConverter.ToString(hash).Replace("-", string.Empty); |
||||||
|
} |
||||||
|
|
||||||
|
private record SubtitleToExtract(Subtitle Subtitle, string OutputPath); |
||||||
|
|
||||||
|
private record FontToExtract(MediaStream Stream, string OutputPath); |
||||||
|
} |
||||||
@ -0,0 +1,4 @@ |
|||||||
|
1 |
||||||
|
00:01:00,400 --> 00:03:00,300 |
||||||
|
This is an example of |
||||||
|
a subtitle. |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
namespace ErsatzTV.Core.Domain; |
||||||
|
|
||||||
|
public class Subtitle |
||||||
|
{ |
||||||
|
public int Id { get; set; } |
||||||
|
public SubtitleKind SubtitleKind { get; set; } |
||||||
|
public int StreamIndex { get; set; } |
||||||
|
public string Codec { get; set; } |
||||||
|
public bool Default { get; set; } |
||||||
|
public bool Forced { get; set; } |
||||||
|
public string Language { get; set; } |
||||||
|
public bool IsExtracted { get; set; } |
||||||
|
public string Path { get; set; } |
||||||
|
public DateTime DateAdded { get; set; } |
||||||
|
public DateTime DateUpdated { get; set; } |
||||||
|
public bool IsImage => Codec is "hdmv_pgs_subtitle" or "dvd_subtitle"; |
||||||
|
} |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
namespace ErsatzTV.Core.Domain; |
||||||
|
|
||||||
|
public enum SubtitleKind |
||||||
|
{ |
||||||
|
Embedded = 0, |
||||||
|
Sidecar = 1 |
||||||
|
} |
||||||
@ -0,0 +1,39 @@ |
|||||||
|
using System.Runtime.InteropServices; |
||||||
|
|
||||||
|
namespace ErsatzTV.FFmpeg.Filter; |
||||||
|
|
||||||
|
public class SubtitlesFilter : BaseFilter |
||||||
|
{ |
||||||
|
private readonly string _fontsDir; |
||||||
|
private readonly SubtitleInputFile _subtitleInputFile; |
||||||
|
|
||||||
|
public SubtitlesFilter(string fontsDir, SubtitleInputFile subtitleInputFile) |
||||||
|
{ |
||||||
|
_fontsDir = fontsDir; |
||||||
|
_subtitleInputFile = subtitleInputFile; |
||||||
|
} |
||||||
|
|
||||||
|
public override FrameState NextState(FrameState currentState) => currentState; |
||||||
|
|
||||||
|
public override string Filter |
||||||
|
{ |
||||||
|
get |
||||||
|
{ |
||||||
|
string fontsDir = _fontsDir; |
||||||
|
string effectiveFile = _subtitleInputFile.Path; |
||||||
|
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) |
||||||
|
{ |
||||||
|
fontsDir = fontsDir |
||||||
|
.Replace(@"\", @"/\") |
||||||
|
.Replace(@":/", @"\\:/"); |
||||||
|
|
||||||
|
effectiveFile = effectiveFile |
||||||
|
.Replace(@"\", @"/\") |
||||||
|
.Replace(@":/", @"\\:/"); |
||||||
|
} |
||||||
|
|
||||||
|
return $"subtitles={effectiveFile}:fontsdir={fontsDir}"; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
using ErsatzTV.FFmpeg.Environment; |
||||||
|
|
||||||
|
namespace ErsatzTV.FFmpeg.Option; |
||||||
|
|
||||||
|
public class CopyTimestampInputOption : IInputOption |
||||||
|
{ |
||||||
|
public IList<EnvironmentVariable> EnvironmentVariables => Array.Empty<EnvironmentVariable>(); |
||||||
|
public IList<string> GlobalOptions => Array.Empty<string>(); |
||||||
|
|
||||||
|
public IList<string> InputOptions(InputFile inputFile) => new List<string> { "-copyts" }; |
||||||
|
|
||||||
|
public IList<string> FilterOptions => Array.Empty<string>(); |
||||||
|
public IList<string> OutputOptions => Array.Empty<string>(); |
||||||
|
public FrameState NextState(FrameState currentState) => currentState; |
||||||
|
|
||||||
|
public bool AppliesTo(AudioInputFile audioInputFile) => false; |
||||||
|
|
||||||
|
public bool AppliesTo(VideoInputFile videoInputFile) => true; |
||||||
|
|
||||||
|
public bool AppliesTo(ConcatInputFile concatInputFile) => false; |
||||||
|
} |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
using ErsatzTV.FFmpeg.Environment; |
||||||
|
|
||||||
|
namespace ErsatzTV.FFmpeg.Option; |
||||||
|
|
||||||
|
public class StreamSeekFilterOption : IPipelineStep |
||||||
|
{ |
||||||
|
private readonly TimeSpan _start; |
||||||
|
|
||||||
|
public StreamSeekFilterOption(TimeSpan start) |
||||||
|
{ |
||||||
|
_start = start; |
||||||
|
} |
||||||
|
|
||||||
|
public IList<EnvironmentVariable> EnvironmentVariables => Array.Empty<EnvironmentVariable>(); |
||||||
|
public IList<string> GlobalOptions => Array.Empty<string>(); |
||||||
|
public IList<string> InputOptions(InputFile inputFile) => Array.Empty<string>(); |
||||||
|
|
||||||
|
public IList<string> FilterOptions => new List<string> { "-ss", $"{_start:c}" }; |
||||||
|
public IList<string> OutputOptions => Array.Empty<string>(); |
||||||
|
public FrameState NextState(FrameState currentState) => currentState; |
||||||
|
} |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using Microsoft.EntityFrameworkCore; |
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Data.Configurations; |
||||||
|
|
||||||
|
public class SubtitleConfiguration : IEntityTypeConfiguration<Subtitle> |
||||||
|
{ |
||||||
|
public void Configure(EntityTypeBuilder<Subtitle> builder) |
||||||
|
{ |
||||||
|
builder.ToTable("Subtitle"); |
||||||
|
|
||||||
|
builder.Property(s => s.IsExtracted) |
||||||
|
.HasDefaultValue(false); |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,127 @@ |
|||||||
|
using System; |
||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Migrations |
||||||
|
{ |
||||||
|
public partial class Add_MetadataSubtitles : Migration |
||||||
|
{ |
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.CreateTable( |
||||||
|
name: "Subtitle", |
||||||
|
columns: table => new |
||||||
|
{ |
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false) |
||||||
|
.Annotation("Sqlite:Autoincrement", true), |
||||||
|
Path = table.Column<string>(type: "TEXT", nullable: true), |
||||||
|
SubtitleKind = table.Column<int>(type: "INTEGER", nullable: false), |
||||||
|
DateAdded = table.Column<DateTime>(type: "TEXT", nullable: false), |
||||||
|
DateUpdated = table.Column<DateTime>(type: "TEXT", nullable: false), |
||||||
|
ArtistMetadataId = table.Column<int>(type: "INTEGER", nullable: true), |
||||||
|
EpisodeMetadataId = table.Column<int>(type: "INTEGER", nullable: true), |
||||||
|
MovieMetadataId = table.Column<int>(type: "INTEGER", nullable: true), |
||||||
|
MusicVideoMetadataId = table.Column<int>(type: "INTEGER", nullable: true), |
||||||
|
OtherVideoMetadataId = table.Column<int>(type: "INTEGER", nullable: true), |
||||||
|
SeasonMetadataId = table.Column<int>(type: "INTEGER", nullable: true), |
||||||
|
ShowMetadataId = table.Column<int>(type: "INTEGER", nullable: true), |
||||||
|
SongMetadataId = table.Column<int>(type: "INTEGER", nullable: true) |
||||||
|
}, |
||||||
|
constraints: table => |
||||||
|
{ |
||||||
|
table.PrimaryKey("PK_Subtitle", x => x.Id); |
||||||
|
table.ForeignKey( |
||||||
|
name: "FK_Subtitle_ArtistMetadata_ArtistMetadataId", |
||||||
|
column: x => x.ArtistMetadataId, |
||||||
|
principalTable: "ArtistMetadata", |
||||||
|
principalColumn: "Id"); |
||||||
|
table.ForeignKey( |
||||||
|
name: "FK_Subtitle_EpisodeMetadata_EpisodeMetadataId", |
||||||
|
column: x => x.EpisodeMetadataId, |
||||||
|
principalTable: "EpisodeMetadata", |
||||||
|
principalColumn: "Id", |
||||||
|
onDelete: ReferentialAction.Cascade); |
||||||
|
table.ForeignKey( |
||||||
|
name: "FK_Subtitle_MovieMetadata_MovieMetadataId", |
||||||
|
column: x => x.MovieMetadataId, |
||||||
|
principalTable: "MovieMetadata", |
||||||
|
principalColumn: "Id", |
||||||
|
onDelete: ReferentialAction.Cascade); |
||||||
|
table.ForeignKey( |
||||||
|
name: "FK_Subtitle_MusicVideoMetadata_MusicVideoMetadataId", |
||||||
|
column: x => x.MusicVideoMetadataId, |
||||||
|
principalTable: "MusicVideoMetadata", |
||||||
|
principalColumn: "Id", |
||||||
|
onDelete: ReferentialAction.Cascade); |
||||||
|
table.ForeignKey( |
||||||
|
name: "FK_Subtitle_OtherVideoMetadata_OtherVideoMetadataId", |
||||||
|
column: x => x.OtherVideoMetadataId, |
||||||
|
principalTable: "OtherVideoMetadata", |
||||||
|
principalColumn: "Id", |
||||||
|
onDelete: ReferentialAction.Cascade); |
||||||
|
table.ForeignKey( |
||||||
|
name: "FK_Subtitle_SeasonMetadata_SeasonMetadataId", |
||||||
|
column: x => x.SeasonMetadataId, |
||||||
|
principalTable: "SeasonMetadata", |
||||||
|
principalColumn: "Id"); |
||||||
|
table.ForeignKey( |
||||||
|
name: "FK_Subtitle_ShowMetadata_ShowMetadataId", |
||||||
|
column: x => x.ShowMetadataId, |
||||||
|
principalTable: "ShowMetadata", |
||||||
|
principalColumn: "Id"); |
||||||
|
table.ForeignKey( |
||||||
|
name: "FK_Subtitle_SongMetadata_SongMetadataId", |
||||||
|
column: x => x.SongMetadataId, |
||||||
|
principalTable: "SongMetadata", |
||||||
|
principalColumn: "Id"); |
||||||
|
}); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
name: "IX_Subtitle_ArtistMetadataId", |
||||||
|
table: "Subtitle", |
||||||
|
column: "ArtistMetadataId"); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
name: "IX_Subtitle_EpisodeMetadataId", |
||||||
|
table: "Subtitle", |
||||||
|
column: "EpisodeMetadataId"); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
name: "IX_Subtitle_MovieMetadataId", |
||||||
|
table: "Subtitle", |
||||||
|
column: "MovieMetadataId"); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
name: "IX_Subtitle_MusicVideoMetadataId", |
||||||
|
table: "Subtitle", |
||||||
|
column: "MusicVideoMetadataId"); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
name: "IX_Subtitle_OtherVideoMetadataId", |
||||||
|
table: "Subtitle", |
||||||
|
column: "OtherVideoMetadataId"); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
name: "IX_Subtitle_SeasonMetadataId", |
||||||
|
table: "Subtitle", |
||||||
|
column: "SeasonMetadataId"); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
name: "IX_Subtitle_ShowMetadataId", |
||||||
|
table: "Subtitle", |
||||||
|
column: "ShowMetadataId"); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
name: "IX_Subtitle_SongMetadataId", |
||||||
|
table: "Subtitle", |
||||||
|
column: "SongMetadataId"); |
||||||
|
} |
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.DropTable( |
||||||
|
name: "Subtitle"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,68 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Migrations |
||||||
|
{ |
||||||
|
public partial class Add_SubtitlesProperties : Migration |
||||||
|
{ |
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.AddColumn<string>( |
||||||
|
name: "Codec", |
||||||
|
table: "Subtitle", |
||||||
|
type: "TEXT", |
||||||
|
nullable: true); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>( |
||||||
|
name: "Default", |
||||||
|
table: "Subtitle", |
||||||
|
type: "INTEGER", |
||||||
|
nullable: false, |
||||||
|
defaultValue: false); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>( |
||||||
|
name: "Forced", |
||||||
|
table: "Subtitle", |
||||||
|
type: "INTEGER", |
||||||
|
nullable: false, |
||||||
|
defaultValue: false); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>( |
||||||
|
name: "Language", |
||||||
|
table: "Subtitle", |
||||||
|
type: "TEXT", |
||||||
|
nullable: true); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>( |
||||||
|
name: "StreamIndex", |
||||||
|
table: "Subtitle", |
||||||
|
type: "INTEGER", |
||||||
|
nullable: false, |
||||||
|
defaultValue: 0); |
||||||
|
} |
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "Codec", |
||||||
|
table: "Subtitle"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "Default", |
||||||
|
table: "Subtitle"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "Forced", |
||||||
|
table: "Subtitle"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "Language", |
||||||
|
table: "Subtitle"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "StreamIndex", |
||||||
|
table: "Subtitle"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,35 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Migrations |
||||||
|
{ |
||||||
|
public partial class Add_MediaStreamFileNameMimeType : Migration |
||||||
|
{ |
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.AddColumn<string>( |
||||||
|
name: "FileName", |
||||||
|
table: "MediaStream", |
||||||
|
type: "TEXT", |
||||||
|
nullable: true); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>( |
||||||
|
name: "MimeType", |
||||||
|
table: "MediaStream", |
||||||
|
type: "TEXT", |
||||||
|
nullable: true); |
||||||
|
} |
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "FileName", |
||||||
|
table: "MediaStream"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "MimeType", |
||||||
|
table: "MediaStream"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,26 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Migrations |
||||||
|
{ |
||||||
|
public partial class Add_SubtitleIsExtracted : Migration |
||||||
|
{ |
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.AddColumn<bool>( |
||||||
|
name: "IsExtracted", |
||||||
|
table: "Subtitle", |
||||||
|
type: "INTEGER", |
||||||
|
nullable: false, |
||||||
|
defaultValue: false); |
||||||
|
} |
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "IsExtracted", |
||||||
|
table: "Subtitle"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,54 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Migrations |
||||||
|
{ |
||||||
|
public partial class Sync_MediaStreamSubtitle : Migration |
||||||
|
{ |
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
var now = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.FFFFFFF"); |
||||||
|
|
||||||
|
// delete all subtitles
|
||||||
|
migrationBuilder.Sql("DELETE FROM Subtitle"); |
||||||
|
|
||||||
|
// sync media stream (kind == 3/subtitles) to subtitles table
|
||||||
|
migrationBuilder.Sql( |
||||||
|
$@"INSERT INTO Subtitle (Codec, `Default`, Forced, Language, StreamIndex, SubtitleKind, DateAdded, DateUpdated, EpisodeMetadataId)
|
||||||
|
SELECT Codec, `Default`, Forced, Language, `Index`, 0, '{now}', '{now}', EM.Id |
||||||
|
FROM MediaStream |
||||||
|
INNER JOIN MediaVersion MV on MV.Id = MediaStream.MediaVersionId |
||||||
|
INNER JOIN EpisodeMetadata EM on MV.EpisodeId = EM.EpisodeId |
||||||
|
WHERE MediaStreamKind = 3");
|
||||||
|
|
||||||
|
migrationBuilder.Sql( |
||||||
|
$@"INSERT INTO Subtitle (Codec, `Default`, Forced, Language, StreamIndex, SubtitleKind, DateAdded, DateUpdated, MovieMetadataId)
|
||||||
|
SELECT Codec, `Default`, Forced, Language, `Index`, 0, '{now}', '{now}', MM.Id |
||||||
|
FROM MediaStream |
||||||
|
INNER JOIN MediaVersion MV on MV.Id = MediaStream.MediaVersionId |
||||||
|
INNER JOIN MovieMetadata MM on MV.MovieId = MM.MovieId |
||||||
|
WHERE MediaStreamKind = 3");
|
||||||
|
|
||||||
|
migrationBuilder.Sql( |
||||||
|
$@"INSERT INTO Subtitle (Codec, `Default`, Forced, Language, StreamIndex, SubtitleKind, DateAdded, DateUpdated, MusicVideoMetadataId)
|
||||||
|
SELECT Codec, `Default`, Forced, Language, `Index`, 0, '{now}', '{now}', MVM.Id |
||||||
|
FROM MediaStream |
||||||
|
INNER JOIN MediaVersion MV on MV.Id = MediaStream.MediaVersionId |
||||||
|
INNER JOIN MusicVideoMetadata MVM on MV.MusicVideoId = MVM.MusicVideoId |
||||||
|
WHERE MediaStreamKind = 3");
|
||||||
|
|
||||||
|
migrationBuilder.Sql( |
||||||
|
$@"INSERT INTO Subtitle (Codec, `Default`, Forced, Language, StreamIndex, SubtitleKind, DateAdded, DateUpdated, OtherVideoMetadataId)
|
||||||
|
SELECT Codec, `Default`, Forced, Language, `Index`, 0, '{now}', '{now}', OVM.Id |
||||||
|
FROM MediaStream |
||||||
|
INNER JOIN MediaVersion MV on MV.Id = MediaStream.MediaVersionId |
||||||
|
INNER JOIN OtherVideoMetadata OVM on MV.OtherVideoId = OVM.OtherVideoId |
||||||
|
WHERE MediaStreamKind = 3");
|
||||||
|
} |
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,66 @@ |
|||||||
|
using System.Threading.Channels; |
||||||
|
using Bugsnag; |
||||||
|
using ErsatzTV.Application; |
||||||
|
using ErsatzTV.Application.Subtitles; |
||||||
|
using MediatR; |
||||||
|
|
||||||
|
namespace ErsatzTV.Services; |
||||||
|
|
||||||
|
public class SubtitleWorkerService : BackgroundService |
||||||
|
{ |
||||||
|
private readonly ChannelReader<ISubtitleWorkerRequest> _channel; |
||||||
|
private readonly ILogger<SubtitleWorkerService> _logger; |
||||||
|
private readonly IServiceScopeFactory _serviceScopeFactory; |
||||||
|
|
||||||
|
public SubtitleWorkerService( |
||||||
|
ChannelReader<ISubtitleWorkerRequest> channel, |
||||||
|
IServiceScopeFactory serviceScopeFactory, |
||||||
|
ILogger<SubtitleWorkerService> logger) |
||||||
|
{ |
||||||
|
_channel = channel; |
||||||
|
_serviceScopeFactory = serviceScopeFactory; |
||||||
|
_logger = logger; |
||||||
|
} |
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
_logger.LogInformation("Subtitle worker service started"); |
||||||
|
|
||||||
|
await foreach (ISubtitleWorkerRequest request in _channel.ReadAllAsync(cancellationToken)) |
||||||
|
{ |
||||||
|
using IServiceScope scope = _serviceScopeFactory.CreateScope(); |
||||||
|
|
||||||
|
try |
||||||
|
{ |
||||||
|
switch (request) |
||||||
|
{ |
||||||
|
case ExtractEmbeddedSubtitles extractEmbeddedSubtitles: |
||||||
|
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>(); |
||||||
|
await mediator.Send(extractEmbeddedSubtitles, cancellationToken); |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
_logger.LogWarning(ex, "Failed to handle subtitle worker request"); |
||||||
|
|
||||||
|
try |
||||||
|
{ |
||||||
|
IClient client = scope.ServiceProvider.GetRequiredService<IClient>(); |
||||||
|
client.Notify(ex); |
||||||
|
} |
||||||
|
catch (Exception) |
||||||
|
{ |
||||||
|
// do nothing
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) |
||||||
|
{ |
||||||
|
_logger.LogInformation("Subtitle worker service shutting down"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue