From b4cdcd887411b36e63911c82ddeaf8a391d4ae52 Mon Sep 17 00:00:00 2001 From: Prayuj Tuli Date: Tue, 2 Jun 2026 01:27:22 -0400 Subject: [PATCH 1/4] feat: add scrape TUI client and make install target scripts/scrape.sh is a server-agnostic search/select/download client using curl + jq + fzf. Targets AUDIO_SCRAPER_HOST (default localhost:8080). Install to local bin with `make install` (audio-scrape). --- Makefile | 20 +++++++++++- scripts/scrape.sh | 81 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100755 scripts/scrape.sh diff --git a/Makefile b/Makefile index ad53c72..7f62c4b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,9 @@ -include .env -.PHONY: all build start +PREFIX ?= $(HOME)/.local +BINDIR := $(PREFIX)/bin + +.PHONY: all build start scrape install uninstall all: build @@ -9,3 +12,18 @@ build: start: ./bin/audio-scraper + +# Interactive search/select/download client. Pass a query with q="...": +# make scrape q="daft punk get lucky" +scrape: + ./scripts/scrape.sh $(q) + +# Install the TUI client to $(BINDIR) as audio-scrape (override with PREFIX=...). +install: + install -d $(BINDIR) + install -m755 scripts/scrape.sh $(BINDIR)/audio-scrape + @echo "installed audio-scrape to $(BINDIR)" + +uninstall: + rm -f $(BINDIR)/audio-scrape + @echo "removed audio-scrape from $(BINDIR)" diff --git a/scripts/scrape.sh b/scripts/scrape.sh new file mode 100755 index 0000000..49492f3 --- /dev/null +++ b/scripts/scrape.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# scrape.sh - interactive search/select/download client for audio-scraper. +# +# Talks to the server over HTTP. Set the target with AUDIO_SCRAPER_HOST +# (default http://localhost:8080) - export it from your zshrc to point at a +# local or remote instance, e.g.: +# +# export AUDIO_SCRAPER_HOST="http://localhost:8080" +# +# Usage: +# scrape.sh [query...] # query as args, or prompted if omitted +# +# Flow: search -> pick results in fzf (TAB = multi-select) -> queue download. + +set -euo pipefail + +HOST="${AUDIO_SCRAPER_HOST:-http://localhost:8080}" + +for tool in curl jq fzf; do + command -v "$tool" >/dev/null 2>&1 || { + echo "missing required tool: $tool" >&2 + exit 1 + } +done + +query="$*" +if [[ -z "$query" ]]; then + read -rp "search> " query +fi +[[ -n "$query" ]] || { + echo "no query given" >&2 + exit 1 +} + +echo "searching '$query' on $HOST ..." >&2 +resp="$(curl -sS --get "$HOST/search" --data-urlencode "q=$query")" || { + echo "search request failed (is the server up? AUDIO_SCRAPER_HOST=$HOST)" >&2 + exit 1 +} + +request_id="$(jq -r '.request_id // empty' <<<"$resp")" +if [[ -z "$request_id" ]]; then + echo "unexpected response from server:" >&2 + echo "$resp" >&2 + exit 1 +fi + +mapfile -t choices < <(jq -r '.choices[]?' <<<"$resp") +if ((${#choices[@]} == 0)); then + echo "no results for '$query'" >&2 + exit 1 +fi + +selected="$(printf '%s\n' "${choices[@]}" | + fzf --multi --reverse --height=80% \ + --prompt="select> " \ + --header="TAB to multi-select, ENTER to download, ESC to cancel")" || true + +if [[ -z "$selected" ]]; then + echo "nothing selected" >&2 + exit 0 +fi + +# Build {request_id, choices: [...]} from the selected labels. +choices_json="$(printf '%s\n' "$selected" | jq -R . | jq -s .)" +payload="$(jq -n --arg rid "$request_id" --argjson ch "$choices_json" \ + '{request_id: $rid, choices: $ch}')" + +code="$(curl -sS -o /dev/null -w '%{http_code}' \ + -X POST "$HOST/download" \ + -H 'Content-Type: application/json' \ + -d "$payload")" + +count="$(printf '%s\n' "$selected" | grep -c .)" +if [[ "$code" == "202" ]]; then + echo "queued $count selection(s) for download (HTTP $code)" +else + echo "download request failed (HTTP $code)" >&2 + exit 1 +fi From 4fde8af11613179f65b76e4094071c7c505348e8 Mon Sep 17 00:00:00 2001 From: Prayuj Tuli Date: Tue, 2 Jun 2026 01:45:47 -0400 Subject: [PATCH 2/4] feat: env-based config, subsonic adapter with batched rescan, deploy script - Add internal/config using caarlos0/env, parsed once and passed through - Add subsonic adapter (interface/impl/mocks) for Navidrome: ping, rescan, search, playlists, scrobble, star; no-ops with a warning when SUBSONIC_URL is unset, ping verified at startup (fatal on failure when configured) - Batch downloads into a periodic coalesced rescan instead of per-track - Add scripts/deploy.sh and `make deploy`; drop nimbus.yaml --- Makefile | 6 +- cmd/main.go | 43 +-- go.mod | 1 + go.sum | 2 + internal/adapters/subsonic/impl/subsonic.go | 307 ++++++++++++++++++++ internal/adapters/subsonic/interface.go | 67 +++++ internal/adapters/subsonic/mocks/mock.go | 78 +++++ internal/config/config.go | 28 ++ internal/constants/constants.go | 2 - internal/pool/pool.go | 76 ++++- nimbus.yaml | 12 - scripts/deploy.sh | 44 +++ 12 files changed, 623 insertions(+), 43 deletions(-) create mode 100644 internal/adapters/subsonic/impl/subsonic.go create mode 100644 internal/adapters/subsonic/interface.go create mode 100644 internal/adapters/subsonic/mocks/mock.go create mode 100644 internal/config/config.go delete mode 100644 nimbus.yaml create mode 100755 scripts/deploy.sh diff --git a/Makefile b/Makefile index 7f62c4b..7c83b9f 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ PREFIX ?= $(HOME)/.local BINDIR := $(PREFIX)/bin -.PHONY: all build start scrape install uninstall +.PHONY: all build start scrape install uninstall deploy all: build @@ -27,3 +27,7 @@ install: uninstall: rm -f $(BINDIR)/audio-scrape @echo "removed audio-scrape from $(BINDIR)" + +# Build, tag and push the image to docker.prayujt.com/audio-scraper. +deploy: + ./scripts/deploy.sh diff --git a/cmd/main.go b/cmd/main.go index 09b5dc2..2e92671 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,10 +1,11 @@ +// Command audio-scraper is the HTTP server entrypoint: it loads configuration, +// wires up the adapters, and serves the search/download API. package main import ( + "context" "fmt" "net/http" - "os" - "strconv" "time" "github.com/gorilla/mux" @@ -13,9 +14,10 @@ import ( itunesimpl "audio-scraper/internal/adapters/itunes/impl" lrclibimpl "audio-scraper/internal/adapters/lrclib/impl" storeimpl "audio-scraper/internal/adapters/store/impl" + subsonicimpl "audio-scraper/internal/adapters/subsonic/impl" youtubeimpl "audio-scraper/internal/adapters/youtube/impl" "audio-scraper/internal/api" - "audio-scraper/internal/constants" + "audio-scraper/internal/config" "audio-scraper/internal/logger" "audio-scraper/internal/pool" ) @@ -23,31 +25,36 @@ import ( func main() { log := logger.NewLogger() log.Debug("init starting") - port := os.Getenv("API_PORT") - if port == "" { - port = "8080" + + cfg, err := config.Load() + if err != nil { + log.Error("failed to load config", "error", err) + return } - log.Info("started server", "host", "0.0.0.0", "port", port) + log.Info("started server", "host", "0.0.0.0", "port", cfg.APIPort) md := itunesimpl.New() st := storeimpl.New(log) yt := youtubeimpl.New() lrc := lrclibimpl.New() - fs, err := filesystemimpl.New(os.Getenv("MUSIC_HOME"), lrc) + ss := subsonicimpl.New(cfg.SubsonicURL, cfg.SubsonicUser, cfg.SubsonicPassword) + // Verify Subsonic credentials up front. When no URL is configured this + // warns and returns nil; when one is, a failure is fatal. + if err := ss.Ping(logger.Into(context.Background(), log)); err != nil { + log.Error("subsonic ping failed", "error", err) + return + } + fs, err := filesystemimpl.New(cfg.MusicHome, lrc) if err != nil { log.Error("failed to initialize filesystem provider", "error", err) return } - poolSizeEnv := os.Getenv("WORKER_SIZE") - poolSize, err := strconv.Atoi(poolSizeEnv) - if err != nil || poolSize <= 0 { - poolSize = constants.DownloadWorkerPoolSize - } - q := pool.NewDownloadWorkerPool(poolSize, &pool.Deps{ - Log: log, - YT: yt, - FS: fs, + q := pool.NewDownloadWorkerPool(cfg.WorkerSize, &pool.Deps{ + Log: log, + YT: yt, + FS: fs, + Subsonic: ss, }) h := api.NewHandlers(&api.Deps{ Log: log, @@ -62,7 +69,7 @@ func main() { server := &http.Server{ Handler: router, - Addr: fmt.Sprintf("0.0.0.0:%s", port), + Addr: fmt.Sprintf("0.0.0.0:%s", cfg.APIPort), WriteTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second, } diff --git a/go.mod b/go.mod index df117d9..661f0ac 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.5 require ( github.com/bogem/id3v2/v2 v2.1.4 + github.com/caarlos0/env/v11 v11.4.1 github.com/faiface/beep v1.1.0 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 diff --git a/go.sum b/go.sum index 0faaafb..d169464 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY= +github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= +github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c= github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4= diff --git a/internal/adapters/subsonic/impl/subsonic.go b/internal/adapters/subsonic/impl/subsonic.go new file mode 100644 index 0000000..c39170f --- /dev/null +++ b/internal/adapters/subsonic/impl/subsonic.go @@ -0,0 +1,307 @@ +// Package subsonicimpl implements the subsonic.Provider port against a +// Subsonic-compatible REST API (Navidrome) using salted-token authentication. +// +// If the configured base URL is empty the client is considered disabled: every +// method logs a warning and returns a zero value without making a request. This +// lets callers invoke it unconditionally when no server is configured. +package subsonicimpl + +import ( + "context" + "crypto/md5" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + "audio-scraper/internal/adapters/subsonic" + "audio-scraper/internal/logger" +) + +const ( + // apiVersion is the Subsonic API version we advertise. + apiVersion = "1.16.1" + // clientName identifies this client to the server (the "c" param). + clientName = "audio-scraper" +) + +// Client is the Subsonic-backed provider. +type Client struct { + http *http.Client + baseURL string + user string + password string +} + +// compile-time assertion that Client satisfies the port. +var _ subsonic.Provider = (*Client)(nil) + +// New returns a Subsonic provider. A blank baseURL disables the client: all +// calls become warning-logged no-ops. +func New(baseURL, user, password string) *Client { + return &Client{ + http: &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 3 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 20, + MaxIdleConnsPerHost: 20, + ResponseHeaderTimeout: 10 * time.Second, + TLSHandshakeTimeout: 3 * time.Second, + }, + }, + baseURL: strings.TrimRight(baseURL, "/"), + user: user, + password: password, + } +} + +// enabled reports whether a server is configured. When it is not, it logs a +// warning naming the skipped operation and returns false. +func (c *Client) enabled(ctx context.Context, op string) bool { + if c.baseURL == "" { + logger.From(ctx).Warn("subsonic url not configured, skipping operation", "op", op) + return false + } + return true +} + +func (c *Client) Ping(ctx context.Context) error { + if !c.enabled(ctx, "ping") { + return nil + } + _, err := c.do(ctx, "ping", nil) + return err +} + +func (c *Client) StartScan(ctx context.Context) error { + if !c.enabled(ctx, "startScan") { + return nil + } + log := logger.From(ctx) + log.Info("triggering subsonic library rescan") + _, err := c.do(ctx, "startScan", nil) + if err != nil { + log.Error("subsonic rescan failed", "error", err) + } + return err +} + +func (c *Client) Search(ctx context.Context, query string) (subsonic.SearchResult, error) { + if !c.enabled(ctx, "search") { + return subsonic.SearchResult{}, nil + } + resp, err := c.do(ctx, "search3", url.Values{"query": {query}}) + if err != nil { + return subsonic.SearchResult{}, err + } + if resp.SearchResult3 == nil { + return subsonic.SearchResult{}, nil + } + + var out subsonic.SearchResult + for _, a := range resp.SearchResult3.Artist { + out.Artists = append(out.Artists, subsonic.Artist{ID: a.ID, Name: a.Name}) + } + for _, a := range resp.SearchResult3.Album { + out.Albums = append(out.Albums, subsonic.Album{ID: a.ID, Name: a.Name, Artist: a.Artist}) + } + for _, s := range resp.SearchResult3.Song { + out.Songs = append(out.Songs, subsonic.Song{ + ID: s.ID, + Title: s.Title, + Album: s.Album, + Artist: s.Artist, + Duration: s.Duration, + }) + } + return out, nil +} + +func (c *Client) GetPlaylists(ctx context.Context) ([]subsonic.Playlist, error) { + if !c.enabled(ctx, "getPlaylists") { + return nil, nil + } + resp, err := c.do(ctx, "getPlaylists", nil) + if err != nil { + return nil, err + } + if resp.Playlists == nil { + return nil, nil + } + out := make([]subsonic.Playlist, 0, len(resp.Playlists.Playlist)) + for _, p := range resp.Playlists.Playlist { + out = append(out, subsonic.Playlist{ID: p.ID, Name: p.Name, SongCount: p.SongCount}) + } + return out, nil +} + +func (c *Client) CreatePlaylist(ctx context.Context, name string, songIDs []string) (subsonic.Playlist, error) { + if !c.enabled(ctx, "createPlaylist") { + return subsonic.Playlist{}, nil + } + params := url.Values{"name": {name}} + for _, id := range songIDs { + params.Add("songId", id) + } + resp, err := c.do(ctx, "createPlaylist", params) + if err != nil { + return subsonic.Playlist{}, err + } + if resp.Playlist == nil { + return subsonic.Playlist{}, nil + } + return subsonic.Playlist{ + ID: resp.Playlist.ID, + Name: resp.Playlist.Name, + SongCount: resp.Playlist.SongCount, + }, nil +} + +func (c *Client) UpdatePlaylist(ctx context.Context, playlistID string, songIDsToAdd []string) error { + if !c.enabled(ctx, "updatePlaylist") { + return nil + } + params := url.Values{"playlistId": {playlistID}} + for _, id := range songIDsToAdd { + params.Add("songIdToAdd", id) + } + _, err := c.do(ctx, "updatePlaylist", params) + return err +} + +func (c *Client) Scrobble(ctx context.Context, songID string) error { + if !c.enabled(ctx, "scrobble") { + return nil + } + _, err := c.do(ctx, "scrobble", url.Values{"id": {songID}}) + return err +} + +func (c *Client) Star(ctx context.Context, songID string) error { + if !c.enabled(ctx, "star") { + return nil + } + _, err := c.do(ctx, "star", url.Values{"id": {songID}}) + return err +} + +// do performs an authenticated GET against /rest/.view and returns the +// decoded subsonic-response body, mapping a "failed" status to an error. +func (c *Client) do(ctx context.Context, view string, extra url.Values) (*apiResponse, error) { + params := c.authParams() + for k, vs := range extra { + for _, v := range vs { + params.Add(k, v) + } + } + + u := c.baseURL + "/rest/" + view + ".view?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + + res, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("subsonic: unexpected status %d", res.StatusCode) + } + + var env apiEnvelope + if err := json.NewDecoder(res.Body).Decode(&env); err != nil { + return nil, err + } + if env.Response.Status != "ok" { + if env.Response.Error != nil { + return nil, fmt.Errorf("subsonic: %s (code %d)", + env.Response.Error.Message, env.Response.Error.Code) + } + return nil, fmt.Errorf("subsonic: request failed with status %q", env.Response.Status) + } + return &env.Response, nil +} + +// authParams builds the salted-token auth query params required on every call. +func (c *Client) authParams() url.Values { + salt := randSalt() + sum := md5.Sum([]byte(c.password + salt)) + return url.Values{ + "u": {c.user}, + "t": {hex.EncodeToString(sum[:])}, + "s": {salt}, + "v": {apiVersion}, + "c": {clientName}, + "f": {"json"}, + } +} + +// randSalt returns a random hex salt for token auth. +func randSalt() string { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + // crypto/rand failure is effectively fatal; fall back to a timestamp. + return fmt.Sprintf("%x", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} + +// apiEnvelope wraps every Subsonic response under the "subsonic-response" key. +type apiEnvelope struct { + Response apiResponse `json:"subsonic-response"` +} + +type apiResponse struct { + Status string `json:"status"` + Version string `json:"version"` + Error *apiError `json:"error"` + SearchResult3 *searchResult3 `json:"searchResult3"` + Playlists *playlists `json:"playlists"` + Playlist *playlist `json:"playlist"` +} + +type apiError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type searchResult3 struct { + Artist []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"artist"` + Album []struct { + ID string `json:"id"` + Name string `json:"name"` + Artist string `json:"artist"` + } `json:"album"` + Song []struct { + ID string `json:"id"` + Title string `json:"title"` + Album string `json:"album"` + Artist string `json:"artist"` + Duration int `json:"duration"` + } `json:"song"` +} + +type playlists struct { + Playlist []playlist `json:"playlist"` +} + +type playlist struct { + ID string `json:"id"` + Name string `json:"name"` + SongCount int `json:"songCount"` +} diff --git a/internal/adapters/subsonic/interface.go b/internal/adapters/subsonic/interface.go new file mode 100644 index 0000000..f7ef5b9 --- /dev/null +++ b/internal/adapters/subsonic/interface.go @@ -0,0 +1,67 @@ +// Package subsonic defines the port for talking to a Subsonic-compatible +// server (e.g. Navidrome): trigger library rescans, search, and manage +// playlists. The concrete implementation lives in the impl subpackage and a +// test double in mocks. +// +// When the server URL is not configured, the implementation no-ops every call +// and emits a warning, so the rest of the app can call it unconditionally. +package subsonic + +import "context" + +// Provider is the Subsonic server contract. +type Provider interface { + // Ping verifies connectivity and credentials. + Ping(ctx context.Context) error + // StartScan triggers a library rescan so newly added files are indexed. + StartScan(ctx context.Context) error + // Search returns artists, albums and songs matching the query (search3). + Search(ctx context.Context, query string) (SearchResult, error) + // GetPlaylists lists the current user's playlists. + GetPlaylists(ctx context.Context) ([]Playlist, error) + // CreatePlaylist creates a playlist with the given name and songs, and + // returns it. + CreatePlaylist(ctx context.Context, name string, songIDs []string) (Playlist, error) + // UpdatePlaylist appends the given songs to an existing playlist. + UpdatePlaylist(ctx context.Context, playlistID string, songIDsToAdd []string) error + // Scrobble registers a play for a song. + Scrobble(ctx context.Context, songID string) error + // Star marks a song as a favorite. + Star(ctx context.Context, songID string) error +} + +// Song is a single track in the Subsonic library. +type Song struct { + ID string + Title string + Album string + Artist string + Duration int +} + +// Album is an album entry from a Subsonic search. +type Album struct { + ID string + Name string + Artist string +} + +// Artist is an artist entry from a Subsonic search. +type Artist struct { + ID string + Name string +} + +// Playlist is a Subsonic playlist (metadata only). +type Playlist struct { + ID string + Name string + SongCount int +} + +// SearchResult aggregates the entity kinds returned by search3. +type SearchResult struct { + Artists []Artist + Albums []Album + Songs []Song +} diff --git a/internal/adapters/subsonic/mocks/mock.go b/internal/adapters/subsonic/mocks/mock.go new file mode 100644 index 0000000..d9e3f6e --- /dev/null +++ b/internal/adapters/subsonic/mocks/mock.go @@ -0,0 +1,78 @@ +// Package subsonicmock provides a test double for subsonic.Provider. +package subsonicmock + +import ( + "context" + + "audio-scraper/internal/adapters/subsonic" +) + +// Mock implements subsonic.Provider; set the *Func fields to control behavior. +type Mock struct { + PingFunc func(ctx context.Context) error + StartScanFunc func(ctx context.Context) error + SearchFunc func(ctx context.Context, query string) (subsonic.SearchResult, error) + GetPlaylistsFunc func(ctx context.Context) ([]subsonic.Playlist, error) + CreatePlaylistFunc func(ctx context.Context, name string, songIDs []string) (subsonic.Playlist, error) + UpdatePlaylistFunc func(ctx context.Context, playlistID string, songIDsToAdd []string) error + ScrobbleFunc func(ctx context.Context, songID string) error + StarFunc func(ctx context.Context, songID string) error +} + +var _ subsonic.Provider = (*Mock)(nil) + +func (m *Mock) Ping(ctx context.Context) error { + if m.PingFunc != nil { + return m.PingFunc(ctx) + } + return nil +} + +func (m *Mock) StartScan(ctx context.Context) error { + if m.StartScanFunc != nil { + return m.StartScanFunc(ctx) + } + return nil +} + +func (m *Mock) Search(ctx context.Context, query string) (subsonic.SearchResult, error) { + if m.SearchFunc != nil { + return m.SearchFunc(ctx, query) + } + return subsonic.SearchResult{}, nil +} + +func (m *Mock) GetPlaylists(ctx context.Context) ([]subsonic.Playlist, error) { + if m.GetPlaylistsFunc != nil { + return m.GetPlaylistsFunc(ctx) + } + return nil, nil +} + +func (m *Mock) CreatePlaylist(ctx context.Context, name string, songIDs []string) (subsonic.Playlist, error) { + if m.CreatePlaylistFunc != nil { + return m.CreatePlaylistFunc(ctx, name, songIDs) + } + return subsonic.Playlist{}, nil +} + +func (m *Mock) UpdatePlaylist(ctx context.Context, playlistID string, songIDsToAdd []string) error { + if m.UpdatePlaylistFunc != nil { + return m.UpdatePlaylistFunc(ctx, playlistID, songIDsToAdd) + } + return nil +} + +func (m *Mock) Scrobble(ctx context.Context, songID string) error { + if m.ScrobbleFunc != nil { + return m.ScrobbleFunc(ctx, songID) + } + return nil +} + +func (m *Mock) Star(ctx context.Context, songID string) error { + if m.StarFunc != nil { + return m.StarFunc(ctx, songID) + } + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2ab89b4 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,28 @@ +// Package config loads runtime configuration from the environment. +package config + +import "github.com/caarlos0/env/v11" + +// Config is the application configuration, populated from environment +// variables. It is parsed once at startup and passed through the composition +// root to the components that need it. +type Config struct { + APIPort string `env:"API_PORT" envDefault:"8080"` + MusicHome string `env:"MUSIC_HOME"` + WorkerSize int `env:"WORKER_SIZE" envDefault:"5"` + + // SubsonicURL is the base URL of a Subsonic-compatible server (Navidrome). + // When empty, all Subsonic operations are skipped. + SubsonicURL string `env:"SUBSONIC_URL"` + SubsonicUser string `env:"SUBSONIC_USER"` + SubsonicPassword string `env:"SUBSONIC_PASSWORD"` +} + +// Load parses the environment into a Config. +func Load() (*Config, error) { + cfg, err := env.ParseAs[Config]() + if err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 9af3ac6..4ab4586 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -1,8 +1,6 @@ // Package constants contains constant values used across the application. package constants -const DownloadWorkerPoolSize = 5 - type EntityType string const ( diff --git a/internal/pool/pool.go b/internal/pool/pool.go index 27f3327..f36e081 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -4,29 +4,42 @@ package pool import ( "context" "sync" + "sync/atomic" + "time" "audio-scraper/internal/adapters/filesystem" + "audio-scraper/internal/adapters/subsonic" "audio-scraper/internal/adapters/youtube" "audio-scraper/internal/logger" "audio-scraper/internal/models" ) +// scanInterval is how often the pool coalesces completed downloads into a +// single Subsonic rescan, rather than scanning after every track. +const scanInterval = 30 * time.Second + type DownloadWorkerPool struct { jobs chan models.DownloadJob workers int - log logger.Logger - yt youtube.Provider - fs filesystem.Provider + log logger.Logger + yt youtube.Provider + fs filesystem.Provider + subsonic subsonic.Provider + + // dirty is set by workers when a download completes and cleared by the + // scanner when it triggers a rescan, batching bursts into one scan. + dirty atomic.Bool wg sync.WaitGroup stop chan struct{} } type Deps struct { - Log logger.Logger - YT youtube.Provider - FS filesystem.Provider + Log logger.Logger + YT youtube.Provider + FS filesystem.Provider + Subsonic subsonic.Provider } func NewDownloadWorkerPool( @@ -36,10 +49,11 @@ func NewDownloadWorkerPool( p := &DownloadWorkerPool{ jobs: make(chan models.DownloadJob, 1000), workers: workers, - log: deps.Log.With("component", "DownloadWorkerPool"), - yt: deps.YT, - fs: deps.FS, - stop: make(chan struct{}), + log: deps.Log.With("component", "DownloadWorkerPool"), + yt: deps.YT, + fs: deps.FS, + subsonic: deps.Subsonic, + stop: make(chan struct{}), } p.start() @@ -51,6 +65,43 @@ func (p *DownloadWorkerPool) start() { p.wg.Add(1) go p.worker(i) } + p.wg.Add(1) + go p.scanner() +} + +// scanner periodically triggers a Subsonic rescan if any downloads have +// completed since the last scan, coalescing bursts into a single call. It +// flushes one final time on shutdown. +func (p *DownloadWorkerPool) scanner() { + defer p.wg.Done() + log := p.log.With("component", "subsonic-scanner") + ctx := context.Background() + + ticker := time.NewTicker(scanInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + p.flushScan(ctx, log) + case <-p.stop: + p.flushScan(ctx, log) + return + } + } +} + +// flushScan triggers a rescan when the library is dirty and clears the flag. +// On failure it re-marks dirty so the next tick retries. +func (p *DownloadWorkerPool) flushScan(ctx context.Context, log logger.Logger) { + if !p.dirty.Swap(false) { + return + } + log.Info("flushing batched subsonic rescan") + if err := p.subsonic.StartScan(logger.Into(ctx, log)); err != nil { + log.Error("subsonic rescan failed", "error", err) + p.dirty.Store(true) + } } func (p *DownloadWorkerPool) worker(id int) { @@ -98,6 +149,11 @@ func (p *DownloadWorkerPool) worker(id int) { log.Error("failed to tag file", "error", err) continue } + + // Mark the library dirty so the scanner batches a rescan, rather + // than scanning after every single track. + p.dirty.Store(true) + log.Info("download job completed successfully") case <-p.stop: log.Info("received stop signal, worker exiting") diff --git a/nimbus.yaml b/nimbus.yaml deleted file mode 100644 index 6429239..0000000 --- a/nimbus.yaml +++ /dev/null @@ -1,12 +0,0 @@ -app: audio-scraper -services: - - name: api - public: true - template: http - image: docker.prayujt.com/audio-scraper - network: - ports: - - 8080 - env: - - name: API_PORT - value: 8080 diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..54ca0ae --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# +# deploy.sh - build, tag and push the audio-scraper image to the registry. +# +# Tags the image with both the current git short SHA and "latest", then pushes +# both to docker.prayujt.com/audio-scraper. +# +# Usage: +# scripts/deploy.sh # build + push : and :latest +# TAG=v1.2.3 scripts/deploy.sh # also build + push that explicit tag + +set -euo pipefail + +REGISTRY="docker.prayujt.com" +IMAGE="$REGISTRY/audio-scraper" + +cd "$(dirname "$0")/.." + +command -v docker >/dev/null 2>&1 || { + echo "docker not found" >&2 + exit 1 +} + +sha="$(git rev-parse --short HEAD 2>/dev/null || echo "dev")" + +tags=("$sha" "latest") +if [[ -n "${TAG:-}" ]]; then + tags+=("$TAG") +fi + +build_args=() +for t in "${tags[@]}"; do + build_args+=(-t "$IMAGE:$t") +done + +echo "building $IMAGE (${tags[*]}) ..." >&2 +docker build "${build_args[@]}" . + +for t in "${tags[@]}"; do + echo "pushing $IMAGE:$t ..." >&2 + docker push "$IMAGE:$t" +done + +echo "deployed $IMAGE (${tags[*]})" From 9b4f5ad4c709ace66b54506fedc16c8bd6a14155 Mon Sep 17 00:00:00 2001 From: Prayuj Tuli Date: Tue, 2 Jun 2026 01:54:52 -0400 Subject: [PATCH 3/4] fix: install latest yt-dlp and drop forced android player client The Alpine yt-dlp package lagged far enough behind that YouTube extraction broke on the android client (PO token required). Install the latest yt-dlp via pip at build time and let yt-dlp pick its default client. --- Dockerfile | 6 ++++-- internal/adapters/youtube/impl/youtube.go | 3 --- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6b0f7cf..03cf885 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,8 +14,10 @@ WORKDIR /app COPY --from=build /app/bin/audio-scraper /app/audio-scraper -RUN apk update && \ - apk add -U yt-dlp ffmpeg +# Install the latest yt-dlp from pip rather than the (often months-stale) Alpine +# package, since YouTube extraction breaks quickly on old versions. +RUN apk add --no-cache ffmpeg python3 py3-pip && \ + pip install --no-cache-dir --break-system-packages -U yt-dlp EXPOSE 8080 diff --git a/internal/adapters/youtube/impl/youtube.go b/internal/adapters/youtube/impl/youtube.go index 71a2471..2bc3f94 100644 --- a/internal/adapters/youtube/impl/youtube.go +++ b/internal/adapters/youtube/impl/youtube.go @@ -184,9 +184,6 @@ func (y *Client) Download(ctx context.Context, path, videoURL string) (int, erro "-x", "--audio-quality", "0", "--audio-format", "mp3", - // The android player client avoids YouTube's SABR streaming, which - // otherwise 403s without a JS runtime. - "--extractor-args", "youtube:player_client=android", "-o", path, videoURL, ) From d72e3cc6dbfcc94643793c02c21bc5af01820bb8 Mon Sep 17 00:00:00 2001 From: Prayuj Tuli Date: Tue, 2 Jun 2026 02:14:29 -0400 Subject: [PATCH 4/4] feat: manual youtube replacement flow and deno js runtime Adds a replacement flow to re-pick the YouTube source for a song already on the Subsonic library, re-downloading just the audio while preserving existing ID3 tags. Exposes /library/search, /library/candidates, and /replace endpoints plus an `audio-scrape replace` subcommand. Also installs deno in the image since recent yt-dlp needs a JS runtime to decipher many YouTube videos. --- Dockerfile | 5 +- cmd/main.go | 5 + .../adapters/filesystem/impl/filesystem.go | 106 ++++++++++++-- internal/adapters/filesystem/interface.go | 5 + internal/adapters/filesystem/mocks/mock.go | 8 ++ internal/adapters/store/interface.go | 8 ++ internal/adapters/youtube/impl/youtube.go | 62 ++++++--- internal/adapters/youtube/interface.go | 11 ++ internal/adapters/youtube/mocks/mock.go | 12 +- internal/api/api.go | 131 +++++++++++++++++- internal/api/helpers.go | 55 ++++++++ internal/constants/constants.go | 5 + internal/models/models.go | 11 ++ internal/pool/pool.go | 18 +++ scripts/scrape.sh | 77 +++++++++- 15 files changed, 480 insertions(+), 39 deletions(-) diff --git a/Dockerfile b/Dockerfile index 03cf885..c443d81 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,8 +15,9 @@ WORKDIR /app COPY --from=build /app/bin/audio-scraper /app/audio-scraper # Install the latest yt-dlp from pip rather than the (often months-stale) Alpine -# package, since YouTube extraction breaks quickly on old versions. -RUN apk add --no-cache ffmpeg python3 py3-pip && \ +# package, since YouTube extraction breaks quickly on old versions. deno is the +# JS runtime yt-dlp now needs to decipher many YouTube videos. +RUN apk add --no-cache ffmpeg python3 py3-pip deno && \ pip install --no-cache-dir --break-system-packages -U yt-dlp EXPOSE 8080 diff --git a/cmd/main.go b/cmd/main.go index 2e92671..41398be 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -59,6 +59,8 @@ func main() { h := api.NewHandlers(&api.Deps{ Log: log, Metadata: md, + Subsonic: ss, + YouTube: yt, Store: st, Queue: q, }) @@ -66,6 +68,9 @@ func main() { router.HandleFunc("/", h.HealthHandler).Methods("GET") router.HandleFunc("/search", h.Search).Methods("GET") router.HandleFunc("/download", h.Download).Methods("POST") + router.HandleFunc("/library/search", h.LibrarySearch).Methods("GET") + router.HandleFunc("/library/candidates", h.LibraryCandidates).Methods("POST") + router.HandleFunc("/replace", h.Replace).Methods("POST") server := &http.Server{ Handler: router, diff --git a/internal/adapters/filesystem/impl/filesystem.go b/internal/adapters/filesystem/impl/filesystem.go index 3389cee..b07bd2c 100644 --- a/internal/adapters/filesystem/impl/filesystem.go +++ b/internal/adapters/filesystem/impl/filesystem.go @@ -42,24 +42,25 @@ func New(musicHome string, lrc lrclib.Provider) (*Client, error) { }, nil } +// outputPath returns the deterministic on-disk path for a track: +// MUSIC_HOME/Artist/Album/sha256(Track).mp3. +func (f *Client) outputPath(artist, album, track string) string { + hasher := sha256.New() + hasher.Write([]byte(track)) + trackNameHash := hex.EncodeToString(hasher.Sum(nil)) + return filepath.Join(f.musicHome, artist, album, trackNameHash+".mp3") +} + func (f *Client) InitializePath(ctx context.Context, job *models.DownloadJob) (string, error) { log := logger.From(ctx) - path := filepath.Join( - f.musicHome, - job.Artist, - job.Album, - ) - - if err := os.MkdirAll(path, 0755); err != nil { - log.Error("failed to create directories", "path", path, "error", err) + dir := filepath.Join(f.musicHome, job.Artist, job.Album) + + if err := os.MkdirAll(dir, 0755); err != nil { + log.Error("failed to create directories", "path", dir, "error", err) return "", errors.New("failed to create directories") } - hasher := sha256.New() - hasher.Write([]byte(job.Track)) - trackNameHash := hex.EncodeToString(hasher.Sum(nil)) - - outputPath := filepath.Join(path, trackNameHash+".mp3") + outputPath := f.outputPath(job.Artist, job.Album, job.Track) if _, err := os.Stat(outputPath); err == nil { if err := os.Remove(outputPath); err != nil { @@ -71,6 +72,85 @@ func (f *Client) InitializePath(ctx context.Context, job *models.DownloadJob) (s return outputPath, nil } +// ReplaceAudio re-downloads the audio for an existing file while keeping its +// tags. It downloads to a sibling temp file, copies the original's ID3 frames +// onto it, then atomically renames it over the original. The original is only +// touched once the new file is fully prepared. +func (f *Client) ReplaceAudio(ctx context.Context, job *models.DownloadJob, download func(ctx context.Context, dest string) error) error { + log := logger.From(ctx) + path := f.outputPath(job.Artist, job.Album, job.Track) + log = log.With("path", path) + + if _, err := os.Stat(path); err != nil { + log.Error("existing file not found for replacement", "error", err) + return errors.New("existing file not found") + } + + // Capture the existing tags before we touch anything. + frames, err := readFrames(path) + if err != nil { + log.Error("failed to read existing tags", "error", err) + return errors.New("failed to read existing tags") + } + + tmp := filepath.Join(filepath.Dir(path), ".replace-"+filepath.Base(path)) + _ = os.Remove(tmp) // clear any stale temp from a prior failed run + + log.Info("downloading replacement audio") + if err := download(ctx, tmp); err != nil { + log.Error("replacement download failed", "error", err) + os.Remove(tmp) + return errors.New("replacement download failed") + } + + if err := writeFrames(tmp, frames); err != nil { + log.Error("failed to restore tags", "error", err) + os.Remove(tmp) + return errors.New("failed to restore tags") + } + + if err := os.Rename(tmp, path); err != nil { + log.Error("failed to replace original file", "error", err) + os.Remove(tmp) + return errors.New("failed to replace original file") + } + + log.Info("replaced audio, preserved existing tags") + return nil +} + +// readFrames reads all ID3 frames from an mp3 file. +func readFrames(path string) (map[string][]id3v2.Framer, error) { + tag, err := id3v2.Open(path, id3v2.Options{Parse: true}) + if err != nil { + return nil, err + } + defer tag.Close() + + frames := make(map[string][]id3v2.Framer) + for id, fs := range tag.AllFrames() { + frames[id] = append([]id3v2.Framer(nil), fs...) + } + return frames, nil +} + +// writeFrames replaces all ID3 frames on an mp3 file with the given frames. +func writeFrames(path string, frames map[string][]id3v2.Framer) error { + tag, err := id3v2.Open(path, id3v2.Options{Parse: true}) + if err != nil { + return err + } + defer tag.Close() + + tag.DeleteAllFrames() + for id, fs := range frames { + for _, fr := range fs { + tag.AddFrame(id, fr) + } + } + return tag.Save() +} + func (f *Client) TagFile(ctx context.Context, filePath string, job *models.DownloadJob) error { log := logger.From(ctx) tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) diff --git a/internal/adapters/filesystem/interface.go b/internal/adapters/filesystem/interface.go index bf527d3..add48d2 100644 --- a/internal/adapters/filesystem/interface.go +++ b/internal/adapters/filesystem/interface.go @@ -16,4 +16,9 @@ type Provider interface { InitializePath(ctx context.Context, job *models.DownloadJob) (string, error) // TagFile writes ID3 metadata, cover art and lyrics to the file at filePath. TagFile(ctx context.Context, filePath string, job *models.DownloadJob) error + // ReplaceAudio swaps the audio of the existing file for job (located by + // Artist/Album/Track) with freshly downloaded audio, preserving the file's + // existing tags. download must write an mp3 to the dest path it is given. + // The original file is left intact if anything fails. + ReplaceAudio(ctx context.Context, job *models.DownloadJob, download func(ctx context.Context, dest string) error) error } diff --git a/internal/adapters/filesystem/mocks/mock.go b/internal/adapters/filesystem/mocks/mock.go index d281153..d918603 100644 --- a/internal/adapters/filesystem/mocks/mock.go +++ b/internal/adapters/filesystem/mocks/mock.go @@ -12,10 +12,18 @@ import ( type Mock struct { InitializePathFunc func(ctx context.Context, job *models.DownloadJob) (string, error) TagFileFunc func(ctx context.Context, filePath string, job *models.DownloadJob) error + ReplaceAudioFunc func(ctx context.Context, job *models.DownloadJob, download func(ctx context.Context, dest string) error) error } var _ filesystem.Provider = (*Mock)(nil) +func (m *Mock) ReplaceAudio(ctx context.Context, job *models.DownloadJob, download func(ctx context.Context, dest string) error) error { + if m.ReplaceAudioFunc != nil { + return m.ReplaceAudioFunc(ctx, job, download) + } + return nil +} + func (m *Mock) InitializePath(ctx context.Context, job *models.DownloadJob) (string, error) { if m.InitializePathFunc != nil { return m.InitializePathFunc(ctx, job) diff --git a/internal/adapters/store/interface.go b/internal/adapters/store/interface.go index 7ae0f98..4a3de0a 100644 --- a/internal/adapters/store/interface.go +++ b/internal/adapters/store/interface.go @@ -10,6 +10,14 @@ type Choice struct { Type constants.EntityType `json:"type"` ID string `json:"id"` Label string `json:"label"` + + // The following are used by the replacement flow to carry song identity and + // the chosen YouTube candidate URL between steps. + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + Track string `json:"track,omitempty"` + Duration int `json:"duration,omitempty"` + URL string `json:"url,omitempty"` } type Choices []Choice diff --git a/internal/adapters/youtube/impl/youtube.go b/internal/adapters/youtube/impl/youtube.go index 2bc3f94..d9bb734 100644 --- a/internal/adapters/youtube/impl/youtube.go +++ b/internal/adapters/youtube/impl/youtube.go @@ -10,6 +10,7 @@ import ( "math" "os" "os/exec" + "sort" "strings" "github.com/faiface/beep/mp3" @@ -45,6 +46,22 @@ type ytSearchResponse struct { } func (y *Client) Search(ctx context.Context, track, artist string, duration int) (string, error) { + candidates, err := y.Candidates(ctx, track, artist, duration) + if err != nil { + return "", err + } + if len(candidates) == 0 { + return "", errors.New("yt search returned no usable results") + } + best := candidates[0] + logger.From(ctx).Info("yt search selected", "url", best.URL, "title", best.Title) + return best.URL, nil +} + +// Candidates runs a ytsearch and returns the entries ranked by score, best +// first. It mirrors the ranking used by Search but exposes the full list for +// manual selection. +func (y *Client) Candidates(ctx context.Context, track, artist string, duration int) ([]youtube.Candidate, error) { log := logger.From(ctx) query := strings.TrimSpace(track + " " + artist) log.Info("performing yt search", "query", query, "duration", duration) @@ -60,37 +77,44 @@ func (y *Client) Search(ctx context.Context, track, artist string, duration int) out, err := cmd.Output() if err != nil { log.Error("yt search command failed", "error", err) - return "", errors.New("yt search failed") + return nil, errors.New("yt search failed") } var res ytSearchResponse if err := json.Unmarshal(out, &res); err != nil { log.Error("failed to parse yt search output", "error", err) - return "", errors.New("yt search failed") - } - if len(res.Entries) == 0 { - return "", errors.New("yt search returned no results") + return nil, errors.New("yt search failed") } - best := -1 - bestScore := math.Inf(-1) - for i, e := range res.Entries { + type scored struct { + entry ytEntry + score float64 + } + var ranked []scored + for _, e := range res.Entries { if e.ID == "" { continue } - if s := score(track, artist, duration, e); s > bestScore { - bestScore = s - best = i - } - } - if best == -1 { - return "", errors.New("yt search returned no usable results") + ranked = append(ranked, scored{entry: e, score: score(track, artist, duration, e)}) } + sort.SliceStable(ranked, func(i, j int) bool { + return ranked[i].score > ranked[j].score + }) - selected := res.Entries[best] - url := "https://www.youtube.com/watch?v=" + selected.ID - log.Info("yt search selected", "url", url, "title", selected.Title, "score", bestScore) - return url, nil + candidates := make([]youtube.Candidate, 0, len(ranked)) + for _, r := range ranked { + uploader := r.entry.Uploader + if uploader == "" { + uploader = r.entry.Channel + } + candidates = append(candidates, youtube.Candidate{ + URL: "https://www.youtube.com/watch?v=" + r.entry.ID, + Title: r.entry.Title, + Uploader: uploader, + Duration: int(r.entry.Duration), + }) + } + return candidates, nil } // score rates how well a search result matches the desired track. Higher is diff --git a/internal/adapters/youtube/interface.go b/internal/adapters/youtube/interface.go index 44150ce..9dfa6bb 100644 --- a/internal/adapters/youtube/interface.go +++ b/internal/adapters/youtube/interface.go @@ -10,7 +10,18 @@ type Provider interface { // expected track length in seconds (0 if unknown) and is used to rank // candidates; pass it to disambiguate between versions. Search(ctx context.Context, track, artist string, duration int) (string, error) + // Candidates returns the ranked list of video candidates for the given + // track (best first), for manual selection. duration is used for ranking. + Candidates(ctx context.Context, track, artist string, duration int) ([]Candidate, error) // Download fetches the audio at videoURL to path and returns its duration // in seconds (-1 if the duration could not be determined). Download(ctx context.Context, path, videoURL string) (int, error) } + +// Candidate is a single YouTube search result offered for manual selection. +type Candidate struct { + URL string + Title string + Uploader string + Duration int +} diff --git a/internal/adapters/youtube/mocks/mock.go b/internal/adapters/youtube/mocks/mock.go index e959a44..4a5fdc0 100644 --- a/internal/adapters/youtube/mocks/mock.go +++ b/internal/adapters/youtube/mocks/mock.go @@ -9,8 +9,9 @@ import ( // Mock implements youtube.Provider; set the *Func fields to control behavior. type Mock struct { - SearchFunc func(ctx context.Context, track, artist string, duration int) (string, error) - DownloadFunc func(ctx context.Context, path, videoURL string) (int, error) + SearchFunc func(ctx context.Context, track, artist string, duration int) (string, error) + CandidatesFunc func(ctx context.Context, track, artist string, duration int) ([]youtube.Candidate, error) + DownloadFunc func(ctx context.Context, path, videoURL string) (int, error) } var _ youtube.Provider = (*Mock)(nil) @@ -22,6 +23,13 @@ func (m *Mock) Search(ctx context.Context, track, artist string, duration int) ( return "", nil } +func (m *Mock) Candidates(ctx context.Context, track, artist string, duration int) ([]youtube.Candidate, error) { + if m.CandidatesFunc != nil { + return m.CandidatesFunc(ctx, track, artist, duration) + } + return nil, nil +} + func (m *Mock) Download(ctx context.Context, path, videoURL string) (int, error) { if m.DownloadFunc != nil { return m.DownloadFunc(ctx, path, videoURL) diff --git a/internal/api/api.go b/internal/api/api.go index 48523e3..eab926a 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -10,6 +10,8 @@ import ( "audio-scraper/internal/adapters/itunes" "audio-scraper/internal/adapters/store" + "audio-scraper/internal/adapters/subsonic" + "audio-scraper/internal/adapters/youtube" "audio-scraper/internal/constants" "audio-scraper/internal/logger" "audio-scraper/internal/models" @@ -19,6 +21,8 @@ import ( type Deps struct { Log logger.Logger Metadata itunes.Provider + Subsonic subsonic.Provider + YouTube youtube.Provider Store store.Provider Queue *pool.DownloadWorkerPool } @@ -26,12 +30,21 @@ type Deps struct { type Handlers struct { log logger.Logger metadata itunes.Provider + subsonic subsonic.Provider + youtube youtube.Provider store store.Provider queue *pool.DownloadWorkerPool } func NewHandlers(deps *Deps) *Handlers { - return &Handlers{log: deps.Log, metadata: deps.Metadata, store: deps.Store, queue: deps.Queue} + return &Handlers{ + log: deps.Log, + metadata: deps.Metadata, + subsonic: deps.Subsonic, + youtube: deps.YouTube, + store: deps.Store, + queue: deps.Queue, + } } func (h *Handlers) HealthHandler(w http.ResponseWriter, r *http.Request) { @@ -85,6 +98,122 @@ func (h *Handlers) Search(w http.ResponseWriter, r *http.Request) { }) } +// LibrarySearch performs a partial search against the Subsonic library and +// returns the matching songs as selectable choices (replacement flow, step 1). +func (h *Handlers) LibrarySearch(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + requestID := uuid.New().String() + log := h.log.With("handler", "LibrarySearch", "request_id", requestID) + + query := strings.TrimSpace(r.URL.Query().Get("q")) + if query == "" { + log.Warn("search query parameter 'q' is missing") + http.Error(w, "missing query parameter 'q'", http.StatusBadRequest) + return + } + + res, err := h.subsonic.Search(logger.Into(ctx, log.With("query", query)), query) + if err != nil { + log.Error("subsonic search failed", "error", err) + http.Error(w, "subsonic search failed", http.StatusInternalServerError) + return + } + + choices := songsToChoices(res.Songs) + h.store.Set(requestID, choices) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.SearchResponse{ + RequestID: requestID, + Choices: choiceLabels(choices), + }) +} + +// LibraryCandidates returns the ranked YouTube candidates for a previously +// selected library song (replacement flow, step 2). +func (h *Handlers) LibraryCandidates(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := h.log.With("handler", "LibraryCandidates") + + var req models.ChoiceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Warn("invalid candidates request", "error", err) + http.Error(w, "Invalid request: "+err.Error(), http.StatusBadRequest) + return + } + log = log.With("request_id", req.RequestID) + + data, found := h.store.Get(req.RequestID) + if !found { + http.Error(w, "Request ID not found", http.StatusBadRequest) + return + } + song := data.FindByLabel(req.Choice) + if song == nil { + http.Error(w, "Choice not found: "+req.Choice, http.StatusBadRequest) + return + } + + cands, err := h.youtube.Candidates(logger.Into(ctx, log), song.Track, song.Artist, song.Duration) + if err != nil { + log.Error("youtube candidate search failed", "error", err) + http.Error(w, "youtube candidate search failed", http.StatusInternalServerError) + return + } + + choices := candidatesToChoices(cands, song) + h.store.Set(req.RequestID, choices) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.SearchResponse{ + RequestID: req.RequestID, + Choices: choiceLabels(choices), + }) +} + +// Replace enqueues a replacement job for a chosen YouTube candidate +// (replacement flow, step 3). +func (h *Handlers) Replace(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := h.log.With("handler", "Replace") + + var req models.ChoiceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Warn("invalid replace request", "error", err) + http.Error(w, "Invalid request: "+err.Error(), http.StatusBadRequest) + return + } + log = log.With("request_id", req.RequestID) + + data, found := h.store.Get(req.RequestID) + if !found { + http.Error(w, "Request ID not found", http.StatusBadRequest) + return + } + c := data.FindByLabel(req.Choice) + if c == nil { + http.Error(w, "Choice not found: "+req.Choice, http.StatusBadRequest) + return + } + + job := models.DownloadJob{ + RequestID: req.RequestID, + Track: c.Track, + Album: c.Album, + Artist: c.Artist, + Duration: c.Duration, + YouTubeURL: c.URL, + } + if err := h.queue.Enqueue(ctx, job); err != nil { + log.Error("failed to enqueue replacement", "error", err) + http.Error(w, "failed to enqueue replacement", http.StatusInternalServerError) + return + } + + log.Info("replacement queued", "track", c.Track, "url", c.URL) + w.WriteHeader(http.StatusAccepted) +} + func (h *Handlers) Download(w http.ResponseWriter, r *http.Request) { log := h.log.With("handler", "Download") diff --git a/internal/api/helpers.go b/internal/api/helpers.go index 4fabb8d..8209e09 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -6,12 +6,67 @@ import ( "audio-scraper/internal/adapters/itunes" "audio-scraper/internal/adapters/store" + "audio-scraper/internal/adapters/subsonic" + "audio-scraper/internal/adapters/youtube" "audio-scraper/internal/constants" "audio-scraper/internal/logger" "audio-scraper/internal/models" "audio-scraper/internal/pool" ) +// choiceLabels extracts the display labels from a set of choices. +func choiceLabels(choices []store.Choice) []string { + var labels []string + for _, c := range choices { + labels = append(labels, c.Label) + } + return labels +} + +// songsToChoices turns Subsonic library songs into selectable choices, carrying +// the song identity needed by later replacement steps. +func songsToChoices(songs []subsonic.Song) []store.Choice { + var choices []store.Choice + for _, s := range songs { + choices = append(choices, store.Choice{ + Type: constants.EntityTypeSong, + ID: s.ID, + Label: fmt.Sprintf("%s - %s [%s]", s.Title, s.Artist, s.Album), + Artist: s.Artist, + Album: s.Album, + Track: s.Title, + Duration: s.Duration, + }) + } + return choices +} + +// candidatesToChoices turns YouTube candidates into selectable choices, +// denormalizing the song identity onto each so /replace is self-contained. +func candidatesToChoices(cands []youtube.Candidate, song *store.Choice) []store.Choice { + var choices []store.Choice + for _, c := range cands { + choices = append(choices, store.Choice{ + Type: constants.EntityTypeCandidate, + Label: fmt.Sprintf("%s — %s (%s)", c.Title, c.Uploader, fmtDuration(c.Duration)), + URL: c.URL, + Artist: song.Artist, + Album: song.Album, + Track: song.Track, + Duration: song.Duration, + }) + } + return choices +} + +// fmtDuration formats seconds as m:ss, or "?" when unknown. +func fmtDuration(sec int) string { + if sec <= 0 { + return "?" + } + return fmt.Sprintf("%d:%02d", sec/60, sec%60) +} + func processSearchData(result models.SearchResult, log logger.Logger) []store.Choice { trackCount := 10 albumCount := 5 diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 4ab4586..d95be13 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -7,4 +7,9 @@ const ( EntityTypeTrack EntityType = "track" EntityTypeAlbum EntityType = "album" EntityTypeArtist EntityType = "artist" + + // EntityTypeSong is a song already in the Subsonic library (replacement flow). + EntityTypeSong EntityType = "song" + // EntityTypeCandidate is a YouTube candidate for a replacement. + EntityTypeCandidate EntityType = "candidate" ) diff --git a/internal/models/models.go b/internal/models/models.go index dfce3d5..eb79b74 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -11,6 +11,13 @@ type DownloadRequest struct { Choices []string `json:"choices"` } +// ChoiceRequest is a single-selection request referencing a previously returned +// choice label (used by the replacement flow's candidate/replace steps). +type ChoiceRequest struct { + RequestID string `json:"request_id"` + Choice string `json:"choice"` +} + type DownloadJob struct { RequestID string TrackID string @@ -21,6 +28,10 @@ type DownloadJob struct { TrackNumber int Duration int ThumbnailURL string + // YouTubeURL, when set, marks this as a replacement job: the audio at this + // URL replaces the existing file for Track/Album/Artist while preserving + // the file's existing tags. + YouTubeURL string } // Track is a provider-neutral representation of a single song. It carries the diff --git a/internal/pool/pool.go b/internal/pool/pool.go index f36e081..525169e 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -119,6 +119,24 @@ func (p *DownloadWorkerPool) worker(id int) { log := log.With("request_id", job.RequestID, "track_id", job.TrackID) + // Replacement job: swap audio for an existing file, preserving tags. + if job.YouTubeURL != "" { + rlog := log.With("youtube_url", job.YouTubeURL, "track", job.Track) + rlog.Info("processing replacement job") + j := job + err := p.fs.ReplaceAudio(logger.Into(ctx, rlog), &j, func(ctx context.Context, dest string) error { + _, derr := p.yt.Download(ctx, dest, j.YouTubeURL) + return derr + }) + if err != nil { + rlog.Error("replacement failed", "error", err) + continue + } + p.dirty.Store(true) + rlog.Info("replacement job completed successfully") + continue + } + log.Info("processing download job") videoURL, err := p.yt.Search(logger.Into(ctx, log), job.Track, job.Artist, job.Duration) diff --git a/scripts/scrape.sh b/scripts/scrape.sh index 49492f3..e293040 100755 --- a/scripts/scrape.sh +++ b/scripts/scrape.sh @@ -9,9 +9,12 @@ # export AUDIO_SCRAPER_HOST="http://localhost:8080" # # Usage: -# scrape.sh [query...] # query as args, or prompted if omitted +# scrape.sh [query...] # search metadata -> pick -> download +# scrape.sh replace [query...] # search your library -> pick song -> +# # pick a YouTube source -> replace its audio # -# Flow: search -> pick results in fzf (TAB = multi-select) -> queue download. +# Download flow: search -> pick results in fzf (TAB = multi-select) -> queue. +# Replace flow: library search -> pick one song -> pick one YouTube candidate. set -euo pipefail @@ -24,6 +27,76 @@ for tool in curl jq fzf; do } done +# Replacement subcommand: re-pick the YouTube source for an existing song. +if [[ "${1:-}" == "replace" ]]; then + shift + query="$*" + if [[ -z "$query" ]]; then + read -rp "library search> " query + fi + [[ -n "$query" ]] || { + echo "no query given" >&2 + exit 1 + } + + echo "searching library for '$query' on $HOST ..." >&2 + resp="$(curl -sS --get "$HOST/library/search" --data-urlencode "q=$query")" || { + echo "library search request failed (is the server up? AUDIO_SCRAPER_HOST=$HOST)" >&2 + exit 1 + } + request_id="$(jq -r '.request_id // empty' <<<"$resp")" + if [[ -z "$request_id" ]]; then + echo "unexpected response from server:" >&2 + echo "$resp" >&2 + exit 1 + fi + mapfile -t songs < <(jq -r '.choices[]?' <<<"$resp") + if ((${#songs[@]} == 0)); then + echo "no library matches for '$query'" >&2 + exit 1 + fi + + song="$(printf '%s\n' "${songs[@]}" | + fzf --reverse --height=80% --prompt="song> " \ + --header="pick the song to replace (ENTER), ESC to cancel")" || true + [[ -n "$song" ]] || { + echo "nothing selected" >&2 + exit 0 + } + + echo "fetching YouTube candidates ..." >&2 + cand_payload="$(jq -n --arg rid "$request_id" --arg ch "$song" '{request_id: $rid, choice: $ch}')" + cresp="$(curl -sS -X POST "$HOST/library/candidates" \ + -H 'Content-Type: application/json' -d "$cand_payload")" || { + echo "candidate request failed" >&2 + exit 1 + } + mapfile -t candidates < <(jq -r '.choices[]?' <<<"$cresp") + if ((${#candidates[@]} == 0)); then + echo "no YouTube candidates found" >&2 + exit 1 + fi + + candidate="$(printf '%s\n' "${candidates[@]}" | + fzf --reverse --height=80% --prompt="youtube> " \ + --header="pick the correct source (ENTER), ESC to cancel")" || true + [[ -n "$candidate" ]] || { + echo "nothing selected" >&2 + exit 0 + } + + rep_payload="$(jq -n --arg rid "$request_id" --arg ch "$candidate" '{request_id: $rid, choice: $ch}')" + code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$HOST/replace" \ + -H 'Content-Type: application/json' -d "$rep_payload")" + if [[ "$code" == "202" ]]; then + echo "queued replacement (HTTP $code)" + else + echo "replace request failed (HTTP $code)" >&2 + exit 1 + fi + exit 0 +fi + query="$*" if [[ -z "$query" ]]; then read -rp "search> " query