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 4 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.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Added
- Add support for burning in embedded text subtitles
## [0.5.1-beta] - 2022-04-17 ## [0.5.1-beta] - 2022-04-17
### Fixed ### Fixed

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

@ -1,20 +1,29 @@
using System.Globalization; using System.Globalization;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Channels.Mapper; using static ErsatzTV.Application.Channels.Mapper;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels; namespace ErsatzTV.Application.Channels;
public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>> public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
{ {
private readonly ChannelWriter<ISubtitleWorkerRequest> _ffmpegWorkerChannel;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
public UpdateChannelHandler(IDbContextFactory<TvContext> dbContextFactory) => public UpdateChannelHandler(
ChannelWriter<ISubtitleWorkerRequest> ffmpegWorkerChannel,
IDbContextFactory<TvContext> dbContextFactory)
{
_ffmpegWorkerChannel = ffmpegWorkerChannel;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, ChannelViewModel>> Handle( public async Task<Either<BaseError, ChannelViewModel>> Handle(
UpdateChannel request, UpdateChannel request,
@ -65,6 +74,18 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
c.WatermarkId = update.WatermarkId; c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId; c.FallbackFillerId = update.FallbackFillerId;
await dbContext.SaveChangesAsync(); 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); return ProjectToViewModel(c);
} }

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

@ -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/=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_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/=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/=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_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Cqueries/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary> <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 @@
namespace ErsatzTV.Application;
public interface ISubtitleWorkerRequest
{
}

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

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

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

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

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

@ -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 @@
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 @@
<Content Include="Resources\test.sup"> <Content Include="Resources\test.sup">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Resources\test.srt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup> </ItemGroup>
</Project> </Project>

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

@ -68,7 +68,8 @@ public class TranscodingTests
public enum Subtitle public enum Subtitle
{ {
None, None,
Picture Picture,
Text
} }
private class TestData private class TestData
@ -83,7 +84,8 @@ public class TranscodingTests
public static Subtitle[] Subtitles = public static Subtitle[] Subtitles =
{ {
Subtitle.None, Subtitle.None,
Subtitle.Picture Subtitle.Picture,
Subtitle.Text
}; };
public static Padding[] Paddings = public static Padding[] Paddings =
@ -104,24 +106,24 @@ public class TranscodingTests
new("libx264", "yuvj420p"), new("libx264", "yuvj420p"),
new("libx264", "yuv420p10le"), new("libx264", "yuv420p10le"),
// new("libx264", "yuv444p10le"), // new("libx264", "yuv444p10le"),
new("mpeg1video", "yuv420p"), new("mpeg1video", "yuv420p"),
new("mpeg2video", "yuv420p"), new("mpeg2video", "yuv420p"),
new("libx265", "yuv420p"), new("libx265", "yuv420p"),
new("libx265", "yuv420p10le"), new("libx265", "yuv420p10le"),
new("mpeg4", "yuv420p"), new("mpeg4", "yuv420p"),
new("libvpx-vp9", "yuv420p"), new("libvpx-vp9", "yuv420p"),
// new("libaom-av1", "yuv420p") // new("libaom-av1", "yuv420p")
// av1 yuv420p10le 51 // av1 yuv420p10le 51
new("msmpeg4v2", "yuv420p"), new("msmpeg4v2", "yuv420p"),
new("msmpeg4v3", "yuv420p") new("msmpeg4v3", "yuv420p")
// wmv3 yuv420p 1 // wmv3 yuv420p 1
}; };
@ -219,12 +221,15 @@ public class TranscodingTests
switch (subtitle) switch (subtitle)
{ {
case Subtitle.Picture: case Subtitle.Text or Subtitle.Picture:
string sourceFile = Path.GetTempFileName() + ".mkv"; string sourceFile = Path.GetTempFileName() + ".mkv";
File.Move(file, sourceFile, true); File.Move(file, sourceFile, true);
string tempFileName = Path.GetTempFileName() + ".mkv"; 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 var p2 = new Process
{ {
StartInfo = new ProcessStartInfo StartInfo = new ProcessStartInfo
@ -288,7 +293,8 @@ public class TranscodingTests
MediaFiles = new List<MediaFile> MediaFiles = new List<MediaFile>
{ {
new() { Path = file } new() { Path = file }
} },
Streams = new List<MediaStream>()
}; };
var metadataRepository = new Mock<IMetadataRepository>(); var metadataRepository = new Mock<IMetadataRepository>();
@ -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; DateTimeOffset now = DateTimeOffset.Now;
Option<ChannelWatermark> channelWatermark = Option<ChannelWatermark>.None; Option<ChannelWatermark> channelWatermark = Option<ChannelWatermark>.None;
@ -373,10 +404,18 @@ public class TranscodingTests
ChannelSubtitleMode subtitleMode = subtitle switch ChannelSubtitleMode subtitleMode = subtitle switch
{ {
Subtitle.Picture => ChannelSubtitleMode.Any, Subtitle.Picture or Subtitle.Text => ChannelSubtitleMode.Any,
_ => ChannelSubtitleMode.None _ => 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( Command process = await service.ForPlayoutItem(
ExecutableName("ffmpeg"), ExecutableName("ffmpeg"),
ExecutableName("ffprobe"), ExecutableName("ffprobe"),
@ -398,6 +437,7 @@ public class TranscodingTests
v, v,
file, file,
file, file,
subtitles,
now, now,
now + TimeSpan.FromSeconds(5), now + TimeSpan.FromSeconds(5),
now, now,
@ -418,7 +458,8 @@ public class TranscodingTests
{ {
"No support for codec", "No support for codec",
"No usable", "No usable",
"Provided device doesn't support" "Provided device doesn't support",
"Current pixel format is unsupported"
}; };
var sb = new StringBuilder(); var sb = new StringBuilder();

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

@ -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
public bool AttachedPic { get; set; } public bool AttachedPic { get; set; }
public string PixelFormat { get; set; } public string PixelFormat { get; set; }
public int BitsPerRawSample { get; set; } public int BitsPerRawSample { get; set; }
public string FileName { get; set; }
public string MimeType { get; set; }
public int MediaVersionId { get; set; } public int MediaVersionId { get; set; }
public MediaVersion MediaVersion { get; set; } public MediaVersion MediaVersion { get; set; }
} }

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

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

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

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

56
ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs

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

5
ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs

@ -113,6 +113,11 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
playlist += "#EXT-X-DISCONTINUITY" + Environment.NewLine; 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); return new TrimPlaylistResult(nextPlaylistStart, startSequence, playlist, segments);
} }
catch (Exception ex) catch (Exception ex)

5
ErsatzTV.Core/FileSystemLayout.cs

@ -40,4 +40,9 @@ public static class FileSystemLayout
public static readonly string LogoCacheFolder = Path.Combine(ArtworkCacheFolder, "logos"); public static readonly string LogoCacheFolder = Path.Combine(ArtworkCacheFolder, "logos");
public static readonly string FanArtCacheFolder = Path.Combine(ArtworkCacheFolder, "fanart"); public static readonly string FanArtCacheFolder = Path.Combine(ArtworkCacheFolder, "fanart");
public static readonly string WatermarkCacheFolder = Path.Combine(ArtworkCacheFolder, "watermarks"); 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
MediaVersion audioVersion, MediaVersion audioVersion,
string videoPath, string videoPath,
string audioPath, string audioPath,
List<Subtitle> subtitles,
DateTimeOffset start, DateTimeOffset start,
DateTimeOffset finish, DateTimeOffset finish,
DateTimeOffset now, DateTimeOffset now,

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

@ -35,4 +35,5 @@ public interface IMetadataRepository
Task<bool> AddGuid(Domain.Metadata metadata, MetadataGuid guid); Task<bool> AddGuid(Domain.Metadata metadata, MetadataGuid guid);
Task<bool> RemoveDirector(Director director); Task<bool> RemoveDirector(Director director);
Task<bool> RemoveWriter(Writer writer); 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
version.Streams.Add(stream); 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) foreach (FFprobeChapter probedChapter in json.chapters)
{ {
if (double.TryParse( if (double.TryParse(
@ -440,7 +459,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
public record FFprobeDisposition(int @default, int forced, int attached_pic); 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( public record FFprobeFormatTags(
string title, string title,

50
ErsatzTV.Core/Metadata/MovieFolderScanner.cs

@ -1,5 +1,6 @@
using Bugsnag; using Bugsnag;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
@ -16,6 +17,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
private readonly ILibraryRepository _libraryRepository; private readonly ILibraryRepository _libraryRepository;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly IMetadataRepository _metadataRepository;
private readonly ILogger<MovieFolderScanner> _logger; private readonly ILogger<MovieFolderScanner> _logger;
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly IClient _client; private readonly IClient _client;
@ -53,6 +55,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_movieRepository = movieRepository; _movieRepository = movieRepository;
_localMetadataProvider = localMetadataProvider; _localMetadataProvider = localMetadataProvider;
_metadataRepository = metadataRepository;
_searchIndex = searchIndex; _searchIndex = searchIndex;
_searchRepository = searchRepository; _searchRepository = searchRepository;
_libraryRepository = libraryRepository; _libraryRepository = libraryRepository;
@ -136,6 +139,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
.BindT(UpdateMetadata) .BindT(UpdateMetadata)
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster, cancellationToken)) .BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster, cancellationToken))
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt, cancellationToken)) .BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt, cancellationToken))
.BindT(movie => UpdateSubtitles(movie, cancellationToken))
.BindT(FlagNormal); .BindT(FlagNormal);
await maybeMovie.Match( await maybeMovie.Match(
@ -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) private Option<string> LocateNfoFile(Movie movie)
{ {
string path = movie.MediaVersions.Head().MediaFiles.Head().Path; string path = movie.MediaVersions.Head().MediaFiles.Head().Path;

60
ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs

@ -1,5 +1,6 @@
using Bugsnag; using Bugsnag;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
@ -16,6 +17,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
private readonly ILibraryRepository _libraryRepository; private readonly ILibraryRepository _libraryRepository;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly IMetadataRepository _metadataRepository;
private readonly ILogger<MusicVideoFolderScanner> _logger; private readonly ILogger<MusicVideoFolderScanner> _logger;
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly IClient _client; private readonly IClient _client;
@ -52,6 +54,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
{ {
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider; _localMetadataProvider = localMetadataProvider;
_metadataRepository = metadataRepository;
_searchIndex = searchIndex; _searchIndex = searchIndex;
_searchRepository = searchRepository; _searchRepository = searchRepository;
_artistRepository = artistRepository; _artistRepository = artistRepository;
@ -88,8 +91,14 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
Either<BaseError, MediaItemScanResult<Artist>> maybeArtist = Either<BaseError, MediaItemScanResult<Artist>> maybeArtist =
await FindOrCreateArtist(libraryPath.Id, artistFolder) await FindOrCreateArtist(libraryPath.Id, artistFolder)
.BindT(artist => UpdateMetadataForArtist(artist, artistFolder)) .BindT(artist => UpdateMetadataForArtist(artist, artistFolder))
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.Thumbnail, cancellationToken)) .BindT(
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.FanArt, cancellationToken)); artist => UpdateArtworkForArtist(
artist,
artistFolder,
ArtworkKind.Thumbnail,
cancellationToken))
.BindT(
artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.FanArt, cancellationToken));
await maybeArtist.Match( await maybeArtist.Match(
async result => async result =>
@ -281,6 +290,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
.BindT(musicVideo => UpdateStatistics(musicVideo, ffmpegPath, ffprobePath)) .BindT(musicVideo => UpdateStatistics(musicVideo, ffmpegPath, ffprobePath))
.BindT(UpdateMetadata) .BindT(UpdateMetadata)
.BindT(result => UpdateThumbnail(result, cancellationToken)) .BindT(result => UpdateThumbnail(result, cancellationToken))
.BindT(result => UpdateSubtitles(result, cancellationToken))
.BindT(FlagNormal); .BindT(FlagNormal);
await maybeMusicVideo.Match( await maybeMusicVideo.Match(
@ -403,6 +413,52 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
return BaseError.New(ex.ToString()); 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) private Option<string> LocateThumbnail(MusicVideo musicVideo)
{ {

49
ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs

@ -1,5 +1,6 @@
using Bugsnag; using Bugsnag;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
@ -14,6 +15,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
{ {
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly IMetadataRepository _metadataRepository;
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly ISearchIndex _searchIndex; private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository; private readonly ISearchRepository _searchRepository;
@ -50,6 +52,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
{ {
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider; _localMetadataProvider = localMetadataProvider;
_metadataRepository = metadataRepository;
_mediator = mediator; _mediator = mediator;
_searchIndex = searchIndex; _searchIndex = searchIndex;
_searchRepository = searchRepository; _searchRepository = searchRepository;
@ -128,6 +131,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
.GetOrAdd(libraryPath, file) .GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath)) .BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
.BindT(UpdateMetadata) .BindT(UpdateMetadata)
.BindT(UpdateSubtitles)
.BindT(FlagNormal); .BindT(FlagNormal);
await maybeVideo.Match( await maybeVideo.Match(
@ -200,4 +204,49 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
return BaseError.New(ex.ToString()); 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 @@
using Bugsnag; using Bugsnag;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
@ -236,6 +237,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
.MapT(_ => episode)) .MapT(_ => episode))
.BindT(UpdateMetadata) .BindT(UpdateMetadata)
.BindT(e => UpdateThumbnail(e, cancellationToken)) .BindT(e => UpdateThumbnail(e, cancellationToken))
.BindT(e => UpdateSubtitles(e, cancellationToken))
.BindT(e => FlagNormal(new MediaItemScanResult<Episode>(e))) .BindT(e => FlagNormal(new MediaItemScanResult<Episode>(e)))
.MapT(r => r.Item); .MapT(r => r.Item);
@ -435,6 +437,48 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
return BaseError.New(ex.ToString()); 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) => private Option<string> LocateNfoFileForShow(string showFolder) =>
Optional(Path.Combine(showFolder, "tvshow.nfo")) Optional(Path.Combine(showFolder, "tvshow.nfo"))

8
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

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

7
ErsatzTV.FFmpeg.Tests/PipelineBuilderTests.cs

@ -67,7 +67,7 @@ public class PipelineGeneratorTests
Option<string>.None, Option<string>.None,
0); 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); FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
result.PipelineSteps.Should().HaveCountGreaterThan(0); result.PipelineSteps.Should().HaveCountGreaterThan(0);
@ -82,7 +82,7 @@ public class PipelineGeneratorTests
var resolution = new FrameSize(1920, 1080); var resolution = new FrameSize(1920, 1080);
var concatInputFile = new ConcatInputFile("http://localhost:8080/ffmpeg/concat/1", resolution); 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")); FFmpegPipeline result = builder.Concat(concatInputFile, FFmpegState.Concat(false, "Some Channel"));
result.PipelineSteps.Should().HaveCountGreaterThan(0); result.PipelineSteps.Should().HaveCountGreaterThan(0);
@ -142,7 +142,7 @@ public class PipelineGeneratorTests
Option<string>.None, Option<string>.None,
0); 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); FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
result.PipelineSteps.Should().HaveCountGreaterThan(0); result.PipelineSteps.Should().HaveCountGreaterThan(0);
@ -173,6 +173,7 @@ public class PipelineGeneratorTests
Option<WatermarkInputFile>.None, Option<WatermarkInputFile>.None,
Option<SubtitleInputFile>.None, Option<SubtitleInputFile>.None,
"", "",
"",
_logger); _logger);
FFmpegPipeline result = pipelineBuilder.Resize("/test/output/file.jpg", new FrameSize(-1, height)); 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
arguments.AddRange(step.FilterOptions); arguments.AddRange(step.FilterOptions);
} }
foreach (IPipelineStep step in pipelineSteps) foreach (IPipelineStep step in pipelineSteps.Filter(s => s is not StreamSeekFilterOption))
{ {
arguments.AddRange(step.OutputOptions); arguments.AddRange(step.OutputOptions);
} }

