Browse Source

feat: sync generated music video credits to next engine (#2917)

* add endpoint for generating music video credits

* feat: sync music video subtitles to next engine
pull/2918/head
Jason Dove 2 months ago committed by GitHub
parent
commit
14ee8063d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 47
      ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs
  3. 3
      ErsatzTV.Application/Streaming/Queries/GetMusicVideoCreditsByPlayoutItemId.cs
  4. 95
      ErsatzTV.Application/Streaming/Queries/GetMusicVideoCreditsByPlayoutItemIdHandler.cs
  5. 2
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  6. 2
      ErsatzTV.Core/Interfaces/FFmpeg/IMusicVideoCreditsGenerator.cs
  7. 10
      ErsatzTV.Infrastructure/FFmpeg/MusicVideoCreditsGenerator.cs
  8. 17
      ErsatzTV/Controllers/InternalController.cs

1
CHANGELOG.md

@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

47
ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs

@ -304,6 +304,7 @@ public partial class SyncNextPlayoutHandler( @@ -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( @@ -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( @@ -335,7 +337,7 @@ public partial class SyncNextPlayoutHandler(
ChannelSubtitleMode subtitleMode,
CancellationToken cancellationToken)
{
List<Subtitle> allSubtitles = await GetSubtitles(audioVersion.MediaItem);
List<Subtitle> allSubtitles = await GetSubtitles(audioVersion.MediaItem, playoutItem.Id, playoutItem.InPoint);
Option<MediaStream> maybeAudioStream = Option<MediaStream>.None;
Option<Subtitle> maybeSubtitle = Option<Subtitle>.None;
@ -407,6 +409,21 @@ public partial class SyncNextPlayoutHandler( @@ -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( @@ -672,7 +689,10 @@ public partial class SyncNextPlayoutHandler(
}
}
private static async Task<List<Subtitle>> GetSubtitles(MediaItem mediaItem)
private static async Task<List<Subtitle>> GetSubtitles(
MediaItem mediaItem,
int playoutItemId,
TimeSpan playoutItemInPoint)
{
List<Subtitle> allSubtitles = mediaItem switch
{
@ -682,7 +702,7 @@ public partial class SyncNextPlayoutHandler( @@ -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( @@ -703,4 +723,25 @@ public partial class SyncNextPlayoutHandler(
return allSubtitles;
}
private static List<Subtitle> 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
}
];
}
}

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

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Streaming;
public record GetMusicVideoCreditsByPlayoutItemId(int PlayoutItemId, Option<long> SeekToMs) : IRequest<Option<string>>;

95
ErsatzTV.Application/Streaming/Queries/GetMusicVideoCreditsByPlayoutItemIdHandler.cs

@ -0,0 +1,95 @@ @@ -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<TvContext> dbContextFactory,
IMusicVideoCreditsGenerator musicVideoCreditsGenerator,
ILogger<GetMusicVideoCreditsByPlayoutItemIdHandler> logger)
: IRequestHandler<GetMusicVideoCreditsByPlayoutItemId, Option<string>>
{
public async Task<Option<string>> Handle(
GetMusicVideoCreditsByPlayoutItemId request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<PlayoutItem> 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<Subtitle>();
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);
}
}

2
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -654,7 +654,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -654,7 +654,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
await _musicVideoCreditsGenerator.GenerateCreditsSubtitleFromTemplate(
musicVideo,
channel.FFmpegProfile,
settings,
settings.StreamSeek,
Path.Combine(FileSystemLayout.MusicVideoCreditsTemplatesFolder, fileWithExtension)));
}
else

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

@ -10,6 +10,6 @@ public interface IMusicVideoCreditsGenerator @@ -10,6 +10,6 @@ public interface IMusicVideoCreditsGenerator
Task<Option<Subtitle>> GenerateCreditsSubtitleFromTemplate(
MusicVideo musicVideo,
FFmpegProfile ffmpegProfile,
FFmpegPlaybackSettings settings,
Option<TimeSpan> streamSeek,
string templateFileName);
}

10
ErsatzTV.Infrastructure/FFmpeg/MusicVideoCreditsGenerator.cs

@ -87,7 +87,7 @@ public class MusicVideoCreditsGenerator(ITempFilePool tempFilePool, ILogger<Musi @@ -87,7 +87,7 @@ public class MusicVideoCreditsGenerator(ITempFilePool tempFilePool, ILogger<Musi
public async Task<Option<Subtitle>> GenerateCreditsSubtitleFromTemplate(
MusicVideo musicVideo,
FFmpegProfile ffmpegProfile,
FFmpegPlaybackSettings settings,
Option<TimeSpan> streamSeek,
string templateFileName)
{
try
@ -111,12 +111,12 @@ public class MusicVideoCreditsGenerator(ITempFilePool tempFilePool, ILogger<Musi @@ -111,12 +111,12 @@ public class MusicVideoCreditsGenerator(ITempFilePool tempFilePool, ILogger<Musi
metadata.Album,
metadata.Plot,
metadata.ReleaseDate,
AllArtists = (metadata.Artists ?? new List<MusicVideoArtist>()).Map(a => a.Name),
AllArtists = (metadata.Artists ?? []).Map(a => a.Name),
Artist = artist,
Studios = (metadata.Studios ?? new List<Studio>()).Map(s => s.Name),
Directors = (metadata.Directors ?? new List<Director>()).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);

17
ErsatzTV/Controllers/InternalController.cs

@ -46,6 +46,23 @@ public class InternalController : StreamingControllerBase @@ -46,6 +46,23 @@ public class InternalController : StreamingControllerBase
[HttpGet("ffmpeg/stream/{channelNumber}")]
public Task<IActionResult> GetStream(string channelNumber) => GetTsLegacyStream(channelNumber);
[HttpGet("ffmpeg/music-video-credits/{playoutItemId:int}")]
public async Task<IActionResult> GetMusicVideoCredits(
int playoutItemId,
[FromQuery] long? seekToMs,
CancellationToken cancellationToken)
{
Option<string> 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<IActionResult> GetRemoteStream(int remoteStreamId, CancellationToken cancellationToken)
{

Loading…
Cancel
Save