Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions Jellyfin/backend/Api/AnimeMarkersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -421,18 +421,27 @@ public async Task<ActionResult<object>> GetPreview(

_diagnostics.Write($"preview series=\"{series.Name}\" (from the admin page)");

var show = _resolver.MatchSeries(series);
// Sync the id to a englisch/romanji title.
await _resolver.RefreshAliasesAsync(new[] { series }, cancellationToken).ConfigureAwait(false);

var match = _resolver.MatchSeriesDetailed(series);
var show = match.Show;
if (show == null)
{
_diagnostics.Write(" -> matched nothing on AnimeFillerList");
_diagnostics.Write(" -> matched nothing on AnimeFillerList, by title or by provider id");
return Ok(new
{
series = series.Name,
matched = false,
hint = "No AnimeFillerList show has this title. Check the site's spelling for it."
hint = "No AnimeFillerList show has this title, and none of its provider ids lead to one. Check its AniList, AniDB or MyAnimeList id."
});
}

if (match.AliasKey != null)
{
_diagnostics.Write($" -> matched {show.Slug} through {match.AliasKey}");
}

var entry = _resolver.GetCachedEntry(series, CacheMaxAge);
if (entry == null)
{
Expand Down Expand Up @@ -515,6 +524,7 @@ public async Task<ActionResult<object>> RefreshSeries(
}

await _client.EnsureCatalogAsync(cancellationToken).ConfigureAwait(false);
await _resolver.RefreshAliasesAsync(new[] { series }, cancellationToken).ConfigureAwait(false);

var show = _resolver.MatchSeries(series);
if (show == null)
Expand Down
3 changes: 2 additions & 1 deletion Jellyfin/backend/Pages/configPage.html
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,8 @@ <h3 class="sectionTitle">Anime Filler &amp; Recap Markers</h3>
Nightly task that matches your anime against
<a href="https://www.animefillerlist.com/" target="_blank">AnimeFillerList</a> so Moonfin
clients can badge filler episodes. No account or API key is needed. Series are matched by
title, and only shows the site actually lists are fetched.
title, falling back to the English and romaji titles behind their ids for libraries in other languages,
and only shows the site actually lists are fetched.
</div>
</div>
<div class="inputContainer">
Expand Down
1 change: 1 addition & 0 deletions Jellyfin/backend/PluginServiceRegistrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public void RegisterServices(IServiceCollection serviceCollection, IServerApplic
serviceCollection.AddSingleton<AnimeIdMappingService>();
serviceCollection.AddSingleton<AnimeFillerListClient>();
serviceCollection.AddSingleton<AnimeMarkerCacheService>();
serviceCollection.AddSingleton<AnimeTitleAliasService>();
serviceCollection.AddSingleton<AnimeRecapFetchService>();
serviceCollection.AddSingleton<AnimeMarkerResolver>();
serviceCollection.AddSingleton<CustomRowCacheService>();
Expand Down
82 changes: 77 additions & 5 deletions Jellyfin/backend/Services/AnimeIdMappingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ private async Task DownloadAsync(CancellationToken cancellationToken)
}
}

/// <summary>
/// Loads a table from a given file, so tests can check the parsing without a download.
/// </summary>
internal void LoadFrom(string path) => _index = Parse(path);

private static MappingIndex Parse(string path)
{
var index = new MappingIndex();
Expand All @@ -157,7 +162,11 @@ private static MappingIndex Parse(string path)
Add(index.ByAniDb, ReadInt(entry, "anidb_id"), malId.Value);
Add(index.ByAniSearch, ReadInt(entry, "anisearch_id"), malId.Value);
Add(index.ByKitsu, ReadInt(entry, "kitsu_id"), malId.Value);
Add(index.ByImdb, ReadString(entry, "imdb_id"), malId.Value);
// The table lists IMDb ids as an array, since one entry can have several titles.
foreach (var imdbId in ReadStrings(entry, "imdb_id"))
{
Add(index.ByImdb, imdbId, malId.Value);
}

// One TVDB or TMDB id covers a whole show, while MyAnimeList splits it per
// season, so the first entry wins. That is the earliest season, and the recap
Expand All @@ -173,6 +182,48 @@ private static MappingIndex Parse(string path)
return index;
}

/// <summary>
/// Returns true when the mapping table has been loaded, either from disk or from a download.
/// </summary>
public bool IsLoaded => _index != null;

/// <summary>
/// Resolves one provider id, given by the canonical names <see cref="AnimeTitleAliasService"/>
/// uses ("anidb", "tvdb", ...), to a MyAnimeList id. Returns null when it isnt in the table.
/// </summary>
public int? ResolveProvider(string provider, string value)
{
var index = _index;
if (index == null || string.IsNullOrWhiteSpace(value))
{
return null;
}

var trimmed = value.Trim();
if (provider == "imdb")
{
return index.ByImdb.TryGetValue(trimmed, out var fromImdb) ? fromImdb : null;
}

if (!int.TryParse(trimmed, out var id))
{
return null;
}

var map = provider switch
{
"anilist" => index.ByAniList,
"anidb" => index.ByAniDb,
"anisearch" => index.ByAniSearch,
"kitsu" => index.ByKitsu,
"tvdb" => index.ByTvdb,
"tmdb" => index.ByTmdb,
_ => null
};

return map != null && map.TryGetValue(id, out var malId) ? malId : null;
}

/// <summary>
/// Resolves a MyAnimeList id for a series, using the provider ids the library already has. Returns null if no mapping is found.
/// </summary>
Expand Down Expand Up @@ -256,10 +307,31 @@ private static void Add(Dictionary<string, int> map, string? key, int malId)
? parsed
: null;

private static string? ReadString(JsonElement element, string property) =>
element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
/// <summary>
/// Reads a property that is either one string or an array of them.
/// </summary>
private static IEnumerable<string?> ReadStrings(JsonElement element, string property)
{
if (!element.TryGetProperty(property, out var value))
{
yield break;
}

if (value.ValueKind == JsonValueKind.String)
{
yield return value.GetString();
}
else if (value.ValueKind == JsonValueKind.Array)
{
foreach (var item in value.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
yield return item.GetString();
}
}
}
}

