Compare commits
44
Commits
a0673ab79e
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b116303abe | ||
|
|
0735cb2e09 | ||
|
|
a295f2d610 | ||
|
|
d8cc416b20 | ||
|
|
215c38f4fb | ||
|
|
020944d4a0 | ||
|
|
40d99e1e22 | ||
|
|
efb2ff33b4 | ||
|
|
684665f0be | ||
|
|
3025019504 | ||
|
|
d9df512e0a | ||
|
|
221ce7b6bb | ||
|
|
aaf6790afe | ||
|
|
ceec0d797c | ||
|
|
6ccaec53d7 | ||
|
|
fef1cb7057 | ||
|
|
9f57addec8 | ||
|
|
7507ffa02b | ||
|
|
5a5f1e6d88 | ||
|
|
5a624a5d60 | ||
|
|
44a365822b | ||
|
|
7169a7b5fd | ||
|
|
4607312b7a | ||
|
|
a60801173f | ||
|
|
dea1473424 | ||
|
|
b853a239ff | ||
|
|
7d79d43b61 | ||
|
|
c79381555a | ||
|
|
5fbf9fe252 | ||
|
|
dcf01e8f4c | ||
|
|
c4aa30e564 | ||
|
|
8b31956c6d | ||
|
|
7628390966 | ||
|
|
72fdd3b74c | ||
|
|
803362bbc7 | ||
|
|
285064d144 | ||
|
|
6c585ff835 | ||
|
|
94e84b53cf | ||
|
|
83224fe5b5 | ||
|
|
f376348b05 | ||
|
|
0da24cb183 | ||
|
|
1de4f9d85d | ||
|
|
b7756756c5 | ||
|
|
356c3273b0 |
+10
-4
@@ -1,5 +1,11 @@
|
|||||||
.git
|
.git
|
||||||
__pycache__
|
.gitignore
|
||||||
*.pyc
|
data
|
||||||
tests
|
downloads
|
||||||
.dedupe
|
*.db
|
||||||
|
*.db-*
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.part
|
||||||
|
legacy
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 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
|
||||||
+8
-5
@@ -1,6 +1,9 @@
|
|||||||
.dedupe/
|
data/
|
||||||
|
downloads/
|
||||||
*.db
|
*.db
|
||||||
__pycache__/
|
*.db-*
|
||||||
.pytest_cache/
|
*.log
|
||||||
.venv/
|
.env
|
||||||
am_music_deduper.egg-info/
|
.env.*
|
||||||
|
*.part
|
||||||
|
legacy/
|
||||||
|
|||||||
+13
-6
@@ -1,7 +1,14 @@
|
|||||||
FROM python:3.12-slim
|
FROM golang:1.26-alpine AS build
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends libchromaprint-tools libchromaprint1 && rm -rf /var/lib/apt/lists/*
|
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
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY pyproject.toml README.md ./
|
COPY --from=build /out/am-downloader /app/am-downloader
|
||||||
COPY am_deduper ./am_deduper
|
COPY index.html /app/index.html
|
||||||
RUN pip install --no-cache-dir .
|
RUN mkdir -p /data /downloads
|
||||||
ENTRYPOINT ["am-deduper"]
|
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"]
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# 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. 测试、迁移兼容性、备份与部署
|
||||||
@@ -1,147 +1,123 @@
|
|||||||
# AM Music Deduper
|
# AM Downloader
|
||||||
|
|
||||||
轻量级家庭 NAS 音乐库去重服务,面向 `AllMusic/{username}/` 场景。
|
独立的音乐搜索与下载 Web 服务,使用 AM 音乐 API 解析资源,并可选同步到 WebDAV/NAS。
|
||||||
|
|
||||||
## 处理模型
|
## 特性
|
||||||
|
|
||||||
```text
|
- 多用户登录、角色与权限隔离(admin / privileged / user)
|
||||||
AllMusic
|
- 积分账户、预扣、成功扣除、失败退款
|
||||||
↓
|
- 兑换码批次生成、使用、撤销与审计
|
||||||
文件稳定检测(size + mtime)
|
- 音乐搜索与分页
|
||||||
↓
|
- 下载队列、用户任务去重、跨用户媒体资源去重、失败重试
|
||||||
L1:size + head/tail 64 KiB
|
- 自动识别实际音频格式,避免错误扩展名
|
||||||
↓
|
- 标记解析后的音质与实际音质
|
||||||
完整 SHA-256
|
- WebDAV/NAS 同步
|
||||||
├─ 相同 → Exact Duplicate → hard link
|
- 可配置同曲低音质版本清理策略
|
||||||
└─ 不同 → duration ±3s → Chromaprint
|
- 可配置上传成功后的本地文件清理策略
|
||||||
├─ 高相似且版本一致 → hard link
|
- Docker Compose 部署
|
||||||
├─ 中等相似/版本冲突 → review
|
|
||||||
└─ 低相似 → 保留
|
|
||||||
```
|
|
||||||
|
|
||||||
### 安全规则
|
## 架构
|
||||||
|
|
||||||
- SHA-256 是 Exact Duplicate 的最终依据;L1 只负责缩小候选范围。
|
`9540` 提供 Downloader Web/API;音乐解析服务通过配置的 `AM_API_URL` 访问,默认指向本机 `9530`。
|
||||||
- 不根据文件名、歌手、歌名、专辑或来源单独判重。
|
|
||||||
- 确认相同版本后,优先使用 hard link 保留每个用户原来的逻辑路径。
|
|
||||||
- 用户删除自己的路径不会影响其他 hard link;最后一个 link 删除后数据才释放。
|
|
||||||
- hard link 要求源文件和目标文件位于同一文件系统。
|
|
||||||
- 正在写入的文件必须先通过稳定性检查。
|
|
||||||
- L2 对版本标记冲突的文件进入 review,不自动合并。
|
|
||||||
- SQLite 是加速索引,文件系统是最终事实;数据库丢失后可以重新扫描。
|
|
||||||
- `.dedupe` 不参与音乐扫描,避免把内部文件再次处理。
|
|
||||||
|
|
||||||
## CLI
|
运行时数据库和下载文件不会进入 Git 仓库。
|
||||||
|
|
||||||
|
## 快速部署
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 建立/更新索引,不修改音乐文件
|
git clone <your-gitea-repository>
|
||||||
am-deduper scan /music/AllMusic --db /data/dedupe.db
|
cd am-downloader
|
||||||
|
docker compose up -d --build
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Feiniu NAS 部署
|
默认访问:`http://127.0.0.1:9540`
|
||||||
|
|
||||||
生产环境采用 **Gitea Container Registry → NAS 拉取镜像 → NAS 本地运行**。
|
## 配置
|
||||||
镜像由 wjyhk 构建并推送到 `gitea.king.nyc.mn`,Feiniu NAS 不需要本地 build。
|
|
||||||
|
|
||||||
建议目录:
|
Docker Compose 中只保存非敏感的运行参数。WebDAV 凭据通过 Web UI 的设置页面写入运行时数据库,**不要将数据库提交到 Git**。
|
||||||
|
|
||||||
```text
|
可配置:
|
||||||
NAS
|
|
||||||
├── Gitea Registry
|
|
||||||
│ └── gitea.king.nyc.mn/openclaw/am-deduper:latest
|
|
||||||
├── AllMusic/
|
|
||||||
│ ├── admin/
|
|
||||||
│ ├── alice/
|
|
||||||
│ └── bob/
|
|
||||||
└── AppData/
|
|
||||||
└── am-deduper/
|
|
||||||
└── dedupe.db
|
|
||||||
```
|
|
||||||
|
|
||||||
### NAS `.env`
|
- `PORT`:Web 服务端口,默认 `9540`
|
||||||
|
- `DB_PATH`:SQLite 数据库路径
|
||||||
|
- `DOWNLOAD_DIR`:下载目录
|
||||||
|
- `AM_API_URL`:AM 音乐 API 地址
|
||||||
|
- `WORKERS`:下载 worker 数量
|
||||||
|
|
||||||
```dotenv
|
首次部署后进入设置页面填写 WebDAV 地址、用户名、密码和远程目录。
|
||||||
ALLMUSIC_PATH=<NAS绝对路径>/AllMusic
|
|
||||||
DEDUPE_DATA_PATH=<NAS绝对路径>/AppData/am-deduper
|
|
||||||
```
|
|
||||||
|
|
||||||
### 首次部署
|
## 数据与安全
|
||||||
|
|
||||||
先登录 Gitea Container Registry:
|
以下内容属于运行时数据,默认被 `.gitignore` 排除:
|
||||||
|
|
||||||
|
- `data/`
|
||||||
|
- `downloads/`
|
||||||
|
- `*.db`
|
||||||
|
- 日志、临时文件和本地环境文件
|
||||||
|
|
||||||
|
提交前应检查 Git diff,确认没有密码、Token、Cookie、私有密钥或个人环境配置。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker login gitea.king.nyc.mn
|
go test ./...
|
||||||
|
go build -o am-downloader .
|
||||||
```
|
```
|
||||||
|
|
||||||
拉取生产镜像并准备数据目录:
|
前端为单页静态界面,由 Go 服务直接提供。
|
||||||
|
|
||||||
```bash
|
## API
|
||||||
docker pull gitea.king.nyc.mn/openclaw/am-deduper:latest
|
|
||||||
mkdir -p <NAS绝对路径>/AppData/am-deduper
|
|
||||||
```
|
|
||||||
|
|
||||||
然后使用 Compose 启动容器;当前 Compose 已配置 NAS 实际目录,并提供独立 Web 管理界面。首次执行建议先运行 scan 和 dry-run:
|
认证:
|
||||||
|
|
||||||
```bash
|
- `POST /api/auth/login`
|
||||||
docker compose run --rm am-deduper scan /music/AllMusic --db /data/dedupe.db
|
- `POST /api/auth/logout`
|
||||||
docker compose run --rm am-deduper dedupe /music/AllMusic --db /data/dedupe.db --dry-run
|
- `POST /api/auth/password`
|
||||||
```
|
- `GET /api/auth/me`
|
||||||
|
|
||||||
确认 dry-run 输出后,再执行正式 Exact/L2:
|
用户功能:
|
||||||
|
|
||||||
```bash
|
- `GET /api/search`
|
||||||
docker compose run --rm am-deduper dedupe /music/AllMusic --db /data/dedupe.db
|
- `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`
|
||||||
|
|
||||||
然后启动长期监听和 Web 管理界面:
|
管理员功能:
|
||||||
|
|
||||||
```bash
|
- `GET/POST /api/admin/users`
|
||||||
docker compose up -d
|
- `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`
|
||||||
|
|
||||||
Web UI 默认端口为 `18080`:
|
## 数据库迁移注意事项
|
||||||
|
|
||||||
```text
|
首次从旧版本升级时,启动过程会自动执行 GORM schema migration,并将旧任务归属到首次创建的管理员账号;同时把旧的全局任务唯一索引改为按用户隔离的唯一索引,并创建共享媒体资源表。
|
||||||
http://<NAS-IP>:18080/
|
|
||||||
```
|
|
||||||
|
|
||||||
管理界面包含:
|
升级前仍建议先备份 `data/downloader.db`。Docker 部署时不要把 `data/` 或 `downloads/` 加入 Git。
|
||||||
- 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
|
|
||||||
```
|
|
||||||
|
|
||||||
当前生产镜像:
|
- 同一 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 需要时才允许清理。
|
||||||
|
|
||||||
```text
|
并发回归测试覆盖:Task 抢占、共享 Asset 租约恢复、已提交额度重试、已退款额度重试、并发积分预留以及 Asset generation 隔离。
|
||||||
gitea.king.nyc.mn/openclaw/am-deduper:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
wjyhk 已验证推送成功;当前镜像 digest:
|
|
||||||
|
|
||||||
```text
|
|
||||||
sha256:96e0b87e454bc9fa96aa5171b117a029bbcef34d20099577db6442544ce5c9ee
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 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 契约完整无副作用。
|
||||||
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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
@@ -0,0 +1,362 @@
|
|||||||
|
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})
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AMAPIClient struct {
|
||||||
|
BaseURL string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AMAPIClient) Search(keyword, source string, page, limit int) (*http.Response, error) {
|
||||||
|
u, err := url.Parse(strings.TrimRight(c.BaseURL, "/") + "/search")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("keyword", keyword)
|
||||||
|
q.Set("source", source)
|
||||||
|
q.Set("page", strconv.Itoa(page))
|
||||||
|
q.Set("limit", strconv.Itoa(limit))
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return c.HTTP.Get(u.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AMAPIClient) Resolve(source, rid, quality string) (playInfo, error) {
|
||||||
|
u, err := url.Parse(strings.TrimRight(c.BaseURL, "/") + "/song/play-url")
|
||||||
|
if err != nil {
|
||||||
|
return playInfo{}, err
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("source", source)
|
||||||
|
q.Set("rid", rid)
|
||||||
|
q.Set("level", quality)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
resp, err := c.HTTP.Get(u.String())
|
||||||
|
if err != nil {
|
||||||
|
return playInfo{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var x struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data playInfo `json:"data"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&x); err != nil {
|
||||||
|
return playInfo{}, err
|
||||||
|
}
|
||||||
|
if x.Code != 0 || x.Data.URL == "" {
|
||||||
|
if x.Message == "" {
|
||||||
|
x.Message = "music API returned no playable URL"
|
||||||
|
}
|
||||||
|
return playInfo{}, errors.New(x.Message)
|
||||||
|
}
|
||||||
|
return x.Data, nil
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
__version__ = "0.1.0"
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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))
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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}")
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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",
|
|
||||||
})
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
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=>({'&':'&','<':'<','>':'>','"':'"'}[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()
|
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
db *gorm.DB
|
||||||
|
cfg Config
|
||||||
|
queue chan uint
|
||||||
|
wg sync.WaitGroup
|
||||||
|
httpClient *http.Client
|
||||||
|
am AMClient
|
||||||
|
storage RemoteStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
// AMClient isolates the music API from the task engine.
|
||||||
|
type AMClient interface {
|
||||||
|
Search(keyword, source string, page, limit int) (*http.Response, error)
|
||||||
|
Resolve(source, rid, quality string) (playInfo, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteStorage isolates WebDAV and leaves room for S3/local backends later.
|
||||||
|
type RemoteStorage interface {
|
||||||
|
Upload(localPath, remotePath string) error
|
||||||
|
ExistsSameSize(remotePath string, size int64) (bool, error)
|
||||||
|
Remove(remotePath string) error
|
||||||
|
MkdirAll(remoteDir string) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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) }
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
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
@@ -0,0 +1,255 @@
|
|||||||
|
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(¤t, 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(¤t).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
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
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(¤t, task.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := a.prepareRetry(¤t); 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) loadSettings() {
|
||||||
|
var ss []Setting
|
||||||
|
a.db.Find(&ss)
|
||||||
|
for _, s := range ss {
|
||||||
|
switch s.Key {
|
||||||
|
case "am_api":
|
||||||
|
a.cfg.AMAPI = s.Value
|
||||||
|
case "download_dir":
|
||||||
|
a.cfg.DownloadDir = s.Value
|
||||||
|
case "webdav_url":
|
||||||
|
a.cfg.WebDAVURL = s.Value
|
||||||
|
case "webdav_user":
|
||||||
|
a.cfg.WebDAVUser = s.Value
|
||||||
|
case "webdav_pass":
|
||||||
|
a.cfg.WebDAVPass = s.Value
|
||||||
|
case "webdav_path":
|
||||||
|
a.cfg.WebDAVPath = s.Value
|
||||||
|
case "quality":
|
||||||
|
a.cfg.DefaultQuality = s.Value
|
||||||
|
case "webdav_version_policy":
|
||||||
|
a.cfg.WebDAVVersionPolicy = s.Value
|
||||||
|
case "local_file_policy":
|
||||||
|
a.cfg.LocalFilePolicy = s.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) saveSetting(k, v string) {
|
||||||
|
var s Setting
|
||||||
|
if a.db.Where("key = ?", k).First(&s).Error != nil {
|
||||||
|
a.db.Create(&Setting{Key: k, Value: v})
|
||||||
|
} else {
|
||||||
|
s.Value = v
|
||||||
|
a.db.Save(&s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig(db *gorm.DB) Config {
|
||||||
|
cfg := Config{
|
||||||
|
AMAPI: getenv("AM_API_URL", "http://127.0.0.1:9530/api"),
|
||||||
|
DownloadDir: getenv("DOWNLOAD_DIR", "/downloads"),
|
||||||
|
DefaultQuality: getenv("DEFAULT_QUALITY", "standard"),
|
||||||
|
WorkerCount: atoi(getenv("WORKERS", "2"), 2),
|
||||||
|
}
|
||||||
|
_ = db
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeConfig(cfg *Config) {
|
||||||
|
if cfg.WorkerCount < 1 {
|
||||||
|
cfg.WorkerCount = 1
|
||||||
|
}
|
||||||
|
if cfg.WebDAVVersionPolicy != "keep" && cfg.WebDAVVersionPolicy != "replace_lower" {
|
||||||
|
cfg.WebDAVVersionPolicy = "keep"
|
||||||
|
}
|
||||||
|
if cfg.LocalFilePolicy != "keep" && cfg.LocalFilePolicy != "delete_after_upload" {
|
||||||
|
cfg.LocalFilePolicy = "keep"
|
||||||
|
}
|
||||||
|
cfg.WebDAVPath = strings.Trim(cfg.WebDAVPath, "/")
|
||||||
|
_ = os.MkdirAll(cfg.DownloadDir, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenv(k, d string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoi(s string, d int) int {
|
||||||
|
v, e := strconv.Atoi(s)
|
||||||
|
if e != nil || v < 1 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbDir(path string) string { return filepath.Dir(path) }
|
||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
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
@@ -0,0 +1,125 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-33
@@ -1,37 +1,15 @@
|
|||||||
services:
|
services:
|
||||||
am-deduper:
|
am-downloader:
|
||||||
image: gitea.king.nyc.mn/openclaw/am-deduper:latest
|
build: .
|
||||||
container_name: am-deduper
|
container_name: am-downloader
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
network_mode: host
|
||||||
- watch
|
|
||||||
- /music/AllMusic
|
|
||||||
- --db
|
|
||||||
- /data/dedupe.db
|
|
||||||
environment:
|
environment:
|
||||||
TZ: Asia/Shanghai
|
PORT: "9540"
|
||||||
PYTHONUNBUFFERED: "1"
|
DB_PATH: /data/downloader.db
|
||||||
|
DOWNLOAD_DIR: /downloads
|
||||||
|
AM_API_URL: http://127.0.0.1:9530/api
|
||||||
|
WORKERS: "2"
|
||||||
volumes:
|
volumes:
|
||||||
- /vol2/1000/Docker/chfs/data/Backup/AllMusic:/music/AllMusic
|
- ./data:/data
|
||||||
- /vol2/1000/Docker/am-deduper/data:/data
|
- ./downloads:/downloads
|
||||||
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
@@ -0,0 +1,265 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizedFormat(format string) string {
|
||||||
|
f := strings.ToLower(strings.TrimPrefix(format, "."))
|
||||||
|
switch f {
|
||||||
|
case "m4a", "mp4", "mmp4":
|
||||||
|
return "m4a"
|
||||||
|
case "mflac", "flac":
|
||||||
|
return "flac"
|
||||||
|
case "mp3":
|
||||||
|
return "mp3"
|
||||||
|
case "ogg", "opus":
|
||||||
|
return "ogg"
|
||||||
|
case "wav":
|
||||||
|
return "wav"
|
||||||
|
case "aac":
|
||||||
|
return "aac"
|
||||||
|
default:
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func actualQuality(requested, resolved, format string) string {
|
||||||
|
f := normalizedFormat(format)
|
||||||
|
if f == "flac" {
|
||||||
|
if resolved != "" {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
if requested == "lossless" || requested == "hires" {
|
||||||
|
return requested
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f == "mp3" {
|
||||||
|
if resolved == "standard" || resolved == "exhigh" || resolved == "sq" {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameToActualFormat(local string, format string) (string, error) {
|
||||||
|
ext := normalizedFormat(format)
|
||||||
|
if ext == "" {
|
||||||
|
return local, nil
|
||||||
|
}
|
||||||
|
target := strings.TrimSuffix(local, filepath.Ext(local)) + "." + ext
|
||||||
|
if target == local {
|
||||||
|
return local, nil
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(target); err == nil {
|
||||||
|
if err := os.Remove(local); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
if err := os.Rename(local, target); err != nil {
|
||||||
|
return "", fmt.Errorf("rename actual format: %w", err)
|
||||||
|
}
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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
@@ -0,0 +1,199 @@
|
|||||||
|
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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
[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"]
|
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
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})
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/studio-b12/gowebdav"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebDAVStorage struct{ client *gowebdav.Client }
|
||||||
|
|
||||||
|
func NewWebDAVStorage(cfg Config) *WebDAVStorage {
|
||||||
|
c := gowebdav.NewClient(cfg.WebDAVURL, cfg.WebDAVUser, cfg.WebDAVPass)
|
||||||
|
c.SetTimeout(10 * time.Minute)
|
||||||
|
return &WebDAVStorage{client: c}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebDAVStorage) MkdirAll(remoteDir string) error { return s.client.MkdirAll(remoteDir, 0755) }
|
||||||
|
|
||||||
|
func (s *WebDAVStorage) ExistsSameSize(remotePath string, size int64) (bool, error) {
|
||||||
|
fi, err := s.client.Stat(remotePath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return fi.Size() == size, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebDAVStorage) Upload(localPath, remotePath string) error {
|
||||||
|
f, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return s.client.WriteStream(remotePath, f, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebDAVStorage) Remove(remotePath string) error { return s.client.Remove(remotePath) }
|
||||||
|
func (s *WebDAVStorage) Stat(remotePath string) error {
|
||||||
|
_, err := s.client.Stat(remotePath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
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})
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func detectAudioFormat(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
b := make([]byte, 64*1024)
|
||||||
|
n, err := f.Read(b)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b = b[:n]
|
||||||
|
if len(b) >= 4 && string(b[:4]) == "fLaC" {
|
||||||
|
return "flac", nil
|
||||||
|
}
|
||||||
|
if len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WAVE" {
|
||||||
|
return "wav", nil
|
||||||
|
}
|
||||||
|
if len(b) >= 12 && string(b[:4]) == "FORM" && (string(b[8:12]) == "AIFF" || string(b[8:12]) == "AIFC") {
|
||||||
|
return "aiff", nil
|
||||||
|
}
|
||||||
|
if len(b) >= 4 && string(b[:4]) == "OggS" {
|
||||||
|
return "ogg", nil
|
||||||
|
}
|
||||||
|
if len(b) >= 12 && string(b[4:8]) == "ftyp" {
|
||||||
|
return "m4a", nil
|
||||||
|
}
|
||||||
|
start := 0
|
||||||
|
if len(b) >= 10 && string(b[:3]) == "ID3" {
|
||||||
|
size := int(b[6]&0x7f)<<21 | int(b[7]&0x7f)<<14 | int(b[8]&0x7f)<<7 | int(b[9]&0x7f)
|
||||||
|
start = 10 + size
|
||||||
|
if start > len(b) {
|
||||||
|
start = 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := start; i+4 <= len(b) && i < start+4096; i++ {
|
||||||
|
if b[i] == 0xff && b[i+1]&0xe0 == 0xe0 {
|
||||||
|
h := binary.BigEndian.Uint32(b[i : i+4])
|
||||||
|
layer := (h >> 17) & 3
|
||||||
|
if layer == 1 {
|
||||||
|
return "mp3", nil
|
||||||
|
}
|
||||||
|
if b[i+1]&0x06 == 0x06 {
|
||||||
|
return "aac", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New("unknown audio format")
|
||||||
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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
@@ -0,0 +1,132 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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(¤t, 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()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user