Compare commits

...

10 Commits

3
.gitignore vendored

@ -183,3 +183,6 @@ cython_debug/
/static/thumbnails
/.temp
.last_checked
/concated
/edited
config_recorder.txt

@ -1,100 +0,0 @@
from moviepy.editor import VideoFileClip, concatenate_videoclips
import os, cv2
def add_intro_to_video(input_video, intro_video='intro.mp4', output_video='output.mp4'):
clip_main = VideoFileClip(input_video)
clip_intro = VideoFileClip(intro_video).resize(clip_main.size).set_fps(clip_main.fps)
if clip_main.audio is not None and clip_intro.audio is None:
from moviepy.editor import AudioArrayClip
silent_audio = AudioArrayClip([[0] * int(clip_intro.duration * clip_main.audio.fps)], fps=clip_main.audio.fps)
clip_intro = clip_intro.set_audio(silent_audio)
final_clip = concatenate_videoclips([clip_intro, clip_main])
final_clip.write_videofile(output_video, codec='libx264')
def get_duration(input_file):
if not os.path.isfile(input_file):
print('Input file does not exist')
return 0
try:
video = cv2.VideoCapture(input_file)
frames = video.get(cv2.CAP_PROP_FRAME_COUNT)
fps = video.get(cv2.CAP_PROP_FPS)
duration = frames / fps
video.release()
return int(duration)
except Exception as e:
print(e)
return 0
def generate_thumbnails(input_file, filename):
output_folder = 'temp/'
if not os.path.isfile(input_file):
raise ValueError('Input file does not exist')
if not os.path.exists(output_folder):
os.makedirs(output_folder)
posterPath = os.path.join(output_folder, f'{filename}.jpg')
previewPath = os.path.join(output_folder, f'{filename}.mp4')
clip = VideoFileClip(input_file)
duration = clip.duration
interval = duration / 11.0
start_time_first_clip = 0 * interval
try:
clip.save_frame(posterPath, t=start_time_first_clip)
except:
pass
clips = []
for i in range(10):
start_time = i * interval
end_time = start_time + 1
clips.append(clip.subclip(start_time, end_time))
final_clip = concatenate_videoclips(clips).resize(newsize=(384, 216)).without_audio()
final_clip.write_videofile(previewPath, fps=24, codec="libx264")
for subclip in clips:
subclip.close()
clip.close()
final_clip.close()
return posterPath, previewPath
def split_video(file_path, segment_size_gb=8):
import subprocess
# Convert GB to bytes
segment_size_bytes = segment_size_gb * 1024 * 1024 * 1024
# Get the total size of the video file
total_size_bytes = os.path.getsize(file_path)
# Calculate the number of segments needed
num_segments = total_size_bytes // segment_size_bytes + 1
# Get the duration of the video file
duration = get_duration(file_path)
# Calculate the duration of each segment
segment_duration = duration / num_segments
# Generate output file pattern
file_name, file_extension = os.path.splitext(file_path)
output_pattern = f"{file_name}_segment_%03d{file_extension}"
# Run FFmpeg command to split the video
command = [
"ffmpeg", "-i", file_path, "-c", "copy", "-map", "0",
"-segment_time", str(segment_duration), "-f", "segment", output_pattern
]
subprocess.run(command)

@ -1,257 +0,0 @@
import os
from config import get_local_db_connection
from funcs import get_duration, get_file_size_in_mb, calculate_file_hash
from tqdm import tqdm
import os, hashlib, subprocess, shutil
from config import get_local_db_connection
from concurrent.futures import ThreadPoolExecutor
EDITED_DIR = "edited/"
THUMB_DIR = "static/thumbnails"
THUMB_WIDTH = 640
FF_QUALITY = "80"
VIDEO_DIRS = [
"U:/streamaster/",
"E:/streamaster/streamaster/downloaded"
]
def get_all_video_files():
files = {}
for base in VIDEO_DIRS:
for root, _, filenames in os.walk(base):
for filename in filenames:
if filename.endswith(".mp4"):
files[filename] = os.path.join(root, filename)
return files
def find_video_path(filename: str):
return all_videos[filename] if filename in all_videos else None
def mark_missing_videos(cursor, conn):
cursor.execute("SELECT video_id, filepath FROM videos WHERE status != 'missing'")
videos = cursor.fetchall()
with tqdm(videos, desc="Scanning for missing videos...") as pbar:
for vid in videos:
pbar.update(1)
video_id, filepath = vid.values()
if not filepath:
continue
filename = os.path.basename(filepath)
if not find_video_path(filename):
print(f"🚫 Missing: {filename}")
cursor.execute("UPDATE videos SET status = 'missing' WHERE video_id = %s", (video_id,))
conn.commit()
def fill_missing_filepaths(cursor, conn):
cursor.execute("SELECT id, filepath, status, video_id FROM videos")
videos = cursor.fetchall()
with tqdm(videos, desc="Updating filepaths...") as pbar:
for vid in videos:
pbar.update(1)
filepath = vid['filepath']
if not filepath:
filename = f'{vid["video_id"]}.mp4'
else:
filename = os.path.basename(filepath)
status = vid['status']
path = find_video_path(filename)
if not path:
continue
path = path.replace("\\", "/")
if path == filepath and status != 'missing':
continue
cursor.execute("UPDATE videos SET filepath = %s, status = 'active' WHERE id = %s", (path, vid['id']))
conn.commit()
def fill_missing_hashes(cursor, conn):
cursor.execute("SELECT video_id, filepath FROM videos WHERE (hash IS NULL OR hash = '') AND status != 'missing'")
videos = cursor.fetchall()
with tqdm(videos, desc="Updating hashes...") as pbar:
for vid in videos:
pbar.update(1)
video_id, filepath = vid.values()
if filepath and os.path.exists(filepath):
h = calculate_file_hash(filepath)
cursor.execute("UPDATE videos SET hash = %s WHERE video_id = %s", (h, video_id))
conn.commit()
def fill_missing_sizes(cursor, conn):
cursor.execute("SELECT video_id, filepath FROM videos WHERE size = 0 AND status != 'missing'")
videos = cursor.fetchall()
with tqdm(videos, desc="Updating sizes...") as pbar:
for vid in videos:
pbar.update(1)
video_id, filepath = vid.values()
if filepath and os.path.exists(filepath):
size = get_file_size_in_mb(filepath)
cursor.execute("UPDATE videos SET size = %s WHERE video_id = %s", (size, video_id))
conn.commit()
def fill_missing_durations(cursor, conn):
cursor.execute("SELECT video_id, filepath FROM videos WHERE duration = 0 AND status != 'missing' ORDER BY size ASC")
videos = cursor.fetchall()
with tqdm(videos, desc="Updating durations...") as pbar:
for vid in videos:
pbar.update(1)
video_id, filepath = vid.values()
if filepath and os.path.exists(filepath):
duration = get_duration(filepath)
if duration <= 0:
print(f"🚫 Failed to get duration for {filepath}")
os.remove(filepath)
continue
cursor.execute("UPDATE videos SET duration = %s WHERE video_id = %s", (duration, video_id))
conn.commit()
def fill_missing_gender(cursor, conn):
def get_data(username):
import requests
url = f"https://chaturbate.com/api/biocontext/{username}"
data = requests.get(url)
data = data.json()
return data
cursor.execute("SELECT DISTINCT username, site FROM videos WHERE gender IS NULL AND status != 'missing'")
videos = cursor.fetchall()
with tqdm(videos, desc="Updating genders...") as pbar:
for vid in videos:
pbar.update(1)
username, site = vid.values()
# try to fetch an item from videos table with the same username and site but with a non-null gender
cursor.execute("SELECT gender FROM videos WHERE username = %s AND site = %s AND gender IS NOT NULL LIMIT 1", (username, site))
gender = cursor.fetchone()
if not gender:
data = get_data(username)
if not data:
continue
if 'status' in data:
if data['status'] == 401:
continue
gender = data['sex']
if 'woman' in gender:
gender_str = 'Female'
elif 'couple' in gender:
gender_str = 'Couple'
elif 'trans' in gender:
gender_str = 'Trans'
else:
print(f"fuck?: {gender}")
continue
else:
gender_str = gender['gender']
cursor.execute("UPDATE videos SET gender = %s WHERE username = %s AND site = %s", (gender_str, username, site))
conn.commit()
print(f"[{cursor.rowcount}] ✅ Updated gender for {username} on {site}")
def generate_thumbnails_for_videos(cursor, conn):
cursor.execute("SELECT video_id, filepath FROM videos WHERE status != 'missing' AND thumbnail IS NULL")
videos = cursor.fetchall()
tasks = []
with tqdm(videos, desc="Generating thumbnails...") as pbar:
for v in videos:
pbar.update(1)
video_id = v.get("video_id")
filepath = v.get("filepath")
if not filepath:
continue
if not os.path.exists(filepath):
continue
thumb_path = _hashed_thumb_path(video_id)
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))
for v in videos:
if 'thumbnail' not in v:
continue
v['thumbnail'] = v['thumbnail'].replace("\\", "/")
cursor.execute("UPDATE videos SET thumbnail = %s WHERE video_id = %s", (v['thumbnail'], v['video_id']))
conn.commit()
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 move_edited_videos(cursor, conn):
edited_videos = [f for f in os.listdir(EDITED_DIR) if os.path.isfile(os.path.join(EDITED_DIR, f)) and f.endswith(".mp4")]
for filename in edited_videos:
edited_path = os.path.join(EDITED_DIR, filename)
video_id = filename.split(".")[0]
cursor.execute("SELECT filepath FROM videos WHERE video_id = %s", (video_id,))
video = cursor.fetchone()
if not video:
continue
video_path = video['filepath']
if not os.path.exists(video_path):
continue
shutil.move(edited_path, video_path)
print(f"✅ Moved edited video {video_id} to {video_path}")
if __name__ == '__main__':
conn, cursor = get_local_db_connection()
print("🔍 Scanning for missing data...")
move_edited_videos(cursor, conn)
if True:
all_videos = get_all_video_files()
fill_missing_filepaths(cursor, conn)
mark_missing_videos(cursor, conn)
generate_thumbnails_for_videos(cursor, conn)
fill_missing_sizes(cursor, conn)
fill_missing_durations(cursor, conn)
# fill_missing_gender(cursor, conn)
# fill_missing_hashes(cursor, conn)
cursor.close()
conn.close()
print("✅ All cleanup tasks completed.")

