feat: implement multi-user credits redeem and storage isolation
This commit is contained in:
+137
@@ -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)
|
||||||
|
}
|
||||||
+138
@@ -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
|
||||||
|
}
|
||||||
@@ -71,6 +71,11 @@ func (a *App) finishExisting(t *Task, size int64) {
|
|||||||
}
|
}
|
||||||
t.TotalBytes, t.DownloadedBytes, t.Progress, t.Status = size, size, 100, "done"
|
t.TotalBytes, t.DownloadedBytes, t.Progress, t.Status = size, size, 100, "done"
|
||||||
a.db.Save(t)
|
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)
|
a.upload(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+43
-7
@@ -35,7 +35,12 @@ func (a *App) search(c *gin.Context) {
|
|||||||
|
|
||||||
func (a *App) listTasks(c *gin.Context) {
|
func (a *App) listTasks(c *gin.Context) {
|
||||||
var ts []Task
|
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()})
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -54,31 +59,60 @@ func (a *App) addTask(c *gin.Context) {
|
|||||||
if in.Quality == "" {
|
if in.Quality == "" {
|
||||||
in.Quality = a.cfg.DefaultQuality
|
in.Quality = a.cfg.DefaultQuality
|
||||||
}
|
}
|
||||||
|
user := c.MustGet("user").(*User)
|
||||||
var existing Task
|
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 err == nil {
|
||||||
if existing.Status != "error" {
|
if existing.Status != "error" {
|
||||||
c.JSON(200, existing)
|
c.JSON(200, existing)
|
||||||
return
|
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
|
a.queue <- existing.ID
|
||||||
c.JSON(202, existing)
|
c.JSON(202, existing)
|
||||||
return
|
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 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)
|
c.JSON(200, existing)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
return
|
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
|
a.queue <- t.ID
|
||||||
c.JSON(202, t)
|
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) {
|
func (a *App) retryTask(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -86,7 +120,8 @@ func (a *App) retryTask(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var t Task
|
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)
|
c.Status(404)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -102,7 +137,8 @@ func (a *App) deleteTask(c *gin.Context) {
|
|||||||
c.Status(400)
|
c.Status(400)
|
||||||
return
|
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()})
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-7
@@ -5,10 +5,11 @@
|
|||||||
<style>
|
<style>
|
||||||
*{box-sizing:border-box}body{margin:0;background:#0b1220;color:#e7edf7;font:14px system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.wrap{max-width:1100px;margin:auto;padding:20px 16px 80px}.top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.brand{font-size:24px;font-weight:850}.brand b{color:#10b981}.sub,.muted{color:#8492a7}.sub{margin-top:4px;font-size:12px}.nav{display:flex;gap:6px;background:#101a2b;border:1px solid #22304a;border-radius:14px;padding:5px}.nav button{border:0;background:transparent;color:#9aa8bb;padding:9px 13px;border-radius:10px;cursor:pointer;font-weight:650}.nav button.active{background:#1c2b42;color:#fff}.badge{display:inline-flex;min-width:19px;height:19px;align-items:center;justify-content:center;border-radius:10px;background:#10b981;color:#06251b;font-size:11px;margin-left:5px}.view{display:none}.view.active{display:block}.hero{padding:44px 8px 34px;text-align:center}.hero h1{font-size:34px;margin:0 0 10px}.hero p{color:#8492a7;margin:0 0 24px}.searchbar{display:flex;max-width:720px;margin:auto;gap:8px}.input{width:100%;padding:12px 13px;border-radius:11px;border:1px solid #2a3852;background:#0c1525;color:#fff;outline:none}.input:focus{border-color:#10b981}.btn{border:0;border-radius:11px;padding:11px 16px;background:#10b981;color:#06251b;font-weight:750;cursor:pointer;white-space:nowrap}.btn.alt{background:#1b2940;color:#dbe5f2}.btn:disabled{opacity:.5;cursor:not-allowed}.card{background:#111b2c;border:1px solid #22304a;border-radius:17px;padding:18px;margin-bottom:15px}.section-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:13px}.title{font-size:17px;font-weight:780}.toolbar{display:flex;gap:8px;align-items:center}.result{display:grid;grid-template-columns:50px minmax(0,1fr) auto;gap:12px;align-items:center;padding:10px;border-radius:12px}.result:hover{background:#162238}.cover{width:50px;height:50px;border-radius:10px;object-fit:cover;background:#1b2638}.name{font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta{font-size:12px;color:#8492a7;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.results{display:grid;gap:3px}.empty{text-align:center;padding:42px 12px;color:#8492a7}.loadmore{width:100%;margin-top:12px}.task-tabs{display:flex;gap:6px;overflow:auto;margin-bottom:14px}.task-tabs button{border:1px solid #283750;background:#0e1829;color:#aab6c7;border-radius:10px;padding:8px 12px;white-space:nowrap}.task-tabs button.active{background:#1c2b42;color:#fff}.status{font-size:12px;padding:5px 8px;border-radius:8px;background:#1c2a40}.done{color:#34d399}.error{color:#fb7185}.progress{height:5px;background:#1b2940;border-radius:4px;margin-top:8px;overflow:hidden}.progress i{display:block;height:100%;background:#10b981}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:15px}.setting-card h3{margin:0 0 5px;font-size:15px}.setting-card p{margin:0 0 15px;color:#8492a7;font-size:12px}.field{margin-top:12px}.field label{display:block;color:#9aa8bb;font-size:12px;margin-bottom:6px}.row{display:flex;gap:9px}.row>*{min-width:0;flex:1}.notice{padding:10px 12px;background:#0d1728;border:1px solid #23314a;border-radius:10px;color:#9eacc0;font-size:12px;margin-top:12px}.recent{margin-top:4px}.mobile-nav{display:none}.quality-select{max-width:132px}.file-info{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}.chip{display:inline-flex;align-items:center;padding:3px 7px;border-radius:7px;background:#17243a;color:#b9c6d8;font-size:11px}.chip.actual{color:#d9fbe9;background:#123126}.chip.warn{color:#fde7b0;background:#352b16}
|
*{box-sizing:border-box}body{margin:0;background:#0b1220;color:#e7edf7;font:14px system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.wrap{max-width:1100px;margin:auto;padding:20px 16px 80px}.top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.brand{font-size:24px;font-weight:850}.brand b{color:#10b981}.sub,.muted{color:#8492a7}.sub{margin-top:4px;font-size:12px}.nav{display:flex;gap:6px;background:#101a2b;border:1px solid #22304a;border-radius:14px;padding:5px}.nav button{border:0;background:transparent;color:#9aa8bb;padding:9px 13px;border-radius:10px;cursor:pointer;font-weight:650}.nav button.active{background:#1c2b42;color:#fff}.badge{display:inline-flex;min-width:19px;height:19px;align-items:center;justify-content:center;border-radius:10px;background:#10b981;color:#06251b;font-size:11px;margin-left:5px}.view{display:none}.view.active{display:block}.hero{padding:44px 8px 34px;text-align:center}.hero h1{font-size:34px;margin:0 0 10px}.hero p{color:#8492a7;margin:0 0 24px}.searchbar{display:flex;max-width:720px;margin:auto;gap:8px}.input{width:100%;padding:12px 13px;border-radius:11px;border:1px solid #2a3852;background:#0c1525;color:#fff;outline:none}.input:focus{border-color:#10b981}.btn{border:0;border-radius:11px;padding:11px 16px;background:#10b981;color:#06251b;font-weight:750;cursor:pointer;white-space:nowrap}.btn.alt{background:#1b2940;color:#dbe5f2}.btn:disabled{opacity:.5;cursor:not-allowed}.card{background:#111b2c;border:1px solid #22304a;border-radius:17px;padding:18px;margin-bottom:15px}.section-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:13px}.title{font-size:17px;font-weight:780}.toolbar{display:flex;gap:8px;align-items:center}.result{display:grid;grid-template-columns:50px minmax(0,1fr) auto;gap:12px;align-items:center;padding:10px;border-radius:12px}.result:hover{background:#162238}.cover{width:50px;height:50px;border-radius:10px;object-fit:cover;background:#1b2638}.name{font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta{font-size:12px;color:#8492a7;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.results{display:grid;gap:3px}.empty{text-align:center;padding:42px 12px;color:#8492a7}.loadmore{width:100%;margin-top:12px}.task-tabs{display:flex;gap:6px;overflow:auto;margin-bottom:14px}.task-tabs button{border:1px solid #283750;background:#0e1829;color:#aab6c7;border-radius:10px;padding:8px 12px;white-space:nowrap}.task-tabs button.active{background:#1c2b42;color:#fff}.status{font-size:12px;padding:5px 8px;border-radius:8px;background:#1c2a40}.done{color:#34d399}.error{color:#fb7185}.progress{height:5px;background:#1b2940;border-radius:4px;margin-top:8px;overflow:hidden}.progress i{display:block;height:100%;background:#10b981}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:15px}.setting-card h3{margin:0 0 5px;font-size:15px}.setting-card p{margin:0 0 15px;color:#8492a7;font-size:12px}.field{margin-top:12px}.field label{display:block;color:#9aa8bb;font-size:12px;margin-bottom:6px}.row{display:flex;gap:9px}.row>*{min-width:0;flex:1}.notice{padding:10px 12px;background:#0d1728;border:1px solid #23314a;border-radius:10px;color:#9eacc0;font-size:12px;margin-top:12px}.recent{margin-top:4px}.mobile-nav{display:none}.quality-select{max-width:132px}.file-info{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}.chip{display:inline-flex;align-items:center;padding:3px 7px;border-radius:7px;background:#17243a;color:#b9c6d8;font-size:11px}.chip.actual{color:#d9fbe9;background:#123126}.chip.warn{color:#fde7b0;background:#352b16}
|
||||||
@media(max-width:720px){.wrap{padding:14px 12px 132px}.top{align-items:flex-start}.top .nav{display:none}.hero{padding:35px 4px 25px}.hero h1{font-size:28px}.searchbar{flex-wrap:wrap}.searchbar .input{flex-basis:100%}.searchbar select,.searchbar button{flex:1}.settings-grid{grid-template-columns:1fr}.result{grid-template-columns:42px minmax(0,1fr);gap:10px;padding:10px 6px}.cover{width:42px;height:42px}.result .toolbar{grid-column:2;display:flex;width:100%;gap:7px;margin-top:4px}.result .quality-select{max-width:none;min-width:0;flex:1}.result .toolbar .btn{padding:9px 14px;min-width:76px}.mobile-nav{position:fixed;left:10px;right:10px;bottom:max(10px,env(safe-area-inset-bottom));display:grid;grid-template-columns:repeat(4,1fr);gap:4px;padding:5px;background:#111b2c;border:1px solid #2a3852;border-radius:16px;z-index:10}.mobile-nav button{border:0;background:transparent;color:#8492a7;padding:9px 4px;border-radius:11px;font-size:12px}.mobile-nav button.active{background:#1c2b42;color:#fff}.row{flex-wrap:wrap}.row>*{flex-basis:100%}}
|
@media(max-width:720px){.wrap{padding:14px 12px 132px}.top{align-items:flex-start}.top .nav{display:none}.hero{padding:35px 4px 25px}.hero h1{font-size:28px}.searchbar{flex-wrap:wrap}.searchbar .input{flex-basis:100%}.searchbar select,.searchbar button{flex:1}.settings-grid{grid-template-columns:1fr}.result{grid-template-columns:42px minmax(0,1fr);gap:10px;padding:10px 6px}.cover{width:42px;height:42px}.result .toolbar{grid-column:2;display:flex;width:100%;gap:7px;margin-top:4px}.result .quality-select{max-width:none;min-width:0;flex:1}.result .toolbar .btn{padding:9px 14px;min-width:76px}.mobile-nav{position:fixed;left:10px;right:10px;bottom:max(10px,env(safe-area-inset-bottom));display:grid;grid-template-columns:repeat(4,1fr);gap:4px;padding:5px;background:#111b2c;border:1px solid #2a3852;border-radius:16px;z-index:10}.mobile-nav button{border:0;background:transparent;color:#8492a7;padding:9px 4px;border-radius:11px;font-size:12px}.mobile-nav button.active{background:#1c2b42;color:#fff}.row{flex-wrap:wrap}.row>*{flex-basis:100%}}
|
||||||
|
.authbar{display:flex;align-items:center;gap:8px;justify-content:flex-end;margin:-8px 0 14px}.userpill{padding:7px 10px;border:1px solid #22304a;border-radius:10px;background:#111b2c;color:#b9c6d8}.login-overlay{position:fixed;inset:0;background:#0b1220;z-index:50;display:flex;align-items:center;justify-content:center;padding:20px}.login-box{width:min(390px,100%);background:#111b2c;border:1px solid #22304a;border-radius:18px;padding:24px}.login-box h2{margin:0 0 6px}.login-box p{color:#8492a7;font-size:12px;margin:0 0 18px}.login-box .field{margin-top:12px}.login-error{color:#fb7185;font-size:12px;margin-top:10px;min-height:16px}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body><div class="wrap">
|
<body><div id="loginOverlay" class="login-overlay"><form id="loginForm" class="login-box"><h2>AM Downloader</h2><p>登录后管理你的下载任务、积分和同步设置。</p><div class="field"><label>用户名</label><input id="loginUser" class="input" autocomplete="username"></div><div class="field"><label>密码</label><input id="loginPass" type="password" class="input" autocomplete="current-password"></div><div id="loginError" class="login-error"></div><button class="btn" style="width:100%;margin-top:10px">登录</button></form></div><div class="wrap">
|
||||||
<header class="top"><div><div class="brand">AM <b>Downloader</b></div><div class="sub">搜索 · 下载 · WebDAV 同步</div></div><nav class="nav" id="topNav"><button data-view="home">首页</button><button data-view="search">搜索</button><button data-view="queue">下载<span id="navBadge" class="badge">0</span></button><button data-view="settings">设置</button></nav></header>
|
<header class="top"><div><div class="brand">AM <b>Downloader</b></div><div class="sub">搜索 · 下载 · WebDAV 同步</div></div><nav class="nav" id="topNav"><button data-view="home">首页</button><button data-view="search">搜索</button><button data-view="queue">下载<span id="navBadge" class="badge">0</span></button><button data-view="settings">设置</button></nav></header><div class="authbar"><span id="userPill" class="userpill"></span><button id="logoutBtn" class="btn alt">退出</button></div>
|
||||||
<main>
|
<main>
|
||||||
<section id="home" class="view"><div class="hero"><h1>找到你想听的音乐</h1><p>搜索歌曲、歌手或专辑,加入下载队列即可。</p><form id="homeSearch" class="searchbar"><input id="homeKeyword" class="input" placeholder="输入歌曲名、歌手或专辑" autocomplete="off"><select id="homeSource" class="input"><option value="kw">小窝</option><option value="kg">小狗</option><option value="wy">小云</option><option value="tx">小Q</option></select><button class="btn">开始搜索</button></form></div><div class="card"><div class="section-head"><div class="title">最近下载</div><button class="btn alt" data-go="queue">查看全部</button></div><div id="recentTasks"></div></div></section>
|
<section id="home" class="view"><div class="hero"><h1>找到你想听的音乐</h1><p>搜索歌曲、歌手或专辑,加入下载队列即可。</p><form id="homeSearch" class="searchbar"><input id="homeKeyword" class="input" placeholder="输入歌曲名、歌手或专辑" autocomplete="off"><select id="homeSource" class="input"><option value="kw">小窝</option><option value="kg">小狗</option><option value="wy">小云</option><option value="tx">小Q</option></select><button class="btn">开始搜索</button></form></div><div class="card"><div class="section-head"><div class="title">最近下载</div><button class="btn alt" data-go="queue">查看全部</button></div><div id="recentTasks"></div></div></section>
|
||||||
<section id="search" class="view"><div class="card"><div class="section-head"><div><div class="title">搜索音乐</div><div class="muted">支持歌曲名、歌手、专辑</div></div></div><form id="searchForm" class="row"><input id="keyword" class="input" placeholder="输入关键词" autocomplete="off"><select id="source" class="input"><option value="kw">小窝</option><option value="kg">小狗</option><option value="wy">小云</option><option value="tx">小Q</option></select><button class="btn">搜索</button></form></div><div id="resultsCard" class="card" style="display:none"><div class="section-head"><div><div class="title">搜索结果</div><div id="resultHint" class="muted"></div></div></div><div id="results" class="results"></div><button id="loadMore" class="btn alt loadmore" style="display:none">加载更多</button></div></section>
|
<section id="search" class="view"><div class="card"><div class="section-head"><div><div class="title">搜索音乐</div><div class="muted">支持歌曲名、歌手、专辑</div></div></div><form id="searchForm" class="row"><input id="keyword" class="input" placeholder="输入关键词" autocomplete="off"><select id="source" class="input"><option value="kw">小窝</option><option value="kg">小狗</option><option value="wy">小云</option><option value="tx">小Q</option></select><button class="btn">搜索</button></form></div><div id="resultsCard" class="card" style="display:none"><div class="section-head"><div><div class="title">搜索结果</div><div id="resultHint" class="muted"></div></div></div><div id="results" class="results"></div><button id="loadMore" class="btn alt loadmore" style="display:none">加载更多</button></div></section>
|
||||||
@@ -17,7 +18,12 @@
|
|||||||
</main><nav class="mobile-nav" id="mobileNav"><button data-view="home">⌂<br>首页</button><button data-view="search">⌕<br>搜索</button><button data-view="queue">♫ <span id="mobileBadge">0</span><br>下载</button><button data-view="settings">⚙<br>设置</button></nav></div>
|
</main><nav class="mobile-nav" id="mobileNav"><button data-view="home">⌂<br>首页</button><button data-view="search">⌕<br>搜索</button><button data-view="queue">♫ <span id="mobileBadge">0</span><br>下载</button><button data-view="settings">⚙<br>设置</button></nav></div>
|
||||||
<script>
|
<script>
|
||||||
const $=id=>document.getElementById(id);let searchPage=1,searchKeyword='',searchSource='kw',searchLoading=false,hasMore=false,taskFilter='all';
|
const $=id=>document.getElementById(id);let searchPage=1,searchKeyword='',searchSource='kw',searchLoading=false,hasMore=false,taskFilter='all';
|
||||||
async function api(u,o){const r=await fetch('/api'+u,o);if(!r.ok)throw new Error(await r.text());return r.status===204?null:r.json()}
|
async function api(u,o){const r=await fetch('/api'+u,o);if(r.status===401){showLogin();throw new Error('请先登录')}if(!r.ok)throw new Error(await r.text());return r.status===204?null:r.json()}
|
||||||
|
function showLogin(){document.getElementById('loginOverlay').style.display='flex'}
|
||||||
|
function hideLogin(){document.getElementById('loginOverlay').style.display='none'}
|
||||||
|
async function initAuth(){try{const x=await api('/auth/me');const u=x.user;document.getElementById('userPill').textContent=u.username+' · '+u.role;document.getElementById('settings').style.display=u.role==='admin'?'':'none';document.querySelectorAll('[data-view=\"settings\"]').forEach(x=>x.style.display=u.role==='admin'?'':'none');const c=await api('/credits');document.getElementById('userPill').textContent=u.username+' · '+u.role+' · 积分 '+c.balance+' / 可用 '+c.available;hideLogin();return true}catch(e){showLogin();return false}}
|
||||||
|
$('loginForm').addEventListener('submit',async e=>{e.preventDefault();$('loginError').textContent='登录中…';try{const x=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:$('loginUser').value.trim(),password:$('loginPass').value})});if(!x.ok)throw new Error('用户名或密码错误');await initAuth();go(location.hash.slice(1)||'home');loadRecent()}catch(e){$('loginError').textContent=e.message}});
|
||||||
|
$('logoutBtn').addEventListener('click',async()=>{try{await api('/auth/logout',{method:'POST'})}finally{showLogin()}});
|
||||||
function esc(s){return String(s??'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
|
function esc(s){return String(s??'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
|
||||||
function go(view){document.querySelectorAll('.view').forEach(x=>x.classList.toggle('active',x.id===view));document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x.dataset.view===view));if(view==='settings')loadSettings();if(view==='queue')loadTasks();if(view==='home')loadRecent();history.replaceState(null,'','#'+view)}
|
function go(view){document.querySelectorAll('.view').forEach(x=>x.classList.toggle('active',x.id===view));document.querySelectorAll('[data-view]').forEach(x=>x.classList.toggle('active',x.dataset.view===view));if(view==='settings')loadSettings();if(view==='queue')loadTasks();if(view==='home')loadRecent();history.replaceState(null,'','#'+view)}
|
||||||
document.querySelectorAll('[data-view]').forEach(b=>b.addEventListener('click',()=>go(b.dataset.view)));document.querySelectorAll('[data-go]').forEach(b=>b.addEventListener('click',()=>go(b.dataset.go)));
|
document.querySelectorAll('[data-view]').forEach(b=>b.addEventListener('click',()=>go(b.dataset.view)));document.querySelectorAll('[data-go]').forEach(b=>b.addEventListener('click',()=>go(b.dataset.go)));
|
||||||
@@ -32,8 +38,8 @@ function taskHtml(t){let pct=Math.max(0,Math.min(100,Number(t.progress)||0));let
|
|||||||
async function loadTasks(){try{const x=await api('/tasks');const all=x.data||[];const active=all.filter(t=>['pending','resolving','downloading'].includes(t.status));$('navBadge').textContent=active.length;$('mobileBadge').textContent=active.length;let arr=taskFilter==='active'?active:taskFilter==='done'?all.filter(t=>t.status==='done'):taskFilter==='error'?all.filter(t=>t.status==='error'):all;$('tasks').innerHTML=arr.length?arr.map(taskHtml).join(''):'<div class="empty">暂无任务</div>';$('tasks').querySelectorAll('.retry').forEach((b,i)=>b.addEventListener('click',async()=>{const errs=arr.filter(t=>t.status==='error');await api('/tasks/'+errs[i].id+'/retry',{method:'POST'});loadTasks()}))}catch(e){$('tasks').innerHTML='<div class="empty">加载失败:'+esc(e.message)+'</div>'}}
|
async function loadTasks(){try{const x=await api('/tasks');const all=x.data||[];const active=all.filter(t=>['pending','resolving','downloading'].includes(t.status));$('navBadge').textContent=active.length;$('mobileBadge').textContent=active.length;let arr=taskFilter==='active'?active:taskFilter==='done'?all.filter(t=>t.status==='done'):taskFilter==='error'?all.filter(t=>t.status==='error'):all;$('tasks').innerHTML=arr.length?arr.map(taskHtml).join(''):'<div class="empty">暂无任务</div>';$('tasks').querySelectorAll('.retry').forEach((b,i)=>b.addEventListener('click',async()=>{const errs=arr.filter(t=>t.status==='error');await api('/tasks/'+errs[i].id+'/retry',{method:'POST'});loadTasks()}))}catch(e){$('tasks').innerHTML='<div class="empty">加载失败:'+esc(e.message)+'</div>'}}
|
||||||
async function loadRecent(){try{const x=await api('/tasks');const a=(x.data||[]).slice(0,5);$('recentTasks').innerHTML=a.length?a.map(taskHtml).join(''):'<div class="empty">还没有下载记录</div>'}catch(e){$('recentTasks').innerHTML='<div class="empty">加载失败</div>'}}
|
async function loadRecent(){try{const x=await api('/tasks');const a=(x.data||[]).slice(0,5);$('recentTasks').innerHTML=a.length?a.map(taskHtml).join(''):'<div class="empty">还没有下载记录</div>'}catch(e){$('recentTasks').innerHTML='<div class="empty">加载失败</div>'}}
|
||||||
document.querySelectorAll('#taskTabs button').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#taskTabs button').forEach(x=>x.classList.remove('active'));b.classList.add('active');taskFilter=b.dataset.filter;loadTasks()}));
|
document.querySelectorAll('#taskTabs button').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#taskTabs button').forEach(x=>x.classList.remove('active'));b.classList.add('active');taskFilter=b.dataset.filter;loadTasks()}));
|
||||||
async function loadSettings(){try{const s=await api('/settings');$('amApi').value=s.amApi||'';$('downloadDir').value=s.downloadDir||'';$('localFilePolicy').value=s.localFilePolicy||'keep';$('davUrl').value=s.webdavUrl||'';$('davUser').value=s.webdavUser||'';$('davPass').value=s.webdavPass||'';$('davPath').value=s.webdavPath||'';$('defaultQuality').value=s.quality||'standard';$('davVersionPolicy').value=s.webdavVersionPolicy||'keep'}catch(e){alert('设置加载失败:'+e.message)}}
|
async function loadSettings(){try{const s=await api('/admin/settings');$('amApi').value=s.amApi||'';$('downloadDir').value=s.downloadDir||'';$('localFilePolicy').value=s.localFilePolicy||'keep';$('davUrl').value=s.webdavUrl||'';$('davUser').value=s.webdavUser||'';$('davPass').value=s.webdavPass||'';$('davPath').value=s.webdavPath||'';$('defaultQuality').value=s.quality||'standard';$('davVersionPolicy').value=s.webdavVersionPolicy||'keep'}catch(e){alert('设置加载失败:'+e.message)}}
|
||||||
$('saveSettings').addEventListener('click',async()=>{const b={amApi:$('amApi').value,downloadDir:$('downloadDir').value,localFilePolicy:$('localFilePolicy').value,webdavUrl:$('davUrl').value,webdavUser:$('davUser').value,webdavPass:$('davPass').value,webdavPath:$('davPath').value,quality:$('defaultQuality').value,webdavVersionPolicy:$('davVersionPolicy').value};try{await api('/settings',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});alert('设置已保存')}catch(e){alert('保存失败:'+e.message)}});
|
$('saveSettings').addEventListener('click',async()=>{const b={amApi:$('amApi').value,downloadDir:$('downloadDir').value,localFilePolicy:$('localFilePolicy').value,webdavUrl:$('davUrl').value,webdavUser:$('davUser').value,webdavPass:$('davPass').value,webdavPath:$('davPath').value,quality:$('defaultQuality').value,webdavVersionPolicy:$('davVersionPolicy').value};try{await api('/admin/settings',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});alert('设置已保存')}catch(e){alert('保存失败:'+e.message)}});
|
||||||
$('testDav').addEventListener('click',async()=>{try{const b={url:$('davUrl').value,user:$('davUser').value,pass:$('davPass').value,path:$('davPath').value};await api('/webdav/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});alert('WebDAV 连接成功')}catch(e){alert('WebDAV 连接失败:'+e.message)}});
|
$('testDav').addEventListener('click',async()=>{try{const b={url:$('davUrl').value,user:$('davUser').value,pass:$('davPass').value,path:$('davPath').value};await api('/admin/webdav/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)});alert('WebDAV 连接成功')}catch(e){alert('WebDAV 连接失败:'+e.message)}});
|
||||||
go(location.hash.slice(1)||'home');loadRecent();setInterval(()=>{loadTasks();if(document.getElementById('home').classList.contains('active'))loadRecent()},3000);
|
initAuth().then(ok=>{if(ok){go(location.hash.slice(1)||'home');loadRecent()}});setInterval(()=>{loadTasks();if(document.getElementById('home').classList.contains('active'))loadRecent()},3000);
|
||||||
</script></body></html>
|
</script></body></html>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ func main() {
|
|||||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||||
must(err)
|
must(err)
|
||||||
must(db.AutoMigrate(&Task{}, &Setting{}, &User{}, &UserPermission{}, &CreditAccount{}, &CreditTransaction{}, &CreditReservation{}, &RedeemBatch{}, &RedeemCode{}, &RedeemRecord{}, &WebDAVConfig{}, &Session{}, &AuditLog{}))
|
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}
|
client := &http.Client{Timeout: 2 * 60 * 1e9}
|
||||||
cfg := loadConfig(db)
|
cfg := loadConfig(db)
|
||||||
@@ -38,10 +40,20 @@ func main() {
|
|||||||
auth := api.Group("", a.requireAuth)
|
auth := api.Group("", a.requireAuth)
|
||||||
auth.GET("/search", a.requirePermission(PermDownload), a.search)
|
auth.GET("/search", a.requirePermission(PermDownload), a.search)
|
||||||
auth.GET("/tasks", a.requirePermission(PermDownload), a.listTasks)
|
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", a.requirePermission(PermDownload), a.addTask)
|
||||||
auth.POST("/tasks/:id/retry", a.requirePermission(PermDownload), a.retryTask)
|
auth.POST("/tasks/:id/retry", a.requirePermission(PermDownload), a.retryTask)
|
||||||
auth.DELETE("/tasks/:id", a.requirePermission(PermDownload), a.deleteTask)
|
auth.DELETE("/tasks/:id", a.requirePermission(PermDownload), a.deleteTask)
|
||||||
admin := api.Group("/admin", a.requireAuth, a.requireAdmin)
|
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.GET("/settings", a.getSettings)
|
||||||
admin.PUT("/settings", a.putSettings)
|
admin.PUT("/settings", a.putSettings)
|
||||||
admin.POST("/webdav/test", a.testWebDAV)
|
admin.POST("/webdav/test", a.testWebDAV)
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import "time"
|
|||||||
|
|
||||||
type Task struct {
|
type Task struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
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"`
|
CreditReservationID uint `gorm:"index" json:"creditReservationId,omitempty"`
|
||||||
Source string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"source"`
|
Source string `gorm:"uniqueIndex:idx_tasks_user_source_rid_quality" json:"source"`
|
||||||
RID string `gorm:"uniqueIndex:idx_tasks_source_rid_quality" json:"rid"`
|
RID string `gorm:"uniqueIndex:idx_tasks_user_source_rid_quality" json:"rid"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Artist string `json:"artist"`
|
Artist string `json:"artist"`
|
||||||
Album string `json:"album"`
|
Album string `json:"album"`
|
||||||
Pic string `json:"pic"`
|
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"`
|
ResolvedQuality string `json:"resolvedQuality,omitempty"`
|
||||||
ActualQuality string `json:"actualQuality,omitempty"`
|
ActualQuality string `json:"actualQuality,omitempty"`
|
||||||
Format string `json:"format"`
|
Format string `json:"format"`
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
+1
-1
@@ -12,7 +12,7 @@ func (a *App) getSettings(c *gin.Context) {
|
|||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"amApi": a.cfg.AMAPI, "downloadDir": a.cfg.DownloadDir,
|
"amApi": a.cfg.AMAPI, "downloadDir": a.cfg.DownloadDir,
|
||||||
"webdavUrl": a.cfg.WebDAVURL, "webdavUser": a.cfg.WebDAVUser,
|
"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,
|
"quality": a.cfg.DefaultQuality, "webdavVersionPolicy": a.cfg.WebDAVVersionPolicy,
|
||||||
"localFilePolicy": a.cfg.LocalFilePolicy,
|
"localFilePolicy": a.cfg.LocalFilePolicy,
|
||||||
})
|
})
|
||||||
|
|||||||
+106
@@ -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
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ func (a *App) fail(t *Task, err error) {
|
|||||||
}
|
}
|
||||||
t.Status, t.Error = "error", err.Error()
|
t.Status, t.Error = "error", err.Error()
|
||||||
a.db.Save(t)
|
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(`[/\\:*?"<>|]+`)
|
var bad = regexp.MustCompile(`[/\\:*?"<>|]+`)
|
||||||
|
|||||||
@@ -7,35 +7,48 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) upload(t *Task) {
|
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
|
t.Status, t.Progress = "done", 100
|
||||||
a.db.Save(t)
|
a.db.Save(t)
|
||||||
|
if err := a.commitCredit(t); err != nil {
|
||||||
|
a.fail(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.db.Model(t).Updates(map[string]any{"status": "uploading"})
|
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
|
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)
|
a.fail(t, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if fi, err := os.Stat(t.LocalPath); err == nil {
|
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
|
t.Status, t.Progress = "done", 100
|
||||||
a.db.Save(t)
|
a.db.Save(t)
|
||||||
|
if err := a.commitCredit(t); err != nil {
|
||||||
|
a.fail(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
a.cleanupLocalFile(t)
|
a.cleanupLocalFile(t)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := a.storage.Upload(t.LocalPath, remote); err != nil {
|
if err := storage.Upload(t.LocalPath, remote); err != nil {
|
||||||
a.fail(t, err)
|
a.fail(t, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if a.cfg.WebDAVVersionPolicy == "replace_lower" {
|
if a.cfg.WebDAVVersionPolicy == "replace_lower" {
|
||||||
a.cleanupLowerQualityVersions(t)
|
a.cleanupLowerQualityVersions(t, storage)
|
||||||
}
|
}
|
||||||
t.Status, t.Progress = "done", 100
|
t.Status, t.Progress = "done", 100
|
||||||
a.db.Save(t)
|
a.db.Save(t)
|
||||||
|
if err := a.commitCredit(t); err != nil {
|
||||||
|
a.fail(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
a.cleanupLocalFile(t)
|
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}
|
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)
|
current := qualityRank(levels, t.ActualQuality, t.ResolvedQuality, t.Quality)
|
||||||
if current == 0 {
|
if current == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var old []Task
|
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 {
|
for _, candidate := range old {
|
||||||
q := qualityRank(levels, candidate.ActualQuality, candidate.ResolvedQuality, candidate.Quality)
|
q := qualityRank(levels, candidate.ActualQuality, candidate.ResolvedQuality, candidate.Quality)
|
||||||
if q == 0 || q >= current || candidate.RemotePath == "" || candidate.RemotePath == t.RemotePath {
|
if q == 0 || q >= current || candidate.RemotePath == "" || candidate.RemotePath == t.RemotePath {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_ = a.storage.Remove(candidate.RemotePath)
|
_ = storage.Remove(candidate.RemotePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user