Plex API returns Directory entries without type field, causing TypeError crash in fromJson. Made fields nullable and migrated callers to use mediaType enum and displayTitle getter instead of raw string access.
969 lines
35 KiB
Dart
969 lines
35 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:collection';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:plezy/utils/content_utils.dart';
|
|
import '../models/download_models.dart';
|
|
import '../models/plex_metadata.dart';
|
|
import '../services/download_manager_service.dart';
|
|
import '../services/download_storage_service.dart';
|
|
import '../services/storage_service.dart';
|
|
import '../services/plex_api_cache.dart';
|
|
import '../services/plex_client.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/global_key_utils.dart';
|
|
|
|
/// 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 {
|
|
final DownloadManagerService _downloadManager;
|
|
StreamSubscription<DownloadProgress>? _progressSubscription;
|
|
StreamSubscription<DeletionProgress>? _deletionProgressSubscription;
|
|
late final Future<void> _initFuture;
|
|
|
|
// Track download progress by globalKey (serverId:ratingKey)
|
|
final Map<String, DownloadProgress> _downloads = {};
|
|
|
|
// Store metadata for display
|
|
final Map<String, PlexMetadata> _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 = {};
|
|
|
|
// 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 = {};
|
|
|
|
DownloadProvider({required DownloadManagerService downloadManager}) : _downloadManager = downloadManager {
|
|
// Listen to progress updates from the download manager
|
|
_progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate);
|
|
|
|
// Listen to deletion progress updates
|
|
_deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate);
|
|
|
|
// Load persisted downloads from database
|
|
_initFuture = _loadPersistedDownloads();
|
|
}
|
|
|
|
/// Ensures persisted downloads have been loaded from disk.
|
|
Future<void> ensureInitialized() => _initFuture;
|
|
|
|
/// 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();
|
|
|
|
final storageService = DownloadStorageService.instance;
|
|
final apiCache = PlexApiCache.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 in a single query instead of per-item DB calls
|
|
final allMetadata = await apiCache.getAllPinnedMetadata();
|
|
|
|
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,
|
|
);
|
|
|
|
// Store Plex thumb path reference (file path computed from hash when needed)
|
|
_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)
|
|
final metadata = allMetadata[item.globalKey] ?? await apiCache.getMetadata(item.serverId, item.ratingKey);
|
|
if (metadata != null) {
|
|
_metadata[item.globalKey] = metadata;
|
|
|
|
// For episodes, also load parent (show and season) metadata from the same map
|
|
if (metadata.isEpisode) {
|
|
_loadParentMetadataFromMap(metadata, allMetadata);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load total episode counts from StorageService
|
|
await _loadTotalEpisodeCounts();
|
|
|
|
appLogger.i(
|
|
'Loaded ${_downloads.length} downloads, ${_metadata.length} metadata entries, '
|
|
'and ${_totalEpisodeCounts.length} episode counts',
|
|
);
|
|
notifyListeners();
|
|
} catch (e) {
|
|
appLogger.e('Failed to load persisted downloads', error: e);
|
|
}
|
|
}
|
|
|
|
/// 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(PlexMetadata episode, Map<String, PlexMetadata> allMetadata) {
|
|
final serverId = episode.serverId;
|
|
if (serverId == null) return;
|
|
|
|
// Load show metadata
|
|
final showRatingKey = episode.grandparentRatingKey;
|
|
if (showRatingKey != null) {
|
|
final showGlobalKey = buildGlobalKey(serverId, showRatingKey);
|
|
if (!_metadata.containsKey(showGlobalKey)) {
|
|
final showMetadata = allMetadata[showGlobalKey];
|
|
if (showMetadata != null) {
|
|
_metadata[showGlobalKey] = showMetadata;
|
|
if (showMetadata.thumb != null) {
|
|
_artworkPaths[showGlobalKey] = DownloadedArtwork(thumbPath: showMetadata.thumb);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load season metadata
|
|
final seasonRatingKey = episode.parentRatingKey;
|
|
if (seasonRatingKey != null) {
|
|
final seasonGlobalKey = buildGlobalKey(serverId, seasonRatingKey);
|
|
if (!_metadata.containsKey(seasonGlobalKey)) {
|
|
final seasonMetadata = allMetadata[seasonGlobalKey];
|
|
if (seasonMetadata != null) {
|
|
_metadata[seasonGlobalKey] = seasonMetadata;
|
|
if (seasonMetadata.thumb != null) {
|
|
_artworkPaths[seasonGlobalKey] = DownloadedArtwork(thumbPath: seasonMetadata.thumb);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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}');
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_progressSubscription?.cancel();
|
|
_deletionProgressSubscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Ensure metadata has a serverId, falling back to a parent's serverId.
|
|
PlexMetadata _ensureServerId(PlexMetadata metadata, String? fallbackServerId) =>
|
|
metadata.serverId != null ? metadata : metadata.copyWith(serverId: fallbackServerId);
|
|
|
|
/// All current download progress entries
|
|
Map<String, DownloadProgress> get downloads => Map.unmodifiable(_downloads);
|
|
|
|
/// All metadata for downloads
|
|
Map<String, PlexMetadata> get metadata => Map.unmodifiable(_metadata);
|
|
|
|
/// Get all queued/downloading items (for Queue tab)
|
|
List<DownloadProgress> get queuedDownloads {
|
|
return _downloads.values
|
|
.where(
|
|
(p) =>
|
|
p.status == DownloadStatus.queued ||
|
|
p.status == DownloadStatus.downloading ||
|
|
p.status == DownloadStatus.paused,
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
/// Get all completed downloads
|
|
List<DownloadProgress> get completedDownloads {
|
|
return _downloads.values.where((p) => p.status == DownloadStatus.completed).toList();
|
|
}
|
|
|
|
/// Get completed TV episode downloads (individual episodes)
|
|
List<PlexMetadata> get downloadedEpisodes {
|
|
return _metadata.entries
|
|
.where((entry) {
|
|
final progress = _downloads[entry.key];
|
|
return progress?.status == DownloadStatus.completed && entry.value.type == 'episode';
|
|
})
|
|
.map((entry) => entry.value)
|
|
.toList();
|
|
}
|
|
|
|
/// Get unique TV shows that have downloaded episodes
|
|
/// Returns stored show metadata, or synthesizes from episode metadata as fallback
|
|
List<PlexMetadata> get downloadedShows {
|
|
final Map<String, PlexMetadata> shows = {};
|
|
|
|
for (final entry in _metadata.entries) {
|
|
final globalKey = entry.key;
|
|
final meta = entry.value;
|
|
final progress = _downloads[globalKey];
|
|
|
|
if (progress?.status == DownloadStatus.completed && meta.type == 'episode') {
|
|
final showRatingKey = meta.grandparentRatingKey;
|
|
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.type == 'show') {
|
|
// Use stored show metadata (has year, summary, clearLogo)
|
|
shows[showRatingKey] = storedShow;
|
|
} else {
|
|
// Fallback: synthesize from episode metadata (missing year, summary)
|
|
shows[showRatingKey] = PlexMetadata(
|
|
ratingKey: showRatingKey,
|
|
key: '/library/metadata/$showRatingKey',
|
|
type: 'show',
|
|
title: meta.grandparentTitle ?? 'Unknown Show',
|
|
thumb: meta.grandparentThumb,
|
|
art: meta.grandparentArt,
|
|
serverId: meta.serverId,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return shows.values.toList();
|
|
}
|
|
|
|
/// Get completed movie downloads
|
|
List<PlexMetadata> get downloadedMovies {
|
|
return _metadata.entries
|
|
.where((entry) {
|
|
final progress = _downloads[entry.key];
|
|
return progress?.status == DownloadStatus.completed && entry.value.type == 'movie';
|
|
})
|
|
.map((entry) => entry.value)
|
|
.toList();
|
|
}
|
|
|
|
/// Get metadata for a specific download
|
|
PlexMetadata? 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<PlexMetadata> getDownloadedEpisodesForShow(String showRatingKey) {
|
|
return _metadata.entries
|
|
.where((entry) {
|
|
final progress = _downloads[entry.key];
|
|
final meta = entry.value;
|
|
return progress?.status == DownloadStatus.completed &&
|
|
meta.type == 'episode' &&
|
|
meta.grandparentRatingKey == 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) {
|
|
final meta = _metadata[entry.key];
|
|
if (meta?.type != 'episode') return false;
|
|
if (showRatingKey != null && meta?.grandparentRatingKey != showRatingKey) return false;
|
|
if (seasonRatingKey != null && meta?.parentRatingKey != 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?.type}\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',
|
|
);
|
|
}
|
|
|
|
/// Whether there are any downloads (active or completed)
|
|
bool get hasDownloads => _downloads.isNotEmpty;
|
|
|
|
/// Whether there are any active downloads
|
|
bool get hasActiveDownloads =>
|
|
_downloads.values.any((p) => p.status == DownloadStatus.downloading || p.status == DownloadStatus.queued);
|
|
|
|
/// 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) {
|
|
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 type
|
|
final mt = meta.mediaType;
|
|
if (mt == PlexMediaType.show) {
|
|
return getAggregateProgressForShow(serverId, ratingKey);
|
|
} else if (mt == PlexMediaType.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');
|
|
|
|
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(PlexMetadata metadata, PlexClient client) async {
|
|
final globalKey = metadata.globalKey;
|
|
|
|
// 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);
|
|
notifyListeners();
|
|
|
|
final mt = metadata.mediaType;
|
|
|
|
if (mt == PlexMediaType.movie || mt == PlexMediaType.episode) {
|
|
// Direct download of a single item
|
|
await _queueSingleDownload(metadata, client);
|
|
return 1;
|
|
} else if (mt == PlexMediaType.show) {
|
|
// Store show metadata so getProgress() can identify it as a show
|
|
_metadata[globalKey] = metadata;
|
|
|
|
// Download all episodes from all seasons
|
|
return await _queueShowDownload(metadata, client);
|
|
} else if (mt == PlexMediaType.season) {
|
|
// Store season metadata so getProgress() can identify it as a season
|
|
_metadata[globalKey] = metadata;
|
|
|
|
// Download all episodes in season
|
|
return await _queueSeasonDownload(metadata, client);
|
|
} else {
|
|
throw Exception('Cannot download ${metadata.type}');
|
|
}
|
|
} finally {
|
|
// Always remove from queueing set, even on error
|
|
_queueing.remove(globalKey);
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// Queue a single movie or episode for download
|
|
Future<void> _queueSingleDownload(PlexMetadata metadata, PlexClient client) async {
|
|
final globalKey = metadata.globalKey;
|
|
|
|
// Don't re-queue if already downloading or completed
|
|
if (_downloads.containsKey(globalKey)) {
|
|
final existing = _downloads[globalKey]!;
|
|
if (existing.status == DownloadStatus.downloading || existing.status == DownloadStatus.completed) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 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 getMetadataWithImages has been called.
|
|
PlexMetadata metadataToStore = metadata;
|
|
try {
|
|
final fullMetadata = await client.getMetadataWithImages(metadata.ratingKey);
|
|
if (fullMetadata != null) {
|
|
metadataToStore = fullMetadata.copyWith(serverId: metadata.serverId, serverName: metadata.serverName);
|
|
}
|
|
} catch (e) {
|
|
appLogger.w('Failed to fetch full metadata for ${metadata.ratingKey}, using partial', error: e);
|
|
}
|
|
|
|
// For episodes, also fetch and store show and season metadata for offline display
|
|
if (metadataToStore.type == 'episode') {
|
|
await _fetchAndStoreParentMetadata(metadataToStore, client);
|
|
}
|
|
|
|
// Store full metadata for display
|
|
_metadata[globalKey] = metadataToStore;
|
|
|
|
// Update local state immediately for UI feedback
|
|
_downloads[globalKey] = DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued);
|
|
notifyListeners();
|
|
|
|
// Actually trigger download via DownloadManagerService
|
|
await _downloadManager.queueDownload(metadata: metadataToStore, client: client);
|
|
}
|
|
|
|
/// Fetch and store show and season metadata for an episode
|
|
/// Also downloads artwork for show and season
|
|
Future<void> _fetchAndStoreParentMetadata(PlexMetadata episode, PlexClient client) async {
|
|
final serverId = episode.serverId;
|
|
if (serverId == null) return;
|
|
|
|
await _fetchAndStoreRelatedMetadata(serverId: serverId, ratingKey: episode.grandparentRatingKey, client: client);
|
|
await _fetchAndStoreRelatedMetadata(serverId: serverId, ratingKey: episode.parentRatingKey, 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 PlexClient client,
|
|
}) async {
|
|
if (ratingKey == null) return;
|
|
final globalKey = buildGlobalKey(serverId, ratingKey);
|
|
final storageService = DownloadStorageService.instance;
|
|
|
|
PlexMetadata? metadata = _metadata[globalKey];
|
|
if (metadata == null) {
|
|
try {
|
|
metadata = await client.getMetadataWithImages(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);
|
|
|
|
final thumbPath = withServer.thumb;
|
|
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, PlexMetadata 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(PlexMetadata show, PlexClient client) async {
|
|
int count = 0;
|
|
final seasons = await client.getChildren(show.ratingKey);
|
|
|
|
await _storeLeafCount(show.globalKey, show);
|
|
|
|
for (final season in seasons) {
|
|
if (season.type == 'season') {
|
|
final seasonWithServer = _ensureServerId(season, show.serverId);
|
|
count += await _queueSeasonDownload(seasonWithServer, client);
|
|
}
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
/// Queue all episodes from a season for download
|
|
Future<int> _queueSeasonDownload(PlexMetadata season, PlexClient client) async {
|
|
int count = 0;
|
|
final episodes = await client.getChildren(season.ratingKey);
|
|
|
|
await _storeLeafCount(season.globalKey, season);
|
|
|
|
for (final episode in episodes) {
|
|
if (episode.type == 'episode') {
|
|
final episodeWithServer = _ensureServerId(episode, season.serverId);
|
|
await _queueSingleDownload(episodeWithServer, client);
|
|
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(PlexMetadata metadata, PlexClient client) async {
|
|
final mt = metadata.mediaType;
|
|
|
|
if (mt == PlexMediaType.show) {
|
|
return await _queueMissingShowEpisodes(metadata, client);
|
|
} else if (mt == PlexMediaType.season) {
|
|
return await _queueMissingSeasonEpisodes(metadata, client);
|
|
} else {
|
|
throw Exception('queueMissingEpisodes only supports shows/seasons');
|
|
}
|
|
}
|
|
|
|
/// Queue missing episodes for a show
|
|
Future<int> _queueMissingShowEpisodes(PlexMetadata show, PlexClient client) async {
|
|
int queuedCount = 0;
|
|
|
|
// Fetch all seasons
|
|
final seasons = await client.getChildren(show.ratingKey);
|
|
|
|
for (final season in seasons) {
|
|
if (season.type == 'season') {
|
|
final seasonWithServer = _ensureServerId(season, show.serverId);
|
|
queuedCount += await _queueMissingSeasonEpisodes(seasonWithServer, client);
|
|
}
|
|
}
|
|
|
|
appLogger.i('Queued $queuedCount missing episodes for show ${show.title}');
|
|
return queuedCount;
|
|
}
|
|
|
|
/// Queue missing episodes for a season
|
|
Future<int> _queueMissingSeasonEpisodes(PlexMetadata season, PlexClient client) async {
|
|
int queuedCount = 0;
|
|
|
|
// Fetch all episodes
|
|
final episodes = await client.getChildren(season.ratingKey);
|
|
|
|
for (final episode in episodes) {
|
|
if (episode.type == 'episode') {
|
|
final episodeWithServer = _ensureServerId(episode, season.serverId);
|
|
|
|
final episodeGlobalKey = episodeWithServer.globalKey;
|
|
|
|
// Only queue if NOT already downloaded or in progress
|
|
final progress = _downloads[episodeGlobalKey];
|
|
if (progress == null ||
|
|
(progress.status != DownloadStatus.completed &&
|
|
progress.status != DownloadStatus.downloading &&
|
|
progress.status != DownloadStatus.queued)) {
|
|
await _queueSingleDownload(episodeWithServer, client);
|
|
queuedCount++;
|
|
appLogger.d('Queued missing episode: ${episode.title} ($episodeGlobalKey)');
|
|
}
|
|
}
|
|
}
|
|
|
|
return queuedCount;
|
|
}
|
|
|
|
/// Pause a download (works for both downloading and queued items)
|
|
Future<void> pauseDownload(String globalKey) async {
|
|
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, PlexClient client) async {
|
|
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, PlexClient client) async {
|
|
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 {
|
|
final progress = _downloads[globalKey];
|
|
if (progress != null) {
|
|
await _downloadManager.cancelDownload(globalKey);
|
|
_downloads.remove(globalKey);
|
|
_metadata.remove(globalKey);
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// Delete a downloaded item
|
|
Future<void> deleteDownload(String globalKey) async {
|
|
try {
|
|
// Check if this is a show/season and clean up episode count
|
|
final meta = _metadata[globalKey];
|
|
if (meta?.type == 'show' || meta?.type == 'season') {
|
|
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: ${meta?.type}\n'
|
|
' - Metadata title: ${meta?.title}\n'
|
|
' - Remaining stored counts: ${_totalEpisodeCounts.length}',
|
|
);
|
|
}
|
|
|
|
// 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);
|
|
|
|
notifyListeners();
|
|
} catch (e) {
|
|
// Remove from deletion tracking on error
|
|
_deletionProgress.remove(globalKey);
|
|
notifyListeners();
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Check if an item is being deleted
|
|
bool isDeleting(String globalKey) => _deletionProgress.containsKey(globalKey);
|
|
|
|
/// Get deletion progress for an item
|
|
DeletionProgress? getDeletionProgress(String globalKey) => _deletionProgress[globalKey];
|
|
|
|
/// Get all items currently being deleted
|
|
UnmodifiableMapView<String, DeletionProgress> get deletionProgress => UnmodifiableMapView(_deletionProgress);
|
|
|
|
/// Refresh the downloads list from database
|
|
Future<void> refresh() async {
|
|
await _loadPersistedDownloads();
|
|
}
|
|
|
|
/// Resume queued downloads that were interrupted by app kill.
|
|
/// Call after a PlexClient becomes available (e.g. after server connect on launch).
|
|
void resumeQueuedDownloads(PlexClient client) {
|
|
_downloadManager.resumeQueuedDownloads(client);
|
|
}
|
|
|
|
/// 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 apiCache = PlexApiCache.instance;
|
|
int updatedCount = 0;
|
|
|
|
for (final globalKey in _metadata.keys.toList()) {
|
|
final parsed = parseGlobalKey(globalKey);
|
|
if (parsed == null) continue;
|
|
|
|
final serverId = parsed.serverId;
|
|
final ratingKey = parsed.ratingKey;
|
|
|
|
try {
|
|
final metadata = await apiCache.getMetadata(serverId, ratingKey);
|
|
if (metadata != null) {
|
|
_metadata[globalKey] = metadata;
|
|
updatedCount++;
|
|
}
|
|
} catch (e) {
|
|
appLogger.d('Failed to refresh metadata for $globalKey: $e');
|
|
}
|
|
}
|
|
|
|
if (updatedCount > 0) {
|
|
appLogger.i('Refreshed metadata from cache for $updatedCount items');
|
|
notifyListeners();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
}
|