fix(downloads): repair offline artwork

This commit is contained in:
edde746
2026-05-24 05:27:19 +02:00
parent a189ee5774
commit dcd0b03096
8 changed files with 928 additions and 102 deletions
+60 -15
View File
@@ -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 = <String>{};
final ensuredArtworkKeys = <String>{};
}
/// 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<void> 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<void> _fetchAndStoreParentMetadata(MediaItem episode, MediaServerClient client) async {
Future<void> _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 = <MediaItem>[];
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),
);
}
+68 -26
View File
@@ -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<MediaDetailScreen>
}) {
if (!widget.isOffline || _metadata.serverId == null) return null;
final downloadProvider = context.read<DownloadProvider>();
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<MediaDetailScreen>
return null;
}
String? _offlineArtworkLocalPath(BuildContext context, String? artworkPath) {
if (!widget.isOffline || _metadata.serverId == null) return null;
final localPath = context.read<DownloadProvider>().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<MediaDetailScreen>
// 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<MediaDetailScreen>
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<MediaDetailScreen>
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),
],
),
+142
View File
@@ -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<void> 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<String, _ArtworkDownloadOperation> _downloadsByPath = <String, _ArtworkDownloadOperation>{};
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<String> localPath(String serverId, String pathOrUrl) {
return storageService.getArtworkPathFromThumb(serverId, normalizeKey(pathOrUrl));
}
Future<bool> existsUsable(String serverId, String pathOrUrl) async {
final file = File(await localPath(serverId, pathOrUrl));
return isUsableArtworkFile(file);
}
Future<bool> hasMissingArtwork(String serverId, Iterable<DownloadArtworkSpec> specs) async {
for (final spec in specs) {
if (!await existsUsable(serverId, spec.localKey)) return true;
}
return false;
}
Future<void> ensureArtworkForMetadata(MediaItem metadata, MediaServerClient client) async {
final serverId = metadata.serverId;
if (serverId == null) return;
await ensureArtworkSpecs(serverId, client.resolveDownloadArtwork(metadata));
}
Future<void> ensureArtworkSpecs(String serverId, Iterable<DownloadArtworkSpec> 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<void> 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<void> _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<bool> 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('<!doctype html') || prefix.startsWith('<html') || prefix.startsWith('{')) {
return false;
}
return true;
} finally {
await raf.close();
}
} catch (_) {
return false;
}
}
}
+234 -54
View File
@@ -1,3 +1,5 @@
// ignore_for_file: prefer_initializing_formals
import 'dart:async';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
@@ -16,6 +18,7 @@ import '../media/media_kind.dart';
import '../media/media_server_client.dart';
import 'api_cache.dart';
import 'download_artwork_helpers.dart';
import 'download_artwork_service.dart';
import 'settings_service.dart';
import 'saf_storage_service.dart';
import 'package:saf_util/saf_util_platform_interface.dart' show SafDocumentFile;
@@ -58,6 +61,7 @@ class DownloadManagerService {
final AppDatabase _database;
final DownloadStorageService _storageService;
final MediaServerHttpClient _http;
final DownloadArtworkService _artworkService;
final bool? _downloadsSupportedOverride;
final _progressController = StreamController<DownloadProgress>.broadcast();
@@ -89,11 +93,15 @@ class DownloadManagerService {
// Keys currently being paused — prevents holding queue from promoting them
final Set<String> _pausingKeys = {};
// Keys currently being cancelled — prevents queue promotion/completion races.
final Set<String> _cancellingKeys = {};
// Keys whose completion callback is in-flight — prevents orphan scan from re-queuing them
final Set<String> _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<void> 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<void> repairMissingArtworkForDownloads() async {
if (_isRepairingArtwork || _isOffline) return;
_isRepairingArtwork = true;
try {
final rows = await _database.select(_database.downloadedMedia).get();
final ensuredParentKeys = <String>{};
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<void> _repairParentArtwork(
String serverId,
String? ratingKey,
MediaServerClient client,
Set<String> 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<void> _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<void> _processPendingSupplementaryDownloads(MediaServerClient client) async {
@@ -805,6 +927,31 @@ class DownloadManagerService {
await _database.updateDownloadProgress(globalKey, 0, 0, 0);
}
Future<bool> _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<bool> _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<bool> _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<bool> _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<void> _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<void> _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<void> _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<void> 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<void> 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<void> deleteDownload(String globalKey) async {
@@ -2358,6 +2537,7 @@ class DownloadManagerService {
_pendingSupplementaryDownloads.clear();
_completingKeys.clear();
_pausingKeys.clear();
_cancellingKeys.clear();
_progressController.close();
_deletionProgressController.close();
}
+22 -1
View File
@@ -208,8 +208,21 @@ class MediaServerHttpClient {
abort: requestAbort,
);
if (streamed.statusCode < 200 || streamed.statusCode >= 300) {
await streamed.stream.drain<void>();
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);
+49 -6
View File
@@ -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 = <String?>[
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,
@@ -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<String?> getApplicationDocumentsPath() async => _ensure('documents');
@override
Future<String?> getApplicationSupportPath() async => _ensure('support');
@override
Future<String?> getApplicationCachePath() async => _ensure('cache');
@override
Future<String?> 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<int> body;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
return http.StreamedResponse(Stream<List<int>>.value(body), statusCode, request: request);
}
}
class _DelayedCountingHttpClient extends http.BaseClient {
_DelayedCountingHttpClient(this.body);
final List<int> body;
final release = Completer<void>();
int sends = 0;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
sends++;
await release.future;
return http.StreamedResponse(Stream<List<int>>.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<MediaServerHttpException>()),
);
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('<html>not an image</html>');
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<void>.delayed(Duration.zero);
final second = service.downloadSingleArtwork('srv', spec);
await Future<void>.delayed(Duration.zero);
httpClient.release.complete();
await Future.wait([first, second]);
expect(httpClient.sends, 1);
expect(await service.existsUsable('srv', rawPath), isTrue);
});
}
@@ -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<String?> getApplicationDocumentsPath() async => _ensure('documents');
@override
Future<String?> getApplicationSupportPath() async => _ensure('support');
@override
Future<String?> getApplicationCachePath() async => _ensure('cache');
@override
Future<String?> 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<int> body;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
return http.StreamedResponse(Stream<List<int>>.value(body), statusCode, request: request);
}
}
class _ArtworkRepairClient implements MediaServerClient {
_ArtworkRepairClient({required this.serverId, required this.items});
@override
final String serverId;
final Map<String, MediaItem> items;
final fetchCounts = <String, int>{};
@override
String? get serverName => 'Server';
@override
MediaBackend get backend => MediaBackend.plex;
@override
Future<MediaItem?> fetchItem(String id) async {
fetchCounts[id] = (fetchCounts[id] ?? 0) + 1;
return items[id];
}
@override
List<DownloadArtworkSpec> resolveDownloadArtwork(MediaItem item) {
return buildArtworkSpecs(item, (path) => 'https://example.test$path');
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}