mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-13 00:26:37 +08:00
feat(organize/scan): transfer modes, seeding-safe relocation, inode dedup, incremental scanning
- Organizer: add move/copy/hardlink/symlink transfer modes (default move), per-request target_path/transfer_mode overrides, and honor organize.target_dir / organize.transfer_mode settings. - keep_seeding (default on): escalate move->hardlink (cross-device->copy) so the qBittorrent source stays in place and continues seeding after organize. - qBittorrent SetLocation + POST /downloads/relocate to migrate whole torrents while keeping them seeding. - Scanner: FileID (device:inode) hardlink dedup to avoid duplicate recognition and double-counted storage; extract single-file ingest. - Watcher: recursive watch + incremental per-file ingest/remove instead of full re-scan; periodic full library scan now gated behind scan.periodic_enabled (default off) to reduce disk wear. - Telegram bot: handle callback_query in polling, declare allowed_updates in webhook, answer callbacks. - Frontend: settings for transfer mode / keep_seeding / periodic scan; organize panel target dir + transfer mode overrides. - Tests for transfer modes, organizer resolution, SetLocation, inode dedup, incremental ingest/remove. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -212,3 +212,66 @@ func multipartHasTorrentFile(reader *multipart.Reader) bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQBitSetLocationPostsHashAndLocation(t *testing.T) {
|
||||
var gotHash, gotLocation string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/auth/login":
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
case "/api/v2/torrents/setLocation":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
gotHash = r.PostFormValue("hashes")
|
||||
gotLocation = r.PostFormValue("location")
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewQBitClient(zap.NewNop(), QBitConfig{
|
||||
BaseURL: server.URL,
|
||||
Username: "admin",
|
||||
Password: "adminadmin",
|
||||
})
|
||||
if err := client.SetLocation(context.Background(), "abc123", "/data/media/Movie"); err != nil {
|
||||
t.Fatalf("setLocation: %v", err)
|
||||
}
|
||||
if gotHash != "abc123" {
|
||||
t.Fatalf("hashes = %q, want abc123", gotHash)
|
||||
}
|
||||
if gotLocation != "/data/media/Movie" {
|
||||
t.Fatalf("location = %q, want /data/media/Movie", gotLocation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQBitSetLocationSurfacesConflict(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/auth/login":
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
case "/api/v2/torrents/setLocation":
|
||||
http.Error(w, "cannot write", http.StatusConflict)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewQBitClient(zap.NewNop(), QBitConfig{
|
||||
BaseURL: server.URL,
|
||||
Username: "admin",
|
||||
Password: "adminadmin",
|
||||
})
|
||||
err := client.SetLocation(context.Background(), "abc123", "/data/media/Movie")
|
||||
if err == nil {
|
||||
t.Fatal("expected error on 409 conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "无法写入目标路径") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user