feat(explore): MyAnimeList catalog source

This commit is contained in:
edde746
2026-07-10 07:07:54 +02:00
parent dcdbbc8483
commit 2aa084287d
13 changed files with 1129 additions and 3 deletions
+175
View File
@@ -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<String, dynamic> json) => _$MalPictureFromJson(json);
}
@JsonSerializable(createToJson: false)
class MalAlternativeTitles {
final String? en;
final String? ja;
final List<String>? synonyms;
const MalAlternativeTitles({this.en, this.ja, this.synonyms});
factory MalAlternativeTitles.fromJson(Map<String, dynamic> json) => _$MalAlternativeTitlesFromJson(json);
}
@JsonSerializable(createToJson: false)
class MalGenre {
final String? name;
const MalGenre({this.name});
factory MalGenre.fromJson(Map<String, dynamic> json) => _$MalGenreFromJson(json);
}
@JsonSerializable(createToJson: false)
class MalStudio {
final String? name;
const MalStudio({this.name});
factory MalStudio.fromJson(Map<String, dynamic> json) => _$MalStudioFromJson(json);
}
@JsonSerializable(createToJson: false)
class MalStartSeason {
@JsonKey(fromJson: flexibleInt)
final int? year;
const MalStartSeason({this.year});
factory MalStartSeason.fromJson(Map<String, dynamic> 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<String, String> _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, 010.
final double? mean;
final List<MalGenre>? 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<MalStudio>? 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<String>? get genreNames {
final names = [for (final genre in genres ?? const <MalGenre>[]) ?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<String, dynamic> json) => _$MalAnimeFromJson(json);
}
+62
View File
@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'mal_anime.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MalPicture _$MalPictureFromJson(Map<String, dynamic> json) => MalPicture(
medium: json['medium'] as String?,
large: json['large'] as String?,
);
MalAlternativeTitles _$MalAlternativeTitlesFromJson(
Map<String, dynamic> json,
) => MalAlternativeTitles(
en: json['en'] as String?,
ja: json['ja'] as String?,
synonyms: (json['synonyms'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
);
MalGenre _$MalGenreFromJson(Map<String, dynamic> json) =>
MalGenre(name: json['name'] as String?);
MalStudio _$MalStudioFromJson(Map<String, dynamic> json) =>
MalStudio(name: json['name'] as String?);
MalStartSeason _$MalStartSeasonFromJson(Map<String, dynamic> json) =>
MalStartSeason(year: flexibleInt(json['year']));
MalAnime _$MalAnimeFromJson(Map<String, dynamic> json) => MalAnime(
id: flexibleInt(json['id']),
title: json['title'] as String?,
mainPicture: json['main_picture'] == null
? null
: MalPicture.fromJson(json['main_picture'] as Map<String, dynamic>),
alternativeTitles: json['alternative_titles'] == null
? null
: MalAlternativeTitles.fromJson(
json['alternative_titles'] as Map<String, dynamic>,
),
startDate: json['start_date'] as String?,
synopsis: json['synopsis'] as String?,
mean: (json['mean'] as num?)?.toDouble(),
genres: (json['genres'] as List<dynamic>?)
?.map((e) => MalGenre.fromJson(e as Map<String, dynamic>))
.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<String, dynamic>),
status: json['status'] as String?,
studios: (json['studios'] as List<dynamic>?)
?.map((e) => MalStudio.fromJson(e as Map<String, dynamic>))
.toList(),
numScoringUsers: flexibleInt(json['num_scoring_users']),
);
+30
View File
@@ -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<String, dynamic> entry) {
final node = entry['node'];
final map = node is Map ? node.cast<String, dynamic>() : const <String, dynamic>{};
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,
);
}
}
+8
View File
@@ -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;
@@ -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<CatalogRowId> get supportedRows => const [
CatalogRowId.watchlist,
CatalogRowId.suggestedAnime,
CatalogRowId.airingAnime,
CatalogRowId.popularAnime,
];
@override
bool get supportsWatchlist => true;
@override
Future<CatalogPage> 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<List<CatalogItem>> 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<List<CatalogItem>> 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<List<CatalogItem>> _toCatalogItems(List<MalAnime> 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<List<CatalogCastMember>> 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<CatalogItemIds?> 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<FribbMappingRow> 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<String> membershipKeysFor(MediaKind kind, CatalogItemIds ids) => [
if (ids.mal case final int malId) _membershipKey(malId),
];
@override
Future<WatchlistKeyPage> 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<CatalogItemIds> 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<void> 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<void> _deleteEntry(int malId) async {
try {
await _client.deleteMyListStatus(malId);
} on TrackerApiException catch (e) {
if (e.statusCode == 404) return;
rethrow;
}
}
@override
void dispose() {
disposeWatchlistMachinery();
}
}
+15 -3
View File
@@ -24,13 +24,19 @@ class FribbIndex {
final Map<int, List<FribbMappingRow>> byTmdb;
final Map<String, List<FribbMappingRow>> 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<int, FribbMappingRow> 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<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId});
Future<FribbMappingRow?> 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<FribbMappingRow?> 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 = <int, List<FribbMappingRow>>{};
final byTmdb = <int, List<FribbMappingRow>>{};
final byImdb = <String, List<FribbMappingRow>>{};
final byMal = <int, FribbMappingRow>{};
var skipped = 0;
for (final raw in decoded) {
@@ -271,8 +281,10 @@ FribbIndex parseFribbIndex(String raw) {
if (imdb.isEmpty) continue;
(byImdb[imdb] ??= <FribbMappingRow>[]).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);
}
+72
View File
@@ -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<MalPage<MalCharacter>> 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<MalPage<MalAnime>> 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<MalPage<MalAnime>> getSuggestedAnime({int page = 1, int limit = 100}) =>
_getAnimePage('/anime/suggestions', const {}, page: page, limit: limit);
Future<MalPage<MalAnime>> 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<MalPage<MalAnime>> 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<List<MalAnime>> 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<String, dynamic> && entry['node'] is Map<String, dynamic>)
MalAnime.fromJson(entry['node'] as Map<String, dynamic>),
];
}
Future<MalPage<MalAnime>> _getAnimePage(
String path,
Map<String, String> 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<int?> getAnimeEpisodeCount(int animeId) async {
final res = await _request('GET', '/anime/$animeId?fields=num_episodes');
if (res is! Map) return null;
@@ -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
+41
View File
@@ -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<T> {
final List<T> items;
final bool hasMore;
const MalPage({required this.items, this.hasMore = false});
static MalPage<T> fromJson<T>(Object? json, T Function(Map<String, dynamic> node) fromNode) {
if (json is! Map) return MalPage(items: List<T>.empty());
final data = json['data'];
final items = <T>[
if (data is List)
for (final entry in data)
if (entry is Map && entry['node'] is Map) fromNode((entry['node'] as Map).cast<String, dynamic>()),
];
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<T> fromJsonEntries<T>(Object? json, T Function(Map<String, dynamic> entry) fromEntry) {
if (json is! Map) return MalPage(items: List<T>.empty());
final data = json['data'];
final items = <T>[
if (data is List)
for (final entry in data)
if (entry is Map) fromEntry(entry.cast<String, dynamic>()),
];
return MalPage(items: items, hasMore: _hasNext(json));
}
static bool _hasNext(Map<dynamic, dynamic> json) {
final paging = json['paging'];
final next = paging is Map ? paging['next'] : null;
return next is String && next.isNotEmpty;
}
}
@@ -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<FribbMappingRow> rows;
_FakeFribb(this.rows);
@override
Future<List<FribbMappingRow>> 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<FribbMappingRow?> lookupByMal(int malId) async => rows.where((row) => row.malId == malId).firstOrNull;
}
Map<String, dynamic> _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<String, dynamic> _pageBody(List<Map<String, dynamic>> 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<http.Request> requests;
late List<http.Response Function(http.Request)> 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': <String, dynamic>{},
}),
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));
});
});
}
@@ -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);
});
});
}
@@ -66,6 +66,9 @@ class _FakeFribbLookup implements FribbMappingLookup {
@override
Future<List<FribbMappingRow>> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async => rows;
@override
Future<FribbMappingRow?> lookupByMal(int malId) async => rows.where((row) => row.malId == malId).firstOrNull;
}
class _FakeAnimeListsLookup implements AnimeListsMappingLookup {
@@ -38,6 +38,9 @@ class _FakeFribbLookup implements FribbMappingLookup {
lookups++;
return rows;
}
@override
Future<FribbMappingRow?> lookupByMal(int malId) async => rows.where((row) => row.malId == malId).firstOrNull;
}
class _FakeAnimeProgressLookup implements AnimeEpisodeProgressLookup {