Browse Source

add support for embedded text subtitles (#742)

* first pass at text subtitle support

* support text subtitles from movies, music videos and other videos

* fixes

* qsv fixes

* vaapi fixes

* update changelog
pull/744/head
Jason Dove 3 years ago committed by GitHub
parent
commit
e250e93a8e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      CHANGELOG.md
  2. 23
      ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
  3. 1
      ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings
  4. 5
      ErsatzTV.Application/ISubtitleWorkerRequest.cs
  5. 12
      ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs
  6. 33
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  7. 6
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitles.cs
  8. 398
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs
  9. 3
      ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
  10. 71
      ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs
  11. 4
      ErsatzTV.Core.Tests/Resources/test.srt
  12. 2
      ErsatzTV.Core/Domain/MediaItem/MediaStream.cs
  13. 3
      ErsatzTV.Core/Domain/MediaItem/MediaStreamKind.cs
  14. 1
      ErsatzTV.Core/Domain/Metadata/Metadata.cs
  15. 17
      ErsatzTV.Core/Domain/Metadata/Subtitle.cs
  16. 7
      ErsatzTV.Core/Domain/Metadata/SubtitleKind.cs
  17. 45
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  18. 56
      ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs
  19. 5
      ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs
  20. 5
      ErsatzTV.Core/FileSystemLayout.cs
  21. 1
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  22. 1
      ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs
  23. 21
      ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs
  24. 50
      ErsatzTV.Core/Metadata/MovieFolderScanner.cs
  25. 60
      ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs
  26. 49
      ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs
  27. 44
      ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs
  28. 8
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  29. 7
      ErsatzTV.FFmpeg.Tests/PipelineBuilderTests.cs
  30. 2
      ErsatzTV.FFmpeg/CommandGenerator.cs
  31. 5
      ErsatzTV.FFmpeg/Decoder/AvailableDecoders.cs
  32. 8
      ErsatzTV.FFmpeg/Encoder/AvailableEncoders.cs
  33. 29
      ErsatzTV.FFmpeg/Encoder/Qsv/EncoderH264Qsv.cs
  34. 28
      ErsatzTV.FFmpeg/Encoder/Qsv/EncoderHevcQsv.cs
  35. 6
      ErsatzTV.FFmpeg/Encoder/Vaapi/EncoderH264Vaapi.cs
  36. 6
      ErsatzTV.FFmpeg/Encoder/Vaapi/EncoderHevcVaapi.cs
  37. 5
      ErsatzTV.FFmpeg/Filter/AvailableDeinterlaceFilters.cs
  38. 4
      ErsatzTV.FFmpeg/Filter/AvailableSubtitleOverlayFilters.cs
  39. 51
      ErsatzTV.FFmpeg/Filter/ComplexFilter.cs
  40. 2
      ErsatzTV.FFmpeg/Filter/HardwareUploadFilter.cs
  41. 2
      ErsatzTV.FFmpeg/Filter/Qsv/DeinterlaceQsvFilter.cs
  42. 10
      ErsatzTV.FFmpeg/Filter/Qsv/OverlaySubtitleQsvFilter.cs
  43. 4
      ErsatzTV.FFmpeg/Filter/Qsv/ScaleQsvFilter.cs
  44. 2
      ErsatzTV.FFmpeg/Filter/SubtitleHardwareUploadFilter.cs
  45. 39
      ErsatzTV.FFmpeg/Filter/SubtitlesFilter.cs
  46. 2
      ErsatzTV.FFmpeg/Filter/WatermarkHardwareUploadFilter.cs
  47. 21
      ErsatzTV.FFmpeg/Option/CopyTimestampInputOption.cs
  48. 21
      ErsatzTV.FFmpeg/Option/StreamSeekFilterOption.cs
  49. 95
      ErsatzTV.FFmpeg/PipelineBuilder.cs
  50. 4
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/EpisodeMetadataConfiguration.cs
  51. 4
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/MovieMetadataConfiguration.cs
  52. 4
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/MusicVideoMetadataConfiguration.cs
  53. 4
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/OtherVideoMetadataConfiguration.cs
  54. 16
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/SubtitleConfiguration.cs
  55. 63
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  56. 4033
      ErsatzTV.Infrastructure/Migrations/20220417230100_Add_MetadataSubtitles.Designer.cs
  57. 127
      ErsatzTV.Infrastructure/Migrations/20220417230100_Add_MetadataSubtitles.cs
  58. 4048
      ErsatzTV.Infrastructure/Migrations/20220418001349_Add_SubtitlesProperties.Designer.cs
  59. 68
      ErsatzTV.Infrastructure/Migrations/20220418001349_Add_SubtitlesProperties.cs
  60. 4054
      ErsatzTV.Infrastructure/Migrations/20220418142642_Add_MediaStreamFileNameMimeType.Designer.cs
  61. 35
      ErsatzTV.Infrastructure/Migrations/20220418142642_Add_MediaStreamFileNameMimeType.cs
  62. 4059
      ErsatzTV.Infrastructure/Migrations/20220418162128_Add_SubtitleIsExtracted.Designer.cs
  63. 26
      ErsatzTV.Infrastructure/Migrations/20220418162128_Add_SubtitleIsExtracted.cs
  64. 4059
      ErsatzTV.Infrastructure/Migrations/20220419033955_Sync_MediaStreamSubtitle.Designer.cs
  65. 54
      ErsatzTV.Infrastructure/Migrations/20220419033955_Sync_MediaStreamSubtitle.cs
  66. 144
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  67. 66
      ErsatzTV/Services/SubtitleWorkerService.cs
  68. 7
      ErsatzTV/Startup.cs

2
CHANGELOG.md

@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Add support for burning in embedded text subtitles
## [0.5.1-beta] - 2022-04-17
### Fixed

23
ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs

@ -1,20 +1,29 @@ @@ -1,20 +1,29 @@
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Channels.Mapper;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
{
private readonly ChannelWriter<ISubtitleWorkerRequest> _ffmpegWorkerChannel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public UpdateChannelHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public UpdateChannelHandler(
ChannelWriter<ISubtitleWorkerRequest> ffmpegWorkerChannel,
IDbContextFactory<TvContext> dbContextFactory)
{
_ffmpegWorkerChannel = ffmpegWorkerChannel;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, ChannelViewModel>> Handle(
UpdateChannel request,
@ -65,6 +74,18 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr @@ -65,6 +74,18 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
await dbContext.SaveChangesAsync();
if (c.SubtitleMode != ChannelSubtitleMode.None)
{
Option<Playout> maybePlayout = await dbContext.Playouts
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id);
foreach (Playout playout in maybePlayout)
{
await _ffmpegWorkerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
}
}
return ProjectToViewModel(c);
}

1
ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings

@ -38,6 +38,7 @@ @@ -38,6 +38,7 @@
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=search_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=subtitles_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=television_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Cqueries/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

5
ErsatzTV.Application/ISubtitleWorkerRequest.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
namespace ErsatzTV.Application;
public interface ISubtitleWorkerRequest
{
}

12
ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
using Bugsnag;
using System.Threading.Channels;
using Bugsnag;
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
@ -9,23 +11,26 @@ using Microsoft.EntityFrameworkCore; @@ -9,23 +11,26 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts;
public class BuildPlayoutHandler : MediatR.IRequestHandler<BuildPlayout, Either<BaseError, Unit>>
public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseError, Unit>>
{
private readonly IClient _client;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IPlayoutBuilder _playoutBuilder;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly ChannelWriter<ISubtitleWorkerRequest> _ffmpegWorkerChannel;
public BuildPlayoutHandler(
IClient client,
IDbContextFactory<TvContext> dbContextFactory,
IPlayoutBuilder playoutBuilder,
IFFmpegSegmenterService ffmpegSegmenterService)
IFFmpegSegmenterService ffmpegSegmenterService,
ChannelWriter<ISubtitleWorkerRequest> ffmpegWorkerChannel)
{
_client = client;
_dbContextFactory = dbContextFactory;
_playoutBuilder = playoutBuilder;
_ffmpegSegmenterService = ffmpegSegmenterService;
_ffmpegWorkerChannel = ffmpegWorkerChannel;
}
public async Task<Either<BaseError, Unit>> Handle(BuildPlayout request, CancellationToken cancellationToken)
@ -44,6 +49,7 @@ public class BuildPlayoutHandler : MediatR.IRequestHandler<BuildPlayout, Either< @@ -44,6 +49,7 @@ public class BuildPlayoutHandler : MediatR.IRequestHandler<BuildPlayout, Either<
{
_ffmpegSegmenterService.PlayoutUpdated(playout.Channel.Number);
}
await _ffmpegWorkerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
}
catch (Exception ex)
{

33
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -64,6 +64,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -64,6 +64,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
DateTimeOffset now = request.Now;
Either<BaseError, PlayoutItemWithPath> maybePlayoutItem = await dbContext.PlayoutItems
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Subtitles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
@ -71,18 +74,27 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -71,18 +74,27 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.ThenInclude(mi => (mi as Episode).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Movie).MovieMetadata)
.ThenInclude(mm => mm.Subtitles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Movie).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Movie).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Subtitles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Subtitles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
.ThenInclude(ov => ov.MediaFiles)
.Include(i => i.MediaItem)
@ -139,6 +151,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -139,6 +151,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
List<Subtitle> subtitles = GetSubtitles(playoutItemWithPath.PlayoutItem.MediaItem);
Command process = await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
ffprobePath,
@ -148,6 +162,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -148,6 +162,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
audioVersion,
videoPath,
audioPath,
subtitles,
playoutItemWithPath.PlayoutItem.StartOffset,
playoutItemWithPath.PlayoutItem.FinishOffset,
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
@ -220,6 +235,24 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -220,6 +235,24 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
return BaseError.New($"Unexpected error locating playout item for channel {channel.Number}");
}
private static List<Subtitle> GetSubtitles(MediaItem mediaItem) =>
mediaItem switch
{
Episode episode => episode.EpisodeMetadata.HeadOrNone()
.Map(mm => mm.Subtitles)
.IfNone(new List<Subtitle>()),
Movie movie => movie.MovieMetadata.HeadOrNone()
.Map(mm => mm.Subtitles)
.IfNone(new List<Subtitle>()),
MusicVideo musicVideo => musicVideo.MusicVideoMetadata.HeadOrNone()
.Map(mm => mm.Subtitles)
.IfNone(new List<Subtitle>()),
OtherVideo otherVideo => otherVideo.OtherVideoMetadata.HeadOrNone()
.Map(mm => mm.Subtitles)
.IfNone(new List<Subtitle>()),
_ => new List<Subtitle>()
};
private async Task<Either<BaseError, PlayoutItemWithPath>> CheckForFallbackFiller(
TvContext dbContext,
Channel channel,

6
ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitles.cs

@ -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;

398
ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs

@ -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);
}

