Files
plezy/lib/utils/external_ids.dart
T
edde746 4c8272d5b1 refactor(trackers): drive Trakt through the tracker coordinator
Trakt was the one service outside the tracker abstraction. TraktScrobbleService
re-implemented the whole playback lifecycle beside TrackerCoordinator, and
TraktSyncService pushed watched state from its own WatchStateNotifier
subscription, so the player called two objects at every lifecycle point and one
watch could be written twice. TraktTracker now implements RealtimeScrobbleTracker
like Simkl; the duplicated player call sites collapse to one each, and Trakt
shares the coordinator's ID resolver instead of re-fetching show ids every
episode.

Capabilities are split so a tracker declares what it is rather than being
special-cased: ScrobblePolicy carries each service's own resend/seek rules,
EpisodeHistoryTracker names the remote row a per-item history write targets, and
SeriesProgressTracker covers one-counter-per-series services. Writes from all
four trackers go through a shared TrackerWriteQueue, generalised from the
Trakt-only queue, with the legacy Trakt payload migrated on load. Trakt becomes
the fourth TrackersProvider slot and TraktAccountProvider is deleted, so one
object owns the active session per profile.

Two failure paths found while consolidating are fixed here too.

The queue's retries only ran on profile bind, connect and app foreground, so a
network blip mid-session left queued watches waiting for the next foreground.
OfflineModeProvider now notifies on connectivity changes, not just offline-state
or WiFi-flag changes, and main.dart flushes the queue when the network returns.

The queue also counted every failure toward the five attempts that permanently
drop an item, so a rate limit or a service having a bad hour could discard a
pending watch - the loss the queue exists to prevent. Only an answer about the
write itself now spends an attempt: 4xx counts, while rate limits, 5xx,
recoverable token-refresh failures and requests that never arrived do not. A
back-off answer also defers that service for the rest of the flush, so a queue
holding many rows does not fire all of them at a service that just asked for
quiet.
2026-07-30 14:51:32 +02:00

193 lines
7.6 KiB
Dart

