From 7070649d01597287280554727483229bfea4ecc8 Mon Sep 17 00:00:00 2001 From: pacholoamit Date: Thu, 25 Jun 2026 00:17:48 +0800 Subject: [PATCH 1/3] feat(downloader): add SpotiFLAC as a download source Add a 'spotiflac' download service that fetches lossless FLAC via the SpotiFLAC python module (deezer/tidal/qobuz/amazon, in priority order), mirroring the existing youtube_music subprocess pattern. Tracks are resolved by ISRC with a title/artist search fallback; downloads run synchronously and degrade gracefully so other DOWNLOAD_SERVICES take over when a track can't be sourced. - src/downloader/spotiflac.go: Downloader implementation (no queue monitoring) - src/downloader/spotiflac/spotiflac_dl.py: bundled wrapper around the module - src/config/config.go: SPOTIFLAC_* options + DownloadConfig wiring - src/downloader/downloader.go: register service in factory + needsDownloadDir - Dockerfile: pip install SpotiFLAC + bundle the wrapper script - sample.env, src/web/sample.env, README: document the new source --- Dockerfile | 7 +- README.md | 4 +- sample.env | 22 +++ src/config/config.go | 13 ++ src/downloader/downloader.go | 4 +- src/downloader/spotiflac.go | 186 +++++++++++++++++++++++ src/downloader/spotiflac/spotiflac_dl.py | 112 ++++++++++++++ src/web/sample.env | 22 +++ 8 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 src/downloader/spotiflac.go create mode 100644 src/downloader/spotiflac/spotiflac_dl.py diff --git a/Dockerfile b/Dockerfile index f65e5c07..2b2da16a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,16 +33,17 @@ RUN apk add --no-cache \ shadow \ su-exec -# Install ytmusicapi in the container -RUN pip install --no-cache-dir ytmusicapi +# Install ytmusicapi (youtube fallback search) and SpotiFLAC (FLAC download source) +RUN pip install --no-cache-dir ytmusicapi SpotiFLAC==1.2.0 # Set working directory WORKDIR /opt/explo/ -# Copy entrypoint, binary, python helper +# Copy entrypoint, binary, python helpers COPY ./docker/start.sh /start.sh COPY --from=builder /app/explo . COPY src/downloader/youtube_music/search_ytmusic.py . +COPY src/downloader/spotiflac/spotiflac_dl.py . RUN chmod +x /start.sh ./explo diff --git a/README.md b/README.md index 33092061..5a675458 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Explo uses the [ListenBrainz](https://listenbrainz.org/) recommendation engine t - Apple Music - ListenBrainz - Spotify -- Request tracks from YouTube, Soulseek, or both +- Request tracks from YouTube, Soulseek, or SpotiFLAC (lossless FLAC) - Add metadata to downloaded tracks - Create playlists in your music system - Keep previous playlists for later listening @@ -55,6 +55,8 @@ Explo uses the following 3rd-party libraries: - [ytmusicapi](https://github.com/sigma67/ytmusicapi): Unofficial Youtube Music API +- [SpotiFLAC](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version): Lossless FLAC download module + - [notify](https://github.com/nikoksr/notify): Module for sending notifications to different services - [gocron](https://github.com/go-co-op/gocron): Internal cron scheduling diff --git a/sample.env b/sample.env index 360232ba..c701609d 100644 --- a/sample.env +++ b/sample.env @@ -48,6 +48,7 @@ LIBRARY_NAME= # Keep original file permissions when moving files (set to false on Synology devices) # KEEP_PERMISSIONS=true # Comma-separated list (no spaces) of download services, in priority order (default: youtube) +# Supported: youtube, slskd, spotiflac # DOWNLOAD_SERVICES=youtube # Path templating, Options are Artist, Album, TrackName, TrackNumber, File, Ext (eg. "{{Artist}}/{{Album}}/{{File}}") # PATH_TEMPLATING="" @@ -101,6 +102,27 @@ LIBRARY_NAME= # Comma-separated (without spaces) keywords to avoid, when filtering slskd results (default: live,remix,instrumental,extended,clean,acapella) # FILTER_LIST=live,remix,instrumental,extended,clean,acapella +# === SpotiFLAC Configuration === + +# Downloads lossless FLAC via the SpotiFLAC python module (https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version). +# Requires python3 with the SpotiFLAC module installed (bundled in the docker image) and ffmpeg in $PATH. +# Add 'spotiflac' to DOWNLOAD_SERVICES to enable it. + +# FLAC sources to try, in priority order (default: deezer,tidal,qobuz,amazon) +# SPOTIFLAC_SOURCES=deezer,tidal,qobuz,amazon +# Preferred quality passed to the source (leave empty to use the provider's lossless default) +# SPOTIFLAC_QUALITY=LOSSLESS +# Python interpreter that has the SpotiFLAC module installed (default: python3) +# SPOTIFLAC_PYTHON_PATH=python3 +# Path to the bundled wrapper script (default: spotiflac_dl.py; set to an absolute path for the binary version) +# SPOTIFLAC_SCRIPT_PATH=spotiflac_dl.py +# Max seconds to spend downloading a single track before giving up (default: 180) +# SPOTIFLAC_TIMEOUT=180 +# Filename format used by SpotiFLAC (default: {title} - {artist}) +# SPOTIFLAC_FILENAME_FORMAT={title} - {artist} +# Optional Qobuz auth token passthrough +# SPOTIFLAC_QOBUZ_TOKEN= + # === Metadata / Formatting === # Set to true to merge featured artists into title (recommended), false appends them to artist field (default: true) diff --git a/src/config/config.go b/src/config/config.go index 8eabc58a..7474c72f 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -99,6 +99,7 @@ type DownloadConfig struct { Youtube Youtube YoutubeMusic YoutubeMusic Slskd Slskd + Spotiflac Spotiflac ExcludeLocal bool DownloadLimiter int `env:"DOWNLOAD_LIMITER" env-default:"1"` // rate limit download operations OverwriteMetadata bool `env:"OVERWRITE_METADATA" env-default:"false"` // overwrite metadata when migrating downloads @@ -134,6 +135,18 @@ type YoutubeMusic struct { Filters Filters } +// Spotiflac configures the 'spotiflac' download service. Sources is the list of +// FLAC providers to try in priority order; see sample.env for the full reference. +type Spotiflac struct { + Sources []string `env:"SPOTIFLAC_SOURCES" env-default:"deezer,tidal,qobuz,amazon"` + Quality string `env:"SPOTIFLAC_QUALITY" env-default:"LOSSLESS"` + PythonPath string `env:"SPOTIFLAC_PYTHON_PATH" env-default:"python3"` + ScriptPath string `env:"SPOTIFLAC_SCRIPT_PATH" env-default:"spotiflac_dl.py"` + Timeout int `env:"SPOTIFLAC_TIMEOUT" env-default:"180"` + FilenameFormat string `env:"SPOTIFLAC_FILENAME_FORMAT" env-default:"{title} - {artist}"` + QobuzToken string `env:"SPOTIFLAC_QOBUZ_TOKEN"` +} + type Slskd struct { APIKey string `env:"SLSKD_API_KEY"` URL string `env:"SLSKD_URL"` diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 9451f66f..baea9891 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -44,6 +44,8 @@ func NewDownloader(cfg *cfg.DownloadConfig, httpClient *util.HttpClient, filterL slskdClient := NewSlskd(cfg.Slskd, cfg.DownloadDir) slskdClient.AddHeader() downloader = append(downloader, slskdClient) + case "spotiflac": + downloader = append(downloader, NewSpotiflac(cfg.Spotiflac, cfg.DownloadDir)) default: return nil, fmt.Errorf("downloader '%s' not supported", service) } @@ -120,7 +122,7 @@ func (c *DownloadClient) StartDownload(tracks *[]*models.Track) { } func (c *DownloadClient) needsDownloadDir() bool { for _, svc := range c.Cfg.Services { - if svc == "youtube" || svc == "youtube-music" { + if svc == "youtube" || svc == "youtube-music" || svc == "spotiflac" { return true } } diff --git a/src/downloader/spotiflac.go b/src/downloader/spotiflac.go new file mode 100644 index 00000000..e117f2d0 --- /dev/null +++ b/src/downloader/spotiflac.go @@ -0,0 +1,186 @@ +package downloader + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "os/exec" + "strings" + "time" + + cfg "explo/src/config" + "explo/src/models" +) + +// Spotiflac downloads lossless (FLAC) tracks by shelling out to the bundled +// SpotiFLAC python wrapper (src/downloader/spotiflac/spotiflac_dl.py), mirroring +// how the youtube service uses the ytmusicapi helper. SpotiFLAC resolves a track +// by ISRC (falling back to a title/artist search) and downloads it from one of +// several FLAC sources (deezer, tidal, qobuz, amazon) in priority order. +type Spotiflac struct { + DownloadDir string + Cfg cfg.Spotiflac +} + +func NewSpotiflac(cfg cfg.Spotiflac, downloadDir string) *Spotiflac { + return &Spotiflac{ + DownloadDir: downloadDir, + Cfg: cfg, + } +} + +// spotiflacPayload is the JSON contract passed to the python wrapper. +type spotiflacPayload struct { + ID string `json:"id"` + Title string `json:"title"` + Artists string `json:"artists"` + Album string `json:"album"` + AlbumArtist string `json:"album_artist"` + ISRC string `json:"isrc"` + TrackNumber int `json:"track_number"` + DurationMs int `json:"duration_ms"` + CoverURL string `json:"cover_url"` + OutputDir string `json:"output_dir"` + Sources []string `json:"sources"` + Quality string `json:"quality,omitempty"` + FilenameFormat string `json:"filename_format,omitempty"` + QobuzToken string `json:"qobuz_token,omitempty"` + TimeoutS int `json:"timeout_s,omitempty"` +} + +// spotiflacResult is the JSON the wrapper prints on stdout. +type spotiflacResult struct { + Success bool `json:"success"` + File string `json:"file"` + Path string `json:"path"` + Provider string `json:"provider"` + Format string `json:"format"` + Error string `json:"error"` +} + +func (c *Spotiflac) QueryTrack(track *models.Track) error { + // SpotiFLAC resolves and downloads in a single step (GetTrack); here we only + // ensure the track carries enough to match on. + if firstISRC(track) == "" && (track.CleanTitle == "" || track.Artist == "") { + return fmt.Errorf("[spotiflac] insufficient metadata (need ISRC or title+artist) for '%s - %s'", track.Title, track.Artist) + } + return nil +} + +func (c *Spotiflac) GetTrack(track *models.Track) error { + albumArtist := track.AlbumArtist + if albumArtist == "" { + albumArtist = track.MainArtist + } + + payload := spotiflacPayload{ + ID: track.MusicBrainzTrackID, + Title: track.CleanTitle, + Artists: track.Artist, + Album: track.Album, + AlbumArtist: albumArtist, + ISRC: firstISRC(track), + TrackNumber: track.TrackNumber, + DurationMs: track.Duration, + CoverURL: track.CoverURL, + OutputDir: c.DownloadDir, + Sources: c.Cfg.Sources, + Quality: c.Cfg.Quality, + FilenameFormat: c.Cfg.FilenameFormat, + QobuzToken: c.Cfg.QobuzToken, + TimeoutS: c.Cfg.Timeout, + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal spotiflac payload: %w", err) + } + + ctx := context.Background() + if c.Cfg.Timeout > 0 { + // Give the subprocess some slack over its own per-track timeout so it can + // report a clean failure before the context kills it. + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(c.Cfg.Timeout+30)*time.Second) + defer cancel() + } + + cmd := exec.CommandContext(ctx, c.Cfg.PythonPath, c.Cfg.ScriptPath, string(body)) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + stdout, runErr := cmd.Output() // wrapper exits non-zero on failure but still prints JSON + + result, parseErr := parseSpotiflacResult(stdout) + if parseErr != nil { + detail := lastLine(stderr.String()) + if detail == "" && runErr != nil { + detail = runErr.Error() + } + return fmt.Errorf("[spotiflac] wrapper failed for '%s - %s': %s", track.CleanTitle, track.Artist, detail) + } + + if !result.Success { + return fmt.Errorf("[spotiflac] no download for '%s - %s': %s", track.CleanTitle, track.Artist, result.Error) + } + + track.File = result.File + track.Present = true + slog.Info("download finished", "service", "spotiflac", "track", result.File, "source", result.Provider) + return nil +} + +// Monitor interface — SpotiFLAC downloads synchronously, there is no queue to poll. +func (c *Spotiflac) GetConf() (MonitorConfig, error) { + return MonitorConfig{}, fmt.Errorf("[spotiflac] no monitoring required") +} + +func (c *Spotiflac) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus, error) { + return nil, fmt.Errorf("[spotiflac] no monitoring required") +} + +func (c *Spotiflac) Cleanup(track models.Track, ID string) error { + return nil +} + +func firstISRC(track *models.Track) string { + if len(track.ISRCs) > 0 { + return track.ISRCs[0] + } + return "" +} + +// parseSpotiflacResult reads the last JSON object printed on the wrapper's stdout. +func parseSpotiflacResult(stdout []byte) (*spotiflacResult, error) { + line := lastJSONLine(string(stdout)) + if line == "" { + return nil, fmt.Errorf("no JSON output") + } + var res spotiflacResult + if err := json.Unmarshal([]byte(line), &res); err != nil { + return nil, fmt.Errorf("failed to parse wrapper output: %w", err) + } + return &res, nil +} + +func lastJSONLine(s string) string { + lines := strings.Split(strings.TrimSpace(s), "\n") + for i := len(lines) - 1; i >= 0; i-- { + if l := strings.TrimSpace(lines[i]); strings.HasPrefix(l, "{") { + return l + } + } + return "" +} + +func lastLine(s string) string { + lines := strings.Split(strings.TrimSpace(s), "\n") + for i := len(lines) - 1; i >= 0; i-- { + if l := strings.TrimSpace(lines[i]); l != "" { + return l + } + } + return "" +} diff --git a/src/downloader/spotiflac/spotiflac_dl.py b/src/downloader/spotiflac/spotiflac_dl.py new file mode 100644 index 00000000..9036a342 --- /dev/null +++ b/src/downloader/spotiflac/spotiflac_dl.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Bundled wrapper that downloads a single track via the SpotiFLAC module. + +Explo invokes this as a subprocess (mirroring how youtube_music/search_ytmusic.py +shells out to ytmusicapi). It receives the track metadata as a JSON object on +argv[1] (or stdin), tries each configured source/provider in priority order, and +prints a single JSON result line to stdout: + + {"success": true, "file": "Song.flac", "path": "/abs/Song.flac", + "provider": "deezer", "format": "flac"} + +or, on failure: + + {"success": false, "error": "deezer: ...; tidal: ..."} + +All library/diagnostic output is routed to stderr so stdout carries only the +final JSON line for the Go side to parse. +""" +import sys +import os +import json +import contextlib + + +def _read_payload(): + if len(sys.argv) > 1 and sys.argv[1] not in ("-", ""): + return json.loads(sys.argv[1]) + return json.loads(sys.stdin.read()) + + +def _run(p): + try: + from SpotiFLAC.providers import PROVIDER_REGISTRY + from SpotiFLAC.core.models import TrackMetadata + except Exception as e: # noqa: BLE001 - report import failure to caller as JSON + return {"success": False, "error": f"SpotiFLAC import failed: {e}"} + + meta = TrackMetadata( + id=str(p.get("id") or p.get("isrc") or "explo"), + title=p.get("title", ""), + artists=p.get("artists", ""), + album=p.get("album", ""), + album_artist=p.get("album_artist") or p.get("artists", ""), + isrc=p.get("isrc") or "", + track_number=int(p.get("track_number") or 0), + duration_ms=int(p.get("duration_ms") or 0), + cover_url=p.get("cover_url") or "", + ) + + output_dir = p["output_dir"] + os.makedirs(output_dir, exist_ok=True) + + sources = p.get("sources") or ["deezer", "tidal", "qobuz", "amazon"] + quality = p.get("quality") or "" + filename_format = p.get("filename_format") or "{title} - {artist}" + qobuz_token = p.get("qobuz_token") or "" + timeout_s = int(p.get("timeout_s") or 0) or None + + errors = [] + for name in sources: + cls = PROVIDER_REGISTRY.get(name) + if cls is None: + errors.append(f"{name}: unknown source") + continue + try: + provider = cls(timeout_s=timeout_s) if timeout_s else cls() + except TypeError: + provider = cls() + + kwargs = {"allow_fallback": True, "filename_format": filename_format} + if quality: + kwargs["quality"] = quality + if qobuz_token: + kwargs["qobuz_token"] = qobuz_token + + try: + res = provider.download_track(meta, output_dir, **kwargs) + except Exception as e: # noqa: BLE001 - a failing provider must not abort the chain + errors.append(f"{name}: {e}") + continue + + if getattr(res, "success", False) and getattr(res, "file_path", None): + return { + "success": True, + "file": os.path.basename(res.file_path), + "path": res.file_path, + "provider": res.provider, + "format": getattr(res, "format", None) or "flac", + } + errors.append(f"{name}: {getattr(res, 'error', None) or 'no file downloaded'}") + + return {"success": False, "error": "; ".join(errors) or "all sources failed"} + + +def main(): + try: + payload = _read_payload() + except Exception as e: # noqa: BLE001 + print(json.dumps({"success": False, "error": f"invalid payload: {e}"})) + return 1 + + # Keep stdout clean: library prints/progress and logging go to stderr; only + # the final JSON (printed below, outside this block) reaches stdout. + with contextlib.redirect_stdout(sys.stderr): + outcome = _run(payload) + + print(json.dumps(outcome)) + return 0 if outcome.get("success") else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/web/sample.env b/src/web/sample.env index 360232ba..c701609d 100644 --- a/src/web/sample.env +++ b/src/web/sample.env @@ -48,6 +48,7 @@ LIBRARY_NAME= # Keep original file permissions when moving files (set to false on Synology devices) # KEEP_PERMISSIONS=true # Comma-separated list (no spaces) of download services, in priority order (default: youtube) +# Supported: youtube, slskd, spotiflac # DOWNLOAD_SERVICES=youtube # Path templating, Options are Artist, Album, TrackName, TrackNumber, File, Ext (eg. "{{Artist}}/{{Album}}/{{File}}") # PATH_TEMPLATING="" @@ -101,6 +102,27 @@ LIBRARY_NAME= # Comma-separated (without spaces) keywords to avoid, when filtering slskd results (default: live,remix,instrumental,extended,clean,acapella) # FILTER_LIST=live,remix,instrumental,extended,clean,acapella +# === SpotiFLAC Configuration === + +# Downloads lossless FLAC via the SpotiFLAC python module (https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version). +# Requires python3 with the SpotiFLAC module installed (bundled in the docker image) and ffmpeg in $PATH. +# Add 'spotiflac' to DOWNLOAD_SERVICES to enable it. + +# FLAC sources to try, in priority order (default: deezer,tidal,qobuz,amazon) +# SPOTIFLAC_SOURCES=deezer,tidal,qobuz,amazon +# Preferred quality passed to the source (leave empty to use the provider's lossless default) +# SPOTIFLAC_QUALITY=LOSSLESS +# Python interpreter that has the SpotiFLAC module installed (default: python3) +# SPOTIFLAC_PYTHON_PATH=python3 +# Path to the bundled wrapper script (default: spotiflac_dl.py; set to an absolute path for the binary version) +# SPOTIFLAC_SCRIPT_PATH=spotiflac_dl.py +# Max seconds to spend downloading a single track before giving up (default: 180) +# SPOTIFLAC_TIMEOUT=180 +# Filename format used by SpotiFLAC (default: {title} - {artist}) +# SPOTIFLAC_FILENAME_FORMAT={title} - {artist} +# Optional Qobuz auth token passthrough +# SPOTIFLAC_QOBUZ_TOKEN= + # === Metadata / Formatting === # Set to true to merge featured artists into title (recommended), false appends them to artist field (default: true) From 678873efe2af2a39bad14d0fe5c3581b5c44c5e8 Mon Sep 17 00:00:00 2001 From: pacholoamit Date: Thu, 25 Jun 2026 02:54:39 +0800 Subject: [PATCH 2/3] feat(downloader): support SpotiFLAC 1.2.1 async API + configurable module version - spotiflac_dl.py: use download_track_async (SpotiFLAC >=1.2.1), falling back to download_track (<=1.2.0) - Dockerfile: install the module via a SPOTIFLAC_VERSION build arg (default 1.2.1), pulled from the project's matching GitHub version-branch archive since 1.2.1 restored the upstream endpoint registry and is not on PyPI yet - sample.env / src/web/sample.env: document the >=1.2.1 requirement and the build arg Verified end-to-end: a real FLAC downloads via Deezer through the Go downloader. --- Dockerfile | 9 ++++++++- sample.env | 3 ++- src/downloader/spotiflac/spotiflac_dl.py | 10 +++++++++- src/web/sample.env | 3 ++- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2b2da16a..929e0eb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,8 +33,15 @@ RUN apk add --no-cache \ shadow \ su-exec +# SpotiFLAC python module version to install (FLAC download source). Pulled from the +# project's matching GitHub version-branch archive, since 1.2.1 (which restored the +# upstream endpoint registry) is not on PyPI yet. Override at build time, e.g.: +# --build-arg SPOTIFLAC_VERSION=1.2.2 +ARG SPOTIFLAC_VERSION=1.2.1 + # Install ytmusicapi (youtube fallback search) and SpotiFLAC (FLAC download source) -RUN pip install --no-cache-dir ytmusicapi SpotiFLAC==1.2.0 +RUN pip install --no-cache-dir ytmusicapi \ + "https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version/archive/${SPOTIFLAC_VERSION}.tar.gz" # Set working directory WORKDIR /opt/explo/ diff --git a/sample.env b/sample.env index c701609d..310909ef 100644 --- a/sample.env +++ b/sample.env @@ -105,7 +105,8 @@ LIBRARY_NAME= # === SpotiFLAC Configuration === # Downloads lossless FLAC via the SpotiFLAC python module (https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version). -# Requires python3 with the SpotiFLAC module installed (bundled in the docker image) and ffmpeg in $PATH. +# Requires python3 with the SpotiFLAC module (>=1.2.1) installed (bundled in the docker image) and ffmpeg in $PATH. +# To build the image with a different module version: --build-arg SPOTIFLAC_VERSION= # Add 'spotiflac' to DOWNLOAD_SERVICES to enable it. # FLAC sources to try, in priority order (default: deezer,tidal,qobuz,amazon) diff --git a/src/downloader/spotiflac/spotiflac_dl.py b/src/downloader/spotiflac/spotiflac_dl.py index 9036a342..27904089 100644 --- a/src/downloader/spotiflac/spotiflac_dl.py +++ b/src/downloader/spotiflac/spotiflac_dl.py @@ -19,6 +19,7 @@ import sys import os import json +import asyncio import contextlib @@ -74,7 +75,14 @@ def _run(p): kwargs["qobuz_token"] = qobuz_token try: - res = provider.download_track(meta, output_dir, **kwargs) + # download_track (<=1.2.0) became the async download_track_async (>=1.2.1) + if hasattr(provider, "download_track"): + res = provider.download_track(meta, output_dir, **kwargs) + elif hasattr(provider, "download_track_async"): + res = asyncio.run(provider.download_track_async(meta, output_dir, **kwargs)) + else: + errors.append(f"{name}: provider exposes no download method") + continue except Exception as e: # noqa: BLE001 - a failing provider must not abort the chain errors.append(f"{name}: {e}") continue diff --git a/src/web/sample.env b/src/web/sample.env index c701609d..310909ef 100644 --- a/src/web/sample.env +++ b/src/web/sample.env @@ -105,7 +105,8 @@ LIBRARY_NAME= # === SpotiFLAC Configuration === # Downloads lossless FLAC via the SpotiFLAC python module (https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version). -# Requires python3 with the SpotiFLAC module installed (bundled in the docker image) and ffmpeg in $PATH. +# Requires python3 with the SpotiFLAC module (>=1.2.1) installed (bundled in the docker image) and ffmpeg in $PATH. +# To build the image with a different module version: --build-arg SPOTIFLAC_VERSION= # Add 'spotiflac' to DOWNLOAD_SERVICES to enable it. # FLAC sources to try, in priority order (default: deezer,tidal,qobuz,amazon) From f13908a9475f89ccf2a4dea12b1dfb2752978a6f Mon Sep 17 00:00:00 2001 From: pacholoamit Date: Thu, 25 Jun 2026 22:38:41 +0800 Subject: [PATCH 3/3] feat(downloader): hybrid SpotiFLAC (official CLI + module helper) on 1.2.3 Migrate the spotiflac source to SpotiFLAC 1.2.3, installed from PyPI (which ships the `spotiflac` CLI alongside the importable module). The downloader now serves all Explo flows via two paths: - Tracks with a streaming URL (Spotify-imported playlists) download through the official `spotiflac` CLI, matching the exact track. The Spotify import path now captures each track's URL (partner API `uri`) and threads it through the import -> cache -> run pipeline onto models.Track.SourceURL. - Tracks matched only by metadata (ISRC or title/artist, e.g. ListenBrainz discovery) download through the bundled module helper (spotiflac_dl.py), which searches each FLAC provider by ISRC with a title/artist text-search fallback, mirroring how the youtube service shells out to ytmusicapi. Either path tries the configured sources in priority order; tracks SpotiFLAC cannot source fall through to the other DOWNLOAD_SERVICES. - Dockerfile installs SpotiFLAC from PyPI and bundles the helper. - Config: SPOTIFLAC_BIN (CLI), SPOTIFLAC_PYTHON_PATH/SPOTIFLAC_SCRIPT_PATH (helper), plus SPOTIFLAC_SOURCES/QUALITY/TIMEOUT/RETRIES. --- Dockerfile | 18 +- README.md | 2 +- sample.env | 25 +-- src/config/config.go | 22 ++- src/downloader/spotiflac.go | 202 +++++++++++++++++------ src/downloader/spotiflac/spotiflac_dl.py | 50 +++--- src/main/main.go | 2 + src/models/types.go | 1 + src/web/backend/playlists.go | 6 +- src/web/backend/spotify.go | 14 ++ src/web/sample.env | 25 +-- 11 files changed, 252 insertions(+), 115 deletions(-) diff --git a/Dockerfile b/Dockerfile index 929e0eb9..8cff473c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,15 +33,15 @@ RUN apk add --no-cache \ shadow \ su-exec -# SpotiFLAC python module version to install (FLAC download source). Pulled from the -# project's matching GitHub version-branch archive, since 1.2.1 (which restored the -# upstream endpoint registry) is not on PyPI yet. Override at build time, e.g.: -# --build-arg SPOTIFLAC_VERSION=1.2.2 -ARG SPOTIFLAC_VERSION=1.2.1 - -# Install ytmusicapi (youtube fallback search) and SpotiFLAC (FLAC download source) -RUN pip install --no-cache-dir ytmusicapi \ - "https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version/archive/${SPOTIFLAC_VERSION}.tar.gz" +# SpotiFLAC version to install (FLAC download source). Installs from PyPI and puts +# the `spotiflac` CLI on PATH. Override at build time, e.g.: +# --build-arg SPOTIFLAC_VERSION=1.2.4 +ARG SPOTIFLAC_VERSION=1.2.3 + +# Install ytmusicapi (youtube fallback search) and SpotiFLAC (FLAC download source). +# This provides both the `spotiflac` CLI (URL-based imports) and the importable +# SpotiFLAC module used by spotiflac_dl.py (ISRC/title-artist matching). +RUN pip install --no-cache-dir ytmusicapi "SpotiFLAC==${SPOTIFLAC_VERSION}" # Set working directory WORKDIR /opt/explo/ diff --git a/README.md b/README.md index 5a675458..ff328757 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Explo uses the following 3rd-party libraries: - [ytmusicapi](https://github.com/sigma67/ytmusicapi): Unofficial Youtube Music API -- [SpotiFLAC](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version): Lossless FLAC download module +- [SpotiFLAC](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version): Lossless FLAC downloader (official CLI) - [notify](https://github.com/nikoksr/notify): Module for sending notifications to different services diff --git a/sample.env b/sample.env index 310909ef..34c3e4fe 100644 --- a/sample.env +++ b/sample.env @@ -104,25 +104,28 @@ LIBRARY_NAME= # === SpotiFLAC Configuration === -# Downloads lossless FLAC via the SpotiFLAC python module (https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version). -# Requires python3 with the SpotiFLAC module (>=1.2.1) installed (bundled in the docker image) and ffmpeg in $PATH. -# To build the image with a different module version: --build-arg SPOTIFLAC_VERSION= -# Add 'spotiflac' to DOWNLOAD_SERVICES to enable it. +# Downloads lossless FLAC via SpotiFLAC (https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version), +# bundled in the docker image (pip install "SpotiFLAC>=1.2.3") with ffmpeg in $PATH. +# To build the image with a different version: --build-arg SPOTIFLAC_VERSION= +# Add 'spotiflac' to DOWNLOAD_SERVICES to enable it. Tracks with a streaming URL +# (Spotify-imported playlists) use the official `spotiflac` CLI; tracks matched by +# ISRC/title-artist (e.g. ListenBrainz discovery) use the bundled module helper. +# Tracks SpotiFLAC can't source fall through to the other configured services. # FLAC sources to try, in priority order (default: deezer,tidal,qobuz,amazon) # SPOTIFLAC_SOURCES=deezer,tidal,qobuz,amazon -# Preferred quality passed to the source (leave empty to use the provider's lossless default) +# Preferred quality for the CLI path (default: LOSSLESS; e.g. HI_RES_LOSSLESS, LOSSLESS, HIGH) # SPOTIFLAC_QUALITY=LOSSLESS -# Python interpreter that has the SpotiFLAC module installed (default: python3) +# Path to the spotiflac CLI (default: spotiflac, resolved from $PATH) +# SPOTIFLAC_BIN=spotiflac +# Python interpreter with the SpotiFLAC module installed, for the helper path (default: python3) # SPOTIFLAC_PYTHON_PATH=python3 -# Path to the bundled wrapper script (default: spotiflac_dl.py; set to an absolute path for the binary version) +# Path to the bundled module helper (default: spotiflac_dl.py; set an absolute path for the binary version) # SPOTIFLAC_SCRIPT_PATH=spotiflac_dl.py # Max seconds to spend downloading a single track before giving up (default: 180) # SPOTIFLAC_TIMEOUT=180 -# Filename format used by SpotiFLAC (default: {title} - {artist}) -# SPOTIFLAC_FILENAME_FORMAT={title} - {artist} -# Optional Qobuz auth token passthrough -# SPOTIFLAC_QOBUZ_TOKEN= +# Extra download attempts per track on failure, cycling all sources (default: 2) +# SPOTIFLAC_RETRIES=2 # === Metadata / Formatting === diff --git a/src/config/config.go b/src/config/config.go index 7474c72f..51c6815b 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -135,16 +135,20 @@ type YoutubeMusic struct { Filters Filters } -// Spotiflac configures the 'spotiflac' download service. Sources is the list of -// FLAC providers to try in priority order; see sample.env for the full reference. +// Spotiflac configures the 'spotiflac' download service. It has two paths: +// tracks that carry a streaming URL (e.g. Spotify imports) are downloaded via the +// official SpotiFLAC CLI (BinPath); tracks matched by ISRC/title-artist (e.g. +// ListenBrainz discovery) are downloaded via the bundled module helper +// (PythonPath + ScriptPath). Sources is the list of FLAC providers to try in +// priority order; see sample.env for the full reference. type Spotiflac struct { - Sources []string `env:"SPOTIFLAC_SOURCES" env-default:"deezer,tidal,qobuz,amazon"` - Quality string `env:"SPOTIFLAC_QUALITY" env-default:"LOSSLESS"` - PythonPath string `env:"SPOTIFLAC_PYTHON_PATH" env-default:"python3"` - ScriptPath string `env:"SPOTIFLAC_SCRIPT_PATH" env-default:"spotiflac_dl.py"` - Timeout int `env:"SPOTIFLAC_TIMEOUT" env-default:"180"` - FilenameFormat string `env:"SPOTIFLAC_FILENAME_FORMAT" env-default:"{title} - {artist}"` - QobuzToken string `env:"SPOTIFLAC_QOBUZ_TOKEN"` + Sources []string `env:"SPOTIFLAC_SOURCES" env-default:"deezer,tidal,qobuz,amazon"` + Quality string `env:"SPOTIFLAC_QUALITY" env-default:"LOSSLESS"` + BinPath string `env:"SPOTIFLAC_BIN" env-default:"spotiflac"` + PythonPath string `env:"SPOTIFLAC_PYTHON_PATH" env-default:"python3"` + ScriptPath string `env:"SPOTIFLAC_SCRIPT_PATH" env-default:"spotiflac_dl.py"` + Timeout int `env:"SPOTIFLAC_TIMEOUT" env-default:"180"` + Retries int `env:"SPOTIFLAC_RETRIES" env-default:"2"` } type Slskd struct { diff --git a/src/downloader/spotiflac.go b/src/downloader/spotiflac.go index e117f2d0..7cf26022 100644 --- a/src/downloader/spotiflac.go +++ b/src/downloader/spotiflac.go @@ -6,7 +6,10 @@ import ( "encoding/json" "fmt" "log/slog" + "os" "os/exec" + "path/filepath" + "strconv" "strings" "time" @@ -14,11 +17,26 @@ import ( "explo/src/models" ) -// Spotiflac downloads lossless (FLAC) tracks by shelling out to the bundled -// SpotiFLAC python wrapper (src/downloader/spotiflac/spotiflac_dl.py), mirroring -// how the youtube service uses the ytmusicapi helper. SpotiFLAC resolves a track -// by ISRC (falling back to a title/artist search) and downloads it from one of -// several FLAC sources (deezer, tidal, qobuz, amazon) in priority order. +// spotiflacMinBytes is the smallest file the CLI path treats as a successful +// download. A real lossless (or even lossy fallback) track is multiple megabytes; +// a partial/aborted download is far smaller, so this guards against half-written +// files being reported as "present". +const spotiflacMinBytes = 64 * 1024 + +// Spotiflac downloads lossless (FLAC) tracks via SpotiFLAC +// (https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version), with two paths: +// +// - Tracks that carry a streaming URL (e.g. Spotify-imported playlists) are +// downloaded with the official `spotiflac` CLI, which matches the exact track. +// - Tracks matched only by metadata (e.g. ListenBrainz discovery: ISRC or +// title/artist) are downloaded with the bundled module helper +// (spotiflac_dl.py), which searches each FLAC provider by ISRC with a +// title/artist text-search fallback — mirroring how the youtube service shells +// out to ytmusicapi. +// +// Either way SpotiFLAC tries the configured sources (deezer, tidal, qobuz, amazon) +// in priority order. Tracks with neither a URL nor enough metadata are skipped so +// the other configured services (slskd/youtube) can take over. type Spotiflac struct { DownloadDir string Cfg cfg.Spotiflac @@ -31,26 +49,23 @@ func NewSpotiflac(cfg cfg.Spotiflac, downloadDir string) *Spotiflac { } } -// spotiflacPayload is the JSON contract passed to the python wrapper. +// spotiflacPayload is the JSON contract passed to the module helper. type spotiflacPayload struct { - ID string `json:"id"` - Title string `json:"title"` - Artists string `json:"artists"` - Album string `json:"album"` - AlbumArtist string `json:"album_artist"` - ISRC string `json:"isrc"` - TrackNumber int `json:"track_number"` - DurationMs int `json:"duration_ms"` - CoverURL string `json:"cover_url"` - OutputDir string `json:"output_dir"` - Sources []string `json:"sources"` - Quality string `json:"quality,omitempty"` - FilenameFormat string `json:"filename_format,omitempty"` - QobuzToken string `json:"qobuz_token,omitempty"` - TimeoutS int `json:"timeout_s,omitempty"` + ID string `json:"id"` + Title string `json:"title"` + Artists string `json:"artists"` + Album string `json:"album"` + AlbumArtist string `json:"album_artist"` + ISRC string `json:"isrc"` + TrackNumber int `json:"track_number"` + DurationMs int `json:"duration_ms"` + CoverURL string `json:"cover_url"` + OutputDir string `json:"output_dir"` + Sources []string `json:"sources"` + TimeoutS int `json:"timeout_s,omitempty"` } -// spotiflacResult is the JSON the wrapper prints on stdout. +// spotiflacResult is the JSON the module helper prints on stdout. type spotiflacResult struct { Success bool `json:"success"` File string `json:"file"` @@ -61,36 +76,125 @@ type spotiflacResult struct { } func (c *Spotiflac) QueryTrack(track *models.Track) error { - // SpotiFLAC resolves and downloads in a single step (GetTrack); here we only - // ensure the track carries enough to match on. - if firstISRC(track) == "" && (track.CleanTitle == "" || track.Artist == "") { - return fmt.Errorf("[spotiflac] insufficient metadata (need ISRC or title+artist) for '%s - %s'", track.Title, track.Artist) + // SpotiFLAC resolves and downloads in a single step (GetTrack). Accept the + // track if we have any usable handle on it; otherwise return an error so + // StartDownload skips it and the next configured service takes over. + if track.SourceURL != "" || firstISRC(track) != "" { + return nil } - return nil + if (track.CleanTitle != "" || track.Title != "") && track.Artist != "" { + return nil + } + return fmt.Errorf("[spotiflac] insufficient metadata (need a streaming URL, ISRC, or title+artist) for '%s - %s'", track.Title, track.Artist) } func (c *Spotiflac) GetTrack(track *models.Track) error { + if track.SourceURL != "" { + return c.getViaCLI(track) + } + return c.getViaHelper(track) +} + +// getViaCLI downloads a track that carries a streaming URL using the official +// `spotiflac` CLI. The CLI emits no machine-readable result, so success is +// determined by the deterministic -o output file, not the exit code. +func (c *Spotiflac) getViaCLI(track *models.Track) error { + bin := c.Cfg.BinPath + if bin == "" { + bin = "spotiflac" + } + + filename := getFilename(track.CleanTitle, track.MainArtist) + ".flac" + outPath := filepath.Join(c.DownloadDir, filename) + + // Clear any stale file at the target path so the post-run existence check + // reliably reflects this download. + _ = os.Remove(outPath) + + quality := c.Cfg.Quality + if quality == "" { + quality = "LOSSLESS" + } + + args := []string{ + track.SourceURL, + c.DownloadDir, + "-o", outPath, + "-q", quality, + "--retries", strconv.Itoa(c.Cfg.Retries), + // Skip lyrics + metadata enrichment: keeps batch downloads fast and + // deterministic. The music system handles richer metadata itself. + "--no-lyrics", "--no-enrich", + } + if c.Cfg.Timeout > 0 { + args = append(args, "--timeout", strconv.Itoa(c.Cfg.Timeout)) + } + // --service takes a space-separated list (nargs='+'), so it must come last + // to avoid argparse swallowing the positional url/output_dir arguments. + if len(c.Cfg.Sources) > 0 { + args = append(args, "-s") + args = append(args, c.Cfg.Sources...) + } + + ctx := context.Background() + if c.Cfg.Timeout > 0 { + // Generous outer ceiling so a wedged process can't hang a run: the + // per-track --timeout bounds each attempt; retries cycle all providers + // with backoff, so budget for (retries+1) attempts plus IO/metadata slack. + budget := c.Cfg.Timeout*(c.Cfg.Retries+1) + 120 + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(budget)*time.Second) + defer cancel() + } + + cmd := exec.CommandContext(ctx, bin, args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + runErr := cmd.Run() + + info, statErr := os.Stat(outPath) + if statErr != nil || info.Size() < spotiflacMinBytes { + detail := lastLine(stderr.String()) + if detail == "" && runErr != nil { + detail = runErr.Error() + } + if detail == "" { + detail = "no file produced" + } + return fmt.Errorf("[spotiflac] no download for '%s - %s': %s", track.CleanTitle, track.Artist, detail) + } + + track.File = filename + track.Present = true + slog.Info("download finished", "service", "spotiflac", "track", filename, "source", track.SourceURL) + return nil +} + +// getViaHelper downloads a track matched only by metadata (ISRC or title/artist) +// using the bundled SpotiFLAC module helper, which searches each provider. +func (c *Spotiflac) getViaHelper(track *models.Track) error { albumArtist := track.AlbumArtist if albumArtist == "" { albumArtist = track.MainArtist } + title := track.CleanTitle + if title == "" { + title = track.Title + } payload := spotiflacPayload{ - ID: track.MusicBrainzTrackID, - Title: track.CleanTitle, - Artists: track.Artist, - Album: track.Album, - AlbumArtist: albumArtist, - ISRC: firstISRC(track), - TrackNumber: track.TrackNumber, - DurationMs: track.Duration, - CoverURL: track.CoverURL, - OutputDir: c.DownloadDir, - Sources: c.Cfg.Sources, - Quality: c.Cfg.Quality, - FilenameFormat: c.Cfg.FilenameFormat, - QobuzToken: c.Cfg.QobuzToken, - TimeoutS: c.Cfg.Timeout, + ID: track.MusicBrainzTrackID, + Title: title, + Artists: track.Artist, + Album: track.Album, + AlbumArtist: albumArtist, + ISRC: firstISRC(track), + TrackNumber: track.TrackNumber, + DurationMs: track.Duration, + CoverURL: track.CoverURL, + OutputDir: c.DownloadDir, + Sources: c.Cfg.Sources, + TimeoutS: c.Cfg.Timeout, } body, err := json.Marshal(payload) @@ -100,7 +204,7 @@ func (c *Spotiflac) GetTrack(track *models.Track) error { ctx := context.Background() if c.Cfg.Timeout > 0 { - // Give the subprocess some slack over its own per-track timeout so it can + // Give the subprocess slack over its own per-track timeout so it can // report a clean failure before the context kills it. var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, time.Duration(c.Cfg.Timeout+30)*time.Second) @@ -111,7 +215,7 @@ func (c *Spotiflac) GetTrack(track *models.Track) error { var stderr bytes.Buffer cmd.Stderr = &stderr - stdout, runErr := cmd.Output() // wrapper exits non-zero on failure but still prints JSON + stdout, runErr := cmd.Output() // helper exits non-zero on failure but still prints JSON result, parseErr := parseSpotiflacResult(stdout) if parseErr != nil { @@ -119,11 +223,11 @@ func (c *Spotiflac) GetTrack(track *models.Track) error { if detail == "" && runErr != nil { detail = runErr.Error() } - return fmt.Errorf("[spotiflac] wrapper failed for '%s - %s': %s", track.CleanTitle, track.Artist, detail) + return fmt.Errorf("[spotiflac] helper failed for '%s - %s': %s", title, track.Artist, detail) } if !result.Success { - return fmt.Errorf("[spotiflac] no download for '%s - %s': %s", track.CleanTitle, track.Artist, result.Error) + return fmt.Errorf("[spotiflac] no download for '%s - %s': %s", title, track.Artist, result.Error) } track.File = result.File @@ -152,7 +256,7 @@ func firstISRC(track *models.Track) string { return "" } -// parseSpotiflacResult reads the last JSON object printed on the wrapper's stdout. +// parseSpotiflacResult reads the last JSON object printed on the helper's stdout. func parseSpotiflacResult(stdout []byte) (*spotiflacResult, error) { line := lastJSONLine(string(stdout)) if line == "" { @@ -160,7 +264,7 @@ func parseSpotiflacResult(stdout []byte) (*spotiflacResult, error) { } var res spotiflacResult if err := json.Unmarshal([]byte(line), &res); err != nil { - return nil, fmt.Errorf("failed to parse wrapper output: %w", err) + return nil, fmt.Errorf("failed to parse helper output: %w", err) } return &res, nil } @@ -175,6 +279,8 @@ func lastJSONLine(s string) string { return "" } +// lastLine returns the last non-empty line of s, used to surface a concise +// failure reason from a subprocess's stderr. func lastLine(s string) string { lines := strings.Split(strings.TrimSpace(s), "\n") for i := len(lines) - 1; i >= 0; i-- { diff --git a/src/downloader/spotiflac/spotiflac_dl.py b/src/downloader/spotiflac/spotiflac_dl.py index 27904089..1ca45022 100644 --- a/src/downloader/spotiflac/spotiflac_dl.py +++ b/src/downloader/spotiflac/spotiflac_dl.py @@ -1,10 +1,16 @@ #!/usr/bin/env python3 -"""Bundled wrapper that downloads a single track via the SpotiFLAC module. +"""Bundled helper that downloads a single track via the SpotiFLAC module's +provider API, used by Explo's spotiflac downloader for tracks that have no +streaming URL (e.g. ListenBrainz discovery). -Explo invokes this as a subprocess (mirroring how youtube_music/search_ytmusic.py -shells out to ytmusicapi). It receives the track metadata as a JSON object on -argv[1] (or stdin), tries each configured source/provider in priority order, and -prints a single JSON result line to stdout: +This mirrors how youtube_music/search_ytmusic.py shells out to ytmusicapi: it +resolves a track by ISRC (falling back to a title/artist text search inside each +provider) and downloads it from one of the configured FLAC sources in priority +order. The official `spotiflac` CLI handles URL-based imports (e.g. Spotify); this +helper handles the metadata-search path the CLI cannot do. + +It receives the track metadata as a JSON object on argv[1] (or stdin) and prints a +single JSON result line to stdout: {"success": true, "file": "Song.flac", "path": "/abs/Song.flac", "provider": "deezer", "format": "flac"} @@ -36,12 +42,13 @@ def _run(p): except Exception as e: # noqa: BLE001 - report import failure to caller as JSON return {"success": False, "error": f"SpotiFLAC import failed: {e}"} + artists = p.get("artists") or "" meta = TrackMetadata( id=str(p.get("id") or p.get("isrc") or "explo"), title=p.get("title", ""), - artists=p.get("artists", ""), - album=p.get("album", ""), - album_artist=p.get("album_artist") or p.get("artists", ""), + artists=artists, + album=p.get("album") or "", + album_artist=p.get("album_artist") or artists, isrc=p.get("isrc") or "", track_number=int(p.get("track_number") or 0), duration_ms=int(p.get("duration_ms") or 0), @@ -52,9 +59,7 @@ def _run(p): os.makedirs(output_dir, exist_ok=True) sources = p.get("sources") or ["deezer", "tidal", "qobuz", "amazon"] - quality = p.get("quality") or "" filename_format = p.get("filename_format") or "{title} - {artist}" - qobuz_token = p.get("qobuz_token") or "" timeout_s = int(p.get("timeout_s") or 0) or None errors = [] @@ -68,21 +73,18 @@ def _run(p): except TypeError: provider = cls() - kwargs = {"allow_fallback": True, "filename_format": filename_format} - if quality: - kwargs["quality"] = quality - if qobuz_token: - kwargs["qobuz_token"] = qobuz_token - try: - # download_track (<=1.2.0) became the async download_track_async (>=1.2.1) - if hasattr(provider, "download_track"): - res = provider.download_track(meta, output_dir, **kwargs) - elif hasattr(provider, "download_track_async"): - res = asyncio.run(provider.download_track_async(meta, output_dir, **kwargs)) - else: - errors.append(f"{name}: provider exposes no download method") - continue + # Each provider matches by ISRC first, then a title/artist text search. + # allow_fallback=False keeps this provider self-contained so our own + # source loop controls the priority order. + res = asyncio.run( + provider.download_track_async( + meta, + output_dir, + filename_format=filename_format, + allow_fallback=False, + ) + ) except Exception as e: # noqa: BLE001 - a failing provider must not abort the chain errors.append(f"{name}: {e}") continue diff --git a/src/main/main.go b/src/main/main.go index 2c818342..2ce545b2 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -35,6 +35,7 @@ func loadCustomTracks(dataDir, playlistID string) ([]*models.Track, string, erro Release string `json:"release"` CoverURL string `json:"coverUrl"` CoverPath string `json:"coverPath"` + SourceURL string `json:"sourceUrl"` } type cacheFile struct { Tracks []cachedTrack `json:"tracks"` @@ -81,6 +82,7 @@ func loadCustomTracks(dataDir, playlistID string) ([]*models.Track, string, erro Album: t.Release, CoverURL: t.CoverURL, CoverPath: t.CoverPath, + SourceURL: t.SourceURL, } } return tracks, name, nil diff --git a/src/models/types.go b/src/models/types.go index f3656baf..7e8f6017 100644 --- a/src/models/types.go +++ b/src/models/types.go @@ -26,6 +26,7 @@ type Track struct { OriginalYear int Genres string ISRCs []string + SourceURL string // Streaming URL (e.g. Spotify track link) captured from a URL-based import; used by the spotiflac downloader Media string // Media format (e.g., CD, Digital Media) TrackNumber int // Track position in media TrackTotal int // Total tracks in media diff --git a/src/web/backend/playlists.go b/src/web/backend/playlists.go index 8db584fc..ab785af3 100644 --- a/src/web/backend/playlists.go +++ b/src/web/backend/playlists.go @@ -30,6 +30,7 @@ type PlaylistTrack struct { MainArtist string Album string CoverURL string + SourceURL string // streaming URL (e.g. Spotify track link); empty for sources without per-track URLs } // validPlaylistTypes is derived from playlistDefs — no manual sync needed. @@ -235,6 +236,7 @@ type cachedPrefetchTrack struct { Release string `json:"release"` CoverURL string `json:"coverUrl,omitempty"` CoverPath string `json:"coverPath,omitempty"` + SourceURL string `json:"sourceUrl,omitempty"` } // writePreliminaryCache writes the track cache with remote cover URLs immediately. @@ -242,7 +244,7 @@ type cachedPrefetchTrack struct { func writePreliminaryCache(cfgDir, playlistType string, tracks []PlaylistTrack) bool { ct := make([]cachedPrefetchTrack, len(tracks)) for i, t := range tracks { - ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: t.CoverURL} + ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: t.CoverURL, SourceURL: t.SourceURL} } if !writeTrackCache(cfgDir, playlistType, ct) { return false @@ -262,7 +264,7 @@ func downloadAndCacheCovers(cfgDir, playlistType string, tracks []PlaylistTrack) ct := make([]cachedPrefetchTrack, len(tracks)) for i, t := range tracks { APIPath, coverPath := util.DownloadCover(t.CoverURL, coversDir) - ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: APIPath, CoverPath: coverPath} + ct[i] = cachedPrefetchTrack{Rank: i + 1, Title: t.Title, Artist: t.Artist, MainArtist: t.MainArtist, Release: t.Album, CoverURL: APIPath, CoverPath: coverPath, SourceURL: t.SourceURL} } if writeTrackCache(cfgDir, playlistType, ct) { slog.Info("prefetch: cache updated", "playlist", playlistType, "covers", "local") diff --git a/src/web/backend/spotify.go b/src/web/backend/spotify.go index 202e9029..38cba32f 100644 --- a/src/web/backend/spotify.go +++ b/src/web/backend/spotify.go @@ -540,6 +540,7 @@ type partnerItem struct { type partnerTrackData struct { Typename string `json:"__typename"` + URI string `json:"uri"` // e.g. "spotify:track:" — used to build a track URL for SpotiFLAC Name string `json:"name"` Artists struct { Items []struct { @@ -706,11 +707,24 @@ func extractTracks(items []partnerItem) []PlaylistTrack { MainArtist: mainArtist, Album: t.AlbumOfTrack.Name, CoverURL: coverURL, + SourceURL: spotifyTrackURL(t.URI), }) } return tracks } +// spotifyTrackURL converts a Spotify track URI ("spotify:track:") into an +// open.spotify.com track URL, which the SpotiFLAC CLI accepts as input. +// Returns "" for anything that isn't a track URI (e.g. local/episode items). +func spotifyTrackURL(uri string) string { + const prefix = "spotify:track:" + id := strings.TrimPrefix(uri, prefix) + if id == uri || id == "" { + return "" + } + return "https://open.spotify.com/track/" + id +} + // pickBestSource returns the image URL closest to targetSize pixels. func pickBestSource(sources []spotifyImageSource, targetSize int) string { if len(sources) == 0 { diff --git a/src/web/sample.env b/src/web/sample.env index 310909ef..34c3e4fe 100644 --- a/src/web/sample.env +++ b/src/web/sample.env @@ -104,25 +104,28 @@ LIBRARY_NAME= # === SpotiFLAC Configuration === -# Downloads lossless FLAC via the SpotiFLAC python module (https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version). -# Requires python3 with the SpotiFLAC module (>=1.2.1) installed (bundled in the docker image) and ffmpeg in $PATH. -# To build the image with a different module version: --build-arg SPOTIFLAC_VERSION= -# Add 'spotiflac' to DOWNLOAD_SERVICES to enable it. +# Downloads lossless FLAC via SpotiFLAC (https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version), +# bundled in the docker image (pip install "SpotiFLAC>=1.2.3") with ffmpeg in $PATH. +# To build the image with a different version: --build-arg SPOTIFLAC_VERSION= +# Add 'spotiflac' to DOWNLOAD_SERVICES to enable it. Tracks with a streaming URL +# (Spotify-imported playlists) use the official `spotiflac` CLI; tracks matched by +# ISRC/title-artist (e.g. ListenBrainz discovery) use the bundled module helper. +# Tracks SpotiFLAC can't source fall through to the other configured services. # FLAC sources to try, in priority order (default: deezer,tidal,qobuz,amazon) # SPOTIFLAC_SOURCES=deezer,tidal,qobuz,amazon -# Preferred quality passed to the source (leave empty to use the provider's lossless default) +# Preferred quality for the CLI path (default: LOSSLESS; e.g. HI_RES_LOSSLESS, LOSSLESS, HIGH) # SPOTIFLAC_QUALITY=LOSSLESS -# Python interpreter that has the SpotiFLAC module installed (default: python3) +# Path to the spotiflac CLI (default: spotiflac, resolved from $PATH) +# SPOTIFLAC_BIN=spotiflac +# Python interpreter with the SpotiFLAC module installed, for the helper path (default: python3) # SPOTIFLAC_PYTHON_PATH=python3 -# Path to the bundled wrapper script (default: spotiflac_dl.py; set to an absolute path for the binary version) +# Path to the bundled module helper (default: spotiflac_dl.py; set an absolute path for the binary version) # SPOTIFLAC_SCRIPT_PATH=spotiflac_dl.py # Max seconds to spend downloading a single track before giving up (default: 180) # SPOTIFLAC_TIMEOUT=180 -# Filename format used by SpotiFLAC (default: {title} - {artist}) -# SPOTIFLAC_FILENAME_FORMAT={title} - {artist} -# Optional Qobuz auth token passthrough -# SPOTIFLAC_QOBUZ_TOKEN= +# Extra download attempts per track on failure, cycling all sources (default: 2) +# SPOTIFLAC_RETRIES=2 # === Metadata / Formatting ===