Initial commit: KV-Tube v2.0 complete

This commit is contained in:
Khoa.vo 2025-12-17 07:51:54 +07:00
commit fb65d88e6b
26 changed files with 6120 additions and 0 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
.DS_Store
__pycache__/
*.pyc
venv/
.env
data/
videos/
*.db

68
Dockerfile Normal file
View file

@ -0,0 +1,68 @@
# Build stage
FROM python:3.11-slim as builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
python3-dev \
&& rm -rf /var/lib/apt/lists/*
# Create and activate virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Runtime stage
FROM python:3.11-slim
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
libcairo2 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf-2.0-0 \
libffi-dev \
shared-mime-info \
&& rm -rf /var/lib/apt/lists/*
# Copy static ffmpeg
COPY --from=mwader/static-ffmpeg:6.1 /ffmpeg /usr/local/bin/
COPY --from=mwader/static-ffmpeg:6.1 /ffprobe /usr/local/bin/
# Copy virtual environment from builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
# Copy application code
COPY app.py .
COPY templates/ templates/
COPY static/ static/
# Create directories for data persistence
RUN mkdir -p /app/videos /app/data
# Create directories for data persistence
RUN mkdir -p /app/videos /app/data
# Environment variables
ENV PYTHONUNBUFFERED=1
ENV FLASK_APP=app.py
ENV FLASK_ENV=production
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:5001/ || exit 1
# Expose port
EXPOSE 5001
# Run with Gunicorn for production
CMD ["gunicorn", "--bind", "0.0.0.0:5001", "--workers", "2", "--threads", "4", "--timeout", "120", "app:app"]

103
README.md Normal file
View file

@ -0,0 +1,103 @@
# KV-Tube
A YouTube-like video streaming web application with a pixel-perfect YouTube dark theme UI.
## Recent Updates (v1.1)
- 🚀 **Performance**: Reduced Docker image size by using static `ffmpeg`.
- 📱 **Mobile UI**: Improved 2-column video grid layout and compact sort options on mobile.
- 📦 **NAS Support**: Fixed permission issues by running as root and added multi-arch support (AMD64/ARM64).
## Features
- 🎬 **YouTube Video Playback** - Stream any YouTube video via HLS proxy
- 🎨 **YouTube Dark Theme** - Pixel-perfect recreation of YouTube's UI
- 📱 **Responsive Design** - Works on desktop, tablet, and mobile
- 🔍 **Search** - Search YouTube videos directly
- 📚 **Library** - Save videos and view history
- 🎯 **Categories** - Browse by Music, Gaming, News, Sports, etc.
- 🖥️ **Local Videos** - Play local video files
## Quick Start (Docker)
### Build and Run
```bash
# Build the image
docker build -t kv-tube .
# Run the container
docker run -d -p 5001:5001 --name kv-tube kv-tube
```
### Using Docker Compose
```bash
docker-compose up -d
```
Access the app at: http://localhost:5001
## Synology NAS Deployment
### Option 1: Container Manager (Docker)
1. Open **Container Manager** on your Synology NAS
2. Go to **Project** → **Create**
3. Upload the `docker-compose.yml` file
4. Click **Build** and wait for completion
5. Access via `http://your-nas-ip:5001`
### Option 2: Manual Docker
```bash
# SSH into your NAS
ssh admin@your-nas-ip
# Navigate to your docker folder
cd /volume1/docker
# Clone/copy the project
git clone <repo-url> kv-tube
cd kv-tube
# Build and run
docker-compose up -d
```
## Project Structure
```
kv-tube/
├── app.py # Flask application
├── requirements.txt # Python dependencies
├── Dockerfile # Docker build config
├── docker-compose.yml # Docker Compose config
├── templates/ # HTML templates
│ ├── layout.html # Base layout (header, sidebar)
│ ├── index.html # Home page
│ ├── watch.html # Video player page
│ └── ...
├── static/
│ ├── css/style.css # YouTube-style CSS
│ └── js/main.js # Frontend JavaScript
└── kctube.db # SQLite database
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `FLASK_ENV` | production | Flask environment |
| `PYTHONUNBUFFERED` | 1 | Python output buffering |
## Tech Stack
- **Backend**: Flask + Gunicorn
- **Frontend**: Vanilla JS + Artplayer
- **Video**: yt-dlp + HLS.js
- **Database**: SQLite
- **Container**: Docker
## License
MIT

778
app.py Normal file
View file

@ -0,0 +1,778 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, send_file, Response, stream_with_context, session, flash
import os
import sys
import subprocess
import json
import requests
import sqlite3
from werkzeug.security import generate_password_hash, check_password_hash
import yt_dlp
from functools import wraps
import yt_dlp
from functools import wraps
from youtube_transcript_api import YouTubeTranscriptApi, TranscriptsDisabled
import re
import heapq
# nltk removed to avoid SSL/download issues. Using regex instead.
app = Flask(__name__)
app.secret_key = 'super_secret_key_change_this' # Required for sessions
# Ensure data directory exists for persistence
DATA_DIR = "data"
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
DB_NAME = os.path.join(DATA_DIR, "kvtube.db")
# --- Database Setup ---
def init_db():
conn = sqlite3.connect(DB_NAME)
c = conn.cursor()
# Users Table
c.execute('''CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)''')
# Saved/History Table
# type: 'history' or 'saved'
c.execute('''CREATE TABLE IF NOT EXISTS user_videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
video_id TEXT,
title TEXT,
thumbnail TEXT,
type TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)''')
# Cache Table for video metadata/streams
c.execute('''CREATE TABLE IF NOT EXISTS video_cache (
video_id TEXT PRIMARY KEY,
data TEXT,
expires_at DATETIME
)''')
conn.commit()
conn.close()
# Run init
init_db()
# --- Auth Helpers ---
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
def get_db_connection():
conn = sqlite3.connect(DB_NAME)
conn.row_factory = sqlite3.Row
return conn
# --- Auth Routes ---
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
conn = get_db_connection()
user = conn.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone()
conn.close()
if user and check_password_hash(user['password'], password):
session['user_id'] = user['id']
session['username'] = user['username']
return redirect(url_for('index')) # Changed from 'home' to 'index'
else:
flash('Invalid username or password')
return render_template('login.html')
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
hashed_pw = generate_password_hash(password)
try:
conn = get_db_connection()
conn.execute('INSERT INTO users (username, password) VALUES (?, ?)', (username, hashed_pw))
conn.commit()
conn.close()
flash('Registration successful! Please login.')
return redirect(url_for('login'))
except sqlite3.IntegrityError:
flash('Username already exists')
return render_template('register.html')
@app.route('/logout')
@app.route('/api/update_profile', methods=['POST'])
@login_required
def update_profile():
data = request.json
new_username = data.get('username')
if not new_username:
return jsonify({'success': False, 'message': 'Username is required'}), 400
try:
conn = get_db_connection()
conn.execute('UPDATE users SET username = ? WHERE id = ?',
(new_username, session['user_id']))
conn.commit()
conn.close()
session['username'] = new_username
return jsonify({'success': True, 'message': 'Profile updated'})
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
def logout():
session.clear()
return redirect(url_for('index')) # Changed from 'home' to 'index'
# Configuration for local video path - configurable via env var
VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos')
@app.route('/')
def index():
return render_template('index.html', page='home')
@app.route('/my-videos')
@login_required
def my_videos():
filter_type = request.args.get('type', 'saved') # 'saved' or 'history'
conn = get_db_connection()
videos = conn.execute('''
SELECT * FROM user_videos
WHERE user_id = ? AND type = ?
ORDER BY timestamp DESC
''', (session['user_id'], filter_type)).fetchall()
conn.close()
return render_template('my_videos.html', videos=videos, filter_type=filter_type)
@app.route('/api/save_video', methods=['POST'])
@login_required
def save_video():
data = request.json
video_id = data.get('id')
title = data.get('title')
thumbnail = data.get('thumbnail')
action_type = data.get('type', 'history') # 'history' or 'saved'
conn = get_db_connection()
# Check if already exists to prevent duplicates (optional, strictly for 'saved')
if action_type == 'saved':
exists = conn.execute('SELECT id FROM user_videos WHERE user_id = ? AND video_id = ? AND type = ?',
(session['user_id'], video_id, 'saved')).fetchone()
if exists:
conn.close()
return jsonify({'status': 'already_saved'})
conn.execute('INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (?, ?, ?, ?, ?)',
(session['user_id'], video_id, title, thumbnail, action_type))
conn.commit()
conn.close()
return jsonify({'status': 'success'})
@app.route('/stream/<path:filename>')
def stream_local(filename):
return send_from_directory(VIDEO_DIR, filename)
@app.route('/settings')
def settings():
return render_template('settings.html', page='settings')
@app.route('/video_proxy')
def video_proxy():
url = request.args.get('url')
if not url:
return "No URL provided", 400
# Forward headers to mimic browser and support seeking
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
}
# Support Range requests (scrubbing)
range_header = request.headers.get('Range')
if range_header:
headers['Range'] = range_header
try:
req = requests.get(url, headers=headers, stream=True, timeout=30)
# Handle HLS (M3U8) Rewriting - CRITICAL for 1080p+ and proper sync
content_type = req.headers.get('content-type', '').lower()
# Extract URL path without query params for checking extension
url_path = url.split('?')[0]
is_manifest = (url_path.endswith('.m3u8') or
'application/x-mpegurl' in content_type or
'application/vnd.apple.mpegurl' in content_type)
if is_manifest:
content = req.text
base_url = url.rsplit('/', 1)[0]
new_lines = []
for line in content.splitlines():
if line.strip() and not line.startswith('#'):
# It's a segment or sub-playlist
# If relative, make absolute
if not line.startswith('http'):
full_url = f"{base_url}/{line}"
else:
full_url = line
# Proxy it - use urllib.parse.quote with safe parameter
from urllib.parse import quote
quoted_url = quote(full_url, safe='')
new_lines.append(f"/video_proxy?url={quoted_url}")
else:
new_lines.append(line)
return Response('\n'.join(new_lines), content_type='application/vnd.apple.mpegurl')
# Standard Stream Proxy (Binary)
# We exclude headers that might confuse the browser/flask
excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection']
response_headers = [(name, value) for (name, value) in req.headers.items()
if name.lower() not in excluded_headers]
return Response(stream_with_context(req.iter_content(chunk_size=8192)),
status=req.status_code,
headers=response_headers,
content_type=req.headers.get('content-type'))
except Exception as e:
print(f"Proxy Error: {e}")
return str(e), 500
@app.route('/watch')
def watch():
video_id = request.args.get('v')
local_file = request.args.get('local')
if local_file:
return render_template('watch.html', video_type='local', src=url_for('stream_local', filename=local_file), title=local_file)
if not video_id:
return "No video ID provided", 400
return render_template('watch.html', video_type='youtube', video_id=video_id)
@app.route('/api/get_stream_info')
def get_stream_info():
video_id = request.args.get('v')
if not video_id:
return jsonify({'error': 'No video ID'}), 400
try:
# 1. Check Cache
import time
conn = get_db_connection()
cached = conn.execute('SELECT data, expires_at FROM video_cache WHERE video_id = ?', (video_id,)).fetchone()
current_time = time.time()
if cached:
# Check expiry (stored as unix timestamp or datetime string, we'll use timestamp for simplicity)
try:
expires_at = float(cached['expires_at'])
if current_time < expires_at:
data = json.loads(cached['data'])
conn.close()
# Re-proxy the URL just in case, or use cached if valid.
# Actually proxy url requires encoding, let's reconstruct it to be safe.
from urllib.parse import quote
proxied_url = f"/video_proxy?url={quote(data['original_url'], safe='')}"
data['stream_url'] = proxied_url
# Add cache hit header for debug
response = jsonify(data)
response.headers['X-Cache'] = 'HIT'
return response
except:
pass # Invalid cache, fall through
# 2. Fetch from YouTube (Library Optimization)
url = f"https://www.youtube.com/watch?v={video_id}"
ydl_opts = {
'format': 'best[ext=mp4]/best',
'noplaylist': True,
'quiet': True,
'no_warnings': True,
'skip_download': True,
'force_ipv4': True,
'socket_timeout': 10,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try:
info = ydl.extract_info(url, download=False)
except Exception as e:
print(f"❌ yt-dlp error for {video_id}: {str(e)}")
return jsonify({'error': 'Stream extraction failed'}), 500
stream_url = info.get('url')
if not stream_url:
return jsonify({'error': 'No stream URL found in metadata'}), 500
# Fetch Related Videos (Fallback to search if not provided)
# We use the title + " related" to find relevant content
related_videos = []
try:
search_query = f"{info.get('title', '')} related"
related_videos = fetch_videos(search_query, limit=10)
except:
pass
# Extract Subtitles (English preferred)
subtitle_url = None
start_lang = 'en'
subs = info.get('subtitles') or {}
auto_subs = info.get('automatic_captions') or {}
# Check manual subs first
if 'en' in subs:
subtitle_url = subs['en'][0]['url']
elif 'vi' in subs: # Vietnamese fallback
subtitle_url = subs['vi'][0]['url']
# Check auto subs
elif 'en' in auto_subs:
subtitle_url = auto_subs['en'][0]['url']
# If still none, just pick the first one from manual
if not subtitle_url and subs:
first_key = list(subs.keys())[0]
subtitle_url = subs[first_key][0]['url']
# 3. Construct Response Data
response_data = {
'original_url': stream_url,
'title': info.get('title', 'Unknown Title'),
'description': info.get('description', ''),
'uploader': info.get('uploader', ''),
'upload_date': info.get('upload_date', ''),
'view_count': info.get('view_count', 0),
'related': related_videos,
'subtitle_url': subtitle_url
}
# 4. Cache It (valid for 1 hour = 3600s)
# YouTube URLs expire in ~6 hours usually.
expiry = current_time + 3600
conn.execute('INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)',
(video_id, json.dumps(response_data), expiry))
conn.commit()
conn.close()
# 5. Return Response
from urllib.parse import quote
proxied_url = f"/video_proxy?url={quote(stream_url, safe='')}"
response_data['stream_url'] = proxied_url
response = jsonify(response_data)
response.headers['X-Cache'] = 'MISS'
return response
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/search')
def search():
query = request.args.get('q')
if not query:
return jsonify({'error': 'No query provided'}), 400
try:
# Check if query is a YouTube URL
import re
# Regex to catch youtube.com/watch?v=, youtu.be/, shorts/, etc.
youtube_regex = r'(https?://)?(www\.)?(youtube\.com/watch\?v=|youtu\.be/|youtube\.com/shorts/)([\w-]+)'
match = re.search(youtube_regex, query)
if match:
video_id = match.group(4)
# Fetch direct metadata
meta_cmd = [sys.executable, '-m', 'yt_dlp', '--dump-json', '--no-playlist', f'https://www.youtube.com/watch?v={video_id}']
meta_proc = subprocess.run(meta_cmd, capture_output=True, text=True)
results = []
search_title = ""
if meta_proc.returncode == 0:
data = json.loads(meta_proc.stdout)
search_title = data.get('title', '')
# Format duration
duration_secs = data.get('duration')
if duration_secs:
mins, secs = divmod(int(duration_secs), 60)
hours, mins = divmod(mins, 60)
duration = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
else:
duration = None
# Add the exact match first
results.append({
'id': data.get('id'),
'title': data.get('title', 'Unknown'),
'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
'thumbnail': f"https://i.ytimg.com/vi/{data.get('id')}/hqdefault.jpg",
'view_count': data.get('view_count', 0),
'upload_date': data.get('upload_date', ''),
'duration': duration,
'is_exact_match': True # Flag for frontend highlighting if desired
})
# Now fetch related/similar videos using title
if search_title:
rel_cmd = [
sys.executable, '-m', 'yt_dlp',
f'ytsearch19:{search_title}', # Get 19 more to make ~20 total
'--dump-json',
'--default-search', 'ytsearch',
'--no-playlist',
'--flat-playlist'
]
rel_proc = subprocess.Popen(rel_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
stdout, _ = rel_proc.communicate()
for line in stdout.splitlines():
try:
r_data = json.loads(line)
r_id = r_data.get('id')
# Don't duplicate the exact match
if r_id != video_id:
# Helper to format duration (dup code, could be function)
r_dur = r_data.get('duration')
if r_dur:
m, s = divmod(int(r_dur), 60)
h, m = divmod(m, 60)
dur_str = f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
else:
dur_str = None
results.append({
'id': r_id,
'title': r_data.get('title', 'Unknown'),
'uploader': r_data.get('uploader') or r_data.get('channel') or 'Unknown',
'thumbnail': f"https://i.ytimg.com/vi/{r_id}/hqdefault.jpg",
'view_count': r_data.get('view_count', 0),
'upload_date': r_data.get('upload_date', ''),
'duration': dur_str
})
except:
continue
return jsonify(results)
else:
# Standard Text Search
cmd = [
sys.executable, '-m', 'yt_dlp',
f'ytsearch20:{query}',
'--dump-json',
'--default-search', 'ytsearch',
'--no-playlist',
'--flat-playlist'
]
# Run command
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
stdout, stderr = process.communicate()
results = []
for line in stdout.splitlines():
try:
data = json.loads(line)
video_id = data.get('id')
if video_id:
# Format duration
duration_secs = data.get('duration')
if duration_secs:
mins, secs = divmod(int(duration_secs), 60)
hours, mins = divmod(mins, 60)
duration = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
else:
duration = None
results.append({
'id': video_id,
'title': data.get('title', 'Unknown'),
'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
'view_count': data.get('view_count', 0),
'upload_date': data.get('upload_date', ''),
'duration': duration
})
except:
continue
return jsonify(results)
except Exception as e:
return jsonify({'error': str(e)}), 500
# --- Helper: Extractive Summarization ---
def extractive_summary(text, num_sentences=5):
# 1. Clean and parse text
# Remove metadata like [Music] (common in auto-caps)
clean_text = re.sub(r'\[.*?\]', '', text)
clean_text = clean_text.replace('\n', ' ')
# 2. Split into sentences (simple punctuation split)
sentences = re.split(r'(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?)\s', clean_text)
# 3. Tokenize and Calculate Word Frequencies
word_frequencies = {}
stop_words = set(['the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'to', 'of', 'in', 'on', 'at', 'for', 'width', 'that', 'this', 'it', 'you', 'i', 'we', 'they', 'he', 'she'])
for word in re.findall(r'\w+', clean_text.lower()):
if word not in stop_words:
if word not in word_frequencies:
word_frequencies[word] = 1
else:
word_frequencies[word] += 1
if not word_frequencies:
return "Not enough content to summarize."
# Normalize frequencies
max_freq = max(word_frequencies.values())
for word in word_frequencies:
word_frequencies[word] = word_frequencies[word] / max_freq
# 4. Score Sentences
sentence_scores = {}
for sent in sentences:
for word in re.findall(r'\w+', sent.lower()):
if word in word_frequencies:
if sent not in sentence_scores:
sentence_scores[sent] = word_frequencies[word]
else:
sentence_scores[sent] += word_frequencies[word]
# 5. Extract Top N Sentences
summary_sentences = heapq.nlargest(num_sentences, sentence_scores, key=sentence_scores.get)
return ' '.join(summary_sentences)
@app.route('/api/summarize')
def summarize_video():
video_id = request.args.get('v')
if not video_id:
return jsonify({'error': 'No video ID'}), 400
try:
# Fetch Transcript
transcript_list = YouTubeTranscriptApi.list_transcripts(video_id)
# Try to find english or manually created first, then auto
try:
transcript = transcript_list.find_transcript(['en', 'vi'])
except:
# Fallback to whatever is available (likely auto-generated)
transcript = transcript_list.find_generated_transcript(['en', 'vi'])
transcript_data = transcript.fetch()
# Combine text
full_text = " ".join([entry['text'] for entry in transcript_data])
# Summarize
summary = extractive_summary(full_text, num_sentences=7)
return jsonify({'success': True, 'summary': summary})
except TranscriptsDisabled:
return jsonify({'success': False, 'message': 'Subtitles are disabled for this video.'})
except Exception as e:
return jsonify({'success': False, 'message': f'Could not summarize: {str(e)}'})
@app.route('/api/trending')
def fetch_videos(query, limit=20):
try:
cmd = [
sys.executable, '-m', 'yt_dlp',
f'ytsearch{limit}:{query}',
'--dump-json',
'--default-search', 'ytsearch',
'--no-playlist',
'--flat-playlist'
]
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
stdout, stderr = process.communicate()
results = []
for line in stdout.splitlines():
try:
data = json.loads(line)
video_id = data.get('id')
if video_id:
# Format duration
duration_secs = data.get('duration')
if duration_secs:
mins, secs = divmod(int(duration_secs), 60)
hours, mins = divmod(mins, 60)
duration = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
else:
duration = None
results.append({
'id': video_id,
'title': data.get('title', 'Unknown'),
'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
'view_count': data.get('view_count', 0),
'upload_date': data.get('upload_date', ''),
'duration': duration
})
except:
continue
return results
except Exception as e:
print(f"Error fetching videos: {e}")
return []
@app.route('/api/trending')
def trending():
try:
category = request.args.get('category', 'general')
page = int(request.args.get('page', 1))
sort = request.args.get('sort', 'month')
region = request.args.get('region', 'vietnam')
limit = 20
# Define search queries
if region == 'vietnam':
queries = {
'general': 'trending vietnam',
'tech': 'AI tools software tech review IT việt nam',
'all': 'trending vietnam',
'music': 'nhạc việt trending',
'gaming': 'gaming việt nam',
'movies': 'phim việt nam',
'news': 'tin tức việt nam hôm nay',
'sports': 'thể thao việt nam',
'shorts': 'shorts việt nam',
'trending': 'trending việt nam',
'podcasts': 'podcast việt nam',
'live': 'live stream việt nam'
}
else:
queries = {
'general': 'trending',
'tech': 'AI tools software tech review IT',
'all': 'trending',
'music': 'music trending',
'gaming': 'gaming trending',
'movies': 'movies trending',
'news': 'news today',
'sports': 'sports highlights',
'shorts': 'shorts trending',
'trending': 'trending now',
'podcasts': 'podcast trending',
'live': 'live stream'
}
base_query = queries.get(category, 'trending vietnam' if region == 'vietnam' else 'trending')
# Add sort filter
sort_filters = {
'day': ', today',
'week': ', this week',
'month': ', this month',
'year': ', this year'
}
query = base_query + sort_filters.get(sort, ', this month')
# For pagination, we can't easily offset ytsearch efficiently without fetching all previous
# So we'll fetch a larger chunk and slice it in python, or just accept that page 2 is similar
# A simple hack for "randomness" or pages is to append a random term or year, but let's stick to standard behavior
# Or better: search for "query page X"
if page > 1:
query += f" page {page}"
results = fetch_videos(query, limit=limit)
return jsonify(results)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/update_ytdlp', methods=['POST'])
def update_ytdlp():
try:
# Run pip install -U yt-dlp
cmd = [sys.executable, '-m', 'pip', 'install', '-U', 'yt-dlp']
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
# Check new version
ver_cmd = [sys.executable, '-m', 'yt_dlp', '--version']
ver_result = subprocess.run(ver_cmd, capture_output=True, text=True)
version = ver_result.stdout.strip()
return jsonify({'success': True, 'message': f'Updated successfully to {version}'})
else:
return jsonify({'success': False, 'message': f'Update failed: {result.stderr}'}), 500
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/comments')
def get_comments():
"""Get comments for a YouTube video"""
video_id = request.args.get('v')
if not video_id:
return jsonify({'error': 'No video ID'}), 400
try:
url = f"https://www.youtube.com/watch?v={video_id}"
cmd = [
sys.executable, '-m', 'yt_dlp',
url,
'--write-comments',
'--skip-download',
'--dump-json'
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode == 0:
data = json.loads(result.stdout)
comments_data = data.get('comments', [])
# Format comments for frontend
comments = []
for c in comments_data[:50]: # Limit to 50 comments
comments.append({
'author': c.get('author', 'Unknown'),
'author_thumbnail': c.get('author_thumbnail', ''),
'text': c.get('text', ''),
'likes': c.get('like_count', 0),
'time': c.get('time_text', ''),
'is_pinned': c.get('is_pinned', False)
})
return jsonify({
'comments': comments,
'count': data.get('comment_count', len(comments))
})
else:
return jsonify({'comments': [], 'count': 0, 'error': 'Could not load comments'})
except subprocess.TimeoutExpired:
return jsonify({'comments': [], 'count': 0, 'error': 'Comments loading timed out'})
except Exception as e:
return jsonify({'comments': [], 'count': 0, 'error': str(e)})
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5001)

View file

@ -0,0 +1,46 @@
Product Requirements Document (PRD) - KCTube
1. Product Overview
Product Name: KCTube Version: 1.0 (In Development) Description: KCTube is a comprehensive media center web application designed to provide an ad-free YouTube experience, a curated movie streaming service, and a local video management system. It emphasizes privacy, absence of advertisements, and utility features like AI summarization and language learning tools.
2. User Personas
The Binge Watcher: Wants uninterrupted access to YouTube content and movies without ads.
The Archivist: Maintains a local collection of videos and wants a clean interface to organize and watch them securely.
The Learner: Uses video content for educational purposes, specifically English learning.
The Tech Enthusiast: Appreciates PWA support, torrent integration, and customizable settings.
3. Core Features
3.1. YouTube Viewer (Home)
Ad-Free Experience: Plays YouTube videos without third-party advertisements.
Search: Integrated search bar powered by yt-dlp to find videos, channels, and playlists.
Playback: Custom video player with support for quality selection and playback speed.
AI Summarization: Feature to summarize video content using Google Gemini API (Optional).
3.2. local Video Manager ("My Videos")
Secure Access: Password-protected section for personal video collections.
File Management: Scans local directories for video files.
Metadata: Extracts metadata (duration, format) and generates thumbnails using FFmpeg/MoviePy.
Playback: Native HTML5 player for local files.
3.3. Utilities
Torrent Player: Interface for streaming/playing video content via torrents.
Playlist Manager: Create and manage custom playlists of YouTube videos.
Camera/Photo: ("Chụp ảnh") Feature to capture or manage photos (Webcam integration).
Configuration: Web-based settings to manage application behavior (e.g., password, storage paths).
4. Technical Architecture
Backend: Python / Flask
Frontend: HTML5, CSS3, JavaScript (Vanilla)
Database/Storage: JSON-based local storage and file system.
Video Processing: yt-dlp (YouTube), FFmpeg (Conversion/Thumbnail), MoviePy (Optional).
AI Service: Google Gemini API (for summarization).
Deployment: Docker container support (xehopnet/kctube).
5. Non-Functional Requirements
Performance: Fast load times and responsive UI.
Compatibility: PWA-ready for installation on desktop and mobile.
Reliability: graceful degradation if optional dependencies (MoviePy, Gemini) are missing.
Privacy: No user tracking or external analytics.
6. Known Limitations
Search Reliability: Dependent on yt-dlp stability and YouTube's anti-bot measures.
External APIs: Movie features rely on third-party APIs which may have downtime.
Dependency Management: Some Python libraries (MoviePy, Numpy) require compilation tools.
7. Future Roadmap
Database: Migrate from JSON to SQLite for better performance with large libraries.
User Accounts: Individual user profiles and history.
Offline Mode: Enhanced offline capabilities for PWA.
Casting: Support for Chromecast/AirPlay.

29
docker-compose.yml Normal file
View file

@ -0,0 +1,29 @@
# KV-Tube Docker Compose for Synology NAS
# Usage: docker-compose up -d
version: '3.8'
services:
kv-tube:
# build: .
image: vndangkhoa/kvtube:latest
container_name: kv-tube
restart: unless-stopped
ports:
- "5011:5001"
volumes:
# Persist data (Easy setup: Just maps a folder)
- ./data:/app/data
# Local videos folder (Optional)
# - ./videos:/app/youtube_downloads
environment:
- PYTHONUNBUFFERED=1
- FLASK_ENV=production
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:5001/" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
labels:
- "com.centurylinklabs.watchtower.enable=true"

29
generate_icons.py Normal file
View file

@ -0,0 +1,29 @@
from PIL import Image, ImageDraw, ImageFont
import os
def create_icon(size, output_path):
"""Create a simple icon with the given size"""
img = Image.new('RGB', (size, size), color='#ff0000')
d = ImageDraw.Draw(img)
# Add text to the icon
try:
font_size = size // 3
font = ImageFont.truetype("Arial", font_size)
d.text((size//2, size//2), "KV", fill="white", anchor="mm", font=font, align="center")
except:
# Fallback if font loading fails
d.rectangle([size//4, size//4, 3*size//4, 3*size//4], fill="white")
# Save the icon
os.makedirs(os.path.dirname(output_path), exist_ok=True)
img.save(output_path, 'PNG')
# Generate icons in different sizes
icon_sizes = [192, 512]
for size in icon_sizes:
output_path = f"static/icons/icon-{size}x{size}.png"
create_icon(size, output_path)
print(f"Created icon: {output_path}")
print("Icons generated successfully!")

1073
proxy_check.m3u8 Normal file

File diff suppressed because it is too large Load diff

16
requirements.txt Normal file
View file

@ -0,0 +1,16 @@
flask==3.0.2
requests==2.31.0
pytube==15.0.0
python-dotenv==1.0.1
yt-dlp
moviepy
numpy
google-generativeai==0.8.3
flask-cors==4.0.0
youtube-transcript-api==0.6.2
werkzeug==3.0.1
Pillow
pysrt==1.1.2
cairosvg
gunicorn==21.2.0
gunicorn==21.2.0

1
response.json Normal file

File diff suppressed because one or more lines are too long

1
response_error.json Normal file
View file

@ -0,0 +1 @@
{"description":"\ud83c\udfa7 TOP 10 NH\u1ea0C VI\u1ec6T AI COVER HAY NH\u1ea4T 2025 | Ballad \u2013 Metal Rock \u2013 Cover C\u1ef1c K\u1ef3 C\u1ea3m X\u00fac\nTuy\u1ec3n t\u1eadp 10 ca kh\u00fac Vi\u1ec7t \u0111\u01b0\u1ee3c AI cover v\u1edbi nhi\u1ec1u phong c\u00e1ch: Ballad s\u00e2u l\u1eafng, Metal Rock b\u00f9ng n\u1ed5, v\u00e0 nh\u1eefng b\u1ea3n c\u1ef1c k\u1ef3 c\u1ea3m x\u00fac ch\u1ea1m \u0111\u1ebfn tr\u00e1i tim.\n\ud83c\udfb6 Danh s\u00e1ch b\u00e0i h\u00e1t:\n1.Hai M\u00f9a Noel \u2013 \u0110\u00e0i Ph\u01b0\u01a1ng Trang\n2.Gi\u1eadn H\u1eddn \u2013 Ng\u1ecdc S\u01a1n\n3.N\u1ed7i \u0110au Mu\u1ed9n M\u00e0ng \u2013 Ng\u00f4 Th\u1ee5y Mi\u00ean\n4.Tri\u1ec7u \u0110\u00f3a H\u1ed3ng \u2013 Nh\u1ea1c Ngo\u1ea1i L\u1eddi Vi\u1ec7t\n5.B\u00ean Nhau \u0110\u00eam Nay ( Dancing All Night) Nh\u1ea1c Ngo\u1ea1i L\u1eddi Vi\u1ec7t\n6.Xin C\u00f2n G\u1ecdi T\u00ean Nhau \u2013 Tr\u01b0\u1eddng Sa\n7.Ng\u00e0y Ch\u01b0a Gi\u00f4ng B\u00e3o \u2013 Phan M\u1ea1nh Qu\u1ef3nh\n8.V\u00ec Anh Y\u00eau Em \u2013 \u0110\u1ee9c Tr\u00ed\n9.T\u00ecnh Phai \u2013 B\u1ea3o Ch\u1ea5n\n10.M\u01b0a Chi\u1ec1u - Anh B\u1eb1ng\n\ud83d\udc9b H\u00e3y chia s\u1ebb c\u1ea3m x\u00fac c\u1ee7a b\u1ea1n\nB\u1ea1n th\u00edch phi\u00ean b\u1ea3n Ballad, Metal Rock, hay b\u1ea3n cover c\u1ef1c k\u1ef3 c\u1ea3m x\u00fac nh\u1ea5t?\nH\u00e3y \u0111\u1ec3 l\u1ea1i b\u00ecnh lu\u1eadn b\u00ean d\u01b0\u1edbi!\n\ud83d\udccc Like \u2013 Share \u2013 Subscribe\n\u0110\u1eebng qu\u00ean \u1ee7ng h\u1ed9 k\u00eanh \u0111\u1ec3 kh\u00f4ng b\u1ecf l\u1ee1 nh\u1eefng playlist AI Cover Nh\u1ea1c Vi\u1ec7t m\u1edbi nh\u1ea5t!\n#AICover #NhacVietHayNhat #MetalRockCover #CoverCamXuc #Top10Cover","original_url":"https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1765805811/ei/k7o_afmFHazapt8P1dC92Qg/ip/14.224.158.192/id/344b71adfe77a807/itag/96/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgoap/clen%3D44663091%3Bdur%3D2759.668%3Bgir%3Dyes%3Bitag%3D140%3Blmt%3D1765336826803662/sgovp/clen%3D331946345%3Bdur%3D2759.599%3Bgir%3Dyes%3Bitag%3D137%3Blmt%3D1765341180328971/rqh/1/hls_chunk_host/rr5---sn-i3belney.googlevideo.com/xpc/EgVo2aDSNQ%3D%3D/cps/80/met/1765784211,/mh/o0/mm/31,29/mn/sn-i3belney,sn-i3b7kn6s/ms/au,rdu/mv/m/mvi/5/pl/23/rms/au,au/initcwndbps/803750/bui/AYUSA3AaRTPVQR9AxZ34LEZZbskDQYky8w0H-64K2-Agba81LDhyJvj0g3xcpDInynrnA_DiQwpxZb1h/spc/wH4Qqx7pNgBE3ay_Sjas2uufs1KmfJ4_fwxH1n9h1fFKv_xcj6dMs5fl9awBNyrsJPrk2CdTAeSdnuESLCE2crs6/vprv/1/ns/aheANYTAhlRZjT8Yd9mq1RcR/playlist_type/CLEAN/dover/11/txp/5535534/mt/1765783743/fvip/4/keepalive/yes/fexp/51355912,51552689,51565115,51565682,51580968/n/Q2cHoLCNB42OyhS/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgoap,sgovp,rqh,xpc,bui,spc,vprv,ns,playlist_type/sig/AJfQdSswRQIgNonUW7lMmUnoMzBbnh31XHqEySuhEQwmfh4YL1nBQO4CIQDRd9XQFY___68jBfyHbyQGW5Qir54itGggV81mbCsnLQ%3D%3D/lsparams/hls_chunk_host,cps,met,mh,mm,mn,ms,mv,mvi,pl,rms,initcwndbps/lsig/APaTxxMwRQIhANb6xXunUvkchc6tqz4TmX0KNFDCLzJnudPYIRXxM4BHAiAVhVDt56hNjHqgVHQn_dify-P31t6iW_MsKa_40ZT3Jw%3D%3D/playlist/index.m3u8","related":[],"stream_url":"/video_proxy?url=https%3A%2F%2Fmanifest.googlevideo.com%2Fapi%2Fmanifest%2Fhls_playlist%2Fexpire%2F1765805811%2Fei%2Fk7o_afmFHazapt8P1dC92Qg%2Fip%2F14.224.158.192%2Fid%2F344b71adfe77a807%2Fitag%2F96%2Fsource%2Fyoutube%2Frequiressl%2Fyes%2Fratebypass%2Fyes%2Fpfa%2F1%2Fsgoap%2Fclen%253D44663091%253Bdur%253D2759.668%253Bgir%253Dyes%253Bitag%253D140%253Blmt%253D1765336826803662%2Fsgovp%2Fclen%253D331946345%253Bdur%253D2759.599%253Bgir%253Dyes%253Bitag%253D137%253Blmt%253D1765341180328971%2Frqh%2F1%2Fhls_chunk_host%2Frr5---sn-i3belney.googlevideo.com%2Fxpc%2FEgVo2aDSNQ%253D%253D%2Fcps%2F80%2Fmet%2F1765784211%2C%2Fmh%2Fo0%2Fmm%2F31%2C29%2Fmn%2Fsn-i3belney%2Csn-i3b7kn6s%2Fms%2Fau%2Crdu%2Fmv%2Fm%2Fmvi%2F5%2Fpl%2F23%2Frms%2Fau%2Cau%2Finitcwndbps%2F803750%2Fbui%2FAYUSA3AaRTPVQR9AxZ34LEZZbskDQYky8w0H-64K2-Agba81LDhyJvj0g3xcpDInynrnA_DiQwpxZb1h%2Fspc%2FwH4Qqx7pNgBE3ay_Sjas2uufs1KmfJ4_fwxH1n9h1fFKv_xcj6dMs5fl9awBNyrsJPrk2CdTAeSdnuESLCE2crs6%2Fvprv%2F1%2Fns%2FaheANYTAhlRZjT8Yd9mq1RcR%2Fplaylist_type%2FCLEAN%2Fdover%2F11%2Ftxp%2F5535534%2Fmt%2F1765783743%2Ffvip%2F4%2Fkeepalive%2Fyes%2Ffexp%2F51355912%2C51552689%2C51565115%2C51565682%2C51580968%2Fn%2FQ2cHoLCNB42OyhS%2Fsparams%2Fexpire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cratebypass%2Cpfa%2Csgoap%2Csgovp%2Crqh%2Cxpc%2Cbui%2Cspc%2Cvprv%2Cns%2Cplaylist_type%2Fsig%2FAJfQdSswRQIgNonUW7lMmUnoMzBbnh31XHqEySuhEQwmfh4YL1nBQO4CIQDRd9XQFY___68jBfyHbyQGW5Qir54itGggV81mbCsnLQ%253D%253D%2Flsparams%2Fhls_chunk_host%2Ccps%2Cmet%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Crms%2Cinitcwndbps%2Flsig%2FAPaTxxMwRQIhANb6xXunUvkchc6tqz4TmX0KNFDCLzJnudPYIRXxM4BHAiAVhVDt56hNjHqgVHQn_dify-P31t6iW_MsKa_40ZT3Jw%253D%253D%2Fplaylist%2Findex.m3u8","title":"Top 10 B\u1ea3n Cover Nh\u1ea1c Vi\u1ec7t B\u1ea5t H\u1ee7 - Nh\u1eefng B\u1ea3n Nh\u1ea1c Cover \u0110\u1ec9nh Cao","upload_date":"20251209","uploader":"NDT - Music","view_count":36225}

1120
static/css/style.css Normal file

File diff suppressed because it is too large Load diff

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

2
static/js/hls.min.js vendored Normal file

File diff suppressed because one or more lines are too long

369
static/js/main.js Normal file
View file

@ -0,0 +1,369 @@
// KV-Tube Main JavaScript - YouTube Clone
document.addEventListener('DOMContentLoaded', () => {
const searchInput = document.getElementById('searchInput');
const resultsArea = document.getElementById('resultsArea');
if (searchInput) {
searchInput.addEventListener('keypress', async (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const query = searchInput.value.trim();
if (query) {
// Check if on search page already, if not redirect
// Since we are SPA-ish, we just call searchYouTube
// But if we want a dedicated search page URL, we could do:
// window.history.pushState({}, '', `/?q=${encodeURIComponent(query)}`);
searchYouTube(query);
}
}
});
// Load trending on init
loadTrending();
}
// Init Theme
initTheme();
});
// Note: Global variables like currentCategory are defined below
let currentCategory = 'general';
let currentPage = 1;
let isLoading = false;
// --- UI Helpers ---
function renderSkeleton() {
// Generate 8 skeleton cards
return Array(8).fill(0).map(() => `
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
`).join('');
}
function renderNoContent(message = 'Try searching for something else', title = 'No videos found') {
return `
<div class="yt-empty-state">
<div class="yt-empty-icon"><i class="fas fa-film"></i></div>
<div class="yt-empty-title">${title}</div>
<div class="yt-empty-desc">${message}</div>
</div>
`;
}
// Search YouTube videos
async function searchYouTube(query) {
if (isLoading) return;
const resultsArea = document.getElementById('resultsArea');
const loadMoreArea = document.getElementById('loadMoreArea');
isLoading = true;
resultsArea.innerHTML = renderSkeleton();
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
if (data.error) {
resultsArea.innerHTML = `<div class="yt-loader" style="grid-column: 1/-1;"><p style="color:#f00;">Error: ${data.error}</p></div>`;
return;
}
displayResults(data, false);
if (loadMoreArea) loadMoreArea.style.display = 'none';
} catch (error) {
console.error('Search error:', error);
resultsArea.innerHTML = `<div class="yt-loader" style="grid-column: 1/-1;"><p style="color:#f00;">Failed to fetch results</p></div>`;
} finally {
isLoading = false;
}
}
// Switch category
async function switchCategory(category, btn) {
if (isLoading) return;
// Update UI (Pills)
document.querySelectorAll('.yt-category-pill').forEach(b => b.classList.remove('active'));
if (btn && btn.classList) btn.classList.add('active');
// Update UI (Sidebar)
document.querySelectorAll('.yt-sidebar-item').forEach(item => {
item.classList.remove('active');
if (item.getAttribute('data-category') === category) {
item.classList.add('active');
}
});
// Reset state
currentCategory = category;
currentPage = 1;
window.currentPage = 1;
const resultsArea = document.getElementById('resultsArea');
resultsArea.innerHTML = renderSkeleton();
// Hide pagination while loading
const paginationArea = document.getElementById('paginationArea');
if (paginationArea) paginationArea.style.display = 'none';
// Load both videos and shorts with current category, sort, and region
await loadTrending(true);
// Also reload shorts to match category
if (typeof loadShorts === 'function') {
loadShorts();
}
// Render pagination
if (typeof renderPagination === 'function') {
renderPagination();
}
}
// Load more videos
async function loadMore() {
currentPage++;
await loadTrending(false);
}
// Load trending videos
async function loadTrending(reset = true) {
if (isLoading && reset) isLoading = false;
const resultsArea = document.getElementById('resultsArea');
const loadMoreArea = document.getElementById('loadMoreArea');
const loadMoreBtn = document.getElementById('loadMoreBtn');
if (!resultsArea) return; // Exit if not on home page
isLoading = true;
if (!reset && loadMoreBtn) {
loadMoreBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
}
try {
// Get sort and region values from page (if available)
const sortValue = window.currentSort || 'month';
const regionValue = window.currentRegion || 'vietnam';
const response = await fetch(`/api/trending?category=${currentCategory}&page=${currentPage}&sort=${sortValue}&region=${regionValue}`);
const data = await response.json();
if (data.error) {
console.error('Trending error:', data.error);
if (reset) {
resultsArea.innerHTML = renderNoContent(`Error: ${data.error}`, 'Something went wrong');
}
return;
}
if (reset) resultsArea.innerHTML = '';
if (data.length === 0) {
if (reset) {
resultsArea.innerHTML = renderNoContent();
}
const paginationArea = document.getElementById('paginationArea');
if (paginationArea) paginationArea.style.display = 'none';
} else {
displayResults(data, !reset);
const paginationArea = document.getElementById('paginationArea');
if (paginationArea) paginationArea.style.display = 'flex';
// Update pagination if function exists
if (typeof renderPagination === 'function') {
renderPagination();
}
}
} catch (e) {
console.error('Failed to load trending:', e);
if (reset) {
resultsArea.innerHTML = `<div class="yt-loader" style="grid-column: 1/-1;"><p style="color:#f00;">Connection error</p></div>`;
}
} finally {
isLoading = false;
}
}
// Display results with YouTube-style cards
function displayResults(videos, append = false) {
const resultsArea = document.getElementById('resultsArea');
if (!append) resultsArea.innerHTML = '';
if (videos.length === 0 && !append) {
resultsArea.innerHTML = renderNoContent();
return;
}
videos.forEach(video => {
const card = document.createElement('div');
card.className = 'yt-video-card';
card.innerHTML = `
<div class="yt-thumbnail-container">
<img class="yt-thumbnail" src="${video.thumbnail}" alt="${escapeHtml(video.title)}" loading="lazy">
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
</div>
<div class="yt-video-details">
<div class="yt-channel-avatar">
${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'}
</div>
<div class="yt-video-meta">
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
<p class="yt-channel-name">${escapeHtml(video.uploader || 'Unknown')}</p>
<p class="yt-video-stats">${formatViews(video.view_count)} views ${formatDate(video.upload_date)}</p>
</div>
</div>
`;
card.addEventListener('click', () => {
window.location.href = `/watch?v=${video.id}`;
});
resultsArea.appendChild(card);
});
}
// Format view count (YouTube style)
function formatViews(views) {
if (!views) return '0';
const num = parseInt(views);
if (num >= 1000000000) return (num / 1000000000).toFixed(1) + 'B';
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return num.toLocaleString();
}
// Format date (YouTube style: "2 hours ago", "3 days ago", etc.)
function formatDate(dateStr) {
if (!dateStr) return 'Recently';
// Handle YYYYMMDD format
if (/^\d{8}$/.test(dateStr)) {
const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8);
dateStr = `${year}-${month}-${day}`;
}
const date = new Date(dateStr);
if (isNaN(date.getTime())) return 'Recently';
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
const diffWeek = Math.floor(diffDay / 7);
const diffMonth = Math.floor(diffDay / 30);
const diffYear = Math.floor(diffDay / 365);
if (diffYear > 0) return `${diffYear} year${diffYear > 1 ? 's' : ''} ago`;
if (diffMonth > 0) return `${diffMonth} month${diffMonth > 1 ? 's' : ''} ago`;
if (diffWeek > 0) return `${diffWeek} week${diffWeek > 1 ? 's' : ''} ago`;
if (diffDay > 0) return `${diffDay} day${diffDay > 1 ? 's' : ''} ago`;
if (diffHour > 0) return `${diffHour} hour${diffHour > 1 ? 's' : ''} ago`;
if (diffMin > 0) return `${diffMin} minute${diffMin > 1 ? 's' : ''} ago`;
return 'Just now';
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Sidebar toggle (for mobile)
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const main = document.getElementById('mainContent');
if (window.innerWidth <= 1024) {
sidebar.classList.toggle('open');
} else {
sidebar.classList.toggle('collapsed');
main.classList.toggle('sidebar-collapsed');
localStorage.setItem('sidebarCollapsed', sidebar.classList.contains('collapsed'));
}
}
// Close sidebar when clicking outside (mobile)
document.addEventListener('click', (e) => {
const sidebar = document.getElementById('sidebar');
const menuBtn = document.querySelector('.yt-menu-btn');
if (window.innerWidth <= 1024 &&
sidebar &&
sidebar.classList.contains('open') &&
!sidebar.contains(e.target) &&
menuBtn && !menuBtn.contains(e.target)) {
sidebar.classList.remove('open');
}
});
// --- Theme Logic ---
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
// Update toggle if exists
const toggle = document.getElementById('themeToggle');
if (toggle) {
toggle.checked = savedTheme === 'dark';
}
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const newTheme = current === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
}
// --- Profile Logic ---
async function updateProfile(e) {
if (e) e.preventDefault();
const displayName = document.getElementById('displayName').value;
const btn = e.target.querySelector('button');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Saving...';
try {
const response = await fetch('/api/update_profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username: displayName })
});
const data = await response.json();
if (data.success) {
showToast('Profile updated successfully!', 'success');
// Update UI immediately
const avatarName = document.querySelector('.yt-avatar');
if (avatarName) avatarName.title = displayName;
} else {
showToast(data.message || 'Update failed', 'error');
}
} catch (err) {
showToast('Network error', 'error');
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}

25
static/manifest.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "KV-Tube",
"short_name": "KV-Tube",
"description": "A self-hosted YouTube alternative with local video support",
"start_url": "/",
"display": "standalone",
"background_color": "#0f0f0f",
"theme_color": "#ff0000",
"icons": [
{
"src": "/static/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"orientation": "portrait",
"scope": "/",
"prefer_related_applications": false
}

35
static/sw.js Normal file
View file

@ -0,0 +1,35 @@
const CACHE_NAME = 'kv-tube-v1';
const STATIC_CACHE_URLS = [
'/',
'/static/css/style.css',
'/static/js/main.js',
'/static/icons/icon-192x192.png',
'/static/icons/icon-512x512.png',
'/static/favicon.ico'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(STATIC_CACHE_URLS))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(response => response || fetch(event.request))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.filter(cacheName => {
return cacheName.startsWith('kv-tube-') && cacheName !== CACHE_NAME;
}).map(cacheName => caches.delete(cacheName))
);
})
);
});

650
templates/index.html Normal file
View file

@ -0,0 +1,650 @@
{% extends "layout.html" %}
{% block content %}
<!-- Sort Options -->
<div class="yt-sort-container">
<!-- Combined Filter Group -->
<div class="yt-filter-group">
<button class="yt-sort-btn" data-sort="day" onclick="switchSort('day', this)">Today</button>
<button class="yt-sort-btn" data-sort="week" onclick="switchSort('week', this)">This Week</button>
<button class="yt-sort-btn active" data-sort="month" onclick="switchSort('month', this)">This Month</button>
<button class="yt-sort-btn" data-sort="year" onclick="switchSort('year', this)">This Year</button>
<div class="yt-divider-vertical"></div>
<button class="yt-region-btn active" data-region="vietnam" onclick="switchRegion('vietnam', this)">🇻🇳
VN</button>
<button class="yt-region-btn" data-region="global" onclick="switchRegion('global', this)">🌍 Global</button>
</div>
</div>
<!-- ... (Categories kept same) ... -->
<!-- CSS Update -->
<style>
/* ... existing styles ... */
.yt-sort-container {
display: flex;
align-items: center;
overflow-x: auto;
padding: 12px 0;
border-bottom: 1px solid var(--yt-border);
margin-bottom: 8px;
scrollbar-width: none;
}
.yt-sort-container::-webkit-scrollbar {
display: none;
}
.yt-filter-group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.yt-divider-vertical {
width: 1px;
height: 20px;
background: var(--yt-border);
margin: 0 4px;
}
/* Mobile Enhancements */
@media (max-width: 768px) {
/* Mobile List View Transition */
.yt-video-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.yt-video-card {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 12px;
height: auto;
}
.yt-thumbnail-container {
width: 160px;
/* Base width for list view thumbnail */
min-width: 140px;
aspect-ratio: 16/9;
flex-shrink: 0;
border-radius: 8px;
/* Slightly tighter radius */
}
.yt-video-info {
padding: 2px 0 0 0;
/* Remove top padding */
}
.yt-video-details {
padding: 0;
flex-direction: column;
gap: 4px;
}
/* Show avatar/channel info again but styled for list view */
.yt-channel-avatar {
display: none;
/* Keep avatar hidden to save space or move to meta */
}
.yt-profile-pic-mobile {
display: none;
/* handled below */
}
.yt-video-meta {
padding: 0;
}
.yt-video-title {
font-size: 14px;
line-height: 1.3;
max-height: 2.6em;
/* 2 lines */
margin-bottom: 4px;
}
.yt-channel-name {
display: block;
/* Show channel name again */
font-size: 12px;
color: var(--yt-text-secondary);
}
.yt-video-stats {
display: block;
/* Show views */
font-size: 12px;
}
/* Sort container adjustments */
.yt-sort-container {
padding-right: 16px;
}
.yt-sort-btn,
.yt-region-btn {
font-size: 13px;
padding: 6px 12px;
background: var(--yt-bg-secondary);
border-radius: 8px;
/* More like chips */
border: none;
}
.yt-sort-btn.active,
.yt-region-btn.active {
background: var(--yt-text-primary);
color: var(--yt-bg-primary);
}
}
</style>
<!-- Category Pills -->
<div class="yt-categories">
<button class="yt-category-pill" data-category="tech" onclick="switchCategory('tech', this)">AI & Tech</button>
<button class="yt-category-pill active" data-category="all" onclick="switchCategory('all', this)">All</button>
<button class="yt-category-pill" data-category="shorts" onclick="switchCategory('shorts', this)">Shorts</button>
<button class="yt-category-pill" data-category="music" onclick="switchCategory('music', this)">Music</button>
<button class="yt-category-pill" data-category="gaming" onclick="switchCategory('gaming', this)">Gaming</button>
<button class="yt-category-pill" data-category="news" onclick="switchCategory('news', this)">News</button>
<button class="yt-category-pill" data-category="trending"
onclick="switchCategory('trending', this)">Trending</button>
<button class="yt-category-pill" data-category="sports" onclick="switchCategory('sports', this)">Sports</button>
<button class="yt-category-pill" data-category="podcasts"
onclick="switchCategory('podcasts', this)">Podcasts</button>
<button class="yt-category-pill" data-category="live" onclick="switchCategory('live', this)">Live</button>
</div>
<!-- Shorts Section -->
<div id="shortsSection" class="yt-section">
<div class="yt-section-header">
<h2><i class="fas fa-bolt"></i> Shorts</h2>
</div>
<div class="yt-shorts-container">
<button class="yt-shorts-arrow yt-shorts-left" onclick="scrollShorts('left')">
<i class="fas fa-chevron-left"></i>
</button>
<div id="shortsGrid" class="yt-shorts-grid">
<!-- Shorts loaded here -->
</div>
<button class="yt-shorts-arrow yt-shorts-right" onclick="scrollShorts('right')">
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
<!-- Videos Section -->
<div id="videosSection" class="yt-section">
<div class="yt-section-header">
<h2><i class="fas fa-play-circle"></i> Videos</h2>
</div>
<div id="resultsArea" class="yt-video-grid">
<div id="initialLoading" class="yt-loader" style="grid-column: 1/-1;">
<div class="yt-spinner">
<div class="yt-spinner-ring"></div>
<div class="yt-spinner-ring"></div>
<div class="yt-spinner-ring"></div>
<div class="yt-spinner-glow"></div>
</div>
<p>Loading videos...</p>
</div>
</div>
</div>
<!-- Pagination -->
<div id="paginationArea" class="yt-pagination">
<button class="yt-page-btn yt-page-prev" onclick="goToPage(currentPage - 1)" disabled>
<i class="fas fa-chevron-left"></i>
</button>
<div class="yt-page-numbers" id="pageNumbers">
</div>
<button class="yt-page-btn yt-page-next" onclick="goToPage(currentPage + 1)">
<i class="fas fa-chevron-right"></i>
</button>
</div>
<style>
.yt-sort-container {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--yt-border);
margin-bottom: 8px;
}
.yt-sort-group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.yt-sort-label {
font-size: 14px;
color: var(--yt-text-secondary);
margin-right: 4px;
}
.yt-sort-btn,
.yt-region-btn {
padding: 6px 12px;
background: transparent;
border: 1px solid var(--yt-border);
border-radius: 18px;
font-size: 13px;
color: var(--yt-text-primary);
cursor: pointer;
transition: all 0.2s;
}
.yt-sort-btn:hover,
.yt-region-btn:hover {
background: var(--yt-bg-secondary);
}
.yt-sort-btn.active {
background: var(--yt-text-primary);
color: var(--yt-bg-primary);
border-color: var(--yt-text-primary);
}
.yt-region-btn.active {
background: var(--yt-accent-red);
color: white;
border-color: var(--yt-accent-red);
}
/* Sections */
.yt-section {
margin-bottom: 32px;
}
.yt-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding: 8px 0;
}
.yt-section-header h2 {
font-size: 20px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.yt-section-header h2 i {
color: var(--yt-accent-red);
}
.yt-see-all {
color: var(--yt-accent-blue);
font-size: 14px;
font-weight: 500;
}
.yt-see-all:hover {
text-decoration: underline;
}
/* Shorts Container with Arrows */
.yt-shorts-container {
position: relative;
display: flex;
align-items: center;
}
.yt-shorts-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--yt-bg-primary);
border: 1px solid var(--yt-border);
color: var(--yt-text-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.yt-shorts-arrow:hover {
background: var(--yt-bg-secondary);
transform: translateY(-50%) scale(1.1);
}
.yt-shorts-left {
left: -20px;
}
.yt-shorts-right {
right: -20px;
}
/* Shorts Grid (Horizontal Scroll) */
.yt-shorts-grid {
display: flex;
gap: 12px;
overflow-x: auto;
padding: 8px 0;
scroll-behavior: smooth;
scrollbar-width: none;
flex: 1;
}
.yt-shorts-grid::-webkit-scrollbar {
display: none;
}
.yt-short-card {
flex-shrink: 0;
width: 180px;
cursor: pointer;
transition: transform 0.2s;
}
.yt-short-card:hover {
transform: scale(1.02);
}
.yt-short-thumb {
width: 180px;
height: 320px;
border-radius: 12px;
object-fit: cover;
background: var(--yt-bg-secondary);
}
.yt-short-title {
font-size: 14px;
font-weight: 500;
margin-top: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.yt-short-views {
font-size: 12px;
color: var(--yt-text-secondary);
margin-top: 4px;
}
/* Pagination */
.yt-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin: 32px 0;
}
.yt-page-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--yt-bg-secondary);
color: var(--yt-text-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.2s;
}
.yt-page-btn:hover:not(:disabled) {
background: var(--yt-bg-hover);
}
.yt-page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.yt-page-numbers {
display: flex;
gap: 4px;
}
.yt-page-num {
min-width: 40px;
height: 40px;
border-radius: 20px;
background: var(--yt-bg-secondary);
color: var(--yt-text-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 500;
padding: 0 8px;
transition: all 0.2s;
}
.yt-page-num:hover {
background: var(--yt-bg-hover);
}
.yt-page-num.active {
background: var(--yt-accent-red);
color: white;
}
@media (max-width: 768px) {
.yt-sort-container {
/* flex-direction: column; Old: stacked */
flex-direction: row;
/* New: horizontal scroll */
align-items: center;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
/* Hide scrollbar Firefox */
-ms-overflow-style: none;
/* IE/Edge */
padding-right: 16px;
/* Right padding for scroll */
}
.yt-sort-container::-webkit-scrollbar {
display: none;
/* Hide scrollbar Chrome/Safari */
}
.yt-sort-label {
display: none;
/* Hide labels on mobile */
}
.yt-sort-btn,
.yt-region-btn {
padding: 6px 10px;
font-size: 12px;
white-space: nowrap;
flex-shrink: 0;
}
/* Force 2 columns on mobile */
.yt-video-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.yt-short-card {
width: 140px;
}
.yt-short-thumb {
width: 140px;
height: 250px;
}
.yt-shorts-arrow {
display: none;
}
}
</style>
<script>
let currentSort = 'month';
let currentRegion = 'vietnam';
let totalPages = 10;
// Scroll shorts left/right
function scrollShorts(direction) {
const grid = document.getElementById('shortsGrid');
const scrollAmount = 400; // pixels to scroll
if (direction === 'left') {
grid.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
} else {
grid.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
}
// Generate page numbers
function renderPagination() {
const container = document.getElementById('pageNumbers');
container.innerHTML = '';
const startPage = Math.max(1, currentPage - 4);
const endPage = Math.min(totalPages, startPage + 9);
for (let i = startPage; i <= endPage; i++) {
const btn = document.createElement('button');
btn.className = `yt-page-num ${i === currentPage ? 'active' : ''}`;
btn.innerText = i;
btn.onclick = () => goToPage(i);
container.appendChild(btn);
}
document.querySelector('.yt-page-prev').disabled = currentPage <= 1;
document.querySelector('.yt-page-next').disabled = currentPage >= totalPages;
}
function goToPage(page) {
if (page < 1 || page > totalPages) return;
currentPage = page;
window.currentPage = page;
renderPagination();
loadTrending(true);
loadShorts(); // Also update shorts when page changes
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function switchSort(sort, btn) {
document.querySelectorAll('.yt-sort-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentSort = sort;
window.currentSort = sort;
currentPage = 1;
loadTrending(true);
loadShorts();
}
function switchRegion(region, btn) {
document.querySelectorAll('.yt-region-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentRegion = region;
window.currentRegion = region;
currentPage = 1;
loadTrending(true);
loadShorts();
}
window.currentSort = 'month';
window.currentRegion = 'vietnam';
window.currentPage = 1;
// Load shorts with current category, pagination, sort, region
async function loadShorts() {
const shortsGrid = document.getElementById('shortsGrid');
shortsGrid.innerHTML = `<div class="yt-loader"><div class="yt-spinner">
<div class="yt-spinner-ring"></div>
<div class="yt-spinner-ring"></div>
<div class="yt-spinner-ring"></div>
<div class="yt-spinner-glow"></div>
</div></div>`;
try {
const page = window.currentPage || 1;
// Get shorts related to current category
const category = window.currentCategory || currentCategory || 'general';
// For shorts, we combine category with 'shorts' keyword
const shortsCategory = category === 'all' || category === 'general' ? 'shorts' : category;
const response = await fetch(`/api/trending?category=${shortsCategory}&page=${page}&sort=${currentSort}&region=${currentRegion}&shorts=1`);
const data = await response.json();
shortsGrid.innerHTML = '';
if (data && data.length > 0) {
data.slice(0, 10).forEach(video => {
const card = document.createElement('div');
card.className = 'yt-short-card';
card.innerHTML = `
<img src="${video.thumbnail}" class="yt-short-thumb" loading="lazy">
<p class="yt-short-title">${escapeHtml(video.title)}</p>
<p class="yt-short-views">${formatViews(video.view_count)} views</p>
`;
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
shortsGrid.appendChild(card);
});
} else {
shortsGrid.innerHTML = '<p style="color:var(--yt-text-secondary);padding:20px;">No shorts found</p>';
}
} catch (e) {
console.error('Error loading shorts:', e);
shortsGrid.innerHTML = '<p style="color:var(--yt-text-secondary);padding:20px;">Could not load shorts</p>';
}
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatViews(views) {
if (!views) return '0';
const num = parseInt(views);
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
return num.toLocaleString();
}
// Init
document.addEventListener('DOMContentLoaded', () => {
loadShorts();
renderPagination();
const urlParams = new URLSearchParams(window.location.search);
const category = urlParams.get('category');
if (category) {
const pill = document.querySelector(`.yt-category-pill[data-category="${category}"]`);
if (pill) pill.click();
}
});
</script>
{% endblock %}

342
templates/layout.html Normal file
View file

@ -0,0 +1,342 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="KV-Tube">
<title>KV-Tube</title>
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="{{ request.url }}">
<meta property="og:title" content="KV-Tube - Video Streaming">
<meta property="og:description" content="Stream your favorite videos on KV-Tube.">
<meta property="og:image" content="{{ url_for('static', filename='og-image.jpg', _external=True) }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
</head>
<body>
<div class="app-wrapper">
<!-- YouTube-style Header -->
<header class="yt-header">
<div class="yt-header-start">
<button class="yt-menu-btn" onclick="toggleSidebar()" aria-label="Menu">
<i class="fas fa-bars"></i>
</button>
<a href="/" class="yt-logo">
<div class="yt-logo-icon">KV-Tube</div>
</a>
</div>
<div class="yt-header-center">
<form class="yt-search-form" action="/" method="get" onsubmit="handleSearch(event)">
<input type="text" id="searchInput" class="yt-search-input" placeholder="Search">
<button type="submit" class="yt-search-btn" aria-label="Search">
<i class="fas fa-search"></i>
</button>
</form>
</div>
<div class="yt-header-end">
<button class="yt-icon-btn yt-mobile-search" onclick="toggleMobileSearch()" aria-label="Search">
<i class="fas fa-search"></i>
</button>
{% if session.get('user_id') %}
<div class="yt-avatar" title="{{ session.username }}">
{{ session.username[0]|upper }}
</div>
{% else %}
<a href="/login" class="yt-signin-btn">
<i class="fas fa-user"></i>
<span class="yt-signin-text">Sign in</span>
</a>
{% endif %}
</div>
</header>
<!-- Mobile Search Bar -->
<div class="yt-mobile-search-bar" id="mobileSearchBar">
<button onclick="toggleMobileSearch()" class="yt-back-btn">
<i class="fas fa-arrow-left"></i>
</button>
<input type="text" id="mobileSearchInput" placeholder="Search"
onkeypress="if(event.key==='Enter'){searchYouTube(this.value);toggleMobileSearch();}">
</div>
<!-- YouTube-style Sidebar -->
<aside class="yt-sidebar" id="sidebar">
<a href="/" class="yt-sidebar-item {% if request.path == '/' %}active{% endif %}" data-category="all">
<i class="fas fa-home"></i>
<span>Home</span>
</a>
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="shorts"
onclick="navigateCategory('shorts')">
<i class="fas fa-bolt"></i>
<span>Shorts</span>
</a>
<div class="yt-sidebar-divider"></div>
<a href="/my-videos?type=history" class="yt-sidebar-item" data-category="history">
<i class="fas fa-history"></i>
<span>History</span>
</a>
<a href="/my-videos?type=saved"
class="yt-sidebar-item {% if request.path == '/my-videos' %}active{% endif %}" data-category="saved">
<i class="fas fa-bookmark"></i>
<span>Library</span>
</a>
<div class="yt-sidebar-divider"></div>
<div class="yt-sidebar-title">Explore</div>
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="tech"
onclick="navigateCategory('tech')">
<i class="fas fa-microchip"></i>
<span>AI & Tech</span>
</a>
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="trending"
onclick="navigateCategory('trending')">
<i class="fas fa-fire"></i>
<span>Trending</span>
</a>
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="music"
onclick="navigateCategory('music')">
<i class="fas fa-music"></i>
<span>Music</span>
</a>
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="gaming"
onclick="navigateCategory('gaming')">
<i class="fas fa-gamepad"></i>
<span>Gaming</span>
</a>
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="news"
onclick="navigateCategory('news')">
<i class="fas fa-newspaper"></i>
<span>News</span>
</a>
<a href="javascript:void(0)" class="yt-sidebar-item" data-category="sports"
onclick="navigateCategory('sports')">
<i class="fas fa-football-ball"></i>
<span>Sports</span>
</a>
<div class="yt-sidebar-divider"></div>
<a href="/settings" class="yt-sidebar-item {% if request.path == '/settings' %}active{% endif %}">
<i class="fas fa-cog"></i>
<span>Settings</span>
</a>
{% if session.get('user_id') %}
<a href="/logout" class="yt-sidebar-item">
<i class="fas fa-sign-out-alt"></i>
<span>Sign out</span>
</a>
{% endif %}
</aside>
<!-- Sidebar Overlay (Mobile) -->
<div class="yt-sidebar-overlay" id="sidebarOverlay" onclick="closeSidebar()"></div>
<!-- Main Content -->
<main class="yt-main" id="mainContent">
{% block content %}{% endblock %}
</main>
</div>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
<script>
// Register Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('{{ url_for("static", filename="sw.js") }}')
.then(registration => {
console.log('ServiceWorker registration successful');
})
.catch(err => {
console.log('ServiceWorker registration failed: ', err);
});
});
}
// Add to Home Screen prompt
let deferredPrompt;
const addBtn = document.querySelector('.add-button');
window.addEventListener('beforeinstallprompt', (e) => {
// Prevent Chrome 67 and earlier from automatically showing the prompt
e.preventDefault();
// Stash the event so it can be triggered later
deferredPrompt = e;
// Show the button
if (addBtn) addBtn.style.display = 'block';
// Show the install button if it exists
const installButton = document.getElementById('install-button');
if (installButton) {
installButton.style.display = 'block';
installButton.addEventListener('click', () => {
// Show the prompt
deferredPrompt.prompt();
// Wait for the user to respond to the prompt
deferredPrompt.userChoice.then((choiceResult) => {
if (choiceResult.outcome === 'accepted') {
console.log('User accepted the install prompt');
} else {
console.log('User dismissed the install prompt');
}
deferredPrompt = null;
});
});
}
});
// Sidebar toggle
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const main = document.getElementById('mainContent');
const overlay = document.getElementById('sidebarOverlay');
if (window.innerWidth <= 1024) {
sidebar.classList.toggle('open');
overlay.classList.toggle('active');
} else {
sidebar.classList.toggle('collapsed');
main.classList.toggle('sidebar-collapsed');
localStorage.setItem('sidebarCollapsed', sidebar.classList.contains('collapsed'));
}
}
function closeSidebar() {
document.getElementById('sidebar').classList.remove('open');
document.getElementById('sidebarOverlay').classList.remove('active');
}
// Restore sidebar state (desktop only)
if (window.innerWidth > 1024 && localStorage.getItem('sidebarCollapsed') === 'true') {
document.getElementById('sidebar').classList.add('collapsed');
document.getElementById('mainContent').classList.add('sidebar-collapsed');
}
// Mobile search toggle
function toggleMobileSearch() {
const searchBar = document.getElementById('mobileSearchBar');
searchBar.classList.toggle('active');
if (searchBar.classList.contains('active')) {
document.getElementById('mobileSearchInput').focus();
}
}
// Search handler
function handleSearch(e) {
e.preventDefault();
const query = document.getElementById('searchInput').value.trim();
if (query && typeof searchYouTube === 'function') {
searchYouTube(query);
}
}
// Navigate to category (syncs sidebar with top pills)
function navigateCategory(category) {
// Close mobile sidebar
closeSidebar();
// If on home page, trigger category switch
if (window.location.pathname === '/') {
const pill = document.querySelector(`.yt-category-pill[data-category="${category}"]`);
if (pill) {
pill.click();
} else if (typeof switchCategory === 'function') {
// Create a mock button for the function
const pills = document.querySelectorAll('.yt-category-pill');
pills.forEach(p => p.classList.remove('active'));
switchCategory(category, { classList: { add: () => { } } });
}
} else {
// Navigate to home with category
window.location.href = `/?category=${category}`;
}
}
</script>
<!-- Toast Notification Container -->
<div id="toastContainer" class="yt-toast-container"></div>
<style>
.yt-toast-container {
position: fixed;
bottom: 24px;
left: 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: none;
}
.yt-toast {
background: #1f1f1f;
color: #fff;
padding: 12px 24px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
font-size: 14px;
animation: slideUp 0.3s ease;
pointer-events: auto;
display: flex;
align-items: center;
gap: 12px;
min-width: 280px;
border-left: 4px solid #3ea6ff;
}
.yt-toast.error {
border-left-color: #ff4e45;
}
.yt-toast.success {
border-left-color: #2ba640;
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>
<script>
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `yt-toast ${type}`;
toast.innerHTML = `<span>${message}</span>`;
container.appendChild(toast);
// Remove after 3 seconds
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateY(20px)';
toast.style.transition = 'all 0.3s';
setTimeout(() => toast.remove(), 300);
}, 5000);
}
</script>
</body>
</html>

