7 Commits

Author SHA1 Message Date
Martin
86d8e2a05b fix: delete yt-dlp partial files when it fails (v1.0.0.14)
All checks were successful
Publish Release / release (push) Successful in 11s
yt-dlp removes its separate video/audio files after a successful merge, but
a failed run (e.g. HTTP 403 mid-download) left "<name>.f137.mp4.part" etc.
in the trailer folder, even when the built-in fallback then succeeded.
Always remove files sharing the output's "<name>." prefix after yt-dlp runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:19:24 -04:00
gitea-actions
c013416473 chore: update manifest.json for v1.0.0.13 2026-10-05 22:11:03 +00:00
Martin
e43e3f7245 fix(build): quote-safe changelog line for v1.0.0.13
All checks were successful
Publish Release / release (push) Successful in 11s
"Fix a possible hang: ..." has ": " inside a plain YAML scalar, which
PyYAML reads as a mapping key, so update_manifest.py failed in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:10:49 -04:00
Martin
4145e1f1dd feat: yt-dlp prefers H.264, falls back to built-in, no pipe hang (v1.0.0.13)
Some checks failed
Publish Release / release (push) Failing after 8s
- Format string prefers avc1 video + m4a audio, then any codec. The RTX 2070
  host can hardware-transcode H.264 but not AV1.
- Pass Jellyfin's ffmpeg via --ffmpeg-location instead of relying on PATH.
- If yt-dlp fails, retry with the built-in YoutubeExplode downloader.
- Read stdout/stderr while yt-dlp runs (and add --no-progress); before,
  stdout was never read, so a full pipe buffer could block yt-dlp forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:01:33 -04:00
gitea-actions
6002ce7b46 chore: update manifest.json for v1.0.0.12 2026-10-05 21:57:24 +00:00
Martin
d850e12964 feat: restrict anime trailers, not all animation (v1.0.0.12)
All checks were successful
Publish Release / release (push) Successful in 9s
v1.0.0.11 kept every animated trailer away from non-animated content, which
also blocked Western animation (Pixar, Disney...). Only anime should be
restricted.

At download, a trailer is tagged ct4j-anime when TMDB has the "anime" or
"based on manga" keyword, or it is Animation in ja/zh/ko (keywords are now
appended to the details request). At playback, anime trailers are dropped
unless the feature is anime (genre "Anime", or those keywords as tags).
Western animation follows the normal genre/rating filters again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:57:12 -04:00
gitea-actions
205d64dc6f chore: update manifest.json for v1.0.0.11 2026-10-05 21:52:47 +00:00
8 changed files with 124 additions and 30 deletions

View File

@@ -3,8 +3,8 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.CinemaTrailers4Jellyfins</RootNamespace> <RootNamespace>Jellyfin.Plugin.CinemaTrailers4Jellyfins</RootNamespace>
<AssemblyVersion>1.0.0.11</AssemblyVersion> <AssemblyVersion>1.0.0.14</AssemblyVersion>
<FileVersion>1.0.0.11</FileVersion> <FileVersion>1.0.0.14</FileVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GenerateDocumentationFile>false</GenerateDocumentationFile> <GenerateDocumentationFile>false</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors> <TreatWarningsAsErrors>false</TreatWarningsAsErrors>

View File

