commit 9a79c97a4c72f90e70a3708cd4aded0c6a8bcbd1 Author: Gabe Farrell Date: Sat Nov 15 00:24:21 2025 -0500 first diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..9fd1b29 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,28 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + +jobs: + docker: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Log in to Docker registry + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build Docker image + run: | + docker build -t gabehf/music-importer:latest . + + - name: Push Docker image + run: | + docker push gabehf/music-importer:latest \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3cd13d8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# Stage 1: Build Go binary using lightweight Alpine +FROM golang:1.24-alpine AS builder + +# Install git for module fetching +RUN apk add --no-cache git + +# Set workdir +WORKDIR /app + +# Copy go.mod/go.sum and download dependencies +COPY go.mod ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build Go binary +RUN CGO_ENABLED=0 GOOS=linux go build -o importer . + +# Stage 2: Runtime on Ubuntu 24.04 +FROM ubuntu:24.04 + +# Avoid interactive prompts during apt installs +ENV DEBIAN_FRONTEND=noninteractive + +# Install runtime dependencies: python3-pip, ffmpeg, git, curl, rsgain +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + python3-pip \ + ffmpeg \ + git \ + curl \ + rsgain \ + && rm -rf /var/lib/apt/lists/* + +# Install beets via pip +RUN pip3 install --break-system-packages --no-cache-dir beets + + +# Set up import/library directories (can be mounted) +# ENV IMPORT_DIR=/import +# ENV LIBRARY_DIR=/library +# RUN mkdir -p $IMPORT_DIR $LIBRARY_DIR + +# Copy Go binary from builder stage +COPY --from=builder /app/importer /usr/local/bin/importer + +# Entrypoint +ENTRYPOINT ["importer"] diff --git a/README b/README new file mode 100644 index 0000000..0034fdb --- /dev/null +++ b/README @@ -0,0 +1,30 @@ +# music-importer + +this thing is like 95% AI code. use at your own risk + +i didn't feel like spending the time to do it all right and i figured its simple enough that chatgpt couldn't possible screw it up *that* bad + +## Usage + +docker compose + +```yaml +services: + music-importer: + image: music-importer:latest + container_name: music-importer + ports: + - "8080:8080" + volumes: + - /my/import/dir:/import + - /my/library/dir:/library + environment: + IMPORT_DIR: /import + LIBRARY_DIR: /library + +``` + +## Quirks + +- only works for .flac, .mp3, and .m4a +- not configurable at all, other than dirs \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..841b967 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/gabehf/music-import + +go 1.24.2 diff --git a/main.go b/main.go new file mode 100644 index 0000000..6d4b5a0 --- /dev/null +++ b/main.go @@ -0,0 +1,327 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "text/template" +) + +type MusicMetadata struct { + Artist string + Album string + Title string +} + +// Run a shell command and return combined stdout/stderr. +func runCmd(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// Use beets to fetch metadata and tag the file. +// The -A flag is "autotag" with no import", -W is "write tags". +func tagWithBeets(path string) error { + fmt.Println("→ Tagging with beets:", path) + return runCmd("beet", "import", "-Cq", path) +} + +// Fallback: query MusicBrainz API manually if beets fails. +// (very basic lookup using "track by name" search) +func fetchMusicBrainzInfo(filename string) (*MusicMetadata, error) { + fmt.Println("→ Fallback: querying MusicBrainz:", filename) + + query := fmt.Sprintf("recording:%q", strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))) + url := "https://musicbrainz.org/ws/2/recording/?query=" + query + "&fmt=json" + + resp, err := exec.Command("curl", "-s", url).Output() + if err != nil { + return nil, err + } + + var data struct { + Recordings []struct { + Title string `json:"title"` + Releases []struct { + Title string `json:"title"` + ArtistCredit []struct { + Name string `json:"name"` + } `json:"artist-credit"` + } `json:"releases"` + } `json:"recordings"` + } + + if err := json.Unmarshal(resp, &data); err != nil { + return nil, err + } + + if len(data.Recordings) == 0 || len(data.Recordings[0].Releases) == 0 { + return nil, errors.New("no MusicBrainz match") + } + + r := data.Recordings[0] + rel := r.Releases[0] + + artist := rel.ArtistCredit[0].Name + album := rel.Title + title := r.Title + + return &MusicMetadata{Artist: artist, Album: album, Title: title}, nil +} + +// Apply ReplayGain using rsgain in "easy" mode. +func applyReplayGain(path string) error { + fmt.Println("→ Applying ReplayGain:", path) + return runCmd("rsgain", "easy", path) +} + +// Move file to {LIBRARY_DIR}/{artist}/{album}/filename +func moveToLibrary(libDir string, md *MusicMetadata, srcPath string) error { + targetDir := filepath.Join(libDir, sanitize(md.Artist), sanitize(md.Album)) + if err := os.MkdirAll(targetDir, 0755); err != nil { + return err + } + + dst := filepath.Join(targetDir, filepath.Base(srcPath)) + fmt.Println("→ Moving:", srcPath, "→", dst) + return os.Rename(srcPath, dst) +} + +// Remove filesystem-unsafe chars +func sanitize(s string) string { + r := strings.NewReplacer("/", "_", "\\", "_", ":", "-", "?", "", "*", "", "\"", "", "<", "", ">", "", "|", "") + return r.Replace(s) +} + +// Read embedded tags using ffprobe (works for most formats). +func readTags(path string) (*MusicMetadata, error) { + out, err := exec.Command( + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_format", path, + ).Output() + if err != nil { + return nil, err + } + + var data struct { + Format struct { + Tags map[string]string `json:"tags"` + } `json:"format"` + } + + json.Unmarshal(out, &data) + + t := data.Format.Tags + if t == nil { + return &MusicMetadata{}, nil + } + + return &MusicMetadata{ + Artist: firstNonEmpty(t["artist"], t["ARTIST"]), + Album: firstNonEmpty(t["album"], t["ALBUM"]), + Title: firstNonEmpty(t["title"], t["TITLE"]), + }, nil +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +func RunImporter() { + importDir := os.Getenv("IMPORT_DIR") + libraryDir := os.Getenv("LIBRARY_DIR") + + if importDir == "" || libraryDir == "" { + log.Println("IMPORT_DIR and LIBRARY_DIR must be set") + return + } + + fmt.Println("=== Starting Import ===") + + entries, err := os.ReadDir(importDir) + if err != nil { + log.Println("Failed to read import dir:", err) + return + } + + for _, e := range entries { + if !e.IsDir() { + continue // skip files + } + + albumPath := filepath.Join(importDir, e.Name()) + + // Check if the folder contains audio files + tracks, err := getAudioFiles(albumPath) + if err != nil { + fmt.Println("Skipping (error scanning):", albumPath, err) + continue + } + if len(tracks) == 0 { + continue // no valid audio files → not an album folder + } + + fmt.Println("\n===== Album:", e.Name(), "=====") + + // Get metadata for this album (using first track) + md, err := getAlbumMetadata(albumPath, tracks[0]) + if err != nil { + fmt.Println("Metadata failed, skipping album:", err) + continue + } + + // Apply album-wide ReplayGain + fmt.Println("→ Applying ReplayGain to album:", albumPath) + if err := applyReplayGain(albumPath); err != nil { + fmt.Println("ReplayGain failed, skipping album:", err) + continue + } + + // Move files to library + for _, track := range tracks { + if err := moveToLibrary(libraryDir, md, track); err != nil { + fmt.Println("Failed to move track:", track, err) + } + } + + // Remove empty album directory after moving files + os.Remove(albumPath) + } + + fmt.Println("\n=== Import Complete ===") +} + +func getAudioFiles(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var tracks []string + for _, e := range entries { + if e.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(e.Name())) + if ext == ".flac" || ext == ".mp3" || ext == ".m4a" { + tracks = append(tracks, filepath.Join(dir, e.Name())) + } + } + + return tracks, nil +} + +func getAlbumMetadata(albumPath, trackPath string) (*MusicMetadata, error) { + fmt.Println("→ Tagging track with beets:", trackPath) + + if err := tagWithBeets(albumPath); err != nil { + fmt.Println("Beets tagging failed; fallback to manual MusicBrainz lookup:", err) + } + + md, err := readTags(trackPath) + if err == nil && md.Artist != "" && md.Album != "" { + return md, nil + } + + fmt.Println("→ Missing tags, attempting MusicBrainz manual lookup...") + + md, err = fetchMusicBrainzInfo(trackPath) + if err != nil { + return nil, fmt.Errorf("metadata lookup failed: %w", err) + } + + return md, nil +} + +// --- WEB SERVER --- // +var importerMu sync.Mutex +var importerRunning bool +var tmpl = template.Must(template.New("index").Parse(` + + + + Music Importer + + + +

Music Importer

+
+ +
+ + +`)) + +func handleHome(w http.ResponseWriter, r *http.Request) { + importerMu.Lock() + running := importerRunning + importerMu.Unlock() + + tmpl.Execute(w, struct{ Running bool }{Running: running}) +} + +func handleRun(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + + importerMu.Lock() + running := importerRunning + importerMu.Unlock() + + if running { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + // Run importer in a background goroutine + go RunImporter() + + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func main() { + http.HandleFunc("/", handleHome) + http.HandleFunc("/run", handleRun) + + fmt.Println("Web server listening on http://localhost:8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +}