Browse Source

fix: text subtitle fixes (#2960)

* chore: update quicktype models

* feat: pass extracted subtitle paths to next engine

* fix: subtitle playback fixes
pull/2961/head
Jason Dove 5 days ago committed by GitHub
parent
commit
4ffd775ae7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 4
      ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
  3. 3
      ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs
  4. 29
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs
  5. 22
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs
  6. 462
      ErsatzTV.Core/Next/Config/ChannelConfig.cs
  7. 523
      ErsatzTV.Core/Next/Playout.cs
  8. 75
      ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs
  9. 44
      ErsatzTV/Controllers/InternalController.cs

4
CHANGELOG.md

@ -40,6 +40,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Fix case where block playouts would occasionally get stuck building forever - Fix case where block playouts would occasionally get stuck building forever
- Fix green line sometimes seen with NVIDIA and AMD/VAAPI encoding - Fix green line sometimes seen with NVIDIA and AMD/VAAPI encoding
- Both bugs were in ffmpeg, and ETV's patched ffmpeg 8.1.2 is required for the fixes - Both bugs were in ffmpeg, and ETV's patched ffmpeg 8.1.2 is required for the fixes
- Pass extracted subtitle paths to next engine; this fixes text subtitle burn-in when extraction is enabled
- Embedded text subtitles will otherwise be ignored and unused (when extraction is disabled)
- Fix next engine music video playback when music video credits are disabled
- Fix playback when seeking into content beyond final text subtitle cue
## [26.6.0] - 2026-07-09 ## [26.6.0] - 2026-07-09
### Added ### Added

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

@ -49,7 +49,9 @@ public class UpdateChannelHandler(
bool hasPlayoutChange = hasEpgChange || c.WatermarkId != update.WatermarkId || bool hasPlayoutChange = hasEpgChange || c.WatermarkId != update.WatermarkId ||
c.PreferredAudioLanguageCode != update.PreferredAudioLanguageCode || c.PreferredAudioLanguageCode != update.PreferredAudioLanguageCode ||
c.PreferredAudioTitle != update.PreferredAudioTitle || c.PreferredAudioTitle != update.PreferredAudioTitle ||
c.PreferredSubtitleLanguageCode != update.PreferredSubtitleLanguageCode; c.PreferredSubtitleLanguageCode != update.PreferredSubtitleLanguageCode ||
c.MusicVideoCreditsMode != update.MusicVideoCreditsMode ||
c.SubtitleMode != update.SubtitleMode;
c.Name = update.Name; c.Name = update.Name;
c.Number = update.Number; c.Number = update.Number;

3
ErsatzTV.Application/Streaming/Commands/StartFFmpegNextSessionHandler.cs

@ -353,7 +353,8 @@ public class StartFFmpegNextSessionHandler(
{ {
NextEngineTextSubtitleMode.Convert => Mode.Convert, NextEngineTextSubtitleMode.Convert => Mode.Convert,
_ => Mode.Burn _ => Mode.Burn
} },
FontsFolder = FileSystemLayout.FontsCacheFolder
}; };
string playoutFolder = _fileSystem.Path.Combine(FileSystemLayout.NextPlayoutsFolder, channel.Number, "current"); string playoutFolder = _fileSystem.Path.Combine(FileSystemLayout.NextPlayoutsFolder, channel.Number, "current");

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

@ -1,6 +1,7 @@
using System.IO.Abstractions; using System.IO.Abstractions;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Application.Maintenance; using ErsatzTV.Application.Maintenance;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Locking;
@ -168,6 +169,7 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa
.Map(pi => pi.MediaItemId) .Map(pi => pi.MediaItemId)
.ToList(); .ToList();
bool extractedAnything = false;
foreach (int mediaItemId in toUpdate) foreach (int mediaItemId in toUpdate)
{ {
if (cancellationToken.IsCancellationRequested) if (cancellationToken.IsCancellationRequested)
@ -176,8 +178,16 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa
} }
// extract subtitles and fonts for each item and update db // extract subtitles and fonts for each item and update db
await ExtractSubtitles(dbContext, mediaItemId, ffmpegPath, cancellationToken); extractedAnything |= await ExtractSubtitles(
await ExtractFonts(dbContext, mediaItemId, ffmpegPath, cancellationToken); dbContext,
mediaItemId,
ffmpegPath,
cancellationToken);
extractedAnything |= await ExtractFonts(
dbContext,
mediaItemId,
ffmpegPath,
cancellationToken);
} }
_logger.LogDebug("Done checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck); _logger.LogDebug("Done checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck);
@ -186,6 +196,21 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa
{ {
await _entityLocker.UnlockPlayout(playoutId); await _entityLocker.UnlockPlayout(playoutId);
} }
if (extractedAnything)
{
List<string> channelNumbers = await dbContext.Channels
.AsNoTracking()
.Where(c => c.StreamingEngine == StreamingEngine.Next &&
c.Playouts.Any(p => playoutIdsToCheck.Contains(p.Id)))
.Select(c => c.Number)
.ToListAsync(cancellationToken);
foreach (string channelNumber in channelNumbers)
{
await _workerChannel.WriteAsync(new SyncNextPlayout(channelNumber), cancellationToken);
}
}
} }
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{ {

22
ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs

@ -89,12 +89,14 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem
return result; return result;
} }
protected async Task ExtractSubtitles( protected async Task<bool> ExtractSubtitles(
TvContext dbContext, TvContext dbContext,
int mediaItemId, int mediaItemId,
string ffmpegPath, string ffmpegPath,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
bool extractedAnything = false;
foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId, cancellationToken)) foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId, cancellationToken))
{ {
foreach (List<Subtitle> allSubtitles in GetSubtitles(mediaItem)) foreach (List<Subtitle> allSubtitles in GetSubtitles(mediaItem))
@ -133,9 +135,10 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem
.Add("-hide_banner") .Add("-hide_banner")
.Add("-i").Add(mediaItemPath); .Add("-i").Add(mediaItemPath);
var subtitleIndexes = subtitlesToExtract var subtitleIndexes = allSubtitles
.OrderBy(s => s.Subtitle.StreamIndex) .Filter(s => s.SubtitleKind is SubtitleKind.Embedded)
.Select(s => s.Subtitle.StreamIndex) .OrderBy(s => s.StreamIndex)
.Select(s => s.StreamIndex)
.ToList(); .ToList();
foreach (SubtitleToExtract subtitle in subtitlesToExtract) foreach (SubtitleToExtract subtitle in subtitlesToExtract)
@ -172,6 +175,8 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem
"Successfully extracted {Count} subtitles in {Duration}", "Successfully extracted {Count} subtitles in {Duration}",
subtitlesToExtract.Count, subtitlesToExtract.Count,
sw.Elapsed.Humanize()); sw.Elapsed.Humanize());
extractedAnything = true;
} }
else else
{ {
@ -182,14 +187,18 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem
} }
} }
} }
return extractedAnything;
} }
protected async Task ExtractFonts( protected async Task<bool> ExtractFonts(
TvContext dbContext, TvContext dbContext,
int mediaItemId, int mediaItemId,
string ffmpegPath, string ffmpegPath,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
bool extractedAnything = false;
foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId, cancellationToken)) foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId, cancellationToken))
{ {
MediaVersion headVersion = mediaItem.GetHeadVersion(); MediaVersion headVersion = mediaItem.GetHeadVersion();
@ -232,6 +241,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem
if (fileSystem.File.Exists(fullOutputPath)) if (fileSystem.File.Exists(fullOutputPath))
{ {
logger.LogDebug("Successfully extracted font {Font}", fontStream.FileName); logger.LogDebug("Successfully extracted font {Font}", fontStream.FileName);
extractedAnything = true;
} }
else else
{ {
@ -242,6 +252,8 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem
} }
} }
} }
return extractedAnything;
} }