@ -1,401 +0,0 @@
from datetime import datetime, timedelta, timezone
from VideoManager import get_duration
import os, json, subprocess, shutil
def is_file_empty(filepath):
return os.stat(filepath).st_size == 0
def format_datetime(datetime_str):
"""Format the datetime string to a more readable format."""
return datetime.strptime(datetime_str, "%Y-%m-%d %H:%M:%S")
def get_file_size_in_mb(file_path):
return os.path.getsize(file_path) / (1024 ** 2)
def get_file_size_gb(file_path):
return os.path.getsize(file_path) / 1024 / 1024 / 1024
def get_data(data_path):
try:
with open(data_path, 'r') as file:
data = json.load(file)
return data
except Exception as e:
print(f"Error loading {data_path}: {e}")
return None
def update_video_data(dataPath, data):
"""Update or create a JSON file for the video metadata."""
if os.path.exists(dataPath):
with open(dataPath, "r") as f:
existing_data = json.load(f)
if existing_data == data:
return # No update needed if data hasn't changed.
data["updatedAt"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with open(dataPath, "w") as f:
json.dump(data, f) # Write to file if new or if data has changed.
def is_recent(updated_at_str, minutes=30):
updated_at = format_datetime(updated_at_str)
updated_at = updated_at.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
return now - updated_at < timedelta(minutes=minutes)
def is_file_size_bigger_than(file_size_in_mb, max_size_gb):
"""Check if the file size is bigger than the specified max size in GB."""
max_size_megabytes = max_size_gb * 1024 # Convert GB to MB
return file_size_in_mb > max_size_megabytes
def cleanup_data_files(folder_path):
videos = [video for video in os.listdir(folder_path) if video.endswith(".json")]
for filename in videos:
json_path = os.path.join(folder_path, filename)
video_path = json_path.replace(".json", ".mp4")
if not os.path.exists(video_path):
os.remove(json_path)
def get_video_data(videoPath):
with open(videoPath, "r") as f:
data = json.load(f)
return data
def get_videos(folder_path):
"""Retrieve video metadata from the JSON files in a specified folder."""
video_list = []
# List all .mp4 files and their corresponding .json metadata files
videos = [f for f in os.listdir(folder_path) if f.endswith(".mp4")]
for video_filename in videos:
video_path = os.path.join(folder_path, video_filename)
json_path = video_path.replace(".mp4", ".json")
if not os.path.exists(json_path):
continue
data = get_video_data(json_path)
data['size'] = get_file_size_in_mb(video_path) # Include size in MB for further processing
data['filepath'] = video_path
video_list.append(data)
return video_list
def group_videos(video_list, sort_by="count", order="desc"):
"""Group video data by username and site, and sort the groups by video creation time."""
video_data = {}
is_desc = order == "desc"
for video in video_list:
key = (video["username"], video["site"])
if key not in video_data:
video_data[key] = []
video_data[key].append(video)
# Ensure videos for each user and site are sorted by creation date
for key in video_data:
video_data[key].sort(key=lambda x: format_datetime(x["createdAt"]))
# Further sort groups if required based on size or count
if sort_by == "size":
video_data = dict(sorted(video_data.items(), key=lambda x: sum(item['size'] for item in x[1]), reverse=is_desc))
elif sort_by == "count":
video_data = dict(sorted(video_data.items(), key=lambda x: len(x[1]), reverse=is_desc))
return video_data
def process_videos(video_data):
processed_videos = []
failed_directory = "failed"
for video in video_data:
is_updated = False
video_path = video["filepath"]
data_path = video["jsonpath"]
if 'size' not in video:
filesize = get_file_size_in_mb(video_path)
video['size'] = filesize
is_updated = True
if is_updated and 'duration' not in video:
video['duration'] = get_duration(video_path)
is_updated = True
# Move corrupted videos to the failed folder
if video['duration'] == 0:
print(f"{video['videoID']} is corrupted, moving to failed folder")
failed_video_path = os.path.join(failed_directory, video["videoID"] + ".mp4")
failed_data_path = failed_video_path.replace(".mp4", ".json")
shutil.move(video_path, failed_video_path)
shutil.move(data_path, failed_data_path)
continue # Skip further processing for this video
if is_updated:
update_video_data(data_path, video)
processed_videos.append(video)
return processed_videos
def group_for_concatenation(videos, time_limit=30):
"""
Groups videos into lists where:
- total group size <= 9GB (9216 MB),
- time gap between consecutive videos <= time_limit minutes,
- AND all have the same resolution/fps/codecs for no-reencode concat.
"""
concatenated_video_groups = []
current_group = []
current_size_mb = 0
last_video_end = None
reference_params = None # We'll store the 'ffprobe' params for the first video in each group
for video in videos:
video_start = format_datetime(video['createdAt'])
video_end = video_start + timedelta(seconds=video['duration'])
# Probe the video to get parameters
video_path = video['filepath']
params = get_video_params(video_path)
if params is None:
# If ffprobe fails, skip or handle the error
print(f"Skipping {video_path}, failed to get ffprobe info.")
continue
if current_group:
# Check if adding this video breaks the size limit
time_difference = (video_start - last_video_end).total_seconds() / 60
size_exceeded = (current_size_mb + video['size'] > 9216)
time_exceeded = (time_difference > time_limit)
# Check if the video parameters match the group's reference
param_mismatch = False
if reference_params:
# Compare relevant fields
for field in ['video_codec','width','height','pix_fmt','fps',
'audio_codec','audio_sample_rate','audio_channels','audio_channel_layout']:
if params[field] != reference_params[field]:
param_mismatch = True
break
# If we exceed size, exceed time gap, or mismatch in parameters => start new group
if size_exceeded or time_exceeded or param_mismatch:
concatenated_video_groups.append(current_group)
current_group = []
current_size_mb = 0
reference_params = None # reset for new group
# If we're starting a new group, set reference parameters
if not current_group:
reference_params = params
# Add the current video to the group
current_group.append(video)
current_size_mb += video['size']
last_video_end = video_end
# Add the last group if not empty
if current_group:
concatenated_video_groups.append(current_group)
# Optional: Ensure the last group is "ready" for upload based on time difference
# (Your original logic that if last video was updated < time_limit minutes ago, remove the group)
if concatenated_video_groups:
last_group = concatenated_video_groups[-1]
last_video = last_group[-1]
last_updated_at = datetime.strptime(last_video['createdAt'], "%Y-%m-%d %H:%M:%S")
if datetime.now() - last_updated_at <= timedelta(minutes=time_limit):
print(f"Last group is not ready for upload. Removing from final groups.")
concatenated_video_groups.pop()
concatenated_video_groups = [group for group in concatenated_video_groups if len(group) > 1]
return concatenated_video_groups
def get_video_params(video_path):
"""
Run ffprobe on a given video path to extract:
- codec_name (video + audio)
- width, height
- pix_fmt
- r_frame_rate (frame rate)
- sample_rate, channel_layout (audio)
Returns a dict with these parameters or None if there's an error.
"""
cmd = [
'ffprobe', '-v', 'error',
'-print_format', 'json',
'-show_streams',
'-show_format',
video_path
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
info = json.loads(result.stdout)
# We'll parse out the first video & audio streams we find.
video_stream = next((s for s in info['streams'] if s['codec_type'] == 'video'), None)
audio_stream = next((s for s in info['streams'] if s['codec_type'] == 'audio'), None)
if not video_stream:
raise ValueError(f"No video stream found in {video_path}")
# Frame rate can be something like "30000/1001" - convert to float
r_frame_rate = video_stream.get('r_frame_rate', '0/0')
try:
num, den = r_frame_rate.split('/')
fps = float(num) / float(den) if float(den) != 0 else 0.0
except:
fps = 0.0
# Gather the key parameters
params = {
'video_codec': video_stream.get('codec_name', 'unknown'),
'width': video_stream.get('width', 0),
'height': video_stream.get('height', 0),
'pix_fmt': video_stream.get('pix_fmt', 'unknown'),
'fps': fps,
'audio_codec': audio_stream.get('codec_name', 'none') if audio_stream else 'none',
'audio_sample_rate': audio_stream.get('sample_rate', '0') if audio_stream else '0',
'audio_channels': audio_stream.get('channels', 0) if audio_stream else 0,
'audio_channel_layout': audio_stream.get('channel_layout', 'none') if audio_stream else 'none'
}
return params
except subprocess.CalledProcessError as e:
print(f"Failed to run ffprobe on {video_path}: {e}")
return None
def generate_list_file(videos):
directory = os.path.dirname(videos[0]["filepath"])
list_filename = os.path.join(directory, f"{videos[0]['videoID']}.txt")
with open(list_filename, "w") as list_file:
for video in videos:
list_file.write(f"file '{video['videoID']}.mp4'\n")
return list_filename
def concatenate_videos(grouped_videos, directory):
"""Concatenate pre-grouped videos, updating metadata and managing file operations."""
processed_videos = []
for group in grouped_videos:
if len(group) == 1:
processed_videos.append(group[0])
continue
# Set up paths based on the first video in the group
first_video = group[0]
video_path = first_video["filepath"]
data_path = video_path.replace(".mp4", ".json")
temp_path = video_path.replace(".mp4", "_temp.mp4")
# Generate a list file for ffmpeg concatenation
list_filename = generate_list_file(directory, group)
# Run ffmpeg to concatenate videos
subprocess.run(["ffmpeg", "-f", "concat", "-safe", "0", "-i", list_filename, "-c", "copy", temp_path])
# Remove individual video files and their metadata
[os.remove(v["filepath"]) for v in group]
[os.remove(v["filepath"].replace(".mp4", ".json")) for v in group]
os.remove(list_filename)
os.rename(temp_path, video_path)
# Update the metadata for the concatenated video
first_video["filepath"] = video_path
first_video["size"] = get_file_size_in_mb(video_path)
first_video["duration"] = get_duration(video_path)
update_video_data(data_path, first_video) # Ensure this function reflects the changes of concatenation
processed_videos.append(first_video)
return processed_videos
def get_all_videos(directory):
# find all .mp4 files in the directory and its subdirectories
videos = []
for root, dirs, files in os.walk(directory):
for file in files:
if file.endswith(".mp4"):
videos.append(os.path.join(root, file))
return videos
def get_all_data(directory):
# finds all json files in the directory and its subdirectories
data = []
for root, dirs, files in os.walk(directory):
for file in files:
if file.endswith(".json"):
data.append(os.path.join(root, file))
return data
def match_data_to_video_fast(videos, data):
data_dict = {os.path.splitext(os.path.basename(d))[0]: d for d in data}
matched, unmatched = [], []
for v in videos:
video_id = os.path.splitext(os.path.basename(v))[0]
if video_id in data_dict:
matched.append((v, data_dict[video_id]))
else:
unmatched.append(v)
return parse_video_data(matched), unmatched
def parse_video_data(matched_videos):
"""Retrieve video metadata from the JSON files in a specified folder."""
import tqdm
video_list = []
with tqdm.tqdm(total=len(matched_videos), desc="Parsing video data") as pbar:
for video in matched_videos:
pbar.update(1)
video_path, json_path = video
data = get_video_data(json_path)
data['filepath'] = video_path
data['jsonpath'] = json_path
video_list.append(data)
return video_list
def get_videos_matched(video_dirs, data_dirs):
# get all videos
videos = []
for d in video_dirs:
videos += get_all_videos(d)
# get all data
data = []
for d in data_dirs:
data += get_all_data(d)
# match the data to the videos
parsed_videos, unmatched = match_data_to_video_fast(videos, data)
return parsed_videos, unmatched
def calculate_file_hash(file_path):
import hashlib
with open(file_path, 'rb') as f:
data = f.read()
return hashlib.sha256(data).hexdigest()

@ -20,16 +20,21 @@ def build_cache(start=None, end=None):
key = f"{username}::{platform}"
total_gb = 0.0
last_online = None
for v in vids:
try:
total_gb += float(v.get("size", 0) or 0) / 1024.0
except (ValueError, TypeError):
# ignore bad rows
continue
pass
# track latest created_at
ca = v.get("created_at")
if ca and (last_online is None or ca > last_online):
last_online = ca
storage_usage[key] = {
"total_size": total_gb,
"video_count": len(vids)
"video_count": len(vids),
"last_online": last_online, # <— new
}
avg_sizes[key] = (total_gb / len(vids)) if vids else 0.0
video_map[key] = vids

@ -16,7 +16,7 @@ def db_get_videos(username: str = None, start=None, end=None):
filepath, size, duration, gender,
created_at, updated_at, thumbnail
FROM videos
WHERE status != 'missing'
WHERE status = 'active'
"""
params = []

@ -70,4 +70,11 @@ def db_get_favorites(page: int, per_page: int):
total = cur.fetchone()[0]
cur.close(); conn.close()
return rows, total
return rows, total
def db_get_favorite_users():
conn, cur = get_local_db_connection()
cur.execute("SELECT username FROM favorite_users")
favorite_users = [r['username'] for r in cur.fetchall()]
cur.close(); conn.close()
return favorite_users

@ -7,6 +7,10 @@ from config import get_local_db_connection
api = Blueprint("api", __name__)
import requests
SESSION = requests.Session()
SESSION.trust_env = False # ignore system proxies
@api.route('/open-folder', methods=['POST'])
def open_folder():
data = request.json
@ -52,7 +56,7 @@ def delete_file():
os.remove(file_path)
conn, cur = get_local_db_connection()
cur.execute("UPDATE videos SET status = 'missing' WHERE video_id = %s", (video_id,))
cur.execute("UPDATE videos SET status = 'deleted' WHERE video_id = %s", (video_id,))
conn.commit()
cur.close(); conn.close()
@ -81,15 +85,24 @@ def api_add_moment(video_id):
@api.route("/api/get_recording/", methods=["GET"])
def get_online():
url = 'http://localhost:8000/get_streamers'
import requests
r = requests.get(url)
streamers = r.json()['streamers']
parsed_streamers = []
for streamer in streamers:
streamer['is_online'] = streamer['status'] == 'Channel online'
parsed_streamers.append(streamer)
return jsonify(parsed_streamers)
url = "http://127.0.0.1:8000/get_streamers" # force IPv4
r = SESSION.get(url, timeout=1) # small timeout
streamers = r.json().get("streamers", [])
for s in streamers:
s["is_online"] = (s.get("status") == "Channel online")
return jsonify(streamers)
@api.route("/api/favorite_user", methods=["POST"])
def favorite_user():
data = request.get_json(force=True)
username = data.get("username")
fav = bool(data.get("favorite"))
conn, cur = get_local_db_connection()
if fav:
cur.execute("INSERT INTO favorite_users (username) VALUES (%s) ON CONFLICT DO NOTHING", (username,))
else:
cur.execute("DELETE FROM favorite_users WHERE username = %s", (username,))
conn.commit()
cur.close(); conn.close()
return {"ok": True}

@ -1,22 +1,22 @@
from flask import Blueprint, render_template, request, send_file, jsonify
import math, time
import math
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.favorites import mark_favorites, db_get_favorites, db_get_fav_set, db_get_favorite_users
from helpers.cache import build_cache
from config import VIDEOS_PER_PAGE, DASHBOARD_PER_PAGE, get_local_db_connection
from datetime import date, datetime, timedelta
import requests
web = Blueprint("web", __name__)
import requests
def _get_recording_streamers() -> set[str]:
try:
resp = requests.get("http://localhost:5000/api/get_recording/", timeout=3)
resp = requests.get("http://127.0.0.1:5000/api/get_recording/")
resp.raise_for_status()
return resp.json()
except Exception:
return set()
from datetime import date, datetime, timedelta
def _parse_dates(timeframe: str, start_str: str | None, end_str: str | None):
"""Return (start, end) as date objects or (None, None). End inclusive by day."""
today = date.today()
@ -47,13 +47,12 @@ def dashboard():
timeframe = request.args.get("timeframe", "all")
start_str = request.args.get("start")
end_str = request.args.get("end")
show_online_first = request.args.get("online") == "1"
start, end = _parse_dates(timeframe, start_str, end_str)
# ---- build cache over timeframe ----
cache = build_cache(start=start, end=end)
items = list(cache["storage_usage"].items()) # [("user::platform", {...}), ...]
items = list(cache["storage_usage"].items())
# ---- search ----
if query:
@ -65,6 +64,10 @@ def dashboard():
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]]
def k_last(x):
v = x[1].get("last_online")
# put None at the end when descending
return v if v is not None else (float("-inf") if not reverse else float("inf"))
key_map = {
"user": k_user,
@ -72,28 +75,14 @@ def dashboard():
"total_size": k_total,
"video_count": k_count,
"avg_size": k_avg,
"last_online": k_last, # <— new
}
base_key = key_map.get(sort, k_total)
# ---- get recording list → two sets: online + recording_offline ----
# _get_recording_streamers() returns: [{"username": "...", "is_online": true/false}, ...]
online_usernames: set[str] = set()
recording_offline_usernames: set[str] = set()
if show_online_first:
try:
rec_list = _get_recording_streamers()
for s in rec_list or []:
u = (s.get("username") or "").lower()
if not u:
continue
if s.get("is_online"):
online_usernames.add(u)
else:
recording_offline_usernames.add(u)
except Exception:
pass
def user_of(item) -> str:
return item[0].split("::")[0]
def is_online(item) -> bool:
@ -102,19 +91,28 @@ def dashboard():
u = user_of(item).lower()
return (u in recording_offline_usernames) and (u not in online_usernames)
# ---- sort with optional grouping ----
if show_online_first:
online_items = [x for x in items if is_online(x)]
recording_offline_items= [x for x in items if is_recording_offline(x)]
the_rest = [x for x in items if (x not in online_items) and (x not in recording_offline_items)]
try:
rec_list = _get_recording_streamers()
for s in rec_list or []:
u = (s.get("username") or "").lower()
if not u:
continue
if s.get("is_online"):
online_usernames.add(u)
else:
recording_offline_usernames.add(u)
except Exception:
pass
online_items.sort(key=base_key, reverse=reverse)
recording_offline_items.sort(key=base_key, reverse=reverse)
the_rest.sort(key=base_key, reverse=reverse)
# ---- sort with optional grouping ----
online_items = [x for x in items if is_online(x)]
recording_offline_items = [x for x in items if is_recording_offline(x)]
the_rest = [x for x in items if (x not in online_items) and (x not in recording_offline_items)]
items = online_items + recording_offline_items + the_rest
else:
items.sort(key=base_key, reverse=reverse)
online_items.sort(key=base_key, reverse=reverse)
recording_offline_items.sort(key=base_key, reverse=reverse)
the_rest.sort(key=base_key, reverse=reverse)
items = online_items + recording_offline_items + the_rest
# ---- paginate ----
page = max(1, int(request.args.get("page", 1)))
@ -122,6 +120,8 @@ def dashboard():
start_idx = (page - 1) * DASHBOARD_PER_PAGE
paginated = items[start_idx:start_idx + DASHBOARD_PER_PAGE]
favorite_users = db_get_favorite_users()
return render_template(
"main.html",
storage_usage=paginated,
@ -134,21 +134,11 @@ def dashboard():
timeframe=timeframe,
start_date=start_str,
end_date=end_str,
online="1" if show_online_first else "0",
# send both sets so we can color dots
online_set=online_usernames,
recording_offline_set=recording_offline_usernames,
favorite_users=favorite_users,
)
@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)
@ -211,7 +201,6 @@ def favorites_page():
@web.route("/users")
def users():
import math
# ---- filters ----
q = (request.args.get("q") or "").lower().strip()
sort = request.args.get("sort", "total_size") # user|site|total_size|video_count
@ -283,7 +272,6 @@ def users():
"""
params.update({"limit": per_page, "offset": offset})
from config import get_local_db_connection
conn, cur = get_local_db_connection()
cur = conn.cursor()

@ -1,50 +0,0 @@
import os, shutil, config
from tqdm import tqdm
if __name__ == "__main__":
output_dir = 'U:/streamaster/streams/'
conn, cursor = config.get_local_db_connection()
cursor.execute("SELECT * FROM videos WHERE status != 'missing' AND filepath NOT LIKE %s ORDER BY size ASC;", ("%" + output_dir + "%",))
videos = cursor.fetchall()
# process the videos
output_dir = "U:/streamaster/streams/"
os.makedirs(output_dir, exist_ok=True)
total_size = int(sum([video['size'] for video in videos]))
total_moved = 0
with tqdm(total=total_size, desc=f"Moved [{total_moved}/{len(videos)}] videos", unit="MB") as pbar:
for video in videos:
file_size_mb = int(video["size"]) if video["size"] >= 1 else 1
pbar.update(file_size_mb)
username = video["username"]
video_path = video["filepath"]
if not video_path:
continue
user_folder = os.path.join(output_dir, username)
video_name = os.path.basename(video_path)
new_video_path = os.path.join(user_folder, video_name)
if os.path.exists(new_video_path):
cursor.execute("UPDATE videos SET filepath = %s WHERE id = %s;", (new_video_path, video["id"],))
conn.commit()
continue
if not os.path.exists(video_path):
continue
os.makedirs(user_folder, exist_ok=True)
# move the file to the new location
shutil.move(video_path, new_video_path)
cursor.execute("UPDATE videos SET filepath = %s WHERE id = %s;", (new_video_path, video["id"],))
conn.commit()
total_moved += 1
pbar.desc = f"Moved [{total_moved}/{len(videos)}] videos"

@ -0,0 +1,196 @@
/* =========================
Theme / Base
========================= */
:root{
--bg:#111; --card:#1a1a1a; --muted:#bbb; --text:#eee; --line:#333;
--chip:#181818; --link:#4af; --link-hover:#7bb7ff;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
font-family:Arial,Helvetica,sans-serif;
background:var(--bg);
color:var(--text);
}
a{color:var(--link); text-decoration:none}
a:hover{color:var(--link-hover)}
/* =========================
Top Nav & Footer
========================= */
.topbar{
position:sticky; top:0; z-index:100;
background:#0d0d0d; border-bottom:1px solid var(--line);
}
.nav{
max-width:1200px; margin:0 auto;
display:flex; align-items:center; justify-content:space-between;
padding:12px 16px;
}
.nav .brand{color:#fff; font-weight:600}
.nav .links{display:flex; gap:14px; flex-wrap:wrap}
.nav .links a{color:var(--link)}
.nav .links a:hover{color:var(--link-hover)}
.footer{
margin-top:32px; border-top:1px solid var(--line);
padding:16px; text-align:center; color:var(--muted)
}
/* =========================
Layout Helpers
========================= */
.container{max-width:1200px; margin:24px auto; padding:0 16px}
/* =========================
Controls / Forms
========================= */
.controls{
margin:10px 0; display:flex; gap:10px;
flex-wrap:wrap; justify-content:center
}
input[type="text"], input[type="date"], select{
padding:8px; background:var(--chip); color:var(--text);
border:1px solid var(--line); border-radius:4px;
}
button{
padding:8px 12px; background:var(--chip); color:var(--text);
border:1px solid var(--line); border-radius:4px; cursor:pointer
}
button:hover{background:#202020}
/* =========================
Table (Dashboard)
========================= */
.table-wrap{overflow:auto}
table{
margin:auto; border-collapse:collapse; width:100%;
background:var(--card)
}
th,td{border:1px solid var(--line); padding:10px; text-align:left}
th{
background:#222; position:sticky;
z-index:10
}
tr:nth-child(even){background:#181818}
/* =========================
Pagination (shared macro)
========================= */
.pagination{margin:16px 0; text-align:center}
.pagination a,.pagination span{
display:inline-block; background:var(--chip); color:var(--text);
border:1px solid var(--line); margin:0 5px; padding:6px 12px; text-decoration:none;
border-radius:4px;
}
.pagination .active{background:#333}
/* =========================
Status dots
========================= */
.status-dot{
display:inline-block; width:10px; height:10px;
border-radius:50%; margin-left:6px; vertical-align:middle
}
.dot-online{background:#22c55e}
.dot-record{background:#ef4444}
.dot-offline{background:#666}
/* =========================
Grid container (shared)
========================= */
.grid{
width:100%;
display:grid;
grid-template-columns:repeat(auto-fill,minmax(220px,1fr));
gap:14px;
}
/* =========================
Card base (shared)
========================= */
.card{
background:var(--card); border:1px solid var(--line);
border-radius:8px; padding:10px; min-height:120px;
}
/* =========================
Users grid (users.html)
Two-column card with tall thumbnail
========================= */
.user-card{
display:grid;
grid-template-columns:180px 1fr;
gap:12px; align-items:stretch;
}
/* =========================
Video grids (favorites, user_page)
Stacked card (thumb on top, meta below)
========================= */
.video-card{
display:flex;
flex-direction:column;
gap:10px;
}
/* Thumbnails (shared) */
.thumb{
position:relative;
height:100%; min-height:120px;
background:#222; border:1px solid var(--line);
border-radius:6px; overflow:hidden;
}
.thumb img,.thumb .fallback{
position:absolute; inset:0;
width:100%; height:100%; object-fit:cover; display:block;
}
.thumb .fallback{
display:none; align-items:center; justify-content:center; font-size:28px;
}
/* Taller thumbs on video cards */
.grid.videos .thumb{ min-height:140px; }
/* Meta panel */
.meta{
display:flex; flex-direction:column; gap:6px; min-width:0;
}
.meta h3{margin:0; font-size:16px; display:flex; align-items:center; gap:6px}
.row{font-size:14px}
.muted{color:var(--muted)}
/* Favorite star overlay (works for both grids) */
.fav-btn{
position:absolute; top:8px; right:8px; z-index:2;
font-size:18px; line-height:1;
border:none; border-radius:6px; padding:.25rem .45rem; cursor:pointer;
background:rgba(0,0,0,.55); color:#fff; backdrop-filter:blur(2px)
}
.fav-btn[aria-pressed="true"]{color:gold}
.fav-btn:hover{transform:scale(1.05)}
.user-fav {
position: static; /* no absolute positioning */
background: none;
padding: 0;
margin-right: 6px; /* little gap before username */
font-size: 16px;
backdrop-filter: none;
}
/* =========================
Responsive tweaks
========================= */
@media (max-width: 900px){
.user-card{grid-template-columns:150px 1fr}
th{top:56px}
}
@media (max-width: 640px){
.user-card{grid-template-columns:1fr}
.thumb{min-height:160px}
th{top:60px}
}

@ -1,271 +0,0 @@
import os, shutil, config
import ffmpeg
from tqdm import tqdm
def is_av1(filepath):
try:
probe = ffmpeg.probe(filepath)
for stream in probe['streams']:
if stream['codec_type'] == 'video' and 'codec_name' in stream:
if stream['codec_name'] == 'av1':
return True
except ffmpeg.Error as e:
print(f"Error probing {filepath}: {e}")
return False
def get_video_info(filepath):
try:
probe = ffmpeg.probe(filepath)
format_info = probe['format']
video_stream = next(
(stream for stream in probe['streams'] if stream['codec_type'] == 'video'),
None
)
if video_stream:
# Convert from bits/sec to kbps
bitrate_kbps = int(format_info['bit_rate']) // 1000
width = video_stream['width']
height = video_stream['height']
return bitrate_kbps, (width, height)
except ffmpeg.Error as e:
print(f"Error getting video info for {filepath}: {e}")
return None, (None, None)
def get_target_bitrate(width, height):
resolutions = {
(854, 480): 1000,
(1280, 720): 1500,
(1920, 1080): 3000,
(2560, 1440): 5000,
(3840, 2160): 12000
}
for res, bitrate in resolutions.items():
if width <= res[0] and height <= res[1]:
return bitrate
return 2500
def get_fps(filepath):
try:
probe = ffmpeg.probe(filepath)
video_stream = next((stream for stream in probe['streams'] if stream['codec_type'] == 'video'), None)
if video_stream and 'r_frame_rate' in video_stream:
fps_str = video_stream['r_frame_rate']
num, den = map(int, fps_str.split('/'))
fps = num / den
return fps
except ffmpeg.Error as e:
print(f"Error getting FPS for {filepath}: {e}")
return None
def encode_video(filepath, output_path, target_bitrate):
try:
fps = get_fps(filepath)
if fps is None:
print(f"Could not determine FPS for {filepath}.")
return False
keyframe_interval = int(fps) # Set the keyframe interval to match 1 second
# Calculate 1.5x for max bitrate
max_bitrate = int(1.5 * target_bitrate)
print(f" Encoding {filepath} to AV1 at {target_bitrate} kbps...")
(
ffmpeg
.input(filepath)
.output(
output_path,
vcodec='av1_nvenc',
format='mp4',
b=f"{target_bitrate}k",
maxrate=f"{max_bitrate}k",
bufsize=f"{max_bitrate}k",
preset='p5',
g=keyframe_interval
)
.run(
overwrite_output=True, quiet=True
)
)
print(f" Finished encoding {os.path.basename(filepath)} to AV1 at {target_bitrate} kbps "
f"(maxrate={max_bitrate} kbps).")
return True
except ffmpeg.Error as e:
print(f" Error encoding {os.path.basename(filepath)} to AV1: {e}")
def check_and_replace_if_smaller(original_path, temp_output_path):
if not os.path.exists(temp_output_path):
print(f"[ERROR] Temp file {temp_output_path} not found. Skipping replacement...")
return
original_size = os.path.getsize(original_path)
processed_size = os.path.getsize(temp_output_path)
size_original_mb = original_size / (1024 * 1024)
size_processed_mb = processed_size / (1024 * 1024)
size_diff_perc = (1 - processed_size / original_size) * 100
size_diff_mb = size_original_mb - size_processed_mb
if processed_size >= original_size or size_diff_mb < 1:
os.remove(temp_output_path)
return False
else:
print(100*"=")
print(f" Re-encoded is smaller by {size_diff_perc:.2f}% ({size_diff_mb:.2f} MB). Replacing original.")
print(f" Original: {size_original_mb:.2f} MB \n Re-encoded: {size_processed_mb:.2f} MB.")
print(100*"=")
shutil.move(temp_output_path, original_path)
return True
def update_codec_db(video_id, codec):
conn, cursor = config.get_local_db_connection()
cursor.execute("UPDATE videos SET codec = %s WHERE id = %s", (codec, video_id))
conn.commit()
conn.close()
def smart_choice(cursor, small_mb=250):
"""
Returns a list of candidate videos to encode, ordered by:
1) time window priority: 7d, then 30d, then 90d, then fallback (any time)
2) streamer priority: total MB per (username, site) DESC within the window
3) small (< small_mb MB) first, then big
4) inside each group: size DESC, then created_at DESC
NOTE: 'size' is stored in MB.
"""
def pick(days: int):
# Build the prioritized list for a given window
cursor.execute("""
WITH candidates AS (
SELECT v.*
FROM videos v
WHERE v.codec IS NULL
AND v.status <> 'missing'
AND v.filepath IS NOT NULL
AND v.created_at >= NOW() - make_interval(days => %s)
),
by_streamer AS (
SELECT username, site, SUM(size) AS total_mb
FROM candidates
GROUP BY username, site
),
ordered AS (
SELECT c.*,
bs.total_mb,
CASE WHEN c.size < %s THEN 0 ELSE 1 END AS small_first
FROM candidates c
JOIN by_streamer bs
ON bs.username = c.username
AND bs.site = c.site
)
SELECT *
FROM ordered
ORDER BY
total_mb DESC, -- top streamers first
small_first ASC, -- small (< small_mb) first
size DESC, -- then bigger files first inside each group
created_at DESC; -- then newest
""", (days, small_mb))
return cursor.fetchall()
# Try 7d → 30d → 90d
for d in (7, 30, 90):
rows = pick(d)
if rows:
return rows
# Fallback: any time, same ordering logic
cursor.execute("""
WITH candidates AS (
SELECT v.*
FROM videos v
WHERE v.codec IS NULL
AND v.status <> 'missing'
AND v.filepath IS NOT NULL
),
by_streamer AS (
SELECT username, site, SUM(size) AS total_mb
FROM candidates
GROUP BY username, site
),
ordered AS (
SELECT c.*,
bs.total_mb,
CASE WHEN c.size < %s THEN 0 ELSE 1 END AS small_first
FROM candidates c
JOIN by_streamer bs
ON bs.username = c.username
AND bs.site = c.site
)
SELECT *
FROM ordered
ORDER BY
total_mb DESC,
small_first ASC,
size DESC,
created_at DESC;
""", (small_mb,))
return cursor.fetchall()
def reencode_videos_av1():
# get videos
conn, cursor = config.get_local_db_connection()
# cursor.execute("SELECT * FROM videos WHERE codec IS NULL AND status != 'missing' AND filepath IS NOT NULL AND filepath NOT LIKE 'U:%' ORDER BY size ASC;")
# videos = cursor.fetchall()
while True:
videos = smart_choice(cursor)
with tqdm(videos, desc="Processing videos", unit="file") as pbar:
for video in videos:
pbar.update(1)
input_path = video['filepath']
if not os.path.exists(input_path):
print(f"🚫 File not found: {input_path}")
continue
file_size_in_mb = os.path.getsize(input_path) / (1024 * 1024)
print(f"\nProcessing {os.path.basename(input_path)} ({file_size_in_mb:.2f} MB)...")
if file_size_in_mb < 1:
print("Video is too small. Skipping.")
os.remove(input_path)
continue
# 2) Get current bitrate & resolution
current_bitrate, (width, height) = get_video_info(input_path)
if not current_bitrate:
print("Video's bitrate is not available. Skipping")
continue
target_bitrate = get_target_bitrate(width, height)
# If current bitrate <= target, it's not worth it to re-encode
if current_bitrate <= target_bitrate:
target_bitrate = current_bitrate
if is_av1(input_path):
print("Video is already encoded in AV1. Skipping")
update_codec_db(video['id'], 'av1')
continue
# 3) Re-encode
output_path = os.path.join('.temp', os.path.basename(input_path))
os.makedirs(os.path.dirname(output_path), exist_ok=True)
encoded = encode_video(input_path, output_path, target_bitrate)
if not encoded:
print("Encoding failed. Skipping.")
continue
# 4) Compare file sizes and replace if smaller
if check_and_replace_if_smaller(input_path, output_path):
update_codec_db(video['id'], 'av1')
if __name__ == "__main__":
reencode_videos_av1()

@ -0,0 +1,33 @@
{% macro pager(endpoint, page, total_pages, q=None, sort=None, dir=None, timeframe=None, start=None, end=None, username=None) -%}
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}
{% if username %}
<a href="{{ url_for(endpoint, username=username, page=page-1, q=q, sort=sort, dir=dir, timeframe=timeframe, start=start, end=end) }}">« Prev</a>
{% else %}
<a href="{{ url_for(endpoint, page=page-1, q=q, sort=sort, dir=dir, timeframe=timeframe, start=start, end=end) }}">« Prev</a>
{% endif %}
{% else %}<span>« Prev</span>{% endif %}
{% for p in range(1, total_pages + 1) %}
{% if p == page %}
<span class="active">{{ p }}</span>
{% else %}
{% if username %}
<a href="{{ url_for(endpoint, username=username, page=p, q=q, sort=sort, dir=dir, timeframe=timeframe, start=start, end=end) }}">{{ p }}</a>
{% else %}
<a href="{{ url_for(endpoint, page=p, q=q, sort=sort, dir=dir, timeframe=timeframe, start=start, end=end) }}">{{ p }}</a>
{% endif %}
{% endif %}
{% endfor %}
{% if page < total_pages %}
{% if username %}
<a href="{{ url_for(endpoint, username=username, page=page+1, q=q, sort=sort, dir=dir, timeframe=timeframe, start=start, end=end) }}">Next »</a>
{% else %}
<a href="{{ url_for(endpoint, page=page+1, q=q, sort=sort, dir=dir, timeframe=timeframe, start=start, end=end) }}">Next »</a>
{% endif %}
{% else %}<span>Next »</span>{% endif %}
</div>
{% endif %}
{%- endmacro %}

@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{% block title %}Streamaster Finder{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<header class="topbar">
<nav class="nav">
<a class="brand" href="{{ url_for('web.dashboard') }}">📺 Streamaster Finder</a>
<div class="links">
<a href="{{ url_for('web.dashboard') }}">Dashboard</a>
<a href="{{ url_for('web.users') }}">Users</a>
<a href="{{ url_for('web.recent') }}">Recent</a>
<a href="{{ url_for('web.favorites_page') }}">Favorites</a>
</div>
</nav>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
<footer class="footer">
<div>© <span id="yr"></span> Streamaster</div>
<script>document.getElementById('yr').textContent = new Date().getFullYear();</script>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

@ -1,173 +1,72 @@
<!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 %}
{% extends "base.html" %}
{% from "_pagination.html" import pager %}
{% block title %}★ Favorites{% endblock %}
{% block content %}
<h1>★ Favorites</h1>
<div class="grid videos">
{% for video in videos %}
<div class="card video-card" data-video-id="{{ video.video_id }}">
<div class="thumb thumb-wrap">
{% set thumb = video.thumbnail %}
<a class="thumb-link" href="{{ url_for('web.view_video', video_id=video.video_id) }}">
<img
src="{{ thumb if thumb and thumb.startswith('http') else ('/' ~ (thumb or '')) }}"
alt="Thumbnail"
loading="lazy"
decoding="async"
onerror="this.style.display='none'; this.parentElement.parentElement.querySelector('.fallback').style.display='flex';"
>
</a>
<span class="fallback">🎞️</span>
<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>
<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 class="meta">
<div class="row muted">{{ "%.2f"|format((video.size or 0)/1024) }} GB</div>
</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>
</div>
{% endfor %}
</div>
{{ pager('web.favorites_page', page, total_pages) }}
{% endblock %}
{% block scripts %}
<script>
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';
// Remove immediately if unfavorited (since this is the Favorites page)
if (!isFav) card.remove();
} catch (err) {
alert('Could not toggle favorite. ' + (err?.message || ''));
}
});
</script>
{% endblock %}

@ -1,291 +1,149 @@
<!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>
<style>
.status-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-left: 6px;
vertical-align: middle;
}
.dot-online {
background: #22c55e;
}
/* green */
.dot-record {
background: #ef4444;
}
/* red */
.dot-offline {
background: #666;
}
/* grey */
</style>
{% extends "base.html" %}
{% from "_pagination.html" import pager %}
{% block title %}📊 Video Storage Analytics{% endblock %}
{% block content %}
<h1>📊 Video Storage Analytics</h1>
<div class="controls">
<form method="get" action="{{ url_for('web.dashboard') }}" style="display:inline-flex;gap:8px;align-items:center;">
<label>Timeframe:</label>
<select name="timeframe" onchange="this.form.submit()">
<option value="all" {{ 'selected' if timeframe=='all' else '' }}>All time</option>
<option value="week" {{ 'selected' if timeframe=='week' else '' }}>This week</option>
<option value="month" {{ 'selected' if timeframe=='month' else '' }}>Last 30 days</option>
<option value="year" {{ 'selected' if timeframe=='year' else '' }}>Last 365 days</option>
<option value="custom"{{ 'selected' if timeframe=='custom' else '' }}>Custom…</option>
</select>
<input type="date" name="start" value="{{ start_date or '' }}" placeholder="Start" />
<input type="date" name="end" value="{{ end_date or '' }}" placeholder="End" />
<input type="hidden" name="q" value="{{ query or '' }}">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="dir" value="{{ dir }}">
<button type="submit">Apply</button>
</form>
<form method="get" action="{{ url_for('web.dashboard') }}" style="display:inline-block;">
<input type="text" name="q" placeholder="Search users..." value="{{ query or '' }}">
<button type="submit">Search</button>
</form>
</div>
<div class="table-wrap">
<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' %}
{% set next_last_dir = 'asc' if sort != 'last_online' or dir == 'desc' else 'desc' %}
<th><a href="{{ url_for('web.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('web.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('web.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('web.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('web.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>
<th><a href="{{ url_for('web.dashboard', q=query, page=1, sort='last_online', dir=next_last_dir) }}">Last Online{% if sort=='last_online' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a></th>
</tr>
</thead>
<tbody>
{% for key, stats in storage_usage %}
{% set user, platform = key.split("::") %}
<tr data-username="{{ user|lower }}">
<td>
<!-- ⭐ favorite star on the left -->
<button class="fav-btn user-fav"
data-username="{{ user }}"
aria-pressed="{{ 'true' if user in favorite_users else 'false' }}">
</button>
<a href="/user/{{ user }}">{{ user }}</a>
{% set uname = user.lower() %}
{% if uname in online_set %}
<span class="status-dot dot-online" title="Online"></span>
{% elif uname in recording_offline_set %}
<span class="status-dot dot-record" title="Recording (offline)"></span>
{% else %}
<span class="status-dot dot-offline" title="Offline"></span>
{% endif %}
</td>
<td><a href="https://{{ platform }}.com/{{ user }}">{{ platform }}</a></td>
</head>
<td>{{ "%.2f"|format(stats.total_size) }}</td>
<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;
<td>{{ stats.video_count }}</td>
// 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');
<td>{{ "%.2f"|format(avg_sizes[key]) }}</td>
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();
});
});
<td>
{% if stats.last_online %}
{{ stats.last_online.strftime('%Y-%m-%d') }}
{% else %}
{% endif %}
</td>
// Search
searchInput.addEventListener('keyup', () => {
currentPage = 1;
updateTable();
</tr>
{% endfor %}
</tbody>
</table>
</div>
{{ pager('web.dashboard', page, total_pages, q=query, sort=sort, dir=dir, timeframe=timeframe, start=start_str, end=end_str) }}
{% endblock %}
{% block scripts %}
<script>
async function refreshStatusDots() {
try {
const resp = await fetch("/api/get_recording/");
if (!resp.ok) return;
const streamers = await resp.json();
const online = new Set(streamers.filter(s => s.is_online).map(s => (s.username||"").toLowerCase()));
const recOff = new Set(streamers.filter(s => !s.is_online).map(s => (s.username||"").toLowerCase()));
document.querySelectorAll('#analytics-table tbody tr').forEach(tr => {
const uname = (tr.dataset.username || "").toLowerCase();
const dot = tr.querySelector('.status-dot'); if (!dot) return;
dot.classList.remove('dot-online','dot-record','dot-offline');
if (online.has(uname)) { dot.classList.add('dot-online'); dot.title='Online'; }
else if (recOff.has(uname)) { dot.classList.add('dot-record'); dot.title='Recording (offline)'; }
else { dot.classList.add('dot-offline'); dot.title='Offline'; }
});
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));
} catch(e) { console.error('status refresh failed:', e); }
}
refreshStatusDots(); setInterval(refreshStatusDots, 20000);
</script>
<script>
document.addEventListener("click", async (e) => {
if (e.target.classList.contains("fav-btn")) {
const btn = e.target;
const username = btn.dataset.username;
const videoId = btn.dataset.videoId;
const isFav = btn.getAttribute("aria-pressed") === "true";
const newState = !isFav;
btn.setAttribute("aria-pressed", newState);
try {
if (username) {
await fetch("/api/favorite_user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, favorite: newState })
});
} else if (videoId) {
await fetch(`/api/fav/toggle/${videoId}`, { method: "POST" });
}
} catch (err) {
console.error("Favorite toggle failed:", err);
}
}
});
</script>
updateTable(); // Initial render
</script>
<h1>📊 Video Storage Analytics</h1>
<div class="controls">
<form method="get" action="{{ url_for('web.dashboard') }}"
style="display:inline-flex; gap:8px; align-items:center;">
<input type="hidden" name="q" value="{{ query or '' }}">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="dir" value="{{ dir }}">
<input type="hidden" name="timeframe" value="{{ timeframe }}">
<input type="hidden" name="start" value="{{ start_date or '' }}">
<input type="hidden" name="end" value="{{ end_date or '' }}">
<input type="hidden" name="online" value="{{ '0' if online=='1' else '1' }}">
<button type="submit">
{{ '📶 Show online (ON)' if online=='1' else '📶 Show online (OFF)' }}
</button>
</form>
<form method="get" action="{{ url_for('web.dashboard') }}"
style="display:inline-flex; gap:8px; align-items:center; margin-left:10px;">
<label>Timeframe:</label>
<select name="timeframe" onchange="this.form.submit()">
<option value="all" {{ 'selected' if timeframe=='all' else '' }}>All time</option>
<option value="week" {{ 'selected' if timeframe=='week' else '' }}>This week</option>
<option value="month" {{ 'selected' if timeframe=='month' else '' }}>Last 30 days</option>
<option value="year" {{ 'selected' if timeframe=='year' else '' }}>Last 365 days</option>
<option value="custom" {{ 'selected' if timeframe=='custom' else '' }}>Custom…</option>
</select>
<input type="date" name="start" value="{{ start_date or '' }}" placeholder="Start" />
<input type="date" name="end" value="{{ end_date or '' }}" placeholder="End" />
<input type="hidden" name="q" value="{{ query or '' }}">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="dir" value="{{ dir }}">
<button type="submit">Apply</button>
</form>
<button onclick="window.location.href='/refresh'">🔄 Refresh Data</button>
<!-- Server-side search -->
<form method="get" action="{{ url_for('web.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('web.dashboard', q=query, page=1, sort='user', dir=next_user_dir, online=online) }}">
User{% if sort=='user' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}
</a>
</th>
<th>
<a
href="{{ url_for('web.dashboard', q=query, page=1, sort='platform', dir=next_platform_dir, online=online) }}">
Platform{% if sort=='platform' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}
</a>
</th>
<th>
<a
href="{{ url_for('web.dashboard', q=query, page=1, sort='total_size', dir=next_total_dir, online=online) }}">
Total Storage (GB){% if sort=='total_size' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}
</a>
</th>
<th>
<a
href="{{ url_for('web.dashboard', q=query, page=1, sort='video_count', dir=next_count_dir, online=online) }}">
Video Count{% if sort=='video_count' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}
</a>
</th>
<th>
<a href="{{ url_for('web.dashboard', q=query, page=1, sort='avg_size', dir=next_avg_dir, online=online) }}">
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>
{% set uname = user.lower() %}
{% if uname in online_set %}
<span class="status-dot dot-online" title="Online"></span>
{% elif uname in recording_offline_set %}
<span class="status-dot dot-record" title="Recording (offline)"></span>
{% else %}
<span class="status-dot dot-offline" title="Offline"></span>
{% endif %}
</td>
<td>
<a href="https://{{ platform }}.com/{{ user }}" style="color:#4af;">{{ platform }}</a>
</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('web.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('web.dashboard', page=p, q=query) }}">{{ p }}</a>
{% endif %}
{% endfor %}
{% if page < total_pages %} <a href="{{ url_for('web.dashboard', page=page+1, q=query) }}">Next »</a>
{% else %}
<span>Next »</span>
{% endif %}
</div>
{% endif %}
</body>
</html>
{% endblock %}

@ -1,168 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ username }}'s Videos</title>
<style>
body {
font-family: Arial, sans-serif;
background: #111;
color: #eee;
text-align: center;
margin: 0;
padding: 0;
}
h1 {
margin-top: 20px;
}
.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;
/* <-- 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 {
margin-top: 8px;
font-size: 14px;
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;
}
.pagination a {
color: #0af;
margin: 0 8px;
text-decoration: none;
font-size: 16px;
}
</style>
</head>
<body>
<h1>🎥 Videos for {{ username }}</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>
<!-- 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 %}
{% extends "base.html" %}
{% from "_pagination.html" import pager %}
{% block title %}{{ username }} — Videos{% endblock %}
{% block content %}
<h1>📼 {{ username }}</h1>
<div class="grid">
{% for v in videos %}
<div class="card video-card" data-video-id="{{ v.video_id }}">
<div class="thumb thumb-wrap">
{% set thumb = v.thumbnail %}
<a class="thumb-link" href="{{ url_for('web.view_video', video_id=v.video_id) }}">
<img
src="{{ thumb if thumb and thumb.startswith('http') else ('/' ~ (thumb or '')) }}"
alt="Thumbnail"
loading="lazy"
decoding="async"
onerror="this.style.display='none'; this.parentElement.parentElement.querySelector('.fallback').style.display='flex';"
>
</a>
<span class="fallback">🎞️</span>
<button
class="fav-btn"
data-video-id="{{ v.video_id }}"
aria-pressed="{{ 'true' if v.is_favorite else 'false' }}"
title="{{ 'Unfavorite' if v.is_favorite else 'Favorite' }}"
>
{{ '★' if v.is_favorite else '☆' }}
</button>
</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 class="meta">
<div class="row">
<span class="muted">Size:</span>
{{ "%.2f"|format((v.size or 0)/1024) }} GB
</div>
{% if v.site %}
<div class="row">
<span class="muted">Site:</span>
<a href="https://{{ v.site }}.com/{{ username }}" target="_blank" rel="noopener">{{ v.site }}</a>
</div>
{% endif %}
{% if v.created_at %}
<div class="row muted">
{{ v.created_at }}
</div>
{% 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>
</div>
{% endfor %}
</div>
{{ pager('web.user_page', page, total_pages, username=username) }}
{% endblock %}
{% block scripts %}
<script>
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';
} catch (err) {
alert('Could not toggle favorite. ' + (err?.message || ''));
}
});
</script>
{% endblock %}

@ -1,181 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>👥 Users</title>
<style>
body { font-family: Arial, sans-serif; background:#111; color:#eee; }
a { color:#4af; text-decoration:none; }
.header { display:flex; gap:12px; align-items:center; justify-content:center; margin:18px 0; flex-wrap:wrap; }
input[type="text"] { padding:8px; width:280px; }
select, button { padding:8px 12px; background:#222; color:#eee; border:1px solid #444; }
.grid { width:92%; margin:0 auto; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:14px; }
/* Make thumb take full card height */
.card {
background:#1a1a1a; border:1px solid #333; border-radius:8px; padding:10px;
display:grid; grid-template-columns: 180px 1fr; gap:12px; align-items:stretch;
min-height: 120px;
}
.thumb {
position: relative;
width: 100%;
height: 100%;
min-height: 120px;
background:#222; border:1px solid #333; border-radius:6px; overflow:hidden;
}
.thumb img, .thumb .fallback {
position:absolute; inset:0;
width:100%; height:100%;
object-fit:cover; object-position:center center; display:block;
}
.thumb .fallback { display:none; align-items:center; justify-content:center; font-size:28px; }
.meta { display:flex; flex-direction:column; gap:6px; }
.meta h3 { margin:0; font-size:16px; display:flex; align-items:center; gap:6px; }
.muted { color:#bbb; }
.row { font-size:14px; }
.status-dot { display:inline-block; width:10px; height:10px; border-radius:50%; vertical-align:middle; }
.dot-online { background:#22c55e; }
.dot-record { background:#ef4444; }
.dot-offline { background:#666; }
.pagination { margin:16px 0; text-align:center; }
.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; }
.toolbar { display:flex; gap:10px; align-items:center; justify-content:center; flex-wrap:wrap; }
</style>
<script>
// If an <img> fails, hide it and show the next candidate; if none left, show the fallback emoji.
function tryNext(el) {
el.style.display = 'none';
let sib = el.nextElementSibling;
while (sib && sib.tagName !== 'IMG' && !(sib.classList && sib.classList.contains('fallback'))) {
sib = sib.nextElementSibling;
}
if (!sib) return;
if (sib.tagName === 'IMG') {
sib.style.display = 'block';
} else {
sib.style.display = 'flex';
}
}
</script>
</head>
<body>
<h1>👥 Users</h1>
<div class="header">
<!-- Search -->
<form method="get" action="{{ url_for('web.users') }}" class="toolbar">
<input type="text" name="q" placeholder="Search users..." value="{{ query or '' }}">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="dir" value="{{ dir }}">
<input type="hidden" name="timeframe" value="{{ timeframe }}">
<input type="hidden" name="start" value="{{ start_date or '' }}">
<input type="hidden" name="end" value="{{ end_date or '' }}">
<button type="submit">Search</button>
</form>
<!-- Sort -->
{% set next_user_dir = 'asc' if sort != 'user' or dir == 'desc' else 'desc' %}
{% set next_site_dir = 'asc' if sort != 'site' 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' %}
<div class="toolbar">
<a href="{{ url_for('web.users', q=query, page=1, sort='user', dir=next_user_dir, online=online, timeframe=timeframe, start=start_date, end=end_date) }}">Sort: User{% if sort=='user' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a>
<a href="{{ url_for('web.users', q=query, page=1, sort='site', dir=next_site_dir, online=online, timeframe=timeframe, start=start_date, end=end_date) }}">Sort: Site{% if sort=='site' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a>
<a href="{{ url_for('web.users', q=query, page=1, sort='total_size', dir=next_total_dir, online=online, timeframe=timeframe, start=start_date, end=end_date) }}">Sort: Total Size{% if sort=='total_size' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a>
<a href="{{ url_for('web.users', q=query, page=1, sort='video_count', dir=next_count_dir, online=online, timeframe=timeframe, start=start_date, end=end_date) }}">Sort: Videos{% if sort=='video_count' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a>
<!-- Online-first toggle -->
<a href="{{ url_for('web.users', q=query, page=1, sort=sort, dir=dir, online=('0' if online=='1' else '1'), timeframe=timeframe, start=start_date, end=end_date) }}">
{{ '📶 Show online (ON)' if online=='1' else '📶 Show online (OFF)' }}
</a>
</div>
<!-- Timeframe -->
<form method="get" action="{{ url_for('web.users') }}" class="toolbar">
<label>Timeframe:</label>
<select name="timeframe" onchange="this.form.submit()">
<option value="all" {{ 'selected' if timeframe=='all' else '' }}>All time</option>
<option value="week" {{ 'selected' if timeframe=='week' else '' }}>This week</option>
<option value="month" {{ 'selected' if timeframe=='month' else '' }}>Last 30 days</option>
<option value="year" {{ 'selected' if timeframe=='year' else '' }}>Last 365 days</option>
<option value="custom"{{ 'selected' if timeframe=='custom' else '' }}>Custom…</option>
</select>
<input type="date" name="start" value="{{ start_date or '' }}" placeholder="Start" />
<input type="date" name="end" value="{{ end_date or '' }}" placeholder="End" />
<input type="hidden" name="q" value="{{ query or '' }}">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="dir" value="{{ dir }}">
<input type="hidden" name="online" value="{{ online }}">
<button type="submit">Apply</button>
</form>
{% extends "base.html" %}
{% from "_pagination.html" import pager %}
{% block title %}👥 Users{% endblock %}
{% block content %}
<h1>👥 Users</h1>
<div class="controls">
<form method="get" action="{{ url_for('web.users') }}">
<input type="text" name="q" placeholder="Search users..." value="{{ query or '' }}">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="dir" value="{{ dir }}">
<input type="hidden" name="timeframe" value="{{ timeframe }}">
<input type="hidden" name="start" value="{{ start_date or '' }}">
<input type="hidden" name="end" value="{{ end_date or '' }}">
<button type="submit">Search</button>
</form>
<!-- sort links -->
{% set next_user_dir = 'asc' if sort != 'user' or dir == 'desc' else 'desc' %}
{% set next_site_dir = 'asc' if sort != 'site' 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' %}
<div class="controls">
<a href="{{ url_for('web.users', q=query, page=1, sort='user', dir=next_user_dir, timeframe=timeframe, start=start_date, end=end_date) }}">Sort: User{% if sort=='user' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a>
<a href="{{ url_for('web.users', q=query, page=1, sort='site', dir=next_site_dir, timeframe=timeframe, start=start_date, end=end_date) }}">Sort: Site{% if sort=='site' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a>
<a href="{{ url_for('web.users', q=query, page=1, sort='total_size', dir=next_total_dir, timeframe=timeframe, start=start_date, end=end_date) }}">Sort: Total Size{% if sort=='total_size' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a>
<a href="{{ url_for('web.users', q=query, page=1, sort='video_count', dir=next_count_dir, timeframe=timeframe, start=start_date, end=end_date) }}">Sort: Videos{% if sort=='video_count' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a>
</div>
<div class="grid">
{% for c in cards %}
<div class="card">
<div class="thumb">
{% if c.thumb_urls and c.thumb_urls|length %}
{# render all candidates; show first, hide the rest; each tries the next on error #}
{% for url in c.thumb_urls %}
<img src="{{ url }}" loading="lazy" decoding="async" alt="{{ c.user }}" {% if not loop.first %}style="display:none"{% endif %} onerror="tryNext(this)">
{% endfor %}
<span class="fallback">🎞️</span>
<form method="get" action="{{ url_for('web.users') }}">
<label>Timeframe:</label>
<select name="timeframe" onchange="this.form.submit()">
<option value="all" {{ 'selected' if timeframe=='all' else '' }}>All time</option>
<option value="week" {{ 'selected' if timeframe=='week' else '' }}>This week</option>
<option value="month" {{ 'selected' if timeframe=='month' else '' }}>Last 30 days</option>
<option value="year" {{ 'selected' if timeframe=='year' else '' }}>Last 365 days</option>
<option value="custom"{{ 'selected' if timeframe=='custom' else '' }}>Custom…</option>
</select>
<input type="date" name="start" value="{{ start_date or '' }}" />
<input type="date" name="end" value="{{ end_date or '' }}" />
<input type="hidden" name="q" value="{{ query or '' }}">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="dir" value="{{ dir }}">
<button type="submit">Apply</button>
</form>
</div>
<div class="grid">
{% for c in cards %}
<div class="card user-card" data-username="{{ c.user|lower }}">
<div class="thumb">
{% if c.thumb_urls and c.thumb_urls|length %}
{% for url in c.thumb_urls %}
<img src="{{ url }}" loading="lazy" decoding="async" alt="{{ c.user }}" {% if not loop.first %}style="display:none"{% endif %} onerror="this.style.display='none'; const n=this.nextElementSibling; if(n){ if(n.tagName==='IMG'){ n.style.display='block'; } else { n.style.display='flex'; } }">
{% endfor %}
{% endif %}
<span class="fallback">🎞️</span>
</div>
<div class="meta">
<h3>
<a href="{{ url_for('web.user_page', username=c.user) }}">{{ c.user }}</a>
{% if c.is_online %}
<span class="status-dot dot-online" title="Online"></span>
{% elif c.is_recording_offline %}
<span class="status-dot dot-record" title="Recording (offline)"></span>
{% else %}
{# no thumbnails at all → show fallback by default #}
<span class="fallback" style="display:flex">🎞️</span>
<span class="status-dot dot-offline" title="Offline"></span>
{% endif %}
</div>
<div class="meta">
<h3>
<a href="{{ url_for('web.user_page', username=c.user) }}">{{ c.user }}</a>
{% if c.is_online %}
<span class="status-dot dot-online" title="Online"></span>
{% elif c.is_recording_offline %}
<span class="status-dot dot-record" title="Recording (offline)"></span>
{% else %}
<span class="status-dot dot-offline" title="Offline"></span>
{% endif %}
</h3>
<div class="row">
<span class="muted">Site:</span>
<a href="https://{{ c.site }}.com/{{ c.user }}" target="_blank" rel="noopener">{{ c.site }}</a>
</div>
<div class="row"><span class="muted">Total size:</span> {{ c.total_size_display }} GB</div>
<div class="row"><span class="muted">Videos:</span> {{ c.video_count }}</div>
</div>
</h3>
<div class="row"><span class="muted">Site:</span> <a href="https://{{ c.site }}.com/{{ c.user }}" target="_blank" rel="noopener">{{ c.site }}</a></div>
<div class="row"><span class="muted">Total size:</span> {{ c.total_size_display }} GB</div>
<div class="row"><span class="muted">Videos:</span> {{ c.video_count }}</div>
</div>
{% endfor %}
</div>
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="{{ url_for('web.users', page=page-1, q=query, sort=sort, dir=dir, online=online, timeframe=timeframe, start=start_date, end=end_date) }}">« 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('web.users', page=p, q=query, sort=sort, dir=dir, online=online, timeframe=timeframe, start=start_date, end=end_date) }}">{{ p }}</a>
{% endif %}
{% endfor %}
{% if page < total_pages %}
<a href="{{ url_for('web.users', page=page+1, q=query, sort=sort, dir=dir, online=online, timeframe=timeframe, start=start_date, end=end_date) }}">Next »</a>
{% else %}<span>Next »</span>{% endif %}
</div>
{% endif %}
</body>
</html>
{% endfor %}
</div>
{{ pager('web.users', page, total_pages, q=query, sort=sort, dir=dir, timeframe=timeframe, start=start_date, end=end_date) }}
{% endblock %}
{% block scripts %}
<script>
async function refreshStatus() {
try {
const resp = await fetch("/api/get_recording/"); if (!resp.ok) return;
const s = await resp.json();
const online = new Set(s.filter(x => x.is_online).map(x => (x.username||"").toLowerCase()));
const recOff = new Set(s.filter(x => !x.is_online).map(x => (x.username||"").toLowerCase()));
document.querySelectorAll('.card').forEach(card => {
const u = (card.dataset.username||"").toLowerCase();
const dot = card.querySelector('.status-dot'); if (!dot) return;
dot.classList.remove('dot-online','dot-record','dot-offline');
if (online.has(u)) dot.classList.add('dot-online');
else if (recOff.has(u)) dot.classList.add('dot-record');
else dot.classList.add('dot-offline');
});
} catch(e) {}
}
refreshStatus(); setInterval(refreshStatus, 20000);
</script>
{% endblock %}

Loading…
Cancel
Save