Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -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);
}
}
+113
View File
@@ -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),
]);
}
}