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(); } } } }