mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
749 lines
26 KiB
Go
749 lines
26 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// attemptExtensionDownload runs a single provider.Download attempt: builds the
|
|
// output path, reports progress, and on success assembles the full
|
|
// DownloadResponse (overlay, request fallbacks, optional title/artist/composer
|
|
// fallback, metadata embed, ISRC index). On failure it writes into
|
|
// lastErr/lastErrType/lastRetryAfterSeconds exactly as the inline code did
|
|
// (leaving them untouched when neither branch applies) so callers can keep
|
|
// their own verification_required/stop-fallback handling and error messages.
|
|
// cancelledOuter true means the caller must return (nil, ErrDownloadCancelled).
|
|
func attemptExtensionDownload(
|
|
req DownloadRequest,
|
|
ext *loadedExtension,
|
|
provider *extensionProviderWrapper,
|
|
trackID, quality, providerLabel string,
|
|
applyTitleFallback bool,
|
|
lastErr *error,
|
|
lastErrType *string,
|
|
lastRetryAfterSeconds *int,
|
|
) (resp *DownloadResponse, cancelledOuter bool) {
|
|
outputPath := buildOutputPathForExtension(req, ext)
|
|
if shouldReuseExistingOutput(req, outputPath) {
|
|
result := DownloadResult{FilePath: outputPath}
|
|
enrichResultQualityFromFile(&result)
|
|
built := buildDownloadSuccessResponse(
|
|
req,
|
|
result,
|
|
providerLabel,
|
|
"File already exists",
|
|
outputPath,
|
|
true,
|
|
)
|
|
if req.ItemID != "" {
|
|
CompleteItemProgress(req.ItemID)
|
|
}
|
|
GoLog("[DownloadWithExtensionFallback] Keeping existing output instead of replacing it: %s\n", outputPath)
|
|
return &built, false
|
|
}
|
|
if req.ItemID != "" {
|
|
SetItemPreparingStage(req.ItemID, "resolving_stream")
|
|
}
|
|
|
|
result, err := provider.Download(trackID, quality, outputPath, req.ItemID, func(percent int) {
|
|
if req.ItemID != "" {
|
|
normalized := float64(percent) / 100.0
|
|
if normalized < 0 {
|
|
normalized = 0
|
|
}
|
|
if normalized > 1 {
|
|
normalized = 1
|
|
}
|
|
SetItemProgress(req.ItemID, normalized, 0, 0)
|
|
}
|
|
})
|
|
downloadSucceeded := err == nil && result != nil && result.Success
|
|
if downloadSucceeded {
|
|
resolved := resolvedTrackInfo{
|
|
Title: result.Title,
|
|
ArtistName: result.Artist,
|
|
AlbumName: result.Album,
|
|
ISRC: result.ISRC,
|
|
Duration: result.DurationMS / 1000,
|
|
SkipNameVerification: strings.EqualFold(strings.TrimSpace(req.Source), strings.TrimSpace(providerLabel)) ||
|
|
ext.Manifest.HasCustomMatching(),
|
|
}
|
|
if !trackMatchesRequest(req, resolved, "Extension "+providerLabel) {
|
|
discardRejectedExtensionOutput(result, outputPath)
|
|
*lastErr = fmt.Errorf("provider %s returned a different track", providerLabel)
|
|
*lastErrType = "not_found"
|
|
return nil, false
|
|
}
|
|
}
|
|
if req.ItemID != "" && downloadSucceeded {
|
|
SetItemFinalizing(req.ItemID)
|
|
}
|
|
if shouldAbortCancelledFallback(req.ItemID, err) {
|
|
return nil, true
|
|
}
|
|
|
|
if downloadSucceeded {
|
|
metadataStartedAt := time.Now()
|
|
enrichRequestExtendedMetadata(&req)
|
|
LogDebug(
|
|
"DownloadPipeline",
|
|
"item=%s provider=%s post-transfer metadataMs=%.1f",
|
|
req.ItemID,
|
|
providerLabel,
|
|
extensionDurationMs(time.Since(metadataStartedAt)),
|
|
)
|
|
|
|
normalizedResult, alreadyExists := normalizeExtensionDownloadResult(result)
|
|
message := "Downloaded from " + providerLabel
|
|
if alreadyExists {
|
|
message = "File already exists"
|
|
}
|
|
|
|
built := buildDownloadSuccessResponse(
|
|
req,
|
|
normalizedResult,
|
|
providerLabel,
|
|
message,
|
|
normalizedResult.FilePath,
|
|
alreadyExists,
|
|
)
|
|
overlayExtensionDownloadMetadata(&built, result)
|
|
if ext.Manifest.SkipMetadataEnrichment {
|
|
built.SkipMetadataEnrichment = true
|
|
}
|
|
applyExtensionRequestFallbacks(&built, req)
|
|
|
|
if applyTitleFallback {
|
|
if req.TrackName != "" && built.Title == "" {
|
|
built.Title = req.TrackName
|
|
}
|
|
if req.ArtistName != "" && built.Artist == "" {
|
|
built.Artist = req.ArtistName
|
|
}
|
|
if req.Composer != "" && built.Composer == "" {
|
|
built.Composer = req.Composer
|
|
}
|
|
}
|
|
|
|
embedExtensionDownloadMetadata(built, req, alreadyExists)
|
|
|
|
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
|
indexISRC := strings.TrimSpace(built.ISRC)
|
|
if indexISRC == "" {
|
|
indexISRC = strings.TrimSpace(req.ISRC)
|
|
}
|
|
if indexISRC != "" && strings.TrimSpace(built.FilePath) != "" {
|
|
AddToISRCIndex(req.OutputDir, indexISRC, built.FilePath)
|
|
}
|
|
}
|
|
|
|
if req.ItemID != "" {
|
|
CompleteItemProgress(req.ItemID)
|
|
}
|
|
return &built, false
|
|
}
|
|
|
|
if err != nil {
|
|
if errors.Is(err, ErrDownloadCancelled) {
|
|
return &DownloadResponse{
|
|
Success: false,
|
|
Error: "Download cancelled",
|
|
ErrorType: "cancelled",
|
|
Service: providerLabel,
|
|
}, false
|
|
}
|
|
*lastErr = err
|
|
*lastErrType = ""
|
|
} else if result != nil && result.ErrorMessage != "" {
|
|
*lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
|
*lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage)
|
|
*lastRetryAfterSeconds = result.RetryAfterSeconds
|
|
} else if result == nil {
|
|
*lastErr = fmt.Errorf("extension returned no download result")
|
|
*lastErrType = "extension_error"
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// attemptVerifiedResumeBeforeMetadata gives a just-verified request one direct
|
|
// chance against its selected provider before optional metadata providers run.
|
|
// Download-provider sources keep their normal precedence because they may
|
|
// dynamically lock fallback from checkAvailability.
|
|
func attemptVerifiedResumeBeforeMetadata(
|
|
req DownloadRequest,
|
|
selectedProvider string,
|
|
extManager *extensionManager,
|
|
) (*DownloadResponse, bool) {
|
|
selectedProvider = strings.TrimSpace(selectedProvider)
|
|
if selectedProvider == "" || extManager == nil {
|
|
return nil, false
|
|
}
|
|
|
|
sourceProvider := strings.TrimSpace(req.Source)
|
|
if sourceProvider != "" && !strings.EqualFold(sourceProvider, selectedProvider) {
|
|
if sourceExt, err := extManager.GetExtension(sourceProvider); err == nil &&
|
|
sourceExt != nil && sourceExt.Enabled && sourceExt.Error == "" &&
|
|
sourceExt.Manifest != nil && sourceExt.Manifest.IsDownloadProvider() {
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
ext, err := extManager.GetExtension(selectedProvider)
|
|
if err != nil || ext == nil || !ext.Enabled || ext.Error != "" ||
|
|
ext.Manifest == nil || !ext.Manifest.IsDownloadProvider() {
|
|
return nil, false
|
|
}
|
|
|
|
provider := newExtensionProviderWrapper(ext)
|
|
var availability *ExtAvailabilityResult
|
|
trackID := ""
|
|
if strings.EqualFold(sourceProvider, selectedProvider) {
|
|
trackID = resolvePreferredTrackIDForExtension(ext, req, "")
|
|
} else {
|
|
availability, err = provider.CheckAvailabilityForItemID(
|
|
req.ISRC,
|
|
req.TrackName,
|
|
req.ArtistName,
|
|
req.SpotifyID,
|
|
req.DeezerID,
|
|
req.TidalID,
|
|
req.QobuzID,
|
|
req.DurationMS,
|
|
req.ItemID,
|
|
)
|
|
if shouldAbortCancelledFallback(req.ItemID, err) {
|
|
return nil, true
|
|
}
|
|
if err != nil {
|
|
if strings.EqualFold(classifyDownloadErrorType(err.Error()), "verification_required") {
|
|
return &DownloadResponse{
|
|
Success: false,
|
|
Error: "Download failed: " + err.Error(),
|
|
ErrorType: "verification_required",
|
|
Service: selectedProvider,
|
|
}, false
|
|
}
|
|
return nil, false
|
|
}
|
|
if availability == nil || !availability.Available {
|
|
if shouldStopProviderFallback(availability) {
|
|
return buildExtensionFallbackStoppedResponse(selectedProvider, availability, nil), false
|
|
}
|
|
return nil, false
|
|
}
|
|
trackID = resolvePreferredTrackIDForExtension(ext, req, availability.TrackID)
|
|
}
|
|
|
|
var lastErr error
|
|
var lastErrType string
|
|
var lastRetryAfterSeconds int
|
|
resp, cancelled := attemptExtensionDownload(
|
|
req,
|
|
ext,
|
|
provider,
|
|
trackID,
|
|
req.Quality,
|
|
selectedProvider,
|
|
strings.EqualFold(sourceProvider, selectedProvider),
|
|
&lastErr,
|
|
&lastErrType,
|
|
&lastRetryAfterSeconds,
|
|
)
|
|
if cancelled || resp != nil {
|
|
return resp, cancelled
|
|
}
|
|
|
|
errorType := lastErrType
|
|
if errorType == "" && lastErr != nil {
|
|
errorType = classifyDownloadErrorType(lastErr.Error())
|
|
}
|
|
if strings.EqualFold(errorType, "verification_required") {
|
|
errorMessage := "Verification required"
|
|
if lastErr != nil {
|
|
errorMessage = "Download failed: " + lastErr.Error()
|
|
}
|
|
return &DownloadResponse{
|
|
Success: false,
|
|
Error: errorMessage,
|
|
ErrorType: "verification_required",
|
|
RetryAfterSeconds: lastRetryAfterSeconds,
|
|
Service: selectedProvider,
|
|
}, false
|
|
}
|
|
if lastErr != nil && isOutputStorageWriteFailure(errorType, lastErr.Error()) {
|
|
return buildOutputStorageFailureResponse(
|
|
selectedProvider,
|
|
lastErr,
|
|
lastRetryAfterSeconds,
|
|
), false
|
|
}
|
|
// A failed fast attempt may still succeed after source enrichment resolves a
|
|
// better provider-native ID. The normal path below retains strict/stop-
|
|
// fallback semantics for that prepared retry.
|
|
return nil, false
|
|
}
|
|
|
|
func buildOutputStorageFailureResponse(
|
|
providerID string,
|
|
err error,
|
|
retryAfterSeconds int,
|
|
) *DownloadResponse {
|
|
message := "Output storage is not writable"
|
|
if err != nil {
|
|
message = err.Error()
|
|
}
|
|
return &DownloadResponse{
|
|
Success: false,
|
|
Error: "Download failed: " + message,
|
|
ErrorType: "permission",
|
|
RetryAfterSeconds: retryAfterSeconds,
|
|
Service: providerID,
|
|
}
|
|
}
|
|
|
|
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
|
pipelineStartedAt := time.Now()
|
|
defer func() {
|
|
LogDebug(
|
|
"DownloadPipeline",
|
|
"item=%s service=%s totalMs=%.1f",
|
|
req.ItemID,
|
|
req.Service,
|
|
extensionDurationMs(time.Since(pipelineStartedAt)),
|
|
)
|
|
}()
|
|
preparationKey := downloadPreparationKey(req)
|
|
metadataPrepared := false
|
|
resumedAfterVerification := false
|
|
if prepared, preparedMetadata, ok := takePreparedDownloadRequest(preparationKey, req); ok {
|
|
req = prepared
|
|
metadataPrepared = preparedMetadata
|
|
resumedAfterVerification = true
|
|
GoLog("[DownloadWithExtensionFallback] Resuming item %s after verification (metadata prepared: %v)\n", req.ItemID, metadataPrepared)
|
|
}
|
|
if req.ItemID != "" {
|
|
StartItemProgress(req.ItemID)
|
|
SetItemPreparingStage(req.ItemID, "resolving_metadata")
|
|
}
|
|
|
|
priority := GetProviderPriority()
|
|
extManager := getExtensionManager()
|
|
strictMode := !req.UseFallback
|
|
selectedProvider := strings.TrimSpace(req.Service)
|
|
|
|
if isDownloadCancelled(req.ItemID) {
|
|
return nil, ErrDownloadCancelled
|
|
}
|
|
|
|
if strictMode {
|
|
if selectedProvider == "" {
|
|
selectedProvider = strings.TrimSpace(req.Source)
|
|
}
|
|
if selectedProvider != "" {
|
|
priority = []string{selectedProvider}
|
|
GoLog("[DownloadWithExtensionFallback] Strict mode enabled, provider locked to: %s\n", selectedProvider)
|
|
}
|
|
}
|
|
|
|
if !strictMode && req.Service != "" {
|
|
found := false
|
|
for _, p := range priority {
|
|
if strings.EqualFold(p, req.Service) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
newPriority := []string{req.Service}
|
|
for _, p := range priority {
|
|
if !strings.EqualFold(p, req.Service) {
|
|
newPriority = append(newPriority, p)
|
|
}
|
|
}
|
|
priority = newPriority
|
|
if !found {
|
|
GoLog("[DownloadWithExtensionFallback] Extension service '%s' added to priority front\n", req.Service)
|
|
} else {
|
|
GoLog("[DownloadWithExtensionFallback] Extension service '%s' moved to priority front\n", req.Service)
|
|
}
|
|
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
|
|
}
|
|
|
|
var lastErr error
|
|
var lastErrType string
|
|
var lastRetryAfterSeconds int
|
|
var stopProviderFallback bool
|
|
var sourceExtensionLocked bool
|
|
var sourceExtensionAvailability *ExtAvailabilityResult
|
|
var sourceExtensionTrackID string
|
|
|
|
if resumedAfterVerification && !metadataPrepared {
|
|
GoLog("[DownloadWithExtensionFallback] Trying verified provider %s before optional metadata enrichment\n", selectedProvider)
|
|
resp, cancelled := attemptVerifiedResumeBeforeMetadata(req, selectedProvider, extManager)
|
|
if cancelled {
|
|
return nil, ErrDownloadCancelled
|
|
}
|
|
if resp != nil {
|
|
return resp, nil
|
|
}
|
|
if req.ItemID != "" {
|
|
SetItemPreparingStage(req.ItemID, "resolving_metadata")
|
|
}
|
|
}
|
|
|
|
if req.Source != "" && selectedProvider != req.Source {
|
|
ext, err := extManager.GetExtension(req.Source)
|
|
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
|
|
provider := newExtensionProviderWrapper(ext)
|
|
availability, availErr := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.TidalID, req.QobuzID, req.DurationMS, req.ItemID)
|
|
if shouldAbortCancelledFallback(req.ItemID, availErr) {
|
|
return nil, ErrDownloadCancelled
|
|
}
|
|
if availErr != nil {
|
|
GoLog("[DownloadWithExtensionFallback] Source extension %s preflight failed (non-fatal): %v\n", req.Source, availErr)
|
|
} else if shouldStopProviderFallback(availability) {
|
|
sourceExtensionLocked = true
|
|
sourceExtensionAvailability = availability
|
|
sourceExtensionTrackID = strings.TrimSpace(availability.TrackID)
|
|
selectedProvider = req.Source
|
|
GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback (available=%v), locking download to source extension\n", req.Source, availability.Available)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !metadataPrepared {
|
|
if req.Source != "" {
|
|
ext, err := extManager.GetExtension(req.Source)
|
|
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
|
|
GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source)
|
|
|
|
provider := newExtensionProviderWrapper(ext)
|
|
trackMeta := &ExtTrackMetadata{
|
|
ID: req.SpotifyID,
|
|
Name: req.TrackName,
|
|
Artists: req.ArtistName,
|
|
AlbumName: req.AlbumName,
|
|
DurationMS: req.DurationMS,
|
|
ISRC: req.ISRC,
|
|
ReleaseDate: req.ReleaseDate,
|
|
TrackNumber: req.TrackNumber,
|
|
TotalTracks: req.TotalTracks,
|
|
DiscNumber: req.DiscNumber,
|
|
TotalDiscs: req.TotalDiscs,
|
|
ProviderID: req.Source,
|
|
Composer: req.Composer,
|
|
}
|
|
|
|
enrichedTrack, err := provider.EnrichTrackForItemID(trackMeta, req.ItemID)
|
|
if shouldAbortCancelledFallback(req.ItemID, err) {
|
|
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)
|
|
req.ISRC = enrichedTrack.ISRC
|
|
}
|
|
if enrichedTrack.TidalID != "" {
|
|
GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID)
|
|
req.TidalID = enrichedTrack.TidalID
|
|
}
|
|
if enrichedTrack.QobuzID != "" {
|
|
GoLog("[DownloadWithExtensionFallback] Qobuz ID from Odesli: %s\n", enrichedTrack.QobuzID)
|
|
req.QobuzID = enrichedTrack.QobuzID
|
|
}
|
|
if enrichedTrack.DeezerID != "" {
|
|
GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID)
|
|
req.DeezerID = enrichedTrack.DeezerID
|
|
}
|
|
if enrichedTrack.Name != "" {
|
|
req.TrackName = enrichedTrack.Name
|
|
}
|
|
if enrichedTrack.Artists != "" {
|
|
req.ArtistName = enrichedTrack.Artists
|
|
}
|
|
overlayStr(&req.AlbumName, enrichedTrack.AlbumName, "AlbumName")
|
|
overlayStr(&req.AlbumArtist, enrichedTrack.AlbumArtist, "")
|
|
overlayInt(&req.DurationMS, enrichedTrack.DurationMS, "DurationMS")
|
|
overlayStr(&req.CoverURL, enrichedTrack.CoverURL, "")
|
|
overlayStr(&req.SpotifyID, enrichedTrack.ID, "Track ID")
|
|
overlayStr(&req.Label, enrichedTrack.Label, "Label")
|
|
overlayStr(&req.Copyright, enrichedTrack.Copyright, "Copyright")
|
|
overlayStr(&req.Genre, enrichedTrack.Genre, "Genre")
|
|
overlayStr(&req.ReleaseDate, enrichedTrack.ReleaseDate, "ReleaseDate")
|
|
overlayInt(&req.TrackNumber, enrichedTrack.TrackNumber, "TrackNumber")
|
|
overlayInt(&req.TotalTracks, enrichedTrack.TotalTracks, "TotalTracks")
|
|
overlayInt(&req.DiscNumber, enrichedTrack.DiscNumber, "DiscNumber")
|
|
overlayInt(&req.TotalDiscs, enrichedTrack.TotalDiscs, "TotalDiscs")
|
|
overlayStr(&req.Composer, enrichedTrack.Composer, "Composer")
|
|
}
|
|
}
|
|
}
|
|
|
|
if req.Source != "" &&
|
|
req.TrackName != "" && req.ArtistName != "" &&
|
|
(req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") {
|
|
|
|
searchQuery := req.TrackName + " " + req.ArtistName
|
|
GoLog("[DownloadWithExtensionFallback] Metadata incomplete, searching providers for: %s\n", searchQuery)
|
|
|
|
// Only the first match is consumed below. Asking for five made the manager
|
|
// continue through additional providers even after it already had a usable
|
|
// match, multiplying the per-provider timeout on slow networks.
|
|
tracks, searchErr := extManager.SearchTracksWithMetadataProvidersForItemID(searchQuery, 1, true, req.ItemID)
|
|
if shouldAbortCancelledFallback(req.ItemID, searchErr) {
|
|
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",
|
|
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate, track.ISRC)
|
|
|
|
overlayStr(&req.AlbumName, track.AlbumName, "")
|
|
overlayStr(&req.AlbumArtist, track.AlbumArtist, "")
|
|
overlayStr(&req.ReleaseDate, track.ReleaseDate, "")
|
|
overlayStr(&req.ISRC, track.ISRC, "")
|
|
overlayInt(&req.TrackNumber, track.TrackNumber, "")
|
|
overlayInt(&req.TotalTracks, track.TotalTracks, "")
|
|
overlayInt(&req.DiscNumber, track.DiscNumber, "")
|
|
overlayInt(&req.TotalDiscs, track.TotalDiscs, "")
|
|
overlayStr(&req.Composer, track.Composer, "")
|
|
overlayStr(&req.CoverURL, track.CoverURL, "")
|
|
overlayStr(&req.Genre, track.Genre, "")
|
|
overlayStr(&req.Label, track.Label, "")
|
|
overlayStr(&req.Copyright, track.Copyright, "")
|
|
} else if searchErr != nil {
|
|
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
|
|
}
|
|
|
|
}
|
|
}
|
|
if req.Source != "" && selectedProvider == req.Source {
|
|
if isDownloadCancelled(req.ItemID) {
|
|
return nil, ErrDownloadCancelled
|
|
}
|
|
|
|
if sourceExtensionLocked && (sourceExtensionAvailability == nil || !sourceExtensionAvailability.Available) {
|
|
GoLog("[DownloadWithExtensionFallback] Source extension %s stopped fallback before download (reason: %s)\n", req.Source, resolveExtensionAvailabilityReason(sourceExtensionAvailability, nil))
|
|
return buildExtensionFallbackStoppedResponse(req.Source, sourceExtensionAvailability, nil), nil
|
|
}
|
|
|
|
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s' matching selected provider, trying it first\n", req.Source)
|
|
|
|
ext, err := extManager.GetExtension(req.Source)
|
|
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
|
|
stopProviderFallback = ext.Manifest.StopsProviderFallback()
|
|
|
|
provider := newExtensionProviderWrapper(ext)
|
|
|
|
trackID := resolvePreferredTrackIDForExtension(ext, req, sourceExtensionTrackID)
|
|
|
|
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (stopProviderFallback: %v)\n", trackID, stopProviderFallback)
|
|
|
|
resp, cancelledOuter := attemptExtensionDownload(req, ext, provider, trackID, req.Quality, req.Source, true, &lastErr, &lastErrType, &lastRetryAfterSeconds)
|
|
if cancelledOuter {
|
|
return nil, ErrDownloadCancelled
|
|
}
|
|
if resp != nil {
|
|
return resp, nil
|
|
}
|
|
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
|
|
|
|
sourceErrType := lastErrType
|
|
if sourceErrType == "" && lastErr != nil {
|
|
sourceErrType = classifyDownloadErrorType(lastErr.Error())
|
|
}
|
|
if lastErr != nil && isOutputStorageWriteFailure(sourceErrType, lastErr.Error()) {
|
|
GoLog("[DownloadWithExtensionFallback] Source extension %s hit an unwritable output path; stopping provider fallback\n", req.Source)
|
|
return buildOutputStorageFailureResponse(
|
|
req.Source,
|
|
lastErr,
|
|
lastRetryAfterSeconds,
|
|
), nil
|
|
}
|
|
if strings.EqualFold(sourceErrType, "verification_required") {
|
|
GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source)
|
|
cachePreparedDownloadRequest(preparationKey, req)
|
|
return &DownloadResponse{
|
|
Success: false,
|
|
Error: "Download failed: " + lastErr.Error(),
|
|
ErrorType: "verification_required",
|
|
Service: req.Source,
|
|
}, nil
|
|
}
|
|
|
|
if stopProviderFallback || sourceExtensionLocked {
|
|
if sourceExtensionLocked {
|
|
GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback, not trying other providers\n", req.Source)
|
|
return buildExtensionFallbackStoppedResponse(req.Source, sourceExtensionAvailability, lastErr), nil
|
|
}
|
|
GoLog("[DownloadWithExtensionFallback] stopProviderFallback is true, not trying other providers\n")
|
|
return &DownloadResponse{
|
|
Success: false,
|
|
Error: "Download failed: " + lastErr.Error(),
|
|
ErrorType: firstNonEmptyTrimmed(lastErrType, "extension_error"),
|
|
RetryAfterSeconds: lastRetryAfterSeconds,
|
|
Service: req.Source,
|
|
}, nil
|
|
}
|
|
} else {
|
|
GoLog("[DownloadWithExtensionFallback] Source extension %s not available or not a download provider\n", req.Source)
|
|
}
|
|
}
|
|
|
|
healthProtectedProvider := selectedProvider
|
|
if strings.TrimSpace(healthProtectedProvider) == "" {
|
|
healthProtectedProvider = req.Source
|
|
}
|
|
priority = prioritizeFallbackProvidersByHealth(priority, extManager, healthProtectedProvider)
|
|
priority = moveProviderToFront(priority, selectedProvider)
|
|
|
|
for _, providerID := range priority {
|
|
if isDownloadCancelled(req.ItemID) {
|
|
return nil, ErrDownloadCancelled
|
|
}
|
|
|
|
providerID = strings.TrimSpace(providerID)
|
|
if providerID == "" {
|
|
continue
|
|
}
|
|
// Skip the origin extension only when it differs from the explicitly
|
|
// selected provider; otherwise it must still be attempted here.
|
|
if providerID == req.Source && req.Source != selectedProvider {
|
|
continue
|
|
}
|
|
|
|
if providerID != selectedProvider && !isExtensionFallbackAllowed(providerID) {
|
|
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (not enabled for fallback)\n", providerID)
|
|
continue
|
|
}
|
|
|
|
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
|
|
|
{
|
|
ext, err := extManager.GetExtension(providerID)
|
|
if err != nil || !ext.Enabled || ext.Error != "" {
|
|
GoLog("[DownloadWithExtensionFallback] Extension %s not available\n", providerID)
|
|
continue
|
|
}
|
|
|
|
if !ext.Manifest.IsDownloadProvider() {
|
|
continue
|
|
}
|
|
|
|
provider := newExtensionProviderWrapper(ext)
|
|
|
|
availability, err := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.TidalID, req.QobuzID, req.DurationMS, req.ItemID)
|
|
if shouldAbortCancelledFallback(req.ItemID, err) {
|
|
return nil, ErrDownloadCancelled
|
|
}
|
|
terminalAvailability := shouldStopProviderFallback(availability)
|
|
if err != nil || !availability.Available {
|
|
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
|
if err != nil {
|
|
lastErr = err
|
|
if strings.EqualFold(classifyDownloadErrorType(err.Error()), "verification_required") {
|
|
GoLog("[DownloadWithExtensionFallback] %s requires verification (availability); pausing fallback to open the challenge\n", providerID)
|
|
cachePreparedDownloadRequest(preparationKey, req)
|
|
return &DownloadResponse{
|
|
Success: false,
|
|
Error: "Download failed: " + err.Error(),
|
|
ErrorType: "verification_required",
|
|
Service: providerID,
|
|
}, nil
|
|
}
|
|
}
|
|
if terminalAvailability {
|
|
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after availability check\n", providerID)
|
|
return buildExtensionFallbackStoppedResponse(providerID, availability, err), nil
|
|
}
|
|
continue
|
|
}
|
|
|
|
req.OutputExt = ""
|
|
|
|
// Honor the requested quality when this provider recognizes it
|
|
// (e.g. an explicit user selection). Only when the token is not
|
|
// one of this provider's own options do we fall back to its
|
|
// highest quality, since a source provider's token may not map.
|
|
fallbackQuality := req.Quality
|
|
if len(ext.Manifest.QualityOptions) > 0 {
|
|
requested := strings.TrimSpace(req.Quality)
|
|
recognized := false
|
|
if requested != "" {
|
|
for _, opt := range ext.Manifest.QualityOptions {
|
|
if strings.EqualFold(strings.TrimSpace(opt.ID), requested) {
|
|
recognized = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !recognized {
|
|
if best := strings.TrimSpace(ext.Manifest.QualityOptions[0].ID); best != "" {
|
|
fallbackQuality = best
|
|
}
|
|
}
|
|
}
|
|
|
|
resp, cancelledOuter := attemptExtensionDownload(req, ext, provider, availability.TrackID, fallbackQuality, providerID, false, &lastErr, &lastErrType, &lastRetryAfterSeconds)
|
|
if cancelledOuter {
|
|
return nil, ErrDownloadCancelled
|
|
}
|
|
if resp != nil {
|
|
return resp, nil
|
|
}
|
|
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr)
|
|
|
|
if lastErr != nil {
|
|
effType := lastErrType
|
|
if effType == "" {
|
|
effType = classifyDownloadErrorType(lastErr.Error())
|
|
}
|
|
if isOutputStorageWriteFailure(effType, lastErr.Error()) {
|
|
GoLog("[DownloadWithExtensionFallback] %s hit an unwritable output path; stopping provider fallback\n", providerID)
|
|
return buildOutputStorageFailureResponse(
|
|
providerID,
|
|
lastErr,
|
|
lastRetryAfterSeconds,
|
|
), nil
|
|
}
|
|
if strings.EqualFold(effType, "verification_required") {
|
|
GoLog("[DownloadWithExtensionFallback] %s requires verification; pausing fallback to open the challenge\n", providerID)
|
|
cachePreparedDownloadRequest(preparationKey, req)
|
|
return &DownloadResponse{
|
|
Success: false,
|
|
Error: "Download failed: " + lastErr.Error(),
|
|
ErrorType: "verification_required",
|
|
RetryAfterSeconds: lastRetryAfterSeconds,
|
|
Service: providerID,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
if terminalAvailability {
|
|
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after download failure\n", providerID)
|
|
return buildExtensionFallbackStoppedResponse(providerID, availability, lastErr), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if lastErr != nil {
|
|
errorType := firstNonEmptyTrimmed(lastErrType, classifyDownloadErrorType(lastErr.Error()))
|
|
if errorType == "unknown" {
|
|
errorType = "not_found"
|
|
}
|
|
return &DownloadResponse{
|
|
Success: false,
|
|
Error: "All providers failed. Last error: " + lastErr.Error(),
|
|
ErrorType: errorType,
|
|
RetryAfterSeconds: lastRetryAfterSeconds,
|
|
}, nil
|
|
}
|
|
|
|
return &DownloadResponse{
|
|
Success: false,
|
|
Error: "No extension download providers available",
|
|
ErrorType: "not_found",
|
|
}, nil
|
|
}
|