feat: Add hidden channels functionality

This commit is contained in:
wander 2026-02-07 15:19:27 -05:00
parent a12d3f6fb1
commit fb12e3ce26
5 changed files with 295 additions and 45 deletions

View file

@ -6,6 +6,7 @@ services:
volumes:
- ./source:/app/source
- ./target:/app/target
- ./hidden:/app/hidden
- ./data:/app/data
- ./import:/app/import
ports:

View file

@ -21,6 +21,7 @@ UI_USERNAME = os.getenv("UI_USERNAME", "admin")
UI_PASSWORD = os.getenv("UI_PASSWORD", "password")
SOURCE_DIR = Path("/app/source")
TARGET_DIR = Path("/app/target")
HIDDEN_DIR = Path("/app/hidden")
IMPORT_DIR = Path("/app/import")
HEADERS = {"Authorization": f"Token {API_TOKEN}"}
@ -61,6 +62,9 @@ def init_db():
filepath TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS hidden_channels (
channel_name TEXT PRIMARY KEY
);
""")
conn.commit()
@ -667,7 +671,19 @@ def process_videos():
# 1. Fetch all metadata first
video_map = fetch_all_metadata()
# 2. Run cleanup
# Get hidden channels
hidden_channels = set()
with get_db() as conn:
rows = conn.execute("SELECT channel_name FROM hidden_channels").fetchall()
hidden_channels = {row["channel_name"] for row in rows}
# Ensure hidden directory exists
HIDDEN_DIR.mkdir(parents=True, exist_ok=True)
# 2. Run cleanup (On both target and hidden)
# We need to adapt cleanup to handle hidden too, or just run it on both explicitly if we update the function
# Let's keep it simple for now and rely on logic below to move things
cleanup_old_folders()
# Statistics
@ -690,51 +706,69 @@ def process_videos():
if not meta:
continue
sanitized_channel_name = sanitize(meta["channel_name"])
channel_dir = TARGET_DIR / sanitized_channel_name
channel_dir.mkdir(parents=True, exist_ok=True)
sanitized_title = sanitize(meta["title"])
folder_name = f"{meta['published']} - {sanitized_title}"
video_dir = channel_dir / folder_name
video_dir.mkdir(parents=True, exist_ok=True)
actual_file = next(channel_path.glob(f"{video_id}.*"), None)
if not actual_file:
continue
host_path_root = Path("/mnt/user/tubearchives/bp")
host_source_path = host_path_root / actual_file.relative_to(SOURCE_DIR)
dest_file = video_dir / f"video{actual_file.suffix}"
# Determine target root
is_hidden = meta["channel_name"] in hidden_channels
target_root = HIDDEN_DIR if is_hidden else TARGET_DIR
other_root = TARGET_DIR if is_hidden else HIDDEN_DIR
# Check if channel exists in the WRONG place and remove it (Migration/Toggle)
wrong_channel_dir = other_root / sanitized_channel_name
if wrong_channel_dir.exists():
try:
if dest_file.exists():
if dest_file.is_symlink():
current_target = Path(os.readlink(dest_file))
if current_target.resolve() != host_source_path.resolve():
dest_file.unlink()
os.symlink(host_source_path, dest_file)
log(f" [FIX] Relinked: {folder_name}")
new_links += 1
else:
verified_links += 1
shutil.rmtree(wrong_channel_dir)
log(f" [MOVE] Removed {sanitized_channel_name} from {other_root.name} (Status Change)")
except Exception as e:
log(f" ❌ Failed to move/delete {sanitized_channel_name} from old location: {e}")
channel_dir = target_root / sanitized_channel_name
channel_dir.mkdir(parents=True, exist_ok=True)
sanitized_title = sanitize(meta["title"])
folder_name = f"{meta['published']} - {sanitized_title}"
video_dir = channel_dir / folder_name
video_dir.mkdir(parents=True, exist_ok=True)
actual_file = next(channel_path.glob(f"{video_id}.*"), None)
if not actual_file:
continue
host_path_root = Path("/mnt/user/tubearchives/bp")
host_source_path = host_path_root / actual_file.relative_to(SOURCE_DIR)
dest_file = video_dir / f"video{actual_file.suffix}"
try:
if dest_file.exists():
if dest_file.is_symlink():
current_target = Path(os.readlink(dest_file))
if current_target.resolve() != host_source_path.resolve():
dest_file.unlink()
os.symlink(host_source_path, dest_file)
log(f" [FIX] Relinked: {folder_name}")
new_links += 1
else:
verified_links += 1
else:
os.symlink(host_source_path, dest_file)
log(f" [NEW] Linked: {folder_name}")
new_links += 1
except Exception:
pass
# Store in database
conn.execute("""
INSERT OR REPLACE INTO videos
(video_id, title, channel, published, symlink, status)
VALUES (?, ?, ?, ?, ?, 'linked')
""", (video_id, meta["title"], meta["channel_name"],
meta["published"], str(dest_file)))
processed_videos.append({
"video_id": video_id,
"title": meta["title"],
"channel": meta["channel_name"],
"published": meta["published"],
"symlink": str(dest_file)
})
# It's a file or something else, replace it? No, unsafe.
pass
else:
os.symlink(host_source_path, dest_file)
log(f" [NEW] Linked: {folder_name}")
new_links += 1
except Exception:
pass
# Store in database
conn.execute("""
INSERT OR REPLACE INTO videos
(video_id, title, channel, published, symlink, status)
VALUES (?, ?, ?, ?, ?, 'linked')
""", (video_id, meta["title"], meta["channel_name"],
meta["published"], str(dest_file)))
processed_videos.append({
"video_id": video_id,
"title": meta["title"],
"channel": meta["channel_name"],
"published": meta["published"],
"symlink": str(dest_file)
})
except Exception as e:
conn.rollback()
return str(e)
@ -1104,9 +1138,46 @@ def api_recovery_force():
return jsonify({"success": True, "message": "Force import successful"})
except Exception as e:
log(f" ❌ Force import failed: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/hidden", methods=["GET"])
@requires_auth
def api_get_hidden():
with get_db() as conn:
rows = conn.execute("SELECT channel_name FROM hidden_channels ORDER BY channel_name").fetchall()
channels = [row["channel_name"] for row in rows]
return jsonify({"channels": channels})
@app.route("/api/hidden", methods=["POST"])
@requires_auth
def api_add_hidden():
data = request.json
channel = data.get("channel")
if not channel:
return jsonify({"error": "No channel name provided"}), 400
with get_db() as conn:
conn.execute("INSERT OR IGNORE INTO hidden_channels (channel_name) VALUES (?)", (channel,))
conn.commit()
log(f"🙈 Added to hidden list: {channel}")
return jsonify({"success": True})
@app.route("/api/hidden", methods=["DELETE"])
@requires_auth
def api_remove_hidden():
data = request.json
channel = data.get("channel")
if not channel:
return jsonify({"error": "No channel name provided"}), 400
with get_db() as conn:
conn.execute("DELETE FROM hidden_channels WHERE channel_name = ?", (channel,))
conn.commit()
log(f"👁️ Removed from hidden list: {channel}")
return jsonify({"success": True})
if __name__ == "__main__":
# Start scheduler in background thread

View file

@ -5,6 +5,7 @@
import VideoTable from "./VideoTable.svelte";
import DashboardControls from "./DashboardControls.svelte";
import RecoveryModal from "./RecoveryModal.svelte";
import HiddenManager from "./HiddenManager.svelte";
let stats = {
total_videos: 0,
@ -16,6 +17,7 @@
let loading = true;
let error: string | null = null;
let showRecovery = false;
let showHiddenManager = false;
let interval: ReturnType<typeof setInterval>;
@ -102,6 +104,7 @@
<DashboardControls
on:scan={handleScanTriggered}
on:openRecovery={() => (showRecovery = true)}
on:openHidden={() => (showHiddenManager = true)}
/>
</div>
@ -116,4 +119,8 @@
{#if showRecovery}
<RecoveryModal on:close={() => (showRecovery = false)} />
{/if}
{#if showHiddenManager}
<HiddenManager on:close={() => (showHiddenManager = false)} />
{/if}
</div>

View file

@ -75,6 +75,13 @@
>
<i class="bi bi-bandaid"></i> Recovery Mode
</button>
<button
class="btn-cyber-secondary py-3 text-sm font-semibold border border-purple-500/30 text-purple-400 hover:bg-purple-500/10 transition-colors rounded-lg flex items-center justify-center gap-2 col-span-2"
on:click={() => dispatch("openHidden")}
>
<i class="bi bi-eye-slash"></i> Hidden Channels
</button>
</div>
{#if orphanResult}

View file

@ -0,0 +1,164 @@
<script lang="ts">
import { createEventDispatcher, onMount } from "svelte";
export let show = false;
const dispatch = createEventDispatcher();
let hiddenChannels: string[] = [];
let newChannel = "";
let loading = true;
let error: string | null = null;
async function fetchHidden() {
loading = true;
try {
const res = await fetch("/api/hidden");
if (!res.ok) throw new Error("Failed to fetch hidden list");
const data = await res.json();
hiddenChannels = data.channels || [];
error = null;
} catch (e: any) {
error = e.message;
} finally {
loading = false;
}
}
async function addChannel() {
if (!newChannel.trim()) return;
try {
const res = await fetch("/api/hidden", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ channel: newChannel.trim() }),
});
if (!res.ok) throw new Error("Failed to add channel");
newChannel = "";
fetchHidden();
} catch (e: any) {
error = e.message;
}
}
async function removeChannel(channel: string) {
if (
!confirm(
`Unhide channel "${channel}"? It will be moved to public target on next scan.`,
)
)
return;
try {
const res = await fetch("/api/hidden", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ channel }),
});
if (!res.ok) throw new Error("Failed to remove channel");
fetchHidden();
} catch (e: any) {
error = e.message;
}
}
onMount(() => {
fetchHidden();
});
</script>
<div
class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
>
<div
class="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-lg shadow-2xl overflow-hidden"
>
<div
class="p-6 border-b border-gray-800 flex justifying-between items-center"
>
<h2 class="text-xl font-bold text-white flex items-center">
<i class="bi bi-eye-slash text-purple-400 mr-2"></i> Hidden Channels
</h2>
<button
class="text-gray-400 hover:text-white transition-colors"
on:click={() => dispatch("close")}
>
<i class="bi bi-x-lg text-lg"></i>
</button>
</div>
<div class="p-6 space-y-6">
<div
class="text-sm text-gray-400 bg-gray-800/50 p-3 rounded border border-gray-700"
>
<i class="bi bi-info-circle mr-1"></i>
Channels in this list will be synchronized to the
<code>/hidden</code>
folder instead of the public <code>/target</code> folder.
</div>
<!-- Add New -->
<div class="flex gap-2">
<input
type="text"
bind:value={newChannel}
placeholder="Enter Exact Channel Name..."
class="flex-1 bg-gray-800 border border-gray-700 rounded px-4 py-2 text-white focus:outline-none focus:border-purple-500 transition-colors"
on:keydown={(e) => e.key === "Enter" && addChannel()}
/>
<button
class="bg-purple-600 hover:bg-purple-500 text-white px-4 py-2 rounded font-medium transition-colors"
on:click={addChannel}
>
<i class="bi bi-plus-lg mr-1"></i> Hide
</button>
</div>
<!-- List -->
<div
class="max-h-60 overflow-y-auto space-y-2 pr-1 custom-scrollbar"
>
{#if loading}
<div class="text-center py-4 text-gray-500">Loading...</div>
{:else if hiddenChannels.length === 0}
<div class="text-center py-4 text-gray-500 italic">
No hidden channels
</div>
{:else}
{#each hiddenChannels as channel}
<div
class="flex items-center justify-between bg-gray-800 p-3 rounded group hover:bg-gray-750 transition-colors border border-transparent hover:border-gray-700"
>
<span class="text-gray-200 font-medium"
>{channel}</span
>
<button
class="text-gray-500 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100"
title="Unhide"
on:click={() => removeChannel(channel)}
>
<i class="bi bi-eye text-lg"></i>
</button>
</div>
{/each}
{/if}
</div>
{#if error}
<div
class="text-red-400 text-sm bg-red-900/20 p-2 rounded border border-red-900/50"
>
{error}
</div>
{/if}
</div>
<div class="p-4 bg-gray-950 border-t border-gray-800 flex justify-end">
<button
class="px-4 py-2 rounded text-gray-300 hover:bg-gray-800 transition-colors"
on:click={() => dispatch("close")}
>
Close
</button>
</div>
</div>
</div>