import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import 'package:http_interceptor/http_interceptor.dart'; import 'package:intl/intl.dart'; import 'package:mangayomi/models/track.dart'; import 'package:mangayomi/models/track_preference.dart'; import 'package:mangayomi/models/track_search.dart'; import 'package:mangayomi/modules/more/settings/track/myanimelist/model.dart'; import 'package:mangayomi/modules/more/settings/track/providers/track_providers.dart'; import 'package:mangayomi/services/http/m_client.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'myanimelist.g.dart'; @riverpod class MyAnimeList extends _$MyAnimeList { final http = MClient.init(reqcopyWith: {'useDartHttpClient': true}); String baseOAuthUrl = 'https://myanimelist.net/v1/oauth2'; String baseApiUrl = 'https://api.myanimelist.net/v2'; String codeVerifier = ""; String clientId = (Platform.isWindows || Platform.isLinux) ? '39e9be346b4e7dbcc59a98357e2f8472' : '0c9100ccd443ddb441a319a881180f7f'; @override void build({required int syncId, required bool? isManga}) {} Future login() async { final callbackUrlScheme = (Platform.isWindows || Platform.isLinux) ? 'http://localhost:43824' : 'mangayomi'; final loginUrl = _authUrl(); try { final uri = await FlutterWebAuth2.authenticate(url: loginUrl, callbackUrlScheme: callbackUrlScheme); final queryParams = Uri.parse(uri).queryParameters; if (queryParams['code'] == null) return null; final oAuth = await _getOAuth(queryParams['code']!); final mALOAuth = OAuth.fromJson(oAuth as Map); final username = await _getUserName(mALOAuth.accessToken!); ref .read(tracksProvider(syncId: syncId).notifier) .login(TrackPreference(syncId: syncId, username: username, oAuth: jsonEncode(mALOAuth.toJson()))); return true; } catch (_) { return false; } } Future _getAccesToken() async { final track = ref.watch(tracksProvider(syncId: syncId)); final mALOAuth = OAuth.fromJson(jsonDecode(track!.oAuth!) as Map); final expiresIn = DateTime.fromMillisecondsSinceEpoch(mALOAuth.expiresIn!); if (DateTime.now().isAfter(expiresIn)) { final params = { 'client_id': clientId, 'grant_type': 'refresh_token', 'refresh_token': mALOAuth.refreshToken, }; final response = await http.post(Uri.parse('$baseOAuthUrl/token'), body: params); final oAuth = OAuth.fromJson(jsonDecode(response.body) as Map); final username = await _getUserName(oAuth.accessToken!); ref .read(tracksProvider(syncId: syncId).notifier) .login(TrackPreference(syncId: syncId, username: username, prefs: "", oAuth: jsonEncode(oAuth.toJson()))); return oAuth.accessToken!; } return mALOAuth.accessToken!; } Future> search(String query) async { final accessToken = await _getAccesToken(); final url = Uri.parse(isManga! ? '$baseApiUrl/manga' : '$baseApiUrl/anime').replace(queryParameters: { 'q': query.trim(), 'nsfw': 'true', }); final result = await http.get(url, headers: {'Authorization': 'Bearer $accessToken'}); final res = jsonDecode(result.body) as Map; List mangaIds = res['data'] == null ? [] : (res['data'] as List).map((e) => e['node']["id"] as int).toList(); List trackSearchResult = []; for (var mangaId in mangaIds) { final trackSearch = isManga! ? await getMangaDetails(mangaId, accessToken) : await getAnimeDetails(mangaId, accessToken); trackSearchResult.add(trackSearch); } return trackSearchResult.where((element) => !element.publishingType!.contains("novel")).toList(); } Future getMangaDetails(int id, String accessToken) async { final url = Uri.parse('$baseApiUrl/manga/$id').replace(queryParameters: { 'fields': 'id,title,synopsis,num_chapters,main_picture,status,media_type,start_date', }); final result = await http.get(url, headers: {'Authorization': 'Bearer $accessToken'}); final res = jsonDecode(result.body) as Map; return TrackSearch( mediaId: res["id"], summary: res["synopsis"] ?? "", totalChapter: res["num_chapters"], coverUrl: res["main_picture"]["large"] ?? "", title: res["title"], startDate: res["start_date"] ?? "", publishingType: res["media_type"].toString().replaceAll("_", " "), publishingStatus: res["status"].toString().replaceAll("_", " "), trackingUrl: "https://myanimelist.net/manga/${res["id"]}"); } Future getAnimeDetails(int id, String accessToken) async { final url = Uri.parse('$baseApiUrl/anime/$id').replace(queryParameters: { 'fields': 'id,title,synopsis,num_episodes,main_picture,status,media_type,start_date', }); final result = await http.get(url, headers: {'Authorization': 'Bearer $accessToken'}); final res = jsonDecode(result.body) as Map; return TrackSearch( mediaId: res["id"], summary: res["synopsis"] ?? "", totalChapter: res["num_episodes"], coverUrl: res["main_picture"]["large"] ?? "", title: res["title"], startDate: res["start_date"] ?? "", publishingType: res["media_type"].toString().replaceAll("_", " "), publishingStatus: res["status"].toString().replaceAll("_", " "), trackingUrl: "https://myanimelist.net/anime/${res["id"]}"); } String _convertToIsoDate(int? epochTime) { String date = ""; try { date = DateFormat("yyyy-MM-dd", "en_US").format(DateTime.fromMillisecondsSinceEpoch(epochTime!)); } catch (_) {} return date; } String _codeVerifier() { final random = Random.secure(); final values = List.generate(200, (i) => random.nextInt(256)); codeVerifier = base64UrlEncode(values).substring(0, 128); return codeVerifier; } String _authUrl() { _codeVerifier(); return '$baseOAuthUrl/authorize?client_id=$clientId&code_challenge=$codeVerifier&response_type=code'; } TrackStatus _getMALTrackStatusManga(String status) { return switch (status) { "reading" => TrackStatus.reading, "completed" => TrackStatus.completed, "on_hold" => TrackStatus.onHold, "dropped" => TrackStatus.dropped, "plan_to_read" => TrackStatus.planToRead, _ => TrackStatus.rereading, }; } TrackStatus _getMALTrackStatusAnime(String status) { return switch (status) { "watching" => TrackStatus.watching, "completed" => TrackStatus.completed, "on_hold" => TrackStatus.onHold, "dropped" => TrackStatus.dropped, _ => TrackStatus.planToWatch, }; } List myAnimeListStatusListManga = [ TrackStatus.reading, TrackStatus.completed, TrackStatus.onHold, TrackStatus.dropped, TrackStatus.planToRead, TrackStatus.rereading ]; List myAnimeListStatusListAnime = [ TrackStatus.watching, TrackStatus.completed, TrackStatus.onHold, TrackStatus.dropped, TrackStatus.planToWatch ]; String? toMyAnimeListStatusManga(TrackStatus status) { return switch (status) { TrackStatus.reading => "reading", TrackStatus.completed => "completed", TrackStatus.onHold => "on_hold", TrackStatus.dropped => "dropped", TrackStatus.planToRead => "plan_to_read", _ => "reading", }; } String? toMyAnimeListStatusAnime(TrackStatus status) { return switch (status) { TrackStatus.watching => "watching", TrackStatus.completed => "completed", TrackStatus.onHold => "on_hold", TrackStatus.dropped => "dropped", _ => "plan_to_watch", }; } Future _getOAuth(String code) async { final params = { 'client_id': clientId, 'code': code, 'code_verifier': codeVerifier, 'grant_type': 'authorization_code', }; final response = await http.post(Uri.parse('$baseOAuthUrl/token'), body: params); return jsonDecode(response.body); } Future _getUserName(String accessToken) async { final response = await http.get(Uri.parse('$baseApiUrl/users/@me'), headers: {'Authorization': 'Bearer $accessToken'}); return jsonDecode(response.body)['name']; } Future findManga(Track track) async { final accessToken = await _getAccesToken(); final uri = Uri.parse(isManga! ? '$baseApiUrl/manga/${track.mediaId}' : '$baseApiUrl/anime/${track.mediaId}') .replace(queryParameters: { 'fields': isManga! ? 'num_chapters,my_list_status{start_date,finish_date}' : 'num_episodes,my_list_status{start_date,finish_date}', }); final response = await http.get(uri, headers: {'Authorization': 'Bearer $accessToken'}); final mJson = jsonDecode(response.body); track.totalChapter = isManga! ? mJson['num_chapters'] ?? 0 : mJson['num_episodes'] ?? 0; if (mJson['my_list_status'] != null) { track = isManga! ? _parseMangaItem(mJson["my_list_status"], track) : _parseAnimeItem(mJson["my_list_status"], track); } else { track = isManga! ? await updateManga(track) : await updateAnime(track); } return track; } Track _parseMangaItem(Map mJson, Track track) { bool isRereading = mJson["is_rereading"] ?? false; track.status = isRereading ? TrackStatus.rereading : _getMALTrackStatusManga(mJson["status"]); track.lastChapterRead = int.parse(mJson["num_chapters_read"].toString()); track.score = int.parse(mJson["score"].toString()); track.startedReadingDate = _parseDate(mJson["start_date"]); track.finishedReadingDate = _parseDate(mJson["finish_date"]); return track; } Track _parseAnimeItem(Map mJson, Track track) { bool isReWatching = mJson["is_rewatching"] ?? false; track.status = isReWatching ? TrackStatus.reWatching : _getMALTrackStatusAnime(mJson["status"]); track.lastChapterRead = int.parse(mJson["num_episodes_watched"].toString()); track.score = int.parse(mJson["score"].toString()); track.startedReadingDate = _parseDate(mJson["start_date"]); track.finishedReadingDate = _parseDate(mJson["finish_date"]); return track; } int? _parseDate(String? isoDate) { if (isoDate == null) return null; final date = DateFormat('yyyy-MM-dd', 'en_US').parse(isoDate); return date.millisecondsSinceEpoch; } Future updateAnime(Track track) async { final accessToken = await _getAccesToken(); final formBody = { 'status': (toMyAnimeListStatusAnime(track.status) ?? 'watching').toString(), 'is_rewatching': (track.status == TrackStatus.reWatching).toString(), 'score': track.score.toString(), 'num_watched_episodes': track.lastChapterRead.toString(), if (track.startedReadingDate != null) 'start_date': _convertToIsoDate(track.startedReadingDate), if (track.finishedReadingDate != null) 'finish_date': _convertToIsoDate(track.finishedReadingDate) }; final request = Request('PUT', Uri.parse('$baseApiUrl/anime/${track.mediaId}/my_list_status')); request.bodyFields = formBody; request.headers.addAll({'Authorization': 'Bearer $accessToken'}); final response = await Client().send(request); final mJson = jsonDecode(await response.stream.bytesToString()); return _parseAnimeItem(mJson, track); } Future updateManga(Track track) async { final accessToken = await _getAccesToken(); final formBody = { 'status': (toMyAnimeListStatusManga(track.status) ?? 'reading').toString(), 'is_rereading': (track.status == TrackStatus.rereading).toString(), 'score': track.score.toString(), 'num_chapters_read': track.lastChapterRead.toString(), if (track.startedReadingDate != null) 'start_date': _convertToIsoDate(track.startedReadingDate), if (track.finishedReadingDate != null) 'finish_date': _convertToIsoDate(track.finishedReadingDate) }; final request = Request('PUT', Uri.parse('$baseApiUrl/manga/${track.mediaId}/my_list_status')); request.bodyFields = formBody; request.headers.addAll({'Authorization': 'Bearer $accessToken'}); final response = await Client().send(request); final mJson = jsonDecode(await response.stream.bytesToString()); return _parseMangaItem(mJson, track); } }