feat(explore): MyAnimeList catalog source
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user