3
ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj

@ -40,6 +40,9 @@ @@ -40,6 +40,9 @@
<Content Include="Resources\test.sup">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Resources\test.srt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

71
ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs

@ -68,7 +68,8 @@ public class TranscodingTests @@ -68,7 +68,8 @@ public class TranscodingTests
public enum Subtitle
{
None,
Picture
Picture,
Text
}
private class TestData
@ -83,7 +84,8 @@ public class TranscodingTests @@ -83,7 +84,8 @@ public class TranscodingTests
public static Subtitle[] Subtitles =
{
Subtitle.None,
Subtitle.Picture
Subtitle.Picture,
Subtitle.Text
};
public static Padding[] Paddings =
@ -104,24 +106,24 @@ public class TranscodingTests @@ -104,24 +106,24 @@ public class TranscodingTests
new("libx264", "yuvj420p"),
new("libx264", "yuv420p10le"),
// new("libx264", "yuv444p10le"),
new("mpeg1video", "yuv420p"),
new("mpeg2video", "yuv420p"),
new("libx265", "yuv420p"),
new("libx265", "yuv420p10le"),
new("mpeg4", "yuv420p"),
new("libvpx-vp9", "yuv420p"),
// new("libaom-av1", "yuv420p")
// av1 yuv420p10le 51
new("msmpeg4v2", "yuv420p"),
new("msmpeg4v3", "yuv420p")
// wmv3 yuv420p 1
};
@ -219,12 +221,15 @@ public class TranscodingTests @@ -219,12 +221,15 @@ public class TranscodingTests
switch (subtitle)
{
case Subtitle.Picture:
case Subtitle.Text or Subtitle.Picture:
string sourceFile = Path.GetTempFileName() + ".mkv";
File.Move(file, sourceFile, true);
string tempFileName = Path.GetTempFileName() + ".mkv";
string subPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "test.sup");
string subPath = Path.Combine(
TestContext.CurrentContext.TestDirectory,
"Resources",
subtitle == Subtitle.Picture ? "test.sup" : "test.srt");
var p2 = new Process
{
StartInfo = new ProcessStartInfo
@ -288,7 +293,8 @@ public class TranscodingTests @@ -288,7 +293,8 @@ public class TranscodingTests
MediaFiles = new List<MediaFile>
{
new() { Path = file }
}
},
Streams = new List<MediaStream>()
};
var metadataRepository = new Mock<IMetadataRepository>();
@ -324,6 +330,31 @@ public class TranscodingTests @@ -324,6 +330,31 @@ public class TranscodingTests
}
});
var subtitleStreams = v.Streams
.Filter(s => s.MediaStreamKind == MediaStreamKind.Subtitle)
.ToList();
var subtitles = new List<Core.Domain.Subtitle>();
foreach (MediaStream stream in subtitleStreams)
{
var s = new Core.Domain.Subtitle
{
Codec = stream.Codec,
Default = stream.Default,
Forced = stream.Forced,
Language = stream.Language,
StreamIndex = stream.Index,
SubtitleKind = SubtitleKind.Embedded,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow,
Path = "test.srt",
IsExtracted = true
};
subtitles.Add(s);
}
DateTimeOffset now = DateTimeOffset.Now;
Option<ChannelWatermark> channelWatermark = Option<ChannelWatermark>.None;
@ -373,10 +404,18 @@ public class TranscodingTests @@ -373,10 +404,18 @@ public class TranscodingTests
ChannelSubtitleMode subtitleMode = subtitle switch
{
Subtitle.Picture => ChannelSubtitleMode.Any,
Subtitle.Picture or Subtitle.Text => ChannelSubtitleMode.Any,
_ => ChannelSubtitleMode.None
};
string srtFile = Path.Combine(FileSystemLayout.SubtitleCacheFolder, "test.srt");
if (subtitle == Subtitle.Text && !File.Exists(srtFile))
{
string sourceFile = Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "test.srt");
Directory.CreateDirectory(FileSystemLayout.SubtitleCacheFolder);
File.Copy(sourceFile, srtFile, true);
}
Command process = await service.ForPlayoutItem(
ExecutableName("ffmpeg"),
ExecutableName("ffprobe"),
@ -398,6 +437,7 @@ public class TranscodingTests @@ -398,6 +437,7 @@ public class TranscodingTests
v,
file,
file,
subtitles,
now,
now + TimeSpan.FromSeconds(5),
now,
@ -418,7 +458,8 @@ public class TranscodingTests @@ -418,7 +458,8 @@ public class TranscodingTests
{
"No support for codec",
"No usable",
"Provided device doesn't support"
"Provided device doesn't support",
"Current pixel format is unsupported"
};
var sb = new StringBuilder();

4
ErsatzTV.Core.Tests/Resources/test.srt

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
1
00:01:00,400 --> 00:03:00,300
This is an example of
a subtitle.

2
ErsatzTV.Core/Domain/MediaItem/MediaStream.cs

@ -15,6 +15,8 @@ public class MediaStream @@ -15,6 +15,8 @@ public class MediaStream
public bool AttachedPic { get; set; }
public string PixelFormat { get; set; }
public int BitsPerRawSample { get; set; }
public string FileName { get; set; }
public string MimeType { get; set; }
public int MediaVersionId { get; set; }
public MediaVersion MediaVersion { get; set; }
}

3
ErsatzTV.Core/Domain/MediaItem/MediaStreamKind.cs

@ -4,5 +4,6 @@ public enum MediaStreamKind @@ -4,5 +4,6 @@ public enum MediaStreamKind
{
Video = 1,
Audio = 2,
Subtitle = 3
Subtitle = 3,
Attachment = 4
}

1
ErsatzTV.Core/Domain/Metadata/Metadata.cs

@ -17,4 +17,5 @@ public class Metadata @@ -17,4 +17,5 @@ public class Metadata
public List<Studio> Studios { get; set; }
public List<Actor> Actors { get; set; }
public List<MetadataGuid> Guids { get; set; }
public List<Subtitle> Subtitles { get; set; }
}

17
ErsatzTV.Core/Domain/Metadata/Subtitle.cs

@ -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";
}

7
ErsatzTV.Core/Domain/Metadata/SubtitleKind.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum SubtitleKind
{
Embedded = 0,
Sidecar = 1
}

45
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -40,6 +40,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -40,6 +40,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
MediaVersion audioVersion,
string videoPath,
string audioPath,
List<Subtitle> subtitles,
DateTimeOffset start,
DateTimeOffset finish,
DateTimeOffset now,
@ -130,22 +131,37 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -130,22 +131,37 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return new AudioInputFile(audioPath, new List<AudioStream> { ffmpegAudioStream }, audioState);
});
Option<SubtitleInputFile> subtitleInputFile = maybeSubtitleStream.Map(
Option<SubtitleInputFile> subtitleInputFile = maybeSubtitleStream.Map<Option<SubtitleInputFile>>(
subtitleStream =>
{
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(
subtitleStream.Index,
subtitleStream.Codec,
StreamKind.Video);
return new SubtitleInputFile(
videoPath,
new List<ErsatzTV.FFmpeg.MediaStream> { ffmpegSubtitleStream },
false);
foreach (Subtitle subtitle in Optional(subtitles.Find(s => s.StreamIndex == subtitleStream.Index)))
{
if (!subtitle.IsImage && !subtitle.IsExtracted)
{
_logger.LogWarning("Subtitles are not yet available for this item");
return None;
}
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(
subtitle.IsImage ? subtitleStream.Index : 0,
subtitleStream.Codec,
StreamKind.Video);
string path = subtitle.IsImage
? videoPath
: Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.Path);
return new SubtitleInputFile(
path,
new List<ErsatzTV.FFmpeg.MediaStream> { ffmpegSubtitleStream },
false);
}
return None;
// TODO: figure out HLS direct
// channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect);
});
}).Flatten();
Option<WatermarkInputFile> watermarkInputFile = GetWatermarkInputFile(watermarkOptions, maybeFadePoints);
@ -214,13 +230,14 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -214,13 +230,14 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
ptsOffset);
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
var pipelineBuilder = new PipelineBuilder(
videoInputFile,
audioInputFile,
watermarkInputFile,
subtitleInputFile,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
_logger);
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
@ -312,6 +329,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -312,6 +329,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
None,
None,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
_logger);
FFmpegPipeline pipeline = pipelineBuilder.Concat(
@ -336,6 +354,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -336,6 +354,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
None,
None,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
_logger);
FFmpegPipeline pipeline = pipelineBuilder.Resize(outputFile, new FrameSize(-1, height));

