diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt index a12d49cb..5800a380 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt @@ -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() } diff --git a/integration_test/download_output_container_test.dart b/integration_test/download_output_container_test.dart new file mode 100644 index 00000000..1fd9ce0d --- /dev/null +++ b/integration_test/download_output_container_test.dart @@ -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.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 response; + if (explicitWorkingPath) { + final raw = await channel.invokeMethod( + 'downloadByStrategy', + jsonEncode({ + ...request.toJson(), + 'output_path': '${output.path}/working.flac', + }), + ); + response = Map.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, + ); + }, + ); + } + } +} diff --git a/rust_backend/crates/extensions/src/backend/download_container_tests.rs b/rust_backend/crates/extensions/src/backend/download_container_tests.rs new file mode 100644 index 00000000..947efd1f --- /dev/null +++ b/rust_backend/crates/extensions/src/backend/download_container_tests.rs @@ -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(); + } + } +} diff --git a/rust_backend/crates/extensions/src/backend/downloads.rs b/rust_backend/crates/extensions/src/backend/downloads.rs index 153e0aae..68ebe0dd 100644 --- a/rust_backend/crates/extensions/src/backend/downloads.rs +++ b/rust_backend/crates/extensions/src/backend/downloads.rs @@ -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;