refactor: modularize downloader architecture
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AMAPIClient struct {
|
||||
BaseURL string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func (c *AMAPIClient) Search(keyword, source string, page, limit int) (*http.Response, error) {
|
||||
u, err := url.Parse(strings.TrimRight(c.BaseURL, "/") + "/search")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("keyword", keyword)
|
||||
q.Set("source", source)
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
u.RawQuery = q.Encode()
|
||||
return c.HTTP.Get(u.String())
|
||||
}
|
||||
|
||||
func (c *AMAPIClient) Resolve(source, rid, quality string) (playInfo, error) {
|
||||
u, err := url.Parse(strings.TrimRight(c.BaseURL, "/") + "/song/play-url")
|
||||
if err != nil {
|
||||
return playInfo{}, err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("source", source)
|
||||
q.Set("rid", rid)
|
||||
q.Set("level", quality)
|
||||
u.RawQuery = q.Encode()
|
||||
resp, err := c.HTTP.Get(u.String())
|
||||
if err != nil {
|
||||
return playInfo{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var x struct {
|
||||
Code int `json:"code"`
|
||||
Data playInfo `json:"data"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&x); err != nil {
|
||||
return playInfo{}, err
|
||||
}
|
||||
if x.Code != 0 || x.Data.URL == "" {
|
||||
if x.Message == "" {
|
||||
x.Message = "music API returned no playable URL"
|
||||
}
|
||||
return playInfo{}, errors.New(x.Message)
|
||||
}
|
||||
return x.Data, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
db *gorm.DB
|
||||
cfg Config
|
||||
queue chan uint
|
||||
wg sync.WaitGroup
|
||||
httpClient *http.Client
|
||||
am AMClient
|
||||
storage RemoteStorage
|
||||
}
|
||||
|
||||
// AMClient isolates the music API from the task engine.
|
||||
type AMClient interface {
|
||||
Search(keyword, source string, page, limit int) (*http.Response, error)
|
||||
Resolve(source, rid, quality string) (playInfo, error)
|
||||
}
|
||||
|
||||
// RemoteStorage isolates WebDAV and leaves room for S3/local backends later.
|
||||
type RemoteStorage interface {
|
||||
Upload(localPath, remotePath string) error
|
||||
ExistsSameSize(remotePath string, size int64) (bool, error)
|
||||
Remove(remotePath string) error
|
||||
MkdirAll(remoteDir string) error
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (a *App) loadSettings() {
|
||||
var ss []Setting
|
||||
a.db.Find(&ss)
|
||||
for _, s := range ss {
|
||||
switch s.Key {
|
||||
case "am_api":
|
||||
a.cfg.AMAPI = s.Value
|
||||
case "download_dir":
|
||||
a.cfg.DownloadDir = s.Value
|
||||
case "webdav_url":
|
||||
a.cfg.WebDAVURL = s.Value
|
||||
case "webdav_user":
|
||||
a.cfg.WebDAVUser = s.Value
|
||||
case "webdav_pass":
|
||||
a.cfg.WebDAVPass = s.Value
|
||||
case "webdav_path":
|
||||
a.cfg.WebDAVPath = s.Value
|
||||
case "quality":
|
||||
a.cfg.DefaultQuality = s.Value
|
||||
case "webdav_version_policy":
|
||||
a.cfg.WebDAVVersionPolicy = s.Value
|
||||
case "local_file_policy":
|
||||
a.cfg.LocalFilePolicy = s.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) saveSetting(k, v string) {
|
||||
var s Setting
|
||||
if a.db.Where("key = ?", k).First(&s).Error != nil {
|
||||
a.db.Create(&Setting{Key: k, Value: v})
|
||||
} else {
|
||||
s.Value = v
|
||||
a.db.Save(&s)
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig(db *gorm.DB) Config {
|
||||
cfg := Config{
|
||||
AMAPI: getenv("AM_API_URL", "http://127.0.0.1:9530/api"),
|
||||
DownloadDir: getenv("DOWNLOAD_DIR", "/downloads"),
|
||||
DefaultQuality: getenv("DEFAULT_QUALITY", "standard"),
|
||||
WorkerCount: atoi(getenv("WORKERS", "2"), 2),
|
||||
}
|
||||
_ = db
|
||||
return cfg
|
||||
}
|
||||
|
||||
func normalizeConfig(cfg *Config) {
|
||||
if cfg.WorkerCount < 1 {
|
||||
cfg.WorkerCount = 1
|
||||
}
|
||||
if cfg.WebDAVVersionPolicy != "keep" && cfg.WebDAVVersionPolicy != "replace_lower" {
|
||||
cfg.WebDAVVersionPolicy = "keep"
|
||||
}
|
||||
if cfg.LocalFilePolicy != "keep" && cfg.LocalFilePolicy != "delete_after_upload" {
|
||||
cfg.LocalFilePolicy = "keep"
|
||||
}
|
||||
cfg.WebDAVPath = strings.Trim(cfg.WebDAVPath, "/")
|
||||
_ = os.MkdirAll(cfg.DownloadDir, 0755)
|
||||
}
|
||||
|
||||
func getenv(k, d string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func atoi(s string, d int) int {
|
||||
v, e := strconv.Atoi(s)
|
||||
if e != nil || v < 1 {
|
||||
return d
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func dbDir(path string) string { return filepath.Dir(path) }
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (a *App) startWorkers() {
|
||||
for i := 0; i < a.cfg.WorkerCount; i++ {
|
||||
a.wg.Add(1)
|
||||
go func() {
|
||||
defer a.wg.Done()
|
||||
for id := range a.queue {
|
||||
a.download(id)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) download(id uint) {
|
||||
var t Task
|
||||
if a.db.First(&t, id).Error != nil || t.Status != "pending" {
|
||||
return
|
||||
}
|
||||
a.db.Model(&t).Updates(map[string]any{"status": "resolving", "error": "", "progress": 0})
|
||||
info, err := a.am.Resolve(t.Source, t.RID, t.Quality)
|
||||
if err != nil {
|
||||
a.fail(&t, err)
|
||||
return
|
||||
}
|
||||
|
||||
ext := normalizedFormat(info.Format)
|
||||
if ext == "" {
|
||||
ext = normalizedFormat(filepath.Ext(info.URL))
|
||||
}
|
||||
if ext == "" {
|
||||
ext = "mp3"
|
||||
}
|
||||
filename := safe(t.Artist) + " - " + safe(t.Title) + "." + ext
|
||||
local := filepath.Join(a.cfg.DownloadDir, filename)
|
||||
t.Format, t.ResolvedQuality, t.ActualQuality, t.LocalPath = ext, info.Level, "", local
|
||||
|
||||
// Reuse only the path recorded on this task. Never borrow another task's quality.
|
||||
if t.LocalPath != "" {
|
||||
if fi, statErr := os.Stat(t.LocalPath); statErr == nil && fi.Size() > 0 {
|
||||
a.finishExisting(&t, fi.Size())
|
||||
return
|
||||
}
|
||||
}
|
||||
a.downloadFile(&t, info)
|
||||
}
|
||||
|
||||
func (a *App) finishExisting(t *Task, size int64) {
|
||||
actual, err := detectAudioFormat(t.LocalPath)
|
||||
if err == nil {
|
||||
actual = normalizedFormat(actual)
|
||||
if actual != t.Format {
|
||||
if renamed, renameErr := renameToActualFormat(t.LocalPath, actual); renameErr == nil {
|
||||
t.LocalPath = renamed
|
||||
} else {
|
||||
a.fail(t, renameErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Format = actual
|
||||
t.ActualQuality = actualQuality(t.Quality, t.ResolvedQuality, actual)
|
||||
}
|
||||
t.TotalBytes, t.DownloadedBytes, t.Progress, t.Status = size, size, 100, "done"
|
||||
a.db.Save(t)
|
||||
a.upload(t)
|
||||
}
|
||||
|
||||
func (a *App) downloadFile(t *Task, info playInfo) {
|
||||
a.db.Model(t).Updates(map[string]any{"status": "downloading", "format": t.Format, "local_path": t.LocalPath})
|
||||
req, err := http.NewRequest("GET", info.URL, nil)
|
||||
if err != nil {
|
||||
a.fail(t, err)
|
||||
return
|
||||
}
|
||||
for k, v := range info.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
a.fail(t, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
a.fail(t, fmt.Errorf("media http %d", resp.StatusCode))
|
||||
return
|
||||
}
|
||||
t.Status, t.TotalBytes, t.DownloadedBytes, t.Progress = "downloading", resp.ContentLength, 0, 0
|
||||
a.db.Save(t)
|
||||
|
||||
tmp := t.LocalPath + ".part"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
a.fail(t, err)
|
||||
return
|
||||
}
|
||||
n, err := a.copyWithProgress(f, resp.Body, t)
|
||||
closeErr := f.Close()
|
||||
if err != nil {
|
||||
a.fail(t, err)
|
||||
return
|
||||
}
|
||||
if closeErr != nil {
|
||||
a.fail(t, closeErr)
|
||||
return
|
||||
}
|
||||
if t.TotalBytes > 0 && n != t.TotalBytes {
|
||||
a.fail(t, fmt.Errorf("incomplete download: got %d of %d bytes", n, t.TotalBytes))
|
||||
return
|
||||
}
|
||||
|
||||
actual, err := detectAudioFormat(tmp)
|
||||
if err != nil {
|
||||
a.fail(t, fmt.Errorf("detect audio format: %w", err))
|
||||
_ = os.Remove(tmp)
|
||||
return
|
||||
}
|
||||
actual = normalizedFormat(actual)
|
||||
finalPath := strings.TrimSuffix(t.LocalPath, filepath.Ext(t.LocalPath)) + "." + actual
|
||||
if err = os.Rename(tmp, finalPath); err != nil {
|
||||
a.fail(t, err)
|
||||
_ = os.Remove(tmp)
|
||||
return
|
||||
}
|
||||
t.Format, t.LocalPath, t.ActualQuality, t.DownloadedBytes = actual, finalPath, actualQuality(t.Quality, t.ResolvedQuality, actual), n
|
||||
if t.TotalBytes > 0 {
|
||||
t.Progress = 100
|
||||
}
|
||||
a.db.Save(t)
|
||||
a.upload(t)
|
||||
}
|
||||
|
||||
func (a *App) copyWithProgress(dst *os.File, src io.Reader, t *Task) (int64, error) {
|
||||
var n, last int64
|
||||
buf := make([]byte, 256*1024)
|
||||
for {
|
||||
nr, er := src.Read(buf)
|
||||
if nr > 0 {
|
||||
wn, ew := dst.Write(buf[:nr])
|
||||
n += int64(wn)
|
||||
if ew != nil {
|
||||
return n, ew
|
||||
}
|
||||
if n-last >= 1024*1024 {
|
||||
progress := 0.0
|
||||
if t.TotalBytes > 0 {
|
||||
progress = float64(n) * 100 / float64(t.TotalBytes)
|
||||
}
|
||||
t.DownloadedBytes, t.Progress = n, progress
|
||||
a.db.Model(t).Updates(map[string]any{"downloaded_bytes": n, "progress": progress})
|
||||
last = n
|
||||
}
|
||||
}
|
||||
if er == io.EOF {
|
||||
return n, nil
|
||||
}
|
||||
if er != nil {
|
||||
return n, er
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (a *App) search(c *gin.Context) {
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
if keyword == "" {
|
||||
c.JSON(400, gin.H{"error": "keyword required"})
|
||||
return
|
||||
}
|
||||
source := c.DefaultQuery("source", "kw")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
if limit < 1 {
|
||||
limit = 20
|
||||
}
|
||||
resp, err := a.am.Search(keyword, source, page, limit)
|
||||
if err != nil {
|
||||
c.JSON(502, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
c.Status(resp.StatusCode)
|
||||
_, _ = io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
|
||||
func (a *App) listTasks(c *gin.Context) {
|
||||
var ts []Task
|
||||
if err := a.db.Order("id desc").Limit(100).Find(&ts).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"data": ts})
|
||||
}
|
||||
|
||||
func (a *App) addTask(c *gin.Context) {
|
||||
var in SongRequest
|
||||
if c.BindJSON(&in) != nil || in.RID == "" || in.Title == "" {
|
||||
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 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 {
|
||||
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
|
||||
}
|
||||
a.queue <- t.ID
|
||||
c.JSON(202, t)
|
||||
}
|
||||
|
||||
func (a *App) retryTask(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
var t Task
|
||||
if a.db.First(&t, id).Error != nil {
|
||||
c.Status(404)
|
||||
return
|
||||
}
|
||||
a.db.Model(&t).Updates(map[string]any{"status": "pending", "error": "", "progress": 0, "downloaded_bytes": 0})
|
||||
t.Status, t.Error, t.Progress, t.DownloadedBytes = "pending", "", 0, 0
|
||||
a.queue <- t.ID
|
||||
c.JSON(202, t)
|
||||
}
|
||||
|
||||
func (a *App) deleteTask(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
if err := a.db.Delete(&Task{}, id).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(204)
|
||||
}
|
||||
@@ -1,98 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/studio-b12/gowebdav"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Task struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
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 `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"`
|
||||
LocalPath string `json:"localPath,omitempty"`
|
||||
RemotePath string `json:"remotePath,omitempty"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
DownloadedBytes int64 `json:"downloadedBytes"`
|
||||
Progress float64 `json:"progress"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Setting struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Key string `gorm:"uniqueIndex"`
|
||||
Value string
|
||||
}
|
||||
type SongRequest struct {
|
||||
Source string `json:"source"`
|
||||
RID string `json:"rid"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
Pic string `json:"pic"`
|
||||
Quality string `json:"quality"`
|
||||
}
|
||||
type Config struct {
|
||||
AMAPI, DownloadDir, WebDAVURL, WebDAVUser, WebDAVPass, WebDAVPath, DefaultQuality, WebDAVVersionPolicy, LocalFilePolicy string
|
||||
WorkerCount int
|
||||
}
|
||||
type App struct {
|
||||
db *gorm.DB
|
||||
cfg Config
|
||||
queue chan uint
|
||||
wg sync.WaitGroup
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type playInfo struct {
|
||||
URL string `json:"url"`
|
||||
Level string `json:"level"`
|
||||
Format string `json:"format"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
dbPath := getenv("DB_PATH", "/data/downloader.db")
|
||||
_ = os.MkdirAll(filepath.Dir(dbPath), 0755)
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
must(err)
|
||||
must(db.AutoMigrate(&Task{}, &Setting{}))
|
||||
a := &App{db: db, cfg: Config{AMAPI: getenv("AM_API_URL", "http://127.0.0.1:9530/api"), DownloadDir: getenv("DOWNLOAD_DIR", "/downloads"), DefaultQuality: getenv("DEFAULT_QUALITY", "standard"), WorkerCount: atoi(getenv("WORKERS", "2"), 2)}, queue: make(chan uint, 100), httpClient: &http.Client{Timeout: 2 * time.Minute}}
|
||||
_ = os.MkdirAll(a.cfg.DownloadDir, 0755)
|
||||
|
||||
client := &http.Client{Timeout: 2 * 60 * 1e9}
|
||||
cfg := loadConfig(db)
|
||||
a := &App{db: db, cfg: cfg, queue: make(chan uint, 100), httpClient: client}
|
||||
a.loadSettings()
|
||||
if a.cfg.WebDAVVersionPolicy == "" {
|
||||
a.cfg.WebDAVVersionPolicy = "keep"
|
||||
}
|
||||
if a.cfg.LocalFilePolicy == "" {
|
||||
a.cfg.LocalFilePolicy = "keep"
|
||||
normalizeConfig(&a.cfg)
|
||||
a.am = &AMAPIClient{BaseURL: a.cfg.AMAPI, HTTP: client}
|
||||
if a.cfg.WebDAVURL != "" {
|
||||
a.storage = NewWebDAVStorage(a.cfg)
|
||||
}
|
||||
a.startWorkers()
|
||||
|
||||
r := gin.Default()
|
||||
r.StaticFile("/", "/app/index.html")
|
||||
api := r.Group("/api")
|
||||
@@ -107,490 +42,8 @@ func main() {
|
||||
must(r.Run(":" + getenv("PORT", "9540")))
|
||||
}
|
||||
|
||||
func (a *App) loadSettings() {
|
||||
var ss []Setting
|
||||
a.db.Find(&ss)
|
||||
for _, s := range ss {
|
||||
switch s.Key {
|
||||
case "am_api":
|
||||
a.cfg.AMAPI = s.Value
|
||||
case "download_dir":
|
||||
a.cfg.DownloadDir = s.Value
|
||||
case "webdav_url":
|
||||
a.cfg.WebDAVURL = s.Value
|
||||
case "webdav_user":
|
||||
a.cfg.WebDAVUser = s.Value
|
||||
case "webdav_pass":
|
||||
a.cfg.WebDAVPass = s.Value
|
||||
case "webdav_path":
|
||||
a.cfg.WebDAVPath = s.Value
|
||||
case "quality":
|
||||
a.cfg.DefaultQuality = s.Value
|
||||
case "webdav_version_policy":
|
||||
a.cfg.WebDAVVersionPolicy = s.Value
|
||||
case "local_file_policy":
|
||||
a.cfg.LocalFilePolicy = s.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
func (a *App) save(k, v string) {
|
||||
var s Setting
|
||||
if a.db.Where("key = ?", k).First(&s).Error != nil {
|
||||
a.db.Create(&Setting{Key: k, Value: v})
|
||||
} else {
|
||||
s.Value = v
|
||||
a.db.Save(&s)
|
||||
}
|
||||
}
|
||||
func (a *App) startWorkers() {
|
||||
for i := 0; i < a.cfg.WorkerCount; i++ {
|
||||
a.wg.Add(1)
|
||||
go func() {
|
||||
defer a.wg.Done()
|
||||
for id := range a.queue {
|
||||
a.download(id)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
func (a *App) addTask(c *gin.Context) {
|
||||
var in SongRequest
|
||||
if c.BindJSON(&in) != nil || in.RID == "" || in.Title == "" {
|
||||
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 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
|
||||
}
|
||||
a.queue <- t.ID
|
||||
c.JSON(202, t)
|
||||
}
|
||||
func (a *App) search(c *gin.Context) {
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
source := c.DefaultQuery("source", "kw")
|
||||
if keyword == "" {
|
||||
c.JSON(400, gin.H{"error": "keyword required"})
|
||||
return
|
||||
}
|
||||
u, _ := url.Parse(strings.TrimRight(a.cfg.AMAPI, "/") + "/search")
|
||||
q := u.Query()
|
||||
q.Set("keyword", keyword)
|
||||
q.Set("source", source)
|
||||
q.Set("page", c.DefaultQuery("page", "1"))
|
||||
q.Set("limit", c.DefaultQuery("limit", "20"))
|
||||
u.RawQuery = q.Encode()
|
||||
resp, err := a.httpClient.Get(u.String())
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
c.JSON(502, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
c.Status(resp.StatusCode)
|
||||
_, _ = io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
|
||||
func (a *App) listTasks(c *gin.Context) {
|
||||
var ts []Task
|
||||
a.db.Order("id desc").Limit(100).Find(&ts)
|
||||
c.JSON(200, gin.H{"data": ts})
|
||||
}
|
||||
func (a *App) retryTask(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var t Task
|
||||
if a.db.First(&t, id).Error != nil {
|
||||
c.Status(404)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
func (a *App) deleteTask(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
a.db.Delete(&Task{}, id)
|
||||
c.Status(204)
|
||||
}
|
||||
|
||||
func (a *App) download(id uint) {
|
||||
var t Task
|
||||
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
|
||||
}
|
||||
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) + "." + ext
|
||||
local := filepath.Join(a.cfg.DownloadDir, filename)
|
||||
t.Format = ext
|
||||
t.ResolvedQuality = info.Level
|
||||
t.ActualQuality = ""
|
||||
t.LocalPath = local
|
||||
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)
|
||||
return
|
||||
}
|
||||
for k, v := range info.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
a.fail(&t, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
a.fail(&t, fmt.Errorf("media http %d", resp.StatusCode))
|
||||
return
|
||||
}
|
||||
t.Status = "downloading"
|
||||
t.TotalBytes = resp.ContentLength
|
||||
t.DownloadedBytes = 0
|
||||
t.Progress = 0
|
||||
a.db.Save(&t)
|
||||
tmp := local + ".part"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
a.fail(&t, err)
|
||||
return
|
||||
}
|
||||
var n int64
|
||||
buf := make([]byte, 256*1024)
|
||||
lastReport := int64(0)
|
||||
for {
|
||||
nr, er := resp.Body.Read(buf)
|
||||
if nr > 0 {
|
||||
wn, ew := f.Write(buf[:nr])
|
||||
n += int64(wn)
|
||||
if ew != nil {
|
||||
err = ew
|
||||
break
|
||||
}
|
||||
if n-lastReport >= 1024*1024 {
|
||||
progress := 0.0
|
||||
if resp.ContentLength > 0 {
|
||||
progress = float64(n) * 100 / float64(resp.ContentLength)
|
||||
}
|
||||
a.db.Model(&t).Updates(map[string]any{"downloaded_bytes": n, "total_bytes": resp.ContentLength, "progress": progress})
|
||||
lastReport = n
|
||||
}
|
||||
}
|
||||
if er == io.EOF {
|
||||
break
|
||||
}
|
||||
if er != nil {
|
||||
err = er
|
||||
break
|
||||
}
|
||||
}
|
||||
cerr := f.Close()
|
||||
if err != nil {
|
||||
a.fail(&t, err)
|
||||
return
|
||||
}
|
||||
if cerr != nil {
|
||||
a.fail(&t, cerr)
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
a.db.Save(&t)
|
||||
a.upload(&t)
|
||||
}
|
||||
|
||||
func (a *App) resolve(t Task) (playInfo, error) {
|
||||
u, _ := url.Parse(strings.TrimRight(a.cfg.AMAPI, "/") + "/song/play-url")
|
||||
q := u.Query()
|
||||
q.Set("source", t.Source)
|
||||
q.Set("rid", t.RID)
|
||||
q.Set("level", t.Quality)
|
||||
u.RawQuery = q.Encode()
|
||||
resp, err := a.httpClient.Get(u.String())
|
||||
if err != nil {
|
||||
return playInfo{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var x struct {
|
||||
Code int `json:"code"`
|
||||
Data playInfo `json:"data"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err = json.NewDecoder(resp.Body).Decode(&x); err != nil {
|
||||
return playInfo{}, err
|
||||
}
|
||||
if x.Code != 0 || x.Data.URL == "" {
|
||||
return playInfo{}, errors.New(x.Message)
|
||||
}
|
||||
return x.Data, nil
|
||||
}
|
||||
|
||||
func (a *App) upload(t *Task) {
|
||||
if a.cfg.WebDAVURL == "" {
|
||||
t.Status = "done"
|
||||
a.db.Save(t)
|
||||
return
|
||||
}
|
||||
a.db.Model(t).Updates(map[string]any{"status": "uploading"})
|
||||
remote := strings.TrimRight(a.cfg.WebDAVPath, "/") + "/" + safe(t.Artist) + "/" + safe(t.Album) + "/" + safe(t.Title) + "." + safe(t.Format)
|
||||
t.RemotePath = remote
|
||||
remoteDir := filepath.ToSlash(filepath.Dir(remote))
|
||||
client := gowebdav.NewClient(a.cfg.WebDAVURL, a.cfg.WebDAVUser, a.cfg.WebDAVPass)
|
||||
client.SetTimeout(10 * time.Minute)
|
||||
if err := client.MkdirAll(remoteDir, 0755); err != nil {
|
||||
a.fail(t, err)
|
||||
return
|
||||
}
|
||||
if fi, err := os.Stat(t.LocalPath); err == nil {
|
||||
if ri, err := client.Stat(remote); err == nil && ri.Size() == fi.Size() {
|
||||
t.Status = "done"
|
||||
t.Progress = 100
|
||||
a.db.Save(t)
|
||||
a.cleanupLocalFile(t)
|
||||
return
|
||||
}
|
||||
}
|
||||
f, err := os.Open(t.LocalPath)
|
||||
if err != nil {
|
||||
a.fail(t, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
if err = client.WriteStream(remote, f, 0644); err != nil {
|
||||
a.fail(t, err)
|
||||
return
|
||||
}
|
||||
if a.cfg.WebDAVVersionPolicy == "replace_lower" {
|
||||
a.removeLowerQualityVersions(client, remote, t)
|
||||
}
|
||||
t.Status = "done"
|
||||
t.Progress = 100
|
||||
a.db.Save(t)
|
||||
a.cleanupLocalFile(t)
|
||||
}
|
||||
func (a *App) cleanupLocalFile(t *Task) {
|
||||
if a.cfg.LocalFilePolicy != "delete_after_upload" || t.LocalPath == "" {
|
||||
return
|
||||
}
|
||||
if err := os.Remove(t.LocalPath); err != nil && !os.IsNotExist(err) {
|
||||
t.Error = "WebDAV 上传成功,但删除本地文件失败: " + err.Error()
|
||||
a.db.Save(t)
|
||||
}
|
||||
}
|
||||
func (a *App) removeLowerQualityVersions(client *gowebdav.Client, remote string, t *Task) {
|
||||
levels := map[string]int{"standard": 1, "exhigh": 2, "sq": 3, "lossless": 4, "hires": 5, "jyeffect": 6, "sky": 7, "jymaster": 8, "atmos": 9, "atmos_plus": 10, "master": 11}
|
||||
current := levels[t.ActualQuality]
|
||||
if current == 0 {
|
||||
current = levels[t.ResolvedQuality]
|
||||
}
|
||||
if current == 0 {
|
||||
current = levels[t.Quality]
|
||||
}
|
||||
if current == 0 {
|
||||
return
|
||||
}
|
||||
var old []Task
|
||||
a.db.Where("artist = ? AND title = ? AND album = ? AND status = ? AND id <> ?", t.Artist, t.Title, t.Album, "done", t.ID).Find(&old)
|
||||
for _, candidate := range old {
|
||||
q := candidate.ActualQuality
|
||||
if q == "" {
|
||||
q = candidate.ResolvedQuality
|
||||
}
|
||||
if q == "" {
|
||||
q = candidate.Quality
|
||||
}
|
||||
if levels[q] <= 0 || levels[q] >= current || candidate.RemotePath == "" || candidate.RemotePath == remote {
|
||||
continue
|
||||
}
|
||||
if _, err := client.Stat(candidate.RemotePath); err == nil {
|
||||
_ = client.Remove(candidate.RemotePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, "webdavVersionPolicy": a.cfg.WebDAVVersionPolicy, "localFilePolicy": a.cfg.LocalFilePolicy})
|
||||
}
|
||||
func (a *App) putSettings(c *gin.Context) {
|
||||
var v map[string]string
|
||||
if c.BindJSON(&v) != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
if x := v["amApi"]; x != "" {
|
||||
a.cfg.AMAPI = x
|
||||
a.save("am_api", x)
|
||||
}
|
||||
if p := v["webdavVersionPolicy"]; p != "keep" && p != "replace_lower" {
|
||||
v["webdavVersionPolicy"] = "keep"
|
||||
}
|
||||
if p := v["localFilePolicy"]; p != "keep" && p != "delete_after_upload" {
|
||||
v["localFilePolicy"] = "keep"
|
||||
}
|
||||
if x := v["downloadDir"]; x != "" {
|
||||
a.cfg.DownloadDir = x
|
||||
a.save("download_dir", x)
|
||||
os.MkdirAll(x, 0755)
|
||||
}
|
||||
a.cfg.WebDAVURL = v["webdavUrl"]
|
||||
a.cfg.WebDAVUser = v["webdavUser"]
|
||||
a.cfg.WebDAVPass = v["webdavPass"]
|
||||
a.cfg.WebDAVPath = strings.Trim(v["webdavPath"], "/")
|
||||
a.cfg.DefaultQuality = v["quality"]
|
||||
if p := v["webdavVersionPolicy"]; p == "keep" || p == "replace_lower" {
|
||||
a.cfg.WebDAVVersionPolicy = p
|
||||
}
|
||||
if p := v["localFilePolicy"]; p == "keep" || p == "delete_after_upload" {
|
||||
a.cfg.LocalFilePolicy = p
|
||||
}
|
||||
for k, x := range map[string]string{"webdav_url": a.cfg.WebDAVURL, "webdav_user": a.cfg.WebDAVUser, "webdav_pass": a.cfg.WebDAVPass, "webdav_path": a.cfg.WebDAVPath, "quality": a.cfg.DefaultQuality, "webdav_version_policy": a.cfg.WebDAVVersionPolicy, "local_file_policy": a.cfg.LocalFilePolicy} {
|
||||
a.save(k, x)
|
||||
}
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
func (a *App) testWebDAV(c *gin.Context) {
|
||||
var v struct{ URL, User, Pass, Path string }
|
||||
if c.BindJSON(&v) != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
client := gowebdav.NewClient(v.URL, v.User, v.Pass)
|
||||
client.SetTimeout(20 * time.Second)
|
||||
if err := client.Connect(); err != nil {
|
||||
c.JSON(502, gin.H{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"ok": true, "path": v.Path})
|
||||
}
|
||||
|
||||
var bad = regexp.MustCompile(`[/\\:*?"<>|]+`)
|
||||
|
||||
func safe(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = bad.ReplaceAllString(s, "_")
|
||||
s = strings.Trim(s, ". ")
|
||||
if s == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
return s
|
||||
}
|
||||
func getenv(k, d string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
func atoi(s string, d int) int {
|
||||
v, e := strconv.Atoi(s)
|
||||
if e != nil || v < 1 {
|
||||
return d
|
||||
}
|
||||
return v
|
||||
}
|
||||
func must(e error) {
|
||||
if e != nil {
|
||||
panic(e)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
|
||||
type Task struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
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 `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"`
|
||||
LocalPath string `json:"localPath,omitempty"`
|
||||
RemotePath string `json:"remotePath,omitempty"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
DownloadedBytes int64 `json:"downloadedBytes"`
|
||||
Progress float64 `json:"progress"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Setting struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Key string `gorm:"uniqueIndex"`
|
||||
Value string
|
||||
}
|
||||
|
||||
type SongRequest struct {
|
||||
Source string `json:"source"`
|
||||
RID string `json:"rid"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
Pic string `json:"pic"`
|
||||
Quality string `json:"quality"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
AMAPI, DownloadDir, WebDAVURL, WebDAVUser, WebDAVPass, WebDAVPath, DefaultQuality, WebDAVVersionPolicy, LocalFilePolicy string
|
||||
WorkerCount int
|
||||
}
|
||||
|
||||
type playInfo struct {
|
||||
URL string `json:"url"`
|
||||
Level string `json:"level"`
|
||||
Format string `json:"format"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/studio-b12/gowebdav"
|
||||
)
|
||||
|
||||
type WebDAVStorage struct{ client *gowebdav.Client }
|
||||
|
||||
func NewWebDAVStorage(cfg Config) *WebDAVStorage {
|
||||
c := gowebdav.NewClient(cfg.WebDAVURL, cfg.WebDAVUser, cfg.WebDAVPass)
|
||||
c.SetTimeout(10 * time.Minute)
|
||||
return &WebDAVStorage{client: c}
|
||||
}
|
||||
|
||||
func (s *WebDAVStorage) MkdirAll(remoteDir string) error { return s.client.MkdirAll(remoteDir, 0755) }
|
||||
|
||||
func (s *WebDAVStorage) ExistsSameSize(remotePath string, size int64) (bool, error) {
|
||||
fi, err := s.client.Stat(remotePath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return fi.Size() == size, nil
|
||||
}
|
||||
|
||||
func (s *WebDAVStorage) Upload(localPath, remotePath string) error {
|
||||
f, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return s.client.WriteStream(remotePath, f, 0644)
|
||||
}
|
||||
|
||||
func (s *WebDAVStorage) Remove(remotePath string) error { return s.client.Remove(remotePath) }
|
||||
func (s *WebDAVStorage) Stat(remotePath string) error {
|
||||
_, err := s.client.Stat(remotePath)
|
||||
return err
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/studio-b12/gowebdav"
|
||||
)
|
||||
|
||||
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, "webdavVersionPolicy": a.cfg.WebDAVVersionPolicy,
|
||||
"localFilePolicy": a.cfg.LocalFilePolicy,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) putSettings(c *gin.Context) {
|
||||
var v map[string]string
|
||||
if c.BindJSON(&v) != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
if x := v["amApi"]; x != "" {
|
||||
a.cfg.AMAPI = x
|
||||
a.saveSetting("am_api", x)
|
||||
a.am = &AMAPIClient{BaseURL: x, HTTP: a.httpClient}
|
||||
}
|
||||
if x := v["downloadDir"]; x != "" {
|
||||
a.cfg.DownloadDir = x
|
||||
a.saveSetting("download_dir", x)
|
||||
}
|
||||
if p := v["webdavVersionPolicy"]; p == "keep" || p == "replace_lower" {
|
||||
a.cfg.WebDAVVersionPolicy = p
|
||||
}
|
||||
if p := v["localFilePolicy"]; p == "keep" || p == "delete_after_upload" {
|
||||
a.cfg.LocalFilePolicy = p
|
||||
}
|
||||
if x, ok := v["webdavUrl"]; ok {
|
||||
a.cfg.WebDAVURL = x
|
||||
}
|
||||
if x, ok := v["webdavUser"]; ok {
|
||||
a.cfg.WebDAVUser = x
|
||||
}
|
||||
if x, ok := v["webdavPass"]; ok {
|
||||
a.cfg.WebDAVPass = x
|
||||
}
|
||||
if x, ok := v["webdavPath"]; ok {
|
||||
a.cfg.WebDAVPath = strings.Trim(x, "/")
|
||||
}
|
||||
if x, ok := v["quality"]; ok && x != "" {
|
||||
a.cfg.DefaultQuality = x
|
||||
}
|
||||
for k, x := range map[string]string{
|
||||
"webdav_url": a.cfg.WebDAVURL, "webdav_user": a.cfg.WebDAVUser, "webdav_pass": a.cfg.WebDAVPass,
|
||||
"webdav_path": a.cfg.WebDAVPath, "quality": a.cfg.DefaultQuality,
|
||||
"webdav_version_policy": a.cfg.WebDAVVersionPolicy, "local_file_policy": a.cfg.LocalFilePolicy,
|
||||
} {
|
||||
a.saveSetting(k, x)
|
||||
}
|
||||
if a.cfg.WebDAVURL != "" {
|
||||
a.storage = NewWebDAVStorage(a.cfg)
|
||||
} else {
|
||||
a.storage = nil
|
||||
}
|
||||
c.JSON(200, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) testWebDAV(c *gin.Context) {
|
||||
var v struct{ URL, User, Pass, Path string }
|
||||
if c.BindJSON(&v) != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
client := gowebdav.NewClient(v.URL, v.User, v.Pass)
|
||||
client.SetTimeout(20 * time.Second)
|
||||
if err := client.Connect(); err != nil {
|
||||
c.JSON(502, gin.H{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"ok": true, "path": v.Path})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func (a *App) fail(t *Task, err error) {
|
||||
if t.LocalPath != "" {
|
||||
_ = os.Remove(t.LocalPath + ".part")
|
||||
}
|
||||
if err == nil {
|
||||
err = errors.New("unknown download error")
|
||||
}
|
||||
t.Status, t.Error = "error", err.Error()
|
||||
a.db.Save(t)
|
||||
}
|
||||
|
||||
var bad = regexp.MustCompile(`[/\\:*?"<>|]+`)
|
||||
|
||||
func safe(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = bad.ReplaceAllString(s, "_")
|
||||
s = strings.Trim(s, ". ")
|
||||
if s == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func audioPath(dir, artist, title, format string) string {
|
||||
return filepath.Join(dir, safe(artist)+" - "+safe(title)+"."+normalizedFormat(format))
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (a *App) upload(t *Task) {
|
||||
if a.storage == nil || a.cfg.WebDAVURL == "" {
|
||||
t.Status, t.Progress = "done", 100
|
||||
a.db.Save(t)
|
||||
return
|
||||
}
|
||||
a.db.Model(t).Updates(map[string]any{"status": "uploading"})
|
||||
remote := strings.TrimRight(a.cfg.WebDAVPath, "/") + "/" + safe(t.Artist) + "/" + safe(t.Album) + "/" + safe(t.Title) + "." + safe(t.Format)
|
||||
t.RemotePath = remote
|
||||
if err := a.storage.MkdirAll(filepath.ToSlash(filepath.Dir(remote))); err != nil {
|
||||
a.fail(t, err)
|
||||
return
|
||||
}
|
||||
if fi, err := os.Stat(t.LocalPath); err == nil {
|
||||
if same, _ := a.storage.ExistsSameSize(remote, fi.Size()); same {
|
||||
t.Status, t.Progress = "done", 100
|
||||
a.db.Save(t)
|
||||
a.cleanupLocalFile(t)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := a.storage.Upload(t.LocalPath, remote); err != nil {
|
||||
a.fail(t, err)
|
||||
return
|
||||
}
|
||||
if a.cfg.WebDAVVersionPolicy == "replace_lower" {
|
||||
a.cleanupLowerQualityVersions(t)
|
||||
}
|
||||
t.Status, t.Progress = "done", 100
|
||||
a.db.Save(t)
|
||||
a.cleanupLocalFile(t)
|
||||
}
|
||||
|
||||
func (a *App) cleanupLocalFile(t *Task) {
|
||||
if a.cfg.LocalFilePolicy != "delete_after_upload" || t.LocalPath == "" {
|
||||
return
|
||||
}
|
||||
if err := os.Remove(t.LocalPath); err != nil && !os.IsNotExist(err) {
|
||||
t.Error = "WebDAV 上传成功,但删除本地文件失败: " + err.Error()
|
||||
a.db.Save(t)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cleanupLowerQualityVersions(t *Task) {
|
||||
levels := map[string]int{"standard": 1, "exhigh": 2, "sq": 3, "lossless": 4, "hires": 5, "jyeffect": 6, "sky": 7, "jymaster": 8, "atmos": 9, "atmos_plus": 10, "master": 11}
|
||||
current := qualityRank(levels, t.ActualQuality, t.ResolvedQuality, t.Quality)
|
||||
if current == 0 {
|
||||
return
|
||||
}
|
||||
var old []Task
|
||||
a.db.Where("artist = ? AND title = ? AND album = ? AND status = ? AND id <> ?", t.Artist, t.Title, t.Album, "done", t.ID).Find(&old)
|
||||
for _, candidate := range old {
|
||||
q := qualityRank(levels, candidate.ActualQuality, candidate.ResolvedQuality, candidate.Quality)
|
||||
if q == 0 || q >= current || candidate.RemotePath == "" || candidate.RemotePath == t.RemotePath {
|
||||
continue
|
||||
}
|
||||
_ = a.storage.Remove(candidate.RemotePath)
|
||||
}
|
||||
}
|
||||
|
||||
func qualityRank(levels map[string]int, values ...string) int {
|
||||
for _, q := range values {
|
||||
if n := levels[q]; n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user