56
ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs

@ -87,7 +87,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector @@ -87,7 +87,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
{
return None;
}
if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect &&
string.IsNullOrWhiteSpace(channel.PreferredSubtitleLanguageCode))
{
@ -99,7 +99,6 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector @@ -99,7 +99,6 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
var subtitleStreams = version.Streams
.Filter(s => s.MediaStreamKind == MediaStreamKind.Subtitle)
.Filter(s => s.Codec is "hdmv_pgs_subtitle" or "dvd_subtitle")
.ToList();
string language = (channel.PreferredSubtitleLanguageCode ?? string.Empty).ToLowerInvariant();
@ -117,32 +116,39 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector @@ -117,32 +116,39 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
.ToList();
}
if (subtitleStreams.Count == 0)
if (subtitleStreams.Count > 0)
{
return None;
switch (channel.SubtitleMode)
{
case ChannelSubtitleMode.Forced:
foreach (MediaStream stream in subtitleStreams.OrderBy(s => s.Index).Find(s => s.Forced))
{
return stream;
}
break;
case ChannelSubtitleMode.Default:
foreach (MediaStream stream in subtitleStreams.OrderBy(s => s.Default ? 0 : 1).ThenBy(s => s.Index))
{
return stream;
}
break;
case ChannelSubtitleMode.Any:
foreach (MediaStream stream in subtitleStreams.OrderBy(s => s.Index).HeadOrNone())
{
return stream;
}
break;
}
}
switch (channel.SubtitleMode)
{
case ChannelSubtitleMode.Forced:
foreach (MediaStream stream in Optional(subtitleStreams.OrderBy(s => s.Index).Find(s => s.Forced)))
{
return stream;
}
break;
case ChannelSubtitleMode.Default:
foreach (MediaStream stream in Optional(subtitleStreams.OrderBy(s => s.Index).Find(s => s.Default)))
{
return stream;
}
break;
case ChannelSubtitleMode.Any:
foreach (MediaStream stream in subtitleStreams.OrderBy(s => s.Index).HeadOrNone())
{
return stream;
}
break;
}
_logger.LogDebug(
"Found no subtitles for channel {ChannelNumber} with mode {Mode} matching language {Language}",
channel.Number,
channel.SubtitleMode,
channel.PreferredSubtitleLanguageCode);
return None;
}

5
ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs

@ -113,6 +113,11 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter @@ -113,6 +113,11 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
playlist += "#EXT-X-DISCONTINUITY" + Environment.NewLine;
}
if (playlist.Trim().Split(Environment.NewLine).All(l => l.StartsWith('#')))
{
throw new Exception("Trimming playlist to nothing");
}
return new TrimPlaylistResult(nextPlaylistStart, startSequence, playlist, segments);
}
catch (Exception ex)

5
ErsatzTV.Core/FileSystemLayout.cs

@ -40,4 +40,9 @@ public static class FileSystemLayout @@ -40,4 +40,9 @@ public static class FileSystemLayout
public static readonly string LogoCacheFolder = Path.Combine(ArtworkCacheFolder, "logos");
public static readonly string FanArtCacheFolder = Path.Combine(ArtworkCacheFolder, "fanart");
public static readonly string WatermarkCacheFolder = Path.Combine(ArtworkCacheFolder, "watermarks");
public static readonly string StreamsCacheFolder = Path.Combine(AppDataFolder, "cache", "streams");
public static readonly string SubtitleCacheFolder = Path.Combine(StreamsCacheFolder, "subtitles");
public static readonly string FontsCacheFolder = Path.Combine(StreamsCacheFolder, "fonts");
}

1
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs

@ -17,6 +17,7 @@ public interface IFFmpegProcessService @@ -17,6 +17,7 @@ public interface IFFmpegProcessService
MediaVersion audioVersion,
string videoPath,
string audioPath,
List<Subtitle> subtitles,
DateTimeOffset start,
DateTimeOffset finish,
DateTimeOffset now,

1
ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs

@ -35,4 +35,5 @@ public interface IMetadataRepository @@ -35,4 +35,5 @@ public interface IMetadataRepository
Task<bool> AddGuid(Domain.Metadata metadata, MetadataGuid guid);
Task<bool> RemoveDirector(Director director);
Task<bool> RemoveWriter(Writer writer);
Task<bool> UpdateSubtitles(Domain.Metadata metadata, List<Subtitle> subtitles);
}

21
ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs

@ -374,6 +374,25 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -374,6 +374,25 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
version.Streams.Add(stream);
}
foreach (FFprobeStream attachmentStream in json.streams.Filter(s => s.codec_type == "attachment"))
{
var stream = new MediaStream
{
MediaVersionId = version.Id,
MediaStreamKind = MediaStreamKind.Attachment,
Index = attachmentStream.index,
Codec = attachmentStream.codec_name
};
if (attachmentStream.tags is not null)
{
stream.FileName = attachmentStream.tags.filename;
stream.MimeType = attachmentStream.tags.mimetype;
}
version.Streams.Add(stream);
}
foreach (FFprobeChapter probedChapter in json.chapters)
{
if (double.TryParse(
@ -440,7 +459,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -440,7 +459,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
public record FFprobeDisposition(int @default, int forced, int attached_pic);
public record FFprobeTags(string language, string title);
public record FFprobeTags(string language, string title, string filename, string mimetype);
public record FFprobeFormatTags(
string title,

50
ErsatzTV.Core/Metadata/MovieFolderScanner.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using Bugsnag;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
@ -16,6 +17,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner @@ -16,6 +17,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
private readonly ILibraryRepository _libraryRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly IMetadataRepository _metadataRepository;
private readonly ILogger<MovieFolderScanner> _logger;
private readonly IMediator _mediator;
private readonly IClient _client;
@ -53,6 +55,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner @@ -53,6 +55,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
_localFileSystem = localFileSystem;
_movieRepository = movieRepository;
_localMetadataProvider = localMetadataProvider;
_metadataRepository = metadataRepository;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_libraryRepository = libraryRepository;
@ -136,6 +139,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner @@ -136,6 +139,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
.BindT(UpdateMetadata)
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster, cancellationToken))
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt, cancellationToken))
.BindT(movie => UpdateSubtitles(movie, cancellationToken))
.BindT(FlagNormal);
await maybeMovie.Match(
@ -251,6 +255,52 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner @@ -251,6 +255,52 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
}
}
private async Task<Either<BaseError, MediaItemScanResult<Movie>>> UpdateSubtitles(
MediaItemScanResult<Movie> result,
CancellationToken _)
{
try
{
Movie movie = result.Item;
foreach (MovieMetadata metadata in movie.MovieMetadata)
{
MediaVersion version = movie.GetHeadVersion();
var subtitleStreams = version.Streams
.Filter(s => s.MediaStreamKind == MediaStreamKind.Subtitle)
.ToList();
var subtitles = new List<Subtitle>();
foreach (MediaStream stream in subtitleStreams)
{
var subtitle = new Subtitle
{
Codec = stream.Codec,
Default = stream.Default,
Forced = stream.Forced,
Language = stream.Language,
StreamIndex = stream.Index,
SubtitleKind = SubtitleKind.Embedded,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
};
subtitles.Add(subtitle);
}
await _metadataRepository.UpdateSubtitles(metadata, subtitles);
}
return result;
}
catch (Exception ex)
{
_client.Notify(ex);
return BaseError.New(ex.ToString());
}
}
private Option<string> LocateNfoFile(Movie movie)
{
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;

60
ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using Bugsnag;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
@ -16,6 +17,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -16,6 +17,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
private readonly ILibraryRepository _libraryRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly IMetadataRepository _metadataRepository;
private readonly ILogger<MusicVideoFolderScanner> _logger;
private readonly IMediator _mediator;
private readonly IClient _client;
@ -52,6 +54,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -52,6 +54,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
{
_localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider;
_metadataRepository = metadataRepository;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_artistRepository = artistRepository;
@ -88,8 +91,14 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -88,8 +91,14 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
Either<BaseError, MediaItemScanResult<Artist>> maybeArtist =
await FindOrCreateArtist(libraryPath.Id, artistFolder)
.BindT(artist => UpdateMetadataForArtist(artist, artistFolder))
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.Thumbnail, cancellationToken))
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.FanArt, cancellationToken));
.BindT(
artist => UpdateArtworkForArtist(
artist,
artistFolder,
ArtworkKind.Thumbnail,
cancellationToken))
.BindT(
artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.FanArt, cancellationToken));
await maybeArtist.Match(
async result =>
@ -281,6 +290,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -281,6 +290,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
.BindT(musicVideo => UpdateStatistics(musicVideo, ffmpegPath, ffprobePath))
.BindT(UpdateMetadata)
.BindT(result => UpdateThumbnail(result, cancellationToken))
.BindT(result => UpdateSubtitles(result, cancellationToken))
.BindT(FlagNormal);
await maybeMusicVideo.Match(
@ -403,6 +413,52 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -403,6 +413,52 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
return BaseError.New(ex.ToString());
}
}
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateSubtitles(
MediaItemScanResult<MusicVideo> result,
CancellationToken _)
{
try
{
MusicVideo musicVideo = result.Item;
foreach (MusicVideoMetadata metadata in musicVideo.MusicVideoMetadata)
{
MediaVersion version = musicVideo.GetHeadVersion();
var subtitleStreams = version.Streams
.Filter(s => s.MediaStreamKind == MediaStreamKind.Subtitle)
.ToList();
var subtitles = new List<Subtitle>();
foreach (MediaStream stream in subtitleStreams)
{
var subtitle = new Subtitle
{
Codec = stream.Codec,
Default = stream.Default,
Forced = stream.Forced,
Language = stream.Language,
StreamIndex = stream.Index,
SubtitleKind = SubtitleKind.Embedded,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
};
subtitles.Add(subtitle);
}
await _metadataRepository.UpdateSubtitles(metadata, subtitles);
}
return result;
}
catch (Exception ex)
{
_client.Notify(ex);
return BaseError.New(ex.ToString());
}
}
private Option<string> LocateThumbnail(MusicVideo musicVideo)
{

49
ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using Bugsnag;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
@ -14,6 +15,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -14,6 +15,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly IMetadataRepository _metadataRepository;
private readonly IMediator _mediator;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
@ -50,6 +52,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -50,6 +52,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
{
_localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider;
_metadataRepository = metadataRepository;
_mediator = mediator;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
@ -128,6 +131,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -128,6 +131,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
.BindT(UpdateMetadata)
.BindT(UpdateSubtitles)
.BindT(FlagNormal);
await maybeVideo.Match(
@ -200,4 +204,49 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -200,4 +204,49 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
return BaseError.New(ex.ToString());
}
}
private async Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> UpdateSubtitles(
MediaItemScanResult<OtherVideo> result)
{
try
{
OtherVideo otherVideo = result.Item;
foreach (OtherVideoMetadata metadata in otherVideo.OtherVideoMetadata)
{
MediaVersion version = otherVideo.GetHeadVersion();
var subtitleStreams = version.Streams
.Filter(s => s.MediaStreamKind == MediaStreamKind.Subtitle)
.ToList();
var subtitles = new List<Subtitle>();
foreach (MediaStream stream in subtitleStreams)
{
var subtitle = new Subtitle
{
Codec = stream.Codec,
Default = stream.Default,
Forced = stream.Forced,
Language = stream.Language,
StreamIndex = stream.Index,
SubtitleKind = SubtitleKind.Embedded,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
};
subtitles.Add(subtitle);
}
await _metadataRepository.UpdateSubtitles(metadata, subtitles);
}
return result;
}
catch (Exception ex)
{
_client.Notify(ex);
return BaseError.New(ex.ToString());
}
}
}

44
ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using Bugsnag;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
@ -236,6 +237,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan @@ -236,6 +237,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
.MapT(_ => episode))
.BindT(UpdateMetadata)
.BindT(e => UpdateThumbnail(e, cancellationToken))
.BindT(e => UpdateSubtitles(e, cancellationToken))
.BindT(e => FlagNormal(new MediaItemScanResult<Episode>(e)))
.MapT(r => r.Item);
@ -435,6 +437,48 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan @@ -435,6 +437,48 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
return BaseError.New(ex.ToString());
}
}
private async Task<Either<BaseError, Episode>> UpdateSubtitles(Episode episode, CancellationToken _)
{
try
{
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
{
MediaVersion version = episode.GetHeadVersion();
var subtitleStreams = version.Streams
.Filter(s => s.MediaStreamKind == MediaStreamKind.Subtitle)
.ToList();
var subtitles = new List<Subtitle>();
foreach (MediaStream stream in subtitleStreams)
{
var subtitle = new Subtitle
{
Codec = stream.Codec,
Default = stream.Default,
Forced = stream.Forced,
Language = stream.Language,
StreamIndex = stream.Index,
SubtitleKind = SubtitleKind.Embedded,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
};
subtitles.Add(subtitle);
}
await _metadataRepository.UpdateSubtitles(metadata, subtitles);
}
return episode;
}
catch (Exception ex)
{
_client.Notify(ex);
return BaseError.New(ex.ToString());
}
}
private Option<string> LocateNfoFileForShow(string showFolder) =>
Optional(Path.Combine(showFolder, "tvshow.nfo"))

8
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -648,10 +648,10 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -648,10 +648,10 @@ public class PlayoutBuilder : IPlayoutBuilder
&& a.SmartCollectionId == collectionKey.SmartCollectionId
&& a.MediaItemId == collectionKey.MediaItemId);
foreach (PlayoutProgramScheduleAnchor anchor in maybeAnchor)
{
_logger.LogDebug("Selecting anchor {@Anchor}", anchor);
}
// foreach (PlayoutProgramScheduleAnchor anchor in maybeAnchor)
// {
// _logger.LogDebug("Selecting anchor {@Anchor}", anchor);
// }
CollectionEnumeratorState state = maybeAnchor.Match(
anchor => anchor.EnumeratorState ??

7
ErsatzTV.FFmpeg.Tests/PipelineBuilderTests.cs

@ -67,7 +67,7 @@ public class PipelineGeneratorTests @@ -67,7 +67,7 @@ public class PipelineGeneratorTests
Option<string>.None,
0);
var builder = new PipelineBuilder(videoInputFile, audioInputFile, None, None, "", _logger);
var builder = new PipelineBuilder(videoInputFile, audioInputFile, None, None, "", "", _logger);
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
result.PipelineSteps.Should().HaveCountGreaterThan(0);
@ -82,7 +82,7 @@ public class PipelineGeneratorTests @@ -82,7 +82,7 @@ public class PipelineGeneratorTests
var resolution = new FrameSize(1920, 1080);
var concatInputFile = new ConcatInputFile("http://localhost:8080/ffmpeg/concat/1", resolution);
var builder = new PipelineBuilder(None, None, None, None, "", _logger);
var builder = new PipelineBuilder(None, None, None, None, "", "", _logger);
FFmpegPipeline result = builder.Concat(concatInputFile, FFmpegState.Concat(false, "Some Channel"));
result.PipelineSteps.Should().HaveCountGreaterThan(0);
@ -142,7 +142,7 @@ public class PipelineGeneratorTests @@ -142,7 +142,7 @@ public class PipelineGeneratorTests
Option<string>.None,
0);
var builder = new PipelineBuilder(videoInputFile, audioInputFile, None, None, "", _logger);
var builder = new PipelineBuilder(videoInputFile, audioInputFile, None, None, "", "", _logger);
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
result.PipelineSteps.Should().HaveCountGreaterThan(0);
@ -173,6 +173,7 @@ public class PipelineGeneratorTests @@ -173,6 +173,7 @@ public class PipelineGeneratorTests
Option<WatermarkInputFile>.None,
Option<SubtitleInputFile>.None,
"",
"",
_logger);
FFmpegPipeline result = pipelineBuilder.Resize("/test/output/file.jpg", new FrameSize(-1, height));

2
ErsatzTV.FFmpeg/CommandGenerator.cs

@ -82,7 +82,7 @@ public static class CommandGenerator @@ -82,7 +82,7 @@ public static class CommandGenerator
arguments.AddRange(step.FilterOptions);
}
foreach (IPipelineStep step in pipelineSteps)
foreach (IPipelineStep step in pipelineSteps.Filter(s => s is not StreamSeekFilterOption))
{
arguments.AddRange(step.OutputOptions);
}

5
ErsatzTV.FFmpeg/Decoder/AvailableDecoders.cs

