diff --git a/CHANGELOG.md b/CHANGELOG.md index aa1096fe9..ee1669ff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix fallback filler duration on mirror channels - Fix slow startup caused by check for overlapping playout items - Fix green line in *most* cases when overlaying content using NVIDIA acceleration and H264 output +- Fix non-SRT (e.g. SSA/ASS) external subtitle playback from media servers ### Changed - Filler presets: use separate text fields for `hours`, `minutes` and `seconds` duration diff --git a/ErsatzTV.Application/Streaming/Queries/GetSeekTextSubtitleProcess.cs b/ErsatzTV.Application/Streaming/Queries/GetSeekTextSubtitleProcess.cs index 0c8f050e8..05b5a60e4 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetSeekTextSubtitleProcess.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetSeekTextSubtitleProcess.cs @@ -1,6 +1,7 @@ +using ErsatzTV.Application.Subtitles; using ErsatzTV.Core; namespace ErsatzTV.Application.Streaming; -public record GetSeekTextSubtitleProcess(string SubtitlePath, TimeSpan Seek) +public record GetSeekTextSubtitleProcess(SubtitlePathAndCodec PathAndCodec, TimeSpan Seek) : IRequest>; diff --git a/ErsatzTV.Application/Streaming/Queries/GetSeekTextSubtitleProcessHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetSeekTextSubtitleProcessHandler.cs index e6e74e06e..5019235f3 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetSeekTextSubtitleProcessHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetSeekTextSubtitleProcessHandler.cs @@ -31,7 +31,8 @@ public class GetSeekTextSubtitleProcessHandler( { Command process = await ffmpegProcessService.SeekTextSubtitle( ffmpegPath, - request.SubtitlePath, + request.PathAndCodec.Path, + request.PathAndCodec.Codec, request.Seek); return new SeekTextSubtitleProcess(process); diff --git a/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathById.cs b/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathById.cs index 6b9f5f4fe..6782b8e4d 100644 --- a/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathById.cs +++ b/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathById.cs @@ -2,4 +2,4 @@ using ErsatzTV.Core; namespace ErsatzTV.Application.Subtitles.Queries; -public record GetSubtitlePathById(int Id) : IRequest>; +public record GetSubtitlePathById(int Id) : IRequest>; diff --git a/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs b/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs index 034175f5a..02a31686b 100644 --- a/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs +++ b/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs @@ -9,9 +9,9 @@ using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.Subtitles.Queries; public class GetSubtitlePathByIdHandler(IDbContextFactory dbContextFactory) - : IRequestHandler> + : IRequestHandler> { - public async Task> Handle( + public async Task> Handle( GetSubtitlePathById request, CancellationToken cancellationToken) { @@ -22,33 +22,35 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory dbContextFa foreach (var subtitle in maybeSubtitle) { + string path = subtitle.Path; + if (subtitle is { SubtitleKind: SubtitleKind.Embedded, IsExtracted: true }) { - return Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.Path); + path = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.Path); } foreach (string plexUrl in await GetPlexUrl(request.Id, dbContext, maybeSubtitle)) { - return plexUrl; + path = plexUrl; } foreach (string jellyfinUrl in await GetJellyfinUrl(request.Id, dbContext, maybeSubtitle)) { - return jellyfinUrl; + path = jellyfinUrl; } foreach (string embyUrl in await GetEmbyUrl(request.Id, dbContext, maybeSubtitle)) { - return embyUrl; + path = embyUrl; } - return subtitle.Path; + return new SubtitlePathAndCodec(path, subtitle.Codec); } return BaseError.New($"Unable to locate subtitle with id {request.Id}"); } - protected static async Task> GetPlexUrl( + protected static async Task> GetPlexUrl( int subtitleId, TvContext dbContext, Option maybeSubtitle) diff --git a/ErsatzTV.Application/Subtitles/SubtitlePathAndCodec.cs b/ErsatzTV.Application/Subtitles/SubtitlePathAndCodec.cs new file mode 100644 index 000000000..e327e00e1 --- /dev/null +++ b/ErsatzTV.Application/Subtitles/SubtitlePathAndCodec.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Subtitles; + +public record SubtitlePathAndCodec(string Path, string Codec); diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index fe4330cef..b8f20d916 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -967,7 +967,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService watermarkWidthPercent, cancellationToken); - public async Task SeekTextSubtitle(string ffmpegPath, string inputFile, TimeSpan seek) + public async Task SeekTextSubtitle(string ffmpegPath, string inputFile, string codec, TimeSpan seek) { var videoInputFile = new VideoInputFile( inputFile, @@ -975,7 +975,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService { new( 0, - string.Empty, + codec, string.Empty, None, ColorParams.Default, @@ -1002,7 +1002,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService FileSystemLayout.FontsCacheFolder, ffmpegPath); - FFmpegPipeline pipeline = pipelineBuilder.Seek(inputFile, seek); + FFmpegPipeline pipeline = pipelineBuilder.Seek(inputFile, codec, seek); return GetCommand(ffmpegPath, videoInputFile, None, None, None, None, pipeline, false); } diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs index 1bcbd4eb6..295aa1797 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs @@ -90,5 +90,5 @@ public interface IFFmpegProcessService int watermarkWidthPercent, CancellationToken cancellationToken); - Task SeekTextSubtitle(string ffmpegPath, string inputFile, TimeSpan seek); + Task SeekTextSubtitle(string ffmpegPath, string inputFile, string codec, TimeSpan seek); } diff --git a/ErsatzTV.FFmpeg/Pipeline/IPipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/IPipelineBuilder.cs index 69aa1871f..de104f10b 100644 --- a/ErsatzTV.FFmpeg/Pipeline/IPipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/IPipelineBuilder.cs @@ -3,7 +3,7 @@ namespace ErsatzTV.FFmpeg.Pipeline; public interface IPipelineBuilder { FFmpegPipeline Resize(string outputFile, FrameSize scaledSize); - FFmpegPipeline Seek(string inputFile, TimeSpan seek); + FFmpegPipeline Seek(string inputFile, string codec, TimeSpan seek); FFmpegPipeline Concat(ConcatInputFile concatInputFile, FFmpegState ffmpegState); FFmpegPipeline WrapSegmenter(ConcatInputFile concatInputFile, FFmpegState ffmpegState); FFmpegPipeline Build(FFmpegState ffmpegState, FrameState desiredState); diff --git a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs index df0d86afc..d34fe5f7e 100644 --- a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs +++ b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs @@ -75,12 +75,14 @@ public abstract class PipelineBuilderBase : IPipelineBuilder return new FFmpegPipeline(pipelineSteps, false); } - public FFmpegPipeline Seek(string inputFile, TimeSpan seek) + public FFmpegPipeline Seek(string inputFile, string codec, TimeSpan seek) { IPipelineStep outputFormat = Path.GetExtension(inputFile).ToLowerInvariant() switch { ".ass" or ".ssa" => new OutputFormatAss(), ".vtt" => new OutputFormatWebVtt(), + _ when codec.ToLowerInvariant() is "ass" or "ssa" => new OutputFormatAss(), + _ when codec.ToLowerInvariant() is "vtt" => new OutputFormatWebVtt(), _ => new OutputFormatSrt() }; diff --git a/ErsatzTV/Controllers/InternalController.cs b/ErsatzTV/Controllers/InternalController.cs index 831a9e685..196206e93 100644 --- a/ErsatzTV/Controllers/InternalController.cs +++ b/ErsatzTV/Controllers/InternalController.cs @@ -7,6 +7,7 @@ using ErsatzTV.Application.Jellyfin; using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.Plex; using ErsatzTV.Application.Streaming; +using ErsatzTV.Application.Subtitles; using ErsatzTV.Application.Subtitles.Queries; using ErsatzTV.Core; using ErsatzTV.Core.FFmpeg; @@ -202,21 +203,23 @@ public class InternalController : ControllerBase [HttpGet("/media/subtitle/{id:int}")] public async Task GetSubtitle(int id, [FromQuery] long? seekToMs) { - Either maybePath = await _mediator.Send(new GetSubtitlePathById(id)); + Either maybePath = await _mediator.Send(new GetSubtitlePathById(id)); - foreach (string path in maybePath.RightToSeq()) + foreach (SubtitlePathAndCodec pathAndCodec in maybePath.RightToSeq()) { - string mimeType = Path.GetExtension(path).ToLowerInvariant() switch + string mimeType = Path.GetExtension(pathAndCodec.Path ?? string.Empty).ToLowerInvariant() switch { ".ass" or ".ssa" => "text/x-ssa", ".vtt" => "text/vtt", + _ when pathAndCodec.Codec.ToLowerInvariant() is "ass" or "ssa" => "text/x-ssa", + _ when pathAndCodec.Codec.ToLowerInvariant() is "vtt" => "text/vtt", _ => "application/x-subrip" }; if (seekToMs is > 0) { Either maybeProcess = await _mediator.Send( - new GetSeekTextSubtitleProcess(path, TimeSpan.FromMilliseconds(seekToMs.Value))); + new GetSeekTextSubtitleProcess(pathAndCodec, TimeSpan.FromMilliseconds(seekToMs.Value))); foreach (SeekTextSubtitleProcess processModel in maybeProcess.RightToSeq()) { Command command = processModel.Process; @@ -250,12 +253,12 @@ public class InternalController : ControllerBase return new NotFoundResult(); } - if (path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + if (pathAndCodec.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { - return new RedirectResult(path); + return new RedirectResult(pathAndCodec.Path); } - return new PhysicalFileResult(path, mimeType); + return new PhysicalFileResult(pathAndCodec.Path, mimeType); } return new NotFoundResult();