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
{
///
/// 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.
///
public class FakeMovieService
{
private const string MasterFileName = "master.mp4";
private readonly ILogger _logger;
private readonly SemaphoreSlim _masterLock = new(1, 1);
public FakeMovieService(ILogger logger)
{
_logger = logger;
}
private static string MasterFilePath =>
Path.Combine(Plugin.Instance.DataFolderPath, "fake-movie", MasterFileName);
///
/// Copies the master fake-movie file to ,
/// generating the master via ffmpeg first if it doesn't exist yet.
///
public async Task 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;
}
}
///
/// Writes a minimal Jellyfin/Kodi-compatible movie NFO with <lockdata>true</lockdata>
/// so Jellyfin never tries to refresh metadata for the fake entry from TMDB.
///
public void WriteNfo(string nfoPath, string title, int? year, IReadOnlyList? genres = null, string? mpaa = null, IReadOnlyList? 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 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}\"");
// Prefer Jellyfin's bundled ffmpeg; fall back to whatever is on PATH.
var ffmpegPath = File.Exists("/usr/lib/jellyfin-ffmpeg/ffmpeg")
? "/usr/lib/jellyfin-ffmpeg/ffmpeg"
: "ffmpeg";
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = ffmpegPath,
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();
}
}
}
}