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.
373 lines
13 KiB
Dart
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);
|
|
}
|
|
}
|
|
}
|