From 2aa084287dd10a56497db7b2a6342898ec3cd338 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:07:54 +0200 Subject: [PATCH] feat(explore): MyAnimeList catalog source --- lib/models/mal/mal_anime.dart | 175 ++++++++ lib/models/mal/mal_anime.g.dart | 62 +++ lib/models/mal/mal_character.dart | 30 ++ lib/providers/trackers_provider.dart | 8 + lib/services/catalog/mal_catalog_source.dart | 231 +++++++++++ .../trackers/fribb_mapping_store.dart | 18 +- lib/services/trackers/mal/mal_client.dart | 72 ++++ lib/services/trackers/mal/mal_constants.dart | 8 + lib/services/trackers/mal/mal_page.dart | 41 ++ .../catalog/mal_catalog_source_test.dart | 375 ++++++++++++++++++ .../trackers/mal_catalog_parsing_test.dart | 106 +++++ .../tracker_coordinator_manual_test.dart | 3 + .../trackers/tracker_id_resolver_test.dart | 3 + 13 files changed, 1129 insertions(+), 3 deletions(-) create mode 100644 lib/models/mal/mal_anime.dart create mode 100644 lib/models/mal/mal_anime.g.dart create mode 100644 lib/models/mal/mal_character.dart create mode 100644 lib/services/catalog/mal_catalog_source.dart create mode 100644 lib/services/trackers/mal/mal_page.dart create mode 100644 test/services/catalog/mal_catalog_source_test.dart create mode 100644 test/services/trackers/mal_catalog_parsing_test.dart diff --git a/lib/models/mal/mal_anime.dart b/lib/models/mal/mal_anime.dart new file mode 100644 index 00000000..72b59238 --- /dev/null +++ b/lib/models/mal/mal_anime.dart @@ -0,0 +1,175 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../utils/json_utils.dart'; + +part 'mal_anime.g.dart'; + +/// Poster art from MAL's `main_picture` field (absolute https URLs on +/// `api-cdn.myanimelist.net`). MAL serves no backdrop/fanart art. +@JsonSerializable(createToJson: false) +class MalPicture { + final String? medium; + final String? large; + + const MalPicture({this.medium, this.large}); + + String? get primary { + final url = large ?? medium; + return url == null || url.isEmpty ? null : url; + } + + factory MalPicture.fromJson(Map json) => _$MalPictureFromJson(json); +} + +@JsonSerializable(createToJson: false) +class MalAlternativeTitles { + final String? en; + final String? ja; + final List? synonyms; + + const MalAlternativeTitles({this.en, this.ja, this.synonyms}); + + factory MalAlternativeTitles.fromJson(Map json) => _$MalAlternativeTitlesFromJson(json); +} + +@JsonSerializable(createToJson: false) +class MalGenre { + final String? name; + + const MalGenre({this.name}); + + factory MalGenre.fromJson(Map json) => _$MalGenreFromJson(json); +} + +@JsonSerializable(createToJson: false) +class MalStudio { + final String? name; + + const MalStudio({this.name}); + + factory MalStudio.fromJson(Map json) => _$MalStudioFromJson(json); +} + +@JsonSerializable(createToJson: false) +class MalStartSeason { + @JsonKey(fromJson: flexibleInt) + final int? year; + + const MalStartSeason({this.year}); + + factory MalStartSeason.fromJson(Map json) => _$MalStartSeasonFromJson(json); +} + +/// An anime summary node from MAL API v2 catalog endpoints +/// (`/users/@me/animelist`, `/anime/suggestions`, `/anime/ranking`), with the +/// fields Plezy requests (see `MalClient.catalogFields`). +@JsonSerializable(createToJson: false) +class MalAnime { + /// MAL's audience-rating strings mapped for display. + static const Map _certifications = { + 'g': 'G', + 'pg': 'PG', + 'pg_13': 'PG-13', + 'r': 'R', + 'r+': 'R+', + 'rx': 'Rx', + }; + + @JsonKey(fromJson: flexibleInt) + final int? id; + + /// Default (romaji) title; [displayTitle] prefers the English one. + final String? title; + @JsonKey(name: 'main_picture') + final MalPicture? mainPicture; + @JsonKey(name: 'alternative_titles') + final MalAlternativeTitles? alternativeTitles; + + /// `YYYY-MM-DD`, `YYYY-MM`, or `YYYY`. + @JsonKey(name: 'start_date') + final String? startDate; + final String? synopsis; + + /// Community rating, 0–10. + final double? mean; + final List? genres; + + /// `tv` / `movie` / `ova` / `ona` / `special` / `music` / ... + @JsonKey(name: 'media_type') + final String? mediaType; + + /// Audience rating: `g` / `pg` / `pg_13` / `r` / `r+` / `rx`. + final String? rating; + @JsonKey(name: 'num_episodes', fromJson: flexibleInt) + final int? numEpisodes; + + /// Seconds per episode (total runtime for movies). + @JsonKey(name: 'average_episode_duration', fromJson: flexibleInt) + final int? averageEpisodeDuration; + @JsonKey(name: 'start_season') + final MalStartSeason? startSeason; + + /// `currently_airing` / `finished_airing` / `not_yet_aired`. + final String? status; + final List? studios; + @JsonKey(name: 'num_scoring_users', fromJson: flexibleInt) + final int? numScoringUsers; + + const MalAnime({ + this.id, + this.title, + this.mainPicture, + this.alternativeTitles, + this.startDate, + this.synopsis, + this.mean, + this.genres, + this.mediaType, + this.rating, + this.numEpisodes, + this.averageEpisodeDuration, + this.startSeason, + this.status, + this.studios, + this.numScoringUsers, + }); + + bool get isMovie => mediaType == 'movie'; + + /// English title when MAL has one, else the default (romaji) title. Media + /// servers index by the English/agent title, so this is also what library + /// matching searches for. + String get displayTitle { + final en = alternativeTitles?.en; + if (en != null && en.isNotEmpty) return en; + return title ?? ''; + } + + int? get year => startSeason?.year ?? _yearFromStartDate; + + int? get _yearFromStartDate { + final date = startDate; + if (date == null || date.length < 4) return null; + return int.tryParse(date.substring(0, 4)); + } + + int? get runtimeMinutes { + final seconds = averageEpisodeDuration; + if (seconds == null || seconds <= 0) return null; + return (seconds / 60).round(); + } + + String? get certification => _certifications[rating]; + + List? get genreNames { + final names = [for (final genre in genres ?? const []) ?genre.name]; + return names.isEmpty ? null : names; + } + + String? get primaryStudio { + final name = studios?.firstOrNull?.name; + return name == null || name.isEmpty ? null : name; + } + + factory MalAnime.fromJson(Map json) => _$MalAnimeFromJson(json); +} diff --git a/lib/models/mal/mal_anime.g.dart b/lib/models/mal/mal_anime.g.dart new file mode 100644 index 00000000..864c3190 --- /dev/null +++ b/lib/models/mal/mal_anime.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mal_anime.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MalPicture _$MalPictureFromJson(Map json) => MalPicture( + medium: json['medium'] as String?, + large: json['large'] as String?, +); + +MalAlternativeTitles _$MalAlternativeTitlesFromJson( + Map json, +) => MalAlternativeTitles( + en: json['en'] as String?, + ja: json['ja'] as String?, + synonyms: (json['synonyms'] as List?) + ?.map((e) => e as String) + .toList(), +); + +MalGenre _$MalGenreFromJson(Map json) => + MalGenre(name: json['name'] as String?); + +MalStudio _$MalStudioFromJson(Map json) => + MalStudio(name: json['name'] as String?); + +MalStartSeason _$MalStartSeasonFromJson(Map json) => + MalStartSeason(year: flexibleInt(json['year'])); + +MalAnime _$MalAnimeFromJson(Map json) => MalAnime( + id: flexibleInt(json['id']), + title: json['title'] as String?, + mainPicture: json['main_picture'] == null + ? null + : MalPicture.fromJson(json['main_picture'] as Map), + alternativeTitles: json['alternative_titles'] == null + ? null + : MalAlternativeTitles.fromJson( + json['alternative_titles'] as Map, + ), + startDate: json['start_date'] as String?, + synopsis: json['synopsis'] as String?, + mean: (json['mean'] as num?)?.toDouble(), + genres: (json['genres'] as List?) + ?.map((e) => MalGenre.fromJson(e as Map)) + .toList(), + mediaType: json['media_type'] as String?, + rating: json['rating'] as String?, + numEpisodes: flexibleInt(json['num_episodes']), + averageEpisodeDuration: flexibleInt(json['average_episode_duration']), + startSeason: json['start_season'] == null + ? null + : MalStartSeason.fromJson(json['start_season'] as Map), + status: json['status'] as String?, + studios: (json['studios'] as List?) + ?.map((e) => MalStudio.fromJson(e as Map)) + .toList(), + numScoringUsers: flexibleInt(json['num_scoring_users']), +); diff --git a/lib/models/mal/mal_character.dart b/lib/models/mal/mal_character.dart new file mode 100644 index 00000000..f6d65b7f --- /dev/null +++ b/lib/models/mal/mal_character.dart @@ -0,0 +1,30 @@ +/// One entry from `GET /anime/{id}/characters`: the character node plus its +/// entry-level `role` (`Main` / `Supporting`). MAL's API exposes characters +/// only — no voice actors. Hand-parsed because the interesting fields span +/// the entry and its node. +class MalCharacter { + final String name; + final String? role; + final String? imageUrl; + + const MalCharacter({required this.name, this.role, this.imageUrl}); + + factory MalCharacter.fromEntry(Map entry) { + final node = entry['node']; + final map = node is Map ? node.cast() : const {}; + final first = map['first_name']; + final last = map['last_name']; + final name = [ + if (first is String && first.isNotEmpty) first, + if (last is String && last.isNotEmpty) last, + ].join(' '); + final picture = map['main_picture']; + final medium = picture is Map ? picture['medium'] : null; + final role = entry['role']; + return MalCharacter( + name: name, + role: role is String && role.isNotEmpty ? role : null, + imageUrl: medium is String && medium.isNotEmpty ? medium : null, + ); + } +} diff --git a/lib/providers/trackers_provider.dart b/lib/providers/trackers_provider.dart index a24bb2f4..0c746a66 100644 --- a/lib/providers/trackers_provider.dart +++ b/lib/providers/trackers_provider.dart @@ -58,6 +58,14 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin bool get isAnilistConnected => _anilist != null; bool get isSimklConnected => _simkl != null; + /// The live MAL client for the Explore catalog, shared with the scrobble + /// tracker so both ride one session (MAL rotates refresh tokens — a second + /// client would race refreshes and log the user out). Gated on this + /// provider's own session so a freshly-mounted profile subtree never sees + /// the previous profile's client while its sessions are still loading; + /// every rebind is followed by a notify, so proxy consumers track identity. + MalClient? get malCatalogClient => _mal == null ? null : MalTracker.instance.client; + String? get malUsername => _mal?.username; String? get anilistUsername => _anilist?.username; String? get simklUsername => _simkl?.username; diff --git a/lib/services/catalog/mal_catalog_source.dart b/lib/services/catalog/mal_catalog_source.dart new file mode 100644 index 00000000..701f9a08 --- /dev/null +++ b/lib/services/catalog/mal_catalog_source.dart @@ -0,0 +1,231 @@ +import 'package:collection/collection.dart'; + +import '../../media/media_kind.dart'; +import '../../models/catalog/catalog_cast_member.dart'; +import '../../models/catalog/catalog_item.dart'; +import '../../models/mal/mal_anime.dart'; +import '../../models/trackers/fribb_mapping_row.dart'; +import '../../utils/external_ids.dart'; +import '../trackers/fribb_mapping_store.dart'; +import '../trackers/mal/mal_client.dart'; +import '../trackers/mal/mal_constants.dart'; +import '../trackers/tracker_exceptions.dart'; +import 'catalog_source.dart'; +import 'catalog_watchlist_machinery.dart'; + +/// [CatalogSource] backed by the MyAnimeList API. +/// +/// Wraps the [MalClient] owned by `MalTracker` (rebound per profile by +/// `TrackersProvider`; never disposed here). MAL is anime-only with no +/// movie/show split, so it serves the anime rows, and its watchlist is the +/// user's Plan to Watch list. +/// +/// MAL entries carry no media-server external ids; the Fribb anime-lists +/// mapping bridges both directions: catalog items are enriched with +/// tvdb/tmdb/imdb (library matching, cross-source membership), and library +/// items resolve to a MAL id via [resolveItemIds]. +class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource { + final MalClient _client; + final FribbMappingLookup _fribb; + + MalCatalogSource(this._client, {FribbMappingLookup? fribb}) : _fribb = fribb ?? FribbMappingStore.instance; + + @override + String get watchlistLogLabel => 'MAL: Plan to Watch'; + + /// Full-snapshot paging: 4 × 500 covers 2000 Plan to Watch entries. + @override + int get watchlistPageLimit => 500; + @override + int get watchlistMaxPages => 4; + + @override + CatalogSourceId get id => CatalogSourceId.mal; + + @override + String get displayName => 'MyAnimeList'; + + @override + List get supportedRows => const [ + CatalogRowId.watchlist, + CatalogRowId.suggestedAnime, + CatalogRowId.airingAnime, + CatalogRowId.popularAnime, + ]; + + @override + bool get supportsWatchlist => true; + + @override + Future fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) async { + final res = switch (row) { + CatalogRowId.watchlist => await _client.getPlanToWatch(page: page, limit: limit), + CatalogRowId.suggestedAnime => await _client.getSuggestedAnime(page: page, limit: limit), + CatalogRowId.airingAnime => await _client.getAnimeRanking(MalRankingType.airing, page: page, limit: limit), + CatalogRowId.popularAnime => await _client.getAnimeRanking(MalRankingType.bypopularity, page: page, limit: limit), + CatalogRowId.recommendedMovies || + CatalogRowId.recommendedShows || + CatalogRowId.trendingMovies || + CatalogRowId.trendingShows || + CatalogRowId.popularMovies || + CatalogRowId.popularShows || + CatalogRowId.trending || + CatalogRowId.upcomingMovies || + CatalogRowId.upcomingShows => throw ArgumentError('MAL does not serve ${row.name}'), + }; + return CatalogPage(items: await _toCatalogItems(res.items), hasMore: res.hasMore); + } + + /// MAL rejects queries under 3 characters (`invalid q`) — return empty + /// instead of surfacing a 400 while the user is still typing. + @override + Future> search(String query, {int limit = 30}) async { + final trimmed = query.trim(); + if (trimmed.length < 3) return const []; + final res = await _client.searchAnime(trimmed, limit: limit); + return _toCatalogItems(res.items); + } + + @override + Future> fetchRelated(CatalogItem item, {int limit = 20}) async { + final malId = item.ids.mal; + if (malId == null) return const []; + return _toCatalogItems(await _client.getAnimeRecommendations(malId, limit: limit)); + } + + /// Enrich concurrently so all items share one Fribb index load (per-item + /// awaits would retry the download for every item when it is failing). + Future> _toCatalogItems(List anime) async { + final withIds = [ + for (final entry in anime) + if (entry.id != null) entry, + ]; + final rows = await Future.wait([for (final entry in withIds) _fribb.lookupByMal(entry.id!)]); + return [for (var i = 0; i < withIds.length; i++) _toCatalogItem(withIds[i], rows[i])]; + } + + /// Normalize MAL's status strings. `finished_airing` on a movie maps to + /// null — an "Ended" chip on every movie is noise. + static CatalogAirStatus? airStatusFor(MalAnime anime) => switch (anime.status) { + 'currently_airing' => CatalogAirStatus.airing, + 'finished_airing' => anime.isMovie ? null : CatalogAirStatus.ended, + 'not_yet_aired' => CatalogAirStatus.upcoming, + _ => null, + }; + + CatalogItem _toCatalogItem(MalAnime anime, FribbMappingRow? row) => CatalogItem( + source: CatalogSourceId.mal, + kind: anime.isMovie ? MediaKind.movie : MediaKind.show, + title: anime.displayTitle, + year: anime.year, + overview: anime.synopsis, + runtimeMinutes: anime.runtimeMinutes, + rating: anime.mean, + votes: anime.numScoringUsers, + genres: anime.genreNames, + certification: anime.certification, + airStatus: airStatusFor(anime), + episodeCount: anime.isMovie || (anime.numEpisodes ?? 0) <= 0 ? null : anime.numEpisodes, + network: anime.primaryStudio, + ids: CatalogItemIds( + mal: anime.id, + imdb: row?.imdbIds?.firstOrNull, + tmdb: row?.tmdbIds?.firstOrNull, + tvdb: row?.tvdbId, + ), + posterUrl: anime.mainPicture?.primary, + ); + + @override + Future> fetchCast(CatalogItem item, {int limit = 20}) async { + final malId = item.ids.mal; + if (malId == null) return const []; + final res = await _client.getAnimeCharacters(malId, limit: limit); + return [ + for (final character in res.items) + if (character.name.isNotEmpty) + CatalogCastMember(name: character.name, secondary: character.role, imageUrl: character.imageUrl), + ]; + } + + /// Reverse-map a library item's external ids to its MAL entry. A show-level + /// id can resolve to several rows (split-cour anime, one row per season); + /// prefer season 1 — "add this show" means its first season on MAL. Null + /// (non-anime) hides the watchlist action. + @override + Future resolveItemIds(MediaKind kind, ExternalIds external) async { + if (!external.hasAny) return null; + final rows = await _fribb.lookup(tvdbId: external.tvdb, tmdbId: external.tmdb, imdbId: external.imdb); + final malId = _pickRow(kind, rows)?.malId; + if (malId == null) return null; + return CatalogItemIds(mal: malId, imdb: external.imdb, tmdb: external.tmdb, tvdb: external.tvdb); + } + + static FribbMappingRow? _pickRow(MediaKind kind, List rows) { + final withMal = [ + for (final row in rows) + if (row.malId != null) row, + ]; + if (withMal.isEmpty) return null; + if (kind == MediaKind.movie) { + return withMal.firstWhereOrNull((row) => row.isMovie) ?? withMal.first; + } + return withMal.firstWhereOrNull((row) => row.tvdbSeason == 1 || row.tmdbSeason == 1) ?? withMal.first; + } + + /// MAL ids are globally unique across anime, so membership keys skip the + /// kind namespace — a library movie and a MAL `ova` entry for the same + /// title still agree. + static String _membershipKey(int malId) => 'mal:$malId'; + + @override + List membershipKeysFor(MediaKind kind, CatalogItemIds ids) => [ + if (ids.mal case final int malId) _membershipKey(malId), + ]; + + @override + Future fetchWatchlistKeyPage(int page, int limit) async { + final res = await _client.getPlanToWatch(page: page, limit: limit); + return ( + groups: [ + for (final anime in res.items) + if (anime.id != null) [_membershipKey(anime.id!)], + ], + hasMore: res.hasMore, + ); + } + + @override + Future resolveWatchlistMutationIds(MediaKind kind, CatalogItemIds ids) async { + final malId = ids.mal ?? (await resolveItemIds(kind, ids.toExternalIds()))?.mal; + if (malId == null) { + throw StateError('MAL: no anime mapping for ${ids.canonicalKey ?? 'item'}'); + } + return CatalogItemIds(mal: malId, imdb: ids.imdb, tmdb: ids.tmdb, tvdb: ids.tvdb); + } + + @override + Future performWatchlistMutation(MediaKind kind, CatalogItemIds ids, {required bool add}) async { + final malId = ids.mal!; + if (add) { + await _client.updateMyListStatus(malId, const {'status': 'plan_to_watch'}); + } else { + await _deleteEntry(malId); + } + } + + /// Removing an entry that is already gone is success, not failure. + Future _deleteEntry(int malId) async { + try { + await _client.deleteMyListStatus(malId); + } on TrackerApiException catch (e) { + if (e.statusCode == 404) return; + rethrow; + } + } + + @override + void dispose() { + disposeWatchlistMachinery(); + } +} diff --git a/lib/services/trackers/fribb_mapping_store.dart b/lib/services/trackers/fribb_mapping_store.dart index d9352569..2e4a5917 100644 --- a/lib/services/trackers/fribb_mapping_store.dart +++ b/lib/services/trackers/fribb_mapping_store.dart @@ -24,13 +24,19 @@ class FribbIndex { final Map> byTmdb; final Map> byImdb; - const FribbIndex({required this.byTvdb, required this.byTmdb, required this.byImdb}); + /// Reverse index for the Explore catalog: MAL id → its (single) row, so a + /// MAL entry can be matched back to library external ids. + final Map byMal; - bool get isEmpty => byTvdb.isEmpty && byTmdb.isEmpty && byImdb.isEmpty; + const FribbIndex({required this.byTvdb, required this.byTmdb, required this.byImdb, this.byMal = const {}}); + + bool get isEmpty => byTvdb.isEmpty && byTmdb.isEmpty && byImdb.isEmpty && byMal.isEmpty; } abstract interface class FribbMappingLookup { Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}); + + Future lookupByMal(int malId); } /// Loads and refreshes the Fribb anime-lists mapping on demand. @@ -149,6 +155,9 @@ class FribbMappingStore implements FribbMappingLookup { return const []; } + @override + Future lookupByMal(int malId) async => (await _ensureLoaded()).byMal[malId]; + /// Conditional-GET the mapping if the last check was >[_refreshInterval] ago /// and we already have an index loaded. No-op when nothing is loaded — the /// first lookup handles the initial download. @@ -249,6 +258,7 @@ FribbIndex parseFribbIndex(String raw) { final byTvdb = >{}; final byTmdb = >{}; final byImdb = >{}; + final byMal = {}; var skipped = 0; for (final raw in decoded) { @@ -271,8 +281,10 @@ FribbIndex parseFribbIndex(String raw) { if (imdb.isEmpty) continue; (byImdb[imdb] ??= []).add(row); } + final mal = row.malId; + if (mal != null) byMal.putIfAbsent(mal, () => row); } if (skipped > 0) appLogger.w('Fribb: skipped $skipped malformed row(s)'); - return FribbIndex(byTvdb: byTvdb, byTmdb: byTmdb, byImdb: byImdb); + return FribbIndex(byTvdb: byTvdb, byTmdb: byTmdb, byImdb: byImdb, byMal: byMal); } diff --git a/lib/services/trackers/mal/mal_client.dart b/lib/services/trackers/mal/mal_client.dart index abdb1ef2..7970a6fa 100644 --- a/lib/services/trackers/mal/mal_client.dart +++ b/lib/services/trackers/mal/mal_client.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:http/http.dart' as http; +import '../../../models/mal/mal_anime.dart'; +import '../../../models/mal/mal_character.dart'; import '../../../utils/app_logger.dart'; import '../../../utils/json_utils.dart'; import '../future_coalescer.dart'; @@ -12,6 +14,7 @@ import '../tracker_http_client.dart'; import '../tracker_session.dart'; import 'mal_auth_service.dart'; import 'mal_constants.dart'; +import 'mal_page.dart'; /// HTTP wrapper for the MAL REST API. /// @@ -77,6 +80,75 @@ class MalClient implements DisposableTrackerClient { } } + /// Anime summary fields the Explore catalog requests on list endpoints. + static const String catalogFields = + 'id,title,main_picture,alternative_titles,start_date,synopsis,mean,' + 'genres,media_type,rating,num_episodes,average_episode_duration,start_season,' + 'status,studios,num_scoring_users'; + + static const String _characterFields = 'role,main_picture,first_name,last_name'; + + /// Characters of an anime, main roles first (MAL exposes no voice actors). + Future> getAnimeCharacters(int animeId, {int limit = 20}) async { + final res = await _request('GET', '/anime/$animeId/characters?limit=$limit&fields=$_characterFields'); + return MalPage.fromJsonEntries(res, MalCharacter.fromEntry); + } + + /// The user's Plan to Watch list — the MAL equivalent of a watchlist. + /// `nsfw=true` because it is the user's own list. + Future> getPlanToWatch({int page = 1, int limit = 100}) => _getAnimePage( + '/users/@me/animelist', + {'status': 'plan_to_watch', 'sort': 'list_updated_at', 'nsfw': 'true'}, + page: page, + limit: limit, + ); + + /// Personalized recommendations. Empty for accounts without history. + Future> getSuggestedAnime({int page = 1, int limit = 100}) => + _getAnimePage('/anime/suggestions', const {}, page: page, limit: limit); + + Future> getAnimeRanking(MalRankingType type, {int page = 1, int limit = 100}) => + _getAnimePage('/anime/ranking', {'ranking_type': type.queryValue}, page: page, limit: limit); + + /// Title search. MAL rejects queries under 3 characters (`invalid q`) — + /// callers guard the minimum length. + Future> searchAnime(String query, {int page = 1, int limit = 30}) => + _getAnimePage('/anime', {'q': query}, page: page, limit: limit); + + /// Community "users also liked" titles from the anime detail's + /// `recommendations` field, with the catalog fields selected on the nested + /// nodes (`fields=recommendations{...}` — braces percent-encoded, MAL + /// accepts the nested selector). + Future> getAnimeRecommendations(int animeId, {int limit = 20}) async { + final res = await _request('GET', '/anime/$animeId?fields=recommendations%7B$catalogFields%7D'); + if (res is! Map) return const []; + final recommendations = res['recommendations']; + if (recommendations is! List) return const []; + return [ + for (final entry in recommendations.take(limit)) + if (entry is Map && entry['node'] is Map) + MalAnime.fromJson(entry['node'] as Map), + ]; + } + + Future> _getAnimePage( + String path, + Map params, { + required int page, + required int limit, + }) async { + final query = Uri( + queryParameters: { + ...params, + 'limit': '$limit', + if (page > 1) 'offset': '${(page - 1) * limit}', + 'fields': catalogFields, + }, + ).query; + final res = await _request('GET', '$path?$query'); + return MalPage.fromJson(res, MalAnime.fromJson); + } + Future getAnimeEpisodeCount(int animeId) async { final res = await _request('GET', '/anime/$animeId?fields=num_episodes'); if (res is! Map) return null; diff --git a/lib/services/trackers/mal/mal_constants.dart b/lib/services/trackers/mal/mal_constants.dart index f1f7c516..7aa0eb1e 100644 --- a/lib/services/trackers/mal/mal_constants.dart +++ b/lib/services/trackers/mal/mal_constants.dart @@ -1,3 +1,11 @@ +/// MAL `/anime/ranking` types used by the Explore catalog rows. +enum MalRankingType { + airing, + bypopularity; + + String get queryValue => name; +} + /// Bundled MyAnimeList API endpoints and public client ID. /// /// The authorize flow lives in the Plezy relay's OAuth proxy; see diff --git a/lib/services/trackers/mal/mal_page.dart b/lib/services/trackers/mal/mal_page.dart new file mode 100644 index 00000000..8e5b5dc8 --- /dev/null +++ b/lib/services/trackers/mal/mal_page.dart @@ -0,0 +1,41 @@ +/// One page of a MAL API v2 list response (`{"data": [{"node": ...}], "paging": {...}}`). +/// +/// MAL pages by offset; `paging.next` is present exactly when more items +/// exist, so callers never need to track counts. +class MalPage { + final List items; + final bool hasMore; + + const MalPage({required this.items, this.hasMore = false}); + + static MalPage fromJson(Object? json, T Function(Map node) fromNode) { + if (json is! Map) return MalPage(items: List.empty()); + final data = json['data']; + final items = [ + if (data is List) + for (final entry in data) + if (entry is Map && entry['node'] is Map) fromNode((entry['node'] as Map).cast()), + ]; + return MalPage(items: items, hasMore: _hasNext(json)); + } + + /// Like [fromJson] but hands the parser the whole `data[]` entry, for + /// endpoints whose interesting fields sit beside the node (characters' + /// `role`). + static MalPage fromJsonEntries(Object? json, T Function(Map entry) fromEntry) { + if (json is! Map) return MalPage(items: List.empty()); + final data = json['data']; + final items = [ + if (data is List) + for (final entry in data) + if (entry is Map) fromEntry(entry.cast()), + ]; + return MalPage(items: items, hasMore: _hasNext(json)); + } + + static bool _hasNext(Map json) { + final paging = json['paging']; + final next = paging is Map ? paging['next'] : null; + return next is String && next.isNotEmpty; + } +} diff --git a/test/services/catalog/mal_catalog_source_test.dart b/test/services/catalog/mal_catalog_source_test.dart new file mode 100644 index 00000000..1d0f898c --- /dev/null +++ b/test/services/catalog/mal_catalog_source_test.dart @@ -0,0 +1,375 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/models/catalog/catalog_item.dart'; +import 'package:plezy/models/trackers/fribb_mapping_row.dart'; +import 'package:plezy/services/catalog/catalog_source.dart'; +import 'package:plezy/services/catalog/mal_catalog_source.dart'; +import 'package:plezy/services/trackers/fribb_mapping_store.dart'; +import 'package:plezy/services/trackers/mal/mal_client.dart'; +import 'package:plezy/services/trackers/tracker_session.dart'; +import 'package:plezy/utils/external_ids.dart'; + +TrackerSession _session() { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return TrackerSession( + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: now + 86400, + scope: null, + createdAt: now - 3600, + username: 'alice', + ); +} + +class _FakeFribb implements FribbMappingLookup { + final List rows; + + _FakeFribb(this.rows); + + @override + Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async => [ + for (final row in rows) + if ((tvdbId != null && row.tvdbId == tvdbId) || + (tmdbId != null && (row.tmdbIds?.contains(tmdbId) ?? false)) || + (imdbId != null && (row.imdbIds?.contains(imdbId) ?? false))) + row, + ]; + + @override + Future lookupByMal(int malId) async => rows.where((row) => row.malId == malId).firstOrNull; +} + +Map _node({ + required int id, + required String title, + String? en, + String mediaType = 'tv', + String status = 'finished_airing', +}) => { + 'id': id, + 'title': title, + if (en != null) 'alternative_titles': {'en': en}, + 'media_type': mediaType, + 'main_picture': {'large': 'https://cdn.myanimelist.net/images/anime/$id.jpg'}, + 'status': status, + 'num_episodes': 25, + 'num_scoring_users': 2326268, + 'studios': [ + {'id': 858, 'name': 'Wit Studio'}, + ], +}; + +Map _pageBody(List> nodes, {bool hasMore = false}) => { + 'data': [ + for (final node in nodes) {'node': node}, + ], + 'paging': {if (hasMore) 'next': 'https://api.myanimelist.net/v2/whatever?offset=2'}, +}; + +void main() { + // Attack on Titan: split-cour show — one Fribb row per season, same tvdb id. + const aotSeason1 = FribbMappingRow(malId: 16498, tvdbId: 267440, tvdbSeason: 1, imdbIds: ['tt2560140']); + const aotSeason3 = FribbMappingRow(malId: 35760, tvdbId: 267440, tvdbSeason: 3, imdbIds: ['tt2560140']); + // An anime movie. + const yourName = FribbMappingRow(malId: 32281, tmdbIds: [372058], imdbIds: ['tt5311514'], type: 'MOVIE'); + + group('MalCatalogSource', () { + late List requests; + late List handlers; + late MalClient client; + late MalCatalogSource source; + + setUp(() { + requests = []; + handlers = []; + client = MalClient( + _session(), + onSessionInvalidated: () => fail('should not invalidate'), + httpClient: MockClient((request) async { + requests.add(request); + if (handlers.isNotEmpty) return handlers.removeAt(0)(request); + return http.Response( + json.encode( + _pageBody([ + _node(id: 16498, title: 'Shingeki no Kyojin', en: 'Attack on Titan'), + _node(id: 32281, title: 'Kimi no Na wa.', en: 'Your Name.', mediaType: 'movie'), + ]), + ), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ); + source = MalCatalogSource(client, fribb: _FakeFribb(const [aotSeason1, aotSeason3, yourName])); + }); + + tearDown(() { + source.dispose(); + client.dispose(); + }); + + test('fetchRow(watchlist) requests Plan to Watch and enriches ids via Fribb', () async { + final page = await source.fetchRow(CatalogRowId.watchlist); + + final request = requests.single; + expect(request.url.path, '/v2/users/@me/animelist'); + expect(request.url.queryParameters['status'], 'plan_to_watch'); + expect(request.url.queryParameters['fields'], contains('alternative_titles')); + + expect(page.items, hasLength(2)); + final show = page.items[0]; + expect(show.kind, MediaKind.show); + expect(show.title, 'Attack on Titan'); + expect(show.ids.mal, 16498); + expect(show.ids.tvdb, 267440); + expect(show.ids.imdb, 'tt2560140'); + expect(show.source, CatalogSourceId.mal); + + // List-endpoint metadata flows through to the item. + expect(show.airStatus, CatalogAirStatus.ended); + expect(show.episodeCount, 25); + expect(show.votes, 2326268); + expect(show.network, 'Wit Studio'); + + final movie = page.items[1]; + expect(movie.kind, MediaKind.movie); + expect(movie.ids.mal, 32281); + expect(movie.ids.tmdb, 372058); + expect(movie.posterUrl, 'https://cdn.myanimelist.net/images/anime/32281.jpg'); + // finished_airing on a movie is noise, and movies have no episode chip. + expect(movie.airStatus, isNull); + expect(movie.episodeCount, isNull); + }); + + test('fetchCast maps MAL characters with joined names and roles', () async { + handlers.add( + (request) => http.Response( + json.encode({ + 'data': [ + { + 'node': { + 'id': 11, + 'first_name': 'Edward', + 'last_name': 'Elric', + 'main_picture': {'medium': 'https://cdn.myanimelist.net/images/characters/9/72533.jpg'}, + }, + 'role': 'Main', + }, + { + 'node': {'id': 63, 'first_name': '', 'last_name': 'Winry'}, + 'role': 'Supporting', + }, + { + 'node': {'id': 99}, // nameless — skipped + 'role': 'Supporting', + }, + ], + 'paging': {}, + }), + 200, + ), + ); + + final cast = await source.fetchCast( + const CatalogItem( + source: CatalogSourceId.mal, + kind: MediaKind.show, + title: 'Fullmetal Alchemist: Brotherhood', + ids: CatalogItemIds(mal: 5114), + ), + ); + + final request = requests.single; + expect(request.url.path, '/v2/anime/5114/characters'); + expect(request.url.queryParameters['fields'], contains('first_name')); + expect(cast, hasLength(2)); + expect(cast[0].name, 'Edward Elric'); + expect(cast[0].secondary, 'Main'); + expect(cast[0].imageUrl, 'https://cdn.myanimelist.net/images/characters/9/72533.jpg'); + expect(cast[1].name, 'Winry'); + }); + + test('fetchRow(airingAnime) hits the ranking endpoint and pages by offset', () async { + handlers.add((request) => http.Response(json.encode(_pageBody([], hasMore: true)), 200)); + final page = await source.fetchRow(CatalogRowId.airingAnime, page: 3, limit: 50); + + final request = requests.single; + expect(request.url.path, '/v2/anime/ranking'); + expect(request.url.queryParameters['ranking_type'], 'airing'); + expect(request.url.queryParameters['limit'], '50'); + expect(request.url.queryParameters['offset'], '100'); + expect(page.hasMore, isTrue); + }); + + test('fetchRow throws on rows MAL does not serve', () { + expect(() => source.fetchRow(CatalogRowId.trendingMovies), throwsArgumentError); + }); + + test('membership is keyed by MAL id, ignoring kind', () async { + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(mal: 16498)), isNull); + + var notified = 0; + source.watchlistChanges.addListener(() => notified++); + await source.ensureWatchlistLoaded(); + + expect(notified, 1); + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(mal: 16498)), isTrue); + // A library item stored under the other kind still matches its entry. + expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(mal: 16498)), isTrue); + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(mal: 99999)), isFalse); + // External-only ids can't check membership without a resolved MAL id. + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(tvdb: 267440)), isFalse); + }); + + test('resolveItemIds prefers the season-1 row for shows and MOVIE rows for movies', () async { + final show = await source.resolveItemIds(MediaKind.show, const ExternalIds(tvdb: 267440)); + expect(show?.mal, 16498); + expect(show?.tvdb, 267440); + + final movie = await source.resolveItemIds(MediaKind.movie, const ExternalIds(tmdb: 372058)); + expect(movie?.mal, 32281); + + // Non-anime items resolve to null, hiding the watchlist action. + expect(await source.resolveItemIds(MediaKind.movie, const ExternalIds(tmdb: 603)), isNull); + expect(await source.resolveItemIds(MediaKind.show, const ExternalIds()), isNull); + }); + + test('addToWatchlist PUTs plan_to_watch optimistically', () async { + await source.ensureWatchlistLoaded(); + requests.clear(); + + handlers.add((request) => http.Response('{"status":"plan_to_watch"}', 200)); + await source.addToWatchlist(MediaKind.show, const CatalogItemIds(mal: 40028)); + + final request = requests.single; + expect(request.method, 'PUT'); + expect(request.url.path, '/v2/anime/40028/my_list_status'); + expect(request.bodyFields, {'status': 'plan_to_watch'}); + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(mal: 40028)), isTrue); + }); + + test('addToWatchlist resolves a MAL id from external ids when missing', () async { + await source.ensureWatchlistLoaded(); + requests.clear(); + + handlers.add((request) => http.Response('{"status":"plan_to_watch"}', 200)); + await source.addToWatchlist(MediaKind.show, const CatalogItemIds(tvdb: 267440)); + + expect(requests.single.url.path, '/v2/anime/16498/my_list_status'); + }); + + test('mutating an unmappable item throws without a request', () async { + await expectLater(source.addToWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), throwsStateError); + expect(requests, isEmpty); + }); + + test('removeFromWatchlist DELETEs and treats 404 as success', () async { + await source.ensureWatchlistLoaded(); + requests.clear(); + + handlers.add((request) => http.Response('', 404)); + await source.removeFromWatchlist(MediaKind.show, const CatalogItemIds(mal: 16498)); + + expect(requests.single.method, 'DELETE'); + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(mal: 16498)), isFalse); + }); + + test('failed mutation reverts the optimistic snapshot flip', () async { + await source.ensureWatchlistLoaded(); + + var notified = 0; + source.watchlistChanges.addListener(() => notified++); + handlers.add((request) => http.Response('oops', 500)); + + await expectLater( + source.removeFromWatchlist(MediaKind.show, const CatalogItemIds(mal: 16498)), + throwsA(anything), + ); + + expect(notified, 2); // optimistic flip + revert + expect(source.isOnWatchlist(MediaKind.show, const CatalogItemIds(mal: 16498)), isTrue); + }); + + test('search queries /anime and enriches via Fribb like rows', () async { + handlers.add((request) { + expect(request.url.path, '/v2/anime'); + expect(request.url.queryParameters['q'], 'attack on titan'); + return http.Response(json.encode(_pageBody([_node(id: 16498, title: 'Shingeki no Kyojin')])), 200); + }); + + final items = await source.search('attack on titan'); + expect(items, hasLength(1)); + expect(items.single.ids.mal, 16498); + expect(items.single.ids.tvdb, 267440); + }); + + test('search under three characters returns empty without a request', () async { + expect(await source.search('86'), isEmpty); + expect(requests, isEmpty); + }); + + test('fetchRelated reads the nested recommendations field and enriches via Fribb', () async { + handlers.add((request) { + expect(request.url.path, '/v2/anime/16498'); + expect(request.url.queryParameters['fields'], startsWith('recommendations{')); + return http.Response( + json.encode({ + 'id': 16498, + 'recommendations': [ + { + 'node': _node(id: 32281, title: 'Kimi no Na wa.', en: 'Your Name.', mediaType: 'movie'), + 'num_recommendations': 42, + }, + ], + }), + 200, + ); + }); + + final item = CatalogItem( + source: CatalogSourceId.mal, + kind: MediaKind.show, + title: 'Attack on Titan', + ids: const CatalogItemIds(mal: 16498), + ); + final related = await source.fetchRelated(item); + expect(related.single.title, 'Your Name.'); + expect(related.single.kind, MediaKind.movie); + expect(related.single.ids.tmdb, 372058); + }); + + test('fetchRelated without a mal id returns empty without a request', () async { + final item = CatalogItem( + source: CatalogSourceId.mal, + kind: MediaKind.show, + title: 'Unknown', + ids: const CatalogItemIds(tmdb: 1), + ); + expect(await source.fetchRelated(item), isEmpty); + expect(requests, isEmpty); + }); + }); + + group('parseFribbIndex byMal', () { + test('indexes rows by mal_id for reverse lookup', () { + final index = parseFribbIndex( + json.encode([ + {'mal_id': 16498, 'tvdb_id': 267440}, + { + 'mal_id': 32281, + 'imdb_id': ['tt5311514'], + }, + {'anidb_id': 1}, // no mal id — must not appear + ]), + ); + + expect(index.byMal[16498]?.tvdbId, 267440); + expect(index.byMal[32281]?.imdbIds, ['tt5311514']); + expect(index.byMal, hasLength(2)); + }); + }); +} diff --git a/test/services/trackers/mal_catalog_parsing_test.dart b/test/services/trackers/mal_catalog_parsing_test.dart new file mode 100644 index 00000000..b6d21abb --- /dev/null +++ b/test/services/trackers/mal_catalog_parsing_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/models/mal/mal_anime.dart'; +import 'package:plezy/services/trackers/mal/mal_page.dart'; + +void main() { + group('MalPage.fromJson', () { + test('extracts nodes and reads hasMore from paging.next', () { + final page = MalPage.fromJson({ + 'data': [ + { + 'node': {'id': 1, 'title': 'Cowboy Bebop'}, + }, + { + 'node': {'id': 5, 'title': 'Cowboy Bebop: Tengoku no Tobira'}, + 'ranking': {'rank': 2}, + }, + ], + 'paging': {'next': 'https://api.myanimelist.net/v2/anime/ranking?offset=2'}, + }, MalAnime.fromJson); + + expect(page.items, hasLength(2)); + expect(page.items.first.id, 1); + expect(page.hasMore, isTrue); + }); + + test('tolerates malformed entries and missing paging', () { + final page = MalPage.fromJson({ + 'data': [ + 'garbage', + {'no_node': true}, + { + 'node': {'id': 30, 'title': 'Neon Genesis Evangelion'}, + }, + ], + }, MalAnime.fromJson); + + expect(page.items.single.id, 30); + expect(page.hasMore, isFalse); + + expect(MalPage.fromJson(null, MalAnime.fromJson).items, isEmpty); + expect(MalPage.fromJson([], MalAnime.fromJson).items, isEmpty); + }); + }); + + group('MalAnime', () { + test('parses the catalog fields and derives display values', () { + final anime = MalAnime.fromJson({ + 'id': 5114, + 'title': 'Hagane no Renkinjutsushi: Fullmetal Alchemist', + 'main_picture': {'medium': 'https://cdn.myanimelist.net/images/anime/1223/96541.jpg'}, + 'alternative_titles': { + 'en': 'Fullmetal Alchemist: Brotherhood', + 'ja': '鋼の錬金術師', + 'synonyms': ['FMA'], + }, + 'start_date': '2009-04-05', + 'synopsis': 'After a horrific alchemy experiment...', + 'mean': 9.1, + 'genres': [ + {'id': 1, 'name': 'Action'}, + {'id': 10, 'name': 'Fantasy'}, + ], + 'media_type': 'tv', + 'rating': 'r', + 'num_episodes': 64, + 'average_episode_duration': 1460, + 'start_season': {'year': 2009, 'season': 'spring'}, + 'status': 'finished_airing', + 'num_scoring_users': 2326268, + 'studios': [ + {'id': 4, 'name': 'Bones'}, + ], + }); + + expect(anime.displayTitle, 'Fullmetal Alchemist: Brotherhood'); + expect(anime.isMovie, isFalse); + expect(anime.year, 2009); + expect(anime.runtimeMinutes, 24); + expect(anime.certification, 'R'); + expect(anime.genreNames, ['Action', 'Fantasy']); + expect(anime.mainPicture?.primary, 'https://cdn.myanimelist.net/images/anime/1223/96541.jpg'); + expect(anime.mean, 9.1); + expect(anime.status, 'finished_airing'); + expect(anime.numScoringUsers, 2326268); + expect(anime.primaryStudio, 'Bones'); + }); + + test('falls back to the romaji title and the start_date year', () { + final anime = MalAnime.fromJson({ + 'id': 1, + 'title': 'Cowboy Bebop', + 'alternative_titles': {'en': ''}, + 'start_date': '1998-04-03', + 'media_type': 'movie', + 'rating': 'pg_13', + }); + + expect(anime.displayTitle, 'Cowboy Bebop'); + expect(anime.isMovie, isTrue); + expect(anime.year, 1998); + expect(anime.certification, 'PG-13'); + expect(anime.runtimeMinutes, isNull); + expect(anime.genreNames, isNull); + }); + }); +} diff --git a/test/services/trackers/tracker_coordinator_manual_test.dart b/test/services/trackers/tracker_coordinator_manual_test.dart index 81e0e229..efd3c608 100644 --- a/test/services/trackers/tracker_coordinator_manual_test.dart +++ b/test/services/trackers/tracker_coordinator_manual_test.dart @@ -66,6 +66,9 @@ class _FakeFribbLookup implements FribbMappingLookup { @override Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async => rows; + + @override + Future lookupByMal(int malId) async => rows.where((row) => row.malId == malId).firstOrNull; } class _FakeAnimeListsLookup implements AnimeListsMappingLookup { diff --git a/test/services/trackers/tracker_id_resolver_test.dart b/test/services/trackers/tracker_id_resolver_test.dart index fc6a6c6a..2ea7a12a 100644 --- a/test/services/trackers/tracker_id_resolver_test.dart +++ b/test/services/trackers/tracker_id_resolver_test.dart @@ -38,6 +38,9 @@ class _FakeFribbLookup implements FribbMappingLookup { lookups++; return rows; } + + @override + Future lookupByMal(int malId) async => rows.where((row) => row.malId == malId).firstOrNull; } class _FakeAnimeProgressLookup implements AnimeEpisodeProgressLookup {