diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f00b9fd..add7bd1b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Periodically delete unused artwork from cache folder on disk - Probe Dolby Vision and HDR10 metadata in local library content - All HDR content in local libraries will be re-scanned once after updating to refresh this metadata +- Add music video credits drop down to playback troubleshooter ### Changed - Next engine: diff --git a/ErsatzTV.Application/Streaming/Queries/GetMusicVideoCreditsByPlayoutItemIdHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetMusicVideoCreditsByPlayoutItemIdHandler.cs index 98cdf7655..952616bfa 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetMusicVideoCreditsByPlayoutItemIdHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetMusicVideoCreditsByPlayoutItemIdHandler.cs @@ -1,6 +1,9 @@ +using System.IO.Abstractions; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.Core.Interfaces.Troubleshooting; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -10,6 +13,8 @@ namespace ErsatzTV.Application.Streaming; public class GetMusicVideoCreditsByPlayoutItemIdHandler( IDbContextFactory dbContextFactory, IMusicVideoCreditsGenerator musicVideoCreditsGenerator, + ITroubleshootingPlayoutItemStore troubleshootingPlayoutItemStore, + IFileSystem fileSystem, ILogger logger) : IRequestHandler> { @@ -17,6 +22,30 @@ public class GetMusicVideoCreditsByPlayoutItemIdHandler( GetMusicVideoCreditsByPlayoutItemId request, CancellationToken cancellationToken) { + if (request.PlayoutItemId == MusicVideoCreditsSubtitle.TroubleshootingPlayoutItemId) + { + foreach (TroubleshootingPlayoutItem item in troubleshootingPlayoutItemStore.Current()) + { + if (item.PlayoutItem.MediaItem is not MusicVideo musicVideo) + { + return None; + } + + Option maybePath = await Generate(musicVideo, item.Channel, request.SeekToMs); + foreach (string path in maybePath) + { + fileSystem.File.Copy( + path, + Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "music-video-credits.ass"), + overwrite: true); + } + + return maybePath; + } + + return None; + } + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); Option maybePlayoutItem = await dbContext.PlayoutItems @@ -49,47 +78,47 @@ public class GetMusicVideoCreditsByPlayoutItemIdHandler( .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) + if (playoutItem.MediaItem is MusicVideo musicVideo) { - break; + return await Generate(musicVideo, playoutItem.Playout.Channel, request.SeekToMs); } + } - 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); + return None; + } - subtitles.AddRange( - await musicVideoCreditsGenerator.GenerateCreditsSubtitle( - musicVideo, - playoutItem.Playout.Channel.FFmpegProfile)); - } + private async Task> Generate(MusicVideo musicVideo, Channel channel, Option seekToMs) + { + if (channel.MusicVideoCreditsMode is not ChannelMusicVideoCreditsMode.GenerateSubtitles) + { + return None; + } - break; - case ChannelMusicVideoCreditsMode.None: - default: - break; - } + Option maybeSubtitle; + + string templateName = channel.MusicVideoCreditsTemplate; + if (!string.IsNullOrWhiteSpace(templateName)) + { + var fileWithExtension = $"{templateName}.sbntxt"; + maybeSubtitle = await musicVideoCreditsGenerator.GenerateCreditsSubtitleFromTemplate( + musicVideo, + channel.FFmpegProfile, + 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); + + maybeSubtitle = await musicVideoCreditsGenerator.GenerateCreditsSubtitle( + musicVideo, + channel.FFmpegProfile); } - return subtitles.HeadOrNone().Map(s => s.Path); + return maybeSubtitle.Map(s => s.Path); } } diff --git a/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs index 78541956c..ada145da6 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs @@ -57,6 +57,12 @@ public class ArchiveTroubleshootingResultsHandler(ILocalFileSystem localFileSyst continue; } + if (fileName.Contains("music-video-credits", StringComparison.OrdinalIgnoreCase)) + { + zipArchive.CreateEntryFromFile(file, fileName); + continue; + } + if (fileName.Contains("ffreport", StringComparison.OrdinalIgnoreCase)) { hasReport = true; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs index 8c073af48..280ac96bc 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs @@ -15,6 +15,7 @@ public record PrepareTroubleshootingPlayback( List WatermarkIds, List GraphicsElementIds, int? SubtitleId, + string MusicVideoCreditsTemplate, Option SeekSeconds, Option Start) : IRequest>; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index 179548aaf..344ffe4c3 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -39,6 +39,7 @@ public class PrepareTroubleshootingPlaybackHandler( IFileSystem fileSystem, ILocalFileSystem localFileSystem, ISongVideoGenerator songVideoGenerator, + IMusicVideoCreditsGenerator musicVideoCreditsGenerator, IWatermarkSelector watermarkSelector, IEntityLocker entityLocker, IChannelConfigConverter channelConfigConverter, @@ -210,6 +211,12 @@ public class PrepareTroubleshootingPlaybackHandler( channel.StreamSelector = request.StreamSelector; } + if (mediaItem is MusicVideo && !string.IsNullOrWhiteSpace(request.MusicVideoCreditsTemplate)) + { + channel.MusicVideoCreditsMode = ChannelMusicVideoCreditsMode.GenerateSubtitles; + channel.MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate; + } + MediaVersion version = mediaItem.GetHeadVersion(); var duration = TimeSpan.FromSeconds(Math.Min(version.Duration.TotalSeconds, 30)); @@ -371,7 +378,7 @@ public class PrepareTroubleshootingPlaybackHandler( [], TimeSpan.Zero, playoutItem, - await GetSubtitles(mediaItem, request), + await GetNextSubtitles(mediaItem, channel, request, inPoint), shouldLogMessages: true, cancellationToken); @@ -496,7 +503,7 @@ public class PrepareTroubleshootingPlaybackHandler( new MediaItemAudioVersion(mediaItem, version), videoPath, mediaPath, - _ => GetSubtitles(mediaItem, request), + settings => GetLegacySubtitles(mediaItem, channel, request, settings), string.Empty, string.Empty, string.Empty, @@ -526,6 +533,52 @@ public class PrepareTroubleshootingPlaybackHandler( return playoutItemResult; } + private async Task> GetLegacySubtitles( + MediaItem mediaItem, + Channel channel, + PrepareTroubleshootingPlayback request, + FFmpegPlaybackSettings settings) + { + if (mediaItem is not MusicVideo musicVideo || + channel.MusicVideoCreditsMode is not ChannelMusicVideoCreditsMode.GenerateSubtitles) + { + return await GetSubtitles(mediaItem, request); + } + + Option maybeSubtitle = await musicVideoCreditsGenerator.GenerateCreditsSubtitleFromTemplate( + musicVideo, + channel.FFmpegProfile, + settings.StreamSeek, + Path.Combine( + FileSystemLayout.MusicVideoCreditsTemplatesFolder, + $"{channel.MusicVideoCreditsTemplate}.sbntxt")); + + foreach (Subtitle subtitle in maybeSubtitle) + { + _fileSystem.File.Copy( + subtitle.Path, + _fileSystem.Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "music-video-credits.ass"), + overwrite: true); + } + + return maybeSubtitle.ToSeq().ToList(); + } + + private static async Task> GetNextSubtitles( + MediaItem mediaItem, + Channel channel, + PrepareTroubleshootingPlayback request, + TimeSpan inPoint) + { + if (mediaItem is MusicVideo && channel.MusicVideoCreditsMode is ChannelMusicVideoCreditsMode.GenerateSubtitles) + { + // next fetches the credits over http; the endpoint resolves the troubleshooting playout item from the store + return [MusicVideoCreditsSubtitle.ForPlayoutItem(MusicVideoCreditsSubtitle.TroubleshootingPlayoutItemId, inPoint)]; + } + + return await GetSubtitles(mediaItem, request); + } + private static async Task> GetSubtitles(MediaItem mediaItem, PrepareTroubleshootingPlayback request) { List allSubtitles = mediaItem switch diff --git a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs index 6b3452914..abe263c6b 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs @@ -8,6 +8,7 @@ public record StartTroubleshootingPlayback( Guid SessionId, StreamingEngine StreamingEngine, string StreamSelector, + string MusicVideoCreditsTemplate, PlayoutItemResult PlayoutItemResult, Option MediaItemInfo, TroubleshootingInfo TroubleshootingInfo) : IRequest, IFFmpegWorkerRequest; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs index b78dba1f5..3c09bb85d 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs @@ -94,6 +94,22 @@ public class StartTroubleshootingPlaybackHandler( } } + // write music video credits template + if (!string.IsNullOrWhiteSpace(request.MusicVideoCreditsTemplate)) + { + string fullPath = Path.Combine( + FileSystemLayout.MusicVideoCreditsTemplatesFolder, + $"{request.MusicVideoCreditsTemplate}.sbntxt"); + if (File.Exists(fullPath)) + { + File.Copy( + fullPath, + Path.Combine( + FileSystemLayout.TranscodeTroubleshootingFolder, + "music-video-credits-template.sbntxt")); + } + } + HardwareAccelerationKind hwAccel = request.TroubleshootingInfo.FFmpegProfiles.Head().HardwareAcceleration; if (hwAccel is HardwareAccelerationKind.Qsv) { diff --git a/ErsatzTV.Core/FFmpeg/MusicVideoCreditsSubtitle.cs b/ErsatzTV.Core/FFmpeg/MusicVideoCreditsSubtitle.cs new file mode 100644 index 000000000..234114d51 --- /dev/null +++ b/ErsatzTV.Core/FFmpeg/MusicVideoCreditsSubtitle.cs @@ -0,0 +1,28 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.FFmpeg; + +public static class MusicVideoCreditsSubtitle +{ + // troubleshooting playout items never reach the database, so the credits endpoint + // resolves this id from the troubleshooting playout item store instead + public const int TroubleshootingPlayoutItemId = 0; + + public static Subtitle ForPlayoutItem(int playoutItemId, TimeSpan inPoint) + { + string seekToMs = inPoint > TimeSpan.Zero + ? $"?seekToMs={(long)inPoint.TotalMilliseconds}" + : string.Empty; + + return new Subtitle + { + Codec = "ass", + Default = true, + Forced = true, + IsExtracted = false, + SubtitleKind = SubtitleKind.Generated, + Path = $"http://localhost:{Settings.StreamingPort}/internal/ffmpeg/music-video-credits/{playoutItemId}{seekToMs}", + SDH = false + }; + } +} diff --git a/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs index 5bf82c5d2..8324ba3f3 100644 --- a/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs +++ b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs @@ -678,24 +678,7 @@ public class PlayoutItemConverter( return []; } - 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}/internal/ffmpeg/music-video-credits/{playoutItemId}{seekToMs}", - SDH = false - } - ]; + return [MusicVideoCreditsSubtitle.ForPlayoutItem(playoutItemId, playoutItemInPoint)]; } private static void SetInOutPoints(PlayoutItem playoutItem, Core.Next.Source source) diff --git a/ErsatzTV/Controllers/Api/TroubleshootController.cs b/ErsatzTV/Controllers/Api/TroubleshootController.cs index 272949828..3bacc0d54 100644 --- a/ErsatzTV/Controllers/Api/TroubleshootController.cs +++ b/ErsatzTV/Controllers/Api/TroubleshootController.cs @@ -47,6 +47,8 @@ public class TroubleshootController( [FromQuery] int? subtitleId, [FromQuery] + string musicVideoCreditsTemplate, + [FromQuery] int seekSeconds, [FromQuery] DateTimeOffset? start, @@ -71,6 +73,7 @@ public class TroubleshootController( watermark, graphicsElement, subtitleId, + musicVideoCreditsTemplate, ss, Optional(start)), cancellationToken); @@ -104,6 +107,7 @@ public class TroubleshootController( sessionId, streamingEngine, streamSelector, + musicVideoCreditsTemplate, playoutItemResult, maybeMediaInfo.ToOption(), troubleshootingInfo), diff --git a/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor b/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor index 7089a5f36..8563e0fdd 100644 --- a/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor +++ b/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor @@ -5,6 +5,7 @@ @using ErsatzTV.Application.FFmpegProfiles @using ErsatzTV.Application.Graphics @using ErsatzTV.Application.MediaItems +@using ErsatzTV.Application.Templates @using ErsatzTV.Application.Troubleshooting @using ErsatzTV.Application.Troubleshooting.Queries @using ErsatzTV.Application.Watermarks @@ -115,6 +116,21 @@ } + @if (_isMusicVideo) + { + +
+ Music Video Credits +
+ + (none) + @foreach (string template in _musicVideoCreditsTemplates) + { + @template + } + +
+ }
Watermarks @@ -210,6 +226,7 @@ private readonly List _watermarks = []; private readonly List _subtitleStreams = []; private readonly List _graphicsElements = []; + private readonly List _musicVideoCreditsTemplates = []; private string _title; private MediaItemInfo _info; private readonly StreamingMode _streamingMode = StreamingMode.HttpLiveStreamingSegmenter; @@ -221,6 +238,8 @@ private IReadOnlyCollection _graphicsElementNames = new System.Collections.Generic.HashSet(); private bool _startFromBeginning; private int? _subtitleId; + private bool _isMusicVideo; + private string _musicVideoCreditsTemplate; private int _seekSeconds; private bool _hasPlayed; private double? _lastSpeed; @@ -273,6 +292,9 @@ _graphicsElements.Clear(); _graphicsElements.AddRange(await Mediator.Send(new GetAllGraphicsElements(), token)); + _musicVideoCreditsTemplates.Clear(); + _musicVideoCreditsTemplates.AddRange(await Mediator.Send(new GetMusicVideoCreditTemplates(), token)); + if (MediaItemId is not null) { _channelMode = false; @@ -353,6 +375,11 @@ } } + if (_isMusicVideo && !string.IsNullOrWhiteSpace(_musicVideoCreditsTemplate)) + { + queryString.Add(new KeyValuePair("musicVideoCreditsTemplate", _musicVideoCreditsTemplate)); + } + if (!string.IsNullOrWhiteSpace(_streamSelector)) { queryString.Add(new KeyValuePair("streamSelector", _streamSelector)); @@ -383,6 +410,8 @@ IEnumerable kindString = info.Kind.SelectMany((c, i) => i != 0 && char.IsUpper(c) && !char.IsUpper(info.Kind[i - 1]) ? new[] { ' ', c } : new[] { c }); _info = info with { Kind = new string(kindString.ToArray()) }; _title = info.Title; + _isMusicVideo = string.Equals(info.Kind, nameof(MusicVideo), StringComparison.OrdinalIgnoreCase); + _musicVideoCreditsTemplate = null; OnStartFromBeginningChanged(string.Equals(info.Kind, "RemoteStream", StringComparison.OrdinalIgnoreCase));