1737 lines
66 KiB
Dart
1737 lines
66 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'package:flutter/foundation.dart';
|
|
import '../media/media_backend.dart';
|
|
import '../media/media_item.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/download_manager_service.dart';
|
|
import '../services/api_cache.dart';
|
|
import '../services/download_storage_service.dart';
|
|
import '../services/multi_server_manager.dart';
|
|
import '../services/offline_mode_source.dart';
|
|
import '../services/storage_service.dart';
|
|
import '../media/media_server_client.dart';
|
|
import '../services/sync_rule_executor.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/deletion_notifier.dart';
|
|
import '../utils/episode_collection.dart';
|
|
import '../utils/global_key_utils.dart';
|
|
import '../utils/watch_state_notifier.dart';
|
|
import '../mixins/disposable_change_notifier_mixin.dart';
|
|
|
|
/// 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, String serverId) {
|
|
if (thumbPath == null) return null;
|
|
return storage.getArtworkPathSync(serverId, thumbPath!);
|
|
}
|
|
}
|
|
|
|
/// Provider for managing download state and operations.
|
|
class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
|
|
final DownloadManagerService _downloadManager;
|
|
final AppDatabase _database;
|
|
final SyncRuleExecutor _syncRuleExecutor;
|
|
StreamSubscription<DownloadProgress>? _progressSubscription;
|
|
StreamSubscription<DeletionProgress>? _deletionProgressSubscription;
|
|
StreamSubscription<WatchStateEvent>? _watchStateSubscription;
|
|
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 = {};
|
|
|
|
// Store metadata for display
|
|
final Map<String, MediaItem> _metadata = {};
|
|
|
|
// Store Plex thumb paths for offline display (actual file path computed from hash)
|
|
final Map<String, DownloadedArtwork> _artworkPaths = {};
|
|
|
|
// Track items currently being queued (building download queue)
|
|
final Set<String> _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 = {};
|
|
|
|
// Track total episode counts for shows/seasons (for partial download detection)
|
|
// Key: globalKey (serverId:ratingKey), Value: total episode count
|
|
final Map<String, int> _totalEpisodeCounts = {};
|
|
|
|
// Persistent sync rules keyed by profile-scoped globalKey
|
|
// (profileId|serverId:ratingKey). Downloads remain public/shared.
|
|
final Map<String, SyncRuleItem> _syncRules = {};
|
|
|
|
String? _activeProfileId;
|
|
|
|
OfflineModeSource? _offlineSource;
|
|
|
|
DownloadProvider({required this._downloadManager, required this._database})
|
|
: _syncRuleExecutor = SyncRuleExecutor(database: _database) {
|
|
// Listen to progress updates from the download manager
|
|
_progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate);
|
|
|
|
// Listen to deletion progress updates
|
|
_deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate);
|
|
|
|
// Keep cached metadata fresh when items get marked watched/unwatched anywhere
|
|
// in the app, so re-entering a screen reflects the latest state.
|
|
_watchStateSubscription = WatchStateNotifier().stream.listen(_onWatchStateChanged);
|
|
|
|
// Load persisted downloads from database
|
|
_initFuture = _loadPersistedDownloads();
|
|
}
|
|
|
|
/// Test-only constructor that skips the heavy initial load (artwork dir,
|
|
/// pinned-metadata bulk fetch, episode counts). 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) {
|
|
_progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate);
|
|
_deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate);
|
|
_watchStateSubscription = WatchStateNotifier().stream.listen(_onWatchStateChanged);
|
|
_initFuture = _loadProfileScopedState();
|
|
}
|
|
|
|
/// Inject the offline-mode source so queueing paths can short-circuit when
|
|
/// the device has no Plex connectivity. Propagates to the download manager
|
|
/// and the sync-rule executor so background paths see the same flag.
|
|
void setOfflineSource(OfflineModeSource? source) {
|
|
_offlineSource = source;
|
|
_downloadManager.setOfflineSource(source);
|
|
_syncRuleExecutor.setOfflineSource(source);
|
|
}
|
|
|
|
/// Ensures persisted downloads have been loaded from disk.
|
|
Future<void> ensureInitialized() => _initFuture;
|
|
|
|
/// 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;
|
|
_activeProfileId = profileId;
|
|
unawaited(_reloadProfileScopedStateForActiveProfile());
|
|
}
|
|
|
|
Future<void> _reloadProfileScopedStateForActiveProfile() async {
|
|
final targetProfileId = _activeProfileId;
|
|
await _initFuture;
|
|
if (_activeProfileId != targetProfileId) return;
|
|
await _loadProfileScopedState();
|
|
if (_activeProfileId == targetProfileId) {
|
|
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);
|
|
|
|
Future<bool> _claimDownloadForActiveProfile(String globalKey) async {
|
|
final profileId = _requireActiveProfileId();
|
|
if (_ownedDownloadKeys.contains(globalKey)) return false;
|
|
await _database.addDownloadOwner(profileId: profileId, globalKey: globalKey);
|
|
if (_activeProfileId != profileId) return false;
|
|
_ownedDownloadKeys.add(globalKey);
|
|
return true;
|
|
}
|
|
|
|
Future<bool> _releaseDownloadForActiveProfile(String globalKey) async {
|
|
final profileId = _requireActiveProfileId();
|
|
if (!_ownedDownloadKeys.contains(globalKey)) return false;
|
|
await _database.removeDownloadOwner(profileId: profileId, globalKey: globalKey);
|
|
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);
|
|
}
|
|
|
|
/// 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];
|
|
await _database.removeDownloadOwner(profileId: profileId, globalKey: globalKey);
|
|
if (_activeProfileId == profileId) {
|
|
_ownedDownloadKeys.remove(globalKey);
|
|
}
|
|
if (await _database.hasDownloadOwner(globalKey)) {
|
|
changed = true;
|
|
continue;
|
|
}
|
|
|
|
await _downloadManager.deleteDownload(globalKey);
|
|
_downloads.remove(globalKey);
|
|
_metadata.remove(globalKey);
|
|
_artworkPaths.remove(globalKey);
|
|
_totalEpisodeCounts.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,
|
|
Map<String, int>? episodeCounts,
|
|
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 (episodeCounts != null) _totalEpisodeCounts.addAll(episodeCounts);
|
|
if (queueing != null) _queueing.addAll(queueing);
|
|
if (deletionProgress != null) _deletionProgress.addAll(deletionProgress);
|
|
if (ownedDownloadKeys != null) {
|
|
_ownedDownloadKeys.addAll(ownedDownloadKeys);
|
|
} else if (downloads != null) {
|
|
_ownedDownloadKeys.addAll(downloads.keys);
|
|
}
|
|
}
|
|
|
|
/// Test-only inspector for `_totalEpisodeCounts` (no public getter today).
|
|
@visibleForTesting
|
|
int? totalEpisodeCountFor(String globalKey) => _totalEpisodeCounts[globalKey];
|
|
|
|
/// 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();
|
|
_totalEpisodeCounts.clear();
|
|
_queueing.clear();
|
|
_deletionProgress.clear();
|
|
_ownedDownloadKeys.clear();
|
|
|
|
final storageService = DownloadStorageService.instance;
|
|
|
|
// Initialize artwork directory path for synchronous access
|
|
await storageService.getArtworkDirectory();
|
|
|
|
// Load all downloads from database
|
|
final downloads = await _downloadManager.getAllDownloads();
|
|
|
|
// Bulk-load all pinned metadata across both backends in a single pass
|
|
// instead of per-item DB calls.
|
|
final allMetadata = await _downloadManager.getAllPinnedMetadata(preferActiveScope: true);
|
|
|
|
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);
|
|
|
|
// Look up metadata from the bulk-loaded map (O(1) instead of DB query per item)
|
|
// Falls back to individual query for any unpinned entries (e.g., legacy data).
|
|
// The fallback dispatches by backend.
|
|
final cached =
|
|
allMetadata[item.globalKey] ??
|
|
await _downloadManager.lookupMetadata(item.serverId, item.ratingKey, preferActiveScope: true);
|
|
if (cached != null) {
|
|
_metadata[item.globalKey] = cached;
|
|
|
|
// For episodes, also load parent (show and season) metadata from the same map
|
|
if (cached.isEpisode) {
|
|
_loadParentMetadataFromMap(
|
|
cached,
|
|
allMetadata,
|
|
clientScopeId: _downloadManager.activeClientScopeIdForServer(item.serverId) ?? item.clientScopeId,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load total episode counts from StorageService
|
|
await _loadTotalEpisodeCounts();
|
|
|
|
// Load sync rules from database
|
|
await _loadProfileScopedState();
|
|
|
|
// 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, '
|
|
'${_totalEpisodeCounts.length} episode counts, and ${_syncRules.length} sync rules',
|
|
);
|
|
safeNotifyListeners();
|
|
} catch (e) {
|
|
appLogger.e('Failed to load persisted downloads', error: e);
|
|
}
|
|
}
|
|
|
|
/// Patch `_metadata` viewCount/viewOffsetMs from queued OfflineWatchProgress
|
|
/// actions. Idempotent and cheap (one batched DB read).
|
|
Future<void> _applyOfflineWatchOverlay() async {
|
|
if (_metadata.isEmpty) return;
|
|
try {
|
|
final keys = _metadata.keys.toSet();
|
|
final scopes = <String, String?>{};
|
|
for (final key in keys) {
|
|
scopes[key] = await _offlineWatchScopeForGlobalKey(key);
|
|
}
|
|
final profileId = _activeProfileId;
|
|
final actions = await _database.getLatestWatchActionsForKeys(
|
|
keys,
|
|
profileId: profileId,
|
|
filterProfile: profileId != null,
|
|
clientScopeIdsByGlobalKey: scopes,
|
|
);
|
|
if (actions.isEmpty) return;
|
|
for (final entry in actions.entries) {
|
|
final base = _metadata[entry.key];
|
|
if (base == null) continue;
|
|
final action = entry.value;
|
|
bool? isWatched;
|
|
switch (action.actionType) {
|
|
case 'watched':
|
|
isWatched = true;
|
|
case 'unwatched':
|
|
isWatched = false;
|
|
case 'progress':
|
|
isWatched = action.shouldMarkWatched;
|
|
}
|
|
if (isWatched == null) continue;
|
|
_metadata[entry.key] = base.copyWith(
|
|
viewCount: isWatched ? 1 : 0,
|
|
viewOffsetMs: isWatched ? base.viewOffsetMs : 0,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
appLogger.w('Failed to apply offline watch overlay', error: e);
|
|
}
|
|
}
|
|
|
|
Future<String?> _offlineWatchScopeForGlobalKey(String globalKey) async {
|
|
final parsed = parseGlobalKey(globalKey);
|
|
if (parsed == null) return null;
|
|
final activeScope = _downloadManager.activeClientScopeIdForServer(parsed.serverId);
|
|
if (activeScope != null && activeScope.isNotEmpty) return activeScope;
|
|
final downloaded = await _database.getDownloadedMedia(globalKey);
|
|
final downloadedScope = downloaded?.clientScopeId;
|
|
return downloadedScope == null || downloadedScope.isEmpty ? null : downloadedScope;
|
|
}
|
|
|
|
/// Load total episode counts from StorageService
|
|
Future<void> _loadTotalEpisodeCounts() async {
|
|
try {
|
|
final storage = await StorageService.getInstance();
|
|
final counts = storage.loadAllEpisodeCounts();
|
|
_totalEpisodeCounts.addAll(counts);
|
|
|
|
appLogger.i('Loaded ${_totalEpisodeCounts.length} episode counts from StorageService');
|
|
} catch (e) {
|
|
appLogger.w('Failed to load episode counts', error: e);
|
|
}
|
|
}
|
|
|
|
/// Persist total episode count to StorageService
|
|
Future<void> _persistTotalEpisodeCount(String globalKey, int count) async {
|
|
try {
|
|
final storage = await StorageService.getInstance();
|
|
await storage.saveTotalEpisodeCount(globalKey, count);
|
|
appLogger.d('Persisted episode count for $globalKey: $count');
|
|
} catch (e) {
|
|
appLogger.w('Failed to persist episode count for $globalKey', error: e);
|
|
}
|
|
}
|
|
|
|
/// Load parent (show and season) metadata from a pre-loaded map (no DB I/O).
|
|
/// Used during bulk initialization to avoid per-item DB queries.
|
|
void _loadParentMetadataFromMap(MediaItem episode, Map<String, MediaItem> allMetadata, {String? clientScopeId}) {
|
|
final serverId = episode.serverId;
|
|
if (serverId == null) return;
|
|
|
|
MediaItem? lookupParent(String ratingKey) {
|
|
if (clientScopeId != null && clientScopeId.isNotEmpty) {
|
|
final scoped = allMetadata[buildGlobalKey(clientScopeId, ratingKey)];
|
|
if (scoped != null) return scoped;
|
|
}
|
|
return allMetadata[buildGlobalKey(serverId, ratingKey)];
|
|
}
|
|
|
|
// Load show metadata
|
|
final showRatingKey = episode.grandparentId;
|
|
if (showRatingKey != null) {
|
|
final showGlobalKey = buildGlobalKey(serverId, showRatingKey);
|
|
if (!_metadata.containsKey(showGlobalKey)) {
|
|
final showMetadata = lookupParent(showRatingKey);
|
|
if (showMetadata != null) {
|
|
_metadata[showGlobalKey] = showMetadata;
|
|
if (showMetadata.thumbPath != null) {
|
|
_artworkPaths[showGlobalKey] = DownloadedArtwork(thumbPath: showMetadata.thumbPath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load season metadata
|
|
final seasonRatingKey = episode.parentId;
|
|
if (seasonRatingKey != null) {
|
|
final seasonGlobalKey = buildGlobalKey(serverId, seasonRatingKey);
|
|
if (!_metadata.containsKey(seasonGlobalKey)) {
|
|
final seasonMetadata = lookupParent(seasonRatingKey);
|
|
if (seasonMetadata != null) {
|
|
_metadata[seasonGlobalKey] = seasonMetadata;
|
|
if (seasonMetadata.thumbPath != null) {
|
|
_artworkPaths[seasonGlobalKey] = DownloadedArtwork(thumbPath: seasonMetadata.thumbPath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void _onProgressUpdate(DownloadProgress progress) {
|
|
appLogger.d('Progress update received: ${progress.globalKey} - ${progress.status} - ${progress.progress}%');
|
|
|
|
_downloads[progress.globalKey] = progress;
|
|
|
|
// Sync artwork paths when they are available
|
|
if (progress.hasArtworkPaths) {
|
|
_artworkPaths[progress.globalKey] = DownloadedArtwork(thumbPath: progress.thumbPath);
|
|
}
|
|
|
|
appLogger.d('Notifying listeners for ${progress.globalKey}');
|
|
safeNotifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_progressSubscription?.cancel();
|
|
_deletionProgressSubscription?.cancel();
|
|
_watchStateSubscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onWatchStateChanged(WatchStateEvent event) {
|
|
// Progress ticks fire continuously during playback; only react to discrete
|
|
// watched/unwatched flips so we don't churn listeners on every frame.
|
|
if (event.changeType == WatchStateChangeType.progressUpdate) return;
|
|
if (event.isNowWatched == null) return;
|
|
|
|
final globalKey = buildGlobalKey(event.serverId, event.itemId);
|
|
final base = _metadata[globalKey];
|
|
if (base == null) return;
|
|
|
|
final isWatched = event.isNowWatched!;
|
|
_metadata[globalKey] = base.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: isWatched ? base.viewOffsetMs : 0);
|
|
// Persist into the per-backend pinned cache so the patch survives reloads
|
|
// (`_loadPersistedDownloads` rehydrates `_metadata` from the cache).
|
|
unawaited(
|
|
ApiCache.forBackend(base.backend)
|
|
.applyWatchState(serverId: event.cacheServerId ?? event.serverId, itemId: event.itemId, isWatched: isWatched)
|
|
.catchError((Object e) {
|
|
appLogger.w('Failed to apply watch state to cache for $globalKey', error: e);
|
|
}),
|
|
);
|
|
safeNotifyListeners();
|
|
}
|
|
|
|
/// 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)));
|
|
|
|
/// All metadata for downloads
|
|
Map<String, MediaItem> get metadata => Map.unmodifiable(_metadata);
|
|
|
|
/// 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 = 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(meta.serverId!, showRatingKey);
|
|
final storedShow = _metadata[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 Jellyfin-shaped item for Jellyfin (Id + Type=Series).
|
|
final synthesizedRaw = switch (meta.backend) {
|
|
MediaBackend.plex => <String, dynamic>{'key': '/library/metadata/$showRatingKey'},
|
|
MediaBackend.jellyfin => <String, dynamic>{'Id': showRatingKey, 'Type': 'Series'},
|
|
};
|
|
shows[showRatingKey] = MediaItem(
|
|
id: showRatingKey,
|
|
backend: meta.backend,
|
|
kind: MediaKind.show,
|
|
title: meta.grandparentTitle ?? 'Unknown Show',
|
|
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) => entry.value)
|
|
.toList();
|
|
}
|
|
|
|
/// Get metadata for a specific download
|
|
MediaItem? getMetadata(String globalKey) => _metadata[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(String serverId, String? artworkPath) {
|
|
if (artworkPath == null) return null;
|
|
return DownloadStorageService.instance.getArtworkPathSync(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) => entry.value)
|
|
.toList();
|
|
}
|
|
|
|
/// Get episode downloads filtered by show and/or season ratingKey.
|
|
List<DownloadProgress> _getEpisodeDownloads({String? showRatingKey, String? seasonRatingKey}) {
|
|
return _downloads.entries
|
|
.where((entry) {
|
|
if (!_ownsDownloadKey(entry.key)) return false;
|
|
final meta = _metadata[entry.key];
|
|
if (meta == null || !meta.isEpisode) return false;
|
|
if (showRatingKey != null && meta.grandparentId != showRatingKey) return false;
|
|
if (seasonRatingKey != null && meta.parentId != seasonRatingKey) 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(String serverId, String showRatingKey) {
|
|
return _calculateAggregateProgress(
|
|
serverId: serverId,
|
|
ratingKey: showRatingKey,
|
|
episodes: _getEpisodeDownloads(showRatingKey: showRatingKey),
|
|
entityType: 'show',
|
|
);
|
|
}
|
|
|
|
/// Calculate aggregate progress for a season (based on all its episodes)
|
|
/// Returns synthetic DownloadProgress with aggregated values
|
|
DownloadProgress? getAggregateProgressForSeason(String serverId, String seasonRatingKey) {
|
|
return _calculateAggregateProgress(
|
|
serverId: serverId,
|
|
ratingKey: seasonRatingKey,
|
|
episodes: _getEpisodeDownloads(seasonRatingKey: seasonRatingKey),
|
|
entityType: 'season',
|
|
);
|
|
}
|
|
|
|
/// Shared helper to calculate aggregate download progress for shows/seasons
|
|
DownloadProgress? _calculateAggregateProgress({
|
|
required String serverId,
|
|
required String ratingKey,
|
|
required List<DownloadProgress> episodes,
|
|
required String entityType,
|
|
}) {
|
|
final globalKey = buildGlobalKey(serverId, ratingKey);
|
|
|
|
// DIAGNOSTIC: Check all sources of episode count
|
|
final meta = _metadata[globalKey];
|
|
final metadataLeafCount = meta?.leafCount;
|
|
final storedCount = _totalEpisodeCounts[globalKey];
|
|
final downloadedCount = episodes.length;
|
|
|
|
appLogger.d(
|
|
'📊 Episode count sources for $entityType $ratingKey:\n'
|
|
' - Metadata leafCount: $metadataLeafCount\n'
|
|
' - Stored count: $storedCount\n'
|
|
' - Downloaded episodes: $downloadedCount\n'
|
|
' - Metadata exists: ${meta != null}\n'
|
|
' - Type: ${meta?.kind.id}\n'
|
|
' - Title: ${meta?.title}',
|
|
);
|
|
|
|
// Get total episode count - Use metadata.leafCount as primary source
|
|
int totalEpisodes;
|
|
String countSource;
|
|
|
|
if (metadataLeafCount != null && metadataLeafCount > 0) {
|
|
totalEpisodes = metadataLeafCount;
|
|
countSource = 'metadata.leafCount';
|
|
} else if (storedCount != null && storedCount > 0) {
|
|
totalEpisodes = storedCount;
|
|
countSource = 'stored count (StorageService)';
|
|
} else {
|
|
totalEpisodes = downloadedCount;
|
|
countSource = 'downloaded episodes (fallback)';
|
|
}
|
|
|
|
appLogger.d('✅ Using totalEpisodes=$totalEpisodes from [$countSource] for $entityType $ratingKey');
|
|
|
|
// If we have stored count but no downloads, check if it's a valid partial state
|
|
if (totalEpisodes == 0 || (episodes.isEmpty && totalEpisodes > 0)) {
|
|
appLogger.d('⚠️ No valid downloads for $entityType $ratingKey, returning null');
|
|
return null;
|
|
}
|
|
|
|
// Calculate aggregate statistics
|
|
int completedCount = 0;
|
|
int downloadingCount = 0;
|
|
int queuedCount = 0;
|
|
int failedCount = 0;
|
|
|
|
for (final ep in episodes) {
|
|
switch (ep.status) {
|
|
case DownloadStatus.completed:
|
|
completedCount++;
|
|
case DownloadStatus.downloading:
|
|
downloadingCount++;
|
|
case DownloadStatus.queued:
|
|
queuedCount++;
|
|
case DownloadStatus.failed:
|
|
failedCount++;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Determine overall status
|
|
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;
|
|
}
|
|
|
|
// Calculate overall progress percentage based on TOTAL episodes
|
|
final int overallProgress = totalEpisodes > 0 ? ((completedCount * 100) / totalEpisodes).round() : 0;
|
|
|
|
appLogger.d(
|
|
'Aggregate progress for $entityType $ratingKey: $overallProgress% '
|
|
'($completedCount completed, $downloadingCount downloading, '
|
|
'$queuedCount queued of $totalEpisodes total) - Status: $overallStatus',
|
|
);
|
|
|
|
return DownloadProgress(
|
|
globalKey: globalKey,
|
|
status: overallStatus,
|
|
progress: overallProgress,
|
|
downloadedBytes: 0,
|
|
totalBytes: 0,
|
|
currentFile: '$completedCount/$totalEpisodes episodes',
|
|
);
|
|
}
|
|
|
|
/// 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) {
|
|
// First check if we have direct progress (for episodes/movies)
|
|
final directProgress = _downloads[globalKey];
|
|
if (directProgress != null) {
|
|
if (!_ownsDownloadKey(globalKey)) return null;
|
|
return directProgress;
|
|
}
|
|
|
|
// If no direct progress, check if this is a show or season
|
|
// and calculate aggregate progress from episodes
|
|
final parsed = parseGlobalKey(globalKey);
|
|
if (parsed == null) return null;
|
|
|
|
final serverId = parsed.serverId;
|
|
final ratingKey = parsed.ratingKey;
|
|
|
|
// Try to get metadata to determine type
|
|
final meta = _metadata[globalKey];
|
|
if (meta == null) {
|
|
// No metadata stored yet, might be a show/season being queued
|
|
// Check if any episodes exist for this as a parent
|
|
final episodesAsShow = _getEpisodeDownloads(showRatingKey: ratingKey);
|
|
if (episodesAsShow.isNotEmpty) {
|
|
return getAggregateProgressForShow(serverId, ratingKey);
|
|
}
|
|
|
|
final episodesAsSeason = _getEpisodeDownloads(seasonRatingKey: ratingKey);
|
|
if (episodesAsSeason.isNotEmpty) {
|
|
return getAggregateProgressForSeason(serverId, ratingKey);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// We have metadata, check kind
|
|
if (meta.kind == MediaKind.show) {
|
|
return getAggregateProgressForShow(serverId, ratingKey);
|
|
} else if (meta.kind == MediaKind.season) {
|
|
return getAggregateProgressForSeason(serverId, ratingKey);
|
|
}
|
|
|
|
return 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
|
|
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.contains(globalKey);
|
|
|
|
/// 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) 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;
|
|
}
|
|
if (downloadedItem.status != DownloadStatus.completed.index) {
|
|
appLogger.w('Download not complete. Status: ${downloadedItem.status}');
|
|
return null;
|
|
}
|
|
if (downloadedItem.videoFilePath == null) {
|
|
appLogger.w('Video file path is null for globalKey: $globalKey');
|
|
return null;
|
|
}
|
|
|
|
final storedPath = downloadedItem.videoFilePath!;
|
|
final storageService = DownloadStorageService.instance;
|
|
|
|
// SAF URIs (content://) are already valid - don't transform them
|
|
if (storageService.isSafUri(storedPath)) {
|
|
appLogger.d('Found SAF video path: $storedPath');
|
|
return storedPath;
|
|
}
|
|
|
|
// Convert stored path (may be relative) to absolute path
|
|
final absolutePath = await storageService.ensureAbsolutePath(storedPath);
|
|
|
|
// Verify file exists
|
|
final file = File(absolutePath);
|
|
if (!await file.exists()) {
|
|
appLogger.w('Offline video file not found: $absolutePath');
|
|
return null;
|
|
}
|
|
return absolutePath;
|
|
}
|
|
|
|
/// Queue a download for a media item.
|
|
/// For movies and episodes, queues directly.
|
|
/// For shows and seasons, fetches all child episodes and queues them.
|
|
/// Returns the number of items queued.
|
|
Future<int> queueDownload(
|
|
MediaItem metadata,
|
|
MediaServerClient client, {
|
|
DownloadVersionConfig? versionConfig,
|
|
DownloadFilter filter = DownloadFilter.all,
|
|
int? maxCount,
|
|
}) async {
|
|
if (!_downloadManager.downloadsSupported) return 0;
|
|
|
|
final globalKey = metadata.globalKey;
|
|
final config = versionConfig ?? DownloadVersionConfig();
|
|
|
|
// Check if downloads are blocked on cellular
|
|
if (await DownloadManagerService.shouldBlockDownloadOnCellular()) {
|
|
throw CellularDownloadBlockedException();
|
|
}
|
|
|
|
try {
|
|
// Mark as queueing to show loading state in UI
|
|
_queueing.add(globalKey);
|
|
safeNotifyListeners();
|
|
|
|
if (metadata.isMovie || metadata.isEpisode) {
|
|
final queued = await _queueSingleDownload(metadata, client, mediaIndex: config.mediaIndex);
|
|
return queued ? 1 : 0;
|
|
} else if (metadata.isShow) {
|
|
// Stash metadata pre-queue so the UI can render the queueing state;
|
|
// roll back if expansion throws so the orphan doesn't linger.
|
|
final hadMetadata = _metadata.containsKey(globalKey);
|
|
_metadata[globalKey] = metadata;
|
|
try {
|
|
return await _queueShowDownload(metadata, client, versionConfig: config, filter: filter, maxCount: maxCount);
|
|
} catch (_) {
|
|
if (!hadMetadata) _metadata.remove(globalKey);
|
|
rethrow;
|
|
}
|
|
} else if (metadata.isSeason) {
|
|
final hadMetadata = _metadata.containsKey(globalKey);
|
|
_metadata[globalKey] = metadata;
|
|
try {
|
|
return await _queueSeasonDownload(
|
|
metadata,
|
|
client,
|
|
versionConfig: config,
|
|
filter: filter,
|
|
maxCount: maxCount,
|
|
);
|
|
} catch (_) {
|
|
if (!hadMetadata) _metadata.remove(globalKey);
|
|
rethrow;
|
|
}
|
|
} else {
|
|
throw Exception('Cannot download ${metadata.kind.id}');
|
|
}
|
|
} finally {
|
|
_queueing.remove(globalKey);
|
|
safeNotifyListeners();
|
|
}
|
|
}
|
|
|
|
/// Queue every playable item from a collection/playlist for download.
|
|
///
|
|
/// Movies and episodes are queued directly. Shows and seasons are expanded
|
|
/// into their episodes (when [expandShows] is true). Music items, nested
|
|
/// collections/playlists, and unknown types are skipped.
|
|
Future<int> queueListDownload(
|
|
List<MediaItem> items,
|
|
MediaServerClient client, {
|
|
DownloadFilter filter = DownloadFilter.all,
|
|
bool expandShows = true,
|
|
}) async {
|
|
if (!_downloadManager.downloadsSupported) return 0;
|
|
|
|
if (await DownloadManagerService.shouldBlockDownloadOnCellular()) {
|
|
throw CellularDownloadBlockedException();
|
|
}
|
|
|
|
final unwatchedOnly = filter == DownloadFilter.unwatched;
|
|
int count = 0;
|
|
|
|
Future<void> queueItem(MediaItem item) async {
|
|
if (unwatchedOnly && item.isWatched && !item.hasActiveProgress) return;
|
|
final queued = await _queueSingleDownload(item, client);
|
|
if (queued) count++;
|
|
}
|
|
|
|
for (final item in items) {
|
|
if (item.isMovie || item.isEpisode) {
|
|
await queueItem(item);
|
|
} else if (item.isShow || item.isSeason) {
|
|
if (!expandShows) continue;
|
|
// One-shot recursive expansion (Plex /grandchildren, Jellyfin
|
|
// Recursive=true) — the per-season walk that used to live here
|
|
// was the same pattern as collectEpisodes*, just inlined.
|
|
final episodes = <MediaItem>[];
|
|
if (item.isShow) {
|
|
await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item);
|
|
} else {
|
|
await collectEpisodesForSeason(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item);
|
|
}
|
|
for (final ep in episodes) {
|
|
await queueItem(ep);
|
|
}
|
|
} else {
|
|
// Skip music, clips, nested collections/playlists, unknown types.
|
|
continue;
|
|
}
|
|
}
|
|
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, {
|
|
int mediaIndex = 0,
|
|
DownloadVersionConfig? versionConfig,
|
|
}) async {
|
|
if (!_downloadManager.downloadsSupported) return false;
|
|
|
|
_requireActiveProfileId();
|
|
final globalKey = metadata.globalKey;
|
|
|
|
// Don't duplicate the physical download. If another profile already owns
|
|
// the shared row, claiming it makes it visible for the active profile.
|
|
if (_downloads.containsKey(globalKey)) {
|
|
final existing = _downloads[globalKey]!;
|
|
if (existing.status == DownloadStatus.downloading ||
|
|
existing.status == DownloadStatus.completed ||
|
|
existing.status == DownloadStatus.queued) {
|
|
final claimed = await _claimDownloadForActiveProfile(globalKey);
|
|
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.
|
|
MediaItem metadataToStore = metadata;
|
|
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 = fullMetadata.copyWith(
|
|
serverId: metadata.serverId ?? fullMetadata.serverId,
|
|
serverName: metadata.serverName ?? fullMetadata.serverName,
|
|
libraryId: fullMetadata.libraryId ?? metadata.libraryId,
|
|
libraryTitle: fullMetadata.libraryTitle ?? metadata.libraryTitle,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
appLogger.w('Failed to fetch full metadata for ${metadata.id}, using partial', error: e);
|
|
}
|
|
}
|
|
|
|
// 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 (pickedIndex == null) return false;
|
|
resolvedIndex = pickedIndex;
|
|
versionConfig.acceptedSignatures.add(versions[pickedIndex].signature);
|
|
}
|
|
}
|
|
}
|
|
|
|
// For episodes, also fetch and store show and season metadata for offline display
|
|
if (metadataToStore.isEpisode) {
|
|
await _fetchAndStoreParentMetadata(metadataToStore, client);
|
|
}
|
|
|
|
// Store full metadata for display
|
|
_metadata[globalKey] = metadataToStore;
|
|
|
|
await _claimDownloadForActiveProfile(globalKey);
|
|
|
|
// Update local state immediately for UI feedback
|
|
_downloads[globalKey] = DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued);
|
|
safeNotifyListeners();
|
|
|
|
// Actually trigger download via DownloadManagerService
|
|
await _downloadManager.queueDownload(metadata: metadataToStore, client: client, mediaIndex: resolvedIndex);
|
|
return true;
|
|
}
|
|
|
|
/// Fetch and store show and season metadata for an episode
|
|
/// Also downloads artwork for show and season
|
|
Future<void> _fetchAndStoreParentMetadata(MediaItem episode, MediaServerClient client) async {
|
|
final serverId = episode.serverId;
|
|
if (serverId == null) return;
|
|
|
|
await _fetchAndStoreRelatedMetadata(serverId: serverId, ratingKey: episode.grandparentId, client: client);
|
|
await _fetchAndStoreRelatedMetadata(serverId: serverId, ratingKey: episode.parentId, client: client);
|
|
}
|
|
|
|
/// Fetch, persist, and download artwork for a related metadata item (show or season).
|
|
Future<void> _fetchAndStoreRelatedMetadata({
|
|
required String serverId,
|
|
required String? ratingKey,
|
|
required MediaServerClient client,
|
|
}) async {
|
|
if (ratingKey == null) return;
|
|
final globalKey = buildGlobalKey(serverId, ratingKey);
|
|
final storageService = DownloadStorageService.instance;
|
|
|
|
MediaItem? metadata = _metadata[globalKey];
|
|
if (metadata == null) {
|
|
try {
|
|
metadata = await client.fetchItem(ratingKey);
|
|
} catch (e) {
|
|
appLogger.w('Failed to fetch metadata for $ratingKey', error: e);
|
|
}
|
|
}
|
|
if (metadata == null) return;
|
|
|
|
final withServer = metadata.copyWith(serverId: serverId);
|
|
_metadata[globalKey] = withServer;
|
|
await _downloadManager.saveMetadata(withServer, client);
|
|
|
|
final thumbPath = withServer.thumbPath;
|
|
final hasPoster = thumbPath != null && await storageService.artworkExists(serverId, thumbPath);
|
|
if (!hasPoster) {
|
|
await _downloadManager.downloadArtworkForMetadata(withServer, client);
|
|
}
|
|
_artworkPaths[globalKey] = DownloadedArtwork(thumbPath: thumbPath);
|
|
}
|
|
|
|
/// Store leafCount for a show or season so aggregate progress works.
|
|
Future<void> _storeLeafCount(String globalKey, MediaItem metadata) async {
|
|
if (metadata.leafCount != null && metadata.leafCount! > 0) {
|
|
_totalEpisodeCounts[globalKey] = metadata.leafCount!;
|
|
await _persistTotalEpisodeCount(globalKey, metadata.leafCount!);
|
|
}
|
|
}
|
|
|
|
/// Queue all episodes from a TV show for download
|
|
Future<int> _queueShowDownload(
|
|
MediaItem show,
|
|
MediaServerClient client, {
|
|
DownloadVersionConfig? versionConfig,
|
|
DownloadFilter filter = DownloadFilter.all,
|
|
int? maxCount,
|
|
}) async {
|
|
await _storeLeafCount(show.globalKey, show);
|
|
return _expandAndQueue(
|
|
container: show,
|
|
client: client,
|
|
versionConfig: versionConfig,
|
|
filter: filter,
|
|
maxCount: maxCount,
|
|
skipExisting: false,
|
|
);
|
|
}
|
|
|
|
/// Queue all episodes from a season for download
|
|
Future<int> _queueSeasonDownload(
|
|
MediaItem season,
|
|
MediaServerClient client, {
|
|
DownloadVersionConfig? versionConfig,
|
|
DownloadFilter filter = DownloadFilter.all,
|
|
int? maxCount,
|
|
}) async {
|
|
await _storeLeafCount(season.globalKey, season);
|
|
return _expandAndQueue(
|
|
container: season,
|
|
client: client,
|
|
versionConfig: versionConfig,
|
|
filter: filter,
|
|
maxCount: maxCount,
|
|
skipExisting: false,
|
|
);
|
|
}
|
|
|
|
/// 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 queued = await _expandAndQueue(
|
|
container: metadata,
|
|
client: client,
|
|
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 DownloadVersionConfig? versionConfig,
|
|
required DownloadFilter filter,
|
|
required int? maxCount,
|
|
required bool skipExisting,
|
|
}) async {
|
|
final unwatchedOnly = filter == DownloadFilter.unwatched;
|
|
final episodes = <MediaItem>[];
|
|
if (container.kind == MediaKind.show) {
|
|
await collectEpisodesForShow(
|
|
client,
|
|
container.id,
|
|
unwatchedOnly: unwatchedOnly,
|
|
out: episodes,
|
|
fallback: container,
|
|
);
|
|
} else {
|
|
await collectEpisodesForSeason(
|
|
client,
|
|
container.id,
|
|
unwatchedOnly: unwatchedOnly,
|
|
out: episodes,
|
|
fallback: container,
|
|
);
|
|
}
|
|
|
|
int count = 0;
|
|
for (final episode in episodes) {
|
|
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, versionConfig: versionConfig);
|
|
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 released = await _releaseDownloadForActiveProfile(globalKey);
|
|
final hasOtherOwners = await _database.hasDownloadOwner(globalKey);
|
|
final removedMeta = _metadata[globalKey];
|
|
if (!hasOtherOwners) {
|
|
await _downloadManager.cancelDownload(globalKey);
|
|
await _database.deleteDownload(globalKey);
|
|
_downloads.remove(globalKey);
|
|
_metadata.remove(globalKey);
|
|
_artworkPaths.remove(globalKey);
|
|
_totalEpisodeCounts.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) async {
|
|
try {
|
|
final meta = _metadata[globalKey];
|
|
if (meta != null && (meta.isShow || meta.isSeason)) {
|
|
await _deleteOwnedContainerDownloads(globalKey, meta);
|
|
return;
|
|
}
|
|
if (!_ownsDownloadKey(globalKey)) return;
|
|
|
|
final released = await _releaseDownloadForActiveProfile(globalKey);
|
|
final hasOtherOwners = await _database.hasDownloadOwner(globalKey);
|
|
if (hasOtherOwners) {
|
|
if (meta != null) {
|
|
DeletionNotifier().notifyDeletedItem(item: meta, isDownloadOnly: true);
|
|
}
|
|
if (released) safeNotifyListeners();
|
|
return;
|
|
}
|
|
|
|
// Start deletion (progress will be tracked via stream)
|
|
await _downloadManager.deleteDownload(globalKey);
|
|
|
|
// Remove from local state
|
|
_downloads.remove(globalKey);
|
|
_metadata.remove(globalKey);
|
|
_artworkPaths.remove(globalKey);
|
|
|
|
// Notify any open screens so they can drop the item from their lists
|
|
// immediately instead of waiting for an exit/re-enter.
|
|
if (meta != null) {
|
|
DeletionNotifier().notifyDeletedItem(item: meta, isDownloadOnly: true);
|
|
}
|
|
|
|
safeNotifyListeners();
|
|
} catch (e) {
|
|
// Remove from deletion tracking on error
|
|
_deletionProgress.remove(globalKey);
|
|
safeNotifyListeners();
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteOwnedContainerDownloads(String globalKey, MediaItem container) async {
|
|
final removedCount = _totalEpisodeCounts.remove(globalKey);
|
|
final storage = await StorageService.getInstance();
|
|
await storage.removeEpisodeCount(globalKey);
|
|
appLogger.i(
|
|
'Removed episode count for $globalKey\n'
|
|
' - Removed count value: $removedCount\n'
|
|
' - Metadata type: ${container.kind.id}\n'
|
|
' - Metadata title: ${container.title}\n'
|
|
' - Remaining stored counts: ${_totalEpisodeCounts.length}',
|
|
);
|
|
|
|
final descendants = _ownedDescendantEntries(container).toList();
|
|
for (final entry in descendants) {
|
|
await deleteDownload(entry.key);
|
|
}
|
|
|
|
DeletionNotifier().notifyDeletedItem(item: container, isDownloadOnly: true);
|
|
safeNotifyListeners();
|
|
}
|
|
|
|
Iterable<MapEntry<String, MediaItem>> _ownedDescendantEntries(MediaItem container) {
|
|
return _metadata.entries.where((entry) {
|
|
if (!_ownsDownloadKey(entry.key)) return false;
|
|
final meta = entry.value;
|
|
if (meta.serverId != container.serverId) return false;
|
|
return container.isShow
|
|
? (meta.grandparentId == container.id || meta.parentId == container.id)
|
|
: meta.parentId == container.id;
|
|
});
|
|
}
|
|
|
|
/// Handle deletion progress updates
|
|
void _onDeletionProgressUpdate(DeletionProgress progress) {
|
|
if (progress.isComplete) {
|
|
// Deletion complete - remove from tracking
|
|
_deletionProgress.remove(progress.globalKey);
|
|
} else {
|
|
// Update progress
|
|
_deletionProgress[progress.globalKey] = progress;
|
|
}
|
|
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(String 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 {
|
|
// 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();
|
|
|
|
// 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>{..._metadata.keys, ..._downloads.keys};
|
|
if (keys.isEmpty) return;
|
|
|
|
final allMetadata = await _downloadManager.getAllPinnedMetadata(preferActiveScope: true);
|
|
int cacheHits = 0;
|
|
int networkFills = 0;
|
|
int misses = 0;
|
|
|
|
for (final globalKey in keys) {
|
|
final parsed = parseGlobalKey(globalKey);
|
|
if (parsed == null) continue;
|
|
|
|
try {
|
|
final downloadRecord = await _downloadManager.getDownloadedMedia(globalKey);
|
|
var cached =
|
|
allMetadata[globalKey] ??
|
|
await _downloadManager.lookupMetadata(parsed.serverId, parsed.ratingKey, preferActiveScope: true);
|
|
if (cached != null) {
|
|
cacheHits++;
|
|
} else if (_downloads.containsKey(globalKey)) {
|
|
// Cache miss for an item we know is downloaded — pull from the
|
|
// live server. Repairs profiles where the per-backend cache row
|
|
// was never written or got cleared, the case that produces
|
|
// empty-title sync rules and a missing-downloads list.
|
|
cached = await _downloadManager.fetchAndPinMetadata(
|
|
parsed.serverId,
|
|
parsed.ratingKey,
|
|
preferActiveScope: true,
|
|
);
|
|
if (cached != null) networkFills++;
|
|
}
|
|
|
|
if (cached != null) {
|
|
_metadata[globalKey] = cached;
|
|
if (cached.isEpisode) {
|
|
_loadParentMetadataFromMap(
|
|
cached,
|
|
allMetadata,
|
|
clientScopeId:
|
|
_downloadManager.activeClientScopeIdForServer(parsed.serverId) ?? downloadRecord?.clientScopeId,
|
|
);
|
|
}
|
|
} else {
|
|
misses++;
|
|
}
|
|
} 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();
|
|
|
|
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.
|
|
/// [activeId] is excluded from deletion to protect the currently playing item.
|
|
Future<List<String>> autoDeleteWatchedDownloads({String? activeId}) 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 = _metadata[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 (activeId != null && meta.id == activeId) continue;
|
|
|
|
try {
|
|
appLogger.i('Auto-deleting watched download: ${meta.title} ($globalKey)');
|
|
await deleteDownload(globalKey);
|
|
deletedTitles.add(meta.title ?? '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(String serverId, String ratingKey, {String? profileId}) {
|
|
final owner = profileId ?? _activeProfileId;
|
|
if (owner == null || owner.isEmpty) return buildGlobalKey(serverId, ratingKey);
|
|
return buildProfileScopedGlobalKey(owner, serverId, ratingKey);
|
|
}
|
|
|
|
String syncRuleKeyForGlobalKey(String globalKey) {
|
|
final scoped = parseProfileScopedGlobalKey(globalKey);
|
|
if (scoped != null) {
|
|
return syncRuleKeyFor(scoped.serverId, scoped.ratingKey, profileId: scoped.profileId);
|
|
}
|
|
final parsed = parseGlobalKey(globalKey);
|
|
if (parsed == null) return globalKey;
|
|
return syncRuleKeyFor(parsed.serverId, parsed.ratingKey);
|
|
}
|
|
|
|
String syncRuleKeyForClient(MediaServerClient client, String ratingKey, {String? 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(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];
|
|
|
|
/// 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 String serverId,
|
|
required String ratingKey,
|
|
required String targetType,
|
|
required int episodeCount,
|
|
int mediaIndex = 0,
|
|
String downloadFilter = SyncRuleFilter.unwatched,
|
|
MediaItem? targetMetadata,
|
|
}) async {
|
|
final profileId = _requireActiveProfileId();
|
|
final publicGlobalKey = buildGlobalKey(serverId, ratingKey);
|
|
final scopedGlobalKey = syncRuleKeyFor(serverId, ratingKey, profileId: profileId);
|
|
await _database.insertSyncRule(
|
|
profileId: profileId,
|
|
serverId: serverId,
|
|
ratingKey: ratingKey,
|
|
globalKey: scopedGlobalKey,
|
|
targetType: targetType,
|
|
episodeCount: episodeCount,
|
|
mediaIndex: mediaIndex,
|
|
downloadFilter: downloadFilter,
|
|
);
|
|
|
|
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)');
|
|
}
|
|
|
|
/// 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);
|
|
final publicGlobalKey = existing == null ? globalKey : buildGlobalKey(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);
|
|
}
|
|
safeNotifyListeners();
|
|
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 [];
|
|
|
|
final profileId = _activeProfileId;
|
|
if (profileId == null || profileId.isEmpty) return [];
|
|
if (_syncRules.isEmpty) return [];
|
|
|
|
final results = await _syncRuleExecutor.executeSyncRules(
|
|
profileId: profileId,
|
|
serverManager: serverManager,
|
|
downloads: downloads,
|
|
metadata: Map.unmodifiable(_metadata),
|
|
queueSingleDownload: (episode, client, {int mediaIndex = 0}) =>
|
|
_queueSingleDownload(episode, client, mediaIndex: mediaIndex),
|
|
force: force,
|
|
);
|
|
|
|
return results.where((r) => r.queuedCount > 0).map((r) {
|
|
final title = r.title ?? '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;
|
|
|
|
final profileId = _activeProfileId;
|
|
if (profileId == null || profileId.isEmpty) return null;
|
|
if (!_syncRules.containsKey(globalKey)) return null;
|
|
|
|
return _syncRuleExecutor.executeSingleRule(
|
|
profileId: profileId,
|
|
globalKey: globalKey,
|
|
serverManager: serverManager,
|
|
downloads: downloads,
|
|
metadata: Map.unmodifiable(_metadata),
|
|
queueSingleDownload: (episode, client, {int mediaIndex = 0}) =>
|
|
_queueSingleDownload(episode, client, mediaIndex: mediaIndex),
|
|
);
|
|
}
|
|
|
|
Future<void> _loadSyncRules() async {
|
|
try {
|
|
_syncRules.clear();
|
|
final profileId = _activeProfileId;
|
|
if (profileId == null || profileId.isEmpty) return;
|
|
await _database.adoptLegacySyncRulesForProfile(profileId);
|
|
if (_activeProfileId != profileId) return;
|
|
final rules = await _database.getSyncRules(profileId: profileId);
|
|
for (final rule in rules) {
|
|
_syncRules[rule.globalKey] = rule;
|
|
}
|
|
} catch (e) {
|
|
appLogger.w('Failed to load sync rules', error: e);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadDownloadOwners() async {
|
|
try {
|
|
_ownedDownloadKeys.clear();
|
|
final profileId = _activeProfileId;
|
|
if (profileId == null || profileId.isEmpty) return;
|
|
await _database.adoptLegacyDownloadsForProfile(profileId);
|
|
if (_activeProfileId != profileId) return;
|
|
_ownedDownloadKeys.addAll(await _database.getDownloadOwnerKeysForProfile(profileId));
|
|
} catch (e) {
|
|
appLogger.w('Failed to load download ownership', error: e);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Exception thrown when download is blocked due to cellular-only setting
|
|
class CellularDownloadBlockedException implements Exception {
|
|
final String message = 'Downloads are disabled on cellular data';
|
|
|
|
@override
|
|
String toString() => message;
|
|
}
|