feat(explore): MyAnimeList catalog source
This commit is contained in:
@@ -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, 0–10.
|
||||
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);
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user