mirror of
https://github.com/kodjodevf/mangayomi.git
synced 2026-04-25 03:24:02 +00:00
- now refreshes expired tokens at startup - prevents accidental logouts for users who has the tracking screen as default navigation
427 lines
14 KiB
Dart
427 lines
14 KiB
Dart
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}) {}
|
|
|
|
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" : "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<List<TrackSearch>> 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<String, dynamic>?;
|
|
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<Track?> 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<List<TrackSearch>> 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<TrackStatus> statusList(bool isManga) => [
|
|
TrackStatus.watching,
|
|
TrackStatus.completed,
|
|
TrackStatus.onHold,
|
|
TrackStatus.dropped,
|
|
TrackStatus.planToWatch,
|
|
];
|
|
|
|
@override
|
|
Future<Track> 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<String, dynamic>?)?["added"];
|
|
final data = _extractTrackData(
|
|
temp?[isMovie ? "movies" : "shows"] as List?,
|
|
track.mediaId,
|
|
);
|
|
return _parseTrack(track, data);
|
|
}
|
|
|
|
Map<String, dynamic>? _extractTrackData(List? data, int? mediaId) {
|
|
return data?.firstWhereOrNull((e) => e["ids"]?["simkl"] == mediaId);
|
|
}
|
|
|
|
Track _parseTrack(Track track, Map<String, dynamic>? 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<String> _getAccessToken() async {
|
|
final track = ref.read(tracksProvider(syncId: syncId));
|
|
final mALOAuth = OAuth.fromJson(
|
|
jsonDecode(track!.oAuth!) as Map<String, dynamic>,
|
|
);
|
|
return mALOAuth.accessToken!;
|
|
}
|
|
|
|
OAuth _buildOAuth(Map<String, dynamic> json, String clientId) {
|
|
return OAuth.fromJson(json)..clientId = clientId;
|
|
}
|
|
|
|
void _saveOAuth(String username, OAuth oAuth) {
|
|
ref
|
|
.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)['account']['id']}";
|
|
}
|
|
|
|
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('$_baseApiUrl/oauth/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',
|
|
'simkl-api-key': _clientId,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<Response> _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<bool> checkRefresh() async {
|
|
ref.read(tracksProvider(syncId: syncId).notifier).setRefreshing(false);
|
|
return true;
|
|
}
|
|
}
|