diff --git a/admin_users.go b/admin_users.go
new file mode 100644
index 0000000..cedae65
--- /dev/null
+++ b/admin_users.go
@@ -0,0 +1,137 @@
+package main
+
+import (
+ "strconv"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+func defaultPermissions(role string) []string {
+ if role == RolePrivileged {
+ return []string{PermDownload, PermRedeem, PermWebDAV, PermWebDAVConfig}
+ }
+ if role == RoleUser {
+ return []string{PermDownload, PermRedeem}
+ }
+ return nil
+}
+
+func (a *App) adminListUsers(c *gin.Context) {
+ var users []User
+ if err := a.db.Order("id asc").Find(&users).Error; err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+ for i := range users {
+ users[i].PasswordHash = ""
+ }
+ c.JSON(200, gin.H{"data": users})
+}
+
+func (a *App) adminCreateUser(c *gin.Context) {
+ var in struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ Role string `json:"role"`
+ Credits int64 `json:"credits"`
+ }
+ if c.BindJSON(&in) != nil || strings.TrimSpace(in.Username) == "" || len(in.Password) < 8 {
+ c.JSON(400, gin.H{"error": "username and password(8+) required"})
+ return
+ }
+ if in.Role == "" {
+ in.Role = RoleUser
+ }
+ if in.Role != RoleUser && in.Role != RolePrivileged && in.Role != RoleAdmin {
+ c.JSON(400, gin.H{"error": "invalid role"})
+ return
+ }
+ hash, err := hashPassword(in.Password)
+ if err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+ var u User
+ err = a.db.Transaction(func(tx *gorm.DB) error {
+ u = User{Username: strings.TrimSpace(in.Username), PasswordHash: hash, Role: in.Role, Status: UserActive}
+ if err := tx.Create(&u).Error; err != nil {
+ return err
+ }
+ if err := tx.Create(&CreditAccount{UserID: u.ID, Balance: in.Credits}).Error; err != nil {
+ return err
+ }
+ for _, p := range defaultPermissions(in.Role) {
+ if err := tx.Create(&UserPermission{UserID: u.ID, Permission: p, Enabled: true}).Error; err != nil {
+ return err
+ }
+ }
+ if in.Credits > 0 {
+ if err := tx.Create(&CreditTransaction{UserID: u.ID, Type: "grant", Amount: in.Credits, BalanceAfter: in.Credits, ReferenceType: "admin", ReferenceID: strconv.FormatUint(uint64(c.MustGet("user").(*User).ID), 10), Reason: "initial credits"}).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ c.JSON(400, gin.H{"error": err.Error()})
+ return
+ }
+ u.PasswordHash = ""
+ c.JSON(201, u)
+}
+
+func (a *App) adminUpdateUser(c *gin.Context) {
+ id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil {
+ c.Status(400)
+ return
+ }
+ var in struct {
+ Password *string `json:"password"`
+ Role *string `json:"role"`
+ Status *string `json:"status"`
+ }
+ if c.BindJSON(&in) != nil {
+ c.Status(400)
+ return
+ }
+ var u User
+ if a.db.First(&u, id).Error != nil {
+ c.Status(404)
+ return
+ }
+ if in.Password != nil {
+ if len(*in.Password) < 8 {
+ c.JSON(400, gin.H{"error": "password must be 8+"})
+ return
+ }
+ h, e := hashPassword(*in.Password)
+ if e != nil {
+ c.JSON(500, gin.H{"error": e.Error()})
+ return
+ }
+ u.PasswordHash = h
+ }
+ if in.Role != nil {
+ if *in.Role != RoleUser && *in.Role != RolePrivileged && *in.Role != RoleAdmin {
+ c.JSON(400, gin.H{"error": "invalid role"})
+ return
+ }
+ u.Role = *in.Role
+ }
+ if in.Status != nil {
+ if *in.Status != UserActive && *in.Status != UserDisabled {
+ c.JSON(400, gin.H{"error": "invalid status"})
+ return
+ }
+ u.Status = *in.Status
+ }
+ if err := a.db.Save(&u).Error; err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+ u.PasswordHash = ""
+ c.JSON(200, u)
+}
diff --git a/credits.go b/credits.go
new file mode 100644
index 0000000..717245a
--- /dev/null
+++ b/credits.go
@@ -0,0 +1,138 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+var (
+ errInsufficientCredits = errors.New("insufficient credits")
+ errReservationState = errors.New("invalid credit reservation state")
+)
+
+// reserveCredit places one credit on hold for a task. The hold is released on
+// failure or converted into a debit after a successful new download.
+func (a *App) reserveCredit(userID, taskID uint, amount int64) (*CreditReservation, error) {
+ if amount <= 0 {
+ return nil, fmt.Errorf("invalid credit amount")
+ }
+ var reservation CreditReservation
+ err := a.db.Transaction(func(tx *gorm.DB) error {
+ var account CreditAccount
+ if err := tx.Where("user_id = ?", userID).First(&account).Error; err != nil {
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ account = CreditAccount{UserID: userID}
+ if err := tx.Create(&account).Error; err != nil {
+ return err
+ }
+ }
+ var existing CreditReservation
+ if err := tx.Where("task_id = ?", taskID).First(&existing).Error; err == nil {
+ reservation = existing
+ return nil
+ } else if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ if account.Balance-account.Reserved < amount {
+ return errInsufficientCredits
+ }
+ account.Reserved += amount
+ if err := tx.Save(&account).Error; err != nil {
+ return err
+ }
+ reservation = CreditReservation{UserID: userID, TaskID: taskID, Amount: amount, Status: "reserved"}
+ if err := tx.Create(&reservation).Error; err != nil {
+ return err
+ }
+ return tx.Create(&CreditTransaction{UserID: userID, Type: "reserve", Amount: 0, BalanceAfter: account.Balance, ReferenceType: "task", ReferenceID: strconv.FormatUint(uint64(taskID), 10), Reason: "download reservation"}).Error
+ })
+ return &reservation, err
+}
+
+func (a *App) commitCredit(task *Task) error {
+ if task.CreditReservationID == 0 {
+ return nil
+ }
+ return a.db.Transaction(func(tx *gorm.DB) error {
+ var r CreditReservation
+ if err := tx.First(&r, task.CreditReservationID).Error; err != nil {
+ return err
+ }
+ if r.Status == "committed" {
+ return nil
+ }
+ if r.Status != "reserved" {
+ return errReservationState
+ }
+ var account CreditAccount
+ if err := tx.Where("user_id = ?", r.UserID).First(&account).Error; err != nil {
+ return err
+ }
+ account.Reserved -= r.Amount
+ account.Balance -= r.Amount
+ if account.Reserved < 0 || account.Balance < 0 {
+ return errReservationState
+ }
+ if err := tx.Save(&account).Error; err != nil {
+ return err
+ }
+ now := time.Now()
+ r.Status, r.CompletedAt = "committed", &now
+ if err := tx.Save(&r).Error; err != nil {
+ return err
+ }
+ return tx.Create(&CreditTransaction{UserID: r.UserID, Type: "debit", Amount: -r.Amount, BalanceAfter: account.Balance, ReferenceType: "task", ReferenceID: strconv.FormatUint(uint64(task.ID), 10), Reason: "successful download"}).Error
+ })
+}
+
+// refundCredit releases a reservation without debiting the user's balance.
+// It is idempotent so download failure paths can safely call fail repeatedly.
+func (a *App) refundCredit(task *Task) error {
+ if task.CreditReservationID == 0 {
+ return nil
+ }
+ return a.db.Transaction(func(tx *gorm.DB) error {
+ var r CreditReservation
+ if err := tx.First(&r, task.CreditReservationID).Error; err != nil {
+ return err
+ }
+ if r.Status == "refunded" {
+ return nil
+ }
+ if r.Status == "committed" {
+ return errReservationState
+ }
+ if r.Status != "reserved" {
+ return errReservationState
+ }
+ var account CreditAccount
+ if err := tx.Where("user_id = ?", r.UserID).First(&account).Error; err != nil {
+ return err
+ }
+ account.Reserved -= r.Amount
+ if account.Reserved < 0 {
+ return errReservationState
+ }
+ if err := tx.Save(&account).Error; err != nil {
+ return err
+ }
+ now := time.Now()
+ r.Status, r.CompletedAt = "refunded", &now
+ if err := tx.Save(&r).Error; err != nil {
+ return err
+ }
+ return tx.Create(&CreditTransaction{UserID: r.UserID, Type: "refund", Amount: 0, BalanceAfter: account.Balance, ReferenceType: "task", ReferenceID: strconv.FormatUint(uint64(task.ID), 10), Reason: "download failed"}).Error
+ })
+}
+
+func (a *App) creditBalance(userID uint) (CreditAccount, error) {
+ var account CreditAccount
+ err := a.db.Where("user_id = ?", userID).First(&account).Error
+ return account, err
+}
diff --git a/downloader.go b/downloader.go
index 820eb03..74f6a69 100644
--- a/downloader.go
+++ b/downloader.go
@@ -71,6 +71,11 @@ func (a *App) finishExisting(t *Task, size int64) {
}
t.TotalBytes, t.DownloadedBytes, t.Progress, t.Status = size, size, 100, "done"
a.db.Save(t)
+ // Existing local media is reused and therefore does not consume a credit.
+ if err := a.refundCredit(t); err != nil {
+ a.fail(t, err)
+ return
+ }
a.upload(t)
}
diff --git a/handlers.go b/handlers.go
index 13239b0..265931e 100644
--- a/handlers.go
+++ b/handlers.go
@@ -35,7 +35,12 @@ func (a *App) search(c *gin.Context) {
func (a *App) listTasks(c *gin.Context) {
var ts []Task
- if err := a.db.Order("id desc").Limit(100).Find(&ts).Error; err != nil {
+ user := c.MustGet("user").(*User)
+ query := a.db.Where("user_id = ?", user.ID)
+ if user.Role == RoleAdmin && c.Query("all") == "1" {
+ query = a.db
+ }
+ if err := query.Order("id desc").Limit(100).Find(&ts).Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
@@ -54,31 +59,60 @@ func (a *App) addTask(c *gin.Context) {
if in.Quality == "" {
in.Quality = a.cfg.DefaultQuality
}
+ user := c.MustGet("user").(*User)
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
+ err := a.db.Where("user_id = ? AND source = ? AND r_id = ? AND quality = ?", user.ID, 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": ""})
+ r, reserveErr := a.reserveCredit(user.ID, existing.ID, 1)
+ if reserveErr != nil {
+ c.JSON(402, gin.H{"error": reserveErr.Error()})
+ return
+ }
+ a.db.Model(&existing).Updates(map[string]any{"title": in.Title, "artist": in.Artist, "album": in.Album, "pic": in.Pic, "status": "pending", "error": "", "credit_reservation_id": r.ID})
+ existing.CreditReservationID = r.ID
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"}
+ t := Task{UserID: user.ID, 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 {
+ if a.db.Where("user_id = ? AND source = ? AND r_id = ? AND quality = ?", user.ID, 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
}
+ r, reserveErr := a.reserveCredit(user.ID, t.ID, 1)
+ if reserveErr != nil {
+ _ = a.db.Delete(&t).Error
+ c.JSON(402, gin.H{"error": reserveErr.Error()})
+ return
+ }
+ t.CreditReservationID = r.ID
+ if err := a.db.Model(&t).Update("credit_reservation_id", r.ID).Error; err != nil {
+ _ = a.refundCredit(&t)
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
a.queue <- t.ID
c.JSON(202, t)
}
+func (a *App) creditInfo(c *gin.Context) {
+ user := c.MustGet("user").(*User)
+ account, err := a.creditBalance(user.ID)
+ if err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(200, gin.H{"balance": account.Balance, "reserved": account.Reserved, "available": account.Balance - account.Reserved})
+}
+
func (a *App) retryTask(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
@@ -86,7 +120,8 @@ func (a *App) retryTask(c *gin.Context) {
return
}
var t Task
- if a.db.First(&t, id).Error != nil {
+ user := c.MustGet("user").(*User)
+ if a.db.Where("id = ? AND user_id = ?", id, user.ID).First(&t).Error != nil {
c.Status(404)
return
}
@@ -102,7 +137,8 @@ func (a *App) deleteTask(c *gin.Context) {
c.Status(400)
return
}
- if err := a.db.Delete(&Task{}, id).Error; err != nil {
+ user := c.MustGet("user").(*User)
+ if err := a.db.Where("id = ? AND user_id = ?", id, user.ID).Delete(&Task{}).Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
diff --git a/index.html b/index.html
index d812ff5..3d51a17 100644
--- a/index.html
+++ b/index.html
@@ -5,10 +5,11 @@
-
-
AM Downloader
搜索 · 下载 · WebDAV 同步
+
+
AM Downloader
搜索 · 下载 · WebDAV 同步
找到你想听的音乐
搜索歌曲、歌手或专辑,加入下载队列即可。
@@ -17,7 +18,12 @@
diff --git a/main.go b/main.go
index cf46d6e..6b827ad 100644
--- a/main.go
+++ b/main.go
@@ -16,6 +16,8 @@ func main() {
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
must(err)
must(db.AutoMigrate(&Task{}, &Setting{}, &User{}, &UserPermission{}, &CreditAccount{}, &CreditTransaction{}, &CreditReservation{}, &RedeemBatch{}, &RedeemCode{}, &RedeemRecord{}, &WebDAVConfig{}, &Session{}, &AuditLog{}))
+ // Replace the legacy global task uniqueness constraint with per-user uniqueness.
+ must(db.Exec("DROP INDEX IF EXISTS idx_tasks_source_rid_quality").Error)
client := &http.Client{Timeout: 2 * 60 * 1e9}
cfg := loadConfig(db)
@@ -38,10 +40,20 @@ func main() {
auth := api.Group("", a.requireAuth)
auth.GET("/search", a.requirePermission(PermDownload), a.search)
auth.GET("/tasks", a.requirePermission(PermDownload), a.listTasks)
+ auth.GET("/credits", a.creditInfo)
+ auth.POST("/redeem", a.requirePermission(PermRedeem), a.redeem)
+ auth.GET("/webdav/config", a.requirePermission(PermWebDAVConfig), a.getUserWebDAV)
+ auth.PUT("/webdav/config", a.requirePermission(PermWebDAVConfig), a.putUserWebDAV)
+ auth.POST("/webdav/test", a.requirePermission(PermWebDAVConfig), a.testUserWebDAV)
auth.POST("/tasks", a.requirePermission(PermDownload), a.addTask)
auth.POST("/tasks/:id/retry", a.requirePermission(PermDownload), a.retryTask)
auth.DELETE("/tasks/:id", a.requirePermission(PermDownload), a.deleteTask)
admin := api.Group("/admin", a.requireAuth, a.requireAdmin)
+ admin.GET("/users", a.adminListUsers)
+ admin.POST("/users", a.adminCreateUser)
+ admin.PUT("/users/:id", a.adminUpdateUser)
+ admin.GET("/redeem", a.adminListRedeemBatches)
+ admin.POST("/redeem", a.adminCreateRedeemBatch)
admin.GET("/settings", a.getSettings)
admin.PUT("/settings", a.putSettings)
admin.POST("/webdav/test", a.testWebDAV)
diff --git a/models.go b/models.go
index 0416d24..9022de2 100644
--- a/models.go
+++ b/models.go
@@ -4,15 +4,15 @@ import "time"
type Task struct {
ID uint `gorm:"primaryKey" json:"id"`
- UserID uint `gorm:"index" json:"userId"`
+ UserID uint `gorm:"uniqueIndex:idx_tasks_user_source_rid_quality" json:"userId"`
CreditReservationID uint `gorm:"index" json:"creditReservationId,omitempty"`
- Source string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"source"`
- RID string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"rid"`
+ Source string `gorm:"uniqueIndex:idx_tasks_user_source_rid_quality" json:"source"`
+ RID string `gorm:"uniqueIndex:idx_tasks_user_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"`
+ Quality string `gorm:"uniqueIndex:idx_tasks_user_source_rid_quality" json:"quality"`
ResolvedQuality string `json:"resolvedQuality,omitempty"`
ActualQuality string `json:"actualQuality,omitempty"`
Format string `json:"format"`
diff --git a/redeem.go b/redeem.go
new file mode 100644
index 0000000..c39c270
--- /dev/null
+++ b/redeem.go
@@ -0,0 +1,139 @@
+package main
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+func hashRedeemCode(code string) string {
+ s := sha256.Sum256([]byte(strings.ToUpper(strings.TrimSpace(code))))
+ return hex.EncodeToString(s[:])
+}
+
+func generateRedeemCode() (string, error) {
+ b := make([]byte, 9)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
+ out := make([]byte, 12)
+ for i := range out {
+ out[i] = alphabet[int(b[i%len(b)])%len(alphabet)]
+ }
+ return string(out[:4]) + "-" + string(out[4:8]) + "-" + string(out[8:]), nil
+}
+
+func (a *App) adminCreateRedeemBatch(c *gin.Context) {
+ var in struct {
+ Credits int64 `json:"credits"`
+ Count int `json:"count"`
+ ExpiresAt *time.Time `json:"expiresAt"`
+ Note string `json:"note"`
+ }
+ if c.BindJSON(&in) != nil || in.Credits <= 0 || in.Count < 1 || in.Count > 10000 {
+ c.JSON(400, gin.H{"error": "credits/count invalid"})
+ return
+ }
+ admin := c.MustGet("user").(*User)
+ batch := RedeemBatch{Code: fmt.Sprintf("B-%d", time.Now().UnixNano()), Credits: in.Credits, Count: in.Count, ExpiresAt: in.ExpiresAt, Note: in.Note, CreatedBy: admin.ID}
+ codes := make([]string, 0, in.Count)
+ err := a.db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Create(&batch).Error; err != nil {
+ return err
+ }
+ for i := 0; i < in.Count; i++ {
+ code, err := generateRedeemCode()
+ if err != nil {
+ return err
+ }
+ row := RedeemCode{BatchID: batch.ID, CodeHash: hashRedeemCode(code), Prefix: code[:4], Credits: in.Credits, Status: "unused", ExpiresAt: in.ExpiresAt}
+ if err := tx.Create(&row).Error; err != nil {
+ return err
+ }
+ codes = append(codes, code)
+ }
+ return nil
+ })
+ if err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(201, gin.H{"batch": batch, "codes": codes})
+}
+
+func (a *App) redeem(c *gin.Context) {
+ user := c.MustGet("user").(*User)
+ var in struct {
+ Code string `json:"code"`
+ }
+ if c.BindJSON(&in) != nil || strings.TrimSpace(in.Code) == "" {
+ c.JSON(400, gin.H{"error": "code required"})
+ return
+ }
+ now := time.Now()
+ var amount int64
+ err := a.db.Transaction(func(tx *gorm.DB) error {
+ var code RedeemCode
+ if err := tx.Where("code_hash = ?", hashRedeemCode(in.Code)).First(&code).Error; err != nil {
+ return err
+ }
+ if code.Status != "unused" {
+ return errors.New("code already used or unavailable")
+ }
+ if code.ExpiresAt != nil && code.ExpiresAt.Before(now) {
+ return errors.New("code expired")
+ }
+ var account CreditAccount
+ if err := tx.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ account = CreditAccount{UserID: user.ID}
+ if err := tx.Create(&account).Error; err != nil {
+ return err
+ }
+ }
+ account.Balance += code.Credits
+ if err := tx.Save(&account).Error; err != nil {
+ return err
+ }
+ code.Status, code.RedeemedBy, code.RedeemedAt = "used", &user.ID, &now
+ if err := tx.Save(&code).Error; err != nil {
+ return err
+ }
+ if err := tx.Create(&RedeemRecord{CodeID: code.ID, BatchID: code.BatchID, UserID: user.ID, Credits: code.Credits}).Error; err != nil {
+ return err
+ }
+ if err := tx.Create(&CreditTransaction{UserID: user.ID, Type: "redeem", Amount: code.Credits, BalanceAfter: account.Balance, ReferenceType: "redeem_code", ReferenceID: fmt.Sprint(code.ID), Reason: "redeem code"}).Error; err != nil {
+ return err
+ }
+ amount = code.Credits
+ return nil
+ })
+ if err != nil {
+ status := 500
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ status = 404
+ }
+ c.JSON(status, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(200, gin.H{"credits": amount})
+}
+
+func (a *App) adminListRedeemBatches(c *gin.Context) {
+ var batches []RedeemBatch
+ if err := a.db.Order("id desc").Limit(100).Find(&batches).Error; err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(200, gin.H{"data": batches})
+}
diff --git a/settings.go b/settings.go
index 4894eff..650805e 100644
--- a/settings.go
+++ b/settings.go
@@ -12,7 +12,7 @@ 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,
+ "webdavPass": "", "hasWebdavPassword": a.cfg.WebDAVPass != "", "webdavPath": a.cfg.WebDAVPath,
"quality": a.cfg.DefaultQuality, "webdavVersionPolicy": a.cfg.WebDAVVersionPolicy,
"localFilePolicy": a.cfg.LocalFilePolicy,
})
diff --git a/user_webdav.go b/user_webdav.go
new file mode 100644
index 0000000..33d39c2
--- /dev/null
+++ b/user_webdav.go
@@ -0,0 +1,106 @@
+package main
+
+import (
+ "path"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/studio-b12/gowebdav"
+)
+
+func cleanWebDAVRoot(root string) string {
+ root = strings.TrimSpace(strings.ReplaceAll(root, "\\", "/"))
+ root = path.Clean("/" + root)
+ if root == "/" {
+ return ""
+ }
+ return strings.Trim(root, "/")
+}
+
+func (a *App) getUserWebDAV(c *gin.Context) {
+ user := c.MustGet("user").(*User)
+ var cfg WebDAVConfig
+ if err := a.db.Where("user_id = ?", user.ID).First(&cfg).Error; err != nil {
+ c.JSON(200, gin.H{"enabled": false, "url": "", "username": "", "rootPath": "", "hasPassword": false})
+ return
+ }
+ c.JSON(200, gin.H{"enabled": cfg.Enabled, "url": cfg.URL, "username": cfg.Username, "rootPath": cfg.RootPath, "hasPassword": cfg.Password != ""})
+}
+
+func (a *App) putUserWebDAV(c *gin.Context) {
+ user := c.MustGet("user").(*User)
+ var in struct {
+ Enabled bool `json:"enabled"`
+ URL string `json:"url"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ RootPath string `json:"rootPath"`
+ }
+ if c.BindJSON(&in) != nil {
+ c.Status(400)
+ return
+ }
+ root := cleanWebDAVRoot(in.RootPath)
+ var cfg WebDAVConfig
+ err := a.db.Where("user_id = ?", user.ID).First(&cfg).Error
+ if err != nil {
+ cfg = WebDAVConfig{UserID: user.ID, Enabled: in.Enabled, URL: strings.TrimRight(strings.TrimSpace(in.URL), "/"), Username: strings.TrimSpace(in.Username), Password: in.Password, RootPath: root}
+ } else {
+ cfg.Enabled = in.Enabled
+ cfg.URL = strings.TrimRight(strings.TrimSpace(in.URL), "/")
+ cfg.Username = strings.TrimSpace(in.Username)
+ cfg.RootPath = root
+ if in.Password != "" {
+ cfg.Password = in.Password
+ }
+ }
+ if err := a.db.Save(&cfg).Error; err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(200, gin.H{"ok": true})
+}
+
+func (a *App) testUserWebDAV(c *gin.Context) {
+ user := c.MustGet("user").(*User)
+ var in struct{ URL, Username, Password, RootPath string }
+ if c.BindJSON(&in) != nil {
+ c.Status(400)
+ return
+ }
+ if in.URL == "" {
+ var cfg WebDAVConfig
+ if a.db.Where("user_id = ?", user.ID).First(&cfg).Error == nil {
+ in.URL, in.Username, in.Password, in.RootPath = cfg.URL, cfg.Username, cfg.Password, cfg.RootPath
+ }
+ }
+ client := gowebdav.NewClient(in.URL, in.Username, in.Password)
+ 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, "rootPath": cleanWebDAVRoot(in.RootPath)})
+}
+
+func (a *App) storageForTask(t *Task) (RemoteStorage, string, bool) {
+ var user User
+ if a.db.First(&user, t.UserID).Error != nil {
+ return nil, "", false
+ }
+ if user.Role == RoleAdmin {
+ if a.cfg.WebDAVURL == "" {
+ return nil, "", false
+ }
+ return a.storage, a.cfg.WebDAVPath, a.storage != nil
+ }
+ if !a.hasPermission(user.ID, PermWebDAV) {
+ return nil, "", false
+ }
+ var cfg WebDAVConfig
+ if a.db.Where("user_id = ? AND enabled = ?", user.ID, true).First(&cfg).Error != nil || cfg.URL == "" {
+ return nil, "", false
+ }
+ return NewWebDAVStorage(Config{WebDAVURL: cfg.URL, WebDAVUser: cfg.Username, WebDAVPass: cfg.Password}), cfg.RootPath, true
+}
diff --git a/util.go b/util.go
index a54d702..49b4119 100644
--- a/util.go
+++ b/util.go
@@ -18,6 +18,10 @@ func (a *App) fail(t *Task, err error) {
}
t.Status, t.Error = "error", err.Error()
a.db.Save(t)
+ if refundErr := a.refundCredit(t); refundErr != nil {
+ t.Error += "; credit refund failed: " + refundErr.Error()
+ a.db.Save(t)
+ }
}
var bad = regexp.MustCompile(`[/\\:*?"<>|]+`)
diff --git a/webdav.go b/webdav.go
index 259e99d..99aba9c 100644
--- a/webdav.go
+++ b/webdav.go
@@ -7,35 +7,48 @@ import (
)
func (a *App) upload(t *Task) {
- if a.storage == nil || a.cfg.WebDAVURL == "" {
+ storage, root, enabled := a.storageForTask(t)
+ if !enabled || storage == nil || root == "" {
t.Status, t.Progress = "done", 100
a.db.Save(t)
+ if err := a.commitCredit(t); err != nil {
+ a.fail(t, err)
+ return
+ }
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)
+ remote := strings.TrimRight(root, "/") + "/" + 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 {
+ if err := 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 {
+ if same, _ := storage.ExistsSameSize(remote, fi.Size()); same {
t.Status, t.Progress = "done", 100
a.db.Save(t)
+ if err := a.commitCredit(t); err != nil {
+ a.fail(t, err)
+ return
+ }
a.cleanupLocalFile(t)
return
}
}
- if err := a.storage.Upload(t.LocalPath, remote); err != nil {
+ if err := storage.Upload(t.LocalPath, remote); err != nil {
a.fail(t, err)
return
}
if a.cfg.WebDAVVersionPolicy == "replace_lower" {
- a.cleanupLowerQualityVersions(t)
+ a.cleanupLowerQualityVersions(t, storage)
}
t.Status, t.Progress = "done", 100
a.db.Save(t)
+ if err := a.commitCredit(t); err != nil {
+ a.fail(t, err)
+ return
+ }
a.cleanupLocalFile(t)
}
@@ -49,20 +62,20 @@ func (a *App) cleanupLocalFile(t *Task) {
}
}
-func (a *App) cleanupLowerQualityVersions(t *Task) {
+func (a *App) cleanupLowerQualityVersions(t *Task, storage RemoteStorage) {
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)
+ a.db.Where("user_id = ? AND artist = ? AND title = ? AND album = ? AND status = ? AND id <> ?", t.UserID, 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)
+ _ = storage.Remove(candidate.RemotePath)
}
}