Files
plezy/lib/providers/download_provider.dart
2026-08-10 20:28:41 +02:00

2175 lines
84 KiB
Dart

import 'dart:async';
import '../media/ids.dart';
import 'package:flutter/foundation.dart';
import '../i18n/strings.g.dart';
import '../media/media_backend.dart';
import '../media/media_item.dart';
import '../media/media_item_merge.dart';
import '../media/media_item_types.dart';
import '../media/media_kind.dart';
import '../media/media_version.dart';
import '../models/download_models.dart';
import '../utils/download_version_utils.dart';
import '../database/app_database.dart';
import '../database/download_operations.dart';
import '../services/background_work_diagnostics_service.dart';
import '../services/download_manager_service.dart';
import '../services/api_cache.dart';
import '../services/download_artwork_service.dart';
import '../services/download_storage_service.dart';
import '../services/downloaded_video_source.dart';
import '../services/multi_server_manager.dart';
import '../services/offline_mode_source.dart';
import '../services/watch_state_resolver.dart';
import 'watch_state_store.dart';
import '../media/media_server_client.dart';
import '../services/sync_rule_executor.dart';
import '../utils/app_logger.dart';
import '../utils/deletion_notifier.dart';
import '../media/episode_collection.dart';
import '../utils/global_key_utils.dart';
import '../utils/content_utils.dart';
import '../utils/notification_permission.dart';
import '../utils/watch_state_notifier.dart';
import '../mixins/disposable_change_notifier_mixin.dart';
part 'download_metadata_store.dart';
typedef _QueueOwnership = ({String profileId, int generation});
/// Filter mode for batch downloads (shows/seasons).
/// Use [all] to download everything, or [unwatched] with an optional maxCount.
enum DownloadFilter { all, unwatched }
/// Holds Plex thumb path reference for downloaded artwork.
/// The actual file path is computed from the hash of serverId + thumb path.
class DownloadedArtwork {
/// The Plex thumb path (e.g., /library/metadata/12345/thumb/1234567890)
final String? thumbPath;
const DownloadedArtwork({this.thumbPath});
/// Get the local file path for this artwork
String? getLocalPath(DownloadStorageService storage, ServerId serverId) {
if (thumbPath == null) return null;
return DownloadArtworkService.localPathSync(storage, serverId, thumbPath);
}
}
class _RelatedMetadataDownloadContext {
final hydratedMetadataKeys = <String>{};
final ensuredArtworkKeys = <String>{};
}
typedef _MetadataHydrationResult = ({MediaItem? metadata, bool networkFilled, bool stale});
/// Provider for managing download state and operations.
class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
int _batchDeletionDepth = 0;
final DownloadManagerService _downloadManager;
final AppDatabase _database;
final SyncRuleExecutor _syncRuleExecutor;
StreamSubscription<DownloadProgress>? _progressSubscription;
StreamSubscription<DeletionProgress>? _deletionProgressSubscription;
late final Future<void> _initFuture;
// Track download progress by public globalKey (serverId:ratingKey).
// Downloads are shared across profiles/users; scoped Jellyfin state lives in
// watch actions, cache namespaces, and sync-rule ownership.
final Map<String, DownloadProgress> _downloads = {};
// Metadata and artwork cache lifecycle is isolated from queue ownership.
late final _DownloadMetadataStore _metadataStore;
Map<String, MediaItem> get _metadata => _metadataStore.items;
Map<String, DownloadedArtwork> get _artworkPaths => _metadataStore.artworkPaths;
// Track items currently being queued (building download queue)
final Map<String, _QueueOwnership> _queueing = {};
// Public download keys owned by the active profile. Physical download rows
// stay app-wide; this set controls profile-visible state.
final Set<String> _ownedDownloadKeys = {};
// Track items currently being deleted with progress
final Map<String, DeletionProgress> _deletionProgress = {};
// Persistent sync rules keyed by profile-scoped globalKey
// (profileId|serverId:ratingKey). Downloads remain public/shared.
final Map<String, SyncRuleItem> _syncRules = {};
final Set<String> _removingSyncRuleKeys = {};
bool _syncRuleCleanupInProgress = false;
String? _activeProfileId;
int _profileGeneration = 0;
Future<void>? _profileScopedReloadFuture;
_QueueOwnership _captureQueueOwnership() => (profileId: _requireActiveProfileId(), generation: _profileGeneration);
bool _isQueueOwnershipCurrent(_QueueOwnership ownership) =>
_activeProfileId == ownership.profileId && _profileGeneration == ownership.generation;
OfflineModeSource? _offlineSource;
int _networkStateGeneration = 0;
DownloadProvider({required this._downloadManager, required this._database})
: _syncRuleExecutor = SyncRuleExecutor(database: _database) {
_metadataStore = _DownloadMetadataStore(_downloadManager, _database)..addListener(_onMetadataStoreChanged);
_progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate);
_deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate);
_initFuture = _loadPersistedDownloads();
// Lets the diagnostics service score whether downloads actually advance
// while the app is backgrounded, without it reaching into providers.
BackgroundWorkDiagnosticsService.instance.bindActivitySource(downloadActivitySnapshot);
}
/// Test-only constructor that skips the heavy initial load (artwork dir,
/// pinned-metadata bulk fetch). Only sync rules are loaded
/// from the database. Use this in tests that exercise the provider's public
/// database-backed API without mocking [DownloadStorageService],
/// or path_provider.
@visibleForTesting
DownloadProvider.forTesting({
required this._downloadManager,
required this._database,
this._activeProfileId = 'test-profile',
}) : _syncRuleExecutor = SyncRuleExecutor(database: _database) {
_metadataStore = _DownloadMetadataStore(_downloadManager, _database, activeProfileId: _activeProfileId)
..addListener(_onMetadataStoreChanged);
_progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate);
_deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate);
_initFuture = _loadProfileScopedState();
}
/// Inject the offline-mode source so queueing paths can short-circuit when
/// the device has no Plex connectivity. Sync-rule execution receives a
/// snapshot of this state when invoked, keeping this provider as the owner.
void setOfflineSource(OfflineModeSource? source) {
if (!identical(_offlineSource, source)) {
_offlineSource?.removeListener(_onOfflineSourceChanged);
_offlineSource = source;
_offlineSource?.addListener(_onOfflineSourceChanged);
_networkStateGeneration++;
}
_downloadManager.setOfflineSource(source);
}
/// Ensures persisted downloads have been loaded from disk.
Future<void> ensureInitialized() => _initFuture;
Future<void> setDownloadLocation({required String path, required String pathType}) {
return _downloadManager.setDownloadLocation(path: path, pathType: pathType);
}
Future<void> resetDownloadLocation() {
return _downloadManager.resetDownloadLocation();
}
/// Switch the visible sync-rule scope to [profileId]. Physical downloads are
/// intentionally not reloaded because they are shared across profiles.
void setActiveProfileId(String? profileId) {
if (_activeProfileId == profileId) return;
_profileGeneration++;
_queueing.clear();
_ownedDownloadKeys.clear();
_syncRules.clear();
_metadata.clear();
_activeProfileId = profileId;
_metadataStore.setActiveProfileId(profileId);
safeNotifyListeners();
final reload = _reloadProfileScopedStateForActiveProfile();
_profileScopedReloadFuture = reload;
unawaited(reload);
}
Future<void> _reloadProfileScopedStateForActiveProfile() async {
final targetProfileId = _activeProfileId;
final targetGeneration = _profileGeneration;
await _initFuture;
if (_activeProfileId != targetProfileId || _profileGeneration != targetGeneration) return;
await _loadProfileScopedState();
await refreshMetadataFromCache();
await _applyOfflineWatchOverlay(expectedProfileGeneration: targetGeneration);
if (_activeProfileId == targetProfileId && _profileGeneration == targetGeneration) {
safeNotifyListeners();
}
}
String _requireActiveProfileId() {
final profileId = _activeProfileId;
if (profileId == null || profileId.isEmpty) {
throw StateError('Cannot create, update, or claim downloads without an active profile');
}
return profileId;
}
bool _ownsDownloadKey(String globalKey) => _ownedDownloadKeys.contains(globalKey);
bool _ownsProgressEntry(MapEntry<String, DownloadProgress> entry) => _ownsDownloadKey(entry.key);
/// Claim [globalKey] for an explicit [profileId] — sync rules claim for
/// the RULE'S owner, not whoever is active when the pass lands, so a
/// mid-run profile switch can't leak ownership across profiles.
Future<bool> _claimDownloadForProfile(String globalKey, _QueueOwnership ownership, MediaServerClient client) async {
if (!_isQueueOwnershipCurrent(ownership)) return false;
if (_ownedDownloadKeys.contains(globalKey)) return false;
await _database.addDownloadOwner(
profileId: ownership.profileId,
globalKey: globalKey,
backendId: client.backend.id,
clientScopeId: client.cacheServerId,
);
if (!_isQueueOwnershipCurrent(ownership)) return false;
_ownedDownloadKeys.add(globalKey);
return true;
}
Future<bool> _releaseDownloadForProfile(
String globalKey,
String profileId, {
bool onlyIfShared = false,
DownloadOwnerItem? ownerHint,
}) async {
DownloadOwnerItem? owner = ownerHint;
if (onlyIfShared) {
// Capture the departing cache namespace before the atomic database
// release; the ownership row is gone by the time cache cleanup runs.
owner ??= await _database.getDownloadOwner(profileId: profileId, globalKey: globalKey);
final result = await _database.removeSharedDownloadOwnerAndRebindIncompleteMedia(
profileId: profileId,
globalKey: globalKey,
);
if (!result.hasRemainingOwner) return false;
owner = result.removedOwner ?? owner;
} else {
owner ??= await _database.getDownloadOwner(profileId: profileId, globalKey: globalKey);
await _database.removeDownloadOwner(profileId: profileId, globalKey: globalKey);
}
final parsed = parseGlobalKey(globalKey);
if (parsed != null && owner != null) {
await _downloadManager.deleteMetadataForOwner(
globalKey: globalKey,
serverId: parsed.serverId,
itemId: parsed.ratingKey,
profileId: profileId,
backendId: owner.backend,
clientScopeId: owner.clientScopeId,
);
}
if (_activeProfileId == profileId) {
_ownedDownloadKeys.remove(globalKey);
}
return true;
}
/// Remove all ownership rows for a deleted profile and delete physical files
/// that no remaining valid profile owns.
Future<void> deleteDownloadsForProfile(String profileId) async {
await _releaseDownloadsForProfileWhere(profileId, (_) => true);
}
/// Preserve physical downloads across a full logout while detaching them
/// from profiles that are about to be deleted. The next selected profile
/// adopts the ownerless rows through [_loadDownloadOwners].
Future<void> detachDownloadsForLogout() async {
// Invalidate any in-flight profile reload before waiting for it: an old
// reload may still finish its DB adoption, but cannot repopulate the
// active-profile view after this point.
_activeProfileId = null;
_metadataStore.setActiveProfileId(null);
_profileGeneration++;
await _initFuture;
await _profileScopedReloadFuture;
await _downloadManager.preparePlexMetadataForLogoutTransfer();
await _database.clearAllDownloadOwners();
_ownedDownloadKeys.clear();
_syncRules.clear();
safeNotifyListeners();
}
/// Remove ownership rows for [profileId] that belong to the removed
/// connection's public server ids. Physical files stay when any other valid
/// owner remains.
Future<void> releaseDownloadsForProfileServers(String profileId, Set<String> serverIds) async {
if (serverIds.isEmpty) return;
await _releaseDownloadsForProfileWhere(profileId, (globalKey) {
final parsed = parseGlobalKey(globalKey);
return parsed != null && serverIds.contains(parsed.serverId);
});
}
Future<void> _releaseDownloadsForProfileWhere(String profileId, bool Function(String globalKey) shouldRelease) async {
if (profileId.isEmpty) return;
final ownedKeys = await _database.getDownloadOwnerKeysForProfile(profileId);
var changed = false;
for (final globalKey in ownedKeys) {
if (!shouldRelease(globalKey)) continue;
final meta = _metadata[globalKey];
final releasedAsShared = await _releaseDownloadForProfile(globalKey, profileId, onlyIfShared: true);
if (releasedAsShared) {
changed = true;
continue;
}
// Keep the final durable owner until physical deletion succeeds. A
// retry can then resume cleanup without orphaning the shared row.
final finalOwner = await _database.getDownloadOwner(profileId: profileId, globalKey: globalKey);
await _downloadManager.deleteDownload(globalKey);
await _releaseDownloadForProfile(globalKey, profileId, ownerHint: finalOwner);
_downloads.remove(globalKey);
_metadata.remove(globalKey);
_artworkPaths.remove(globalKey);
if (meta != null) {
DeletionNotifier().notifyDeletedItem(item: meta, isDownloadOnly: true);
}
changed = true;
}
if (changed) safeNotifyListeners();
}
Future<void> _loadProfileScopedState() async {
await _loadDownloadOwners();
await _loadSyncRules();
}
/// Test-only seam to populate internal state maps without driving the full
/// queue/progress pipeline. Intended for tests that exercise functions whose
/// behavior depends on pre-existing state (e.g. cancelDownload artwork
/// cleanup, _loadPersistedDownloads transient-state clearing).
@visibleForTesting
void debugSeedState({
Map<String, DownloadProgress>? downloads,
Map<String, MediaItem>? metadata,
Map<String, DownloadedArtwork>? artwork,
Set<String>? queueing,
Map<String, DeletionProgress>? deletionProgress,
Set<String>? ownedDownloadKeys,
}) {
if (downloads != null) _downloads.addAll(downloads);
if (metadata != null) _metadata.addAll(metadata);
if (artwork != null) _artworkPaths.addAll(artwork);
if (queueing != null) {
final ownership = _captureQueueOwnership();
for (final globalKey in queueing) {
_queueing[globalKey] = ownership;
}
}
if (deletionProgress != null) _deletionProgress.addAll(deletionProgress);
if (ownedDownloadKeys != null) {
_ownedDownloadKeys.addAll(ownedDownloadKeys);
} else if (downloads != null) {
_ownedDownloadKeys.addAll(downloads.keys);
}
}
@visibleForTesting
Future<void> debugHydrateOfflineWatchOverlay() => _applyOfflineWatchOverlay();
@visibleForTesting
Future<void> debugWaitForProfileScopedReload() async {
await _profileScopedReloadFuture;
}
@visibleForTesting
Future<void> debugWaitForWatchStateWrites() => _metadataStore.waitForWatchStateWrites();
/// Load all persisted downloads and metadata from the database/cache
Future<void> _loadPersistedDownloads() async {
try {
// Wait for recovery to finish before loading state so that
// interrupted "downloading" rows have been transitioned to "queued"
await _downloadManager.recoveryFuture;
// Clear existing data to prevent stale entries after deletions
_downloads.clear();
_artworkPaths.clear();
_metadata.clear();
_queueing.clear();
_deletionProgress.clear();
_ownedDownloadKeys.clear();
await _loadDownloadOwners();
final storageService = DownloadStorageService.instance;
// Initialize artwork directory path for synchronous access
await storageService.getArtworkDirectory();
final downloads = await _downloadManager.getAllDownloads();
// Bulk-load all pinned metadata across every backend in a single pass
// instead of per-item DB calls.
final allMetadata = await _downloadManager.getAllPinnedMetadata(
preferActiveScope: true,
activeProfileId: _activeProfileId,
);
for (final item in downloads) {
_downloads[item.globalKey] = DownloadProgress(
globalKey: item.globalKey,
status: DownloadStatus.values[item.status],
progress: item.progress,
downloadedBytes: item.downloadedBytes,
totalBytes: item.totalBytes ?? 0,
errorMessage: item.errorMessage,
);
_artworkPaths[item.globalKey] = DownloadedArtwork(thumbPath: item.thumbPath);
if (_ownsDownloadKey(item.globalKey)) {
await _hydrateDownloadMetadata(item.globalKey, allMetadata);
}
}
await _loadSyncRules();
// Apply queued offline watch actions on top of the server-time metadata
// we just loaded, so re-entries reflect locally-marked watched/unwatched
// state from previous sessions until those actions sync to the server.
await _applyOfflineWatchOverlay();
appLogger.i(
'Loaded ${_downloads.length} downloads, ${_metadata.length} metadata entries, '
'and ${_syncRules.length} sync rules',
);
safeNotifyListeners();
} catch (e) {
appLogger.e('Failed to load persisted downloads', error: e);
}
}
/// Hydrate queued OfflineWatchProgress actions into the canonical
/// hierarchy-aware watch-state layer.
Future<void> _applyOfflineWatchOverlay({int? expectedProfileGeneration}) {
return _metadataStore.hydrateOfflineWatchOverlay(
isStale: expectedProfileGeneration == null
? null
: () => isDisposed || expectedProfileGeneration != _profileGeneration,
);
}
/// Load parent metadata (show + season for episodes, artist + album for
/// tracks) from a pre-loaded map (no DB I/O). Used during bulk
/// initialization to avoid per-item DB queries.
void _loadParentMetadataFromMap(MediaItem leaf, Map<String, MediaItem> allMetadata, {String? clientScopeId}) {
_metadataStore.loadParentMetadataFromMap(leaf, allMetadata, clientScopeId: clientScopeId);
}
Future<_MetadataHydrationResult> _hydrateDownloadMetadata(
String globalKey,
Map<String, MediaItem> allMetadata, {
bool fetchOnMiss = false,
bool Function()? isStale,
}) async {
final parsed = parseGlobalKey(globalKey);
if (parsed == null) return (metadata: null, networkFilled: false, stale: false);
var cached =
allMetadata[globalKey] ??
await _downloadManager.lookupMetadata(
parsed.serverId,
parsed.ratingKey,
preferActiveScope: true,
activeProfileId: _activeProfileId,
);
if (isStale?.call() ?? false) return (metadata: null, networkFilled: false, stale: true);
var networkFilled = false;
if (cached == null && fetchOnMiss && _downloads.containsKey(globalKey)) {
cached = await _downloadManager.fetchAndPinMetadata(
parsed.serverId,
parsed.ratingKey,
preferActiveScope: true,
activeProfileId: _activeProfileId,
);
if (isStale?.call() ?? false) return (metadata: null, networkFilled: false, stale: true);
networkFilled = cached != null;
}
if (cached == null) {
// Parent rows can be shared by multiple downloaded siblings. A missing
// leaf invalidates only that leaf; profile changes clear the whole store.
_metadata.remove(globalKey);
}
if (cached != null) {
_metadata[globalKey] = cached;
if (cached.isEpisode || cached.kind == MediaKind.track) {
final clientScopeId = await _downloadManager.profileClientScopeIdForServer(parsed.serverId, _activeProfileId);
if (isStale?.call() ?? false) return (metadata: null, networkFilled: false, stale: true);
_loadParentMetadataFromMap(cached, allMetadata, clientScopeId: clientScopeId);
}
}
return (metadata: cached, networkFilled: networkFilled, stale: false);
}
void _onProgressUpdate(DownloadProgress progress) {
appLogger.d('Progress update received: ${progress.globalKey} - ${progress.status} - ${progress.progress}%');
final ownedByActiveProfile = _ownsDownloadKey(progress.globalKey);
final previous = _downloads[progress.globalKey];
final terminalUpdateOmittedBytes =
previous != null &&
progress.downloadedBytes == 0 &&
previous.downloadedBytes > 0 &&
switch (progress.status) {
DownloadStatus.completed ||
DownloadStatus.failed ||
DownloadStatus.cancelled ||
DownloadStatus.partial => true,
DownloadStatus.queued || DownloadStatus.downloading || DownloadStatus.paused => false,
};
// Terminal status-only events omit byte fields. Preserve only those omitted
// counters; live progress and explicit retry resets must remain authoritative.
final merged = terminalUpdateOmittedBytes
? progress.copyWith(
progress: progress.progress == 0 ? previous.progress : progress.progress,
downloadedBytes: previous.downloadedBytes,
totalBytes: progress.totalBytes == 0 ? previous.totalBytes : progress.totalBytes,
)
: progress;
_downloads[progress.globalKey] = merged;
// Sync artwork paths when they are available.
if (merged.hasArtworkPaths) {
_artworkPaths[merged.globalKey] = DownloadedArtwork(thumbPath: merged.thumbPath);
}
if (ownedByActiveProfile) safeNotifyListeners();
}
@override
void dispose() {
BackgroundWorkDiagnosticsService.instance.unbindActivitySource(downloadActivitySnapshot);
_offlineSource?.removeListener(_onOfflineSourceChanged);
_progressSubscription?.cancel();
_deletionProgressSubscription?.cancel();
_metadataStore
..removeListener(_onMetadataStoreChanged)
..dispose();
super.dispose();
}
void _onMetadataStoreChanged() => safeNotifyListeners();
void _onOfflineSourceChanged() => _networkStateGeneration++;
/// Ensure metadata has a serverId, falling back to a parent's serverId.
MediaItem _ensureServerId(MediaItem metadata, String? fallbackServerId) =>
metadata.serverId != null ? metadata : metadata.copyWith(serverId: fallbackServerId);
/// All current download progress entries
Map<String, DownloadProgress> get downloads =>
Map.unmodifiable(Map.fromEntries(_downloads.entries.where(_ownsProgressEntry)));
/// Aggregate transfer activity for [BackgroundWorkDiagnosticsService].
///
/// Deliberately unfiltered by profile: the OS restricts the process, not a
/// profile. Byte and percentage totals are merged monotonically per row;
/// status counters distinguish a drained queue from a killed task.
///
/// A method rather than a getter so the tear-off handed to
/// [BackgroundWorkDiagnosticsService.bindActivitySource] compares equal at
/// unbind time.
DownloadActivitySnapshot downloadActivitySnapshot() {
var activeTasks = 0;
var completedTasks = 0;
var failedTasks = 0;
var downloadedBytes = 0;
var progressUnits = 0;
for (final progress in _downloads.values) {
switch (progress.status) {
case DownloadStatus.downloading:
activeTasks++;
case DownloadStatus.completed:
completedTasks++;
case DownloadStatus.failed:
failedTasks++;
case DownloadStatus.queued:
case DownloadStatus.paused:
case DownloadStatus.cancelled:
case DownloadStatus.partial:
break;
}
downloadedBytes += progress.downloadedBytes;
progressUnits += progress.progress;
}
return (
activeTasks: activeTasks,
completedTasks: completedTasks,
failedTasks: failedTasks,
downloadedBytes: downloadedBytes,
progressUnits: progressUnits,
networkAvailable: _offlineSource == null ? null : !_offlineSource!.isOffline,
networkStateGeneration: _networkStateGeneration,
);
}
/// All metadata for downloads
Map<String, MediaItem> get metadata => _metadataStore.resolvedItems;
/// Get unique TV shows that have downloaded episodes
/// Returns stored show metadata, or synthesizes from episode metadata as fallback
List<MediaItem> get downloadedShows {
final Map<String, MediaItem> shows = {};
for (final entry in _metadata.entries) {
final globalKey = entry.key;
if (!_ownsDownloadKey(globalKey)) continue;
final meta = _metadataStore.applyWatchState(entry.value);
final progress = _downloads[globalKey];
if (progress?.status == DownloadStatus.completed && meta.isEpisode) {
final showRatingKey = meta.grandparentId;
if (showRatingKey != null && !shows.containsKey(showRatingKey)) {
// Try to get stored show metadata first
final showGlobalKey = buildGlobalKey(ServerId(meta.serverId!), showRatingKey);
final storedShow = _resolvedMetadata(showGlobalKey);
if (storedShow != null && storedShow.isShow) {
// Use stored show metadata (has year, summary, clearLogo)
shows[showRatingKey] = storedShow;
} else {
// Fallback: synthesize from episode metadata (missing year, summary)
// Only Plex consumers read `raw['key']` (library-section + folder
// navigation), so we synthesize the Plex URI for Plex shows and
// emit a MediaBrowser-shaped item for Jellyfin or Emby
// (`Id` + `Type=Series`).
final synthesizedRaw = switch (meta.backend) {
MediaBackend.plex => <String, dynamic>{'key': '/library/metadata/$showRatingKey'},
MediaBackend.jellyfin || MediaBackend.emby => <String, dynamic>{'Id': showRatingKey, 'Type': 'Series'},
};
shows[showRatingKey] = MediaItem(
id: showRatingKey,
backend: meta.backend,
kind: MediaKind.show,
title: meta.grandparentTitle ?? t.common.unknown,
thumbPath: meta.grandparentThumbPath,
artPath: meta.grandparentArtPath,
serverId: meta.serverId,
raw: synthesizedRaw,
);
}
}
}
}
return shows.values.toList();
}
/// Get completed movie downloads
List<MediaItem> get downloadedMovies {
return _metadata.entries
.where((entry) {
if (!_ownsDownloadKey(entry.key)) return false;
final progress = _downloads[entry.key];
return progress?.status == DownloadStatus.completed && entry.value.isMovie;
})
.map((entry) => _metadataStore.applyWatchState(entry.value))
.toList();
}
/// Unique albums that have completed downloaded tracks, sorted by artist
/// then album title. Uses stored album metadata (persisted alongside each
/// track download) and falls back to synthesizing from track fields.
List<MediaItem> get downloadedAlbums {
final Map<String, MediaItem> albums = {};
for (final entry in _metadata.entries) {
final globalKey = entry.key;
if (!_ownsDownloadKey(globalKey)) continue;
final meta = _metadataStore.applyWatchState(entry.value);
if (meta.kind != MediaKind.track) continue;
if (_downloads[globalKey]?.status != DownloadStatus.completed) continue;
final albumRatingKey = meta.parentId;
if (albumRatingKey == null || albums.containsKey(albumRatingKey)) continue;
final albumGlobalKey = buildGlobalKey(ServerId(meta.serverId!), albumRatingKey);
final storedAlbum = _resolvedMetadata(albumGlobalKey);
if (storedAlbum != null && storedAlbum.kind == MediaKind.album) {
albums[albumRatingKey] = storedAlbum;
} else {
albums[albumRatingKey] = MediaItem(
id: albumRatingKey,
backend: meta.backend,
kind: MediaKind.album,
title: meta.albumTitle ?? t.common.unknown,
parentId: meta.grandparentId,
parentTitle: meta.grandparentTitle,
thumbPath: meta.parentThumbPath ?? meta.thumbPath,
serverId: meta.serverId,
);
}
}
final list = albums.values.toList();
list.sort((a, b) {
final byArtist = (a.albumArtistTitle ?? '').compareTo(b.albumArtistTitle ?? '');
if (byArtist != 0) return byArtist;
return (a.title ?? '').compareTo(b.title ?? '');
});
return list;
}
/// Completed downloaded tracks of an album, sorted by disc then track
/// number — the offline playback queue for that album.
List<MediaItem> getDownloadedTracksForAlbum(String albumRatingKey) {
final tracks = _metadata.entries
.where((entry) {
if (!_ownsDownloadKey(entry.key)) return false;
final meta = entry.value;
return meta.kind == MediaKind.track &&
meta.parentId == albumRatingKey &&
_downloads[entry.key]?.status == DownloadStatus.completed;
})
.map((entry) => _metadataStore.applyWatchState(entry.value))
.toList();
tracks.sort((a, b) {
final byDisc = (a.discNumber ?? 1).compareTo(b.discNumber ?? 1);
if (byDisc != 0) return byDisc;
return (a.trackNumber ?? 0).compareTo(b.trackNumber ?? 0);
});
return tracks;
}
/// Get metadata for a specific download
MediaItem? _resolvedMetadata(String globalKey) => _metadataStore.resolved(globalKey);
MediaItem? getMetadata(String globalKey) => _resolvedMetadata(globalKey);
/// Get artwork paths for a specific download (for offline display)
DownloadedArtwork? getArtworkPaths(String globalKey) => _artworkPaths[globalKey];
/// Get local file path for any artwork type (thumb, art, clearLogo, etc.)
/// Returns null if artwork directory isn't initialized or artworkPath is null
String? getArtworkLocalPath(ServerId serverId, String? artworkPath) {
if (artworkPath == null) return null;
return DownloadArtworkService.localPathSync(DownloadStorageService.instance, serverId, artworkPath);
}
/// Get downloaded episodes for a specific show (by grandparentRatingKey)
List<MediaItem> getDownloadedEpisodesForShow(String showRatingKey) {
return _metadata.entries
.where((entry) {
if (!_ownsDownloadKey(entry.key)) return false;
final progress = _downloads[entry.key];
final meta = entry.value;
return progress?.status == DownloadStatus.completed && meta.isEpisode && meta.grandparentId == showRatingKey;
})
.map((entry) => _metadataStore.applyWatchState(entry.value))
.toList();
}
/// Get leaf downloads (episodes or tracks) filtered by grandparent
/// (show/artist) and/or parent (season/album) ratingKey.
List<DownloadProgress> _getLeafDownloads({String? grandparentRatingKey, String? parentRatingKey}) {
return _downloads.entries
.where((entry) {
if (!_ownsDownloadKey(entry.key)) return false;
final meta = _metadata[entry.key];
if (meta == null || !(meta.isEpisode || meta.kind == MediaKind.track)) return false;
if (grandparentRatingKey != null && meta.grandparentId != grandparentRatingKey) return false;
if (parentRatingKey != null && meta.parentId != parentRatingKey) return false;
return true;
})
.map((entry) => entry.value)
.toList();
}
/// Calculate aggregate progress for a show (based on all its episodes)
/// Returns synthetic DownloadProgress with aggregated values
DownloadProgress? getAggregateProgressForShow(ServerId serverId, String showRatingKey) {
return _calculateAggregateProgress(
serverId: serverId,
ratingKey: showRatingKey,
episodes: _getLeafDownloads(grandparentRatingKey: showRatingKey),
entityType: 'show',
);
}
/// Calculate aggregate progress for a season (based on all its episodes)
/// Returns synthetic DownloadProgress with aggregated values
DownloadProgress? getAggregateProgressForSeason(ServerId serverId, String seasonRatingKey) {
return _calculateAggregateProgress(
serverId: serverId,
ratingKey: seasonRatingKey,
episodes: _getLeafDownloads(parentRatingKey: seasonRatingKey),
entityType: 'season',
);
}
/// Aggregate progress for an album (parent of its tracks).
DownloadProgress? getAggregateProgressForAlbum(ServerId serverId, String albumRatingKey) {
return _calculateAggregateProgress(
serverId: serverId,
ratingKey: albumRatingKey,
episodes: _getLeafDownloads(parentRatingKey: albumRatingKey),
entityType: 'album',
);
}
/// Aggregate progress for an artist (grandparent of its tracks).
DownloadProgress? getAggregateProgressForArtist(ServerId serverId, String artistRatingKey) {
return _calculateAggregateProgress(
serverId: serverId,
ratingKey: artistRatingKey,
episodes: _getLeafDownloads(grandparentRatingKey: artistRatingKey),
entityType: 'artist',
);
}
/// Shared helper to calculate aggregate download progress for shows/seasons
DownloadProgress? _calculateAggregateProgress({
required ServerId serverId,
required String ratingKey,
required List<DownloadProgress> episodes,
required String entityType,
}) {
final globalKey = buildGlobalKey(ServerId(serverId), ratingKey);
// The progress ring reflects only the episodes the user actually queued for
// this show/season — not the show's full episode count. _getEpisodeDownloads
// returns just the owned download records, so episodes.length IS the queued
// count. Downloading 5 of a 50-episode show therefore reaches 100% at 5/5.
//
final int totalEpisodes = episodes.length;
if (totalEpisodes == 0) {
appLogger.d('⚠️ No queued downloads for $entityType $ratingKey, returning null');
return null;
}
int completedCount = 0;
int downloadingCount = 0;
int queuedCount = 0;
int failedCount = 0;
int summedProgress = 0; // sum of per-episode progress (completed counts as 100)
for (final ep in episodes) {
summedProgress += ep.status == DownloadStatus.completed ? 100 : ep.progress;
switch (ep.status) {
case DownloadStatus.completed:
completedCount++;
case DownloadStatus.downloading:
downloadingCount++;
case DownloadStatus.queued:
queuedCount++;
case DownloadStatus.failed:
failedCount++;
default:
break;
}
}
final DownloadStatus overallStatus;
if (completedCount == totalEpisodes) {
overallStatus = DownloadStatus.completed;
} else if (completedCount > 0 && downloadingCount == 0 && queuedCount == 0 && completedCount < totalEpisodes) {
overallStatus = DownloadStatus.partial;
} else if (downloadingCount > 0) {
overallStatus = DownloadStatus.downloading;
} else if (queuedCount > 0) {
overallStatus = DownloadStatus.queued;
} else if (failedCount > 0) {
overallStatus = DownloadStatus.failed;
} else {
return null;
}
// Smooth percentage across the queued episodes: an in-flight episode
// contributes its partial progress so the ring advances continuously,
// rather than jumping only when whole episodes complete. Cap below 100%
// until every episode is actually complete — otherwise rounding (e.g.
// 99.8 → 100) could fill the ring while a download is still finishing.
final int rawProgress = (summedProgress / totalEpisodes).round();
final int overallProgress = completedCount == totalEpisodes ? 100 : (rawProgress > 99 ? 99 : rawProgress);
appLogger.d(
'Aggregate progress for $entityType $ratingKey: $overallProgress% '
'($completedCount completed, $downloadingCount downloading, '
'$queuedCount queued of $totalEpisodes total) - Status: $overallStatus',
);
final leafNoun = entityType == 'album' || entityType == 'artist' ? 'tracks' : 'episodes';
return DownloadProgress(
globalKey: globalKey,
status: overallStatus,
progress: overallProgress,
downloadedBytes: 0,
totalBytes: 0,
currentFile: '$completedCount/$totalEpisodes $leafNoun',
);
}
/// Get download progress for a specific item
/// For shows/seasons, returns aggregate progress of all child episodes
/// For episodes/movies, returns direct progress
DownloadProgress? getProgress(String globalKey) {
final directProgress = _downloads[globalKey];
if (directProgress != null) {
if (!_ownsDownloadKey(globalKey)) return null;
return directProgress;
}
final parsed = parseGlobalKey(globalKey);
if (parsed == null) return null;
final serverId = parsed.serverId;
final ratingKey = parsed.ratingKey;
final meta = _metadata[globalKey];
if (meta == null) {
// No metadata stored yet, might be a container (show/season/artist/
// album) being queued. Check if any leaves exist for this as a parent —
// the aggregate helpers are kind-agnostic over grandparent/parent keys.
final leavesAsGrandparent = _getLeafDownloads(grandparentRatingKey: ratingKey);
if (leavesAsGrandparent.isNotEmpty) {
return getAggregateProgressForShow(serverId, ratingKey);
}
final leavesAsParent = _getLeafDownloads(parentRatingKey: ratingKey);
if (leavesAsParent.isNotEmpty) {
return getAggregateProgressForSeason(serverId, ratingKey);
}
return null;
}
// We have metadata, check kind
return switch (meta.kind) {
MediaKind.show => getAggregateProgressForShow(serverId, ratingKey),
MediaKind.season => getAggregateProgressForSeason(serverId, ratingKey),
MediaKind.album => getAggregateProgressForAlbum(serverId, ratingKey),
MediaKind.artist => getAggregateProgressForArtist(serverId, ratingKey),
_ => null,
};
}
/// Check if an item is downloaded
/// For shows/seasons, checks if all episodes are downloaded
bool isDownloaded(String globalKey) {
final progress = getProgress(globalKey);
return progress?.status == DownloadStatus.completed;
}
/// Check if an item is currently downloading
/// For shows/seasons, checks if any episodes are downloading
bool isDownloading(String globalKey) {
final progress = getProgress(globalKey);
return progress?.status == DownloadStatus.downloading;
}
/// Check if an item is in the queue
/// For shows/seasons, checks if any episodes are queued
@visibleForTesting
bool isQueued(String globalKey) {
final progress = getProgress(globalKey);
return progress?.status == DownloadStatus.queued;
}
/// Check if an item is currently being queued (building download queue)
bool isQueueing(String globalKey) => _queueing.containsKey(globalKey);
/// Get the completed download record for an item, or null when the item
/// isn't fully downloaded or isn't owned by the active profile. Callers use
/// the row's mediaIndex/mediaSourceId to target the version actually on
/// disk instead of assuming the server default.
Future<DownloadedMediaItem?> getCompletedDownload(String globalKey) async {
if (!_ownsDownloadKey(globalKey)) return null;
final downloadedItem = await _downloadManager.getDownloadedMedia(globalKey);
if (downloadedItem == null || downloadedItem.status != DownloadStatus.completed.index) {
return null;
}
return downloadedItem;
}
/// Get the local video file path for a downloaded item
/// Returns null if not downloaded or file doesn't exist
Future<String?> getVideoFilePath(String globalKey, {int? mediaIndex, String? mediaSourceId}) async {
appLogger.d('getVideoFilePath called with globalKey: $globalKey');
if (!_ownsDownloadKey(globalKey)) {
appLogger.w('Profile does not own downloaded item: $globalKey');
return null;
}
final downloadedItem = await _downloadManager.getDownloadedMedia(globalKey);
if (downloadedItem == null) {
appLogger.w('No downloaded item found for globalKey: $globalKey');
return null;
}
final source = await resolveDownloadedVideoSource(
downloadedItem,
requestedMediaIndex: mediaIndex,
requestedMediaSourceId: mediaSourceId,
);
return source?.path;
}
/// Queue a download for a media item.
/// For movies, episodes, and tracks, queues directly.
/// For shows and seasons, fetches all child episodes and queues them.
/// For albums and artists, fetches all child tracks and queues them.
/// Returns the number of items queued.
Future<int> queueDownload(
MediaItem metadata,
MediaServerClient client, {
DownloadVersionConfig? versionConfig,
DownloadFilter filter = DownloadFilter.all,
int? maxCount,
bool includeSpecials = true,
}) async {
if (!_downloadManager.downloadsSupported) return 0;
final ownership = _captureQueueOwnership();
final globalKey = metadata.globalKey;
final config = versionConfig ?? DownloadVersionConfig();
if (_queueing.containsKey(globalKey)) return 0;
_queueing[globalKey] = ownership;
safeNotifyListeners();
try {
// Claim the operation before the first await so a second tap cannot
// launch a duplicate container expansion.
if (await DownloadManagerService.shouldBlockDownloadOnCellular()) {
throw CellularDownloadBlockedException();
}
if (!_isQueueOwnershipCurrent(ownership)) return 0;
// The queueing claim above remains held while Android's permission
// dialog is open, so a second tap cannot launch duplicate expansion.
await NotificationPermission.ensure();
if (!_isQueueOwnershipCurrent(ownership)) return 0;
if (metadata.isMovie || metadata.isEpisode || metadata.kind == MediaKind.track) {
final queued = await _queueSingleDownload(
metadata,
client,
ownership: ownership,
mediaIndex: config.mediaIndex,
);
return queued ? 1 : 0;
} else if (metadata.kind == MediaKind.album || metadata.kind == MediaKind.artist) {
return await _withStashedMetadata(
metadata,
ownership,
() => _queueMusicContainerDownload(metadata, client, ownership),
);
} else if (metadata.isShow || metadata.isSeason) {
return await _withStashedMetadata(
metadata,
ownership,
() => _expandAndQueue(
container: metadata,
client: client,
ownership: ownership,
versionConfig: config,
filter: filter,
maxCount: maxCount,
skipExisting: false,
includeSpecials: includeSpecials,
),
);
} else {
throw Exception('Cannot download ${metadata.kind.id}');
}
} finally {
if (_queueing[globalKey] == ownership) {
_queueing.remove(globalKey);
safeNotifyListeners();
}
}
}
Future<T> _withStashedMetadata<T>(
MediaItem metadata,
_QueueOwnership ownership,
Future<T> Function() operation,
) async {
if (!_isQueueOwnershipCurrent(ownership)) {
throw StateError('Queue ownership is stale');
}
final globalKey = metadata.globalKey;
final previous = _metadata[globalKey];
_metadata[globalKey] = metadata;
try {
return await operation();
} catch (_) {
if (_isQueueOwnershipCurrent(ownership)) {
if (previous == null) {
_metadata.remove(globalKey);
} else {
_metadata[globalKey] = previous;
}
}
rethrow;
}
}
/// Queue every playable item from a collection/playlist for download.
///
/// Expansion follows [collectListLeaves] so a one-shot list download queues
/// exactly what a sync rule on the same list would.
///
/// When [syncRule] is given, the rule's membership — the unfiltered leaves of
/// the list, not just the ones this pass queues — is linked to the rule so a
/// later "delete rule and its downloads" pass can find every associated row.
Future<int> queueListDownload(
List<MediaItem> items,
MediaServerClient client, {
DownloadFilter filter = DownloadFilter.all,
SyncRuleItem? syncRule,
}) async {
if (!_downloadManager.downloadsSupported) return 0;
final ownership = _captureQueueOwnership();
if (await DownloadManagerService.shouldBlockDownloadOnCellular()) {
throw CellularDownloadBlockedException();
}
if (!_isQueueOwnershipCurrent(ownership)) return 0;
final unwatchedOnly = filter == DownloadFilter.unwatched;
final membership = <MediaItem>[];
final candidates = <MediaItem>[];
// Expand one list entry at a time so a cancelled queue stops before the
// next container is fetched.
for (final item in items) {
if (!_isQueueOwnershipCurrent(ownership)) return 0;
final leaves = <MediaItem>[];
await collectListLeaves(client, [item], unwatchedOnly: unwatchedOnly, out: leaves);
candidates.addAll(leaves);
if (syncRule != null) {
if (unwatchedOnly) {
// Rule membership spans the whole list; only the queue is filtered.
await collectListLeaves(client, [item], unwatchedOnly: false, out: membership);
} else {
membership.addAll(leaves);
}
}
}
if (!_isQueueOwnershipCurrent(ownership)) return 0;
if (syncRule != null) {
for (final item in membership) {
final withServer = _ensureServerId(item, client.serverId);
if (_hasActiveOwnedDownload(withServer.globalKey)) {
await _associateSyncRuleDownload(syncRule, withServer.globalKey, ownership);
}
}
}
final relatedContext = _RelatedMetadataDownloadContext();
var count = 0;
for (final item in candidates) {
if (!_isQueueOwnershipCurrent(ownership)) return count;
final withServer = _ensureServerId(item, client.serverId);
if (_hasActiveOwnedDownload(withServer.globalKey)) continue;
final queued = await _queueSingleDownload(
withServer,
client,
ownership: ownership,
relatedContext: relatedContext,
);
if (syncRule != null) {
await _associateSyncRuleDownload(syncRule, withServer.globalKey, ownership);
}
if (queued) count++;
}
if (syncRule != null && _isQueueOwnershipCurrent(ownership)) {
await markSyncRuleDownloadLinksInitialized(syncRule.globalKey);
}
return count;
}
/// Queue a single movie or episode for download.
/// Returns true if the item was actually queued, false if skipped.
Future<bool> _queueSingleDownload(
MediaItem metadata,
MediaServerClient client, {
required _QueueOwnership ownership,
int mediaIndex = 0,
DownloadVersionConfig? versionConfig,
_RelatedMetadataDownloadContext? relatedContext,
}) async {
if (!_downloadManager.downloadsSupported) return false;
if (!_isQueueOwnershipCurrent(ownership)) return false;
var metadataToStore = metadata.serverId == null ? metadata.copyWith(serverId: client.serverId) : metadata;
final globalKey = metadataToStore.globalKey;
// Don't duplicate the physical download. If another profile already owns
// the shared row, claiming it makes it visible for the owning profile.
if (_downloads.containsKey(globalKey)) {
final existing = _downloads[globalKey]!;
if (existing.status == DownloadStatus.downloading ||
existing.status == DownloadStatus.completed ||
existing.status == DownloadStatus.queued ||
existing.status == DownloadStatus.paused) {
try {
await _downloadManager.saveMetadata(metadataToStore, client);
} catch (e) {
// Claiming an already-present physical download must also work
// offline. Cache enrichment is best effort; ownership is durable.
appLogger.w('Failed to pin metadata while claiming $globalKey', error: e);
}
if (!_isQueueOwnershipCurrent(ownership)) return false;
final claimed = await _claimDownloadForProfile(globalKey, ownership, client);
if (!_isQueueOwnershipCurrent(ownership)) return false;
if (claimed) safeNotifyListeners();
return claimed;
}
}
// Always fetch full metadata before downloading.
// Hub items may have summary but the cache at /library/metadata/$ratingKey
// won't have the full API response (with Media/Part data needed for video URL)
// unless fetchItem has been called.
//
// Skip the fetch when offline — it would just fail. The partial metadata
// from whatever hub/grid invoked the queue is good enough to enqueue; the
// actual video URL resolves later when we're back online.
if (_offlineSource?.isOffline ?? false) {
appLogger.d('Offline — using partial metadata for ${metadata.id}');
} else {
try {
final fullMetadata = await client.fetchItem(metadata.id);
if (fullMetadata != null) {
metadataToStore = mergeFetchedMediaItem(
fetched: fullMetadata,
existing: metadataToStore,
fallbackServerId: client.serverId,
);
}
} catch (e) {
appLogger.w('Failed to fetch full metadata for ${metadata.id}, using partial', error: e);
}
}
if (!_isQueueOwnershipCurrent(ownership)) return false;
// Smart version matching for series/season downloads
var resolvedIndex = mediaIndex;
if (versionConfig != null && versionConfig.acceptedSignatures.isNotEmpty) {
final versions = metadataToStore.mediaVersions;
if (versions != null && versions.isNotEmpty) {
final matchedIndex = MediaVersion.findMatchingIndex(versions, versionConfig.acceptedSignatures);
if (matchedIndex != null) {
resolvedIndex = matchedIndex;
} else if (versionConfig.onVersionMismatch != null) {
final pickedIndex = await versionConfig.onVersionMismatch!(metadataToStore, versions);
if (!_isQueueOwnershipCurrent(ownership)) return false;
if (pickedIndex == null) return false;
resolvedIndex = pickedIndex;
if (!_isQueueOwnershipCurrent(ownership)) return false;
versionConfig.acceptedSignatures.add(versions[pickedIndex].signature);
}
}
}
// For episodes (show + season) and tracks (artist + album), also fetch
// and store parent metadata for offline display.
if (metadataToStore.isEpisode || metadataToStore.kind == MediaKind.track) {
await _fetchAndStoreParentMetadata(
metadataToStore,
client,
ownership: ownership,
context: relatedContext ?? _RelatedMetadataDownloadContext(),
);
if (!_isQueueOwnershipCurrent(ownership)) return false;
}
// Store full metadata for display
if (!_isQueueOwnershipCurrent(ownership)) return false;
_metadata[globalKey] = metadataToStore;
await _claimDownloadForProfile(globalKey, ownership, client);
if (!_isQueueOwnershipCurrent(ownership)) return false;
_downloads[globalKey] = DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued);
safeNotifyListeners();
if (!_isQueueOwnershipCurrent(ownership)) return false;
await _downloadManager.queueDownload(metadata: metadataToStore, client: client, mediaIndex: resolvedIndex);
return true;
}
/// Fetch and store parent metadata for a leaf item — show + season for an
/// episode, artist + album for a track (same grandparent/parent fields).
/// Also downloads the parents' artwork.
Future<void> _fetchAndStoreParentMetadata(
MediaItem leaf,
MediaServerClient client, {
required _QueueOwnership ownership,
required _RelatedMetadataDownloadContext context,
}) async {
final serverId = leaf.serverId;
if (serverId == null) return;
await _fetchAndStoreRelatedMetadata(
serverId: ServerId(serverId),
ratingKey: leaf.grandparentId,
client: client,
ownership: ownership,
context: context,
);
if (!_isQueueOwnershipCurrent(ownership)) return;
await _fetchAndStoreRelatedMetadata(
serverId: ServerId(serverId),
ratingKey: leaf.parentId,
client: client,
ownership: ownership,
context: context,
);
}
/// Fetch, persist, and download artwork for a related metadata item (show or season).
Future<void> _fetchAndStoreRelatedMetadata({
required ServerId serverId,
required String? ratingKey,
required MediaServerClient client,
required _QueueOwnership ownership,
required _RelatedMetadataDownloadContext context,
}) async {
if (ratingKey == null || !_isQueueOwnershipCurrent(ownership)) return;
final globalKey = buildGlobalKey(ServerId(serverId), ratingKey);
MediaItem? metadata = _metadata[globalKey];
var fetchedFreshMetadata = false;
if (!(_offlineSource?.isOffline ?? false) && !context.hydratedMetadataKeys.contains(globalKey)) {
try {
final fetched = await client.fetchItem(ratingKey);
if (fetched != null) {
metadata = mergeFetchedMediaItem(fetched: fetched, existing: metadata, fallbackServerId: serverId);
context.hydratedMetadataKeys.add(globalKey);
fetchedFreshMetadata = true;
}
} catch (e) {
appLogger.w('Failed to fetch metadata for $ratingKey', error: e);
}
}
if (metadata == null || !_isQueueOwnershipCurrent(ownership)) return;
final withServer = metadata.copyWith(serverId: serverId);
_metadata[globalKey] = withServer;
if (!_isQueueOwnershipCurrent(ownership)) return;
await _downloadManager.saveMetadata(withServer, client);
if (!_isQueueOwnershipCurrent(ownership)) return;
final thumbPath = withServer.thumbPath;
if (fetchedFreshMetadata || context.ensuredArtworkKeys.add(globalKey)) {
if (!_isQueueOwnershipCurrent(ownership)) return;
await _downloadManager.downloadArtworkForMetadata(withServer, client);
if (!_isQueueOwnershipCurrent(ownership)) return;
}
_artworkPaths[globalKey] = DownloadedArtwork(thumbPath: thumbPath);
}
/// Queue every track under an album/artist. Expansion is one
/// recursive-leaves call ([MediaServerClient.fetchPlayableDescendants]) on
/// every backend — Plex branches album→/children, while MediaBrowser retries
/// tag-only artists by album-artist credit.
Future<int> _queueMusicContainerDownload(
MediaItem container,
MediaServerClient client,
_QueueOwnership ownership,
) async {
final tracks = await client.fetchPlayableDescendants(container.id);
if (!_isQueueOwnershipCurrent(ownership)) return 0;
final relatedContext = _RelatedMetadataDownloadContext();
int count = 0;
for (final track in tracks) {
if (!_isQueueOwnershipCurrent(ownership)) return count;
final trackWithServer = _ensureServerId(track, container.serverId);
final queued = await _queueSingleDownload(
trackWithServer,
client,
ownership: ownership,
relatedContext: relatedContext,
);
if (queued) count++;
}
return count;
}
/// Queue only the missing (not downloaded) episodes for a show/season.
/// Used for resuming partial downloads. Returns the number of episodes queued.
Future<int> queueMissingEpisodes(
MediaItem metadata,
MediaServerClient client, {
DownloadVersionConfig? versionConfig,
}) async {
if (!metadata.isShow && !metadata.isSeason) {
throw Exception('queueMissingEpisodes only supports shows/seasons');
}
final ownership = _captureQueueOwnership();
final queued = await _expandAndQueue(
container: metadata,
client: client,
ownership: ownership,
versionConfig: versionConfig,
filter: DownloadFilter.all,
maxCount: null,
skipExisting: true,
);
if (metadata.isShow) {
appLogger.i('Queued $queued missing episodes for show ${metadata.title}');
}
return queued;
}
/// Shared expansion: fetch all episodes under [container] (show or season),
/// apply [filter] and optional [maxCount], optionally skip items already
/// queued/downloading/completed ([skipExisting]), and queue each one.
Future<int> _expandAndQueue({
required MediaItem container,
required MediaServerClient client,
required _QueueOwnership ownership,
required DownloadVersionConfig? versionConfig,
required DownloadFilter filter,
required int? maxCount,
required bool skipExisting,
bool includeSpecials = true,
}) async {
final unwatchedOnly = filter == DownloadFilter.unwatched;
// Downloading the Specials season itself must still queue its episodes —
// only suppress Specials when sweeping a whole show or a regular season.
final effectiveIncludeSpecials =
includeSpecials || (container.kind == MediaKind.season && isSpecialSeasonNumber(container.index));
final relatedContext = _RelatedMetadataDownloadContext();
final episodes = <MediaItem>[];
await collectEpisodes(
client,
container.id,
unwatchedOnly: unwatchedOnly,
out: episodes,
fallback: container,
includeSpecials: effectiveIncludeSpecials,
);
if (!_isQueueOwnershipCurrent(ownership)) return 0;
int count = 0;
for (final episode in episodes) {
if (!_isQueueOwnershipCurrent(ownership)) return count;
if (maxCount != null && count >= maxCount) break;
final episodeWithServer = _ensureServerId(episode, container.serverId);
if (skipExisting) {
final progress = _downloads[episodeWithServer.globalKey];
if (progress != null &&
_ownsDownloadKey(episodeWithServer.globalKey) &&
(progress.status == DownloadStatus.completed ||
progress.status == DownloadStatus.downloading ||
progress.status == DownloadStatus.queued)) {
continue;
}
}
final queued = await _queueSingleDownload(
episodeWithServer,
client,
ownership: ownership,
versionConfig: versionConfig,
relatedContext: relatedContext,
);
if (queued) count++;
}
return count;
}
/// Pause a download (works for both downloading and queued items)
Future<void> pauseDownload(String globalKey) async {
if (!_ownsDownloadKey(globalKey)) return;
final progress = _downloads[globalKey];
if (progress != null &&
(progress.status == DownloadStatus.downloading || progress.status == DownloadStatus.queued)) {
await _downloadManager.pauseDownload(globalKey);
}
}
/// Resume a paused download
Future<void> resumeDownload(String globalKey, MediaServerClient client) async {
if (!_ownsDownloadKey(globalKey)) return;
final progress = _downloads[globalKey];
if (progress != null && progress.status == DownloadStatus.paused) {
await _downloadManager.resumeDownload(globalKey, client);
}
}
/// Retry a failed download
Future<void> retryDownload(String globalKey, MediaServerClient client) async {
if (!_ownsDownloadKey(globalKey)) return;
final progress = _downloads[globalKey];
if (progress != null && progress.status == DownloadStatus.failed) {
await _downloadManager.retryDownload(globalKey, client);
}
}
/// Cancel a download
Future<void> cancelDownload(String globalKey) async {
if (!_ownsDownloadKey(globalKey)) return;
final progress = _downloads[globalKey];
if (progress != null) {
final profileId = _requireActiveProfileId();
final removedMeta = _metadata[globalKey];
var released = await _releaseDownloadForProfile(globalKey, profileId, onlyIfShared: true);
if (!released) {
final finalOwner = await _database.getDownloadOwner(profileId: profileId, globalKey: globalKey);
await _downloadManager.cancelAndRemoveDownload(globalKey);
released = await _releaseDownloadForProfile(globalKey, profileId, ownerHint: finalOwner);
_downloads.remove(globalKey);
_metadata.remove(globalKey);
_artworkPaths.remove(globalKey);
}
if (removedMeta != null) {
DeletionNotifier().notifyDeletedItem(item: removedMeta, isDownloadOnly: true);
} else if (!released) {
return;
}
safeNotifyListeners();
}
}
/// Delete a downloaded item.
Future<void> deleteDownload(String globalKey) => _deleteDownload(globalKey, notify: true);
Future<void> _deleteDownload(String globalKey, {required bool notify}) async {
try {
final meta = _metadata[globalKey];
if (meta != null &&
(meta.isShow || meta.isSeason || meta.kind == MediaKind.album || meta.kind == MediaKind.artist)) {
await _deleteOwnedContainerDownloads(globalKey, meta);
return;
}
if (!_ownsDownloadKey(globalKey)) return;
final profileId = _requireActiveProfileId();
final releasedAsShared = await _releaseDownloadForProfile(globalKey, profileId, onlyIfShared: true);
if (releasedAsShared) {
if (notify && meta != null) {
DeletionNotifier().notifyDeletedItem(item: meta, isDownloadOnly: true);
}
if (notify) safeNotifyListeners();
return;
}
final finalOwner = await _database.getDownloadOwner(profileId: profileId, globalKey: globalKey);
await _downloadManager.deleteDownload(globalKey);
await _releaseDownloadForProfile(globalKey, profileId, ownerHint: finalOwner);
_downloads.remove(globalKey);
_metadata.remove(globalKey);
_artworkPaths.remove(globalKey);
if (notify && meta != null) {
DeletionNotifier().notifyDeletedItem(item: meta, isDownloadOnly: true);
}
if (notify) safeNotifyListeners();
} catch (e) {
_deletionProgress.remove(globalKey);
if (notify) safeNotifyListeners();
rethrow;
}
}
Future<void> _deleteOwnedContainerDownloads(String globalKey, MediaItem container) async {
final descendants = _ownedDescendantEntries(container).toList();
_batchDeletionDepth++;
try {
for (final entry in descendants) {
await _deleteDownload(entry.key, notify: false);
DeletionNotifier().notifyDeletedItem(item: entry.value, isDownloadOnly: true);
}
} finally {
_batchDeletionDepth--;
}
DeletionNotifier().notifyDeletedItem(item: container, isDownloadOnly: true);
safeNotifyListeners();
}
Iterable<MapEntry<String, MediaItem>> _ownedDescendantEntries(MediaItem container) {
// Shows and artists are grandparents of their leaves; seasons and albums
// are direct parents.
final matchesGrandparent = container.isShow || container.kind == MediaKind.artist;
return _metadata.entries.where((entry) {
if (!_ownsDownloadKey(entry.key)) return false;
final meta = entry.value;
if (meta.serverId != container.serverId) return false;
return matchesGrandparent
? (meta.grandparentId == container.id || meta.parentId == container.id)
: meta.parentId == container.id;
});
}
/// Handle deletion progress updates.
void _onDeletionProgressUpdate(DeletionProgress progress) {
if (progress.isComplete) {
_deletionProgress.remove(progress.globalKey);
} else {
_deletionProgress[progress.globalKey] = progress;
}
if (_batchDeletionDepth == 0 && _ownsDownloadKey(progress.globalKey)) {
safeNotifyListeners();
}
}
/// Get deletion progress for an item
DeletionProgress? getDeletionProgress(String globalKey) => _deletionProgress[globalKey];
/// Refresh the downloads list from database
Future<void> refresh() async {
await _loadPersistedDownloads();
}
/// Resume queued downloads that were interrupted by app kill.
/// Call after a [MediaServerClient] becomes available (e.g. after server connect on launch).
void resumeQueuedDownloads(MediaServerClient client) {
if (!_downloadManager.downloadsSupported) return;
_downloadManager.resumeQueuedDownloads(client);
}
/// Backend-aware metadata lookup for offline UI. Routes through
/// [DownloadManagerService] which dispatches to [PlexApiCache] or
/// [JellyfinApiCache] based on the connection's `kind`.
Future<MediaItem?> lookupOfflineMetadata(ServerId serverId, String itemId) =>
_downloadManager.lookupMetadata(serverId, itemId);
/// Refresh only metadata from API cache (after watch state sync).
///
/// This is more lightweight than full refresh() - only updates metadata
/// without reloading download progress from database.
Future<void> refreshMetadataFromCache() async {
final profileGeneration = _profileGeneration;
bool isStale() => isDisposed || profileGeneration != _profileGeneration;
// The initial load runs in the constructor and may still be in flight
// when callers (e.g. `onServersConnected`) trigger this. Wait for it so
// `_downloads` is populated before we walk it — otherwise an early call
// sees an empty map and does nothing useful.
await ensureInitialized();
if (isStale()) return;
// Walk every download — not just keys we already have metadata for. The
// initial `_loadPersistedDownloads` may have raced with connection setup
// (Jellyfin's cache reads need a [Connections] row) and skipped entries;
// this lets a later refresh actually populate them.
final keys = <String>{..._downloads.keys.where(_ownsDownloadKey)};
if (keys.isEmpty) {
await _applyOfflineWatchOverlay(expectedProfileGeneration: profileGeneration);
return;
}
final allMetadata = await _downloadManager.getAllPinnedMetadata(
preferActiveScope: true,
activeProfileId: _activeProfileId,
);
if (isStale()) return;
int cacheHits = 0;
int networkFills = 0;
int misses = 0;
for (final globalKey in keys) {
try {
final result = await _hydrateDownloadMetadata(globalKey, allMetadata, fetchOnMiss: true, isStale: isStale);
if (result.stale) return;
if (result.metadata == null) {
misses++;
} else if (result.networkFilled) {
networkFills++;
} else {
cacheHits++;
}
} catch (e) {
appLogger.d('Failed to refresh metadata for $globalKey: $e');
}
}
// Re-apply offline overlay so locally-queued watch actions aren't clobbered
// by stale per-backend caches that haven't yet seen the server roundtrip.
await _applyOfflineWatchOverlay(expectedProfileGeneration: profileGeneration);
if (isStale()) return;
final updatedCount = cacheHits + networkFills;
appLogger.i(
'refreshMetadataFromCache: walked ${keys.length} keys → '
'$cacheHits cache hits, $networkFills network fills, $misses unresolved',
);
if (updatedCount > 0) {
safeNotifyListeners();
}
}
/// Auto-delete downloaded episodes/movies that are now marked as watched.
///
/// Only deletes individual episodes and movies, never show/season containers.
/// [activeGlobalKey] is excluded from deletion to protect the currently playing item.
Future<List<String>> autoDeleteWatchedDownloads({String? activeGlobalKey}) async {
final deletedTitles = <String>[];
final completedKeys = _downloads.entries
.where((e) => _ownsDownloadKey(e.key) && e.value.status == DownloadStatus.completed)
.map((e) => e.key)
.toList();
for (final globalKey in completedKeys) {
final meta = _resolvedMetadata(globalKey);
if (meta == null) continue;
if (!meta.isEpisode && !meta.isMovie) continue;
if (!meta.isWatched) continue;
// Don't delete the episode that's currently playing
if (activeGlobalKey != null && meta.globalKey == activeGlobalKey) continue;
try {
appLogger.i('Auto-deleting watched download: ${meta.title} ($globalKey)');
await deleteDownload(globalKey);
deletedTitles.add(meta.title ?? t.common.unknown);
} catch (e) {
appLogger.w('Failed to auto-delete watched download $globalKey: $e');
}
}
return deletedTitles;
}
/// All sync rules for the active profile (profile-scoped globalKey -> SyncRuleItem).
Map<String, SyncRuleItem> get syncRules => Map.unmodifiable(_syncRules);
String syncRuleKeyFor(ServerId serverId, String ratingKey, {String? profileId}) {
final owner = profileId ?? _activeProfileId;
if (owner == null || owner.isEmpty) return buildGlobalKey(ServerId(serverId), ratingKey);
return buildProfileScopedGlobalKey(owner, ServerId(serverId), ratingKey);
}
String syncRuleKeyForClient(MediaServerClient client, String ratingKey, {ServerId? serverId}) {
return syncRuleKeyFor(serverId ?? client.serverId, ratingKey);
}
/// Candidate active-profile sync-rule keys touched by a watched item event.
Set<String> syncRuleKeysForWatchEvent(WatchStateEvent event) {
final profileId = _activeProfileId;
if (profileId == null || profileId.isEmpty) return const {};
final keys = <String>{};
void add(String ratingKey) {
keys.add(syncRuleKeyFor(ServerId(event.serverId), ratingKey, profileId: profileId));
}
add(event.itemId);
for (final parentKey in event.parentChain) {
add(parentKey);
}
return keys;
}
/// Check if a sync rule exists for the given item
bool hasSyncRule(String globalKey) => _syncRules.containsKey(globalKey);
/// Get a sync rule for the given item
SyncRuleItem? getSyncRule(String globalKey) => _syncRules[globalKey];
bool _hasActiveOwnedDownload(String globalKey) {
if (!_ownsDownloadKey(globalKey)) return false;
final progress = _downloads[globalKey];
return progress != null &&
(progress.status == DownloadStatus.downloading ||
progress.status == DownloadStatus.completed ||
progress.status == DownloadStatus.queued ||
progress.status == DownloadStatus.paused);
}
Future<void> _associateSyncRuleDownload(
SyncRuleItem rule,
String downloadGlobalKey,
_QueueOwnership ownership,
) async {
if (!_isQueueOwnershipCurrent(ownership) ||
_removingSyncRuleKeys.contains(rule.globalKey) ||
!_hasActiveOwnedDownload(downloadGlobalKey)) {
return;
}
final currentRule = _syncRules[rule.globalKey];
if (currentRule == null) return;
await _database.associateSyncRuleDownload(currentRule, downloadGlobalKey);
}
Future<bool> _queueSyncRuleDownload(
MediaItem item,
MediaServerClient client, {
required _QueueOwnership ownership,
required _RelatedMetadataDownloadContext relatedContext,
int mediaIndex = 0,
}) async {
if (!_isQueueOwnershipCurrent(ownership)) return false;
return _queueSingleDownload(
item,
client,
ownership: ownership,
mediaIndex: mediaIndex,
relatedContext: relatedContext,
);
}
Future<void> markSyncRuleDownloadLinksInitialized(String globalKey) async {
await _database.markSyncRuleDownloadLinksInitialized(globalKey);
final existing = _syncRules[globalKey];
if (existing != null) {
_syncRules[globalKey] = existing.copyWith(downloadLinksInitialized: true);
}
}
/// Create (or upsert) a sync rule for a show, season, collection, or playlist.
///
/// [targetMetadata], when provided, is stored in the in-memory metadata map so
/// the Sync Rules screen shows the item's title immediately instead of a bare
/// rating key — useful for collection/playlist rules where no underlying
/// episode download would otherwise populate it.
Future<void> createSyncRule({
required ServerId serverId,
required String ratingKey,
required String targetType,
required int episodeCount,
int mediaIndex = 0,
String downloadFilter = SyncRuleFilter.unwatched,
bool includeSpecials = true,
MediaItem? targetMetadata,
}) async {
final profileId = _requireActiveProfileId();
final publicGlobalKey = buildGlobalKey(ServerId(serverId), ratingKey);
final scopedGlobalKey = syncRuleKeyFor(ServerId(serverId), ratingKey, profileId: profileId);
await _database.insertSyncRule(
profileId: profileId,
serverId: serverId,
ratingKey: ratingKey,
globalKey: scopedGlobalKey,
targetType: targetType,
episodeCount: episodeCount,
mediaIndex: mediaIndex,
downloadFilter: downloadFilter,
includeSpecials: includeSpecials,
);
if (targetMetadata != null) {
final withServer = targetMetadata.serverId != null ? targetMetadata : targetMetadata.copyWith(serverId: serverId);
_metadata[publicGlobalKey] = withServer;
}
// Reload to get the full row with id/timestamps
final rule = await _database.getSyncRule(scopedGlobalKey);
if (rule != null) {
_syncRules[rule.globalKey] = rule;
safeNotifyListeners();
}
appLogger.i(
'Created sync rule: $scopedGlobalKey '
'($targetType, filter=$downloadFilter, keep $episodeCount, includeSpecials=$includeSpecials)',
);
}
/// Update the episode count for an existing show/season sync rule.
Future<void> updateSyncRuleCount(String globalKey, int episodeCount) async {
_requireActiveProfileId();
await _database.updateSyncRuleCount(globalKey, episodeCount);
final existing = _syncRules[globalKey];
if (existing != null) {
_syncRules[globalKey] = existing.copyWith(episodeCount: episodeCount);
safeNotifyListeners();
}
appLogger.i('Updated sync rule $globalKey: keep $episodeCount');
}
/// Update the download filter for an existing collection/playlist sync rule.
Future<void> updateSyncRuleFilter(String globalKey, String downloadFilter) async {
_requireActiveProfileId();
await _database.updateSyncRuleFilter(globalKey, downloadFilter);
final existing = _syncRules[globalKey];
if (existing != null) {
_syncRules[globalKey] = existing.copyWith(downloadFilter: downloadFilter);
safeNotifyListeners();
}
appLogger.i('Updated sync rule $globalKey: filter=$downloadFilter');
}
/// Toggle a sync rule's enabled state.
Future<void> setSyncRuleEnabled(String globalKey, bool enabled) async {
_requireActiveProfileId();
await _database.updateSyncRuleEnabled(globalKey, enabled);
final existing = _syncRules[globalKey];
if (existing != null) {
_syncRules[globalKey] = existing.copyWith(enabled: enabled);
safeNotifyListeners();
}
appLogger.i('${enabled ? 'Enabled' : 'Disabled'} sync rule: $globalKey');
}
/// Delete a sync rule. Downloaded episodes are kept.
Future<void> deleteSyncRule(String globalKey) async {
_requireActiveProfileId();
final existing = _syncRules[globalKey] ?? await _database.getSyncRule(globalKey);
await _deleteSyncRuleRecord(globalKey, existing);
safeNotifyListeners();
}
/// Delete a list sync rule and every active-profile download associated only
/// with that rule. Other rules and profile owners keep their copies.
Future<void> deleteSyncRuleAndDownloads(String globalKey, MultiServerManager serverManager) async {
final profileId = _requireActiveProfileId();
if (_syncRuleCleanupInProgress || _syncRuleExecutor.isExecuting) {
throw const SyncRuleCleanupBusyException();
}
final ownership = _captureQueueOwnership();
final existing = await _database.getSyncRule(globalKey);
if (existing == null || existing.profileId != profileId) return;
if (existing.targetType != ContentTypes.collection && existing.targetType != ContentTypes.playlist) {
throw ArgumentError.value(existing.targetType, 'targetType', 'Only collection/playlist rules support cleanup');
}
var stateChanged = false;
_syncRuleCleanupInProgress = true;
try {
await _backfillUninitializedRuleLinksForServer(existing, serverManager, ownership);
if (!_isQueueOwnershipCurrent(ownership)) {
throw const SyncRuleCleanupBusyException();
}
final trackedRule = await _database.getSyncRule(globalKey);
if (trackedRule == null || !trackedRule.downloadLinksInitialized) {
throw SyncRuleCleanupUnavailableException(globalKey);
}
_removingSyncRuleKeys.add(globalKey);
await _database.updateSyncRuleEnabled(globalKey, false);
final cachedRule = _syncRules[globalKey];
if (cachedRule != null) {
_syncRules[globalKey] = cachedRule.copyWith(enabled: false, downloadLinksInitialized: true);
}
stateChanged = true;
final downloadKeys = await _database.getExclusiveSyncRuleDownloadKeys(trackedRule);
_batchDeletionDepth++;
try {
for (final downloadKey in downloadKeys) {
if (!_isQueueOwnershipCurrent(ownership)) {
throw const SyncRuleCleanupBusyException();
}
final wasOwned = _ownsDownloadKey(downloadKey);
final metadata = _metadata[downloadKey];
await _deleteDownload(downloadKey, notify: false);
if (wasOwned && metadata != null) {
DeletionNotifier().notifyDeletedItem(item: metadata, isDownloadOnly: true);
}
}
} finally {
_batchDeletionDepth--;
}
await _deleteSyncRuleRecord(globalKey, trackedRule);
appLogger.i('Deleted sync rule and ${downloadKeys.length} associated downloads: $globalKey');
} finally {
_removingSyncRuleKeys.remove(globalKey);
_syncRuleCleanupInProgress = false;
if (stateChanged) safeNotifyListeners();
}
}
Future<void> _backfillUninitializedRuleLinksForServer(
SyncRuleItem target,
MultiServerManager serverManager,
_QueueOwnership ownership,
) async {
final rules = await _database.getUninitializedSyncRulesForServer(
profileId: target.profileId,
serverId: ServerId(target.serverId),
);
final requiredRules = rules.where((rule) => rule.enabled || rule.globalKey == target.globalKey);
for (final rule in requiredRules) {
if (!_isQueueOwnershipCurrent(ownership)) {
throw const SyncRuleCleanupBusyException();
}
switch (rule.targetType) {
case ContentTypes.show:
case ContentTypes.season:
final downloadKeys = await _database.getOwnedDownloadKeysForAncestorRule(
profileId: rule.profileId,
serverId: ServerId(rule.serverId),
ratingKey: rule.ratingKey,
matchGrandparent: rule.targetType == ContentTypes.show,
);
for (final downloadKey in downloadKeys) {
await _database.associateSyncRuleDownload(rule, downloadKey);
}
await _database.markSyncRuleDownloadLinksInitialized(rule.globalKey);
break;
case ContentTypes.collection:
case ContentTypes.playlist:
final backfilled = await _syncRuleExecutor.backfillListRuleDownloadLinks(
rule: rule,
serverManager: serverManager,
downloads: downloads,
metadata: Map.unmodifiable(_metadata),
associateDownload: (resolvedRule, downloadKey) async {
if (_isQueueOwnershipCurrent(ownership) && _hasActiveOwnedDownload(downloadKey)) {
await _database.associateSyncRuleDownload(resolvedRule, downloadKey);
}
},
);
if (!backfilled) {
throw SyncRuleCleanupUnavailableException(rule.globalKey);
}
break;
default:
throw SyncRuleCleanupUnavailableException(rule.globalKey);
}
final cachedRule = _syncRules[rule.globalKey];
if (cachedRule != null) {
_syncRules[rule.globalKey] = cachedRule.copyWith(downloadLinksInitialized: true);
}
}
}
Future<void> _deleteSyncRuleRecord(String globalKey, SyncRuleItem? existing) async {
final publicGlobalKey = existing == null
? globalKey
: buildGlobalKey(ServerId(existing.serverId), existing.ratingKey);
await _database.deleteSyncRule(globalKey);
_syncRules.remove(globalKey);
// createSyncRule may have stashed targetMetadata for collection/playlist
// rules with no underlying download; release it if nothing else holds it.
if (!_downloads.containsKey(publicGlobalKey)) {
_metadata.remove(publicGlobalKey);
}
appLogger.i('Deleted sync rule: $globalKey');
}
/// Execute all sync rules: auto-delete watched + queue replacements.
///
/// Pass [force] `true` from user-initiated triggers (watch-state events,
/// offline-sync drains) to bypass the executor's cooldown. Defaults to
/// `false` for background probes (e.g. connectivity reconnects).
///
/// Returns titles of newly queued items (for snackbar display).
Future<List<String>> executeSyncRules(MultiServerManager serverManager, {bool force = false}) async {
if (!_downloadManager.downloadsSupported) return [];
if (_syncRuleCleanupInProgress) return [];
final profileId = _activeProfileId;
if (profileId == null || profileId.isEmpty) return [];
final ownership = _captureQueueOwnership();
if (_syncRules.isEmpty) return [];
final relatedContext = _RelatedMetadataDownloadContext();
final results = await _syncRuleExecutor.executeSyncRules(
profileId: profileId,
serverManager: serverManager,
downloads: downloads,
metadata: Map.unmodifiable(_metadata),
associateDownload: (rule, downloadGlobalKey) => _associateSyncRuleDownload(rule, downloadGlobalKey, ownership),
queueSingleDownload: (episode, client, {int mediaIndex = 0}) {
// A profile switch mid-pass must not keep queueing the old profile's
// rules; whatever does get queued is claimed for the rule's owner,
// never the new active profile.
return _queueSyncRuleDownload(
episode,
client,
ownership: ownership,
relatedContext: relatedContext,
mediaIndex: mediaIndex,
);
},
isOffline: _offlineSource?.isOffline ?? false,
force: force,
);
return results.where((r) => r.queuedCount > 0).map((r) {
final title = r.title ?? t.common.unknown;
return '$title (${r.queuedCount})';
}).toList();
}
/// Execute a single sync rule immediately (eager path for `addToPlaylist` /
/// `addToCollection`). Bypasses the cooldown.
Future<SyncRuleResult?> executeSyncRuleFor(String globalKey, MultiServerManager serverManager) async {
if (!_downloadManager.downloadsSupported) return null;
if (_syncRuleCleanupInProgress) return null;
final profileId = _activeProfileId;
if (profileId == null || profileId.isEmpty) return null;
final ownership = _captureQueueOwnership();
if (!_syncRules.containsKey(globalKey)) return null;
final relatedContext = _RelatedMetadataDownloadContext();
return _syncRuleExecutor.executeSingleRule(
profileId: profileId,
globalKey: globalKey,
serverManager: serverManager,
downloads: downloads,
metadata: Map.unmodifiable(_metadata),
associateDownload: (rule, downloadGlobalKey) => _associateSyncRuleDownload(rule, downloadGlobalKey, ownership),
queueSingleDownload: (episode, client, {int mediaIndex = 0}) => _queueSyncRuleDownload(
episode,
client,
ownership: ownership,
relatedContext: relatedContext,
mediaIndex: mediaIndex,
),
isOffline: _offlineSource?.isOffline ?? false,
);
}
Future<void> _loadSyncRules() async {
try {
final profileId = _activeProfileId;
if (profileId == null || profileId.isEmpty) {
_syncRules.clear();
return;
}
await _database.adoptLegacySyncRulesForProfile(profileId);
if (_activeProfileId != profileId) return;
final rules = await _database.getSyncRules(profileId: profileId);
if (_activeProfileId != profileId) return;
_syncRules
..clear()
..addEntries(rules.map((rule) => MapEntry(rule.globalKey, rule)));
} catch (e) {
appLogger.w('Failed to load sync rules', error: e);
}
}
Future<void> _loadDownloadOwners() async {
try {
final profileId = _activeProfileId;
final generation = _profileGeneration;
if (profileId == null || profileId.isEmpty) {
_ownedDownloadKeys.clear();
return;
}
bool isStillActive() => _activeProfileId == profileId && _profileGeneration == generation;
await _database.adoptLegacyDownloadsForProfile(profileId, isStillActive: isStillActive);
await _downloadManager.adoptTransferredPlexMetadataForProfile(profileId, isStillActive: isStillActive);
if (!isStillActive()) return;
final ownedKeys = await _database.getDownloadOwnerKeysForProfile(profileId);
if (!isStillActive()) return;
_ownedDownloadKeys
..clear()
..addAll(ownedKeys);
} catch (e) {
appLogger.w('Failed to load download ownership', error: e);
}
}
}
class SyncRuleCleanupBusyException implements Exception {
const SyncRuleCleanupBusyException();
}
class SyncRuleCleanupUnavailableException implements Exception {
final String ruleGlobalKey;
const SyncRuleCleanupUnavailableException(this.ruleGlobalKey);
}
/// Exception thrown when download is blocked due to cellular-only setting
class CellularDownloadBlockedException implements Exception {
String get message => t.settings.cellularDownloadBlocked;
@override
String toString() => message;
}