diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..a64173b
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+.git
+.gitignore
+data
+downloads
+*.pre-*
+*.backup-*
+*.log
diff --git a/Dockerfile b/Dockerfile
index 0e57152..c7f2d25 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,8 +2,8 @@ FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
-COPY main.go index.html ./
-RUN CGO_ENABLED=0 go build -o /out/am-downloader ./main.go
+COPY . .
+RUN CGO_ENABLED=0 go build -o /out/am-downloader .
FROM alpine:3.22
WORKDIR /app
COPY --from=build /out/am-downloader /app/am-downloader
diff --git a/format.go b/format.go
new file mode 100644
index 0000000..a74b263
--- /dev/null
+++ b/format.go
@@ -0,0 +1,67 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func normalizedFormat(format string) string {
+ f := strings.ToLower(strings.TrimPrefix(format, "."))
+ switch f {
+ case "m4a", "mp4", "mmp4":
+ return "m4a"
+ case "mflac", "flac":
+ return "flac"
+ case "mp3":
+ return "mp3"
+ case "ogg", "opus":
+ return "ogg"
+ case "wav":
+ return "wav"
+ case "aac":
+ return "aac"
+ default:
+ return f
+ }
+}
+
+func actualQuality(requested, resolved, format string) string {
+ f := normalizedFormat(format)
+ if f == "flac" {
+ if resolved != "" {
+ return resolved
+ }
+ if requested == "lossless" || requested == "hires" {
+ return requested
+ }
+ }
+ if f == "mp3" {
+ if resolved == "standard" || resolved == "exhigh" || resolved == "sq" {
+ return resolved
+ }
+ }
+ return ""
+}
+
+func renameToActualFormat(local string, format string) (string, error) {
+ ext := normalizedFormat(format)
+ if ext == "" {
+ return local, nil
+ }
+ target := strings.TrimSuffix(local, filepath.Ext(local)) + "." + ext
+ if target == local {
+ return local, nil
+ }
+ if _, err := os.Stat(target); err == nil {
+ if err := os.Remove(local); err != nil {
+ return "", err
+ }
+ return target, nil
+ }
+ if err := os.Rename(local, target); err != nil {
+ return "", fmt.Errorf("rename actual format: %w", err)
+ }
+ return target, nil
+}
diff --git a/index.html b/index.html
index 6c3e205..2d293f6 100644
--- a/index.html
+++ b/index.html
@@ -1,13 +1,39 @@
-
AM DownloaderAM Downloader
独立下载队列 · WebDAV NAS 同步
-
-
-
-
\ No newline at end of file
+
+
+
+AM Downloader
+
+
+
+
AM Downloader
搜索 · 下载 · WebDAV 同步
+
+找到你想听的音乐
搜索歌曲、歌手或专辑,加入下载队列即可。
+
+下载队列
任务会自动处理,完成后可在本地下载目录找到文件。
+音乐 API
用于搜索和解析音乐资源,与 Downloader Web 服务分离。
当前服务架构:Downloader Web 使用 9540,音乐 API 使用 9530。
下载
设置文件保存位置与默认音质。
+
+
diff --git a/main.go b/main.go
index 99137da..237411a 100644
--- a/main.go
+++ b/main.go
@@ -23,13 +23,15 @@ import (
type Task struct {
ID uint `gorm:"primaryKey" json:"id"`
- Source string `json:"source"`
- RID string `json:"rid"`
+ Source string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"source"`
+ RID string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"rid"`
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
Pic string `json:"pic"`
- Quality string `json:"quality"`
+ Quality string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"quality"`
+ ResolvedQuality string `json:"resolvedQuality,omitempty"`
+ ActualQuality string `json:"actualQuality,omitempty"`
Format string `json:"format"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
@@ -70,6 +72,7 @@ type App struct {
type playInfo struct {
URL string `json:"url"`
+ Level string `json:"level"`
Format string `json:"format"`
Headers map[string]string `json:"headers"`
}
@@ -146,19 +149,31 @@ func (a *App) addTask(c *gin.Context) {
c.JSON(400, gin.H{"error": "source/rid/title required"})
return
}
+ if in.Source == "" {
+ in.Source = "kw"
+ }
if in.Quality == "" {
in.Quality = a.cfg.DefaultQuality
}
var existing Task
- err := a.db.Where("source = ? AND rid = ? AND quality = ?", in.Source, in.RID, in.Quality).Order("id desc").First(&existing).Error
+ err := a.db.Where("source = ? AND r_id = ? AND quality = ?", in.Source, in.RID, in.Quality).Order("id desc").First(&existing).Error
if err == nil {
if existing.Status != "error" {
c.JSON(200, existing)
return
}
+ a.db.Model(&existing).Updates(map[string]any{"title": in.Title, "artist": in.Artist, "album": in.Album, "pic": in.Pic, "status": "pending", "error": ""})
+ a.queue <- existing.ID
+ c.JSON(202, existing)
+ return
}
t := Task{Source: in.Source, RID: in.RID, Title: in.Title, Artist: in.Artist, Album: in.Album, Pic: in.Pic, Quality: in.Quality, Status: "pending"}
if err := a.db.Create(&t).Error; err != nil {
+ // A concurrent request may have inserted the same task; return that task.
+ if a.db.Where("source = ? AND r_id = ? AND quality = ?", in.Source, in.RID, in.Quality).Order("id desc").First(&existing).Error == nil {
+ c.JSON(200, existing)
+ return
+ }
c.JSON(500, gin.H{"error": err.Error()})
return
}
@@ -201,7 +216,9 @@ func (a *App) retryTask(c *gin.Context) {
c.Status(404)
return
}
- a.db.Model(&t).Updates(map[string]any{"status": "pending", "error": ""})
+ a.db.Model(&t).Updates(map[string]any{"status": "pending", "error": "", "progress": 0, "downloaded_bytes": 0})
+ t.Status = "pending"
+ t.Error = ""
a.queue <- t.ID
c.JSON(202, t)
}
@@ -216,24 +233,60 @@ func (a *App) download(id uint) {
if a.db.First(&t, id).Error != nil {
return
}
+ // A task may be queued twice; only pending tasks are eligible to start.
+ if t.Status != "pending" {
+ return
+ }
a.db.Model(&t).Updates(map[string]any{"status": "resolving", "error": "", "progress": 0})
info, err := a.resolve(t)
if err != nil {
a.fail(&t, err)
return
}
- ext := info.Format
+ priorLocal := t.LocalPath
+ ext := normalizedFormat(info.Format)
+ if ext == "" {
+ ext = normalizedFormat(filepath.Ext(info.URL))
+ }
if ext == "" {
ext = "mp3"
}
- filename := safe(t.Artist) + " - " + safe(t.Title) + "." + safe(ext)
+ filename := safe(t.Artist) + " - " + safe(t.Title) + "." + ext
local := filepath.Join(a.cfg.DownloadDir, filename)
t.Format = ext
+ t.ResolvedQuality = info.Level
+ t.ActualQuality = ""
t.LocalPath = local
- if _, err = os.Stat(local); err == nil {
+ if priorLocal != "" {
+ if fi, statErr := os.Stat(priorLocal); statErr == nil && fi.Size() > 0 {
+ local = priorLocal
+ t.LocalPath = local
+ }
+ }
+ if fi, statErr := os.Stat(local); statErr == nil && fi.Size() > 0 {
+ actual, detectErr := detectAudioFormat(local)
+ if detectErr == nil {
+ actual = normalizedFormat(actual)
+ if actual != t.Format {
+ newLocal, renameErr := renameToActualFormat(local, actual)
+ if renameErr != nil {
+ a.fail(&t, renameErr)
+ return
+ }
+ local = newLocal
+ t.LocalPath = local
+ }
+ t.Format = actual
+ t.ActualQuality = actualQuality(t.Quality, t.ResolvedQuality, actual)
+ }
+ t.TotalBytes = fi.Size()
+ t.DownloadedBytes = fi.Size()
+ t.Progress = 100
+ a.db.Save(&t)
a.upload(&t)
return
}
+ a.db.Model(&t).Updates(map[string]any{"status": "downloading", "format": ext, "local_path": local})
req, err := http.NewRequest("GET", info.URL, nil)
if err != nil {
a.fail(&t, err)
@@ -301,10 +354,26 @@ func (a *App) download(id uint) {
a.fail(&t, cerr)
return
}
- if err = os.Rename(tmp, local); err != nil {
- a.fail(&t, err)
+ if t.TotalBytes > 0 && n != t.TotalBytes {
+ a.fail(&t, fmt.Errorf("incomplete download: got %d of %d bytes", n, t.TotalBytes))
return
}
+ actual, detectErr := detectAudioFormat(tmp)
+ if detectErr != nil {
+ a.fail(&t, fmt.Errorf("detect audio format: %w", detectErr))
+ _ = os.Remove(tmp)
+ return
+ }
+ actual = normalizedFormat(actual)
+ local = strings.TrimSuffix(local, filepath.Ext(local)) + "." + actual
+ if err = os.Rename(tmp, local); err != nil {
+ a.fail(&t, err)
+ _ = os.Remove(tmp)
+ return
+ }
+ t.Format = actual
+ t.LocalPath = local
+ t.ActualQuality = actualQuality(t.Quality, t.ResolvedQuality, actual)
t.DownloadedBytes = n
if t.TotalBytes > 0 {
t.Progress = 100
@@ -377,7 +446,14 @@ func (a *App) upload(t *Task) {
t.Progress = 100
a.db.Save(t)
}
-func (a *App) fail(t *Task, err error) { t.Status = "error"; t.Error = err.Error(); a.db.Save(t) }
+func (a *App) fail(t *Task, err error) {
+ if t.LocalPath != "" {
+ _ = os.Remove(t.LocalPath + ".part")
+ }
+ t.Status = "error"
+ t.Error = err.Error()
+ a.db.Save(t)
+}
func (a *App) getSettings(c *gin.Context) {
c.JSON(200, gin.H{"amApi": a.cfg.AMAPI, "downloadDir": a.cfg.DownloadDir, "webdavUrl": a.cfg.WebDAVURL, "webdavUser": a.cfg.WebDAVUser, "webdavPass": a.cfg.WebDAVPass, "webdavPath": a.cfg.WebDAVPath, "quality": a.cfg.DefaultQuality})
diff --git a/sniff.go b/sniff.go
new file mode 100644
index 0000000..ac66379
--- /dev/null
+++ b/sniff.go
@@ -0,0 +1,58 @@
+package main
+
+import (
+ "encoding/binary"
+ "errors"
+ "io"
+ "os"
+)
+
+func detectAudioFormat(path string) (string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ b := make([]byte, 64*1024)
+ n, err := f.Read(b)
+ if err != nil && err != io.EOF {
+ return "", err
+ }
+ b = b[:n]
+ if len(b) >= 4 && string(b[:4]) == "fLaC" {
+ return "flac", nil
+ }
+ if len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WAVE" {
+ return "wav", nil
+ }
+ if len(b) >= 12 && string(b[:4]) == "FORM" && (string(b[8:12]) == "AIFF" || string(b[8:12]) == "AIFC") {
+ return "aiff", nil
+ }
+ if len(b) >= 4 && string(b[:4]) == "OggS" {
+ return "ogg", nil
+ }
+ if len(b) >= 12 && string(b[4:8]) == "ftyp" {
+ return "m4a", nil
+ }
+ start := 0
+ if len(b) >= 10 && string(b[:3]) == "ID3" {
+ size := int(b[6]&0x7f)<<21 | int(b[7]&0x7f)<<14 | int(b[8]&0x7f)<<7 | int(b[9]&0x7f)
+ start = 10 + size
+ if start > len(b) {
+ start = 10
+ }
+ }
+ for i := start; i+4 <= len(b) && i < start+4096; i++ {
+ if b[i] == 0xff && b[i+1]&0xe0 == 0xe0 {
+ h := binary.BigEndian.Uint32(b[i : i+4])
+ layer := (h >> 17) & 3
+ if layer == 1 {
+ return "mp3", nil
+ }
+ if b[i+1]&0x06 == 0x06 {
+ return "aac", nil
+ }
+ }
+ }
+ return "", errors.New("unknown audio format")
+}