fix(downloads): repair offline artwork
This commit is contained in:
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user