feat: Add hidden channels functionality
This commit is contained in:
parent
a12d3f6fb1
commit
fb12e3ce26
5 changed files with 295 additions and 45 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
157
ta_symlink.py
157
ta_symlink.py
|
|
@ -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,51 +706,69 @@ 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
|
|
||||||
channel_dir.mkdir(parents=True, exist_ok=True)
|
# Determine target root
|
||||||
sanitized_title = sanitize(meta["title"])
|
is_hidden = meta["channel_name"] in hidden_channels
|
||||||
folder_name = f"{meta['published']} - {sanitized_title}"
|
target_root = HIDDEN_DIR if is_hidden else TARGET_DIR
|
||||||
video_dir = channel_dir / folder_name
|
other_root = TARGET_DIR if is_hidden else HIDDEN_DIR
|
||||||
video_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
actual_file = next(channel_path.glob(f"{video_id}.*"), None)
|
# Check if channel exists in the WRONG place and remove it (Migration/Toggle)
|
||||||
if not actual_file:
|
wrong_channel_dir = other_root / sanitized_channel_name
|
||||||
continue
|
if wrong_channel_dir.exists():
|
||||||
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:
|
try:
|
||||||
if dest_file.exists():
|
shutil.rmtree(wrong_channel_dir)
|
||||||
if dest_file.is_symlink():
|
log(f" [MOVE] Removed {sanitized_channel_name} from {other_root.name} (Status Change)")
|
||||||
current_target = Path(os.readlink(dest_file))
|
except Exception as e:
|
||||||
if current_target.resolve() != host_source_path.resolve():
|
log(f" ❌ Failed to move/delete {sanitized_channel_name} from old location: {e}")
|
||||||
dest_file.unlink()
|
|
||||||
os.symlink(host_source_path, dest_file)
|
channel_dir = target_root / sanitized_channel_name
|
||||||
log(f" [FIX] Relinked: {folder_name}")
|
channel_dir.mkdir(parents=True, exist_ok=True)
|
||||||
new_links += 1
|
sanitized_title = sanitize(meta["title"])
|
||||||
else:
|
folder_name = f"{meta['published']} - {sanitized_title}"
|
||||||
verified_links += 1
|
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:
|
else:
|
||||||
os.symlink(host_source_path, dest_file)
|
# It's a file or something else, replace it? No, unsafe.
|
||||||
log(f" [NEW] Linked: {folder_name}")
|
pass
|
||||||
new_links += 1
|
else:
|
||||||
except Exception:
|
os.symlink(host_source_path, dest_file)
|
||||||
pass
|
log(f" [NEW] Linked: {folder_name}")
|
||||||
|
new_links += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Store in database
|
# Store in database
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT OR REPLACE INTO videos
|
INSERT OR REPLACE INTO videos
|
||||||
(video_id, title, channel, published, symlink, status)
|
(video_id, title, channel, published, symlink, status)
|
||||||
VALUES (?, ?, ?, ?, ?, 'linked')
|
VALUES (?, ?, ?, ?, ?, 'linked')
|
||||||
""", (video_id, meta["title"], meta["channel_name"],
|
""", (video_id, meta["title"], meta["channel_name"],
|
||||||
meta["published"], str(dest_file)))
|
meta["published"], str(dest_file)))
|
||||||
|
|
||||||
processed_videos.append({
|
processed_videos.append({
|
||||||
"video_id": video_id,
|
"video_id": video_id,
|
||||||
"title": meta["title"],
|
"title": meta["title"],
|
||||||
"channel": meta["channel_name"],
|
"channel": meta["channel_name"],
|
||||||
"published": meta["published"],
|
"published": meta["published"],
|
||||||
"symlink": str(dest_file)
|
"symlink": str(dest_file)
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
return str(e)
|
return str(e)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
164
ui/src/components/HiddenManager.svelte
Normal file
164
ui/src/components/HiddenManager.svelte
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue