mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
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:
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user