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: volumes:
- ./source:/app/source - ./source:/app/source
- ./target:/app/target - ./target:/app/target
- ./hidden:/app/hidden
- ./data:/app/data - ./data:/app/data
- ./import:/app/import - ./import:/app/import
ports: ports:

View file

@ -21,6 +21,7 @@ UI_USERNAME = os.getenv("UI_USERNAME", "admin")
UI_PASSWORD = os.getenv("UI_PASSWORD", "password") UI_PASSWORD = os.getenv("UI_PASSWORD", "password")
SOURCE_DIR = Path("/app/source") SOURCE_DIR = Path("/app/source")
TARGET_DIR = Path("/app/target") TARGET_DIR = Path("/app/target")
HIDDEN_DIR = Path("/app/hidden")
IMPORT_DIR = Path("/app/import") IMPORT_DIR = Path("/app/import")
HEADERS = {"Authorization": f"Token {API_TOKEN}"} HEADERS = {"Authorization": f"Token {API_TOKEN}"}
@ -61,6 +62,9 @@ def init_db():
filepath TEXT, filepath TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS hidden_channels (
channel_name TEXT PRIMARY KEY
);
""") """)
conn.commit() conn.commit()
@ -667,7 +671,19 @@ def process_videos():
# 1. Fetch all metadata first # 1. Fetch all metadata first
video_map = fetch_all_metadata() 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() cleanup_old_folders()
# Statistics # Statistics
@ -690,7 +706,22 @@ def process_videos():
if not meta: if not meta:
continue continue
sanitized_channel_name = sanitize(meta["channel_name"]) sanitized_channel_name = sanitize(meta["channel_name"])
channel_dir = TARGET_DIR / sanitized_channel_name
# 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:
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) channel_dir.mkdir(parents=True, exist_ok=True)
sanitized_title = sanitize(meta["title"]) sanitized_title = sanitize(meta["title"])
folder_name = f"{meta['published']} - {sanitized_title}" folder_name = f"{meta['published']} - {sanitized_title}"
@ -713,6 +744,9 @@ def process_videos():
new_links += 1 new_links += 1
else: else:
verified_links += 1 verified_links += 1
else:
# It's a file or something else, replace it? No, unsafe.
pass
else: else:
os.symlink(host_source_path, dest_file) os.symlink(host_source_path, dest_file)
log(f" [NEW] Linked: {folder_name}") log(f" [NEW] Linked: {folder_name}")
@ -1104,10 +1138,47 @@ def api_recovery_force():
return jsonify({"success": True, "message": "Force import successful"}) return jsonify({"success": True, "message": "Force import successful"})
except Exception as e:
log(f" ❌ Force import failed: {e}") log(f" ❌ Force import failed: {e}")
return jsonify({"error": str(e)}), 500 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__": if __name__ == "__main__":
# Start scheduler in background thread # Start scheduler in background thread
thread = threading.Thread(target=scheduler, daemon=True) thread = threading.Thread(target=scheduler, daemon=True)

View file

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

View file

@ -75,6 +75,13 @@
> >
<i class="bi bi-bandaid"></i> Recovery Mode <i class="bi bi-bandaid"></i> Recovery Mode
</button> </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> </div>
{#if orphanResult} {#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>