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.
This commit is contained in:
zarzet
2026-09-06 14:04:04 +07:00
parent df8f4f5d7b
commit b32bdbc291
7 changed files with 111 additions and 17 deletions
@@ -1495,23 +1495,17 @@ class MainActivity: FlutterFragmentActivity() {
}
"readFileMetadata" -> {
val filePath = call.argument<String>("file_path") ?: ""
val displayName = call.argument<String>("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)
@@ -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
+8 -3
View File
@@ -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 {
+65
View File
@@ -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)
}
})
}
}
+1 -1
View File
@@ -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
+8 -2
View File
@@ -1080,8 +1080,14 @@ class PlatformBridge {
return _invokeMap('reEnrichFile', {'request_json': jsonEncode(request)});
}
static Future<Map<String, dynamic>> readFileMetadata(String filePath) {
return _invokeMap('readFileMetadata', {'file_path': filePath});
static Future<Map<String, dynamic>> 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.
+17
View File
@@ -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 = <String>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger