cleanup and ui updates

main
oscar 2 months ago
parent ecfa2f8745
commit d4ed0dfb0a

191
app.py

@ -1,9 +1,6 @@
# app.py — Streamaster, DB-Only, Clean
import os, time, json, math, hashlib, subprocess
from concurrent.futures import ThreadPoolExecutor
import psycopg2.extras
from flask import Flask, render_template, request, jsonify, send_file
from config import get_local_db_connection
from flask import Flask
import os
# ───────── CONFIG ───────── #
app = Flask(__name__)
@ -29,184 +26,14 @@ def find_video_file(filename: str) -> str | None:
return candidate
return None
# ───────── DB HELPER ───────── #
def db_get_videos():
conn, cur = get_local_db_connection()
cur = conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
cur.execute("""
SELECT
video_id, username, site AS platform,
filepath, size, duration, gender,
created_at, updated_at, thumbnail
FROM videos
WHERE status != 'missing'
""")
rows = cur.fetchall()
cur.close(); conn.close()
return [dict(r) for r in rows]
# ───────── BLUEPRINTS ───────── #
from routes.web import web
from routes.api import api
from helpers.favorites import db_init_favorites_table
# ───────── THUMBNAIL HELPERS ───────── #
def _hashed_thumb_path(video_id: str):
h = hashlib.md5(video_id.encode()).hexdigest()
sub1, sub2 = h[:2], h[2:4]
path = os.path.join(THUMB_DIR, sub1, sub2)
os.makedirs(path, exist_ok=True)
return os.path.join(path, f"{video_id}.webp")
def _gen_thumb_cmd(src: str, dest: str):
return [
"ffmpeg", "-y", "-loglevel", "error",
"-ss", "0", "-i", src,
"-vframes", "1",
"-vf", f"thumbnail,scale={THUMB_WIDTH}:-1",
"-q:v", FF_QUALITY,
dest
]
def generate_thumbnails_for_videos(videos):
tasks = []
for v in videos:
video_id = v.get("video_id")
filepath = v.get("filepath")
thumb_path = _hashed_thumb_path(video_id)
if not filepath:
print(f"⚠️ Skipping {video_id}: missing filepath")
continue
if not os.path.exists(filepath):
print(f"⚠️ Skipping {video_id}: file not found → {filepath}")
continue
if not os.path.exists(thumb_path):
tasks.append((filepath, thumb_path))
v["thumbnail"] = thumb_path
if tasks:
with ThreadPoolExecutor(max_workers=os.cpu_count() * 2) as exe:
list(exe.map(lambda t: subprocess.run(
_gen_thumb_cmd(*t),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
), tasks))
# ───────── CACHE BUILDER ───────── #
def build_cache():
videos = db_get_videos()
grouped = {}
for v in videos:
key = (v["username"], v["platform"])
grouped.setdefault(key, []).append(v)
storage_usage, avg_sizes, video_map = {}, {}, {}
for (username, platform), vids in grouped.items():
key = f"{username}::{platform}"
total_gb = 0
for v in vids:
try:
total_gb += float(v.get("size", 0) or 0) / 1024
except ValueError:
print(f"⚠️ Invalid size for video {v.get('video_id')}: {v.get('size')}")
storage_usage[key] = {
"total_size": total_gb,
"video_count": len(vids)
}
avg_sizes[key] = total_gb / len(vids) if vids else 0
video_map[key] = vids
# generate_thumbnails_for_videos(videos)
return {
"timestamp": time.time(),
"videos": video_map,
"storage_usage": storage_usage,
"avg_sizes": avg_sizes
}
# ───────── ROUTES ───────── #
@app.route("/")
def dashboard():
cache = build_cache()
query = request.args.get("q", "").lower().strip()
sorted_usage = sorted(
cache["storage_usage"].items(),
key=lambda x: x[1]["total_size"],
reverse=True
)
if query:
sorted_usage = [e for e in sorted_usage if query in e[0].lower()]
page = max(1, int(request.args.get("page", 1)))
total_pages = max(1, math.ceil(len(sorted_usage) / DASHBOARD_PER_PAGE))
start = (page - 1) * DASHBOARD_PER_PAGE
paginated = sorted_usage[start:start + DASHBOARD_PER_PAGE]
return render_template(
"analytics.html",
storage_usage=paginated,
avg_sizes=cache["avg_sizes"],
page=page,
total_pages=total_pages,
query=query
)
@app.route("/refresh")
def refresh():
cache = build_cache()
return jsonify({
"status": "ok",
"videos": sum(x["video_count"] for x in cache["storage_usage"].values()),
"updated": time.ctime(cache["timestamp"])
})
@app.route("/user/<username>")
def user_page(username):
cache = build_cache()
videos = [
v | {"platform": key.split("::")[1]}
for key, vids in cache["videos"].items()
if key.split("::")[0] == username
for v in vids
]
page = max(1, int(request.args.get("page", 1)))
total_pages = max(1, math.ceil(len(videos) / VIDEOS_PER_PAGE))
start = (page - 1) * VIDEOS_PER_PAGE
paginated = videos[start:start + VIDEOS_PER_PAGE]
return render_template(
"user_page.html",
username=username,
videos=paginated,
page=page,
total_pages=total_pages
)
@app.route("/video/stream/<video_id>")
def stream_video(video_id):
cache = build_cache()
for vids in cache["videos"].values():
for v in vids:
if v["video_id"] == video_id:
return send_file(v["filepath"], mimetype="video/mp4")
return "Video not found", 404
@app.route("/video/<video_id>")
def view_video(video_id):
cache = build_cache()
for vids in cache["videos"].values():
for v in vids:
if v["video_id"] == video_id:
return render_template("video_view.html", video=v)
return "Video not found", 404
app.register_blueprint(web)
app.register_blueprint(api)
if __name__ == "__main__":
db_init_favorites_table()
app.run(debug=True)

