mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-15 21:28:20 +02:00
feat: propagate download cancellation through entire pipeline, add MusicBrainz album artist fallback, and allow disabling home feed
- Add reference-counted cancel entries to prevent premature cleanup when multiple operations share the same itemID - Propagate cancellation to DownloadTrack, DownloadWithFallback, DownloadWithExtensionsJSON, extension providers, and ISRC search - Fetch album artist from MusicBrainz when missing during download and re-enrich - Make ALBUMARTIST tag nullable to avoid writing artistName as album artist - Add home feed 'Off' option in extension settings - Skip deezer in download provider priority sanitization
This commit is contained in:
@@ -2946,8 +2946,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
"searchDeezerByISRC" -> {
|
||||
val isrc = call.argument<String>("isrc") ?: ""
|
||||
val itemId = call.argument<String>("item_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchDeezerByISRC(isrc)
|
||||
Gobackend.searchDeezerByISRCForItemID(isrc, itemId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+200
-9
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2208,11 +2208,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
Future<String?> _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<DownloadQueueState> {
|
||||
_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<DownloadQueueState> {
|
||||
|
||||
Future<_DeezerLookupPreparation> _resolveProviderTrackForDeezerLookup(
|
||||
Track track,
|
||||
String itemId,
|
||||
) async {
|
||||
try {
|
||||
final colonIdx = track.id.indexOf(':');
|
||||
@@ -2608,6 +2612,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final deezerTrackId = await _searchDeezerTrackIdByIsrc(
|
||||
resolvedIsrc,
|
||||
lookupContext: '$provider ISRC',
|
||||
itemId: itemId,
|
||||
);
|
||||
|
||||
return _DeezerLookupPreparation(
|
||||
@@ -3347,7 +3352,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'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<DownloadQueueState> {
|
||||
'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<DownloadQueueState> {
|
||||
Track _buildTrackForMetadataEmbedding(
|
||||
Track baseTrack,
|
||||
Map<String, dynamic> 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<DownloadQueueState> {
|
||||
}
|
||||
|
||||
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<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
deezerTrackId = await _searchDeezerTrackIdByIsrc(
|
||||
trackToDownload.isrc,
|
||||
lookupContext: 'ISRC',
|
||||
itemId: item.id,
|
||||
);
|
||||
|
||||
if (shouldAbortWork('during Deezer ISRC lookup')) {
|
||||
@@ -4624,6 +4634,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
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<DownloadQueueState> {
|
||||
|
||||
_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') ||
|
||||
|
||||
@@ -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<ExploreState> {
|
||||
|
||||
Future<void> _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<ExploreState> {
|
||||
Future<void> 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 &&
|
||||
|
||||
@@ -397,7 +397,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
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<ArtistScreen> {
|
||||
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,
|
||||
|
||||
@@ -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<HomeTab>
|
||||
}
|
||||
|
||||
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<HomeTab>
|
||||
(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<HomeTab>
|
||||
!hasActualResults &&
|
||||
!isLoading &&
|
||||
!showRecentAccess &&
|
||||
!homeFeedDisabled &&
|
||||
(hasHomeFeedExtension || hasExploreContent) &&
|
||||
hasExploreContent;
|
||||
|
||||
@@ -1536,6 +1549,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
|
||||
if (hasHomeFeedExtension &&
|
||||
!homeFeedDisabled &&
|
||||
!hasActualResults &&
|
||||
!isLoading &&
|
||||
exploreLoading)
|
||||
@@ -4687,7 +4701,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
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,
|
||||
|
||||
@@ -891,7 +891,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
'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 ?? '',
|
||||
|
||||
@@ -5136,7 +5136,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
'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 ?? '',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -2674,7 +2674,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
'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,
|
||||
|
||||
@@ -609,9 +609,13 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> searchDeezerByISRC(String isrc) async {
|
||||
static Future<Map<String, dynamic>> searchDeezerByISRC(
|
||||
String isrc, {
|
||||
String? itemId,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('searchDeezerByISRC', {
|
||||
'isrc': isrc,
|
||||
'item_id': itemId ?? '',
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user