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:
zarzet
2026-04-16 02:55:40 +07:00
parent 57051bd649
commit bcd8a05352
31 changed files with 683 additions and 59 deletions
+9 -1
View File
@@ -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
View File
@@ -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
+93
View File
@@ -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
+162 -5
View File
@@ -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