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 @@
@@ -0,0 +1,5 @@
|
||||
namespace ErsatzTV.Application; |
||||
|
||||
public interface ISubtitleWorkerRequest |
||||
{ |
||||
} |
@ -0,0 +1,6 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1,4 @@
|
||||
1 |
||||
00:01:00,400 --> 00:03:00,300 |
||||
This is an example of |
||||
a subtitle. |
@ -0,0 +1,17 @@
@@ -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 @@
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Domain; |
||||
|
||||
public enum SubtitleKind |
||||
{ |
||||
Embedded = 0, |
||||
Sidecar = 1 |
||||
} |
@ -0,0 +1,39 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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