Browse Source

fix external ssa subtitles from media servers (#2450)

pull/2451/head
Jason Dove 8 months ago committed by GitHub
parent
commit
b790b5944c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 3
      ErsatzTV.Application/Streaming/Queries/GetSeekTextSubtitleProcess.cs
  3. 3
      ErsatzTV.Application/Streaming/Queries/GetSeekTextSubtitleProcessHandler.cs
  4. 2
      ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathById.cs
  5. 18
      ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs
  6. 3
      ErsatzTV.Application/Subtitles/SubtitlePathAndCodec.cs
  7. 6
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  8. 2
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  9. 2
      ErsatzTV.FFmpeg/Pipeline/IPipelineBuilder.cs
  10. 4
      ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs
  11. 17
      ErsatzTV/Controllers/InternalController.cs

1
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 fallback filler duration on mirror channels
- Fix slow startup caused by check for overlapping playout items - 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 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 ### Changed
- Filler presets: use separate text fields for `hours`, `minutes` and `seconds` duration - Filler presets: use separate text fields for `hours`, `minutes` and `seconds` duration

3
ErsatzTV.Application/Streaming/Queries/GetSeekTextSubtitleProcess.cs

@ -1,6 +1,7 @@
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core; using ErsatzTV.Core;
namespace ErsatzTV.Application.Streaming; namespace ErsatzTV.Application.Streaming;
public record GetSeekTextSubtitleProcess(string SubtitlePath, TimeSpan Seek) public record GetSeekTextSubtitleProcess(SubtitlePathAndCodec PathAndCodec, TimeSpan Seek)
: IRequest<Either<BaseError, SeekTextSubtitleProcess>>; : IRequest<Either<BaseError, SeekTextSubtitleProcess>>;

3
ErsatzTV.Application/Streaming/Queries/GetSeekTextSubtitleProcessHandler.cs

@ -31,7 +31,8 @@ public class GetSeekTextSubtitleProcessHandler(
{ {
Command process = await ffmpegProcessService.SeekTextSubtitle( Command process = await ffmpegProcessService.SeekTextSubtitle(
ffmpegPath, ffmpegPath,
request.SubtitlePath, request.PathAndCodec.Path,
request.PathAndCodec.Codec,
request.Seek); request.Seek);
return new SeekTextSubtitleProcess(process); return new SeekTextSubtitleProcess(process);

2
ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathById.cs

@ -2,4 +2,4 @@ using ErsatzTV.Core;
namespace ErsatzTV.Application.Subtitles.Queries; namespace ErsatzTV.Application.Subtitles.Queries;
public record GetSubtitlePathById(int Id) : IRequest<Either<BaseError, string>>; public record GetSubtitlePathById(int Id) : IRequest<Either<BaseError, SubtitlePathAndCodec>>;

18
ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs

@ -9,9 +9,9 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Subtitles.Queries; namespace ErsatzTV.Application.Subtitles.Queries;
public class GetSubtitlePathByIdHandler(IDbContextFactory<TvContext> dbContextFactory) public class GetSubtitlePathByIdHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetSubtitlePathById, Either<BaseError, string>> : IRequestHandler<GetSubtitlePathById, Either<BaseError, SubtitlePathAndCodec>>
{ {
public async Task<Either<BaseError, string>> Handle( public async Task<Either<BaseError, SubtitlePathAndCodec>> Handle(
GetSubtitlePathById request, GetSubtitlePathById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@ -22,33 +22,35 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory<TvContext> dbContextFa
foreach (var subtitle in maybeSubtitle) foreach (var subtitle in maybeSubtitle)
{ {
string path = subtitle.Path;
if (subtitle is { SubtitleKind: SubtitleKind.Embedded, IsExtracted: true }) 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)) foreach (string plexUrl in await GetPlexUrl(request.Id, dbContext, maybeSubtitle))
{ {
return plexUrl; path = plexUrl;
} }
foreach (string jellyfinUrl in await GetJellyfinUrl(request.Id, dbContext, maybeSubtitle)) foreach (string jellyfinUrl in await GetJellyfinUrl(request.Id, dbContext, maybeSubtitle))
{ {
return jellyfinUrl; path = jellyfinUrl;
} }
foreach (string embyUrl in await GetEmbyUrl(request.Id, dbContext, maybeSubtitle)) 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}"); return BaseError.New($"Unable to locate subtitle with id {request.Id}");
} }
protected static async Task<Option<string>> GetPlexUrl( protected static async Task<Option<string>> GetPlexUrl(
int subtitleId, int subtitleId,
TvContext dbContext, TvContext dbContext,
Option<Subtitle> maybeSubtitle) Option<Subtitle> maybeSubtitle)

3
ErsatzTV.Application/Subtitles/SubtitlePathAndCodec.cs

@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Subtitles;
public record SubtitlePathAndCodec(string Path, string Codec);

6
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -967,7 +967,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
watermarkWidthPercent, watermarkWidthPercent,
cancellationToken); cancellationToken);
public async Task<Command> SeekTextSubtitle(string ffmpegPath, string inputFile, TimeSpan seek) public async Task<Command> SeekTextSubtitle(string ffmpegPath, string inputFile, string codec, TimeSpan seek)
{ {
var videoInputFile = new VideoInputFile( var videoInputFile = new VideoInputFile(
inputFile, inputFile,
@ -975,7 +975,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
{ {
new( new(
0, 0,
string.Empty, codec,
string.Empty, string.Empty,
None, None,
ColorParams.Default, ColorParams.Default,
@ -1002,7 +1002,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
FileSystemLayout.FontsCacheFolder, FileSystemLayout.FontsCacheFolder,
ffmpegPath); ffmpegPath);
FFmpegPipeline pipeline = pipelineBuilder.Seek(inputFile, seek); FFmpegPipeline pipeline = pipelineBuilder.Seek(inputFile, codec, seek);
return GetCommand(ffmpegPath, videoInputFile, None, None, None, None, pipeline, false); return GetCommand(ffmpegPath, videoInputFile, None, None, None, None, pipeline, false);
} }

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

@ -90,5 +90,5 @@ public interface IFFmpegProcessService
int watermarkWidthPercent, int watermarkWidthPercent,
CancellationToken cancellationToken); CancellationToken cancellationToken);
Task<Command> SeekTextSubtitle(string ffmpegPath, string inputFile, TimeSpan seek); Task<Command> SeekTextSubtitle(string ffmpegPath, string inputFile, string codec, TimeSpan seek);
} }

2
ErsatzTV.FFmpeg/Pipeline/IPipelineBuilder.cs

@ -3,7 +3,7 @@ namespace ErsatzTV.FFmpeg.Pipeline;
public interface IPipelineBuilder public interface IPipelineBuilder
{ {
FFmpegPipeline Resize(string outputFile, FrameSize scaledSize); 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 Concat(ConcatInputFile concatInputFile, FFmpegState ffmpegState);
FFmpegPipeline WrapSegmenter(ConcatInputFile concatInputFile, FFmpegState ffmpegState); FFmpegPipeline WrapSegmenter(ConcatInputFile concatInputFile, FFmpegState ffmpegState);
FFmpegPipeline Build(FFmpegState ffmpegState, FrameState desiredState); FFmpegPipeline Build(FFmpegState ffmpegState, FrameState desiredState);

4
ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs

@ -75,12 +75,14 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
return new FFmpegPipeline(pipelineSteps, false); 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 IPipelineStep outputFormat = Path.GetExtension(inputFile).ToLowerInvariant() switch
{ {
".ass" or ".ssa" => new OutputFormatAss(), ".ass" or ".ssa" => new OutputFormatAss(),
".vtt" => new OutputFormatWebVtt(), ".vtt" => new OutputFormatWebVtt(),
_ when codec.ToLowerInvariant() is "ass" or "ssa" => new OutputFormatAss(),
_ when codec.ToLowerInvariant() is "vtt" => new OutputFormatWebVtt(),
_ => new OutputFormatSrt() _ => new OutputFormatSrt()
}; };

17
ErsatzTV/Controllers/InternalController.cs

@ -7,6 +7,7 @@ using ErsatzTV.Application.Jellyfin;
using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Plex; using ErsatzTV.Application.Plex;
using ErsatzTV.Application.Streaming; using ErsatzTV.Application.Streaming;
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Application.Subtitles.Queries; using ErsatzTV.Application.Subtitles.Queries;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
@ -202,21 +203,23 @@ public class InternalController : ControllerBase
[HttpGet("/media/subtitle/{id:int}")] [HttpGet("/media/subtitle/{id:int}")]
public async Task<IActionResult> GetSubtitle(int id, [FromQuery] long? seekToMs) public async Task<IActionResult> GetSubtitle(int id, [FromQuery] long? seekToMs)
{ {
Either<BaseError, string> maybePath = await _mediator.Send(new GetSubtitlePathById(id)); Either<BaseError, SubtitlePathAndCodec> 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", ".ass" or ".ssa" => "text/x-ssa",
".vtt" => "text/vtt", ".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" _ => "application/x-subrip"
}; };
if (seekToMs is > 0) if (seekToMs is > 0)
{ {
Either<BaseError, SeekTextSubtitleProcess> maybeProcess = await _mediator.Send( Either<BaseError, SeekTextSubtitleProcess> maybeProcess = await _mediator.Send(
new GetSeekTextSubtitleProcess(path, TimeSpan.FromMilliseconds(seekToMs.Value))); new GetSeekTextSubtitleProcess(pathAndCodec, TimeSpan.FromMilliseconds(seekToMs.Value)));
foreach (SeekTextSubtitleProcess processModel in maybeProcess.RightToSeq()) foreach (SeekTextSubtitleProcess processModel in maybeProcess.RightToSeq())
{ {
Command command = processModel.Process; Command command = processModel.Process;
@ -250,12 +253,12 @@ public class InternalController : ControllerBase
return new NotFoundResult(); 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(); return new NotFoundResult();

Loading…
Cancel
Save