mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 06:28:27 +02:00
fix(downloads): preserve actual provider audio format
This commit is contained in:
1 parent
dc1e7c1273
commit
6325c4769c
4 files changed
+390
-20
No files matched your search
@@ -140,9 +140,11 @@ object SafDownloadHandler {
|
||||
val response = backend.downloadByStrategy(req.toString())
|
||||
val respObj = JSONObject(response)
|
||||
if (respObj.optBoolean("success", false)) {
|
||||
val actualExt = normalizeExt(respObj.optString("actual_extension", ""))
|
||||
.ifBlank { outputExt }
|
||||
val resolvedFileName = respObj.optString("resolved_file_name", "")
|
||||
.trim()
|
||||
.let { if (it.isNotEmpty()) forceFilenameExt(it, outputExt) else fileName }
|
||||
.let { forceFilenameExt(it.ifBlank { fileName }, actualExt) }
|
||||
val reportedPath = respObj.optString("file_path", "").trim()
|
||||
if (reportedPath.isEmpty() || reportedPath.startsWith("/proc/self/fd/")) {
|
||||
respObj.put("file_path", workingFile.absolutePath)
|
||||
@@ -159,8 +161,8 @@ object SafDownloadHandler {
|
||||
)
|
||||
respObj.put("saf_relative_dir", resolvedDir)
|
||||
respObj.put("saf_tree_uri", treeUriStr)
|
||||
respObj.put("saf_output_ext", outputExt)
|
||||
respObj.put("saf_final_mime_type", mimeType)
|
||||
respObj.put("saf_output_ext", actualExt)
|
||||
respObj.put("saf_final_mime_type", mimeTypeForExt(actualExt))
|
||||
} else {
|
||||
workingFile.delete()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:spotiflac_android/models/download_result.dart';
|
||||
import 'package:spotiflac_android/models/extension.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/download_metadata_embedding.dart';
|
||||
import 'package:spotiflac_android/services/download_request_payload.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
late Directory root;
|
||||
late Directory output;
|
||||
late Directory sources;
|
||||
|
||||
setUp(() async {
|
||||
root = await Directory.systemTemp.createTemp('download-container-');
|
||||
output = await Directory('${root.path}/output').create();
|
||||
sources = await Directory('${root.path}/extensions').create();
|
||||
final data = await Directory('${root.path}/data').create();
|
||||
await PlatformBridge.initExtensionSystem(
|
||||
sources.path,
|
||||
data.path,
|
||||
masterKey: base64Encode(List<int>.filled(32, 1)),
|
||||
lyricsProviders: const [],
|
||||
lyricsFetchOptions: const {},
|
||||
allowedDirectories: [output.path],
|
||||
);
|
||||
final implementations = await PlatformBridge.getBackendImplementations();
|
||||
expect(implementations['downloads'], 'rust');
|
||||
expect(implementations['extensions'], 'rust');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await PlatformBridge.cleanupExtensions();
|
||||
await root.delete(recursive: true);
|
||||
});
|
||||
|
||||
for (final (format, returnedExtension) in [
|
||||
('m4a', 'm4a'),
|
||||
('m4a', 'mp4'),
|
||||
('m4a', 'flac'),
|
||||
('mp3', 'mp3'),
|
||||
('flac', 'flac'),
|
||||
]) {
|
||||
for (final explicitWorkingPath in [false, true]) {
|
||||
testWidgets(
|
||||
'foreground $format returned as $returnedExtension (working: $explicitWorkingPath)',
|
||||
(tester) async {
|
||||
final fixture = File('${root.path}/fixture.$format');
|
||||
final codec = switch (format) {
|
||||
'm4a' => 'aac',
|
||||
'mp3' => 'libmp3lame',
|
||||
_ => 'flac',
|
||||
};
|
||||
final session = await FFmpegKit.executeWithArguments([
|
||||
'-y',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'sine=frequency=440:sample_rate=44100',
|
||||
'-t',
|
||||
'0.25',
|
||||
'-c:a',
|
||||
codec,
|
||||
fixture.path,
|
||||
]);
|
||||
expect(
|
||||
ReturnCode.isSuccess(await session.getReturnCode()),
|
||||
true,
|
||||
reason: await session.getOutput(),
|
||||
);
|
||||
final audio = await fixture.readAsBytes();
|
||||
final provider = await Directory(
|
||||
'${sources.path}/example.audio',
|
||||
).create();
|
||||
await File('${provider.path}/manifest.json').writeAsString(
|
||||
jsonEncode({
|
||||
'name': 'example.audio',
|
||||
'version': '1',
|
||||
'description': 'Generic native-container fixture',
|
||||
'type': ['download_provider'],
|
||||
'permissions': {'file': true},
|
||||
'skipMetadataEnrichment': true,
|
||||
}),
|
||||
);
|
||||
await File('${provider.path}/index.js').writeAsString('''
|
||||
registerExtension({
|
||||
checkAvailability() { return { available: true, track_id: 'track' }; },
|
||||
download(trackId, quality, outputPath) {
|
||||
const path = outputPath.slice(0, outputPath.lastIndexOf('.')) + '.$returnedExtension';
|
||||
file.writeBytes(path, ${jsonEncode(base64Encode(audio))});
|
||||
return { success: true, file_path: path };
|
||||
}
|
||||
});
|
||||
''');
|
||||
await PlatformBridge.loadExtensionsFromDir(sources.path);
|
||||
await PlatformBridge.setExtensionEnabled('example.audio', true);
|
||||
const settings = AppSettings(
|
||||
nativeDownloadWorkerEnabled: false,
|
||||
embedMetadata: true,
|
||||
embedLyrics: false,
|
||||
embedReplayGain: false,
|
||||
);
|
||||
final request = DownloadRequestPayload(
|
||||
service: 'example.audio',
|
||||
providerTrackId: 'track',
|
||||
trackName: 'Track',
|
||||
artistName: 'Artist & Guest',
|
||||
albumName: 'Album',
|
||||
outputDir: output.path,
|
||||
filenameFormat: '{title}',
|
||||
outputExt: '.flac',
|
||||
// MP4 bytes named .flac must never enter the Rust FLAC tag writer.
|
||||
embedMetadata: format == 'm4a' && returnedExtension == 'flac',
|
||||
embedLyrics: false,
|
||||
useExtensions: true,
|
||||
useFallback: false,
|
||||
);
|
||||
// These are the foreground queue's real bridge and explicit local
|
||||
// working-path contract. No service-owned worker or channel mock.
|
||||
final Map<String, dynamic> response;
|
||||
if (explicitWorkingPath) {
|
||||
final raw = await channel.invokeMethod<String>(
|
||||
'downloadByStrategy',
|
||||
jsonEncode({
|
||||
...request.toJson(),
|
||||
'output_path': '${output.path}/working.flac',
|
||||
}),
|
||||
);
|
||||
response = Map<String, dynamic>.from(jsonDecode(raw!) as Map);
|
||||
} else {
|
||||
response = await PlatformBridge.downloadByStrategy(
|
||||
payload: request,
|
||||
);
|
||||
}
|
||||
expect(response['success'], true, reason: response.toString());
|
||||
final result = DownloadResult.fromMap(response);
|
||||
final suffix = '.$format';
|
||||
final stem = explicitWorkingPath ? 'working' : 'Track';
|
||||
expect(result.filePath, '${output.path}/$stem$suffix');
|
||||
expect(result.outputExtension(), suffix);
|
||||
expect(response['resolved_file_name'], 'Track$suffix');
|
||||
final downloaded = File(result.filePath!);
|
||||
expect(await downloaded.readAsBytes(), audio);
|
||||
if (format != 'flac') {
|
||||
expect(await File('${output.path}/$stem.flac').exists(), false);
|
||||
}
|
||||
final audioCodec = format == 'mp3' ? 'mp3' : codec;
|
||||
expect(
|
||||
await FFmpegService.probePrimaryAudioCodec(downloaded.path),
|
||||
audioCodec,
|
||||
);
|
||||
// The same tag writer used by the foreground queue must preserve
|
||||
// the native stream while embedding source credits into its container.
|
||||
await DownloadMetadataEmbedding(onReplayGain: (_, _, _) {}).embed(
|
||||
downloaded.path,
|
||||
const Track(
|
||||
id: 'example:track',
|
||||
name: 'Track',
|
||||
artistName: 'Artist & Guest',
|
||||
albumName: 'Album',
|
||||
duration: 1,
|
||||
),
|
||||
settings: settings,
|
||||
extensionState: const ExtensionState(),
|
||||
format: format,
|
||||
);
|
||||
final metadata = await PlatformBridge.readFileMetadata(
|
||||
downloaded.path,
|
||||
);
|
||||
expect(metadata['title'], 'Track');
|
||||
expect(metadata['artist'], 'Artist & Guest');
|
||||
expect(metadata['album'], 'Album');
|
||||
expect(
|
||||
await FFmpegService.probePrimaryAudioCodec(downloaded.path),
|
||||
audioCodec,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use super::*;
|
||||
use crate::RuntimeLimits;
|
||||
use base64::Engine;
|
||||
|
||||
#[test]
|
||||
fn provider_format_fields_and_returned_path_override_the_planned_extension() {
|
||||
let request = DownloadRequest {
|
||||
track_name: "Track".into(),
|
||||
filename_format: "{title}".into(),
|
||||
output_ext: ".flac".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let manifest = ExtensionManifest::default();
|
||||
for (result, expected) in [
|
||||
(json!({"file_path":"staged/Track.m4a"}), ".m4a"),
|
||||
(json!({"file_path":"staged/Track.MP3"}), ".mp3"),
|
||||
(json!({"file_path":"staged/Track.opus"}), ".opus"),
|
||||
(json!({"file_path":"staged/Track.mp4"}), ".m4a"),
|
||||
(json!({"file_path":"staged/Track.flac"}), ".flac"),
|
||||
(
|
||||
json!({"file_path":"staged/Track.mp3","actual_container":"audio/mpeg"}),
|
||||
".mp3",
|
||||
),
|
||||
(
|
||||
json!({"file_path":"staged/Track.m4a","actual_extension":"audio/mp4"}),
|
||||
".m4a",
|
||||
),
|
||||
(
|
||||
json!({"file_path":"staged/Track.flac","actual_container":"m4a"}),
|
||||
".m4a",
|
||||
),
|
||||
(
|
||||
json!({"file_path":"staged/Track.flac","output_extension":" MP4 "}),
|
||||
".m4a",
|
||||
),
|
||||
(
|
||||
json!({"file_path":"staged/Track.m4a","actual_extension":".mp3","output_extension":".flac"}),
|
||||
".mp3",
|
||||
),
|
||||
(json!({}), ".flac"),
|
||||
] {
|
||||
let response = success(
|
||||
&request,
|
||||
&result,
|
||||
"planned/Track.flac",
|
||||
false,
|
||||
&manifest,
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(response["actual_extension"], expected);
|
||||
assert_eq!(response["resolved_file_name"], format!("Track{expected}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downloads_publish_the_actual_container_for_regular_and_explicit_working_paths() {
|
||||
// Header-only payloads verify byte-preserving publication; real decodable
|
||||
// audio and Flutter metadata finalization are exercised on devices.
|
||||
for (returned_extension, payload, expected) in [
|
||||
("m4a", b"\0\0\0\x18ftypM4A fixture".as_slice(), ".m4a"),
|
||||
("mp4", b"\0\0\0\x18ftypM4A fixture".as_slice(), ".m4a"),
|
||||
("mp3", b"ID3example audio".as_slice(), ".mp3"),
|
||||
("opus", b"OggSexample audio".as_slice(), ".opus"),
|
||||
// A legacy provider may leave the requested suffix on MP4 bytes.
|
||||
("flac", b"\0\0\0\x18ftypM4A fixture".as_slice(), ".m4a"),
|
||||
] {
|
||||
for explicit_path in [false, true] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let source = directory.path().join("sources/example.audio");
|
||||
std::fs::create_dir_all(&source).unwrap();
|
||||
std::fs::write(
|
||||
source.join("manifest.json"),
|
||||
json!({"name":"example.audio","version":"1",
|
||||
"description":"Generic native-container fixture","type":["download_provider"],
|
||||
"permissions":{"file":true},"skipMetadataEnrichment":true})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(payload);
|
||||
std::fs::write(
|
||||
source.join("index.js"),
|
||||
format!(
|
||||
r#"registerExtension({{checkAvailability(){{return {{available:true,track_id:'track'}};}},
|
||||
download(trackId,quality,outputPath){{
|
||||
const path=outputPath.replace(/\.[^.]+$/,'.{returned_extension}');
|
||||
file.writeBytes(path,{encoded});
|
||||
return {{success:true,file_path:path}};}}}});"#,
|
||||
encoded = json!(encoded),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let backend = Backend::new(
|
||||
&directory.path().join("sources"),
|
||||
&directory.path().join("data"),
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
"1",
|
||||
RuntimeLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
backend.load_all().unwrap();
|
||||
backend.set_enabled("example.audio", true).unwrap();
|
||||
backend.download_manifest("example.audio").unwrap();
|
||||
let output = directory.path().join("output");
|
||||
std::fs::create_dir_all(&output).unwrap();
|
||||
backend
|
||||
.environment()
|
||||
.set_allowed_download_directories(std::slice::from_ref(&output))
|
||||
.unwrap();
|
||||
let mut request = json!({"service":"example.audio","provider_track_id":"track",
|
||||
"use_extensions":true,"use_fallback":false,"output_dir":output,
|
||||
"track_name":"Track","artist_name":"Artist","album_name":"Album",
|
||||
"filename_format":"{title}","output_ext":".flac","quality":"LOSSLESS",
|
||||
"embed_metadata":returned_extension == "flac","embed_lyrics":false});
|
||||
if explicit_path {
|
||||
request["output_path"] = output.join("working.flac").to_string_lossy().into();
|
||||
}
|
||||
let raw = backend
|
||||
.download_by_strategy(&request.to_string(), &|| Ok(()))
|
||||
.unwrap();
|
||||
let response: Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(response["success"], true, "{response}");
|
||||
assert_eq!(response["actual_extension"], expected, "{response}");
|
||||
assert_eq!(response["resolved_file_name"], format!("Track{expected}"));
|
||||
let stem = if explicit_path { "working" } else { "Track" };
|
||||
let published = output.join(format!("{stem}{expected}"));
|
||||
assert_eq!(response["file_path"], published.to_string_lossy().as_ref());
|
||||
assert_eq!(std::fs::read(&published).unwrap(), payload);
|
||||
assert!(!output.join(format!("{stem}.flac")).exists());
|
||||
// Resolving the corrected suffix must never replace a sibling.
|
||||
let second: Value = serde_json::from_str(
|
||||
&backend
|
||||
.download_by_strategy(&request.to_string(), &|| Ok(()))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(second["success"], false, "{second}");
|
||||
assert_eq!(std::fs::read(&published).unwrap(), payload);
|
||||
backend.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -842,10 +842,15 @@ impl Backend {
|
||||
let mut response = success(request, &result, &path, false, manifest, &check)?;
|
||||
let mut resolved = request.clone();
|
||||
resolved.output_ext = text(&response, "actual_extension").to_owned();
|
||||
let mut final_path = output_path(&resolved, &directory, text(&response, "album"), &check)?;
|
||||
if !request.output_path.is_empty() {
|
||||
final_path = request.output_path.clone();
|
||||
// SAF uses a local working path too. Keep its directory and stem,
|
||||
// but use the provider's actual container before final publication.
|
||||
resolved.output_path = Path::new(&request.output_path)
|
||||
.with_extension(resolved.output_ext.trim_start_matches('.'))
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
}
|
||||
let final_path = output_path(&resolved, &directory, text(&response, "album"), &check)?;
|
||||
// Keep the original lock for an unchanged destination. Release it
|
||||
// before acquiring a different path lock to avoid lock-order cycles.
|
||||
let destination = files.resolve_legacy(&final_path)?;
|
||||
@@ -874,7 +879,7 @@ impl Backend {
|
||||
// ReplayGain, decryption/conversion, external LRC and extension hooks
|
||||
// are executed by the existing Android/Dart finalizers. This is the
|
||||
// Go backend's local FLAC embed step; provider lyrics are reused.
|
||||
let embed = request.embed_metadata && returned.to_ascii_lowercase().ends_with(".flac");
|
||||
let embed = request.embed_metadata && resolved.output_ext == ".flac";
|
||||
let cover = if embed {
|
||||
let url = [text(&response, "cover_url"), &request.cover_url]
|
||||
.into_iter()
|
||||
@@ -1488,8 +1493,18 @@ fn probe_result(
|
||||
let mut header = [0; 8];
|
||||
// Quality is probed only for FLAC/MP4. Missing album folders may use
|
||||
// tags from any supported container, including undecoded audio.
|
||||
let quality = file.read_exact(&mut header).is_ok()
|
||||
&& (&header[..4] == b"fLaC" || &header[4..] == b"ftyp");
|
||||
let has_header = file.read_exact(&mut header).is_ok();
|
||||
let container = if has_header && &header[..4] == b"fLaC" {
|
||||
"flac"
|
||||
} else if has_header && &header[4..] == b"ftyp" {
|
||||
"m4a"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let quality = !container.is_empty();
|
||||
if quality {
|
||||
result["actual_container"] = container.into();
|
||||
}
|
||||
let album = album_pending && text(result, "album").trim().is_empty();
|
||||
if (quality || album)
|
||||
&& file.seek(SeekFrom::Start(0)).is_ok()
|
||||
@@ -1699,14 +1714,37 @@ fn success(
|
||||
response[key] = value.clone();
|
||||
}
|
||||
}
|
||||
let container = text(result, "actual_container")
|
||||
.trim()
|
||||
.trim_start_matches('.');
|
||||
let container = if [
|
||||
"flac", "m4a", "mp4", "mp3", "opus", "ogg", "aac", "wav", "aiff", "aif", "aifc",
|
||||
]
|
||||
.iter()
|
||||
.any(|extension| container.eq_ignore_ascii_case(extension))
|
||||
{
|
||||
container
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let extension = [
|
||||
text(result, "actual_extension"),
|
||||
text(result, "output_extension"),
|
||||
container,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_lowercase())
|
||||
.find(|value| !value.is_empty())
|
||||
.unwrap_or_default();
|
||||
.find(|value| !value.is_empty() && !value.contains(['/', '\\']))
|
||||
.unwrap_or_else(|| {
|
||||
// Providers may only return a renamed file_path, without explicit
|
||||
// format fields. The planned path still carries the requested quality.
|
||||
[text(result, "file_path"), path]
|
||||
.into_iter()
|
||||
.filter_map(|path| Path::new(path).extension())
|
||||
.map(|extension| extension.to_string_lossy().trim().to_lowercase())
|
||||
.find(|extension| !extension.is_empty())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
if !extension.is_empty() {
|
||||
let extension = format!(".{}", extension.trim_start_matches('.'));
|
||||
response["actual_extension"] = if extension == ".mp4" {
|
||||
@@ -1719,16 +1757,8 @@ fn success(
|
||||
if !text(result, "isrc").trim().is_empty() {
|
||||
named.isrc = text(result, "isrc").trim().into();
|
||||
}
|
||||
let extension = if text(&response, "actual_extension").is_empty() {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
} else {
|
||||
text(&response, "actual_extension").into()
|
||||
};
|
||||
response["resolved_file_name"] = filename(&named, &extension, check)?.into();
|
||||
response["resolved_file_name"] =
|
||||
filename(&named, text(&response, "actual_extension"), check)?.into();
|
||||
response["resolved_album_folder"] = album_folder(request, text(&response, "album")).into();
|
||||
Ok(response)
|
||||
}
|
||||
@@ -1792,3 +1822,7 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
#[path = "download_account_tests.rs"]
|
||||
mod account_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "download_container_tests.rs"]
|
||||
mod container_tests;
|
||||
Reference in new issue
Block a user