212
templates/login.html Normal file
View file

@ -0,0 +1,212 @@
{% extends "layout.html" %}
{% block content %}
<div class="yt-auth-container">
<div class="yt-auth-card">
<!-- Logo -->
<div class="yt-auth-logo">
<div class="yt-logo-icon" style="font-size: 24px;">KV-Tube</div>
</div>
<h2>Sign in</h2>
<p>to continue to KV-Tube</p>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="yt-auth-alert">
<i class="fas fa-exclamation-circle"></i>
{{ messages[0] }}
</div>
{% endif %}
{% endwith %}
<form method="POST" action="/login" class="yt-auth-form">
<div class="yt-form-group">
<input type="text" name="username" class="yt-form-input" placeholder=" " required id="username">
<label for="username" class="yt-form-label">Username</label>
</div>
<div class="yt-form-group">
<input type="password" name="password" class="yt-form-input" placeholder=" " required id="password">
<label for="password" class="yt-form-label">Password</label>
</div>
<button type="submit" class="yt-auth-submit">
<i class="fas fa-sign-in-alt"></i>
Sign In
</button>
</form>
<div class="yt-auth-divider">
<span>or</span>
</div>
<p class="yt-auth-footer">
New to KV-Tube?
<a href="/register" class="yt-auth-link">Create an account</a>
</p>
</div>
</div>
<style>
.yt-auth-container {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - var(--yt-header-height) - 100px);
padding: 24px;
}
.yt-auth-card {
background: var(--yt-bg-secondary);
border-radius: 16px;
padding: 48px 40px;
width: 100%;
max-width: 420px;
text-align: center;
border: 1px solid var(--yt-border);
}
.yt-auth-logo {
margin-bottom: 24px;
}
.yt-auth-card h2 {
font-size: 24px;
font-weight: 500;
margin-bottom: 8px;
color: var(--yt-text-primary);
}
.yt-auth-card>p {
color: var(--yt-text-secondary);
font-size: 14px;
margin-bottom: 32px;
}
.yt-auth-alert {
background: rgba(244, 67, 54, 0.1);
color: #f44336;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 24px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
text-align: left;
}
.yt-auth-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.yt-form-group {
position: relative;
text-align: left;
}
.yt-form-input {
width: 100%;
padding: 16px 14px;
font-size: 16px;
color: var(--yt-text-primary);
background: var(--yt-bg-primary);
border: 1px solid var(--yt-border);
border-radius: 8px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.yt-form-input:focus {
border-color: var(--yt-accent-blue);
box-shadow: 0 0 0 3px rgba(62, 166, 255, 0.2);
}
.yt-form-label {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 16px;
color: var(--yt-text-secondary);
pointer-events: none;
transition: all 0.2s;
background: var(--yt-bg-primary);
padding: 0 4px;
}
.yt-form-input:focus+.yt-form-label,
.yt-form-input:not(:placeholder-shown)+.yt-form-label {
top: 0;
font-size: 12px;
color: var(--yt-accent-blue);
}
.yt-auth-submit {
background: var(--yt-accent-blue);
color: white;
padding: 14px 24px;
border-radius: 24px;
font-size: 15px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
transition: background 0.2s, transform 0.1s;
}
.yt-auth-submit:hover {
background: #258fd9;
transform: scale(1.02);
}
.yt-auth-submit:active {
transform: scale(0.98);
}
.yt-auth-divider {
display: flex;
align-items: center;
margin: 24px 0;
}
.yt-auth-divider::before,
.yt-auth-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--yt-border);
}
.yt-auth-divider span {
padding: 0 16px;
color: var(--yt-text-secondary);
font-size: 13px;
}
.yt-auth-footer {
color: var(--yt-text-secondary);
font-size: 14px;
}
.yt-auth-link {
color: var(--yt-accent-blue);
font-weight: 500;
}
.yt-auth-link:hover {
text-decoration: underline;
}
@media (max-width: 480px) {
.yt-auth-card {
padding: 32px 24px;
}
}
</style>
{% endblock %}

