Files
SpotiFLAC-Mobile/lib/services/motion_artwork_store.dart
T

274 lines
8.5 KiB
Dart

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<Directory> Function()? directory,
Future<double?> Function(String source, String output)? download,
Future<bool> Function(String path)? validate,
}) : _directory = directory ?? defaultDirectory,
_download = download ?? _downloadVideo,
_validate = validate ?? _validateVideo;
final Future<Directory> Function() _directory;
final Future<double?> Function(String source, String output) _download;
final Future<bool> Function(String path) _validate;
final _pending = <String, Future<MotionArtwork?>>{};
final _legacyChecks = <String, Future<bool>>{};
final _invalid = <String>{};
bool _clearing = false;
static final _log = AppLogger('MotionArtwork');
static const _cacheVersion = 2;
static Future<Directory> 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<MotionArtwork?> 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<MotionArtwork?> save(
MotionArtworkAlbum album, {
required Future<String?> 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<void> 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<MotionArtwork?> _save(
MotionArtworkAlbum album,
String key,
Future<String?> 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<double?> _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<double?> _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<bool> _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<bool> _run(
List<String> arguments, {
required Duration timeout,
}) async {
final completed = Completer<bool>();
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;
},
);
}
}