diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index b3b82a6f..4e2b59e2 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1495,23 +1495,17 @@ class MainActivity: FlutterFragmentActivity() { } "readFileMetadata" -> { val filePath = call.argument("file_path") ?: "" + val displayName = call.argument("display_name") ?: "" val response = withContext(Dispatchers.IO) { try { if (filePath.startsWith("content://")) { - val uri = Uri.parse(filePath) - val tempPath = copyUriToTemp(uri) - ?: return@withContext """{"error":"Failed to copy SAF file to temp"}""" - try { - Gobackend.readFileMetadata(tempPath) - } finally { - try { File(tempPath).delete() } catch (_: Exception) {} - } + readCompleteMetadataFromUri(Uri.parse(filePath), displayName) + ?.toString() ?: errorJson("Failed to read SAF metadata") } else { - Gobackend.readFileMetadata(filePath) + Gobackend.readFileMetadataWithHint(filePath, displayName) } } catch (e: Exception) { - android.util.Log.e("SpotiFLAC", "readFileMetadata failed: ${e.message}", e) - """{"error":${org.json.JSONObject.quote(e.message ?: "unknown")}}""" + errorJson(e.message ?: "Failed to read metadata") } } result.success(response) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt index 727644a4..faf53954 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt @@ -301,6 +301,13 @@ internal fun MainActivity.readAudioMetadataFromUri( obj.takeUnless { it.has("error") } } +internal fun MainActivity.readCompleteMetadataFromUri( + uri: Uri, + displayNameHint: String? = null, +): JSONObject? = readMetadataFromUri(uri, displayNameHint) { path, name -> + JSONObject(Gobackend.readFileMetadataWithHint(path, name)).takeUnless { it.has("error") } +} + internal fun MainActivity.writeUriFromPath(uri: Uri, srcPath: String): Boolean { val srcFile = File(srcPath) if (!srcFile.exists()) return false diff --git a/go_backend/exports_metadata.go b/go_backend/exports_metadata.go index cc1a664b..17554d8c 100644 --- a/go_backend/exports_metadata.go +++ b/go_backend/exports_metadata.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "strings" "time" ) @@ -43,7 +42,13 @@ func successMethodJSON(method string) (string, error) { } func ReadFileMetadata(filePath string) (string, error) { - lower := strings.ToLower(filePath) + return ReadFileMetadataWithHint(filePath, "") +} + +// ReadFileMetadataWithHint reads complete tags from extensionless descriptor +// paths without changing their identity or requiring an audio-file copy. +func ReadFileMetadataWithHint(filePath, displayNameHint string) (string, error) { + lower := resolveLibraryAudioExt(filePath, displayNameHint) isFlac := strings.HasSuffix(lower, ".flac") isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac") isMp3 := strings.HasSuffix(lower, ".mp3") @@ -202,7 +207,7 @@ func ReadFileMetadata(filePath string) (string, error) { } } } else if isApe || isWv || isMpc { - result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".") + result["format"] = strings.TrimPrefix(lower, ".") result["audio_codec"] = result["format"] apeTag, apeErr := ReadAPETags(filePath) if apeErr == nil && apeTag != nil { diff --git a/go_backend/metadata_descriptor_test.go b/go_backend/metadata_descriptor_test.go new file mode 100644 index 00000000..abeda797 --- /dev/null +++ b/go_backend/metadata_descriptor_test.go @@ -0,0 +1,65 @@ +package gobackend + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestCompleteMetadataHintMatchesNamedFileAndDescriptor(t *testing.T) { + for _, format := range []string{"mp3", "flac", "m4a", "wav"} { + t.Run(format, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "track."+format) + switch format { + case "mp3": + data := buildID3v23Tag(id3TextFrame("TIT2", "Song"), id3TextFrame("TPE1", "Artist"), id3TextFrame("TSRC", "USRC17607839"), id3CommentFrame("USLT", "Words"), id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_GAIN", "-6.00 dB"), id3UserTextFrame("TXXX", "REPLAYGAIN_ALBUM_GAIN", "-4.00 dB")) + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + case "flac": + writeSinglePassTestFlac(t, path, nil) + case "wav": + writeTestWAV(t, path) + case "m4a": + data, _ := buildTestM4A(t, buildM4ATextAtom("\xa9nam", "Song"), []byte("audio")) + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + } + expected, err := ReadFileMetadata(path) + if err != nil { + t.Fatal(err) + } + extensionless := filepath.Join(dir, "descriptor") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(extensionless, data, 0600); err != nil { + t.Fatal(err) + } + actual, err := ReadFileMetadataWithHint(extensionless, "track."+format) + if err != nil || actual != expected { + t.Fatalf("hinted metadata=%s expected=%s err=%v", actual, expected, err) + } + // Android uses /proc, which reopens with an independent offset. + // macOS /dev/fd duplicates the shared offset and is not that API. + if runtime.GOOS != "linux" { + return + } + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + prefix := "/proc/self/fd/" + actual, err = ReadFileMetadataWithHint(fmt.Sprintf("%s%d", prefix, file.Fd()), "track."+format) + if err != nil || actual != expected { + t.Fatalf("descriptor metadata=%s expected=%s err=%v", actual, expected, err) + } + }) + } +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 15c9ed99..6d3a38fc 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -597,7 +597,7 @@ import Gobackend case "readFileMetadata": let args = call.arguments as! [String: Any] let filePath = args["file_path"] as! String - let response = GobackendReadFileMetadata(filePath, &error) + let response = GobackendReadFileMetadataWithHint(filePath, args["display_name"] as? String ?? "", &error) if let error = error { throw error } return response diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 77cff721..1fa24839 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1080,8 +1080,14 @@ class PlatformBridge { return _invokeMap('reEnrichFile', {'request_json': jsonEncode(request)}); } - static Future> readFileMetadata(String filePath) { - return _invokeMap('readFileMetadata', {'file_path': filePath}); + static Future> readFileMetadata( + String filePath, { + String? displayName, + }) { + return _invokeMap('readFileMetadata', { + 'file_path': filePath, + 'display_name': ?displayName, + }); } /// Reads the tags and quality fields used for automatic Library display. diff --git a/test/platform_bridge_metadata_test.dart b/test/platform_bridge_metadata_test.dart index 8529d91e..19596e31 100644 --- a/test/platform_bridge_metadata_test.dart +++ b/test/platform_bridge_metadata_test.dart @@ -13,6 +13,23 @@ void main() { .setMockMethodCallHandler(backendChannel, null); }); + test('complete metadata forwards descriptor format hint', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, (call) async { + expect(call.method, 'readFileMetadata'); + expect(call.arguments, { + 'file_path': '/proc/self/fd/42', + 'display_name': 'Song.opus', + }); + return jsonEncode({'replaygain_track_gain': '-6.00 dB'}); + }); + final result = await PlatformBridge.readFileMetadata( + '/proc/self/fd/42', + displayName: 'Song.opus', + ); + expect(result['replaygain_track_gain'], '-6.00 dB'); + }); + test('display metadata uses the lightweight scan result directly', () async { final invokedMethods = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger