mirror of
https://github.com/kodjodevf/mangayomi.git
synced 2026-05-13 14:30:41 +00:00
459 lines
16 KiB
Dart
459 lines
16 KiB
Dart
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<bool?> 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<List<TrackSearch>> 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<List<TrackSearch>> 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<Track?> 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<List<TrackSearch>> 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<TrackStatus> statusList(bool isManga) => [];
|
|
|
|
@override
|
|
Future<Track> 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<String> _getAccessToken({bool bypass = false}) async {
|
|
final track = widgetRef.read(tracksProvider(syncId: syncId));
|
|
final mALOAuth = OAuth.fromJson(
|
|
jsonDecode(track!.oAuth!) as Map<String, dynamic>,
|
|
);
|
|
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<OAuth?> _tryRefreshToken(OAuth oldOAuth) async {
|
|
String primaryClientId = oldOAuth.clientId ?? _clientId;
|
|
|
|
Future<OAuth?> 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<String, dynamic>;
|
|
return _buildOAuth(body, cid);
|
|
}
|
|
|
|
return await tryRefresh(primaryClientId) ??
|
|
await tryRefresh(getFallbackClientId(primaryClientId));
|
|
}
|
|
|
|
OAuth _buildOAuth(Map<String, dynamic> 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<String> _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<dynamic> _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<Response> _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<Response> _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<bool> 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);
|
|
}
|
|
}
|
|
}
|