mirror of
https://github.com/gabehf/music-importer.git
synced 2026-04-22 11:31:52 -07:00
bleh
This commit is contained in:
parent
a8a5459b06
commit
853f08221f
9 changed files with 440 additions and 388 deletions
11
.github/workflows/docker.yml
vendored
11
.github/workflows/docker.yml
vendored
|
|
@ -2,8 +2,8 @@ name: Build and Push Docker Image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
tags:
|
||||||
- main
|
- 'v*'
|
||||||
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
|
@ -23,8 +23,13 @@ jobs:
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: |
|
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
|
- name: Push Docker image
|
||||||
run: |
|
run: |
|
||||||
|
docker push gabehf/music-importer:${{ github.ref_name }}
|
||||||
docker push gabehf/music-importer:latest
|
docker push gabehf/music-importer:latest
|
||||||
|
|
@ -14,8 +14,9 @@ RUN go mod download
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build Go binary
|
# Accept version from build arg and bake it into the binary
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o importer .
|
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
|
# Stage 2: Runtime on Ubuntu 24.04
|
||||||
FROM ubuntu:24.04
|
FROM ubuntu:24.04
|
||||||
|
|
|
||||||
40
audio.go
Normal file
40
audio.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
14
cmd.go
Normal file
14
cmd.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
103
files.go
Normal file
103
files.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
119
importer.go
Normal file
119
importer.go
Normal file
|
|
@ -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 ===")
|
||||||
|
}
|
||||||
393
main.go
393
main.go
|
|
@ -1,389 +1,18 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"github.com/gabehf/music-import/media"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type MusicMetadata struct {
|
// version is set at build time via -ldflags="-X main.version=..."
|
||||||
Artist string
|
var version = "dev"
|
||||||
Album string
|
|
||||||
Title string
|
|
||||||
Year 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"`
|
|
||||||
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 importerMu sync.Mutex
|
||||||
var importerRunning bool
|
var importerRunning bool
|
||||||
|
|
||||||
var tmpl = template.Must(template.New("index").Parse(`
|
var tmpl = template.Must(template.New("index").Parse(`
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -410,6 +39,13 @@ var tmpl = template.Must(template.New("index").Parse(`
|
||||||
background: #555;
|
background: #555;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -419,6 +55,7 @@ var tmpl = template.Must(template.New("index").Parse(`
|
||||||
{{if .Running}}Importer Running...{{else}}Run Importer{{end}}
|
{{if .Running}}Importer Running...{{else}}Run Importer{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<footer>{{.Version}}</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`))
|
`))
|
||||||
|
|
@ -428,7 +65,10 @@ func handleHome(w http.ResponseWriter, r *http.Request) {
|
||||||
running := importerRunning
|
running := importerRunning
|
||||||
importerMu.Unlock()
|
importerMu.Unlock()
|
||||||
|
|
||||||
tmpl.Execute(w, struct{ Running bool }{Running: running})
|
tmpl.Execute(w, struct {
|
||||||
|
Running bool
|
||||||
|
Version string
|
||||||
|
}{Running: running, Version: version})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleRun(w http.ResponseWriter, r *http.Request) {
|
func handleRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -446,16 +86,15 @@ func handleRun(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run importer in a background goroutine
|
|
||||||
go RunImporter()
|
go RunImporter()
|
||||||
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
log.Printf("Music Importer %s starting on http://localhost:8080", version)
|
||||||
http.HandleFunc("/", handleHome)
|
http.HandleFunc("/", handleHome)
|
||||||
http.HandleFunc("/run", handleRun)
|
http.HandleFunc("/run", handleRun)
|
||||||
|
|
||||||
fmt.Println("Web server listening on http://localhost:8080")
|
|
||||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package media
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
id3v2 "github.com/bogem/id3v2" // optional alternative
|
id3v2 "github.com/bogem/id3v2" // optional alternative
|
||||||
|
|
@ -14,6 +15,7 @@ import (
|
||||||
var coverNames = []string{
|
var coverNames = []string{
|
||||||
"cover.jpg", "cover.jpeg", "cover.png",
|
"cover.jpg", "cover.jpeg", "cover.png",
|
||||||
"folder.jpg", "folder.jpeg", "folder.png",
|
"folder.jpg", "folder.jpeg", "folder.png",
|
||||||
|
"album.jpg", "album.jpeg", "album.png",
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedAlbumArtIntoFolder scans one album folder and embeds cover art.
|
// EmbedAlbumArtIntoFolder scans one album folder and embeds cover art.
|
||||||
|
|
@ -61,10 +63,8 @@ func FindCoverImage(dir string) (string, error) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
l := strings.ToLower(e.Name())
|
l := strings.ToLower(e.Name())
|
||||||
for _, name := range coverNames {
|
if slices.Contains(coverNames, l) {
|
||||||
if l == name {
|
return filepath.Join(dir, e.Name()), nil
|
||||||
return filepath.Join(dir, e.Name()), nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("no cover image found in %s", dir)
|
return "", fmt.Errorf("no cover image found in %s", dir)
|
||||||
131
metadata.go
Normal file
131
metadata.go
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MusicMetadata struct {
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
Title string
|
||||||
|
Year string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read embedded tags from an audio file using ffprobe.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use beets to fetch metadata and tag all files in a directory.
|
||||||
|
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.
|
||||||
|
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]
|
||||||
|
|
||||||
|
return &MusicMetadata{
|
||||||
|
Artist: rel.ArtistCredit[0].Name,
|
||||||
|
Album: rel.Title,
|
||||||
|
Title: r.Title,
|
||||||
|
Year: strings.Split(r.FirstReleaseDate, "-")[0],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAlbumMetadata attempts beets tagging on the album directory, reads tags
|
||||||
|
// back from the first track, and falls back to MusicBrainz if tags are missing.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(vals ...string) string {
|
||||||
|
for _, v := range vals {
|
||||||
|
if v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue