fix(downloads): preserve actual provider audio format

This commit is contained in:
zarzet committed 2026-10-08 01:50:36 +07:00
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;