cleanup and ui updates
parent
ecfa2f8745
commit
d4ed0dfb0a
@ -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) }}">« Prev</a>
|
||||
{% endif %}
|
||||
<span class="current">Page {{ page }} / {{ total_pages }}</span>
|
||||
{% if page < total_pages %}
|
||||
<a href="{{ url_for('recent', page=page+1) }}">Next »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue