diff --git a/apps/desktop/lib/src/schedule/animeflow.dart b/apps/desktop/lib/src/schedule/animeflow.dart index e93eafb..71483fb 100644 --- a/apps/desktop/lib/src/schedule/animeflow.dart +++ b/apps/desktop/lib/src/schedule/animeflow.dart @@ -148,13 +148,17 @@ class AnimeFlowLibraryItem { final String name; /// Set when AnimeFlow tracks it. final int? seriesId; - /// AniList entry per TVDB season. + /// AniList entry per TVDB season (the first, when a season spans more). final Map anilistIds; + /// Every AniList entry per season with its episode run (`anilist_map`): + /// a season AniList splits (one cour as two entries) has several rows. + final List anilistMap; const AnimeFlowLibraryItem({ required this.jellyfinSeriesId, required this.name, this.seriesId, this.anilistIds = const {}, + this.anilistMap = const [], }); static AnimeFlowLibraryItem fromJson(Map j) => AnimeFlowLibraryItem( @@ -165,9 +169,30 @@ class AnimeFlowLibraryItem { for (final e in ((j['anilist_ids'] as Map?) ?? const {}).entries) if (int.tryParse('${e.key}') case final season? when _int(e.value) != null) season: _int(e.value)!, }, + anilistMap: [ + for (final r in (j['anilist_map'] as List?) ?? const []) + if (r is Map && _int(r['season']) != null && _int(r['anilist_id']) != null) + AniListRun( + season: _int(r['season'])!, + anilistId: _int(r['anilist_id'])!, + firstEpisode: _int(r['first_episode']) ?? 1, + offset: _int(r['offset']) ?? 0, + ), + ], ); } +/// One AniList entry's run within a TVDB season: TVDB episode e (from +/// [firstEpisode] to the next run of that season) is AniList episode +/// e - [offset]. +class AniListRun { + final int season; + final int anilistId; + final int firstEpisode; + final int offset; + const AniListRun({required this.season, required this.anilistId, this.firstEpisode = 1, this.offset = 0}); +} + class AnimeFlowEpisode { /// 0 = specials. final int season; @@ -206,7 +231,9 @@ class AnimeFlowEpisode { absolute: _int(j['absolute']), title: _str(j['episode_title']), airDate: _time(j['air_date']), - anilistId: _int(j['anilist_id']), + // The episode's own entry (a split season rolls into its sequel), + // else the series' one. + anilistId: _int(j['episode_anilist_id']) ?? _int(j['anilist_id']), anilistEpisode: _int(j['anilist_episode']), status: EpisodeStatus.parse(j['status']), progress: (j['progress'] as num?)?.toDouble(), diff --git a/apps/desktop/lib/src/schedule/schedule_model.dart b/apps/desktop/lib/src/schedule/schedule_model.dart index d1d0b58..6b6c188 100644 --- a/apps/desktop/lib/src/schedule/schedule_model.dart +++ b/apps/desktop/lib/src/schedule/schedule_model.dart @@ -102,6 +102,9 @@ List matchSchedule( final lib = {}; for (final item in library) { if (item.jellyfinSeriesId.isEmpty) continue; + for (final r in item.anilistMap) { + lib[r.anilistId] ??= (item.jellyfinSeriesId, r.season); + } for (final e in item.anilistIds.entries) { lib[e.value] ??= (item.jellyfinSeriesId, e.key); } diff --git a/apps/desktop/pubspec.lock b/apps/desktop/pubspec.lock index c7c3471..eaa6086 100644 --- a/apps/desktop/pubspec.lock +++ b/apps/desktop/pubspec.lock @@ -748,14 +748,14 @@ packages: path: "../../packages/core" relative: true source: path - version: "1.0.0" + version: "1.0.1" sync_relay_server: dependency: "direct main" description: path: "../../packages/server" relative: true source: path - version: "1.0.0" + version: "1.0.1" synchronized: dependency: transitive description: diff --git a/apps/desktop/test/schedule_model_test.dart b/apps/desktop/test/schedule_model_test.dart index 2981f03..1720379 100644 --- a/apps/desktop/test/schedule_model_test.dart +++ b/apps/desktop/test/schedule_model_test.dart @@ -63,6 +63,23 @@ void main() { expect(out[2].onJellyfin, isFalse); }); + test('anilist_map: a season split into two AniList entries is on Jellyfin', () { + final item = AnimeFlowLibraryItem.fromJson({ + 'jellyfin_series_id': 'jfJ', + 'name': 'JoJo', + 'anilist_ids': {'6': 190327}, + 'anilist_map': [ + {'season': 6, 'anilist_id': 190327, 'first_episode': 1, 'offset': 0}, + {'season': 6, 'anilist_id': 210482, 'first_episode': 2, 'offset': 1}, + ], + }); + final out = matchSchedule([airing(210482, 3, DateTime(2026, 10, 6, 18))], + schedule: const [], library: [item], now: now); + expect(out.single.status, ScheduleStatus.inLibrary); + expect(out.single.jellyfinSeriesId, 'jfJ'); + expect(out.single.season, 6); + }); + test("AnimeFlow's anilist_episode wins over TVDB numbering (split cour)", () { // S2E13 in TVDB is part 2 (AniList 300) episode 1. final out = matchSchedule( diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dea21e5..b67066a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -592,7 +592,8 @@ always scrolls the page). Space never presses a focused button from AniList's airing schedule (`anilist.dart`: pages cached 10 min in memory and 1 h on disk, stale data shown on 429 or failure) matched against AnimeFlow (`animeflow.dart`, app API with the Jellyfin token; - `/library` marks shows on Jellyfin it doesn't track) into a status per + `/library` marks shows on Jellyfin it doesn't track, through every AniList + entry of a season (`anilist_map`: a season AniList splits has several)) into a status per episode (`schedule_model.dart`); requests and the owner's inbox are in `requests_sheet.dart`. It refreshes every minute; mouse drag and Shift+wheel scroll the board sideways, the plain wheel scrolls a column.