Files
Martin c2d2b1ae44
Some checks failed
Publish Release / release (push) Failing after 17s
Initial commit: CinemaTrailers4Jellyfins plugin
Adapted from Trailers4Jellyfin: keeps TMDB/YouTube trailer downloading,
the scheduled task, language/source/date filters, and trailer rotation,
but drops cinema-mode/IIntroProvider entirely. Each trailer now ships in
its own fake-movie folder (placeholder video + locked NFO + trailer) for
use with a Cinema Mode / trailer pre-roll plugin.
2026-06-08 14:24:28 -04:00

148 lines
5.9 KiB
C#

using System;
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)
{
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());
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",
$"\"{tempPath}\"");
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "ffmpeg",
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. Is ffmpeg installed and on PATH?");
return false;
}
finally
{
_masterLock.Release();
}
}
}
}