@@ -217,7 +217,8 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
var metadata = await _tmdbService.GetMediaMetadataAsync( var metadata = await _tmdbService.GetMediaMetadataAsync(
"movie", movie.Id.ToString(), config.TmdbApiKey, cancellationToken).ConfigureAwait(false); "movie", movie.Id.ToString(), config.TmdbApiKey, cancellationToken).ConfigureAwait(false);
_fakeMovieService.WriteNfo(paths.NfoPath, movie.Title, movie.Year, metadata.Genres, metadata.Certification); _fakeMovieService.WriteNfo(paths.NfoPath, movie.Title, movie.Year, metadata.Genres, metadata.Certification,
metadata.IsAnime ? new[] { TrailerTags.Anime } : null);
downloaded++; downloaded++;
_logger.LogInformation( _logger.LogInformation(
@@ -323,7 +324,8 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.ScheduledTasks
var metadata = await _tmdbService.GetMediaMetadataAsync( var metadata = await _tmdbService.GetMediaMetadataAsync(
"tv", show.Id.ToString(), config.TmdbApiKey, cancellationToken).ConfigureAwait(false); "tv", show.Id.ToString(), config.TmdbApiKey, cancellationToken).ConfigureAwait(false);
_fakeMovieService.WriteNfo(paths.NfoPath, show.Title, show.Year, metadata.Genres, metadata.Certification, new[] { TrailerTags.TvShow }); _fakeMovieService.WriteNfo(paths.NfoPath, show.Title, show.Year, metadata.Genres, metadata.Certification,
metadata.IsAnime ? new[] { TrailerTags.TvShow, TrailerTags.Anime } : new[] { TrailerTags.TvShow });
downloaded++; downloaded++;
_logger.LogInformation( _logger.LogInformation(

View File

@@ -19,7 +19,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
public int? Year => DateTime.TryParse(ReleaseDate, out var d) ? d.Year : (int?)null; public int? Year => DateTime.TryParse(ReleaseDate, out var d) ? d.Year : (int?)null;
} }
public record TmdbMediaMetadata(IReadOnlyList<string> Genres, string? Certification); public record TmdbMediaMetadata(IReadOnlyList<string> Genres, string? Certification, bool IsAnime);
public class TmdbService : IDisposable public class TmdbService : IDisposable
{ {
@@ -236,7 +236,7 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
try try
{ {
var appendField = mediaType == "movie" ? "release_dates" : "content_ratings"; var appendField = mediaType == "movie" ? "release_dates" : "content_ratings";
var url = $"{BaseUrl}/{mediaType}/{tmdbId}?append_to_response={appendField}&language=en-US"; var url = $"{BaseUrl}/{mediaType}/{tmdbId}?append_to_response={appendField},keywords&language=en-US";
using var request = new HttpRequestMessage(HttpMethod.Get, url); using var request = new HttpRequestMessage(HttpMethod.Get, url);
ApplyAuth(request, apiKey); ApplyAuth(request, apiKey);
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
@@ -262,16 +262,40 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
? GetMovieCertification(doc) ? GetMovieCertification(doc)
: GetTvCertification(doc); : GetTvCertification(doc);
return new TmdbMediaMetadata(genres.AsReadOnly(), certification); return new TmdbMediaMetadata(genres.AsReadOnly(), certification, IsAnime(doc, mediaType, genres));
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| GetMediaMetadata failed for {MediaType} TMDB ID {Id}", mediaType, tmdbId); _logger.LogError(ex, "|CinemaTrailers4Jellyfins| GetMediaMetadata failed for {MediaType} TMDB ID {Id}", mediaType, tmdbId);
return new TmdbMediaMetadata(Array.Empty<string>(), null); return new TmdbMediaMetadata(Array.Empty<string>(), null, false);
} }
} }
private static readonly string[] AnimeKeywords = { "anime", "based on manga" };
private static readonly string[] AnimeLanguages = { "ja", "zh", "ko" };
// Anime vs. Western animation: both carry TMDB's "Animation" genre, so use the "anime" /
// "based on manga" keywords, or Animation made in Japanese, Chinese or Korean.
private static bool IsAnime(JsonDocument doc, string mediaType, List<string> genres)
{
// Movie keywords live under keywords.keywords, TV keywords under keywords.results.
var listName = mediaType == "movie" ? "keywords" : "results";
if (doc.RootElement.TryGetProperty("keywords", out var kw)
&& kw.TryGetProperty(listName, out var list)
&& list.ValueKind == JsonValueKind.Array
&& list.EnumerateArray().Any(k =>
k.TryGetProperty("name", out var n)
&& AnimeKeywords.Contains(n.GetString(), StringComparer.OrdinalIgnoreCase)))
{
return true;
}
return genres.Contains("Animation", StringComparer.OrdinalIgnoreCase)
&& doc.RootElement.TryGetProperty("original_language", out var lang)
&& AnimeLanguages.Contains(lang.GetString(), StringComparer.OrdinalIgnoreCase);
}
// Finds the US theatrical certification (G, PG, PG-13, R, NC-17) from release_dates. // Finds the US theatrical certification (G, PG, PG-13, R, NC-17) from release_dates.
private static string? GetMovieCertification(JsonDocument doc) private static string? GetMovieCertification(JsonDocument doc)
{ {

View File

@@ -63,8 +63,10 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
if (!string.IsNullOrWhiteSpace(ytDlpPath) && File.Exists(ytDlpPath)) if (!string.IsNullOrWhiteSpace(ytDlpPath) && File.Exists(ytDlpPath))
{ {
return await DownloadWithYtDlpAsync(youtubeKey, outputPath, preferredHeight, ytDlpPath, ct) if (await DownloadWithYtDlpAsync(youtubeKey, outputPath, preferredHeight, ytDlpPath, ct).ConfigureAwait(false))
.ConfigureAwait(false); return true;
_logger.LogWarning("|CinemaTrailers4Jellyfins| yt-dlp failed for {Key}; falling back to the built-in downloader.", youtubeKey);
} }
return await DownloadWithYoutubeExplodeAsync(youtubeKey, outputPath, preferredHeight, ct) return await DownloadWithYoutubeExplodeAsync(youtubeKey, outputPath, preferredHeight, ct)
@@ -224,13 +226,16 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
{ {
try try
{ {
// Format selects the best video at or below preferredHeight merged with the best audio. // Format selects the best video at or below preferredHeight merged with the best audio,
// --merge-output-format mp4 ensures the output is always an mp4. // preferring H.264 + AAC (plays or hardware-transcodes on every client; VP9/AV1 often
// need a CPU transcode). --merge-output-format mp4 ensures the output is always an mp4.
var args = string.Join(" ", var args = string.Join(" ",
$"-f \"bestvideo[height<={preferredHeight}]+bestaudio/best[height<={preferredHeight}]\"", $"-f \"bestvideo[height<={preferredHeight}][vcodec^=avc1]+bestaudio[ext=m4a]/bestvideo[height<={preferredHeight}]+bestaudio/best[height<={preferredHeight}]\"",
"--merge-output-format mp4", "--merge-output-format mp4",
"--no-playlist", "--no-playlist",
"--no-warnings", "--no-warnings",
"--no-progress",
$"--ffmpeg-location \"{FfmpegLocator.Path}\"",
$"-o \"{outputPath}\"", $"-o \"{outputPath}\"",
$"\"https://www.youtube.com/watch?v={key}\""); $"\"https://www.youtube.com/watch?v={key}\"");
@@ -252,11 +257,15 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
}; };
process.Start(); process.Start();
// Drain both pipes while yt-dlp runs so a full buffer can never block it.
var stdoutTask = process.StandardOutput.ReadToEndAsync(ct);
var stderrTask = process.StandardError.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct).ConfigureAwait(false); await process.WaitForExitAsync(ct).ConfigureAwait(false);
await stdoutTask.ConfigureAwait(false);
var stderr = await stderrTask.ConfigureAwait(false);
if (process.ExitCode != 0) if (process.ExitCode != 0)
{ {
var stderr = await process.StandardError.ReadToEndAsync(ct).ConfigureAwait(false);
_logger.LogError("|CinemaTrailers4Jellyfins| yt-dlp exited with code {Code} for {Key}: {Error}", _logger.LogError("|CinemaTrailers4Jellyfins| yt-dlp exited with code {Code} for {Key}: {Error}",
process.ExitCode, key, stderr); process.ExitCode, key, stderr);
return false; return false;
@@ -273,6 +282,32 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
_logger.LogError(ex, "|CinemaTrailers4Jellyfins| yt-dlp download failed for {Key}", key); _logger.LogError(ex, "|CinemaTrailers4Jellyfins| yt-dlp download failed for {Key}", key);
return false; return false;
} }
finally
{
DeleteYtDlpLeftovers(outputPath);
}
}
// yt-dlp names its separate/partial downloads "<output name>.f137.mp4.part", ".f140.m4a",
// ".ytdl", ... It removes them after a successful merge, but not when it fails. Everything
// sharing the output's "<name>." prefix (other than the output itself) is one of those.
private void DeleteYtDlpLeftovers(string outputPath)
{
var folder = Path.GetDirectoryName(outputPath);
if (folder == null || !Directory.Exists(folder))
return;
var outputName = Path.GetFileName(outputPath);
var prefix = Path.GetFileNameWithoutExtension(outputPath) + ".";
foreach (var file in Directory.EnumerateFiles(folder))
{
var name = Path.GetFileName(file);
if (name.StartsWith(prefix, StringComparison.Ordinal)
&& !string.Equals(name, outputName, StringComparison.Ordinal))
{
TryDeleteFile(file);
}
}
} }
} }
} }

