diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index d86ceced..78229d30 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -2946,8 +2946,9 @@ class MainActivity: FlutterFragmentActivity() { } "searchDeezerByISRC" -> { val isrc = call.argument("isrc") ?: "" + val itemId = call.argument("item_id") ?: "" val response = withContext(Dispatchers.IO) { - Gobackend.searchDeezerByISRC(isrc) + Gobackend.searchDeezerByISRCForItemID(isrc, itemId) } result.success(response) } diff --git a/go_backend/cancel.go b/go_backend/cancel.go index 25f69cca..5369aae9 100644 --- a/go_backend/cancel.go +++ b/go_backend/cancel.go @@ -13,6 +13,7 @@ type cancelEntry struct { ctx context.Context cancel context.CancelFunc canceled bool + refs int } var ( @@ -37,6 +38,7 @@ func initDownloadCancel(itemID string) context.Context { entry.cancel() } } + entry.refs++ return entry.ctx } @@ -45,6 +47,7 @@ func initDownloadCancel(itemID string) context.Context { ctx: ctx, cancel: cancel, canceled: false, + refs: 1, } return ctx } @@ -87,6 +90,11 @@ func clearDownloadCancel(itemID string) { } cancelMu.Lock() - delete(cancelMap, itemID) + if entry, ok := cancelMap[itemID]; ok { + entry.refs-- + if entry.refs <= 0 { + delete(cancelMap, itemID) + } + } cancelMu.Unlock() } diff --git a/go_backend/exports.go b/go_backend/exports.go index 443905e3..2a62c848 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -50,6 +50,22 @@ type musicBrainzRecordingResponse struct { } `json:"recordings"` } +type musicBrainzArtistCredit struct { + Name string `json:"name"` + JoinPhrase string `json:"joinphrase"` +} + +type musicBrainzRelease struct { + Title string `json:"title"` + ArtistCredit []musicBrainzArtistCredit `json:"artist-credit"` +} + +type musicBrainzAlbumArtistResponse struct { + Recordings []struct { + Releases []musicBrainzRelease `json:"releases"` + } `json:"recordings"` +} + func formatMusicBrainzGenre(tags []musicBrainzTag) string { if len(tags) == 0 { return "" @@ -82,6 +98,105 @@ func formatMusicBrainzGenre(tags []musicBrainzTag) string { return bestTag } +func formatMusicBrainzArtistCredit(credits []musicBrainzArtistCredit) string { + var builder strings.Builder + for _, credit := range credits { + name := strings.TrimSpace(credit.Name) + if name == "" { + continue + } + builder.WriteString(name) + builder.WriteString(credit.JoinPhrase) + } + return strings.TrimSpace(builder.String()) +} + +func selectMusicBrainzAlbumArtist(releases []musicBrainzRelease, albumName string) string { + if len(releases) == 0 { + return "" + } + + normalizedAlbum := strings.ToLower(strings.TrimSpace(albumName)) + if normalizedAlbum != "" { + for _, release := range releases { + if strings.ToLower(strings.TrimSpace(release.Title)) != normalizedAlbum { + continue + } + if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" { + return albumArtist + } + } + } + + for _, release := range releases { + if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" { + return albumArtist + } + } + + return "" +} + +func FetchMusicBrainzAlbumArtistByISRC(isrc string, albumName string) (string, error) { + normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc)) + if normalizedISRC == "" { + return "", fmt.Errorf("no ISRC provided") + } + + client := NewMetadataHTTPClient(10 * time.Second) + query := fmt.Sprintf("isrc:%s", normalizedISRC) + reqURL := fmt.Sprintf( + "%s/recording?query=%s&fmt=json&inc=releases+artist-credits", + musicBrainzAPIBase, + url.QueryEscape(query), + ) + + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", getRandomUserAgent()) + + var resp *http.Response + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + resp, lastErr = client.Do(req) + if lastErr == nil && resp.StatusCode == http.StatusOK { + break + } + if resp != nil { + resp.Body.Close() + } + if attempt < 2 { + time.Sleep(2 * time.Second) + } + } + + if lastErr != nil { + return "", lastErr + } + if resp == nil { + return "", fmt.Errorf("MusicBrainz request failed without response") + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return "", fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode) + } + defer resp.Body.Close() + + var payload musicBrainzAlbumArtistResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", err + } + for _, recording := range payload.Recordings { + if albumArtist := selectMusicBrainzAlbumArtist(recording.Releases, albumName); albumArtist != "" { + return albumArtist, nil + } + } + + return "", fmt.Errorf("no MusicBrainz album artist found for ISRC: %s", normalizedISRC) +} + func FetchMusicBrainzGenreByISRC(isrc string) (string, error) { normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc)) if normalizedISRC == "" { @@ -244,6 +359,8 @@ var fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) ( var fetchMusicBrainzGenreByISRC = FetchMusicBrainzGenreByISRC +var fetchMusicBrainzAlbumArtistByISRC = FetchMusicBrainzAlbumArtistByISRC + type reEnrichRequest struct { FilePath string `json:"file_path"` CoverURL string `json:"cover_url"` @@ -870,17 +987,29 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) { return } - if req.ISRC == "" || (req.Genre != "" && req.Label != "" && req.Copyright != "") { + if req.ISRC == "" { return } - enrichExtraMetadataByISRC( - "DownloadWithFallback", - req.ISRC, - &req.Genre, - &req.Label, - &req.Copyright, - ) + if strings.TrimSpace(req.AlbumArtist) == "" { + albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName) + if err != nil { + GoLog("[DownloadWithFallback] Failed to get album artist from MusicBrainz: %v\n", err) + } else if strings.TrimSpace(albumArtist) != "" { + req.AlbumArtist = strings.TrimSpace(albumArtist) + GoLog("[DownloadWithFallback] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist) + } + } + + if req.Genre == "" || req.Label == "" || req.Copyright == "" { + enrichExtraMetadataByISRC( + "DownloadWithFallback", + req.ISRC, + &req.Genre, + &req.Label, + &req.Copyright, + ) + } } func applySongLinkRegionFromRequest(req *DownloadRequest) { @@ -897,6 +1026,13 @@ func DownloadTrack(requestJSON string) (string, error) { } applySongLinkRegionFromRequest(&req) defer closeOwnedOutputFD(req.OutputFD) + if req.ItemID != "" { + initDownloadCancel(req.ItemID) + defer clearDownloadCancel(req.ItemID) + if isDownloadCancelled(req.ItemID) { + return errorResponse("Download cancelled") + } + } req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) @@ -911,6 +1047,9 @@ func DownloadTrack(requestJSON string) (string, error) { } enrichRequestExtendedMetadata(&req) + if isDownloadCancelled(req.ItemID) { + return errorResponse("Download cancelled") + } var result DownloadResult var err error @@ -1040,6 +1179,13 @@ func DownloadWithFallback(requestJSON string) (string, error) { } applySongLinkRegionFromRequest(&req) defer closeOwnedOutputFD(req.OutputFD) + if req.ItemID != "" { + initDownloadCancel(req.ItemID) + defer clearDownloadCancel(req.ItemID) + if isDownloadCancelled(req.ItemID) { + return errorResponse("Download cancelled") + } + } req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) @@ -1054,6 +1200,9 @@ func DownloadWithFallback(requestJSON string) (string, error) { } enrichRequestExtendedMetadata(&req) + if isDownloadCancelled(req.ItemID) { + return errorResponse("Download cancelled") + } allServices := []string{"tidal", "qobuz"} preferredService := req.Service @@ -2131,14 +2280,33 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) { } func SearchDeezerByISRC(isrc string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + return SearchDeezerByISRCForItemID(isrc, "") +} + +func SearchDeezerByISRCForItemID(isrc string, itemID string) (string, error) { + parentCtx := context.Background() + if itemID != "" { + parentCtx = initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + if isDownloadCancelled(itemID) { + return "", ErrDownloadCancelled + } + } + + ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second) defer cancel() client := GetDeezerClient() track, err := client.SearchByISRC(ctx, isrc) if err != nil { + if isDownloadCancelled(itemID) { + return "", ErrDownloadCancelled + } return "", err } + if isDownloadCancelled(itemID) { + return "", ErrDownloadCancelled + } result := buildDeezerISRCSearchResult(track) jsonBytes, err := json.Marshal(result) @@ -2515,6 +2683,17 @@ func ReEnrichFile(requestJSON string) (string, error) { GoLog("[ReEnrich] Skipping provider search: no usable title/artist/album query\n") } + if req.shouldUpdateField("basic_tags") && req.AlbumArtist == "" && req.ISRC != "" { + albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName) + if err != nil { + GoLog("[ReEnrich] Failed to get album artist from MusicBrainz: %v\n", err) + } else if strings.TrimSpace(albumArtist) != "" { + req.AlbumArtist = strings.TrimSpace(albumArtist) + GoLog("[ReEnrich] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist) + found = true + } + } + // Try to enrich extra metadata from ISRC if not already set. if found && req.ISRC != "" && req.shouldUpdateField("extra") && (req.Genre == "" || req.Label == "" || req.Copyright == "") { enrichExtraMetadataByISRC("ReEnrich", req.ISRC, &req.Genre, &req.Label, &req.Copyright) @@ -2954,6 +3133,13 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) { } applySongLinkRegionFromRequest(&req) defer closeOwnedOutputFD(req.OutputFD) + if req.ItemID != "" { + initDownloadCancel(req.ItemID) + defer clearDownloadCancel(req.ItemID) + if isDownloadCancelled(req.ItemID) { + return "", ErrDownloadCancelled + } + } req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) @@ -2966,6 +3152,11 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) { AddAllowedDownloadDir(req.OutputDir) } + enrichRequestExtendedMetadata(&req) + if isDownloadCancelled(req.ItemID) { + return "", ErrDownloadCancelled + } + result, err := DownloadWithExtensionFallback(req) if err != nil { return "", err diff --git a/go_backend/exports_test.go b/go_backend/exports_test.go index 34a5dc59..21fc3bc6 100644 --- a/go_backend/exports_test.go +++ b/go_backend/exports_test.go @@ -2,6 +2,7 @@ package gobackend import ( "context" + "fmt" "testing" ) @@ -176,6 +177,98 @@ func TestFormatMusicBrainzGenrePrefersHighestCountTag(t *testing.T) { } } +func TestSelectMusicBrainzAlbumArtistPrefersMatchingRelease(t *testing.T) { + releases := []musicBrainzRelease{ + { + Title: "Other Album", + ArtistCredit: []musicBrainzArtistCredit{ + {Name: "Wrong Artist"}, + }, + }, + { + Title: "Target Album", + ArtistCredit: []musicBrainzArtistCredit{ + {Name: "Artist A", JoinPhrase: " & "}, + {Name: "Artist B"}, + }, + }, + } + + got := selectMusicBrainzAlbumArtist(releases, "Target Album") + if got != "Artist A & Artist B" { + t.Fatalf("album artist = %q, want matching release artist credit", got) + } +} + +func TestEnrichRequestExtendedMetadataUsesMusicBrainzAlbumArtist(t *testing.T) { + origDeezerFetcher := fetchDeezerExtendedMetadataByISRC + origMusicBrainzGenreFetcher := fetchMusicBrainzGenreByISRC + origMusicBrainzAlbumArtistFetcher := fetchMusicBrainzAlbumArtistByISRC + defer func() { + fetchDeezerExtendedMetadataByISRC = origDeezerFetcher + fetchMusicBrainzGenreByISRC = origMusicBrainzGenreFetcher + fetchMusicBrainzAlbumArtistByISRC = origMusicBrainzAlbumArtistFetcher + }() + + fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) { + return &AlbumExtendedMetadata{}, nil + } + fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) { + return "", fmt.Errorf("no genre") + } + fetchMusicBrainzAlbumArtistByISRC = func(isrc string, albumName string) (string, error) { + if isrc != "TESTISRC" || albumName != "Target Album" { + t.Fatalf("unexpected MusicBrainz args: %q / %q", isrc, albumName) + } + return "MusicBrainz Album Artist", nil + } + + req := DownloadRequest{ + ISRC: "TESTISRC", + ArtistName: "Track Artist", + AlbumName: "Target Album", + } + + enrichRequestExtendedMetadata(&req) + + if req.AlbumArtist != "MusicBrainz Album Artist" { + t.Fatalf("album artist = %q, want MusicBrainz value", req.AlbumArtist) + } +} + +func TestEnrichRequestExtendedMetadataDoesNotFallbackAlbumArtistToTrackArtist(t *testing.T) { + origDeezerFetcher := fetchDeezerExtendedMetadataByISRC + origMusicBrainzGenreFetcher := fetchMusicBrainzGenreByISRC + origMusicBrainzAlbumArtistFetcher := fetchMusicBrainzAlbumArtistByISRC + defer func() { + fetchDeezerExtendedMetadataByISRC = origDeezerFetcher + fetchMusicBrainzGenreByISRC = origMusicBrainzGenreFetcher + fetchMusicBrainzAlbumArtistByISRC = origMusicBrainzAlbumArtistFetcher + }() + + fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) { + return &AlbumExtendedMetadata{}, nil + } + fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) { + return "", fmt.Errorf("no genre") + } + fetchMusicBrainzAlbumArtistByISRC = func(isrc string, albumName string) (string, error) { + return "", fmt.Errorf("no album artist") + } + + req := DownloadRequest{ + ISRC: "TESTISRC", + ArtistName: "Track Artist", + AlbumName: "Target Album", + } + + enrichRequestExtendedMetadata(&req) + + if req.AlbumArtist != "" { + t.Fatalf("album artist = %q, want empty when MusicBrainz has no value", req.AlbumArtist) + } +} + func TestEnrichExtraMetadataByISRCFallsBackToMusicBrainzGenre(t *testing.T) { origDeezerFetcher := fetchDeezerExtendedMetadataByISRC origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 034244b8..5adc06f5 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -227,6 +227,10 @@ func (p *extensionProviderWrapper) lockReadyVM() error { } func (p *extensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) { + return p.SearchTracksForItemID(query, limit, "") +} + +func (p *extensionProviderWrapper) SearchTracksForItemID(query string, limit int, itemID string) (*ExtSearchResult, error) { if !p.extension.Manifest.IsMetadataProvider() { return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) } @@ -238,6 +242,17 @@ func (p *extensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe return nil, err } defer p.extension.VMMu.Unlock() + if itemID != "" { + if p.extension.runtime != nil { + p.extension.runtime.setActiveDownloadItemID(itemID) + defer p.extension.runtime.clearActiveDownloadItemID() + } + initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } + } script := fmt.Sprintf(` (function() { @@ -250,11 +265,17 @@ func (p *extensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } if IsTimeoutError(err) { return nil, fmt.Errorf("searchTracks timeout: extension took too long to respond") } return nil, fmt.Errorf("searchTracks failed: %w", err) } + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { return nil, fmt.Errorf("searchTracks returned null") @@ -443,6 +464,10 @@ func (p *extensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat } func (p *extensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) { + return p.EnrichTrackForItemID(track, "") +} + +func (p *extensionProviderWrapper) EnrichTrackForItemID(track *ExtTrackMetadata, itemID string) (*ExtTrackMetadata, error) { if !p.extension.Manifest.IsMetadataProvider() { return track, nil } @@ -455,6 +480,17 @@ func (p *extensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra return track, nil } defer p.extension.VMMu.Unlock() + if itemID != "" { + if p.extension.runtime != nil { + p.extension.runtime.setActiveDownloadItemID(itemID) + defer p.extension.runtime.clearActiveDownloadItemID() + } + initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + if isDownloadCancelled(itemID) { + return track, ErrDownloadCancelled + } + } trackJSON, err := json.Marshal(track) if err != nil { @@ -474,6 +510,9 @@ func (p *extensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if isDownloadCancelled(itemID) { + return track, ErrDownloadCancelled + } if IsTimeoutError(err) { GoLog("[Extension] EnrichTrack timeout for %s\n", p.extension.ID) } else { @@ -481,6 +520,9 @@ func (p *extensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra } return track, nil } + if isDownloadCancelled(itemID) { + return track, ErrDownloadCancelled + } if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { return track, nil @@ -505,6 +547,10 @@ func (p *extensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra } func (p *extensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID string) (*ExtAvailabilityResult, error) { + return p.CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID, "") +} + +func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID string, itemID string) (*ExtAvailabilityResult, error) { if !p.extension.Manifest.IsDownloadProvider() { return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) } @@ -516,6 +562,17 @@ func (p *extensionProviderWrapper) CheckAvailability(isrc, trackName, artistName return nil, err } defer p.extension.VMMu.Unlock() + if itemID != "" { + if p.extension.runtime != nil { + p.extension.runtime.setActiveDownloadItemID(itemID) + defer p.extension.runtime.clearActiveDownloadItemID() + } + initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } + } script := fmt.Sprintf(` (function() { @@ -528,11 +585,17 @@ func (p *extensionProviderWrapper) CheckAvailability(isrc, trackName, artistName result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } if IsTimeoutError(err) { return nil, fmt.Errorf("checkAvailability timeout: extension took too long to respond") } return nil, fmt.Errorf("checkAvailability failed: %w", err) } + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { return &ExtAvailabilityResult{Available: false, Reason: "not implemented"}, nil @@ -785,6 +848,38 @@ var metadataProviderPriorityMu sync.RWMutex var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks +func searchBuiltInMetadataTracksForItemID(providerID, query string, limit int, itemID string) ([]ExtTrackMetadata, error) { + if itemID == "" { + return searchBuiltInMetadataTracksFunc(providerID, query, limit) + } + + ctx := initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } + + type searchResult struct { + tracks []ExtTrackMetadata + err error + } + done := make(chan searchResult, 1) + go func() { + tracks, err := searchBuiltInMetadataTracksFunc(providerID, query, limit) + done <- searchResult{tracks: tracks, err: err} + }() + + select { + case <-ctx.Done(): + return nil, ErrDownloadCancelled + case result := <-done: + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } + return result.tracks, result.err + } +} + func SetProviderPriority(providerIDs []string) { providerPriorityMu.Lock() defer providerPriorityMu.Unlock() @@ -816,6 +911,9 @@ func sanitizeDownloadProviderPriority(providerIDs []string) []string { } normalizedBuiltIn := strings.ToLower(providerID) + if normalizedBuiltIn == "deezer" { + continue + } if isBuiltInDownloadProvider(normalizedBuiltIn) { providerID = normalizedBuiltIn } @@ -1036,6 +1134,10 @@ func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrac } func (m *extensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) { + return m.SearchTracksWithMetadataProvidersForItemID(query, limit, includeExtensions, "") +} + +func (m *extensionManager) SearchTracksWithMetadataProvidersForItemID(query string, limit int, includeExtensions bool, itemID string) ([]ExtTrackMetadata, error) { priority := GetMetadataProviderPriority() if limit <= 0 { limit = 20 @@ -1073,13 +1175,20 @@ func (m *extensionManager) SearchTracksWithMetadataProviders(query string, limit tracks := make([]ExtTrackMetadata, 0, limit) seenTracks := make(map[string]struct{}) for _, providerID := range orderedProviderIDs { + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } + var ( providerTracks []ExtTrackMetadata err error ) if isBuiltInProvider(providerID) { - providerTracks, err = searchBuiltInMetadataTracksFunc(providerID, query, limit) + providerTracks, err = searchBuiltInMetadataTracksForItemID(providerID, query, limit, itemID) + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } } else { if !includeExtensions { continue @@ -1089,13 +1198,16 @@ func (m *extensionManager) SearchTracksWithMetadataProviders(query string, limit continue } var result *ExtSearchResult - result, err = provider.SearchTracks(query, limit) + result, err = provider.SearchTracksForItemID(query, limit, itemID) if result != nil { providerTracks = result.Tracks } } if err != nil { + if errors.Is(err, ErrDownloadCancelled) { + return nil, ErrDownloadCancelled + } GoLog("[MetadataSearch] Search error from %s: %v\n", providerID, err) continue } @@ -1125,6 +1237,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro strictMode := !req.UseFallback selectedProvider := strings.TrimSpace(req.Service) + if isDownloadCancelled(req.ItemID) { + return nil, ErrDownloadCancelled + } + if strictMode { if selectedProvider == "" { selectedProvider = strings.TrimSpace(req.Source) @@ -1193,7 +1309,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Composer: req.Composer, } - enrichedTrack, err := provider.EnrichTrack(trackMeta) + enrichedTrack, err := provider.EnrichTrackForItemID(trackMeta, req.ItemID) + if errors.Is(err, ErrDownloadCancelled) { + return nil, ErrDownloadCancelled + } if err == nil && enrichedTrack != nil { if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC { GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC) @@ -1282,7 +1401,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro searchQuery := req.TrackName + " " + req.ArtistName GoLog("[DownloadWithExtensionFallback] Metadata incomplete, searching providers for: %s\n", searchQuery) - tracks, searchErr := extManager.SearchTracksWithMetadataProviders(searchQuery, 5, true) + tracks, searchErr := extManager.SearchTracksWithMetadataProvidersForItemID(searchQuery, 5, true, req.ItemID) + if errors.Is(searchErr, ErrDownloadCancelled) { + return nil, ErrDownloadCancelled + } if searchErr == nil && len(tracks) > 0 { track := tracks[0] GoLog("[DownloadWithExtensionFallback] Metadata match (%s): %s - %s (album: %s, date: %s, isrc: %s)\n", @@ -1340,6 +1462,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) && selectedProvider == req.Source { + if isDownloadCancelled(req.ItemID) { + return nil, ErrDownloadCancelled + } + GoLog("[DownloadWithExtensionFallback] Track source is extension '%s' matching selected provider, trying it first\n", req.Source) ext, err := extManager.GetExtension(req.Source) @@ -1524,6 +1650,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } for _, providerID := range priority { + if isDownloadCancelled(req.ItemID) { + return nil, ErrDownloadCancelled + } + providerID = strings.TrimSpace(providerID) if providerID == "" { continue @@ -1551,6 +1681,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro req.ISRC != "" { GoLog("[DownloadWithExtensionFallback] Enriching extra metadata from ISRC: %s\n", req.ISRC) enrichExtraMetadataByISRC("DownloadWithExtensionFallback", req.ISRC, &req.Genre, &req.Label, &req.Copyright) + if isDownloadCancelled(req.ItemID) { + return nil, ErrDownloadCancelled + } } origQuality := req.Quality @@ -1598,7 +1731,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro provider := newExtensionProviderWrapper(ext) - availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID) + availability, err := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.ItemID) + if errors.Is(err, ErrDownloadCancelled) { + return nil, ErrDownloadCancelled + } if err != nil || !availability.Available { GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID) if err != nil { @@ -1931,6 +2067,10 @@ func canEmbedGenreLabel(filePath string) bool { } func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) { + return p.CustomSearchForItemID(query, options, "") +} + +func (p *extensionProviderWrapper) CustomSearchForItemID(query string, options map[string]interface{}, itemID string) ([]ExtTrackMetadata, error) { if !p.extension.Manifest.HasCustomSearch() { return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID) } @@ -1942,6 +2082,17 @@ func (p *extensionProviderWrapper) CustomSearch(query string, options map[string return nil, err } defer p.extension.VMMu.Unlock() + if itemID != "" { + if p.extension.runtime != nil { + p.extension.runtime.setActiveDownloadItemID(itemID) + defer p.extension.runtime.clearActiveDownloadItemID() + } + initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } + } if options == nil { options = map[string]interface{}{} @@ -1970,11 +2121,17 @@ func (p *extensionProviderWrapper) CustomSearch(query string, options map[string result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } if IsTimeoutError(err) { return nil, fmt.Errorf("customSearch timeout: extension took too long to respond") } return nil, fmt.Errorf("customSearch failed: %w", err) } + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { return []ExtTrackMetadata{}, nil diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6e8498fc..0eea9357 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -507,7 +507,8 @@ import Gobackend // Import Go framework case "searchDeezerByISRC": let args = call.arguments as! [String: Any] let isrc = args["isrc"] as! String - let response = GobackendSearchDeezerByISRC(isrc, &error) + let itemId = args["item_id"] as? String ?? "" + let response = GobackendSearchDeezerByISRCForItemID(isrc, itemId, &error) if let error = error { throw error } return response diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e6871f7f..8d28e121 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5571,6 +5571,18 @@ abstract class AppLocalizations { /// **'Automatically select the best available'** String get extensionsHomeFeedAutoSubtitle; + /// Extensions page - home feed provider option: off + /// + /// In en, this message translates to: + /// **'Off'** + String get extensionsHomeFeedOff; + + /// Extensions page - subtitle for off home feed option + /// + /// In en, this message translates to: + /// **'Do not show the home feed on the main screen'** + String get extensionsHomeFeedOffSubtitle; + /// Extensions page - subtitle for a specific extension home feed option /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 88f2981c..db4980b8 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3281,6 +3281,13 @@ class AppLocalizationsDe extends AppLocalizations { String get extensionsHomeFeedAutoSubtitle => 'Automatically select the best available'; + @override + String get extensionsHomeFeedOff => 'Off'; + + @override + String get extensionsHomeFeedOffSubtitle => + 'Do not show the home feed on the main screen'; + @override String extensionsHomeFeedUse(String extensionName) { return 'Use $extensionName home feed'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 232da15b..0315acb9 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3249,6 +3249,13 @@ class AppLocalizationsEn extends AppLocalizations { String get extensionsHomeFeedAutoSubtitle => 'Automatically select the best available'; + @override + String get extensionsHomeFeedOff => 'Off'; + + @override + String get extensionsHomeFeedOffSubtitle => + 'Do not show the home feed on the main screen'; + @override String extensionsHomeFeedUse(String extensionName) { return 'Use $extensionName home feed'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index c813f73b..b386974a 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -3249,6 +3249,13 @@ class AppLocalizationsEs extends AppLocalizations { String get extensionsHomeFeedAutoSubtitle => 'Automatically select the best available'; + @override + String get extensionsHomeFeedOff => 'Off'; + + @override + String get extensionsHomeFeedOffSubtitle => + 'Do not show the home feed on the main screen'; + @override String extensionsHomeFeedUse(String extensionName) { return 'Use $extensionName home feed'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 95aa50b3..c7650c61 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -3250,6 +3250,13 @@ class AppLocalizationsFr extends AppLocalizations { String get extensionsHomeFeedAutoSubtitle => 'Automatically select the best available'; + @override + String get extensionsHomeFeedOff => 'Off'; + + @override + String get extensionsHomeFeedOffSubtitle => + 'Do not show the home feed on the main screen'; + @override String extensionsHomeFeedUse(String extensionName) { return 'Use $extensionName home feed'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index d542fc63..3c17805c 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -3248,6 +3248,13 @@ class AppLocalizationsHi extends AppLocalizations { String get extensionsHomeFeedAutoSubtitle => 'Automatically select the best available'; + @override + String get extensionsHomeFeedOff => 'Off'; + + @override + String get extensionsHomeFeedOffSubtitle => + 'Do not show the home feed on the main screen'; + @override String extensionsHomeFeedUse(String extensionName) { return 'Use $extensionName home feed'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 9ff27578..df69b667 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -3259,6 +3259,13 @@ class AppLocalizationsId extends AppLocalizations { String get extensionsHomeFeedAutoSubtitle => 'Automatically select the best available'; + @override + String get extensionsHomeFeedOff => 'Off'; + + @override + String get extensionsHomeFeedOffSubtitle => + 'Do not show the home feed on the main screen'; + @override String extensionsHomeFeedUse(String extensionName) { return 'Use $extensionName home feed'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index baa3777e..3627a150 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -3235,6 +3235,13 @@ class AppLocalizationsJa extends AppLocalizations { String get extensionsHomeFeedAutoSubtitle => 'Automatically select the best available'; + @override + String get extensionsHomeFeedOff => 'Off'; + + @override + String get extensionsHomeFeedOffSubtitle => + 'Do not show the home feed on the main screen'; + @override String extensionsHomeFeedUse(String extensionName) { return 'Use $extensionName home feed'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index b7f01002..67500ccc 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3228,6 +3228,13 @@ class AppLocalizationsKo extends AppLocalizations { String get extensionsHomeFeedAutoSubtitle => 'Automatically select the best available'; + @override + String get extensionsHomeFeedOff => 'Off'; + + @override + String get extensionsHomeFeedOffSubtitle => + 'Do not show the home feed on the main screen'; + @override String extensionsHomeFeedUse(String extensionName) { return 'Use $extensionName home feed'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index d89b3158..b095b648 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -3248,6 +3248,13 @@ class AppLocalizationsNl extends AppLocalizations { String get extensionsHomeFeedAutoSubtitle => 'Automatically select the best available'; + @override + String get extensionsHomeFeedOff => 'Off'; + + @override + String get extensionsHomeFeedOffSubtitle => + 'Do not show the home feed on the main screen'; + @override String extensionsHomeFeedUse(String extensionName) { return 'Use $extensionName home feed'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index cd81ead6..5ac4d5af 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -3249,6 +3249,13 @@ class AppLocalizationsPt extends AppLocalizations { String get extensionsHomeFeedAutoSubtitle => 'Automatically select the best available'; + @override + String get extensionsHomeFeedOff => 'Off'; + + @override + String get extensionsHomeFeedOffSubtitle => + 'Do not show the home feed on the main screen'; + @override String extensionsHomeFeedUse(String extensionName) { return 'Use $extensionName home feed'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 73c69db7..ee958fe2 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3308,6 +3308,13 @@ class AppLocalizationsRu extends AppLocalizations { String get extensionsHomeFeedAutoSubtitle => 'Automatically select the best available'; + @override + String get extensionsHomeFeedOff => 'Off'; + + @override + String get extensionsHomeFeedOffSubtitle => + 'Do not show the home feed on the main screen'; + @override String extensionsHomeFeedUse(String extensionName) { return 'Use $extensionName home feed'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index eda85991..eda39c2b 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -3306,6 +3306,13 @@ class AppLocalizationsTr extends AppLocalizations { String get extensionsHomeFeedAutoSubtitle => 'Automatically select the best available'; + @override + String get extensionsHomeFeedOff => 'Off'; + + @override + String get extensionsHomeFeedOffSubtitle => + 'Do not show the home feed on the main screen'; + @override String extensionsHomeFeedUse(String extensionName) { return 'Use $extensionName home feed'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f6230541..84d89e4a 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -3249,6 +3249,13 @@ class AppLocalizationsZh extends AppLocalizations { String get extensionsHomeFeedAutoSubtitle => 'Automatically select the best available'; + @override + String get extensionsHomeFeedOff => 'Off'; + + @override + String get extensionsHomeFeedOffSubtitle => + 'Do not show the home feed on the main screen'; + @override String extensionsHomeFeedUse(String extensionName) { return 'Use $extensionName home feed'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index e3f4a6d0..53ea72bf 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -4255,6 +4255,14 @@ "@extensionsHomeFeedAutoSubtitle": { "description": "Extensions page - subtitle for auto home feed option" }, + "extensionsHomeFeedOff": "Off", + "@extensionsHomeFeedOff": { + "description": "Extensions page - home feed provider option: off" + }, + "extensionsHomeFeedOffSubtitle": "Do not show the home feed on the main screen", + "@extensionsHomeFeedOffSubtitle": { + "description": "Extensions page - subtitle for off home feed option" + }, "extensionsHomeFeedUse": "Use {extensionName} home feed", "@extensionsHomeFeedUse": { "description": "Extensions page - subtitle for a specific extension home feed option", diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 147c2c20..18c77f73 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -5,6 +5,8 @@ part 'settings.g.dart'; @JsonSerializable() class AppSettings { + static const String homeFeedProviderOff = '__off__'; + final String defaultService; final String audioQuality; final String filenameFormat; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 885a98fc..02a36f3f 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2208,11 +2208,12 @@ class DownloadQueueNotifier extends Notifier { return artist; } - String _resolveAlbumArtistForMetadata(Track track, AppSettings settings) { - var albumArtist = - normalizeOptionalString(track.albumArtist) ?? track.artistName; + String? _resolveAlbumArtistForMetadata(Track track, AppSettings settings) { + var albumArtist = normalizeOptionalString(track.albumArtist); if (settings.filterContributingArtistsInAlbumArtist) { - albumArtist = _extractPrimaryArtist(albumArtist); + albumArtist = albumArtist == null + ? null + : normalizeOptionalString(_extractPrimaryArtist(albumArtist)); } return albumArtist; } @@ -2488,6 +2489,7 @@ class DownloadQueueNotifier extends Notifier { Future _searchDeezerTrackIdByIsrc( String? isrc, { required String lookupContext, + String? itemId, }) async { final normalizedIsrc = normalizeOptionalString(isrc); if (normalizedIsrc == null || !_isValidISRC(normalizedIsrc)) { @@ -2498,6 +2500,7 @@ class DownloadQueueNotifier extends Notifier { _log.d('No Deezer ID, searching by $lookupContext: $normalizedIsrc'); final deezerResult = await PlatformBridge.searchDeezerByISRC( normalizedIsrc, + itemId: itemId, ); if (deezerResult['success'] == true && deezerResult['track_id'] != null) { final deezerTrackId = deezerResult['track_id'].toString(); @@ -2564,6 +2567,7 @@ class DownloadQueueNotifier extends Notifier { Future<_DeezerLookupPreparation> _resolveProviderTrackForDeezerLookup( Track track, + String itemId, ) async { try { final colonIdx = track.id.indexOf(':'); @@ -2608,6 +2612,7 @@ class DownloadQueueNotifier extends Notifier { final deezerTrackId = await _searchDeezerTrackIdByIsrc( resolvedIsrc, lookupContext: '$provider ISRC', + itemId: itemId, ); return _DeezerLookupPreparation( @@ -3347,7 +3352,6 @@ class DownloadQueueNotifier extends Notifier { 'title': track.name, 'artist': track.artistName, 'album': track.albumName, - 'album_artist': resolvedAlbumArtist, 'track_number': track.trackNumber ?? 0, 'disc_number': track.discNumber ?? 0, 'isrc': track.isrc ?? '', @@ -3355,6 +3359,9 @@ class DownloadQueueNotifier extends Notifier { 'duration_ms': track.duration * 1000, 'cover_url': track.coverUrl ?? '', }; + if (resolvedAlbumArtist != null) { + metadata['album_artist'] = resolvedAlbumArtist; + } final result = await PlatformBridge.runPostProcessingV2( filePath, @@ -3706,7 +3713,7 @@ class DownloadQueueNotifier extends Notifier { Track _buildTrackForMetadataEmbedding( Track baseTrack, Map backendResult, - String resolvedAlbumArtist, + String? resolvedAlbumArtist, ) { final backendTrackNum = _parsePositiveInt(backendResult['track_number']); final backendDiscNum = _parsePositiveInt(backendResult['disc_number']); @@ -3849,7 +3856,9 @@ class DownloadQueueNotifier extends Notifier { } final albumArtist = _resolveAlbumArtistForMetadata(track, settings); - metadata['ALBUMARTIST'] = albumArtist; + if (albumArtist != null) { + metadata['ALBUMARTIST'] = albumArtist; + } if (track.trackNumber != null && track.trackNumber! > 0) { final trackTag = formatIndexTag(track.trackNumber!, track.totalTracks); @@ -4017,7 +4026,7 @@ class DownloadQueueNotifier extends Notifier { await PlatformBridge.rewriteSplitArtistTags( filePath, track.artistName, - albumArtist, + albumArtist ?? '', ); _log.d('Split artist tags rewritten via native FLAC writer'); } catch (e) { @@ -4607,6 +4616,7 @@ class DownloadQueueNotifier extends Notifier { deezerTrackId = await _searchDeezerTrackIdByIsrc( trackToDownload.isrc, lookupContext: 'ISRC', + itemId: item.id, ); if (shouldAbortWork('during Deezer ISRC lookup')) { @@ -4624,6 +4634,7 @@ class DownloadQueueNotifier extends Notifier { trackToDownload.id.startsWith('qobuz:'))) { final providerLookup = await _resolveProviderTrackForDeezerLookup( trackToDownload, + item.id, ); trackToDownload = providerLookup.track; deezerTrackId ??= providerLookup.deezerTrackId; @@ -4746,7 +4757,7 @@ class DownloadQueueNotifier extends Notifier { trackName: trackToDownload.name, artistName: trackToDownload.artistName, albumName: trackToDownload.albumName, - albumArtist: resolvedAlbumArtist, + albumArtist: resolvedAlbumArtist ?? '', coverUrl: metadataEmbeddingEnabled ? (trackToDownload.coverUrl ?? '') : '', @@ -5889,10 +5900,9 @@ class DownloadQueueNotifier extends Notifier { _log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}'); - final historyAlbumArtist = - resolvedAlbumArtist != trackToDownload.artistName - ? resolvedAlbumArtist - : null; + final historyAlbumArtist = normalizeOptionalString( + trackToDownload.albumArtist, + ); final isLossyOutput = lowerFilePath.endsWith('.mp3') || diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index c0abd0d0..903786be 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; @@ -221,6 +222,12 @@ class ExploreNotifier extends Notifier { Future _restoreFromCache() async { try { + if (ref.read(settingsProvider).homeFeedProvider == + AppSettings.homeFeedProviderOff) { + _log.d('Home feed disabled, skipping cache restore'); + return; + } + final prefs = await SharedPreferences.getInstance(); final cached = prefs.getString(_cacheKey); final cachedTs = prefs.getInt(_cacheTsKey); @@ -271,6 +278,13 @@ class ExploreNotifier extends Notifier { Future fetchHomeFeed({bool forceRefresh = false}) async { _log.i('fetchHomeFeed called, forceRefresh=$forceRefresh'); + if (ref.read(settingsProvider).homeFeedProvider == + AppSettings.homeFeedProviderOff) { + _log.d('Home feed disabled by user setting'); + state = const ExploreState(); + return; + } + if (!forceRefresh && state.hasContent && state.lastFetched != null && diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 2daba947..73cec937 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -397,7 +397,7 @@ class _ArtistScreenState extends ConsumerState { artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? data['album'] ?? album?.name ?? '') .toString(), - albumArtist: data['album_artist']?.toString() ?? widget.artistName, + albumArtist: normalizeOptionalString(data['album_artist']?.toString()), artistId: (data['artist_id'] ?? data['artistId'])?.toString() ?? widget.artistId, @@ -1124,7 +1124,7 @@ class _ArtistScreenState extends ConsumerState { name: (data['title'] ?? data['name'] ?? '').toString(), artistName: artistName, albumName: album.name, - albumArtist: widget.artistName, + albumArtist: null, artistId: widget.artistId, albumId: album.id.isNotEmpty ? album.id : null, coverUrl: album.coverUrl, diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index d806fb19..e653e282 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; @@ -376,6 +377,12 @@ class _HomeTabState extends ConsumerState } void _fetchExploreIfNeeded() { + if (ref.read(settingsProvider).homeFeedProvider == + AppSettings.homeFeedProviderOff) { + ref.read(exploreProvider.notifier).clear(); + return; + } + final extState = ref.read(extensionProvider); final exploreState = ref.read(exploreProvider); final hasHomeFeedExtension = extState.extensions.any( @@ -1259,6 +1266,11 @@ class _HomeTabState extends ConsumerState (s) => s.extensions.any((e) => e.enabled && e.hasHomeFeed), ), ); + final homeFeedDisabled = ref.watch( + settingsProvider.select( + (s) => s.homeFeedProvider == AppSettings.homeFeedProviderOff, + ), + ); final colorScheme = Theme.of(context).colorScheme; final searchText = _urlController.text.trim(); @@ -1285,6 +1297,7 @@ class _HomeTabState extends ConsumerState !hasActualResults && !isLoading && !showRecentAccess && + !homeFeedDisabled && (hasHomeFeedExtension || hasExploreContent) && hasExploreContent; @@ -1536,6 +1549,7 @@ class _HomeTabState extends ConsumerState ), if (hasHomeFeedExtension && + !homeFeedDisabled && !hasActualResults && !isLoading && exploreLoading) @@ -4687,7 +4701,7 @@ class _ExtensionAlbumScreenState extends ConsumerState { name: (data['name'] ?? '').toString(), artistName: (data['artists'] ?? data['artist'] ?? '').toString(), albumName: (data['album_name'] ?? widget.albumName).toString(), - albumArtist: (data['album_artist'] ?? _artistName)?.toString(), + albumArtist: normalizeOptionalString(data['album_artist']?.toString()), artistId: (data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId, albumId: data['album_id']?.toString() ?? widget.albumId, diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 9ffeaca7..6cdd41dd 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -891,7 +891,7 @@ class _LocalAlbumScreenState extends ConsumerState { 'track_name': item.trackName, 'artist_name': item.artistName, 'album_name': item.albumName, - 'album_artist': item.albumArtist ?? item.artistName, + 'album_artist': item.albumArtist ?? '', 'track_number': item.trackNumber ?? 0, 'disc_number': item.discNumber ?? 0, 'release_date': item.releaseDate ?? '', diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index e913245e..d62772d1 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -5136,7 +5136,7 @@ class _QueueTabState extends ConsumerState { 'track_name': item.trackName, 'artist_name': item.artistName, 'album_name': item.albumName, - 'album_artist': item.albumArtist ?? item.artistName, + 'album_artist': item.albumArtist ?? '', 'track_number': item.trackNumber ?? 0, 'disc_number': item.discNumber ?? 0, 'release_date': item.releaseDate ?? '', diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 4fc156cc..eac29a1e 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -859,9 +859,13 @@ class _HomeFeedProviderSelector extends ConsumerWidget { .toList(); final hasAnyProvider = homeFeedProviders.isNotEmpty; + final homeFeedDisabled = + settings.homeFeedProvider == AppSettings.homeFeedProviderOff; String currentProviderName = context.l10n.extensionsHomeFeedAuto; - if (settings.homeFeedProvider != null && + if (homeFeedDisabled) { + currentProviderName = context.l10n.extensionsHomeFeedOff; + } else if (settings.homeFeedProvider != null && settings.homeFeedProvider!.isNotEmpty) { final ext = homeFeedProviders .where((e) => e.id == settings.homeFeedProvider) @@ -873,23 +877,19 @@ class _HomeFeedProviderSelector extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ InkWell( - onTap: !hasAnyProvider - ? null - : () => _showHomeFeedProviderPicker( - context, - ref, - settings, - homeFeedProviders, - ), + onTap: () => _showHomeFeedProviderPicker( + context, + ref, + settings, + homeFeedProviders, + ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Icon( Icons.explore_outlined, - color: !hasAnyProvider - ? colorScheme.outline - : colorScheme.onSurfaceVariant, + color: colorScheme.onSurfaceVariant, ), const SizedBox(width: 16), Expanded( @@ -898,13 +898,11 @@ class _HomeFeedProviderSelector extends ConsumerWidget { children: [ Text( context.l10n.extensionsHomeFeedProvider, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: !hasAnyProvider ? colorScheme.outline : null, - ), + style: Theme.of(context).textTheme.bodyLarge, ), const SizedBox(height: 2), Text( - !hasAnyProvider + !hasAnyProvider && !homeFeedDisabled ? context.l10n.extensionsNoHomeFeedExtensions : currentProviderName, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -914,12 +912,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget { ], ), ), - Icon( - Icons.chevron_right, - color: !hasAnyProvider - ? colorScheme.outline - : colorScheme.onSurfaceVariant, - ), + Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant), ], ), ), @@ -982,6 +975,31 @@ class _HomeFeedProviderSelector extends ConsumerWidget { Navigator.pop(ctx); }, ), + ListTile( + leading: Icon(Icons.block, color: colorScheme.error), + title: Text(ctx.l10n.extensionsHomeFeedOff), + subtitle: Text(ctx.l10n.extensionsHomeFeedOffSubtitle), + trailing: + settings.homeFeedProvider == AppSettings.homeFeedProviderOff + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.circle_outlined, color: colorScheme.outline), + onTap: () { + ref + .read(settingsProvider.notifier) + .setHomeFeedProvider(AppSettings.homeFeedProviderOff); + ref.read(exploreProvider.notifier).clear(); + Navigator.pop(ctx); + }, + ), + if (homeFeedProviders.isEmpty) + ListTile( + enabled: false, + leading: Icon( + Icons.extension_off, + color: colorScheme.outline, + ), + title: Text(ctx.l10n.extensionsNoHomeFeedExtensions), + ), ...homeFeedProviders.map( (ext) => ListTile( leading: Icon(Icons.extension, color: colorScheme.secondary), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 5d94b6ed..04a83fd0 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -2674,7 +2674,7 @@ class _TrackMetadataScreenState extends ConsumerState { 'track_name': trackName, 'artist_name': artistName, 'album_name': albumName, - 'album_artist': albumArtist ?? artistName, + 'album_artist': albumArtist ?? '', 'track_number': trackNumber ?? 0, 'total_tracks': totalTracks ?? 0, 'disc_number': discNumber ?? 0, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index b88174fb..8dc91b3a 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -609,9 +609,13 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - static Future> searchDeezerByISRC(String isrc) async { + static Future> searchDeezerByISRC( + String isrc, { + String? itemId, + }) async { final result = await _channel.invokeMethod('searchDeezerByISRC', { 'isrc': isrc, + 'item_id': itemId ?? '', }); return jsonDecode(result as String) as Map; }