import 'dart:convert'; import 'dart:io'; import 'package:flutter_qjs/quickjs/ffi.dart'; import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import 'package:http_interceptor/http_interceptor.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 'base_tracker.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'simkl.g.dart'; @riverpod class Simkl extends _$Simkl implements BaseTracker { final http = MClient.init(reqcopyWith: {'useDartHttpClient': true}); static const _baseOAuthUrl = 'https://simkl.com/oauth'; static const _baseApiUrl = 'https://api.simkl.com'; static final _isDesktop = (Platform.isWindows || Platform.isLinux); static final _redirectUri = _isDesktop ? 'http://localhost:43824' : 'mangayomi://'; static const _clientId = '1e0a52930b1bdface4e30c1a94a44641475f3c80b69a5ea939562153fccffb68'; static const _clientSecret = 'aed1dc0fa8b9906c493b87c513b430fde75ea5cdad0087e8d129fbc5d36f9be0'; String getFallbackClientId(String usedId) { return _clientId; } @override void build({ required int syncId, required ItemType? itemType, required dynamic widgetRef, }) {} Future login() async { final callbackUrlScheme = _isDesktop ? 'http://localhost:43824' : 'mangayomi'; final loginUrl = _authUrl(); try { final uri = await FlutterWebAuth2.authenticate( url: "$loginUrl&redirect_uri=$_redirectUri", callbackUrlScheme: callbackUrlScheme, ); final code = Uri.parse(uri).queryParameters['code']; if (code == null) return null; final oAuthData = await _getOAuth(code); final oAuth = _buildOAuth(oAuthData, _clientId); final username = await _getUserName(oAuth.accessToken!); _saveOAuth(username, oAuth); return true; } catch (_) { return false; } } @override Future> fetchGeneralData({ bool isManga = true, String rankingType = "trending", }) async { /// isManga <> isMovie final type = isManga ? "movies" : "tv"; final accessToken = await _getAccessToken(); final url = Uri.parse('$_baseApiUrl/$type/$rankingType').replace( queryParameters: { 'extended': 'overview', if (rankingType == "airing") 'date': 'today', if (rankingType == "airing") 'sort': 'rank', if (rankingType == "best") 'filter': 'all', if (rankingType == "best") 'type': 'series', 'clientId': _clientId, 'limit': '15', }, ); final result = await _makeGetRequest(url, accessToken); final data = jsonDecode(result.body) as List?; return data?.map((e) { return TrackSearch( mediaId: e['ids']?['simkl_id'] ?? e['ids']?['simkl'], summary: e['overview'] ?? 'No summary available.', totalChapter: 0, coverUrl: e['fanart'] != null ? 'https://wsrv.nl/?url=https://simkl.in/fanart/${e['fanart']}_medium.jpg' : e['poster'] != null ? 'https://wsrv.nl/?url=https://simkl.in/posters/${e['poster']}_m.webp' : '', title: e['title'] ?? 'Unknown Title', score: (e["ratings"]?["simkl"]?["rating"] as num?)?.toDouble(), startDate: e["release_date"] ?? "", publishingType: isManga ? "movie" : "tv", publishingStatus: e["status"], trackingUrl: "https://simkl.com/$type/${e['ids']?['simkl_id'] ?? e['ids']?['simkl']}", syncId: syncId, ); }).toList() ?? []; } @override Future> fetchUserData({bool isManga = true}) async { final type = isManga ? "movies" : "shows"; final nodeType = isManga ? "movie" : "show"; final accessToken = await _getAccessToken(); final url = Uri.parse('$_baseApiUrl/sync/all-items/$type'); final result = await _makeGetRequest(url, accessToken); final data = jsonDecode(result.body) as Map?; return (data?[type] as List?)?.map((e) { final node = e[nodeType]; return TrackSearch( mediaId: node['ids']?['simkl'], summary: 'No summary available.', totalChapter: isManga ? 1 : e['total_episodes_count'], coverUrl: node['poster'] != null ? "https://wsrv.nl/?url=https://simkl.in/posters/${node['poster']}_m.jpg" : "", title: node['title'] ?? 'Unknown Title', score: 0, startDate: "", publishingType: isManga ? "movie" : "tv", publishingStatus: e["status"], trackingUrl: "https://simkl.com/$type/${node['ids']?['simkl']}", syncId: syncId, ); }).toList() ?? []; } @override Future findLibItem(Track track, bool isManga) async { final accessToken = await _getAccessToken(); final url = Uri.parse( '$_baseApiUrl/sync/watched/', ).replace(queryParameters: {"extended": "episodes,specials,counters"}); final result = await _makePostRequest(url, accessToken, [ {"simkl": track.mediaId}, ]); final data = jsonDecode(result.body) as List?; if ((data?.isNotEmpty ?? false) && data!.firstOrNull?["result"] != "not_found") { final node = data.firstOrNull; if (node?["list"] is String) { track.status = _trackFromSimklStatus(node!["list"]); if (track.status == TrackStatus.completed && node?["last_watched_at"] is String) { track.finishedReadingDate = DateTime.tryParse( node!["last_watched_at"], )?.millisecondsSinceEpoch; } } if (node?["episodes_watched"] is num) { track.lastChapterRead = (node!["episodes_watched"] as num).toInt(); } if (node?["episodes_total"] is num) { track.totalChapter = (node!["episodes_total"] as num).toInt(); } track.libraryId = 1; if (node?["result"] == false) { track.libraryId = 0; return await update(track, isManga); } } return track; } @override Future> search(String query, bool isManga) async { final accessToken = await _getAccessToken(); final urlMovies = Uri.parse('$_baseApiUrl/search/movies').replace( queryParameters: { 'q': query, 'extended': 'full', 'clientId': _clientId, 'limit': '15', }, ); final resultMovies = await _makeGetRequest(urlMovies, accessToken); final dataMovies = jsonDecode(resultMovies.body) as List?; final movies = dataMovies?.map((e) { return TrackSearch( mediaId: e['ids']?['simkl_id'] ?? e['ids']?['simkl'], summary: e['overview'] ?? 'No summary available.', totalChapter: 0, coverUrl: e['poster'] != null ? 'https://wsrv.nl/?url=https://simkl.in/posters/${e['poster']}_m.webp' : '', title: e['title'] ?? 'Unknown Title', score: (e["ratings"]?["simkl"]?["rating"] as num?)?.toDouble(), startDate: e["release_date"] ?? "", publishingType: "movie", publishingStatus: e["status"], trackingUrl: "https://simkl.com/movie/${e['ids']?['simkl_id'] ?? e['ids']?['simkl']}", syncId: syncId, ); }).toList() ?? []; final urlSeries = Uri.parse('$_baseApiUrl/search/tv').replace( queryParameters: { 'q': query, 'extended': 'full', 'clientId': _clientId, 'limit': '15', }, ); final resultSeries = await _makeGetRequest(urlSeries, accessToken); final dataSeries = jsonDecode(resultSeries.body) as List?; final series = dataSeries?.map((e) { return TrackSearch( mediaId: e['ids']?['simkl_id'] ?? e['ids']?['simkl'], summary: e['overview'] ?? 'No summary available.', totalChapter: 0, coverUrl: e['poster'] != null ? 'https://wsrv.nl/?url=https://simkl.in/posters/${e['poster']}_m.webp' : '', title: e['title'] ?? 'Unknown Title', score: (e["ratings"]?["simkl"]?["rating"] as num?)?.toDouble(), startDate: e["release_date"] ?? "", publishingType: "tv", publishingStatus: e["status"], trackingUrl: "https://simkl.com/tv/${e['ids']?['simkl_id'] ?? e['ids']?['simkl']}", syncId: syncId, ); }).toList() ?? []; return movies + series; } @override List statusList(bool isManga) => [ TrackStatus.watching, TrackStatus.completed, TrackStatus.onHold, TrackStatus.dropped, TrackStatus.planToWatch, ]; @override Future update(Track track, bool isManga) async { final accessToken = await _getAccessToken(); final existRemote = track.libraryId == 1; final isMovie = track.trackingUrl?.replaceAll("https://simkl.com/", "").split("/")[0] == "movie"; final url = Uri.parse( existRemote ? "$_baseApiUrl/sync/history" : "$_baseApiUrl/sync/add-to-list", ).replace( queryParameters: { 'extended': 'full', 'clientId': _clientId, 'limit': '15', }, ); final body = isMovie ? { 'movies': [ { if (!existRemote) 'to': _trackToSimklStatus(track), if (existRemote) 'status': _trackToSimklStatus(track), 'ids': {'simkl': track.mediaId}, }, ], } : { 'shows': [ { if (!existRemote) 'to': _trackToSimklStatus(track), if (existRemote) 'status': _trackToSimklStatus(track), 'ids': {'simkl': track.mediaId}, 'episodes': [ for (int i = 1; i <= (track.lastChapterRead ?? 1); i++) {'number': i}, ], }, ], }; final result = await _makePostRequest(url, accessToken, body); if (result.statusCode >= 200 && result.statusCode < 300) { track.libraryId = 1; } if (result.statusCode == 201) { return track; } final temp = (jsonDecode(result.body) as Map?)?["added"]; final data = _extractTrackData( temp?[isMovie ? "movies" : "shows"] as List?, track.mediaId, ); return _parseTrack(track, data); } Map? _extractTrackData(List? data, int? mediaId) { return data?.firstWhereOrNull((e) => e["ids"]?["simkl"] == mediaId); } Track _parseTrack(Track track, Map? data) { if (data?["to"] is String) { track.status = _trackFromSimklStatus(data!["to"]); } return track; } String _trackToSimklStatus(Track track) => switch (track.status) { TrackStatus.completed => "completed", TrackStatus.watching => "watching", TrackStatus.onHold => "hold", TrackStatus.dropped => "dropped", _ => "plantowatch", }; TrackStatus _trackFromSimklStatus(String status) => switch (status) { "completed" => TrackStatus.completed, "watching" => TrackStatus.watching, "hold" => TrackStatus.onHold, "dropped" => TrackStatus.dropped, _ => TrackStatus.planToWatch, }; Future _getAccessToken() async { final track = widgetRef.read(tracksProvider(syncId: syncId)); final mALOAuth = OAuth.fromJson( jsonDecode(track!.oAuth!) as Map, ); return mALOAuth.accessToken!; } OAuth _buildOAuth(Map json, String clientId) { return OAuth.fromJson(json)..clientId = clientId; } void _saveOAuth(String username, OAuth oAuth) { widgetRef .read(tracksProvider(syncId: syncId).notifier) .login( TrackPreference( syncId: syncId, username: username, prefs: "", oAuth: jsonEncode(oAuth.toJson()), ), ); } Future _getUserName(String accessToken) async { final response = await _makeGetRequest( Uri.parse('$_baseApiUrl/users/settings'), accessToken, ); return "${jsonDecode(response.body)['account']['id']}"; } String _authUrl() { return '$_baseOAuthUrl/authorize?client_id=$_clientId&response_type=code'; } Future _getOAuth(String code) async { final params = { 'code': code, 'client_id': _clientId, 'client_secret': _clientSecret, 'redirect_uri': _redirectUri, 'grant_type': 'authorization_code', }; final response = await http.post( Uri.parse('$_baseApiUrl/oauth/token'), headers: {'Content-Type': 'application/json'}, body: jsonEncode(params), ); return jsonDecode(response.body); } Future _makeGetRequest(Uri url, String accessToken) async { return await http.get( url, headers: { 'Authorization': 'Bearer $accessToken', 'simkl-api-key': _clientId, 'Content-Type': 'application/json', }, ); } Future _makePostRequest( Uri url, String accessToken, Object? body, ) async { return await http.post( url, headers: { 'Authorization': 'Bearer $accessToken', 'simkl-api-key': _clientId, 'Content-Type': 'application/json', }, body: jsonEncode(body), ); } @override String displayScore(int score) { throw UnimplementedError(); } @override (int, int) getScoreValue() { throw UnimplementedError(); } @override Future checkRefresh() async { widgetRef .read(tracksProvider(syncId: syncId).notifier) .setRefreshing(false); return true; } }