From 83224fe5b5ecdf6c5817262a02020c353c300112 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Wed, 16 Sep 2026 05:34:47 +0800 Subject: [PATCH] feat: detect actual audio format and resolve quality mismatch --- .dockerignore | 7 ++++ Dockerfile | 4 +-- format.go | 67 +++++++++++++++++++++++++++++++++++ index.html | 52 ++++++++++++++++++++------- main.go | 98 +++++++++++++++++++++++++++++++++++++++++++++------ sniff.go | 58 ++++++++++++++++++++++++++++++ 6 files changed, 260 insertions(+), 26 deletions(-) create mode 100644 .dockerignore create mode 100644 format.go create mode 100644 sniff.go 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 Downloader
AM Downloader
独立下载队列 · WebDAV NAS 同步
-
搜索音乐
- -
下载与 WebDAV
-
下载队列
加载中…
\ No newline at end of file + + + +AM Downloader + + +
+
AM Downloader
搜索 · 下载 · WebDAV 同步
+
+

找到你想听的音乐

搜索歌曲、歌手或专辑,加入下载队列即可。

最近下载
+ +
下载队列
任务会自动处理,完成后可在本地下载目录找到文件。
+

音乐 API

用于搜索和解析音乐资源,与 Downloader Web 服务分离。

当前服务架构:Downloader Web 使用 9540,音乐 API 使用 9530。

下载

设置文件保存位置与默认音质。

WebDAV

可选的 NAS/远程音乐库同步配置。

+
+ 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") +}