Files
plezy/lib/services/trakt/trakt_scrobble_service.dart
T
edde746 352b88109b refactor: extract shared mixins and helpers, drop dead abstractions
Introduces shared seams for paginated views, D-pad reorder, media control
routing, async singletons and the device method channel, then points the
open-coded copies at them.

Also removes unused models and duplicated provider/server plumbing, folds
the twice-implemented artifact store in the server, and factors the
repeated Flutter toolchain prologue in CI into a composite action.
2026-07-26 06:09:48 +02:00

373 lines
13 KiB
Dart

import 'dart:async';
import 'package:http/http.dart' as http;
import '../../media/media_item.dart';
import '../../media/media_kind.dart';
import '../../media/media_server_client.dart';
import '../../media/playback_timeline.dart';
import '../../models/trakt/trakt_ids.dart';
import '../../models/trakt/trakt_scrobble_request.dart';
import '../../utils/app_logger.dart';
import '../../utils/json_utils.dart';
import '../settings_service.dart';
import '../trackers/tracker.dart';
import '../trackers/tracker_constants.dart';
import '../trackers/tracker_id_resolver.dart';
import '../trackers/tracker_rating_match.dart';
import '../trackers/tracker_session.dart';
import 'trakt_client.dart';
import 'trakt_constants.dart';
/// Real-time scrobble service for Trakt.
///
/// Mirrors the lifecycle shape of `DiscordRPCService`: invoked from
/// `video_player_screen.dart` at the same call sites (start/pause/resume/stop,
/// position updates).
class TraktScrobbleService implements TrackerRatingSource {
/// Drop a duplicate state transition within this window — mpv emits multiple
/// playing-state events on seek.
static const Duration _duplicateStateDebounce = Duration(seconds: 1);
/// Drop a `start` re-send within this window of the previous start.
/// Trakt enforces "max one scrobble per 15 min per item"; this avoids
/// spamming 409s during rapid pause/play cycles.
static const Duration _startResendThrottle = Duration(seconds: 30);
/// Max one seek-checkpoint per this window — slider drag fires many position
/// updates per second; we only want to ship one to Trakt.
static const Duration _seekCheckpointThrottle = Duration(seconds: 5);
static TraktScrobbleService? _instance;
static TraktScrobbleService get instance => _instance ??= TraktScrobbleService._();
TraktScrobbleService._();
bool _isInitialized = false;
bool _isEnabled = false;
TraktClient? _client;
TrackerIdResolver? _resolver;
TraktScrobbleRequest? _currentBody;
final PlaybackTimeline _timeline = PlaybackTimeline();
TraktScrobbleState? _lastSentState;
DateTime? _lastSentAt;
DateTime? _lastSeekCheckpointAt;
int _playbackRevision = 0;
Future<void> initialize() async {
if (_isInitialized) return;
_isInitialized = true;
final settings = await SettingsService.getInstance();
_isEnabled = settings.read(SettingsService.scrobblePref(TrackerService.trakt));
}
Future<void> setEnabled(bool enabled) async {
_isEnabled = enabled;
if (!enabled) cancelInFlight();
}
/// Switch to a different account. Cancels any in-flight scrobble for the
/// previous account so we don't send a stop event to the wrong user.
void rebindToProfile(
TrackerSession? session, {
required void Function() onSessionInvalidated,
void Function(TrackerSession session)? onSessionUpdated,
http.Client? httpClient,
}) {
_client?.dispose();
_client = session != null
? TraktClient(
session,
onSessionInvalidated: onSessionInvalidated,
onSessionUpdated: onSessionUpdated,
httpClient: httpClient,
)
: null;
cancelInFlight();
}
void updateSession(TrackerSession session) {
_client?.updateSession(session);
}
@override
Future<int?> getRating(TrackerRatingContext ctx) async {
final client = _client;
if (client == null) throw const TrackerRatingUnavailableException('Trakt');
final localIds = TraktIds.fromExternal(ctx.ids.external).toJson();
if (localIds.isEmpty) throw const TrackerRatingUnavailableException('Trakt');
final entries = await client.getRatings(_ratingType(ctx));
for (final entry in entries) {
if (entry is! Map) continue;
if (!_ratingEntryMatches(ctx, entry.cast<String, dynamic>(), localIds)) continue;
final rating = flexibleInt(entry['rating']);
return rating != null && rating > 0 ? rating.clamp(1, 10).toInt() : null;
}
return null;
}
@override
Future<void> rate(TrackerRatingContext ctx, int score) async {
final client = _client;
if (client == null) throw const TrackerRatingUnavailableException('Trakt');
await client.addRatings(_ratingBody(ctx, rating: score.clamp(1, 10).toInt()));
}
@override
Future<void> clearRating(TrackerRatingContext ctx) async {
final client = _client;
if (client == null) throw const TrackerRatingUnavailableException('Trakt');
await client.removeRatings(_ratingBody(ctx));
}
/// Drop the current scrobble state without sending a stop. Called on profile
/// switch and when the service is disabled mid-playback.
void cancelInFlight() {
++_playbackRevision;
_clearPlaybackState();
}
void _clearPlaybackState() {
_currentBody = null;
_lastSentState = null;
_lastSentAt = null;
_lastSeekCheckpointAt = null;
_resolver?.clearCache();
_resolver = null;
_timeline.reset();
}
bool get _canScrobble => _isEnabled && _client != null;
String _ratingType(TrackerRatingContext ctx) => switch (ctx.kind) {
MediaKind.movie => 'movies',
MediaKind.show => 'shows',
MediaKind.season => 'seasons',
MediaKind.episode => 'episodes',
_ => throw const TrackerRatingUnavailableException('Trakt'),
};
bool _ratingEntryMatches(TrackerRatingContext ctx, Map<String, dynamic> entry, Map<String, dynamic> localIds) {
final show = entry['show'];
final movie = entry['movie'];
return switch (ctx.kind) {
MediaKind.movie => trackerIdsMatch(trackerNestedIds(movie), localIds),
MediaKind.show => trackerIdsMatch(trackerNestedIds(show), localIds),
MediaKind.season =>
trackerIdsMatch(trackerNestedIds(show), localIds) && _numberMatches(entry['season'], ctx.season),
MediaKind.episode =>
trackerIdsMatch(trackerNestedIds(show), localIds) &&
_numberMatches(entry['episode'], ctx.episodeNumber) &&
_seasonMatches(entry['episode'], ctx.season),
_ => false,
};
}
bool _numberMatches(Object? value, int? expected) {
if (expected == null || value is! Map) return false;
return flexibleInt(value['number']) == expected;
}
bool _seasonMatches(Object? value, int? expected) {
if (expected == null || value is! Map) return false;
return flexibleInt(value['season']) == expected;
}
Map<String, dynamic> _ratingBody(TrackerRatingContext ctx, {int? rating}) {
final ids = TraktIds.fromExternal(ctx.ids.external).toJson();
final item = {'ids': ids, 'rating': ?rating};
return switch (ctx.kind) {
MediaKind.movie => {
'movies': [item],
},
MediaKind.show => {
'shows': [item],
},
MediaKind.season => {
'shows': [
{
'ids': ids,
'seasons': [
{'number': ctx.season, 'rating': ?rating},
],
},
],
},
MediaKind.episode => {
'shows': [
{
'ids': ids,
'seasons': [
{
'number': ctx.season,
'episodes': [
{'number': ctx.episodeNumber, 'rating': ?rating},
],
},
],
},
],
},
_ => throw const TrackerRatingUnavailableException('Trakt'),
};
}
Future<void> startPlayback(MediaItem metadata, MediaServerClient client, {bool isLive = false}) async {
final revision = ++_playbackRevision;
_clearPlaybackState();
if (!_canScrobble || isLive) return;
final type = metadata.kind;
if (type != MediaKind.movie && type != MediaKind.episode) return;
final settings = SettingsService.instanceOrNull;
if (settings != null && !settings.isLibraryAllowedForTracker(TrackerService.trakt, metadata.libraryGlobalKey)) {
appLogger.d('Trakt: library filtered out for ${metadata.id}');
return;
}
// Seed with the resume offset so the first real position update doesn't
// look like a seek when resuming mid-item.
_timeline.reset(
position: metadata.viewOffsetMs != null ? Duration(milliseconds: metadata.viewOffsetMs!) : Duration.zero,
duration: metadata.durationMs != null ? Duration(milliseconds: metadata.durationMs!) : null,
);
_resolver = TrackerIdResolver(client, needsFribb: () => false);
final body = await _buildBody(metadata);
if (revision != _playbackRevision) return;
if (body == null) {
appLogger.d('Trakt: skipping scrobble — no usable IDs for ${metadata.id}');
_clearPlaybackState();
return;
}
_currentBody = body;
await _send(TraktScrobbleState.start, progress: _progressPercent());
}
void updatePosition(Duration position) {
final isSeek = _timeline.updatePosition(position);
// Trakt has no seek event — instead, official apps send pause+start with
// the new progress to checkpoint. Without this, the "resume on another
// device" feature is stuck on the pre-seek position until the next
// pause/stop.
if (_currentBody == null) return;
if (_lastSentState != TraktScrobbleState.start) return;
if (!isSeek) return;
final now = DateTime.now();
if (_lastSeekCheckpointAt != null && now.difference(_lastSeekCheckpointAt!) < _seekCheckpointThrottle) {
return;
}
_lastSeekCheckpointAt = now;
unawaited(_sendSeekCheckpoint());
}
void updateDuration(Duration duration) {
_timeline.updateDuration(duration);
}
Future<void> pausePlayback() async {
if (_currentBody == null) return;
await _send(TraktScrobbleState.pause, progress: _progressPercent());
}
Future<void> resumePlayback() async {
if (_currentBody == null) return;
await _send(TraktScrobbleState.start, progress: _progressPercent());
}
Future<void> stopPlayback() async {
final revision = ++_playbackRevision;
if (_currentBody == null) {
_clearPlaybackState();
return;
}
await _send(TraktScrobbleState.stop, progress: _progressPercent());
if (revision == _playbackRevision) _clearPlaybackState();
}
Future<TraktScrobbleRequest?> _buildBody(MediaItem metadata) async {
final resolver = _resolver;
if (resolver == null) return null;
if (metadata.kind == MediaKind.movie) {
final ids = await resolver.resolveForMovie(metadata.id);
if (ids == null) return null;
return TraktScrobbleRequest.movie(ids: TraktIds.fromExternal(ids.external));
}
final season = metadata.parentIndex;
final number = metadata.index;
if (season == null || number == null) return null;
final showIds = await resolver.resolveShowForEpisode(metadata, includeAnimeProgress: false);
if (showIds == null) return null;
return TraktScrobbleRequest.episode(
showIds: TraktIds.fromExternal(showIds.external),
season: season,
number: number,
);
}
double _progressPercent() => _timeline.progressPercent;
/// Send pause→start to Trakt so the playback-progress endpoint reflects the
/// new position. Bypasses [_send]'s state throttle (this is a checkpoint,
/// not a state change) but updates the throttle bookkeeping so a regular
/// `start` immediately after won't double-fire.
Future<void> _sendSeekCheckpoint() async {
final client = _client;
final body = _currentBody;
if (client == null || body == null) return;
final progress = _progressPercent();
final scrobble = body.copyWith(progress: progress);
try {
await client.scrobblePause(scrobble);
await client.scrobbleStart(scrobble);
_lastSentState = TraktScrobbleState.start;
_lastSentAt = DateTime.now();
appLogger.d('Trakt: seek checkpoint @ ${progress.toStringAsFixed(1)}%');
} catch (e) {
appLogger.d('Trakt: seek checkpoint failed', error: e);
}
}
Future<void> _send(TraktScrobbleState state, {required double progress}) async {
final client = _client;
final body = _currentBody;
if (client == null || body == null) return;
final now = DateTime.now();
if (_lastSentState == state && _lastSentAt != null) {
final elapsed = now.difference(_lastSentAt!);
if (elapsed < _duplicateStateDebounce) return;
if (state == TraktScrobbleState.start && elapsed < _startResendThrottle) return;
}
_lastSentState = state;
_lastSentAt = now;
final scrobble = body.copyWith(progress: progress);
try {
switch (state) {
case TraktScrobbleState.start:
await client.scrobbleStart(scrobble);
case TraktScrobbleState.pause:
await client.scrobblePause(scrobble);
case TraktScrobbleState.stop:
await client.scrobbleStop(scrobble);
}
appLogger.d('Trakt: scrobble ${state.name} @ ${progress.toStringAsFixed(1)}%');
} catch (e) {
// Never let scrobble errors block playback.
appLogger.d('Trakt: scrobble ${state.name} failed', error: e);
}
}
}