Project import generated by Copybara.

GitOrigin-RevId: ac3722bf830b8d24ba6123ca5646b837de017eb7
This commit is contained in:
Madari Developers 2025-01-08 13:21:35 +00:00
parent 43e84608a0
commit 2473e0f513
7 changed files with 376 additions and 254 deletions

View file

@ -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({

View file

@ -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)

View file

@ -200,10 +200,12 @@ class _VideoViewerState extends State<VideoViewer> {
});
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);

View file

@ -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<TraktContainer> createState() => _TraktContainerState();
State<TraktContainer> createState() => TraktContainerState();
}
class _TraktContainerState extends State<TraktContainer> {
late Query<List<LibraryItem>> _query;
class TraktContainerState extends State<TraktContainer> {
late final TraktCacheService _cacheService;
List<LibraryItem>? _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<void> _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<void> refresh() async {
await _cacheService.refresh(widget.loadId);
await _loadData();
}
String get title {
@ -61,97 +63,89 @@ class _TraktContainerState extends State<TraktContainer> {
@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!,
),
),
],
)
],
),
);
}
}

View file

@ -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<String, dynamic> _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<BaseConnectionService> initStremioService() async {
@ -75,6 +98,93 @@ class TraktService {
'Authorization': 'Bearer $_token',
};
Future<void> _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<void> _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<dynamic> _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<String, dynamic> _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<List<LibraryItem>> 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<dynamic> 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<String, double> 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<dynamic> 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<int?> 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<dynamic>;
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<String, dynamic> _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<void> 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<TraktProgress> 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") {

View file

@ -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<String, List<LibraryItem>> _cache = {};
final Map<String, bool> _isLoading = {};
final Map<String, String?> _errors = {};
Future<List<LibraryItem>> 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<void> refresh(String loadId) async {
_cache.remove(loadId);
_errors.remove(loadId);
await fetchData(loadId);
}
List<LibraryItem>? getCachedData(String loadId) => _cache[loadId];
bool isLoading(String loadId) => _isLoading[loadId] ?? false;
String? getError(String loadId) => _errors[loadId];
Future<List<LibraryItem>> _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");
}
}
}

View file

@ -47,6 +47,28 @@ class _HomeTabPageState extends State<HomeTabPage> {
],
);
final Map<int, GlobalKey<TraktContainerState>> _keyMap = {};
GlobalKey<TraktContainerState> _getKey(int id) {
return _keyMap.putIfAbsent(
id,
() => GlobalKey<TraktContainerState>(),
);
}
Future<void> _onRefresh() async {
final List<Future> 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<HomeTabPage> {
setState(() {
traktLibraries = getTraktLibraries();
});
await _onRefresh();
return;
},
child: QueryBuilder(
@ -123,6 +146,8 @@ class _HomeTabPageState extends State<HomeTabPage> {
setState(() {
traktLibraries = getTraktLibraries();
});
await _onRefresh();
},
),
),
@ -140,6 +165,7 @@ class _HomeTabPageState extends State<HomeTabPage> {
final category = traktLibraries[index];
return TraktContainer(
key: _getKey(index),
loadId: category,
);
}