@ -198,12 +198,13 @@ if __name__ == '__main__':
print("🔍 Scanning for missing data...")
fill_missing_filepaths(cursor, conn)
# mark_missing_videos(cursor, conn)
# fill_missing_durations(cursor, conn)
# fill_missing_sizes(cursor, conn)
# generate_thumbnails_for_videos(cursor, conn)
# fill_missing_hashes(cursor, conn)
fill_missing_sizes(cursor, conn)
fill_missing_durations(cursor, conn)
# mark_missing_videos(cursor, conn)
# fill_missing_gender(cursor, conn)
generate_thumbnails_for_videos(cursor, conn)
cursor.close()

@ -0,0 +1,38 @@
# ───────── CACHE BUILDER ───────── #
def build_cache():
videos = db_get_videos()
grouped = {}
for v in videos:
key = (v["username"], v["platform"])
grouped.setdefault(key, []).append(v)
storage_usage, avg_sizes, video_map = {}, {}, {}
for (username, platform), vids in grouped.items():
key = f"{username}::{platform}"
total_gb = 0
for v in vids:
try:
total_gb += float(v.get("size", 0) or 0) / 1024
except ValueError:
print(f"⚠️ Invalid size for video {v.get('video_id')}: {v.get('size')}")
storage_usage[key] = {
"total_size": total_gb,
"video_count": len(vids)
}
avg_sizes[key] = total_gb / len(vids) if vids else 0
video_map[key] = vids
# generate_thumbnails_for_videos(videos)
return {
"timestamp": time.time(),
"videos": video_map,
"storage_usage": storage_usage,
"avg_sizes": avg_sizes
}

@ -0,0 +1,56 @@
# ───────── DB HELPER ───────── #
def db_get_videos(username = None):
conn, cur = get_local_db_connection()
cur = conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
query = "SELECT video_id, username, site AS platform, filepath, size, duration, gender, created_at, updated_at, thumbnail FROM videos WHERE status != 'missing'"
if username:
query += f" AND username = '{username}'"
if True:
query += " ORDER BY created_at DESC"
cur.execute(query)
rows = cur.fetchall()
cur.close(); conn.close()
return [dict(r) for r in rows]
def db_get_video(video_id: str):
conn, cur = get_local_db_connection()
cur = conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
cur.execute("""
SELECT
video_id, username, site AS platform,
filepath, size, duration, gender,
created_at, updated_at, thumbnail
FROM videos
WHERE video_id = %s
""", (video_id,))
row = cur.fetchone()
cur.close(); conn.close()
return dict(row)
def db_get_recent(page: int, per_page: int):
offset = (page - 1) * per_page
conn, cur = get_local_db_connection()
cur = conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
# Order by created_at desc (fallback to updated_at if you prefer)
cur.execute("""
SELECT
video_id, username, site AS platform,
filepath, size, duration, gender,
created_at, updated_at, thumbnail
FROM videos
WHERE status != 'missing'
ORDER BY created_at DESC NULLS LAST, updated_at DESC NULLS LAST
LIMIT %s OFFSET %s
""", (per_page, offset))
rows = [dict(r) for r in cur.fetchall()]
cur.execute("SELECT COUNT(*) FROM videos WHERE status != 'missing'")
total = cur.fetchone()[0]
cur.close(); conn.close()
return rows, total