View File

@@ -147,12 +147,12 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
} }
else else
{ {
// Animation is a style, not a topic: animated trailers (anime included) only play // Anime trailers only play before anime. Applied before the fallback below, so
// before animated content. Applied before the fallback below, so a live-action // non-anime content never gets one — it gets no trailer instead. Western animation
// feature never gets one — it gets no trailer instead. // is not affected and follows the normal filters.
var eligible = IsAnimated(feature) var eligible = IsAnimeFeature(feature)
? pairs ? pairs
: pairs.Where(p => !IsAnimated(p.Movie)).ToList(); : pairs.Where(p => !IsAnimeTrailer(p.Movie)).ToList();
// Apply enabled filters. If nothing survives, fall back to the eligible pool. // Apply enabled filters. If nothing survives, fall back to the eligible pool.
var filtered = ApplyFilters(eligible, feature, config); var filtered = ApplyFilters(eligible, feature, config);
@@ -236,11 +236,19 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
return new IntroInfo { ItemId = movie.Id, Path = movie.Path }; return new IntroInfo { ItemId = movie.Id, Path = movie.Path };
} }
// TMDB tags anime as "Animation"; anime metadata providers (e.g. AniDB) use "Anime". // Library items: anime providers (e.g. AniDB) set the "Anime" genre; TMDB-scraped items
private static readonly string[] AnimatedGenres = { "Animation", "Anime" }; // carry TMDB keywords as tags ("anime", "based on manga"). "Animation" alone is not enough —
// it also covers Western animation.
private static readonly string[] AnimeFeatureTags = { "anime", "based on manga" };
private static bool IsAnimated(BaseItem item) private static bool IsAnimeFeature(BaseItem item)
=> item.Genres.Any(g => AnimatedGenres.Contains(g, StringComparer.OrdinalIgnoreCase)); => item.Genres.Contains("Anime", StringComparer.OrdinalIgnoreCase)
|| item.Tags.Any(t => AnimeFeatureTags.Contains(t, StringComparer.OrdinalIgnoreCase));
// Trailers are tagged at download time from TMDB data (see TmdbService.IsAnime).
private static bool IsAnimeTrailer(BaseItem item)
=> item.Tags.Contains(TrailerTags.Anime, StringComparer.OrdinalIgnoreCase)
|| item.Genres.Contains("Anime", StringComparer.OrdinalIgnoreCase);
private static List<(Movie Movie, BaseItem Trailer)> ApplyFilters( private static List<(Movie Movie, BaseItem Trailer)> ApplyFilters(
List<(Movie Movie, BaseItem Trailer)> pairs, List<(Movie Movie, BaseItem Trailer)> pairs,

View File

@@ -2,11 +2,14 @@ namespace Jellyfin.Plugin.CinemaTrailers4Jellyfins.Services
{ {
/// <summary> /// <summary>
/// Tags written into each downloaded trailer's NFO so <see cref="TrailerIntroProvider"/> /// Tags written into each downloaded trailer's NFO so <see cref="TrailerIntroProvider"/>
/// can tell movie trailers and TV show trailers apart within the shared output folder. /// can tell movie trailers, TV show trailers and anime trailers apart within the shared output folder.
/// </summary> /// </summary>
public static class TrailerTags public static class TrailerTags
{ {
/// <summary>Marks a fake-movie folder as containing a TV show trailer.</summary> /// <summary>Marks a fake-movie folder as containing a TV show trailer.</summary>
public const string TvShow = "ct4j-tvshow"; public const string TvShow = "ct4j-tvshow";
/// <summary>Marks a trailer as anime, so it only plays before anime content.</summary>
public const string Anime = "ct4j-anime";
} }
} }

View File

@@ -1,5 +1,5 @@
--- ---
version: 1.0.0.11 version: 1.0.0.14
name: CinemaTrailers4Jellyfins name: CinemaTrailers4Jellyfins
guid: b581493e-1046-40ed-b6dc-cb8027624984 guid: b581493e-1046-40ed-b6dc-cb8027624984
description: > description: >
@@ -12,11 +12,9 @@ category: General
owner: 514mart owner: 514mart
targetAbi: 10.11.0.0 targetAbi: 10.11.0.0
changelog: changelog:
- Animated trailers (anime included) now only play before animated - When yt-dlp fails part-way, its partial download files (.part, .ytdl,
content. Before, an anime trailer could play before a live-action separate video/audio) are now deleted instead of being left in the
movie or show when they shared a genre like Drama or Action, or when trailer folder
no trailer matched the genre filter. A live-action feature now gets no
trailer rather than an animated one
dotnetProjects: dotnetProjects:
- name: Jellyfin.Plugin.CinemaTrailers4Jellyfins - name: Jellyfin.Plugin.CinemaTrailers4Jellyfins

View File

@@ -8,6 +8,30 @@
"owner": "514mart", "owner": "514mart",
"imageUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/raw/branch/main/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Images/logo.svg", "imageUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/raw/branch/main/Jellyfin.Plugin.CinemaTrailers4Jellyfins/Images/logo.svg",
"versions": [ "versions": [
{
"checksum": "f1fe201e7b0238fa432ae7fb5dbb1844",
"changelog": "- yt-dlp now prefers H.264 + AAC (plays or hardware-transcodes on every client; VP9/AV1 often needed a CPU transcode) and uses Jellyfin's own ffmpeg for the merge\n- If yt-dlp fails, the built-in downloader is tried instead\n- Fix a possible hang. yt-dlp's output is now read while it runs, so a full output buffer can no longer block it\n",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.13/cinematrailers4jellyfins_1.0.0.13.zip",
"timestamp": "2026-10-05T22:11:03Z",
"version": "1.0.0.13"
},
{
"checksum": "d7a565b0e4d47effb9db40fb44847920",
"changelog": "- Anime trailers now only play before anime. Western animation (Pixar, Disney, ...) is no longer blocked and follows the normal genre filter again. A trailer counts as anime when TMDB tags it \"anime\" or \"based on manga\", or it is animation in Japanese, Chinese or Korean; it is marked with a ct4j-anime tag in its NFO at download time. Your anime is recognised by the \"Anime\" genre or the same TMDB keywords\n",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.12/cinematrailers4jellyfins_1.0.0.12.zip",
"timestamp": "2026-10-05T21:57:24Z",
"version": "1.0.0.12"
},
{
"checksum": "ccb45ef4bda23b245e47f6226e3c06fc",
"changelog": "- Animated trailers (anime included) now only play before animated content. Before, an anime trailer could play before a live-action movie or show when they shared a genre like Drama or Action, or when no trailer matched the genre filter. A live-action feature now gets no trailer rather than an animated one\n",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://www.git.quarantinedstudio.com/mvezina/CinemaTrailers4Jellyfins/releases/download/v1.0.0.11/cinematrailers4jellyfins_1.0.0.11.zip",
"timestamp": "2026-10-05T21:52:47Z",
"version": "1.0.0.11"
},
{ {
"checksum": "13e6467ce568b1abe987ef5a8a0ee95d", "checksum": "13e6467ce568b1abe987ef5a8a0ee95d",
"changelog": "- Fix trailer downloads, broken by a YouTube change. YouTube no longer serves combined video+audio files, so the built-in downloader now fetches the video and audio separately, merges them with ffmpeg (no re-encode) and deletes the separate files afterwards\n- A failed download no longer leaves an empty trailer folder behind. Empty folders counted toward \"Max trailers to keep\" (pushing out real trailers) and were never retried\n", "changelog": "- Fix trailer downloads, broken by a YouTube change. YouTube no longer serves combined video+audio files, so the built-in downloader now fetches the video and audio separately, merges them with ffmpeg (no re-encode) and deletes the separate files afterwards\n- A failed download no longer leaves an empty trailer folder behind. Empty folders counted toward \"Max trailers to keep\" (pushing out real trailers) and were never retried\n",