diff --git a/CHANGELOG.md b/CHANGELOG.md index e70e06c0a..ef94c183e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix HLS Direct playback when JWT auth is also used - Use configured ffmpeg path for motion and subtitle graphics elements - Previously, these elements required ffmpeg to be on PATH +- Fix erroneous warning `Unable to locate MPEG-TS Script in folder Default` on installations with case-sensitive file systems ## [26.5.1] - 2026-05-08 ### Fixed diff --git a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs index a656955bc..4d626bd52 100644 --- a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs @@ -304,6 +304,7 @@ public partial class SyncNextPlayoutHandler( var audioVersion = new MediaItemAudioVersion(playoutItem.MediaItem, headVersion); await SelectTracks( channel, + playoutItem, audioVersion, nextPlayoutItem, playoutItem.PreferredAudioLanguageCode ?? channel.PreferredAudioLanguageCode, @@ -327,6 +328,7 @@ public partial class SyncNextPlayoutHandler( private async Task SelectTracks( Channel channel, + PlayoutItem playoutItem, MediaItemAudioVersion audioVersion, Core.Next.PlayoutItem nextPlayoutItem, string preferredAudioLanguage, @@ -335,7 +337,7 @@ public partial class SyncNextPlayoutHandler( ChannelSubtitleMode subtitleMode, CancellationToken cancellationToken) { - List allSubtitles = await GetSubtitles(audioVersion.MediaItem); + List allSubtitles = await GetSubtitles(audioVersion.MediaItem, playoutItem.Id, playoutItem.InPoint); Option maybeAudioStream = Option.None; Option maybeSubtitle = Option.None; @@ -407,6 +409,21 @@ public partial class SyncNextPlayoutHandler( }; } } + else if (subtitle.Path.StartsWith("http://localhost", StringComparison.OrdinalIgnoreCase)) + { + 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.Http, + Uri = subtitle.Path, + KeepAlive = false, + Reconnect = true + }; + } + } } } @@ -672,7 +689,10 @@ public partial class SyncNextPlayoutHandler( } } - private static async Task> GetSubtitles(MediaItem mediaItem) + private static async Task> GetSubtitles( + MediaItem mediaItem, + int playoutItemId, + TimeSpan playoutItemInPoint) { List allSubtitles = mediaItem switch { @@ -682,7 +702,7 @@ public partial class SyncNextPlayoutHandler( Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone() .Map(mm => mm.Subtitles ?? []) .IfNoneAsync([]), - //MusicVideo musicVideo => await GetMusicVideoSubtitles(musicVideo, channel, settings), + MusicVideo => GetMusicVideoSubtitles(playoutItemId, playoutItemInPoint), OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone() .Map(mm => mm.Subtitles ?? []) .IfNoneAsync([]), @@ -703,4 +723,25 @@ public partial class SyncNextPlayoutHandler( return allSubtitles; } + + private static List GetMusicVideoSubtitles(int playoutItemId, TimeSpan playoutItemInPoint) + { + string seekToMs = playoutItemInPoint > TimeSpan.Zero + ? $"?seekToMs={(long)playoutItemInPoint.TotalMilliseconds}" + : string.Empty; + + return + [ + new Subtitle + { + Codec = "ass", + Default = true, + Forced = true, + IsExtracted = false, + SubtitleKind = SubtitleKind.Generated, + Path = $"http://localhost:{Settings.StreamingPort}/ffmpeg/music-video-credits/{playoutItemId}{seekToMs}", + SDH = false + } + ]; + } } diff --git a/ErsatzTV.Application/Streaming/Queries/GetMusicVideoCreditsByPlayoutItemId.cs b/ErsatzTV.Application/Streaming/Queries/GetMusicVideoCreditsByPlayoutItemId.cs new file mode 100644 index 000000000..e78b0573c --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/GetMusicVideoCreditsByPlayoutItemId.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Streaming; + +public record GetMusicVideoCreditsByPlayoutItemId(int PlayoutItemId, Option SeekToMs) : IRequest>; diff --git a/ErsatzTV.Application/Streaming/Queries/GetMusicVideoCreditsByPlayoutItemIdHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetMusicVideoCreditsByPlayoutItemIdHandler.cs new file mode 100644 index 000000000..98cdf7655 --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/GetMusicVideoCreditsByPlayoutItemIdHandler.cs @@ -0,0 +1,95 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Application.Streaming; + +public class GetMusicVideoCreditsByPlayoutItemIdHandler( + IDbContextFactory dbContextFactory, + IMusicVideoCreditsGenerator musicVideoCreditsGenerator, + ILogger logger) + : IRequestHandler> +{ + public async Task> Handle( + GetMusicVideoCreditsByPlayoutItemId request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + Option maybePlayoutItem = await dbContext.PlayoutItems + .AsNoTracking() + .Include(pi => pi.MediaItem) + .ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Subtitles) + .Include(i => i.MediaItem) + .ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Artists) + .Include(i => i.MediaItem) + .ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Studios) + .Include(i => i.MediaItem) + .ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata) + .ThenInclude(mvm => mvm.Directors) + .Include(i => i.MediaItem) + .ThenInclude(mi => (mi as MusicVideo).MediaVersions) + .ThenInclude(mv => mv.MediaFiles) + .Include(i => i.MediaItem) + .ThenInclude(mi => (mi as MusicVideo).MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(i => i.MediaItem) + .ThenInclude(mi => (mi as MusicVideo).Artist) + .ThenInclude(mv => mv.ArtistMetadata) + .Include(pi => pi.Playout) + .ThenInclude(p => p.Channel) + .ThenInclude(c => c.FFmpegProfile) + .ThenInclude(ff => ff.Resolution) + .SingleOrDefaultAsync(pi => pi.Id == request.PlayoutItemId, cancellationToken) + .Map(Optional); + + var subtitles = new List(); + foreach (PlayoutItem playoutItem in maybePlayoutItem) + { + if (playoutItem.MediaItem is not MusicVideo musicVideo) + { + break; + } + + switch (playoutItem.Playout.Channel.MusicVideoCreditsMode) + { + case ChannelMusicVideoCreditsMode.GenerateSubtitles: + string templateName = playoutItem.Playout.Channel.MusicVideoCreditsTemplate; + if (!string.IsNullOrWhiteSpace(templateName)) + { + var fileWithExtension = $"{templateName}.sbntxt"; + subtitles.AddRange( + await musicVideoCreditsGenerator.GenerateCreditsSubtitleFromTemplate( + musicVideo, + playoutItem.Playout.Channel.FFmpegProfile, + request.SeekToMs.Map(TimeSpan.FromMilliseconds), + Path.Combine(FileSystemLayout.MusicVideoCreditsTemplatesFolder, fileWithExtension))); + } + else + { + logger.LogWarning( + "Music video credits template {Template} does not exist; falling back to built-in template", + templateName); + + subtitles.AddRange( + await musicVideoCreditsGenerator.GenerateCreditsSubtitle( + musicVideo, + playoutItem.Playout.Channel.FFmpegProfile)); + } + + break; + case ChannelMusicVideoCreditsMode.None: + default: + break; + } + } + + return subtitles.HeadOrNone().Map(s => s.Path); + } +} diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index d5e2a4238..b96f99894 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -654,7 +654,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< await _musicVideoCreditsGenerator.GenerateCreditsSubtitleFromTemplate( musicVideo, channel.FFmpegProfile, - settings, + settings.StreamSeek, Path.Combine(FileSystemLayout.MusicVideoCreditsTemplatesFolder, fileWithExtension))); } else diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IMusicVideoCreditsGenerator.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IMusicVideoCreditsGenerator.cs index ca08ceaa9..65e3b5ad3 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IMusicVideoCreditsGenerator.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IMusicVideoCreditsGenerator.cs @@ -10,6 +10,6 @@ public interface IMusicVideoCreditsGenerator Task> GenerateCreditsSubtitleFromTemplate( MusicVideo musicVideo, FFmpegProfile ffmpegProfile, - FFmpegPlaybackSettings settings, + Option streamSeek, string templateFileName); } diff --git a/ErsatzTV.Infrastructure/FFmpeg/MusicVideoCreditsGenerator.cs b/ErsatzTV.Infrastructure/FFmpeg/MusicVideoCreditsGenerator.cs index 50b5b7873..4de279c14 100644 --- a/ErsatzTV.Infrastructure/FFmpeg/MusicVideoCreditsGenerator.cs +++ b/ErsatzTV.Infrastructure/FFmpeg/MusicVideoCreditsGenerator.cs @@ -87,7 +87,7 @@ public class MusicVideoCreditsGenerator(ITempFilePool tempFilePool, ILogger> GenerateCreditsSubtitleFromTemplate( MusicVideo musicVideo, FFmpegProfile ffmpegProfile, - FFmpegPlaybackSettings settings, + Option streamSeek, string templateFileName) { try @@ -111,12 +111,12 @@ public class MusicVideoCreditsGenerator(ITempFilePool tempFilePool, ILogger()).Map(a => a.Name), + AllArtists = (metadata.Artists ?? []).Map(a => a.Name), Artist = artist, - Studios = (metadata.Studios ?? new List()).Map(s => s.Name), - Directors = (metadata.Directors ?? new List()).Map(s => s.Name), + Studios = (metadata.Studios ?? []).Map(s => s.Name), + Directors = (metadata.Directors ?? []).Map(s => s.Name), musicVideo.GetHeadVersion().Duration, - StreamSeek = await settings.StreamSeek.IfNoneAsync(TimeSpan.Zero) + StreamSeek = await streamSeek.IfNoneAsync(TimeSpan.Zero) }); string fileName = tempFilePool.GetNextTempFile(TempFileCategory.Subtitle); diff --git a/ErsatzTV/Controllers/InternalController.cs b/ErsatzTV/Controllers/InternalController.cs index 66669123b..18927814f 100644 --- a/ErsatzTV/Controllers/InternalController.cs +++ b/ErsatzTV/Controllers/InternalController.cs @@ -46,6 +46,23 @@ public class InternalController : StreamingControllerBase [HttpGet("ffmpeg/stream/{channelNumber}")] public Task GetStream(string channelNumber) => GetTsLegacyStream(channelNumber); + [HttpGet("ffmpeg/music-video-credits/{playoutItemId:int}")] + public async Task GetMusicVideoCredits( + int playoutItemId, + [FromQuery] long? seekToMs, + CancellationToken cancellationToken) + { + Option maybeCreditsFile = await _mediator.Send( + new GetMusicVideoCreditsByPlayoutItemId(playoutItemId, Optional(seekToMs)), + cancellationToken); + foreach (string creditsFile in maybeCreditsFile) + { + return new PhysicalFileResult(creditsFile, "text/x-ssa"); + } + + return NotFound(); + } + [HttpGet("ffmpeg/remote-stream/{remoteStreamId}")] public async Task GetRemoteStream(int remoteStreamId, CancellationToken cancellationToken) {