import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/motion_artwork_download.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; typedef MotionArtworkAlbum = ({String album, String artist}); class MotionArtwork { const MotionArtwork(this.source, {this.aspectRatio}); final String source; final double? aspectRatio; } /// Durable, album-scoped artwork. Relative filenames survive iOS reinstalls /// that relocate the app container; temporary cache cleanup does not remove it. class MotionArtworkStore { MotionArtworkStore({ Future Function()? directory, Future Function(String source, String output)? download, Future Function(String path)? validate, }) : _directory = directory ?? defaultDirectory, _download = download ?? _downloadVideo, _validate = validate ?? _validateVideo; final Future Function() _directory; final Future Function(String source, String output) _download; final Future Function(String path) _validate; final _pending = >{}; final _legacyChecks = >{}; final _invalid = {}; bool _clearing = false; static final _log = AppLogger('MotionArtwork'); static const _cacheVersion = 2; static Future defaultDirectory() async => Directory( '${(await getApplicationSupportDirectory()).path}/motion_artwork', ); String _key(MotionArtworkAlbum album) { String normalize(String value) => value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); return sha256 .convert( utf8.encode( jsonEncode([normalize(album.album), normalize(album.artist)]), ), ) .toString(); } Future find(MotionArtworkAlbum album) async { if (_clearing) return null; try { final root = await _directory(); final key = _key(album); if (_invalid.contains(key)) return null; final file = File('${root.path}/$key.mp4'); if (!await file.exists() || await file.length() == 0) return null; double? ratio; var version = 0; final info = File('${root.path}/$key.json'); if (await info.exists()) { final data = jsonDecode(await info.readAsString()); if (data is Map) { if (data['version'] is int) version = data['version'] as int; if (data['aspectRatio'] is num) { final value = (data['aspectRatio'] as num).toDouble(); if (value.isFinite && value > 0) ratio = value; } } } // Older downloads could report success with corrupt HLS byte ranges. // Check those once per session; retain healthy covers for offline use. if (version != _cacheVersion && !await _legacyChecks.putIfAbsent(key, () async { try { return await _validate(file.path); } catch (_) { return false; } })) { _invalid.add(key); _log.w('Saved motion artwork failed validation; repair required'); return null; } return MotionArtwork(file.uri.toString(), aspectRatio: ratio); } catch (_) { return null; } } Future save( MotionArtworkAlbum album, { required Future Function() resolveSource, }) { if (_clearing || album.album.trim().isEmpty || album.artist.trim().isEmpty) { return Future.value(); } final key = _key(album); return _pending.putIfAbsent(key, () async { try { return await _save(album, key, resolveSource); } finally { _pending.remove(key); } }); } Future clear() async { _clearing = true; try { await Future.wait(_pending.values.toList()); await Future.wait(_legacyChecks.values.toList()); final root = await _directory(); if (await root.exists()) await root.delete(recursive: true); _legacyChecks.clear(); _invalid.clear(); } finally { _clearing = false; } } Future _save( MotionArtworkAlbum album, String key, Future Function() resolveSource, ) async { File? temporary; File? infoTemporary; try { final existing = await find(album); if (existing != null) return existing; final source = normalizeRemoteHttpUrl(await resolveSource()); if (source == null) return null; final root = await _directory(); await root.create(recursive: true); temporary = File('${root.path}/$key.partial.mp4'); final ratio = await _download(source, temporary.path); if (ratio == null || !ratio.isFinite || ratio <= 0 || !await temporary.exists() || await temporary.length() == 0 || !await _validate(temporary.path)) { return null; } infoTemporary = File('${root.path}/$key.partial.json'); await infoTemporary.writeAsString( jsonEncode({'version': _cacheVersion, 'aspectRatio': ratio}), flush: true, ); final file = await temporary.rename('${root.path}/$key.mp4'); await infoTemporary.rename('${root.path}/$key.json'); _invalid.remove(key); _legacyChecks.remove(key); return MotionArtwork(file.uri.toString(), aspectRatio: ratio); } catch (error) { _log.w('Could not save optional motion artwork: ${error.runtimeType}'); return null; } finally { for (final file in [temporary, infoTemporary]) { try { if (file != null && await file.exists()) await file.delete(); } catch (_) {} } } } static Future _downloadVideo(String source, String output) async { final original = await downloadMotionArtworkSource( source, '$output.source.mp4', ); try { return await _remuxVideo(original?.path ?? source, output); } finally { if (original != null && await original.exists()) await original.delete(); } } static Future _remuxVideo(String source, String output) async { // Remux public video/HLS into a self-contained, silent MP4. Bound both // transfer time and output size; never let optional artwork block audio. final success = await _run([ '-y', '-protocol_whitelist', 'file,http,https,tcp,tls,crypto', '-rw_timeout', '10000000', // HLS byte ranges can share one MP4 URL. Reusing/prefetching HTTP // connections can splice the next range into an unfinished segment. if (Uri.parse(source).path.toLowerCase().endsWith('.m3u8')) ...[ '-http_multiple', '0', '-http_persistent', '0', ], '-i', source, '-map', '0:v:0', '-an', '-sn', '-dn', '-c:v', 'copy', '-t', '30', '-fs', '25165824', '-movflags', '+faststart', output, ], timeout: const Duration(seconds: 60)); if (!success) return null; final dimensions = await FFmpegService.probeImageDimensions(output); return dimensions == null ? null : dimensions.width / dimensions.height; } static Future _validateVideo(String path) => _run([ '-v', 'error', '-xerror', '-err_detect', 'explode', '-i', path, '-map', '0:v:0', '-an', '-sn', '-dn', '-f', 'null', '-', ], timeout: const Duration(seconds: 20)); static Future _run( List arguments, { required Duration timeout, }) async { final completed = Completer(); final session = await FFmpegKit.executeWithArgumentsAsync(arguments, ( session, ) async { final success = ReturnCode.isSuccess(await session.getReturnCode()); if (!completed.isCompleted) completed.complete(success); }); return completed.future.timeout( timeout, onTimeout: () async { await FFmpegKit.cancel(session.getSessionId()); // Wait for this session to release its file before removing partials. await completed.future.timeout( const Duration(seconds: 5), onTimeout: () => false, ); return false; }, ); } }