diff --git a/lib/features/connections/types/stremio/stremio_base.types.dart b/lib/features/connections/types/stremio/stremio_base.types.dart index 05bc16a..0a70b7d 100644 --- a/lib/features/connections/types/stremio/stremio_base.types.dart +++ b/lib/features/connections/types/stremio/stremio_base.types.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:pocketbase/pocketbase.dart'; @@ -324,9 +325,15 @@ class Meta extends LibraryItem { } Video? get currentVideo { - return videos?.firstWhere((episode) { - return nextEpisode == episode.episode && nextSeason == episode.season; - }); + if (type == "movie") { + return null; + } + + return videos?.firstWhereOrNull( + (episode) { + return nextEpisode == episode.episode && nextSeason == episode.season; + }, + ); } Meta({ diff --git a/lib/features/connections/widget/stremio/stremio_card.dart b/lib/features/connections/widget/stremio/stremio_card.dart index 508e491..78c15c6 100644 --- a/lib/features/connections/widget/stremio/stremio_card.dart +++ b/lib/features/connections/widget/stremio/stremio_card.dart @@ -255,11 +255,31 @@ class StremioCard extends StatelessWidget { child: (backgroundImage == null) ? Center( child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(meta.name ?? "No name"), - if (meta.description != null) Text(meta.description!), + const Expanded( + child: Center( + child: Icon( + Icons.image_not_supported, + size: 26, + ), + ), + ), + Container( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + meta.name ?? "No name", + style: + Theme.of(context).textTheme.labelMedium?.copyWith( + color: Colors.black54, + fontWeight: FontWeight.w600, + ), + ), + ), + ), ], ), ) @@ -331,7 +351,8 @@ class StremioCard extends StatelessWidget { left: 0, right: 0, child: LinearProgressIndicator( - value: meta.progress, + value: meta.progress! / 100, + minHeight: 5, ), ), if (meta.nextEpisode != null && meta.nextSeason != null) diff --git a/lib/features/doc_viewer/container/video_viewer.dart b/lib/features/doc_viewer/container/video_viewer.dart index 650e582..151853a 100644 --- a/lib/features/doc_viewer/container/video_viewer.dart +++ b/lib/features/doc_viewer/container/video_viewer.dart @@ -200,10 +200,12 @@ class _VideoViewerState extends State { }); final oneMore = player.stream.completed.listen((item) { - TraktService.instance!.stopScrobbling( - meta: widget.meta as types.Meta, - progress: currentProgressInPercentage, - ); + if (item && player.state.duration.inSeconds > 10) { + TraktService.instance!.stopScrobbling( + meta: widget.meta as types.Meta, + progress: currentProgressInPercentage, + ); + } }); listener.add(streams); diff --git a/lib/features/trakt/containers/up_next.container.dart b/lib/features/trakt/containers/up_next.container.dart index cf519e0..7c5aec1 100644 --- a/lib/features/trakt/containers/up_next.container.dart +++ b/lib/features/trakt/containers/up_next.container.dart @@ -1,10 +1,10 @@ -import 'package:cached_query_flutter/cached_query_flutter.dart'; import 'package:flutter/material.dart'; import 'package:madari_client/features/connections/service/base_connection_service.dart'; import 'package:madari_client/features/trakt/service/trakt.service.dart'; import '../../connections/widget/base/render_library_list.dart'; import '../../settings/screen/trakt_integration_screen.dart'; +import '../service/trakt_cache.service.dart'; class TraktContainer extends StatefulWidget { final String loadId; @@ -14,43 +14,45 @@ class TraktContainer extends StatefulWidget { }); @override - State createState() => _TraktContainerState(); + State createState() => TraktContainerState(); } -class _TraktContainerState extends State { - late Query> _query; +class TraktContainerState extends State { + late final TraktCacheService _cacheService; + List? _cachedItems; + bool _isLoading = false; + String? _error; @override void initState() { super.initState(); + _cacheService = TraktCacheService(); + _loadData(); + } - _query = Query( - key: widget.loadId, - config: QueryConfig( - cacheDuration: const Duration(days: 30), - refetchDuration: const Duration(minutes: 10), - storageDuration: const Duration(days: 30), - ), - queryFn: () { - switch (widget.loadId) { - case "up_next": - case "up_next_series": - return TraktService.instance!.getUpNextSeries(); - case "continue_watching": - return TraktService.instance!.getContinueWatching(); - case "upcoming_schedule": - return TraktService.instance!.getUpcomingSchedule(); - case "watchlist": - return TraktService.instance!.getWatchlist(); - case "show_recommendations": - return TraktService.instance!.getShowRecommendations(); - case "movie_recommendations": - return TraktService.instance!.getMovieRecommendations(); - default: - throw Exception("Invalid loadId: ${widget.loadId}"); - } - }, - ); + Future _loadData() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final items = await _cacheService.fetchData(widget.loadId); + setState(() { + _cachedItems = items; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + Future refresh() async { + await _cacheService.refresh(widget.loadId); + await _loadData(); } String get title { @@ -61,97 +63,89 @@ class _TraktContainerState extends State { @override Widget build(BuildContext context) { - return QueryBuilder( - query: _query, - builder: (context, snapshot) { - final theme = Theme.of(context); - final item = snapshot.data; + final theme = Theme.of(context); - return Container( - margin: const EdgeInsets.only(bottom: 6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Container( + margin: const EdgeInsets.only(bottom: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - title, - style: theme.textTheme.bodyLarge, - ), - const Spacer(), - SizedBox( - height: 30, - child: TextButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return Scaffold( - appBar: AppBar( - title: Text("Trakt - $title"), - ), - body: Padding( - padding: const EdgeInsets.all(8.0), - child: RenderListItems( - items: item ?? [], - error: snapshot.error, - hasError: - snapshot.status == QueryStatus.error, - heroPrefix: "trakt_up_next${widget.loadId}", - service: TraktService.stremioService!, - isGrid: true, - isWide: false, - ), - ), - ); - }, - ), - ); - }, - child: Text( - "Show more", - style: theme.textTheme.labelMedium?.copyWith( - color: Colors.white70, - ), + Text( + title, + style: theme.textTheme.bodyLarge, + ), + const Spacer(), + SizedBox( + height: 30, + child: TextButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return Scaffold( + appBar: AppBar( + title: Text("Trakt - $title"), + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: RenderListItems( + items: _cachedItems ?? [], + error: _error, + hasError: _error != null, + heroPrefix: "trakt_up_next${widget.loadId}", + service: TraktService.stremioService!, + isGrid: true, + isWide: false, + ), + ), + ); + }, ), + ); + }, + child: Text( + "Show more", + style: theme.textTheme.labelMedium?.copyWith( + color: Colors.white70, ), ), - ], + ), ), - const SizedBox( - height: 8, - ), - Stack( - children: [ - if ((item ?? []).isEmpty && - snapshot.status != QueryStatus.loading) - const Positioned.fill( - child: Center( - child: Text("Nothing to see here"), - ), - ), - SizedBox( - height: getListHeight(context), - child: snapshot.status == QueryStatus.loading - ? SpinnerCards( - isWide: widget.loadId == "up_next_series", - ) - : RenderListItems( - isWide: widget.loadId == "up_next_series", - items: item ?? [], - error: snapshot.error, - hasError: snapshot.status == QueryStatus.error, - heroPrefix: "trakt_up_next${widget.loadId}", - service: TraktService.stremioService!, - ), - ), - ], - ) ], ), - ); - }, + const SizedBox( + height: 8, + ), + Stack( + children: [ + if ((_cachedItems ?? []).isEmpty && !_isLoading) + const Positioned.fill( + child: Center( + child: Text("Nothing to see here"), + ), + ), + SizedBox( + height: getListHeight(context), + child: _isLoading + ? SpinnerCards( + isWide: widget.loadId == "up_next_series", + ) + : RenderListItems( + isWide: widget.loadId == "up_next_series", + items: _cachedItems ?? [], + error: _error, + hasError: _error != null, + heroPrefix: "trakt_up_next${widget.loadId}", + service: TraktService.stremioService!, + ), + ), + ], + ) + ], + ), ); } } diff --git a/lib/features/trakt/service/trakt.service.dart b/lib/features/trakt/service/trakt.service.dart index cdca9cd..27a2aae 100644 --- a/lib/features/trakt/service/trakt.service.dart +++ b/lib/features/trakt/service/trakt.service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:cached_storage/cached_storage.dart'; @@ -16,18 +17,40 @@ class TraktService { static const String _baseUrl = 'https://api.trakt.tv'; static const String _apiVersion = '2'; + static const int _authedPostLimit = 100; + static const int _authedGetLimit = 1000; + static const Duration _rateLimitWindow = Duration(minutes: 5); + + static const Duration _cacheRevalidationInterval = Duration(minutes: 5); + static TraktService? _instance; static TraktService? get instance => _instance; static BaseConnectionService? stremioService; + int _postRequestCount = 0; + int _getRequestCount = 0; + DateTime _lastRateLimitReset = DateTime.now(); + + final Map _cache = {}; + Timer? _cacheRevalidationTimer; + static ensureInitialized() async { if (_instance != null) { return _instance; } + AppEngine.engine.pb.authStore.onChange.listen((item) { + if (!AppEngine.engine.pb.authStore.isValid) { + _instance?._cache.clear(); + } + }); + final traktService = TraktService(); await traktService.initStremioService(); _instance = traktService; + + // Start cache revalidation timer + _instance!._startCacheRevalidation(); } Future initStremioService() async { @@ -75,6 +98,93 @@ class TraktService { 'Authorization': 'Bearer $_token', }; + Future _checkRateLimit(String method) async { + final now = DateTime.now(); + if (now.difference(_lastRateLimitReset) > _rateLimitWindow) { + _postRequestCount = 0; + _getRequestCount = 0; + _lastRateLimitReset = now; + } + + if (method == 'GET') { + if (_getRequestCount >= _authedGetLimit) { + throw Exception('GET rate limit exceeded'); + } + _getRequestCount++; + } else if (method == 'POST' || method == 'PUT' || method == 'DELETE') { + if (_postRequestCount >= _authedPostLimit) { + throw Exception('POST/PUT/DELETE rate limit exceeded'); + } + _postRequestCount++; + } + } + + void _startCacheRevalidation() { + _cacheRevalidationTimer = Timer.periodic( + _cacheRevalidationInterval, + (_) async { + await _revalidateCache(); + }, + ); + } + + Future _revalidateCache() async { + for (final key in _cache.keys) { + final cachedData = _cache[key]; + if (cachedData != null) { + final updatedData = await _makeRequest(key, bypassCache: true); + _cache[key] = updatedData; + } + } + } + + Future _makeRequest(String url, {bool bypassCache = false}) async { + if (!bypassCache && _cache.containsKey(url)) { + return _cache[url]; + } + + await _checkRateLimit('GET'); + + final response = await http.get(Uri.parse(url), headers: headers); + + if (response.statusCode != 200) { + throw Exception('Failed to fetch data from $url'); + } + + final data = json.decode(response.body); + _cache[url] = data; + + return data; + } + + Map _buildObjectForMeta(Meta meta) { + if (meta.type == "movie") { + return { + 'movie': { + 'title': meta.name, + 'year': meta.year, + 'ids': { + 'imdb': meta.imdbId ?? meta.id, + }, + }, + }; + } else { + return { + "show": { + "title": meta.name, + "year": meta.year, + "ids": { + "imdb": meta.imdbId ?? meta.id, + } + }, + "episode": { + "season": meta.nextSeason, + "number": meta.nextEpisode, + }, + }; + } + } + Future> getUpNextSeries() async { await initStremioService(); @@ -83,33 +193,19 @@ class TraktService { } try { - final watchedResponse = await http.get( - Uri.parse('$_baseUrl/sync/watched/shows'), - headers: headers, + final List watchedShows = await _makeRequest( + '$_baseUrl/sync/watched/shows', ); - if (watchedResponse.statusCode != 200) { - throw ArgumentError('Failed to fetch watched shows'); - } - - final watchedShows = json.decode(watchedResponse.body) as List; - final progressFutures = watchedShows.map((show) async { final showId = show['show']['ids']['trakt']; final imdb = show['show']['ids']['imdb']; try { - final progressResponse = await http.get( - Uri.parse('$_baseUrl/shows/$showId/progress/watched'), - headers: headers, + final progress = await _makeRequest( + '$_baseUrl/shows/$showId/progress/watched', ); - if (progressResponse.statusCode != 200) { - return null; - } - - final progress = json.decode(progressResponse.body); - final nextEpisode = progress['next_episode']; if (nextEpisode != null && imdb != null) { @@ -155,16 +251,7 @@ class TraktService { } try { - final watchedResponse = await http.get( - Uri.parse('$_baseUrl/sync/playback'), - headers: headers, - ); - - if (watchedResponse.statusCode != 200) { - throw Exception('Failed to fetch watched movies'); - } - - final continueWatching = json.decode(watchedResponse.body) as List; + final continueWatching = await _makeRequest('$_baseUrl/sync/playback'); final Map progress = {}; @@ -232,20 +319,10 @@ class TraktService { } try { - final scheduleResponse = await http.get( - Uri.parse( - '$_baseUrl/calendars/my/shows/${DateFormat('yyyy-MM-dd').format(DateTime.now())}/7', - ), - headers: headers, + final List scheduleShows = await _makeRequest( + '$_baseUrl/calendars/my/shows/${DateFormat('yyyy-MM-dd').format(DateTime.now())}/7', ); - if (scheduleResponse.statusCode != 200) { - print('Failed to fetch upcoming schedule'); - throw Error(); - } - - final scheduleShows = json.decode(scheduleResponse.body) as List; - final result = await stremioService!.getBulkItem( scheduleShows.map((show) { final imdb = show['show']['ids']['imdb']; @@ -272,16 +349,7 @@ class TraktService { } try { - final watchlistResponse = await http.get( - Uri.parse('$_baseUrl/sync/watchlist'), - headers: headers, - ); - - if (watchlistResponse.statusCode != 200) { - throw Exception('Failed to fetch watchlist'); - } - - final watchlistItems = json.decode(watchlistResponse.body) as List; + final watchlistItems = await _makeRequest('$_baseUrl/sync/watchlist'); final result = await stremioService!.getBulkItem( watchlistItems @@ -317,17 +385,8 @@ class TraktService { } try { - final recommendationsResponse = await http.get( - Uri.parse('$_baseUrl/recommendations/shows'), - headers: headers, - ); - - if (recommendationsResponse.statusCode != 200) { - throw Exception('Failed to fetch show recommendations'); - } - final recommendedShows = - json.decode(recommendationsResponse.body) as List; + await _makeRequest('$_baseUrl/recommendations/shows'); final result = (await stremioService!.getBulkItem( recommendedShows @@ -363,17 +422,8 @@ class TraktService { } try { - final recommendationsResponse = await http.get( - Uri.parse('$_baseUrl/recommendations/movies'), - headers: headers, - ); - - if (recommendationsResponse.statusCode != 200) { - throw Exception('Failed to fetch movie recommendations'); - } - final recommendedMovies = - json.decode(recommendationsResponse.body) as List; + await _makeRequest('$_baseUrl/recommendations/movies'); final result = await stremioService!.getBulkItem( recommendedMovies @@ -421,16 +471,7 @@ class TraktService { } Future getTraktIdForMovie(String imdb) async { - final id = await http.get( - Uri.parse("$_baseUrl/search/imdb/$imdb"), - headers: headers, - ); - - if (id.statusCode != 200) { - throw ArgumentError("failed to get trakt id"); - } - - final body = jsonDecode(id.body) as List; + final body = await _makeRequest("$_baseUrl/search/imdb/$imdb"); if (body.isEmpty) { return null; @@ -457,6 +498,8 @@ class TraktService { return; } + await _checkRateLimit('POST'); + try { final response = await http.post( Uri.parse('$_baseUrl/scrobble/start'), @@ -472,6 +515,9 @@ class TraktService { print(response.body); throw Exception('Failed to start scrobbling'); } + + _cache.remove('$_baseUrl/sync/watched/shows'); + _cache.remove('$_baseUrl/sync/playback'); } catch (e, stack) { print('Error starting scrobbling: $e'); print(stack); @@ -487,6 +533,8 @@ class TraktService { return; } + await _checkRateLimit('POST'); + try { final response = await http.post( Uri.parse('$_baseUrl/scrobble/pause'), @@ -507,34 +555,6 @@ class TraktService { } } - Map _buildObjectForMeta(Meta meta) { - if (meta.type == "movie") { - return { - 'movie': { - 'title': meta.name, - 'year': meta.year, - 'ids': { - 'imdb': meta.imdbId ?? meta.id, - }, - }, - }; - } else { - return { - "show": { - "title": meta.name, - "year": meta.year, - "ids": { - "imdb": meta.imdbId ?? meta.id, - } - }, - "episode": { - "season": meta.nextSeason, - "number": meta.nextEpisode, - }, - }; - } - } - Future stopScrobbling({ required Meta meta, required double progress, @@ -543,6 +563,8 @@ class TraktService { return; } + await _checkRateLimit('POST'); + try { final response = await http.post( Uri.parse('$_baseUrl/scrobble/stop'), @@ -557,6 +579,9 @@ class TraktService { print(response.statusCode); throw Exception('Failed to stop scrobbling'); } + + _cache.remove('$_baseUrl/sync/watched/shows'); + _cache.remove('$_baseUrl/sync/playback'); } catch (e, stack) { print('Error stopping scrobbling: $e'); print(stack); @@ -571,16 +596,7 @@ class TraktService { try { if (meta.type == "series") { - final response = await http.get( - Uri.parse("$_baseUrl/sync/playback/episodes"), - headers: headers, - ); - - if (response.statusCode != 200) { - return []; - } - - final body = jsonDecode(response.body) as List; + final body = await _makeRequest("$_baseUrl/sync/playback/episodes"); final List result = []; @@ -621,16 +637,7 @@ class TraktService { return result; } else { - final response = await http.get( - Uri.parse("$_baseUrl/sync/playback/movies"), - headers: headers, - ); - - if (response.statusCode != 200) { - return []; - } - - final body = jsonDecode(response.body) as List; + final body = await _makeRequest("$_baseUrl/sync/playback/movies"); for (final item in body) { if (item["type"] != "movie") { diff --git a/lib/features/trakt/service/trakt_cache.service.dart b/lib/features/trakt/service/trakt_cache.service.dart new file mode 100644 index 0000000..42d288c --- /dev/null +++ b/lib/features/trakt/service/trakt_cache.service.dart @@ -0,0 +1,65 @@ +import 'package:madari_client/features/trakt/service/trakt.service.dart'; + +import '../../connections/service/base_connection_service.dart'; + +class TraktCacheService { + static final TraktCacheService _instance = TraktCacheService._internal(); + factory TraktCacheService() => _instance; + TraktCacheService._internal(); + + final Map> _cache = {}; + final Map _isLoading = {}; + final Map _errors = {}; + + Future> fetchData(String loadId) async { + if (_cache.containsKey(loadId)) { + return _cache[loadId]!; + } + + _isLoading[loadId] = true; + _errors[loadId] = null; + + try { + final data = await _fetchFromTrakt(loadId); + _cache[loadId] = data; + return data; + } catch (e) { + _errors[loadId] = e.toString(); + rethrow; + } finally { + _isLoading[loadId] = false; + } + } + + Future refresh(String loadId) async { + _cache.remove(loadId); + _errors.remove(loadId); + await fetchData(loadId); + } + + List? getCachedData(String loadId) => _cache[loadId]; + + bool isLoading(String loadId) => _isLoading[loadId] ?? false; + + String? getError(String loadId) => _errors[loadId]; + + Future> _fetchFromTrakt(String loadId) async { + switch (loadId) { + case "up_next": + case "up_next_series": + return TraktService.instance!.getUpNextSeries(); + case "continue_watching": + return TraktService.instance!.getContinueWatching(); + case "upcoming_schedule": + return TraktService.instance!.getUpcomingSchedule(); + case "watchlist": + return TraktService.instance!.getWatchlist(); + case "show_recommendations": + return TraktService.instance!.getShowRecommendations(); + case "movie_recommendations": + return TraktService.instance!.getMovieRecommendations(); + default: + throw Exception("Invalid loadId: $loadId"); + } + } +} diff --git a/lib/pages/home_tab.page.dart b/lib/pages/home_tab.page.dart index bdaadc5..2ebf234 100644 --- a/lib/pages/home_tab.page.dart +++ b/lib/pages/home_tab.page.dart @@ -47,6 +47,28 @@ class _HomeTabPageState extends State { ], ); + final Map> _keyMap = {}; + + GlobalKey _getKey(int id) { + return _keyMap.putIfAbsent( + id, + () => GlobalKey(), + ); + } + + Future _onRefresh() async { + final List promises = []; + for (final item in traktLibraries) { + final state = _getKey(traktLibraries.indexOf(item)).currentState; + + if (state == null) continue; + + promises.add(state.refresh()); + } + + await Future.wait(promises); + } + @override void initState() { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -91,6 +113,7 @@ class _HomeTabPageState extends State { setState(() { traktLibraries = getTraktLibraries(); }); + await _onRefresh(); return; }, child: QueryBuilder( @@ -123,6 +146,8 @@ class _HomeTabPageState extends State { setState(() { traktLibraries = getTraktLibraries(); }); + + await _onRefresh(); }, ), ), @@ -140,6 +165,7 @@ class _HomeTabPageState extends State { final category = traktLibraries[index]; return TraktContainer( + key: _getKey(index), loadId: category, ); }