private static bool TryGetInt(BaseItem series, out int value, params string[] keys)
{
Expand Down
21 changes: 21 additions & 0 deletions Jellyfin/backend/Services/AnimeMarkerModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,24 @@ public class AnimeMarkerCacheEntry
[JsonPropertyName("cachedAt")]
public DateTimeOffset CachedAt { get; set; }
}

/// <summary>
/// The titles AniList knows one provider id by. Lets a series whose library name is
/// localized still be found under the English or romaji title AnimeFillerList uses.
/// </summary>
public class AnimeTitleAliasEntry
{
/// <summary>
/// The MyAnimeList id behind the titles, when AniList has one.
/// </summary>
[JsonPropertyName("malId")]
public int? MalId { get; set; }

/// <summary>
/// English title, then romaji, then synonyms. Empty when AniList had no entry.</summary>
[JsonPropertyName("titles")]
public List<string> Titles { get; set; } = new();

[JsonPropertyName("fetchedAt")]
public DateTimeOffset FetchedAt { get; set; }
}
75 changes: 64 additions & 11 deletions Jellyfin/backend/Services/AnimeMarkerResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,22 @@ public class AnimeMarkerResolver
private readonly IMediaSourceManager? _mediaSourceManager;
private readonly AnimeFillerListClient _client;
private readonly AnimeMarkerCacheService _cache;
private readonly AnimeTitleAliasService _aliases;
private readonly ILogger<AnimeMarkerResolver> _logger;

public AnimeMarkerResolver(
ILibraryManager libraryManager,
IMediaSourceManager mediaSourceManager,
AnimeFillerListClient client,
AnimeMarkerCacheService cache,
AnimeTitleAliasService aliases,
ILogger<AnimeMarkerResolver> logger)
{
_libraryManager = libraryManager;
_mediaSourceManager = mediaSourceManager;
_client = client;
_cache = cache;
_aliases = aliases;
_logger = logger;
}

Expand Down Expand Up @@ -136,13 +139,50 @@ private IEnumerable<Series> QuerySeries(Guid? parentId)
}

/// <summary>
/// Finds the AnimeFillerList show for a series, by its library name and its original
/// title. Returns null when neither matches, which is the right answer for the large
/// majority of a general library.
/// Finds the AnimeFillerList show for a series. Returns null when nothing matches, which
/// is the right answer for the large majority of a general library.
/// </summary>
public AnimeFillerShow? MatchSeries(Series series) =>
public AnimeFillerShow? MatchSeries(Series series) => MatchSeriesDetailed(series).Show;

