From 1e296a91b33e043cf2e1e675b46a3ef1d4cf7d70 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Tue, 26 May 2026 10:29:14 -0500 Subject: [PATCH] add endpoint for generating music video credits --- CHANGELOG.md | 1 + .../GetMusicVideoCreditsByPlayoutItemId.cs | 3 + ...MusicVideoCreditsByPlayoutItemIdHandler.cs | 94 +++++++++++++++++++ ...layoutItemProcessByChannelNumberHandler.cs | 2 +- .../FFmpeg/IMusicVideoCreditsGenerator.cs | 2 +- .../FFmpeg/MusicVideoCreditsGenerator.cs | 10 +- ErsatzTV/Controllers/InternalController.cs | 17 ++++ 7 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 ErsatzTV.Application/Streaming/Queries/GetMusicVideoCreditsByPlayoutItemId.cs create mode 100644 ErsatzTV.Application/Streaming/Queries/GetMusicVideoCreditsByPlayoutItemIdHandler.cs 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/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..3ce977cc2 --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/GetMusicVideoCreditsByPlayoutItemIdHandler.cs @@ -0,0 +1,94 @@ +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: + var fileWithExtension = $"{playoutItem.Playout.Channel.MusicVideoCreditsTemplate}.sbntxt"; + if (!string.IsNullOrWhiteSpace(fileWithExtension)) + { + 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", + fileWithExtension); + + 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) {