diff --git a/Dockerfile b/Dockerfile index f65e5c0..8cff473 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,16 +33,24 @@ RUN apk add --no-cache \ shadow \ su-exec -# Install ytmusicapi in the container -RUN pip install --no-cache-dir ytmusicapi +# 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/ -# 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 3309206..ff32875 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 downloader (official CLI) + - [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 360232b..34c3e4f 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,31 @@ 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 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 for the CLI path (default: LOSSLESS; e.g. HI_RES_LOSSLESS, LOSSLESS, HIGH) +# SPOTIFLAC_QUALITY=LOSSLESS +# 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 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 +# Extra download attempts per track on failure, cycling all sources (default: 2) +# SPOTIFLAC_RETRIES=2 + # === 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 8eabc58..51c6815 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,22 @@ type YoutubeMusic struct { Filters Filters } +// 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"` + 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 { APIKey string `env:"SLSKD_API_KEY"` URL string `env:"SLSKD_URL"` diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 9451f66..baea989 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 0000000..7cf2602 --- /dev/null +++ b/src/downloader/spotiflac.go @@ -0,0 +1,292 @@ +package downloader + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + cfg "explo/src/config" + "explo/src/models" +) + +// 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 +} + +func NewSpotiflac(cfg cfg.Spotiflac, downloadDir string) *Spotiflac { + return &Spotiflac{ + DownloadDir: downloadDir, + Cfg: cfg, + } +} + +// 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"` + TimeoutS int `json:"timeout_s,omitempty"` +} + +// spotiflacResult is the JSON the module helper 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). 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 + } + 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: 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) + if err != nil { + return fmt.Errorf("failed to marshal spotiflac payload: %w", err) + } + + ctx := context.Background() + if c.Cfg.Timeout > 0 { + // 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) + 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() // helper 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] helper failed for '%s - %s': %s", title, track.Artist, detail) + } + + if !result.Success { + return fmt.Errorf("[spotiflac] no download for '%s - %s': %s", title, 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 helper'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 helper 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 "" +} + +// 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-- { + 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 0000000..1ca4502 --- /dev/null +++ b/src/downloader/spotiflac/spotiflac_dl.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""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). + +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"} + +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 asyncio +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}"} + + artists = p.get("artists") or "" + meta = TrackMetadata( + id=str(p.get("id") or p.get("isrc") or "explo"), + title=p.get("title", ""), + 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), + 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"] + filename_format = p.get("filename_format") or "{title} - {artist}" + 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() + + try: + # 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 + + 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/main/main.go b/src/main/main.go index 2c81834..2ce545b 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 f3656ba..7e8f601 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 8db584f..ab785af 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 202e902..38cba32 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 360232b..34c3e4f 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,31 @@ 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 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 for the CLI path (default: LOSSLESS; e.g. HI_RES_LOSSLESS, LOSSLESS, HIGH) +# SPOTIFLAC_QUALITY=LOSSLESS +# 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 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 +# Extra download attempts per track on failure, cycling all sources (default: 2) +# SPOTIFLAC_RETRIES=2 + # === Metadata / Formatting === # Set to true to merge featured artists into title (recommended), false appends them to artist field (default: true)