From b32bdbc291706a30d4beb68209d7a85c96abfe18 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:04:04 +0700 Subject: [PATCH] perf(metadata): read complete SAF tags through descriptors Add complete metadata reads with a display-name hint in Go and both native bridges. Read seekable SAF descriptors directly and use temporary copies only when direct reading fails. Add format parity tests for MP3, FLAC, M4A, and WAV plus a Dart bridge hint regression. --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 16 ++--- .../com/zarz/spotiflac/MainActivitySafIo.kt | 7 ++ go_backend/exports_metadata.go | 11 +++- go_backend/metadata_descriptor_test.go | 65 +++++++++++++++++++ ios/Runner/AppDelegate.swift | 2 +- lib/services/platform_bridge.dart | 10 ++- test/platform_bridge_metadata_test.dart | 17 +++++ 7 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 go_backend/metadata_descriptor_test.go 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