@ -12,6 +12,7 @@ public static class AvailableDecoders @@ -12,6 +12,7 @@ public static class AvailableDecoders
FrameState currentState,
FrameState desiredState,
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
ILogger logger)
{
return (ffmpegState.HardwareAccelerationMode, currentState.VideoFormat,
@ -52,9 +53,9 @@ public static class AvailableDecoders @@ -52,9 +53,9 @@ public static class AvailableDecoders
(HardwareAccelerationMode.Qsv, VideoFormat.Vc1, _) => new DecoderVc1Qsv(),
(HardwareAccelerationMode.Qsv, VideoFormat.Vp9, _) => new DecoderVp9Qsv(),
// vaapi should use implicit decoders when scaling or no watermark
// vaapi should use implicit decoders when scaling or no watermark/subtitles
// otherwise, fall back to software decoders
(HardwareAccelerationMode.Vaapi, _, _) when watermarkInputFile.IsNone ||
(HardwareAccelerationMode.Vaapi, _, _) when (watermarkInputFile.IsNone && subtitleInputFile.IsNone) ||
(currentState.ScaledSize != desiredState.ScaledSize) =>
new DecoderVaapi(),

8
ErsatzTV.FFmpeg/Encoder/AvailableEncoders.cs

@ -30,8 +30,12 @@ public static class AvailableEncoders @@ -30,8 +30,12 @@ public static class AvailableEncoders
(HardwareAccelerationMode.Qsv, VideoFormat.Hevc) => new EncoderHevcQsv(
currentState,
maybeWatermarkInputFile),
(HardwareAccelerationMode.Qsv, VideoFormat.H264) => new EncoderH264Qsv(currentState),
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.Qsv, VideoFormat.H264) => new EncoderH264Qsv(
currentState,
maybeWatermarkInputFile,
maybeSubtitleInputFile),
(HardwareAccelerationMode.Vaapi, VideoFormat.Hevc) => new EncoderHevcVaapi(
currentState,

29
ErsatzTV.FFmpeg/Encoder/Qsv/EncoderH264Qsv.cs

@ -5,10 +5,17 @@ namespace ErsatzTV.FFmpeg.Encoder.Qsv; @@ -5,10 +5,17 @@ namespace ErsatzTV.FFmpeg.Encoder.Qsv;
public class EncoderH264Qsv : EncoderBase
{
private readonly FrameState _currentState;
private readonly Option<WatermarkInputFile> _maybeWatermarkInputFile;
private readonly Option<SubtitleInputFile> _maybeSubtitleInputFile;
public EncoderH264Qsv(FrameState currentState)
public EncoderH264Qsv(
FrameState currentState,
Option<WatermarkInputFile> maybeWatermarkInputFile,
Option<SubtitleInputFile> maybeSubtitleInputFile)
{
_currentState = currentState;
_maybeWatermarkInputFile = maybeWatermarkInputFile;
_maybeSubtitleInputFile = maybeSubtitleInputFile;
}
public override FrameState NextState(FrameState currentState) => currentState with
@ -20,21 +27,27 @@ public class EncoderH264Qsv : EncoderBase @@ -20,21 +27,27 @@ public class EncoderH264Qsv : EncoderBase
public override string Name => "h264_qsv";
public override StreamKind Kind => StreamKind.Video;
// need to convert to nv12 if we're still in software
// need to upload if we're still in software and a watermark is used
public override string Filter
{
get
{
// only upload to hw if we need to overlay (watermark or subtitle)
if (_currentState.FrameDataLocation == FrameDataLocation.Software)
{
// pixel format should already be converted to a supported format by QsvHardwareAccelerationOption
foreach (IPixelFormat pixelFormat in _currentState.PixelFormat)
bool isPictureSubtitle = _maybeSubtitleInputFile.Map(s => s.IsImageBased).IfNone(false);
if (isPictureSubtitle || _maybeWatermarkInputFile.IsSome)
{
return $"format={pixelFormat.FFmpegName},hwupload=extra_hw_frames=64";
// pixel format should already be converted to a supported format by QsvHardwareAccelerationOption
foreach (IPixelFormat pixelFormat in _currentState.PixelFormat)
{
return $"format={pixelFormat.FFmpegName},hwupload=extra_hw_frames=128";
}
// default to nv12
return "format=nv12,hwupload=extra_hw_frames=128";
}
// default to nv12
return "format=nv12,hwupload=extra_hw_frames=64";
}
return string.Empty;

28
ErsatzTV.FFmpeg/Encoder/Qsv/EncoderHevcQsv.cs

@ -6,11 +6,16 @@ public class EncoderHevcQsv : EncoderBase @@ -6,11 +6,16 @@ public class EncoderHevcQsv : EncoderBase
{
private readonly FrameState _currentState;
private readonly Option<WatermarkInputFile> _maybeWatermarkInputFile;
private readonly Option<SubtitleInputFile> _maybeSubtitleInputFile;
public EncoderHevcQsv(FrameState currentState, Option<WatermarkInputFile> maybeWatermarkInputFile)
public EncoderHevcQsv(
FrameState currentState,
Option<WatermarkInputFile> maybeWatermarkInputFile,
Option<SubtitleInputFile> maybeSubtitleInputFile)
{
_currentState = currentState;
_maybeWatermarkInputFile = maybeWatermarkInputFile;
_maybeSubtitleInputFile = maybeSubtitleInputFile;
}
public override FrameState NextState(FrameState currentState) => currentState with
@ -27,17 +32,22 @@ public class EncoderHevcQsv : EncoderBase @@ -27,17 +32,22 @@ public class EncoderHevcQsv : EncoderBase
{
get
{
// only upload to hw if we need to overlay a watermark
if (_maybeWatermarkInputFile.IsSome && _currentState.FrameDataLocation == FrameDataLocation.Software)
// only upload to hw if we need to overlay (watermark or subtitle)
if (_currentState.FrameDataLocation == FrameDataLocation.Software)
{
// pixel format should already be converted to a supported format by QsvHardwareAccelerationOption
foreach (IPixelFormat pixelFormat in _currentState.PixelFormat)
bool isPictureSubtitle = _maybeSubtitleInputFile.Map(s => s.IsImageBased).IfNone(false);
if (isPictureSubtitle || _maybeWatermarkInputFile.IsSome)
{
return $"format={pixelFormat.FFmpegName},hwupload=extra_hw_frames=64";
}
// pixel format should already be converted to a supported format by QsvHardwareAccelerationOption
foreach (IPixelFormat pixelFormat in _currentState.PixelFormat)
{
return $"format={pixelFormat.FFmpegName},hwupload=extra_hw_frames=128";
}
// default to nv12
return "format=nv12,hwupload=extra_hw_frames=64";
// default to nv12
return "format=nv12,hwupload=extra_hw_frames=128";
}
}
return string.Empty;

6
ErsatzTV.FFmpeg/Encoder/Vaapi/EncoderH264Vaapi.cs

@ -27,16 +27,14 @@ public class EncoderH264Vaapi : EncoderBase @@ -27,16 +27,14 @@ public class EncoderH264Vaapi : EncoderBase
public override string Name => "h264_vaapi";
public override StreamKind Kind => StreamKind.Video;
// need to upload if we're still in software unless a watermark or picture subtitle is used
// need to upload if we're still in software unless a watermark or subtitle is used
public override string Filter
{
get
{
if (_currentState.FrameDataLocation == FrameDataLocation.Software)
{
bool isNotImageSubtitle = _maybeSubtitleInputFile.Map(s => s.IsImageBased).IfNone(false) == false;
if (_maybeWatermarkInputFile.IsNone && isNotImageSubtitle)
if (_maybeWatermarkInputFile.IsNone && _maybeSubtitleInputFile.IsNone)
{
return "format=nv12|vaapi,hwupload";
}

6
ErsatzTV.FFmpeg/Encoder/Vaapi/EncoderHevcVaapi.cs

@ -27,16 +27,14 @@ public class EncoderHevcVaapi : EncoderBase @@ -27,16 +27,14 @@ public class EncoderHevcVaapi : EncoderBase
public override string Name => "hevc_vaapi";
public override StreamKind Kind => StreamKind.Video;
// need to upload if we're still in software unless a watermark or picture subtitle is used
// need to upload if we're still in software unless a watermark or subtitle is used
public override string Filter
{
get
{
if (_currentState.FrameDataLocation == FrameDataLocation.Software)
{
bool isNotImageSubtitle = _maybeSubtitleInputFile.Map(s => s.IsImageBased).IfNone(false) == false;
if (_maybeWatermarkInputFile.IsNone && isNotImageSubtitle)
if (_maybeWatermarkInputFile.IsNone && _maybeSubtitleInputFile.IsNone)
{
return "format=nv12|vaapi,hwupload";
}

5
ErsatzTV.FFmpeg/Filter/AvailableDeinterlaceFilters.cs

@ -9,7 +9,8 @@ public static class AvailableDeinterlaceFilters @@ -9,7 +9,8 @@ public static class AvailableDeinterlaceFilters
HardwareAccelerationMode accelMode,
FrameState currentState,
FrameState desiredState,
Option<WatermarkInputFile> watermarkInputFile) =>
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile) =>
accelMode switch
{
HardwareAccelerationMode.Nvenc => new YadifCudaFilter(currentState),
@ -18,7 +19,7 @@ public static class AvailableDeinterlaceFilters @@ -18,7 +19,7 @@ public static class AvailableDeinterlaceFilters
// HardwareAccelerationMode.Qsv => new DeinterlaceQsvFilter(currentState),
// fall back to software deinterlace with watermark and no scaling
HardwareAccelerationMode.Vaapi when watermarkInputFile.IsNone ||
HardwareAccelerationMode.Vaapi when (watermarkInputFile.IsNone && subtitleInputFile.IsNone) ||
(currentState.ScaledSize != desiredState.ScaledSize) =>
new DeinterlaceVaapiFilter(currentState),

4
ErsatzTV.FFmpeg/Filter/AvailableSubtitleOverlayFilters.cs

@ -5,11 +5,11 @@ namespace ErsatzTV.FFmpeg.Filter; @@ -5,11 +5,11 @@ namespace ErsatzTV.FFmpeg.Filter;
public static class AvailableSubtitleOverlayFilters
{
public static IPipelineFilterStep ForAcceleration(HardwareAccelerationMode accelMode) =>
public static IPipelineFilterStep ForAcceleration(HardwareAccelerationMode accelMode, FrameState currentState) =>
accelMode switch
{
HardwareAccelerationMode.Nvenc => new OverlaySubtitleCudaFilter(),
HardwareAccelerationMode.Qsv => new OverlaySubtitleQsvFilter(),
HardwareAccelerationMode.Qsv => new OverlaySubtitleQsvFilter(currentState),
_ => new OverlaySubtitleFilter()
};
}

51
ErsatzTV.FFmpeg/Filter/ComplexFilter.cs

@ -12,6 +12,7 @@ public class ComplexFilter : IPipelineStep @@ -12,6 +12,7 @@ public class ComplexFilter : IPipelineStep
private readonly Option<WatermarkInputFile> _maybeWatermarkInputFile;
private readonly Option<SubtitleInputFile> _maybeSubtitleInputFile;
private readonly FrameSize _resolution;
private readonly string _fontsDir;
public ComplexFilter(
FrameState currentState,
@ -20,7 +21,8 @@ public class ComplexFilter : IPipelineStep @@ -20,7 +21,8 @@ public class ComplexFilter : IPipelineStep
Option<AudioInputFile> maybeAudioInputFile,
Option<WatermarkInputFile> maybeWatermarkInputFile,
Option<SubtitleInputFile> maybeSubtitleInputFile,
FrameSize resolution)
FrameSize resolution,
string fontsDir)
{
_currentState = currentState;
_ffmpegState = ffmpegState;
@ -29,6 +31,7 @@ public class ComplexFilter : IPipelineStep @@ -29,6 +31,7 @@ public class ComplexFilter : IPipelineStep
_maybeWatermarkInputFile = maybeWatermarkInputFile;
_maybeSubtitleInputFile = maybeSubtitleInputFile;
_resolution = resolution;
_fontsDir = fontsDir;
}
private IList<string> Arguments()
@ -146,22 +149,30 @@ public class ComplexFilter : IPipelineStep @@ -146,22 +149,30 @@ public class ComplexFilter : IPipelineStep
// vaapi uses software overlay and needs to upload
// videotoolbox seems to require a hwupload for hevc
// also wait to upload videotoolbox if a subtitle overlay is coming
string uploadFilter = string.Empty;
if (_maybeSubtitleInputFile.Map(s => s.IsImageBased).IfNone(false) == false &&
// also wait to upload if a subtitle overlay is coming
string uploadDownloadFilter = string.Empty;
if (_maybeSubtitleInputFile.IsNone &&
(_ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Vaapi ||
_ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.VideoToolbox &&
_currentState.VideoFormat == VideoFormat.Hevc))
{
uploadFilter = new HardwareUploadFilter(_ffmpegState).Filter;
uploadDownloadFilter = new HardwareUploadFilter(_ffmpegState).Filter;
}
if (!string.IsNullOrWhiteSpace(uploadFilter))
if (_maybeSubtitleInputFile.Map(s => !s.IsImageBased).IfNone(false) &&
_ffmpegState.HardwareAccelerationMode != HardwareAccelerationMode.Vaapi &&
_ffmpegState.HardwareAccelerationMode != HardwareAccelerationMode.VideoToolbox)
{
uploadFilter = "," + uploadFilter;
uploadDownloadFilter = new HardwareDownloadFilter(_currentState).Filter;
}
watermarkOverlayFilterComplex = $"{tempVideoLabel}{watermarkLabel}{overlayFilter.Filter}{uploadFilter}[vf]";
if (!string.IsNullOrWhiteSpace(uploadDownloadFilter))
{
uploadDownloadFilter = "," + uploadDownloadFilter;
}
watermarkOverlayFilterComplex =
$"{tempVideoLabel}{watermarkLabel}{overlayFilter.Filter}{uploadDownloadFilter}[vf]";
// change the mapped label
videoLabel = "[vf]";
@ -189,10 +200,23 @@ public class ComplexFilter : IPipelineStep @@ -189,10 +200,23 @@ public class ComplexFilter : IPipelineStep
subtitleLabel = $"[{subtitleLabel}]";
}
IPipelineFilterStep overlayFilter =
AvailableSubtitleOverlayFilters.ForAcceleration(_ffmpegState.HardwareAccelerationMode);
string filter;
if (subtitleInputFile.IsImageBased)
{
IPipelineFilterStep overlayFilter =
AvailableSubtitleOverlayFilters.ForAcceleration(
_ffmpegState.HardwareAccelerationMode,
_currentState);
filter = overlayFilter.Filter;
}
else
{
subtitleLabel = string.Empty;
var subtitlesFilter = new SubtitlesFilter(_fontsDir, subtitleInputFile);
filter = subtitlesFilter.Filter;
}
if (overlayFilter.Filter != string.Empty)
if (filter != string.Empty)
{
string tempVideoLabel = string.IsNullOrWhiteSpace(videoFilterComplex) &&
string.IsNullOrWhiteSpace(watermarkFilterComplex)
@ -214,8 +238,7 @@ public class ComplexFilter : IPipelineStep @@ -214,8 +238,7 @@ public class ComplexFilter : IPipelineStep
uploadFilter = "," + uploadFilter;
}
subtitleOverlayFilterComplex =
$"{tempVideoLabel}{subtitleLabel}{overlayFilter.Filter}{uploadFilter}[vst]";
subtitleOverlayFilterComplex = $"{tempVideoLabel}{subtitleLabel}{filter}{uploadFilter}[vst]";
// change the mapped label
videoLabel = "[vst]";

2
ErsatzTV.FFmpeg/Filter/HardwareUploadFilter.cs

@ -19,7 +19,7 @@ public class HardwareUploadFilter : BaseFilter @@ -19,7 +19,7 @@ public class HardwareUploadFilter : BaseFilter
{
HardwareAccelerationMode.None => string.Empty,
HardwareAccelerationMode.Nvenc => "hwupload_cuda",
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=64",
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=128",
HardwareAccelerationMode.Vaapi => "format=nv12|vaapi,hwupload",
_ => "hwupload"
};

2
ErsatzTV.FFmpeg/Filter/Qsv/DeinterlaceQsvFilter.cs

@ -13,7 +13,7 @@ public class DeinterlaceQsvFilter : BaseFilter @@ -13,7 +13,7 @@ public class DeinterlaceQsvFilter : BaseFilter
// deinterlace_qsv seems to only support nv12, not p010le
public override string Filter => _currentState.FrameDataLocation == FrameDataLocation.Software
? "format=nv12,hwupload=extra_hw_frames=64,deinterlace_qsv"
? "format=nv12,hwupload=extra_hw_frames=128,deinterlace_qsv"
: "deinterlace_qsv";
public override FrameState NextState(FrameState currentState)

10
ErsatzTV.FFmpeg/Filter/Qsv/OverlaySubtitleQsvFilter.cs

@ -2,7 +2,15 @@ @@ -2,7 +2,15 @@
public class OverlaySubtitleQsvFilter : BaseFilter
{
private readonly FrameState _currentState;
public OverlaySubtitleQsvFilter(FrameState currentState)
{
_currentState = currentState;
}
public override FrameState NextState(FrameState currentState) => currentState;
public override string Filter => "overlay_qsv";
public override string Filter =>
$"overlay_qsv=eof_action=endall:shortest=1:repeatlast=0:w={_currentState.PaddedSize.Width}:h={_currentState.PaddedSize.Height}";
}

4
ErsatzTV.FFmpeg/Filter/Qsv/ScaleQsvFilter.cs

@ -49,10 +49,10 @@ public class ScaleQsvFilter : BaseFilter @@ -49,10 +49,10 @@ public class ScaleQsvFilter : BaseFilter
string initialPixelFormat = _currentState.PixelFormat.Match(pf => pf.FFmpegName, FFmpegFormat.NV12);
if (!string.IsNullOrWhiteSpace(scale))
{
return $"format={initialPixelFormat},hwupload=extra_hw_frames=64,{scale}";
return $"format={initialPixelFormat},hwupload=extra_hw_frames=128,{scale}";
}
return $"format={initialPixelFormat},hwupload=extra_hw_frames=64";
return $"format={initialPixelFormat},hwupload=extra_hw_frames=128";
}
}

2
ErsatzTV.FFmpeg/Filter/SubtitleHardwareUploadFilter.cs

@ -18,7 +18,7 @@ public class SubtitleHardwareUploadFilter : BaseFilter @@ -18,7 +18,7 @@ public class SubtitleHardwareUploadFilter : BaseFilter
{
HardwareAccelerationMode.None => string.Empty,
HardwareAccelerationMode.Nvenc => "hwupload_cuda",
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=64",
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=128",
// leave vaapi in software since we don't (yet) use overlay_vaapi
HardwareAccelerationMode.Vaapi when _currentState.FrameDataLocation == FrameDataLocation.Software =>

39
ErsatzTV.FFmpeg/Filter/SubtitlesFilter.cs

@ -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}";
}
}
}

2
ErsatzTV.FFmpeg/Filter/WatermarkHardwareUploadFilter.cs

@ -17,7 +17,7 @@ public class WatermarkHardwareUploadFilter : BaseFilter @@ -17,7 +17,7 @@ public class WatermarkHardwareUploadFilter : BaseFilter
{
HardwareAccelerationMode.None => string.Empty,
HardwareAccelerationMode.Nvenc => "hwupload_cuda",
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=64",
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=128",
// leave vaapi in software since we don't (yet) use overlay_vaapi
HardwareAccelerationMode.Vaapi when _currentState.FrameDataLocation == FrameDataLocation.Software =>

21
ErsatzTV.FFmpeg/Option/CopyTimestampInputOption.cs

@ -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;
}

21
ErsatzTV.FFmpeg/Option/StreamSeekFilterOption.cs

@ -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;
}

95
ErsatzTV.FFmpeg/PipelineBuilder.cs

@ -1,5 +1,4 @@ @@ -1,5 +1,4 @@
using System.Numerics;
using ErsatzTV.FFmpeg.Decoder;
using ErsatzTV.FFmpeg.Decoder;
using ErsatzTV.FFmpeg.Encoder;
using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Filter;
@ -23,6 +22,7 @@ public class PipelineBuilder @@ -23,6 +22,7 @@ public class PipelineBuilder
private readonly Option<WatermarkInputFile> _watermarkInputFile;
private readonly Option<SubtitleInputFile> _subtitleInputFile;
private readonly string _reportsFolder;
private readonly string _fontsFolder;
private readonly ILogger _logger;
public PipelineBuilder(
@ -31,6 +31,7 @@ public class PipelineBuilder @@ -31,6 +31,7 @@ public class PipelineBuilder
Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile,
string reportsFolder,
string fontsFolder,
ILogger logger)
{
_pipelineSteps = new List<IPipelineStep>
@ -51,6 +52,7 @@ public class PipelineBuilder @@ -51,6 +52,7 @@ public class PipelineBuilder
_watermarkInputFile = watermarkInputFile;
_subtitleInputFile = subtitleInputFile;
_reportsFolder = reportsFolder;
_fontsFolder = fontsFolder;
_logger = logger;
}
@ -127,6 +129,12 @@ public class PipelineBuilder @@ -127,6 +129,12 @@ public class PipelineBuilder
var option = new StreamSeekInputOption(desiredStart);
_audioInputFile.Iter(f => f.AddOption(option));
_videoInputFile.Iter(f => f.AddOption(option));
// need to seek text subtitle files
if (_subtitleInputFile.Map(s => !s.IsImageBased).IfNone(false))
{
_pipelineSteps.Add(new StreamSeekFilterOption(desiredStart));
}
}
foreach (TimeSpan desiredFinish in ffmpegState.Finish)
@ -215,6 +223,7 @@ public class PipelineBuilder @@ -215,6 +223,7 @@ public class PipelineBuilder
currentState,
desiredState,
_watermarkInputFile,
_subtitleInputFile,
_logger))
{
foreach (VideoInputFile videoInputFile in _videoInputFile)
@ -298,7 +307,8 @@ public class PipelineBuilder @@ -298,7 +307,8 @@ public class PipelineBuilder
ffmpegState.HardwareAccelerationMode,
currentState,
desiredState,
_watermarkInputFile);
_watermarkInputFile,
_subtitleInputFile);
currentState = step.NextState(currentState);
_videoInputFile.Iter(f => f.FilterSteps.Add(step));
}
@ -465,24 +475,6 @@ public class PipelineBuilder @@ -465,24 +475,6 @@ public class PipelineBuilder
}
}
}
// after everything else is done, apply the encoder
if (_pipelineSteps.OfType<IEncoder>().All(e => e.Kind != StreamKind.Video))
{
foreach (IEncoder e in AvailableEncoders.ForVideoFormat(
ffmpegState,
currentState,
desiredState,
_watermarkInputFile,
_subtitleInputFile,
_logger))
{
encoder = e;
_pipelineSteps.Add(encoder);
_videoInputFile.Iter(f => f.FilterSteps.Add(encoder));
currentState = encoder.NextState(currentState);
}
}
}
// TODO: if all video filters are software, use software pixel format for hwaccel output
@ -530,18 +522,44 @@ public class PipelineBuilder @@ -530,18 +522,44 @@ public class PipelineBuilder
foreach (SubtitleInputFile subtitleInputFile in _subtitleInputFile)
{
// vaapi and videotoolbox use a software overlay, so we need to ensure the background is already in software
// though videotoolbox uses software decoders, so no need to download for that
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Vaapi)
if (subtitleInputFile.IsImageBased)
{
// vaapi and videotoolbox use a software overlay, so we need to ensure the background is already in software
// though videotoolbox uses software decoders, so no need to download for that
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Vaapi)
{
var downloadFilter = new HardwareDownloadFilter(currentState);
currentState = downloadFilter.NextState(currentState);
_videoInputFile.Iter(f => f.FilterSteps.Add(downloadFilter));
}
subtitleInputFile.FilterSteps.Add(new SubtitlePixelFormatFilter(ffmpegState));
subtitleInputFile.FilterSteps.Add(new SubtitleHardwareUploadFilter(currentState, ffmpegState));
}
else
{
_videoInputFile.Iter(f => f.AddOption(new CopyTimestampInputOption()));
// text-based subtitles are always added in software, so always try to download the background
// nvidia needs some extra format help if the only filter will be the download filter
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Nvenc &&
_videoInputFile.Map(f => f.FilterSteps.Count).IfNone(1) == 0)
{
IPipelineFilterStep scaleFilter = AvailableScaleFilters.ForAcceleration(
ffmpegState.HardwareAccelerationMode,
currentState,
desiredState.ScaledSize,
desiredState.PaddedSize);
currentState = scaleFilter.NextState(currentState);
_videoInputFile.Iter(f => f.FilterSteps.Add(scaleFilter));
}
var downloadFilter = new HardwareDownloadFilter(currentState);
currentState = downloadFilter.NextState(currentState);
_videoInputFile.Iter(f => f.FilterSteps.Add(downloadFilter));
}
subtitleInputFile.FilterSteps.Add(new SubtitlePixelFormatFilter(ffmpegState));
subtitleInputFile.FilterSteps.Add(new SubtitleHardwareUploadFilter(currentState, ffmpegState));
}
foreach (WatermarkInputFile watermarkInputFile in _watermarkInputFile)
@ -589,6 +607,24 @@ public class PipelineBuilder @@ -589,6 +607,24 @@ public class PipelineBuilder
watermarkInputFile.FilterSteps.Add(new WatermarkHardwareUploadFilter(currentState, ffmpegState));
}
// after everything else is done, apply the encoder
if (_pipelineSteps.OfType<IEncoder>().All(e => e.Kind != StreamKind.Video))
{
foreach (IEncoder e in AvailableEncoders.ForVideoFormat(
ffmpegState,
currentState,
desiredState,
_watermarkInputFile,
_subtitleInputFile,
_logger))
{
encoder = e;
_pipelineSteps.Add(encoder);
_videoInputFile.Iter(f => f.FilterSteps.Add(encoder));
currentState = encoder.NextState(currentState);
}
}
if (ffmpegState.DoNotMapMetadata)
{
@ -642,7 +678,8 @@ public class PipelineBuilder @@ -642,7 +678,8 @@ public class PipelineBuilder
_audioInputFile,
_watermarkInputFile,
_subtitleInputFile,
currentState.PaddedSize);
currentState.PaddedSize,
_fontsFolder);
_pipelineSteps.Add(complexFilter);
}

4
ErsatzTV.Infrastructure/Data/Configurations/Metadata/EpisodeMetadataConfiguration.cs

@ -29,5 +29,9 @@ public class EpisodeMetadataConfiguration : IEntityTypeConfiguration<EpisodeMeta @@ -29,5 +29,9 @@ public class EpisodeMetadataConfiguration : IEntityTypeConfiguration<EpisodeMeta
builder.HasMany(mm => mm.Guids)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(em => em.Subtitles)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}

4
ErsatzTV.Infrastructure/Data/Configurations/Metadata/MovieMetadataConfiguration.cs

@ -41,5 +41,9 @@ public class MovieMetadataConfiguration : IEntityTypeConfiguration<MovieMetadata @@ -41,5 +41,9 @@ public class MovieMetadataConfiguration : IEntityTypeConfiguration<MovieMetadata
builder.HasMany(mm => mm.Guids)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Subtitles)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}

4
ErsatzTV.Infrastructure/Data/Configurations/Metadata/MusicVideoMetadataConfiguration.cs

@ -25,5 +25,9 @@ public class MusicVideoMetadataConfiguration : IEntityTypeConfiguration<MusicVid @@ -25,5 +25,9 @@ public class MusicVideoMetadataConfiguration : IEntityTypeConfiguration<MusicVid
builder.HasMany(mm => mm.Studios)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mvm => mvm.Subtitles)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}

