feat: make NAS self-build deployment canonical

This commit is contained in:
OpenClaw Agent
2026-09-18 05:13:22 +08:00
parent 7219d93b99
commit e08010610e
6 changed files with 186 additions and 88 deletions
+33 -19
View File
@@ -7,7 +7,7 @@
```text ```text
AllMusic AllMusic
↓ ↓
文件稳定检测 文件稳定检测(size + mtime)
↓ ↓
L1:size + head/tail 64 KiB L1:size + head/tail 64 KiB
↓ ↓
@@ -19,17 +19,17 @@ L1:size + head/tail 64 KiB
└─ 低相似 → 保留 └─ 低相似 → 保留
``` ```
### 关键安全规则 ### 安全规则
- SHA-256 是 Exact Duplicate 的最终依据;L1 只负责缩小候选范围。 - SHA-256 是 Exact Duplicate 的最终依据;L1 只负责缩小候选范围。
- 不根据文件名、歌手、歌名、专辑或来源单独判重。 - 不根据文件名、歌手、歌名、专辑或来源单独判重。
- 确认相同版本后,优先使用 hard link 保留每个用户原来的逻辑路径。 - 确认相同版本后,优先使用 hard link 保留每个用户原来的逻辑路径。
- 用户删除自己的路径不会影响其他 hard link;最后一个 link 删除后数据才释放。 - 用户删除自己的路径不会影响其他 hard link;最后一个 link 删除后数据才释放。
- 不跨文件系统创建 hard link。 - hard link 要求源文件和目标文件位于同一文件系统。
- 正在写入的文件必须先通过稳定性检查。 - 正在写入的文件必须先通过稳定性检查。
- L2 对版本标记冲突的文件进入 review,不自动合并。 - L2 对版本标记冲突的文件进入 review,不自动合并。
- `--dry-run` 只报告/更新索引,不修改音乐文件。
- SQLite 是加速索引,文件系统是最终事实;数据库丢失后可以重新扫描。 - SQLite 是加速索引,文件系统是最终事实;数据库丢失后可以重新扫描。
- `.dedupe` 不参与音乐扫描,避免把内部文件再次处理。
## CLI ## CLI
@@ -46,18 +46,21 @@ am-deduper dedupe /music/AllMusic --db /data/dedupe.db
# 长驻监听:默认只自动执行安全的 Exact hard-link # 长驻监听:默认只自动执行安全的 Exact hard-link
am-deduper watch /music/AllMusic --db /data/dedupe.db am-deduper watch /music/AllMusic --db /data/dedupe.db
# 开启 L2 自动 acoustic 匹配(建议在 dry-run 验证阈值后再启用) # 开启 L2 acoustic 匹配;建议先 dry-run 验证
am-deduper watch /music/AllMusic --db /data/dedupe.db --l2 am-deduper watch /music/AllMusic --db /data/dedupe.db --l2
``` ```
## Docker / Feiniu NAS 部署 ## Feiniu NAS 部署
wjyhk 负责构建镜像,推送到 Gitea Container Registry;NAS 只负责 pull + run,不在 NAS 上重新 build。 生产环境采用 **Gitea 源码 → NAS 本地 Docker build → NAS 本地运行**。
不需要 Docker Registry,也不需要让 wjyhk 构建生产镜像。
NAS 的 `AllMusic` 与 dedupe 数据建议分开: 建议目录:
```text ```text
NAS NAS
├── Gitea 仓库
│ └── am-deduper/
├── AllMusic/ ├── AllMusic/
│ ├── admin/ │ ├── admin/
│ ├── alice/ │ ├── alice/
@@ -67,29 +70,40 @@ NAS
└── dedupe.db └── dedupe.db
``` ```
注意:如果以后启用 trash,trash 目录必须位于 `AllMusic` 所在同一文件系统内,不能把需要原子 rename/hard-link 语义的文件直接移动到 `/data`。
### NAS `.env` ### NAS `.env`
```dotenv ```dotenv
AM_DEDUPER_IMAGE=<GITEA_REGISTRY>/<OWNER>/am-music-deduper:<TAG>
ALLMUSIC_PATH=<NAS绝对路径>/AllMusic ALLMUSIC_PATH=<NAS绝对路径>/AllMusic
DEDUPE_DATA_PATH=<NAS绝对路径>/AppData/am-deduper DEDUPE_DATA_PATH=<NAS绝对路径>/AppData/am-deduper
``` ```
### NAS 部署 ### 首次部署
```bash
cd <Gitea仓库目录>/am-deduper
git pull
mkdir -p <NAS绝对路径>/AppData/am-deduper
docker compose build
docker compose run --rm am-deduper scan /music/AllMusic --db /data/dedupe.db
docker compose run --rm am-deduper dedupe /music/AllMusic --db /data/dedupe.db --dry-run
```
确认 dry-run 输出后,再执行正式 Exact/L2:
```bash
docker compose run --rm am-deduper dedupe /music/AllMusic --db /data/dedupe.db
```
然后启动长期监听:
```bash ```bash
mkdir -p "$DEDUPE_DATA_PATH"
docker login <GITEA_REGISTRY>
docker compose pull
docker compose up -d docker compose up -d
``` ```
查看日志: 更新版本:
```bash ```bash
docker logs -f am-deduper git pull
docker compose build
docker compose up -d
``` ```
首次上线建议先暂停 `watch`,执行一次完整 `scan` + `dedupe --dry-run`,确认候选数量与 review 项,再启用正式 dedupe;L2 自动处理单独验证后再打开 `--l2`。
+17 -10
View File
@@ -9,25 +9,32 @@ from .scanner import scan
from .watcher import watch from .watcher import watch
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: def main() -> None:
parser = argparse.ArgumentParser(prog="am-deduper") parser = argparse.ArgumentParser(prog="am-deduper")
sub = parser.add_subparsers(dest="cmd", required=True) sub = parser.add_subparsers(dest="cmd", required=True)
def add_common(sp): for name in ("scan", "dedupe", "reconcile"):
sp = sub.add_parser(name)
sp.add_argument("root", type=Path) sp.add_argument("root", type=Path)
sp.add_argument("--db", type=Path, default=None) sp.add_argument("--db", type=Path, default=None)
sp.add_argument("--dry-run", action="store_true", help="report actions without changing files") if name != "scan":
sp.add_argument("--dry-run", action="store_true")
add_common(sub.add_parser("scan")) wp = sub.add_parser("watch")
add_common(sub.add_parser("dedupe")) wp.add_argument("root", type=Path)
add_common(sub.add_parser("reconcile")) wp.add_argument("--db", type=Path, default=None)
watch_parser = sub.add_parser("watch") wp.add_argument("--l2", action="store_true", help="enable acoustic matching")
watch_parser.add_argument("root", type=Path)
watch_parser.add_argument("--db", type=Path, default=None)
watch_parser.add_argument("--l2", action="store_true", help="enable acoustic matching in the watcher")
args = parser.parse_args() args = parser.parse_args()
db = args.db or (Path("/data/dedupe.db") if args.cmd != "watch" and str(args.root).startswith("/music/") else args.root / ".dedupe" / "dedupe.db") db = _db_path(args.root, args.db)
if args.cmd == "watch": if args.cmd == "watch":
watch(args.root, db, l2=args.l2) watch(args.root, db, l2=args.l2)
+74 -36
View File
@@ -2,68 +2,106 @@ from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
from .db import connect from .db import connect
from .hashing import sha256 from .hashing import sha256
def confirm_sha256(conn, row) -> str | None: def confirm_sha256(conn, row) -> str | None:
path = Path(row["file_path"]) path = Path(row["file_path"])
try: st = path.stat() try:
except FileNotFoundError: return None st = path.stat()
if st.st_size != row["file_size"]: return None except (FileNotFoundError, PermissionError):
return None
if st.st_size != row["file_size"]:
return None
digest = sha256(path) digest = sha256(path)
try: try:
st2 = path.stat() st2 = path.stat()
except FileNotFoundError: return None except (FileNotFoundError, PermissionError):
if st2.st_size != st.st_size or st2.st_mtime_ns != st.st_mtime_ns: return None return None
conn.execute("UPDATE audio_tracks SET sha256=?,updated_at=CURRENT_TIMESTAMP WHERE id=?", (digest,row["id"])) 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 return digest
def hardlink_duplicate(loser: Path, winner: Path) -> None: 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") tmp = loser.with_name(loser.name + ".dedupe-linking")
try: try:
if tmp.exists(): tmp.unlink() if tmp.exists():
tmp.unlink()
os.link(winner, tmp) os.link(winner, tmp)
os.replace(tmp, loser) os.replace(tmp, loser)
finally: finally:
if tmp.exists(): if tmp.exists():
try: tmp.unlink() try:
except OSError: pass tmp.unlink()
except OSError:
pass
def choose_winner(rows): def choose_winner(rows):
def key(r): def key(r):
p=str(r["file_path"]); admin="/admin/" in p or p.endswith("/admin") p = str(r["file_path"])
admin = "/admin/" in p or p.endswith("/admin")
return (0 if admin else 1, r["created_at"], p) return (0 if admin else 1, r["created_at"], p)
return min(rows,key=key)
return min(rows, key=key)
def exact_dedupe(db_path: Path, root: Path, dry_run: bool = False) -> list[tuple[str,str]]: def exact_dedupe(db_path: Path, root: Path, dry_run: bool = False) -> list[tuple[str, str]]:
actions=[] actions: list[tuple[str, str]] = []
root = root.resolve()
with connect(db_path) as conn: with connect(db_path) as conn:
rows=conn.execute("SELECT * FROM audio_tracks WHERE status IN ('active','hardlinked')").fetchall() rows = conn.execute(
groups={} "SELECT * FROM audio_tracks WHERE status IN ('active','hardlinked')"
for r in rows: groups.setdefault((r["file_size"],r["fast_hash"]),[]).append(r) ).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(): for candidates in groups.values():
if len(candidates)<2: continue if len(candidates) < 2:
for r in candidates: continue
if not r["sha256"]: confirm_sha256(conn,r) for row in candidates:
ids=[r["id"] for r in candidates] if not row["sha256"]:
refreshed=conn.execute("SELECT * FROM audio_tracks WHERE id IN (%s)"%','.join('?'*len(ids)),ids).fetchall() confirm_sha256(conn, row)
by={} ids = [row["id"] for row in candidates]
for r in refreshed: placeholders = ",".join("?" * len(ids))
if r["sha256"]: by.setdefault(r["sha256"],[]).append(r) refreshed = conn.execute(
for same in by.values(): f"SELECT * FROM audio_tracks WHERE id IN ({placeholders})", ids
if len(same)<2: continue ).fetchall()
winner=choose_winner(same); wp=Path(winner["file_path"]) by_sha: dict[str, list] = {}
if not wp.exists(): continue for row in refreshed:
for r in same: if row["sha256"]:
if r["id"]==winner["id"]: continue by_sha.setdefault(row["sha256"], []).append(row)
lp=Path(r["file_path"])
if not lp.exists(): continue for same in by_sha.values():
if lp.stat().st_ino != wp.stat().st_ino and not dry_run: if len(same) < 2:
hardlink_duplicate(lp,wp) continue
conn.execute("UPDATE audio_tracks SET status='hardlinked',updated_at=CURRENT_TIMESTAMP WHERE id=?",(r["id"],)) winner = choose_winner(same)
actions.append((str(lp),str(wp))) 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(
"UPDATE audio_tracks SET status='hardlinked',updated_at=CURRENT_TIMESTAMP WHERE id=?",
(row["id"],),
)
actions.append((str(lp), str(wp)))
return actions return actions
+26 -8
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import threading
import time import time
from pathlib import Path from pathlib import Path
@@ -15,28 +16,43 @@ from .scanner import index_file
class Handler(FileSystemEventHandler): class Handler(FileSystemEventHandler):
def __init__(self, root: Path, db_path: Path, l2: bool = False): def __init__(self, root: Path, db_path: Path, l2: bool = False):
self.root = root self.root = root.resolve()
self.db_path = db_path self.db_path = db_path
self.l2 = l2 self.l2 = l2
self._lock = threading.Lock()
def _process(self, filename: str) -> None: def _process(self, filename: str) -> None:
path = Path(filename) path = Path(filename)
if path.is_symlink() or path.suffix.lower() not in MUSIC_EXTENSIONS or ".dedupe" in path.parts: if path.is_symlink() or path.suffix.lower() not in MUSIC_EXTENSIONS:
return return
try: try:
previous = path.stat().st_size 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 stable = 0
while stable < STABILITY_CHECKS - 1: while stable < STABILITY_CHECKS - 1:
time.sleep(STABILITY_INTERVAL_SECONDS) time.sleep(STABILITY_INTERVAL_SECONDS)
size = path.stat().st_size current = path.stat()
if size == previous: if (current.st_size, current.st_mtime_ns) == (
previous.st_size,
previous.st_mtime_ns,
):
stable += 1 stable += 1
else: else:
previous = size previous = current
stable = 0 stable = 0
with connect(self.db_path) as conn: with connect(self.db_path) as conn:
index_file(conn, path) index_file(conn, path)
# Exact dedupe is byte-for-byte safe. Acoustic dedupe is opt-in. # Exact byte-for-byte dedupe is always safe. L2 remains opt-in.
exact_dedupe(self.db_path, self.root) exact_dedupe(self.db_path, self.root)
if self.l2: if self.l2:
acoustic_dedupe(self.db_path, self.root) acoustic_dedupe(self.db_path, self.root)
@@ -52,9 +68,11 @@ class Handler(FileSystemEventHandler):
self._process(event.dest_path) self._process(event.dest_path)
def watch(root: Path, db_path: Path, l2: bool = False) -> None: def watch(root: Path, db_path: Path, l2: bool = False) -> None:
root = root.resolve() 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 = Observer()
observer.schedule(Handler(root, db_path, l2=l2), str(root), recursive=True) observer.schedule(Handler(root, db_path, l2=l2), str(root), recursive=True)
observer.start() observer.start()
+7 -2
View File
@@ -1,9 +1,14 @@
services: services:
am-deduper: am-deduper:
image: ${AM_DEDUPER_IMAGE:?Set AM_DEDUPER_IMAGE to the pushed Gitea registry image} build: .
image: am-music-deduper:local
container_name: am-deduper container_name: am-deduper
restart: unless-stopped restart: unless-stopped
command: ["watch", "/music/AllMusic"] command:
- watch
- /music/AllMusic
- --db
- /data/dedupe.db
environment: environment:
TZ: Asia/Shanghai TZ: Asia/Shanghai
PYTHONUNBUFFERED: "1" PYTHONUNBUFFERED: "1"
+17 -1
View File
@@ -19,4 +19,20 @@ def test_exact_dedupe_creates_hardlink(tmp_path: Path):
actions = exact_dedupe(db, root) actions = exact_dedupe(db, root)
assert len(actions) == 1 assert len(actions) == 1
assert a.stat().st_ino == b.stat().st_ino assert a.stat().st_ino == b.stat().st_ino
assert a.read_bytes() == b.read_bytes()
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