36
templates/my_videos.html Normal file
View file

@ -0,0 +1,36 @@
{% extends "layout.html" %}
{% block content %}
<div class="library-header" style="margin-bottom: 2rem; display: flex; align-items: center; gap: 1rem;">
<h1>My Library</h1>
<div class="tabs" style="display: flex; gap: 0.5rem; background: #e8eaed; padding: 0.3rem; border-radius: 100px;">
<a href="/my-videos?type=saved" class="btn {% if filter_type == 'saved' %}btn-primary{% endif %}"
style="border-radius: 100px; font-size: 0.9rem; padding: 0.5rem 1.5rem; color: {% if filter_type != 'saved' %}var(--text-secondary){% else %}white{% endif %}">Saved</a>
<a href="/my-videos?type=history" class="btn {% if filter_type == 'history' %}btn-primary{% endif %}"
style="border-radius: 100px; font-size: 0.9rem; padding: 0.5rem 1.5rem; color: {% if filter_type != 'history' %}var(--text-secondary){% else %}white{% endif %}">History</a>
</div>
</div>
{% if videos %}
<div class="video-grid">
{% for video in videos %}
<div class="video-card" onclick="window.location.href='/watch?v={{ video.video_id }}'">
<img src="{{ video.thumbnail }}" class="thumbnail" loading="lazy">
<div class="video-info">
<div class="video-title">{{ video.title }}</div>
<div class="video-meta">
<i class="far fa-clock"></i> {{ video.timestamp }}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div style="text-align: center; padding: 4rem; color: var(--text-secondary);">
<i class="far fa-folder-open fa-3x" style="margin-bottom: 1rem; opacity: 0.5;"></i>
<h3>Nothing here yet</h3>
<p>Go watch some videos to fill this up!</p>
<a href="/" class="btn btn-primary" style="margin-top: 1rem;">Browse Content</a>
</div>
{% endif %}
{% endblock %}

212
templates/register.html Normal file
View file

@ -0,0 +1,212 @@
{% extends "layout.html" %}
{% block content %}
<div class="yt-auth-container">
<div class="yt-auth-card">
<!-- Logo -->
<div class="yt-auth-logo">
<div class="yt-logo-icon" style="font-size: 24px;">KV-Tube</div>
</div>
<h2>Create account</h2>
<p>to start watching on KV-Tube</p>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="yt-auth-alert">
<i class="fas fa-exclamation-circle"></i>
{{ messages[0] }}
</div>
{% endif %}
{% endwith %}
<form method="POST" action="/register" class="yt-auth-form">
<div class="yt-form-group">
<input type="text" name="username" class="yt-form-input" placeholder=" " required id="username">
<label for="username" class="yt-form-label">Username</label>
</div>
<div class="yt-form-group">
<input type="password" name="password" class="yt-form-input" placeholder=" " required id="password">
<label for="password" class="yt-form-label">Password</label>
</div>
<button type="submit" class="yt-auth-submit">
<i class="fas fa-user-plus"></i>
Create Account
</button>
</form>
<div class="yt-auth-divider">
<span>or</span>
</div>
<p class="yt-auth-footer">
Already have an account?
<a href="/login" class="yt-auth-link">Sign in</a>
</p>
</div>
</div>
<style>
.yt-auth-container {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - var(--yt-header-height) - 100px);
padding: 24px;
}
.yt-auth-card {
background: var(--yt-bg-secondary);
border-radius: 16px;
padding: 48px 40px;
width: 100%;
max-width: 420px;
text-align: center;
border: 1px solid var(--yt-border);
}
.yt-auth-logo {
margin-bottom: 24px;
}
.yt-auth-card h2 {
font-size: 24px;
font-weight: 500;
margin-bottom: 8px;
color: var(--yt-text-primary);
}
.yt-auth-card>p {
color: var(--yt-text-secondary);
font-size: 14px;
margin-bottom: 32px;
}
.yt-auth-alert {
background: rgba(244, 67, 54, 0.1);
color: #f44336;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 24px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
text-align: left;
}
.yt-auth-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.yt-form-group {
position: relative;
text-align: left;
}
.yt-form-input {
width: 100%;
padding: 16px 14px;
font-size: 16px;
color: var(--yt-text-primary);
background: var(--yt-bg-primary);
border: 1px solid var(--yt-border);
border-radius: 8px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.yt-form-input:focus {
border-color: var(--yt-accent-blue);
box-shadow: 0 0 0 3px rgba(62, 166, 255, 0.2);
}
.yt-form-label {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 16px;
color: var(--yt-text-secondary);
pointer-events: none;
transition: all 0.2s;
background: var(--yt-bg-primary);
padding: 0 4px;
}
.yt-form-input:focus+.yt-form-label,
.yt-form-input:not(:placeholder-shown)+.yt-form-label {
top: 0;
font-size: 12px;
color: var(--yt-accent-blue);
}
.yt-auth-submit {
background: var(--yt-accent-blue);
color: white;
padding: 14px 24px;
border-radius: 24px;
font-size: 15px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
transition: background 0.2s, transform 0.1s;
}
.yt-auth-submit:hover {
background: #258fd9;
transform: scale(1.02);
}
.yt-auth-submit:active {
transform: scale(0.98);
}
.yt-auth-divider {
display: flex;
align-items: center;
margin: 24px 0;
}
.yt-auth-divider::before,
.yt-auth-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--yt-border);
}
.yt-auth-divider span {
padding: 0 16px;
color: var(--yt-text-secondary);
font-size: 13px;
}
.yt-auth-footer {
color: var(--yt-text-secondary);
font-size: 14px;
}
.yt-auth-link {
color: var(--yt-accent-blue);
font-weight: 500;
}
.yt-auth-link:hover {
text-decoration: underline;
}
@media (max-width: 480px) {
.yt-auth-card {
padding: 32px 24px;
}
}
</style>
{% endblock %}

235
templates/settings.html Normal file
View file

@ -0,0 +1,235 @@
{% extends "layout.html" %}
{% block content %}
<div class="yt-settings-container">
<h2 class="yt-settings-title">Settings</h2>
<div class="yt-settings-card">
<h3>Appearance</h3>
<p class="yt-settings-desc">Customize how KV-Tube looks on your device.</p>
<div class="yt-setting-row">
<span>Dark Mode</span>
<label class="yt-switch">
<input type="checkbox" id="themeToggle" checked onchange="toggleTheme()">
<span class="yt-slider round"></span>
</label>
</div>
</div>
{% if session.get('user_id') %}
<div class="yt-settings-card">
<h3>Profile</h3>
<p class="yt-settings-desc">Update your public profile information.</p>
<form id="profileForm" onsubmit="updateProfile(event)">
<div class="yt-form-group">
<label>Display Name</label>
<input type="text" class="yt-form-input" id="displayName" value="{{ session.username }}" required>
</div>
<button type="submit" class="yt-update-btn">Save Changes</button>
</form>
</div>
{% endif %}
<div class="yt-settings-card">
<h3>System Updates</h3>
<p class="yt-settings-desc">Manage core components of KV-Tube.</p>
<div class="yt-update-section">
<div class="yt-update-info">
<div>
<h4>yt-dlp</h4>
<span class="yt-update-subtitle">Core video extraction engine</span>
</div>
<button id="updateBtn" onclick="updateYtDlp()" class="yt-update-btn">
<i class="fas fa-sync-alt"></i> Check for Updates
</button>
</div>
<div id="updateStatus" class="yt-update-status"></div>
</div>
</div>
<div class="yt-settings-card">
<h3>About</h3>
<p class="yt-settings-desc">KV-Tube v1.0</p>
<p class="yt-settings-desc">A YouTube-like streaming application.</p>
</div>
</div>
<style>
.yt-settings-container {
max-width: 600px;
margin: 0 auto;
padding: 24px;
}
.yt-settings-title {
font-size: 24px;
font-weight: 500;
margin-bottom: 24px;
text-align: center;
}
.yt-settings-card {
background: var(--yt-bg-secondary);
border-radius: 12px;
padding: 24px;
margin-bottom: 16px;
}
.yt-settings-card h3 {
font-size: 18px;
margin-bottom: 8px;
}
.yt-settings-desc {
color: var(--yt-text-secondary);
font-size: 14px;
margin-bottom: 16px;
}
.yt-update-section {
background: var(--yt-bg-elevated);
border-radius: 8px;
padding: 16px;
}
.yt-update-info {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.yt-update-info h4 {
font-size: 16px;
margin-bottom: 4px;
}
.yt-update-subtitle {
font-size: 12px;
color: var(--yt-text-secondary);
}
.yt-update-btn {
background: var(--yt-accent-red);
color: white;
padding: 12px 24px;
border-radius: 24px;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
transition: opacity 0.2s, transform 0.2s;
}
.yt-update-btn:hover {
opacity: 0.9;
transform: scale(1.02);
}
.yt-update-btn:disabled {
background: var(--yt-bg-hover);
cursor: not-allowed;
}
.yt-update-status {
margin-top: 12px;
font-size: 13px;
text-align: center;
}
/* Toggle Switch */
.yt-setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.yt-switch {
position: relative;
display: inline-block;
width: 48px;
height: 24px;
}
.yt-switch input {
opacity: 0;
width: 0;
height: 0;
}
.yt-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #666;
transition: .4s;
}
.yt-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
}
input:checked+.yt-slider {
background-color: var(--yt-accent-blue);
}
input:checked+.yt-slider:before {
transform: translateX(24px);
}
.yt-slider.round {
border-radius: 24px;
}
.yt-slider.round:before {
border-radius: 50%;
}
</style>
<script>
async function updateYtDlp() {
const btn = document.getElementById('updateBtn');
const status = document.getElementById('updateStatus');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Updating...';
status.style.color = 'var(--yt-text-secondary)';
status.innerText = 'Running pip install -U yt-dlp... This may take a moment.';
try {
const response = await fetch('/api/update_ytdlp', { method: 'POST' });
const data = await response.json();
if (data.success) {
status.style.color = '#4caf50';
status.innerText = '✓ ' + data.message;
btn.innerHTML = '<i class="fas fa-check"></i> Updated';
} else {
status.style.color = '#f44336';
status.innerText = '✗ ' + data.message;
btn.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Failed';
btn.disabled = false;
}
} catch (e) {
status.style.color = '#f44336';
status.innerText = '✗ Network error: ' + e.message;
btn.disabled = false;
btn.innerHTML = 'Retry';
}
}
</script>
{% endblock %}