@ -0,0 +1,75 @@
# ───────── FAVORITES ───────── #
def db_init_favorites_table():
conn, cur = get_local_db_connection()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS favorites (
video_id TEXT PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW()
)
""")
conn.commit()
cur.close(); conn.close()
def db_get_fav_set():
conn, cur = get_local_db_connection()
cur = conn.cursor()
cur.execute("SELECT video_id FROM favorites")
favs = {row[0] for row in cur.fetchall()}
cur.close(); conn.close()
return favs
def db_toggle_fav(video_id: str):
conn, cur = get_local_db_connection()
cur = conn.cursor()
# Try to delete; if nothing deleted, insert
cur.execute("DELETE FROM favorites WHERE video_id = %s", (video_id,))
if cur.rowcount == 0:
cur.execute("INSERT INTO favorites (video_id) VALUES (%s)", (video_id,))
conn.commit()
cur.close(); conn.close()
return True # now favorited
conn.commit()
cur.close(); conn.close()
return False # now unfavorited
def mark_favorites(videos: list[dict]):
"""Adds is_favorite: bool to each video dict in-place."""
favs = db_get_fav_set()
for v in videos:
vid = str(v.get("video_id"))
v["is_favorite"] = (vid in favs)
return videos
def db_get_favorites(page: int, per_page: int):
offset = (page - 1) * per_page
conn, cur = get_local_db_connection()
cur = conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
cur.execute("""
SELECT
v.video_id, v.username, v.site AS platform,
v.filepath, v.size, v.duration, v.gender,
v.created_at, v.updated_at, v.thumbnail
FROM favorites f
JOIN videos v ON v.video_id = f.video_id
WHERE v.status != 'missing'
ORDER BY f.created_at DESC NULLS LAST, v.created_at DESC NULLS LAST
LIMIT %s OFFSET %s
""", (per_page, offset))
rows = [dict(r) for r in cur.fetchall()]
cur.execute("""
SELECT COUNT(*)
FROM favorites f
JOIN videos v ON v.video_id = f.video_id
WHERE v.status != 'missing'
""")
total = cur.fetchone()[0]
cur.close(); conn.close()
return rows, total

@ -0,0 +1,46 @@
# ───────── THUMBNAIL HELPERS ───────── #
def _hashed_thumb_path(video_id: str):
h = hashlib.md5(video_id.encode()).hexdigest()
sub1, sub2 = h[:2], h[2:4]
path = os.path.join(THUMB_DIR, sub1, sub2)
os.makedirs(path, exist_ok=True)
return os.path.join(path, f"{video_id}.webp")
def _gen_thumb_cmd(src: str, dest: str):
return [
"ffmpeg", "-y", "-loglevel", "error",
"-ss", "0", "-i", src,
"-vframes", "1",
"-vf", f"thumbnail,scale={THUMB_WIDTH}:-1",
"-q:v", FF_QUALITY,
dest
]
def generate_thumbnails_for_videos(videos):
tasks = []
for v in videos:
video_id = v.get("video_id")
filepath = v.get("filepath")
thumb_path = _hashed_thumb_path(video_id)
if not filepath:
print(f"⚠️ Skipping {video_id}: missing filepath")
continue
if not os.path.exists(filepath):
print(f"⚠️ Skipping {video_id}: file not found → {filepath}")
continue
if not os.path.exists(thumb_path):
tasks.append((filepath, thumb_path))
v["thumbnail"] = thumb_path
if tasks:
with ThreadPoolExecutor(max_workers=os.cpu_count() * 2) as exe:
list(exe.map(lambda t: subprocess.run(
_gen_thumb_cmd(*t),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
), tasks))

@ -0,0 +1,138 @@
from archiveConfig import get_local_db_connection
from psycopg2.extras import execute_values
from datetime import datetime
import uuid, shutil, json, os
from tqdm import tqdm
DATA_DIR = 'data'
DOWNLOAD_DIR = 'downloaded'
conn, cursor = get_local_db_connection()
def is_valid_uuid(val: str, version=None) -> bool:
try:
u = uuid.UUID(val, version=version) if version else uuid.UUID(val)
return str(u) == val.lower() # Match exact input (handles casing)
except (ValueError, AttributeError, TypeError):
return False
def parse_json_file(filepath):
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
if "createdAt" in data:
date = data.get("createdAt")
elif "date" in data:
date = data.get("date")
if date:
created_at = datetime.strptime(date, "%Y-%m-%d %H:%M:%S")
else:
created_at = None
print(f"⚠️ No createdAt or date found in {filepath}")
if "updatedAt" in data:
updated_at = datetime.strptime(data.get("updatedAt"), "%Y-%m-%d %H:%M:%S")
else:
updated_at = created_at
video_id = os.path.splitext(os.path.basename(filepath))[0]
if not is_valid_uuid(video_id):
print(f"⚠️ Invalid video_id: {video_id}")
return
parsed_data = {
'video_id': video_id,
'username': data.get("username"),
'site': data.get("site"),
'gender': data.get("gender"),
'size': data.get("size") if data.get("size") else 0,
'duration': data.get("duration") if data.get("duration") else 0,
'filepath': data.get("filepath"),
'jsonpath': data.get("jsonpath"),
'hash': None, # You can add hash calculation here if needed
'created_at': created_at,
'updated_at': updated_at
}
return parsed_data
def insert_data(all_data):
query = """
INSERT INTO videos (
video_id, username, site, gender, size, duration,
filepath, hash, created_at, updated_at
)
VALUES %s
ON CONFLICT (video_id) DO NOTHING;
"""
values = [
(
d['video_id'], d['username'], d['site'], d['gender'],
d['size'], d['duration'], d['filepath'],
d['hash'], d['created_at'], d['updated_at']
)
for d in all_data
]
execute_values(cursor, query, values)
conn.commit()
print(f"✅ Inserted {cursor.rowcount} new records.")
def get_files(dir):
files = []
for root, _, filenames in os.walk(dir):
for filename in filenames:
if filename.endswith('.json'):
files.append(os.path.join(root, filename))
return files
def main():
all_records = []
data_files = [f for f in get_files(DOWNLOAD_DIR) if f.endswith('.json')]
with tqdm(data_files, desc="Processing files", unit="file") as t:
for filepath in data_files:
t.update(1)
try:
record = parse_json_file(filepath)
all_records.append(record)
except Exception as e:
print(f"❌ Failed to process {filepath}: {e}")
if all_records:
insert_data(all_records)
else:
print("⚠️ No new records to insert.")
def check_and_move():
db_ids = get_video_ids_from_db()
moved = 0
for path in get_json_files(DOWNLOAD_DIR):
video_id = os.path.splitext(os.path.basename(path))[0]
if video_id in db_ids:
output_path = os.path.join(DATA_DIR, os.path.basename(path))
if os.path.exists(output_path):
print(f"⚠️ Skipping {path} because it already exists in {DOWNLOAD_DIR}/")
continue
shutil.move(path, output_path)
moved += 1
print(f"✅ Moved {moved} files to {DOWNLOAD_DIR}/")
# Get all existing video IDs
def get_video_ids_from_db():
cursor.execute("SELECT video_id FROM videos;")
return {row['video_id'] for row in cursor.fetchall()}
# Iterate files
def get_json_files(dir):
for root, _, files in os.walk(dir):
for file in files:
if file.endswith('.json'):
yield os.path.join(root, file)
if __name__ == '__main__':
main()
check_and_move()

@ -0,0 +1,53 @@
@app.route('/open-folder', methods=['POST'])
def open_folder():
data = request.json
file_path = data.get("file_path")
file_path = os.path.abspath(file_path)
dir_path = os.path.dirname(file_path)
if not file_path or not os.path.exists(file_path):
return jsonify({"error": "File not found"}), 404
# Open folder in Explorer and select file
subprocess.run(f'explorer {dir_path}')
return jsonify({"success": True})
@app.route("/api/fav/toggle/<video_id>", methods=["POST"])
def api_fav_toggle(video_id):
# Optional: validate video exists
try:
_ = db_get_video(video_id)
except Exception:
return jsonify({"error": "video not found"}), 404
is_fav = db_toggle_fav(video_id)
return jsonify({"ok": True, "video_id": video_id, "is_favorite": is_fav})
@app.route("/api/fav/list")
def api_fav_list():
return jsonify({"favorites": sorted(list(db_get_fav_set()))})
@app.route('/delete-file', methods=['POST'])
def delete_file():
data = request.json
file_path = data.get("file_path")
video_id = data.get("video_id")
if not file_path or not os.path.exists(file_path):
return jsonify({"error": "File not found"}), 404
try:
# delete from filesystem
os.remove(file_path)
# optional: mark video as missing in DB
conn, cur = get_local_db_connection()
cur = conn.cursor()
cur.execute("UPDATE videos SET status = 'missing' WHERE video_id = %s", (video_id,))
conn.commit()
cur.close(); conn.close()
return jsonify({"success": True})
except Exception as e:
return jsonify({"error": str(e)}), 500

@ -0,0 +1,134 @@
from flask import Blueprint, render_template, request, send_file
from helpers.db import db_get_videos, db_get_video, db_get_recent
from helpers.favorites import mark_favorites, db_get_favorites, db_get_fav_set
from helpers.cache import build_cache
web = Blueprint("web", __name__)
@web.route("/")
def dashboard():
cache = build_cache()
query = request.args.get("q", "").lower().strip()
sort = request.args.get("sort", "total_size") # user | platform | total_size | video_count | avg_size
dir_ = request.args.get("dir", "desc") # asc | desc
reverse = (dir_ == "desc")
# Start with list of (key, stats)
items = list(cache["storage_usage"].items())
# Search (by user)
if query:
items = [e for e in items if query in e[0].split("::")[0].lower()]
# Sorting
def k_user(x): return x[0].split("::")[0].lower()
def k_platform(x): return x[0].split("::")[1].lower()
def k_total(x): return x[1]["total_size"]
def k_count(x): return x[1]["video_count"]
def k_avg(x): return cache["avg_sizes"][x[0]]
key_map = {
"user": k_user,
"platform": k_platform,
"total_size": k_total,
"video_count": k_count,
"avg_size": k_avg,
}
items.sort(key=key_map.get(sort, k_total), reverse=reverse)
# Pagination
page = max(1, int(request.args.get("page", 1)))
total_pages = max(1, math.ceil(len(items) / DASHBOARD_PER_PAGE))
start = (page - 1) * DASHBOARD_PER_PAGE
paginated = items[start:start + DASHBOARD_PER_PAGE]
return render_template(
"main.html",
storage_usage=paginated,
avg_sizes=cache["avg_sizes"],
page=page,
total_pages=total_pages,
query=query,
sort=sort,
dir=dir_
)
@web.route("/refresh")
def refresh():
cache = build_cache()
return jsonify({
"status": "ok",
"videos": sum(x["video_count"] for x in cache["storage_usage"].values()),
"updated": time.ctime(cache["timestamp"])
})
@web.route("/user/<username>")
def user_page(username):
videos = db_get_videos(username=username)
page = max(1, int(request.args.get("page", 1)))
total_pages = max(1, math.ceil(len(videos) / VIDEOS_PER_PAGE))
start = (page - 1) * VIDEOS_PER_PAGE
paginated = videos[start:start + VIDEOS_PER_PAGE]
mark_favorites(paginated)
return render_template(
"user_page.html",
username=username,
videos=paginated,
page=page,
total_pages=total_pages
)
@web.route("/video/stream/<video_id>")
def stream_video(video_id):
video = db_get_video(video_id)
if video:
return send_file(video["filepath"], mimetype="video/mp4")
return "Video not found", 404
@web.route("/video/<video_id>")
def view_video(video_id):
video = db_get_video(video_id)
if video:
video["is_favorite"] = (video["video_id"] in db_get_fav_set())
return render_template("video_view.html", video=video)
return "Video not found", 404
@web.route("/recent")
def recent():
page = max(1, int(request.args.get("page", 1)))
per_page = VIDEOS_PER_PAGE
videos, total = db_get_recent(page, per_page)
total_pages = max(1, math.ceil(total / per_page))
mark_favorites(videos)
return render_template(
"recent.html",
videos=videos,
page=page,
total_pages=total_pages
)
@web.route("/favorites")
def favorites_page():
page = max(1, int(request.args.get("page", 1)))
per_page = VIDEOS_PER_PAGE
videos, total = db_get_favorites(page, per_page)
total_pages = max(1, math.ceil(total / per_page))
# tag stars (not strictly necessary since everything here is fav, but keeps UI consistent)
mark_favorites(videos)
return render_template(
"favorites.html",
videos=videos,
page=page,
total_pages=total_pages
)

@ -1,168 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>📊 Video Storage Analytics</title>
<style>
body {
font-family: Arial, sans-serif;
background: #111;
color: #eee;
text-align: center;
}
table {
margin: auto;
border-collapse: collapse;
width: 90%;
margin-top: 20px;
}
th,
td {
border: 1px solid #444;
padding: 10px;
cursor: pointer;
}
th {
background: #333;
}
tr:nth-child(even) {
background: #222;
}
th.sort-asc::after {
content: " ▲";
}
th.sort-desc::after {
content: " ▼";
}
#search {
margin: 10px;
padding: 8px;
width: 300px;
}
.pagination {
margin-top: 15px;
}
.pagination button {
background: #222;
color: #eee;
border: 1px solid #444;
margin: 0 5px;
padding: 6px 12px;
cursor: pointer;
}
.pagination button.active {
background: #555;
}
</style>
</head>
<body>
<h1>📊 Video Storage Analytics</h1>
<button onclick="window.location.href='/refresh'">🔄 Refresh Data</button>
<input type="text" id="search" placeholder="Search users...">
<table id="analytics-table">
<thead>
<tr>
<th>User</th>
<th>Platform</th>
<th>Total Storage (GB)</th>
<th>Video Count</th>
<th>Avg Size per Video (GB)</th>
</tr>
</thead>
<tbody>
{% for key, stats in storage_usage %}
{% set user, platform = key.split("::") %}
<tr>
<td><a href="/user/{{ user }}" style="color: #4af;">{{ user }}</a></td>
<td>{{ platform }}</td>
<td>{{ "%.2f"|format(stats.total_size) }}</td>
<td>{{ stats.video_count }}</td>
<td>{{ "%.2f"|format(avg_sizes[key]) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pagination" id="pagination"></div>
<script>
const table = document.getElementById('analytics-table');
const headers = table.querySelectorAll('th');
const searchInput = document.getElementById('search');
const rows = Array.from(table.querySelector('tbody').rows);
const pagination = document.getElementById('pagination');
let sortDirection = {};
let currentPage = 1;
const rowsPerPage = 100;
// Sorting
headers.forEach((header, index) => {
header.addEventListener('click', () => {
const isNumeric = index >= 2;
const dir = sortDirection[index] === 'asc' ? 'desc' : 'asc';
sortDirection = { [index]: dir };
headers.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
header.classList.add(dir === 'asc' ? 'sort-asc' : 'sort-desc');
rows.sort((a, b) => {
const aVal = isNumeric ? parseFloat(a.cells[index].innerText) : a.cells[index].innerText.toLowerCase();
const bVal = isNumeric ? parseFloat(b.cells[index].innerText) : b.cells[index].innerText.toLowerCase();
return dir === 'asc' ? (aVal > bVal ? 1 : -1) : (aVal < bVal ? 1 : -1);
});
updateTable();
});
});
// Search
searchInput.addEventListener('keyup', () => {
currentPage = 1;
updateTable();
});
// Pagination
function updatePagination(totalPages) {
pagination.innerHTML = '';
for (let i = 1; i <= totalPages; i++) {
const btn = document.createElement('button');
btn.innerText = i;
btn.classList.toggle('active', i === currentPage);
btn.addEventListener('click', () => {
currentPage = i;
updateTable();
});
pagination.appendChild(btn);
}
}
function updateTable() {
const term = searchInput.value.toLowerCase();
const filtered = rows.filter(row => row.cells[0].innerText.toLowerCase().includes(term));
const totalPages = Math.ceil(filtered.length / rowsPerPage);
updatePagination(totalPages);
table.querySelector('tbody').innerHTML = '';
const start = (currentPage - 1) * rowsPerPage;
const paginated = filtered.slice(start, start + rowsPerPage);
paginated.forEach(row => table.querySelector('tbody').appendChild(row));
}
updateTable(); // Initial render
</script>
</body>
</html>

@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>★ Favorites</title>
<style>
body {
font-family: Arial, sans-serif;
background: #111;
color: #eee;
text-align: center;
margin: 0;
padding: 0;
}
h1 {
margin-top: 20px;
}
.nav-link,
.back-link {
display: inline-block;
margin: 10px;
color: #0af;
text-decoration: none;
font-size: 16px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 18px;
margin: 20px;
padding: 0 20px;
}
.video-card {
background: #222;
padding: 10px;
border-radius: 8px;
transition: transform 0.2s ease;
position: relative;
}
.video-card:hover {
transform: scale(1.03);
}
.thumb-wrap {
position: relative;
border-radius: 5px;
overflow: hidden;
}
.thumb-link {
display: block;
}
.thumb-link img {
width: 100%;
display: block;
border-radius: 5px;
}
.video-size {
margin-top: 8px;
font-size: 14px;
color: #bbb;
}
.fav-btn {
position: absolute;
bottom: 10px;
left: 10px;
z-index: 2;
font-size: 1.2rem;
line-height: 1;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: pointer;
padding: .25rem .45rem;
border-radius: 6px;
backdrop-filter: blur(2px);
pointer-events: auto;
}
.fav-btn[aria-pressed="true"] {
color: gold;
}
.fav-btn:hover {
transform: scale(1.05);
}
.pagination {
margin: 20px 0;
}
.pagination a {
color: #0af;
margin: 0 8px;
text-decoration: none;
font-size: 16px;
}
</style>
</head>
<body>
<h1>★ Favorites</h1>
<a href="/" class="back-link">⬅ Back to Dashboard</a>
<div class="grid">
{% for video in videos %}
<div class="video-card">
<div class="thumb-wrap">
<a class="thumb-link" href="/video/{{ video['video_id'] }}">
<img src="/{{ video.thumbnail }}" alt="Thumbnail">
</a>
<button class="fav-btn" data-video-id="{{ video.video_id }}"
aria-pressed="{{ 'true' if video.is_favorite else 'false' }}"
title="{{ 'Unfavorite' if video.is_favorite else 'Favorite' }}">
{{ '★' if video.is_favorite else '☆' }}
</button>
</div>
<p class="video-size">{{ "%.2f"|format(video.size/1024) }} GB</p>
</div>
{% endfor %}
</div>
<div class="pagination">
{% if page > 1 %}
<a href="?page={{ page-1 }}">⬅ Prev</a>
{% endif %}
<span>Page {{ page }} / {{ total_pages }}</span>
{% if page < total_pages %} <a href="?page={{ page+1 }}">Next ➡</a>
{% endif %}
</div>
<script>
// Toggle favorite on this page; if unfavorited, you can either leave it or remove the card.
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.fav-btn');
if (!btn) return;
e.preventDefault(); e.stopPropagation();
const card = btn.closest('.video-card');
const vid = btn.dataset.videoId;
try {
const res = await fetch(`/api/fav/toggle/${encodeURIComponent(vid)}`, { method: 'POST' });
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error || 'Failed');
const isFav = data.is_favorite;
btn.textContent = isFav ? '★' : '☆';
btn.setAttribute('aria-pressed', isFav ? 'true' : 'false');
btn.title = isFav ? 'Unfavorite' : 'Favorite';
// Optional: remove card if unfavorited so it disappears from Favorites immediately
if (!isFav) {
card.remove();
}
} catch (err) {
alert('Could not toggle favorite. ' + (err?.message || ''));
}
});
</script>
</body>
</html>

@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>📊 Video Storage Analytics</title>
<style>
body { font-family: Arial, sans-serif; background:#111; color:#eee; text-align:center; }
table { margin:auto; border-collapse:collapse; width:90%; margin-top:20px; }
th, td { border:1px solid #444; padding:10px; }
th { background:#333; }
tr:nth-child(even){ background:#222; }
.controls { margin: 10px 0; }
.pagination { margin-top: 15px; }
.pagination a, .pagination span {
display:inline-block; background:#222; color:#eee; border:1px solid #444;
margin:0 5px; padding:6px 12px; text-decoration:none;
}
.pagination .active { background:#555; }
input[type="text"] { padding:8px; width:300px; }
button { padding:8px 12px; }
</style>
</head>
<body>
<script>
const table = document.getElementById('analytics-table');
const headers = table.querySelectorAll('th');
const searchInput = document.getElementById('search');
const rows = Array.from(table.querySelector('tbody').rows);
const pagination = document.getElementById('pagination');
let sortDirection = {};
let currentPage = 1;
const rowsPerPage = 100;
// Sorting
headers.forEach((header, index) => {
header.addEventListener('click', () => {
const isNumeric = index >= 2;
const dir = sortDirection[index] === 'asc' ? 'desc' : 'asc';
sortDirection = { [index]: dir };
headers.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
header.classList.add(dir === 'asc' ? 'sort-asc' : 'sort-desc');
rows.sort((a, b) => {
const aVal = isNumeric ? parseFloat(a.cells[index].innerText) : a.cells[index].innerText.toLowerCase();
const bVal = isNumeric ? parseFloat(b.cells[index].innerText) : b.cells[index].innerText.toLowerCase();
return dir === 'asc' ? (aVal > bVal ? 1 : -1) : (aVal < bVal ? 1 : -1);
});
updateTable();
});
});
// Search
searchInput.addEventListener('keyup', () => {
currentPage = 1;
updateTable();
});
function updateTable() {
const term = searchInput.value.toLowerCase();
const filtered = rows.filter(row => row.cells[0].innerText.toLowerCase().includes(term));
const totalPages = Math.ceil(filtered.length / rowsPerPage);
updatePagination(totalPages);
table.querySelector('tbody').innerHTML = '';
const start = (currentPage - 1) * rowsPerPage;
const paginated = filtered.slice(start, start + rowsPerPage);
paginated.forEach(row => table.querySelector('tbody').appendChild(row));
}
updateTable(); // Initial render
</script>
<h1>📊 Video Storage Analytics</h1>
<div class="controls">
<button onclick="window.location.href='/refresh'">🔄 Refresh Data</button>
<!-- Server-side search -->
<form method="get" action="{{ url_for('dashboard') }}" style="display:inline-block; margin-left:10px;">
<input type="text" name="q" placeholder="Search users..." value="{{ query or '' }}">
<button type="submit">Search</button>
</form>
</div>
<table id="analytics-table">
<thead>
<tr>
{% set next_user_dir = 'asc' if sort != 'user' or dir == 'desc' else 'desc' %}
{% set next_platform_dir = 'asc' if sort != 'platform' or dir == 'desc' else 'desc' %}
{% set next_total_dir = 'asc' if sort != 'total_size' or dir == 'desc' else 'desc' %}
{% set next_count_dir = 'asc' if sort != 'video_count' or dir == 'desc' else 'desc' %}
{% set next_avg_dir = 'asc' if sort != 'avg_size' or dir == 'desc' else 'desc' %}
<th>
<a href="{{ url_for('dashboard', q=query, page=1, sort='user', dir=next_user_dir) }}">
User{% if sort=='user' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}
</a>
</th>
<th>
<a href="{{ url_for('dashboard', q=query, page=1, sort='platform', dir=next_platform_dir) }}">
Platform{% if sort=='platform' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}
</a>
</th>
<th>
<a href="{{ url_for('dashboard', q=query, page=1, sort='total_size', dir=next_total_dir) }}">
Total Storage (GB){% if sort=='total_size' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}
</a>
</th>
<th>
<a href="{{ url_for('dashboard', q=query, page=1, sort='video_count', dir=next_count_dir) }}">
Video Count{% if sort=='video_count' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}
</a>
</th>
<th>
<a href="{{ url_for('dashboard', q=query, page=1, sort='avg_size', dir=next_avg_dir) }}">
Avg Size per Video (GB){% if sort=='avg_size' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}
</a>
</th>
</tr>
</thead>
<tbody>
{% for key, stats in storage_usage %}
{% set user, platform = key.split("::") %}
<tr>
<td><a href="/user/{{ user }}" style="color:#4af;">{{ user }}</a></td>
<td>{{ platform }}</td>
<td>{{ "%.2f"|format(stats.total_size) }}</td>
<td>{{ stats.video_count }}</td>
<td>{{ "%.2f"|format(avg_sizes[key]) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Server-side pagination -->
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="{{ url_for('dashboard', page=page-1, q=query) }}">« Prev</a>
{% else %}
<span>« Prev</span>
{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<span class="active">{{ p }}</span>
{% else %}
<a href="{{ url_for('dashboard', page=p, q=query) }}">{{ p }}</a>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="{{ url_for('dashboard', page=page+1, q=query) }}">Next »</a>
{% else %}
<span>Next »</span>
{% endif %}
</div>
{% endif %}
</body>
</html>

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Recent Videos</title>
<style>
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
.card { border: 1px solid #ddd; border-radius: 8px; padding: 10px; background: #fff; }
.thumb { width: 100%; height: auto; border-radius: 6px; display: block; }
.meta { margin-top: 8px; font-size: 14px; line-height: 1.4; }
.meta a { text-decoration: none; color: #0b73ff; }
.pager { margin: 16px 0; display: flex; gap: 8px; justify-content: center; }
.pager a { padding: 6px 10px; border: 1px solid #ccc; border-radius: 6px; text-decoration: none; color: #333; }
.pager .current { padding: 6px 10px; border-radius: 6px; background: #eee; }
body {
font-family: Arial, sans-serif;
background: #111;
color: #eee;
text-align: center;
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<h1>Recent Videos</h1>
<div class="grid">
{% for v in videos %}
<div class="card">
<a href="{{ url_for('view_video', video_id=v.video_id) }}">
<img class="thumb"
src="{{ (v.thumbnail or '') | replace('\\','/') }}"
alt="thumb"
onerror="this.style.display='none'">
</a>
<div class="meta">
<div>
<strong><a href="{{ url_for('view_video', video_id=v.video_id) }}">{{ v.video_id }}</a></strong>
</div>
<div>
by <a href="{{ url_for('user_page', username=v.username) }}">{{ v.username }}</a>
· {{ v.platform }}
</div>
<div>
{{ (v.created_at or v.updated_at) }}
</div>
<div>
{{ "%.1f"|format((v.size or 0)|float) }} MB · {{ v.duration or 0 }}s
</div>
</div>
</div>
{% endfor %}
</div>
<div class="pager">
{% if page > 1 %}
<a href="{{ url_for('recent', page=page-1) }}">&laquo; Prev</a>
{% endif %}
<span class="current">Page {{ page }} / {{ total_pages }}</span>
{% if page < total_pages %}
<a href="{{ url_for('recent', page=page+1) }}">Next &raquo;</a>
{% endif %}
</div>
</body>
</html>

@ -39,16 +39,24 @@
padding: 10px;
border-radius: 8px;
transition: transform 0.2s ease;
position: relative;
/* <-- allow overlay star positioning */
}
.video-card:hover {
transform: scale(1.03);
}
.thumb-link {
display: block;
position: relative;
}
.video-card img {
width: 100%;
border-radius: 5px;
cursor: pointer;
display: block;
}
.video-size {
@ -57,6 +65,30 @@
color: #bbb;
}
/* Favorite star */
.fav-btn {
position: absolute;
top: 12px;
right: 12px;
font-size: 1.2rem;
line-height: 1;
border: none;
background: rgba(0, 0, 0, 0.45);
color: #fff;
cursor: pointer;
padding: .25rem .45rem;
border-radius: 6px;
backdrop-filter: blur(2px);
}
.fav-btn[aria-pressed="true"] {
color: gold;
}
.fav-btn:hover {
transform: scale(1.05);
}
.pagination {
margin: 20px 0;
}
@ -77,9 +109,19 @@
<div class="grid">
{% for video in videos %}
<div class="video-card">
<a href="/video/{{ video['video_id'] }}">
<div class="thumb-wrap">
<a class="thumb-link" href="/video/{{ video['video_id'] }}">
<img src="/{{ video.thumbnail }}" alt="Thumbnail">
</a>
<!-- Star overlay INSIDE the thumb-wrap so it sits on top of the image -->
<button class="fav-btn" data-video-id="{{ video.video_id }}"
aria-pressed="{{ 'true' if video.is_favorite else 'false' }}"
title="{{ 'Unfavorite' if video.is_favorite else 'Favorite' }}">
{{ '★' if video.is_favorite else '☆' }}
</button>
</div>
<p class="video-size">{{ "%.2f"|format(video.size/1024) }} GB</p>
</div>
{% endfor %}
@ -90,10 +132,37 @@
<a href="?page={{ page-1 }}">⬅ Prev</a>
{% endif %}
<span>Page {{ page }} / {{ total_pages }}</span>
{% if page < total_pages %}
<a href="?page={{ page+1 }}">Next ➡</a>
{% if page < total_pages %} <a href="?page={{ page+1 }}">Next ➡</a>
{% endif %}
</div>
<script>
// Handle favorite toggles (and prevent navigating when clicking the star)
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.fav-btn');
if (!btn) return;
// prevent the underlying link click if someone clicks near the corner
e.preventDefault();
e.stopPropagation();
const vid = btn.dataset.videoId;
try {
const res = await fetch(`/api/fav/toggle/${encodeURIComponent(vid)}`, {
method: 'POST'
});
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error || 'Failed');
const isFav = data.is_favorite;
btn.textContent = isFav ? '★' : '☆';
btn.setAttribute('aria-pressed', isFav ? 'true' : 'false');
btn.title = isFav ? 'Unfavorite' : 'Favorite';
} catch (err) {
alert('Could not toggle favorite. ' + (err?.message || ''));
}
});
</script>
</body>
</html>

@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
@ -13,6 +14,7 @@
margin: 0;
padding: 0;
}
video {
margin-top: 20px;
width: 80%;
@ -21,6 +23,7 @@
border-radius: 8px;
background: #000;
}
.meta {
margin: 20px auto;
text-align: left;
@ -29,8 +32,32 @@
padding: 15px;
border-radius: 6px;
}
.meta p { margin: 8px 0; }
a { color: #0af; text-decoration: none; }
.meta p {
margin: 8px 0;
}
a {
color: #0af;
text-decoration: none;
}
.fav-btn {
font-size: 1.1rem;
line-height: 1;
border: none;
background: transparent;
cursor: pointer;
padding: .2rem .35rem;
}
.fav-btn[aria-pressed="true"] {
color: gold;
}
.fav-btn:hover {
transform: scale(1.05);
}
</style>
<script>
@ -49,27 +76,88 @@
}
</script>
<script>
function deleteFile(filePath, videoId) {
if (!confirm("Are you sure you want to permanently delete this file?")) return;
fetch('/delete-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath, video_id: videoId })
})
.then(res => res.json())
.then(data => {
if (data.success) {
alert("File deleted successfully");
// redirect back to the user page
window.location.href = "/user/{{ video.username }}";
} else {
alert("Failed to delete: " + (data.error || 'Unknown error'));
}
})
.catch(err => alert("Error: " + err));
}
</script>
<script>
// existing openFolder(...) here
document.addEventListener('click', async (e) => {
const btn = e.target.closest('#favBtn');
if (!btn) return;
const vid = btn.dataset.videoId;
try {
const res = await fetch(`/api/fav/toggle/${encodeURIComponent(vid)}`, { method: 'POST' });
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error || 'Failed');
const isFav = data.is_favorite;
btn.textContent = isFav ? '★' : '☆';
btn.setAttribute('aria-pressed', isFav ? 'true' : 'false');
btn.title = isFav ? 'Unfavorite' : 'Favorite';
} catch (err) {
alert('Could not toggle favorite. ' + (err?.message || ''));
}
});
</script>
</head>
<body>
<h1>{{ video.username }} - {{ video.site }}</h1>
<div>
<a href="/user/{{ video.username }}">⬅ Back to {{ video.username }}'s Videos</a>
</div>
<video controls muted>
<source src="/video/stream/{{ video.video_id }}" type="video/mp4">
<source src="/video/stream/{{ video.video_id }}" type="video/mp4" poster="{{ video.thumbnail }}">
Your browser does not support the video tag.
</video>
<div class="meta">
<button onclick="openFolder('{{ video.filepath }}')">Open Folder</button>
<button onclick="deleteFile('{{ video.filepath }}','{{ video.video_id }}')" style="margin-left:8px">Delete
File</button>
<!-- Favorite star -->
<button id="favBtn" class="fav-btn" data-video-id="{{ video.video_id }}"
aria-pressed="{{ 'true' if video.is_favorite else 'false' }}"
title="{{ 'Unfavorite' if video.is_favorite else 'Favorite' }}" style="margin-left:8px">
{{ '★' if video.is_favorite else '☆' }}
</button>
</div>
<div class="meta">
<p><strong>Username:</strong> {{ video.username }}</p>
<a href="{{ url_for('user_page', username=video.username) }}" class="username-link">
{{ video.username }}
</a>
<p><strong>Size:</strong> {{ "%.2f"|format(video.size/1024) }} GB</p>
<p><strong>Duration:</strong> {{ video.duration if video.duration else "Unknown" }}</p>
<p><strong>Path:</strong> {{ video.filepath }}</p>
<p><strong>Date:</strong> {{ video.createdAt }}</p>
<p><strong>Date:</strong> {{ video.created_at }}</p>
</div>
</body>
</html>
Loading…
Cancel
Save