refactor(backend): drop dead exports/availability path and dedup Go tag writers

Remove the never-called checkAvailability chain end to end (Dart bridge +
provider, Android/iOS MethodChannel handlers, Go exports) and stop restoring
its persistent lookup cache on every cold start; the legacy prefs key is
cleared on next cache reset.

Delete dead Go: romaji.go (test-only), the no-op pre-warm chain
(PreWarmTrackCache/PreWarmCache/…), retired Download stubs, and a batch of
exports with no Dart/Kotlin/Swift/Go caller. Kept GetTrackCacheSize/
ClearTrackIDCache (Settings cache screen), SetAllowedDownloadDirs and
Clear/GetItemProgress (live test seams).

Dedup: single updateFlacVorbis + replaceFlacPictures helper behind the six
FLAC tag writers; merge byte-identical extractCommentFrame/extractLyricsFrame
into extractLangTextFrame.

go build/vet + 237 go tests, flutter analyze + tests green.
This commit is contained in:
zarzet
2026-07-14 18:25:13 +07:00
parent e2a2e8a914
commit f0afb1382e
27 changed files with 114 additions and 1006 deletions
@@ -2198,14 +2198,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
"checkAvailability" -> {
val spotifyId = call.argument<String>("spotify_id") ?: ""
val isrc = call.argument<String>("isrc") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.checkAvailability(spotifyId, isrc)
}
result.success(response)
}
"downloadByStrategy" -> {
val requestJson = call.arguments as String
val response = withContext(Dispatchers.IO) {
@@ -3038,13 +3030,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(payload)
}
"preWarmTrackCache" -> {
val tracksJson = call.argument<String>("tracks") ?: "[]"
withContext(Dispatchers.IO) {
Gobackend.preWarmTrackCacheJSON(tracksJson)
}
result.success(null)
}
"getTrackCacheSize" -> {
val size = withContext(Dispatchers.IO) {
Gobackend.getTrackCacheSize()
@@ -3097,22 +3082,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"checkAvailabilityFromDeezerID" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.checkAvailabilityFromDeezerID(deezerTrackId)
}
result.success(response)
}
"checkAvailabilityByPlatformID" -> {
val platform = call.argument<String>("platform") ?: ""
val entityType = call.argument<String>("entity_type") ?: ""
val entityId = call.argument<String>("entity_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.checkAvailabilityByPlatformID(platform, entityType, entityId)
}
result.success(response)
}
"getSpotifyIDFromDeezerTrack" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
+6 -57
View File
@@ -186,7 +186,7 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
case "TCR":
metadata.Copyright = value
case "ULT":
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
if v := extractLangTextFrame(frameData); v != "" && metadata.Lyrics == "" {
metadata.Lyrics = v
}
case "TXX":
@@ -307,11 +307,11 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
case "TCOP":
metadata.Copyright = value
case "COMM":
if v := extractCommentFrame(frameData); v != "" {
if v := extractLangTextFrame(frameData); v != "" {
metadata.Comment = v
}
case "USLT":
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
if v := extractLangTextFrame(frameData); v != "" && metadata.Lyrics == "" {
metadata.Lyrics = v
}
case "TXXX":
@@ -391,7 +391,9 @@ func extractTextFrame(data []byte) string {
}
}
func extractCommentFrame(data []byte) string {
// extractLangTextFrame decodes ID3 frames with an encoding byte, 3-byte
// language code, and null-terminated descriptor before the text (COMM, USLT).
func extractLangTextFrame(data []byte) string {
if len(data) < 5 {
return ""
}
@@ -426,42 +428,6 @@ func extractCommentFrame(data []byte) string {
return extractTextFrame(framed)
}
func extractLyricsFrame(data []byte) string {
if len(data) < 5 {
return ""
}
encoding := data[0]
rest := data[4:]
var text []byte
switch encoding {
case 1, 2:
for i := 0; i+1 < len(rest); i += 2 {
if rest[i] == 0 && rest[i+1] == 0 {
text = rest[i+2:]
break
}
}
default:
idx := bytes.IndexByte(rest, 0)
if idx >= 0 && idx+1 < len(rest) {
text = rest[idx+1:]
} else {
text = rest
}
}
if len(text) == 0 {
return ""
}
framed := make([]byte, 1+len(text))
framed[0] = encoding
copy(framed[1:], text)
return extractTextFrame(framed)
}
func extractUserTextFrame(data []byte) (string, string) {
if len(data) < 2 {
return "", ""
@@ -582,11 +548,6 @@ func cleanGenre(genre string) string {
return genre
}
func parseTrackNumber(s string) int {
num, _ := parseIndexPair(s)
return num
}
func parseIndexPair(s string) (int, int) {
s = strings.TrimSpace(s)
if s == "" {
@@ -1554,10 +1515,6 @@ func parseFLACPictureBlock(data []byte) ([]byte, string) {
return imageData, mimeType
}
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
return extractAnyCoverArtWithHint(filePath, "")
}
func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
ext := strings.ToLower(filepath.Ext(filePath))
if ext == "" {
@@ -1605,14 +1562,6 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
}
}
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
return SaveCoverToCacheWithHintAndKey(filePath, "", cacheDir, "")
}
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
return SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, "")
}
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
explicitKey = strings.TrimSpace(explicitKey)
if explicitKey != "" {
@@ -85,9 +85,6 @@ func TestAudioMetadataID3ParsingBranches(t *testing.T) {
if n, total := parseIndexPair(" 8 / 10 "); n != 8 || total != 10 {
t.Fatalf("parseIndexPair = %d/%d", n, total)
}
if got := parseTrackNumber("9/11"); got != 9 {
t.Fatalf("parseTrackNumber = %d", got)
}
if got := removeUnsync([]byte{0xff, 0x00, 0xe0}); !bytes.Equal(got, []byte{0xff, 0xe0}) {
t.Fatalf("removeUnsync = %#v", got)
}
@@ -424,9 +421,6 @@ func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
if image, mime, err := extractAnyCoverArtWithHint(coverPath, "cover.opus"); err != nil || mime != "image/png" || len(image) == 0 {
t.Fatalf("extractAnyCoverArtWithHint = %s/%#v/%v", mime, image, err)
}
if image, mime, err := extractAnyCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 {
t.Fatalf("extractAnyCoverArt = %s/%#v/%v", mime, image, err)
}
extractedCoverPath := filepath.Join(dir, "extracted.png")
if err := ExtractCoverToFile(coverPath, extractedCoverPath); err != nil {
t.Fatalf("ExtractCoverToFile = %v", err)
@@ -438,17 +432,6 @@ func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
if err != nil || cachePath == "" {
t.Fatalf("SaveCoverToCacheWithHintAndKey = %q/%v", cachePath, err)
}
cacheDir := filepath.Join(dir, "cache")
if path, err := SaveCoverToCache(coverPath, cacheDir); err != nil || !strings.HasSuffix(path, ".png") {
t.Fatalf("SaveCoverToCache = %q/%v", path, err)
}
if path, err := SaveCoverToCacheWithHint(coverPath, "cover.opus", cacheDir); err != nil || path == "" {
t.Fatalf("SaveCoverToCacheWithHint = %q/%v", path, err)
}
hitPath, err := SaveCoverToCache(coverPath, cacheDir)
if err != nil || hitPath == "" {
t.Fatalf("SaveCoverToCache cache hit = %q/%v", hitPath, err)
}
if _, err := SaveCoverToCacheWithHintAndKey(filepath.Join(dir, "missing.opus"), "missing.opus", dir, "missing"); err == nil {
t.Fatal("expected missing cover cache error")
}
-14
View File
@@ -247,17 +247,3 @@ func upgradeQobuzCover(coverURL string) string {
}
return upgraded
}
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
if imageURL == "" {
return ""
}
result := convertSmallToMedium(imageURL)
if maxQuality {
result = upgradeToMaxQuality(result)
}
return result
}
-10
View File
@@ -40,16 +40,6 @@ func ReleaseMemory() {
debug.FreeOSMemory()
}
func CheckAvailability(spotifyID, isrc string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err != nil {
return "", err
}
return marshalJSONString(availability)
}
// SetSongLinkNetworkOptions is kept for backward compatibility.
func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
+4 -58
View File
@@ -2,53 +2,19 @@ package gobackend
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
var tracks []struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
SpotifyID string `json:"spotify_id"`
Service string `json:"service"`
}
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
return errorResponse("Invalid JSON: " + err.Error())
}
requests := make([]PreWarmCacheRequest, len(tracks))
for i, t := range tracks {
requests[i] = PreWarmCacheRequest{
ISRC: t.ISRC,
TrackName: t.TrackName,
ArtistName: t.ArtistName,
SpotifyID: t.SpotifyID,
Service: t.Service,
}
}
go PreWarmTrackCache(requests)
resp := map[string]any{
"success": true,
"message": fmt.Sprintf("Pre-warming cache for %d tracks in background", len(tracks)),
}
s, _ := marshalJSONString(resp)
return s, nil
}
// GetTrackCacheSize and ClearTrackIDCache back the Settings cache screen. The
// track-ID cache is currently a no-op, so these report an empty cache and clear
// nothing, but the gomobile export contract is kept for the Dart/Kotlin callers.
func GetTrackCacheSize() int {
return GetCacheSize()
return 0
}
func ClearTrackIDCache() {
ClearTrackCache()
}
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
@@ -237,26 +203,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
return "", fmt.Errorf("spotify to Deezer conversion only supported for tracks and albums: please search by name for %s", resourceType)
}
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil {
return "", err
}
return marshalJSONString(availability)
}
func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckAvailabilityByPlatform(platform, entityType, entityID)
if err != nil {
return "", err
}
return marshalJSONString(availability)
}
func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) {
client := NewSongLinkClient()
return client.GetSpotifyIDFromDeezer(deezerTrackID)
-8
View File
@@ -358,10 +358,6 @@ func applySongLinkRegionFromRequest(req *DownloadRequest) {
SetSongLinkRegion(req.SongLinkRegion)
}
func DownloadTrack(requestJSON string) (string, error) {
return errorResponse("Built-in download providers have been retired. Use downloadByStrategy with extension providers.")
}
// DownloadByStrategy routes all download requests through extension providers.
func DownloadByStrategy(requestJSON string) (string, error) {
var req DownloadRequest
@@ -385,10 +381,6 @@ func DownloadByStrategy(requestJSON string) (string, error) {
return errorResponse("Extension providers are disabled; built-in download providers have been retired")
}
func DownloadWithFallback(requestJSON string) (string, error) {
return errorResponse("Built-in fallback has been retired. Use extension fallback through downloadByStrategy.")
}
func GetDownloadProgress() string {
progress := getProgress()
jsonBytes, _ := json.Marshal(progress)
-5
View File
@@ -394,11 +394,6 @@ func SetExtensionFallbackProviderIDsJSON(providerIDsJSON string) error {
return nil
}
func GetExtensionFallbackProviderIDsJSON() (string, error) {
providerIDs := GetExtensionFallbackProviderIDs()
return marshalJSONString(providerIDs)
}
func SetMetadataProviderPriorityJSON(priorityJSON string) error {
var priority []string
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
-4
View File
@@ -28,10 +28,6 @@ func ReadAudioMetadataJSON(filePath string) (string, error) {
return ReadAudioMetadata(filePath)
}
func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) {
return ReadAudioMetadataWithDisplayName(filePath, displayName)
}
func ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filePath, displayName, coverCacheKey string) (string, error) {
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayName, coverCacheKey)
}
@@ -1,7 +1,6 @@
package gobackend
import (
"context"
"io"
"net/http"
"os"
@@ -55,13 +54,9 @@ func TestLyricsExportWrappersWithoutNetwork(t *testing.T) {
func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
origClient := globalSongLinkClient
origRetryConfig := songLinkRetryConfig
origSearchByISRC := songLinkSearchByISRC
origCheckFromDeezer := songLinkCheckAvailabilityFromDeezer
defer func() {
globalSongLinkClient = origClient
songLinkRetryConfig = origRetryConfig
songLinkSearchByISRC = origSearchByISRC
songLinkCheckAvailabilityFromDeezer = origCheckFromDeezer
SetSongLinkNetworkOptions(false, false)
}()
songLinkRetryConfig = func() RetryConfig {
@@ -82,15 +77,6 @@ func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
songLinkClientOnce.Do(func() {})
SetSongLinkNetworkOptions(true, true)
if availabilityJSON, err := CheckAvailability("spotify-1", ""); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
t.Fatalf("CheckAvailability = %q/%v", availabilityJSON, err)
}
if availabilityJSON, err := CheckAvailabilityFromDeezerID("101"); err != nil || !strings.Contains(availabilityJSON, `"spotify_id":"spotify-1"`) {
t.Fatalf("CheckAvailabilityFromDeezerID = %q/%v", availabilityJSON, err)
}
if availabilityJSON, err := CheckAvailabilityByPlatformID("deezer", "song", "101"); err != nil || !strings.Contains(availabilityJSON, `"tidal_url"`) {
t.Fatalf("CheckAvailabilityByPlatformID = %q/%v", availabilityJSON, err)
}
if spotifyID, err := GetSpotifyIDFromDeezerTrack("101"); err != nil || spotifyID != "spotify-1" {
t.Fatalf("GetSpotifyIDFromDeezerTrack = %q/%v", spotifyID, err)
}
@@ -122,15 +108,6 @@ func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
t.Fatalf("CheckAvailabilityFromURL = %#v/%v", availability, err)
}
songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) {
return &TrackMetadata{SpotifyID: "deezer:101", ExternalURL: "https://www.deezer.com/track/101"}, nil
}
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
return &TrackAvailability{SpotifyID: "spotify-1", Deezer: true, DeezerID: deezerTrackID}, nil
}
if availabilityJSON, err := CheckAvailability("", "USRC17607839"); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
t.Fatalf("CheckAvailability by ISRC = %q/%v", availabilityJSON, err)
}
if songLinkExtractDeezerTrackID(nil) != "" || songLinkExtractDeezerTrackID(&TrackMetadata{ExternalURL: "https://www.deezer.com/track/202"}) != "202" {
t.Fatal("songLinkExtractDeezerTrackID mismatch")
}
-18
View File
@@ -91,18 +91,12 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
manager.mu.Unlock()
}()
if response, err := DownloadTrack(`{}`); err != nil || !strings.Contains(response, "retired") {
t.Fatalf("DownloadTrack = %q/%v", response, err)
}
if response, err := DownloadByStrategy(`not-json`); err != nil || !strings.Contains(response, "Invalid request") {
t.Fatalf("DownloadByStrategy invalid = %q/%v", response, err)
}
if response, err := DownloadByStrategy(`{"use_extensions":false}`); err != nil || !strings.Contains(response, "disabled") {
t.Fatalf("DownloadByStrategy disabled = %q/%v", response, err)
}
if response, err := DownloadWithFallback(`{}`); err != nil || !strings.Contains(response, "retired") {
t.Fatalf("DownloadWithFallback = %q/%v", response, err)
}
InitItemProgress("item-1")
FinishItemProgress("item-1")
@@ -183,12 +177,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
t.Fatalf("SanitizeFilename = %q", got)
}
if response, err := PreWarmTrackCacheJSON(`not-json`); err != nil || !strings.Contains(response, "Invalid JSON") {
t.Fatalf("PreWarmTrackCacheJSON invalid = %q/%v", response, err)
}
if response, err := PreWarmTrackCacheJSON(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(response, "success") {
t.Fatalf("PreWarmTrackCacheJSON = %q/%v", response, err)
}
if GetTrackCacheSize() != 0 {
t.Fatal("expected empty track cache")
}
@@ -219,9 +207,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if err := SetExtensionFallbackProviderIDsJSON(`["coverage-ext"]`); err != nil {
t.Fatalf("SetExtensionFallbackProviderIDsJSON: %v", err)
}
if jsonText, err := GetExtensionFallbackProviderIDsJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
t.Fatalf("GetExtensionFallbackProviderIDsJSON = %q/%v", jsonText, err)
}
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
t.Fatalf("reset extension fallback IDs: %v", err)
}
@@ -484,9 +469,6 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
if metadataJSON, err := ReadAudioMetadataJSON(filepath.Join(libraryDir, "missing.mp3")); err != nil || metadataJSON == "" {
t.Fatalf("ReadAudioMetadataJSON = %q/%v", metadataJSON, err)
}
if metadataJSON, err := ReadAudioMetadataWithHintJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing"); err != nil || metadataJSON == "" {
t.Fatalf("ReadAudioMetadataWithHintJSON = %q/%v", metadataJSON, err)
}
if metadataJSON, err := ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing", "key"); err != nil || metadataJSON == "" {
t.Fatalf("ReadAudioMetadataWithHintAndCoverCacheKeyJSON = %q/%v", metadataJSON, err)
}
@@ -71,11 +71,7 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) {
}
}
func TestCoverRomajiParallelAndIDHSHelpers(t *testing.T) {
spotify := "https://i.scdn.co/image/ab67616d00001e02abcdef"
if got := GetCoverFromSpotify(spotify, true); !strings.Contains(got, spotifySizeMax) {
t.Fatalf("spotify cover = %q", got)
}
func TestCoverAndIDHSHelpers(t *testing.T) {
if got := upgradeToMaxQuality("https://cdn-images.dzcdn.net/images/cover/abc/500x500-000000-80-0-0.jpg"); !strings.Contains(got, "1800x1800") {
t.Fatalf("deezer cover = %q", got)
}
@@ -89,32 +85,6 @@ func TestCoverRomajiParallelAndIDHSHelpers(t *testing.T) {
t.Fatalf("expected empty cover error")
}
if !ContainsJapanese("カタカナ") || ContainsJapanese("abc") {
t.Fatal("unexpected Japanese detection")
}
if got := JapaneseToRomaji("きゃット"); got != "kyatto" {
t.Fatalf("romaji = %q", got)
}
if got := BuildSearchQuery("きゃ! song", "アーティスト"); got != "atisuto kya song" {
t.Fatalf("query = %q", got)
}
if got := CleanToASCII("A, B. C!"); got != "A B C" {
t.Fatalf("ascii = %q", got)
}
if err := PreWarmCache(`not-json`); err == nil {
t.Fatal("expected prewarm JSON error")
}
if err := PreWarmCache(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist","spotify_id":"sp","service":"tidal"}]`); err != nil {
t.Fatalf("PreWarmCache: %v", err)
}
if result := FetchCoverAndLyricsParallel("", false, "", "", "", false, 0); result == nil || result.CoverErr != nil || result.LyricsErr != nil {
t.Fatalf("parallel result = %#v", result)
}
if ClearTrackCache(); GetCacheSize() != 0 {
t.Fatal("expected empty cache size")
}
client := &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodPost {
t.Fatalf("method = %s", req.Method)
+9 -7
View File
@@ -18,13 +18,6 @@ var (
allowedDownloadDirsMu sync.RWMutex
)
func SetAllowedDownloadDirs(dirs []string) {
allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock()
allowedDownloadDirs = dirs
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
}
func AddAllowedDownloadDir(dir string) {
allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock()
@@ -34,6 +27,15 @@ func AddAllowedDownloadDir(dir string) {
}
}
// SetAllowedDownloadDirs replaces the whole allow-list in one call (passing nil
// clears it). Used by tests to reset the sandbox between cases; production code
// appends via AddAllowedDownloadDir.
func SetAllowedDownloadDirs(dirs []string) {
allowedDownloadDirsMu.Lock()
defer allowedDownloadDirsMu.Unlock()
allowedDownloadDirs = dirs
}
func isPathInAllowedDirs(absPath string) bool {
allowedDownloadDirsMu.RLock()
defer allowedDownloadDirsMu.RUnlock()
-10
View File
@@ -98,16 +98,6 @@ func (r *extensionRuntime) ensureStorageLoaded() error {
return nil
}
func (r *extensionRuntime) loadStorage() (map[string]any, error) {
if err := r.ensureStorageLoaded(); err != nil {
return nil, err
}
r.storageMu.RLock()
defer r.storageMu.RUnlock()
return cloneInterfaceMap(r.storageCache), nil
}
func (r *extensionRuntime) queueStorageFlushLocked(delay time.Duration) {
if r.storageClosed {
return
@@ -635,10 +635,6 @@ func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]any{"nested": "value"})}}); !ok.ToBoolean() {
t.Fatal("storageSet equal false")
}
loaded, err := runtime.loadStorage()
if err != nil || loaded["key"] == nil {
t.Fatalf("loadStorage = %#v/%v", loaded, err)
}
if err := runtime.flushStorageNow(); err != nil {
t.Fatalf("flushStorageNow: %v", err)
}
-4
View File
@@ -19,10 +19,6 @@ func (e *JSExecutionError) Error() string {
return e.Message
}
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
return RunWithTimeoutContext(context.Background(), vm, script, timeout)
}
func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
if vm == nil {
return nil, fmt.Errorf("extension runtime unavailable")
-3
View File
@@ -158,9 +158,6 @@ func TestRateLimiterHelpers(t *testing.T) {
if limiter.Available() != 0 {
t.Fatalf("available after acquire = %d", limiter.Available())
}
if GetSongLinkRateLimiter() == nil {
t.Fatal("expected global limiter")
}
}
func mustNewRequest(t *testing.T, rawURL string) *http.Request {
@@ -96,18 +96,8 @@ func TestProgressItemHelpersAndWriter(t *testing.T) {
}
func TestRunWithTimeoutBranches(t *testing.T) {
if _, err := RunWithTimeout(nil, "1 + 1", time.Millisecond); err == nil {
t.Fatal("expected nil VM error")
}
vm := goja.New()
value, err := RunWithTimeout(vm, "1 + 2", time.Second)
if err != nil || value.ToInteger() != 3 {
t.Fatalf("RunWithTimeout success = %v/%v", value, err)
}
timeoutVM := goja.New()
_, err = RunWithTimeoutAndRecover(timeoutVM, "for (;;) {}", 10*time.Millisecond)
_, err := RunWithTimeoutAndRecover(timeoutVM, "for (;;) {}", 10*time.Millisecond)
if err == nil {
t.Fatal("expected timeout error")
}
+78 -242
View File
@@ -218,19 +218,19 @@ func parseFlacFile(filePath string) (*flac.File, error) {
return f, nil
}
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
// updateFlacVorbis parses a FLAC file, hands the parsed file and its Vorbis
// comment block (created if absent) to mutate, then marshals the block back
// into the file and saves it. Shared scaffold for all FLAC tag writers.
func updateFlacVorbis(filePath string, mutate func(f *flac.File, cmt *flacvorbis.MetaDataBlockVorbisComment) error) error {
f, err := parseFlacFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
defer f.Close()
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
for idx, meta := range f.Meta {
for _, meta := range f.Meta {
if meta.Type == flac.VorbisComment {
cmtIdx = idx
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
return fmt.Errorf("failed to parse vorbis comment: %w", err)
@@ -238,99 +238,83 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
break
}
}
if cmt == nil {
cmt = flacvorbis.New()
}
writeVorbisMetadata(cmt, metadata)
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
} else {
f.Meta = append(f.Meta, &cmtBlock)
if err := mutate(f, cmt); err != nil {
return err
}
if coverPath != "" {
if fileExists(coverPath) {
coverData, err := os.ReadFile(coverPath)
if err != nil {
fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err)
} else {
for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
}
}
picBlock, err := buildPictureBlock(coverPath, coverData)
if err != nil {
fmt.Printf("[Metadata] Warning: skipping cover art: %v\n", err)
} else {
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
}
} else {
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
// Re-scan for the block index: mutate may have removed blocks.
cmtBlock := cmt.Marshal()
replaced := false
for idx, meta := range f.Meta {
if meta.Type == flac.VorbisComment {
f.Meta[idx] = &cmtBlock
replaced = true
break
}
}
if !replaced {
f.Meta = append(f.Meta, &cmtBlock)
}
return saveFlacFile(f, filePath)
}
func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error {
f, err := parseFlacFile(filePath)
// replaceFlacPictures strips all Picture blocks and appends a new front cover
// built from coverData. On error the pictures stay removed and no cover is added.
func replaceFlacPictures(f *flac.File, coverPath string, coverData []byte) error {
for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
}
}
picBlock, err := buildPictureBlock(coverPath, coverData)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
return err
}
defer f.Close()
f.Meta = append(f.Meta, &picBlock)
return nil
}
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
return updateFlacVorbis(filePath, func(f *flac.File, cmt *flacvorbis.MetaDataBlockVorbisComment) error {
writeVorbisMetadata(cmt, metadata)
for idx, meta := range f.Meta {
if meta.Type == flac.VorbisComment {
cmtIdx = idx
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
return fmt.Errorf("failed to parse vorbis comment: %w", err)
}
break
}
}
if cmt == nil {
cmt = flacvorbis.New()
}
writeVorbisMetadata(cmt, metadata)
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
} else {
f.Meta = append(f.Meta, &cmtBlock)
}
if len(coverData) > 0 {
for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
if coverPath != "" {
if fileExists(coverPath) {
coverData, err := os.ReadFile(coverPath)
if err != nil {
fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err)
} else if err := replaceFlacPictures(f, coverPath, coverData); err != nil {
fmt.Printf("[Metadata] Warning: skipping cover art: %v\n", err)
} else {
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
} else {
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
}
}
return nil
})
}
picBlock, err := buildPictureBlock("", coverData)
if err != nil {
fmt.Printf("[Metadata] Warning: skipping cover art: %v\n", err)
} else {
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error {
return updateFlacVorbis(filePath, func(f *flac.File, cmt *flacvorbis.MetaDataBlockVorbisComment) error {
writeVorbisMetadata(cmt, metadata)
if len(coverData) > 0 {
if err := replaceFlacPictures(f, "", coverData); err != nil {
fmt.Printf("[Metadata] Warning: skipping cover art: %v\n", err)
} else {
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
}
}
return saveFlacFile(f, filePath)
return nil
})
}
func ReadMetadata(filePath string) (*Metadata, error) {
@@ -424,55 +408,17 @@ func ReadMetadata(filePath string) (*Metadata, error) {
// absent from the map are left untouched. This is the correct function for
// partial edits (e.g. writing only ReplayGain tags) and full editor saves alike.
func EditFlacFields(filePath string, fields map[string]string) error {
f, err := parseFlacFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
defer f.Close()
return updateFlacVorbis(filePath, func(f *flac.File, cmt *flacvorbis.MetaDataBlockVorbisComment) error {
applyVorbisFieldEdits(cmt, fields)
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
for idx, meta := range f.Meta {
if meta.Type == flac.VorbisComment {
cmtIdx = idx
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
return fmt.Errorf("failed to parse vorbis comment: %w", err)
}
break
}
}
if cmt == nil {
cmt = flacvorbis.New()
}
applyVorbisFieldEdits(cmt, fields)
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
} else {
f.Meta = append(f.Meta, &cmtBlock)
}
coverPath := strings.TrimSpace(fields["cover_path"])
if coverPath != "" && fileExists(coverPath) {
coverData, err := os.ReadFile(coverPath)
if err == nil && len(coverData) > 0 {
for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
}
}
picBlock, err := buildPictureBlock("", coverData)
if err == nil {
f.Meta = append(f.Meta, &picBlock)
coverPath := strings.TrimSpace(fields["cover_path"])
if coverPath != "" && fileExists(coverPath) {
if coverData, err := os.ReadFile(coverPath); err == nil && len(coverData) > 0 {
_ = replaceFlacPictures(f, "", coverData)
}
}
}
return saveFlacFile(f, filePath)
return nil
})
}
// applyVorbisFieldEdits applies the editor's set-or-clear field semantics to a
@@ -711,45 +657,11 @@ func setOrClearArtistComments(cmt *flacvorbis.MetaDataBlockVorbisComment, key, v
// the last value survives when multiple -metadata ARTIST=X flags are used.
// The native go-flac writer correctly handles multiple Vorbis comments.
func RewriteSplitArtistTags(filePath, artist, albumArtist string) error {
if !shouldSplitVorbisArtistTags(artistTagModeSplitVorbis) {
return updateFlacVorbis(filePath, func(_ *flac.File, cmt *flacvorbis.MetaDataBlockVorbisComment) error {
setArtistComments(cmt, "ARTIST", artist, artistTagModeSplitVorbis)
setArtistComments(cmt, "ALBUMARTIST", albumArtist, artistTagModeSplitVorbis)
return nil
}
f, err := parseFlacFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
defer f.Close()
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
for idx, meta := range f.Meta {
if meta.Type == flac.VorbisComment {
cmtIdx = idx
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
return fmt.Errorf("failed to parse vorbis comment: %w", err)
}
break
}
}
if cmt == nil {
cmt = flacvorbis.New()
}
setArtistComments(cmt, "ARTIST", artist, artistTagModeSplitVorbis)
setArtistComments(cmt, "ALBUMARTIST", albumArtist, artistTagModeSplitVorbis)
cmtMeta := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtMeta
} else {
f.Meta = append(f.Meta, &cmtMeta)
}
return saveFlacFile(f, filePath)
})
}
func removeCommentKey(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) {
@@ -884,87 +796,11 @@ func ExtractCoverArt(filePath string) ([]byte, error) {
}
func EmbedLyrics(filePath string, lyrics string) error {
f, err := parseFlacFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
defer f.Close()
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
for idx, meta := range f.Meta {
if meta.Type == flac.VorbisComment {
cmtIdx = idx
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
return fmt.Errorf("failed to parse vorbis comment: %w", err)
}
break
}
}
if cmt == nil {
cmt = flacvorbis.New()
}
setComment(cmt, "LYRICS", lyrics)
setComment(cmt, "UNSYNCEDLYRICS", lyrics)
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
} else {
f.Meta = append(f.Meta, &cmtBlock)
}
return saveFlacFile(f, filePath)
}
func EmbedGenreLabel(filePath string, genre, label string) error {
if genre == "" && label == "" {
return updateFlacVorbis(filePath, func(_ *flac.File, cmt *flacvorbis.MetaDataBlockVorbisComment) error {
setComment(cmt, "LYRICS", lyrics)
setComment(cmt, "UNSYNCEDLYRICS", lyrics)
return nil
}
f, err := parseFlacFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
defer f.Close()
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
for idx, meta := range f.Meta {
if meta.Type == flac.VorbisComment {
cmtIdx = idx
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
return fmt.Errorf("failed to parse vorbis comment: %w", err)
}
break
}
}
if cmt == nil {
cmt = flacvorbis.New()
}
if genre != "" {
setComment(cmt, "GENRE", genre)
}
if label != "" {
setComment(cmt, "ORGANIZATION", label)
}
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
} else {
f.Meta = append(f.Meta, &cmtBlock)
}
return saveFlacFile(f, filePath)
})
}
func ExtractLyrics(filePath string) (string, error) {
@@ -89,7 +89,6 @@ func TestMoreSmallConstructorsRuntimeAndMetadataHelpers(t *testing.T) {
if NewIDHSClient().client == nil {
t.Fatal("expected IDHS HTTP client")
}
ClearTrackCache()
vm := goja.New()
runtime := &extensionRuntime{extensionID: "misc-runtime", vm: vm, settings: map[string]any{}}
@@ -159,15 +158,6 @@ func TestMoreSmallConstructorsRuntimeAndMetadataHelpers(t *testing.T) {
if string(mustReadFile(t, coverPath)) != "cover" {
t.Fatal("downloaded cover mismatch")
}
parallel := FetchCoverAndLyricsParallel(server.URL+"/cover.jpg", false, "spotify-1", "Song Instrumental", "Artist", true, 180000)
if string(parallel.CoverData) != "cover" || parallel.CoverErr != nil || parallel.LyricsErr == nil {
t.Fatalf("FetchCoverAndLyricsParallel = %#v", parallel)
}
emptyParallel := FetchCoverAndLyricsParallel("", false, "", "", "", false, 0)
if emptyParallel.CoverData != nil || emptyParallel.LyricsData != nil {
t.Fatalf("empty FetchCoverAndLyricsParallel = %#v", emptyParallel)
}
}
func TestExtensionHealthInitializeVMAndCustomSearchWrappers(t *testing.T) {
-114
View File
@@ -1,114 +0,0 @@
package gobackend
import (
"encoding/json"
"fmt"
"sync"
)
type ParallelDownloadResult struct {
CoverData []byte
LyricsData *LyricsResponse
LyricsLRC string
CoverErr error
LyricsErr error
}
func FetchCoverAndLyricsParallel(
coverURL string,
maxQualityCover bool,
spotifyID string,
trackName string,
artistName string,
embedLyrics bool,
durationMs int64,
) *ParallelDownloadResult {
result := &ParallelDownloadResult{}
var wg sync.WaitGroup
var resultMu sync.Mutex
if coverURL != "" {
wg.Add(1)
go func() {
defer wg.Done()
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
resultMu.Lock()
if err != nil {
result.CoverErr = err
} else {
result.CoverData = data
}
resultMu.Unlock()
}()
}
if embedLyrics {
wg.Add(1)
go func() {
defer wg.Done()
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
resultMu.Lock()
if err != nil {
result.LyricsErr = err
} else if lyrics != nil && len(lyrics.Lines) > 0 {
result.LyricsData = lyrics
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
} else {
result.LyricsErr = fmt.Errorf("no lyrics found")
}
resultMu.Unlock()
}()
}
wg.Wait()
return result
}
type PreWarmCacheRequest struct {
ISRC string
TrackName string
ArtistName string
SpotifyID string
Service string
}
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
_ = requests
}
func PreWarmCache(tracksJSON string) error {
var tracks []struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
SpotifyID string `json:"spotify_id"`
Service string `json:"service"`
}
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
return fmt.Errorf("failed to parse tracks JSON: %w", err)
}
requests := make([]PreWarmCacheRequest, len(tracks))
for i, t := range tracks {
requests[i] = PreWarmCacheRequest{
ISRC: t.ISRC,
TrackName: t.TrackName,
ArtistName: t.ArtistName,
SpotifyID: t.SpotifyID,
Service: t.Service,
}
}
go PreWarmTrackCache(requests)
return nil
}
func ClearTrackCache() {
}
func GetCacheSize() int {
return 0
}
+11 -11
View File
@@ -193,17 +193,6 @@ func GetMultiProgressDelta(sinceSeq int64) string {
return string(jsonBytes)
}
func GetItemProgress(itemID string) string {
multiMu.RLock()
defer multiMu.RUnlock()
if item, ok := multiProgress.Items[itemID]; ok {
jsonBytes, _ := json.Marshal(item)
return string(jsonBytes)
}
return "{}"
}
func StartItemProgress(itemID string) {
multiMu.Lock()
defer multiMu.Unlock()
@@ -359,6 +348,17 @@ func RemoveItemProgress(itemID string) {
markMultiProgressDirtyLocked()
}
func GetItemProgress(itemID string) string {
multiMu.RLock()
defer multiMu.RUnlock()
if item, ok := multiProgress.Items[itemID]; ok {
jsonBytes, _ := json.Marshal(item)
return string(jsonBytes)
}
return "{}"
}
func ClearAllItemProgress() {
multiMu.Lock()
defer multiMu.Unlock()
-4
View File
@@ -90,7 +90,3 @@ func (r *RateLimiter) Available() int {
// Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10)
var songLinkRateLimiter = NewRateLimiter(9, time.Minute)
func GetSongLinkRateLimiter() *RateLimiter {
return songLinkRateLimiter
}
-193
View File
@@ -1,193 +0,0 @@
package gobackend
import (
"strings"
"unicode"
)
var hiraganaToRomaji = map[rune]string{
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
'や': "ya", 'ゆ': "yu", 'よ': "yo",
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
'わ': "wa", 'を': "wo", 'ん': "n",
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
'っ': "",
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
}
var katakanaToRomaji = map[rune]string{
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", '': "no",
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
'ッ': "",
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
'ー': "",
'ヴ': "vu",
}
var combinationHiragana = map[string]string{
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
"にゃ": "nya", "にゅ": "nyu", "にょ": "nyo",
"ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo",
"みゃ": "mya", "みゅ": "myu", "みょ": "myo",
"りゃ": "rya", "りゅ": "ryu", "りょ": "ryo",
"ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo",
"じゃ": "ja", "じゅ": "ju", "じょ": "jo",
"びゃ": "bya", "びゅ": "byu", "びょ": "byo",
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
}
var combinationKatakana = map[string]string{
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
"シャ": "sha", "シュ": "shu", "ショ": "sho",
"チャ": "cha", "チュ": "chu", "チョ": "cho",
"ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo",
"ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo",
"ミャ": "mya", "ミュ": "myu", "ミョ": "myo",
"リャ": "rya", "リュ": "ryu", "リョ": "ryo",
"ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo",
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
}
func ContainsJapanese(s string) bool {
for _, r := range s {
if isHiragana(r) || isKatakana(r) || isKanji(r) {
return true
}
}
return false
}
func isHiragana(r rune) bool {
return r >= 0x3040 && r <= 0x309F
}
func isKatakana(r rune) bool {
return r >= 0x30A0 && r <= 0x30FF
}
func isKanji(r rune) bool {
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
}
func JapaneseToRomaji(text string) string {
if !ContainsJapanese(text) {
return text
}
var result strings.Builder
runes := []rune(text)
i := 0
for i < len(runes) {
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
nextRomaji := ""
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
nextRomaji = romaji
} else if romaji, ok := katakanaToRomaji[runes[i+1]]; ok {
nextRomaji = romaji
}
if len(nextRomaji) > 0 {
result.WriteByte(nextRomaji[0])
}
i++
continue
}
if i < len(runes)-1 {
combo := string(runes[i : i+2])
if romaji, ok := combinationHiragana[combo]; ok {
result.WriteString(romaji)
i += 2
continue
}
if romaji, ok := combinationKatakana[combo]; ok {
result.WriteString(romaji)
i += 2
continue
}
}
r := runes[i]
if romaji, ok := hiraganaToRomaji[r]; ok {
result.WriteString(romaji)
} else if romaji, ok := katakanaToRomaji[r]; ok {
result.WriteString(romaji)
} else if isKanji(r) {
result.WriteRune(r)
} else {
result.WriteRune(r)
}
i++
}
return result.String()
}
func BuildSearchQuery(trackName, artistName string) string {
trackRomaji := JapaneseToRomaji(trackName)
artistRomaji := JapaneseToRomaji(artistName)
trackClean := cleanSearchQuery(trackRomaji)
artistClean := cleanSearchQuery(artistRomaji)
return strings.TrimSpace(artistClean + " " + trackClean)
}
func cleanSearchQuery(s string) string {
var result strings.Builder
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsSpace(r) {
result.WriteRune(r)
} else if r == '-' || r == '\'' {
result.WriteRune(r)
}
}
return strings.TrimSpace(result.String())
}
func CleanToASCII(s string) string {
var result strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
result.WriteRune(r)
} else if r == ',' || r == '.' {
result.WriteRune(' ')
}
}
cleaned := strings.Join(strings.Fields(result.String()), " ")
return strings.TrimSpace(cleaned)
}
+1 -32
View File
@@ -447,14 +447,6 @@ import Gobackend
var error: NSError?
switch call.method {
case "checkAvailability":
let args = call.arguments as! [String: Any]
let spotifyId = args["spotify_id"] as! String
let isrc = args["isrc"] as! String
let response = GobackendCheckAvailability(spotifyId, isrc, &error)
if let error = error { throw error }
return response
case "downloadByStrategy":
let requestJson = call.arguments as! String
let response = GobackendDownloadByStrategy(requestJson, &error)
@@ -706,22 +698,6 @@ import Gobackend
if let error = error { throw error }
return response
case "checkAvailabilityFromDeezerID":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
let response = GobackendCheckAvailabilityFromDeezerID(deezerTrackId, &error)
if let error = error { throw error }
return response
case "checkAvailabilityByPlatformID":
let args = call.arguments as! [String: Any]
let platform = args["platform"] as! String
let entityType = args["entity_type"] as! String
let entityId = args["entity_id"] as! String
let response = GobackendCheckAvailabilityByPlatformID(platform, entityType, entityId, &error)
if let error = error { throw error }
return response
case "getSpotifyIDFromDeezerTrack":
let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String
@@ -736,19 +712,12 @@ import Gobackend
if let error = error { throw error }
return response
case "preWarmTrackCache":
let args = call.arguments as! [String: Any]
let tracksJson = args["tracks"] as! String
let _ = GobackendPreWarmTrackCacheJSON(tracksJson, &error)
if let error = error { throw error }
return nil
case "getTrackCacheSize":
let response = GobackendGetTrackCacheSize()
return response
case "clearTrackCache":
GobackendClearTrackCache()
GobackendClearTrackIDCache()
return nil
case "getLogs":
-28
View File
@@ -674,34 +674,6 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
Future<void> checkAvailability(int index) async {
if (index < 0 || index >= state.tracks.length) return;
final track = state.tracks[index];
if (track.isrc == null || track.isrc!.isEmpty) return;
try {
final availability = await PlatformBridge.checkAvailability(
track.id,
track.isrc!,
);
final updatedTrack = track.copyWith(
availability: ServiceAvailability(
tidal: availability['tidal'] as bool? ?? false,
qobuz: availability['qobuz'] as bool? ?? false,
amazon: availability['amazon'] as bool? ?? false,
tidalUrl: availability['tidal_url'] as String?,
qobuzUrl: availability['qobuz_url'] as String?,
amazonUrl: availability['amazon_url'] as String?,
),
);
final tracks = List<Track>.from(state.tracks);
tracks[index] = updatedTrack;
state = state.copyWith(tracks: tracks);
} catch (_) {}
}
void clear() {
state = const TrackState();
}
+3 -57
View File
@@ -66,13 +66,10 @@ class PlatformBridge {
static const _jsonResultFileKey = '__json_file';
static const _backgroundJsonDecodeThresholdBytes = 128 * 1024;
static const _metadataCacheTtl = Duration(minutes: 20);
static const _availabilityCacheTtl = Duration(minutes: 15);
static const _urlHandleCacheTtl = Duration(minutes: 5);
static const _customSearchCacheTtl = Duration(minutes: 2);
static const _bridgeCacheMaxEntries = 256;
static const _metadataPersistentCacheKey = 'bridge_metadata_lookup_cache_v1';
static const _availabilityPersistentCacheKey =
'bridge_availability_lookup_cache_v1';
static const _downloadProgressEvents = EventChannel(
'com.zarz.spotiflac/download_progress_stream',
);
@@ -80,12 +77,9 @@ class PlatformBridge {
'com.zarz.spotiflac/library_scan_progress_stream',
);
static final Map<String, _BridgeCacheEntry> _metadataCache = {};
static final Map<String, _BridgeCacheEntry> _availabilityCache = {};
static final Map<String, _BridgeCacheEntry> _urlHandleCache = {};
static final Map<String, _BridgeListCacheEntry> _customSearchCache = {};
static final Map<String, Future<Map<String, dynamic>>> _metadataInFlight = {};
static final Map<String, Future<Map<String, dynamic>>> _availabilityInFlight =
{};
static final Map<String, Future<Map<String, dynamic>?>> _urlHandleInFlight =
{};
static final Map<String, _BridgeInFlight<List<Map<String, dynamic>>>>
@@ -135,30 +129,6 @@ class PlatformBridge {
});
}
static Future<Map<String, dynamic>> checkAvailability(
String spotifyId,
String isrc,
) {
Future<Map<String, dynamic>> load() {
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
return _invokeMap('checkAvailability', {
'spotify_id': spotifyId,
'isrc': isrc,
});
}
final cacheKey = _availabilityCacheKey(spotifyId, isrc);
if (cacheKey.isEmpty) return load();
return _cachedInvoke(
cacheKey,
_availabilityCache,
_availabilityInFlight,
_availabilityCacheTtl,
_availabilityPersistentCacheKey,
load,
);
}
static Future<Map<String, dynamic>> _invokeMap(
String method, [
dynamic args,
@@ -198,16 +168,6 @@ class PlatformBridge {
}
}
static String _availabilityCacheKey(String spotifyId, String isrc) {
final normalizedIsrc = isrc.trim().toUpperCase();
if (normalizedIsrc.isNotEmpty) {
return 'isrc:$normalizedIsrc';
}
final normalizedSpotifyId = spotifyId.trim();
if (normalizedSpotifyId.isEmpty) return '';
return 'spotify:$normalizedSpotifyId';
}
static String _providerMetadataCacheKey(
String providerId,
String resourceType,
@@ -322,11 +282,6 @@ class PlatformBridge {
_metadataPersistentCacheKey,
_metadataCache,
);
_restorePersistentCache(
prefs,
_availabilityPersistentCacheKey,
_availabilityCache,
);
} catch (e) {
_log.w('Failed to load bridge lookup cache: $e');
}
@@ -390,7 +345,8 @@ class PlatformBridge {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_metadataPersistentCacheKey);
await prefs.remove(_availabilityPersistentCacheKey);
// Legacy key from the removed availability-check feature.
await prefs.remove('bridge_availability_lookup_cache_v1');
} catch (e) {
_log.w('Failed to clear bridge lookup cache: $e');
}
@@ -400,11 +356,9 @@ class PlatformBridge {
_lookupCacheGeneration++;
_persistentLookupCacheLoadFuture = null;
_metadataCache.clear();
_availabilityCache.clear();
_urlHandleCache.clear();
_customSearchCache.clear();
_metadataInFlight.clear();
_availabilityInFlight.clear();
_urlHandleInFlight.clear();
for (final inFlight in _customSearchInFlight.values) {
_cancelExtensionRequestUnawaited(inFlight.requestId);
@@ -462,8 +416,7 @@ class PlatformBridge {
static int _lookupCacheSize() {
_pruneExpiredBridgeCache(_metadataCache);
_pruneExpiredBridgeCache(_availabilityCache);
return _metadataCache.length + _availabilityCache.length;
return _metadataCache.length;
}
static Future<Map<String, dynamic>> _invokeDownloadMethod(
@@ -1081,13 +1034,6 @@ class PlatformBridge {
return _decodeMapResult(result);
}
static Future<void> preWarmTrackCache(
List<Map<String, String>> tracks,
) async {
final tracksJson = jsonEncode(tracks);
await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson});
}
static Future<int> getTrackCacheSize() async {
await _ensurePersistentLookupCachesLoaded();
final result = await _channel.invokeMethod('getTrackCacheSize');