145 lines
4.9 KiB
Dart
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;
|
|
}
|
|
}
|
|
}
|