import 'dart:convert'; import 'dart:io'; import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import 'package:http_interceptor/http_interceptor.dart'; import 'package:mangayomi/eval/model/m_bridge.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:mangayomi/utils/log/logger.dart'; import 'base_tracker.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'trakt_tv.g.dart'; @riverpod class TraktTv extends _$TraktTv implements BaseTracker { final http = MClient.init(reqcopyWith: {'useDartHttpClient': true}); static const _baseOAuthUrl = 'https://api.trakt.tv/oauth'; static const _baseApiUrl = 'https://api.trakt.tv'; static final _isDesktop = (Platform.isWindows || Platform.isLinux); static final _redirectUri = _isDesktop ? 'http://localhost:43824' : 'mangayomi://'; static const _clientId = '5520c7e24da0d8d73ec80315b61b9849483583b013cb7f296c6db723eb9886a1'; static const _clientSecret = 'b512565ad92d4179290de257b6e435d03ee47b2e4371b3bd918081beb6121734'; 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" : "shows"; final accessToken = await _getAccessToken(); final url = Uri.parse('$_baseApiUrl/$type/$rankingType').replace( queryParameters: { 'extended': 'full,images', 'clientId': _clientId, 'limit': '15', }, ); final result = await _makeGetRequest(url, accessToken); final data = jsonDecode(result.body) as List?; return data?.map((e) { final type = isManga ? "movie" : "show"; final typeName = type == 'movie' ? 'movies' : 'shows'; return TrackSearch( mediaId: e['ids']?['trakt'] ?? e[type]?['ids']?['trakt'], summary: e['overview'] ?? e[type]?['overview'] ?? 'No summary available.', totalChapter: e['aired_episodes'] ?? e[type]?['aired_episodes'] ?? 1, coverUrl: (e['images']?['fanart'] as List?)?.isNotEmpty ?? false ? 'https://wsrv.nl/?url=${e['images']?['fanart'][0]}' : (e[type]?['images']?['fanart'] as List?)?.isNotEmpty ?? false ? 'https://wsrv.nl/?url=${e[type]?['images']?['fanart'][0]}' : (e['images']?['poster'] as List?)?.isNotEmpty ?? false ? 'https://wsrv.nl/?url=${e['images']?['poster'][0]}' : (e[type]?['images']?['poster'] as List?)?.isNotEmpty ?? false ? 'https://wsrv.nl/?url=${e[type]?['images']?['poster'][0]}' : '', title: e['title'] ?? e[type]?['title'] ?? 'Unknown Title', score: double.tryParse( (e["rating"] ?? e[type]?["rating"] as num?) ?.toDouble() .toStringAsFixed(2) ?? "", ), startDate: e["first_aired"] ?? e[type]?["first_aired"] ?? "", publishingType: type, publishingStatus: e["status"] ?? e[type]["status"], trackingUrl: "https://trakt.tv/$typeName/${e['ids']?['slug'] ?? e[type]?['ids']?['slug']}", syncId: syncId, ); }).toList() ?? []; } @override Future> fetchUserData({bool isManga = true}) async { final type = isManga ? "movies" : "shows"; final accessToken = await _getAccessToken(); final url = Uri.parse( '$_baseApiUrl/sync/watched/$type', ).replace(queryParameters: {"extended": "full,images"}); final result = await _makeGetRequest(url, accessToken); final data = jsonDecode(result.body) as List?; return data?.map((e) { final type = e['movie'] != null ? "movie" : "show"; final typeName = type == 'movie' ? 'movies' : 'shows'; return TrackSearch( mediaId: e[type]?['ids']?['trakt'], summary: e[type]?['overview'] ?? 'No summary available.', totalChapter: e[type]?['aired_episodes'] ?? 1, coverUrl: (e['images']?['fanart'] as List?)?.isNotEmpty ?? false ? 'https://wsrv.nl/?url=${e['images']?['fanart'][0]}' : (e[type]?['images']?['fanart'] as List?)?.isNotEmpty ?? false ? 'https://wsrv.nl/?url=${e[type]?['images']?['fanart'][0]}' : (e['images']?['poster'] as List?)?.isNotEmpty ?? false ? 'https://wsrv.nl/?url=${e['images']?['poster'][0]}' : (e[type]?['images']?['poster'] as List?)?.isNotEmpty ?? false ? 'https://wsrv.nl/?url=${e[type]?['images']?['poster'][0]}' : '', title: e[type]['title'] ?? 'Unknown Title', score: double.tryParse( (e[type]?["rating"] as num?)?.toDouble().toStringAsFixed(2) ?? "", ), startDate: e[type]?["first_aired"] ?? "", publishingType: type, publishingStatus: e[type]["status"], trackingUrl: "https://trakt.tv/$typeName/${e[type]?['ids']?['slug']}", syncId: syncId, ); }).toList() ?? []; } @override Future findLibItem(Track track, bool isManga) async { final accessToken = await _getAccessToken(); final isMovie = track.trackingUrl?.replaceAll("https://trakt.tv/", "").split("/")[0] == "movies"; final url = Uri.parse( '$_baseApiUrl/sync/history/${isMovie ? "movies" : "shows"}/${track.mediaId}', ).replace(queryParameters: {"extended": "full", "page": "1", "limit": "3000"}); final result = await _makeGetRequest(url, accessToken); final data = jsonDecode(result.body) as List?; if (data?.isNotEmpty ?? false) { if (!isMovie) { track.lastChapterRead = data! .where((e) => e["type"] == "episode") .length; } if ((track.lastChapterRead ?? 0) >= (track.totalChapter ?? 0)) { track.finishedReadingDate = DateTime.tryParse( data!.firstOrNull?["watched_at"], )?.millisecondsSinceEpoch; } return track; } return await update(track, isManga); } @override Future> search(String query, bool isManga) async { final accessToken = await _getAccessToken(); final url = Uri.parse('$_baseApiUrl/search/movie,show').replace( queryParameters: { 'query': query, 'extended': 'full,images', 'clientId': _clientId, 'limit': '15', }, ); final result = await _makeGetRequest(url, accessToken); final data = jsonDecode(result.body) as List?; return data?.map((e) { final type = e['type']; final typeName = e['type'] == 'movie' ? 'movies' : 'shows'; return TrackSearch( mediaId: e[type]?['ids']?['trakt'], summary: e[type]?['overview'] ?? 'No summary available.', totalChapter: e[type]?['aired_episodes'] ?? 1, coverUrl: (e['images']?['fanart'] as List?)?.isNotEmpty ?? false ? 'https://wsrv.nl/?url=${e['images']?['fanart'][0]}' : (e[type]?['images']?['fanart'] as List?)?.isNotEmpty ?? false ? 'https://wsrv.nl/?url=${e[type]?['images']?['fanart'][0]}' : (e['images']?['poster'] as List?)?.isNotEmpty ?? false ? 'https://wsrv.nl/?url=${e['images']?['poster'][0]}' : (e[type]?['images']?['poster'] as List?)?.isNotEmpty ?? false ? 'https://wsrv.nl/?url=${e[type]?['images']?['poster'][0]}' : '', title: e[type]['title'] ?? 'Unknown Title', score: double.tryParse( (e["rating"] ?? e[type]?["rating"] as num?) ?.toDouble() .toStringAsFixed(2) ?? "", ), startDate: e[type]?["first_aired"] ?? "", publishingType: type, publishingStatus: e[type]["status"], trackingUrl: "https://trakt.tv/$typeName/${e[type]?['ids']?['slug']}", syncId: syncId, ); }).toList() ?? []; } @override List statusList(bool isManga) => []; @override Future update(Track track, bool isManga) async { final accessToken = await _getAccessToken(); final isMovie = track.trackingUrl?.replaceAll("https://trakt.tv/", "").split("/")[0] == "movies"; /*final urlRemove = Uri.parse( "$_baseApiUrl/sync/history/remove", ).replace(queryParameters: {'clientId': _clientId}); final bodyRemove = isMovie ? { 'movies': [ { 'ids': {'trakt': track.mediaId}, }, ], } : { 'shows': [ { 'ids': {'trakt': track.mediaId}, }, ], }; await _makePostRequest(urlRemove, accessToken, bodyRemove);*/ final url = Uri.parse( "$_baseApiUrl/sync/history", ).replace(queryParameters: {'extended': 'full', 'clientId': _clientId}); final body = isMovie ? { 'movies': [ { 'watched_at': DateTime.timestamp().toIso8601String(), 'ids': {'trakt': track.mediaId}, }, ], } : { 'shows': [ { 'ids': {'trakt': track.mediaId}, 'seasons': [ { 'number': 1, 'episodes': [ for (int i = 1; i <= (track.lastChapterRead ?? 1); i++) { 'watched_at': DateTime.timestamp().toIso8601String(), 'number': i, }, ], }, ], }, ], }; await _makePostRequest(url, accessToken, body); return track; } Future _getAccessToken({bool bypass = false}) async { final track = widgetRef.read(tracksProvider(syncId: syncId)); final mALOAuth = OAuth.fromJson( jsonDecode(track!.oAuth!) as Map, ); final expiresIn = DateTime.fromMillisecondsSinceEpoch(mALOAuth.expiresIn!); if (DateTime.now().isBefore(expiresIn)) return mALOAuth.accessToken!; if (!bypass && (widgetRef.read(tracksProvider(syncId: syncId))?.refreshing ?? false)) { return mALOAuth.accessToken!; } widgetRef.read(tracksProvider(syncId: syncId).notifier).setRefreshing(true); final refreshed = await _tryRefreshToken(mALOAuth); if (refreshed == null) { widgetRef.read(tracksProvider(syncId: syncId).notifier).logout(); botToast("Trakt.tv Token expired"); throw Exception("Token expired"); } final username = await _getUserName(refreshed.accessToken!); _saveOAuth(username, refreshed); await Future.delayed(Duration(seconds: 3)); widgetRef .read(tracksProvider(syncId: syncId).notifier) .setRefreshing(false); return refreshed.accessToken!; } Future _tryRefreshToken(OAuth oldOAuth) async { String primaryClientId = oldOAuth.clientId ?? _clientId; Future tryRefresh(String cid) async { final params = { 'refresh_token': oldOAuth.refreshToken, 'client_id': cid, 'client_secret': _clientSecret, 'redirect_uri': _redirectUri, 'grant_type': 'refresh_token', }; final response = await http.post( Uri.parse('$_baseOAuthUrl/token'), headers: {'Content-Type': 'application/json'}, body: jsonEncode(params), ); if (response.statusCode != 200) return null; final body = jsonDecode(response.body) as Map; return _buildOAuth(body, cid); } return await tryRefresh(primaryClientId) ?? await tryRefresh(getFallbackClientId(primaryClientId)); } OAuth _buildOAuth(Map json, String clientId) { return OAuth.fromJson(json) ..expiresIn = DateTime.now() .add(Duration(seconds: json['expires_in'])) .millisecondsSinceEpoch ..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)['user']['username']}"; } 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('$_baseOAuthUrl/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', 'trakt-api-key': _clientId, 'trakt-api-version': "2", 'Content-Type': 'application/json', }, ); } Future _makePostRequest( Uri url, String accessToken, Object? body, ) async { return await http.post( url, headers: { 'Authorization': 'Bearer $accessToken', 'trakt-api-key': _clientId, 'trakt-api-version': "2", 'Content-Type': 'application/json', }, body: jsonEncode(body), ); } @override String displayScore(int score) { throw UnimplementedError(); } @override (int, int) getScoreValue() { throw UnimplementedError(); } @override Future checkRefresh() async { try { await _getAccessToken(bypass: true); AppLogger.log("Refreshed Trakt.tv token!"); return true; } catch (e) { AppLogger.log( "Failed to refresh Trakt.tv token:", logLevel: LogLevel.error, ); AppLogger.log(e.toString(), logLevel: LogLevel.error); return false; } finally { widgetRef .read(tracksProvider(syncId: syncId).notifier) .setRefreshing(false); } } }