Initial commit
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
import '../../contracts/trakt/trakt_response.dart';
|
||||
import '../../contracts/trakt/trakt_sync_job.dart';
|
||||
import '../../domain/ports/authed_trakt_gateway.dart';
|
||||
import 'trakt_auth.dart';
|
||||
import 'trakt_credentials.dart';
|
||||
import 'trakt_rest_api.dart' show TraktHeaderInterceptor;
|
||||
|
||||
const _tag = 'AuthedTrakt';
|
||||
|
||||
|
||||
class AuthedTraktClient implements AuthedTraktGateway {
|
||||
AuthedTraktClient({required this._auth, Dio? dio})
|
||||
: _dio =
|
||||
(dio ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
baseUrl: TraktCredentials.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
headers: {'Accept': 'application/json'},
|
||||
),
|
||||
))
|
||||
..interceptors.add(TraktHeaderInterceptor());
|
||||
|
||||
final TraktAuth _auth;
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
Future<bool> isConnected() => _auth.isConnected();
|
||||
|
||||
@override
|
||||
Future<TraktScrobbleOutcome> scrobble(
|
||||
TraktSyncJobType type,
|
||||
Map<String, dynamic> body,
|
||||
) {
|
||||
return _post(_scrobblePath(type), body, allowRefreshRetry: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TraktScrobbleOutcome> syncHistory(
|
||||
TraktSyncJobType type,
|
||||
Map<String, dynamic> body,
|
||||
) {
|
||||
final path = type == TraktSyncJobType.historyRemove
|
||||
? '/sync/history/remove'
|
||||
: '/sync/history';
|
||||
return _post(path, body, allowRefreshRetry: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> fetchPlayback() =>
|
||||
_getList('/sync/playback?extended=full');
|
||||
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> fetchCalendarShows() =>
|
||||
_getList('/calendars/my/shows/${_todayUtc()}/7');
|
||||
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> fetchCalendarMovies() =>
|
||||
_getList('/calendars/my/movies/${_todayUtc()}/7');
|
||||
|
||||
static String _todayUtc() {
|
||||
final now = DateTime.now().toUtc();
|
||||
return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
|
||||
Future<List<Map<String, dynamic>>> _getList(
|
||||
String path, {
|
||||
bool allowRefreshRetry = true,
|
||||
}) async {
|
||||
final token = await _auth.validAccessToken();
|
||||
if (token == null) throw StateError('Trakt not connected');
|
||||
|
||||
try {
|
||||
final res = await _dio.get<dynamic>(
|
||||
path,
|
||||
options: Options(
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
validateStatus: (_) => true,
|
||||
),
|
||||
);
|
||||
final status = res.statusCode ?? 0;
|
||||
if (status == 401 && allowRefreshRetry) {
|
||||
final refreshed = await _auth.forceRefresh();
|
||||
if (refreshed == null) throw StateError('Trakt token refresh failed');
|
||||
return _getList(path, allowRefreshRetry: false);
|
||||
}
|
||||
if (status < 200 || status >= 300) {
|
||||
throw StateError('Trakt GET $path → $status');
|
||||
}
|
||||
final data = res.data;
|
||||
if (data is! List) return const [];
|
||||
return data.whereType<Map<String, dynamic>>().toList();
|
||||
} on DioException catch (e) {
|
||||
AppLogger.debug(_tag, 'GET $path network error', e);
|
||||
throw StateError('Trakt network error on GET $path: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static String _scrobblePath(TraktSyncJobType type) {
|
||||
switch (type) {
|
||||
case TraktSyncJobType.scrobbleStart:
|
||||
return '/scrobble/start';
|
||||
case TraktSyncJobType.scrobblePause:
|
||||
return '/scrobble/pause';
|
||||
case TraktSyncJobType.scrobbleStop:
|
||||
default:
|
||||
return '/scrobble/stop';
|
||||
}
|
||||
}
|
||||
|
||||
Future<TraktScrobbleOutcome> _post(
|
||||
String path,
|
||||
Map<String, dynamic> body, {
|
||||
required bool allowRefreshRetry,
|
||||
}) async {
|
||||
final token = await _auth.validAccessToken();
|
||||
if (token == null) {
|
||||
return const TraktScrobbleOutcome(TraktResponseClass.unauthorized);
|
||||
}
|
||||
|
||||
try {
|
||||
final res = await _dio.post<dynamic>(
|
||||
path,
|
||||
data: body,
|
||||
options: Options(
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
validateStatus: (_) => true,
|
||||
),
|
||||
);
|
||||
final status = res.statusCode ?? 0;
|
||||
final cls = classifyTraktStatus(status);
|
||||
|
||||
if (cls == TraktResponseClass.success) {
|
||||
final data = res.data;
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'scrobble $path → $status '
|
||||
'sent=${_identitySummary(body)} '
|
||||
'matched=${data is Map ? _identitySummary(data) : '?'} '
|
||||
'action=${data is Map ? data['action'] : '?'} '
|
||||
'trakt_progress=${data is Map ? data['progress'] : '?'}',
|
||||
);
|
||||
} else {
|
||||
AppLogger.debug(_tag, 'scrobble $path → $status (${cls.name})');
|
||||
}
|
||||
|
||||
if (cls == TraktResponseClass.unauthorized && allowRefreshRetry) {
|
||||
final refreshed = await _auth.forceRefresh();
|
||||
if (refreshed != null) {
|
||||
return _post(path, body, allowRefreshRetry: false);
|
||||
}
|
||||
return const TraktScrobbleOutcome(TraktResponseClass.unauthorized);
|
||||
}
|
||||
|
||||
if (cls == TraktResponseClass.rateLimited) {
|
||||
return TraktScrobbleOutcome(
|
||||
cls,
|
||||
retryAfter: _parseRetryAfter(res.headers.value('retry-after')),
|
||||
);
|
||||
}
|
||||
return TraktScrobbleOutcome(cls);
|
||||
} on DioException catch (e) {
|
||||
final status = e.response?.statusCode;
|
||||
if (status != null) {
|
||||
final cls = classifyTraktStatus(status);
|
||||
if (cls == TraktResponseClass.rateLimited) {
|
||||
return TraktScrobbleOutcome(
|
||||
cls,
|
||||
retryAfter: _parseRetryAfter(
|
||||
e.response?.headers.value('retry-after'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return TraktScrobbleOutcome(cls);
|
||||
}
|
||||
AppLogger.debug(_tag, 'network error on $path → transient', e);
|
||||
return const TraktScrobbleOutcome(TraktResponseClass.transient);
|
||||
} catch (e) {
|
||||
AppLogger.debug(_tag, 'unexpected error on $path → transient', e);
|
||||
return const TraktScrobbleOutcome(TraktResponseClass.transient);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static String _identitySummary(Map<dynamic, dynamic> data) {
|
||||
final movie = data['movie'];
|
||||
if (movie is Map) return 'movie "${movie['title']}" ids=${movie['ids']}';
|
||||
final show = data['show'];
|
||||
final episode = data['episode'];
|
||||
if (show is Map || episode is Map) {
|
||||
final showTitle = show is Map ? show['title'] : null;
|
||||
final showIds = show is Map ? show['ids'] : null;
|
||||
final s = episode is Map ? episode['season'] : null;
|
||||
final n = episode is Map ? episode['number'] : null;
|
||||
final epIds = episode is Map ? episode['ids'] : null;
|
||||
return 'episode "$showTitle" S${s}E$n showIds=$showIds epIds=$epIds';
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
|
||||
static Duration? _parseRetryAfter(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
final secs = int.tryParse(raw.trim());
|
||||
if (secs == null || secs < 0) return null;
|
||||
return Duration(seconds: secs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
import '../../contracts/trakt/trakt_token.dart';
|
||||
import '../../domain/ports/trakt_gateway.dart';
|
||||
import 'trakt_token_store.dart';
|
||||
|
||||
const _tag = 'TraktAuth';
|
||||
|
||||
|
||||
class TraktAuth {
|
||||
TraktAuth({
|
||||
required this.store,
|
||||
required this.gateway,
|
||||
this.refreshBufferSeconds = 24 * 3600,
|
||||
});
|
||||
|
||||
final TraktTokenStore store;
|
||||
final TraktGateway gateway;
|
||||
|
||||
|
||||
final int refreshBufferSeconds;
|
||||
|
||||
|
||||
void Function()? onCleared;
|
||||
|
||||
Future<TraktToken>? _inflightRefresh;
|
||||
int _generation = 0;
|
||||
|
||||
Future<bool> isConnected() async => (await store.read()) != null;
|
||||
|
||||
|
||||
Future<String?> validAccessToken() async {
|
||||
final token = await store.read();
|
||||
if (token == null || token.accessToken.isEmpty) return null;
|
||||
|
||||
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final expiresAt = token.createdAt + token.expiresIn;
|
||||
final notExpiring =
|
||||
token.expiresIn <= 0 || nowSec < expiresAt - refreshBufferSeconds;
|
||||
if (notExpiring) return token.accessToken;
|
||||
|
||||
if (token.refreshToken.isEmpty) {
|
||||
await _clear();
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final fresh = await _refreshSingleFlight(token.refreshToken);
|
||||
return fresh.accessToken;
|
||||
} on _TraktRefreshCancelled {
|
||||
return null;
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'refresh failed, clearing token', e);
|
||||
await _clear();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<String?> forceRefresh() async {
|
||||
final token = await store.read();
|
||||
if (token == null || token.refreshToken.isEmpty) {
|
||||
await _clear();
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final fresh = await _refreshSingleFlight(token.refreshToken);
|
||||
return fresh.accessToken;
|
||||
} on _TraktRefreshCancelled {
|
||||
return null;
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'force refresh failed, clearing token', e);
|
||||
await _clear();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> persist(TraktToken token) => store.write(token);
|
||||
|
||||
|
||||
Future<void> clear() => _clear();
|
||||
|
||||
Future<TraktToken> _refreshSingleFlight(String refreshToken) {
|
||||
final existing = _inflightRefresh;
|
||||
if (existing != null) return existing;
|
||||
final generation = _generation;
|
||||
final future = () async {
|
||||
try {
|
||||
final fresh = await gateway.refreshToken(refreshToken);
|
||||
if (generation != _generation) throw const _TraktRefreshCancelled();
|
||||
await store.write(fresh);
|
||||
AppLogger.debug(_tag, 'token refreshed');
|
||||
return fresh;
|
||||
} finally {
|
||||
_inflightRefresh = null;
|
||||
}
|
||||
}();
|
||||
_inflightRefresh = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
Future<void> _clear() async {
|
||||
_generation++;
|
||||
await store.clear();
|
||||
onCleared?.call();
|
||||
}
|
||||
}
|
||||
|
||||
class _TraktRefreshCancelled implements Exception {
|
||||
const _TraktRefreshCancelled();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
import '../../contracts/trakt/trakt_token.dart';
|
||||
import '../../contracts/trakt/trakt_user.dart';
|
||||
import '../../domain/ports/trakt_gateway.dart';
|
||||
import 'trakt_credentials.dart';
|
||||
import 'trakt_rest_api.dart';
|
||||
|
||||
|
||||
class TraktApiClient implements TraktGateway {
|
||||
static const _tag = 'Trakt';
|
||||
|
||||
TraktApiClient({Dio? dio})
|
||||
: _api = TraktRestApi(
|
||||
(dio ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
headers: {'Accept': 'application/json'},
|
||||
),
|
||||
))
|
||||
..interceptors.add(TraktHeaderInterceptor()),
|
||||
);
|
||||
|
||||
final TraktRestApi _api;
|
||||
|
||||
@override
|
||||
Future<TraktToken> exchangeCode(String code) async {
|
||||
try {
|
||||
return await _api.exchangeToken(<String, dynamic>{
|
||||
'client_id': TraktCredentials.clientId,
|
||||
'client_secret': TraktCredentials.clientSecret,
|
||||
'redirect_uri': TraktCredentials.redirectUri,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
});
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'exchangeCode failed', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TraktToken> refreshToken(String refreshToken) async {
|
||||
try {
|
||||
return await _api.exchangeToken(<String, dynamic>{
|
||||
'client_id': TraktCredentials.clientId,
|
||||
'client_secret': TraktCredentials.clientSecret,
|
||||
'redirect_uri': TraktCredentials.redirectUri,
|
||||
'refresh_token': refreshToken,
|
||||
'grant_type': 'refresh_token',
|
||||
});
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'refreshToken failed', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TraktUser> getMe(String accessToken) async {
|
||||
try {
|
||||
final res = await _api.getUserSettings('Bearer $accessToken');
|
||||
return res.user ?? const TraktUser();
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'getMe failed', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
|
||||
|
||||
abstract final class TraktCredentials {
|
||||
const TraktCredentials._();
|
||||
|
||||
static const String clientId =
|
||||
'YOUR_TRAKT_CLIENT_ID';
|
||||
static const String clientSecret =
|
||||
'YOUR_TRAKT_CLIENT_SECRET';
|
||||
|
||||
|
||||
static const String redirectUri = 'smplayer://oauth-trakt';
|
||||
static const String scheme = 'smplayer';
|
||||
static const String host = 'oauth-trakt';
|
||||
|
||||
static const String apiBaseUrl = 'https://api.trakt.tv';
|
||||
static const String authBaseUrl = 'https://trakt.tv';
|
||||
static const String apiVersion = '2';
|
||||
|
||||
|
||||
static Uri authorizeUrl(String state) =>
|
||||
Uri.parse('$authBaseUrl/oauth/authorize').replace(
|
||||
queryParameters: <String, String>{
|
||||
'response_type': 'code',
|
||||
'client_id': clientId,
|
||||
'redirect_uri': redirectUri,
|
||||
'state': state,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import '../../contracts/trakt/trakt_ids.dart';
|
||||
import '../../contracts/trakt/trakt_scrobble_target.dart';
|
||||
|
||||
|
||||
abstract final class TraktMediaMatcher {
|
||||
const TraktMediaMatcher._();
|
||||
|
||||
|
||||
static TraktScrobbleTarget? match({
|
||||
required String? itemType,
|
||||
required String itemName,
|
||||
required int? productionYear,
|
||||
required Map<String, String> providerIds,
|
||||
Map<String, String> seriesProviderIds = const {},
|
||||
String? seriesName,
|
||||
int? seriesProductionYear,
|
||||
int? parentIndexNumber,
|
||||
int? itemIndexNumber,
|
||||
}) {
|
||||
switch (itemType) {
|
||||
case 'Movie':
|
||||
return _matchMovie(itemName, productionYear, providerIds);
|
||||
case 'Episode':
|
||||
return _matchEpisode(
|
||||
ownIds: providerIds,
|
||||
seriesIds: seriesProviderIds,
|
||||
seriesName: seriesName,
|
||||
seriesYear: seriesProductionYear,
|
||||
season: parentIndexNumber,
|
||||
number: itemIndexNumber,
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static TraktScrobbleTarget? _matchMovie(
|
||||
String name,
|
||||
int? year,
|
||||
Map<String, String> providerIds,
|
||||
) {
|
||||
final ids = _idsFrom(providerIds);
|
||||
final title = name.trim();
|
||||
if (!ids.hasReliableId && title.isEmpty) return null;
|
||||
return TraktScrobbleTarget.movie(title: title, year: year, ids: ids);
|
||||
}
|
||||
|
||||
static TraktScrobbleTarget? _matchEpisode({
|
||||
required Map<String, String> ownIds,
|
||||
required Map<String, String> seriesIds,
|
||||
required String? seriesName,
|
||||
required int? seriesYear,
|
||||
required int? season,
|
||||
required int? number,
|
||||
}) {
|
||||
final episodeIds = _idsFrom(ownIds);
|
||||
final showIds = _idsFrom(seriesIds);
|
||||
final title = (seriesName ?? '').trim();
|
||||
|
||||
if (episodeIds.hasReliableId) {
|
||||
return TraktScrobbleTarget.episode(
|
||||
showTitle: title,
|
||||
showYear: seriesYear,
|
||||
showIds: showIds,
|
||||
season: season,
|
||||
number: number,
|
||||
episodeIds: episodeIds,
|
||||
);
|
||||
}
|
||||
|
||||
final hasShowIdentity = showIds.hasReliableId || title.isNotEmpty;
|
||||
if (hasShowIdentity && season != null && number != null) {
|
||||
return TraktScrobbleTarget.episode(
|
||||
showTitle: title,
|
||||
showYear: seriesYear,
|
||||
showIds: showIds,
|
||||
season: season,
|
||||
number: number,
|
||||
episodeIds: episodeIds,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
static TraktIds _idsFrom(Map<String, String> providerIds) {
|
||||
if (providerIds.isEmpty) return const TraktIds();
|
||||
final lower = <String, String>{};
|
||||
for (final entry in providerIds.entries) {
|
||||
final k = entry.key.trim().toLowerCase();
|
||||
final v = entry.value.trim();
|
||||
if (k.isNotEmpty && v.isNotEmpty) lower[k] = v;
|
||||
}
|
||||
return TraktIds(
|
||||
trakt: _parseInt(lower['trakt']),
|
||||
imdb: lower['imdb'],
|
||||
tmdb: _parseInt(lower['tmdb']),
|
||||
tvdb: _parseInt(lower['tvdb']),
|
||||
slug: lower['slug'],
|
||||
);
|
||||
}
|
||||
|
||||
static int? _parseInt(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
return int.tryParse(raw);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
import '../../../shared/utils/async_lock.dart';
|
||||
import '../../contracts/trakt/trakt_response.dart';
|
||||
import '../../contracts/trakt/trakt_sync_job.dart';
|
||||
import '../../domain/ports/authed_trakt_gateway.dart';
|
||||
import 'trakt_retry_policy.dart';
|
||||
|
||||
const _tag = 'TraktQueue';
|
||||
|
||||
|
||||
class TraktPendingSyncQueue {
|
||||
TraktPendingSyncQueue({
|
||||
required this.store,
|
||||
required this.sender,
|
||||
required this.policy,
|
||||
DateTime Function()? clock,
|
||||
String Function()? idGenerator,
|
||||
}) : _clock = clock ?? DateTime.now,
|
||||
_idGenerator = idGenerator ?? (() => const Uuid().v4());
|
||||
|
||||
final JsonTraktSyncJobStore store;
|
||||
final AuthedTraktGateway sender;
|
||||
final TraktRetryPolicy policy;
|
||||
final DateTime Function() _clock;
|
||||
final String Function() _idGenerator;
|
||||
|
||||
final _lock = AsyncLock();
|
||||
bool _draining = false;
|
||||
|
||||
Future<void> enqueue({
|
||||
required TraktSyncJobType type,
|
||||
required Map<String, dynamic> body,
|
||||
Duration? retryAfter,
|
||||
}) async {
|
||||
await _lock.run(() async {
|
||||
final now = _clock();
|
||||
final jobs = await store.load();
|
||||
final nextJobs = _discardSupersededJobs(jobs, type: type, body: body);
|
||||
final job = TraktSyncJob(
|
||||
id: _idGenerator(),
|
||||
type: type,
|
||||
body: body,
|
||||
nextRetryAt: retryAfter == null ? null : now.add(retryAfter),
|
||||
);
|
||||
nextJobs.add(job);
|
||||
await store.save(nextJobs);
|
||||
AppLogger.debug(_tag, 'enqueue ${type.wire} (total=${nextJobs.length})');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Future<void> discardSupersededBy({
|
||||
required TraktSyncJobType type,
|
||||
required Map<String, dynamic> body,
|
||||
}) async {
|
||||
await _lock.run(() async {
|
||||
final jobs = await store.load();
|
||||
final nextJobs = _discardSupersededJobs(jobs, type: type, body: body);
|
||||
if (nextJobs.length != jobs.length) {
|
||||
await store.save(nextJobs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Future<int> pendingCount() async {
|
||||
final jobs = await _lock.run(store.load);
|
||||
return jobs.where((j) => !j.exhausted).length;
|
||||
}
|
||||
|
||||
|
||||
Future<void> drain() async {
|
||||
if (_draining) return;
|
||||
_draining = true;
|
||||
try {
|
||||
await _lock.run(_drainLocked);
|
||||
} finally {
|
||||
_draining = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _drainLocked() async {
|
||||
final jobs = await store.load();
|
||||
if (jobs.isEmpty) return;
|
||||
|
||||
bool connected;
|
||||
try {
|
||||
connected = await sender.isConnected();
|
||||
} catch (_) {
|
||||
connected = false;
|
||||
}
|
||||
if (!connected) {
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'drain skipped: not connected (${jobs.length} pending)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final now = _clock();
|
||||
final remaining = <TraktSyncJob>[];
|
||||
var changed = false;
|
||||
|
||||
for (final job in jobs) {
|
||||
if (job.exhausted) {
|
||||
remaining.add(job);
|
||||
continue;
|
||||
}
|
||||
final due = job.nextRetryAt == null || !job.nextRetryAt!.isAfter(now);
|
||||
if (!due) {
|
||||
remaining.add(job);
|
||||
continue;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
TraktScrobbleOutcome outcome;
|
||||
try {
|
||||
outcome = job.type.isHistory
|
||||
? await sender.syncHistory(job.type, job.body)
|
||||
: await sender.scrobble(job.type, job.body);
|
||||
} catch (e) {
|
||||
outcome = const TraktScrobbleOutcome(TraktResponseClass.transient);
|
||||
AppLogger.debug(_tag, 'send threw, treat as transient', e);
|
||||
}
|
||||
|
||||
final decision = policy.evaluate(
|
||||
cls: outcome.cls,
|
||||
retryCount: job.retryCount,
|
||||
retryAfter: outcome.retryAfter,
|
||||
);
|
||||
|
||||
switch (decision.decision) {
|
||||
case TraktRetryDecision.complete:
|
||||
AppLogger.debug(_tag, 'job ${job.id} ${job.type.wire} → complete');
|
||||
break;
|
||||
case TraktRetryDecision.discard:
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'job ${job.id} ${job.type.wire} → discard '
|
||||
'(${outcome.cls.name})',
|
||||
);
|
||||
break;
|
||||
case TraktRetryDecision.retry:
|
||||
remaining.add(
|
||||
job.copyWith(
|
||||
retryCount: job.retryCount + 1,
|
||||
nextRetryAt: now.add(decision.delay ?? policy.baseBackoff),
|
||||
),
|
||||
);
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'job ${job.id} → retry in '
|
||||
'${(decision.delay ?? policy.baseBackoff).inSeconds}s '
|
||||
'(count=${job.retryCount + 1})',
|
||||
);
|
||||
break;
|
||||
case TraktRetryDecision.giveUp:
|
||||
remaining.add(job.copyWith(clearNextRetryAt: true, exhausted: true));
|
||||
AppLogger.warn(
|
||||
_tag,
|
||||
'job ${job.id} ${job.type.wire} → giveUp '
|
||||
'(exhausted after ${job.retryCount} retries)',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) await store.save(remaining);
|
||||
}
|
||||
|
||||
List<TraktSyncJob> _discardSupersededJobs(
|
||||
List<TraktSyncJob> jobs, {
|
||||
required TraktSyncJobType type,
|
||||
required Map<String, dynamic> body,
|
||||
}) {
|
||||
final key = _mediaKey(body);
|
||||
if (key == null) return List.of(jobs);
|
||||
if (type.isHistory) {
|
||||
return jobs
|
||||
.where((job) {
|
||||
if (job.exhausted) return true;
|
||||
if (!job.type.isHistory) return true;
|
||||
return _mediaKey(job.body) != key;
|
||||
})
|
||||
.toList(growable: true);
|
||||
}
|
||||
if (type != TraktSyncJobType.scrobbleStop) return List.of(jobs);
|
||||
return jobs
|
||||
.where((job) {
|
||||
if (job.exhausted) return true;
|
||||
if (job.type == TraktSyncJobType.scrobbleStop) return true;
|
||||
return _mediaKey(job.body) != key;
|
||||
})
|
||||
.toList(growable: true);
|
||||
}
|
||||
|
||||
static String? _mediaKey(Map<String, dynamic> body) {
|
||||
final movie = body['movie'];
|
||||
if (movie is Map) {
|
||||
return 'movie:${jsonEncode(_canonical(movie.cast<String, dynamic>()))}';
|
||||
}
|
||||
final movies = body['movies'];
|
||||
if (movies is List && movies.isNotEmpty) {
|
||||
final first = movies.first;
|
||||
if (first is Map) {
|
||||
return 'movie:${jsonEncode(_canonical(first, skipWatchedAt: true))}';
|
||||
}
|
||||
}
|
||||
final show = body['show'];
|
||||
final episode = body['episode'];
|
||||
if (show is Map && episode is Map) {
|
||||
final identity = {
|
||||
'show': _canonical(show.cast<String, dynamic>()),
|
||||
'episode': _canonical(episode.cast<String, dynamic>()),
|
||||
};
|
||||
return 'episode:${jsonEncode(identity)}';
|
||||
}
|
||||
final shows = body['shows'];
|
||||
if (shows is List && shows.isNotEmpty) {
|
||||
final first = shows.first;
|
||||
if (first is Map) {
|
||||
return 'show:${jsonEncode(_canonical(first, skipWatchedAt: true))}';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Object? _canonical(Object? value, {bool skipWatchedAt = false}) {
|
||||
if (value is Map) {
|
||||
var filtered = value.entries;
|
||||
if (skipWatchedAt) {
|
||||
filtered = filtered.where((e) => e.key.toString() != 'watched_at');
|
||||
}
|
||||
final entries =
|
||||
filtered
|
||||
.map(
|
||||
(e) => MapEntry(
|
||||
e.key.toString(),
|
||||
_canonical(e.value, skipWatchedAt: skipWatchedAt),
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
return <String, Object?>{
|
||||
for (final entry in entries) entry.key: entry.value,
|
||||
};
|
||||
}
|
||||
if (value is List) {
|
||||
return value
|
||||
.map((e) => _canonical(e, skipWatchedAt: skipWatchedAt))
|
||||
.toList(growable: false);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class JsonTraktSyncJobStore {
|
||||
JsonTraktSyncJobStore({required this._file});
|
||||
|
||||
final File _file;
|
||||
|
||||
Future<List<TraktSyncJob>> load() async {
|
||||
if (!await _file.exists()) return <TraktSyncJob>[];
|
||||
final raw = await _file.readAsString();
|
||||
if (raw.trim().isEmpty) return <TraktSyncJob>[];
|
||||
try {
|
||||
final list = jsonDecode(raw) as List<dynamic>;
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(TraktSyncJob.fromJson)
|
||||
.toList();
|
||||
} catch (_) {
|
||||
return <TraktSyncJob>[];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> save(List<TraktSyncJob> jobs) async {
|
||||
if (!await _file.parent.exists()) {
|
||||
await _file.parent.create(recursive: true);
|
||||
}
|
||||
await _file.writeAsString(
|
||||
const JsonEncoder.withIndent(
|
||||
' ',
|
||||
).convert(jobs.map((e) => e.toJson()).toList()),
|
||||
flush: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../contracts/trakt/trakt_token.dart';
|
||||
import '../../contracts/trakt/trakt_user.dart';
|
||||
import 'trakt_credentials.dart';
|
||||
|
||||
|
||||
class TraktRestApi {
|
||||
TraktRestApi(this._dio) {
|
||||
_dio.options.baseUrl = _dio.options.baseUrl.isEmpty
|
||||
? TraktCredentials.apiBaseUrl
|
||||
: _dio.options.baseUrl;
|
||||
}
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
|
||||
Future<TraktToken> exchangeToken(Map<String, dynamic> body) async {
|
||||
final response = await _dio.post<dynamic>('/oauth/token', data: body);
|
||||
return TraktToken.fromJson(_jsonObject(response.data));
|
||||
}
|
||||
|
||||
|
||||
Future<TraktUserSettings> getUserSettings(String authorization) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/users/settings',
|
||||
options: Options(headers: {'Authorization': authorization}),
|
||||
);
|
||||
return TraktUserSettings.fromJson(_jsonObject(response.data));
|
||||
}
|
||||
|
||||
Map<String, dynamic> _jsonObject(dynamic data) {
|
||||
if (data is Map<String, dynamic>) return data;
|
||||
if (data is Map) return Map<String, dynamic>.from(data);
|
||||
throw FormatException(
|
||||
'Trakt response is not a JSON object: ${data.runtimeType}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TraktHeaderInterceptor extends Interceptor {
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
options.headers['trakt-api-key'] = TraktCredentials.clientId;
|
||||
options.headers['trakt-api-version'] = TraktCredentials.apiVersion;
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import '../../contracts/trakt/trakt_response.dart';
|
||||
|
||||
enum TraktRetryDecision {
|
||||
|
||||
complete,
|
||||
|
||||
|
||||
discard,
|
||||
|
||||
|
||||
retry,
|
||||
|
||||
|
||||
giveUp,
|
||||
}
|
||||
|
||||
class TraktRetryOutcome {
|
||||
final TraktRetryDecision decision;
|
||||
final Duration? delay;
|
||||
const TraktRetryOutcome(this.decision, {this.delay});
|
||||
}
|
||||
|
||||
|
||||
class TraktRetryPolicy {
|
||||
TraktRetryPolicy({
|
||||
this.maxRetries = 8,
|
||||
this.baseBackoff = const Duration(seconds: 5),
|
||||
this.maxBackoff = const Duration(minutes: 30),
|
||||
});
|
||||
|
||||
|
||||
final int maxRetries;
|
||||
final Duration baseBackoff;
|
||||
final Duration maxBackoff;
|
||||
|
||||
TraktRetryOutcome evaluate({
|
||||
required TraktResponseClass cls,
|
||||
required int retryCount,
|
||||
Duration? retryAfter,
|
||||
}) {
|
||||
switch (cls) {
|
||||
case TraktResponseClass.success:
|
||||
case TraktResponseClass.duplicate:
|
||||
return const TraktRetryOutcome(TraktRetryDecision.complete);
|
||||
case TraktResponseClass.invalid:
|
||||
return const TraktRetryOutcome(TraktRetryDecision.discard);
|
||||
case TraktResponseClass.rateLimited:
|
||||
if (retryCount >= maxRetries) {
|
||||
return const TraktRetryOutcome(TraktRetryDecision.giveUp);
|
||||
}
|
||||
return TraktRetryOutcome(
|
||||
TraktRetryDecision.retry,
|
||||
delay: retryAfter ?? backoff(retryCount),
|
||||
);
|
||||
case TraktResponseClass.unauthorized:
|
||||
case TraktResponseClass.transient:
|
||||
if (retryCount >= maxRetries) {
|
||||
return const TraktRetryOutcome(TraktRetryDecision.giveUp);
|
||||
}
|
||||
return TraktRetryOutcome(
|
||||
TraktRetryDecision.retry,
|
||||
delay: backoff(retryCount),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Duration backoff(int retryCount) {
|
||||
final shift = retryCount.clamp(0, 20);
|
||||
final millis = baseBackoff.inMilliseconds * (1 << shift);
|
||||
if (millis >= maxBackoff.inMilliseconds || millis < 0) return maxBackoff;
|
||||
return Duration(milliseconds: millis);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
import '../../contracts/library.dart';
|
||||
import '../../contracts/trakt/trakt_ids.dart';
|
||||
import '../../contracts/trakt/trakt_response.dart';
|
||||
import '../../contracts/trakt/trakt_scrobble_target.dart';
|
||||
import '../../contracts/trakt/trakt_sync_job.dart';
|
||||
import '../../domain/ports/authed_trakt_gateway.dart';
|
||||
import 'trakt_media_matcher.dart';
|
||||
import 'trakt_pending_sync_queue.dart';
|
||||
|
||||
const _tag = 'TraktSync';
|
||||
|
||||
|
||||
class TraktSyncService {
|
||||
TraktSyncService({required this._traktGateway, required this._queue});
|
||||
|
||||
final AuthedTraktGateway _traktGateway;
|
||||
final TraktPendingSyncQueue _queue;
|
||||
|
||||
|
||||
Future<void> syncEmbyPlayedChange({
|
||||
required EmbyRawItem item,
|
||||
required bool played,
|
||||
EmbyRawItem? seriesItem,
|
||||
}) async {
|
||||
try {
|
||||
final target = TraktMediaMatcher.match(
|
||||
itemType: item.Type,
|
||||
itemName: item.Name,
|
||||
productionYear: item.ProductionYear,
|
||||
providerIds: providerIdsOfItem(item),
|
||||
seriesProviderIds: seriesItem == null
|
||||
? const {}
|
||||
: providerIdsOfItem(seriesItem),
|
||||
seriesName: item.SeriesName ?? seriesItem?.Name,
|
||||
seriesProductionYear: seriesItem?.ProductionYear,
|
||||
parentIndexNumber: (item.extra['ParentIndexNumber'] as num?)?.toInt(),
|
||||
itemIndexNumber: item.IndexNumber,
|
||||
);
|
||||
if (target == null) return;
|
||||
|
||||
switch (target.kind) {
|
||||
case TraktMediaKind.movie:
|
||||
if (played) {
|
||||
await markWatched(
|
||||
ids: target.ids,
|
||||
title: target.title,
|
||||
year: target.year,
|
||||
);
|
||||
} else {
|
||||
await markUnwatched(
|
||||
ids: target.ids,
|
||||
title: target.title,
|
||||
year: target.year,
|
||||
);
|
||||
}
|
||||
case TraktMediaKind.episode:
|
||||
if (played) {
|
||||
await markWatched(
|
||||
ids: target.episodeIds,
|
||||
title: item.Name,
|
||||
year: item.ProductionYear,
|
||||
season: target.season,
|
||||
episode: target.number,
|
||||
showIds: target.ids,
|
||||
showTitle: target.title,
|
||||
showYear: target.year,
|
||||
);
|
||||
} else {
|
||||
await markUnwatched(
|
||||
ids: target.episodeIds,
|
||||
title: item.Name,
|
||||
year: item.ProductionYear,
|
||||
season: target.season,
|
||||
episode: target.number,
|
||||
showIds: target.ids,
|
||||
showTitle: target.title,
|
||||
showYear: target.year,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'syncEmbyPlayedChange failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> markWatched({
|
||||
required TraktIds ids,
|
||||
required String title,
|
||||
required int? year,
|
||||
DateTime? watchedAt,
|
||||
int? season,
|
||||
int? episode,
|
||||
TraktIds? showIds,
|
||||
String? showTitle,
|
||||
int? showYear,
|
||||
}) async {
|
||||
final body = _buildHistoryBody(
|
||||
ids: ids,
|
||||
title: title,
|
||||
year: year,
|
||||
watchedAt: watchedAt ?? DateTime.now().toUtc(),
|
||||
season: season,
|
||||
episode: episode,
|
||||
showIds: showIds,
|
||||
showTitle: showTitle,
|
||||
showYear: showYear,
|
||||
);
|
||||
|
||||
AppLogger.debug(_tag, 'markWatched: $title');
|
||||
|
||||
await _sendHistory(TraktSyncJobType.historyAdd, body, label: 'markWatched');
|
||||
}
|
||||
|
||||
|
||||
Future<void> markUnwatched({
|
||||
required TraktIds ids,
|
||||
required String title,
|
||||
required int? year,
|
||||
int? season,
|
||||
int? episode,
|
||||
TraktIds? showIds,
|
||||
String? showTitle,
|
||||
int? showYear,
|
||||
}) async {
|
||||
final body = _buildHistoryBody(
|
||||
ids: ids,
|
||||
title: title,
|
||||
year: year,
|
||||
watchedAt: null,
|
||||
season: season,
|
||||
episode: episode,
|
||||
showIds: showIds,
|
||||
showTitle: showTitle,
|
||||
showYear: showYear,
|
||||
);
|
||||
|
||||
AppLogger.debug(_tag, 'markUnwatched: $title');
|
||||
|
||||
await _sendHistory(
|
||||
TraktSyncJobType.historyRemove,
|
||||
body,
|
||||
label: 'markUnwatched',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<void> _sendHistory(
|
||||
TraktSyncJobType type,
|
||||
Map<String, dynamic> body, {
|
||||
required String label,
|
||||
}) async {
|
||||
TraktScrobbleOutcome outcome;
|
||||
try {
|
||||
outcome = await _traktGateway.syncHistory(type, body);
|
||||
} catch (e) {
|
||||
AppLogger.debug(_tag, '$label threw; enqueue', e);
|
||||
await _enqueue(type, body);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (outcome.cls) {
|
||||
case TraktResponseClass.success:
|
||||
case TraktResponseClass.duplicate:
|
||||
AppLogger.debug(_tag, '$label ok (${outcome.cls.name})');
|
||||
return;
|
||||
case TraktResponseClass.invalid:
|
||||
AppLogger.debug(_tag, '$label invalid → drop');
|
||||
return;
|
||||
case TraktResponseClass.rateLimited:
|
||||
case TraktResponseClass.unauthorized:
|
||||
case TraktResponseClass.transient:
|
||||
await _enqueue(type, body, retryAfter: outcome.retryAfter);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildHistoryBody({
|
||||
required TraktIds ids,
|
||||
required String title,
|
||||
required int? year,
|
||||
required DateTime? watchedAt,
|
||||
int? season,
|
||||
int? episode,
|
||||
TraktIds? showIds,
|
||||
String? showTitle,
|
||||
int? showYear,
|
||||
}) {
|
||||
final isEpisode = season != null && episode != null;
|
||||
|
||||
if (!isEpisode) {
|
||||
final movie = <String, dynamic>{'title': title, 'ids': ids.toJson()};
|
||||
if (year != null) movie['year'] = year;
|
||||
if (watchedAt != null) movie['watched_at'] = watchedAt.toIso8601String();
|
||||
return {
|
||||
'movies': [movie],
|
||||
'shows': [],
|
||||
'episodes': [],
|
||||
};
|
||||
}
|
||||
|
||||
final show = <String, dynamic>{
|
||||
'title': showTitle ?? title,
|
||||
'ids': (showIds ?? const TraktIds()).toJson(),
|
||||
};
|
||||
if (showYear != null) show['year'] = showYear;
|
||||
|
||||
final epNode = <String, dynamic>{'number': episode};
|
||||
if (watchedAt != null) epNode['watched_at'] = watchedAt.toIso8601String();
|
||||
|
||||
show['seasons'] = [
|
||||
{
|
||||
'number': season,
|
||||
'episodes': [epNode],
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
'movies': [],
|
||||
'shows': [show],
|
||||
'episodes': [],
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _enqueue(
|
||||
TraktSyncJobType type,
|
||||
Map<String, dynamic> body, {
|
||||
Duration? retryAfter,
|
||||
}) async {
|
||||
try {
|
||||
await _queue.enqueue(type: type, body: body, retryAfter: retryAfter);
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'enqueue failed', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../contracts/trakt/trakt_token.dart';
|
||||
|
||||
|
||||
class TraktTokenStore {
|
||||
static const keyAccessToken = 'smplayer.trakt_access_token';
|
||||
static const keyRefreshToken = 'smplayer.trakt_refresh_token';
|
||||
static const keyExpiresIn = 'smplayer.trakt_expires_in';
|
||||
static const keyCreatedAt = 'smplayer.trakt_created_at';
|
||||
|
||||
Future<SharedPreferences> _prefs() => SharedPreferences.getInstance();
|
||||
|
||||
|
||||
Future<TraktToken?> read() async {
|
||||
final p = await _prefs();
|
||||
final access = p.getString(keyAccessToken)?.trim() ?? '';
|
||||
if (access.isEmpty) return null;
|
||||
return TraktToken(
|
||||
accessToken: access,
|
||||
refreshToken: p.getString(keyRefreshToken)?.trim() ?? '',
|
||||
expiresIn: p.getInt(keyExpiresIn) ?? 0,
|
||||
createdAt: p.getInt(keyCreatedAt) ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> write(TraktToken token) async {
|
||||
final p = await _prefs();
|
||||
await Future.wait<Object?>([
|
||||
p.setString(keyAccessToken, token.accessToken),
|
||||
p.setString(keyRefreshToken, token.refreshToken),
|
||||
p.setInt(keyExpiresIn, token.expiresIn),
|
||||
p.setInt(keyCreatedAt, token.createdAt),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final p = await _prefs();
|
||||
await Future.wait<Object?>([
|
||||
p.remove(keyAccessToken),
|
||||
p.remove(keyRefreshToken),
|
||||
p.remove(keyExpiresIn),
|
||||
p.remove(keyCreatedAt),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user