diff --git a/lib/data/global_logs.dart b/lib/data/global_logs.dart new file mode 100644 index 0000000..8c2fcca --- /dev/null +++ b/lib/data/global_logs.dart @@ -0,0 +1 @@ +final List globalLogs = []; diff --git a/lib/features/connection/containers/auto_import.dart b/lib/features/connection/containers/auto_import.dart index d68fe23..9156fc3 100644 --- a/lib/features/connection/containers/auto_import.dart +++ b/lib/features/connection/containers/auto_import.dart @@ -24,13 +24,13 @@ class _AutoImportState extends State { late StremioService _stremio; final List _selected = []; bool _isLoading = false; + bool _selectAll = false; Future>? _folders; @override void initState() { super.initState(); - initialValueImport(); } @@ -89,6 +89,21 @@ class _AutoImportState extends State { } } + void toggleSelectAll() async { + final folders = await _folders; + if (folders == null) return; + + setState(() { + _selectAll = !_selectAll; + if (_selectAll) { + _selected.clear(); + _selected.addAll(folders); + } else { + _selected.clear(); + } + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -97,6 +112,17 @@ class _AutoImportState extends State { title: const Text("Import Libraries"), backgroundColor: Colors.transparent, actions: [ + IconButton( + icon: Icon(_selectAll + ? Icons.check_box_outlined + : Icons.check_box_outline_blank), + onPressed: () { + toggleSelectAll(); + }, + ), + const SizedBox( + width: 12, + ), ElevatedButton.icon( onPressed: _selected.isNotEmpty ? () { @@ -130,25 +156,28 @@ class _AutoImportState extends State { ); } - return ListView.builder( - itemCount: snapshot.data?.length ?? 0, - itemBuilder: (item, index) { - final item = snapshot.data![index]; + final folders = snapshot.data!; - final selected = - _selected.where((selected) => selected.id == item.id); + return ListView.builder( + itemCount: folders.length, + itemBuilder: (context, index) { + final item = folders[index]; + + final isSelected = + _selected.any((selected) => selected.id == item.id); return ListTile( onTap: () { setState(() { - if (selected.isEmpty) { - _selected.add(item); + if (isSelected) { + _selected + .removeWhere((selected) => selected.id == item.id); } else { - _selected.remove(item); + _selected.add(item); } }); }, - leading: selected.isNotEmpty + leading: isSelected ? const Icon(Icons.check) : const Icon(Icons.check_box_outline_blank), title: Text(item.title), diff --git a/lib/features/connections/service/stremio_connection_service.dart b/lib/features/connections/service/stremio_connection_service.dart index 5cd3483..febc1e0 100644 --- a/lib/features/connections/service/stremio_connection_service.dart +++ b/lib/features/connections/service/stremio_connection_service.dart @@ -5,6 +5,7 @@ import 'package:cached_query/cached_query.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:json_annotation/json_annotation.dart'; +import 'package:logging/logging.dart'; import 'package:madari_client/features/connections/types/base/base.dart'; import 'package:madari_client/features/connections/widget/stremio/stremio_card.dart'; import 'package:madari_client/features/connections/widget/stremio/stremio_list_item.dart'; @@ -23,87 +24,96 @@ typedef OnStreamCallback = void Function(List? items, Error?); class StremioConnectionService extends BaseConnectionService { final StremioConfig config; + final Logger _logger = Logger('StremioConnectionService'); StremioConnectionService({ required super.connectionId, required this.config, - }); + }) { + _logger.info('StremioConnectionService initialized with config: $config'); + } @override Future getItemById(LibraryItem id) async { + _logger.fine('Fetching item by ID: ${id.id}'); return Query( - key: "meta_${id.id}", - config: QueryConfig( - cacheDuration: const Duration(days: 30), - refetchDuration: (id as Meta).type == "movie" - ? const Duration(days: 30) - : const Duration( - days: 1, - ), - ), - queryFn: () async { - for (final addon in config.addons) { - final manifest = await _getManifest(addon); + key: "meta_${id.id}", + config: QueryConfig( + cacheDuration: const Duration(days: 30), + refetchDuration: (id as Meta).type == "movie" + ? const Duration(days: 30) + : const Duration(days: 1), + ), + queryFn: () async { + for (final addon in config.addons) { + _logger.finer('Checking addon: $addon'); + final manifest = await _getManifest(addon); - if (manifest.resources == null) { - continue; - } + if (manifest.resources == null) { + _logger.finer('No resources found in manifest for addon: $addon'); + continue; + } - List idPrefixes = []; + List idPrefixes = []; + bool isMeta = false; - bool isMeta = false; - for (final item in manifest.resources!) { - if (item.name == "meta") { - idPrefixes.addAll( - (item.idPrefix ?? []) + (item.idPrefixes ?? [])); - isMeta = true; - break; - } - } + for (final item in manifest.resources!) { + if (item.name == "meta") { + idPrefixes + .addAll((item.idPrefix ?? []) + (item.idPrefixes ?? [])); + isMeta = true; + break; + } + } - if (isMeta == false) { - continue; - } + if (!isMeta) { + _logger + .finer('No meta resource found in manifest for addon: $addon'); + continue; + } - final ids = ((manifest.idPrefixes ?? []) + idPrefixes) - .firstWhere((item) => id.id.startsWith(item), - orElse: () => ""); + final ids = ((manifest.idPrefixes ?? []) + idPrefixes) + .firstWhere((item) => id.id.startsWith(item), orElse: () => ""); - if (ids.isEmpty) { - continue; - } + if (ids.isEmpty) { + _logger.finer('No matching ID prefix found for addon: $addon'); + continue; + } - final result = await http.get( - Uri.parse( - "${_getAddonBaseURL(addon)}/meta/${id.type}/${id.id}.json", - ), - ); + final result = await http.get( + Uri.parse( + "${_getAddonBaseURL(addon)}/meta/${id.type}/${id.id}.json"), + ); - final item = jsonDecode(result.body); + final item = jsonDecode(result.body); - if (item['meta'] == null) { - return null; - } + if (item['meta'] == null) { + _logger.finer( + 'No meta data found for item: ${id.id} in addon: $addon'); + return null; + } - return StreamMetaResponse.fromJson(item).meta; - } + return StreamMetaResponse.fromJson(item).meta; + } - return null; - }) + _logger.warning('No meta data found for item: ${id.id} in any addon'); + return null; + }, + ) .stream - .where((item) { - return item.status != QueryStatus.loading; - }) + .where((item) => item.status != QueryStatus.loading) .first .then((docs) { - if (docs.error != null) { - throw docs.error; - } - return docs.data; - }); + if (docs.error != null) { + _logger.severe('Error fetching item by ID: ${docs.error}'); + throw docs.error!; + } + return docs.data; + }); } List getConfig(dynamic configOutput) { + _logger.fine('Parsing config output'); final List configItems = []; for (final item in configOutput) { @@ -113,6 +123,7 @@ class StremioConnectionService extends BaseConnectionService { configItems.add(itemToPush); } + _logger.finer('Config parsed successfully: $configItems'); return configItems; } @@ -124,6 +135,7 @@ class StremioConnectionService extends BaseConnectionService { int? perPage, String? cursor, }) async { + _logger.fine('Fetching items for library: ${library.id}'); final List returnValue = []; final configItems = getConfig(library.config); @@ -160,35 +172,31 @@ class StremioConnectionService extends BaseConnectionService { final result = await Query( config: QueryConfig( - cacheDuration: const Duration( - hours: 8, - ), + cacheDuration: const Duration(hours: 8), ), queryFn: () async { - final httpBody = await http.get( - Uri.parse(url), - ); - + _logger.finer('Fetching catalog from URL: $url'); + final httpBody = await http.get(Uri.parse(url)); return StrmioMeta.fromJson(jsonDecode(httpBody.body)); }, key: url, ) .stream - .where((item) { - return item.status != QueryStatus.loading; - }) + .where((item) => item.status != QueryStatus.loading) .first .then((docs) { - if (docs.error != null) { - throw docs.error; - } - return docs.data!; - }); + if (docs.error != null) { + _logger.severe('Error fetching catalog: ${docs.error}'); + throw docs.error!; + } + return docs.data!; + }); hasMore = result.hasMore ?? false; returnValue.addAll(result.metas ?? []); } + _logger.finer('Items fetched successfully: ${returnValue.length} items'); return PagePaginatedResult( items: returnValue.toList(), currentPage: page ?? 1, @@ -199,6 +207,7 @@ class StremioConnectionService extends BaseConnectionService { @override Widget renderCard(LibraryItem item, String heroPrefix) { + _logger.fine('Rendering card for item: ${item.id}'); return StremioCard( item: item, prefix: heroPrefix, @@ -209,7 +218,9 @@ class StremioConnectionService extends BaseConnectionService { @override Future> getBulkItem(List ids) async { + _logger.fine('Fetching bulk items: ${ids.length} items'); if (ids.isEmpty) { + _logger.finer('No items to fetch'); return []; } @@ -218,6 +229,7 @@ class StremioConnectionService extends BaseConnectionService { (res) async { return getItemById(res).then((item) { if (item == null) { + _logger.finer('Item not found: ${res.id}'); return null; } @@ -228,9 +240,8 @@ class StremioConnectionService extends BaseConnectionService { nextEpisodeTitle: res.nextEpisodeTitle, ); }).catchError((err, stack) { - print(err); - print(stack); - return null; + _logger.severe('Error fetching item: ${res.id}', err, stack); + return (res as Meta); }); }, ), @@ -241,44 +252,48 @@ class StremioConnectionService extends BaseConnectionService { @override Widget renderList(LibraryItem item, String heroPrefix) { + _logger.fine('Rendering list item: ${item.id}'); return StremioListItem(item: item); } Future _getManifest(String url) async { + _logger.fine('Fetching manifest from URL: $url'); return Query( - key: url, - config: QueryConfig( - cacheDuration: const Duration(days: 30), - refetchDuration: const Duration(days: 1), - ), - queryFn: () async { - final String result; - if (manifestCache.containsKey(url)) { - result = manifestCache[url]!; - } else { - result = (await http.get(Uri.parse(url))).body; - manifestCache[url] = result; - } + key: url, + config: QueryConfig( + cacheDuration: const Duration(days: 30), + refetchDuration: const Duration(days: 1), + ), + queryFn: () async { + final String result; + if (manifestCache.containsKey(url)) { + _logger.finer('Manifest found in cache for URL: $url'); + result = manifestCache[url]!; + } else { + _logger.finer('Fetching manifest from network for URL: $url'); + result = (await http.get(Uri.parse(url))).body; + manifestCache[url] = result; + } - final body = jsonDecode(result); - final resultFinal = StremioManifest.fromJson(body); - return resultFinal; - }) + final body = jsonDecode(result); + final resultFinal = StremioManifest.fromJson(body); + _logger.finer('Manifest successfully parsed for URL: $url'); + return resultFinal; + }, + ) .stream - .where((item) { - return item.status != QueryStatus.loading; - }) + .where((item) => item.status != QueryStatus.loading) .first .then((docs) { - if (docs.error != null) { - throw docs.error; - } - return docs.data!; - }); - ; + if (docs.error != null) { + _logger.severe('Error fetching manifest: ${docs.error}'); + throw docs.error!; + } + return docs.data!; + }); } - _getAddonBaseURL(String input) { + String _getAddonBaseURL(String input) { return input.endsWith("/manifest.json") ? input.replaceAll("/manifest.json", "") : input; @@ -286,6 +301,7 @@ class StremioConnectionService extends BaseConnectionService { @override Future>> getFilters(LibraryRecord library) async { + _logger.fine('Fetching filters for library: ${library.id}'); final configItems = getConfig(library.config); List> filters = []; @@ -294,6 +310,7 @@ class StremioConnectionService extends BaseConnectionService { final addonManifest = await _getManifest(addon.addon); if ((addonManifest.catalogs?.isEmpty ?? true) == true) { + _logger.finer('No catalogs found for addon: ${addon.addon}'); continue; } @@ -303,6 +320,7 @@ class StremioConnectionService extends BaseConnectionService { for (final catalog in catalogs) { if (catalog.extra == null) { + _logger.finer('No extra filters found for catalog: ${catalog.id}'); continue; } for (final extraItem in catalog.extra!) { @@ -326,8 +344,11 @@ class StremioConnectionService extends BaseConnectionService { } } } - } catch (e) {} + } catch (e) { + _logger.severe('Error fetching filters', e); + } + _logger.finer('Filters fetched successfully: $filters'); return filters; } @@ -338,6 +359,7 @@ class StremioConnectionService extends BaseConnectionService { String? episode, OnStreamCallback? callback, }) async { + _logger.fine('Fetching streams for item: ${id.id}'); final List streams = []; final meta = id as Meta; @@ -351,6 +373,9 @@ class StremioConnectionService extends BaseConnectionService { final resource = resource_ as ResourceObject; if (!doesAddonSupportStream(resource, addonManifest, meta)) { + _logger.finer( + 'Addon does not support stream: ${addonManifest.name}', + ); continue; } @@ -363,6 +388,9 @@ class StremioConnectionService extends BaseConnectionService { final result = await http.get(Uri.parse(url), headers: {}); if (result.statusCode == 404) { + _logger.warning( + 'Invalid status code for addon: ${addonManifest.name}', + ); if (callback != null) { callback( null, @@ -377,15 +405,14 @@ class StremioConnectionService extends BaseConnectionService { }, ) .stream - .where((item) { - return item.status != QueryStatus.loading; - }) + .where((item) => item.status != QueryStatus.loading) .first .then((docs) { - return docs.data; - }); + return docs.data; + }); if (result == null) { + _logger.finer('No stream data found for URL: $url'); continue; } @@ -411,7 +438,7 @@ class StremioConnectionService extends BaseConnectionService { } } }).catchError((error, stacktrace) { - print(stacktrace); + _logger.severe('Error fetching streams', error, stacktrace); if (callback != null) callback(null, error); }); @@ -419,7 +446,7 @@ class StremioConnectionService extends BaseConnectionService { } await Future.wait(promises); - + _logger.finer('Streams fetched successfully: ${streams.length} streams'); return; } @@ -429,6 +456,7 @@ class StremioConnectionService extends BaseConnectionService { Meta meta, ) { if (resource.name != "stream") { + _logger.finer('Resource is not a stream: ${resource.name}'); return false; } @@ -438,10 +466,12 @@ class StremioConnectionService extends BaseConnectionService { final types = resource.types ?? addonManifest.types; if (types == null || !types.contains(meta.type)) { + _logger.finer('Addon does not support type: ${meta.type}'); return false; } if ((idPrefixes ?? []).isEmpty == true) { + _logger.finer('No ID prefixes found, assuming support'); return true; } @@ -450,9 +480,11 @@ class StremioConnectionService extends BaseConnectionService { ); if (hasIdPrefix.isEmpty) { + _logger.finer('No matching ID prefix found'); return false; } + _logger.finer('Addon supports stream'); return true; } @@ -470,17 +502,19 @@ class StremioConnectionService extends BaseConnectionService { try { streamTitle = utf8.decode(streamTitle.runes.toList()); - } catch (e) {} + } catch (e) { + _logger.warning('Failed to decode stream title', e); + } String? streamDescription = item.description; try { streamDescription = item.description != null - ? utf8.decode( - (item.description!).runes.toList(), - ) + ? utf8.decode((item.description!).runes.toList()) : null; - } catch (e) {} + } catch (e) { + _logger.warning('Failed to decode stream description', e); + } String title = meta.name ?? item.title ?? "No title"; @@ -506,17 +540,19 @@ class StremioConnectionService extends BaseConnectionService { } if (source == null) { + _logger.finer('No valid source found for stream'); return null; } String addonName = addonManifest.name; try { - addonName = utf8.decode( - (addonName).runes.toList(), - ); - } catch (e) {} + addonName = utf8.decode((addonName).runes.toList()); + } catch (e) { + _logger.warning('Failed to decode addon name', e); + } + _logger.finer('Stream list created successfully'); return StreamList( title: streamTitle, description: streamDescription, diff --git a/lib/features/connections/types/stremio/stremio_base.types.dart b/lib/features/connections/types/stremio/stremio_base.types.dart index b747a09..49c4229 100644 --- a/lib/features/connections/types/stremio/stremio_base.types.dart +++ b/lib/features/connections/types/stremio/stremio_base.types.dart @@ -315,6 +315,7 @@ class Meta extends LibraryItem { final int? traktId; final dynamic externalIds; + final dynamic episodeExternalIds; bool forceRegularMode; @@ -333,6 +334,13 @@ class Meta extends LibraryItem { return videos?.firstWhereOrNull( (episode) { + if (episode.tvdbId != null && episodeExternalIds != null) { + return episode.tvdbId == episodeExternalIds['tvdb']; + } + + print(episode.tvdbId); + print(episodeExternalIds?['tvdb']); + return nextEpisode == episode.episode && nextSeason == episode.season; }, ); @@ -381,6 +389,7 @@ class Meta extends LibraryItem { this.language, this.dvdRelease, this.progress, + this.episodeExternalIds, }) : super(id: id); Meta copyWith({ @@ -403,6 +412,8 @@ class Meta extends LibraryItem { List? writer, String? background, String? logo, + dynamic externalIds, + dynamic episodeExternalIds, String? awards, int? moviedbId, String? runtime, @@ -440,6 +451,7 @@ class Meta extends LibraryItem { year: year ?? this.year, status: status ?? this.status, tvdbId: tvdbId ?? this.tvdbId, + externalIds: externalIds ?? this.externalIds, director: director ?? this.director, writer: writer ?? this.writer, background: background ?? this.background, @@ -464,6 +476,7 @@ class Meta extends LibraryItem { nextEpisodeTitle: nextEpisodeTitle ?? this.nextEpisodeTitle, nextSeason: nextSeason ?? this.nextSeason, progress: progress ?? this.progress, + episodeExternalIds: episodeExternalIds ?? this.episodeExternalIds, ); factory Meta.fromJson(Map json) { diff --git a/lib/features/connections/widget/base/render_library_list.dart b/lib/features/connections/widget/base/render_library_list.dart index ebe8299..b5ae74a 100644 --- a/lib/features/connections/widget/base/render_library_list.dart +++ b/lib/features/connections/widget/base/render_library_list.dart @@ -289,21 +289,17 @@ class __RenderLibraryListState extends State<_RenderLibraryList> { .whereType() .toList(); - if (data.status == QueryStatus.loading && items.isEmpty) { - return const CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: SpinnerCards(), - ) - ], - ); - } - return RenderListItems( hasError: data.status == QueryStatus.error, onRefresh: () { query.refetch(); }, + loadMore: () { + query.getNextPage(); + }, + itemScrollController: _scrollController, + isLoadingMore: data.status == QueryStatus.loading || + data.status == QueryStatus.loading && items.isEmpty, isGrid: widget.isGrid, items: items, heroPrefix: widget.item.id, @@ -326,6 +322,8 @@ class RenderListItems extends StatelessWidget { final String heroPrefix; final dynamic error; final bool isWide; + final bool isLoadingMore; + final VoidCallback? loadMore; const RenderListItems({ super.key, @@ -339,6 +337,8 @@ class RenderListItems extends StatelessWidget { this.itemScrollController, this.error, this.isWide = false, + this.isLoadingMore = false, + this.loadMore, }); @override @@ -351,7 +351,9 @@ class RenderListItems extends StatelessWidget { return CustomScrollView( controller: controller, - physics: isGrid ? null : const NeverScrollableScrollPhysics(), + physics: isGrid + ? const AlwaysScrollableScrollPhysics() + : const NeverScrollableScrollPhysics(), slivers: [ if (hasError) SliverToBoxAdapter( @@ -389,7 +391,7 @@ class RenderListItems extends StatelessWidget { ), ), ), - if (isGrid) + if (isGrid) ...[ SliverGrid.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: getGridResponsiveColumnCount(context), @@ -407,30 +409,67 @@ class RenderListItems extends StatelessWidget { ); }, ), - if (!isGrid) - SliverToBoxAdapter( - child: SizedBox( - height: listHeight, - child: ListView.builder( - itemBuilder: (ctx, index) { - final item = items[index]; - - return SizedBox( - width: itemWidth, - child: Container( - decoration: const BoxDecoration(), - child: service.renderCard( - item, - "${index}_${heroPrefix}", + if (isLoadingMore) + SliverPadding( + padding: const EdgeInsets.only( + top: 8.0, + right: 8.0, + ), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: getGridResponsiveColumnCount(context), + mainAxisSpacing: getGridResponsiveSpacing(context), + crossAxisSpacing: getGridResponsiveSpacing(context), + childAspectRatio: 2 / 3, + ), + delegate: SliverChildBuilderDelegate( + (ctx, index) { + return ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Shimmer.fromColors( + baseColor: Colors.grey[800]!, + highlightColor: Colors.grey[700]!, + child: Container( + color: Colors.grey[800], + ), ), - ), - ); - }, - scrollDirection: Axis.horizontal, - itemCount: items.length, + ); + }, + childCount: 4, // Fixed number of loading items + ), ), ), - ), + ] else ...[ + if (isLoadingMore) + const SliverToBoxAdapter( + child: SpinnerCards(), + ), + if (!isLoadingMore) + SliverToBoxAdapter( + child: SizedBox( + height: listHeight, + child: ListView.builder( + controller: itemScrollController, + itemBuilder: (ctx, index) { + final item = items[index]; + + return SizedBox( + width: itemWidth, + child: Container( + decoration: const BoxDecoration(), + child: service.renderCard( + item, + "${index}_${heroPrefix}", + ), + ), + ); + }, + scrollDirection: Axis.horizontal, + itemCount: items.length, + ), + ), + ), + ], SliverPadding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).padding.bottom, diff --git a/lib/features/connections/widget/stremio/stremio_card.dart b/lib/features/connections/widget/stremio/stremio_card.dart index df0fd02..ba920c0 100644 --- a/lib/features/connections/widget/stremio/stremio_card.dart +++ b/lib/features/connections/widget/stremio/stremio_card.dart @@ -6,7 +6,7 @@ import 'package:intl/intl.dart'; import 'package:madari_client/features/connection/types/stremio.dart'; import 'package:madari_client/features/connections/service/base_connection_service.dart'; -class StremioCard extends StatelessWidget { +class StremioCard extends StatefulWidget { final LibraryItem item; final String prefix; final String connectionId; @@ -20,9 +20,16 @@ class StremioCard extends StatelessWidget { required this.service, }); + @override + State createState() => _StremioCardState(); +} + +class _StremioCardState extends State { + bool hasErrorWhileLoading = false; + @override Widget build(BuildContext context) { - final meta = item as Meta; + final meta = widget.item as Meta; return Card( margin: const EdgeInsets.only(right: 8), @@ -36,10 +43,10 @@ class StremioCard extends StatelessWidget { borderRadius: BorderRadius.circular(12), onTap: () { context.push( - "/info/stremio/$connectionId/${meta.type}/${meta.id}?hero=$prefix${meta.type}${item.id}", + "/info/stremio/${widget.connectionId}/${meta.type}/${meta.id}?hero=${widget.prefix}${meta.type}${widget.item.id}", extra: { 'meta': meta, - 'service': service, + 'service': widget.service, }, ); }, @@ -52,7 +59,7 @@ class StremioCard extends StatelessWidget { } bool get isInFuture { - final video = (item as Meta).currentVideo; + final video = (widget.item as Meta).currentVideo; return video != null && video.firstAired != null && video.firstAired!.isAfter(DateTime.now()); @@ -70,8 +77,15 @@ class StremioCard extends StatelessWidget { image: DecorationImage( image: CachedNetworkImageProvider( "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent( - meta.currentVideo?.thumbnail ?? meta.background!, + hasErrorWhileLoading + ? meta.background! + : (meta.currentVideo?.thumbnail ?? meta.background!), )}@webp", + errorListener: (error) { + setState(() { + hasErrorWhileLoading = true; + }); + }, imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, ), fit: BoxFit.cover, @@ -130,7 +144,7 @@ class StremioCard extends StatelessWidget { ), padding: const EdgeInsets.symmetric(horizontal: 4), child: Text( - "S${meta.nextSeason} E${meta.nextEpisode}", + "S${meta.currentVideo?.season ?? meta.nextSeason} E${meta.currentVideo?.episode ?? meta.nextEpisode}", style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: Colors.black, ), @@ -254,7 +268,7 @@ class StremioCard extends StatelessWidget { meta.poster ?? meta.logo ?? getBackgroundImage(meta); return Hero( - tag: "$prefix${meta.type}${item.id}", + tag: "${widget.prefix}${meta.type}${widget.item.id}", child: (backgroundImage == null) ? Center( child: Column( diff --git a/lib/features/connections/widget/stremio/stremio_season_selector.dart b/lib/features/connections/widget/stremio/stremio_season_selector.dart index ef17e4f..903cc58 100644 --- a/lib/features/connections/widget/stremio/stremio_season_selector.dart +++ b/lib/features/connections/widget/stremio/stremio_season_selector.dart @@ -128,7 +128,8 @@ class _StremioItemSeasonSelectorState extends State context: context, builder: (context) { final meta = widget.meta.copyWith( - id: episode.id, + nextSeason: currentSeason, + nextEpisode: episode.episode, ); return Scaffold( @@ -139,6 +140,7 @@ class _StremioItemSeasonSelectorState extends State service: widget.service!, id: meta, season: currentSeason.toString(), + episode: episode.number?.toString(), shouldPop: widget.shouldPop, ), ); diff --git a/lib/features/doc_viewer/container/video_viewer.dart b/lib/features/doc_viewer/container/video_viewer.dart index b01cb10..3cfb645 100644 --- a/lib/features/doc_viewer/container/video_viewer.dart +++ b/lib/features/doc_viewer/container/video_viewer.dart @@ -16,6 +16,7 @@ import '../../../utils/load_language.dart'; import '../../connections/types/stremio/stremio_base.types.dart' as types; import '../../connections/widget/stremio/stremio_season_selector.dart'; import '../../trakt/service/trakt.service.dart'; +import '../../trakt/types/common.dart'; import '../../watch_history/service/zeee_watch_history.dart'; import '../types/doc_source.dart'; import 'video_viewer/desktop_video_player.dart'; @@ -423,13 +424,19 @@ class _VideoViewerState extends State { }); }, ); - String subtitleStyleName = config.subtitleStyle ?? 'Normal'; - String subtitleStyleColor = config.subtitleColor ?? 'white'; - double subtitleSize = config.subtitleSize ; + String subtitleStyleName = config.subtitleStyle ?? 'Normal'; + String subtitleStyleColor = config.subtitleColor ?? 'white'; + double subtitleSize = config.subtitleSize; + Color hexToColor(String hexColor) { final hexCode = hexColor.replaceAll('#', ''); - return Color(int.parse('0x$hexCode')); + try { + return Color(int.parse('0x$hexCode')); + } catch (e) { + return Colors.white; + } } + FontStyle getFontStyleFromString(String styleName) { switch (styleName.toLowerCase()) { case 'italic': @@ -439,17 +446,19 @@ class _VideoViewerState extends State { return FontStyle.normal; } } + FontStyle currentFontStyle = getFontStyleFromString(subtitleStyleName); return MaterialVideoControlsTheme( fullscreen: mobile, normal: mobile, child: Video( subtitleViewConfiguration: SubtitleViewConfiguration( - style: TextStyle(color: hexToColor(subtitleStyleColor), + style: TextStyle( + color: hexToColor(subtitleStyleColor), fontSize: subtitleSize, - fontStyle: currentFontStyle, + fontStyle: currentFontStyle, fontWeight: FontWeight.bold), - ), + ), fit: isScaled ? BoxFit.fitWidth : BoxFit.fitHeight, pauseUponEnteringBackgroundMode: true, key: key, @@ -620,4 +629,4 @@ int calculateSecondsFromProgress( final clampedProgress = progressPercentage.clamp(0.0, 100.0); final currentSeconds = (duration * (clampedProgress / 100)).round(); return currentSeconds; -} \ No newline at end of file +} diff --git a/lib/features/settings/screen/logs_screen.dart b/lib/features/settings/screen/logs_screen.dart new file mode 100644 index 0000000..2bf63e8 --- /dev/null +++ b/lib/features/settings/screen/logs_screen.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../data/global_logs.dart'; + +class LogsPage extends StatefulWidget { + const LogsPage({super.key}); + + @override + State createState() => _LogsPageState(); +} + +class _LogsPageState extends State { + List parsedLogs = []; + + @override + void initState() { + super.initState(); + _parseLogs(); + } + + void _parseLogs() { + parsedLogs = globalLogs.reversed.map((log) => LogEntry.parse(log)).toList(); + } + + void _copyToClipboard() { + Clipboard.setData(ClipboardData(text: globalLogs.join('\n'))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Logs copied to clipboard'), + behavior: SnackBarBehavior.floating, + duration: Duration(seconds: 2), + ), + ); + } + + Color _getLevelColor(String level) { + switch (level.toUpperCase()) { + case 'ERROR': + return Colors.red; + case 'WARN': + case 'WARNING': + return Colors.orange; + case 'INFO': + return Colors.blue; + case 'DEBUG': + return Colors.grey; + default: + return Colors.grey; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + "Application Logs", + style: TextStyle(fontWeight: FontWeight.bold), + ), + actions: [ + IconButton( + onPressed: _copyToClipboard, + icon: const Icon(Icons.copy), + tooltip: 'Copy all logs', + ), + IconButton( + onPressed: () { + setState(() { + _parseLogs(); + }); + }, + icon: const Icon(Icons.refresh), + tooltip: 'Refresh logs', + ), + ], + ), + body: parsedLogs.isEmpty + ? const Center( + child: Text( + 'No logs available', + style: TextStyle(fontSize: 16, color: Colors.grey), + ), + ) + : ListView.builder( + itemCount: parsedLogs.length, + itemBuilder: (context, index) { + final log = parsedLogs[index]; + return Container( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Colors.grey.withOpacity(0.2), + width: 1, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: _getLevelColor(log.level).withOpacity(0.1), + borderRadius: BorderRadius.circular(3), + border: Border.all( + color: + _getLevelColor(log.level).withOpacity(0.3), + ), + ), + child: Text( + log.level, + style: TextStyle( + fontSize: 11, + color: _getLevelColor(log.level), + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + Text( + '${log.service} • ', + style: TextStyle( + fontSize: 12, + color: Colors.grey[700], + ), + ), + Text( + log.timestamp, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontFamily: 'monospace', + ), + ), + ], + ), + const SizedBox(height: 4), + SelectableText( + log.message, + style: const TextStyle( + fontSize: 13, + fontFamily: 'monospace', + ), + ), + ], + ), + ); + }, + ), + ); + } +} + +class LogEntry { + final String level; + final String service; + final String timestamp; + final String message; + + LogEntry({ + required this.level, + required this.service, + required this.timestamp, + required this.message, + }); + + factory LogEntry.parse(String logLine) { + final parts = logLine.split(RegExp(r'\s+')); + if (parts.length >= 3) { + final level = parts[0]; + final service = parts[1]; + final timestamp = parts[2]; + final message = parts.skip(3).join(' '); + return LogEntry( + level: level, + service: service, + timestamp: timestamp, + message: message, + ); + } + return LogEntry( + level: 'UNKNOWN', + service: 'Unknown', + timestamp: '', + message: logLine, + ); + } +} diff --git a/lib/features/settings/screen/trakt_integration_screen.dart b/lib/features/settings/screen/trakt_integration_screen.dart index a9a6e63..7d4dc0b 100644 --- a/lib/features/settings/screen/trakt_integration_screen.dart +++ b/lib/features/settings/screen/trakt_integration_screen.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:madari_client/engine/engine.dart'; -import 'package:madari_client/features/trakt/service/trakt.service.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../utils/auth_refresh.dart'; @@ -190,30 +189,6 @@ class _TraktIntegrationState extends State { ), centerTitle: true, elevation: 0, - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) { - return Scaffold( - appBar: AppBar( - title: const Text("Logs"), - ), - body: ListView.builder( - itemCount: TraktService.instance!.debugLogs.length, - itemBuilder: (context, item) { - return Text( - TraktService.instance!.debugLogs[item], - ); - }, - ), - ); - }), - ); - }, - child: Text("Debug logs"), - ), - ], ), body: Padding( padding: const EdgeInsets.all(16.0), diff --git a/lib/features/trakt/containers/up_next.container.dart b/lib/features/trakt/containers/up_next.container.dart index 2b0c0be..1609900 100644 --- a/lib/features/trakt/containers/up_next.container.dart +++ b/lib/features/trakt/containers/up_next.container.dart @@ -1,12 +1,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:logging/logging.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; @@ -20,48 +20,121 @@ class TraktContainer extends StatefulWidget { } class TraktContainerState extends State { - late final TraktCacheService _cacheService; + final Logger _logger = Logger('TraktContainerState'); + List? _cachedItems; bool _isLoading = false; String? _error; - late Timer _timer; + int _currentPage = 1; + + static const _itemsPerPage = 5; + + final _scrollController = ScrollController(); + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.offset; + return currentScroll >= (maxScroll * 0.9); + } @override void initState() { super.initState(); - _cacheService = TraktCacheService(); + _logger.info('Initializing TraktContainerState'); _loadData(); - _timer = Timer.periodic( - const Duration(seconds: 30), - (timer) { - _loadData(); - }, - ); + _scrollController.addListener(() { + if (_isBottom) { + _loadData(isLoadMore: true); + } + }); } @override void dispose() { + _logger.info('Disposing TraktContainerState'); + _scrollController.dispose(); super.dispose(); - _timer.cancel(); } - Future _loadData() async { + Future _loadData({ + bool isLoadMore = false, + }) async { + _logger.info('Started loading data for the _loadData'); + if (_isLoading) { + _logger.warning('Load data called while already loading'); + return; + } + _isLoading = true; + setState(() { - _isLoading = true; _error = null; }); try { - final items = await _cacheService.fetchData(widget.loadId); + final page = isLoadMore ? _currentPage + 1 : _currentPage; + + List? newItems; + + _logger.info('Loading data for loadId: ${widget.loadId}, page: $page'); + + switch (widget.loadId) { + case "up_next_series": + newItems = await TraktService.instance! + .getUpNextSeries( + page: page, + itemsPerPage: _itemsPerPage, + ) + .first; + break; + case "continue_watching": + newItems = await TraktService.instance!.getContinueWatching( + page: page, + itemsPerPage: _itemsPerPage, + ); + break; + case "upcoming_schedule": + newItems = await TraktService.instance!.getUpcomingSchedule( + page: page, + itemsPerPage: _itemsPerPage, + ); + break; + case "watchlist": + newItems = await TraktService.instance!.getWatchlist( + page: page, + itemsPerPage: _itemsPerPage, + ); + break; + case "show_recommendations": + newItems = await TraktService.instance!.getShowRecommendations( + page: page, + itemsPerPage: _itemsPerPage, + ); + break; + case "movie_recommendations": + newItems = await TraktService.instance!.getMovieRecommendations( + page: page, + itemsPerPage: _itemsPerPage, + ); + break; + default: + _logger.severe('Invalid loadId: ${widget.loadId}'); + throw Exception("Invalid loadId: ${widget.loadId}"); + } + if (mounted) { setState(() { - _cachedItems = items; + _currentPage = page; + _cachedItems = [...?_cachedItems, ...?newItems]; _isLoading = false; }); + + _logger.info('Data loaded successfully for loadId: ${widget.loadId}'); } } catch (e) { + _logger.severe('Error loading data: $e'); if (mounted) { setState(() { _error = e.toString(); @@ -72,7 +145,7 @@ class TraktContainerState extends State { } Future refresh() async { - await _cacheService.refresh(widget.loadId); + _logger.info('Refreshing data'); await _loadData(); } @@ -103,6 +176,7 @@ class TraktContainerState extends State { height: 30, child: TextButton( onPressed: () { + _logger.info('Navigating to Trakt details page'); Navigator.of(context).push( MaterialPageRoute( builder: (context) { @@ -113,8 +187,14 @@ class TraktContainerState extends State { body: Padding( padding: const EdgeInsets.all(8.0), child: RenderListItems( + loadMore: () { + _loadData( + isLoadMore: true, + ); + }, items: _cachedItems ?? [], error: _error, + isLoadingMore: _isLoading, hasError: _error != null, heroPrefix: "trakt_up_next${widget.loadId}", service: TraktService.stremioService!, @@ -148,20 +228,19 @@ class TraktContainerState extends State { child: Text("Nothing to see here"), ), ), + if (_isLoading && (_cachedItems ?? []).isEmpty) + const SpinnerCards(), 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!, - ), + child: RenderListItems( + isWide: widget.loadId == "up_next_series", + items: _cachedItems ?? [], + error: _error, + itemScrollController: _scrollController, + 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 7e8454d..abdcaab 100644 --- a/lib/features/trakt/service/trakt.service.dart +++ b/lib/features/trakt/service/trakt.service.dart @@ -2,18 +2,19 @@ import 'dart:async'; import 'dart:convert'; import 'package:cached_query/cached_query.dart'; -import 'package:cached_storage/cached_storage.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; import 'package:logging/logging.dart'; import 'package:pocketbase/pocketbase.dart'; +import 'package:rxdart/rxdart.dart'; import '../../../engine/connection_type.dart'; import '../../../engine/engine.dart'; import '../../connections/service/base_connection_service.dart'; import '../../connections/types/stremio/stremio_base.types.dart'; import '../../settings/types/connection.dart'; +import '../types/common.dart'; class TraktService { static final Logger _logger = Logger('TraktService'); @@ -25,9 +26,9 @@ class TraktService { static const int _authedGetLimit = 1000; static const Duration _rateLimitWindow = Duration(minutes: 5); - static const Duration _cacheRevalidationInterval = Duration( - hours: 1, - ); + final refetchKey = BehaviorSubject>(); + + static const Duration _cacheRevalidationInterval = Duration(hours: 1); static TraktService? _instance; static TraktService? get instance => _instance; @@ -65,9 +66,8 @@ class TraktService { _logger.info('Initializing TraktService'); - final result = await CachedQuery.instance.storage?.get( - "trakt_integration_cache", - ); + final result = + await CachedQuery.instance.storage?.get("trakt_integration_cache"); AppEngine.engine.pb.authStore.onChange.listen((item) { if (!AppEngine.engine.pb.authStore.isValid) { @@ -103,8 +103,7 @@ class TraktService { final connection = ConnectionResponse( connection: Connection.fromRecord(model_), connectionTypeRecord: ConnectionTypeRecord.fromRecord( - model_.get("expand.type"), - ), + model_.get("expand.type")), ); stremioService = BaseConnectionService.connectionById(connection); @@ -139,29 +138,6 @@ 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) { - _logger.severe('GET rate limit exceeded'); - throw Exception('GET rate limit exceeded'); - } - _getRequestCount++; - } else if (method == 'POST' || method == 'PUT' || method == 'DELETE') { - if (_postRequestCount >= _authedPostLimit) { - _logger.severe('POST/PUT/DELETE rate limit exceeded'); - throw Exception('POST/PUT/DELETE rate limit exceeded'); - } - _postRequestCount++; - } - } - void _startCacheRevalidation() { _logger.info('Starting cache revalidation timer'); _cacheRevalidationTimer = Timer.periodic( @@ -191,8 +167,6 @@ class TraktService { return _cache[url]; } - await _checkRateLimit('GET'); - _logger.info('Making GET request to $url'); final response = await http.get(Uri.parse(url), headers: headers); @@ -218,7 +192,7 @@ class TraktService { 'year': meta.year, 'ids': { 'imdb': meta.imdbId ?? meta.id, - ...(meta.externalIds ?? {}), + ...(meta.episodeExternalIds ?? {}), }, }, }; @@ -229,30 +203,31 @@ class TraktService { "year": meta.year, "ids": { "imdb": meta.imdbId ?? meta.id, - ...(meta.externalIds ?? {}), + ...(meta.episodeExternalIds ?? {}), } }, "episode": { - "season": meta.nextSeason, - "number": meta.nextEpisode, + "season": meta.currentVideo?.season ?? meta.nextSeason, + "number": meta.currentVideo?.number ?? meta.nextEpisode, }, }; } } - Future> getUpNextSeries() async { + Stream> getUpNextSeries( + {int page = 1, int itemsPerPage = 5}) async* { await initStremioService(); if (!isEnabled()) { _logger.info('Trakt integration is not enabled'); - return []; + yield []; + return; } try { _logger.info('Fetching up next series'); - final List watchedShows = await _makeRequest( - '$_baseUrl/sync/watched/shows', - ); + final List watchedShows = + await _makeRequest('$_baseUrl/sync/watched/shows'); final progressFutures = watchedShows.map((show) async { final showId = show['show']['ids']['trakt']; @@ -271,6 +246,7 @@ class TraktService { type: "series", id: imdb, externalIds: show['show']['ids'], + episodeExternalIds: nextEpisode['ids'], ), ); @@ -280,6 +256,8 @@ class TraktService { nextEpisode: nextEpisode['number'], nextSeason: nextEpisode['season'], nextEpisodeTitle: nextEpisode['title'], + externalIds: show['show']['ids'], + episodeExternalIds: nextEpisode['ids'], ); } } catch (e) { @@ -291,15 +269,31 @@ class TraktService { }).toList(); final results = await Future.wait(progressFutures); + final validResults = results.whereType().toList(); - return results.whereType().toList(); + // Pagination logic + final startIndex = (page - 1) * itemsPerPage; + final endIndex = startIndex + itemsPerPage; + + if (startIndex >= validResults.length) { + yield []; + return; + } + + final paginatedResults = validResults.sublist( + startIndex, + endIndex > validResults.length ? validResults.length : endIndex, + ); + + yield paginatedResults; } catch (e, stack) { _logger.severe('Error fetching up next episodes: $e', stack); - return []; + yield []; } } - Future> getContinueWatching() async { + Future> getContinueWatching( + {int page = 1, int itemsPerPage = 5}) async { await initStremioService(); if (!isEnabled()) { @@ -327,6 +321,8 @@ class TraktService { nextSeason: movie['episode']['season'], nextEpisode: movie['episode']['number'], nextEpisodeTitle: movie['episode']['title'], + externalIds: movie['show']['ids'], + episodeExternalIds: movie['episode']['ids'], ); } @@ -347,71 +343,28 @@ class TraktService { .toList(), ); - return result.map((res) { - Meta returnValue = res as Meta; + // Pagination logic + final startIndex = (page - 1) * itemsPerPage; + final endIndex = startIndex + itemsPerPage; - if (progress.containsKey(res.id)) { - returnValue = res.copyWith( - progress: progress[res.id], - ); - } + if (startIndex >= result.length) { + return []; + } - if (res.type == "series") { - return returnValue.copyWith(); - } - - return returnValue; - }).toList(); + return result.sublist( + startIndex, + endIndex > result.length ? result.length : endIndex, + ); } catch (e, stack) { _logger.severe('Error fetching continue watching: $e', stack); return []; } } - Future _retryPostRequest( - String cacheKey, - String url, - Map body, { - int retryCount = 2, + Future> getUpcomingSchedule({ + int page = 1, + int itemsPerPage = 5, }) async { - for (int i = 0; i < retryCount; i++) { - try { - await _checkRateLimit('POST'); - - _logger.info('Making POST request to $url'); - final response = await http.post( - Uri.parse(url), - headers: headers, - body: json.encode(body), - ); - - if (response.statusCode == 201) { - _logger.info('POST request successful'); - return; - } else if (response.statusCode == 429) { - _logger.warning('Rate limit hit, retrying...'); - await Future.delayed(Duration(seconds: 10)); - continue; - } else { - _logger.severe('Failed to make POST request: ${response.statusCode}'); - throw Exception( - 'Failed to make POST request: ${response.statusCode}'); - } - } catch (e) { - if (i == retryCount - 1) { - _logger - .severe('Failed to make POST request after $retryCount attempts'); - if (_cache.containsKey(cacheKey)) { - _logger.info('Returning cached data'); - return _cache[cacheKey]; - } - rethrow; - } - } - } - } - - Future> getUpcomingSchedule() async { await initStremioService(); if (!isEnabled()) { @@ -443,14 +396,25 @@ class TraktService { .toList(), ); - return result; + final startIndex = (page - 1) * itemsPerPage; + final endIndex = startIndex + itemsPerPage; + + if (startIndex >= result.length) { + return []; + } + + return result.sublist( + startIndex, + endIndex > result.length ? result.length : endIndex, + ); } catch (e, stack) { _logger.severe('Error fetching upcoming schedule: $e', stack); return []; } } - Future> getWatchlist() async { + Future> getWatchlist( + {int page = 1, int itemsPerPage = 5}) async { await initStremioService(); if (!isEnabled()) { @@ -461,6 +425,7 @@ class TraktService { try { _logger.info('Fetching watchlist'); final watchlistItems = await _makeRequest('$_baseUrl/sync/watchlist'); + _logger.info('Got watchlist'); final result = await stremioService!.getBulkItem( watchlistItems @@ -489,14 +454,25 @@ class TraktService { .toList(), ); - return result; + final startIndex = (page - 1) * itemsPerPage; + final endIndex = startIndex + itemsPerPage; + + if (startIndex >= result.length) { + return []; + } + + return result.sublist( + startIndex, + endIndex > result.length ? result.length : endIndex, + ); } catch (e, stack) { _logger.severe('Error fetching watchlist: $e', stack); return []; } } - Future> getShowRecommendations() async { + Future> getShowRecommendations( + {int page = 1, int itemsPerPage = 5}) async { await initStremioService(); if (!isEnabled()) { @@ -527,14 +503,28 @@ class TraktService { .toList(), )); - return result; + // Pagination logic + final startIndex = (page - 1) * itemsPerPage; + final endIndex = startIndex + itemsPerPage; + + if (startIndex >= result.length) { + return []; + } + + return result.sublist( + startIndex, + endIndex > result.length ? result.length : endIndex, + ); } catch (e, stack) { _logger.severe('Error fetching show recommendations: $e', stack); return []; } } - Future> getMovieRecommendations() async { + Future> getMovieRecommendations({ + int page = 1, + int itemsPerPage = 5, + }) async { await initStremioService(); if (!isEnabled()) { @@ -544,8 +534,9 @@ class TraktService { try { _logger.info('Fetching movie recommendations'); - final recommendedMovies = - await _makeRequest('$_baseUrl/recommendations/movies'); + final recommendedMovies = await _makeRequest( + '$_baseUrl/recommendations/movies', + ); final result = await stremioService!.getBulkItem( recommendedMovies @@ -565,7 +556,18 @@ class TraktService { .toList(), ); - return result; + // Pagination logic + final startIndex = (page - 1) * itemsPerPage; + final endIndex = startIndex + itemsPerPage; + + if (startIndex >= result.length) { + return []; + } + + return result.sublist( + startIndex, + endIndex > result.length ? result.length : endIndex, + ); } catch (e, stack) { _logger.severe('Error fetching movie recommendations: $e', stack); return []; @@ -624,10 +626,11 @@ class TraktService { return; } - await _checkRateLimit('POST'); - try { _logger.info('Starting scrobbling for ${meta.type} with ID: ${meta.id}'); + + print(_buildObjectForMeta(meta)); + final response = await http.post( Uri.parse('$_baseUrl/scrobble/start'), headers: headers, @@ -660,6 +663,8 @@ class TraktService { return; } + print(_buildObjectForMeta(meta)); + final cacheKey = '${meta.id}_pauseScrobbling'; _activeScrobbleRequests[cacheKey]?.completeError('Cancelled'); @@ -683,6 +688,50 @@ class TraktService { } } + Future _retryPostRequest( + String cacheKey, + String url, + Map body, { + int retryCount = 2, + }) async { + for (int i = 0; i < retryCount; i++) { + try { + _logger.info('Making POST request to $url'); + final response = await http.post( + Uri.parse(url), + headers: headers, + body: json.encode(body), + ); + + if (response.statusCode == 201) { + _logger.info('POST request successful'); + return; + } else if (response.statusCode == 429) { + _logger.warning('Rate limit hit, retrying...'); + await Future.delayed( + const Duration(seconds: 10), + ); + continue; + } else { + _logger.severe('Failed to make POST request: ${response.statusCode}'); + throw Exception( + 'Failed to make POST request: ${response.statusCode}', + ); + } + } catch (e) { + if (i == retryCount - 1) { + _logger + .severe('Failed to make POST request after $retryCount attempts'); + if (_cache.containsKey(cacheKey)) { + _logger.info('Returning cached data'); + return _cache[cacheKey]; + } + rethrow; + } + } + } + } + Future stopScrobbling({ required Meta meta, required double progress, @@ -699,6 +748,7 @@ class TraktService { try { _logger.info('Stopping scrobbling for ${meta.type} with ID: ${meta.id}'); + _logger.info(_buildObjectForMeta(meta)); await _retryPostRequest( cacheKey, '$_baseUrl/scrobble/stop', @@ -710,6 +760,11 @@ class TraktService { _cache.remove('$_baseUrl/sync/watched/shows'); _cache.remove('$_baseUrl/sync/playback'); + + refetchKey.add([ + "continue_watching", + if (meta.type == "series") "up_next_series", + ]); } catch (e, stack) { _logger.severe('Error stopping scrobbling: $e', stack); rethrow; @@ -792,19 +847,3 @@ class TraktService { return []; } } - -class TraktProgress { - final String id; - final int? episode; - final int? season; - final double progress; - - TraktProgress({ - required this.id, - this.episode, - this.season, - required this.progress, - }); -} - -extension StaticInstance on CachedStorage {} diff --git a/lib/features/trakt/service/trakt_cache.service.dart b/lib/features/trakt/service/trakt_cache.service.dart deleted file mode 100644 index 42d288c..0000000 --- a/lib/features/trakt/service/trakt_cache.service.dart +++ /dev/null @@ -1,65 +0,0 @@ -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/features/trakt/types/common.dart b/lib/features/trakt/types/common.dart new file mode 100644 index 0000000..eb8f7b7 --- /dev/null +++ b/lib/features/trakt/types/common.dart @@ -0,0 +1,13 @@ +class TraktProgress { + final String id; + final int? episode; + final int? season; + final double progress; + + TraktProgress({ + required this.id, + this.episode, + this.season, + required this.progress, + }); +} diff --git a/lib/main.dart b/lib/main.dart index cc91b00..ae7e597 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,7 @@ import 'package:media_kit/media_kit.dart'; import 'package:path/path.dart' as path; import 'package:window_manager/window_manager.dart'; +import 'data/global_logs.dart'; import 'features/doc_viewer/container/iframe.dart'; import 'features/downloads/service/service.dart'; import 'features/watch_history/service/zeee_watch_history.dart'; @@ -29,15 +30,34 @@ void main() async { print("Unable"); } - Logger.root.level = Level.ALL; + Logger.root.level = Level.INFO; Logger.root.onRecord.listen((record) { - print('${record.level.name}: ${record.time}: ${record.message}'); + final logs = + '${record.level.name.padRight(10)}${record.loggerName.padRight(30)}${record.time.hour}:${record.time.minute}:${record.time.second}:${record.time.millisecond}: ${record.message}'; + + print(logs); + + globalLogs.add(logs); + if (globalLogs.length > 1000) { + globalLogs.removeAt(0); + } + if (record.error != null) { - print('Error: ${record.error}'); + final error = 'Error: ${record.time} ${record.error}'; + print(error); + globalLogs.add(error); + if (globalLogs.length > 1000) { + globalLogs.removeAt(0); + } } if (record.stackTrace != null) { - print('StackTrace: ${record.stackTrace}'); + final error = 'StackTrace: ${record.stackTrace}'; + print(error); + globalLogs.add(error); + if (globalLogs.length > 1000) { + globalLogs.removeAt(0); + } } }); @@ -113,7 +133,6 @@ class _MadariAppState extends State { void _initializeFileHandling() { platform.setMethodCallHandler((call) async { if (call.method == "openFile") { - // Handle the new file data structure _openedFileData = call.arguments as Map?; if (_openedFileData != null) { diff --git a/lib/pages/more_tab.page.dart b/lib/pages/more_tab.page.dart index 522f3da..3bf2110 100644 --- a/lib/pages/more_tab.page.dart +++ b/lib/pages/more_tab.page.dart @@ -9,6 +9,7 @@ import 'package:madari_client/pages/sign_in.page.dart'; import '../features/settings/screen/account_screen.dart'; import '../features/settings/screen/connection_screen.dart'; +import '../features/settings/screen/logs_screen.dart'; import '../features/settings/screen/playback_settings_screen.dart'; import '../features/settings/screen/profile_button.dart'; @@ -76,6 +77,35 @@ class MoreContainer extends StatelessWidget { ), ), ), + _buildListHeader('Debug'), + ListTile( + leading: const Icon(Icons.text_snippet), + title: const Text("Show logs"), + onTap: () async { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return const LogsPage(); + }, + ), + ); + }, + ), + ListTile( + leading: const Icon(Icons.clear), + title: const Text("Clear Local Watch History"), + onTap: () async { + await ZeeeWatchHistoryStatic.service?.clear(); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Removed local watch history"), + ), + ); + } + }, + ), ListTile( leading: const Icon(Icons.clear_all), title: const Text("Clear Cache"), diff --git a/lib/pages/stremio_item.page.dart b/lib/pages/stremio_item.page.dart index 17a4144..9b45b92 100644 --- a/lib/pages/stremio_item.page.dart +++ b/lib/pages/stremio_item.page.dart @@ -67,56 +67,63 @@ class _StremioItemPageState extends State { super.initState(); if (widget.meta?.progress != null || widget.meta?.nextEpisode != null) { - Future.delayed( + openVideo(); + } + } + + openVideo() async { + if (widget.meta != null && widget.service != null) { + await Future.delayed( const Duration(milliseconds: 500), - () { - if (widget.meta != null && widget.service != null) { - if (mounted) { - final season = widget.meta?.nextSeason == null - ? "" - : "S${widget.meta?.nextSeason}"; - - final episode = widget.meta?.nextEpisode == null - ? "" - : "E${widget.meta?.nextEpisode}"; - - showModalBottomSheet( - context: context, - builder: (context) { - return Scaffold( - appBar: AppBar( - leading: IconButton( - onPressed: () { - Navigator.of(context).pop(); - }, - icon: const Icon(Icons.close), - ), - title: Text( - "Streams $season $episode".trim(), - ), - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.only(bottom: 14.0), - child: RenderStreamList( - progress: widget.meta!.progress != null - ? widget.meta!.progress! * 100 - : null, - service: widget.service!, - id: widget.meta as LibraryItem, - season: widget.meta?.nextSeason?.toString(), - episode: widget.meta?.nextEpisode?.toString(), - shouldPop: false, - ), - ), - ), - ); - }, - ); - } - } - }, ); + + if (mounted) { + final season = widget.meta?.nextSeason == null + ? "" + : "S${widget.meta?.nextSeason}"; + + final episode = widget.meta?.nextEpisode == null + ? "" + : "E${widget.meta?.nextEpisode}"; + + showModalBottomSheet( + context: context, + builder: (context) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + onPressed: () { + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.close), + ), + title: Text( + "Streams $season $episode".trim(), + ), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only(bottom: 14.0), + child: RenderStreamList( + progress: widget.meta!.progress != null + ? widget.meta!.progress! * 100 + : null, + service: widget.service!, + id: widget.meta as LibraryItem, + season: (widget.meta?.currentVideo?.season ?? + widget.meta?.nextSeason) + ?.toString(), + episode: (widget.meta?.currentVideo?.episode ?? + widget.meta?.nextEpisode) + ?.toString(), + shouldPop: false, + ), + ), + ), + ); + }, + ); + } } }