/// Season of a parent series as numbered by each external provider.
///
/// Populated from the Fribb mapping's `season: {tvdb: N, tmdb: M}`.
class ExternalSeasonRef {
final int? tvdb;
final int? tmdb;
const ExternalSeasonRef({this.tvdb, this.tmdb});
bool get hasAny => tvdb != null || tmdb != null;
/// True when this entry covers a later season under either provider.
///
/// Independent of [agreedSeason]: it answers "is this a sequel at all",
/// which stays knowable when the two providers disagree. Callers use it to
/// drop the ±1 year window, because a sequel's catalog year is its own
/// season's, not the parent show's.
bool get isSequel => (tvdb ?? 0) > 1 || (tmdb ?? 0) > 1;
/// The season number both providers mapped and agree on, else null.
///
/// TVDB and TMDB disagree on split-cour / continuation seasons — a season
/// the former numbers `2` the latter often folds into `1` at an episode
/// offset. Which one a library follows is a server-side setting, not
/// anything a dataset can tell us, and it is NOT inferable from which ids an
/// item exposes (a Plex show carries all three regardless). So when the two
/// disagree the honest answer is "cannot tell" and the caller must not gate.
///
/// A missing number is not agreement either: `tvdb: 2, tmdb: null` means
/// Fribb has no TMDB season mapping, and a TMDB-ordered server would number
/// that season by the mapping we do not have. Holds for 1133 of the 1185
/// gate-eligible Fribb rows; the other 52 simply go ungated.
int? get agreedSeason => tvdb != null && tvdb == tmdb ? tvdb : null;
Map<String, Object?> toJson() => {if (tvdb != null) 'tvdb': tvdb, if (tmdb != null) 'tmdb': tmdb};
factory ExternalSeasonRef.fromJson(Map<String, Object?> json) =>
ExternalSeasonRef(tvdb: json['tvdb'] as int?, tmdb: json['tmdb'] as int?);
@override
bool operator ==(Object other) => other is ExternalSeasonRef && other.tvdb == tvdb && other.tmdb == tmdb;
@override
int get hashCode => Object.hash(tvdb, tmdb);
}
/// External IDs (IMDb / TMDB / TVDB) extracted from a media server's
/// metadata. Shared by the Trakt and tracker resolvers.
///
/// - **Plex** stores modern IDs in a `Guid` array (`imdb://tt123`,
/// `tmdb://456`, `tvdb://789`) and some legacy agents expose one scalar
/// `guid`. Use [ExternalIds.fromGuids] or [ExternalIds.fromLegacyPlexGuid].
/// - **Jellyfin** stores them inline as a `ProviderIds` map on every
/// `BaseItemDto`. Use [ExternalIds.fromJellyfinProviderIds].
class ExternalIds {
final String? imdb;
final int? tmdb;
final int? tvdb;
const ExternalIds({this.imdb, this.tmdb, this.tvdb});
bool get hasAny => imdb != null || tmdb != null || tvdb != null;
/// True when any id form matches [other]. Used to verify reverse-lookup
/// candidates (never yields false positives; the two sides may carry
/// different id subsets).
bool intersects(ExternalIds other) =>
(imdb != null && imdb == other.imdb) ||
(tmdb != null && tmdb == other.tmdb) ||
(tvdb != null && tvdb == other.tvdb);
/// Round-trips through the persisted tracker write queue. Absent ids stay
/// absent so a re-read yields the same [hasAny]/[intersects] answers.
Map<String, Object?> toJson() => {
if (imdb != null) 'imdb': imdb,
if (tmdb != null) 'tmdb': tmdb,
if (tvdb != null) 'tvdb': tvdb,
};
factory ExternalIds.fromJson(Map<String, Object?> json) => ExternalIds(
imdb: json['imdb'] as String?,
tmdb: (json['tmdb'] as num?)?.toInt(),
tvdb: (json['tvdb'] as num?)?.toInt(),
);
factory ExternalIds.fromGuids(List<dynamic> guids) {
String? imdb;
int? tmdb;
int? tvdb;
for (final g in guids) {
if (g is! Map) continue;
final id = g['id'];
if (id is! String) continue;
if (id.startsWith('imdb://')) {
imdb = id.substring(7);
} else if (id.startsWith('tmdb://')) {
tmdb = int.tryParse(id.substring(7));
} else if (id.startsWith('tvdb://')) {
tvdb = int.tryParse(id.substring(7));
}
}
return ExternalIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb);
}
/// Build from a legacy Plex item's scalar `guid`.
///
/// Only agent formats that map directly to IMDb, TMDB, or TVDB are
/// recognized. HAMA AniDB identifiers require an external mapping and are
/// deliberately left unsupported here.
factory ExternalIds.fromLegacyPlexGuid(Object? guid) {
if (guid is! String || guid.isEmpty) return const ExternalIds();
final uri = Uri.tryParse(guid);
if (uri == null || !uri.hasAuthority || uri.path.isNotEmpty) return const ExternalIds();
final value = uri.host;
switch (uri.scheme.toLowerCase()) {
case 'com.plexapp.agents.imdb':
return ExternalIds(imdb: _normalizeImdb(value, allowBareDigits: false));
case 'com.plexapp.agents.themoviedb':
return ExternalIds(tmdb: _parseNumericId(value));
case 'com.plexapp.agents.thetvdb':
return ExternalIds(tvdb: _parseNumericId(value));
case 'com.plexapp.agents.hama':
final separator = value.indexOf('-');
if (separator <= 0 || separator == value.length - 1) return const ExternalIds();
final source = value.substring(0, separator).toLowerCase();
final id = value.substring(separator + 1);
if (source == 'imdb') {
return ExternalIds(imdb: _normalizeImdb(id, allowBareDigits: true));
}
if (source == 'tmdb' || source == 'tsdb') {
return ExternalIds(tmdb: _parseNumericId(id));
}
if (_hamaTvdbSource.hasMatch(source)) {
return ExternalIds(tvdb: _parseNumericId(id));
}
}
return const ExternalIds();
}
static final RegExp _decimalId = RegExp(r'^[0-9]+$');
static final RegExp _hamaTvdbSource = RegExp(r'^tvdb(?:[2-9])?$');
static int? _parseNumericId(String value) => _decimalId.hasMatch(value) ? int.tryParse(value) : null;
static String? _normalizeImdb(String value, {required bool allowBareDigits}) {
final normalized = value.toLowerCase();
if (normalized.startsWith('tt')) {
final digits = normalized.substring(2);
return _decimalId.hasMatch(digits) ? 'tt$digits' : null;
}
return allowBareDigits && _decimalId.hasMatch(normalized) ? 'tt$normalized' : null;
}
/// Pick the first raw Jellyfin item whose inline `ProviderIds` intersect
/// [ids]. Pure helper so the reverse-lookup verification stays
/// unit-testable (its call site lives in a part file).
static Map<String, dynamic>? jellyfinCandidateMatching(List<Map<String, dynamic>> candidates, ExternalIds ids) {
for (final item in candidates) {
final providerIds = item['ProviderIds'];
if (providerIds is! Map) continue;
final candidate = ExternalIds.fromJellyfinProviderIds(providerIds.cast<String, Object?>());
if (ids.intersects(candidate)) return item;
}
return null;
}
/// Build from a Jellyfin `ProviderIds` map. Jellyfin stores external IDs
/// directly on every `BaseItemDto` so no extra fetch is needed.
/// Keys are case-insensitive in practice (`Tmdb`, `Imdb`, `Tvdb`).
factory ExternalIds.fromJellyfinProviderIds(Map<String, Object?> providerIds) {
String? imdb;
int? tmdb;
int? tvdb;
providerIds.forEach((key, value) {
if (value is! String || value.isEmpty) return;
switch (key.toLowerCase()) {
case 'imdb':
imdb = value;
break;
case 'tmdb':
tmdb = int.tryParse(value);
break;
case 'tvdb':
tvdb = int.tryParse(value);
break;
}
});
return ExternalIds(imdb: imdb, tmdb: tmdb, tvdb: tvdb);
}
}