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
+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;
}
}