Initial commit: CinemaTrailers4Jellyfins plugin
Some checks failed
Publish Release / release (push) Failing after 17s
Some checks failed
Publish Release / release (push) Failing after 17s
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.
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
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 <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)
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user