Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ 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. 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

Expand Down
24 changes: 23 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
-include .env

.PHONY: all build start
PREFIX ?= $(HOME)/.local
BINDIR := $(PREFIX)/bin

.PHONY: all build start scrape install uninstall deploy

all: build

Expand All @@ -9,3 +12,22 @@ 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)"

# Build, tag and push the image to docker.prayujt.com/audio-scraper.
deploy:
./scripts/deploy.sh
48 changes: 30 additions & 18 deletions cmd/main.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -13,56 +14,67 @@ 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"
)

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,
Metadata: md,
Subsonic: ss,
YouTube: yt,
Store: st,
Queue: q,
})
router := mux.NewRouter()
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,
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,
}
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
106 changes: 93 additions & 13 deletions internal/adapters/filesystem/impl/filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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})
Expand Down
5 changes: 5 additions & 0 deletions internal/adapters/filesystem/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
8 changes: 8 additions & 0 deletions internal/adapters/filesystem/mocks/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions internal/adapters/store/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading