From dcd0b0309682ab3bd6b1b2ff39583d59b64b8fa0 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 24 May 2026 05:27:19 +0200 Subject: [PATCH] fix(downloads): repair offline artwork --- lib/providers/download_provider.dart | 75 ++++- lib/screens/media_detail_screen.dart | 94 ++++-- lib/services/download_artwork_service.dart | 142 +++++++++ lib/services/download_manager_service.dart | 288 ++++++++++++++---- lib/utils/media_server_http_client.dart | 23 +- lib/widgets/tv_spotlight_background.dart | 55 +++- .../download_artwork_service_test.dart | 184 +++++++++++ .../download_manager_service_test.dart | 169 ++++++++++ 8 files changed, 928 insertions(+), 102 deletions(-) create mode 100644 lib/services/download_artwork_service.dart create mode 100644 test/services/download_artwork_service_test.dart diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 74462ccd..c3c6a446 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -12,6 +12,7 @@ import '../database/app_database.dart'; import '../database/download_operations.dart'; import '../services/download_manager_service.dart'; import '../services/api_cache.dart'; +import '../services/download_artwork_service.dart'; import '../services/download_storage_service.dart'; import '../services/multi_server_manager.dart'; import '../services/offline_mode_source.dart'; @@ -40,10 +41,15 @@ class DownloadedArtwork { /// Get the local file path for this artwork String? getLocalPath(DownloadStorageService storage, String serverId) { if (thumbPath == null) return null; - return storage.getArtworkPathSync(serverId, thumbPath!); + return DownloadArtworkService.localPathSync(storage, serverId, thumbPath); } } +class _RelatedMetadataDownloadContext { + final hydratedMetadataKeys = {}; + final ensuredArtworkKeys = {}; +} + /// Provider for managing download state and operations. class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin { final DownloadManagerService _downloadManager; @@ -592,7 +598,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// 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); + return DownloadArtworkService.localPathSync(DownloadStorageService.instance, serverId, artworkPath); } /// Get downloaded episodes for a specific show (by grandparentRatingKey) @@ -946,11 +952,12 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } final unwatchedOnly = filter == DownloadFilter.unwatched; + final relatedContext = _RelatedMetadataDownloadContext(); int count = 0; Future queueItem(MediaItem item) async { if (unwatchedOnly && item.isWatched && !item.hasActiveProgress) return; - final queued = await _queueSingleDownload(item, client); + final queued = await _queueSingleDownload(item, client, relatedContext: relatedContext); if (queued) count++; } @@ -986,6 +993,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin MediaServerClient client, { int mediaIndex = 0, DownloadVersionConfig? versionConfig, + _RelatedMetadataDownloadContext? relatedContext, }) async { if (!_downloadManager.downloadsSupported) return false; @@ -1051,7 +1059,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // For episodes, also fetch and store show and season metadata for offline display if (metadataToStore.isEpisode) { - await _fetchAndStoreParentMetadata(metadataToStore, client); + await _fetchAndStoreParentMetadata( + metadataToStore, + client, + context: relatedContext ?? _RelatedMetadataDownloadContext(), + ); } // Store full metadata for display @@ -1070,12 +1082,26 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Fetch and store show and season metadata for an episode /// Also downloads artwork for show and season - Future _fetchAndStoreParentMetadata(MediaItem episode, MediaServerClient client) async { + Future _fetchAndStoreParentMetadata( + MediaItem episode, + MediaServerClient client, { + required _RelatedMetadataDownloadContext context, + }) 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); + await _fetchAndStoreRelatedMetadata( + serverId: serverId, + ratingKey: episode.grandparentId, + client: client, + context: context, + ); + await _fetchAndStoreRelatedMetadata( + serverId: serverId, + ratingKey: episode.parentId, + client: client, + context: context, + ); } /// Fetch, persist, and download artwork for a related metadata item (show or season). @@ -1083,15 +1109,27 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin required String serverId, required String? ratingKey, required MediaServerClient client, + required _RelatedMetadataDownloadContext context, }) async { if (ratingKey == null) return; final globalKey = buildGlobalKey(serverId, ratingKey); - final storageService = DownloadStorageService.instance; MediaItem? metadata = _metadata[globalKey]; - if (metadata == null) { + var fetchedFreshMetadata = false; + if (!(_offlineSource?.isOffline ?? false) && !context.hydratedMetadataKeys.contains(globalKey)) { try { - metadata = await client.fetchItem(ratingKey); + final fetched = await client.fetchItem(ratingKey); + if (fetched != null) { + final existing = metadata; + metadata = fetched.copyWith( + serverId: existing?.serverId ?? fetched.serverId ?? serverId, + serverName: existing?.serverName ?? fetched.serverName, + libraryId: fetched.libraryId ?? existing?.libraryId, + libraryTitle: fetched.libraryTitle ?? existing?.libraryTitle, + ); + context.hydratedMetadataKeys.add(globalKey); + fetchedFreshMetadata = true; + } } catch (e) { appLogger.w('Failed to fetch metadata for $ratingKey', error: e); } @@ -1103,8 +1141,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin await _downloadManager.saveMetadata(withServer, client); final thumbPath = withServer.thumbPath; - final hasPoster = thumbPath != null && await storageService.artworkExists(serverId, thumbPath); - if (!hasPoster) { + if (fetchedFreshMetadata || context.ensuredArtworkKeys.add(globalKey)) { await _downloadManager.downloadArtworkForMetadata(withServer, client); } _artworkPaths[globalKey] = DownloadedArtwork(thumbPath: thumbPath); @@ -1192,6 +1229,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin required bool skipExisting, }) async { final unwatchedOnly = filter == DownloadFilter.unwatched; + final relatedContext = _RelatedMetadataDownloadContext(); final episodes = []; if (container.kind == MediaKind.show) { await collectEpisodesForShow( @@ -1228,7 +1266,12 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } - final queued = await _queueSingleDownload(episodeWithServer, client, versionConfig: versionConfig); + final queued = await _queueSingleDownload( + episodeWithServer, + client, + versionConfig: versionConfig, + relatedContext: relatedContext, + ); if (queued) count++; } return count; @@ -1661,13 +1704,14 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (profileId == null || profileId.isEmpty) return []; if (_syncRules.isEmpty) return []; + final relatedContext = _RelatedMetadataDownloadContext(); 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), + _queueSingleDownload(episode, client, mediaIndex: mediaIndex, relatedContext: relatedContext), force: force, ); @@ -1686,6 +1730,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin if (profileId == null || profileId.isEmpty) return null; if (!_syncRules.containsKey(globalKey)) return null; + final relatedContext = _RelatedMetadataDownloadContext(); return _syncRuleExecutor.executeSingleRule( profileId: profileId, globalKey: globalKey, @@ -1693,7 +1738,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin downloads: downloads, metadata: Map.unmodifiable(_metadata), queueSingleDownload: (episode, client, {int mediaIndex = 0}) => - _queueSingleDownload(episode, client, mediaIndex: mediaIndex), + _queueSingleDownload(episode, client, mediaIndex: mediaIndex, relatedContext: relatedContext), ); } diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 10b4f70f..c22b95d4 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -67,6 +67,7 @@ import '../mixins/mounted_set_state_mixin.dart'; import '../mixins/server_bound_media_mixin.dart'; import '../utils/watch_state_notifier.dart'; import '../utils/deletion_notifier.dart'; +import '../utils/global_key_utils.dart'; import '../widgets/episode_card.dart'; import '../widgets/fitting_title_text.dart'; import 'actor_media_screen.dart'; @@ -1104,10 +1105,9 @@ class _MediaDetailScreenState extends State }) { if (!widget.isOffline || _metadata.serverId == null) return null; - final downloadProvider = context.read(); for (final artworkPath in artworkPaths) { - final localPath = downloadProvider.getArtworkLocalPath(_metadata.serverId!, artworkPath); - if (localPath == null || !File(localPath).existsSync()) continue; + final localPath = _offlineArtworkLocalPath(context, artworkPath); + if (localPath == null) continue; return OptimizedMediaImage( client: null, @@ -1123,6 +1123,13 @@ class _MediaDetailScreenState extends State return null; } + String? _offlineArtworkLocalPath(BuildContext context, String? artworkPath) { + if (!widget.isOffline || _metadata.serverId == null) return null; + final localPath = context.read().getArtworkLocalPath(_metadata.serverId!, artworkPath); + if (localPath == null || !File(localPath).existsSync()) return null; + return localPath; + } + Widget _buildHeroNetworkArtwork( BuildContext context, { required MediaServerClient? client, @@ -1506,8 +1513,23 @@ class _MediaDetailScreenState extends State // Create synthetic season MediaItems from the grouped episodes. final seasons = seasonMap.entries.map((entry) { final firstEp = entry.value.first; + final seasonId = firstEp.parentId ?? ''; + final seasonGlobalKey = _metadata.serverId == null || seasonId.isEmpty + ? null + : buildGlobalKey(_metadata.serverId!, seasonId); + final storedSeason = seasonGlobalKey == null ? null : downloadProvider.getMetadata(seasonGlobalKey); + if (storedSeason != null && storedSeason.isSeason) { + return _withFallbackLibrary( + storedSeason.copyWith( + serverId: _metadata.serverId, + serverName: _metadata.serverName ?? storedSeason.serverName, + leafCount: storedSeason.leafCount ?? entry.value.length, + ), + _metadata, + ); + } return MediaItem( - id: firstEp.parentId ?? '', + id: seasonId, backend: _metadata.backend, kind: MediaKind.season, title: firstEp.parentTitle ?? 'Season ${entry.key}', @@ -2121,32 +2143,47 @@ class _MediaDetailScreenState extends State if (showSeasonPosters && posterPath != null && posterPath.isNotEmpty) { const posterWidth = 72.0; const posterHeight = 108.0; - final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); - final client = _getMediaClientForMetadata(context); - final imageUrl = MediaImageHelper.getOptimizedImageUrl( - client: client, - thumbPath: posterPath, - maxWidth: posterWidth, - maxHeight: posterHeight, - devicePixelRatio: dpr, - imageType: ImageType.poster, - ); - final (memWidth, _) = MediaImageHelper.getMemCacheDimensions( - displayWidth: (posterWidth * dpr).round(), - displayHeight: (posterHeight * dpr).round(), + final localArtwork = _buildOfflineArtworkIfAvailable( + context, + artworkPaths: [posterPath], + fit: BoxFit.cover, imageType: ImageType.poster, + errorWidget: (context, url, error) => const PlaceholderContainer(), ); topImage = SizedBox( width: posterWidth, height: posterHeight, - child: CachedNetworkImage( - imageUrl: imageUrl, - cacheManager: PlexImageCacheManager.instance, - fit: BoxFit.cover, - memCacheWidth: memWidth, - placeholder: (context, url) => const PlaceholderContainer(), - errorBuilder: (context, error, stackTrace) => const PlaceholderContainer(), - ), + child: + localArtwork ?? + (widget.isOffline + ? const PlaceholderContainer() + : Builder( + builder: (context) { + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final client = _getMediaClientForMetadata(context); + final imageUrl = MediaImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: posterPath, + maxWidth: posterWidth, + maxHeight: posterHeight, + devicePixelRatio: dpr, + imageType: ImageType.poster, + ); + final (memWidth, _) = MediaImageHelper.getMemCacheDimensions( + displayWidth: (posterWidth * dpr).round(), + displayHeight: (posterHeight * dpr).round(), + imageType: ImageType.poster, + ); + return CachedNetworkImage( + imageUrl: imageUrl, + cacheManager: PlexImageCacheManager.instance, + fit: BoxFit.cover, + memCacheWidth: memWidth, + placeholder: (context, url) => const PlaceholderContainer(), + errorBuilder: (context, error, stackTrace) => const PlaceholderContainer(), + ); + }, + )), ); } return Padding( @@ -3102,7 +3139,12 @@ class _MediaDetailScreenState extends State child: Scaffold( body: Stack( children: [ - TvSpotlightBackground(item: metadata, client: _getArtworkMediaClient(context), showInfo: false), + TvSpotlightBackground( + item: metadata, + client: _getArtworkMediaClient(context), + showInfo: false, + localArtworkPathResolver: widget.isOffline ? (path) => _offlineArtworkLocalPath(context, path) : null, + ), _buildTvDetailRevealGate(revealContent, handleBack), ], ), diff --git a/lib/services/download_artwork_service.dart b/lib/services/download_artwork_service.dart new file mode 100644 index 00000000..57f48ebb --- /dev/null +++ b/lib/services/download_artwork_service.dart @@ -0,0 +1,142 @@ +import 'dart:io'; + +import '../media/download_resolution.dart'; +import '../media/media_item.dart'; +import '../media/media_server_client.dart'; +import '../utils/app_logger.dart'; +import '../utils/media_server_http_client.dart'; +import 'download_artwork_helpers.dart'; +import 'download_storage_service.dart'; + +class _ArtworkDownloadOperation { + final Future future; + + const _ArtworkDownloadOperation(this.future); +} + +/// Centralized helper for downloaded artwork keys, paths, and file writes. +/// +/// Downloaded artwork is addressed by a normalized storage key rather than the +/// raw metadata path. This matters for Jellyfin because metadata URLs include +/// `api_key`, while local filenames must not contain long-lived tokens. +class DownloadArtworkService { + static final Map _downloadsByPath = {}; + + final DownloadStorageService storageService; + final MediaServerHttpClient http; + + const DownloadArtworkService({required this.storageService, required this.http}); + + static String normalizeKey(String pathOrUrl) => artworkStorageKey(pathOrUrl); + + static String? localPathSync(DownloadStorageService storageService, String serverId, String? pathOrUrl) { + if (pathOrUrl == null || pathOrUrl.isEmpty) return null; + return storageService.getArtworkPathSync(serverId, normalizeKey(pathOrUrl)); + } + + Future localPath(String serverId, String pathOrUrl) { + return storageService.getArtworkPathFromThumb(serverId, normalizeKey(pathOrUrl)); + } + + Future existsUsable(String serverId, String pathOrUrl) async { + final file = File(await localPath(serverId, pathOrUrl)); + return isUsableArtworkFile(file); + } + + Future hasMissingArtwork(String serverId, Iterable specs) async { + for (final spec in specs) { + if (!await existsUsable(serverId, spec.localKey)) return true; + } + return false; + } + + Future ensureArtworkForMetadata(MediaItem metadata, MediaServerClient client) async { + final serverId = metadata.serverId; + if (serverId == null) return; + await ensureArtworkSpecs(serverId, client.resolveDownloadArtwork(metadata)); + } + + Future ensureArtworkSpecs(String serverId, Iterable specs) async { + for (final spec in specs) { + await downloadSingleArtwork(serverId, spec); + } + } + + /// Download one artwork blob if it is missing or unusable. + /// + /// The HTTP helper writes atomically. This method validates the final file so + /// HTML/JSON error bodies do not poison future existence checks. + Future downloadSingleArtwork(String serverId, DownloadArtworkSpec spec) async { + if (spec.url.isEmpty) { + appLogger.w('Empty artwork URL for: ${spec.localKey}'); + return; + } + + final filePath = await localPath(serverId, spec.localKey); + final inFlight = _downloadsByPath[filePath]; + if (inFlight != null) { + await inFlight.future; + return; + } + + final operation = _ArtworkDownloadOperation(_downloadSingleArtworkToPath(serverId, spec, filePath)); + _downloadsByPath[filePath] = operation; + try { + await operation.future; + } finally { + if (identical(_downloadsByPath[filePath], operation)) { + _downloadsByPath.remove(filePath); + } + } + } + + Future _downloadSingleArtworkToPath(String serverId, DownloadArtworkSpec spec, String filePath) async { + try { + if (await existsUsable(serverId, spec.localKey)) { + appLogger.d('Artwork already exists: ${spec.localKey}'); + return; + } + + final file = File(filePath); + await file.parent.create(recursive: true); + + if (await file.exists()) { + await file.delete(); + } + + await http.downloadFile(spec.url, filePath); + + if (!await isUsableArtworkFile(file)) { + if (await file.exists()) await file.delete(); + appLogger.w('Downloaded artwork was not a usable image: ${spec.localKey}'); + return; + } + + appLogger.i('Downloaded artwork: ${spec.localKey} -> $filePath'); + } catch (e, stack) { + appLogger.w('Failed to download artwork: ${spec.localKey}', error: e, stackTrace: stack); + } + } + + static Future isUsableArtworkFile(File file) async { + try { + if (!await file.exists()) return false; + final length = await file.length(); + if (length <= 0) return false; + + final raf = await file.open(); + try { + final bytes = await raf.read(length < 512 ? length : 512); + final prefix = String.fromCharCodes(bytes.take(128)).trimLeft().toLowerCase(); + if (prefix.startsWith('.broadcast(); @@ -89,11 +93,15 @@ class DownloadManagerService { // Keys currently being paused — prevents holding queue from promoting them final Set _pausingKeys = {}; + // Keys currently being cancelled — prevents queue promotion/completion races. + final Set _cancellingKeys = {}; + // Keys whose completion callback is in-flight — prevents orphan scan from re-queuing them final Set _completingKeys = {}; // Prevents concurrent _processQueue calls bool _isProcessingQueue = false; + bool _isRepairingArtwork = false; bool _disposed = false; bool _loggedDownloadsUnsupported = false; @@ -142,12 +150,17 @@ class DownloadManagerService { /// Await this before reading download state from the DB to avoid races. late final Future recoveryFuture; + // Public parameter names are used by tests and app setup; the private fields + // cannot be initializing formals without exposing private named parameters. DownloadManagerService({ - required this._database, - required this._storageService, + required AppDatabase database, + required DownloadStorageService storageService, MediaServerHttpClient? http, @visibleForTesting this._downloadsSupportedOverride, - }) : _http = http ?? httpClient; + }) : _database = database, + _storageService = storageService, + _http = http ?? httpClient, + _artworkService = DownloadArtworkService(storageService: storageService, http: http ?? httpClient); bool get downloadsSupported => _downloadsSupportedOverride ?? platformDownloadsSupported; @@ -528,7 +541,8 @@ class DownloadManagerService { } // Attempt deferred supplementary downloads for recovered items - _processPendingSupplementaryDownloads(client); + unawaited(_processPendingSupplementaryDownloads(client)); + unawaited(repairMissingArtworkForDownloads()); unawaited( _database @@ -545,6 +559,114 @@ class DownloadManagerService { ); } + /// Best-effort repair for downloads that completed while supplementary + /// artwork was missing, corrupt, or skipped by older queue logic. + Future repairMissingArtworkForDownloads() async { + if (_isRepairingArtwork || _isOffline) return; + _isRepairingArtwork = true; + try { + final rows = await _database.select(_database.downloadedMedia).get(); + final ensuredParentKeys = {}; + + for (final row in rows) { + if (row.status != DownloadStatus.completed.index) continue; + final client = await _getClientForDownloadKey(row.globalKey); + if (client == null) continue; + + final metadata = await _lookupMetadata(row.serverId, row.ratingKey, clientScopeId: row.clientScopeId); + if (metadata == null) continue; + final withServer = _repairMetadataWithServer(metadata, row.serverId); + await _artworkService.ensureArtworkForMetadata(withServer, client); + await _backfillArtworkPath(row, withServer); + + if (!withServer.isEpisode) continue; + await _repairParentArtwork( + row.serverId, + withServer.grandparentId, + client, + ensuredParentKeys, + clientScopeId: row.clientScopeId, + ); + await _repairParentArtwork( + row.serverId, + withServer.parentId, + client, + ensuredParentKeys, + clientScopeId: row.clientScopeId, + ); + } + } catch (e, st) { + appLogger.w('Missing artwork repair failed', error: e, stackTrace: st); + } finally { + _isRepairingArtwork = false; + } + } + + Future _repairParentArtwork( + String serverId, + String? ratingKey, + MediaServerClient client, + Set ensuredKeys, { + String? clientScopeId, + }) async { + if (ratingKey == null || ratingKey.isEmpty) return; + final globalKey = buildGlobalKey(serverId, ratingKey); + if (!ensuredKeys.add(globalKey)) return; + final cached = await _lookupMetadata(serverId, ratingKey, clientScopeId: clientScopeId); + var metadata = cached; + if (!_isOffline) { + try { + final fetched = await client.fetchItem(ratingKey); + if (fetched != null) { + metadata = _mergeFetchedRepairMetadata(serverId: serverId, cached: cached, fetched: fetched); + await ApiCache.forBackend(client.backend).pinForOffline(client.cacheServerId, metadata.id); + } + } catch (e) { + appLogger.d('Artwork repair parent metadata fetch failed for $globalKey', error: e); + } + } + if (metadata == null) return; + final withServer = _repairMetadataWithServer(metadata, serverId); + await _artworkService.ensureArtworkForMetadata(withServer, client); + } + + MediaItem _repairMetadataWithServer(MediaItem metadata, String serverId) { + return metadata.serverId == null ? metadata.copyWith(serverId: serverId) : metadata; + } + + MediaItem _mergeFetchedRepairMetadata({ + required String serverId, + required MediaItem? cached, + required MediaItem fetched, + }) { + return fetched.copyWith( + serverId: cached?.serverId ?? fetched.serverId ?? serverId, + serverName: cached?.serverName ?? fetched.serverName, + libraryId: fetched.libraryId ?? cached?.libraryId, + libraryTitle: fetched.libraryTitle ?? cached?.libraryTitle, + ); + } + + Future _backfillArtworkPath(DownloadedMediaItem row, MediaItem metadata) async { + final thumbPath = metadata.thumbPath; + if (thumbPath == null || thumbPath.isEmpty) return; + final normalized = artworkStorageKey(thumbPath); + if (row.thumbPath == normalized) return; + + await _database.updateArtworkPaths(globalKey: row.globalKey, thumbPath: normalized); + if (_disposed) return; + _progressController.add( + DownloadProgress( + globalKey: row.globalKey, + status: DownloadStatus.values[row.status], + progress: row.status == DownloadStatus.completed.index ? 100 : row.progress, + downloadedBytes: row.downloadedBytes, + totalBytes: row.totalBytes ?? 0, + thumbPath: normalized, + ), + ); + } + /// Attempt supplementary downloads (artwork, subtitles) for items that were /// recovered with a completed video but missed post-processing. Future _processPendingSupplementaryDownloads(MediaServerClient client) async { @@ -805,6 +927,31 @@ class DownloadManagerService { await _database.updateDownloadProgress(globalKey, 0, 0, 0); } + Future _isInactiveForEnqueue(String globalKey) async { + if (_cancellingKeys.contains(globalKey)) return true; + final existing = await _database.getDownloadedMedia(globalKey); + return existing == null || + existing.status == DownloadStatus.completed.index || + existing.status == DownloadStatus.cancelled.index; + } + + Future _isCancelledOrDeleted(String globalKey) async { + if (_cancellingKeys.contains(globalKey)) return true; + final existing = await _database.getDownloadedMedia(globalKey); + return existing == null || existing.status == DownloadStatus.cancelled.index; + } + + Future _cancelEnqueuedTaskIfInactive(String globalKey, String taskId) async { + if (!await _isCancelledOrDeleted(globalKey)) return false; + if (downloadsSupported) { + await FileDownloader().cancelTaskWithId(taskId); + } + await _database.updateBgTaskId(globalKey, null); + await _database.removeFromQueue(globalKey); + _pendingDownloadContext.remove(globalKey); + return true; + } + /// Resolve metadata, video URL, and file path, then enqueue a background download task. /// Returns true if successfully enqueued, false if it failed immediately. Future _prepareAndEnqueueDownload( @@ -813,11 +960,15 @@ class DownloadManagerService { DownloadQueueItem queueItem, ) async { if (_skipDownloadsUnsupported('download enqueue')) return false; + if (_cancellingKeys.contains(globalKey)) return true; try { // Guard: don't re-enqueue an item that's already completed or was deleted final existing = await _database.getDownloadedMedia(globalKey); - if (existing == null || existing.status == DownloadStatus.completed.index) { + if (_cancellingKeys.contains(globalKey) || + existing == null || + existing.status == DownloadStatus.completed.index || + existing.status == DownloadStatus.cancelled.index) { appLogger.d('Skipping enqueue for $globalKey: already completed or deleted'); await _database.removeFromQueue(globalKey); return true; @@ -825,6 +976,11 @@ class DownloadManagerService { appLogger.i('Preparing download for $globalKey'); if (existing.bgTaskId != null) await _cleanupStaleDownload(globalKey); + if (await _isInactiveForEnqueue(globalKey)) { + appLogger.d('Skipping enqueue for $globalKey: inactive before transition'); + await _database.removeFromQueue(globalKey); + return true; + } await _transitionStatus(globalKey, DownloadStatus.downloading); final parsed = parseGlobalKey(globalKey); @@ -858,6 +1014,13 @@ class DownloadManagerService { if (resolution.videoUrl == null) throw Exception('Could not get video URL for $globalKey'); } + if (await _isCancelledOrDeleted(globalKey)) { + appLogger.d('Skipping enqueue for $globalKey: cancelled during preparation'); + await _database.removeFromQueue(globalKey); + _pendingDownloadContext.remove(globalKey); + return true; + } + final ext = downloadExtensionFromUrl(resolution.videoUrl!) ?? 'mp4'; // Look up show year for episodes @@ -924,6 +1087,7 @@ class DownloadManagerService { await _database.updateBgTaskId(globalKey, task.taskId); final success = await FileDownloader().enqueue(task); if (!success) throw Exception('Failed to enqueue SAF download task'); + if (await _cancelEnqueuedTaskIfInactive(globalKey, task.taskId)) return true; appLogger.i('Enqueued SAF download task ${task.taskId} for $globalKey'); } else { // Normal mode: use DownloadTask with pause/resume support @@ -972,10 +1136,17 @@ class DownloadManagerService { await _database.updateBgTaskId(globalKey, task.taskId); final success = await FileDownloader().enqueue(task); if (!success) throw Exception('Failed to enqueue download task'); + if (await _cancelEnqueuedTaskIfInactive(globalKey, task.taskId)) return true; appLogger.i('Enqueued download task ${task.taskId} for $globalKey'); } return true; } catch (e) { + if (await _isCancelledOrDeleted(globalKey)) { + appLogger.d('Ignoring enqueue failure for inactive download $globalKey', error: e); + await _database.removeFromQueue(globalKey); + _pendingDownloadContext.remove(globalKey); + return true; + } appLogger.e('Failed to prepare download for $globalKey', error: e); await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: e.toString()); await _database.removeFromQueue(globalKey); @@ -995,6 +1166,10 @@ class DownloadManagerService { if (downloadsSupported) FileDownloader().cancelTaskWithId(update.task.taskId); return; } + if (_cancellingKeys.contains(globalKey)) { + if (downloadsSupported) FileDownloader().cancelTaskWithId(update.task.taskId); + return; + } final progress = (update.progress * 100).round().clamp(0, 100); final speedBytesPerSec = update.hasNetworkSpeed ? update.networkSpeed * 1024 * 1024 : 0.0; @@ -1042,7 +1217,7 @@ class DownloadManagerService { case TaskStatus.notFound: _onDownloadPermanentlyFailed(globalKey, 'File not found (404)'); case TaskStatus.canceled: - if (_pausingKeys.contains(globalKey)) break; + if (_pausingKeys.contains(globalKey) || _cancellingKeys.contains(globalKey)) break; _onDownloadCanceled(globalKey); case TaskStatus.paused: appLogger.d('Download paused by system for $globalKey'); @@ -1054,6 +1229,9 @@ class DownloadManagerService { if (_pausingKeys.contains(globalKey)) { if (downloadsSupported) FileDownloader().cancelTaskWithId(update.task.taskId); } + if (_cancellingKeys.contains(globalKey)) { + if (downloadsSupported) FileDownloader().cancelTaskWithId(update.task.taskId); + } break; } } catch (e) { @@ -1069,7 +1247,11 @@ class DownloadManagerService { if (_completingKeys.contains(globalKey)) return; final existing = await _database.getDownloadedMedia(globalKey); - if (existing?.status == DownloadStatus.completed.index) return; + if (existing == null || + existing.status == DownloadStatus.completed.index || + existing.status == DownloadStatus.cancelled.index) { + return; + } appLogger.w('Download cancelled by system for $globalKey, re-queuing'); await _database.updateBgTaskId(globalKey, null); @@ -1082,6 +1264,10 @@ class DownloadManagerService { /// Handle a failed download — auto-retry if retries remain, otherwise permanently fail. /// Native retries (Range-based resume) are already exhausted at this point. Future _onDownloadFailed(String globalKey, String errorMessage) async { + if (_cancellingKeys.contains(globalKey)) { + appLogger.d('Ignoring failure for $globalKey: cancellation in progress'); + return; + } if (_completingKeys.contains(globalKey)) { appLogger.d('Ignoring failure event for $globalKey: completion in progress'); return; @@ -1090,11 +1276,13 @@ class DownloadManagerService { _pendingDownloadContext.remove(globalKey); final existing = await _database.getDownloadedMedia(globalKey); - if (existing?.status == DownloadStatus.completed.index) { - appLogger.d('Ignoring stale failure for completed download $globalKey'); + if (existing == null || + existing.status == DownloadStatus.completed.index || + existing.status == DownloadStatus.cancelled.index) { + appLogger.d('Ignoring stale failure for inactive download $globalKey'); return; } - final retryCount = existing?.retryCount ?? 0; + final retryCount = existing.retryCount; // DNS/connection errors fail instantly and exhaust native retries in milliseconds, // creating a retry storm. Treat them as permanent failures. @@ -1106,7 +1294,7 @@ class DownloadManagerService { final isServerError = errorMessage.contains('500 Internal Server Error'); final client = await _getClientForDownloadKey(globalKey); - final hadProgress = (existing?.downloadedBytes ?? 0) > 0; + final hadProgress = existing.downloadedBytes > 0; if (!isNetworkError && !isServerError && retryCount < _maxAppRetries && client != null) { // App-level auto-retry: schedule a fresh download after a delay. @@ -1136,6 +1324,10 @@ class DownloadManagerService { /// Handle a non-retryable failure (e.g. 404) — fail immediately without auto-retry. Future _onDownloadPermanentlyFailed(String globalKey, String errorMessage) async { + if (_cancellingKeys.contains(globalKey)) { + appLogger.d('Ignoring permanent failure for $globalKey: cancellation in progress'); + return; + } if (_completingKeys.contains(globalKey)) { appLogger.d('Ignoring permanent failure event for $globalKey: completion in progress'); return; @@ -1144,8 +1336,10 @@ class DownloadManagerService { _pendingDownloadContext.remove(globalKey); final existing = await _database.getDownloadedMedia(globalKey); - if (existing?.status == DownloadStatus.completed.index) { - appLogger.d('Ignoring stale permanent failure for completed download $globalKey'); + if (existing == null || + existing.status == DownloadStatus.completed.index || + existing.status == DownloadStatus.cancelled.index) { + appLogger.d('Ignoring stale permanent failure for inactive download $globalKey'); return; } @@ -1195,10 +1389,18 @@ class DownloadManagerService { // Fresh DB check — bail if already completed (guards against race with orphan scan) final existingCheck = await _database.getDownloadedMedia(globalKey); - if (existingCheck?.status == DownloadStatus.completed.index) { + if (_cancellingKeys.contains(globalKey) || existingCheck == null) { + appLogger.d('Download no longer active for $globalKey, skipping completion'); + return; + } + if (existingCheck.status == DownloadStatus.completed.index) { appLogger.d('Download already completed for $globalKey, skipping'); return; } + if (existingCheck.status == DownloadStatus.cancelled.index) { + appLogger.d('Download cancelled for $globalKey, skipping completion'); + return; + } final ctx = _pendingDownloadContext.remove(globalKey); @@ -1367,9 +1569,7 @@ class DownloadManagerService { final serverId = metadata.serverId!; final specs = client.resolveDownloadArtwork(metadata); - for (final spec in specs) { - await _downloadSingleArtwork(serverId, spec); - } + await _artworkService.ensureArtworkSpecs(serverId, specs); final storedThumbPath = metadata.thumbPath == null ? null : artworkStorageKey(metadata.thumbPath!); await _database.updateArtworkPaths(globalKey: globalKey, thumbPath: storedThumbPath); @@ -1386,31 +1586,7 @@ class DownloadManagerService { /// both the storage key (used to hash the local filename) and the absolute /// URL to fetch. Future _downloadSingleArtwork(String serverId, DownloadArtworkSpec spec) async { - try { - // Check if already downloaded (deduplication) - if (await _storageService.artworkExists(serverId, spec.localKey)) { - appLogger.d('Artwork already exists: ${spec.localKey}'); - return; - } - - if (spec.url.isEmpty) { - appLogger.w('Empty artwork URL for: ${spec.localKey}'); - return; - } - - final filePath = await _storageService.getArtworkPathFromThumb(serverId, spec.localKey); - final file = File(filePath); - - // Ensure parent directory exists - await file.parent.create(recursive: true); - - // Download the artwork - await _http.downloadFile(spec.url, filePath); - appLogger.i('Downloaded artwork: ${spec.localKey} -> $filePath'); - } catch (e, stack) { - appLogger.w('Failed to download artwork: ${spec.localKey}', error: e, stackTrace: stack); - // Don't throw - artwork download failures shouldn't kill the entire download - } + await _artworkService.downloadSingleArtwork(serverId, spec); } /// Download all artwork for a metadata item (public method for parent metadata) @@ -1418,9 +1594,7 @@ class DownloadManagerService { Future downloadArtworkForMetadata(MediaItem metadata, MediaServerClient client) async { if (metadata.serverId == null) return; final serverId = metadata.serverId!; - for (final spec in client.resolveDownloadArtwork(metadata)) { - await _downloadSingleArtwork(serverId, spec); - } + await _artworkService.ensureArtworkSpecs(serverId, client.resolveDownloadArtwork(metadata)); } /// Download chapter thumbnail images for a media item. Works for any @@ -1623,17 +1797,22 @@ class DownloadManagerService { /// Cancel a download Future cancelDownload(String globalKey) async { - _cancelDownloadTimers(globalKey); - final bgTaskId = await _database.getBgTaskId(globalKey); - if (bgTaskId != null) { - if (downloadsSupported) { - await FileDownloader().cancelTaskWithId(bgTaskId); + _cancellingKeys.add(globalKey); + try { + _cancelDownloadTimers(globalKey); + final bgTaskId = await _database.getBgTaskId(globalKey); + if (bgTaskId != null) { + if (downloadsSupported) { + await FileDownloader().cancelTaskWithId(bgTaskId); + } + await _database.updateBgTaskId(globalKey, null); } - await _database.updateBgTaskId(globalKey, null); + _pendingDownloadContext.remove(globalKey); + await _transitionStatus(globalKey, DownloadStatus.cancelled); + await _database.removeFromQueue(globalKey); + } finally { + _cancellingKeys.remove(globalKey); } - _pendingDownloadContext.remove(globalKey); - await _transitionStatus(globalKey, DownloadStatus.cancelled); - await _database.removeFromQueue(globalKey); } Future deleteDownload(String globalKey) async { @@ -2358,6 +2537,7 @@ class DownloadManagerService { _pendingSupplementaryDownloads.clear(); _completingKeys.clear(); _pausingKeys.clear(); + _cancellingKeys.clear(); _progressController.close(); _deletionProgressController.close(); } diff --git a/lib/utils/media_server_http_client.dart b/lib/utils/media_server_http_client.dart index 3b533102..d0ed9455 100644 --- a/lib/utils/media_server_http_client.dart +++ b/lib/utils/media_server_http_client.dart @@ -208,8 +208,21 @@ class MediaServerHttpClient { abort: requestAbort, ); + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + await streamed.stream.drain(); + throw MediaServerHttpException( + type: MediaServerHttpErrorType.unknown, + statusCode: streamed.statusCode, + requestUri: uri, + message: 'HTTP ${streamed.statusCode}', + ); + } + final file = File(filePath); - final sink = file.openWrite(); + await file.parent.create(recursive: true); + final tempFile = File('$filePath.download'); + if (await tempFile.exists()) await tempFile.delete(); + final sink = tempFile.openWrite(); try { await _withAbortOnTimeout( streamed.stream.pipe(sink), @@ -220,8 +233,16 @@ class MediaServerHttpClient { } finally { await sink.close(); } + if (await file.exists()) await file.delete(); + await tempFile.rename(filePath); } catch (e) { requestAbort.abort(); + final tempFile = File('$filePath.download'); + if (await tempFile.exists()) { + try { + await tempFile.delete(); + } catch (_) {} + } throw MediaServerHttpException.from(e, uri: uri); } finally { _activeAborts.remove(requestAbort); diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart index 400eb5b1..a23026ea 100644 --- a/lib/widgets/tv_spotlight_background.dart +++ b/lib/widgets/tv_spotlight_background.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:cached_network_image_ce/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -27,6 +29,7 @@ class TvSpotlightBackground extends StatelessWidget { final bool compact; final bool showPrimaryAction; final bool showInfo; + final String? Function(String? artworkPath)? localArtworkPathResolver; const TvSpotlightBackground({ super.key, @@ -41,6 +44,7 @@ class TvSpotlightBackground extends StatelessWidget { this.compact = false, this.showPrimaryAction = true, this.showInfo = true, + this.localArtworkPathResolver, }); double _scale(BuildContext context) => TvLayoutConstants.scaleOf(context); @@ -104,12 +108,33 @@ class TvSpotlightBackground extends StatelessWidget { final size = MediaQuery.sizeOf(context); final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); final containerAspect = size.width / size.height; - final artPath = - media.heroArt(containerAspectRatio: containerAspect) ?? - media.grandparentArtPath ?? - media.artPath ?? - media.backgroundSquarePath ?? - media.thumbPath; + final artCandidates = [ + media.heroArt(containerAspectRatio: containerAspect) ?? + media.grandparentArtPath ?? + media.artPath ?? + media.backgroundSquarePath ?? + media.thumbPath, + media.grandparentArtPath, + media.artPath, + media.backgroundSquarePath, + media.thumbPath, + ]; + for (final candidate in artCandidates) { + final localPath = localArtworkPathResolver?.call(candidate); + if (localPath != null && File(localPath).existsSync()) { + return blurArtwork( + Image.file( + File(localPath), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest), + ), + ); + } + } + + final artPath = artCandidates.firstWhere((path) => path != null && path.isNotEmpty, orElse: () => null); + final imageUrl = MediaImageHelper.getOptimizedImageUrl( client: client, thumbPath: artPath, @@ -210,6 +235,24 @@ class TvSpotlightBackground extends StatelessWidget { return SizedBox(width: logoWidth, height: logoHeight, child: _buildTitle(context, title)); } + final localLogoPath = localArtworkPathResolver?.call(logoPath); + if (localLogoPath != null && File(localLogoPath).existsSync()) { + return SizedBox( + width: logoWidth, + height: logoHeight, + child: blurArtwork( + Image.file( + File(localLogoPath), + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + errorBuilder: (context, error, stackTrace) => _buildTitle(context, title), + ), + sigma: 10, + clip: false, + ), + ); + } + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); final imageUrl = MediaImageHelper.getOptimizedImageUrl( client: client, diff --git a/test/services/download_artwork_service_test.dart b/test/services/download_artwork_service_test.dart new file mode 100644 index 00000000..97d78209 --- /dev/null +++ b/test/services/download_artwork_service_test.dart @@ -0,0 +1,184 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plezy/exceptions/media_server_exceptions.dart'; +import 'package:plezy/media/download_resolution.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/services/download_artwork_helpers.dart'; +import 'package:plezy/services/download_artwork_service.dart'; +import 'package:plezy/services/download_storage_service.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import '../test_helpers/prefs.dart'; + +class _FakePathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin { + _FakePathProvider(this.root); + + final Directory root; + + @override + Future getApplicationDocumentsPath() async => _ensure('documents'); + + @override + Future getApplicationSupportPath() async => _ensure('support'); + + @override + Future getApplicationCachePath() async => _ensure('cache'); + + @override + Future getTemporaryPath() async => _ensure('temp'); + + String _ensure(String name) { + final path = p.join(root.path, name); + Directory(path).createSync(recursive: true); + return path; + } +} + +class _FakeHttpClient extends http.BaseClient { + _FakeHttpClient(this.statusCode, this.body); + + final int statusCode; + final List body; + + @override + Future send(http.BaseRequest request) async { + return http.StreamedResponse(Stream>.value(body), statusCode, request: request); + } +} + +class _DelayedCountingHttpClient extends http.BaseClient { + _DelayedCountingHttpClient(this.body); + + final List body; + final release = Completer(); + int sends = 0; + + @override + Future send(http.BaseRequest request) async { + sends++; + await release.future; + return http.StreamedResponse(Stream>.value(body), 200, request: request); + } +} + +void main() { + late Directory tmpRoot; + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + DownloadStorageService.resetForTesting(); + tmpRoot = await Directory.systemTemp.createTemp('download_artwork_service_test_'); + PathProviderPlatform.instance = _FakePathProvider(tmpRoot); + }); + + tearDown(() async { + DownloadStorageService.resetForTesting(); + SettingsService.resetForTesting(); + if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true); + }); + + test('buildArtworkSpecs includes all standard artwork with sanitized local keys', () { + final item = MediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: 'srv', + thumbPath: 'https://jf/Items/1/Images/Primary?tag=p&api_key=secret', + clearLogoPath: 'https://jf/Items/1/Images/Logo?tag=l&api_key=secret', + artPath: 'https://jf/Items/1/Images/Backdrop/0?tag=b&api_key=secret', + backgroundSquarePath: 'https://jf/Items/1/Images/Thumb?tag=s&api_key=secret', + ); + + final specs = buildArtworkSpecs(item, (path) => path); + + expect(specs, hasLength(4)); + expect(specs.map((spec) => spec.localKey), everyElement(isNot(contains('api_key')))); + expect(specs.map((spec) => spec.url), everyElement(contains('api_key=secret'))); + }); + + test('local paths normalize tokenized Jellyfin URLs', () async { + final settings = await SettingsService.getInstance(); + final storage = DownloadStorageService.instance; + await storage.initialize(settings); + final service = DownloadArtworkService( + storageService: storage, + http: MediaServerHttpClient(client: _FakeHttpClient(200, utf8.encode('image'))), + ); + + const tokenized = 'https://jf/Items/1/Images/Logo?tag=abc&api_key=secret'; + const sanitized = 'https://jf/Items/1/Images/Logo?tag=abc'; + + expect(await service.localPath('srv', tokenized), await service.localPath('srv', sanitized)); + }); + + test('downloadFile rejects non-success responses without leaving final files', () async { + final file = File(p.join(tmpRoot.path, 'art.jpg')); + final httpClient = MediaServerHttpClient(client: _FakeHttpClient(404, utf8.encode('not found'))); + + await expectLater( + httpClient.downloadFile('https://example.test/art.jpg', file.path), + throwsA(isA()), + ); + + expect(file.existsSync(), isFalse); + expect(File('${file.path}.download').existsSync(), isFalse); + }); + + test('downloadSingleArtwork replaces unusable existing files', () async { + final settings = await SettingsService.getInstance(); + final storage = DownloadStorageService.instance; + await storage.initialize(settings); + final body = utf8.encode('valid image bytes'); + final service = DownloadArtworkService( + storageService: storage, + http: MediaServerHttpClient(client: _FakeHttpClient(200, body)), + ); + + const rawPath = 'https://jf/Items/1/Images/Logo?tag=abc&api_key=secret'; + final filePath = await service.localPath('srv', rawPath); + await File(filePath).writeAsString('not an image'); + + await service.downloadSingleArtwork( + 'srv', + DownloadArtworkSpec(localKey: artworkStorageKey(rawPath), url: 'https://example.test/logo.png'), + ); + + expect(await File(filePath).readAsBytes(), body); + expect(await service.existsUsable('srv', rawPath), isTrue); + }); + + test('downloadSingleArtwork serializes duplicate writes to the same local file', () async { + final settings = await SettingsService.getInstance(); + final storage = DownloadStorageService.instance; + await storage.initialize(settings); + final httpClient = _DelayedCountingHttpClient(utf8.encode('valid image bytes')); + final service = DownloadArtworkService( + storageService: storage, + http: MediaServerHttpClient(client: httpClient), + ); + const rawPath = 'https://jf/Items/1/Images/Logo?tag=abc&api_key=secret'; + final spec = DownloadArtworkSpec(localKey: artworkStorageKey(rawPath), url: 'https://example.test/logo.png'); + + final first = service.downloadSingleArtwork('srv', spec); + await Future.delayed(Duration.zero); + final second = service.downloadSingleArtwork('srv', spec); + await Future.delayed(Duration.zero); + httpClient.release.complete(); + + await Future.wait([first, second]); + + expect(httpClient.sends, 1); + expect(await service.existsUsable('srv', rawPath), isTrue); + }); +} diff --git a/test/services/download_manager_service_test.dart b/test/services/download_manager_service_test.dart index 4ef245d1..6912da17 100644 --- a/test/services/download_manager_service_test.dart +++ b/test/services/download_manager_service_test.dart @@ -1,19 +1,31 @@ import 'dart:convert'; +import 'dart:io'; import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:plezy/database/app_database.dart'; +import 'package:plezy/database/download_operations.dart'; +import 'package:plezy/media/download_resolution.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/models/download_models.dart'; import 'package:plezy/services/download_artwork_helpers.dart'; +import 'package:plezy/services/download_artwork_service.dart'; import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import '../test_helpers/prefs.dart'; void main() { group('downloadExtensionFromUrl', () { @@ -158,6 +170,97 @@ void main() { expect(await JellyfinApiCache.instance.get('jf-machine/user-a', '/Users/user-a/Items/item-1'), isNull); expect(await JellyfinApiCache.instance.get('jf-machine/user-a', '/MediaSegments/item-1'), isNull); }); + + test('artwork repair fetches full parent metadata and backfills thumb path', () async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + DownloadStorageService.resetForTesting(); + final tmpRoot = await Directory.systemTemp.createTemp('download_manager_artwork_repair_test_'); + PathProviderPlatform.instance = _FakePathProvider(tmpRoot); + addTearDown(() async { + DownloadStorageService.resetForTesting(); + SettingsService.resetForTesting(); + if (await tmpRoot.exists()) await tmpRoot.delete(recursive: true); + }); + + final settings = await SettingsService.getInstance(); + final storage = DownloadStorageService.instance; + await storage.initialize(settings); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + addTearDown(db.close); + + await db + .into(db.downloadedMedia) + .insert( + DownloadedMediaCompanion.insert( + serverId: 'srv', + ratingKey: 'ep-1', + globalKey: 'srv:ep-1', + type: 'episode', + parentRatingKey: const Value('season-1'), + grandparentRatingKey: const Value('show-1'), + status: DownloadStatus.completed.index, + ), + ); + await PlexApiCache.instance.put('srv', '/library/metadata/ep-1', { + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': 'ep-1', + 'type': 'episode', + 'title': 'Episode', + 'thumb': '/ep-thumb', + 'parentRatingKey': 'season-1', + 'parentTitle': 'Season 1', + 'parentIndex': 1, + 'grandparentRatingKey': 'show-1', + 'grandparentTitle': 'Show', + }, + ], + }, + }); + await PlexApiCache.instance.put('srv', '/library/metadata/show-1', { + 'MediaContainer': { + 'Metadata': [ + {'ratingKey': 'show-1', 'type': 'show', 'title': 'Show', 'thumb': '/show-thumb'}, + ], + }, + }); + + final client = _ArtworkRepairClient( + serverId: 'srv', + items: { + 'show-1': MediaItem( + id: 'show-1', + backend: MediaBackend.plex, + kind: MediaKind.show, + serverId: 'srv', + title: 'Show', + thumbPath: '/show-thumb', + clearLogoPath: '/show-logo', + artPath: '/show-art', + backgroundSquarePath: '/show-square', + ), + }, + ); + final manager = DownloadManagerService( + database: db, + storageService: storage, + http: MediaServerHttpClient(client: _FakeHttpClient(200, utf8.encode('image bytes'))), + )..setClientResolver((serverId, {clientScopeId}) => client); + + await manager.repairMissingArtworkForDownloads(); + + expect(client.fetchCounts['show-1'], isNotNull); + expect(client.fetchCounts['show-1']!, greaterThan(0)); + final logoPath = DownloadArtworkService.localPathSync(storage, 'srv', '/show-logo'); + expect(logoPath, isNotNull); + expect(File(logoPath!).existsSync(), isTrue); + final row = await db.getDownloadedMedia('srv:ep-1'); + expect(row?.thumbPath, artworkStorageKey('/ep-thumb')); + }); }); } @@ -186,3 +289,69 @@ class _ScopedJellyfinClient implements MediaServerClient, ScopedMediaServerClien @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } + +class _FakePathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin { + _FakePathProvider(this.root); + + final Directory root; + + @override + Future getApplicationDocumentsPath() async => _ensure('documents'); + + @override + Future getApplicationSupportPath() async => _ensure('support'); + + @override + Future getApplicationCachePath() async => _ensure('cache'); + + @override + Future getTemporaryPath() async => _ensure('temp'); + + String _ensure(String name) { + final path = p.join(root.path, name); + Directory(path).createSync(recursive: true); + return path; + } +} + +class _FakeHttpClient extends http.BaseClient { + _FakeHttpClient(this.statusCode, this.body); + + final int statusCode; + final List body; + + @override + Future send(http.BaseRequest request) async { + return http.StreamedResponse(Stream>.value(body), statusCode, request: request); + } +} + +class _ArtworkRepairClient implements MediaServerClient { + _ArtworkRepairClient({required this.serverId, required this.items}); + + @override + final String serverId; + + final Map items; + final fetchCounts = {}; + + @override + String? get serverName => 'Server'; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + Future fetchItem(String id) async { + fetchCounts[id] = (fetchCounts[id] ?? 0) + 1; + return items[id]; + } + + @override + List resolveDownloadArtwork(MediaItem item) { + return buildArtworkSpecs(item, (path) => 'https://example.test$path'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +}