diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 303b17b..59af26e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -2,8 +2,8 @@ name: Build and Push Docker Image on: push: - branches: - - main + tags: + - 'v*' workflow_dispatch: @@ -23,8 +23,13 @@ jobs: - name: Build Docker image run: | - docker build -t gabehf/music-importer:latest . + docker build \ + --build-arg VERSION=${{ github.ref_name }} \ + -t gabehf/music-importer:${{ github.ref_name }} \ + -t gabehf/music-importer:latest \ + . - name: Push Docker image run: | - docker push gabehf/music-importer:latest \ No newline at end of file + docker push gabehf/music-importer:${{ github.ref_name }} + docker push gabehf/music-importer:latest diff --git a/Dockerfile b/Dockerfile index 863cc2e..854d2fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,8 +14,9 @@ RUN go mod download # Copy source code COPY . . -# Build Go binary -RUN CGO_ENABLED=0 GOOS=linux go build -o importer . +# Accept version from build arg and bake it into the binary +ARG VERSION=dev +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-X main.version=${VERSION}" -o importer . # Stage 2: Runtime on Ubuntu 24.04 FROM ubuntu:24.04 diff --git a/audio.go b/audio.go new file mode 100644 index 0000000..5faedf2 --- /dev/null +++ b/audio.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// applyReplayGain runs rsgain in "easy" mode on a directory. +func applyReplayGain(path string) error { + fmt.Println("→ Applying ReplayGain:", path) + return runCmd("rsgain", "easy", path) +} + +// cleanAlbumTags strips COMMENT and DESCRIPTION tags from all files in dir. +func cleanAlbumTags(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, e := range entries { + if e.IsDir() { + continue + } + if err := rmDescAndCommentTags(filepath.Join(dir, e.Name())); err != nil { + fmt.Println("Failed to clean comment and description tags:", err) + } + } + return nil +} + +// rmDescAndCommentTags removes COMMENT and DESCRIPTION tags from a single file. +// Currently only handles FLAC; other formats are silently skipped. +func rmDescAndCommentTags(trackpath string) error { + if strings.HasSuffix(strings.ToLower(trackpath), ".flac") { + return runCmd("metaflac", "--remove-tag=COMMENT", "--remove-tag=DESCRIPTION", trackpath) + } + return nil +} diff --git a/cmd.go b/cmd.go new file mode 100644 index 0000000..bc114b8 --- /dev/null +++ b/cmd.go @@ -0,0 +1,14 @@ +package main + +import ( + "os" + "os/exec" +) + +// runCmd executes a shell command, forwarding stdout and stderr to the process output. +func runCmd(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/files.go b/files.go new file mode 100644 index 0000000..5155997 --- /dev/null +++ b/files.go @@ -0,0 +1,103 @@ +package main + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" +) + +// moveToLibrary moves a file to {libDir}/{artist}/[{year}] {album}/filename. +func moveToLibrary(libDir string, md *MusicMetadata, srcPath string) error { + targetDir := filepath.Join(libDir, sanitize(md.Artist), sanitize(fmt.Sprintf("[%s] %s", md.Year, 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) +} + +// cluster moves all top-level audio files in dir into subdirectories named +// after their embedded album tag. +func cluster(dir string) error { + files, err := getAudioFiles(dir) + if err != nil { + return err + } + + for _, f := range files { + tags, err := readTags(f) + if err != nil { + return err + } + albumDir := path.Join(dir, sanitize(tags.Album)) + if err = os.MkdirAll(albumDir, 0755); err != nil { + return err + } + if err = os.Rename(f, path.Join(albumDir, path.Base(f))); err != nil { + return err + } + } + + return nil +} + +// getAudioFiles returns all .flac and .mp3 files directly inside dir. +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" { + tracks = append(tracks, filepath.Join(dir, e.Name())) + } + } + + return tracks, nil +} + +// getLyricFiles returns all .lrc files directly inside dir. +func getLyricFiles(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var lyrics []string + for _, e := range entries { + if e.IsDir() { + continue + } + if strings.ToLower(filepath.Ext(e.Name())) == ".lrc" { + lyrics = append(lyrics, filepath.Join(dir, e.Name())) + } + } + + return lyrics, nil +} + +// sanitize removes or replaces characters that are unsafe in file system paths. +func sanitize(s string) string { + r := strings.NewReplacer( + "/", "_", + "\\", "_", + ":", "-", + "?", "", + "*", "", + "\"", "", + "<", "", + ">", "", + "|", "", + ) + return r.Replace(s) +} diff --git a/importer.go b/importer.go new file mode 100644 index 0000000..ac449b5 --- /dev/null +++ b/importer.go @@ -0,0 +1,119 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" +) + +func RunImporter() { + importDir := os.Getenv("IMPORT_DIR") + libraryDir := os.Getenv("LIBRARY_DIR") + + if importerRunning { + return + } + + importerMu.Lock() + importerRunning = true + importerMu.Unlock() + defer func() { + importerMu.Lock() + importerRunning = false + importerMu.Unlock() + }() + + if importDir == "" || libraryDir == "" { + log.Println("IMPORT_DIR and LIBRARY_DIR must be set") + return + } + + fmt.Println("=== Starting Import ===") + + if err := cluster(importDir); err != nil { + log.Println("Failed to cluster top-level audio files:", err) + return + } + + 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 + } + + albumPath := filepath.Join(importDir, e.Name()) + + tracks, err := getAudioFiles(albumPath) + if err != nil { + fmt.Println("Skipping (error scanning):", albumPath, err) + continue + } + if len(tracks) == 0 { + continue + } + + fmt.Println("\n===== Album:", e.Name(), "=====") + + fmt.Println("→ Cleaning album tags:") + if err = cleanAlbumTags(albumPath); err != nil { + fmt.Println("Cleaning album tags failed:", err) + } + + fmt.Println("→ Tagging album metadata:") + md, err := getAlbumMetadata(albumPath, tracks[0]) + if err != nil { + fmt.Println("Metadata failed, skipping album:", err) + continue + } + + fmt.Println("→ Fetching synced lyrics from LRCLIB:") + if err := DownloadAlbumLyrics(albumPath); err != nil { + fmt.Println("Failed to download synced lyrics.") + } + + fmt.Println("→ Applying ReplayGain to album:", albumPath) + if err := applyReplayGain(albumPath); err != nil { + fmt.Println("ReplayGain failed, skipping album:", err) + continue + } + + fmt.Println("→ Embedding cover art for album:", albumPath) + if err := EmbedAlbumArtIntoFolder(albumPath); err != nil { + fmt.Println("Cover embed failed, skipping album:", err) + continue + } + + fmt.Println("→ Moving tracks into library for album:", albumPath) + for _, track := range tracks { + if err := moveToLibrary(libraryDir, md, track); err != nil { + fmt.Println("Failed to move track:", track, err) + } + } + + lyrics, _ := getLyricFiles(albumPath) + + fmt.Println("→ Moving lyrics into library for album:", albumPath) + for _, file := range lyrics { + if err := moveToLibrary(libraryDir, md, file); err != nil { + fmt.Println("Failed to move lyrics:", file, err) + } + } + + fmt.Println("→ Moving album cover into library for album:", albumPath) + if coverImg, err := FindCoverImage(albumPath); err == nil { + if err := moveToLibrary(libraryDir, md, coverImg); err != nil { + fmt.Println("Failed to cover image:", coverImg, err) + } + } + + os.Remove(albumPath) + } + + fmt.Println("\n=== Import Complete ===") +} diff --git a/main.go b/main.go index 9b717e1..a8f2cb5 100644 --- a/main.go +++ b/main.go @@ -1,389 +1,18 @@ package main import ( - "encoding/json" - "errors" - "fmt" "log" "net/http" - "os" - "os/exec" - "path" - "path/filepath" - "strings" "sync" "text/template" - - "github.com/gabehf/music-import/media" ) -type MusicMetadata struct { - Artist string - Album string - Title string - Year string -} +// version is set at build time via -ldflags="-X main.version=..." +var version = "dev" -// 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"` - FirstReleaseDate string `json:"first-release-date"` - } `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 - year := strings.Split(r.FirstReleaseDate, "-")[0] - - return &MusicMetadata{Artist: artist, Album: album, Title: title, Year: year}, 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(fmt.Sprintf("[%s] %s", md.Year, 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"]), - Year: firstNonEmpty(t["year"], t["YEAR"], t["ORIGINALYEAR"]), - }, 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 importerRunning { - return - } - - importerMu.Lock() - importerRunning = true - importerMu.Unlock() - defer func() { - importerMu.Lock() - importerRunning = false - importerMu.Unlock() - }() - - if importDir == "" || libraryDir == "" { - log.Println("IMPORT_DIR and LIBRARY_DIR must be set") - return - } - - fmt.Println("=== Starting Import ===") - - if err := cluster(importDir); err != nil { - log.Println("Failed to cluster top-level audio files:", err) - return - } - - 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) - fmt.Println("→ Cleaning album tags:") - err = cleanAlbumTags(albumPath) - if err != nil { - fmt.Println("Cleaning album tags failed:", err) - } - - // Get metadata for this album (using first track) - fmt.Println("→ Tagging album metadata:") - md, err := getAlbumMetadata(albumPath, tracks[0]) - if err != nil { - fmt.Println("Metadata failed, skipping album:", err) - continue - } - - fmt.Println("→ Fetching synced lyrics from LRCLIB:") - if err := DownloadAlbumLyrics(albumPath); err != nil { - fmt.Println("Failed to download synced lyrics.") - } - - // 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 - } - - // embed cover img if available - fmt.Println("→ Embedding cover art for album:", albumPath) - if err := media.EmbedAlbumArtIntoFolder(albumPath); err != nil { - fmt.Println("Cover embed failed, skipping album:", err) - continue - } - - // Move files to library - fmt.Println("→ Moving tracks into library for album:", albumPath) - for _, track := range tracks { - if err := moveToLibrary(libraryDir, md, track); err != nil { - fmt.Println("Failed to move track:", track, err) - } - } - - lyrics, _ := getLyricFiles(albumPath) - - fmt.Println("→ Moving lyrics into library for album:", albumPath) - for _, file := range lyrics { - if err := moveToLibrary(libraryDir, md, file); err != nil { - fmt.Println("Failed to move lyrics:", file, err) - } - } - - // Move album cover image - fmt.Println("→ Moving album cover into library for album:", albumPath) - if coverImg, err := media.FindCoverImage(albumPath); err == nil { - if err := moveToLibrary(libraryDir, md, coverImg); err != nil { - fmt.Println("Failed to cover image:", coverImg, err) - } - } - - // Remove empty album directory after moving files - os.Remove(albumPath) - } - - fmt.Println("\n=== Import Complete ===") -} - -// moves all top-level audio files into folders defined by their album tags -func cluster(dir string) error { - files, err := getAudioFiles(dir) - if err != nil { - return err - } - - for _, f := range files { - tags, err := readTags(f) - if err != nil { - return err - } - err = os.MkdirAll(path.Join(dir, fmt.Sprintf("%s", sanitize(tags.Album))), 0755) - if err != nil { - return err - } - err = os.Rename(f, path.Join(dir, fmt.Sprintf("%s", sanitize(tags.Album)), path.Base(f))) - if err != nil { - return err - } - } - - return nil -} - -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" { - tracks = append(tracks, filepath.Join(dir, e.Name())) - } - } - - return tracks, nil -} - -func getLyricFiles(dir string) ([]string, error) { - entries, err := os.ReadDir(dir) - if err != nil { - return nil, err - } - - var lyrics []string - for _, e := range entries { - if e.IsDir() { - continue - } - ext := strings.ToLower(filepath.Ext(e.Name())) - if ext == ".lrc" { - lyrics = append(lyrics, filepath.Join(dir, e.Name())) - } - } - - return lyrics, nil -} - -func cleanAlbumTags(dir string) error { - entries, err := os.ReadDir(dir) - if err != nil { - return err - } - for _, e := range entries { - if e.IsDir() { - continue - } - if err := rmDescAndCommentTags(filepath.Join(dir, e.Name())); err != nil { - fmt.Println("Failed to clean comment and description tags:", err) - } - } - return nil -} - -func rmDescAndCommentTags(trackpath string) error { - lower := strings.ToLower(trackpath) - switch { - case strings.HasSuffix(lower, ".flac"): - return runCmd("metaflac", "--remove-tag=COMMENT", "--remove-tag=DESCRIPTION", trackpath) - default: - return 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(` @@ -410,6 +39,13 @@ var tmpl = template.Must(template.New("index").Parse(` background: #555; cursor: not-allowed; } + footer { + position: fixed; + bottom: 16px; + width: 100%; + font-size: 13px; + color: #999; + }
@@ -419,6 +55,7 @@ var tmpl = template.Must(template.New("index").Parse(` {{if .Running}}Importer Running...{{else}}Run Importer{{end}} +