Compare commits

..
7 Commits
55 changed files with 1002 additions and 5542 deletions
+4 -10
View File
@@ -1,11 +1,5 @@
.git
.gitignore
data
downloads
*.db
*.db-*
*.log
.env
.env.*
*.part
legacy
__pycache__
*.pyc
tests
.dedupe
-6
View File
@@ -1,6 +0,0 @@
# Optional deployment overrides. Do not put real credentials here.
PORT=9540
DB_PATH=/data/downloader.db
DOWNLOAD_DIR=/downloads
AM_API_URL=http://127.0.0.1:9530/api
WORKERS=2
+5 -8
View File
@@ -1,9 +1,6 @@
data/
downloads/
.dedupe/
*.db
*.db-*
*.log
.env
.env.*
*.part
legacy/
__pycache__/
.pytest_cache/
.venv/
am_music_deduper.egg-info/
+6 -13
View File
@@ -1,14 +1,7 @@
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/am-downloader .
FROM alpine:3.22
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends libchromaprint-tools libchromaprint1 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /out/am-downloader /app/am-downloader
COPY index.html /app/index.html
RUN mkdir -p /data /downloads
ENV PORT=9540 DB_PATH=/data/downloader.db DOWNLOAD_DIR=/downloads AM_API_URL=http://127.0.0.1:9530/api WORKERS=2
EXPOSE 9540
CMD ["/app/am-downloader"]
COPY pyproject.toml README.md ./
COPY am_deduper ./am_deduper
RUN pip install --no-cache-dir .
ENTRYPOINT ["am-deduper"]
-90
View File
@@ -1,90 +0,0 @@
# AM Downloader 多用户 V1 方案
## 目标
在现有 Go + Gin + GORM + SQLite + 下载队列 + WebDAV 架构上增量加入多用户、权限、下载额度和兑换码,不推倒重写现有 Downloader。
## 角色与权限
- `admin`:全部权限;用户、兑换码、系统设置、日志、全局 WebDAV Storage 管理。
- `privileged`:普通下载能力 + WebDAV;可以配置自己的 WebDAV。
- `user`:下载、历史、兑换额度;不能访问 WebDAV、系统设置或其他用户。
- 底层使用 capability:`download`、`redeem`、`webdav`、`webdav_config`、`api`、`advanced_download`,避免以后扩展时绑定死角色。
## 设置隔离
- 用户设置:账户、外观;特权用户额外有自己的 WebDAV。
- 管理设置:`/admin/*`,只有管理员可见且后端强制校验。
- 现有全局 AM API、下载目录、默认音质、WebDAV Storage 等属于管理员配置。
- 前端隐藏不是安全措施;API 必须返回 401/403。
## WebDAV
- 管理员定义 Storage Root,例如 `/data/webdav`。
- 特权用户拥有自己的 WebDAV 配置和独立根目录 `/data/webdav/{user_id}/`。
- 禁止用户通过 `..`、绝对路径等逃逸 Storage Root。
- WebDAV 权限和 WebDAV 配置权限分离,方便以后扩展。
- 现有全局 WebDAV 继续作为系统级远程 Storage,逐步迁移为管理员配置。
## Credits
- 一首实际成功下载的歌曲消耗 1 credit。
- 已存在且可复用的文件不扣 credit。
- 新下载创建任务时预留 credit;成功 commit,失败 refund。
- 同一任务重试不能重复扣费。
- 批量任务按实际成功数量结算。
- 所有额度变化进入不可变流水,不允许直接改余额。
## Redeem Code
- 管理员可按批次批量生成兑换码。
- 每个兑换码可以有自定义 credits、有效期、备注。
- 状态:`unused`、`used`、`expired`、`revoked`。
- 数据库只保存 code hash;生成后提供复制/导出明文。
- 兑换必须事务化,防止并发双兑。
- 管理员可以作废兑换码、查看批次统计和兑换记录。
## 数据模型
- `users`
- `user_permissions`
- `credit_accounts`
- `credit_transactions`
- `credit_reservations`
- `redeem_batches`
- `redeem_codes`
- `redeem_records`
- `webdav_configs`
- `audit_logs`
- `sessions`
- 现有 `tasks` 增加 `user_id`、`credit_reservation_id`,继续保留全局资源去重索引。
- `settings` 继续保存管理员系统设置。
## 下载与去重
不要简单把现有唯一键改成 `user_id + source + rid + quality`。用户任务与全局资源/文件应逻辑分离:同一资源可以被多个用户请求,但每个用户拥有自己的任务、额度流水和访问边界。第一阶段保持现有 Task 模型兼容,再逐步增加资源复用层。
## API 分层
- `/api/auth/*`:登录、登出、当前用户。
- `/api/tasks`:当前用户任务;所有查询按 user_id 隔离。
- `/api/credits/*`:余额、流水、兑换。
- `/api/webdav/*`:仅拥有 webdav 权限的用户。
- `/api/admin/users/*`:管理员。
- `/api/admin/redeem/*`:管理员。
- `/api/admin/settings/*`:管理员。
- `/api/admin/audit/*`:管理员。
## 安全
- 密码只保存强哈希。
- Session 使用随机不可预测 token,数据库保存 hash。
- 登录限速。
- 管理员敏感操作记录 audit log。
- 额度操作必须事务化。
- 兑换码不可明文持久化。
- WebDAV 路径必须做目录边界校验。
- 用户绝不能通过 API 读取其他用户 Task、Credit、WebDAV 配置。
## 实施顺序
1. 数据模型与数据库迁移
2. Session/Auth
3. 权限中间件
4. Task 用户隔离
5. Credit Ledger + Reservation
6. 下载成功/失败扣退额度
7. Redeem Code
8. WebDAV 特权用户隔离
9. 管理后台 API
10. 前端用户/特权/管理员界面
11. 测试、迁移兼容性、备份与部署
+121 -97
View File
@@ -1,123 +1,147 @@
# AM Downloader
# AM Music Deduper
独立的音乐搜索与下载 Web 服务,使用 AM 音乐 API 解析资源,并可选同步到 WebDAV/NAS。
轻量级家庭 NAS 音乐库去重服务,面向 `AllMusic/{username}/` 场景。
## 特性
## 处理模型
- 多用户登录、角色与权限隔离(admin / privileged / user)
- 积分账户、预扣、成功扣除、失败退款
- 兑换码批次生成、使用、撤销与审计
- 音乐搜索与分页
- 下载队列、用户任务去重、跨用户媒体资源去重、失败重试
- 自动识别实际音频格式,避免错误扩展名
- 标记解析后的音质与实际音质
- WebDAV/NAS 同步
- 可配置同曲低音质版本清理策略
- 可配置上传成功后的本地文件清理策略
- Docker Compose 部署
## 架构
`9540` 提供 Downloader Web/API;音乐解析服务通过配置的 `AM_API_URL` 访问,默认指向本机 `9530`。
运行时数据库和下载文件不会进入 Git 仓库。
## 快速部署
```bash
git clone <your-gitea-repository>
cd am-downloader
docker compose up -d --build
```text
AllMusic
↓
文件稳定检测(size + mtime)
↓
L1:size + head/tail 64 KiB
↓
完整 SHA-256
├─ 相同 → Exact Duplicate → hard link
└─ 不同 → duration ±3s → Chromaprint
├─ 高相似且版本一致 → hard link
├─ 中等相似/版本冲突 → review
└─ 低相似 → 保留
```
默认访问:`http://127.0.0.1:9540`
### 安全规则
## 配置
- SHA-256 是 Exact Duplicate 的最终依据;L1 只负责缩小候选范围。
- 不根据文件名、歌手、歌名、专辑或来源单独判重。
- 确认相同版本后,优先使用 hard link 保留每个用户原来的逻辑路径。
- 用户删除自己的路径不会影响其他 hard link;最后一个 link 删除后数据才释放。
- hard link 要求源文件和目标文件位于同一文件系统。
- 正在写入的文件必须先通过稳定性检查。
- L2 对版本标记冲突的文件进入 review,不自动合并。
- SQLite 是加速索引,文件系统是最终事实;数据库丢失后可以重新扫描。
- `.dedupe` 不参与音乐扫描,避免把内部文件再次处理。
Docker Compose 中只保存非敏感的运行参数。WebDAV 凭据通过 Web UI 的设置页面写入运行时数据库,**不要将数据库提交到 Git**。
可配置:
- `PORT`:Web 服务端口,默认 `9540`
- `DB_PATH`:SQLite 数据库路径
- `DOWNLOAD_DIR`:下载目录
- `AM_API_URL`:AM 音乐 API 地址
- `WORKERS`:下载 worker 数量
首次部署后进入设置页面填写 WebDAV 地址、用户名、密码和远程目录。
## 数据与安全
以下内容属于运行时数据,默认被 `.gitignore` 排除:
- `data/`
- `downloads/`
- `*.db`
- 日志、临时文件和本地环境文件
提交前应检查 Git diff,确认没有密码、Token、Cookie、私有密钥或个人环境配置。
## 开发
## CLI
```bash
go test ./...
go build -o am-downloader .
# 建立/更新索引,不修改音乐文件
am-deduper scan /music/AllMusic --db /data/dedupe.db
# Exact + L2;首次执行建议 dry-run
am-deduper dedupe /music/AllMusic --db /data/dedupe.db --dry-run
# 确认后执行
am-deduper dedupe /music/AllMusic --db /data/dedupe.db
# 长驻监听:默认只自动执行安全的 Exact hard-link
am-deduper watch /music/AllMusic --db /data/dedupe.db
# 开启 L2 acoustic 匹配;建议先 dry-run 验证
am-deduper watch /music/AllMusic --db /data/dedupe.db --l2
```
前端为单页静态界面,由 Go 服务直接提供。
## Feiniu NAS 部署
## API
生产环境采用 **Gitea Container Registry → NAS 拉取镜像 → NAS 本地运行**。
镜像由 wjyhk 构建并推送到 `gitea.king.nyc.mn`,Feiniu NAS 不需要本地 build。
认证:
建议目录:
- `POST /api/auth/login`
- `POST /api/auth/logout`
- `POST /api/auth/password`
- `GET /api/auth/me`
```text
NAS
├── Gitea Registry
│ └── gitea.king.nyc.mn/openclaw/am-deduper:latest
├── AllMusic/
│ ├── admin/
│ ├── alice/
│ └── bob/
└── AppData/
└── am-deduper/
└── dedupe.db
```
用户功能:
### NAS `.env`
- `GET /api/search`
- `GET /api/tasks`
- `POST /api/tasks`
- `POST /api/tasks/:id/retry`
- `DELETE /api/tasks/:id`
- `GET /api/credits`
- `POST /api/redeem`
- `GET/PUT /api/webdav/config`
- `POST /api/webdav/test`
```dotenv
ALLMUSIC_PATH=<NAS绝对路径>/AllMusic
DEDUPE_DATA_PATH=<NAS绝对路径>/AppData/am-deduper
```
管理员功能:
### 首次部署
- `GET/POST /api/admin/users`
- `PUT /api/admin/users/:id`
- `GET /api/admin/redeem`
- `POST /api/admin/redeem`
- `POST /api/admin/redeem/:id/revoke`
- `GET /api/admin/audit`
- `GET/PUT /api/admin/settings`
- `POST /api/admin/webdav/test`
先登录 Gitea Container Registry:
## 数据库迁移注意事项
```bash
docker login gitea.king.nyc.mn
```
首次从旧版本升级时,启动过程会自动执行 GORM schema migration,并将旧任务归属到首次创建的管理员账号;同时把旧的全局任务唯一索引改为按用户隔离的唯一索引,并创建共享媒体资源表。
拉取生产镜像并准备数据目录:
升级前仍建议先备份 `data/downloader.db`。Docker 部署时不要把 `data/` 或 `downloads/` 加入 Git。
```bash
docker pull gitea.king.nyc.mn/openclaw/am-deduper:latest
mkdir -p <NAS绝对路径>/AppData/am-deduper
```
当前版本不会主动删除旧媒体文件;媒体资源记录缺失本地文件时会在下一次请求中重新取得资源。
然后使用 Compose 启动容器;当前 Compose 已配置 NAS 实际目录,并提供独立 Web 管理界面。首次执行建议先运行 scan 和 dry-run:
## 多用户并发与恢复
```bash
docker compose run --rm am-deduper scan /music/AllMusic --db /data/dedupe.db
docker compose run --rm am-deduper dedupe /music/AllMusic --db /data/dedupe.db --dry-run
```
下载任务和共享媒体资源分离:
确认 dry-run 输出后,再执行正式 Exact/L2:
- 同一 source + rid + quality 只允许一个 Asset 物理下载,多个用户 Task 可以共享。
- 每个用户 Task 保留独立 WebDAV 目标路径、状态和额度预留。
- Task 使用数据库条件更新抢占状态,重复点击不会启动多个 worker。
- Asset 使用数据库租约(heartbeat/lease)防止 worker 崩溃后永久占锁;过期租约会自动恢复旧任务。
- 下载文件使用按 Asset/generation 隔离的缓存目录和 task-specific .part 临时文件,避免并发用户覆盖同一个本地文件。
- WebDAV 上传采用独立的 upload_pending -> uploading -> done 状态机;上传失败只重试上传,不重新扣积分。
- 已提交额度的历史失败任务会自动恢复;已退款任务不会因为后台 resync 被无条件重新扣费。
- SQLite 使用 WAL + busy timeout,并对关键事务做锁冲突重试;高并发写入仍由 SQLite 单写者模型串行化。
- 本地共享文件只有在没有其他活动 Task 需要时才允许清理。
```bash
docker compose run --rm am-deduper dedupe /music/AllMusic --db /data/dedupe.db
```
并发回归测试覆盖:Task 抢占、共享 Asset 租约恢复、已提交额度重试、已退款额度重试、并发积分预留以及 Asset generation 隔离。
然后启动长期监听和 Web 管理界面:
```bash
docker compose up -d
```
Web UI 默认端口为 `18080`:
```text
http://<NAS-IP>:18080/
```
管理界面包含:
- Dashboard:文件数、Hard Link、审核数、索引大小、操作记录。
- 重复组:查看 SHA-256 完全重复组及估算节省空间。
- 待审核:查看相似度、候选文件,可人工确认或保留。
- 任务:查看 scan / dry-run / dedupe 状态。
- 历史:查看 Hard Link 操作,并对仍保持同 inode 的操作执行安全回滚。
- 扫描会把数据库中已经不存在的文件标记为 `missing`,避免脏索引继续计入统计。
> Web UI 当前面向 NAS 内网使用,未启用账号密码;不要直接暴露到公网。
更新版本:
```bash
docker pull gitea.king.nyc.mn/openclaw/am-deduper:latest
docker compose up -d
```
当前生产镜像:
```text
gitea.king.nyc.mn/openclaw/am-deduper:latest
```
wjyhk 已验证推送成功;当前镜像 digest:
```text
sha256:96e0b87e454bc9fa96aa5171b117a029bbcef34d20099577db6442544ce5c9ee
```
-78
View File
@@ -1,78 +0,0 @@
# AM Downloader 交互界面 (UI) 重构与优化文档
## 1. 概述与背景
`am-downloader` 原先的 `index.html` 采用单页平铺逻辑,随着多用户鉴权、积分兑换、用户级 WebDAV 与管理员后台的引入,旧版界面存在层级分散、导航冗余、缺乏现代感与移动端适配生硬等问题。
本项目已完成系统级的前端现代化重构,并保持对后端原有 API 契约和前端 DOM 事件完全兼容,目前已在运行中的 Docker 容器环境(端口 `9540`)热更新上线生效。
---
## 2. 视觉设计系统规范
### 2.1 配色与质感(Modern Dark Glassmorphism)
- **深色基底**:主背景采用 `#080c14`,配合双色极光渐变暗影(翡翠绿 `#10b981` 与天蓝 `#38bdf8`),提升流媒体科技质感。
- **毛玻璃面板**:卡片与导航栏统一采用 `rgba(17, 26, 42, 0.75)` 配以 `backdrop-filter: blur(16px)`,边框引入半透明微发光分割线(`rgba(255, 255, 255, 0.08)`)。
- **字体与层级**:接入 `Plus Jakarta Sans` 作为主要无衬线英文字体,`JetBrains Mono` 作为代码、卡密与音质参数等宽字体。
---
## 3. 核心功能与架构重构细节
### 3.1 导航体系净化与精简
1. **移除顶部及移动端独立「设置」按钮**:
- 彻底移除了原先顶栏导航与移动端底栏的独立「设置」入口。
- 所有系统运行配置统一纳管在管理员后台内部,避免入口碎片化。
2. **下载按钮去除角标数字**:
- 移除桌面端导航与移动端底栏「下载」按钮后方突兀的数字徽章(Badge),保持导航栏整体视觉纯粹和谐。
- 保留 DOM 节点以保证后台任务轮询更新逻辑的向后兼容,不报错不中断。
### 3.2 管理控制台(Admin Center)全面重构
1. **分段式选项卡(Segmented Tabs)**:
- 废弃原有的“点击磁贴切换子面板再返回”繁琐交互,重构为顶部毛玻璃分段 Tab 栏:
- `👥 用户管理`
- `◎ 积分账本`
- `◇ 卡密批次`
- `≡ 审计日志`
- `⚙️ 全局设置`(由原“纳管设置”更名,系统 API、下载存储策略、全局 WebDAV 完整收拢在此)
- Tab 切换平滑无刷新,记忆当前选中状态。
2. **抽屉详情交互(Slide-over Drawer)**:
- 用户详情不再替换整页或跳转离开当前列表。
- 点击任意用户的「查看详情」时,桌面端由右侧平滑滑出毛玻璃抽屉面板,移动端自适应为底部滑起抽屉(Bottom Sheet)。
- 抽屉内置:账号角色与状态、密码重置、账号启停、积分增减与实时流水。
- 配合磨砂半透明背景遮罩(Backdrop),点击空白处或「✕ 关闭」按钮即可无缝收起,列表浏览上下文不丢失。
---
## 4. 兼容性与上线记录
- **DOM 契约兼容**:保留所有核心配置 ID(`amApi`、`downloadDir`、`localFilePolicy`、`defaultQuality`、`davUrl` 等)以及事件回调机制。
- **上线状态**:最新代码已提交 Git(分支 `master`),并同步热更新至运行中的 `am-downloader` 容器内 `/app/index.html`。
---
## 5. 交互与细节修复记录(2026-09-17)
### 5.1 移动端底部导航栏贴底优化
- **改动说明**:解决移动端底栏浮岛胶囊式设计(悬空 `12px` 且带圆角)在滚动时产生的文字从缝隙穿插、视觉割裂问题。
- **调整实现**:
- 改为全宽贴底原生 TabBar 布局(`left: 0; right: 0; bottom: 0; border-radius: 0;`)。
- 顶部微弱边框隔离(`border-top: 1px solid var(--border-subtle)`),四周无多余外边框。
- 适配安全区域与深色毛玻璃(`padding: 6px 8px calc(6px + env(safe-area-inset-bottom, 0px)); background: rgba(8, 12, 20, 0.95); backdrop-filter: blur(20px);`)。
### 5.2 全局接口与弹窗提示中文语义化
- **改动说明**:解决兑换失败、加入队列失败时直接将后端原始错误 JSON(如 `["error""code already used or unavailable"]`)弹给用户的粗糙体验。
- **调整实现**:
- 在底层封装统一错误解析器 `parseErrorMsg(msg)`,自动解包后端返回的 JSON 结构。
- 建立错误语义字典映射:
- `code already used or unavailable` → **该兑换码已被使用或已失效**
- `record not found` → **兑换码不存在,请核对后重试**
- `insufficient credits` → **积分不足,请先兑换或充值积分**
- `code expired` → **该兑换码已过期**
- `user disabled` → **账户已被禁用,无法执行此操作**
- `only failed tasks can be retried` → **只有失败的任务才支持重试**
- 其他校验项(用户名/密码规则、金额非零等)同步完成中文友好化。
### 5.3 用户管理抽屉积分调整事件委托审核
- **问题根因**:原代码在为 `detailAdd` 与 `detailSub` 绑定 `onclick` 后,通过 `innerHTML += ...` 追加积分流水,导致整个容器内 DOM 被全量重构,先前绑定的监听器失效。
- **修复方案与审核**:通过 `document.createElement('div')` + `appendChild(ledger)` 实现流水平行追加,既保留了已渲染节点的事件监听,又保证了 DOM 结构与 ID 契约完整无副作用。
-91
View File
@@ -1,91 +0,0 @@
package main
import (
"github.com/gin-gonic/gin"
"strconv"
)
func (a *App) adminUserCreditTransactions(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(400, gin.H{"error": "invalid user id"})
return
}
limit := 50
if n, e := strconv.Atoi(c.DefaultQuery("limit", "50")); e == nil && n > 0 && n <= 200 {
limit = n
}
var txs []CreditTransaction
q := a.db.Where("user_id = ?", id).Order("id desc").Limit(limit)
if before := c.Query("before"); before != "" {
if n, e := strconv.ParseUint(before, 10, 64); e == nil {
q = q.Where("id < ?", n)
}
}
if err := q.Find(&txs).Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
type item struct {
ID uint `json:"id"`
Type string `json:"type"`
Amount int64 `json:"amount"`
BalanceAfter int64 `json:"balanceAfter"`
ReferenceType string `json:"referenceType"`
ReferenceID string `json:"referenceId"`
Reason string `json:"reason"`
CreatedAt interface{} `json:"createdAt"`
}
out := make([]item, 0, len(txs))
for _, t := range txs {
out = append(out, item{t.ID, t.Type, t.Amount, t.BalanceAfter, t.ReferenceType, t.ReferenceID, t.Reason, t.CreatedAt})
}
next := uint(0)
if len(txs) == limit {
next = txs[len(txs)-1].ID
}
c.JSON(200, gin.H{"data": out, "nextBefore": next})
}
func (a *App) adminCreditTransactions(c *gin.Context) {
limit := 100
if n, e := strconv.Atoi(c.DefaultQuery("limit", "100")); e == nil && n > 0 && n <= 300 {
limit = n
}
var txs []CreditTransaction
q := a.db.Order("id desc").Limit(limit)
if uid := c.Query("user_id"); uid != "" {
if n, e := strconv.ParseUint(uid, 10, 64); e == nil {
q = q.Where("user_id = ?", n)
}
}
if before := c.Query("before"); before != "" {
if n, e := strconv.ParseUint(before, 10, 64); e == nil {
q = q.Where("id < ?", n)
}
}
if err := q.Find(&txs).Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
type item struct {
ID uint `json:"id"`
UserID uint `json:"userId"`
Type string `json:"type"`
Amount int64 `json:"amount"`
BalanceAfter int64 `json:"balanceAfter"`
ReferenceType string `json:"referenceType"`
ReferenceID string `json:"referenceId"`
Reason string `json:"reason"`
CreatedAt interface{} `json:"createdAt"`
}
out := make([]item, 0, len(txs))
for _, t := range txs {
out = append(out, item{t.ID, t.UserID, t.Type, t.Amount, t.BalanceAfter, t.ReferenceType, t.ReferenceID, t.Reason, t.CreatedAt})
}
next := uint(0)
if len(txs) == limit {
next = txs[len(txs)-1].ID
}
c.JSON(200, gin.H{"data": out, "nextBefore": next})
}
-362
View File
@@ -1,362 +0,0 @@
package main
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func (a *App) audit(operator uint, action, targetType, targetID, metadata string) {
_ = a.db.Create(&AuditLog{OperatorID: operator, Action: action, TargetType: targetType, TargetID: targetID, Metadata: metadata}).Error
}
var usernamePattern = regexp.MustCompile(`^[A-Za-z]{4,10}$`)
func defaultPermissions(role string) []string {
if role == RolePrivileged {
return []string{PermDownload, PermRedeem, PermWebDAV, PermWebDAVConfig}
}
if role == RoleUser {
return []string{PermDownload, PermRedeem, PermWebDAV}
}
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
}
type userView struct {
User
Balance int64 `json:"balance"`
Reserved int64 `json:"reserved"`
Available int64 `json:"available"`
TaskCount int64 `json:"taskCount"`
CompletedCount int64 `json:"completedCount"`
}
out := make([]userView, 0, len(users))
for i := range users {
u := users[i]
u.PasswordHash = ""
var account CreditAccount
_ = a.db.Where("user_id = ?", u.ID).First(&account).Error
var taskCount, completedCount int64
_ = a.db.Model(&Task{}).Where("user_id = ?", u.ID).Count(&taskCount).Error
_ = a.db.Model(&Task{}).Where("user_id = ? AND status = ?", u.ID, "done").Count(&completedCount).Error
out = append(out, userView{User: u, Balance: account.Balance, Reserved: account.Reserved, Available: account.Balance - account.Reserved, TaskCount: taskCount, CompletedCount: completedCount})
}
c.JSON(200, gin.H{"data": out})
}
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 {
c.JSON(400, gin.H{"error": "invalid request body"})
return
}
in.Username = strings.TrimSpace(in.Username)
if !usernamePattern.MatchString(in.Username) || len(in.Password) < 8 {
c.JSON(400, gin.H{"error": "username must be 4-10 English letters and password must be 8+ characters"})
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
}
admin := c.MustGet("user").(*User)
var u User
err = a.db.Transaction(func(tx *gorm.DB) error {
u = User{Username: in.Username, PasswordHash: hash, Role: in.Role, Status: UserActive}
if err := tx.Create(&u).Error; err != nil {
return err
}
if in.Role != RoleAdmin {
root := cleanWebDAVRoot(strings.TrimRight(a.cfg.WebDAVPath, "/") + "/" + u.Username)
if err := tx.Create(&WebDAVConfig{UserID: u.ID, Enabled: true, RootPath: root}).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 = ""
a.audit(admin.ID, "user.create", "user", strconv.FormatUint(uint64(u.ID), 10), "role="+u.Role)
c.JSON(201, u)
}
func (a *App) adminAdjustUserCredits(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(400, gin.H{"error": "invalid user id"})
return
}
var in struct {
Amount int64 `json:"amount"`
Reason string `json:"reason"`
}
if c.BindJSON(&in) != nil || in.Amount == 0 {
c.JSON(400, gin.H{"error": "amount must not be zero"})
return
}
operator := c.MustGet("user").(*User)
var account CreditAccount
err = a.db.Transaction(func(tx *gorm.DB) error {
var u User
if err := tx.First(&u, id).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", u.ID).First(&account).Error; err != nil {
if err != gorm.ErrRecordNotFound {
return err
}
account = CreditAccount{UserID: u.ID}
if err := tx.Create(&account).Error; err != nil {
return err
}
}
newBalance := account.Balance + in.Amount
if newBalance < account.Reserved {
return errors.New("balance cannot be lower than reserved credits")
}
account.Balance = newBalance
if err := tx.Save(&account).Error; err != nil {
return err
}
reason := strings.TrimSpace(in.Reason)
if reason == "" {
reason = "admin credit adjustment"
}
return tx.Create(&CreditTransaction{UserID: u.ID, Type: "adjust", Amount: in.Amount, BalanceAfter: account.Balance, ReferenceType: "admin", ReferenceID: strconv.FormatUint(uint64(operator.ID), 10), Reason: reason}).Error
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(404, gin.H{"error": "user not found"})
return
}
c.JSON(400, gin.H{"error": err.Error()})
return
}
a.audit(operator.ID, "user.credit.adjust", "user", strconv.FormatUint(id, 10), fmt.Sprintf("amount=%d;reason=%s", in.Amount, strings.TrimSpace(in.Reason)))
c.JSON(200, gin.H{"balance": account.Balance, "reserved": account.Reserved, "available": account.Balance - account.Reserved})
}
func (a *App) adminAudit(c *gin.Context) {
var logs []AuditLog
q := a.db.Order("id desc").Limit(200)
if action := c.Query("action"); action != "" {
q = q.Where("action = ?", action)
}
if err := q.Find(&logs).Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"data": logs})
}
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
}
operator := c.MustGet("user").(*User)
if u.ID == operator.ID && (u.Role != RoleAdmin || u.Status == UserDisabled) {
var admins int64
if err := a.db.Model(&User{}).Where("role = ? AND status = ?", RoleAdmin, UserActive).Count(&admins).Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
if admins <= 1 {
c.JSON(400, gin.H{"error": "cannot remove the last active admin account"})
return
}
}
if err := a.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(&u).Error; err != nil {
return err
}
if in.Password != nil {
if err := tx.Where("user_id = ?", u.ID).Delete(&Session{}).Error; err != nil {
return err
}
}
if in.Role != nil {
if err := tx.Where("user_id = ?", u.ID).Delete(&UserPermission{}).Error; err != nil {
return err
}
for _, p := range defaultPermissions(u.Role) {
if err := tx.Create(&UserPermission{UserID: u.ID, Permission: p, Enabled: true}).Error; err != nil {
return err
}
}
}
return nil
}); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
u.PasswordHash = ""
changes := []string{}
if in.Password != nil {
changes = append(changes, "password")
}
if in.Role != nil {
changes = append(changes, "role="+u.Role)
}
if in.Status != nil {
changes = append(changes, "status="+u.Status)
}
a.audit(operator.ID, "user.update", "user", strconv.FormatUint(uint64(u.ID), 10), strings.Join(changes, ","))
c.JSON(200, u)
}
func (a *App) adminUserCredits(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.Status(400)
return
}
var account CreditAccount
if err := a.db.Where("user_id = ?", id).First(&account).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(404, gin.H{"error": "credit account not found"})
return
}
c.JSON(500, gin.H{"error": err.Error()})
return
}
var tasks int64
if err := a.db.Model(&Task{}).Where("user_id = ?", id).Count(&tasks).Error; 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, "tasks": tasks})
}
func (a *App) adminAdjustCredits(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.Status(400)
return
}
var in struct {
Amount int64 `json:"amount"`
Reason string `json:"reason"`
}
if c.BindJSON(&in) != nil || in.Amount == 0 {
c.JSON(400, gin.H{"error": "amount cannot be zero"})
return
}
operator := c.MustGet("user").(*User)
err = a.db.Transaction(func(tx *gorm.DB) error {
var account CreditAccount
if err := tx.Where("user_id = ?", id).First(&account).Error; err != nil {
return err
}
if in.Amount < 0 && account.Balance+in.Amount < account.Reserved {
return errors.New("cannot reduce balance below reserved credits")
}
account.Balance += in.Amount
if account.Balance < 0 {
return errors.New("insufficient balance")
}
if err := tx.Save(&account).Error; err != nil {
return err
}
typ, reason := "grant", strings.TrimSpace(in.Reason)
if reason == "" {
reason = "admin credit adjustment"
}
if in.Amount < 0 {
typ = "admin_debit"
if strings.TrimSpace(in.Reason) == "" {
reason = "admin credit deduction"
}
}
return tx.Create(&CreditTransaction{UserID: uint(id), Type: typ, Amount: in.Amount, BalanceAfter: account.Balance, ReferenceType: "admin", ReferenceID: strconv.FormatUint(uint64(operator.ID), 10), Reason: reason}).Error
})
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
a.audit(operator.ID, "user.credits.adjust", "user", strconv.FormatUint(id, 10), "amount="+strconv.FormatInt(in.Amount, 10))
var account CreditAccount
_ = a.db.Where("user_id = ?", id).First(&account)
c.JSON(200, gin.H{"ok": true, "balance": account.Balance, "reserved": account.Reserved, "available": account.Balance - account.Reserved})
}
-61
View File
@@ -1,61 +0,0 @@
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
}
+1
View File
@@ -0,0 +1 @@
__version__ = "0.1.0"
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import json
import subprocess
from pathlib import Path
MAX_SECONDS = 120
MAX_BIT_ERROR = 2
MAX_ALIGN_OFFSET = 120
def chromaprint(path: Path) -> str | None:
try:
p = subprocess.run(
["fpcalc", "-length", str(MAX_SECONDS), "-json", str(path)],
capture_output=True, text=True, timeout=180, check=True,
)
value = json.loads(p.stdout).get("fingerprint")
return value if isinstance(value, str) and value else None
except (OSError, subprocess.SubprocessError, ValueError, json.JSONDecodeError):
return None
def _popcount(value: int) -> int:
return value.bit_count()
def similarity(a: str, b: str) -> float:
"""Compare Chromaprint fingerprints using the AcoustID alignment method.
This is deliberately bounded to the local fingerprint comparison and does
not contact the AcoustID service.
"""
if not a or not b:
return 0.0
try:
import chromaprint as cp
aa = [int(x) for x in cp.decode_fingerprint(a.encode())[0]]
bb = [int(x) for x in cp.decode_fingerprint(b.encode())[0]]
except (ImportError, AttributeError, TypeError, ValueError):
return 0.0
if not aa or not bb:
return 0.0
counts = [0] * (len(aa) + len(bb) + 1)
for i, av in enumerate(aa):
for j in range(max(0, i - MAX_ALIGN_OFFSET), min(len(bb), i + MAX_ALIGN_OFFSET)):
if _popcount(av ^ bb[j]) <= MAX_BIT_ERROR:
counts[i - j + len(bb)] += 1
return max(counts) / min(len(aa), len(bb))
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from pathlib import Path
FORMAT_SCORE = {"flac": 100, "alac": 100, "wav": 100, "m4a": 60, "aac": 60, "ogg": 50, "opus": 50, "mp3": 40}
def score(row) -> int:
value = FORMAT_SCORE.get((row["format"] or "").lower(), 0)
value += int((row["bitrate"] or 0) * 0.1)
if row["sample_rate"]:
value += min(int(row["sample_rate"] / 1000), 20)
if row["bit_depth"]:
value += min(int(row["bit_depth"]), 32)
return value
def is_version_conflict(a, b) -> bool:
af = set((a["version_flags"] or "").split(","))
bf = set((b["version_flags"] or "").split(","))
return bool(af.symmetric_difference(bf))
def choose_winner(rows):
def key(r):
path = str(r["file_path"])
admin = "/admin/" in path or path.endswith("/admin")
return (-score(r), 0 if admin else 1, r["created_at"], path)
return min(rows, key=key)
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import argparse
from pathlib import Path
from .exact import exact_dedupe
from .l2 import acoustic_dedupe
from .scanner import scan
from .watcher import watch
from .web import serve
def _db_path(root: Path, explicit: Path | None) -> Path:
if explicit:
return explicit
if str(root).startswith("/music/"):
return Path("/data/dedupe.db")
return root / ".dedupe" / "dedupe.db"
def main() -> None:
parser = argparse.ArgumentParser(prog="am-deduper")
sub = parser.add_subparsers(dest="cmd", required=True)
for name in ("scan", "dedupe", "reconcile"):
sp = sub.add_parser(name)
sp.add_argument("root", type=Path)
sp.add_argument("--db", type=Path, default=None)
if name != "scan":
sp.add_argument("--dry-run", action="store_true")
srv = sub.add_parser("server")
srv.add_argument("--host", default="0.0.0.0")
srv.add_argument("--port", type=int, default=8080)
wp = sub.add_parser("watch")
wp.add_argument("root", type=Path)
wp.add_argument("--db", type=Path, default=None)
wp.add_argument("--l2", action="store_true", help="enable acoustic matching")
args = parser.parse_args()
if args.cmd == "server":
serve(args.host, args.port)
return
db = _db_path(args.root, args.db)
if args.cmd == "watch":
watch(args.root, db, l2=args.l2)
return
indexed = scan(args.root, db)
print(f"indexed={indexed} db={db}")
if args.cmd == "scan":
return
exact = exact_dedupe(db, args.root, dry_run=args.dry_run)
print(f"exact_hardlinked={len(exact)} dry_run={args.dry_run}")
l2 = acoustic_dedupe(db, args.root, dry_run=args.dry_run)
print(f"l2={l2} dry_run={args.dry_run}")
+15
View File
@@ -0,0 +1,15 @@
from pathlib import Path
MUSIC_EXTENSIONS = frozenset({
".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".wav", ".alac",
})
FAST_HASH_CHUNK = 64 * 1024
STABILITY_CHECKS = 2
STABILITY_INTERVAL_SECONDS = 2
TRASH_DIRNAME = ".dedupe"
TRASH_SUBDIR = "trash"
QUARANTINE_SUBDIR = "quarantine"
VERSION_MARKERS = frozenset({
"live", "remix", "acoustic", "karaoke", "instrumental", "demo",
"radio edit", "edit", "version", "mono", "stereo", "concert",
})
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
SCHEMA = """
CREATE TABLE IF NOT EXISTS audio_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_path TEXT UNIQUE NOT NULL,
file_size INTEGER NOT NULL,
fast_hash TEXT NOT NULL,
sha256 TEXT,
duration REAL,
artist TEXT,
title TEXT,
album TEXT,
format TEXT,
bitrate INTEGER,
sample_rate INTEGER,
bit_depth INTEGER,
chromaprint TEXT,
score INTEGER,
status TEXT NOT NULL DEFAULT 'active',
inode INTEGER,
device INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
trashed_at TEXT,
review_status TEXT
);
CREATE INDEX IF NOT EXISTS idx_fast_hash ON audio_tracks(fast_hash);
CREATE INDEX IF NOT EXISTS idx_sha256 ON audio_tracks(sha256);
CREATE INDEX IF NOT EXISTS idx_duration ON audio_tracks(duration);
CREATE INDEX IF NOT EXISTS idx_status ON audio_tracks(status);
CREATE INDEX IF NOT EXISTS idx_duration_size ON audio_tracks(duration, file_size);
CREATE TABLE IF NOT EXISTS operations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
file_path TEXT NOT NULL,
winner_path TEXT,
details TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_operations_created ON operations(created_at);
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
status TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at TEXT,
result TEXT,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_jobs_started ON jobs(started_at);
"""
def connect(path: Path) -> sqlite3.Connection:
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA busy_timeout=5000")
conn.executescript(SCHEMA)
cols={r[1] for r in conn.execute("PRAGMA table_info(audio_tracks)")}
if "version_flags" not in cols:
conn.execute("ALTER TABLE audio_tracks ADD COLUMN version_flags TEXT")
return conn
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
import os
from pathlib import Path
from .db import connect
from .hashing import sha256
def confirm_sha256(conn, row) -> str | None:
path = Path(row["file_path"])
try:
st = path.stat()
except (FileNotFoundError, PermissionError):
return None
if st.st_size != row["file_size"]:
return None
digest = sha256(path)
try:
st2 = path.stat()
except (FileNotFoundError, PermissionError):
return None
if st2.st_size != st.st_size or st2.st_mtime_ns != st.st_mtime_ns:
return None
conn.execute(
"UPDATE audio_tracks SET sha256=?,updated_at=CURRENT_TIMESTAMP WHERE id=?",
(digest, row["id"]),
)
return digest
def hardlink_duplicate(loser: Path, winner: Path) -> None:
"""Atomically replace loser with a hard link to winner."""
if loser.stat().st_dev != winner.stat().st_dev:
raise OSError("hard link requires both files on the same filesystem")
tmp = loser.with_name(loser.name + ".dedupe-linking")
try:
if tmp.exists():
tmp.unlink()
os.link(winner, tmp)
os.replace(tmp, loser)
finally:
if tmp.exists():
try:
tmp.unlink()
except OSError:
pass
def choose_winner(rows):
def key(r):
p = str(r["file_path"])
admin = "/admin/" in p or p.endswith("/admin")
return (0 if admin else 1, r["created_at"], p)
return min(rows, key=key)
def exact_dedupe(db_path: Path, root: Path, dry_run: bool = False) -> list[tuple[str, str]]:
actions: list[tuple[str, str]] = []
root = root.resolve()
with connect(db_path) as conn:
rows = conn.execute(
"SELECT * FROM audio_tracks WHERE status IN ('active','hardlinked')"
).fetchall()
groups: dict[tuple[int, str], list] = {}
for row in rows:
groups.setdefault((row["file_size"], row["fast_hash"]), []).append(row)
for candidates in groups.values():
if len(candidates) < 2:
continue
for row in candidates:
if not row["sha256"]:
confirm_sha256(conn, row)
ids = [row["id"] for row in candidates]
placeholders = ",".join("?" * len(ids))
refreshed = conn.execute(
f"SELECT * FROM audio_tracks WHERE id IN ({placeholders})", ids
).fetchall()
by_sha: dict[str, list] = {}
for row in refreshed:
if row["sha256"]:
by_sha.setdefault(row["sha256"], []).append(row)
for same in by_sha.values():
if len(same) < 2:
continue
winner = choose_winner(same)
wp = Path(winner["file_path"])
if not wp.exists() or not wp.is_relative_to(root):
continue
for row in same:
if row["id"] == winner["id"]:
continue
lp = Path(row["file_path"])
if not lp.exists() or not lp.is_relative_to(root):
continue
if lp.stat().st_ino != wp.stat().st_ino:
if not dry_run:
hardlink_duplicate(lp, wp)
conn.execute("INSERT INTO operations(action,file_path,winner_path,details) VALUES(?,?,?,?)", ('exact-hardlink',str(lp),str(wp),'sha256 exact duplicate'))
conn.execute(
"UPDATE audio_tracks SET status='hardlinked',updated_at=CURRENT_TIMESTAMP WHERE id=?",
(row["id"],),
)
actions.append((str(lp), str(wp)))
return actions
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
import hashlib
from pathlib import Path
from .constants import FAST_HASH_CHUNK
def fast_hash(path: Path) -> str:
"""Hash size + first/last 64 KiB; used only as a candidate key."""
size = path.stat().st_size
with path.open("rb") as f:
head = f.read(FAST_HASH_CHUNK)
if size > FAST_HASH_CHUNK:
f.seek(max(0, size - FAST_HASH_CHUNK))
tail = f.read(FAST_HASH_CHUNK)
h = hashlib.sha256()
h.update(str(size).encode())
h.update(b"\0")
h.update(head)
h.update(b"\0")
h.update(tail)
return h.hexdigest()
def sha256(path: Path, chunk_size: int = 1024 * 1024) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
from pathlib import Path
from .acoustic import chromaprint, similarity
from .arbitration import choose_winner, is_version_conflict, score
from .exact import hardlink_duplicate
from .db import connect
from .constants import QUARANTINE_SUBDIR, TRASH_SUBDIR
MIN_SIMILARITY = 0.90
HIGH_SIMILARITY = 0.95
REVIEW_SIMILARITY = 0.80
DURATION_DELTA = 3.0
def _set_meta(conn, row, fp):
conn.execute("UPDATE audio_tracks SET chromaprint=?,score=?,updated_at=CURRENT_TIMESTAMP WHERE id=?", (fp,score(row),row["id"]))
def find_candidates(conn,row):
d=row["duration"]
if d is None: return []
return conn.execute("SELECT * FROM audio_tracks WHERE id!=? AND status IN ('active','hardlinked') AND duration IS NOT NULL AND duration BETWEEN ? AND ?", (row["id"],d-DURATION_DELTA,d+DURATION_DELTA)).fetchall()
def acoustic_dedupe(db_path: Path, root: Path, dry_run: bool = False) -> dict:
stats={"checked":0,"equivalent":0,"review":0,"unique":0,"errors":0}
with connect(db_path) as conn:
rows=conn.execute("SELECT * FROM audio_tracks WHERE status='active' ORDER BY id").fetchall()
for row in rows:
if row["chromaprint"]: continue
fp=chromaprint(Path(row["file_path"])); stats["checked"]+=1
if not fp: stats["errors"]+=1; continue
_set_meta(conn,row,fp)
candidates=find_candidates(conn,row); best=None
for c in candidates:
if not c["chromaprint"]:
cfp=chromaprint(Path(c["file_path"]))
if cfp: _set_meta(conn,c,cfp); c=dict(c); c["chromaprint"]=cfp
sim=similarity(fp,c["chromaprint"] or "")
if sim>=REVIEW_SIMILARITY and (best is None or sim>best[0]): best=(sim,c)
if not best or best[0]<REVIEW_SIMILARITY:
stats["unique"]+=1; continue
sim,c=best
if sim<MIN_SIMILARITY or is_version_conflict(row,c):
conn.execute("UPDATE audio_tracks SET status='review',review_status=?,updated_at=CURRENT_TIMESTAMP WHERE id=?",(f"similarity={sim:.3f}",row["id"]))
stats["review"]+=1; continue
winner=choose_winner([row,c]); loser=c if winner["id"]==row["id"] else row
lp=Path(loser["file_path"]); wp=Path(winner["file_path"])
if lp.exists() and wp.exists():
# Confirmed same-version acoustic match keeps the user's logical
# path. The physical data is shared via a hard link.
if lp.stat().st_ino != wp.stat().st_ino:
if not dry_run:
hardlink_duplicate(lp, wp)
conn.execute("INSERT INTO operations(action,file_path,winner_path,details) VALUES(?,?,?,?)", ('acoustic-hardlink',str(lp),str(wp),f'chromaprint similarity={sim:.3f}'))
conn.execute("UPDATE audio_tracks SET status='hardlinked',review_status=?,updated_at=CURRENT_TIMESTAMP WHERE id=?", (f"acoustic={sim:.3f};winner={wp}", loser["id"]))
stats["equivalent"]+=1
return stats
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import re
from pathlib import Path
from .constants import MUSIC_EXTENSIONS, VERSION_MARKERS
try:
from tinytag import TinyTag
except ImportError:
TinyTag = None
def read_metadata(path: Path) -> dict:
data = {"format": path.suffix.lower().lstrip(".")}
if TinyTag is None:
return data
try:
tag = TinyTag.get(str(path), image=False)
except Exception:
return data
data.update({
"duration": tag.duration,
"artist": tag.artist,
"title": tag.title,
"album": tag.album,
"bitrate": int(tag.bitrate) if tag.bitrate else None,
"sample_rate": int(tag.samplerate) if tag.samplerate else None,
"bit_depth": int(tag.bitdepth) if tag.bitdepth else None,
})
text = " ".join(str(data.get(k) or "") for k in ("title", "album")) + " " + path.name
data["version_flags"] = [m for m in VERSION_MARKERS if re.search(rf"(?<!\w){re.escape(m)}(?!\w)", text, re.I)]
return data
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from pathlib import Path
from typing import Iterator
from .constants import MUSIC_EXTENSIONS
from .db import connect
from .hashing import fast_hash
from .metadata import read_metadata
def iter_music(root: Path) -> Iterator[Path]:
for path in root.rglob("*"):
if not path.is_file() or path.is_symlink() or path.name.startswith(".") or ".dedupe" in path.parts:
continue
if path.suffix.lower() in MUSIC_EXTENSIONS:
yield path
def index_file(conn, path: Path) -> None:
st = path.stat()
fh = fast_hash(path)
meta = read_metadata(path)
flags = ",".join(meta.get("version_flags", []))
conn.execute("""
INSERT INTO audio_tracks(file_path,file_size,fast_hash,duration,artist,title,album,format,bitrate,sample_rate,bit_depth,version_flags,inode,device,status)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,'active')
ON CONFLICT(file_path) DO UPDATE SET file_size=excluded.file_size, fast_hash=excluded.fast_hash,
duration=excluded.duration,artist=excluded.artist,title=excluded.title,album=excluded.album,format=excluded.format,
bitrate=excluded.bitrate,sample_rate=excluded.sample_rate,bit_depth=excluded.bit_depth,version_flags=excluded.version_flags,
inode=excluded.inode,device=excluded.device,updated_at=CURRENT_TIMESTAMP,status='active'
""", (str(path),st.st_size,fh,meta.get("duration"),meta.get("artist"),meta.get("title"),meta.get("album"),meta.get("format"),meta.get("bitrate"),meta.get("sample_rate"),meta.get("bit_depth"),flags,st.st_ino,st.st_dev))
def scan(root: Path, db_path: Path) -> int:
count = 0
seen = set()
with connect(db_path) as conn:
for path in iter_music(root):
index_file(conn, path); seen.add(str(path)); count += 1
rows = conn.execute("SELECT id,file_path,status FROM audio_tracks WHERE status!='missing'").fetchall()
for row in rows:
if row["file_path"] not in seen and not Path(row["file_path"]).exists():
conn.execute("UPDATE audio_tracks SET status='missing',updated_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
return count
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import threading
import time
from pathlib import Path
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
from .constants import MUSIC_EXTENSIONS, STABILITY_CHECKS, STABILITY_INTERVAL_SECONDS
from .db import connect
from .exact import exact_dedupe
from .l2 import acoustic_dedupe
from .scanner import index_file
class Handler(FileSystemEventHandler):
def __init__(self, root: Path, db_path: Path, l2: bool = False):
self.root = root.resolve()
self.db_path = db_path
self.l2 = l2
self._lock = threading.Lock()
def _process(self, filename: str) -> None:
path = Path(filename)
if path.is_symlink() or path.suffix.lower() not in MUSIC_EXTENSIONS:
return
try:
path.relative_to(self.root)
except ValueError:
return
if ".dedupe" in path.parts or path.name.startswith("."):
return
# watchdog may emit several events while a downloader is still writing.
# Serialize the worker so a file is never indexed while another event
# is changing the same database or replacing a duplicate.
with self._lock:
try:
previous = path.stat()
stable = 0
while stable < STABILITY_CHECKS - 1:
time.sleep(STABILITY_INTERVAL_SECONDS)
current = path.stat()
if (current.st_size, current.st_mtime_ns) == (
previous.st_size,
previous.st_mtime_ns,
):
stable += 1
else:
previous = current
stable = 0
with connect(self.db_path) as conn:
index_file(conn, path)
# Exact byte-for-byte dedupe is always safe. L2 remains opt-in.
exact_dedupe(self.db_path, self.root)
if self.l2:
acoustic_dedupe(self.db_path, self.root)
except (FileNotFoundError, PermissionError, OSError):
return
def on_created(self, event):
if not event.is_directory:
self._process(event.src_path)
def on_moved(self, event):
if not event.is_directory:
self._process(event.dest_path)
def watch(root: Path, db_path: Path, l2: bool = False) -> None:
root = root.resolve()
if not root.is_dir():
raise ValueError(f"music root does not exist or is not a directory: {root}")
db_path.parent.mkdir(parents=True, exist_ok=True)
observer = Observer()
observer.schedule(Handler(root, db_path, l2=l2), str(root), recursive=True)
observer.start()
try:
while True:
time.sleep(1)
finally:
observer.stop()
observer.join()
+170
View File
@@ -0,0 +1,170 @@
from __future__ import annotations
import json, os, shutil, threading, uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
from .db import connect
from .scanner import scan
from .exact import exact_dedupe, hardlink_duplicate
from .l2 import acoustic_dedupe
ROOT=Path("/music/AllMusic"); DB=Path("/data/dedupe.db")
LOCK=threading.Lock()
HTML=r'''<!doctype html><html lang="zh-CN"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>AM Deduper</title>
<style>
body{font:14px system-ui;margin:0;background:#f5f6f8;color:#202124}header{background:#111827;color:#fff;padding:18px 24px}
main{max-width:1280px;margin:18px auto;padding:0 14px}.grid{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}
.card,section{background:#fff;border:1px solid #ddd;border-radius:10px;padding:14px;margin-bottom:14px}.num{font-size:24px;font-weight:700}
nav{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px}button{padding:8px 12px;border:1px solid #bbb;border-radius:7px;background:#fff;cursor:pointer}
button.primary{background:#111827;color:#fff}button.danger{background:#fee2e2}table{width:100%;border-collapse:collapse}th,td{padding:9px;border-bottom:1px solid #eee;text-align:left;vertical-align:top}
.path{font:12px monospace;word-break:break-all}.muted{color:#666}.badge{background:#eee;border-radius:10px;padding:3px 7px}
@media(max-width:800px){.grid{grid-template-columns:repeat(2,1fr)}table{font-size:12px}}
</style>
<header><b>AM Deduper</b> · NAS Music Library</header><main>
<nav><button class="primary" onclick="run('scan')">扫描索引</button><button onclick="run('dry')">分析 / Dry-run</button>
<button onclick="run('dedupe')">执行去重</button><button onclick="load()">刷新</button></nav>
<div class="grid" id="stats"></div>
<section><h2>重复组</h2><div id="dupes"></div></section>
<section><h2>待人工审核</h2><div id="review"></div></section>
<section><h2>任务</h2><div id="jobsBox"></div></section>
<section><h2>最近操作</h2><div id="historyBox"></div></section>
</main>
<script>
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
async function api(u,o){let r=await fetch(u,o),j=await r.json();if(!r.ok)throw Error(j.error||r.status);return j}
async function load(){try{
let s=await api('/api/stats');stats.innerHTML=s.cards.map(x=>'<div class="card"><div class="muted">'+esc(x.name)+'</div><div class="num">'+esc(x.value)+'</div></div>').join('');
let d=await api('/api/duplicates');dupes.innerHTML=d.groups.length?d.groups.map(g=>'<div class="card"><b>'+esc(g.type)+' · '+esc(g.savings)+' MB 可节省</b><table><tr><th>文件</th><th>状态</th></tr>'+g.items.map(x=>'<tr><td class="path">'+esc(x.file_path)+'</td><td>'+esc(x.status)+'</td></tr>').join('')+'</table></div>').join(''):'<p class="muted">暂无重复组。</p>';
let q=await api('/api/review');review.innerHTML=q.items.length?'<table><tr><th>相似度</th><th>文件</th><th>候选目标</th><th>操作</th></tr>'+q.items.map(x=>'<tr><td>'+esc(x.similarity||'-')+'</td><td class="path">'+esc(x.file_path)+'<br>'+esc(x.title||'')+' '+esc(x.artist||'')+'</td><td class="path">'+esc(x.candidate_path||'-')+'</td><td><button onclick="reviewAction('+x.id+',\'reject\')">保留</button> '+(x.candidate_id?'<button class="primary" onclick="reviewAction('+x.id+',\'approve\','+x.candidate_id+')">确认重复</button>':'')+'</td></tr>').join('')+'</table>':'<p class="muted">暂无待审核项目。</p>';
let j=await api('/api/jobs');jobsBox.innerHTML=j.items.length?'<table><tr><th>ID</th><th>任务</th><th>状态</th><th>开始</th><th>结束</th></tr>'+j.items.map(x=>'<tr><td>'+x.id+'</td><td>'+esc(x.kind)+'</td><td>'+esc(x.status)+'</td><td>'+esc(x.started_at)+'</td><td>'+esc(x.finished_at||'-')+'</td></tr>').join('')+'</table>':'<p class="muted">暂无任务。</p>';
let h=await api('/api/history');historyBox.innerHTML=h.items.length?'<table><tr><th>ID</th><th>时间</th><th>操作</th><th>文件</th><th>目标</th><th></th></tr>'+h.items.map(x=>'<tr><td>'+x.id+'</td><td>'+esc(x.created_at)+'</td><td>'+esc(x.action)+'</td><td class="path">'+esc(x.file_path)+'</td><td class="path">'+esc(x.winner_path||'')+'</td><td>'+(x.reversible?'<button onclick="rollback('+x.id+')">回滚</button>':'')+'</td></tr>').join('')+'</table>':'<p class="muted">暂无记录。</p>';
}catch(e){alert(e.message)}}
async function run(kind){try{await api('/api/jobs',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({kind})});load()}catch(e){alert(e.message)}}
async function reviewAction(id,action,candidate_id){try{await api('/api/review',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id,action,candidate_id})});load()}catch(e){alert(e.message)}}
async function rollback(id){if(!confirm('确认回滚此 Hard Link?'))return;try{await api('/api/rollback',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id})});load()}catch(e){alert(e.message)}}
load();setInterval(load,5000);
</script></html>'''
def stats():
with connect(DB) as c:
total=c.execute("SELECT COUNT(*) n FROM audio_tracks WHERE status IN ('active','hardlinked','review')").fetchone()["n"]
hard=c.execute("SELECT COUNT(*) n FROM audio_tracks WHERE status='hardlinked'").fetchone()["n"]
review=c.execute("SELECT COUNT(*) n FROM audio_tracks WHERE status='review'").fetchone()["n"]
size=c.execute("SELECT COALESCE(SUM(file_size),0) n FROM audio_tracks WHERE status IN ('active','hardlinked','review')").fetchone()["n"]
ops=c.execute("SELECT COUNT(*) n FROM operations").fetchone()["n"]
return {"cards":[{"name":"音乐文件","value":total},{"name":"Hard Link","value":hard},{"name":"待人工审核","value":review},{"name":"索引大小","value":f"{size/1024/1024/1024:.2f} GB"},{"name":"操作记录","value":ops}]}
def duplicates():
with connect(DB) as c:
rows=c.execute("SELECT * FROM audio_tracks WHERE status IN ('active','hardlinked') AND sha256 IS NOT NULL ORDER BY sha256,id").fetchall()
groups={}
for r in rows: groups.setdefault(r["sha256"],[]).append(dict(r))
out=[]
for _,items in groups.items():
if len(items)>1: out.append({"type":"SHA256 完全重复","savings":round(sum(x["file_size"] for x in items[1:])/1024/1024,2),"items":items})
return {"groups":out[:100]}
def review_items():
with connect(DB) as c: rows=c.execute("SELECT * FROM audio_tracks WHERE status='review' ORDER BY updated_at DESC").fetchall()
out=[]
for r in rows:
d=dict(r); d["similarity"]=(r["review_status"] or "").replace("similarity=","")
with connect(DB) as c2:
cand=c2.execute("SELECT id,file_path FROM audio_tracks WHERE id!=? AND status IN ('active','hardlinked') AND duration BETWEEN ? AND ? ORDER BY ABS(duration-?) LIMIT 1",(r["id"],(r["duration"] or 0)-3,(r["duration"] or 0)+3,r["duration"] or 0)).fetchone()
d["candidate_id"]=cand["id"] if cand else None; d["candidate_path"]=cand["file_path"] if cand else None; out.append(d)
return {"items":out}
def history():
with connect(DB) as c: rows=c.execute("SELECT id,created_at,action,file_path,winner_path,details FROM operations ORDER BY id DESC LIMIT 100").fetchall()
return {"items":[dict(r, reversible=r["action"] in ("exact-hardlink","acoustic-hardlink","manual-hardlink")) for r in rows]}
def jobs():
with connect(DB) as c: rows=c.execute("SELECT * FROM jobs ORDER BY id DESC LIMIT 30").fetchall()
return {"items":[dict(r) for r in rows]}
def run_job(kind):
with connect(DB) as c:
cur=c.execute("INSERT INTO jobs(kind,status) VALUES(?,?)",(kind,"running")); jid=cur.lastrowid
def work():
try:
with LOCK:
if kind=="scan": result={"count":scan(ROOT,DB)}
elif kind=="dry":
scan(ROOT,DB); result={"exact":len(exact_dedupe(DB,ROOT,True)),"acoustic":acoustic_dedupe(DB,ROOT,True)}
else:
scan(ROOT,DB); result={"exact":len(exact_dedupe(DB,ROOT)),"acoustic":acoustic_dedupe(DB,ROOT)}
with connect(DB) as c: c.execute("UPDATE jobs SET status='success',finished_at=CURRENT_TIMESTAMP,result=? WHERE id=?",(json.dumps(result,ensure_ascii=False),jid))
except Exception as e:
with connect(DB) as c: c.execute("UPDATE jobs SET status='failed',finished_at=CURRENT_TIMESTAMP,error=? WHERE id=?",(str(e),jid))
threading.Thread(target=work,daemon=True).start()
return jid
def rollback(opid):
with connect(DB) as c: row=c.execute("SELECT * FROM operations WHERE id=?",(opid,)).fetchone()
if not row or row["action"] not in ("exact-hardlink","acoustic-hardlink","manual-hardlink"): raise ValueError("operation not reversible")
loser,winner=Path(row["file_path"]),Path(row["winner_path"])
if not loser.exists() or not winner.exists() or loser.stat().st_ino!=winner.stat().st_ino: raise ValueError("current file state changed; refusing rollback")
tmp=loser.with_name(loser.name+".dedupe-rollback-"+uuid.uuid4().hex)
shutil.copy2(winner,tmp); os.replace(tmp,loser)
with connect(DB) as c:
c.execute("UPDATE audio_tracks SET status='active',review_status='rolled-back',updated_at=CURRENT_TIMESTAMP WHERE file_path=?",(str(loser),))
c.execute("INSERT INTO operations(action,file_path,winner_path,details) VALUES(?,?,?,?)",("rollback",str(loser),str(winner),f"rollback of operation {opid}"))
def approve(rid,cid):
with connect(DB) as c:
r=c.execute("SELECT * FROM audio_tracks WHERE id=? AND status='review'",(rid,)).fetchone()
w=c.execute("SELECT * FROM audio_tracks WHERE id=? AND status IN ('active','hardlinked')",(cid,)).fetchone()
if not r or not w: raise ValueError("review item or candidate not found")
lp,wp=Path(r["file_path"]),Path(w["file_path"])
if not lp.exists() or not wp.exists() or not lp.is_relative_to(ROOT) or not wp.is_relative_to(ROOT): raise ValueError("invalid library path")
if lp.stat().st_dev!=wp.stat().st_dev: raise ValueError("different filesystem")
if lp.stat().st_ino!=wp.stat().st_ino:
hardlink_duplicate(lp,wp)
with connect(DB) as c:
c.execute("UPDATE audio_tracks SET status='hardlinked',review_status='manual-approved',updated_at=CURRENT_TIMESTAMP WHERE id=?",(rid,))
c.execute("INSERT INTO operations(action,file_path,winner_path,details) VALUES(?,?,?,?)",("manual-hardlink",str(lp),str(wp),"manual review approval"))
class Handler(BaseHTTPRequestHandler):
def send(self,status,data,ctype="application/json"):
raw=data.encode() if isinstance(data,str) else json.dumps(data,ensure_ascii=False).encode()
self.send_response(status); self.send_header("Content-Type",ctype+"; charset=utf-8"); self.send_header("Content-Length",str(len(raw))); self.end_headers(); self.wfile.write(raw)
def do_GET(self):
p=urlparse(self.path).path
try:
if p=="/": return self.send(200,HTML,"text/html")
if p=="/api/stats": return self.send(200,stats())
if p=="/api/duplicates": return self.send(200,duplicates())
if p=="/api/review": return self.send(200,review_items())
if p=="/api/history": return self.send(200,history())
if p=="/api/jobs": return self.send(200,jobs())
return self.send(404,{"error":"not found"})
except Exception as e: return self.send(500,{"error":str(e)})
def do_POST(self):
p=urlparse(self.path).path
try: body=json.loads(self.rfile.read(int(self.headers.get("content-length","0"))) or b"{}")
except Exception: return self.send(400,{"error":"invalid json"})
try:
if p=="/api/jobs":
if body.get("kind") not in {"scan","dry","dedupe"}: return self.send(400,{"error":"invalid job"})
return self.send(202,{"ok":True,"job_id":run_job(body["kind"])})
if p=="/api/review":
if body.get("action")=="reject":
with connect(DB) as c: c.execute("UPDATE audio_tracks SET status='active',review_status='rejected',updated_at=CURRENT_TIMESTAMP WHERE id=? AND status='review'",(int(body["id"]),))
return self.send(200,{"ok":True})
if body.get("action")=="approve": approve(int(body["id"]),int(body["candidate_id"])); return self.send(200,{"ok":True})
return self.send(400,{"error":"invalid action"})
if p=="/api/rollback": rollback(int(body["id"])); return self.send(200,{"ok":True})
return self.send(404,{"error":"not found"})
except Exception as e: return self.send(409,{"error":str(e)})
def log_message(self,*args): pass
def serve(host="0.0.0.0",port=8080):
print(f"AM Deduper UI listening on {host}:{port}",flush=True)
ThreadingHTTPServer((host,port),Handler).serve_forever()
-32
View File
@@ -1,32 +0,0 @@
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
}
-65
View File
@@ -1,65 +0,0 @@
package main
import (
"errors"
"strings"
"time"
"gorm.io/gorm"
)
type MediaAsset struct {
ID uint `gorm:"primaryKey" json:"id"`
AssetKey string `gorm:"uniqueIndex;size:512" json:"assetKey"`
Source string `gorm:"index;size:32" json:"source"`
RID string `gorm:"index;size:256" json:"rid"`
Quality string `gorm:"index;size:32" json:"quality"`
LocalPath string `json:"localPath,omitempty"`
Format string `json:"format,omitempty"`
ActualQuality string `json:"actualQuality,omitempty"`
Size int64 `json:"size"`
Status string `gorm:"index;size:20" json:"status"`
OwnerTaskID uint `gorm:"index" json:"ownerTaskId,omitempty"`
LeaseUntil time.Time `gorm:"index" json:"-"`
Generation uint64 `json:"generation"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func assetKey(source, rid, quality string) string {
return strings.Join([]string{source, rid, quality}, "\x00")
}
func (a *App) ensureAsset(t *Task) (*MediaAsset, error) {
if t.AssetID != 0 {
var asset MediaAsset
if err := a.db.First(&asset, t.AssetID).Error; err == nil {
return &asset, nil
}
}
key := assetKey(t.Source, t.RID, t.Quality)
var asset MediaAsset
err := a.db.Where("asset_key = ?", key).First(&asset).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
asset = MediaAsset{AssetKey: key, Source: t.Source, RID: t.RID, Quality: t.Quality, Status: "new"}
if err := a.db.Create(&asset).Error; err != nil {
if a.db.Where("asset_key = ?", key).First(&asset).Error != nil {
return nil, err
}
}
} else if err != nil {
return nil, err
}
t.AssetID = asset.ID
if err := a.db.Model(t).Update("asset_id", asset.ID).Error; err != nil {
return nil, err
}
return &asset, nil
}
// acquireAsset is the compatibility entry point.
func (a *App) acquireAsset(t *Task) (*MediaAsset, bool, error) { return a.claimAsset(t) }
func (a *App) markAssetDone(t *Task) { _ = a.finishAsset(t) }
func (a *App) releaseAsset(t *Task) { a.invalidateAsset(t) }
-236
View File
@@ -1,236 +0,0 @@
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
const sessionCookie = "am_session"
var loginLimiter = struct {
sync.Mutex
attempts map[string][]time.Time
}{attempts: map[string][]time.Time{}}
func loginAllowed(key string) bool {
now := time.Now()
loginLimiter.Lock()
defer loginLimiter.Unlock()
items := loginLimiter.attempts[key]
cutoff := now.Add(-15 * time.Minute)
kept := items[:0]
for _, t := range items {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) >= 10 {
return false
}
loginLimiter.attempts[key] = append(kept, now)
return true
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func hashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(b), err
}
func checkPassword(hash, password string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
func (a *App) currentUser(c *gin.Context) *User {
value, ok := c.Get("user")
if !ok {
return nil
}
u, _ := value.(*User)
return u
}
func (a *App) requireAuth(c *gin.Context) {
token, err := c.Cookie(sessionCookie)
if err != nil || token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
var session Session
if a.db.Where("token_hash = ? AND expires_at > ?", hashToken(token), time.Now()).First(&session).Error != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired session"})
return
}
var user User
if a.db.First(&user, session.UserID).Error != nil || user.Status != UserActive {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user unavailable"})
return
}
c.Set("user", &user)
c.Next()
}
func (a *App) requireAdmin(c *gin.Context) {
u := a.currentUser(c)
if u == nil || u.Role != RoleAdmin {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
return
}
c.Next()
}
func (a *App) requirePermission(permission string) gin.HandlerFunc {
return func(c *gin.Context) {
u := a.currentUser(c)
if u == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
if u.Role == RoleAdmin || a.hasPermission(u.ID, permission) {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "permission denied"})
}
}
func (a *App) hasPermission(userID uint, permission string) bool {
var p UserPermission
return a.db.Where("user_id = ? AND permission = ? AND enabled = ?", userID, permission, true).First(&p).Error == nil
}
func (a *App) login(c *gin.Context) {
var in struct {
Username string `json:"username"`
Password string `json:"password"`
}
if c.BindJSON(&in) != nil || strings.TrimSpace(in.Username) == "" || in.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "username and password required"})
return
}
key := c.ClientIP() + ":" + strings.ToLower(strings.TrimSpace(in.Username))
if !loginAllowed(key) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many login attempts; try again later"})
return
}
var user User
if a.db.Where("username = ?", strings.TrimSpace(in.Username)).First(&user).Error != nil || user.Status != UserActive || checkPassword(user.PasswordHash, in.Password) != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
return
}
token, err := randomToken(32)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session generation failed"})
return
}
// Keep the session table bounded without requiring a background scheduler.
a.db.Where("expires_at <= ?", time.Now()).Delete(&Session{})
session := Session{UserID: user.ID, TokenHash: hashToken(token), ExpiresAt: time.Now().Add(7 * 24 * time.Hour)}
if err := a.db.Create(&session).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session creation failed"})
return
}
http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: c.Request.TLS != nil, Expires: session.ExpiresAt})
user.PasswordHash = ""
c.JSON(http.StatusOK, gin.H{"user": user})
}
func (a *App) logout(c *gin.Context) {
if token, err := c.Cookie(sessionCookie); err == nil && token != "" {
a.db.Where("token_hash = ?", hashToken(token)).Delete(&Session{})
}
http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
c.Status(http.StatusNoContent)
}
func (a *App) changePassword(c *gin.Context) {
user := c.MustGet("user").(*User)
var in struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
if c.BindJSON(&in) != nil || in.CurrentPassword == "" || len(in.NewPassword) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "currentPassword and newPassword(8+) required"})
return
}
var stored User
if err := a.db.First(&stored, user.ID).Error; err != nil || checkPassword(stored.PasswordHash, in.CurrentPassword) != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "current password is incorrect"})
return
}
hash, err := hashPassword(in.NewPassword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "password hashing failed"})
return
}
if err := a.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&User{}).Where("id = ?", user.ID).Update("password_hash", hash).Error; err != nil {
return err
}
return tx.Where("user_id = ?", user.ID).Delete(&Session{}).Error
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "password change failed"})
return
}
http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
a.audit(user.ID, "auth.password.change", "user", fmt.Sprint(user.ID), "password changed; sessions revoked")
c.Status(http.StatusNoContent)
}
func (a *App) me(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"user": a.currentUser(c)})
}
func (a *App) bootstrapAdmin() {
var count int64
a.db.Model(&User{}).Count(&count)
if count != 0 {
return
}
username := getenv("ADMIN_USERNAME", "admin")
password := getenv("ADMIN_PASSWORD", "")
if password == "" {
generated, err := randomToken(12)
if err != nil {
panic(err)
}
password = generated
fmt.Printf("AM Downloader initial admin password: %s\n", password)
}
hash, err := hashPassword(password)
if err != nil {
panic(err)
}
u := User{Username: username, PasswordHash: hash, Role: RoleAdmin, Status: UserActive}
if err := a.db.Create(&u).Error; err != nil {
panic(err)
}
if err := a.db.Model(&Task{}).Where("user_id = 0 OR user_id IS NULL").Update("user_id", u.ID).Error; err != nil {
panic(err)
}
if err := a.db.Create(&CreditAccount{UserID: u.ID}).Error; err != nil {
panic(err)
}
}
-255
View File
@@ -1,255 +0,0 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"gorm.io/gorm"
)
const assetLease = 15 * time.Minute
var (
errTaskRunning = errors.New("task is already running")
errTaskAlreadyDone = errors.New("task is already completed")
)
func (a *App) claimTask(id uint) (*Task, error) {
result := a.db.Model(&Task{}).Where("id = ? AND status = ?", id, "pending").
Updates(map[string]any{"status": "resolving", "error": "", "progress": 0})
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected != 1 {
var t Task
if err := a.db.First(&t, id).Error; err != nil {
return nil, err
}
if t.Status == "upload_pending" {
return nil, fmt.Errorf("task %d is waiting for upload", id)
}
if t.Status == "done" {
return nil, errTaskAlreadyDone
}
return nil, errTaskRunning
}
var t Task
if err := a.db.First(&t, id).Error; err != nil {
return nil, err
}
return &t, nil
}
func (a *App) claimUpload(t *Task) bool {
result := a.db.Model(&Task{}).
Where("id = ? AND (status = ? OR (status = ? AND (remote_path = '' OR remote_path IS NULL)))",
t.ID, "upload_pending", "done").
Updates(map[string]any{"status": "uploading", "error": ""})
if result.Error != nil || result.RowsAffected != 1 {
return false
}
t.Status, t.Error = "uploading", ""
return true
}
func (a *App) taskReservationStatus(taskID, reservationID uint) (string, error) {
if reservationID == 0 {
return "", nil
}
var r CreditReservation
if err := a.db.First(&r, reservationID).Error; err != nil {
return "", err
}
if r.TaskID != taskID {
return "", fmt.Errorf("credit reservation does not belong to task")
}
return r.Status, nil
}
func (a *App) repairCommittedTask(t *Task) error {
status, err := a.taskReservationStatus(t.ID, t.CreditReservationID)
if err != nil {
return err
}
if status == "committed" {
if t.Status == "error" {
t.Status, t.Error = "upload_pending", "下载已完成,正在恢复 WebDAV 上传"
return a.db.Model(t).Updates(map[string]any{
"status": t.Status, "error": t.Error,
}).Error
}
return nil
}
return nil
}
func (a *App) markAssetHeartbeat(assetID, taskID uint) {
if assetID == 0 || taskID == 0 {
return
}
now := time.Now()
lease := now.Add(assetLease)
_ = a.db.Model(&MediaAsset{}).
Where("id = ? AND owner_task_id = ? AND status = ?", assetID, taskID, "downloading").
Updates(map[string]any{"lease_until": lease, "updated_at": now}).Error
}
func (a *App) assetLeaseExpired(asset *MediaAsset) bool {
return asset.Status == "downloading" && !asset.LeaseUntil.IsZero() && time.Now().After(asset.LeaseUntil)
}
func (a *App) claimAsset(t *Task) (*MediaAsset, bool, error) {
for i := 0; i < 180; i++ {
asset, err := a.ensureAsset(t)
if err != nil {
return nil, false, err
}
if asset.Status == "done" && asset.LocalPath != "" {
if fi, statErr := os.Stat(asset.LocalPath); statErr == nil && fi.Size() > 0 {
t.LocalPath, t.Format, t.ActualQuality = asset.LocalPath, asset.Format, asset.ActualQuality
t.TotalBytes, t.DownloadedBytes, t.Progress, t.Status = fi.Size(), fi.Size(), 100, "done"
if err := a.db.Save(t).Error; err != nil {
return nil, false, err
}
return asset, true, nil
}
}
claimed := false
now := time.Now()
lease := now.Add(assetLease)
err = a.transactionRetry(func(tx *gorm.DB) error {
var current MediaAsset
if err := tx.First(&current, asset.ID).Error; err != nil {
return err
}
if current.Status == "downloading" && current.OwnerTaskID != t.ID {
if !current.LeaseUntil.IsZero() && now.Before(current.LeaseUntil) {
return nil
}
if current.OwnerTaskID != 0 {
_ = tx.Model(&Task{}).Where("id = ? AND status IN ?", current.OwnerTaskID, []string{"resolving", "downloading"}).Updates(map[string]any{"status": "pending", "error": "共享资源下载锁超时,已自动恢复", "progress": 0})
}
}
wasEvicted := current.Status == "evicted"
wasStolen := current.Status == "downloading" && current.OwnerTaskID != 0 && current.OwnerTaskID != t.ID && (current.LeaseUntil.IsZero() || !now.Before(current.LeaseUntil))
current.Status = "downloading"
current.OwnerTaskID = t.ID
current.LeaseUntil = lease
current.Generation++
if current.LocalPath == "" || wasEvicted || wasStolen {
current.LocalPath = assetCachePath(a.cfg.DownloadDir, current.AssetKey, t.Format, current.Generation)
}
if err := tx.Save(&current).Error; err != nil {
return err
}
*asset = current
claimed = true
return nil
})
if err != nil {
return nil, false, err
}
if claimed {
t.LocalPath = asset.LocalPath
return asset, false, nil
}
time.Sleep(500 * time.Millisecond)
}
return nil, false, fmt.Errorf("timed out waiting for shared media asset")
}
func assetCachePath(root, key, format string, generation uint64) string {
sum := sha256.Sum256([]byte(key))
return filepath.Join(root, ".assets", hex.EncodeToString(sum[:]), fmt.Sprintf("asset-%d.%s", generation, normalizedFormat(format)))
}
func (a *App) assertAssetOwner(t *Task) bool {
if t.AssetID == 0 {
return true
}
var asset MediaAsset
if a.db.First(&asset, t.AssetID).Error != nil {
return false
}
return asset.OwnerTaskID == t.ID && asset.Status == "downloading"
}
func (a *App) finishAsset(t *Task) bool {
if t.AssetID == 0 {
return true
}
result := a.db.Model(&MediaAsset{}).
Where("id = ? AND owner_task_id = ? AND status = ?", t.AssetID, t.ID, "downloading").
Updates(map[string]any{
"status": "done", "owner_task_id": 0, "lease_until": nil,
"local_path": t.LocalPath, "format": t.Format,
"actual_quality": t.ActualQuality, "size": t.DownloadedBytes,
})
return result.Error == nil && result.RowsAffected == 1
}
func (a *App) invalidateAsset(t *Task) {
if t.AssetID == 0 {
return
}
_ = a.db.Model(&MediaAsset{}).
Where("id = ? AND owner_task_id = ?", t.AssetID, t.ID).
Updates(map[string]any{"status": "error", "owner_task_id": 0, "lease_until": nil})
}
func (a *App) prepareRetry(t *Task) error {
status, err := a.taskReservationStatus(t.ID, t.CreditReservationID)
if err != nil {
return err
}
switch status {
case "committed":
t.Status = "upload_pending"
t.Error = "下载已完成,恢复 WebDAV 上传"
return a.db.Model(t).Updates(map[string]any{
"status": "upload_pending", "error": t.Error,
}).Error
case "reserved":
t.Status, t.Error = "pending", ""
return a.db.Model(t).Updates(map[string]any{
"status": "pending", "error": "", "progress": 0,
}).Error
case "refunded", "":
r, reserveErr := a.reserveCredit(t.UserID, t.ID, 1)
if reserveErr != nil {
return reserveErr
}
t.CreditReservationID = r.ID
t.Status, t.Error, t.Progress, t.DownloadedBytes = "pending", "", 0, 0
return a.db.Model(t).Updates(map[string]any{
"status": "pending", "error": "", "progress": 0,
"downloaded_bytes": 0, "credit_reservation_id": r.ID,
}).Error
default:
return errReservationState
}
}
func (a *App) enqueueTask(id uint) {
select {
case a.queue <- id:
default:
go func() { a.queue <- id }()
}
}
func (a *App) transactionRetry(fn func(*gorm.DB) error) error {
var err error
for attempt := 0; attempt < 8; attempt++ {
err = a.db.Transaction(fn)
if !isSQLiteBusy(err) {
return err
}
time.Sleep(time.Duration(25*(1<<attempt)) * time.Millisecond)
}
return err
}
-223
View File
@@ -1,223 +0,0 @@
package main
import (
"os"
"path/filepath"
"strconv"
"sync"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"time"
)
func TestClaimTaskIsAtomic(t *testing.T) {
a := newCreditTestApp(t)
task := Task{UserID: 1, Source: "kw", RID: "atomic", Quality: "lossless", Status: "pending"}
if err := a.db.Create(&task).Error; err != nil {
t.Fatal(err)
}
if _, err := a.claimTask(task.ID); err != nil {
t.Fatal(err)
}
if _, err := a.claimTask(task.ID); err != errTaskRunning {
t.Fatalf("second claim error = %v, want task running", err)
}
}
func TestRetryCommittedReservationDoesNotChargeAgain(t *testing.T) {
a := newCreditTestApp(t)
user := User{Username: "retry-committed", Role: RoleUser, Status: UserActive}
if err := a.db.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := a.db.Create(&CreditAccount{UserID: user.ID, Balance: 1}).Error; err != nil {
t.Fatal(err)
}
task := Task{UserID: user.ID, Source: "kw", RID: "committed", Quality: "lossless", Status: "pending"}
if err := a.db.Create(&task).Error; err != nil {
t.Fatal(err)
}
r, err := a.reserveCredit(user.ID, task.ID, 1)
if err != nil {
t.Fatal(err)
}
task.CreditReservationID = r.ID
if err := a.db.Model(&task).Update("credit_reservation_id", r.ID).Error; err != nil {
t.Fatal(err)
}
if err := a.commitCredit(&task); err != nil {
t.Fatal(err)
}
if err := a.db.Model(&task).Updates(map[string]any{"status": "error", "error": "legacy"}).Error; err != nil {
t.Fatal(err)
}
var current Task
if err := a.db.First(&current, task.ID).Error; err != nil {
t.Fatal(err)
}
if err := a.prepareRetry(&current); err != nil {
t.Fatal(err)
}
var account CreditAccount
if err := a.db.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
t.Fatal(err)
}
if account.Balance != 0 || account.Reserved != 0 {
t.Fatalf("credit changed on committed retry: %+v", account)
}
if current.Status != "upload_pending" {
t.Fatalf("status = %q, want upload_pending", current.Status)
}
}
func TestRetryRefundedReservationCreatesNewReservation(t *testing.T) {
a := newCreditTestApp(t)
user := User{Username: "retry-refunded", Role: RoleUser, Status: UserActive}
if err := a.db.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := a.db.Create(&CreditAccount{UserID: user.ID, Balance: 1}).Error; err != nil {
t.Fatal(err)
}
task := Task{UserID: user.ID, Source: "kw", RID: "refunded", Quality: "lossless", Status: "error"}
if err := a.db.Create(&task).Error; err != nil {
t.Fatal(err)
}
r1, err := a.reserveCredit(user.ID, task.ID, 1)
if err != nil {
t.Fatal(err)
}
task.CreditReservationID = r1.ID
if err := a.refundCredit(&task); err != nil {
t.Fatal(err)
}
if err := a.prepareRetry(&task); err != nil {
t.Fatal(err)
}
if task.CreditReservationID == r1.ID {
t.Fatal("retry reused refunded reservation")
}
var account CreditAccount
if err := a.db.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
t.Fatal(err)
}
if account.Balance != 1 || account.Reserved != 1 {
t.Fatalf("unexpected account: %+v", account)
}
}
func TestStaleAssetLeaseStealsSafely(t *testing.T) {
a := newCreditTestApp(t)
root := t.TempDir()
a.cfg.DownloadDir = root
first := Task{UserID: 1, Source: "kw", RID: "lease", Quality: "lossless", Format: "flac", Status: "downloading"}
second := Task{UserID: 2, Source: "kw", RID: "lease", Quality: "lossless", Format: "flac", Status: "pending"}
if err := a.db.Create(&first).Error; err != nil {
t.Fatal(err)
}
if err := a.db.Create(&second).Error; err != nil {
t.Fatal(err)
}
asset := MediaAsset{
AssetKey: assetKey("kw", "lease", "lossless"),
Source: "kw", RID: "lease", Quality: "lossless",
LocalPath: filepath.Join(root, "old.flac"), Format: "flac",
Status: "downloading", OwnerTaskID: first.ID,
LeaseUntil: time.Now().Add(-time.Minute), Generation: 1,
}
if err := a.db.Create(&asset).Error; err != nil {
t.Fatal(err)
}
first.AssetID = asset.ID
if err := a.db.Model(&first).Update("asset_id", asset.ID).Error; err != nil {
t.Fatal(err)
}
got, reused, err := a.acquireAsset(&second)
if err != nil || reused {
t.Fatalf("acquire = asset=%+v reused=%v err=%v", got, reused, err)
}
if got.Generation != 2 {
t.Fatalf("generation = %d, want 2", got.Generation)
}
if got.LocalPath == asset.LocalPath {
t.Fatal("stolen generation reused old local path")
}
var old Task
if err := a.db.First(&old, first.ID).Error; err != nil {
t.Fatal(err)
}
if old.Status != "pending" {
t.Fatalf("stale owner status = %q, want pending", old.Status)
}
}
func TestAssetCachePathIsStablePerGeneration(t *testing.T) {
root := t.TempDir()
p1 := assetCachePath(root, "kw-rid-lossless", "flac", 1)
p2 := assetCachePath(root, "kw-rid-lossless", "flac", 2)
if p1 == p2 || filepath.Dir(p1) != filepath.Dir(p2) {
t.Fatalf("unexpected cache paths: %q %q", p1, p2)
}
if err := os.MkdirAll(filepath.Dir(p1), 0755); err != nil {
t.Fatal(err)
}
}
func TestConcurrentCreditReservationsAreAtomic(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "credits.db")
db, err := gorm.Open(sqlite.Open(dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(10000)"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&Task{}, &MediaAsset{}, &User{}, &CreditAccount{}, &CreditTransaction{}, &CreditReservation{}); err != nil {
t.Fatal(err)
}
app := &App{db: db}
user := User{Username: "concurrent-credit", Role: RoleUser, Status: UserActive}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&CreditAccount{UserID: user.ID, Balance: 5}).Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 20; i++ {
if err := db.Create(&Task{UserID: user.ID, Source: "kw", RID: "c-" + strconv.Itoa(i), Quality: "lossless", Status: "pending"}).Error; err != nil {
t.Fatal(err)
}
}
var wg sync.WaitGroup
var mu sync.Mutex
success, unexpected := 0, 0
for i := 0; i < 20; i++ {
var task Task
if err := db.Where("user_id = ? AND r_id = ?", user.ID, "c-"+strconv.Itoa(i)).First(&task).Error; err != nil {
t.Fatal(err)
}
wg.Add(1)
go func(taskID uint) {
defer wg.Done()
if _, err := app.reserveCredit(user.ID, taskID, 1); err == nil {
mu.Lock()
success++
mu.Unlock()
} else if err != errInsufficientCredits {
mu.Lock()
unexpected++
mu.Unlock()
}
}(task.ID)
}
wg.Wait()
if success != 5 || unexpected != 0 {
t.Fatalf("reservations: success=%d unexpected=%d", success, unexpected)
}
var account CreditAccount
if err := db.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
t.Fatal(err)
}
if account.Balance != 5 || account.Reserved != 5 {
t.Fatalf("account after concurrent reservations: %+v", account)
}
}
-89
View File
@@ -1,89 +0,0 @@
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) }
-167
View File
@@ -1,167 +0,0 @@
package main
import (
"errors"
"fmt"
"strconv"
"strings"
"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 reserveCreditTx(tx *gorm.DB, userID, taskID uint, amount int64) (*CreditReservation, error) {
if amount <= 0 {
return nil, fmt.Errorf("invalid credit amount")
}
var account CreditAccount
if err := tx.Where("user_id = ?", userID).First(&account).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
account = CreditAccount{UserID: userID}
if err := tx.Create(&account).Error; err != nil {
if err := tx.Where("user_id = ?", userID).First(&account).Error; err != nil {
return nil, err
}
}
}
var existing CreditReservation
if err := tx.Where("task_id = ? AND status = ?", taskID, "reserved").
Order("id desc").First(&existing).Error; err == nil {
return &existing, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if account.Balance-account.Reserved < amount {
return nil, errInsufficientCredits
}
account.Reserved += amount
if err := tx.Save(&account).Error; err != nil {
return nil, err
}
reservation := &CreditReservation{
UserID: userID, TaskID: taskID, Amount: amount, Status: "reserved",
}
if err := tx.Create(reservation).Error; err != nil {
return nil, err
}
if err := tx.Create(&CreditTransaction{
UserID: userID, Type: "reserve", Amount: 0,
BalanceAfter: account.Balance, ReferenceType: "task",
ReferenceID: strconv.FormatUint(uint64(taskID), 10),
Reason: "download reservation",
}).Error; err != nil {
return nil, err
}
return reservation, nil
}
func isSQLiteBusy(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "sqlite_busy") || strings.Contains(msg, "database is locked")
}
func (a *App) reserveCredit(userID, taskID uint, amount int64) (*CreditReservation, error) {
var reservation *CreditReservation
err := a.transactionRetry(func(tx *gorm.DB) error {
var txErr error
reservation, txErr = reserveCreditTx(tx, userID, taskID, amount)
return txErr
})
return reservation, err
}
func (a *App) commitCredit(task *Task) error {
if task.CreditReservationID == 0 {
return nil
}
return a.transactionRetry(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.transactionRetry(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" {
// A committed reservation belongs to a completed download. A later
// storage/retry error must never debit or refund it a second time.
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
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
}
-125
View File
@@ -1,125 +0,0 @@
package main
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newCreditTestApp(t *testing.T) *App {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&Task{}, &MediaAsset{}, &User{}, &CreditAccount{}, &CreditTransaction{}, &CreditReservation{}); err != nil {
t.Fatal(err)
}
return &App{db: db}
}
func TestCreditReservationRetryAfterRefund(t *testing.T) {
a := newCreditTestApp(t)
u := User{Username: "u", Role: RoleUser, Status: UserActive}
if err := a.db.Create(&u).Error; err != nil {
t.Fatal(err)
}
if err := a.db.Create(&CreditAccount{UserID: u.ID, Balance: 1}).Error; err != nil {
t.Fatal(err)
}
task := Task{UserID: u.ID, Source: "kw", RID: "1", Quality: "standard", Status: "error"}
if err := a.db.Create(&task).Error; err != nil {
t.Fatal(err)
}
r1, err := a.reserveCredit(u.ID, task.ID, 1)
if err != nil {
t.Fatal(err)
}
task.CreditReservationID = r1.ID
if err := a.refundCredit(&task); err != nil {
t.Fatal(err)
}
r2, err := a.reserveCredit(u.ID, task.ID, 1)
if err != nil {
t.Fatal(err)
}
if r2.ID == r1.ID {
t.Fatal("retry reused refunded reservation")
}
var account CreditAccount
if err := a.db.Where("user_id = ?", u.ID).First(&account).Error; err != nil {
t.Fatal(err)
}
if account.Balance != 1 || account.Reserved != 1 {
t.Fatalf("unexpected account: %+v", account)
}
}
func TestSharedAssetIsReusedAcrossUsers(t *testing.T) {
a := newCreditTestApp(t)
u1 := User{Username: "a1", Role: RoleUser, Status: UserActive}
u2 := User{Username: "a2", Role: RoleUser, Status: UserActive}
if err := a.db.Create(&u1).Error; err != nil {
t.Fatal(err)
}
if err := a.db.Create(&u2).Error; err != nil {
t.Fatal(err)
}
t1 := Task{UserID: u1.ID, Source: "kw", RID: "same", Quality: "standard", Title: "Song", Artist: "Artist"}
t2 := Task{UserID: u2.ID, Source: "kw", RID: "same", Quality: "standard", Title: "Song", Artist: "Artist"}
if err := a.db.Create(&t1).Error; err != nil {
t.Fatal(err)
}
if err := a.db.Create(&t2).Error; err != nil {
t.Fatal(err)
}
asset, reused, err := a.acquireAsset(&t1)
if err != nil || reused {
t.Fatalf("first acquire: asset=%+v reused=%v err=%v", asset, reused, err)
}
if asset.ID == 0 {
t.Fatal("missing asset id")
}
if err := a.db.Model(&MediaAsset{}).Where("id = ?", asset.ID).Updates(map[string]any{"status": "done", "owner_task_id": 0, "local_path": "/etc/hosts", "format": "mp3", "size": 123}).Error; err != nil {
t.Fatal(err)
}
t2.AssetID = asset.ID
asset2, reused, err := a.acquireAsset(&t2)
if err != nil || !reused || asset2.ID != asset.ID {
t.Fatalf("second acquire: asset=%+v reused=%v err=%v", asset2, reused, err)
}
}
func TestCommittedCreditIsNotRefunded(t *testing.T) {
a := newCreditTestApp(t)
u := User{Username: "u2", Role: RoleUser, Status: UserActive}
if err := a.db.Create(&u).Error; err != nil {
t.Fatal(err)
}
if err := a.db.Create(&CreditAccount{UserID: u.ID, Balance: 1}).Error; err != nil {
t.Fatal(err)
}
task := Task{UserID: u.ID, Source: "kw", RID: "2", Quality: "standard", Status: "pending"}
if err := a.db.Create(&task).Error; err != nil {
t.Fatal(err)
}
r, err := a.reserveCredit(u.ID, task.ID, 1)
if err != nil {
t.Fatal(err)
}
task.CreditReservationID = r.ID
if err := a.commitCredit(&task); err != nil {
t.Fatal(err)
}
if err := a.refundCredit(&task); err != nil {
t.Fatal(err)
}
var account CreditAccount
if err := a.db.Where("user_id = ?", u.ID).First(&account).Error; err != nil {
t.Fatal(err)
}
if account.Balance != 0 || account.Reserved != 0 {
t.Fatalf("unexpected account after committed refund: %+v", account)
}
}
+33 -11
View File
@@ -1,15 +1,37 @@
services:
am-downloader:
build: .
container_name: am-downloader
am-deduper:
image: gitea.king.nyc.mn/openclaw/am-deduper:latest
container_name: am-deduper
restart: unless-stopped
network_mode: host
command:
- watch
- /music/AllMusic
- --db
- /data/dedupe.db
environment:
PORT: "9540"
DB_PATH: /data/downloader.db
DOWNLOAD_DIR: /downloads
AM_API_URL: http://127.0.0.1:9530/api
WORKERS: "2"
TZ: Asia/Shanghai
PYTHONUNBUFFERED: "1"
volumes:
- ./data:/data
- ./downloads:/downloads
- /vol2/1000/Docker/chfs/data/Backup/AllMusic:/music/AllMusic
- /vol2/1000/Docker/am-deduper/data:/data
stop_grace_period: 30s
am-deduper-web:
image: gitea.king.nyc.mn/openclaw/am-deduper:latest
container_name: am-deduper-web
restart: unless-stopped
command:
- server
- --host
- 0.0.0.0
- --port
- "8080"
environment:
TZ: Asia/Shanghai
PYTHONUNBUFFERED: "1"
volumes:
- /vol2/1000/Docker/chfs/data/Backup/AllMusic:/music/AllMusic
- /vol2/1000/Docker/am-deduper/data:/data
ports:
- "18080:8080"
stop_grace_period: 10s
-265
View File
@@ -1,265 +0,0 @@
package main
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
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.processTask(id)
}
}()
}
}
func (a *App) processTask(id uint) {
var t Task
if a.db.First(&t, id).Error != nil {
return
}
if t.Status == "upload_pending" {
a.upload(&t)
return
}
if t.Status != "pending" {
return
}
a.download(id)
}
func (a *App) download(id uint) {
t, err := a.claimTask(id)
if err != nil {
return
}
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"
}
t.Format, t.ResolvedQuality, t.ActualQuality = ext, info.Level, ""
if err := a.db.Model(t).Updates(map[string]any{
"format": t.Format, "resolved_quality": t.ResolvedQuality,
}).Error; err != nil {
a.fail(t, err)
return
}
asset, reused, err := a.acquireAsset(t)
if err != nil {
a.fail(t, err)
return
}
if reused {
a.finishExisting(t, asset.Size)
return
}
if err := a.db.Model(t).Updates(map[string]any{
"local_path": t.LocalPath, "status": "downloading",
}).Error; err != nil {
a.invalidateAsset(t)
a.fail(t, err)
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"
if err := a.db.Save(t).Error; err != nil {
return
}
if err := a.refundCredit(t); err != nil {
a.fail(t, err)
return
}
a.upload(t)
}
func (a *App) downloadFile(t *Task, info playInfo) {
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.Model(t).Updates(map[string]any{
"status": t.Status, "total_bytes": t.TotalBytes,
"downloaded_bytes": 0, "progress": 0,
}).Error
if err := os.MkdirAll(filepath.Dir(t.LocalPath), 0755); err != nil {
a.fail(t, err)
return
}
tmp := fmt.Sprintf("%s.task-%d.part", t.LocalPath, t.ID)
_ = os.Remove(tmp)
f, err := os.Create(tmp)
if err != nil {
a.fail(t, err)
return
}
stop := make(chan struct{})
ticker := time.NewTicker(30 * time.Second)
go func() {
for {
select {
case <-ticker.C:
a.markAssetHeartbeat(t.AssetID, t.ID)
case <-stop:
return
}
}
}()
n, copyErr := a.copyWithProgress(f, resp.Body, t)
closeErr := f.Close()
ticker.Stop()
close(stop)
if copyErr != nil {
a.fail(t, copyErr)
_ = os.Remove(tmp)
return
}
if closeErr != nil {
a.fail(t, closeErr)
_ = os.Remove(tmp)
return
}
if t.TotalBytes > 0 && n != t.TotalBytes {
a.fail(t, fmt.Errorf("incomplete download: got %d of %d bytes", n, t.TotalBytes))
_ = os.Remove(tmp)
return
}
if !a.assertAssetOwner(t) {
_ = os.Remove(tmp)
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.LocalPath = finalPath
t.Format = actual
t.ActualQuality = actualQuality(t.Quality, t.ResolvedQuality, actual)
t.DownloadedBytes = n
t.Progress = 100
if err := a.db.Model(t).Updates(map[string]any{
"format": t.Format, "local_path": t.LocalPath,
"actual_quality": t.ActualQuality,
"downloaded_bytes": n, "progress": 100,
}).Error; err != nil {
a.invalidateAsset(t)
return
}
if !a.finishAsset(t) {
a.fail(t, fmt.Errorf("shared asset ownership lost before completion"))
return
}
if err := a.commitCredit(t); err != nil {
a.fail(t, err)
return
}
t.Status, t.Error = "upload_pending", ""
if err := a.db.Model(t).Updates(map[string]any{
"status": "upload_pending", "error": "",
}).Error; err != nil {
return
}
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
if err := a.db.Model(t).Updates(map[string]any{
"downloaded_bytes": n, "progress": progress,
}).Error; err != nil {
return n, err
}
a.markAssetHeartbeat(t.AssetID, t.ID)
last = n
}
}
if er == io.EOF {
return n, nil
}
if er != nil {
return n, er
}
}
}
-67
View File
@@ -1,67 +0,0 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
)
func normalizedFormat(format string) string {
f := strings.ToLower(strings.TrimPrefix(format, "."))
switch f {
case "m4a", "mp4", "mmp4":
return "m4a"
case "mflac", "flac":
return "flac"
case "mp3":
return "mp3"
case "ogg", "opus":
return "ogg"
case "wav":
return "wav"
case "aac":
return "aac"
default:
return f
}
}
func actualQuality(requested, resolved, format string) string {
f := normalizedFormat(format)
if f == "flac" {
if resolved != "" {
return resolved
}
if requested == "lossless" || requested == "hires" {
return requested
}
}
if f == "mp3" {
if resolved == "standard" || resolved == "exhigh" || resolved == "sq" {
return resolved
}
}
return ""
}
func renameToActualFormat(local string, format string) (string, error) {
ext := normalizedFormat(format)
if ext == "" {
return local, nil
}
target := strings.TrimSuffix(local, filepath.Ext(local)) + "." + ext
if target == local {
return local, nil
}
if _, err := os.Stat(target); err == nil {
if err := os.Remove(local); err != nil {
return "", err
}
return target, nil
}
if err := os.Rename(local, target); err != nil {
return "", fmt.Errorf("rename actual format: %w", err)
}
return target, nil
}
-52
View File
@@ -1,52 +0,0 @@
module am-downloader
go 1.25.0
require (
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/studio-b12/gowebdav v0.13.0
gorm.io/gorm v1.31.2
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
-122
View File
@@ -1,122 +0,0 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ=
github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
-199
View File
@@ -1,199 +0,0 @@
package main
import (
"io"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
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
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
}
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
}
user := c.MustGet("user").(*User)
var existing Task
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 {
existing.Title, existing.Artist, existing.Album, existing.Pic = in.Title, in.Artist, in.Album, in.Pic
switch existing.Status {
case "pending", "resolving", "downloading", "uploading", "upload_pending":
c.JSON(200, existing)
return
case "done":
c.JSON(200, existing)
return
case "error":
if err := a.prepareRetry(&existing); err != nil {
if err == errInsufficientCredits {
c.JSON(402, gin.H{"error": err.Error()})
} else {
c.JSON(409, gin.H{"error": err.Error()})
}
return
}
a.enqueueTask(existing.ID)
c.JSON(202, existing)
return
}
}
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",
}
var reservation *CreditReservation
err = a.transactionRetry(func(tx *gorm.DB) error {
if err := tx.Create(&t).Error; err != nil {
return err
}
var reserveErr error
reservation, reserveErr = reserveCreditTx(tx, user.ID, t.ID, 1)
if reserveErr != nil {
return reserveErr
}
t.CreditReservationID = reservation.ID
return tx.Model(&t).Update("credit_reservation_id", reservation.ID).Error
})
if err != nil {
if a.db.Where("user_id = ? AND source = ? AND r_id = ? AND quality = ?",
user.ID, in.Source, in.RID, in.Quality).First(&existing).Error == nil {
c.JSON(200, existing)
return
}
if err == errInsufficientCredits {
c.JSON(402, gin.H{"error": err.Error()})
return
}
c.JSON(500, gin.H{"error": err.Error()})
return
}
a.enqueueTask(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 {
c.Status(400)
return
}
user := c.MustGet("user").(*User)
var t Task
if a.db.Where("id = ? AND user_id = ?", id, user.ID).First(&t).Error != nil {
c.Status(404)
return
}
switch t.Status {
case "done":
c.JSON(409, gin.H{"error": "task is already completed"})
return
case "pending", "resolving", "downloading", "uploading":
c.JSON(409, gin.H{"error": "task is already running"})
return
case "upload_pending", "error":
if t.Status == "error" {
if err := a.prepareRetry(&t); err != nil {
if err == errInsufficientCredits {
c.JSON(402, gin.H{"error": err.Error()})
} else {
c.JSON(409, gin.H{"error": err.Error()})
}
return
}
}
default:
c.JSON(409, gin.H{"error": "task cannot be retried from current state"})
return
}
a.enqueueTask(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
}
user := c.MustGet("user").(*User)
var t Task
if err := a.db.Where("id = ? AND user_id = ?", id, user.ID).First(&t).Error; err != nil {
c.Status(404)
return
}
if t.CreditReservationID != 0 {
if err := a.refundCredit(&t); err != nil {
c.JSON(409, gin.H{"error": "cannot release task credit: " + err.Error()})
return
}
}
if err := a.db.Delete(&t).Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.Status(204)
}
-1461
View File
File diff suppressed because it is too large Load Diff
-88
View File
@@ -1,88 +0,0 @@
package main
import (
"net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func main() {
dbPath := getenv("DB_PATH", "/data/downloader.db")
_ = os.MkdirAll(filepath.Dir(dbPath), 0755)
dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(10000)"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
must(err)
sqlDB, err := db.DB()
must(err)
sqlDB.SetMaxOpenConns(8)
sqlDB.SetMaxIdleConns(8)
must(migrateDatabase(db))
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()
a.bootstrapAdmin()
must(a.initializeUserWebDAVDefaults())
must(backfillMediaAssets(db, a.cfg.DownloadDir))
if os.Getenv("MIGRATION_CHECK") == "1" {
var users, tasks, assets int64
db.Model(&User{}).Count(&users)
db.Model(&Task{}).Count(&tasks)
db.Model(&MediaAsset{}).Count(&assets)
println("migration check ok", "users", users, "tasks", tasks, "assets", assets)
return
}
normalizeConfig(&a.cfg)
a.am = &AMAPIClient{BaseURL: a.cfg.AMAPI, HTTP: client}
if a.cfg.WebDAVURL != "" {
a.storage = NewWebDAVStorage(a.cfg)
}
a.startWorkers()
a.startWebDAVResync()
r := gin.Default()
r.StaticFile("/", "/app/index.html")
api := r.Group("/api")
api.POST("/auth/login", a.login)
api.POST("/auth/logout", a.requireAuth, a.logout)
api.POST("/auth/password", a.requireAuth, a.changePassword)
api.GET("/auth/me", a.requireAuth, a.me)
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.GET("/users/:id/credits", a.adminUserCredits)
admin.POST("/users/:id/credits", a.adminAdjustCredits)
admin.GET("/users/:id/credits/transactions", a.adminUserCreditTransactions)
admin.GET("/credits/transactions", a.adminCreditTransactions)
admin.GET("/audit", a.adminAudit)
admin.POST("/users", a.adminCreateUser)
admin.PUT("/users/:id", a.adminUpdateUser)
admin.GET("/redeem", a.adminListRedeemBatches)
admin.POST("/redeem", a.adminCreateRedeemBatch)
admin.POST("/redeem/:id/revoke", a.adminRevokeRedeemBatch)
admin.GET("/settings", a.getSettings)
admin.PUT("/settings", a.putSettings)
admin.POST("/webdav/test", a.testWebDAV)
must(r.Run(":" + getenv("PORT", "9540")))
}
func must(err error) {
if err != nil {
panic(err)
}
}
-78
View File
@@ -1,78 +0,0 @@
package main
import (
"fmt"
"os"
"path/filepath"
"gorm.io/gorm"
)
// migrateDatabase applies schema changes and repairs legacy indexes. It is
// intentionally separate from startup so migration behavior can be tested on
// a disposable SQLite database before touching production data.
func migrateDatabase(db *gorm.DB) error {
if err := db.AutoMigrate(&Task{}, &MediaAsset{}, &Setting{}, &User{}, &UserPermission{}, &CreditAccount{}, &CreditTransaction{}, &CreditReservation{}, &RedeemBatch{}, &RedeemCode{}, &RedeemRecord{}, &WebDAVConfig{}, &Session{}, &AuditLog{}); err != nil {
return err
}
if err := db.Exec("DROP INDEX IF EXISTS idx_tasks_source_rid_quality").Error; err != nil {
return err
}
if err := db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_user_source_rid_quality ON tasks(user_id, source, r_id, quality)").Error; err != nil {
return err
}
if err := db.Exec("DROP INDEX IF EXISTS idx_credit_reservations_task_id").Error; err != nil {
return err
}
return db.Exec("CREATE INDEX IF NOT EXISTS idx_credit_reservations_task_id ON credit_reservations(task_id)").Error
}
// backfillMediaAssets turns legacy completed tasks into shared media assets.
// Existing physical files are reused; no media is moved or deleted.
func backfillMediaAssets(db *gorm.DB, downloadDir string) error {
var tasks []Task
if err := db.Where("(asset_id = 0 OR asset_id IS NULL) AND status = ?", "done").Find(&tasks).Error; err != nil {
return err
}
for i := range tasks {
t := &tasks[i]
key := assetKey(t.Source, t.RID, t.Quality)
var asset MediaAsset
result := db.Where("asset_key = ?", key).Limit(1).Find(&asset)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
asset = MediaAsset{
AssetKey: key,
Source: t.Source,
RID: t.RID,
Quality: t.Quality,
LocalPath: t.LocalPath,
Format: t.Format,
ActualQuality: t.ActualQuality,
Size: t.DownloadedBytes,
Status: "done",
}
if asset.LocalPath != "" {
if fi, statErr := os.Stat(asset.LocalPath); statErr == nil && fi.Size() > 0 {
asset.Size = fi.Size()
}
} else if downloadDir != "" {
candidate := filepath.Join(downloadDir, safe(t.Artist)+" - "+safe(t.Title)+"."+t.Format)
if fi, statErr := os.Stat(candidate); statErr == nil && fi.Size() > 0 {
asset.LocalPath, asset.Size = candidate, fi.Size()
}
}
if err := db.Create(&asset).Error; err != nil {
return fmt.Errorf("create media asset for task %d: %w", t.ID, err)
}
}
if t.AssetID != asset.ID {
if err := db.Model(t).Update("asset_id", asset.ID).Error; err != nil {
return fmt.Errorf("link media asset for task %d: %w", t.ID, err)
}
}
}
return nil
}
-98
View File
@@ -1,98 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func openMigrationTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "migration.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
return db
}
func TestMigrateDatabaseLegacySchema(t *testing.T) {
db := openMigrationTestDB(t)
if err := db.Exec(`CREATE TABLE tasks (
id integer PRIMARY KEY AUTOINCREMENT,
source text, r_id text, title text, artist text, album text, pic text,
quality text, format text, status text, error text, local_path text,
remote_path text, created_at datetime, updated_at datetime, total_bytes integer,
downloaded_bytes integer, progress real, resolved_quality text, actual_quality text
)`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("CREATE UNIQUE INDEX idx_tasks_source_rid_quality ON tasks(source, r_id, quality)").Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("INSERT INTO tasks(source,r_id,quality,status) VALUES('kw','legacy-1','standard','done')").Error; err != nil {
t.Fatal(err)
}
if err := migrateDatabase(db); err != nil {
t.Fatal(err)
}
var task Task
if err := db.First(&task).Error; err != nil {
t.Fatal(err)
}
if task.UserID != 0 {
t.Fatalf("legacy task user id = %d, want 0 before bootstrap", task.UserID)
}
var indexes []struct{ Name string }
if err := db.Raw("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='tasks'").Scan(&indexes).Error; err != nil {
t.Fatal(err)
}
found := false
for _, idx := range indexes {
if idx.Name == "idx_tasks_user_source_rid_quality" {
found = true
}
if idx.Name == "idx_tasks_source_rid_quality" {
t.Fatalf("legacy global task index still exists")
}
}
if !found {
t.Fatalf("scoped task index not created")
}
}
func TestBackfillMediaAssetsLinksExistingFiles(t *testing.T) {
db := openMigrationTestDB(t)
if err := migrateDatabase(db); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
path := filepath.Join(dir, "Artist - Title.mp3")
if err := os.WriteFile(path, []byte("audio"), 0644); err != nil {
t.Fatal(err)
}
task := Task{UserID: 1, Source: "kw", RID: "rid-1", Title: "Title", Artist: "Artist", Quality: "standard", Format: "mp3", Status: "done", LocalPath: path}
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
if err := backfillMediaAssets(db, dir); err != nil {
t.Fatal(err)
}
var asset MediaAsset
if err := db.Where("asset_key = ?", assetKey(task.Source, task.RID, task.Quality)).First(&asset).Error; err != nil {
t.Fatal(err)
}
if asset.Status != "done" || asset.LocalPath != path || asset.Size != int64(len("audio")) {
t.Fatalf("unexpected asset: %+v", asset)
}
var got Task
if err := db.First(&got, task.ID).Error; err != nil {
t.Fatal(err)
}
if got.AssetID != asset.ID {
t.Fatalf("task asset id = %d, want %d", got.AssetID, asset.ID)
}
}
-57
View File
@@ -1,57 +0,0 @@
package main
import "time"
type Task struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_tasks_user_source_rid_quality" json:"userId"`
AssetID uint `gorm:"index" json:"assetId,omitempty"`
CreditReservationID uint `gorm:"index" json:"creditReservationId,omitempty"`
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_user_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"`
}
-125
View File
@@ -1,125 +0,0 @@
package main
import "time"
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;size:64" json:"username"`
PasswordHash string `json:"-"`
Role string `gorm:"size:20;index" json:"role"`
Status string `gorm:"size:20;index" json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type UserPermission struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"uniqueIndex:idx_user_permission" json:"userId"`
Permission string `gorm:"uniqueIndex:idx_user_permission;size:64" json:"permission"`
Enabled bool `json:"enabled"`
}
type CreditAccount struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"uniqueIndex" json:"userId"`
Balance int64 `json:"balance"`
Reserved int64 `json:"reserved"`
UpdatedAt time.Time `json:"updatedAt"`
}
type CreditTransaction struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"index" json:"userId"`
Type string `gorm:"index;size:32" json:"type"`
Amount int64 `json:"amount"`
BalanceAfter int64 `json:"balanceAfter"`
ReferenceType string `gorm:"size:32" json:"referenceType"`
ReferenceID string `gorm:"size:64" json:"referenceId"`
Reason string `json:"reason,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type CreditReservation struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"index" json:"userId"`
TaskID uint `gorm:"index" json:"taskId"`
Amount int64 `json:"amount"`
Status string `gorm:"index;size:20" json:"status"`
CreatedAt time.Time `json:"createdAt"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
}
type RedeemBatch struct {
ID uint `gorm:"primaryKey"`
Code string `gorm:"uniqueIndex;size:64" json:"code"`
Credits int64 `json:"credits"`
Count int `json:"count"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
Note string `json:"note,omitempty"`
CreatedBy uint `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
}
type RedeemCode struct {
ID uint `gorm:"primaryKey"`
BatchID uint `gorm:"index" json:"batchId"`
CodeHash string `gorm:"uniqueIndex;size:64" json:"-"`
Prefix string `gorm:"size:24" json:"prefix"`
Credits int64 `json:"credits"`
Status string `gorm:"index;size:20" json:"status"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
RedeemedBy *uint `json:"redeemedBy,omitempty"`
RedeemedAt *time.Time `json:"redeemedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type RedeemRecord struct {
ID uint `gorm:"primaryKey"`
CodeID uint `gorm:"index" json:"codeId"`
BatchID uint `gorm:"index" json:"batchId"`
UserID uint `gorm:"index" json:"userId"`
Credits int64 `json:"credits"`
CreatedAt time.Time `json:"createdAt"`
}
type WebDAVConfig struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"uniqueIndex" json:"userId"`
Enabled bool `json:"enabled"`
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"-"`
RootPath string `json:"rootPath"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type Session struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"index" json:"userId"`
TokenHash string `gorm:"uniqueIndex;size:64" json:"-"`
ExpiresAt time.Time `gorm:"index" json:"expiresAt"`
CreatedAt time.Time `json:"createdAt"`
}
type AuditLog struct {
ID uint `gorm:"primaryKey"`
OperatorID uint `gorm:"index" json:"operatorId"`
Action string `gorm:"index;size:64" json:"action"`
TargetType string `gorm:"size:32" json:"targetType"`
TargetID string `gorm:"size:64" json:"targetId"`
Metadata string `json:"metadata,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
const (
RoleAdmin = "admin"
RolePrivileged = "privileged"
RoleUser = "user"
UserActive = "active"
UserDisabled = "disabled"
PermDownload = "download"
PermRedeem = "redeem"
PermWebDAV = "webdav"
PermWebDAVConfig = "webdav_config"
)
+20
View File
@@ -0,0 +1,20 @@
[project]
name = "am-music-deduper"
version = "0.1.0"
description = "Lightweight NAS music deduplication service for AllMusic"
requires-python = ">=3.10"
dependencies = [
"tinytag>=2.1.1,<3",
"watchdog>=6,<7",
"pyacoustid>=1.3,<2",
"pychromaprint>=1.2,<2",
]
[project.optional-dependencies]
test = ["pytest>=8,<9"]
[project.scripts]
am-deduper = "am_deduper.cli:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
-152
View File
@@ -1,152 +0,0 @@
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
}
a.audit(admin.ID, "redeem.batch.create", "redeem_batch", fmt.Sprint(batch.ID), fmt.Sprintf("credits=%d,count=%d", batch.Credits, batch.Count))
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
}
a.audit(user.ID, "redeem.use", "redeem_code", "hash:"+hashRedeemCode(in.Code)[:12], fmt.Sprintf("credits=%d", amount))
c.JSON(200, gin.H{"credits": amount})
}
func (a *App) adminRevokeRedeemBatch(c *gin.Context) {
id := c.Param("id")
if err := a.db.Model(&RedeemCode{}).Where("batch_id = ? AND status = ?", id, "unused").Update("status", "revoked").Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
admin := c.MustGet("user").(*User)
a.audit(admin.ID, "redeem.batch.revoke", "redeem_batch", id, "unused codes revoked")
c.JSON(200, gin.H{"ok": true})
}
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})
}
-41
View File
@@ -1,41 +0,0 @@
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
}
-129
View File
@@ -1,129 +0,0 @@
package main
import (
"crypto/rand"
"fmt"
"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": "", "hasWebdavPassword": 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
}
// The GET endpoint intentionally never returns the WebDAV password.
// Therefore an empty webdavPass means "keep the existing password", not clear it.
if x, ok := v["webdavPass"]; ok && x != "" {
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
}
// Credentials/config changes should repair already-downloaded local files
// immediately instead of waiting for the periodic retry interval.
go a.resyncPendingUploads()
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
}
// An empty password means "use the saved password". The password is never
// returned to the browser, so this also makes a fresh settings page test
// the credential that is actually stored by the server.
if v.URL == "" {
v.URL = a.cfg.WebDAVURL
}
if v.User == "" {
v.User = a.cfg.WebDAVUser
}
if v.Pass == "" {
v.Pass = a.cfg.WebDAVPass
}
v.Path = strings.Trim(v.Path, "/")
if v.Path == "" {
v.Path = a.cfg.WebDAVPath
}
if v.URL == "" || v.User == "" || v.Pass == "" {
c.JSON(400, gin.H{"ok": false, "error": "WebDAV URL、用户名或密码未配置"})
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": "WebDAV authentication failed: " + err.Error()})
return
}
if v.Path != "" {
if err := client.MkdirAll(v.Path, 0755); err != nil {
c.JSON(502, gin.H{"ok": false, "error": "WebDAV target path is not writable: " + err.Error()})
return
}
}
var token [12]byte
if _, err := rand.Read(token[:]); err != nil {
c.JSON(500, gin.H{"ok": false, "error": "failed to create test token"})
return
}
testPath := strings.Trim(v.Path, "/") + "/.am-downloader-test-" + fmt.Sprintf("%x", token[:])
if err := client.Write(testPath, []byte("ok"), 0644); err != nil {
c.JSON(502, gin.H{"ok": false, "error": "WebDAV write test failed: " + err.Error()})
return
}
_ = client.Remove(testPath)
c.JSON(200, gin.H{"ok": true, "path": v.Path})
}
-58
View File
@@ -1,58 +0,0 @@
package main
import (
"encoding/binary"
"errors"
"io"
"os"
)
func detectAudioFormat(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
b := make([]byte, 64*1024)
n, err := f.Read(b)
if err != nil && err != io.EOF {
return "", err
}
b = b[:n]
if len(b) >= 4 && string(b[:4]) == "fLaC" {
return "flac", nil
}
if len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WAVE" {
return "wav", nil
}
if len(b) >= 12 && string(b[:4]) == "FORM" && (string(b[8:12]) == "AIFF" || string(b[8:12]) == "AIFC") {
return "aiff", nil
}
if len(b) >= 4 && string(b[:4]) == "OggS" {
return "ogg", nil
}
if len(b) >= 12 && string(b[4:8]) == "ftyp" {
return "m4a", nil
}
start := 0
if len(b) >= 10 && string(b[:3]) == "ID3" {
size := int(b[6]&0x7f)<<21 | int(b[7]&0x7f)<<14 | int(b[8]&0x7f)<<7 | int(b[9]&0x7f)
start = 10 + size
if start > len(b) {
start = 10
}
}
for i := start; i+4 <= len(b) && i < start+4096; i++ {
if b[i] == 0xff && b[i+1]&0xe0 == 0xe0 {
h := binary.BigEndian.Uint32(b[i : i+4])
layer := (h >> 17) & 3
if layer == 1 {
return "mp3", nil
}
if b[i+1]&0x06 == 0x06 {
return "aac", nil
}
}
}
return "", errors.New("unknown audio format")
}
+38
View File
@@ -0,0 +1,38 @@
from pathlib import Path
from am_deduper.db import connect
from am_deduper.exact import exact_dedupe
from am_deduper.scanner import scan
def test_exact_dedupe_creates_hardlink(tmp_path: Path):
root = tmp_path / "AllMusic"
(root / "admin").mkdir(parents=True)
(root / "alice").mkdir(parents=True)
a = root / "admin" / "song.flac"
b = root / "alice" / "song.flac"
data = b"fake flac content" * 10000
a.write_bytes(data)
b.write_bytes(data)
db = root / ".dedupe" / "dedupe.db"
assert scan(root, db) == 2
actions = exact_dedupe(db, root)
assert len(actions) == 1
assert a.stat().st_ino == b.stat().st_ino
def test_deleting_one_hardlink_keeps_data(tmp_path: Path):
root = tmp_path / "AllMusic"
(root / "admin").mkdir(parents=True)
(root / "alice").mkdir(parents=True)
a = root / "admin" / "song.flac"
b = root / "alice" / "song.flac"
a.write_bytes(b"same" * 1000)
b.write_bytes(a.read_bytes())
db = root / ".dedupe" / "dedupe.db"
scan(root, db)
exact_dedupe(db, root)
inode = a.stat().st_ino
b.unlink()
assert a.exists() and a.stat().st_ino == inode
assert a.read_bytes() == b"same" * 1000
+21
View File
@@ -0,0 +1,21 @@
from pathlib import Path
from am_deduper.hashing import fast_hash, sha256
def test_fast_hash_same_content(tmp_path: Path):
a = tmp_path / "a.bin"
b = tmp_path / "b.bin"
data = b"x" * 200_000
a.write_bytes(data)
b.write_bytes(data)
assert fast_hash(a) == fast_hash(b)
assert sha256(a) == sha256(b)
def test_fast_hash_uses_size(tmp_path: Path):
a = tmp_path / "a.bin"
b = tmp_path / "b.bin"
a.write_bytes(b"x" * 100)
b.write_bytes(b"x" * 101)
assert fast_hash(a) != fast_hash(b)
-132
View File
@@ -1,132 +0,0 @@
package main
import (
"path"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/studio-b12/gowebdav"
"gorm.io/gorm"
)
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) initializeUserWebDAVDefaults() error {
var users []User
if err := a.db.Find(&users).Error; err != nil {
return err
}
for _, user := range users {
var cfg WebDAVConfig
err := a.db.Where("user_id = ?", user.ID).First(&cfg).Error
if err == nil {
continue
}
if err != gorm.ErrRecordNotFound {
return err
}
root := cleanWebDAVRoot(strings.TrimRight(a.cfg.WebDAVPath, "/") + "/" + user.Username)
if err := a.db.Create(&WebDAVConfig{UserID: user.ID, Enabled: true, RootPath: root}).Error; err != nil {
return err
}
}
return nil
}
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 !a.hasPermission(user.ID, PermWebDAV) && user.Role != RoleAdmin {
return nil, "", false
}
// All users share the same global WebDAV connection by default. Each user
// gets an isolated destination directory, so upload/retry behavior is
// identical for admin, privileged, and regular users.
if a.cfg.WebDAVURL == "" || a.storage == nil {
return nil, "", false
}
var cfg WebDAVConfig
if a.db.Where("user_id = ?", user.ID).First(&cfg).Error == nil && cfg.RootPath != "" {
return a.storage, cfg.RootPath, true
}
root := cleanWebDAVRoot(strings.TrimRight(a.cfg.WebDAVPath, "/") + "/" + user.Username)
return a.storage, root, true
}
-52
View File
@@ -1,52 +0,0 @@
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")
}
a.releaseAsset(t)
if err == nil {
err = errors.New("unknown download 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)
}
}
func (a *App) failUpload(t *Task, err error) {
if err == nil {
err = errors.New("unknown WebDAV upload error")
}
t.RemotePath = ""
t.Status = "upload_pending"
t.Error = "WebDAV 上传失败,等待自动重试: " + 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))
}
-131
View File
@@ -1,131 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
)
func (a *App) upload(t *Task) {
if t.RemotePath != "" && t.Status == "done" {
return
}
storage, root, enabled := a.storageForTask(t)
if !enabled || storage == nil || root == "" {
if t.Status == "upload_pending" {
if err := a.db.Model(t).Updates(map[string]any{
"status": "done", "error": "",
}).Error; err != nil {
return
}
t.Status, t.Error = "done", ""
}
return
}
if !a.claimUpload(t) {
return
}
remote := strings.TrimRight(root, "/") + "/" + safe(t.Artist) + "/" +
safe(t.Album) + "/" + safe(t.Title) + "." + safe(t.Format)
if err := storage.MkdirAll(filepath.ToSlash(filepath.Dir(remote))); err != nil {
a.failUpload(t, err)
return
}
fi, statErr := os.Stat(t.LocalPath)
if statErr != nil {
if a.recoverMissingLocalUpload(t) {
return
}
a.failUpload(t, statErr)
return
}
if same, _ := storage.ExistsSameSize(remote, fi.Size()); same {
t.RemotePath = remote
t.Status, t.Progress, t.Error = "done", 100, ""
_ = a.db.Model(t).Updates(map[string]any{
"remote_path": remote, "status": "done",
"progress": 100, "error": "",
}).Error
a.cleanupLocalFile(t)
return
}
if err := storage.Upload(t.LocalPath, remote); err != nil {
a.failUpload(t, err)
return
}
t.RemotePath = remote
t.Status, t.Progress, t.Error = "done", 100, ""
if err := a.db.Model(t).Updates(map[string]any{
"remote_path": remote, "status": "done",
"progress": 100, "error": "",
}).Error; err != nil {
return
}
if a.cfg.WebDAVVersionPolicy == "replace_lower" {
a.cleanupLowerQualityVersions(t, storage)
}
a.cleanupLocalFile(t)
}
func (a *App) cleanupLocalFile(t *Task) {
if a.cfg.LocalFilePolicy != "delete_after_upload" || t.LocalPath == "" {
return
}
if t.AssetID != 0 {
var pending int64
if err := a.db.Model(&Task{}).
Where("asset_id = ? AND id <> ? AND local_path = ? AND "+
"(status IN ? OR (status = ? AND (remote_path = '' OR remote_path IS NULL)))",
t.AssetID, t.ID, t.LocalPath,
[]string{"pending", "resolving", "downloading", "uploading", "upload_pending"},
"done").Count(&pending).Error; err != nil {
return
}
if pending > 0 {
return
}
}
if err := os.Remove(t.LocalPath); err != nil && !os.IsNotExist(err) {
t.Error = "WebDAV 上传成功,但删除本地文件失败: " + err.Error()
_ = a.db.Save(t)
return
}
if t.AssetID != 0 {
_ = a.db.Model(&MediaAsset{}).
Where("id = ? AND local_path = ?", t.AssetID, t.LocalPath).
Updates(map[string]any{
"status": "evicted", "local_path": "",
"size": 0, "owner_task_id": 0, "lease_until": nil,
}).Error
}
}
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("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
}
_ = 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
}
-137
View File
@@ -1,137 +0,0 @@
package main
import (
"os"
"time"
"gorm.io/gorm"
)
// recoverMissingLocalUpload re-queues a task when its shared media file has
// disappeared. This can happen after an older version deleted a shared file
// after another user's upload. The asset is invalidated so the next worker
// downloads it again instead of retrying an impossible WebDAV upload forever.
func (a *App) recoverMissingLocalUpload(t *Task) bool {
if t.AssetID == 0 || t.LocalPath == "" {
return false
}
if _, err := os.Stat(t.LocalPath); err == nil || !os.IsNotExist(err) {
return false
}
var queued bool
err := a.transactionRetry(func(tx *gorm.DB) error {
var asset MediaAsset
if err := tx.First(&asset, t.AssetID).Error; err != nil {
return err
}
if asset.Status == "done" && asset.LocalPath != "" {
if _, statErr := os.Stat(asset.LocalPath); statErr == nil {
if err := tx.Model(&Task{}).Where("id = ?", t.ID).Updates(map[string]any{
"local_path": asset.LocalPath, "format": asset.Format,
"actual_quality": asset.ActualQuality,
"status": "upload_pending", "error": "共享资源已恢复,正在继续上传",
}).Error; err != nil {
return err
}
queued = true
return nil
}
}
if asset.LocalPath != "" && asset.LocalPath != t.LocalPath {
return nil
}
asset.Status = "error"
asset.OwnerTaskID = 0
asset.LeaseUntil = time.Time{}
asset.LocalPath = ""
asset.Size = 0
if err := tx.Save(&asset).Error; err != nil {
return err
}
if err := tx.Model(&Task{}).Where("id = ?", t.ID).Updates(map[string]any{
"status": "pending", "error": "本地共享文件不存在,正在重新下载",
"progress": 0, "downloaded_bytes": 0,
}).Error; err != nil {
return err
}
queued = true
return nil
})
if err != nil || !queued {
return false
}
a.enqueueTask(t.ID)
return true
}
// resyncPendingUploads repairs tasks that finished locally while WebDAV was
// unavailable or the user's WebDAV permission was not yet configured.
func (a *App) resyncPendingUploads() {
var tasks []Task
if err := a.db.Where("local_path <> ? AND ((status IN ? AND (remote_path = '' OR remote_path IS NULL)) OR status = ?)",
"", []string{"done", "upload_pending"}, "error").Order("id asc").Limit(100).Find(&tasks).Error; err != nil {
return
}
for i := range tasks {
t := tasks[i]
if t.Status == "error" {
status, err := a.taskReservationStatus(t.ID, t.CreditReservationID)
if err != nil {
continue
}
if status == "committed" {
if err := a.repairCommittedTask(&t); err != nil {
continue
}
} else {
var asset MediaAsset
if t.AssetID == 0 || a.db.First(&asset, t.AssetID).Error != nil ||
asset.Status != "done" || asset.LocalPath == "" {
continue
}
if _, statErr := os.Stat(asset.LocalPath); statErr != nil {
continue
}
t.LocalPath = asset.LocalPath
t.Format = asset.Format
t.ActualQuality = asset.ActualQuality
t.Status = "upload_pending"
t.Error = "共享资源已完成,历史失败任务恢复上传"
if err := a.db.Model(&t).Updates(map[string]any{
"local_path": t.LocalPath, "format": t.Format,
"actual_quality": t.ActualQuality,
"status": t.Status, "error": t.Error,
}).Error; err != nil {
continue
}
}
}
if _, _, ok := a.storageForTask(&t); !ok {
continue
}
if a.recoverMissingLocalUpload(&t) {
continue
}
var current Task
if a.db.First(&current, t.ID).Error != nil {
continue
}
if current.Status == "done" || current.Status == "upload_pending" {
a.enqueueTask(current.ID)
}
}
}
// startWebDAVResync runs a small periodic repair loop. It is intentionally
// conservative: only tasks with a completed local file and no confirmed remote path are retried.
func (a *App) startWebDAVResync() {
go func() {
a.resyncPendingUploads()
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
a.resyncPendingUploads()
}
}()
}
-79
View File
@@ -1,79 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestCleanupLocalFileKeepsSharedAssetForPendingTask(t *testing.T) {
db := openMigrationTestDB(t)
if err := migrateDatabase(db); err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "shared.flac")
if err := os.WriteFile(path, []byte("audio"), 0644); err != nil {
t.Fatal(err)
}
asset := MediaAsset{AssetKey: assetKey("kw", "shared", "lossless"), Source: "kw", RID: "shared", Quality: "lossless", LocalPath: path, Format: "flac", Status: "done", Size: 5}
if err := db.Create(&asset).Error; err != nil {
t.Fatal(err)
}
first := Task{UserID: 1, AssetID: asset.ID, Source: "kw", RID: "shared", Quality: "lossless", Status: "done", LocalPath: path, RemotePath: "Music/shared.flac"}
second := Task{UserID: 2, AssetID: asset.ID, Source: "kw", RID: "shared", Quality: "lossless", Status: "upload_pending", LocalPath: path}
if err := db.Create(&first).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&second).Error; err != nil {
t.Fatal(err)
}
a := &App{db: db, cfg: Config{LocalFilePolicy: "delete_after_upload"}}
a.cleanupLocalFile(&first)
if _, err := os.Stat(path); err != nil {
t.Fatalf("shared file deleted while needed: %v", err)
}
if err := db.Model(&second).Updates(map[string]any{"status": "done", "remote_path": "Music/shared.flac"}).Error; err != nil {
t.Fatal(err)
}
a.cleanupLocalFile(&first)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("shared file still exists: %v", err)
}
var gotAsset MediaAsset
if err := db.First(&gotAsset, asset.ID).Error; err != nil {
t.Fatal(err)
}
if gotAsset.Status != "evicted" || gotAsset.LocalPath != "" {
t.Fatalf("asset not evicted: %+v", gotAsset)
}
}
func TestRecoverMissingLocalUploadRequeuesTask(t *testing.T) {
db := openMigrationTestDB(t)
if err := migrateDatabase(db); err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "missing.flac")
asset := MediaAsset{AssetKey: assetKey("kw", "missing", "lossless"), Source: "kw", RID: "missing", Quality: "lossless", LocalPath: path, Format: "flac", Status: "done", Size: 10}
if err := db.Create(&asset).Error; err != nil {
t.Fatal(err)
}
task := Task{UserID: 1, AssetID: asset.ID, Source: "kw", RID: "missing", Quality: "lossless", Status: "upload_pending", LocalPath: path}
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
a := &App{db: db, queue: make(chan uint, 1)}
if !a.recoverMissingLocalUpload(&task) {
t.Fatal("missing local file was not requeued")
}
var got Task
if err := db.First(&got, task.ID).Error; err != nil {
t.Fatal(err)
}
if got.Status != "pending" {
t.Fatalf("task status = %q, want pending", got.Status)
}
if <-a.queue != task.ID {
t.Fatal("queued task id mismatch")
}
}