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:
|
||||
- ./source:/app/source
|
||||
- ./target:/app/target
|
||||
- ./hidden:/app/hidden
|
||||
- ./data:/app/data
|
||||
- ./import:/app/import
|
||||
ports:
|
||||
|
|
|
|||
161
ta_symlink.py
161
ta_symlink.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
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