Files
CinemaTrailers4Jellyfins/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Services/FakeMovieService.cs
Martin 5176582391
All checks were successful
Publish Release / release (push) Successful in 26s
fix: download trailers as separate video+audio and merge with ffmpeg (v1.0.0.10)
YouTube stopped serving muxed streams and YoutubeExplode 6.6.0 failed on
every video ("Video is not available"), so no trailer downloaded. Bump
YoutubeExplode to 6.6.2, download the best video-only + audio-only streams
and stream-copy them into one MP4 with ffmpeg. The separate files are
always deleted afterwards, merge or not.

Also delete the trailer folder when a download fails. Empty folders were
treated as "already downloaded" (never retried) and counted toward
MaxTotalTrailers, so over time they pushed out every real trailer. The
skip check now requires the trailer file itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:42:28 -04:00

158 lines
6.4 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
{
/// <summary>
/// Produces the placeholder "fake movie" file and its NFO that sit alongside each
/// downloaded trailer. Jellyfin scans the fake movie as a normal library item (its
/// metadata locked via the NFO so it never queries TMDB) and picks up the adjacent
/// "-trailer.mp4" as that item's local trailer — exactly what a Cinema Mode / trailer
/// pre-roll plugin consumes.
/// </summary>
public class FakeMovieService
{
private const string MasterFileName = "master.mp4";
private readonly ILogger<FakeMovieService> _logger;
private readonly SemaphoreSlim _masterLock = new(1, 1);
public FakeMovieService(ILogger<FakeMovieService> logger)
{
_logger = logger;
}
private static string MasterFilePath =>
Path.Combine(Plugin.Instance.DataFolderPath, "fake-movie", MasterFileName);
/// <summary>
/// Copies the master fake-movie file to <paramref name="destinationPath"/>,
/// generating the master via ffmpeg first if it doesn't exist yet.
/// </summary>
public async Task<bool> CopyFakeMovieAsync(string destinationPath, CancellationToken ct)
{
if (!await EnsureMasterFileAsync(ct).ConfigureAwait(false))
return false;
try
{
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
File.Copy(MasterFilePath, destinationPath, overwrite: true);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| Failed to copy fake movie file to {Path}", destinationPath);
return false;
}
}
/// <summary>
/// Writes a minimal Jellyfin/Kodi-compatible movie NFO with &lt;lockdata&gt;true&lt;/lockdata&gt;
/// so Jellyfin never tries to refresh metadata for the fake entry from TMDB.
/// </summary>
public void WriteNfo(string nfoPath, string title, int? year, IReadOnlyList<string>? genres = null, string? mpaa = null, IReadOnlyList<string>? tags = null)
{
var settings = new XmlWriterSettings { Indent = true };
using var writer = XmlWriter.Create(nfoPath, settings);
writer.WriteStartDocument();
writer.WriteStartElement("movie");
writer.WriteElementString("title", title);
if (year.HasValue)
writer.WriteElementString("year", year.Value.ToString());
if (genres != null)
foreach (var genre in genres)
writer.WriteElementString("genre", genre);
if (!string.IsNullOrWhiteSpace(mpaa))
writer.WriteElementString("mpaa", mpaa);
if (tags != null)
foreach (var tag in tags)
writer.WriteElementString("tag", tag);
writer.WriteElementString("lockdata", "true");
writer.WriteEndElement();
writer.WriteEndDocument();
}
// Generates a few seconds of black video with silent audio — just enough for
// Jellyfin to recognize the file as a valid playable video. Built once and reused
// by copying, rather than regenerated per trailer.
private async Task<bool> EnsureMasterFileAsync(CancellationToken ct)
{
if (File.Exists(MasterFilePath))
return true;
await _masterLock.WaitAsync(ct).ConfigureAwait(false);
try
{
if (File.Exists(MasterFilePath))
return true;
Directory.CreateDirectory(Path.GetDirectoryName(MasterFilePath)!);
var tempPath = MasterFilePath + ".tmp";
if (File.Exists(tempPath)) File.Delete(tempPath);
_logger.LogInformation("|CinemaTrailers4Jellyfins| Generating master fake-movie file via ffmpeg at {Path}", MasterFilePath);
var args = string.Join(" ",
"-y",
"-f lavfi -i \"color=c=black:s=640x360:r=24:d=3\"",
"-f lavfi -i \"anullsrc=channel_layout=stereo:sample_rate=44100\"",
"-shortest",
"-c:v libx264 -tune stillimage -pix_fmt yuv420p",
"-c:a aac -b:a 64k",
"-f mp4",
$"\"{tempPath}\"");
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = FfmpegLocator.Path,
Arguments = args,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
}
};
process.Start();
await process.WaitForExitAsync(ct).ConfigureAwait(false);
if (process.ExitCode != 0 || !File.Exists(tempPath))
{
var stderr = await process.StandardError.ReadToEndAsync(ct).ConfigureAwait(false);
_logger.LogError(
"|CinemaTrailers4Jellyfins| ffmpeg failed to generate the master fake-movie file (exit code {Code}): {Error}",
process.ExitCode, stderr);
if (File.Exists(tempPath)) File.Delete(tempPath);
return false;
}
File.Move(tempPath, MasterFilePath, overwrite: true);
_logger.LogInformation("|CinemaTrailers4Jellyfins| Master fake-movie file generated at {Path}", MasterFilePath);
return true;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| Failed to generate the master fake-movie file.");
return false;
}
finally
{
_masterLock.Release();
}
}
}
}