730
templates/watch.html Normal file
View file

@ -0,0 +1,730 @@
{% extends "layout.html" %}
{% block content %}
<!-- Artplayer -->
<script src="https://cdn.jsdelivr.net/npm/artplayer/dist/artplayer.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<div class="yt-watch-layout">
<!-- Player Section -->
<div class="yt-player-section">
<div class="yt-player-container">
<div id="artplayer-app" style="width: 100%; height: 100%;"></div>
</div>
<!-- Loading State -->
<div id="loading" class="yt-loader">
<div class="yt-spinner">
<div class="yt-spinner-ring"></div>
<div class="yt-spinner-ring"></div>
<div class="yt-spinner-ring"></div>
<div class="yt-spinner-glow"></div>
</div>
<p>Loading video...</p>
</div>
<!-- Video Info -->
<div class="yt-video-info" id="videoInfo" style="display:none;">
<h1 id="videoTitle">Loading...</h1>
<!-- Actions -->
<div class="yt-video-actions">
<button class="yt-action-btn" id="likeBtn">
<i class="fas fa-thumbs-up"></i>
<span id="likeCount">Like</span>
</button>
<button class="yt-action-btn" id="dislikeBtn">
<i class="fas fa-thumbs-down"></i>
</button>
<button class="yt-action-btn" id="shareBtn">
<i class="fas fa-share"></i>
Share
</button>
<a class="yt-action-btn" id="downloadBtn" href="#" target="_blank">
<i class="fas fa-download"></i>
Download
</a>
<button class="yt-action-btn" id="saveBtn">
<i class="far fa-bookmark"></i>
Save
</button>
<button class="yt-action-btn" id="summarizeBtn" onclick="summarizeVideo()">
<i class="fas fa-magic"></i>
Summarize
</button>
</div>
<!-- Summary Box (Hidden by default) -->
<div class="yt-description-box" id="summaryBox"
style="display:none; margin-bottom:16px; border: 1px solid var(--yt-accent-blue); background: rgba(62, 166, 255, 0.1);">
<p class="yt-description-stats" style="color: var(--yt-accent-blue);"><i class="fas fa-sparkles"></i> AI
Summary</p>
<p class="yt-description-text" id="summaryText">Generating summary...</p>
</div>
<!-- Channel Info -->
<div class="yt-channel-info">
<div class="yt-channel-details">
<div class="yt-channel-avatar-lg" id="channelAvatar">
<span id="channelAvatarLetter"></span>
</div>
<div>
<p style="font-weight: 500;" id="channelName">Loading...</p>
<p class="yt-video-stats" id="viewCount">0 views</p>
</div>
</div>
<button class="yt-subscribe-btn">Subscribe</button>
</div>
<!-- Description -->
<div class="yt-description-box" id="descriptionBox" onclick="toggleDescription()">
<p class="yt-description-stats" id="descStats"></p>
<p class="yt-description-text" id="videoDesc">Loading description...</p>
</div>
<!-- Comments Section (Collapsible) -->
<div class="yt-comments-section" id="commentsSection">
<button class="yt-comments-toggle" id="commentsToggle" onclick="toggleComments()">
<div class="yt-comments-preview">
<span id="commentCountDisplay">Comments</span>
<i class="fas fa-chevron-down" id="commentsChevron"></i>
</div>
</button>
<div class="yt-comments-content" id="commentsContent" style="display: none;">
<div class="yt-comments-header">
<h3><span id="commentCount">0</span> Comments</h3>
</div>
<div class="yt-comments-list" id="commentsList">
<!-- Comments loaded here -->
</div>
</div>
</div>
</div>
</div>
<!-- Suggested Videos -->
<div class="yt-suggested" id="relatedVideos">
<h3 style="margin-bottom: 16px; font-size: 16px;">Related Videos</h3>
</div>
</div>
<style>
html,
body {
overflow-x: hidden;
width: 100%;
max-width: 100vw;
}
.yt-player-container {
position: relative;
width: 100%;
aspect-ratio: 16/9;
background: #000;
border-radius: 12px;
overflow: hidden;
}
#loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #000;
z-index: 10;
}
.yt-watch-layout {
display: grid;
grid-template-columns: 1fr 400px;
gap: 24px;
max-width: 1800px;
}
.yt-channel-avatar-lg {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: white;
}
/* Comments Section - Collapsible */
.yt-comments-section {
margin-top: 24px;
border-top: 1px solid var(--yt-border);
padding-top: 16px;
}
.yt-comments-toggle {
width: 100%;
background: var(--yt-bg-secondary);
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: background 0.2s;
}
.yt-comments-toggle:hover {
background: var(--yt-bg-hover);
}
.yt-comments-preview {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--yt-text-primary);
font-size: 14px;
font-weight: 500;
}
.yt-comments-preview i {
transition: transform 0.3s;
}
.yt-comments-preview i.rotated {
transform: rotate(180deg);
}
.yt-comments-content {
margin-top: 16px;
animation: fadeIn 0.3s ease;
}
.yt-comments-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.yt-comments-header h3 {
font-size: 16px;
font-weight: 500;
}
.yt-comments-list {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 500px;
overflow-y: auto;
}
/* Hide standard info in shorts mode */
.shorts-mode .yt-video-info,
.shorts-mode .yt-suggested {
display: none !important;
}
@media (max-width: 768px) {
/* Hide time display on mobile to save space for other controls */
.art-control-time {
display: none !important;
}
}
.yt-comment {
display: flex;
gap: 12px;
}
.yt-comment-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--yt-bg-hover);
flex-shrink: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--yt-text-primary);
}
.yt-comment-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.yt-comment-content {
flex: 1;
}
.yt-comment-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
flex-wrap: wrap;
}
.yt-comment-author {
font-size: 13px;
font-weight: 500;
color: var(--yt-text-primary);
}
.yt-comment-time {
font-size: 12px;
color: var(--yt-text-secondary);
}
.yt-comment-text {
font-size: 14px;
line-height: 1.5;
color: var(--yt-text-primary);
margin-bottom: 8px;
white-space: pre-wrap;
word-wrap: break-word;
}
.yt-comment-actions {
display: flex;
align-items: center;
gap: 8px;
}
.yt-comment-action {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--yt-text-secondary);
padding: 4px 8px;
border-radius: 20px;
}
.yt-comment-action:hover {
background: var(--yt-bg-secondary);
}
.yt-pinned-badge {
background: var(--yt-bg-secondary);
font-size: 11px;
padding: 2px 8px;
border-radius: 2px;
color: var(--yt-text-secondary);
}
.yt-no-comments {
text-align: center;
color: var(--yt-text-secondary);
padding: 24px;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 1200px) {
.yt-watch-layout {
grid-template-columns: 1fr;
}
}
</style>
<!-- HLS Support (Local) -->
<script src="/static/js/hls.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/artplayer/5.1.1/artplayer.js"></script>
<script>
let commentsLoaded = false;
function initArtplayer(url, poster, type = 'auto') {
return new Artplayer({
container: '#artplayer-app',
url: url,
poster: poster,
type: type,
volume: 0.5,
muted: false,
autoplay: false,
pip: true,
autoSize: false,
autoMini: true,
screenshot: true,
setting: true,
loop: false,
flip: true,
playbackRate: true,
aspectRatio: true,
fullscreen: true,
fullscreenWeb: true,
miniProgressBar: true,
mutex: true,
backdrop: true,
playsInline: true,
autoPlayback: true,
lock: true,
fastForward: true,
autoOrientation: true,
theme: '#ff0000',
lang: navigator.language.toLowerCase(),
moreVideoAttr: {
crossOrigin: 'anonymous',
},
customType: {
m3u8: function (video, url) {
if (Hls.isSupported()) {
const hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
debug: false, // Set to true if needed
});
hls.loadSource(url);
hls.attachMedia(video);
// HLS Error Handling
hls.on(Hls.Events.ERROR, function (event, data) {
console.error('HLS Error:', data);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.error('fatal network error encountered, try to recover');
hls.startLoad();
showToast("Stream network error. Retrying...", "error");
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.error('fatal media error encountered, try to recover');
hls.recoverMediaError();
showToast("Stream media error. Recovering...", "warning");
break;
default:
console.error('fatal error, cannot recover');
hls.destroy();
showToast("Fatal stream error. Playback failed.", "error");
break;
}
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
} else {
showToast("Your browser does not support HLS playback.", "error");
}
},
},
mounted: function (art) {
// Check metadata when video is ready/loaded
function checkVertical() {
const video = art.video;
if (video.videoHeight > 0 && video.videoHeight > video.videoWidth) {
console.log('Vertical video detected:', video.videoWidth, video.videoHeight);
// Use strict string format "W/H" without spaces for safer parsing
const ratio = `${video.videoWidth}/${video.videoHeight}`;
// Set Artplayer Aspect Ratio
art.aspectRatio = ratio;
art.video.style.objectFit = 'contain';
// Adjust container styling
const container = document.querySelector('.yt-player-container');
if (container) {
container.style.aspectRatio = ratio;
container.style.width = '100%';
container.style.maxWidth = '100%'; /* Critical for preventing overflow */
// On Desktop, limit width
if (window.innerWidth > 768) {
container.style.maxWidth = '450px';
container.style.margin = '0 auto';
}
}
}
}
// Check immediately
checkVertical();
// And on metadata load
art.on('video:loadedmetadata', checkVertical);
},
});
}
function toggleDescription() {
const desc = document.getElementById('videoDesc');
desc.style.webkitLineClamp = desc.style.webkitLineClamp === 'unset' ? '3' : 'unset';
}
function toggleComments() {
const content = document.getElementById('commentsContent');
const chevron = document.getElementById('commentsChevron');
const videoId = "{{ video_id }}";
if (content.style.display === 'none') {
content.style.display = 'block';
chevron.classList.add('rotated');
// Load comments only once
if (!commentsLoaded) {
loadComments(videoId);
commentsLoaded = true;
}
} else {
content.style.display = 'none';
chevron.classList.remove('rotated');
}
}
async function loadComments(videoId) {
const commentsList = document.getElementById('commentsList');
commentsList.innerHTML = `
<div class="yt-loader">
<div class="yt-spinner"></div>
<p>Loading comments...</p>
</div>
`;
try {
const response = await fetch(`/api/comments?v=${videoId}`);
const data = await response.json();
document.getElementById('commentCount').innerText = formatViews(data.count);
document.getElementById('commentCountDisplay').innerText = `${formatViews(data.count)} Comments`;
commentsList.innerHTML = '';
if (data.comments && data.comments.length > 0) {
data.comments.forEach(comment => {
const commentEl = document.createElement('div');
commentEl.className = 'yt-comment';
commentEl.innerHTML = `
<div class="yt-comment-avatar">
${comment.author_thumbnail
? `<img src="${comment.author_thumbnail}" alt="">`
: escapeHtml(comment.author.charAt(0).toUpperCase())
}
</div>
<div class="yt-comment-content">
<div class="yt-comment-header">
${comment.is_pinned ? '<span class="yt-pinned-badge">📌 Pinned</span>' : ''}
<span class="yt-comment-author">${escapeHtml(comment.author)}</span>
<span class="yt-comment-time">${comment.time || ''}</span>
</div>
<p class="yt-comment-text">${escapeHtml(comment.text)}</p>
<div class="yt-comment-actions">
<button class="yt-comment-action">
<i class="fas fa-thumbs-up"></i>
${comment.likes > 0 ? formatViews(comment.likes) : ''}
</button>
<button class="yt-comment-action">
<i class="fas fa-thumbs-down"></i>
</button>
</div>
</div>
`;
commentsList.appendChild(commentEl);
});
} else {
commentsList.innerHTML = `<p class="yt-no-comments">Comments are disabled or unavailable for this video.</p>`;
}
} catch (e) {
console.error('Error loading comments:', e);
commentsList.innerHTML = `<p class="yt-no-comments">Could not load comments.</p>`;
}
}
document.addEventListener('DOMContentLoaded', async () => {
const videoType = "{{ video_type }}";
const loading = document.getElementById('loading');
const videoInfo = document.getElementById('videoInfo');
const videoId = "{{ video_id }}";
if (videoType === 'local') {
const player = initArtplayer("{{ src }}", "");
document.getElementById('videoTitle').innerText = "{{ title }}";
document.getElementById('channelName').innerText = "Local Video";
document.getElementById('channelAvatarLetter').innerText = 'L';
loading.style.display = 'none';
videoInfo.style.display = 'block';
document.getElementById('commentsSection').style.display = 'none';
return;
}
try {
const response = await fetch(`/api/get_stream_info?v=${videoId}`);
if (response.status === 504) {
throw new Error("Server Timeout (504): content source is slow. Please refresh.");
}
if (!response.ok) {
throw new Error(`Server Error (${response.status})`);
}
// Check content type to avoid JSON syntax errors if HTML is returned
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
throw new Error("Received invalid response from server");
}
const data = await response.json();
if (data.error) {
loading.innerHTML = `<p style="color:#f00; text-align:center;">${data.error}</p>`;
showToast(data.error, 'error');
return;
}
const posterUrl = `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`;
// Determine video type
let streamType = 'auto';
if (data.original_url && (data.original_url.includes('.m3u8') || data.original_url.includes('manifest'))) {
streamType = 'm3u8';
}
const player = initArtplayer(data.stream_url, posterUrl, streamType);
loading.style.display = 'none';
videoInfo.style.display = 'block';
document.getElementById('videoTitle').innerText = data.title || 'Untitled';
document.getElementById('channelName').innerText = data.uploader || 'Unknown';
document.getElementById('viewCount').innerText = formatViews(data.view_count) + ' views';
document.getElementById('videoDesc').innerText = data.description || 'No description';
document.getElementById('descStats').innerText = `${formatViews(data.view_count)} views • ${data.upload_date || 'Recently'}`;
document.getElementById('downloadBtn').href = data.stream_url;
const uploaderName = data.uploader || 'Unknown';
document.getElementById('channelAvatarLetter').innerText = uploaderName.charAt(0).toUpperCase();
// Save to History
fetch('/api/save_video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: videoId,
title: data.title,
thumbnail: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
type: 'history'
})
});
// Subtitle Config
// Add subtitle to player config if available
player.subtitle.url = data.subtitle_url || '';
if (data.subtitle_url) {
player.subtitle.show = true;
player.notice.show = 'CC Enabled';
}
document.getElementById('saveBtn').onclick = async () => {
const res = await fetch('/api/save_video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: videoId,
title: data.title,
thumbnail: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
type: 'saved'
})
});
const resData = await res.json();
if (resData.status === 'success' || resData.status === 'already_saved') {
document.getElementById('saveBtn').innerHTML = '<i class="fas fa-bookmark"></i> Saved';
showToast('Video saved to library', 'success');
}
};
document.getElementById('shareBtn').onclick = () => {
navigator.clipboard.writeText(window.location.href);
showToast('Link copied to clipboard!', 'success');
};
// Related Videos
const relatedContainer = document.getElementById('relatedVideos');
if (data.related && data.related.length > 0) {
data.related.forEach(vid => {
const card = document.createElement('div');
card.className = 'yt-suggested-card';
card.innerHTML = `
<img src="${vid.thumbnail}" class="yt-suggested-thumb" loading="lazy">
<div class="yt-suggested-info">
<p class="yt-suggested-title">${escapeHtml(vid.title)}</p>
<p class="yt-suggested-channel">${escapeHtml(vid.uploader || 'Unknown')}</p>
<p class="yt-suggested-stats">${formatViews(vid.view_count)} views</p>
</div>
`;
card.onclick = () => window.location.href = `/watch?v=${vid.id}`;
relatedContainer.appendChild(card);
});
}
} catch (e) {
console.error(e);
loading.innerHTML = `
<div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; gap:16px;">
<i class="fas fa-wifi" style="font-size: 32px; color: #ff4e45;"></i>
<p style="color:#fff;">${e.message || 'Connection Error'}</p>
<button onclick="window.location.reload()" style="background:#3ea6ff; color:white; border:none; padding:10px 20px; border-radius:18px; cursor:pointer; font-weight:500;">Try Again</button>
</div>
`;
showToast(e.message || 'Connection Error', 'error');
}
});
function formatViews(views) {
if (!views) return '0';
const num = parseInt(views);
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
return num.toLocaleString();
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function summarizeVideo() {
const videoId = "{{ video_id }}";
const btn = document.getElementById('summarizeBtn');
const box = document.getElementById('summaryBox');
const text = document.getElementById('summaryText');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating...';
box.style.display = 'block';
text.innerText = 'Analyzing transcript and extracting key insights...';
try {
const response = await fetch(`/api/summarize?v=${videoId}`);
const data = await response.json();
if (data.success) {
text.innerText = data.summary;
} else {
text.innerText = data.message || 'Could not generate summary.';
}
} catch (e) {
text.innerText = 'Network error during summarization.';
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-magic"></i> Summarize';
}
}
</script>
{% endblock %}