4
ErsatzTV.Infrastructure/Data/Configurations/Metadata/OtherVideoMetadataConfiguration.cs

@ -17,5 +17,9 @@ public class OtherVideoMetadataConfiguration : IEntityTypeConfiguration<OtherVid @@ -17,5 +17,9 @@ public class OtherVideoMetadataConfiguration : IEntityTypeConfiguration<OtherVid
builder.HasMany(mm => mm.Tags)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(ovm => ovm.Subtitles)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}

16
ErsatzTV.Infrastructure/Data/Configurations/Metadata/SubtitleConfiguration.cs

@ -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);
}
}

63
ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
@ -165,6 +166,8 @@ public class MetadataRepository : IMetadataRepository @@ -165,6 +166,8 @@ public class MetadataRepository : IMetadataRepository
existingStream.AttachedPic = incomingStream.AttachedPic;
existingStream.PixelFormat = incomingStream.PixelFormat;
existingStream.BitsPerRawSample = incomingStream.BitsPerRawSample;
existingStream.FileName = incomingStream.FileName;
existingStream.MimeType = incomingStream.MimeType;
}
var chaptersToAdd = incoming.Chapters
@ -479,6 +482,66 @@ public class MetadataRepository : IMetadataRepository @@ -479,6 +482,66 @@ public class MetadataRepository : IMetadataRepository
.Map(result => result > 0);
}
public async Task<bool> UpdateSubtitles(Metadata metadata, List<Subtitle> subtitles)
{
int metadataId = metadata.Id;
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<Metadata> maybeMetadata = metadata switch
{
EpisodeMetadata => await dbContext.EpisodeMetadata
.Include(em => em.Subtitles)
.SelectOneAsync(em => em.Id, em => em.Id == metadataId)
.MapT(em => (Metadata)em),
MovieMetadata => await dbContext.MovieMetadata
.Include(mm => mm.Subtitles)
.SelectOneAsync(mm => mm.Id, mm => mm.Id == metadataId)
.MapT(mm => (Metadata)mm),
MusicVideoMetadata => await dbContext.MusicVideoMetadata
.Include(mvm => mvm.Subtitles)
.SelectOneAsync(mvm => mvm.Id, mm => mm.Id == metadataId)
.MapT(mvm => (Metadata)mvm),
OtherVideoMetadata => await dbContext.OtherVideoMetadata
.Include(ovm => ovm.Subtitles)
.SelectOneAsync(ovm => ovm.Id, mm => mm.Id == metadataId)
.MapT(ovm => (Metadata)ovm),
_ => None
};
foreach (Metadata existing in maybeMetadata)
{
var toAdd = subtitles.Filter(s => existing.Subtitles.All(es => es.StreamIndex != s.StreamIndex)).ToList();
var toRemove = existing.Subtitles.Filter(es => subtitles.All(s => s.StreamIndex != es.StreamIndex))
.ToList();
var toUpdate = subtitles.Except(toAdd).ToList();
// add
existing.Subtitles.AddRange(toAdd);
// remove
existing.Subtitles.RemoveAll(s => toRemove.Contains(s));
// update
foreach (Subtitle incomingSubtitle in toUpdate)
{
Subtitle existingSubtitle =
existing.Subtitles.First(s => s.StreamIndex == incomingSubtitle.StreamIndex);
existingSubtitle.Codec = incomingSubtitle.Codec;
existingSubtitle.Default = incomingSubtitle.Default;
existingSubtitle.Forced = incomingSubtitle.Forced;
existingSubtitle.Language = incomingSubtitle.Language;
existingSubtitle.SubtitleKind = incomingSubtitle.SubtitleKind;
existingSubtitle.DateUpdated = incomingSubtitle.DateUpdated;
}
return await dbContext.SaveChangesAsync() > 0;
}
return false;
}
public async Task<bool> RemoveGenre(Genre genre)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();

4033
ErsatzTV.Infrastructure/Migrations/20220417230100_Add_MetadataSubtitles.Designer.cs generated

File diff suppressed because it is too large Load Diff

127
ErsatzTV.Infrastructure/Migrations/20220417230100_Add_MetadataSubtitles.cs

@ -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");
}
}
}

4048
ErsatzTV.Infrastructure/Migrations/20220418001349_Add_SubtitlesProperties.Designer.cs generated

File diff suppressed because it is too large Load Diff

68
ErsatzTV.Infrastructure/Migrations/20220418001349_Add_SubtitlesProperties.cs

@ -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");
}
}
}

4054
ErsatzTV.Infrastructure/Migrations/20220418142642_Add_MediaStreamFileNameMimeType.Designer.cs generated

File diff suppressed because it is too large Load Diff

35
ErsatzTV.Infrastructure/Migrations/20220418142642_Add_MediaStreamFileNameMimeType.cs

@ -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");
}
}
}

4059
ErsatzTV.Infrastructure/Migrations/20220418162128_Add_SubtitleIsExtracted.Designer.cs generated

File diff suppressed because it is too large Load Diff

26
ErsatzTV.Infrastructure/Migrations/20220418162128_Add_SubtitleIsExtracted.cs

@ -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");
}
}
}

4059
ErsatzTV.Infrastructure/Migrations/20220419033955_Sync_MediaStreamSubtitle.Designer.cs generated

File diff suppressed because it is too large Load Diff

54
ErsatzTV.Infrastructure/Migrations/20220419033955_Sync_MediaStreamSubtitle.cs

@ -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)
{
}
}
}

144
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -901,6 +901,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -901,6 +901,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<bool>("Default")
.HasColumnType("INTEGER");
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<bool>("Forced")
.HasColumnType("INTEGER");
@ -916,6 +919,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -916,6 +919,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("MediaVersionId")
.HasColumnType("INTEGER");
b.Property<string>("MimeType")
.HasColumnType("TEXT");
b.Property<string>("PixelFormat")
.HasColumnType("TEXT");
@ -1806,6 +1812,89 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1806,6 +1812,89 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Style");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Subtitle", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<DateTime>("DateUpdated")
.HasColumnType("TEXT");
b.Property<bool>("Default")
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
b.Property<bool>("Forced")
.HasColumnType("INTEGER");
b.Property<bool>("IsExtracted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<string>("Language")
.HasColumnType("TEXT");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("ShowMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SongMetadataId")
.HasColumnType("INTEGER");
b.Property<int>("StreamIndex")
.HasColumnType("INTEGER");
b.Property<int>("SubtitleKind")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
b.HasIndex("SongMetadataId");
b.ToTable("Subtitle", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Tag", b =>
{
b.Property<int>("Id")
@ -3213,6 +3302,45 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3213,6 +3302,45 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Subtitle", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("ArtistMetadataId");
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("MusicVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("SeasonMetadataId");
b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("ShowMetadataId");
b.HasOne("ErsatzTV.Core.Domain.SongMetadata", null)
.WithMany("Subtitles")
.HasForeignKey("SongMetadataId");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Tag", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
@ -3632,6 +3760,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3632,6 +3760,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Styles");
b.Navigation("Subtitles");
b.Navigation("Tags");
});
@ -3663,6 +3793,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3663,6 +3793,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags");
b.Navigation("Writers");
@ -3715,6 +3847,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3715,6 +3847,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags");
b.Navigation("Writers");
@ -3739,6 +3873,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3739,6 +3873,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags");
});
@ -3754,6 +3890,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3754,6 +3890,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags");
});
@ -3783,6 +3921,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3783,6 +3921,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags");
});
@ -3798,6 +3938,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3798,6 +3938,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags");
});
@ -3818,6 +3960,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3818,6 +3960,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags");
});

66
ErsatzTV/Services/SubtitleWorkerService.cs

@ -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");
}
}
}

7
ErsatzTV/Startup.cs

@ -183,6 +183,11 @@ public class Startup @@ -183,6 +183,11 @@ public class Startup
Directory.CreateDirectory(FileSystemLayout.TempFilePoolFolder);
}
if (!Directory.Exists(FileSystemLayout.FontsCacheFolder))
{
Directory.CreateDirectory(FileSystemLayout.FontsCacheFolder);
}
Log.Logger.Information("Database is at {DatabasePath}", FileSystemLayout.DatabasePath);
// until we add a setting for a file-specific scheme://host:port to access
@ -324,6 +329,7 @@ public class Startup @@ -324,6 +329,7 @@ public class Startup
AddChannel<IJellyfinBackgroundServiceRequest>(services);
AddChannel<IEmbyBackgroundServiceRequest>(services);
AddChannel<IFFmpegWorkerRequest>(services);
AddChannel<ISubtitleWorkerRequest>(services);
services.AddScoped<IFFmpegVersionHealthCheck, FFmpegVersionHealthCheck>();
services.AddScoped<IFFmpegReportsHealthCheck, FFmpegReportsHealthCheck>();
@ -412,6 +418,7 @@ public class Startup @@ -412,6 +418,7 @@ public class Startup
services.AddHostedService<WorkerService>();
services.AddHostedService<SchedulerService>();
services.AddHostedService<FFmpegWorkerService>();
services.AddHostedService<SubtitleWorkerService>();
}
private void AddChannel<TMessageType>(IServiceCollection services)

Loading…
Cancel
Save