diff --git a/go_backend/extension_fallback.go b/go_backend/extension_fallback.go index f58c26f1..498e0744 100644 --- a/go_backend/extension_fallback.go +++ b/go_backend/extension_fallback.go @@ -176,56 +176,58 @@ func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult return downloadResult, alreadyExists } +// overlayStr sets *dst = src when dst is empty and src is not. If field is +// non-empty it logs " from enrichment: " on overlay. +func overlayStr(dst *string, src, field string) { + if src == "" || *dst != "" { + return + } + *dst = src + if field != "" { + GoLog("[DownloadWithExtensionFallback] %s from enrichment: %s\n", field, src) + } +} + +// overlayStrTrim is overlayStr but treats a whitespace-only dst as empty too. +func overlayStrTrim(dst *string, src string) { + if src == "" || strings.TrimSpace(*dst) != "" { + return + } + *dst = src +} + +// overlayInt sets *dst = src when dst is zero and src is positive. If field is +// non-empty it logs " from enrichment: " on overlay. +func overlayInt(dst *int, src int, field string) { + if src <= 0 || *dst != 0 { + return + } + *dst = src + if field != "" { + GoLog("[DownloadWithExtensionFallback] %s from enrichment: %d\n", field, src) + } +} + func overlayExtensionDownloadMetadata(resp *DownloadResponse, result *ExtDownloadResult) { if resp == nil || result == nil { return } - if strings.TrimSpace(resp.Title) == "" && result.Title != "" { - resp.Title = result.Title - } - if strings.TrimSpace(resp.Artist) == "" && result.Artist != "" { - resp.Artist = result.Artist - } - if strings.TrimSpace(resp.Album) == "" && result.Album != "" { - resp.Album = result.Album - } - if strings.TrimSpace(resp.AlbumArtist) == "" && result.AlbumArtist != "" { - resp.AlbumArtist = result.AlbumArtist - } - if resp.TrackNumber == 0 && result.TrackNumber > 0 { - resp.TrackNumber = result.TrackNumber - } - if resp.DiscNumber == 0 && result.DiscNumber > 0 { - resp.DiscNumber = result.DiscNumber - } - if resp.TotalTracks == 0 && result.TotalTracks > 0 { - resp.TotalTracks = result.TotalTracks - } - if resp.TotalDiscs == 0 && result.TotalDiscs > 0 { - resp.TotalDiscs = result.TotalDiscs - } - if strings.TrimSpace(resp.ReleaseDate) == "" && result.ReleaseDate != "" { - resp.ReleaseDate = result.ReleaseDate - } - if strings.TrimSpace(resp.CoverURL) == "" && result.CoverURL != "" { - resp.CoverURL = result.CoverURL - } - if strings.TrimSpace(resp.ISRC) == "" && result.ISRC != "" { - resp.ISRC = result.ISRC - } - if strings.TrimSpace(resp.Genre) == "" && result.Genre != "" { - resp.Genre = result.Genre - } - if strings.TrimSpace(resp.Label) == "" && result.Label != "" { - resp.Label = result.Label - } - if strings.TrimSpace(resp.Copyright) == "" && result.Copyright != "" { - resp.Copyright = result.Copyright - } - if strings.TrimSpace(resp.Composer) == "" && result.Composer != "" { - resp.Composer = result.Composer - } + overlayStrTrim(&resp.Title, result.Title) + overlayStrTrim(&resp.Artist, result.Artist) + overlayStrTrim(&resp.Album, result.Album) + overlayStrTrim(&resp.AlbumArtist, result.AlbumArtist) + overlayInt(&resp.TrackNumber, result.TrackNumber, "") + overlayInt(&resp.DiscNumber, result.DiscNumber, "") + overlayInt(&resp.TotalTracks, result.TotalTracks, "") + overlayInt(&resp.TotalDiscs, result.TotalDiscs, "") + overlayStrTrim(&resp.ReleaseDate, result.ReleaseDate) + overlayStrTrim(&resp.CoverURL, result.CoverURL) + overlayStrTrim(&resp.ISRC, result.ISRC) + overlayStrTrim(&resp.Genre, result.Genre) + overlayStrTrim(&resp.Label, result.Label) + overlayStrTrim(&resp.Copyright, result.Copyright) + overlayStrTrim(&resp.Composer, result.Composer) if result.LyricsLRC != "" { resp.LyricsLRC = result.LyricsLRC } @@ -251,33 +253,15 @@ func applyExtensionRequestFallbacks(resp *DownloadResponse, req DownloadRequest) return } - if req.AlbumName != "" && resp.Album == "" { - resp.Album = req.AlbumName - } - if req.AlbumArtist != "" && resp.AlbumArtist == "" { - resp.AlbumArtist = req.AlbumArtist - } - if req.ReleaseDate != "" && resp.ReleaseDate == "" { - resp.ReleaseDate = req.ReleaseDate - } - if req.ISRC != "" && resp.ISRC == "" { - resp.ISRC = req.ISRC - } - if req.TrackNumber > 0 && resp.TrackNumber == 0 { - resp.TrackNumber = req.TrackNumber - } - if req.TotalTracks > 0 && resp.TotalTracks == 0 { - resp.TotalTracks = req.TotalTracks - } - if req.DiscNumber > 0 && resp.DiscNumber == 0 { - resp.DiscNumber = req.DiscNumber - } - if req.TotalDiscs > 0 && resp.TotalDiscs == 0 { - resp.TotalDiscs = req.TotalDiscs - } - if req.CoverURL != "" && resp.CoverURL == "" { - resp.CoverURL = req.CoverURL - } + overlayStr(&resp.Album, req.AlbumName, "") + overlayStr(&resp.AlbumArtist, req.AlbumArtist, "") + overlayStr(&resp.ReleaseDate, req.ReleaseDate, "") + overlayStr(&resp.ISRC, req.ISRC, "") + overlayInt(&resp.TrackNumber, req.TrackNumber, "") + overlayInt(&resp.TotalTracks, req.TotalTracks, "") + overlayInt(&resp.DiscNumber, req.DiscNumber, "") + overlayInt(&resp.TotalDiscs, req.TotalDiscs, "") + overlayStr(&resp.CoverURL, req.CoverURL, "") } func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool { @@ -387,6 +371,119 @@ func normalizeExtensionDownloadErrorType(errorType, message string) string { return normalized } +// 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 req.ItemID != "" { + StartItemProgress(req.ItemID) + } + + 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) + } + }) + if req.ItemID != "" { + if err == nil && result != nil && result.Success { + CompleteItemProgress(req.ItemID) + } else { + RemoveItemProgress(req.ItemID) + } + } + if shouldAbortCancelledFallback(req.ItemID, err) { + return nil, true + } + + if err == nil && result.Success { + 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) + } + } + + 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.ErrorMessage != "" { + *lastErr = fmt.Errorf("%s", result.ErrorMessage) + *lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage) + *lastRetryAfterSeconds = result.RetryAfterSeconds + } + return nil, false +} + func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) { priority := GetProviderPriority() extManager := getExtensionManager() @@ -507,60 +604,20 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro if enrichedTrack.Artists != "" { req.ArtistName = enrichedTrack.Artists } - if enrichedTrack.AlbumName != "" && req.AlbumName == "" { - GoLog("[DownloadWithExtensionFallback] AlbumName from enrichment: %s\n", enrichedTrack.AlbumName) - req.AlbumName = enrichedTrack.AlbumName - } - if enrichedTrack.AlbumArtist != "" && req.AlbumArtist == "" { - req.AlbumArtist = enrichedTrack.AlbumArtist - } - if enrichedTrack.DurationMS > 0 && req.DurationMS == 0 { - GoLog("[DownloadWithExtensionFallback] DurationMS from enrichment: %d\n", enrichedTrack.DurationMS) - req.DurationMS = enrichedTrack.DurationMS - } - if enrichedTrack.CoverURL != "" && req.CoverURL == "" { - req.CoverURL = enrichedTrack.CoverURL - } - if enrichedTrack.ID != "" && req.SpotifyID == "" { - GoLog("[DownloadWithExtensionFallback] Track ID from enrichment: %s\n", enrichedTrack.ID) - req.SpotifyID = enrichedTrack.ID - } - if enrichedTrack.Label != "" && req.Label == "" { - GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label) - req.Label = enrichedTrack.Label - } - if enrichedTrack.Copyright != "" && req.Copyright == "" { - GoLog("[DownloadWithExtensionFallback] Copyright from enrichment: %s\n", enrichedTrack.Copyright) - req.Copyright = enrichedTrack.Copyright - } - if enrichedTrack.Genre != "" && req.Genre == "" { - GoLog("[DownloadWithExtensionFallback] Genre from enrichment: %s\n", enrichedTrack.Genre) - req.Genre = enrichedTrack.Genre - } - if enrichedTrack.ReleaseDate != "" && req.ReleaseDate == "" { - GoLog("[DownloadWithExtensionFallback] ReleaseDate from enrichment: %s\n", enrichedTrack.ReleaseDate) - req.ReleaseDate = enrichedTrack.ReleaseDate - } - if enrichedTrack.TrackNumber > 0 && req.TrackNumber == 0 { - GoLog("[DownloadWithExtensionFallback] TrackNumber from enrichment: %d\n", enrichedTrack.TrackNumber) - req.TrackNumber = enrichedTrack.TrackNumber - } - if enrichedTrack.TotalTracks > 0 && req.TotalTracks == 0 { - GoLog("[DownloadWithExtensionFallback] TotalTracks from enrichment: %d\n", enrichedTrack.TotalTracks) - req.TotalTracks = enrichedTrack.TotalTracks - } - if enrichedTrack.DiscNumber > 0 && req.DiscNumber == 0 { - GoLog("[DownloadWithExtensionFallback] DiscNumber from enrichment: %d\n", enrichedTrack.DiscNumber) - req.DiscNumber = enrichedTrack.DiscNumber - } - if enrichedTrack.TotalDiscs > 0 && req.TotalDiscs == 0 { - GoLog("[DownloadWithExtensionFallback] TotalDiscs from enrichment: %d\n", enrichedTrack.TotalDiscs) - req.TotalDiscs = enrichedTrack.TotalDiscs - } - if enrichedTrack.Composer != "" && req.Composer == "" { - GoLog("[DownloadWithExtensionFallback] Composer from enrichment: %s\n", enrichedTrack.Composer) - req.Composer = enrichedTrack.Composer - } + 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") } } } @@ -581,45 +638,19 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro 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) - if track.AlbumName != "" && req.AlbumName == "" { - req.AlbumName = track.AlbumName - } - if track.AlbumArtist != "" && req.AlbumArtist == "" { - req.AlbumArtist = track.AlbumArtist - } - if track.ReleaseDate != "" && req.ReleaseDate == "" { - req.ReleaseDate = track.ReleaseDate - } - if track.ISRC != "" && req.ISRC == "" { - req.ISRC = track.ISRC - } - if track.TrackNumber > 0 && req.TrackNumber == 0 { - req.TrackNumber = track.TrackNumber - } - if track.TotalTracks > 0 && req.TotalTracks == 0 { - req.TotalTracks = track.TotalTracks - } - if track.DiscNumber > 0 && req.DiscNumber == 0 { - req.DiscNumber = track.DiscNumber - } - if track.TotalDiscs > 0 && req.TotalDiscs == 0 { - req.TotalDiscs = track.TotalDiscs - } - if track.Composer != "" && req.Composer == "" { - req.Composer = track.Composer - } - if track.CoverURL != "" && req.CoverURL == "" { - req.CoverURL = track.CoverURL - } - if track.Genre != "" && req.Genre == "" { - req.Genre = track.Genre - } - if track.Label != "" && req.Label == "" { - req.Label = track.Label - } - if track.Copyright != "" && req.Copyright == "" { - req.Copyright = track.Copyright - } + 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) } @@ -655,95 +686,12 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (stopProviderFallback: %v)\n", trackID, stopProviderFallback) - outputPath := buildOutputPathForExtension(req, ext) - if req.ItemID != "" { - StartItemProgress(req.ItemID) - } - - result, err := provider.Download(trackID, req.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) - } - }) - if req.ItemID != "" { - if err == nil && result != nil && result.Success { - CompleteItemProgress(req.ItemID) - } else { - RemoveItemProgress(req.ItemID) - } - } - if shouldAbortCancelledFallback(req.ItemID, err) { + resp, cancelledOuter := attemptExtensionDownload(req, ext, provider, trackID, req.Quality, req.Source, true, &lastErr, &lastErrType, &lastRetryAfterSeconds) + if cancelledOuter { return nil, ErrDownloadCancelled } - - if err == nil && result.Success { - normalizedResult, alreadyExists := normalizeExtensionDownloadResult(result) - message := "Downloaded from " + req.Source - if alreadyExists { - message = "File already exists" - } - - resp := buildDownloadSuccessResponse( - req, - normalizedResult, - req.Source, - message, - normalizedResult.FilePath, - alreadyExists, - ) - overlayExtensionDownloadMetadata(&resp, result) - if ext.Manifest.SkipMetadataEnrichment { - resp.SkipMetadataEnrichment = true - } - applyExtensionRequestFallbacks(&resp, req) - - if req.TrackName != "" && resp.Title == "" { - resp.Title = req.TrackName - } - if req.ArtistName != "" && resp.Artist == "" { - resp.Artist = req.ArtistName - } - if req.Composer != "" && resp.Composer == "" { - resp.Composer = req.Composer - } - - embedExtensionDownloadMetadata(resp, req, alreadyExists) - - if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" { - indexISRC := strings.TrimSpace(resp.ISRC) - if indexISRC == "" { - indexISRC = strings.TrimSpace(req.ISRC) - } - if indexISRC != "" && strings.TrimSpace(resp.FilePath) != "" { - AddToISRCIndex(req.OutputDir, indexISRC, resp.FilePath) - } - } - - return &resp, nil - } - - if err != nil { - if errors.Is(err, ErrDownloadCancelled) { - return &DownloadResponse{ - Success: false, - Error: "Download cancelled", - ErrorType: "cancelled", - Service: req.Source, - }, nil - } - lastErr = err - lastErrType = "" - } else if result.ErrorMessage != "" { - lastErr = fmt.Errorf("%s", result.ErrorMessage) - lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage) - lastRetryAfterSeconds = result.RetryAfterSeconds + if resp != nil { + return resp, nil } GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr) @@ -840,10 +788,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } req.OutputExt = "" - outputPath := buildOutputPathForExtension(req, ext) - if req.ItemID != "" { - StartItemProgress(req.ItemID) - } // Honor the requested quality when this provider recognizes it // (e.g. an explicit user selection). Only when the token is not @@ -868,80 +812,12 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } } - result, err := provider.Download(availability.TrackID, fallbackQuality, 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) - } - }) - if req.ItemID != "" { - if err == nil && result != nil && result.Success { - CompleteItemProgress(req.ItemID) - } else { - RemoveItemProgress(req.ItemID) - } - } - if shouldAbortCancelledFallback(req.ItemID, err) { + resp, cancelledOuter := attemptExtensionDownload(req, ext, provider, availability.TrackID, fallbackQuality, providerID, false, &lastErr, &lastErrType, &lastRetryAfterSeconds) + if cancelledOuter { return nil, ErrDownloadCancelled } - - if err == nil && result.Success { - normalizedResult, alreadyExists := normalizeExtensionDownloadResult(result) - message := "Downloaded from " + providerID - if alreadyExists { - message = "File already exists" - } - - resp := buildDownloadSuccessResponse( - req, - normalizedResult, - providerID, - message, - normalizedResult.FilePath, - alreadyExists, - ) - overlayExtensionDownloadMetadata(&resp, result) - if ext.Manifest.SkipMetadataEnrichment { - resp.SkipMetadataEnrichment = true - } - applyExtensionRequestFallbacks(&resp, req) - - embedExtensionDownloadMetadata(resp, req, alreadyExists) - - if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" { - indexISRC := strings.TrimSpace(resp.ISRC) - if indexISRC == "" { - indexISRC = strings.TrimSpace(req.ISRC) - } - if indexISRC != "" && strings.TrimSpace(resp.FilePath) != "" { - AddToISRCIndex(req.OutputDir, indexISRC, resp.FilePath) - } - } - - return &resp, nil - } - - if err != nil { - if errors.Is(err, ErrDownloadCancelled) { - return &DownloadResponse{ - Success: false, - Error: "Download cancelled", - ErrorType: "cancelled", - Service: providerID, - }, nil - } - lastErr = err - lastErrType = "" - } else if result.ErrorMessage != "" { - lastErr = fmt.Errorf("%s", result.ErrorMessage) - lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage) - lastRetryAfterSeconds = result.RetryAfterSeconds + if resp != nil { + return resp, nil } GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr) @@ -989,11 +865,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro }, nil } -func buildOutputPath(req DownloadRequest) string { - if strings.TrimSpace(req.OutputPath) != "" { - return strings.TrimSpace(req.OutputPath) - } - +// buildDownloadFilename renders the sanitized "" filename for req +// from its template/metadata, defaulting to " - .flac". +func buildDownloadFilename(req DownloadRequest) string { metadata := map[string]any{ "title": req.TrackName, "artist": req.ArtistName, @@ -1026,6 +900,14 @@ func buildOutputPath(req DownloadRequest) string { ext = "." + ext } + return filename + ext +} + +func buildOutputPath(req DownloadRequest) string { + if strings.TrimSpace(req.OutputPath) != "" { + return strings.TrimSpace(req.OutputPath) + } + outputDir := req.OutputDir if strings.TrimSpace(outputDir) == "" { outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads") @@ -1033,7 +915,7 @@ func buildOutputPath(req DownloadRequest) string { os.MkdirAll(outputDir, 0755) AddAllowedDownloadDir(outputDir) - return filepath.Join(outputDir, filename+ext) + return filepath.Join(outputDir, buildDownloadFilename(req)) } func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) string { @@ -1054,39 +936,7 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri os.MkdirAll(tempDir, 0755) AddAllowedDownloadDir(tempDir) - metadata := map[string]any{ - "title": req.TrackName, - "artist": req.ArtistName, - "album": req.AlbumName, - "album_artist": req.AlbumArtist, - "track": req.TrackNumber, - "track_number": req.TrackNumber, - "total_tracks": req.TotalTracks, - "playlist_position": req.PlaylistPosition, - "disc": req.DiscNumber, - "disc_number": req.DiscNumber, - "total_discs": req.TotalDiscs, - "year": extractYear(req.ReleaseDate), - "date": req.ReleaseDate, - "release_date": req.ReleaseDate, - "isrc": req.ISRC, - "composer": req.Composer, - } - - filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) - if strings.TrimSpace(filename) == "" { - filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName) - } - filename = sanitizeFilename(filename) - - outputExt := strings.TrimSpace(req.OutputExt) - if outputExt == "" { - outputExt = ".flac" - } else if !strings.HasPrefix(outputExt, ".") { - outputExt = "." + outputExt - } - - return filepath.Join(tempDir, filename+outputExt) + return filepath.Join(tempDir, buildDownloadFilename(req)) } func canEmbedGenreLabel(filePath string) bool { diff --git a/go_backend/extension_provider_wrapper.go b/go_backend/extension_provider_wrapper.go index 47c9f4d7..171dba8c 100644 --- a/go_backend/extension_provider_wrapper.go +++ b/go_backend/extension_provider_wrapper.go @@ -32,6 +32,110 @@ func (p *extensionProviderWrapper) lockReadyVM() error { return nil } +// extCallOpts configures a shared extension script invocation. It covers the +// skeleton common to most extensionProviderWrapper methods: perf tracking, VM +// locking, optional download/request cancellation binding, running the +// script, and translating timeouts/cancellation into the right error. +type extCallOpts struct { + perfName string + script string + timeout time.Duration + itemID string // optional: binds download-cancel + active-item tracking + requestID string // optional: binds request-cancel via context (customSearch only) + // beforeRun runs after lock+cancel setup, right before the script executes + // (used to stash query/options as globals instead of embedding them in the + // script source). Its returned cleanup, if any, runs after the script call. + beforeRun func() func() + // timeoutMessage overrides the default "<perfName> timeout: extension took + // too long to respond". + timeoutMessage string + // rawError returns non-timeout script errors unwrapped instead of + // "<perfName> failed: %w". + rawError bool +} + +// callExtensionScript locks the extension's VM, runs opts.script, and hands +// the raw result to parse while the VM lock is still held. parse is where +// each caller does its type-specific parsing, perf.recordParse/setItems, and +// any ProviderID stamping. +func callExtensionScript[T any](p *extensionProviderWrapper, opts extCallOpts, parse func(perf *extensionCallPerf, result goja.Value) (T, error)) (T, error) { + var zero T + + perf := newExtensionCallPerf(p.extension.ID, opts.perfName) + defer perf.finish() + initStartedAt := time.Now() + if err := p.lockReadyVM(); err != nil { + return zero, err + } + perf.recordInit(time.Since(initStartedAt)) + defer p.extension.VMMu.Unlock() + + if opts.itemID != "" { + if p.extension.runtime != nil { + p.extension.runtime.setActiveDownloadItemID(opts.itemID) + defer p.extension.runtime.clearActiveDownloadItemID() + } + initDownloadCancel(opts.itemID) + defer clearDownloadCancel(opts.itemID) + if isDownloadCancelled(opts.itemID) { + return zero, ErrDownloadCancelled + } + } + + ctx := context.Background() + if opts.requestID != "" { + if p.extension.runtime != nil { + p.extension.runtime.setActiveRequestID(opts.requestID) + defer p.extension.runtime.clearActiveRequestID() + } + ctx = initExtensionRequestCancel(opts.requestID) + defer clearExtensionRequestCancel(opts.requestID) + if isExtensionRequestCancelled(opts.requestID) { + return zero, ErrExtensionRequestCancelled + } + } + + if opts.beforeRun != nil { + if cleanup := opts.beforeRun(); cleanup != nil { + defer cleanup() + } + } + + jsStartedAt := time.Now() + result, err := RunWithTimeoutContextAndRecover(ctx, p.vm, opts.script, opts.timeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) + if err != nil { + if opts.requestID != "" && isExtensionRequestCancelled(opts.requestID) { + return zero, ErrExtensionRequestCancelled + } + if opts.itemID != "" && isDownloadCancelled(opts.itemID) { + return zero, ErrDownloadCancelled + } + if opts.requestID != "" && errors.Is(err, ErrExtensionRequestCancelled) { + return zero, ErrExtensionRequestCancelled + } + if IsTimeoutError(err) { + if opts.timeoutMessage != "" { + return zero, errors.New(opts.timeoutMessage) + } + return zero, fmt.Errorf("%s timeout: extension took too long to respond", opts.perfName) + } + if opts.rawError { + return zero, err + } + return zero, fmt.Errorf("%s failed: %w", opts.perfName, err) + } + if opts.itemID != "" && isDownloadCancelled(opts.itemID) { + return zero, ErrDownloadCancelled + } + if opts.requestID != "" && isExtensionRequestCancelled(opts.requestID) { + return zero, ErrExtensionRequestCancelled + } + + return parse(perf, result) +} + func (p *extensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) { return p.SearchTracksForItemID(query, limit, "") } @@ -40,29 +144,9 @@ func (p *extensionProviderWrapper) SearchTracksForItemID(query string, limit int if !p.extension.Manifest.IsMetadataProvider() { return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) } - if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - perf := newExtensionCallPerf(p.extension.ID, "searchTracks") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - 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() { @@ -73,58 +157,38 @@ func (p *extensionProviderWrapper) SearchTracksForItemID(query string, limit int })() `, query, limit) - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if isDownloadCancelled(itemID) { - return nil, ErrDownloadCancelled + return callExtensionScript(p, extCallOpts{ + perfName: "searchTracks", + script: script, + timeout: DefaultJSTimeout, + itemID: itemID, + }, func(perf *extensionCallPerf, result goja.Value) (*ExtSearchResult, error) { + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("searchTracks returned null") } - if IsTimeoutError(err) { - return nil, fmt.Errorf("searchTracks timeout: extension took too long to respond") + parseStartedAt := time.Now() + searchResult, err := parseExtensionSearchResult(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + if err != nil { + return nil, fmt.Errorf("failed to parse search result: %w", err) } - return nil, fmt.Errorf("searchTracks failed: %w", err) - } - if isDownloadCancelled(itemID) { - return nil, ErrDownloadCancelled - } + perf.setItems(len(searchResult.Tracks)) - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return nil, fmt.Errorf("searchTracks returned null") - } + for i := range searchResult.Tracks { + searchResult.Tracks[i].ProviderID = p.extension.ID + } - parseStartedAt := time.Now() - searchResult, err := parseExtensionSearchResult(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - if err != nil { - return nil, fmt.Errorf("failed to parse search result: %w", err) - } - perf.setItems(len(searchResult.Tracks)) - - for i := range searchResult.Tracks { - searchResult.Tracks[i].ProviderID = p.extension.ID - } - - return &searchResult, nil + return &searchResult, nil + }) } func (p *extensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) { if !p.extension.Manifest.IsMetadataProvider() { return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) } - if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - perf := newExtensionCallPerf(p.extension.ID, "getTrack") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` (function() { @@ -135,45 +199,30 @@ func (p *extensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, })() `, trackID) - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if IsTimeoutError(err) { - return nil, fmt.Errorf("getTrack timeout: extension took too long to respond") + return callExtensionScript(p, extCallOpts{ + perfName: "getTrack", + script: script, + timeout: DefaultJSTimeout, + }, func(perf *extensionCallPerf, result goja.Value) (*ExtTrackMetadata, error) { + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getTrack returned null") } - return nil, fmt.Errorf("getTrack failed: %w", err) - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return nil, fmt.Errorf("getTrack returned null") - } - - parseStartedAt := time.Now() - track := parseExtensionTrackValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - perf.setItems(1) - track.ProviderID = p.extension.ID - return &track, nil + parseStartedAt := time.Now() + track := parseExtensionTrackValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) + track.ProviderID = p.extension.ID + return &track, nil + }) } func (p *extensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) { if !p.extension.Manifest.IsMetadataProvider() { return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) } - if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - perf := newExtensionCallPerf(p.extension.ID, "getAlbum") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` (function() { @@ -184,52 +233,37 @@ func (p *extensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, })() `, albumID) - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if IsTimeoutError(err) { - return nil, fmt.Errorf("getAlbum timeout: extension took too long to respond") + return callExtensionScript(p, extCallOpts{ + perfName: "getAlbum", + script: script, + timeout: DefaultJSTimeout, + }, func(perf *extensionCallPerf, result goja.Value) (*ExtAlbumMetadata, error) { + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getAlbum returned null") } - return nil, fmt.Errorf("getAlbum failed: %w", err) - } + parseStartedAt := time.Now() + album, err := parseExtensionAlbumValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + if err != nil { + return nil, fmt.Errorf("failed to parse album: %w", err) + } + perf.setItems(len(album.Tracks)) - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return nil, fmt.Errorf("getAlbum returned null") - } - - parseStartedAt := time.Now() - album, err := parseExtensionAlbumValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - if err != nil { - return nil, fmt.Errorf("failed to parse album: %w", err) - } - perf.setItems(len(album.Tracks)) - - album.ProviderID = p.extension.ID - for i := range album.Tracks { - album.Tracks[i].ProviderID = p.extension.ID - } - return &album, nil + album.ProviderID = p.extension.ID + for i := range album.Tracks { + album.Tracks[i].ProviderID = p.extension.ID + } + return &album, nil + }) } func (p *extensionProviderWrapper) GetPlaylist(playlistID string) (*ExtAlbumMetadata, error) { if !p.extension.Manifest.IsMetadataProvider() { return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) } - if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - perf := newExtensionCallPerf(p.extension.ID, "getPlaylist") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` (function() { @@ -243,52 +277,37 @@ func (p *extensionProviderWrapper) GetPlaylist(playlistID string) (*ExtAlbumMeta })() `, playlistID, playlistID) - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if IsTimeoutError(err) { - return nil, fmt.Errorf("getPlaylist timeout: extension took too long to respond") + return callExtensionScript(p, extCallOpts{ + perfName: "getPlaylist", + script: script, + timeout: DefaultJSTimeout, + }, func(perf *extensionCallPerf, result goja.Value) (*ExtAlbumMetadata, error) { + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getPlaylist returned null") } - return nil, fmt.Errorf("getPlaylist failed: %w", err) - } + parseStartedAt := time.Now() + playlist, err := parseExtensionAlbumValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + if err != nil { + return nil, fmt.Errorf("failed to parse playlist: %w", err) + } + perf.setItems(len(playlist.Tracks)) - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return nil, fmt.Errorf("getPlaylist returned null") - } - - parseStartedAt := time.Now() - playlist, err := parseExtensionAlbumValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - if err != nil { - return nil, fmt.Errorf("failed to parse playlist: %w", err) - } - perf.setItems(len(playlist.Tracks)) - - playlist.ProviderID = p.extension.ID - for i := range playlist.Tracks { - playlist.Tracks[i].ProviderID = p.extension.ID - } - return &playlist, nil + playlist.ProviderID = p.extension.ID + for i := range playlist.Tracks { + playlist.Tracks[i].ProviderID = p.extension.ID + } + return &playlist, nil + }) } func (p *extensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) { if !p.extension.Manifest.IsMetadataProvider() { return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) } - if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - perf := newExtensionCallPerf(p.extension.ID, "getArtist") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` (function() { @@ -299,43 +318,40 @@ func (p *extensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat })() `, artistID) - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if IsTimeoutError(err) { - return nil, fmt.Errorf("getArtist timeout: extension took too long to respond") + return callExtensionScript(p, extCallOpts{ + perfName: "getArtist", + script: script, + timeout: DefaultJSTimeout, + }, func(perf *extensionCallPerf, result goja.Value) (*ExtArtistMetadata, error) { + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getArtist returned null") } - return nil, fmt.Errorf("getArtist failed: %w", err) - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return nil, fmt.Errorf("getArtist returned null") - } - - parseStartedAt := time.Now() - artist, err := parseExtensionArtistValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - if err != nil { - return nil, fmt.Errorf("failed to parse artist: %w", err) - } - perf.setItems(len(artist.Albums) + len(artist.Releases) + len(artist.TopTracks)) - - artist.ProviderID = p.extension.ID - for i := range artist.Releases { - artist.Releases[i].ProviderID = p.extension.ID - for j := range artist.Releases[i].Tracks { - artist.Releases[i].Tracks[j].ProviderID = p.extension.ID + parseStartedAt := time.Now() + artist, err := parseExtensionArtistValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + if err != nil { + return nil, fmt.Errorf("failed to parse artist: %w", err) } - } - return &artist, nil + perf.setItems(len(artist.Albums) + len(artist.Releases) + len(artist.TopTracks)) + + artist.ProviderID = p.extension.ID + for i := range artist.Releases { + artist.Releases[i].ProviderID = p.extension.ID + for j := range artist.Releases[i].Tracks { + artist.Releases[i].Tracks[j].ProviderID = p.extension.ID + } + } + return &artist, nil + }) } func (p *extensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) { return p.EnrichTrackForItemID(track, "") } +// EnrichTrackForItemID is excluded from the shared callExtensionScript helper: +// unlike the other providers it must return the original track (not an error) +// on every failure path, which doesn't fit the helper's error-returning shape. func (p *extensionProviderWrapper) EnrichTrackForItemID(track *ExtTrackMetadata, itemID string) (*ExtTrackMetadata, error) { if !p.extension.Manifest.IsMetadataProvider() { return track, nil @@ -417,29 +433,9 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a if !p.extension.Manifest.IsDownloadProvider() { return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) } - if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - perf := newExtensionCallPerf(p.extension.ID, "checkAvailability") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - 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() { @@ -456,36 +452,28 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a })() `, isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID, durationMS) - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if isDownloadCancelled(itemID) { - return nil, ErrDownloadCancelled + return callExtensionScript(p, extCallOpts{ + perfName: "checkAvailability", + script: script, + timeout: DefaultJSTimeout, + itemID: itemID, + }, func(perf *extensionCallPerf, result goja.Value) (*ExtAvailabilityResult, error) { + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return &ExtAvailabilityResult{Available: false, Reason: "not implemented"}, nil } - 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 - } - - parseStartedAt := time.Now() - availability := parseExtensionAvailabilityValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - perf.setItems(1) - return &availability, nil + parseStartedAt := time.Now() + availability := parseExtensionAvailabilityValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) + return &availability, nil + }) } const ExtDownloadTimeout = DownloadTimeout +// Download is excluded from the shared callExtensionScript helper: it runs in +// an isolated VM/runtime (not p.vm/p.extension.VMMu) with a progress +// callback, which the helper's lock+perf model doesn't cover. func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) { if !p.extension.Manifest.IsDownloadProvider() { return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) @@ -608,42 +596,9 @@ func (p *extensionProviderWrapper) customSearch(query string, options map[string if !p.extension.Manifest.HasCustomSearch() { return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID) } - if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - perf := newExtensionCallPerf(p.extension.ID, "customSearch") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - 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 - } - } - requestCtx := context.Background() - if requestID != "" { - if p.extension.runtime != nil { - p.extension.runtime.setActiveRequestID(requestID) - defer p.extension.runtime.clearActiveRequestID() - } - requestCtx = initExtensionRequestCancel(requestID) - defer clearExtensionRequestCancel(requestID) - if isExtensionRequestCancelled(requestID) { - return nil, ErrExtensionRequestCancelled - } - } - if options == nil { options = map[string]any{} } @@ -652,13 +607,6 @@ func (p *extensionProviderWrapper) customSearch(query string, options map[string // parser/runtime edge cases on specific devices/Goja builds. const queryVar = "__sf_custom_search_query" const optionsVar = "__sf_custom_search_options" - global := p.vm.GlobalObject() - _ = global.Set(queryVar, query) - _ = global.Set(optionsVar, options) - defer func() { - global.Delete(queryVar) - global.Delete(optionsVar) - }() const script = ` (function() { @@ -669,49 +617,39 @@ func (p *extensionProviderWrapper) customSearch(query string, options map[string })() ` - jsStartedAt := time.Now() - result, err := RunWithTimeoutContextAndRecover(requestCtx, p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if isExtensionRequestCancelled(requestID) { - return nil, ErrExtensionRequestCancelled + return callExtensionScript(p, extCallOpts{ + perfName: "customSearch", + script: script, + timeout: DefaultJSTimeout, + itemID: itemID, + requestID: requestID, + beforeRun: func() func() { + global := p.vm.GlobalObject() + _ = global.Set(queryVar, query) + _ = global.Set(optionsVar, options) + return func() { + global.Delete(queryVar) + global.Delete(optionsVar) + } + }, + }, func(perf *extensionCallPerf, result goja.Value) ([]ExtTrackMetadata, error) { + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return []ExtTrackMetadata{}, nil } - if isDownloadCancelled(itemID) { - return nil, ErrDownloadCancelled + parseStartedAt := time.Now() + tracks, err := parseExtensionTrackArray(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + if err != nil { + return nil, fmt.Errorf("failed to parse search result: %w", err) } - if errors.Is(err, ErrExtensionRequestCancelled) { - return nil, ErrExtensionRequestCancelled + perf.setItems(len(tracks)) + + for i := range tracks { + tracks[i].ProviderID = p.extension.ID } - 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 isExtensionRequestCancelled(requestID) { - return nil, ErrExtensionRequestCancelled - } - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return []ExtTrackMetadata{}, nil - } - - parseStartedAt := time.Now() - tracks, err := parseExtensionTrackArray(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - if err != nil { - return nil, fmt.Errorf("failed to parse search result: %w", err) - } - perf.setItems(len(tracks)) - - for i := range tracks { - tracks[i].ProviderID = p.extension.ID - } - - return tracks, nil + return tracks, nil + }) } type ExtURLHandleResult struct { @@ -730,18 +668,9 @@ func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e if !p.extension.Manifest.HasURLHandler() { return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID) } - if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - perf := newExtensionCallPerf(p.extension.ID, "handleUrl") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` (function() { @@ -752,71 +681,65 @@ func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e })() `, url) - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if IsTimeoutError(err) { - return nil, fmt.Errorf("handleUrl timeout: extension took too long to respond") + return callExtensionScript(p, extCallOpts{ + perfName: "handleUrl", + script: script, + timeout: DefaultJSTimeout, + }, func(perf *extensionCallPerf, result goja.Value) (*ExtURLHandleResult, error) { + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("handleUrl returned null - URL not recognized") } - return nil, fmt.Errorf("handleUrl failed: %w", err) - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return nil, fmt.Errorf("handleUrl returned null - URL not recognized") - } - - parseStartedAt := time.Now() - handleResult, err := parseExtensionURLHandleValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - if err != nil { - return nil, fmt.Errorf("failed to parse URL handle result: %w", err) - } - urlItems := len(handleResult.Tracks) - if handleResult.Track != nil { - urlItems++ - } - if handleResult.Album != nil { - urlItems += 1 + len(handleResult.Album.Tracks) - } - if handleResult.Artist != nil { - urlItems += 1 + len(handleResult.Artist.Albums) + len(handleResult.Artist.Releases) + len(handleResult.Artist.TopTracks) - } - perf.setItems(urlItems) - - if handleResult.Track != nil { - handleResult.Track.ProviderID = p.extension.ID - } - for i := range handleResult.Tracks { - handleResult.Tracks[i].ProviderID = p.extension.ID - } - if handleResult.Album != nil { - handleResult.Album.ProviderID = p.extension.ID - for i := range handleResult.Album.Tracks { - handleResult.Album.Tracks[i].ProviderID = p.extension.ID + parseStartedAt := time.Now() + handleResult, err := parseExtensionURLHandleValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + if err != nil { + return nil, fmt.Errorf("failed to parse URL handle result: %w", err) } - } - if handleResult.Artist != nil { - handleResult.Artist.ProviderID = p.extension.ID - for i := range handleResult.Artist.Albums { - handleResult.Artist.Albums[i].ProviderID = p.extension.ID - for j := range handleResult.Artist.Albums[i].Tracks { - handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID + urlItems := len(handleResult.Tracks) + if handleResult.Track != nil { + urlItems++ + } + if handleResult.Album != nil { + urlItems += 1 + len(handleResult.Album.Tracks) + } + if handleResult.Artist != nil { + urlItems += 1 + len(handleResult.Artist.Albums) + len(handleResult.Artist.Releases) + len(handleResult.Artist.TopTracks) + } + perf.setItems(urlItems) + + if handleResult.Track != nil { + handleResult.Track.ProviderID = p.extension.ID + } + for i := range handleResult.Tracks { + handleResult.Tracks[i].ProviderID = p.extension.ID + } + if handleResult.Album != nil { + handleResult.Album.ProviderID = p.extension.ID + for i := range handleResult.Album.Tracks { + handleResult.Album.Tracks[i].ProviderID = p.extension.ID } } - for i := range handleResult.Artist.Releases { - handleResult.Artist.Releases[i].ProviderID = p.extension.ID - for j := range handleResult.Artist.Releases[i].Tracks { - handleResult.Artist.Releases[i].Tracks[j].ProviderID = p.extension.ID + if handleResult.Artist != nil { + handleResult.Artist.ProviderID = p.extension.ID + for i := range handleResult.Artist.Albums { + handleResult.Artist.Albums[i].ProviderID = p.extension.ID + for j := range handleResult.Artist.Albums[i].Tracks { + handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID + } + } + for i := range handleResult.Artist.Releases { + handleResult.Artist.Releases[i].ProviderID = p.extension.ID + for j := range handleResult.Artist.Releases[i].Tracks { + handleResult.Artist.Releases[i].Tracks[j].ProviderID = p.extension.ID + } + } + for i := range handleResult.Artist.TopTracks { + handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID } } - for i := range handleResult.Artist.TopTracks { - handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID - } - } - return &handleResult, nil + return &handleResult, nil + }) } type PostProcessResult struct { @@ -839,125 +762,78 @@ type PostProcessInput struct { const PostProcessTimeout = 2 * time.Minute -func (p *extensionProviderWrapper) PostProcess(filePath string, metadata map[string]any, hookID string) (*PostProcessResult, error) { +// postProcessCommon backs both PostProcess (V1) and PostProcessV2. V1 probes +// only extension.postProcess (its original contract: V2-only extensions are +// not invoked via V1); V2 probes postProcessV2 first, then falls back to +// postProcess. +func (p *extensionProviderWrapper) postProcessCommon(input PostProcessInput, metadata map[string]any, hookID string, preferV2 bool) (*PostProcessResult, error) { if !p.extension.Manifest.HasPostProcessing() { return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID) } - if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - perf := newExtensionCallPerf(p.extension.ID, "postProcess") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return &PostProcessResult{Success: false, Error: err.Error()}, nil - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() - - metadataJSON, _ := json.Marshal(metadata) - - script := fmt.Sprintf(` - (function() { - if (typeof extension !== 'undefined' && typeof extension.postProcess === 'function') { - return extension.postProcess(%q, %s, %q); - } - return null; - })() - `, filePath, string(metadataJSON), hookID) - - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - errMsg := err.Error() - if IsTimeoutError(err) { - errMsg = "postProcess timeout: extension took too long to complete" - } - return &PostProcessResult{ - Success: false, - Error: errMsg, - }, nil - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return &PostProcessResult{ - Success: false, - Error: "postProcess returned null", - }, nil - } - - parseStartedAt := time.Now() - postResult := parseExtensionPostProcessValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - perf.setItems(1) - return &postResult, nil -} - -func (p *extensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]any, hookID string) (*PostProcessResult, error) { - if !p.extension.Manifest.HasPostProcessing() { - return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID) - } - - if !p.extension.Enabled { - return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) - } - perf := newExtensionCallPerf(p.extension.ID, "postProcessV2") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return &PostProcessResult{Success: false, Error: err.Error()}, nil - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() metadataJSON, _ := json.Marshal(metadata) inputJSON, _ := json.Marshal(input) filePath := input.Path - script := fmt.Sprintf(` - (function() { - if (typeof extension !== 'undefined') { - if (typeof extension.postProcessV2 === 'function') { - return extension.postProcessV2(%s, %s, %q); + perfName := "postProcess" + var script string + if preferV2 { + perfName = "postProcessV2" + script = fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined') { + if (typeof extension.postProcessV2 === 'function') { + return extension.postProcessV2(%s, %s, %q); + } + if (typeof extension.postProcess === 'function') { + return extension.postProcess(%q, %s, %q); + } } - if (typeof extension.postProcess === 'function') { + return null; + })() + `, string(inputJSON), string(metadataJSON), hookID, filePath, string(metadataJSON), hookID) + } else { + script = fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.postProcess === 'function') { return extension.postProcess(%q, %s, %q); } - } - return null; - })() - `, string(inputJSON), string(metadataJSON), hookID, filePath, string(metadataJSON), hookID) + return null; + })() + `, filePath, string(metadataJSON), hookID) + } - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - errMsg := err.Error() - if IsTimeoutError(err) { - errMsg = "postProcess timeout: extension took too long to complete" + result, err := callExtensionScript(p, extCallOpts{ + perfName: perfName, + script: script, + timeout: PostProcessTimeout, + timeoutMessage: "postProcess timeout: extension took too long to complete", + rawError: true, + }, func(perf *extensionCallPerf, value goja.Value) (*PostProcessResult, error) { + if value == nil || goja.IsUndefined(value) || goja.IsNull(value) { + return &PostProcessResult{Success: false, Error: "postProcess returned null"}, nil } - return &PostProcessResult{ - Success: false, - Error: errMsg, - }, nil + parseStartedAt := time.Now() + postResult := parseExtensionPostProcessValue(p.vm, value) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) + return &postResult, nil + }) + if err != nil { + return &PostProcessResult{Success: false, Error: err.Error()}, nil } + return result, nil +} - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return &PostProcessResult{ - Success: false, - Error: "postProcess returned null", - }, nil - } +func (p *extensionProviderWrapper) PostProcess(filePath string, metadata map[string]any, hookID string) (*PostProcessResult, error) { + return p.postProcessCommon(PostProcessInput{Path: filePath}, metadata, hookID, false) +} - parseStartedAt := time.Now() - postResult := parseExtensionPostProcessValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - perf.setItems(1) - return &postResult, nil +func (p *extensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]any, hookID string) (*PostProcessResult, error) { + return p.postProcessCommon(input, metadata, hookID, true) } type ExtLyricsResult struct { @@ -978,35 +854,15 @@ func (p *extensionProviderWrapper) FetchLyrics(trackName, artistName, albumName if !p.extension.Manifest.IsLyricsProvider() { return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID) } - if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - perf := newExtensionCallPerf(p.extension.ID, "fetchLyrics") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() // Use global variables to avoid JS injection issues with special characters in track/artist names const trackVar = "__sf_lyrics_track" const artistVar = "__sf_lyrics_artist" const albumVar = "__sf_lyrics_album" const durationVar = "__sf_lyrics_duration" - global := p.vm.GlobalObject() - _ = global.Set(trackVar, trackName) - _ = global.Set(artistVar, artistName) - _ = global.Set(albumVar, albumName) - _ = global.Set(durationVar, durationSec) - defer func() { - global.Delete(trackVar) - global.Delete(artistVar) - global.Delete(albumVar) - global.Delete(durationVar) - }() const script = ` (function() { @@ -1017,57 +873,64 @@ func (p *extensionProviderWrapper) FetchLyrics(trackName, artistName, albumName })() ` - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if IsTimeoutError(err) { - return nil, fmt.Errorf("fetchLyrics timeout: extension took too long to respond") + return callExtensionScript(p, extCallOpts{ + perfName: "fetchLyrics", + script: script, + timeout: DefaultJSTimeout, + beforeRun: func() func() { + global := p.vm.GlobalObject() + _ = global.Set(trackVar, trackName) + _ = global.Set(artistVar, artistName) + _ = global.Set(albumVar, albumName) + _ = global.Set(durationVar, durationSec) + return func() { + global.Delete(trackVar) + global.Delete(artistVar) + global.Delete(albumVar) + global.Delete(durationVar) + } + }, + }, func(perf *extensionCallPerf, result goja.Value) (*LyricsResponse, error) { + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("fetchLyrics returned null") } - return nil, fmt.Errorf("fetchLyrics failed: %w", err) - } + parseStartedAt := time.Now() + extResult, err := parseExtensionLyricsValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + if err != nil { + return nil, fmt.Errorf("failed to parse lyrics result: %w", err) + } + perf.setItems(len(extResult.Lines)) - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return nil, fmt.Errorf("fetchLyrics returned null") - } + response := &LyricsResponse{ + SyncType: extResult.SyncType, + Instrumental: extResult.Instrumental, + PlainLyrics: extResult.PlainLyrics, + Provider: extResult.Provider, + Source: "Extension: " + p.extension.ID, + } - parseStartedAt := time.Now() - extResult, err := parseExtensionLyricsValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - if err != nil { - return nil, fmt.Errorf("failed to parse lyrics result: %w", err) - } - perf.setItems(len(extResult.Lines)) + if response.Provider == "" { + response.Provider = p.extension.Manifest.DisplayName + } - response := &LyricsResponse{ - SyncType: extResult.SyncType, - Instrumental: extResult.Instrumental, - PlainLyrics: extResult.PlainLyrics, - Provider: extResult.Provider, - Source: "Extension: " + p.extension.ID, - } + for _, line := range extResult.Lines { + response.Lines = append(response.Lines, LyricsLine(line)) + } - if response.Provider == "" { - response.Provider = p.extension.Manifest.DisplayName - } - - for _, line := range extResult.Lines { - response.Lines = append(response.Lines, LyricsLine(line)) - } - - if len(response.Lines) == 0 && response.PlainLyrics != "" && !response.Instrumental { - response.SyncType = "UNSYNCED" - for _, line := range strings.Split(response.PlainLyrics, "\n") { - if strings.TrimSpace(line) != "" { - response.Lines = append(response.Lines, LyricsLine{ - StartTimeMs: 0, - Words: line, - EndTimeMs: 0, - }) + if len(response.Lines) == 0 && response.PlainLyrics != "" && !response.Instrumental { + response.SyncType = "UNSYNCED" + for _, line := range strings.Split(response.PlainLyrics, "\n") { + if strings.TrimSpace(line) != "" { + response.Lines = append(response.Lines, LyricsLine{ + StartTimeMs: 0, + Words: line, + EndTimeMs: 0, + }) + } } } - } - return response, nil + return response, nil + }) } diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index d2d38589..85720f91 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -252,57 +252,22 @@ func (m *extensionManager) GetPostProcessingProviders() []*extensionProviderWrap return providers } -func (m *extensionManager) RunPostProcessing(filePath string, metadata map[string]any) (*PostProcessResult, error) { - providers := m.GetPostProcessingProviders() - if len(providers) == 0 { - return &PostProcessResult{Success: true, NewFilePath: filePath}, nil - } - - currentPath := filePath - for _, provider := range providers { - hooks := provider.extension.Manifest.GetPostProcessingHooks() - for _, hook := range hooks { - if !hook.DefaultEnabled { - continue - } - - ext := strings.ToLower(filepath.Ext(currentPath)) - if len(hook.SupportedFormats) > 0 { - supported := false - for _, format := range hook.SupportedFormats { - if "."+format == ext || format == ext[1:] { - supported = true - break - } - } - if !supported { - continue - } - } - - GoLog("[PostProcess] Running hook %s from %s on %s\n", hook.ID, provider.extension.ID, currentPath) - - result, err := provider.PostProcess(currentPath, metadata, hook.ID) - if err != nil { - GoLog("[PostProcess] Hook %s failed: %v\n", hook.ID, err) - continue - } - - if result.Success && result.NewFilePath != "" { - currentPath = result.NewFilePath - } - } - } - - return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil -} - -func (m *extensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]any) (*PostProcessResult, error) { +// runPostProcessingCommon backs both RunPostProcessing (V1) and +// RunPostProcessingV2. V1 delegates into this shared loop with an equivalent +// PostProcessInput; preferV2 controls whether each hook is invoked via +// provider.PostProcessV2 or provider.PostProcess, so V1 keeps calling +// PostProcess (not PostProcessV2) exactly as it did before. +func (m *extensionManager) runPostProcessingCommon(input PostProcessInput, metadata map[string]any, preferV2 bool) (*PostProcessResult, error) { providers := m.GetPostProcessingProviders() if len(providers) == 0 { return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil } + logTag := "[PostProcess]" + if preferV2 { + logTag = "[PostProcessV2]" + } + currentInput := input for _, provider := range providers { hooks := provider.extension.Manifest.GetPostProcessingHooks() @@ -328,11 +293,17 @@ func (m *extensionManager) RunPostProcessingV2(input PostProcessInput, metadata } } - GoLog("[PostProcessV2] Running hook %s from %s on %s\n", hook.ID, provider.extension.ID, currentInput.Path) + GoLog("%s Running hook %s from %s on %s\n", logTag, hook.ID, provider.extension.ID, currentInput.Path) - result, err := provider.PostProcessV2(currentInput, metadata, hook.ID) + var result *PostProcessResult + var err error + if preferV2 { + result, err = provider.PostProcessV2(currentInput, metadata, hook.ID) + } else { + result, err = provider.PostProcess(currentInput.Path, metadata, hook.ID) + } if err != nil { - GoLog("[PostProcessV2] Hook %s failed: %v\n", hook.ID, err) + GoLog("%s Hook %s failed: %v\n", logTag, hook.ID, err) continue } @@ -351,6 +322,18 @@ func (m *extensionManager) RunPostProcessingV2(input PostProcessInput, metadata return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil } +func (m *extensionManager) RunPostProcessing(filePath string, metadata map[string]any) (*PostProcessResult, error) { + result, err := m.runPostProcessingCommon(PostProcessInput{Path: filePath}, metadata, false) + if err != nil { + return result, err + } + return &PostProcessResult{Success: result.Success, NewFilePath: result.NewFilePath}, nil +} + +func (m *extensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]any) (*PostProcessResult, error) { + return m.runPostProcessingCommon(input, metadata, true) +} + func (m *extensionManager) GetLyricsProviders() []*extensionProviderWrapper { m.mu.RLock() defer m.mu.RUnlock() diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index ffaacee0..d95ddcdc 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -50,7 +50,6 @@ final _trimUnderscoresAndSpacesRegex = RegExp(r'^[_ ]+|[_ ]+$'); final _multiWhitespaceRegex = RegExp(r'\s+'); final _multiUnderscoreRegex = RegExp(r'_+'); - /// log10 helper using dart:math's natural log. double _log10(num x) => log(x) / ln10; final _yearRegex = RegExp(r'^(\d{4})'); @@ -169,6 +168,17 @@ class _NativeWorkerRequestContext { }); } +/// Result of [DownloadQueueNotifier._finalizeDecryption]. [failStage] is +/// only meaningful to the inline single-item pipeline, which surfaces a +/// distinct error message per stage; the native-worker pipeline uses one +/// generic message and ignores it. +class _DecryptOutcome { + final String? path; + final String? newFileName; + final String? failStage; + const _DecryptOutcome(this.path, {this.newFileName, this.failStage}); +} + class DownloadQueueNotifier extends Notifier<DownloadQueueState> { Timer? _progressTimer; Timer? _progressStreamBootstrapTimer; @@ -3692,6 +3702,54 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { } } + /// Shared "SAF roundtrip" used by every finalize step that needs to + /// transform a SAF file: copies [uri] to a local temp file, lets [op] + /// transform it (returning the local path to publish plus the file name + /// to publish it under, or null to abort), writes that file back into the + /// SAF tree, deletes the original SAF file if its URI changed, and always + /// cleans up the local temp file(s). Returns the new content:// URI, or + /// null if the temp copy, [op], or the SAF write failed. + Future<String?> _replaceSafFileVia({ + required String uri, + required String treeUri, + required String relativeDir, + required Future<(String path, String fileName)?> Function(String tempPath) + op, + }) async { + final tempPath = await _copySafToTemp(uri); + if (tempPath == null) return null; + String? outPath; + try { + final produced = await op(tempPath); + if (produced == null) return null; + outPath = produced.$1; + final fileName = produced.$2; + final dotIndex = fileName.lastIndexOf('.'); + final ext = dotIndex >= 0 ? fileName.substring(dotIndex) : ''; + final newUri = await _writeTempToSaf( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: fileName, + mimeType: _mimeTypeForExt(ext), + srcPath: outPath, + ); + if (newUri == null) return null; + if (newUri != uri) { + await _deleteSafFile(uri); + } + return newUri; + } finally { + try { + await File(tempPath).delete(); + } catch (_) {} + if (outPath != null && outPath != tempPath) { + try { + await File(outPath).delete(); + } catch (_) {} + } + } + } + bool _hasWifiConnection(List<ConnectivityResult> results) { return results.contains(ConnectivityResult.wifi); } @@ -4054,9 +4112,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { settings, ); } - _requeueInFlightNativeWorkerItems( - {...contexts.keys, ...pendingContextIds}, - ); + _requeueInFlightNativeWorkerItems({ + ...contexts.keys, + ...pendingContextIds, + }); await _clearNativeWorkerRunId(runId); if (state.items.any( (item) => item.status == DownloadStatus.queued, @@ -4089,9 +4148,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { // Items may have been requeued during reconciliation (e.g. batch // mates of a verification challenge); hand them to the queue. if (!state.isPaused && - state.items.any( - (item) => item.status == DownloadStatus.queued, - )) { + state.items.any((item) => item.status == DownloadStatus.queued)) { Future.microtask(() => _processQueue()); } break; @@ -4621,11 +4678,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { } for (final pendingId in pendingBatchIds) { reconciledIds.add(pendingId); - updateItemStatus( - pendingId, - DownloadStatus.queued, - progress: 0.0, - ); + updateItemStatus(pendingId, DownloadStatus.queued, progress: 0.0); } await _handleVerificationRequiredDownload( current, @@ -4704,12 +4757,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { return; } - final finalizedPath = await _finalizeNativeWorkerDecryption( - context: context, + final rawDecryptFileName = + (result['file_name'] as String?) ?? context.safFileName ?? 'track'; + final decryptOutcome = await _finalizeDecryption( result: result, filePath: filePath, + storageMode: context.storageMode, + downloadTreeUri: context.downloadTreeUri, + safRelativeDir: context.safRelativeDir ?? '', + baseName: rawDecryptFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''), + extFallback: context.outputExt, + repairAc4: false, + onStart: (strategy) => _log.i( + 'Native-worker encrypted stream detected, decrypting via $strategy...', + ), ); - if (finalizedPath == null) { + if (decryptOutcome.path == null) { updateItemStatus( item.id, DownloadStatus.failed, @@ -4719,7 +4782,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { _failedInSession++; return; } - filePath = finalizedPath; + filePath = decryptOutcome.path!; + if (decryptOutcome.newFileName != null) { + result['file_name'] = decryptOutcome.newFileName; + } var actualQuality = context.quality; final actualBitDepth = result['actual_bit_depth'] as int?; @@ -4730,9 +4796,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { ) ?? normalizeAudioFormatValue(audioFormatForPath(filePath)); final actualBitrate = isLossyAudioFormat(actualFormat) - ? readPositiveBitrateKbps( - result['bitrate'] ?? result['actual_bitrate'], - ) + ? readPositiveBitrateKbps(result['bitrate'] ?? result['actual_bitrate']) : null; final resolvedQuality = resolveDisplayQuality( filePath: filePath, @@ -4803,12 +4867,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { progress: 1.0, filePath: filePath, ); - await _saveNativeWorkerExternalLrc( - context: context, + await _saveExternalLrc( result: result, settings: settings, + extensionState: ref.read(extensionProvider), track: trackToDownload, + service: context.item.service, filePath: filePath, + storageMode: context.storageMode, + downloadTreeUri: context.downloadTreeUri, + safRelativeDir: context.safRelativeDir ?? '', + resolveBaseName: () async { + final resultFileName = result['file_name'] as String?; + final fileName = (resultFileName != null && resultFileName.isNotEmpty) + ? resultFileName + : context.safFileName; + return fileName != null && fileName.isNotEmpty + ? fileName.replaceFirst(RegExp(r'\.[^.]+$'), '') + : await PlatformBridge.sanitizeFilename( + '${trackToDownload.artistName} - ${trackToDownload.name}', + ); + }, + onFetchError: (e) => + _log.w('Failed to fetch native-worker external LRC: $e'), ); final postProcessedPath = await _runPostProcessingHooks( filePath, @@ -4946,82 +5027,110 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { removeItem(item.id); } - Future<String?> _finalizeNativeWorkerDecryption({ - required _NativeWorkerRequestContext context, + static const _decryptStageSafAccess = 'safAccess'; + static const _decryptStageDecrypt = 'decrypt'; + static const _decryptStageSafWrite = 'safWrite'; + + /// Shared decrypt finalize used by both the inline single-item pipeline + /// and the native-worker pipeline. Divergences captured as parameters: + /// [repairAc4] (inline repairs AC-4 containers using the still-encrypted + /// source; native-worker does not) and [onStart] (inline logs its own + /// "detected" message; native-worker logs a differently worded one). + Future<_DecryptOutcome> _finalizeDecryption({ required Map<String, dynamic> result, required String filePath, + required String storageMode, + String? downloadTreeUri, + required String safRelativeDir, + required String baseName, + required String extFallback, + required bool repairAc4, + void Function(String strategy)? onStart, }) async { if (result['already_exists'] == true) { - return filePath; + return _DecryptOutcome(filePath); } final descriptor = DownloadDecryptionDescriptor.fromDownloadResult(result); if (descriptor == null) { - return filePath; + return _DecryptOutcome(filePath); + } + onStart?.call(descriptor.normalizedStrategy); + + if (storageMode == 'saf' && isContentUri(filePath)) { + if (downloadTreeUri == null || downloadTreeUri.isEmpty) { + return const _DecryptOutcome(null, failStage: _decryptStageSafAccess); + } + String? failStage; + var opStarted = false; + String? producedFileName; + final newUri = await _replaceSafFileVia( + uri: filePath, + treeUri: downloadTreeUri, + relativeDir: safRelativeDir, + op: (tempPath) async { + opStarted = true; + final decryptedTempPath = await FFmpegService.decryptWithDescriptor( + inputPath: tempPath, + descriptor: descriptor, + deleteOriginal: false, + ); + if (decryptedTempPath == null) { + failStage = _decryptStageDecrypt; + return null; + } + if (repairAc4) { + try { + await PlatformBridge.ensureAC4Config(decryptedTempPath, tempPath); + } catch (e) { + _log.w('AC-4 container repair skipped: $e'); + } + } + final dotIndex = decryptedTempPath.lastIndexOf('.'); + final decryptedExt = dotIndex >= 0 + ? decryptedTempPath.substring(dotIndex).toLowerCase() + : extFallback; + const allowedExt = <String>{'.flac', '.m4a', '.mp4', '.mp3', '.opus'}; + final finalExt = allowedExt.contains(decryptedExt) + ? decryptedExt + : extFallback; + final newFileName = '$baseName$finalExt'; + producedFileName = newFileName; + return (decryptedTempPath, newFileName); + }, + ); + if (newUri == null) { + return _DecryptOutcome( + null, + failStage: + failStage ?? + (opStarted ? _decryptStageSafWrite : _decryptStageSafAccess), + ); + } + return _DecryptOutcome(newUri, newFileName: producedFileName); } - _log.i( - 'Native-worker encrypted stream detected, decrypting via ${descriptor.normalizedStrategy}...', - ); - - if (context.storageMode == 'saf' && isContentUri(filePath)) { - final treeUri = context.downloadTreeUri; - if (treeUri == null || treeUri.isEmpty) { - return null; - } - final tempPath = await _copySafToTemp(filePath); - if (tempPath == null) { - return null; - } - - String? decryptedTempPath; - try { - decryptedTempPath = await FFmpegService.decryptWithDescriptor( - inputPath: tempPath, - descriptor: descriptor, - deleteOriginal: false, - ); - if (decryptedTempPath == null) { - return null; - } - - final dotIndex = decryptedTempPath.lastIndexOf('.'); - final decryptedExt = dotIndex >= 0 - ? decryptedTempPath.substring(dotIndex).toLowerCase() - : context.outputExt; - const allowedExt = <String>{'.flac', '.m4a', '.mp4', '.mp3', '.opus'}; - final finalExt = allowedExt.contains(decryptedExt) - ? decryptedExt - : context.outputExt; - final rawFileName = - (result['file_name'] as String?) ?? context.safFileName ?? 'track'; - final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); - final newFileName = '$baseName$finalExt'; - final newUri = await _writeTempToSaf( - treeUri: treeUri, - relativeDir: context.safRelativeDir ?? '', - fileName: newFileName, - mimeType: _mimeTypeForExt(finalExt), - srcPath: decryptedTempPath, - ); - if (newUri == null) { - return null; - } - if (newUri != filePath) { - await _deleteSafFile(filePath); - } - result['file_name'] = newFileName; - return newUri; - } finally { + if (repairAc4) { + final decryptedPath = await FFmpegService.decryptWithDescriptor( + inputPath: filePath, + descriptor: descriptor, + deleteOriginal: false, + ); + if (decryptedPath == null) { try { - await File(tempPath).delete(); + await deleteFile(filePath); } catch (_) {} - if (decryptedTempPath != null && decryptedTempPath != tempPath) { - try { - await File(decryptedTempPath).delete(); - } catch (_) {} - } + return const _DecryptOutcome(null, failStage: _decryptStageDecrypt); } + try { + await PlatformBridge.ensureAC4Config(decryptedPath, filePath); + } catch (e) { + _log.w('AC-4 container repair skipped: $e'); + } + try { + await deleteFile(filePath); + } catch (_) {} + return _DecryptOutcome(decryptedPath); } final decryptedPath = await FFmpegService.decryptWithDescriptor( @@ -5029,7 +5138,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { descriptor: descriptor, deleteOriginal: true, ); - return decryptedPath; + return _DecryptOutcome( + decryptedPath, + failStage: decryptedPath == null ? _decryptStageDecrypt : null, + ); } Future<String?> _finalizeNativeWorkerHighConversion({ @@ -5081,53 +5193,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { if (treeUri == null || treeUri.isEmpty) { return null; } - final tempPath = await _copySafToTemp(filePath); - if (tempPath == null) { + final rawFileName = + (result['file_name'] as String?) ?? context.safFileName ?? 'track'; + final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); + final newFileName = '$baseName$newExt'; + final newUri = await _replaceSafFileVia( + uri: filePath, + treeUri: treeUri, + relativeDir: context.safRelativeDir ?? '', + op: (tempPath) async { + final convertedPath = await FFmpegService.convertM4aToLossy( + tempPath, + format: format, + bitrate: tidalHighFormat, + deleteOriginal: false, + ); + if (convertedPath == null) return null; + await embedConvertedMetadata(convertedPath); + return (convertedPath, newFileName); + }, + ); + if (newUri == null) { return null; } - - String? convertedPath; - try { - convertedPath = await FFmpegService.convertM4aToLossy( - tempPath, - format: format, - bitrate: tidalHighFormat, - deleteOriginal: false, - ); - if (convertedPath == null) { - return null; - } - await embedConvertedMetadata(convertedPath); - final rawFileName = - (result['file_name'] as String?) ?? context.safFileName ?? 'track'; - final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); - final newFileName = '$baseName$newExt'; - final newUri = await _writeTempToSaf( - treeUri: treeUri, - relativeDir: context.safRelativeDir ?? '', - fileName: newFileName, - mimeType: _mimeTypeForExt(newExt), - srcPath: convertedPath, - ); - if (newUri == null) { - return null; - } - if (newUri != filePath) { - await _deleteSafFile(filePath); - } - result['file_name'] = newFileName; - result['_native_actual_quality'] = '$displayFormat $bitrateDisplay'; - return newUri; - } finally { - try { - await File(tempPath).delete(); - } catch (_) {} - if (convertedPath != null) { - try { - await File(convertedPath).delete(); - } catch (_) {} - } - } + result['file_name'] = newFileName; + result['_native_actual_quality'] = '$displayFormat $bitrateDisplay'; + return newUri; } final convertedPath = await FFmpegService.convertM4aToLossy( @@ -5228,85 +5319,62 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { if (treeUri == null || treeUri.isEmpty) { return null; } - final tempPath = await _copySafToTemp(filePath); - if (tempPath == null) { - return null; - } - - String? flacPath; - try { - final codec = await FFmpegService.probePrimaryAudioCodec(tempPath); - final isAlreadyNativeFlac = - codec == 'flac' && await FFmpegService.isNativeFlacFile(tempPath); - if (!FFmpegService.isLosslessAudioCodec(codec)) { - _log.d( - 'Preserving native container; audio codec is ${codec ?? 'unknown'}, ' - 'no FLAC container conversion needed.', - ); - return filePath; - } - if (isAlreadyNativeFlac) { - _log.d( - 'Native FLAC payload detected in temporary container; publishing ' - 'as FLAC and embedding metadata.', - ); - await embedFlacMetadata(tempPath); + var preserve = false; + String? producedFileName; + final newUri = await _replaceSafFileVia( + uri: filePath, + treeUri: treeUri, + relativeDir: context.safRelativeDir ?? '', + op: (tempPath) async { + final codec = await FFmpegService.probePrimaryAudioCodec(tempPath); + final isAlreadyNativeFlac = + codec == 'flac' && await FFmpegService.isNativeFlacFile(tempPath); + if (!FFmpegService.isLosslessAudioCodec(codec)) { + _log.d( + 'Preserving native container; audio codec is ${codec ?? 'unknown'}, ' + 'no FLAC container conversion needed.', + ); + preserve = true; + return null; + } + if (isAlreadyNativeFlac) { + _log.d( + 'Native FLAC payload detected in temporary container; publishing ' + 'as FLAC and embedding metadata.', + ); + await embedFlacMetadata(tempPath); + final rawFileName = + (result['file_name'] as String?) ?? + context.safFileName ?? + 'track'; + final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); + final newFileName = '$baseName.flac'; + producedFileName = newFileName; + return (tempPath, newFileName); + } + final flacPath = await FFmpegService.convertM4aToFlac(tempPath); + if (flacPath == null) { + return null; + } + await embedFlacMetadata(flacPath); final rawFileName = (result['file_name'] as String?) ?? context.safFileName ?? 'track'; final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); final newFileName = '$baseName.flac'; - final newUri = await _writeTempToSaf( - treeUri: treeUri, - relativeDir: context.safRelativeDir ?? '', - fileName: newFileName, - mimeType: _mimeTypeForExt('.flac'), - srcPath: tempPath, - ); - if (newUri == null) { - return null; - } - if (newUri != filePath) { - await _deleteSafFile(filePath); - } - result['file_name'] = newFileName; - return newUri; - } - flacPath = await FFmpegService.convertM4aToFlac(tempPath); - if (flacPath == null) { - return null; - } - await embedFlacMetadata(flacPath); - final rawFileName = - (result['file_name'] as String?) ?? context.safFileName ?? 'track'; - final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); - final newFileName = '$baseName.flac'; - final newUri = await _writeTempToSaf( - treeUri: treeUri, - relativeDir: context.safRelativeDir ?? '', - fileName: newFileName, - mimeType: _mimeTypeForExt('.flac'), - srcPath: flacPath, - ); - if (newUri == null) { - return null; - } - if (newUri != filePath) { - await _deleteSafFile(filePath); - } - result['file_name'] = newFileName; - return newUri; - } finally { - try { - await File(tempPath).delete(); - } catch (_) {} - if (flacPath != null) { - try { - await File(flacPath).delete(); - } catch (_) {} - } + producedFileName = newFileName; + return (flacPath, newFileName); + }, + ); + if (preserve) { + return filePath; } + if (newUri == null) { + return null; + } + result['file_name'] = producedFileName; + return newUri; } final codec = await FFmpegService.probePrimaryAudioCodec(filePath); @@ -5373,22 +5441,30 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { } } - Future<void> _saveNativeWorkerExternalLrc({ - required _NativeWorkerRequestContext context, + /// Shared external-LRC finalize used by both the inline single-item + /// pipeline (SAF only; the local-file case is already handled during + /// metadata embedding) and the native-worker pipeline (both storage + /// modes). [resolveBaseName] and [onFetchError] are each caller's own + /// base-name fallback chain and fetch-failure log line, evaluated lazily + /// to match the original call sites exactly. + Future<void> _saveExternalLrc({ required Map<String, dynamic> result, required AppSettings settings, + required ExtensionState extensionState, required Track track, + required String service, required String filePath, + required String storageMode, + String? downloadTreeUri, + required String safRelativeDir, + required Future<String> Function() resolveBaseName, + required void Function(Object e) onFetchError, }) async { final lyricsMode = settings.lyricsMode; final shouldSaveExternalLrc = settings.embedMetadata && settings.embedLyrics && - !_shouldSkipLyrics( - ref.read(extensionProvider), - track.source, - context.item.service, - ) && + !_shouldSkipLyrics(extensionState, track.source, service) && (lyricsMode == 'external' || lyricsMode == 'both'); if (!shouldSaveExternalLrc) { return; @@ -5404,30 +5480,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { durationMs: track.duration * 1000, ); } catch (e) { - _log.w('Failed to fetch native-worker external LRC: $e'); + onFetchError(e); } } if (lrcContent == null || lrcContent.isEmpty) { return; } - if (context.storageMode == 'saf' && isContentUri(filePath)) { - final treeUri = context.downloadTreeUri; - if (treeUri == null || treeUri.isEmpty) { + if (storageMode == 'saf' && isContentUri(filePath)) { + if (downloadTreeUri == null || downloadTreeUri.isEmpty) { return; } - final resultFileName = result['file_name'] as String?; - final fileName = (resultFileName != null && resultFileName.isNotEmpty) - ? resultFileName - : context.safFileName; - final baseName = fileName != null && fileName.isNotEmpty - ? fileName.replaceFirst(RegExp(r'\.[^.]+$'), '') - : await PlatformBridge.sanitizeFilename( - '${track.artistName} - ${track.name}', - ); + final baseName = await resolveBaseName(); await _writeLrcToSaf( - treeUri: treeUri, - relativeDir: context.safRelativeDir ?? '', + treeUri: downloadTreeUri, + relativeDir: safRelativeDir, baseName: baseName, lrcContent: lrcContent, ); @@ -6432,136 +6499,54 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { ); updateItemStatus(item.id, DownloadStatus.finalizing, progress: 0.9); - if (effectiveSafMode && isContentUri(filePath)) { - final currentFilePath = filePath; - final tempPath = await _copySafToTemp(currentFilePath); - if (tempPath == null) { - _log.e('Failed to copy encrypted SAF file to temp for decrypt'); - updateItemStatus( - item.id, - DownloadStatus.failed, - error: 'Failed to access encrypted SAF file', - errorType: DownloadErrorType.unknown, - ); - return; - } - - String? decryptedTempPath; - try { - decryptedTempPath = await FFmpegService.decryptWithDescriptor( - inputPath: tempPath, - descriptor: decryptionDescriptor, - deleteOriginal: false, - ); - if (decryptedTempPath == null) { - _log.e('FFmpeg decrypt failed for SAF file'); - updateItemStatus( - item.id, - DownloadStatus.failed, - error: 'Failed to decrypt encrypted stream', - errorType: DownloadErrorType.unknown, - ); - return; - } - - // Repair AC-4 (dac4 + ISO MP4) using the still-present encrypted - // source. No-op for other codecs. - try { - await PlatformBridge.ensureAC4Config( - decryptedTempPath, - tempPath, - ); - } catch (e) { - _log.w('AC-4 container repair skipped: $e'); - } - - final dotIndex = decryptedTempPath.lastIndexOf('.'); - final decryptedExt = dotIndex >= 0 - ? decryptedTempPath.substring(dotIndex).toLowerCase() - : '.flac'; - final allowedExt = <String>{ - '.flac', - '.m4a', - '.mp4', - '.mp3', - '.opus', - }; - final finalExt = allowedExt.contains(decryptedExt) - ? decryptedExt - : '.flac'; - - final newFileName = '${safBaseName ?? 'track'}$finalExt'; - final newUri = await _writeTempToSaf( - treeUri: settings.downloadTreeUri, - relativeDir: effectiveOutputDir, - fileName: newFileName, - mimeType: _mimeTypeForExt(finalExt), - srcPath: decryptedTempPath, - ); - - if (newUri == null) { + final isSafSource = effectiveSafMode && isContentUri(filePath); + final decryptOutcome = await _finalizeDecryption( + result: result, + filePath: filePath, + storageMode: effectiveSafMode ? 'saf' : 'app', + downloadTreeUri: settings.downloadTreeUri, + safRelativeDir: effectiveOutputDir, + baseName: safBaseName ?? 'track', + extFallback: '.flac', + repairAc4: true, + ); + if (decryptOutcome.path == null) { + final String errorMsg; + switch (decryptOutcome.failStage) { + case _decryptStageSafAccess: + _log.e('Failed to copy encrypted SAF file to temp for decrypt'); + errorMsg = 'Failed to access encrypted SAF file'; + break; + case _decryptStageSafWrite: _log.e('Failed to write decrypted stream back to SAF'); - updateItemStatus( - item.id, - DownloadStatus.failed, - error: 'Failed to write decrypted file to storage', - errorType: DownloadErrorType.unknown, + errorMsg = 'Failed to write decrypted file to storage'; + break; + default: + _log.e( + isSafSource + ? 'FFmpeg decrypt failed for SAF file' + : 'FFmpeg decrypt failed for local file', ); - return; - } - - if (newUri != currentFilePath) { - await _deleteSafFile(currentFilePath); - } - filePath = newUri; - finalSafFileName = newFileName; - _log.i('SAF decryption completed'); - } finally { - try { - await File(tempPath).delete(); - } catch (_) {} - if (decryptedTempPath != null && decryptedTempPath != tempPath) { - try { - await File(decryptedTempPath).delete(); - } catch (_) {} - } + errorMsg = 'Failed to decrypt encrypted stream'; + break; } - } else { - final encryptedSource = filePath; - final decryptedPath = await FFmpegService.decryptWithDescriptor( - inputPath: encryptedSource, - descriptor: decryptionDescriptor, - deleteOriginal: false, + updateItemStatus( + item.id, + DownloadStatus.failed, + error: errorMsg, + errorType: DownloadErrorType.unknown, ); - if (decryptedPath == null) { - _log.e('FFmpeg decrypt failed for local file'); - updateItemStatus( - item.id, - DownloadStatus.failed, - error: 'Failed to decrypt encrypted stream', - errorType: DownloadErrorType.unknown, - ); - try { - await deleteFile(encryptedSource); - } catch (_) {} - return; - } - // Repair AC-4 (dac4 + ISO MP4) using the still-present encrypted - // source before discarding it. No-op for other codecs. - try { - await PlatformBridge.ensureAC4Config( - decryptedPath, - encryptedSource, - ); - } catch (e) { - _log.w('AC-4 container repair skipped: $e'); - } - try { - await deleteFile(encryptedSource); - } catch (_) {} - filePath = decryptedPath; - _log.i('Local decryption completed'); + return; } + filePath = decryptOutcome.path; + if (decryptOutcome.newFileName != null) { + finalSafFileName = decryptOutcome.newFileName; + } + _log.i( + isSafSource + ? 'SAF decryption completed' + : 'Local decryption completed', + ); } final isContentUriPath = filePath != null && isContentUri(filePath); @@ -6604,26 +6589,34 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { 'Lossy 320kbps quality (SAF), converting M4A to $tidalHighFormat...', ); - final tempPath = await _copySafToTemp(currentFilePath); - if (tempPath != null) { - String? convertedPath; - try { - updateItemStatus( - item.id, - DownloadStatus.finalizing, - progress: 0.95, - ); - - final format = lossyFormatForSetting(tidalHighFormat); - final displayFormat = displayFormatForLossyFormat(format); - convertedPath = await FFmpegService.convertM4aToLossy( - tempPath, - format: format, - bitrate: tidalHighFormat, - deleteOriginal: false, - ); - - if (convertedPath != null) { + final format = lossyFormatForSetting(tidalHighFormat); + final displayFormat = displayFormatForLossyFormat(format); + final newExt = lossyExtensionForFormat(format); + final newFileName = '${safBaseName ?? 'track'}$newExt'; + var opStarted = false; + var convertFailed = false; + try { + final newUri = await _replaceSafFileVia( + uri: currentFilePath, + treeUri: settings.downloadTreeUri, + relativeDir: effectiveOutputDir, + op: (tempPath) async { + opStarted = true; + updateItemStatus( + item.id, + DownloadStatus.finalizing, + progress: 0.95, + ); + final convertedPath = await FFmpegService.convertM4aToLossy( + tempPath, + format: format, + bitrate: tidalHighFormat, + deleteOriginal: false, + ); + if (convertedPath == null) { + convertFailed = true; + return null; + } _log.i( 'Successfully converted M4A to $format (temp): $convertedPath', ); @@ -6648,51 +6641,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { downloadService: item.service, ); - final newExt = lossyExtensionForFormat(format); - final newFileName = '${safBaseName ?? 'track'}$newExt'; - final newUri = await _writeTempToSaf( - treeUri: settings.downloadTreeUri, - relativeDir: effectiveOutputDir, - fileName: newFileName, - mimeType: _mimeTypeForExt(newExt), - srcPath: convertedPath, - ); + return (convertedPath, newFileName); + }, + ); - if (newUri != null) { - if (newUri != currentFilePath) { - await _deleteSafFile(currentFilePath); - } - filePath = newUri; - finalSafFileName = newFileName; - final bitrateDisplay = tidalHighFormat.contains('_') - ? '${tidalHighFormat.split('_').last}kbps' - : '320kbps'; - actualQuality = '$displayFormat $bitrateDisplay'; - } else { - _log.w( - 'Failed to write converted $format to SAF, keeping M4A', - ); - actualQuality = 'AAC 320kbps'; - } - } else { - _log.w( - 'M4A to $format conversion failed, keeping M4A file', - ); - actualQuality = 'AAC 320kbps'; - } - } catch (e) { - _log.w('SAF M4A conversion failed: $e'); + if (newUri != null) { + filePath = newUri; + finalSafFileName = newFileName; + final bitrateDisplay = tidalHighFormat.contains('_') + ? '${tidalHighFormat.split('_').last}kbps' + : '320kbps'; + actualQuality = '$displayFormat $bitrateDisplay'; + } else if (convertFailed) { + _log.w('M4A to $format conversion failed, keeping M4A file'); + actualQuality = 'AAC 320kbps'; + } else if (opStarted) { + _log.w( + 'Failed to write converted $format to SAF, keeping M4A', + ); actualQuality = 'AAC 320kbps'; - } finally { - try { - await File(tempPath).delete(); - } catch (_) {} - if (convertedPath != null) { - try { - await File(convertedPath).delete(); - } catch (_) {} - } } + } catch (e) { + _log.w('SAF M4A conversion failed: $e'); + actualQuality = 'AAC 320kbps'; } } else if (shouldPreserveNativeM4a) { // Decrypted streams are already in their final format. @@ -6700,76 +6671,75 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { _log.d( 'M4A/MP4 file detected (SAF), preserving native container...', ); - final tempPath = await _copySafToTemp(currentFilePath); - if (tempPath != null) { - try { - if (metadataEmbeddingEnabled) { - updateItemStatus( - item.id, - DownloadStatus.finalizing, - progress: 0.99, - ); - final finalTrack = _buildTrackForMetadataEmbedding( - trackToDownload, - result, - resolvedAlbumArtist, - ); - final backendGenre = result['genre'] as String?; - final backendLabel = result['label'] as String?; - final backendCopyright = result['copyright'] as String?; + final preserveExt = currentFilePath.toLowerCase().endsWith('.mp4') + ? '.mp4' + : '.m4a'; + final newFileName = '${safBaseName ?? 'track'}$preserveExt'; + var opStarted = false; + try { + final newUri = await _replaceSafFileVia( + uri: currentFilePath, + treeUri: settings.downloadTreeUri, + relativeDir: effectiveOutputDir, + op: (tempPath) async { + opStarted = true; + if (metadataEmbeddingEnabled) { + updateItemStatus( + item.id, + DownloadStatus.finalizing, + progress: 0.99, + ); + final finalTrack = _buildTrackForMetadataEmbedding( + trackToDownload, + result, + resolvedAlbumArtist, + ); + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; - await _embedMetadataToFile( - tempPath, - finalTrack, - format: 'm4a', - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - downloadService: item.service, - writeExternalLrc: false, - ); - } - - final preserveExt = - currentFilePath.toLowerCase().endsWith('.mp4') - ? '.mp4' - : '.m4a'; - final newFileName = '${safBaseName ?? 'track'}$preserveExt'; - final newUri = await _writeTempToSaf( - treeUri: settings.downloadTreeUri, - relativeDir: effectiveOutputDir, - fileName: newFileName, - mimeType: _mimeTypeForExt(preserveExt), - srcPath: tempPath, - ); - - if (newUri != null) { - if (newUri != currentFilePath) { - await _deleteSafFile(currentFilePath); + await _embedMetadataToFile( + tempPath, + finalTrack, + format: 'm4a', + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + downloadService: item.service, + writeExternalLrc: false, + ); } - filePath = newUri; - finalSafFileName = newFileName; - } else { - _log.w('Failed to write M4A to SAF, keeping original'); - } - } catch (e) { - _log.w('SAF native M4A handling failed: $e'); - } finally { - try { - await File(tempPath).delete(); - } catch (_) {} + return (tempPath, newFileName); + }, + ); + + if (newUri != null) { + filePath = newUri; + finalSafFileName = newFileName; + } else if (opStarted) { + _log.w('Failed to write M4A to SAF, keeping original'); } + } catch (e) { + _log.w('SAF native M4A handling failed: $e'); } } else { _log.d('M4A file detected (SAF), converting to FLAC...'); - final tempPath = await _copySafToTemp(currentFilePath); - if (tempPath != null) { - String? flacPath; - try { - final length = await File(tempPath).length(); - if (length < 1024) { - _log.w('Temp M4A is too small (<1KB), skipping conversion'); - } else { + String? branch; + String? producedFileName; + try { + final newUri = await _replaceSafFileVia( + uri: currentFilePath, + treeUri: settings.downloadTreeUri, + relativeDir: effectiveOutputDir, + op: (tempPath) async { + final length = await File(tempPath).length(); + if (length < 1024) { + _log.w( + 'Temp M4A is too small (<1KB), skipping conversion', + ); + branch = 'skip'; + return null; + } final codec = await FFmpegService.probePrimaryAudioCodec( tempPath, ); @@ -6781,30 +6751,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { 'Preserving native container; audio codec is ${codec ?? 'unknown'}, ' 'no FLAC container conversion needed.', ); + branch = 'preserve'; final preserveExt = resultOutputExt == '.mp4' ? '.mp4' : '.m4a'; final newFileName = '${safBaseName ?? 'track'}$preserveExt'; - final newUri = await _writeTempToSaf( - treeUri: settings.downloadTreeUri, - relativeDir: effectiveOutputDir, - fileName: newFileName, - mimeType: _mimeTypeForExt(preserveExt), - srcPath: tempPath, - ); - if (newUri != null) { - if (newUri != currentFilePath) { - await _deleteSafFile(currentFilePath); - } - filePath = newUri; - finalSafFileName = newFileName; - } + producedFileName = newFileName; + return (tempPath, newFileName); } else if (isAlreadyNativeFlac) { _log.d( 'Native FLAC payload detected in SAF temp file; ' 'publishing as FLAC and embedding metadata.', ); + branch = 'nativeFlac'; final finalTrack = _buildTrackForMetadataEmbedding( trackToDownload, result, @@ -6827,92 +6787,67 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { ); final newFileName = '${safBaseName ?? 'track'}.flac'; - final newUri = await _writeTempToSaf( - treeUri: settings.downloadTreeUri, - relativeDir: effectiveOutputDir, - fileName: newFileName, - mimeType: _mimeTypeForExt('.flac'), - srcPath: tempPath, - ); - if (newUri != null) { - if (newUri != currentFilePath) { - await _deleteSafFile(currentFilePath); - } - filePath = newUri; - finalSafFileName = newFileName; - } else { - _log.w('Failed to write native FLAC to SAF'); - } + producedFileName = newFileName; + return (tempPath, newFileName); } else { updateItemStatus( item.id, DownloadStatus.finalizing, progress: 0.95, ); - flacPath = await FFmpegService.convertM4aToFlac(tempPath); - if (flacPath != null) { - _log.d('Converted to FLAC (temp): $flacPath'); - _log.d( - 'Embedding metadata and cover to converted FLAC...', - ); - final finalTrack = _buildTrackForMetadataEmbedding( - trackToDownload, - result, - resolvedAlbumArtist, - ); - - final backendGenre = result['genre'] as String?; - final backendLabel = result['label'] as String?; - final backendCopyright = result['copyright'] as String?; - - await _embedMetadataToFile( - flacPath, - finalTrack, - format: 'flac', - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - downloadService: item.service, - writeExternalLrc: false, - ); - - final newFileName = '${safBaseName ?? 'track'}.flac'; - final newUri = await _writeTempToSaf( - treeUri: settings.downloadTreeUri, - relativeDir: effectiveOutputDir, - fileName: newFileName, - mimeType: _mimeTypeForExt('.flac'), - srcPath: flacPath, - ); - - if (newUri != null) { - if (newUri != currentFilePath) { - await _deleteSafFile(currentFilePath); - } - filePath = newUri; - finalSafFileName = newFileName; - } else { - _log.w('Failed to write FLAC to SAF, keeping M4A'); - } - } else { + final flacPath = await FFmpegService.convertM4aToFlac( + tempPath, + ); + if (flacPath == null) { _log.w( 'FFmpeg conversion returned null, keeping M4A file', ); + branch = 'convertFailed'; + return null; } + _log.d('Converted to FLAC (temp): $flacPath'); + _log.d( + 'Embedding metadata and cover to converted FLAC...', + ); + final finalTrack = _buildTrackForMetadataEmbedding( + trackToDownload, + result, + resolvedAlbumArtist, + ); + + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; + + await _embedMetadataToFile( + flacPath, + finalTrack, + format: 'flac', + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + downloadService: item.service, + writeExternalLrc: false, + ); + + final newFileName = '${safBaseName ?? 'track'}.flac'; + branch = 'convert'; + producedFileName = newFileName; + return (flacPath, newFileName); } - } - } catch (e) { - _log.w('SAF M4A->FLAC conversion failed: $e'); - } finally { - try { - await File(tempPath).delete(); - } catch (_) {} - if (flacPath != null) { - try { - await File(flacPath).delete(); - } catch (_) {} - } + }, + ); + + if (newUri != null) { + filePath = newUri; + finalSafFileName = producedFileName; + } else if (branch == 'nativeFlac') { + _log.w('Failed to write native FLAC to SAF'); + } else if (branch == 'convert') { + _log.w('Failed to write FLAC to SAF, keeping M4A'); } + } catch (e) { + _log.w('SAF M4A->FLAC conversion failed: $e'); } } } else { @@ -7190,85 +7125,78 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { _log.d( 'SAF $formatName detected, embedding metadata and cover via temp file...', ); - final tempPath = await _copySafToTemp(currentFilePath); - if (tempPath != null) { - try { - updateItemStatus( - item.id, - DownloadStatus.finalizing, - progress: 0.99, - ); - - final finalTrack = _buildTrackForMetadataEmbedding( - trackToDownload, - result, - resolvedAlbumArtist, - ); - final backendGenre = result['genre'] as String?; - final backendLabel = result['label'] as String?; - final backendCopyright = result['copyright'] as String?; - - if (isMp3File) { - await _embedMetadataToFile( - tempPath, - finalTrack, - format: 'mp3', - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - downloadService: item.service, + final newFileName = '${safBaseName ?? 'track'}$ext'; + var opStarted = false; + try { + final newUri = await _replaceSafFileVia( + uri: currentFilePath, + treeUri: settings.downloadTreeUri, + relativeDir: effectiveOutputDir, + op: (tempPath) async { + opStarted = true; + updateItemStatus( + item.id, + DownloadStatus.finalizing, + progress: 0.99, ); - } else if (isOpusFile) { - await _embedMetadataToFile( - tempPath, - finalTrack, - format: 'opus', - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - downloadService: item.service, - ); - } else { - await _embedMetadataToFile( - tempPath, - finalTrack, - format: 'flac', - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - downloadService: item.service, - writeExternalLrc: false, - ); - } - final newFileName = '${safBaseName ?? 'track'}$ext'; - final newUri = await _writeTempToSaf( - treeUri: settings.downloadTreeUri, - relativeDir: effectiveOutputDir, - fileName: newFileName, - mimeType: _mimeTypeForExt(ext), - srcPath: tempPath, - ); + final finalTrack = _buildTrackForMetadataEmbedding( + trackToDownload, + result, + resolvedAlbumArtist, + ); + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; - if (newUri != null) { - if (newUri != currentFilePath) { - await _deleteSafFile(currentFilePath); + if (isMp3File) { + await _embedMetadataToFile( + tempPath, + finalTrack, + format: 'mp3', + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + downloadService: item.service, + ); + } else if (isOpusFile) { + await _embedMetadataToFile( + tempPath, + finalTrack, + format: 'opus', + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + downloadService: item.service, + ); + } else { + await _embedMetadataToFile( + tempPath, + finalTrack, + format: 'flac', + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + downloadService: item.service, + writeExternalLrc: false, + ); } - filePath = newUri; - finalSafFileName = newFileName; - _log.d('SAF $formatName metadata embedding completed'); - } else { - _log.w( - 'Failed to write metadata-updated $formatName back to SAF', - ); - } - } catch (e) { - _log.w('SAF $formatName metadata embedding failed: $e'); - } finally { - try { - await File(tempPath).delete(); - } catch (_) {} + + return (tempPath, newFileName); + }, + ); + + if (newUri != null) { + filePath = newUri; + finalSafFileName = newFileName; + _log.d('SAF $formatName metadata embedding completed'); + } else if (opStarted) { + _log.w( + 'Failed to write metadata-updated $formatName back to SAF', + ); } + } catch (e) { + _log.w('SAF $formatName metadata embedding failed: $e'); } } else if (metadataEmbeddingEnabled && !isContentUriPath && @@ -7379,48 +7307,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> { filePath: filePath, ); - final lyricsMode = settings.lyricsMode; - final shouldSaveExternalLrc = - metadataEmbeddingEnabled && - settings.embedLyrics && - !_shouldSkipLyrics( - extensionState, - trackToDownload.source, - item.service, - ) && - (lyricsMode == 'external' || lyricsMode == 'both'); - if (shouldSaveExternalLrc && - effectiveSafMode && - filePath != null && - isContentUri(filePath)) { - String? lrcContent = result['lyrics_lrc'] as String?; - if (lrcContent == null || lrcContent.isEmpty) { - try { - lrcContent = await PlatformBridge.getLyricsLRC( - trackToDownload.id, - trackToDownload.name, - trackToDownload.artistName, - durationMs: trackToDownload.duration * 1000, - ); - } catch (e) { - _log.w('Failed to fetch lyrics for external LRC: $e'); - } - } - - if (lrcContent != null && lrcContent.isNotEmpty) { - final baseName = finalSafFileName != null - ? finalSafFileName.replaceFirst(RegExp(r'\.[^.]+$'), '') - : safBaseName ?? - await PlatformBridge.sanitizeFilename( - '${trackToDownload.artistName} - ${trackToDownload.name}', - ); - await _writeLrcToSaf( - treeUri: settings.downloadTreeUri, - relativeDir: effectiveOutputDir, - baseName: baseName, - lrcContent: lrcContent, - ); - } + if (effectiveSafMode && filePath != null && isContentUri(filePath)) { + await _saveExternalLrc( + result: result, + settings: settings, + extensionState: extensionState, + track: trackToDownload, + service: item.service, + filePath: filePath, + storageMode: 'saf', + downloadTreeUri: settings.downloadTreeUri, + safRelativeDir: effectiveOutputDir, + resolveBaseName: () async { + final currentFinalName = finalSafFileName; + return currentFinalName != null + ? currentFinalName.replaceFirst(RegExp(r'\.[^.]+$'), '') + : safBaseName ?? + await PlatformBridge.sanitizeFilename( + '${trackToDownload.artistName} - ${trackToDownload.name}', + ); + }, + onFetchError: (e) => + _log.w('Failed to fetch lyrics for external LRC: $e'), + ); } if (filePath != null) {