462
ErsatzTV.Core/Next/Config/ChannelConfig.cs

@ -1,246 +1,262 @@
// <auto-generated /> // <auto-generated />
// //
// To parse this JSON data, add NuGet 'Newtonsoft.Json' then do: // To parse this JSON data, add NuGet 'System.Text.Json' then do:
// //
// using ErsatzTV.Core.Next.Config; // using ErsatzTV.Core.Next.Config;
// //
// var channelConfig = ChannelConfig.FromJson(jsonString); // var channelConfig = ChannelConfig.FromJson(jsonString);
#nullable enable
#pragma warning disable CS8618
#pragma warning disable CS8601
#pragma warning disable CS8602
#pragma warning disable CS8603
namespace ErsatzTV.Core.Next.Config namespace ErsatzTV.Core.Next.Config
{ {
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Globalization; using System.Globalization;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
public partial class ChannelConfig public partial class ChannelConfig
{ {
[JsonProperty("ffmpeg")] [JsonPropertyName("ffmpeg")]
public Ffmpeg Ffmpeg { get; set; } public Ffmpeg Ffmpeg { get; set; }
[JsonProperty("normalization")] [JsonPropertyName("normalization")]
public Normalization Normalization { get; set; } public Normalization Normalization { get; set; }
[JsonProperty("playout")] [JsonPropertyName("playout")]
public Playout Playout { get; set; } public Playout Playout { get; set; }
} }
public partial class Ffmpeg public partial class Ffmpeg
{ {
[JsonProperty("disabled_filters", NullValueHandling = NullValueHandling.Ignore)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string> DisabledFilters { get; set; } [JsonPropertyName("disabled_filters")]
public List<string>? DisabledFilters { get; set; }
[JsonProperty("ffmpeg_path")] [JsonPropertyName("ffmpeg_path")]
public string FfmpegPath { get; set; } public string? FfmpegPath { get; set; }
[JsonProperty("ffprobe_path")] [JsonPropertyName("ffprobe_path")]
public string FfprobePath { get; set; } public string? FfprobePath { get; set; }
[JsonProperty("preferred_filters", NullValueHandling = NullValueHandling.Ignore)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string> PreferredFilters { get; set; } [JsonPropertyName("preferred_filters")]
public List<string>? PreferredFilters { get; set; }
[JsonProperty("reports_folder")] [JsonPropertyName("reports_folder")]
public string ReportsFolder { get; set; } public string? ReportsFolder { get; set; }
} }
public partial class Normalization public partial class Normalization
{ {
[JsonProperty("audio")] [JsonPropertyName("audio")]
public Audio Audio { get; set; } public Audio Audio { get; set; }
[JsonProperty("subtitle", NullValueHandling = NullValueHandling.Ignore)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Subtitle Subtitle { get; set; } [JsonPropertyName("subtitle")]
public Subtitle? Subtitle { get; set; }
[JsonProperty("video")] [JsonPropertyName("video")]
public Video Video { get; set; } public Video Video { get; set; }
} }
public partial class Audio public partial class Audio
{ {
[JsonProperty("bitrate_kbps")] [JsonPropertyName("bitrate_kbps")]
public long? BitrateKbps { get; set; } public long? BitrateKbps { get; set; }
[JsonProperty("buffer_kbps")] [JsonPropertyName("buffer_kbps")]
public long? BufferKbps { get; set; } public long? BufferKbps { get; set; }
[JsonProperty("channels")] [JsonPropertyName("channels")]
public long? Channels { get; set; } public long? Channels { get; set; }
[JsonProperty("format")] [JsonPropertyName("format")]
public AudioFormat? Format { get; set; } public AudioFormat? Format { get; set; }
[JsonProperty("loudness")] [JsonPropertyName("loudness")]
public LoudnessClass Loudness { get; set; } public LoudnessClass? Loudness { get; set; }
[JsonProperty("normalize_loudness", NullValueHandling = NullValueHandling.Ignore)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("normalize_loudness")]
public bool? NormalizeLoudness { get; set; } public bool? NormalizeLoudness { get; set; }
[JsonProperty("sample_rate_hz")] [JsonPropertyName("sample_rate_hz")]
public long? SampleRateHz { get; set; } public long? SampleRateHz { get; set; }
} }
public partial class LoudnessClass public partial class LoudnessClass
{ {
[JsonProperty("integrated_target")] [JsonPropertyName("integrated_target")]
public double? IntegratedTarget { get; set; } public double? IntegratedTarget { get; set; }
[JsonProperty("range_target")] [JsonPropertyName("range_target")]
public double? RangeTarget { get; set; } public double? RangeTarget { get; set; }
[JsonProperty("true_peak")] [JsonPropertyName("true_peak")]
public double? TruePeak { get; set; } public double? TruePeak { get; set; }
} }
public partial class Subtitle public partial class Subtitle
{ {
[JsonProperty("mode", NullValueHandling = NullValueHandling.Ignore)] [JsonPropertyName("fonts_folder")]
public string? FontsFolder { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("mode")]
public Mode? Mode { get; set; } public Mode? Mode { get; set; }
} }
public partial class Video public partial class Video
{ {
[JsonProperty("accel")] [JsonPropertyName("accel")]
public AccelEnum? Accel { get; set; } public AccelEnum? Accel { get; set; }
[JsonProperty("bit_depth")] [JsonPropertyName("bit_depth")]
public long? BitDepth { get; set; } public long? BitDepth { get; set; }
[JsonProperty("bitrate_kbps")] [JsonPropertyName("bitrate_kbps")]
public long? BitrateKbps { get; set; } public long? BitrateKbps { get; set; }
[JsonProperty("buffer_kbps")] [JsonPropertyName("buffer_kbps")]
public long? BufferKbps { get; set; } public long? BufferKbps { get; set; }
[JsonProperty("deinterlace", NullValueHandling = NullValueHandling.Ignore)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("deinterlace")]
public bool? Deinterlace { get; set; } public bool? Deinterlace { get; set; }
[JsonProperty("filters", NullValueHandling = NullValueHandling.Ignore)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Filters Filters { get; set; } [JsonPropertyName("filters")]
public Filters? Filters { get; set; }
[JsonProperty("format")] [JsonPropertyName("format")]
public VideoFormat? Format { get; set; } public VideoFormat? Format { get; set; }
[JsonProperty("height")] [JsonPropertyName("height")]
public long? Height { get; set; } public long? Height { get; set; }
[JsonProperty("scaling_mode", NullValueHandling = NullValueHandling.Ignore)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("scaling_mode")]
public ScalingMode? ScalingMode { get; set; } public ScalingMode? ScalingMode { get; set; }
[JsonProperty("vaapi_device")] [JsonPropertyName("vaapi_device")]
public string VaapiDevice { get; set; } public string? VaapiDevice { get; set; }
[JsonProperty("vaapi_driver")] [JsonPropertyName("vaapi_driver")]
public VaapiDriverEnum? VaapiDriver { get; set; } public VaapiDriverEnum? VaapiDriver { get; set; }
[JsonProperty("width")] [JsonPropertyName("width")]
public long? Width { get; set; } public long? Width { get; set; }
} }
public partial class Filters public partial class Filters
{ {
[JsonProperty("bwdif")] [JsonPropertyName("bwdif")]
public BwdifClass Bwdif { get; set; } public BwdifClass? Bwdif { get; set; }
[JsonProperty("bwdif_cuda")] [JsonPropertyName("bwdif_cuda")]
public BwdifCudaClass BwdifCuda { get; set; } public BwdifCudaClass? BwdifCuda { get; set; }
[JsonProperty("deinterlace_qsv")] [JsonPropertyName("deinterlace_qsv")]
public DeinterlaceQsvClass DeinterlaceQsv { get; set; } public DeinterlaceQsvClass? DeinterlaceQsv { get; set; }
[JsonProperty("deinterlace_vaapi")] [JsonPropertyName("deinterlace_vaapi")]
public DeinterlaceVaapiClass DeinterlaceVaapi { get; set; } public DeinterlaceVaapiClass? DeinterlaceVaapi { get; set; }
[JsonProperty("libplacebo")] [JsonPropertyName("libplacebo")]
public LibplaceboClass Libplacebo { get; set; } public LibplaceboClass? Libplacebo { get; set; }
[JsonProperty("tonemap")] [JsonPropertyName("tonemap")]
public TonemapClass Tonemap { get; set; } public TonemapClass? Tonemap { get; set; }
[JsonProperty("tonemap_opencl")] [JsonPropertyName("tonemap_opencl")]
public TonemapOpenclClass TonemapOpencl { get; set; } public TonemapOpenclClass? TonemapOpencl { get; set; }
[JsonProperty("w3fdif")] [JsonPropertyName("w3fdif")]
public W3FdifClass W3Fdif { get; set; } public W3FdifClass? W3Fdif { get; set; }
[JsonProperty("yadif")] [JsonPropertyName("yadif")]
public YadifClass Yadif { get; set; } public YadifClass? Yadif { get; set; }
[JsonProperty("yadif_cuda")] [JsonPropertyName("yadif_cuda")]
public YadifCudaClass YadifCuda { get; set; } public YadifCudaClass? YadifCuda { get; set; }
} }
public partial class BwdifClass public partial class BwdifClass
{ {
[JsonProperty("mode")] [JsonPropertyName("mode")]
public string Mode { get; set; } public string? Mode { get; set; }
} }
public partial class BwdifCudaClass public partial class BwdifCudaClass
{ {
[JsonProperty("mode")] [JsonPropertyName("mode")]
public string Mode { get; set; } public string? Mode { get; set; }
} }
public partial class DeinterlaceQsvClass public partial class DeinterlaceQsvClass
{ {
[JsonProperty("mode")] [JsonPropertyName("mode")]
public string Mode { get; set; } public string? Mode { get; set; }
} }
public partial class DeinterlaceVaapiClass public partial class DeinterlaceVaapiClass
{ {
[JsonProperty("mode")] [JsonPropertyName("mode")]
public string Mode { get; set; } public string? Mode { get; set; }
} }
public partial class LibplaceboClass public partial class LibplaceboClass
{ {
[JsonProperty("tonemapping")] [JsonPropertyName("tonemapping")]
public string Tonemapping { get; set; } public string? Tonemapping { get; set; }
} }
public partial class TonemapClass public partial class TonemapClass
{ {
[JsonProperty("tonemap")] [JsonPropertyName("tonemap")]
public string Tonemap { get; set; } public string? Tonemap { get; set; }
} }
public partial class TonemapOpenclClass public partial class TonemapOpenclClass
{ {
[JsonProperty("tonemap")] [JsonPropertyName("tonemap")]
public string Tonemap { get; set; } public string? Tonemap { get; set; }
} }
public partial class W3FdifClass public partial class W3FdifClass
{ {
[JsonProperty("mode")] [JsonPropertyName("mode")]
public string Mode { get; set; } public string? Mode { get; set; }
} }
public partial class YadifClass public partial class YadifClass
{ {
[JsonProperty("mode")] [JsonPropertyName("mode")]
public string Mode { get; set; } public string? Mode { get; set; }
} }
public partial class YadifCudaClass public partial class YadifCudaClass
{ {
[JsonProperty("mode")] [JsonPropertyName("mode")]
public string Mode { get; set; } public string? Mode { get; set; }
} }
public partial class Playout public partial class Playout
{ {
[JsonProperty("folder")] [JsonPropertyName("folder")]
public string Folder { get; set; } public string Folder { get; set; }
/// <summary> /// <summary>
/// RFC3339 formatted date/time, e.g. 2026-04-13T00:24:21.527-05:00 /// RFC3339 formatted date/time, e.g. 2026-04-13T00:24:21.527-05:00
/// </summary> /// </summary>
[JsonProperty("virtual_start")] [JsonPropertyName("virtual_start")]
public string VirtualStart { get; set; } public string? VirtualStart { get; set; }
} }
public enum AudioFormat { Aac, Ac3 }; public enum AudioFormat { Aac, Ac3 };
@ -257,21 +273,19 @@ namespace ErsatzTV.Core.Next.Config
public partial class ChannelConfig public partial class ChannelConfig
{ {
public static ChannelConfig FromJson(string json) => JsonConvert.DeserializeObject<ChannelConfig>(json, ErsatzTV.Core.Next.Config.Converter.Settings); public static ChannelConfig FromJson(string json) => JsonSerializer.Deserialize<ChannelConfig>(json, ErsatzTV.Core.Next.Config.Converter.Settings);
} }
public static class Serialize public static class Serialize
{ {
public static string ToJson(this ChannelConfig self) => JsonConvert.SerializeObject(self, ErsatzTV.Core.Next.Config.Converter.Settings); public static string ToJson(this ChannelConfig self) => JsonSerializer.Serialize(self, ErsatzTV.Core.Next.Config.Converter.Settings);
} }
internal static class Converter internal static class Converter
{ {
public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings public static readonly JsonSerializerOptions Settings = new(JsonSerializerDefaults.General)
{ {
NullValueHandling = NullValueHandling.Ignore, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
DateParseHandling = DateParseHandling.None,
Converters = Converters =
{ {
AudioFormatConverter.Singleton, AudioFormatConverter.Singleton,
@ -280,19 +294,20 @@ namespace ErsatzTV.Core.Next.Config
VideoFormatConverter.Singleton, VideoFormatConverter.Singleton,
ScalingModeConverter.Singleton, ScalingModeConverter.Singleton,
VaapiDriverEnumConverter.Singleton, VaapiDriverEnumConverter.Singleton,
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal } new DateOnlyConverter(),
new TimeOnlyConverter(),
IsoDateTimeOffsetConverter.Singleton
}, },
}; };
} }
internal class AudioFormatConverter : JsonConverter internal class AudioFormatConverter : JsonConverter<AudioFormat>
{ {
public override bool CanConvert(Type t) => t == typeof(AudioFormat) || t == typeof(AudioFormat?); public override bool CanConvert(Type t) => t == typeof(AudioFormat);
public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) public override AudioFormat Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{ {
if (reader.TokenType == JsonToken.Null) return null; var value = reader.GetString();
var value = serializer.Deserialize<string>(reader);
switch (value) switch (value)
{ {
case "aac": case "aac":
@ -303,21 +318,15 @@ namespace ErsatzTV.Core.Next.Config
throw new Exception("Cannot unmarshal type AudioFormat"); throw new Exception("Cannot unmarshal type AudioFormat");
} }
public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) public override void Write(Utf8JsonWriter writer, AudioFormat value, JsonSerializerOptions options)
{ {
if (untypedValue == null)
{
serializer.Serialize(writer, null);
return;
}
var value = (AudioFormat)untypedValue;
switch (value) switch (value)
{ {
case AudioFormat.Aac: case AudioFormat.Aac:
serializer.Serialize(writer, "aac"); JsonSerializer.Serialize(writer, "aac", options);
return; return;
case AudioFormat.Ac3: case AudioFormat.Ac3:
serializer.Serialize(writer, "ac3"); JsonSerializer.Serialize(writer, "ac3", options);
return; return;
} }
throw new Exception("Cannot marshal type AudioFormat"); throw new Exception("Cannot marshal type AudioFormat");
@ -326,14 +335,13 @@ namespace ErsatzTV.Core.Next.Config
public static readonly AudioFormatConverter Singleton = new AudioFormatConverter(); public static readonly AudioFormatConverter Singleton = new AudioFormatConverter();
} }
internal class ModeConverter : JsonConverter internal class ModeConverter : JsonConverter<Mode>
{ {
public override bool CanConvert(Type t) => t == typeof(Mode) || t == typeof(Mode?); public override bool CanConvert(Type t) => t == typeof(Mode);
public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) public override Mode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{ {
if (reader.TokenType == JsonToken.Null) return null; var value = reader.GetString();
var value = serializer.Deserialize<string>(reader);
switch (value) switch (value)
{ {
case "burn": case "burn":
@ -344,21 +352,15 @@ namespace ErsatzTV.Core.Next.Config
throw new Exception("Cannot unmarshal type Mode"); throw new Exception("Cannot unmarshal type Mode");
} }
public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) public override void Write(Utf8JsonWriter writer, Mode value, JsonSerializerOptions options)
{ {
if (untypedValue == null)
{
serializer.Serialize(writer, null);
return;
}
var value = (Mode)untypedValue;
switch (value) switch (value)
{ {
case Mode.Burn: case Mode.Burn:
serializer.Serialize(writer, "burn"); JsonSerializer.Serialize(writer, "burn", options);
return; return;
case Mode.Convert: case Mode.Convert:
serializer.Serialize(writer, "convert"); JsonSerializer.Serialize(writer, "convert", options);
return; return;
} }
throw new Exception("Cannot marshal type Mode"); throw new Exception("Cannot marshal type Mode");
@ -367,14 +369,13 @@ namespace ErsatzTV.Core.Next.Config
public static readonly ModeConverter Singleton = new ModeConverter(); public static readonly ModeConverter Singleton = new ModeConverter();
} }
internal class AccelEnumConverter : JsonConverter internal class AccelEnumConverter : JsonConverter<AccelEnum>
{ {
public override bool CanConvert(Type t) => t == typeof(AccelEnum) || t == typeof(AccelEnum?); public override bool CanConvert(Type t) => t == typeof(AccelEnum);
public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) public override AccelEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{ {
if (reader.TokenType == JsonToken.Null) return null; var value = reader.GetString();
var value = serializer.Deserialize<string>(reader);
switch (value) switch (value)
{ {
case "amf": case "amf":
@ -395,36 +396,30 @@ namespace ErsatzTV.Core.Next.Config
throw new Exception("Cannot unmarshal type AccelEnum"); throw new Exception("Cannot unmarshal type AccelEnum");
} }
public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) public override void Write(Utf8JsonWriter writer, AccelEnum value, JsonSerializerOptions options)
{ {
if (untypedValue == null)
{
serializer.Serialize(writer, null);
return;
}
var value = (AccelEnum)untypedValue;
switch (value) switch (value)
{ {
case AccelEnum.Amf: case AccelEnum.Amf:
serializer.Serialize(writer, "amf"); JsonSerializer.Serialize(writer, "amf", options);
return; return;
case AccelEnum.Cuda: case AccelEnum.Cuda:
serializer.Serialize(writer, "cuda"); JsonSerializer.Serialize(writer, "cuda", options);
return; return;
case AccelEnum.Qsv: case AccelEnum.Qsv:
serializer.Serialize(writer, "qsv"); JsonSerializer.Serialize(writer, "qsv", options);
return; return;
case AccelEnum.Rkmpp: case AccelEnum.Rkmpp:
serializer.Serialize(writer, "rkmpp"); JsonSerializer.Serialize(writer, "rkmpp", options);
return; return;
case AccelEnum.Vaapi: case AccelEnum.Vaapi:
serializer.Serialize(writer, "vaapi"); JsonSerializer.Serialize(writer, "vaapi", options);
return; return;
case AccelEnum.Videotoolbox: case AccelEnum.Videotoolbox:
serializer.Serialize(writer, "videotoolbox"); JsonSerializer.Serialize(writer, "videotoolbox", options);
return; return;
case AccelEnum.Vulkan: case AccelEnum.Vulkan:
serializer.Serialize(writer, "vulkan"); JsonSerializer.Serialize(writer, "vulkan", options);
return; return;
} }
throw new Exception("Cannot marshal type AccelEnum"); throw new Exception("Cannot marshal type AccelEnum");
@ -433,14 +428,13 @@ namespace ErsatzTV.Core.Next.Config
public static readonly AccelEnumConverter Singleton = new AccelEnumConverter(); public static readonly AccelEnumConverter Singleton = new AccelEnumConverter();
} }
internal class VideoFormatConverter : JsonConverter internal class VideoFormatConverter : JsonConverter<VideoFormat>
{ {
public override bool CanConvert(Type t) => t == typeof(VideoFormat) || t == typeof(VideoFormat?); public override bool CanConvert(Type t) => t == typeof(VideoFormat);
public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) public override VideoFormat Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{ {
if (reader.TokenType == JsonToken.Null) return null; var value = reader.GetString();
var value = serializer.Deserialize<string>(reader);
switch (value) switch (value)
{ {
case "h264": case "h264":
@ -451,21 +445,15 @@ namespace ErsatzTV.Core.Next.Config
throw new Exception("Cannot unmarshal type VideoFormat"); throw new Exception("Cannot unmarshal type VideoFormat");
} }
public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) public override void Write(Utf8JsonWriter writer, VideoFormat value, JsonSerializerOptions options)
{ {
if (untypedValue == null)
{
serializer.Serialize(writer, null);
return;
}
var value = (VideoFormat)untypedValue;
switch (value) switch (value)
{ {
case VideoFormat.H264: case VideoFormat.H264:
serializer.Serialize(writer, "h264"); JsonSerializer.Serialize(writer, "h264", options);
return; return;
case VideoFormat.Hevc: case VideoFormat.Hevc:
serializer.Serialize(writer, "hevc"); JsonSerializer.Serialize(writer, "hevc", options);
return; return;
} }
throw new Exception("Cannot marshal type VideoFormat"); throw new Exception("Cannot marshal type VideoFormat");
@ -474,14 +462,13 @@ namespace ErsatzTV.Core.Next.Config
public static readonly VideoFormatConverter Singleton = new VideoFormatConverter(); public static readonly VideoFormatConverter Singleton = new VideoFormatConverter();
} }
internal class ScalingModeConverter : JsonConverter internal class ScalingModeConverter : JsonConverter<ScalingMode>
{ {
public override bool CanConvert(Type t) => t == typeof(ScalingMode) || t == typeof(ScalingMode?); public override bool CanConvert(Type t) => t == typeof(ScalingMode);
public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) public override ScalingMode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{ {
if (reader.TokenType == JsonToken.Null) return null; var value = reader.GetString();
var value = serializer.Deserialize<string>(reader);
switch (value) switch (value)
{ {
case "crop": case "crop":
@ -494,24 +481,18 @@ namespace ErsatzTV.Core.Next.Config
throw new Exception("Cannot unmarshal type ScalingMode"); throw new Exception("Cannot unmarshal type ScalingMode");
} }
public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) public override void Write(Utf8JsonWriter writer, ScalingMode value, JsonSerializerOptions options)
{ {
if (untypedValue == null)
{
serializer.Serialize(writer, null);
return;
}
var value = (ScalingMode)untypedValue;
switch (value) switch (value)
{ {
case ScalingMode.Crop: case ScalingMode.Crop:
serializer.Serialize(writer, "crop"); JsonSerializer.Serialize(writer, "crop", options);
return; return;
case ScalingMode.ScaleAndPad: case ScalingMode.ScaleAndPad:
serializer.Serialize(writer, "scale_and_pad"); JsonSerializer.Serialize(writer, "scale_and_pad", options);
return; return;
case ScalingMode.Stretch: case ScalingMode.Stretch:
serializer.Serialize(writer, "stretch"); JsonSerializer.Serialize(writer, "stretch", options);
return; return;
} }
throw new Exception("Cannot marshal type ScalingMode"); throw new Exception("Cannot marshal type ScalingMode");
@ -520,14 +501,13 @@ namespace ErsatzTV.Core.Next.Config
public static readonly ScalingModeConverter Singleton = new ScalingModeConverter(); public static readonly ScalingModeConverter Singleton = new ScalingModeConverter();
} }
internal class VaapiDriverEnumConverter : JsonConverter internal class VaapiDriverEnumConverter : JsonConverter<VaapiDriverEnum>
{ {
public override bool CanConvert(Type t) => t == typeof(VaapiDriverEnum) || t == typeof(VaapiDriverEnum?); public override bool CanConvert(Type t) => t == typeof(VaapiDriverEnum);
public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) public override VaapiDriverEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{ {
if (reader.TokenType == JsonToken.Null) return null; var value = reader.GetString();
var value = serializer.Deserialize<string>(reader);
switch (value) switch (value)
{ {
case "i965": case "i965":
@ -540,24 +520,18 @@ namespace ErsatzTV.Core.Next.Config
throw new Exception("Cannot unmarshal type VaapiDriverEnum"); throw new Exception("Cannot unmarshal type VaapiDriverEnum");
} }
public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) public override void Write(Utf8JsonWriter writer, VaapiDriverEnum value, JsonSerializerOptions options)
{ {
if (untypedValue == null)
{
serializer.Serialize(writer, null);
return;
}
var value = (VaapiDriverEnum)untypedValue;
switch (value) switch (value)
{ {
case VaapiDriverEnum.I965: case VaapiDriverEnum.I965:
serializer.Serialize(writer, "i965"); JsonSerializer.Serialize(writer, "i965", options);
return; return;
case VaapiDriverEnum.Ihd: case VaapiDriverEnum.Ihd:
serializer.Serialize(writer, "ihd"); JsonSerializer.Serialize(writer, "ihd", options);
return; return;
case VaapiDriverEnum.Radeonsi: case VaapiDriverEnum.Radeonsi:
serializer.Serialize(writer, "radeonsi"); JsonSerializer.Serialize(writer, "radeonsi", options);
return; return;
} }
throw new Exception("Cannot marshal type VaapiDriverEnum"); throw new Exception("Cannot marshal type VaapiDriverEnum");
@ -565,4 +539,118 @@ namespace ErsatzTV.Core.Next.Config
public static readonly VaapiDriverEnumConverter Singleton = new VaapiDriverEnumConverter(); public static readonly VaapiDriverEnumConverter Singleton = new VaapiDriverEnumConverter();
} }
public class DateOnlyConverter : JsonConverter<DateOnly>
{
private readonly string serializationFormat;
public DateOnlyConverter() : this(null) { }
public DateOnlyConverter(string? serializationFormat)
{
this.serializationFormat = serializationFormat ?? "yyyy-MM-dd";
}
public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString();
return DateOnly.Parse(value!);
}
public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToString(serializationFormat));
}
public class TimeOnlyConverter : JsonConverter<TimeOnly>
{
private readonly string serializationFormat;
public TimeOnlyConverter() : this(null) { }
public TimeOnlyConverter(string? serializationFormat)
{
this.serializationFormat = serializationFormat ?? "HH:mm:ss.fff";
}
public override TimeOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString();
return TimeOnly.Parse(value!);
}
public override void Write(Utf8JsonWriter writer, TimeOnly value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToString(serializationFormat));
}
internal class IsoDateTimeOffsetConverter : JsonConverter<DateTimeOffset>
{
public override bool CanConvert(Type t) => t == typeof(DateTimeOffset);
private const string DefaultDateTimeFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFFK";
private DateTimeStyles _dateTimeStyles = DateTimeStyles.RoundtripKind;
private string? _dateTimeFormat;
private CultureInfo? _culture;
public DateTimeStyles DateTimeStyles
{
get => _dateTimeStyles;
set => _dateTimeStyles = value;
}
public string? DateTimeFormat
{
get => _dateTimeFormat ?? string.Empty;
set => _dateTimeFormat = (string.IsNullOrEmpty(value)) ? null : value;
}
public CultureInfo Culture
{
get => _culture ?? CultureInfo.CurrentCulture;
set => _culture = value;
}
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
{
string text;
if ((_dateTimeStyles & DateTimeStyles.AdjustToUniversal) == DateTimeStyles.AdjustToUniversal
|| (_dateTimeStyles & DateTimeStyles.AssumeUniversal) == DateTimeStyles.AssumeUniversal)
{
value = value.ToUniversalTime();
}
text = value.ToString(_dateTimeFormat ?? DefaultDateTimeFormat, Culture);
writer.WriteStringValue(text);
}
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
string? dateText = reader.GetString();
if (string.IsNullOrEmpty(dateText) == false)
{
if (!string.IsNullOrEmpty(_dateTimeFormat))
{
return DateTimeOffset.ParseExact(dateText, _dateTimeFormat, Culture, _dateTimeStyles);
}
else
{
return DateTimeOffset.Parse(dateText, Culture, _dateTimeStyles);
}
}
else
{
return default(DateTimeOffset);
}
}
public static readonly IsoDateTimeOffsetConverter Singleton = new IsoDateTimeOffsetConverter();
}
} }
#pragma warning restore CS8618
#pragma warning restore CS8601
#pragma warning restore CS8602
#pragma warning restore CS8603

523
ErsatzTV.Core/Next/Playout.cs

File diff suppressed because it is too large Load Diff

75
ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs

@ -103,20 +103,7 @@ public class PlayoutItemConverter(
foreach (Core.Next.Source source in maybeSource) foreach (Core.Next.Source source in maybeSource)
{ {
if (playoutItem is not DynamicPlayoutItem) SetInOutPoints(playoutItem, source);
{
if (playoutItem.InPoint > TimeSpan.Zero)
{
source.InPointMs = (long)playoutItem.InPoint.TotalMilliseconds;
}
var duration = playoutItem.MediaItem.GetDurationForPlayout();
if (playoutItem.OutPoint > TimeSpan.Zero && playoutItem.OutPoint < duration)
{
source.OutPointMs = (long)playoutItem.OutPoint.TotalMilliseconds;
}
}
nextPlayoutItem.Source = source; nextPlayoutItem.Source = source;
} }
@ -158,7 +145,7 @@ public class PlayoutItemConverter(
Codec = s.Codec Codec = s.Codec
}).ToList(); }).ToList();
nextPlayoutItem.Source.ProbeHint = new Core.Next.ProbeHint nextPlayoutItem.Source!.ProbeHint = new Core.Next.ProbeHint
{ {
Audio = sourceAudioHints, Audio = sourceAudioHints,
Video = sourceVideoHints, Video = sourceVideoHints,
@ -375,7 +362,7 @@ public class PlayoutItemConverter(
ChannelSubtitleMode subtitleMode, ChannelSubtitleMode subtitleMode,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
List<Subtitle> allSubtitles = await GetSubtitles(audioVersion.MediaItem, playoutItem.Id, playoutItem.InPoint); List<Subtitle> allSubtitles = await GetSubtitles(channel, audioVersion.MediaItem, playoutItem.Id, playoutItem.InPoint);
Option<MediaStream> maybeAudioStream = Option<MediaStream>.None; Option<MediaStream> maybeAudioStream = Option<MediaStream>.None;
Option<Subtitle> maybeSubtitle = Option<Subtitle>.None; Option<Subtitle> maybeSubtitle = Option<Subtitle>.None;
@ -428,11 +415,30 @@ public class PlayoutItemConverter(
{ {
if (subtitle.SubtitleKind is SubtitleKind.Embedded) if (subtitle.SubtitleKind is SubtitleKind.Embedded)
{ {
if (nextPlayoutItem.Tracks?.Subtitle?.StreamIndex is null) if (subtitle.IsImage)
{ {
nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); if (nextPlayoutItem.Tracks?.Subtitle?.StreamIndex is null)
nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection(); {
nextPlayoutItem.Tracks.Subtitle.StreamIndex = subtitle.StreamIndex; nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks();
nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection();
nextPlayoutItem.Tracks.Subtitle.StreamIndex = subtitle.StreamIndex;
}
}
// next only supports sidecar text subtitles at the moment; ignore non-extracted text subs
else if (subtitle.IsExtracted && !string.IsNullOrWhiteSpace(subtitle.Path))
{
if (nextPlayoutItem.Tracks?.Subtitle?.Source is null)
{
nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks();
nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection();
nextPlayoutItem.Tracks.Subtitle.Source = new Core.Next.Source
{
SourceType = Core.Next.SourceType.Local,
Path = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.Path),
};
SetInOutPoints(playoutItem, nextPlayoutItem.Tracks.Subtitle.Source);
}
} }
} }
else if (!subtitle.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) else if (!subtitle.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
@ -446,6 +452,8 @@ public class PlayoutItemConverter(
SourceType = Core.Next.SourceType.Local, SourceType = Core.Next.SourceType.Local,
Path = subtitle.Path, Path = subtitle.Path,
}; };
SetInOutPoints(playoutItem, nextPlayoutItem.Tracks.Subtitle.Source);
} }
} }
else if (subtitle.Path.StartsWith("http://localhost", StringComparison.OrdinalIgnoreCase)) else if (subtitle.Path.StartsWith("http://localhost", StringComparison.OrdinalIgnoreCase))
@ -548,6 +556,7 @@ public class PlayoutItemConverter(
} }
private static async Task<List<Subtitle>> GetSubtitles( private static async Task<List<Subtitle>> GetSubtitles(
Channel channel,
MediaItem mediaItem, MediaItem mediaItem,
int playoutItemId, int playoutItemId,
TimeSpan playoutItemInPoint) TimeSpan playoutItemInPoint)
@ -560,7 +569,7 @@ public class PlayoutItemConverter(
Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone() Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? []) .Map(mm => mm.Subtitles ?? [])
.IfNoneAsync([]), .IfNoneAsync([]),
MusicVideo => GetMusicVideoSubtitles(playoutItemId, playoutItemInPoint), MusicVideo => GetMusicVideoSubtitles(channel, playoutItemId, playoutItemInPoint),
OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone() OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? []) .Map(mm => mm.Subtitles ?? [])
.IfNoneAsync([]), .IfNoneAsync([]),
@ -582,8 +591,13 @@ public class PlayoutItemConverter(
return allSubtitles; return allSubtitles;
} }
private static List<Subtitle> GetMusicVideoSubtitles(int playoutItemId, TimeSpan playoutItemInPoint) private static List<Subtitle> GetMusicVideoSubtitles(Channel channel, int playoutItemId, TimeSpan playoutItemInPoint)
{ {
if (channel.MusicVideoCreditsMode is not ChannelMusicVideoCreditsMode.GenerateSubtitles)
{
return [];
}
string seekToMs = playoutItemInPoint > TimeSpan.Zero string seekToMs = playoutItemInPoint > TimeSpan.Zero
? $"?seekToMs={(long)playoutItemInPoint.TotalMilliseconds}" ? $"?seekToMs={(long)playoutItemInPoint.TotalMilliseconds}"
: string.Empty; : string.Empty;
@ -602,4 +616,21 @@ public class PlayoutItemConverter(
} }
]; ];
} }
private static void SetInOutPoints(PlayoutItem playoutItem, Core.Next.Source source)
{
if (playoutItem is not DynamicPlayoutItem)
{
if (playoutItem.InPoint > TimeSpan.Zero)
{
source.InPointMs = (long)playoutItem.InPoint.TotalMilliseconds;
}
var duration = playoutItem.MediaItem.GetDurationForPlayout();
if (playoutItem.OutPoint > TimeSpan.Zero && playoutItem.OutPoint < duration)
{
source.OutPointMs = (long)playoutItem.OutPoint.TotalMilliseconds;
}
}
}
} }

44
ErsatzTV/Controllers/InternalController.cs

@ -1,5 +1,6 @@
using System.CommandLine.Parsing; using System.CommandLine.Parsing;
using System.Diagnostics; using System.Diagnostics;
using System.Text;
using CliWrap; using CliWrap;
using ErsatzTV.Application.Emby; using ErsatzTV.Application.Emby;
using ErsatzTV.Application.Jellyfin; using ErsatzTV.Application.Jellyfin;
@ -22,7 +23,6 @@ using MediatR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Primitives;
using Newtonsoft.Json;
namespace ErsatzTV.Controllers; namespace ErsatzTV.Controllers;
@ -64,7 +64,8 @@ public class InternalController : StreamingControllerBase
[HttpGet("ffmpeg/music-video-credits/{playoutItemId:int}")] [HttpGet("ffmpeg/music-video-credits/{playoutItemId:int}")]
public async Task<IActionResult> GetMusicVideoCredits( public async Task<IActionResult> GetMusicVideoCredits(
int playoutItemId, int playoutItemId,
[FromQuery] long? seekToMs, [FromQuery]
long? seekToMs,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Option<string> maybeCreditsFile = await _mediator.Send( Option<string> maybeCreditsFile = await _mediator.Send(
@ -75,7 +76,7 @@ public class InternalController : StreamingControllerBase
return new PhysicalFileResult(creditsFile, "text/x-ssa"); return new PhysicalFileResult(creditsFile, "text/x-ssa");
} }
return NotFound(); return File(Encoding.UTF8.GetBytes(EmptySubtitleDocument("text/x-ssa")), "text/x-ssa");
} }
[HttpGet("ffmpeg/remote-stream/{remoteStreamId}")] [HttpGet("ffmpeg/remote-stream/{remoteStreamId}")]
@ -219,9 +220,14 @@ public class InternalController : StreamingControllerBase
} }
[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,
CancellationToken cancellationToken)
{ {
Either<BaseError, SubtitlePathAndCodec> maybePath = await _mediator.Send(new GetSubtitlePathById(id)); Either<BaseError, SubtitlePathAndCodec> maybePath = await _mediator.Send(
new GetSubtitlePathById(id),
cancellationToken);
foreach (SubtitlePathAndCodec pathAndCodec in maybePath.RightToSeq()) foreach (SubtitlePathAndCodec pathAndCodec in maybePath.RightToSeq())
{ {
@ -237,7 +243,8 @@ public class InternalController : StreamingControllerBase
if (seekToMs is > 0) if (seekToMs is > 0)
{ {
Either<BaseError, SeekTextSubtitleProcess> maybeProcess = await _mediator.Send( Either<BaseError, SeekTextSubtitleProcess> maybeProcess = await _mediator.Send(
new GetSeekTextSubtitleProcess(pathAndCodec, TimeSpan.FromMilliseconds(seekToMs.Value))); new GetSeekTextSubtitleProcess(pathAndCodec, TimeSpan.FromMilliseconds(seekToMs.Value)),
cancellationToken);
foreach (SeekTextSubtitleProcess processModel in maybeProcess.RightToSeq()) foreach (SeekTextSubtitleProcess processModel in maybeProcess.RightToSeq())
{ {
Command command = processModel.Process; Command command = processModel.Process;
@ -265,10 +272,20 @@ public class InternalController : StreamingControllerBase
} }
process.Start(); process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, mimeType); using var buffer = new MemoryStream();
await process.StandardOutput.BaseStream.CopyToAsync(buffer, cancellationToken);
await process.WaitForExitAsync(cancellationToken);
byte[] bytes = buffer.ToArray();
if (bytes.Length == 0)
{
return Content(EmptySubtitleDocument(mimeType), mimeType);
}
return File(bytes, mimeType);
} }
return new NotFoundResult(); return Content(EmptySubtitleDocument(mimeType), mimeType);
} }
if (pathAndCodec.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) if (pathAndCodec.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
@ -320,7 +337,7 @@ public class InternalController : StreamingControllerBase
foreach (Core.Next.PlayoutItem nextPlayoutItem in maybeNextPlayoutItem) foreach (Core.Next.PlayoutItem nextPlayoutItem in maybeNextPlayoutItem)
{ {
return Content( return Content(
JsonConvert.SerializeObject(nextPlayoutItem, Core.Next.Converter.Settings), System.Text.Json.JsonSerializer.Serialize(nextPlayoutItem, Core.Next.Converter.Settings),
"application/json"); "application/json");
} }
} }
@ -348,4 +365,13 @@ public class InternalController : StreamingControllerBase
return GetProcessResponse(result, channelNumber, StreamingMode.TransportStream); return GetProcessResponse(result, channelNumber, StreamingMode.TransportStream);
} }
private static string EmptySubtitleDocument(string mimeType) => mimeType switch
{
"text/x-ssa" => "[Script Info]\nScriptType: v4.00+\n\n" +
"[V4+ Styles]\nFormat: Name, Fontname, Fontsize\nStyle: Default,Arial,20\n\n" +
"[Events]\nFormat: Layer, Start, End, Style, Text\n",
"text/vtt" => "WEBVTT\n\n",
_ => "1\n00:00:00,000 --> 00:00:00,001\n \n\n"
};
} }

Loading…
Cancel
Save