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/manga.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 ItemType? itemType}) {} 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( itemType == ItemType.manga ? '$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 = itemType == ItemType.manga ? 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( itemType == ItemType.manga ? '$baseApiUrl/manga/${track.mediaId}' : '$baseApiUrl/anime/${track.mediaId}', ).replace( queryParameters: { 'fields': itemType == ItemType.manga ? '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 = itemType == ItemType.manga ? mJson['num_chapters'] ?? 0 : mJson['num_episodes'] ?? 0; if (mJson['my_list_status'] != null) { track = itemType == ItemType.manga ? _parseMangaItem(mJson["my_list_status"], track) : _parseAnimeItem(mJson["my_list_status"], track); } else { track = itemType == ItemType.manga ? 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); } }