feat: implement advanced recovery destruct mode and multi-selection
All checks were successful
Docker Build / build (push) Successful in 14s
All checks were successful
Docker Build / build (push) Successful in 14s
This commit is contained in:
parent
dd25df4bdc
commit
29c3339c39
2 changed files with 242 additions and 60 deletions
|
|
@ -320,7 +320,8 @@ def fetch_all_metadata():
|
||||||
video_map[vid_id] = {
|
video_map[vid_id] = {
|
||||||
"title": title,
|
"title": title,
|
||||||
"channel_name": channel_name,
|
"channel_name": channel_name,
|
||||||
"published": published
|
"published": published,
|
||||||
|
"filesystem_path": video.get("path") or video.get("filesystem_path")
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check pagination to see if we are done
|
# Check pagination to see if we are done
|
||||||
|
|
@ -1135,6 +1136,67 @@ def api_recovery_start():
|
||||||
"status": "completed" if success else "failed"
|
"status": "completed" if success else "failed"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@app.route("/api/recovery/delete-batch", methods=["POST"])
|
||||||
|
@requires_auth
|
||||||
|
def api_recovery_delete_batch():
|
||||||
|
data = request.get_json()
|
||||||
|
filepaths = data.get('filepaths', [])
|
||||||
|
destruct_mode = data.get('destruct_mode', False)
|
||||||
|
|
||||||
|
if not filepaths:
|
||||||
|
return jsonify({"error": "No filepaths provided"}), 400
|
||||||
|
|
||||||
|
results = []
|
||||||
|
# Refresh metadata to ensure we have latest paths for destruct mode
|
||||||
|
video_map = fetch_all_metadata() if destruct_mode else {}
|
||||||
|
|
||||||
|
for filepath in filepaths:
|
||||||
|
p = Path(filepath)
|
||||||
|
if not p.exists():
|
||||||
|
results.append({"path": filepath, "success": False, "error": "File not found"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
vid_id = extract_id_from_filename(p.name)
|
||||||
|
|
||||||
|
# DESTRUCT MODE: Delete source too
|
||||||
|
if destruct_mode and vid_id:
|
||||||
|
meta = video_map.get(vid_id)
|
||||||
|
if meta and meta.get('filesystem_path'):
|
||||||
|
source_path = Path(meta['filesystem_path'])
|
||||||
|
if source_path.exists():
|
||||||
|
source_path.unlink()
|
||||||
|
log(f" [DESTRUCT] Deleted source: {source_path}")
|
||||||
|
|
||||||
|
# Also check lost_media table
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute("DELETE FROM lost_media WHERE video_id = ?", (vid_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# DELETE TARGET (Symlink/Resource)
|
||||||
|
p.unlink()
|
||||||
|
|
||||||
|
# Clean up empty parent folder if it's a video folder
|
||||||
|
parent = p.parent
|
||||||
|
if parent not in [TARGET_DIR, HIDDEN_DIR, SOURCE_DIR] and parent.name != "source":
|
||||||
|
try:
|
||||||
|
if not any(parent.iterdir()):
|
||||||
|
parent.rmdir()
|
||||||
|
log(f" [CLEANUP] Removed empty dir: {parent}")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
results.append({"path": filepath, "success": True})
|
||||||
|
except Exception as e:
|
||||||
|
results.append({"path": filepath, "success": False, "error": str(e)})
|
||||||
|
log(f"❌ Failed to delete {filepath}: {e}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"results": results,
|
||||||
|
"success_count": len([r for r in results if r["success"]]),
|
||||||
|
"fail_count": len([r for r in results if not r["success"]])
|
||||||
|
})
|
||||||
|
|
||||||
@app.route("/api/recovery/delete", methods=["POST"])
|
@app.route("/api/recovery/delete", methods=["POST"])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def api_recovery_delete():
|
def api_recovery_delete():
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@
|
||||||
let status = "idle";
|
let status = "idle";
|
||||||
let results: any = { unindexed: [], rescue: [], redundant: [], lost: [] };
|
let results: any = { unindexed: [], rescue: [], redundant: [], lost: [] };
|
||||||
let pollInterval: ReturnType<typeof setInterval>;
|
let pollInterval: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
// New State for Destruct Mode & Multi-selection
|
||||||
|
let destructMode = false;
|
||||||
|
let selectedPaths = new Set<string>();
|
||||||
|
|
||||||
async function startScan() {
|
async function startScan() {
|
||||||
scanning = true;
|
scanning = true;
|
||||||
|
|
@ -48,7 +52,6 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
async function recoverFile(path: string, isBatch = false) {
|
async function recoverFile(path: string, isBatch = false) {
|
||||||
// Implementation mirrors existing JS logic
|
|
||||||
if (!isBatch && !confirm("Recover this file?")) return;
|
if (!isBatch && !confirm("Recover this file?")) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/recovery/start", {
|
const res = await fetch("/api/recovery/start", {
|
||||||
|
|
@ -60,29 +63,89 @@
|
||||||
if (!isBatch) {
|
if (!isBatch) {
|
||||||
alert(d.message);
|
alert(d.message);
|
||||||
startScan();
|
startScan();
|
||||||
} // Refresh
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e);
|
alert(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteFile(path: string) {
|
async function deleteFile(path: string) {
|
||||||
if (!confirm("Delete file? This cannot be undone.")) return;
|
let msg = "Delete file? This cannot be undone.";
|
||||||
|
if (destructMode) {
|
||||||
|
msg = "☢️ DESTRUCT MODE ACTIVE ☢️\n\nThis will delete BOTH the target folder AND the source file in your archive.\n\nAre you absolutely sure?";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
|
if (destructMode && !confirm("FINAL WARNING: This is IRREVERSIBLE. Delete source file now?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/recovery/delete", {
|
const res = await fetch("/api/recovery/delete-batch", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ filepath: path }),
|
body: JSON.stringify({
|
||||||
|
filepaths: [path],
|
||||||
|
destruct_mode: destructMode
|
||||||
|
}),
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
if (d.success) {
|
if (d.success_count > 0) {
|
||||||
alert("Deleted.");
|
alert("Deleted.");
|
||||||
startScan();
|
startScan();
|
||||||
} else alert("Error: " + d.error);
|
} else alert("Error deleting file.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e);
|
alert(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteSelected() {
|
||||||
|
if (selectedPaths.size === 0) return;
|
||||||
|
|
||||||
|
let msg = `Delete ${selectedPaths.size} selected items?`;
|
||||||
|
if (destructMode) {
|
||||||
|
msg = `☢️ DESTRUCT MODE ACTIVE ☢️\n\nYou are about to delete ${selectedPaths.size} items from BOTH Target and Source.\n\nAre you sure you want to proceed?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
if (destructMode && !confirm("FINAL WARNING: This will permanently delete SOURCE FILES. Continue?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/recovery/delete-batch", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
filepaths: Array.from(selectedPaths),
|
||||||
|
destruct_mode: destructMode
|
||||||
|
}),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
const d = await res.json();
|
||||||
|
alert(`Batch complete. Success: ${d.success_count}, Failed: ${d.fail_count}`);
|
||||||
|
selectedPaths = new Set();
|
||||||
|
startScan();
|
||||||
|
} catch (e) {
|
||||||
|
alert("Batch delete failed: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelect(path: string) {
|
||||||
|
if (selectedPaths.has(path)) {
|
||||||
|
selectedPaths.delete(path);
|
||||||
|
} else {
|
||||||
|
selectedPaths.add(path);
|
||||||
|
}
|
||||||
|
selectedPaths = selectedPaths; // Trigger Svelte reactivity
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
const items = results[activeTab] || [];
|
||||||
|
if (selectedPaths.size === items.length && items.length > 0) {
|
||||||
|
selectedPaths = new Set();
|
||||||
|
} else {
|
||||||
|
selectedPaths = new Set(items.map((i: any) => i.path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: allSelected = results[activeTab]?.length > 0 && selectedPaths.size === results[activeTab]?.length;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -97,6 +160,9 @@
|
||||||
>
|
>
|
||||||
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
||||||
<i class="bi bi-bandaid text-neon-pink"></i> Advanced Recovery
|
<i class="bi bi-bandaid text-neon-pink"></i> Advanced Recovery
|
||||||
|
{#if destructMode}
|
||||||
|
<span class="text-xs bg-red-600 text-white px-2 py-0.5 rounded animate-pulse ml-2 font-mono">DESTRUCT MODE ACTIVE</span>
|
||||||
|
{/if}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
on:click={() => dispatch("close")}
|
on:click={() => dispatch("close")}
|
||||||
|
|
@ -106,15 +172,39 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls -->
|
<!-- Controls -->
|
||||||
<div class="p-4 bg-gray-900/50 flex justify-between items-center">
|
<div class="p-4 bg-gray-900/50 flex flex-wrap gap-4 justify-between items-center">
|
||||||
<button
|
<div class="flex items-center gap-3">
|
||||||
class="btn-primary px-6 py-2 rounded font-bold text-black bg-neon-cyan hover:bg-white transition-colors"
|
<button
|
||||||
on:click={startScan}
|
class="btn-primary px-6 py-2 rounded font-bold text-black bg-neon-cyan hover:bg-white transition-colors disabled:opacity-50"
|
||||||
disabled={scanning}
|
on:click={startScan}
|
||||||
>
|
disabled={scanning}
|
||||||
{scanning ? "Scanning..." : "Run System Scan"}
|
>
|
||||||
</button>
|
{scanning ? "Scanning..." : "Run System Scan"}
|
||||||
<div class="text-xs text-mono text-gray-500">Status: {status}</div>
|
</button>
|
||||||
|
|
||||||
|
{#if selectedPaths.size > 0}
|
||||||
|
<button
|
||||||
|
class="px-6 py-2 rounded font-bold text-white bg-red-600 hover:bg-red-500 transition-colors flex items-center gap-2"
|
||||||
|
on:click={deleteSelected}
|
||||||
|
>
|
||||||
|
<i class="bi bi-trash"></i> Delete Selected ({selectedPaths.size})
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<!-- Destruct Mode Toggle -->
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer group">
|
||||||
|
<input type="checkbox" bind:checked={destructMode} class="sr-only peer">
|
||||||
|
<div class="w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-red-600"></div>
|
||||||
|
<span class="ml-3 text-sm font-medium text-gray-300 group-hover:text-white transition-colors">
|
||||||
|
Destruct Mode
|
||||||
|
<i class="bi bi-exclamation-triangle-fill text-red-500 ml-1 {destructMode ? 'visible' : 'opacity-0'}"></i>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="text-xs text-mono text-gray-500">Status: <span class="text-neon-cyan">{status}</span></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
|
|
@ -125,7 +215,7 @@
|
||||||
{activeTab === tab
|
{activeTab === tab
|
||||||
? 'border-neon-cyan text-neon-cyan'
|
? 'border-neon-cyan text-neon-cyan'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-300'}"
|
: 'border-transparent text-gray-500 hover:text-gray-300'}"
|
||||||
on:click={() => (activeTab = tab as any)}
|
on:click={() => { activeTab = tab as any; selectedPaths = new Set(); }}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
<span class="bg-gray-800 text-xs px-1.5 rounded-full"
|
<span class="bg-gray-800 text-xs px-1.5 rounded-full"
|
||||||
|
|
@ -139,14 +229,23 @@
|
||||||
<div class="flex-grow overflow-y-auto p-4 bg-black/30">
|
<div class="flex-grow overflow-y-auto p-4 bg-black/30">
|
||||||
{#if scanning && (!results[activeTab] || results[activeTab].length === 0)}
|
{#if scanning && (!results[activeTab] || results[activeTab].length === 0)}
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-center h-full text-neon-cyan animate-pulse"
|
class="flex flex-col items-center justify-center h-full text-neon-cyan gap-4"
|
||||||
>
|
>
|
||||||
Scanning...
|
<div class="w-12 h-12 border-4 border-neon-cyan border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
<div class="animate-pulse font-mono">SYSTEM WIDE SCAN IN PROGRESS...</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<table class="w-full text-left text-xs text-gray-300 font-mono">
|
<table class="w-full text-left text-xs text-gray-300 font-mono border-collapse">
|
||||||
<thead>
|
<thead class="sticky top-0 bg-cyber-card z-10 shadow-sm">
|
||||||
<tr class="text-gray-500 border-b border-gray-800">
|
<tr class="text-gray-500 border-b border-gray-800">
|
||||||
|
<th class="p-3 w-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
on:change={toggleAll}
|
||||||
|
class="accent-neon-cyan"
|
||||||
|
>
|
||||||
|
</th>
|
||||||
<th class="p-3">Video ID</th>
|
<th class="p-3">Video ID</th>
|
||||||
<th class="p-3">Filename / Path</th>
|
<th class="p-3">Filename / Path</th>
|
||||||
<th class="p-3">Size/Info</th>
|
<th class="p-3">Size/Info</th>
|
||||||
|
|
@ -156,15 +255,25 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each results[activeTab] || [] as item}
|
{#each results[activeTab] || [] as item}
|
||||||
<tr
|
<tr
|
||||||
class="border-b border-gray-800/50 hover:bg-white/5"
|
class="border-b border-gray-800/50 hover:bg-white/5 transition-colors {selectedPaths.has(item.path) ? 'bg-neon-cyan/5' : ''}"
|
||||||
>
|
>
|
||||||
<td class="p-3 text-neon-pink"
|
<td class="p-3 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedPaths.has(item.path)}
|
||||||
|
on:change={() => toggleSelect(item.path)}
|
||||||
|
class="accent-neon-cyan"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-neon-pink font-bold"
|
||||||
>{item.video_id}</td
|
>{item.video_id}</td
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
class="p-3 truncate max-w-[300px]"
|
class="p-3 truncate max-w-[350px] group relative"
|
||||||
title={item.path}
|
title={item.path}
|
||||||
>{item.filename || item.path}</td
|
>
|
||||||
|
<span class="group-hover:text-white transition-colors">{item.filename || item.path}</span>
|
||||||
|
</td
|
||||||
>
|
>
|
||||||
<td class="p-3"
|
<td class="p-3"
|
||||||
>{item.size_mb
|
>{item.size_mb
|
||||||
|
|
@ -172,46 +281,51 @@
|
||||||
: item.ta_source || "-"}</td
|
: item.ta_source || "-"}</td
|
||||||
>
|
>
|
||||||
<td class="p-3 text-right">
|
<td class="p-3 text-right">
|
||||||
{#if activeTab === "unindexed"}
|
<div class="flex justify-end gap-3">
|
||||||
<button
|
{#if activeTab === "unindexed"}
|
||||||
class="text-neon-green hover:underline"
|
<button
|
||||||
on:click={() =>
|
class="text-neon-green hover:underline flex items-center gap-1"
|
||||||
recoverFile(item.path)}
|
on:click={() =>
|
||||||
>Recover</button
|
recoverFile(item.path)}
|
||||||
>
|
><i class="bi bi-plus-circle"></i> Recover</button
|
||||||
{:else if activeTab === "redundant"}
|
>
|
||||||
<button
|
{:else if activeTab === "redundant"}
|
||||||
class="text-red-500 hover:underline"
|
<button
|
||||||
on:click={() =>
|
class="text-red-500 hover:underline flex items-center gap-1"
|
||||||
deleteFile(item.path)}
|
on:click={() =>
|
||||||
>Delete</button
|
deleteFile(item.path)}
|
||||||
>
|
><i class="bi bi-trash"></i> Delete</button
|
||||||
{:else if activeTab === "lost"}
|
>
|
||||||
<button
|
{:else if activeTab === "lost"}
|
||||||
class="text-neon-yellow hover:underline mr-2"
|
<button
|
||||||
>Force</button
|
class="text-neon-yellow hover:underline flex items-center gap-1"
|
||||||
>
|
><i class="bi bi-lightning-fill"></i> Force</button
|
||||||
<button
|
>
|
||||||
class="text-red-500 hover:underline"
|
<button
|
||||||
on:click={() =>
|
class="text-red-500 hover:underline flex items-center gap-1"
|
||||||
deleteFile(item.path)}
|
on:click={() =>
|
||||||
>Delete</button
|
deleteFile(item.path)}
|
||||||
>
|
><i class="bi bi-trash"></i> Delete</button
|
||||||
{:else}
|
>
|
||||||
<button
|
{:else}
|
||||||
class="text-neon-pink hover:underline"
|
<button
|
||||||
>Rescue</button
|
class="text-neon-pink hover:underline flex items-center gap-1"
|
||||||
>
|
><i class="bi bi-rescue-ambulance"></i> Rescue</button
|
||||||
{/if}
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
{#if !results[activeTab]?.length}
|
{#if !results[activeTab]?.length}
|
||||||
<tr
|
<tr
|
||||||
><td
|
><td
|
||||||
colspan="4"
|
colspan="5"
|
||||||
class="p-10 text-center text-gray-600"
|
class="p-20 text-center text-gray-600 bg-gray-900/20"
|
||||||
>No items found.</td
|
>
|
||||||
|
<i class="bi bi-cloud-check text-4xl mb-4 block opacity-20"></i>
|
||||||
|
Nothing detected in this category.
|
||||||
|
</td
|
||||||
></tr
|
></tr
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -221,3 +335,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.accent-neon-cyan {
|
||||||
|
accent-color: #00f3ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue