feat: add channels tab to advanced recovery for mass deletion
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
29c3339c39
commit
62428c313b
2 changed files with 171 additions and 11 deletions
|
|
@ -1247,6 +1247,42 @@ def api_recovery_delete():
|
||||||
log(f"❌ Delete failed: {e}")
|
log(f"❌ Delete failed: {e}")
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route("/api/channels", methods=["GET"])
|
||||||
|
@requires_auth
|
||||||
|
def api_get_channels():
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute("SELECT DISTINCT channel FROM videos WHERE channel IS NOT NULL ORDER BY channel ASC").fetchall()
|
||||||
|
channels = [row['channel'] for row in rows if row['channel']]
|
||||||
|
return jsonify(channels)
|
||||||
|
|
||||||
|
@app.route("/api/channels/videos", methods=["GET"])
|
||||||
|
@requires_auth
|
||||||
|
def api_get_channel_videos():
|
||||||
|
channel_name = request.args.get('channel')
|
||||||
|
if not channel_name:
|
||||||
|
return jsonify({"error": "No channel name provided"}), 400
|
||||||
|
|
||||||
|
# Refresh metadata to get filesystem paths
|
||||||
|
video_map = fetch_all_metadata()
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute("SELECT video_id, title, symlink FROM videos WHERE channel = ? ORDER BY published DESC", (channel_name,)).fetchall()
|
||||||
|
|
||||||
|
videos = []
|
||||||
|
for row in rows:
|
||||||
|
vid_id = row['video_id']
|
||||||
|
meta = video_map.get(vid_id, {})
|
||||||
|
videos.append({
|
||||||
|
"video_id": vid_id,
|
||||||
|
"title": row['title'],
|
||||||
|
"path": row['symlink'],
|
||||||
|
"filename": Path(row['symlink']).name if row['symlink'] else meta.get('title'),
|
||||||
|
"source_path": meta.get('filesystem_path'),
|
||||||
|
"ta_source": meta.get('channel_name', channel_name)
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(videos)
|
||||||
|
|
||||||
@app.route('/api/recovery/force', methods=['POST'])
|
@app.route('/api/recovery/force', methods=['POST'])
|
||||||
@requires_auth
|
@requires_auth
|
||||||
def api_recovery_force():
|
def api_recovery_force():
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { createEventDispatcher, onMount, onDestroy } from "svelte";
|
import { createEventDispatcher, onMount, onDestroy } from "svelte";
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
let activeTab: "unindexed" | "rescue" | "redundant" | "lost" = "unindexed";
|
let activeTab: "unindexed" | "rescue" | "redundant" | "lost" | "channels" = "unindexed";
|
||||||
let scanning = false;
|
let scanning = false;
|
||||||
let status = "idle";
|
let status = "idle";
|
||||||
let results: any = { unindexed: [], rescue: [], redundant: [], lost: [] };
|
let results: any = { unindexed: [], rescue: [], redundant: [], lost: [] };
|
||||||
|
|
@ -12,6 +12,13 @@
|
||||||
let destructMode = false;
|
let destructMode = false;
|
||||||
let selectedPaths = new Set<string>();
|
let selectedPaths = new Set<string>();
|
||||||
|
|
||||||
|
// Channel specific state
|
||||||
|
let channels: string[] = [];
|
||||||
|
let selectedChannel = "";
|
||||||
|
let channelVideos: any[] = [];
|
||||||
|
let loadingChannels = false;
|
||||||
|
let searchingVideos = false;
|
||||||
|
|
||||||
async function startScan() {
|
async function startScan() {
|
||||||
scanning = true;
|
scanning = true;
|
||||||
try {
|
try {
|
||||||
|
|
@ -47,6 +54,33 @@
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchChannels() {
|
||||||
|
loadingChannels = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/channels");
|
||||||
|
channels = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch channels", e);
|
||||||
|
} finally {
|
||||||
|
loadingChannels = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchChannelVideos(channel: string) {
|
||||||
|
if (!channel) return;
|
||||||
|
searchingVideos = true;
|
||||||
|
selectedChannel = channel;
|
||||||
|
selectedPaths = new Set();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/channels/videos?channel=${encodeURIComponent(channel)}`);
|
||||||
|
channelVideos = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch channel videos", e);
|
||||||
|
} finally {
|
||||||
|
searchingVideos = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (pollInterval) clearInterval(pollInterval);
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
});
|
});
|
||||||
|
|
@ -62,7 +96,8 @@
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
if (!isBatch) {
|
if (!isBatch) {
|
||||||
alert(d.message);
|
alert(d.message);
|
||||||
startScan();
|
if (activeTab === "channels") fetchChannelVideos(selectedChannel);
|
||||||
|
else startScan();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e);
|
alert(e);
|
||||||
|
|
@ -91,7 +126,8 @@
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
if (d.success_count > 0) {
|
if (d.success_count > 0) {
|
||||||
alert("Deleted.");
|
alert("Deleted.");
|
||||||
startScan();
|
if (activeTab === "channels") fetchChannelVideos(selectedChannel);
|
||||||
|
else startScan();
|
||||||
} else alert("Error deleting file.");
|
} else alert("Error deleting file.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e);
|
alert(e);
|
||||||
|
|
@ -121,7 +157,8 @@
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
alert(`Batch complete. Success: ${d.success_count}, Failed: ${d.fail_count}`);
|
alert(`Batch complete. Success: ${d.success_count}, Failed: ${d.fail_count}`);
|
||||||
selectedPaths = new Set();
|
selectedPaths = new Set();
|
||||||
startScan();
|
if (activeTab === "channels") fetchChannelVideos(selectedChannel);
|
||||||
|
else startScan();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert("Batch delete failed: " + e);
|
alert("Batch delete failed: " + e);
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +174,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAll() {
|
function toggleAll() {
|
||||||
const items = results[activeTab] || [];
|
const items = activeTab === "channels" ? channelVideos : (results[activeTab] || []);
|
||||||
if (selectedPaths.size === items.length && items.length > 0) {
|
if (selectedPaths.size === items.length && items.length > 0) {
|
||||||
selectedPaths = new Set();
|
selectedPaths = new Set();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -145,7 +182,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: allSelected = results[activeTab]?.length > 0 && selectedPaths.size === results[activeTab]?.length;
|
$: allSelected = (activeTab === "channels" ? channelVideos : results[activeTab])?.length > 0 && selectedPaths.size === (activeTab === "channels" ? channelVideos : results[activeTab])?.length;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchChannels();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -209,7 +250,7 @@
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="flex border-b border-gray-800 px-4">
|
<div class="flex border-b border-gray-800 px-4">
|
||||||
{#each ["unindexed", "rescue", "redundant", "lost"] as tab}
|
{#each ["unindexed", "rescue", "redundant", "lost", "channels"] as tab}
|
||||||
<button
|
<button
|
||||||
class="px-4 py-3 text-sm font-semibold capitalize border-b-2 transition-colors flex items-center gap-2
|
class="px-4 py-3 text-sm font-semibold capitalize border-b-2 transition-colors flex items-center gap-2
|
||||||
{activeTab === tab
|
{activeTab === tab
|
||||||
|
|
@ -218,16 +259,99 @@
|
||||||
on:click={() => { activeTab = tab as any; selectedPaths = new Set(); }}
|
on:click={() => { activeTab = tab as any; selectedPaths = new Set(); }}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
|
{#if tab !== 'channels'}
|
||||||
<span class="bg-gray-800 text-xs px-1.5 rounded-full"
|
<span class="bg-gray-800 text-xs px-1.5 rounded-full"
|
||||||
>{results[tab]?.length || 0}</span
|
>{results[tab]?.length || 0}</span
|
||||||
>
|
>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<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 activeTab === "channels"}
|
||||||
|
<div class="flex flex-col h-full gap-4">
|
||||||
|
<!-- Channel Selector -->
|
||||||
|
<div class="flex gap-4 items-end">
|
||||||
|
<div class="flex-grow">
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Select Channel</label>
|
||||||
|
<select
|
||||||
|
class="w-full bg-gray-900 border border-gray-700 rounded p-2 text-white outline-none focus:border-neon-cyan"
|
||||||
|
value={selectedChannel}
|
||||||
|
on:change={(e) => fetchChannelVideos(e.currentTarget.value)}
|
||||||
|
>
|
||||||
|
<option value="">-- Choose a Channel --</option>
|
||||||
|
{#each channels as channel}
|
||||||
|
<option value={channel}>{channel}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn-secondary px-4 py-2 rounded bg-gray-800 text-white hover:bg-gray-700"
|
||||||
|
on:click={fetchChannels}
|
||||||
|
>
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Video Table -->
|
||||||
|
<div class="flex-grow overflow-y-auto border border-gray-800 rounded">
|
||||||
|
{#if searchingVideos}
|
||||||
|
<div class="flex items-center justify-center h-full text-neon-cyan animate-pulse">
|
||||||
|
Fetching videos...
|
||||||
|
</div>
|
||||||
|
{:else if channelVideos.length > 0}
|
||||||
|
<table class="w-full text-left text-xs text-gray-300 font-mono border-collapse">
|
||||||
|
<thead class="sticky top-0 bg-cyber-card z-10 shadow-sm">
|
||||||
|
<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">Title</th>
|
||||||
|
<th class="p-3 text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each channelVideos as item}
|
||||||
|
<tr 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-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>
|
||||||
|
<td class="p-3 truncate max-w-[400px]" title={item.title}>{item.title}</td>
|
||||||
|
<td class="p-3 text-right">
|
||||||
|
<button
|
||||||
|
class="text-red-500 hover:underline flex items-center gap-1 ml-auto"
|
||||||
|
on:click={() => deleteFile(item.path)}
|
||||||
|
>
|
||||||
|
<i class="bi bi-trash"></i> Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col items-center justify-center h-full text-gray-600 gap-2">
|
||||||
|
<i class="bi bi-search text-3xl opacity-20"></i>
|
||||||
|
Select a channel to view its videos.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if scanning && (!results[activeTab] || results[activeTab].length === 0)}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-center h-full text-neon-cyan gap-4"
|
class="flex flex-col items-center justify-center h-full text-neon-cyan gap-4"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue