All checks were successful
Publish Release / release (push) Successful in 23s
Trailer selection now supports three optional filters:
- Genre match: prefer trailers whose fake-movie genre overlaps with the
feature being played (requires TMDB genres in NFO)
- Age rating ceiling: exclude trailers rated higher than the feature
(requires TMDB certification in NFO)
- Avoid repeats: cycle through all trailers before replaying any;
resets per-user once the pool is exhausted
Genre and certification are fetched from TMDB at download time via a
single /movie/{id}?append_to_response=release_dates call and written
into the fake-movie NFO as <genre> and <mpaa> tags. All filters fall
back to the full unfiltered pool when no match is found.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
160 lines
6.5 KiB
C#
160 lines
6.5 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 <lockdata>true</lockdata>
|
|
/// 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)
|
|
{
|
|
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);
|
|
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}\"");
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
}
|
|
}
|