/// <summary>
/// The same, along with how it matched. The library name and original title are tried
/// first, then the English and romaji titles behind the series' provider ids, which is
/// what finds a show whose library name is localized. Those titles only exist once
/// <see cref="RefreshAliasesAsync"/> has fetched them.
/// </summary>
public SeriesMatchResult MatchSeriesDetailed(Series series)
{
var byTitle = MatchByLibraryTitle(series);
if (byTitle != null)
{
return new SeriesMatchResult(byTitle, null, null);
}

foreach (var (key, alias) in _aliases.GetAliases(series))
{
var show = AnimeTitleMatcher.MatchPrimary(_client.Index, alias.Titles);
if (show != null)
{
return new SeriesMatchResult(show, key, alias.MalId);
}
}

return new SeriesMatchResult(null, null, null);
}

private AnimeFillerShow? MatchByLibraryTitle(Series series) =>
AnimeTitleMatcher.Match(_client.Index, WithProductionYear(series), series.Name, series.OriginalTitle);

/// <summary>
/// Fetches the provider-id titles for every series whose library title matches nothing.
/// Series that already match by title are left alone, so a library that works today
/// will not change.
/// </summary>
public Task<int> RefreshAliasesAsync(IEnumerable<Series> series, CancellationToken cancellationToken) =>
_aliases.RefreshAsync(
series.Where(item => MatchByLibraryTitle(item) == null).ToList(),
cancellationToken);

/// <summary>
/// The series' name with its production year appended, when it has one.
/// </summary>
Expand All @@ -155,16 +195,19 @@ private IEnumerable<Series> QuerySeries(Guid? parentId)
/// Every series that matches a show on the site, paired with the show. Used both by the
/// sync task to decide what to fetch and by the diagnostics endpoint.
/// </summary>
public List<SeriesMatch> BuildMatches()
public List<SeriesMatch> BuildMatches() => BuildMatches(GetCandidateSeries());

/// <summary>The same, over series the caller has already gathered.</summary>
public List<SeriesMatch> BuildMatches(IEnumerable<Series> candidates)
{
var matches = new List<SeriesMatch>();

foreach (var series in GetCandidateSeries())
foreach (var series in candidates)
{
var show = MatchSeries(series);
if (show != null)
var match = MatchSeriesDetailed(series);
if (match.Show != null)
{
matches.Add(new SeriesMatch(series, show));
matches.Add(new SeriesMatch(series, match.Show, match.MalId));
}
}

Expand Down Expand Up @@ -572,8 +615,18 @@ public class AudioMarkerResult
public Dictionary<string, AnimeAudioKind> Seasons { get; } = new();
}

/// <summary>One library series and the AnimeFillerList show it matched.</summary>
public record SeriesMatch(Series Series, AnimeFillerShow Show);
/// <summary>
/// One library series and the AnimeFillerList show it matched. The MyAnimeList id is set
/// when the match came from a provider id's titles, and is the entry the recap pass should
/// use, since it is the one that actually named the show.
/// </summary>
public record SeriesMatch(Series Series, AnimeFillerShow Show, int? MalId = null);

/// <summary>
/// The show a series matched, if any, and the provider id key ("anidb:266") whose titles
/// matched it. The key is null for a match on the library title.
/// </summary>
public record SeriesMatchResult(AnimeFillerShow? Show, string? AliasKey, int? MalId);

/// <summary>One library episode and the absolute number the site would use for it.</summary>
public record NumberedEpisode(Episode Episode, int AbsoluteNumber);
Expand Down
20 changes: 18 additions & 2 deletions Jellyfin/backend/Services/AnimeMarkerSyncTask.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,19 @@ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken can

progress.Report(5);

var matches = _resolver.BuildMatches();
// A localized library name never matches the site's English titles, so the titles
// behind those series' provider ids are fetched before matching.
var candidates = _resolver.GetCandidateSeries();
try
{
await _resolver.RefreshAliasesAsync(candidates, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Anime markers sync: the provider id title lookup failed, matching on library titles only");
}

var matches = _resolver.BuildMatches(candidates);
var showsBySlug = matches
.GroupBy(match => match.Show.Slug, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First().Show, StringComparer.OrdinalIgnoreCase);
Expand Down Expand Up @@ -247,7 +259,11 @@ private async Task FetchRecapsAsync(List<SeriesMatch> matches, Stopwatch clock,
continue;
}

var malId = await _recapService.TryResolveMalIdAsync(match.Series, cancellationToken).ConfigureAwait(false);
// A match made through a provider id's titles already knows which entry named
// the show. Resolving again could land on another one, such as a movie sharing
// the series' ids.
var malId = match.MalId
?? await _recapService.TryResolveMalIdAsync(match.Series, cancellationToken).ConfigureAwait(false);
if (malId == null)
{
_diagnostics.Write(
Expand Down
Loading
Loading