Merge pull request #561 from Schnitzel5/tracker/simkl

added Simkl tracker
This commit is contained in:
Moustapha Kodjo Amadou 2025-08-27 09:06:04 +01:00 committed by GitHub
commit 78154b7666
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1510 additions and 30 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -6,8 +6,11 @@ import 'package:mangayomi/models/track_search.dart';
import 'package:mangayomi/modules/more/settings/track/providers/track_providers.dart';
import 'package:mangayomi/modules/tracker_library/tracker_library_screen.dart';
import 'package:mangayomi/services/trackers/anilist.dart';
import 'package:mangayomi/services/trackers/base_tracker.dart';
import 'package:mangayomi/services/trackers/kitsu.dart';
import 'package:mangayomi/services/trackers/myanimelist.dart';
import 'package:mangayomi/services/trackers/simkl.dart';
import 'package:mangayomi/services/trackers/trakt_tv.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'track_state_providers.g.dart';
@ -18,7 +21,7 @@ class TrackState extends _$TrackState {
return track!;
}
dynamic getNotifier(int syncId) {
BaseTracker getNotifier(int syncId) {
return switch (syncId) {
1 => ref.read(
myAnimeListProvider(syncId: syncId, itemType: itemType).notifier,
@ -27,6 +30,10 @@ class TrackState extends _$TrackState {
anilistProvider(syncId: syncId, itemType: itemType).notifier,
),
3 => ref.read(kitsuProvider(syncId: syncId, itemType: itemType).notifier),
4 => ref.read(simklProvider(syncId: syncId, itemType: itemType).notifier),
5 => ref.read(
traktTvProvider(syncId: syncId, itemType: itemType).notifier,
),
_ => throw Exception('Unsupported syncId: $syncId'),
};
}
@ -106,13 +113,17 @@ class TrackState extends _$TrackState {
);
final tracker = getNotifier(syncId);
if (syncId == 1) {
if (syncId == TrackerProviders.myAnimeList.syncId) {
findManga = await tracker.findLibItem(newTrack, _isManga);
} else if (syncId == 2) {
} else if (syncId == TrackerProviders.anilist.syncId) {
findManga = await tracker.findLibItem(newTrack, _isManga);
findManga ??= await tracker.update(newTrack, _isManga);
} else if (syncId == 3) {
} else if (syncId == TrackerProviders.kitsu.syncId) {
findManga = await tracker.update(newTrack, _isManga);
} else if (syncId == TrackerProviders.simkl.syncId) {
findManga = await tracker.findLibItem(newTrack, _isManga);
} else if (syncId == TrackerProviders.trakt.syncId) {
findManga = await tracker.findLibItem(newTrack, _isManga);
}
writeBack(findManga!);
}

View file

@ -6,7 +6,7 @@ part of 'track_state_providers.dart';
// RiverpodGenerator
// **************************************************************************
String _$trackStateHash() => r'b70770f8524a0d9059ffd3f52b42634c16672a0f';
String _$trackStateHash() => r'0b2fa471cb843d5880f921f1c721a05b54bc1515';
/// Copied from Dart SDK
class _SystemHash {

View file

@ -5,6 +5,7 @@ import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/track.dart';
import 'package:mangayomi/models/track_preference.dart';
import 'package:mangayomi/modules/manga/detail/widgets/tracker_widget.dart';
import 'package:mangayomi/modules/tracker_library/tracker_library_screen.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/utils/constant.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
@ -20,10 +21,14 @@ class TrackingDetail extends StatefulWidget {
class _TrackingDetailState extends State<TrackingDetail>
with TickerProviderStateMixin {
late TabController _tabBarController;
bool get isMovies =>
widget.trackerPref.syncId == TrackerProviders.simkl.syncId ||
widget.trackerPref.syncId == TrackerProviders.trakt.syncId;
@override
void initState() {
super.initState();
_tabBarController = TabController(length: 2, vsync: this);
_tabBarController = TabController(length: isMovies ? 1 : 2, vsync: this);
}
@override
@ -35,9 +40,10 @@ class _TrackingDetailState extends State<TrackingDetail>
@override
Widget build(BuildContext context) {
final l10n = l10nLocalizations(context)!;
return DefaultTabController(
animationDuration: Duration.zero,
length: 2,
length: isMovies ? 1 : 2,
child: Scaffold(
appBar: AppBar(
elevation: 0,
@ -51,7 +57,7 @@ class _TrackingDetailState extends State<TrackingDetail>
indicatorSize: TabBarIndicatorSize.tab,
controller: _tabBarController,
tabs: [
Tab(text: l10n.manga),
if (!isMovies) Tab(text: l10n.manga),
Tab(text: l10n.anime),
],
),
@ -59,10 +65,11 @@ class _TrackingDetailState extends State<TrackingDetail>
body: TabBarView(
controller: _tabBarController,
children: [
TrackingTab(
itemType: ItemType.manga,
syncId: widget.trackerPref.syncId!,
),
if (!isMovies)
TrackingTab(
itemType: ItemType.manga,
syncId: widget.trackerPref.syncId!,
),
TrackingTab(
itemType: ItemType.anime,
syncId: widget.trackerPref.syncId!,

View file

@ -15,7 +15,7 @@ class OAuth {
OAuth.fromJson(Map<String, dynamic> json) {
tokenType = json['token_type'];
expiresIn = json['expires_in'] as int;
expiresIn = json['expires_in'] as int?;
accessToken = json['access_token'];
refreshToken = json['refresh_token'];
clientId = json['client_id'];

View file

@ -7,10 +7,13 @@ import 'package:mangayomi/models/track_preference.dart';
import 'package:mangayomi/modules/more/settings/track/providers/track_providers.dart';
import 'package:mangayomi/modules/more/settings/track/widgets/track_listile.dart';
import 'package:mangayomi/modules/more/widgets/list_tile_widget.dart';
import 'package:mangayomi/modules/tracker_library/tracker_library_screen.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/services/trackers/anilist.dart';
import 'package:mangayomi/services/trackers/kitsu.dart';
import 'package:mangayomi/services/trackers/myanimelist.dart';
import 'package:mangayomi/services/trackers/simkl.dart';
import 'package:mangayomi/services/trackers/trakt_tv.dart';
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
class TrackScreen extends ConsumerWidget {
@ -65,16 +68,22 @@ class TrackScreen extends ConsumerWidget {
),
TrackListile(
onTap: () async {
await ref.read(anilistProvider(syncId: 2).notifier).login();
await ref
.read(
anilistProvider(
syncId: TrackerProviders.anilist.syncId,
).notifier,
)
.login();
},
id: 2,
id: TrackerProviders.anilist.syncId,
entries: entries!,
),
TrackListile(
onTap: () async {
_showDialogLogin(context, ref);
},
id: 3,
id: TrackerProviders.kitsu.syncId,
entries: entries,
),
TrackListile(
@ -82,13 +91,41 @@ class TrackScreen extends ConsumerWidget {
await ref
.read(
myAnimeListProvider(
syncId: 1,
syncId: TrackerProviders.myAnimeList.syncId,
itemType: null,
).notifier,
)
.login();
},
id: 1,
id: TrackerProviders.myAnimeList.syncId,
entries: entries,
),
TrackListile(
onTap: () async {
await ref
.read(
simklProvider(
syncId: TrackerProviders.simkl.syncId,
itemType: null,
).notifier,
)
.login();
},
id: TrackerProviders.simkl.syncId,
entries: entries,
),
TrackListile(
onTap: () async {
await ref
.read(
traktTvProvider(
syncId: TrackerProviders.trakt.syncId,
itemType: null,
).notifier,
)
.login();
},
id: TrackerProviders.trakt.syncId,
entries: entries,
),
ListTile(
@ -231,7 +268,11 @@ void _showDialogLogin(BuildContext context, WidgetRef ref) {
isLoading = true;
});
final res = await ref
.read(kitsuProvider(syncId: 3).notifier)
.read(
kitsuProvider(
syncId: TrackerProviders.kitsu.syncId,
).notifier,
)
.login(email, password);
if (!res.$1) {
setState(() {

View file

@ -24,7 +24,8 @@ enum TrackerProviders {
myAnimeList(syncId: 1, name: "MAL"),
anilist(syncId: 2, name: "AL"),
kitsu(syncId: 3, name: "Kitsu"),
trakt(syncId: 4, name: "Trakt");
simkl(syncId: 4, name: "Simkl"),
trakt(syncId: 5, name: "Trakt");
const TrackerProviders({required this.syncId, required this.name});
@ -68,6 +69,8 @@ class _TrackerLibraryScreenState extends ConsumerState<TrackerLibraryScreen> {
1 => _sectionsMAL(trackerProvider.syncId, itemType),
2 => _sectionsAL(trackerProvider.syncId, itemType),
3 => _sectionsKitsu(trackerProvider.syncId, itemType),
4 => _sectionsSimkl(trackerProvider.syncId, itemType),
5 => _sectionsTrakt(trackerProvider.syncId, itemType),
_ => [],
};
if (_isSearch && _query.isNotEmpty) {
@ -86,7 +89,10 @@ class _TrackerLibraryScreenState extends ConsumerState<TrackerLibraryScreen> {
return Scaffold(
appBar: AppBar(
title: Text(
"${trackerProvider.name} | ${itemType == ItemType.anime ? l10n.anime : l10n.manga}",
(trackerProvider.syncId == TrackerProviders.simkl.syncId ||
trackerProvider.syncId == TrackerProviders.trakt.syncId)
? trackerProvider.name
: "${trackerProvider.name} | ${itemType == ItemType.anime ? l10n.anime : l10n.manga}",
),
leading: !_isSearch ? null : Container(),
actions: [
@ -211,6 +217,108 @@ class _TrackerLibraryScreenState extends ConsumerState<TrackerLibraryScreen> {
setState(() {});
}
List<TrackLibrarySection> _sectionsTrakt(int syncId, ItemType itemType) {
return [
TrackLibrarySection(
name: "Continue watching movies",
syncId: syncId,
func: _fetchUserData(syncId, ItemType.manga),
itemType: ItemType.anime,
),
TrackLibrarySection(
name: "Continue watching series",
syncId: syncId,
func: _fetchUserData(syncId, ItemType.anime),
itemType: ItemType.anime,
),
TrackLibrarySection(
name: "Trending Movies",
syncId: syncId,
func: _fetchGeneralData(syncId, ItemType.manga),
itemType: ItemType.anime,
),
TrackLibrarySection(
name: "Trending Series",
syncId: syncId,
func: _fetchGeneralData(syncId, ItemType.anime),
itemType: ItemType.anime,
),
TrackLibrarySection(
name: "Popular Movies",
syncId: syncId,
func: _fetchGeneralData(syncId, ItemType.manga, rankingType: "popular"),
itemType: ItemType.anime,
),
TrackLibrarySection(
name: "Popular Series",
syncId: syncId,
func: _fetchGeneralData(syncId, ItemType.anime, rankingType: "popular"),
itemType: ItemType.anime,
),
TrackLibrarySection(
name: "Top Movies (All Time)",
syncId: syncId,
func: _fetchGeneralData(
syncId,
ItemType.manga,
rankingType: "favorited/all",
),
itemType: ItemType.anime,
),
TrackLibrarySection(
name: "Top Series (All Time)",
syncId: syncId,
func: _fetchGeneralData(
syncId,
ItemType.anime,
rankingType: "favorited/all",
),
itemType: ItemType.anime,
),
];
}
List<TrackLibrarySection> _sectionsSimkl(int syncId, ItemType itemType) {
return [
TrackLibrarySection(
name: "Continue watching movies",
syncId: syncId,
func: _fetchUserData(syncId, ItemType.manga),
itemType: ItemType.anime,
),
TrackLibrarySection(
name: "Continue watching series",
syncId: syncId,
func: _fetchUserData(syncId, ItemType.anime),
itemType: ItemType.anime,
),
TrackLibrarySection(
name: "Trending Movies",
syncId: syncId,
func: _fetchGeneralData(syncId, ItemType.manga),
itemType: ItemType.anime,
),
TrackLibrarySection(
name: "Trending Series",
syncId: syncId,
func: _fetchGeneralData(syncId, ItemType.anime),
itemType: ItemType.anime,
),
TrackLibrarySection(
name: "Airing Series",
syncId: syncId,
func: _fetchGeneralData(syncId, ItemType.anime, rankingType: "airing"),
itemType: ItemType.anime,
),
TrackLibrarySection(
name: "Top Series (All Time)",
syncId: syncId,
func: _fetchGeneralData(syncId, ItemType.anime, rankingType: "best"),
itemType: ItemType.anime,
),
];
}
List<TrackLibrarySection> _sectionsMAL(int syncId, ItemType itemType) {
return itemType == ItemType.anime
? [
@ -505,6 +613,8 @@ class _TrackerLibraryScreenState extends ConsumerState<TrackerLibraryScreen> {
_getListile(l10n, TrackerProviders.myAnimeList.syncId),
_getListile(l10n, TrackerProviders.anilist.syncId),
_getListile(l10n, TrackerProviders.kitsu.syncId),
_getListile(l10n, TrackerProviders.simkl.syncId),
_getListile(l10n, TrackerProviders.trakt.syncId),
],
),
),
@ -561,13 +671,17 @@ class _TrackerLibraryScreenState extends ConsumerState<TrackerLibraryScreen> {
),
enabled: isLoggedIn,
onTap: () {
if (isManga == null) {
if (isManga == null &&
syncId != TrackerProviders.simkl.syncId &&
syncId != TrackerProviders.trakt.syncId) {
context.pop();
_openSwitchTypeDialog(l10n, syncId);
} else {
ref.read(lastTrackerLibraryLocationStateProvider.notifier).set((
syncId,
isManga,
isManga ??
(syncId != TrackerProviders.simkl.syncId &&
syncId != TrackerProviders.trakt.syncId),
));
context.pop();
}

View file

@ -10,11 +10,12 @@ 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 'anilist.g.dart';
@riverpod
class Anilist extends _$Anilist {
class Anilist extends _$Anilist implements BaseTracker {
final http = MClient.init(reqcopyWith: {'useDartHttpClient': true});
static final _isDesktop = Platform.isWindows || Platform.isLinux;
final String _clientId = _isDesktop ? '13587' : '13588';
@ -77,6 +78,7 @@ class Anilist extends _$Anilist {
}
}
@override
Future<Track> update(Track track, bool isManga) async {
final isNew = track.libraryId == null;
final opName = isNew ? 'AddEntry' : 'UpdateEntry';
@ -114,6 +116,7 @@ class Anilist extends _$Anilist {
return track;
}
@override
Future<List<TrackSearch>> search(String search, bool isManga) async {
final type = isManga ? "MANGA" : "ANIME";
final contentUnit = isManga ? "chapters" : "episodes";
@ -169,6 +172,7 @@ class Anilist extends _$Anilist {
.toList();
}
@override
Future<Track?> findLibItem(Track track, bool isManga) async {
final userId = int.parse(
ref.read(tracksProvider(syncId: syncId))!.username!,
@ -225,6 +229,7 @@ class Anilist extends _$Anilist {
..totalChapter = jsonRes['media'][contentUnit] as int? ?? 0;
}
@override
Future<List<TrackSearch>> fetchGeneralData({
bool isManga = true,
String rankingType =
@ -284,6 +289,7 @@ class Anilist extends _$Anilist {
.toList();
}
@override
Future<List<TrackSearch>> fetchUserData({bool isManga = true}) async {
final userId = int.parse(
ref.read(tracksProvider(syncId: syncId))!.username!,
@ -454,6 +460,7 @@ class Anilist extends _$Anilist {
};
}
@override
List<TrackStatus> statusList(bool isManga) => [
isManga ? TrackStatus.reading : TrackStatus.watching,
TrackStatus.completed,
@ -497,6 +504,7 @@ class Anilist extends _$Anilist {
return {"year": date.year, "month": date.month, "day": date.day};
}
@override
String displayScore(int score) {
final prefs = isar.trackPreferences.getSync(syncId)!.prefs;
final scoreFormat = jsonDecode(prefs!)['scoreFormat'];
@ -515,6 +523,7 @@ class Anilist extends _$Anilist {
};
}
@override
(int, int) getScoreValue() {
final prefs = isar.trackPreferences.getSync(syncId)!.prefs;
String scoreFormat = jsonDecode(prefs!)['scoreFormat'];

View file

@ -0,0 +1,20 @@
import 'package:mangayomi/models/track.dart';
import 'package:mangayomi/models/track_search.dart';
abstract class BaseTracker {
Future<Track?> findLibItem(Track track, bool isManga);
Future<Track> update(Track track, bool isManga);
List<TrackStatus> statusList(bool isManga);
Future<List<TrackSearch>> search(String query, bool isManga);
Future<List<TrackSearch>> fetchGeneralData({
bool isManga,
String rankingType,
});
Future<List<TrackSearch>> fetchUserData({bool isManga});
/// Anilist
(int, int) getScoreValue();
/// Anilist
String displayScore(int score);
}

View file

@ -9,11 +9,12 @@ 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 'kitsu.g.dart';
@riverpod
class Kitsu extends _$Kitsu {
class Kitsu extends _$Kitsu implements BaseTracker {
final http = MClient.init(reqcopyWith: {'useDartHttpClient': true});
final String _clientId =
'dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd';
@ -76,6 +77,7 @@ class Kitsu extends _$Kitsu {
}
}
@override
Future<Track> update(Track track, bool isManga) async {
final isNew = track.libraryId == null;
final String? userId = isNew ? _getUserId() : null;
@ -127,6 +129,7 @@ class Kitsu extends _$Kitsu {
return track;
}
@override
Future<List<TrackSearch>> search(String search, bool isManga) async {
final accessToken = _getAccessToken();
@ -175,6 +178,7 @@ class Kitsu extends _$Kitsu {
.toList();
}
@override
Future<List<TrackSearch>> fetchGeneralData({
bool isManga = true,
String rankingType = "popularityRank",
@ -215,6 +219,7 @@ class Kitsu extends _$Kitsu {
}).toList();
}
@override
Future<List<TrackSearch>> fetchUserData({bool isManga = true}) async {
final type = isManga ? "manga" : "anime";
final userId = _getUserId();
@ -271,6 +276,7 @@ class Kitsu extends _$Kitsu {
return result;
}
@override
Future<Track?> findLibItem(Track track, bool isManga) async {
final type = isManga ? "manga" : "anime";
final userId = _getUserId();
@ -374,6 +380,7 @@ class Kitsu extends _$Kitsu {
};
}
@override
List<TrackStatus> statusList(bool isManga) => [
isManga ? TrackStatus.reading : TrackStatus.watching,
TrackStatus.completed,
@ -411,4 +418,14 @@ class Kitsu extends _$Kitsu {
String? _toKitsuScore(int score) {
return score > 0 ? (score * 2).toString() : null;
}
@override
String displayScore(int score) {
throw UnimplementedError();
}
@override
(int, int) getScoreValue() {
throw UnimplementedError();
}
}

View file

@ -6,7 +6,7 @@ part of 'kitsu.dart';
// RiverpodGenerator
// **************************************************************************
String _$kitsuHash() => r'd46b955c92bc4d7382d32e17827da2e2b3a8434f';
String _$kitsuHash() => r'3f6af42dd0bb3fe543197db692598384ca5cc95f';
/// Copied from Dart SDK
class _SystemHash {

View file

@ -12,11 +12,12 @@ 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 'myanimelist.g.dart';
@riverpod
class MyAnimeList extends _$MyAnimeList {
class MyAnimeList extends _$MyAnimeList implements BaseTracker {
final http = MClient.init(reqcopyWith: {'useDartHttpClient': true});
static const _baseOAuthUrl = 'https://myanimelist.net/v1/oauth2';
static const _baseApiUrl = 'https://api.myanimelist.net/v2';
@ -118,6 +119,7 @@ class MyAnimeList extends _$MyAnimeList {
);
}
@override
Future<List<TrackSearch>> search(String query, isManga) async {
final accessToken = await _getAccessToken();
final url = Uri.parse(
@ -170,6 +172,7 @@ class MyAnimeList extends _$MyAnimeList {
);
}
@override
Future<List<TrackSearch>> fetchGeneralData({
bool isManga = true,
String rankingType = "airing",
@ -209,6 +212,7 @@ class MyAnimeList extends _$MyAnimeList {
}).toList();
}
@override
Future<List<TrackSearch>> fetchUserData({bool isManga = true}) async {
final accessToken = await _getAccessToken();
final item = isManga ? "mangalist" : "animelist";
@ -292,6 +296,7 @@ class MyAnimeList extends _$MyAnimeList {
};
}
@override
List<TrackStatus> statusList(bool isManga) => [
isManga ? TrackStatus.reading : TrackStatus.watching,
TrackStatus.completed,
@ -349,7 +354,8 @@ class MyAnimeList extends _$MyAnimeList {
return jsonDecode(response.body)['name'];
}
Future<Track> findLibItem(Track track, bool isManga) async {
@override
Future<Track?> findLibItem(Track track, bool isManga) async {
final type = isManga ? "manga" : "anime";
final contentUnit = isManga ? 'num_chapters' : 'num_episodes';
final accessToken = await _getAccessToken();
@ -391,6 +397,7 @@ class MyAnimeList extends _$MyAnimeList {
return date.millisecondsSinceEpoch;
}
@override
Future<Track> update(Track track, bool isManga) async {
final accessToken = await _getAccessToken();
final formBody = {
@ -431,4 +438,14 @@ class MyAnimeList extends _$MyAnimeList {
headers: {'Authorization': 'Bearer $accessToken'},
);
}
@override
String displayScore(int score) {
throw UnimplementedError();
}
@override
(int, int) getScoreValue() {
throw UnimplementedError();
}
}

View file

@ -6,7 +6,7 @@ part of 'myanimelist.dart';
// RiverpodGenerator
// **************************************************************************
String _$myAnimeListHash() => r'739c836ddbfc7c2c2b7593304f481e8d35074391';
String _$myAnimeListHash() => r'9846f76ce69f952d20cc1e8edbc5ca565cd4e7c9';
/// Copied from Dart SDK
class _SystemHash {

View file

@ -0,0 +1,421 @@
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();
}
}

View file

@ -0,0 +1,194 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'simkl.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$simklHash() => r'3b8ff48675ba743d39aef595dc6cd70f4bd404cf';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$Simkl extends BuildlessAutoDisposeNotifier<void> {
late final int syncId;
late final ItemType? itemType;
void build({
required int syncId,
required ItemType? itemType,
});
}
/// See also [Simkl].
@ProviderFor(Simkl)
const simklProvider = SimklFamily();
/// See also [Simkl].
class SimklFamily extends Family<void> {
/// See also [Simkl].
const SimklFamily();
/// See also [Simkl].
SimklProvider call({
required int syncId,
required ItemType? itemType,
}) {
return SimklProvider(
syncId: syncId,
itemType: itemType,
);
}
@override
SimklProvider getProviderOverride(
covariant SimklProvider provider,
) {
return call(
syncId: provider.syncId,
itemType: provider.itemType,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'simklProvider';
}
/// See also [Simkl].
class SimklProvider extends AutoDisposeNotifierProviderImpl<Simkl, void> {
/// See also [Simkl].
SimklProvider({
required int syncId,
required ItemType? itemType,
}) : this._internal(
() => Simkl()
..syncId = syncId
..itemType = itemType,
from: simklProvider,
name: r'simklProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$simklHash,
dependencies: SimklFamily._dependencies,
allTransitiveDependencies: SimklFamily._allTransitiveDependencies,
syncId: syncId,
itemType: itemType,
);
SimklProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.syncId,
required this.itemType,
}) : super.internal();
final int syncId;
final ItemType? itemType;
@override
void runNotifierBuild(
covariant Simkl notifier,
) {
return notifier.build(
syncId: syncId,
itemType: itemType,
);
}
@override
Override overrideWith(Simkl Function() create) {
return ProviderOverride(
origin: this,
override: SimklProvider._internal(
() => create()
..syncId = syncId
..itemType = itemType,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
syncId: syncId,
itemType: itemType,
),
);
}
@override
AutoDisposeNotifierProviderElement<Simkl, void> createElement() {
return _SimklProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SimklProvider &&
other.syncId == syncId &&
other.itemType == itemType;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, syncId.hashCode);
hash = _SystemHash.combine(hash, itemType.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SimklRef on AutoDisposeNotifierProviderRef<void> {
/// The parameter `syncId` of this provider.
int get syncId;
/// The parameter `itemType` of this provider.
ItemType? get itemType;
}
class _SimklProviderElement
extends AutoDisposeNotifierProviderElement<Simkl, void> with SimklRef {
_SimklProviderElement(super.provider);
@override
int get syncId => (origin as SimklProvider).syncId;
@override
ItemType? get itemType => (origin as SimklProvider).itemType;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -0,0 +1,425 @@
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 '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}) {}
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"});
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.now().toIso8601String(),
'ids': {'trakt': track.mediaId},
},
],
}
: {
'shows': [
{
'watched_at': DateTime.now().toIso8601String(),
'ids': {'trakt': track.mediaId},
'seasons': [
{
'number': 1,
'episodes': [
for (int i = 1; i <= (track.lastChapterRead ?? 1); i++)
{
'watched_at': DateTime.now().toIso8601String(),
'number': i,
},
],
},
],
},
],
};
await _makePostRequest(url, accessToken, body);
return track;
}
Future<String> _getAccessToken() async {
final track = ref.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!;
final refreshed = await _tryRefreshToken(mALOAuth);
if (refreshed == null) {
ref.read(tracksProvider(syncId: syncId).notifier).logout();
botToast("Trakt.tv Token expired");
throw Exception("Token expired");
}
final username = await _getUserName(refreshed.accessToken!);
_saveOAuth(username, refreshed);
return refreshed.accessToken!;
}
Future<OAuth?> _tryRefreshToken(OAuth oldOAuth) async {
String primaryClientId = oldOAuth.clientId ?? _clientId;
Future<OAuth?> tryRefresh(String cid) async {
final response = await http.post(
Uri.parse('$_baseOAuthUrl/token'),
body: {
'refresh_token': oldOAuth.refreshToken,
'client_id': cid,
'client_secret': _clientSecret,
'redirect_uri': _redirectUri,
'grant_type': 'refresh_token',
},
headers: {'Content-Type': 'application/json'},
);
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) {
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)['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('$_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',
'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();
}
}

View file

@ -0,0 +1,194 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'trakt_tv.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$traktTvHash() => r'de97ae0edbc905d07af2ce8758441fba6cdd7be2';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$TraktTv extends BuildlessAutoDisposeNotifier<void> {
late final int syncId;
late final ItemType? itemType;
void build({
required int syncId,
required ItemType? itemType,
});
}
/// See also [TraktTv].
@ProviderFor(TraktTv)
const traktTvProvider = TraktTvFamily();
/// See also [TraktTv].
class TraktTvFamily extends Family<void> {
/// See also [TraktTv].
const TraktTvFamily();
/// See also [TraktTv].
TraktTvProvider call({
required int syncId,
required ItemType? itemType,
}) {
return TraktTvProvider(
syncId: syncId,
itemType: itemType,
);
}
@override
TraktTvProvider getProviderOverride(
covariant TraktTvProvider provider,
) {
return call(
syncId: provider.syncId,
itemType: provider.itemType,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'traktTvProvider';
}
/// See also [TraktTv].
class TraktTvProvider extends AutoDisposeNotifierProviderImpl<TraktTv, void> {
/// See also [TraktTv].
TraktTvProvider({
required int syncId,
required ItemType? itemType,
}) : this._internal(
() => TraktTv()
..syncId = syncId
..itemType = itemType,
from: traktTvProvider,
name: r'traktTvProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$traktTvHash,
dependencies: TraktTvFamily._dependencies,
allTransitiveDependencies: TraktTvFamily._allTransitiveDependencies,
syncId: syncId,
itemType: itemType,
);
TraktTvProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.syncId,
required this.itemType,
}) : super.internal();
final int syncId;
final ItemType? itemType;
@override
void runNotifierBuild(
covariant TraktTv notifier,
) {
return notifier.build(
syncId: syncId,
itemType: itemType,
);
}
@override
Override overrideWith(TraktTv Function() create) {
return ProviderOverride(
origin: this,
override: TraktTvProvider._internal(
() => create()
..syncId = syncId
..itemType = itemType,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
syncId: syncId,
itemType: itemType,
),
);
}
@override
AutoDisposeNotifierProviderElement<TraktTv, void> createElement() {
return _TraktTvProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is TraktTvProvider &&
other.syncId == syncId &&
other.itemType == itemType;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, syncId.hashCode);
hash = _SystemHash.combine(hash, itemType.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin TraktTvRef on AutoDisposeNotifierProviderRef<void> {
/// The parameter `syncId` of this provider.
int get syncId;
/// The parameter `itemType` of this provider.
ItemType? get itemType;
}
class _TraktTvProviderElement
extends AutoDisposeNotifierProviderElement<TraktTv, void> with TraktTvRef {
_TraktTvProviderElement(super.provider);
@override
int get syncId => (origin as TraktTvProvider).syncId;
@override
ItemType? get itemType => (origin as TraktTvProvider).itemType;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View file

@ -67,11 +67,21 @@ TrackStatus toTrackStatus(TrackStatus status, ItemType itemType, int syncId) {
"Anilist",
const Color.fromRGBO(51, 37, 50, 1),
),
_ => (
3 => (
"assets/trackers_icons/tracker_kitsu.webp",
"Kitsu",
const Color.fromRGBO(18, 25, 35, 1),
),
4 => (
"assets/trackers_icons/tracker_simkl.webp",
"Simkl",
const Color.fromRGBO(8, 8, 8, 1),
),
_ => (
"assets/trackers_icons/tracker_trakt.webp",
"Trakt",
const Color.fromRGBO(175, 54, 162, 1),
),
};
}