Files
plezy/lib/services/download_artwork_service.dart
T

145 lines
4.9 KiB
Dart

import 'dart:io';
import '../media/ids.dart';
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<bool> 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, ServerId serverId, String? pathOrUrl) {
if (pathOrUrl == null || pathOrUrl.isEmpty) return null;
return storageService.getArtworkPathSync(serverId, normalizeKey(pathOrUrl));
}
Future<String> localPath(ServerId serverId, String pathOrUrl) {
return storageService.getArtworkPathFromThumb(serverId, normalizeKey(pathOrUrl));
}
Future<bool> existsUsable(ServerId serverId, String pathOrUrl) async {
final file = File(await localPath(serverId, pathOrUrl));
return isUsableArtworkFile(file);
}
Future<bool> hasMissingArtwork(ServerId serverId, Iterable<DownloadArtworkSpec> specs) async {
for (final spec in specs) {
if (!await existsUsable(serverId, spec.localKey)) return true;
}
return false;
}
Future<bool> ensureArtworkForMetadata(MediaItem metadata, MediaServerClient client) async {
final serverId = metadata.serverId;
if (serverId == null) return false;
return ensureArtworkSpecs(ServerId(serverId), client.resolveDownloadArtwork(metadata));
}
Future<bool> ensureArtworkSpecs(ServerId serverId, Iterable<DownloadArtworkSpec> specs) async {
var allSettled = true;
for (final spec in specs) {
if (!await downloadSingleArtwork(serverId, spec)) allSettled = false;
}
return allSettled;
}
/// 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<bool> downloadSingleArtwork(ServerId serverId, DownloadArtworkSpec spec) async {
if (spec.url.isEmpty) {
appLogger.w('Empty artwork URL for: ${spec.localKey}');
return false;
}
final filePath = await localPath(serverId, spec.localKey);
final inFlight = _downloadsByPath[filePath];
if (inFlight != null) return inFlight.future;
final operation = _ArtworkDownloadOperation(_downloadSingleArtworkToPath(serverId, spec, filePath));
_downloadsByPath[filePath] = operation;
try {
return await operation.future;
} finally {
if (identical(_downloadsByPath[filePath], operation)) {
_downloadsByPath.remove(filePath);
}
}
}
Future<bool> _downloadSingleArtworkToPath(ServerId serverId, DownloadArtworkSpec spec, String filePath) async {
try {
if (await existsUsable(serverId, spec.localKey)) {
appLogger.d('Artwork already exists: ${spec.localKey}');
return true;
}
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 false;
}
appLogger.i('Downloaded artwork: ${spec.localKey} -> $filePath');
return true;
} catch (e, stack) {
appLogger.w('Failed to download artwork: ${spec.localKey}', error: e, stackTrace: stack);
return false;
}
}
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;
}
}
}