5
ErsatzTV.FFmpeg/Decoder/AvailableDecoders.cs

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

8
ErsatzTV.FFmpeg/Encoder/AvailableEncoders.cs

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

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

@ -5,10 +5,17 @@ namespace ErsatzTV.FFmpeg.Encoder.Qsv;
public class EncoderH264Qsv : EncoderBase public class EncoderH264Qsv : EncoderBase
{ {
private readonly FrameState _currentState; 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; _currentState = currentState;
_maybeWatermarkInputFile = maybeWatermarkInputFile;
_maybeSubtitleInputFile = maybeSubtitleInputFile;
} }
public override FrameState NextState(FrameState currentState) => currentState with public override FrameState NextState(FrameState currentState) => currentState with
@ -20,21 +27,27 @@ public class EncoderH264Qsv : EncoderBase
public override string Name => "h264_qsv"; public override string Name => "h264_qsv";
public override StreamKind Kind => StreamKind.Video; 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 public override string Filter
{ {
get get
{ {
// only upload to hw if we need to overlay (watermark or subtitle)
if (_currentState.FrameDataLocation == FrameDataLocation.Software) if (_currentState.FrameDataLocation == FrameDataLocation.Software)
{ {
// pixel format should already be converted to a supported format by QsvHardwareAccelerationOption bool isPictureSubtitle = _maybeSubtitleInputFile.Map(s => s.IsImageBased).IfNone(false);
foreach (IPixelFormat pixelFormat in _currentState.PixelFormat)
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; return string.Empty;

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

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

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

@ -27,16 +27,14 @@ public class EncoderH264Vaapi : EncoderBase
public override string Name => "h264_vaapi"; public override string Name => "h264_vaapi";
public override StreamKind Kind => StreamKind.Video; 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 public override string Filter
{ {
get get
{ {
if (_currentState.FrameDataLocation == FrameDataLocation.Software) if (_currentState.FrameDataLocation == FrameDataLocation.Software)
{ {
bool isNotImageSubtitle = _maybeSubtitleInputFile.Map(s => s.IsImageBased).IfNone(false) == false; if (_maybeWatermarkInputFile.IsNone && _maybeSubtitleInputFile.IsNone)
if (_maybeWatermarkInputFile.IsNone && isNotImageSubtitle)
{ {
return "format=nv12|vaapi,hwupload"; return "format=nv12|vaapi,hwupload";
} }

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

@ -27,16 +27,14 @@ public class EncoderHevcVaapi : EncoderBase
public override string Name => "hevc_vaapi"; public override string Name => "hevc_vaapi";
public override StreamKind Kind => StreamKind.Video; 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 public override string Filter
{ {
get get
{ {
if (_currentState.FrameDataLocation == FrameDataLocation.Software) if (_currentState.FrameDataLocation == FrameDataLocation.Software)
{ {
bool isNotImageSubtitle = _maybeSubtitleInputFile.Map(s => s.IsImageBased).IfNone(false) == false; if (_maybeWatermarkInputFile.IsNone && _maybeSubtitleInputFile.IsNone)
if (_maybeWatermarkInputFile.IsNone && isNotImageSubtitle)
{ {
return "format=nv12|vaapi,hwupload"; return "format=nv12|vaapi,hwupload";
} }

5
ErsatzTV.FFmpeg/Filter/AvailableDeinterlaceFilters.cs

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

4
ErsatzTV.FFmpeg/Filter/AvailableSubtitleOverlayFilters.cs

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

51
ErsatzTV.FFmpeg/Filter/ComplexFilter.cs

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

2
ErsatzTV.FFmpeg/Filter/HardwareUploadFilter.cs

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

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

@ -13,7 +13,7 @@ public class DeinterlaceQsvFilter : BaseFilter
// deinterlace_qsv seems to only support nv12, not p010le // deinterlace_qsv seems to only support nv12, not p010le
public override string Filter => _currentState.FrameDataLocation == FrameDataLocation.Software 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"; : "deinterlace_qsv";
public override FrameState NextState(FrameState currentState) public override FrameState NextState(FrameState currentState)

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

@ -2,7 +2,15 @@
public class OverlaySubtitleQsvFilter : BaseFilter public class OverlaySubtitleQsvFilter : BaseFilter
{ {
private readonly FrameState _currentState;
public OverlaySubtitleQsvFilter(FrameState currentState)
{
_currentState = currentState;
}
public override FrameState NextState(FrameState 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
string initialPixelFormat = _currentState.PixelFormat.Match(pf => pf.FFmpegName, FFmpegFormat.NV12); string initialPixelFormat = _currentState.PixelFormat.Match(pf => pf.FFmpegName, FFmpegFormat.NV12);
if (!string.IsNullOrWhiteSpace(scale)) 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
{ {
HardwareAccelerationMode.None => string.Empty, HardwareAccelerationMode.None => string.Empty,
HardwareAccelerationMode.Nvenc => "hwupload_cuda", 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 // leave vaapi in software since we don't (yet) use overlay_vaapi
HardwareAccelerationMode.Vaapi when _currentState.FrameDataLocation == FrameDataLocation.Software => HardwareAccelerationMode.Vaapi when _currentState.FrameDataLocation == FrameDataLocation.Software =>

39
ErsatzTV.FFmpeg/Filter/SubtitlesFilter.cs

@ -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
{ {
HardwareAccelerationMode.None => string.Empty, HardwareAccelerationMode.None => string.Empty,
HardwareAccelerationMode.Nvenc => "hwupload_cuda", 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 // leave vaapi in software since we don't (yet) use overlay_vaapi
HardwareAccelerationMode.Vaapi when _currentState.FrameDataLocation == FrameDataLocation.Software => HardwareAccelerationMode.Vaapi when _currentState.FrameDataLocation == FrameDataLocation.Software =>

21
ErsatzTV.FFmpeg/Option/CopyTimestampInputOption.cs

@ -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 @@
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 @@
using System.Numerics; using ErsatzTV.FFmpeg.Decoder;
using ErsatzTV.FFmpeg.Decoder;
using ErsatzTV.FFmpeg.Encoder; using ErsatzTV.FFmpeg.Encoder;
using ErsatzTV.FFmpeg.Environment; using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Filter; using ErsatzTV.FFmpeg.Filter;
@ -23,6 +22,7 @@ public class PipelineBuilder
private readonly Option<WatermarkInputFile> _watermarkInputFile; private readonly Option<WatermarkInputFile> _watermarkInputFile;
private readonly Option<SubtitleInputFile> _subtitleInputFile; private readonly Option<SubtitleInputFile> _subtitleInputFile;
private readonly string _reportsFolder; private readonly string _reportsFolder;
private readonly string _fontsFolder;
private readonly ILogger _logger; private readonly ILogger _logger;
public PipelineBuilder( public PipelineBuilder(
@ -31,6 +31,7 @@ public class PipelineBuilder
Option<WatermarkInputFile> watermarkInputFile, Option<WatermarkInputFile> watermarkInputFile,
Option<SubtitleInputFile> subtitleInputFile, Option<SubtitleInputFile> subtitleInputFile,
string reportsFolder, string reportsFolder,
string fontsFolder,
ILogger logger) ILogger logger)
{ {
_pipelineSteps = new List<IPipelineStep> _pipelineSteps = new List<IPipelineStep>
@ -51,6 +52,7 @@ public class PipelineBuilder
_watermarkInputFile = watermarkInputFile; _watermarkInputFile = watermarkInputFile;
_subtitleInputFile = subtitleInputFile; _subtitleInputFile = subtitleInputFile;
_reportsFolder = reportsFolder; _reportsFolder = reportsFolder;
_fontsFolder = fontsFolder;
_logger = logger; _logger = logger;
} }
@ -127,6 +129,12 @@ public class PipelineBuilder
var option = new StreamSeekInputOption(desiredStart); var option = new StreamSeekInputOption(desiredStart);
_audioInputFile.Iter(f => f.AddOption(option)); _audioInputFile.Iter(f => f.AddOption(option));
_videoInputFile.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) foreach (TimeSpan desiredFinish in ffmpegState.Finish)
@ -215,6 +223,7 @@ public class PipelineBuilder
currentState, currentState,
desiredState, desiredState,
_watermarkInputFile, _watermarkInputFile,
_subtitleInputFile,
_logger)) _logger))
{ {
foreach (VideoInputFile videoInputFile in _videoInputFile) foreach (VideoInputFile videoInputFile in _videoInputFile)
@ -298,7 +307,8 @@ public class PipelineBuilder
ffmpegState.HardwareAccelerationMode, ffmpegState.HardwareAccelerationMode,
currentState, currentState,
desiredState, desiredState,
_watermarkInputFile); _watermarkInputFile,
_subtitleInputFile);
currentState = step.NextState(currentState); currentState = step.NextState(currentState);
_videoInputFile.Iter(f => f.FilterSteps.Add(step)); _videoInputFile.Iter(f => f.FilterSteps.Add(step));
} }
@ -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 // TODO: if all video filters are software, use software pixel format for hwaccel output
@ -530,18 +522,44 @@ public class PipelineBuilder
foreach (SubtitleInputFile subtitleInputFile in _subtitleInputFile) foreach (SubtitleInputFile subtitleInputFile in _subtitleInputFile)
{ {
// vaapi and videotoolbox use a software overlay, so we need to ensure the background is already in software if (subtitleInputFile.IsImageBased)
// though videotoolbox uses software decoders, so no need to download for that
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Vaapi)
{ {
// 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); var downloadFilter = new HardwareDownloadFilter(currentState);
currentState = downloadFilter.NextState(currentState); currentState = downloadFilter.NextState(currentState);
_videoInputFile.Iter(f => f.FilterSteps.Add(downloadFilter)); _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) foreach (WatermarkInputFile watermarkInputFile in _watermarkInputFile)
@ -589,6 +607,24 @@ public class PipelineBuilder
watermarkInputFile.FilterSteps.Add(new WatermarkHardwareUploadFilter(currentState, ffmpegState)); 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) if (ffmpegState.DoNotMapMetadata)
{ {
@ -642,7 +678,8 @@ public class PipelineBuilder
_audioInputFile, _audioInputFile,
_watermarkInputFile, _watermarkInputFile,
_subtitleInputFile, _subtitleInputFile,
currentState.PaddedSize); currentState.PaddedSize,
_fontsFolder);
_pipelineSteps.Add(complexFilter); _pipelineSteps.Add(complexFilter);
} }

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

@ -29,5 +29,9 @@ public class EpisodeMetadataConfiguration : IEntityTypeConfiguration<EpisodeMeta
builder.HasMany(mm => mm.Guids) builder.HasMany(mm => mm.Guids)
.WithOne() .WithOne()
.OnDelete(DeleteBehavior.Cascade); .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
builder.HasMany(mm => mm.Guids) builder.HasMany(mm => mm.Guids)
.WithOne() .WithOne()
.OnDelete(DeleteBehavior.Cascade); .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
builder.HasMany(mm => mm.Studios) builder.HasMany(mm => mm.Studios)
.WithOne() .WithOne()
.OnDelete(DeleteBehavior.Cascade); .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
builder.HasMany(mm => mm.Tags) builder.HasMany(mm => mm.Tags)
.WithOne() .WithOne()
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
builder.HasMany(ovm => ovm.Subtitles)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
} }
} }

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

@ -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 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories; namespace ErsatzTV.Infrastructure.Data.Repositories;
@ -165,6 +166,8 @@ public class MetadataRepository : IMetadataRepository
existingStream.AttachedPic = incomingStream.AttachedPic; existingStream.AttachedPic = incomingStream.AttachedPic;
existingStream.PixelFormat = incomingStream.PixelFormat; existingStream.PixelFormat = incomingStream.PixelFormat;
existingStream.BitsPerRawSample = incomingStream.BitsPerRawSample; existingStream.BitsPerRawSample = incomingStream.BitsPerRawSample;
existingStream.FileName = incomingStream.FileName;
existingStream.MimeType = incomingStream.MimeType;
} }
var chaptersToAdd = incoming.Chapters var chaptersToAdd = incoming.Chapters
@ -479,6 +482,66 @@ public class MetadataRepository : IMetadataRepository
.Map(result => result > 0); .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) public async Task<bool> RemoveGenre(Genre genre)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); 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 @@
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 @@
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 @@
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 @@
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 @@
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
b.Property<bool>("Default") b.Property<bool>("Default")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<bool>("Forced") b.Property<bool>("Forced")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -916,6 +919,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("MediaVersionId") b.Property<int>("MediaVersionId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("MimeType")
.HasColumnType("TEXT");
b.Property<string>("PixelFormat") b.Property<string>("PixelFormat")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -1806,6 +1812,89 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Style"); 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 => modelBuilder.Entity("ErsatzTV.Core.Domain.Tag", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -3213,6 +3302,45 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade); .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 => modelBuilder.Entity("ErsatzTV.Core.Domain.Tag", b =>
{ {
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null) b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
@ -3632,6 +3760,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Styles"); b.Navigation("Styles");
b.Navigation("Subtitles");
b.Navigation("Tags"); b.Navigation("Tags");
}); });
@ -3663,6 +3793,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Studios"); b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags"); b.Navigation("Tags");
b.Navigation("Writers"); b.Navigation("Writers");
@ -3715,6 +3847,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Studios"); b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags"); b.Navigation("Tags");
b.Navigation("Writers"); b.Navigation("Writers");
@ -3739,6 +3873,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Studios"); b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags"); b.Navigation("Tags");
}); });
@ -3754,6 +3890,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Studios"); b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags"); b.Navigation("Tags");
}); });
@ -3783,6 +3921,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Studios"); b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags"); b.Navigation("Tags");
}); });
@ -3798,6 +3938,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Studios"); b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags"); b.Navigation("Tags");
}); });
@ -3818,6 +3960,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Studios"); b.Navigation("Studios");
b.Navigation("Subtitles");
b.Navigation("Tags"); b.Navigation("Tags");
}); });

66
ErsatzTV/Services/SubtitleWorkerService.cs

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

Loading…
Cancel
Save