mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 06:28:27 +02:00
feat(network): enable audio and hi-res analysis for network tracks
This commit is contained in:
1 parent
49aeedbdcf
commit
e22e934525
8 files changed
+705
-8
No files matched your search
@@ -0,0 +1,229 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/services/hires_check_service.dart';
|
||||
import 'package:spotiflac_android/services/network_storage_service.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
|
||||
import 'package:spotiflac_android/widgets/hires_check_card.dart';
|
||||
|
||||
Uint8List _hiResWav() {
|
||||
const sampleRate = 96000;
|
||||
const channels = 2;
|
||||
const seconds = 5;
|
||||
const frameBytes = channels * 3;
|
||||
final bytes = ByteData(44 + sampleRate * seconds * frameBytes);
|
||||
void text(int offset, String value) {
|
||||
for (var i = 0; i < value.length; i++) {
|
||||
bytes.setUint8(offset + i, value.codeUnitAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
text(0, 'RIFF');
|
||||
bytes.setUint32(4, bytes.lengthInBytes - 8, Endian.little);
|
||||
text(8, 'WAVEfmt ');
|
||||
bytes.setUint32(16, 16, Endian.little);
|
||||
bytes.setUint16(20, 1, Endian.little);
|
||||
bytes.setUint16(22, channels, Endian.little);
|
||||
bytes.setUint32(24, sampleRate, Endian.little);
|
||||
bytes.setUint32(28, sampleRate * frameBytes, Endian.little);
|
||||
bytes.setUint16(32, frameBytes, Endian.little);
|
||||
bytes.setUint16(34, 24, Endian.little);
|
||||
text(36, 'data');
|
||||
bytes.setUint32(40, bytes.lengthInBytes - 44, Endian.little);
|
||||
for (var frame = 0; frame < sampleRate * seconds; frame++) {
|
||||
for (var channel = 0; channel < channels; channel++) {
|
||||
final value =
|
||||
(3000000 *
|
||||
math.sin(
|
||||
2 * math.pi * (1000 + 23000 * channel) * frame / sampleRate,
|
||||
))
|
||||
.round();
|
||||
final offset = 44 + frame * frameBytes + channel * 3;
|
||||
bytes.setUint8(offset, value & 255);
|
||||
bytes.setUint8(offset + 1, (value >> 8) & 255);
|
||||
bytes.setUint8(offset + 2, (value >> 16) & 255);
|
||||
}
|
||||
}
|
||||
return bytes.buffer.asUint8List();
|
||||
}
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
const smbAddress = String.fromEnvironment('SMB_TEST_URL');
|
||||
testWidgets('network cards run real FFmpeg and Rust analysers', (
|
||||
tester,
|
||||
) async {
|
||||
final local = await Directory.systemTemp.createTemp('network-reference-');
|
||||
var file = File('${local.path}/reference.wav');
|
||||
await file.writeAsBytes(_hiResWav());
|
||||
if (smbAddress.isEmpty) {
|
||||
final flac = File('${local.path}/reference.flac');
|
||||
final session = await FFmpegKit.executeWithArguments([
|
||||
'-i',
|
||||
file.path,
|
||||
'-c:a',
|
||||
'flac',
|
||||
flac.path,
|
||||
]);
|
||||
expect(ReturnCode.isSuccess(await session.getReturnCode()), true);
|
||||
file = flac;
|
||||
}
|
||||
final audio = await file.readAsBytes();
|
||||
final reference = await HiResCheckService.check(file.path);
|
||||
final storage = NetworkStorageService.instance;
|
||||
final id = 'analysis-fixture-${DateTime.now().microsecondsSinceEpoch}';
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
var fullReads = 0;
|
||||
final requests = <({String method, String? authorization})>[];
|
||||
server.listen((request) async {
|
||||
requests.add((
|
||||
method: request.method,
|
||||
authorization: request.headers.value(HttpHeaders.authorizationHeader),
|
||||
));
|
||||
request.response.contentLength = audio.length;
|
||||
if (request.method == 'GET') {
|
||||
fullReads++;
|
||||
request.response.add(audio);
|
||||
}
|
||||
await request.response.close();
|
||||
});
|
||||
try {
|
||||
await storage.save(
|
||||
NetworkConnection(
|
||||
id: id,
|
||||
name: 'Analysis fixture',
|
||||
protocol: smbAddress.isEmpty
|
||||
? NetworkProtocol.webdav
|
||||
: NetworkProtocol.smb,
|
||||
address: smbAddress.isEmpty
|
||||
? 'http://127.0.0.1:${server.port}/'
|
||||
: smbAddress,
|
||||
username: 'test',
|
||||
password: 'secret',
|
||||
),
|
||||
);
|
||||
final source = NetworkStorageService.source(
|
||||
id,
|
||||
smbAddress.isEmpty ? 'Rock & Roll.flac' : 'A B.wav',
|
||||
);
|
||||
final remote = await HiResCheckService.check(source);
|
||||
expect(remote.supported, true);
|
||||
expect(remote.declaredSampleRate, reference.declaredSampleRate);
|
||||
expect(remote.declaredBitDepth, reference.declaredBitDepth);
|
||||
expect(remote.effectiveBitDepth, reference.effectiveBitDepth);
|
||||
expect(remote.cutoffFrequencyHz, reference.cutoffFrequencyHz);
|
||||
expect(remote.verdict, reference.verdict);
|
||||
fullReads = 0;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeData.dark(useMaterial3: true),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
locale: const Locale('en'),
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
HiResCheckCard(filePath: source),
|
||||
AudioAnalysisCard(filePath: source),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(fullReads, 0, reason: 'opening cards must not fetch audio');
|
||||
await tester.tap(find.text('Check'));
|
||||
for (
|
||||
var i = 0;
|
||||
i < 300 && find.text('96.0 kHz').evaluate().isEmpty;
|
||||
i++
|
||||
) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
expect(find.text('96.0 kHz'), findsOneWidget);
|
||||
if (smbAddress.isEmpty) expect(fullReads, 1);
|
||||
await tester.ensureVisible(find.byType(AudioAnalysisCard));
|
||||
await tester.tap(find.text('Audio Quality Analysis'));
|
||||
for (var i = 0; i < 600 && find.text('24-bit').evaluate().isEmpty; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
expect(find.text('24-bit'), findsOneWidget);
|
||||
expect(find.text('96.0 kHz'), findsNWidgets(2));
|
||||
expect(find.text('Dynamic Range: '), findsOneWidget);
|
||||
expect(find.text('Ch 1'), findsOneWidget);
|
||||
if (smbAddress.isEmpty) expect(fullReads, 2);
|
||||
await tester.ensureVisible(find.text('Ch 1'));
|
||||
await tester.tap(find.text('Ch 1'));
|
||||
await tester.pump();
|
||||
for (
|
||||
var i = 0;
|
||||
i < 600 && find.byType(LinearProgressIndicator).evaluate().isNotEmpty;
|
||||
i++
|
||||
) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
if (smbAddress.isEmpty) expect(fullReads, 3);
|
||||
expect(requests.every((r) => ['GET', 'HEAD'].contains(r.method)), true);
|
||||
expect(
|
||||
requests.every(
|
||||
(r) =>
|
||||
r.authorization ==
|
||||
'Basic ${base64Encode(utf8.encode('test:secret'))}',
|
||||
),
|
||||
true,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
locale: const Locale('en'),
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: AudioAnalysisCard(filePath: source),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
for (var i = 0; i < 300 && find.text('24-bit').evaluate().isEmpty; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
expect(find.text('24-bit'), findsOneWidget);
|
||||
expect(find.text('Ch 1'), findsOneWidget);
|
||||
if (smbAddress.isEmpty) {
|
||||
expect(fullReads, 3, reason: 'reopen uses cached analysis');
|
||||
}
|
||||
final temporary = await getTemporaryDirectory();
|
||||
expect(
|
||||
await temporary
|
||||
.list()
|
||||
.where(
|
||||
(entry) => entry.uri.pathSegments
|
||||
.where((part) => part.isNotEmpty)
|
||||
.last
|
||||
.startsWith('network-analysis-'),
|
||||
)
|
||||
.toList(),
|
||||
isEmpty,
|
||||
);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
} finally {
|
||||
await storage.remove(id);
|
||||
await server.close(force: true);
|
||||
await local.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -91,7 +91,7 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
|
||||
|
||||
_buildLyricsCard(context, colorScheme),
|
||||
|
||||
if (_fileExists && !_isNetworkItem) ...[
|
||||
if (_fileExists) ...[
|
||||
const SizedBox(height: 16),
|
||||
AudioAnalysisCard(
|
||||
filePath: _filePath,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/network_audio_analysis_input.dart';
|
||||
|
||||
class HiResOriginalRestoreException implements Exception {
|
||||
const HiResOriginalRestoreException(this.backupPath, this.cause);
|
||||
@@ -111,7 +112,16 @@ class HiResCheckService {
|
||||
|
||||
/// Runs the check; throws on failure. `supported == false` for formats
|
||||
/// the Rust checker cannot decode (anything but FLAC and PCM WAV).
|
||||
static Future<HiResCheckResult> check(String filePath) async {
|
||||
static Future<HiResCheckResult> check(
|
||||
String filePath, {
|
||||
NetworkAudioAnalysisInput? networkInput,
|
||||
}) async {
|
||||
if (NetworkAudioAnalysisInput.isNetwork(filePath)) {
|
||||
return (networkInput ?? NetworkAudioAnalysisInput()).withFile(
|
||||
filePath,
|
||||
(path) => check(path),
|
||||
);
|
||||
}
|
||||
final json = await PlatformBridge.checkHiResAuthenticity(filePath);
|
||||
final error = json['error'];
|
||||
if (error != null) throw StateError(error.toString());
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/services/audio_analysis_jobs.dart';
|
||||
import 'package:spotiflac_android/services/network_storage_service.dart';
|
||||
|
||||
/// Supplies seekable, unmodified audio to the existing analysers. Credentials
|
||||
/// stay in the storage proxy; only an owned temporary file reaches the decoder.
|
||||
class NetworkAudioAnalysisInput {
|
||||
NetworkAudioAnalysisInput({
|
||||
NetworkStorageService? storage,
|
||||
Future<Directory> Function()? temporaryDirectory,
|
||||
this.timeout = const Duration(seconds: 20),
|
||||
}) : _storage = storage ?? NetworkStorageService.instance,
|
||||
_temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory;
|
||||
|
||||
final NetworkStorageService _storage;
|
||||
final Future<Directory> Function() _temporaryDirectory;
|
||||
final Duration timeout;
|
||||
final Set<HttpClient> _clients = {};
|
||||
int _generation = 0;
|
||||
|
||||
static bool isNetwork(String source) => source.startsWith('network://');
|
||||
|
||||
/// Cancels only transfers owned by this analyser, leaving playback intact.
|
||||
void cancel() {
|
||||
_generation++;
|
||||
for (final client in _clients) {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> _withResponse<T>(
|
||||
String source,
|
||||
String method,
|
||||
Future<T> Function(HttpClientResponse, void Function()) consume,
|
||||
) async {
|
||||
final generation = _generation;
|
||||
void checkpoint() {
|
||||
if (generation != _generation) throw const AudioAnalysisCancelled();
|
||||
}
|
||||
|
||||
final client = HttpClient()
|
||||
..connectionTimeout = timeout
|
||||
..autoUncompress = false;
|
||||
_clients.add(client);
|
||||
try {
|
||||
final url = await _storage.resolve(source);
|
||||
checkpoint();
|
||||
final request = await client
|
||||
.openUrl(method, Uri.parse(url))
|
||||
.timeout(timeout);
|
||||
request.followRedirects = false;
|
||||
request.headers.set(HttpHeaders.acceptEncodingHeader, 'identity');
|
||||
checkpoint();
|
||||
final response = await request.close().timeout(timeout);
|
||||
checkpoint();
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw HttpException('Audio server returned ${response.statusCode}');
|
||||
}
|
||||
return await consume(response, checkpoint);
|
||||
} catch (_) {
|
||||
checkpoint();
|
||||
rethrow;
|
||||
} finally {
|
||||
_clients.remove(client);
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> length(String source) =>
|
||||
_withResponse(source, 'HEAD', (response, checkpoint) async {
|
||||
await response.drain<void>().timeout(timeout);
|
||||
checkpoint();
|
||||
return response.contentLength;
|
||||
});
|
||||
|
||||
/// Streams to disk with backpressure, then retains the file until all native
|
||||
/// reads finish. A partial/failed/cancelled transfer never reaches analysis.
|
||||
Future<T> withFile<T>(
|
||||
String source,
|
||||
Future<T> Function(String path) analyze,
|
||||
) => _withResponse(source, 'GET', (response, checkpoint) async {
|
||||
Directory? directory;
|
||||
try {
|
||||
final root = await _temporaryDirectory();
|
||||
checkpoint();
|
||||
directory = await root.createTemp('network-analysis-');
|
||||
checkpoint();
|
||||
final name = Uri.parse(source).pathSegments.last;
|
||||
final file = File(p.join(directory.path, 'audio${p.extension(name)}'));
|
||||
var received = 0;
|
||||
final sink = file.openWrite();
|
||||
try {
|
||||
await sink.addStream(
|
||||
response.timeout(timeout).map((chunk) {
|
||||
checkpoint();
|
||||
received += chunk.length;
|
||||
return chunk;
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await sink.close();
|
||||
}
|
||||
checkpoint();
|
||||
if (received == 0 ||
|
||||
(response.contentLength >= 0 && received != response.contentLength)) {
|
||||
throw const HttpException('Incomplete network audio file');
|
||||
}
|
||||
final result = await analyze(file.path);
|
||||
checkpoint();
|
||||
return result;
|
||||
} finally {
|
||||
if (directory != null) {
|
||||
try {
|
||||
await directory.delete(recursive: true);
|
||||
} on FileSystemException {
|
||||
// Temporary cleanup must not replace the analysis result or error.
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/services/audio_analysis_jobs.dart';
|
||||
import 'package:spotiflac_android/services/audio_analysis_cache.dart';
|
||||
import 'package:spotiflac_android/services/network_audio_analysis_input.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/widgets/app_content_card.dart';
|
||||
import 'package:spotiflac_android/widgets/app_choice_chip.dart';
|
||||
@@ -609,6 +610,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
bool _spectrogramChannelLoading = false;
|
||||
int _spectrogramRequestId = 0;
|
||||
String? _unsupportedCodec;
|
||||
final _networkInput = NetworkAudioAnalysisInput();
|
||||
final _analysisJobs = AudioAnalysisJobs<FFmpegSession>(
|
||||
start: (arguments) async {
|
||||
final completed = Completer<FFmpegSession>();
|
||||
@@ -697,6 +699,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
|
||||
_spectrogramRequestId++;
|
||||
_analysisJobs.invalidate();
|
||||
_networkInput.cancel();
|
||||
_cancelNativeAnalysis();
|
||||
_spectrogramImage?.dispose();
|
||||
_spectrogramImage = null;
|
||||
@@ -717,6 +720,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
void dispose() {
|
||||
_spectrogramRequestId++;
|
||||
_analysisJobs.dispose();
|
||||
_networkInput.cancel();
|
||||
_cancelNativeAnalysis();
|
||||
_spectrogramImage?.dispose();
|
||||
super.dispose();
|
||||
@@ -753,6 +757,10 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
expectedPath,
|
||||
channel: _spectrogramChannel,
|
||||
);
|
||||
if (image == null &&
|
||||
NetworkAudioAnalysisInput.isNetwork(expectedPath)) {
|
||||
return; // Opening metadata must not fetch the complete remote audio.
|
||||
}
|
||||
image ??= await _generateAndCacheSpectrogram(
|
||||
filePath: expectedPath,
|
||||
analysisData: cached,
|
||||
@@ -806,6 +814,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
final releaseCache = _cacheBudget.retain(_cacheKey(sourcePath));
|
||||
final requestId = ++_spectrogramRequestId;
|
||||
_analysisJobs.invalidate();
|
||||
_networkInput.cancel();
|
||||
_cancelNativeAnalysis();
|
||||
setState(() {
|
||||
_analyzing = true;
|
||||
@@ -938,7 +947,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<AudioAnalysisData?> _loadFromCache(String filePath) async {
|
||||
Future<AudioAnalysisData?> _loadFromCache(String filePath) async {
|
||||
try {
|
||||
final dir = await _cacheDir();
|
||||
final key = _cacheKey(filePath);
|
||||
@@ -953,7 +962,10 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
}
|
||||
final cachedSize = json['fileSize'] as int;
|
||||
|
||||
if (!filePath.startsWith('content://')) {
|
||||
if (NetworkAudioAnalysisInput.isNetwork(filePath)) {
|
||||
final currentSize = await _networkInput.length(filePath);
|
||||
if (currentSize <= 0 || currentSize != cachedSize) return null;
|
||||
} else if (!filePath.startsWith('content://')) {
|
||||
final currentSize = await File(filePath).length();
|
||||
if (currentSize != cachedSize) return null;
|
||||
} else {
|
||||
@@ -1027,9 +1039,16 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
|
||||
Future<_AudioAnalysisRunResult> _runAnalysis(
|
||||
String filePath,
|
||||
int requestId,
|
||||
) async {
|
||||
int requestId, {
|
||||
String? sourcePath,
|
||||
}) async {
|
||||
_checkAnalysisRequest(requestId);
|
||||
if (NetworkAudioAnalysisInput.isNetwork(filePath)) {
|
||||
return _networkInput.withFile(
|
||||
filePath,
|
||||
(path) => _runAnalysis(path, requestId, sourcePath: filePath),
|
||||
);
|
||||
}
|
||||
String workingPath = filePath;
|
||||
String? tempCopy;
|
||||
if (filePath.startsWith('content://')) {
|
||||
@@ -1078,7 +1097,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
|
||||
return _AudioAnalysisRunResult(
|
||||
data: AudioAnalysisData(
|
||||
filePath: filePath,
|
||||
filePath: sourcePath ?? filePath,
|
||||
fileSize: info.fileSize,
|
||||
codec: info.codec,
|
||||
container: info.container,
|
||||
@@ -1126,6 +1145,19 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
int? channels,
|
||||
}) async {
|
||||
_checkAnalysisRequest(requestId);
|
||||
if (NetworkAudioAnalysisInput.isNetwork(filePath)) {
|
||||
return _networkInput.withFile(
|
||||
filePath,
|
||||
(path) => _generateSpectrogramForFile(
|
||||
path,
|
||||
requestId: requestId,
|
||||
channel: channel,
|
||||
durationSeconds: durationSeconds,
|
||||
sampleRate: sampleRate,
|
||||
channels: channels,
|
||||
),
|
||||
);
|
||||
}
|
||||
String workingPath = filePath;
|
||||
String? tempCopy;
|
||||
if (filePath.startsWith('content://')) {
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/services/hires_check_service.dart';
|
||||
import 'package:spotiflac_android/services/audio_analysis_jobs.dart';
|
||||
import 'package:spotiflac_android/services/network_audio_analysis_input.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_icons.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/app_action_button.dart';
|
||||
@@ -32,18 +34,27 @@ class _HiResCheckCardState extends State<HiResCheckCard> {
|
||||
bool _checking = false;
|
||||
String? _error;
|
||||
int _requestId = 0;
|
||||
final _networkInput = NetworkAudioAnalysisInput();
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant HiResCheckCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.filePath != widget.filePath) {
|
||||
_requestId++;
|
||||
_networkInput.cancel();
|
||||
_result = null;
|
||||
_error = null;
|
||||
_checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_requestId++;
|
||||
_networkInput.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _check() async {
|
||||
final requestId = ++_requestId;
|
||||
setState(() {
|
||||
@@ -53,7 +64,12 @@ class _HiResCheckCardState extends State<HiResCheckCard> {
|
||||
String? error;
|
||||
HiResCheckResult? result;
|
||||
try {
|
||||
result = await HiResCheckService.check(widget.filePath);
|
||||
result = await HiResCheckService.check(
|
||||
widget.filePath,
|
||||
networkInput: _networkInput,
|
||||
);
|
||||
} on AudioAnalysisCancelled {
|
||||
return;
|
||||
} on StateError catch (e) {
|
||||
error = e.message;
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/audio_analysis_jobs.dart';
|
||||
import 'package:spotiflac_android/services/hires_check_service.dart';
|
||||
import 'package:spotiflac_android/services/network_audio_analysis_input.dart';
|
||||
import 'package:spotiflac_android/services/network_storage_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
HttpOverrides.global = null;
|
||||
late Directory temporary;
|
||||
late HttpServer server;
|
||||
late NetworkStorageService storage;
|
||||
late NetworkAudioAnalysisInput input;
|
||||
final requests = <String>[];
|
||||
const name = 'हिन्दी & Rock + #1.wav';
|
||||
final audio = List<int>.generate(128 * 1024, (i) => i % 256);
|
||||
var status = HttpStatus.ok;
|
||||
var empty = false;
|
||||
Completer<void>? release;
|
||||
Completer<void>? started;
|
||||
|
||||
setUp(() async {
|
||||
requests.clear();
|
||||
status = HttpStatus.ok;
|
||||
empty = false;
|
||||
release = null;
|
||||
started = null;
|
||||
temporary = await Directory.systemTemp.createTemp('network-analysis-test-');
|
||||
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
server.listen((request) async {
|
||||
try {
|
||||
requests.add(request.method);
|
||||
expect(request.uri.pathSegments.last, name);
|
||||
expect(
|
||||
request.headers.value(HttpHeaders.authorizationHeader),
|
||||
'Basic ${base64Encode(utf8.encode('tester:secret'))}',
|
||||
);
|
||||
request.response.statusCode = status;
|
||||
if (status != HttpStatus.ok) return;
|
||||
request.response.contentLength = empty ? 0 : audio.length;
|
||||
if (request.method == 'GET' && !empty) {
|
||||
request.response.add(audio.sublist(0, 1024));
|
||||
await request.response.flush();
|
||||
started?.complete();
|
||||
if (release != null) await release!.future;
|
||||
request.response.add(audio.sublist(1024));
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await request.response.close();
|
||||
} on IOException {
|
||||
// Cancellation intentionally closes the client mid-transfer.
|
||||
}
|
||||
}
|
||||
});
|
||||
storage = NetworkStorageService(
|
||||
read: () async => null,
|
||||
write: (_) async {},
|
||||
);
|
||||
await storage.save(
|
||||
NetworkConnection(
|
||||
id: 'fixture',
|
||||
name: 'Fixture',
|
||||
protocol: NetworkProtocol.webdav,
|
||||
address: 'http://127.0.0.1:${server.port}/music/',
|
||||
username: 'tester',
|
||||
password: 'secret',
|
||||
),
|
||||
);
|
||||
input = NetworkAudioAnalysisInput(
|
||||
storage: storage,
|
||||
temporaryDirectory: () async => temporary,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (release != null && !release!.isCompleted) release!.complete();
|
||||
input.cancel();
|
||||
await storage.dispose();
|
||||
await server.close(force: true);
|
||||
await temporary.delete(recursive: true);
|
||||
});
|
||||
|
||||
String source() => NetworkStorageService.source('fixture', name);
|
||||
|
||||
test('authenticated staging preserves bytes and is read-only', () async {
|
||||
expect(await input.length(source()), audio.length);
|
||||
String? stagedPath;
|
||||
final result = await input.withFile(source(), (path) async {
|
||||
stagedPath = path;
|
||||
expect(path.endsWith('.wav'), true);
|
||||
expect(await File(path).readAsBytes(), audio);
|
||||
// Native analysis may reopen/seek the input more than once.
|
||||
final handle = await File(path).open();
|
||||
try {
|
||||
await handle.setPosition(1234);
|
||||
expect(await handle.read(10), audio.sublist(1234, 1244));
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return 'analysed';
|
||||
});
|
||||
expect(result, 'analysed');
|
||||
expect(await File(stagedPath!).exists(), false);
|
||||
expect(await temporary.list().toList(), isEmpty);
|
||||
expect(requests, ['HEAD', 'GET']);
|
||||
});
|
||||
|
||||
test('Hi-Res checker receives the staged file, never a proxy URL', () async {
|
||||
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
expect(call.method, 'checkHiResAuthenticity');
|
||||
final path = (call.arguments as Map)['file_path'] as String;
|
||||
expect(await File(path).readAsBytes(), audio);
|
||||
return {'supported': true, 'declared_sample_rate': 96000};
|
||||
});
|
||||
addTearDown(
|
||||
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null),
|
||||
);
|
||||
final result = await HiResCheckService.check(source(), networkInput: input);
|
||||
expect(result.supported, true);
|
||||
expect(result.declaredSampleRate, 96000);
|
||||
expect(await temporary.list().toList(), isEmpty);
|
||||
});
|
||||
|
||||
test('decoder errors clean up the input', () async {
|
||||
await expectLater(
|
||||
input.withFile<void>(source(), (_) async => throw StateError('decoder')),
|
||||
throwsStateError,
|
||||
);
|
||||
expect(await temporary.list().toList(), isEmpty);
|
||||
});
|
||||
|
||||
test('server errors and empty audio never run a decoder', () async {
|
||||
var decoded = false;
|
||||
for (final code in [404, 401, 500, 206, 200]) {
|
||||
status = code;
|
||||
empty = true;
|
||||
await expectLater(
|
||||
input.withFile<void>(source(), (_) async => decoded = true),
|
||||
throwsA(isA<HttpException>()),
|
||||
);
|
||||
expect(decoded, false);
|
||||
expect(await temporary.list().toList(), isEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
test('cancelling a transfer leaves another analyser usable', () async {
|
||||
release = Completer<void>();
|
||||
started = Completer<void>();
|
||||
var decoded = false;
|
||||
final transfer = input.withFile<void>(
|
||||
source(),
|
||||
(_) async => decoded = true,
|
||||
);
|
||||
final cancelled = expectLater(
|
||||
transfer,
|
||||
throwsA(isA<AudioAnalysisCancelled>()),
|
||||
);
|
||||
await started!.future;
|
||||
input.cancel();
|
||||
await cancelled.timeout(const Duration(seconds: 2));
|
||||
expect(decoded, false);
|
||||
expect(await temporary.list().toList(), isEmpty);
|
||||
release!.complete();
|
||||
release = null;
|
||||
started = null;
|
||||
final other = NetworkAudioAnalysisInput(
|
||||
storage: storage,
|
||||
temporaryDirectory: () async => temporary,
|
||||
);
|
||||
expect(await other.length(source()), audio.length);
|
||||
expect(await input.withFile(source(), (_) async => 7), 7);
|
||||
});
|
||||
|
||||
test(
|
||||
'cancel waits for an active decoder before deleting its input',
|
||||
() async {
|
||||
final entered = Completer<void>();
|
||||
final completed = Completer<void>();
|
||||
String? stagedPath;
|
||||
final task = input.withFile<void>(source(), (path) async {
|
||||
stagedPath = path;
|
||||
entered.complete();
|
||||
await completed.future;
|
||||
expect(await File(path).exists(), true);
|
||||
});
|
||||
final cancelled = expectLater(
|
||||
task,
|
||||
throwsA(isA<AudioAnalysisCancelled>()),
|
||||
);
|
||||
await entered.future;
|
||||
input.cancel();
|
||||
expect(await File(stagedPath!).exists(), true);
|
||||
completed.complete();
|
||||
await cancelled;
|
||||
expect(await temporary.list().toList(), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('a stalled stream times out and removes partial audio', () async {
|
||||
release = Completer<void>();
|
||||
started = Completer<void>();
|
||||
final bounded = NetworkAudioAnalysisInput(
|
||||
storage: storage,
|
||||
temporaryDirectory: () async => temporary,
|
||||
timeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
var decoded = false;
|
||||
await expectLater(
|
||||
bounded.withFile<void>(source(), (_) async => decoded = true),
|
||||
throwsA(isA<TimeoutException>()),
|
||||
);
|
||||
expect(decoded, false);
|
||||
expect(await temporary.list().toList(), isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
|
||||
import 'package:spotiflac_android/widgets/hires_check_card.dart';
|
||||
|
||||
void main() {
|
||||
for (final mornye in [false, true]) {
|
||||
for (final format in ['flac', 'wav', 'mp3']) {
|
||||
testWidgets(
|
||||
'network $format exposes supported analysers (Mornye=$mornye)',
|
||||
(tester) async {
|
||||
final item = LocalLibraryItem(
|
||||
id: 'network://fixture/track.$format',
|
||||
filePath: 'network://fixture/track.$format',
|
||||
trackName: 'Network track',
|
||||
artistName: 'Artist',
|
||||
albumName: 'Album',
|
||||
scannedAt: DateTime(2026),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
theme: mornye
|
||||
? MornyeTheme.build(Brightness.dark)
|
||||
: ThemeData.dark(useMaterial3: true),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: TrackMetadataScreen(localItem: item),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.scrollUntilVisible(
|
||||
find.byType(AudioAnalysisCard),
|
||||
300,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
expect(find.byType(AudioAnalysisCard), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<AudioAnalysisCard>(find.byType(AudioAnalysisCard))
|
||||
.filePath,
|
||||
item.filePath,
|
||||
);
|
||||
expect(
|
||||
find.byType(HiResCheckCard),
|
||||
format == 'mp3' ? findsNothing : findsOneWidget,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user