Compare commits

...

No commits in common. "aa1a419c35927b81f89cf0a4fb7edb435d27cfe3" and "f429116ed099738264c3e6377db5b5429c103412" have entirely different histories.

2618 changed files with 11958 additions and 626827 deletions

13
.dockerignore Executable file
View file

@ -0,0 +1,13 @@
.venv/
.venv_clean/
env/
__pycache__/
.git/
.DS_Store
*.pyc
*.pyo
*.pyd
.idea/
.vscode/
videos/
data/

12
.env.example Executable file
View file

@ -0,0 +1,12 @@
# KV-Tube Environment Configuration
# Copy this file to .env and customize as needed
# Secret key for Flask sessions (required for production)
# Generate a secure key: python -c "import os; print(os.urandom(32).hex())"
SECRET_KEY=your-secure-secret-key-here
# Environment: development or production
FLASK_ENV=development
# Local video directory (optional)
KVTUBE_VIDEO_DIR=./videos

1
.gemini/tmp/ytfetcher Submodule

@ -0,0 +1 @@
Subproject commit 246c4c349d97205eb2b51d7d3999ea846f5b2bdc

68
.github/workflows/docker-publish.yml vendored Executable file
View file

@ -0,0 +1,68 @@
name: Docker Build & Push
on:
push:
tags:
- 'v*'
env:
# Use docker.io for Docker Hub if empty
REGISTRY: docker.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log into Forgejo Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: git.khoavo.myds.me
username: ${{ secrets.FORGEJO_USERNAME }}
password: ${{ secrets.FORGEJO_PASSWORD }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
git.khoavo.myds.me/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ github.ref == 'refs/tags/v*' }}
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max

12
.gitignore vendored Executable file
View file

@ -0,0 +1,12 @@
.DS_Store
__pycache__/
*.pyc
venv/
.venv/
.venv_clean/
.env
data/
videos/
*.db
server.log
.ruff_cache/

0
API_DOCUMENTATION.md Normal file → Executable file
View file

66
Dockerfile Normal file → Executable file
View file

@ -1,33 +1,33 @@
# Build stage # Build stage
FROM python:3.11-slim FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# Install system dependencies (ffmpeg is critical for yt-dlp) # Install system dependencies (ffmpeg is critical for yt-dlp)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \ ffmpeg \
curl \ curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Python dependencies # Install Python dependencies
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy application code # Copy application code
COPY . . COPY . .
# Environment variables # Environment variables
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV FLASK_APP=wsgi.py ENV FLASK_APP=wsgi.py
ENV FLASK_ENV=production ENV FLASK_ENV=production
# Create directories for data persistence # Create directories for data persistence
RUN mkdir -p /app/videos /app/data RUN mkdir -p /app/videos /app/data
# Expose port # Expose port
EXPOSE 5000 EXPOSE 5000
# Run with Entrypoint (handles updates) # Run with Entrypoint (handles updates)
COPY entrypoint.sh /app/entrypoint.sh COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
CMD ["/app/entrypoint.sh"] CMD ["/app/entrypoint.sh"]

124
README.md Normal file → Executable file
View file

@ -1,62 +1,62 @@
# KV-Tube v3.0 # KV-Tube v3.0
> A lightweight, privacy-focused YouTube frontend web application with AI-powered features. > A lightweight, privacy-focused YouTube frontend web application with AI-powered features.
KV-Tube removes distractions, tracking, and ads from the YouTube watching experience. It provides a clean interface to search, watch, and discover related content without needing a Google account. KV-Tube removes distractions, tracking, and ads from the YouTube watching experience. It provides a clean interface to search, watch, and discover related content without needing a Google account.
## 🚀 Key Features (v3) ## 🚀 Key Features (v3)
- **Privacy First**: No tracking, no ads. - **Privacy First**: No tracking, no ads.
- **Clean Interface**: Distraction-free watching experience. - **Clean Interface**: Distraction-free watching experience.
- **Efficient Streaming**: Direct video stream extraction using `yt-dlp`. - **Efficient Streaming**: Direct video stream extraction using `yt-dlp`.
- **AI Summary (Experimental)**: Generate concise summaries of videos (Currently disabled due to upstream rate limits). - **AI Summary (Experimental)**: Generate concise summaries of videos (Currently disabled due to upstream rate limits).
- **Multi-Language**: Support for English and Vietnamese (UI & Content). - **Multi-Language**: Support for English and Vietnamese (UI & Content).
- **Auto-Update**: Includes `update_deps.py` to easily keep core fetching tools up-to-date. - **Auto-Update**: Includes `update_deps.py` to easily keep core fetching tools up-to-date.
## 🛠️ Architecture Data Flow ## 🛠️ Architecture Data Flow
![Architecture Data Flow](https://mermaid.ink/img/Z3JhcGggVEQKICAgIHN1YmdyYXBoIENsaWVudCBbIkNsaWVudCBTaWRlIl0KICAgICAgICBVc2VyWyJVc2VyIEJyb3dzZXIiXQogICAgZW5kCgogICAgc3ViZ3JhcGggQmFja2VuZCBbIktWVHViZSBCYWNrZW5kIFN5c3RlbSJdCiAgICAgICAgU2VydmVyWyJLVlR1YmUgU2VydmVyIl0KICAgICAgICBZVERMUFsieXRkbHAgQ29yZSJdCiAgICAgICAgWVRGZXRjaGVyWyJZVEZldGNoZXIgTGliIl0KICAgIGVuZAoKICAgIHN1YmdyYXBoIEV4dGVybmFsIFsiRXh0ZXJuYWwgU2VydmljZXMiXQogICAgICAgIFlvdVR1YmVbIllvdVR1YmUgVjMgQVBJIl0KICAgIGVuZAoKICAgICUlIE1haW4gRmxvdwogICAgVXNlciAtLSAiMS4gU2VhcmNoL1dhdGNoIFJlcXVlc3QiIC0tPiBTZXJ2ZXIKICAgIFNlcnZlciAtLSAiMi4gRXh0cmFjdCBNZXRhZGF0YSIgLS0+IFlURExQCiAgICBZVERMUCAtLSAiMy4gTmV0d29yayBSZXEgKENvb2tpZXMpIiAtLT4gWW91VHViZQogICAgWW91VHViZSAtLSAiNC4gUmF3IFN0cmVhbXMiIC0tPiBZVERMUAogICAgWVRETFAgLS0gIjUuIFN0cmVhbSBVUkwiIC0tPiBTZXJ2ZXIKICAgIFNlcnZlciAtLSAiNi4gUmVuZGVyL1Byb3h5IiAtLT4gVXNlcgogICAgCiAgICAlJSBGYWxsYmFjay9TZWNvbmRhcnkgRmxvdwogICAgU2VydmVyIC0uLT4gWVRGZXRjaGVyCiAgICBZVEZldGNoZXIgLS4tPiBZb3VUdWJlCiAgICBZVEZldGNoZXIgLS4gIkVycm9yIC8gTm8gVHJhbnNjcmlwdCIgLi0+IFNlcnZlcgoKICAgICUlIFN0eWxpbmcgdG8gbWFrZSBpdCBwb3AKICAgIHN0eWxlIEJhY2tlbmQgZmlsbDojZjlmOWY5LHN0cm9rZTojMzMzLHN0cm9rZS13aWR0aDoycHgKICAgIHN0eWxlIEV4dGVybmFsIGZpbGw6I2ZmZWJlZSxzdHJva2U6I2YwMCxzdHJva2Utd2lkdGg6MnB4) ![Architecture Data Flow](https://mermaid.ink/img/Z3JhcGggVEQKICAgIHN1YmdyYXBoIENsaWVudCBbIkNsaWVudCBTaWRlIl0KICAgICAgICBVc2VyWyJVc2VyIEJyb3dzZXIiXQogICAgZW5kCgogICAgc3ViZ3JhcGggQmFja2VuZCBbIktWVHViZSBCYWNrZW5kIFN5c3RlbSJdCiAgICAgICAgU2VydmVyWyJLVlR1YmUgU2VydmVyIl0KICAgICAgICBZVERMUFsieXRkbHAgQ29yZSJdCiAgICAgICAgWVRGZXRjaGVyWyJZVEZldGNoZXIgTGliIl0KICAgIGVuZAoKICAgIHN1YmdyYXBoIEV4dGVybmFsIFsiRXh0ZXJuYWwgU2VydmljZXMiXQogICAgICAgIFlvdVR1YmVbIllvdVR1YmUgVjMgQVBJIl0KICAgIGVuZAoKICAgICUlIE1haW4gRmxvdwogICAgVXNlciAtLSAiMS4gU2VhcmNoL1dhdGNoIFJlcXVlc3QiIC0tPiBTZXJ2ZXIKICAgIFNlcnZlciAtLSAiMi4gRXh0cmFjdCBNZXRhZGF0YSIgLS0+IFlURExQCiAgICBZVERMUCAtLSAiMy4gTmV0d29yayBSZXEgKENvb2tpZXMpIiAtLT4gWW91VHViZQogICAgWW91VHViZSAtLSAiNC4gUmF3IFN0cmVhbXMiIC0tPiBZVERMUAogICAgWVRETFAgLS0gIjUuIFN0cmVhbSBVUkwiIC0tPiBTZXJ2ZXIKICAgIFNlcnZlciAtLSAiNi4gUmVuZGVyL1Byb3h5IiAtLT4gVXNlcgogICAgCiAgICAlJSBGYWxsYmFjay9TZWNvbmRhcnkgRmxvdwogICAgU2VydmVyIC0uLT4gWVRGZXRjaGVyCiAgICBZVEZldGNoZXIgLS4tPiBZb3VUdWJlCiAgICBZVEZldGNoZXIgLS4gIkVycm9yIC8gTm8gVHJhbnNjcmlwdCIgLi0+IFNlcnZlcgoKICAgICUlIFN0eWxpbmcgdG8gbWFrZSBpdCBwb3AKICAgIHN0eWxlIEJhY2tlbmQgZmlsbDojZjlmOWY5LHN0cm9rZTojMzMzLHN0cm9rZS13aWR0aDoycHgKICAgIHN0eWxlIEV4dGVybmFsIGZpbGw6I2ZmZWJlZSxzdHJva2U6I2YwMCxzdHJva2Utd2lkdGg6MnB4)
## 🔧 Installation & Usage ## 🔧 Installation & Usage
### Prerequisites ### Prerequisites
- Python 3.10+ - Python 3.10+
- Git - Git
- Valid `cookies.txt` (Optional, for bypassing age-restrictions or rate limits) - Valid `cookies.txt` (Optional, for bypassing age-restrictions or rate limits)
### Local Setup ### Local Setup
1. Clone the repository: 1. Clone the repository:
```bash ```bash
git clone https://git.khoavo.myds.me/vndangkhoa/kv-tube.git git clone https://git.khoavo.myds.me/vndangkhoa/kv-tube.git
cd kv-tube cd kv-tube
``` ```
2. Install dependencies: 2. Install dependencies:
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
``` ```
3. Run the application: 3. Run the application:
```bash ```bash
python wsgi.py python wsgi.py
``` ```
4. Access at `http://localhost:5002` 4. Access at `http://localhost:5002`
### Docker Deployment (Linux/AMD64) ### Docker Deployment (Linux/AMD64)
Built for stability and ease of use. Built for stability and ease of use.
```bash ```bash
docker pull vndangkhoa/kv-tube:latest docker pull vndangkhoa/kv-tube:latest
docker run -d -p 5002:5002 -v $(pwd)/cookies.txt:/app/cookies.txt vndangkhoa/kv-tube:latest docker run -d -p 5002:5002 -v $(pwd)/cookies.txt:/app/cookies.txt vndangkhoa/kv-tube:latest
``` ```
## 📦 Updates ## 📦 Updates
- **v3.0**: Major release. - **v3.0**: Major release.
- Full modularization of backend routes. - Full modularization of backend routes.
- Integrated `ytfetcher` for specialized fetching. - Integrated `ytfetcher` for specialized fetching.
- Added manual dependency update script (`update_deps.py`). - Added manual dependency update script (`update_deps.py`).
- Enhanced error handling for upstream rate limits. - Enhanced error handling for upstream rate limits.
- Docker `linux/amd64` support verified. - Docker `linux/amd64` support verified.
--- ---
*Developed by Khoa Vo* *Developed by Khoa Vo*

0
USER_GUIDE.md Normal file → Executable file
View file

Binary file not shown.

324
app/__init__.py Normal file → Executable file
View file

@ -1,162 +1,162 @@
""" """
KV-Tube App Package KV-Tube App Package
Flask application factory pattern Flask application factory pattern
""" """
from flask import Flask from flask import Flask
import os import os
import sqlite3 import sqlite3
import logging import logging
# Setup logging # Setup logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Database configuration # Database configuration
DATA_DIR = os.environ.get("KVTUBE_DATA_DIR", "data") DATA_DIR = os.environ.get("KVTUBE_DATA_DIR", "data")
DB_NAME = os.path.join(DATA_DIR, "kvtube.db") DB_NAME = os.path.join(DATA_DIR, "kvtube.db")
def init_db(): def init_db():
"""Initialize the database with required tables.""" """Initialize the database with required tables."""
# Ensure data directory exists # Ensure data directory exists
if not os.path.exists(DATA_DIR): if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR) os.makedirs(DATA_DIR)
conn = sqlite3.connect(DB_NAME) conn = sqlite3.connect(DB_NAME)
c = conn.cursor() c = conn.cursor()
# Users Table # Users Table
c.execute("""CREATE TABLE IF NOT EXISTS users ( c.execute("""CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL password TEXT NOT NULL
)""") )""")
# User Videos (history/saved) # User Videos (history/saved)
c.execute("""CREATE TABLE IF NOT EXISTS user_videos ( c.execute("""CREATE TABLE IF NOT EXISTS user_videos (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER, user_id INTEGER,
video_id TEXT, video_id TEXT,
title TEXT, title TEXT,
thumbnail TEXT, thumbnail TEXT,
type TEXT, type TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
)""") )""")
# Video Cache # Video Cache
c.execute("""CREATE TABLE IF NOT EXISTS video_cache ( c.execute("""CREATE TABLE IF NOT EXISTS video_cache (
video_id TEXT PRIMARY KEY, video_id TEXT PRIMARY KEY,
data TEXT, data TEXT,
expires_at DATETIME expires_at DATETIME
)""") )""")
conn.commit() conn.commit()
conn.close() conn.close()
logger.info("Database initialized") logger.info("Database initialized")
def create_app(config_name=None): def create_app(config_name=None):
""" """
Application factory for creating Flask app instances. Application factory for creating Flask app instances.
Args: Args:
config_name: Configuration name ('development', 'production', or None for default) config_name: Configuration name ('development', 'production', or None for default)
Returns: Returns:
Flask application instance Flask application instance
""" """
app = Flask(__name__, app = Flask(__name__,
template_folder='../templates', template_folder='../templates',
static_folder='../static') static_folder='../static')
# Load configuration # Load configuration
app.secret_key = "super_secret_key_change_this" # Required for sessions app.secret_key = "super_secret_key_change_this" # Required for sessions
# Fix for OMP: Error #15 # Fix for OMP: Error #15
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE" os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
# Initialize database # Initialize database
init_db() init_db()
# Register Jinja filters # Register Jinja filters
register_filters(app) register_filters(app)
# Register Blueprints # Register Blueprints
register_blueprints(app) register_blueprints(app)
# Start Background Cache Warmer (x5 Speedup) # Start Background Cache Warmer (x5 Speedup)
try: try:
from app.routes.api import start_background_warmer from app.routes.api import start_background_warmer
start_background_warmer() start_background_warmer()
except Exception as e: except Exception as e:
logger.warning(f"Failed to start background warmer: {e}") logger.warning(f"Failed to start background warmer: {e}")
logger.info("KV-Tube app created successfully") logger.info("KV-Tube app created successfully")
return app return app
def register_filters(app): def register_filters(app):
"""Register custom Jinja2 template filters.""" """Register custom Jinja2 template filters."""
@app.template_filter("format_views") @app.template_filter("format_views")
def format_views(views): def format_views(views):
if not views: if not views:
return "0" return "0"
try: try:
num = int(views) num = int(views)
if num >= 1000000: if num >= 1000000:
return f"{num / 1000000:.1f}M" return f"{num / 1000000:.1f}M"
if num >= 1000: if num >= 1000:
return f"{num / 1000:.0f}K" return f"{num / 1000:.0f}K"
return f"{num:,}" return f"{num:,}"
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
logger.debug(f"View formatting failed: {e}") logger.debug(f"View formatting failed: {e}")
return str(views) return str(views)
@app.template_filter("format_date") @app.template_filter("format_date")
def format_date(value): def format_date(value):
if not value: if not value:
return "Recently" return "Recently"
from datetime import datetime from datetime import datetime
try: try:
# Handle YYYYMMDD # Handle YYYYMMDD
if len(str(value)) == 8 and str(value).isdigit(): if len(str(value)) == 8 and str(value).isdigit():
dt = datetime.strptime(str(value), "%Y%m%d") dt = datetime.strptime(str(value), "%Y%m%d")
# Handle Timestamp # Handle Timestamp
elif isinstance(value, (int, float)): elif isinstance(value, (int, float)):
dt = datetime.fromtimestamp(value) dt = datetime.fromtimestamp(value)
# Handle YYYY-MM-DD # Handle YYYY-MM-DD
else: else:
try: try:
dt = datetime.strptime(str(value), "%Y-%m-%d") dt = datetime.strptime(str(value), "%Y-%m-%d")
except ValueError: except ValueError:
return str(value) return str(value)
now = datetime.now() now = datetime.now()
diff = now - dt diff = now - dt
if diff.days > 365: if diff.days > 365:
return f"{diff.days // 365} years ago" return f"{diff.days // 365} years ago"
if diff.days > 30: if diff.days > 30:
return f"{diff.days // 30} months ago" return f"{diff.days // 30} months ago"
if diff.days > 0: if diff.days > 0:
return f"{diff.days} days ago" return f"{diff.days} days ago"
if diff.seconds > 3600: if diff.seconds > 3600:
return f"{diff.seconds // 3600} hours ago" return f"{diff.seconds // 3600} hours ago"
return "Just now" return "Just now"
except Exception as e: except Exception as e:
logger.debug(f"Date formatting failed: {e}") logger.debug(f"Date formatting failed: {e}")
return str(value) return str(value)
def register_blueprints(app): def register_blueprints(app):
"""Register all application blueprints.""" """Register all application blueprints."""
from app.routes import pages_bp, api_bp, streaming_bp from app.routes import pages_bp, api_bp, streaming_bp
app.register_blueprint(pages_bp) app.register_blueprint(pages_bp)
app.register_blueprint(api_bp) app.register_blueprint(api_bp)
app.register_blueprint(streaming_bp) app.register_blueprint(streaming_bp)
logger.info("Blueprints registered: pages, api, streaming") logger.info("Blueprints registered: pages, api, streaming")

18
app/routes/__init__.py Normal file → Executable file
View file

@ -1,9 +1,9 @@
""" """
KV-Tube Routes Package KV-Tube Routes Package
Exports all Blueprints for registration Exports all Blueprints for registration
""" """
from app.routes.pages import pages_bp from app.routes.pages import pages_bp
from app.routes.api import api_bp from app.routes.api import api_bp
from app.routes.streaming import streaming_bp from app.routes.streaming import streaming_bp
__all__ = ['pages_bp', 'api_bp', 'streaming_bp'] __all__ = ['pages_bp', 'api_bp', 'streaming_bp']

158
app/routes/api.py Normal file → Executable file
View file

@ -15,11 +15,11 @@ import time
import random import random
import concurrent.futures import concurrent.futures
import yt_dlp import yt_dlp
# from ytfetcher import YTFetcher
from app.services.settings import SettingsService from app.services.settings import SettingsService
from app.services.summarizer import TextRankSummarizer from app.services.summarizer import TextRankSummarizer
from app.services.gemini_summarizer import summarize_with_gemini, extract_key_points_with_gemini from app.services.gemini_summarizer import summarize_with_gemini, extract_key_points_with_gemini
from app.services.youtube import YouTubeService from app.services.youtube import YouTubeService
from app.services.transcript_service import TranscriptService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1405,25 +1405,25 @@ def summarize_video():
return jsonify({"error": "No video ID"}), 400 return jsonify({"error": "No video ID"}), 400
try: try:
# 1. Get Transcript Text # 1. Get Transcript Text using TranscriptService (with ytfetcher fallback)
text = get_transcript_text(video_id) text = TranscriptService.get_transcript(video_id)
if not text: if not text:
return jsonify({ return jsonify({
"success": False, "success": False,
"error": "No transcript available to summarize." "error": "No transcript available to summarize."
}) })
# 2. Use TextRank Summarizer (Gemini removed per user request) # 2. Use TextRank Summarizer - generate longer, more meaningful summaries
summarizer = TextRankSummarizer() summarizer = TextRankSummarizer()
summary_text = summarizer.summarize(text, num_sentences=3) summary_text = summarizer.summarize(text, num_sentences=5) # Increased from 3 to 5
# Limit to 300 characters for concise display # Allow longer summaries for more meaningful content (600 chars instead of 300)
if len(summary_text) > 300: if len(summary_text) > 600:
summary_text = summary_text[:297] + "..." summary_text = summary_text[:597] + "..."
# Extract key points from summary (heuristic) # Key points will be extracted by WebLLM on frontend (better quality)
sentences = [s.strip() for s in summary_text.split('.') if len(s.strip()) > 15] # Backend just returns empty list - WebLLM generates conceptual key points
key_points = sentences[:3] key_points = []
# Store original versions # Store original versions
original_summary = summary_text original_summary = summary_text
@ -1472,78 +1472,90 @@ def translate_text(text, target_lang='vi'):
def get_transcript_text(video_id): def get_transcript_text(video_id):
""" """
Fetch transcript using strictly YTFetcher as requested. Fetch transcript using yt-dlp (downloading subtitles to file).
Ensure 'ytfetcher' is up to date before usage. Reliable method that handles auto-generated captions and cookies.
""" """
from ytfetcher import YTFetcher import yt_dlp
from ytfetcher.config import HTTPConfig import glob
import random import random
import json
import os import os
import http.cookiejar
try: try:
# 1. Prepare Cookies if available video_id = video_id.strip()
# This was key to the previous success! logger.info(f"Fetching transcript for {video_id} using yt-dlp")
cookie_header = ""
cookies_path = os.environ.get('COOKIES_FILE', 'cookies.txt')
if os.path.exists(cookies_path): # Use a temporary filename pattern
try: temp_prefix = f"transcript_{video_id}_{random.randint(1000, 9999)}"
cj = http.cookiejar.MozillaCookieJar(cookies_path)
cj.load()
cookies_list = []
for cookie in cj:
cookies_list.append(f"{cookie.name}={cookie.value}")
cookie_header = "; ".join(cookies_list)
logger.info(f"Loaded {len(cookies_list)} cookies for YTFetcher")
except Exception as e:
logger.warning(f"Failed to process cookies: {e}")
# 2. Configuration to look like a real browser
user_agents = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
]
headers = { ydl_opts = {
"User-Agent": random.choice(user_agents), 'skip_download': True,
"Accept-Language": "en-US,en;q=0.9", 'quiet': True,
'no_warnings': True,
'cookiefile': os.environ.get('COOKIES_FILE', 'cookies.txt') if os.path.exists(os.environ.get('COOKIES_FILE', 'cookies.txt')) else None,
'writesubtitles': True,
'writeautomaticsub': True,
'subtitleslangs': ['en', 'vi', 'en-US'],
'outtmpl': f"/tmp/{temp_prefix}", # Save to /tmp
'subtitlesformat': 'json3/vtt/best', # Prefer json3 for parsing, then vtt
} }
# Inject cookie header if we have it with yt_dlp.YoutubeDL(ydl_opts) as ydl:
if cookie_header: # This will download the subtitle file to /tmp/
headers["Cookie"] = cookie_header ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
config = HTTPConfig(headers=headers) # Find the downloaded file
# yt-dlp appends language code, e.g. .en.json3
# Initialize Fetcher # We look for any file with our prefix
fetcher = YTFetcher.from_video_ids( downloaded_files = glob.glob(f"/tmp/{temp_prefix}*")
video_ids=[video_id],
http_config=config, if not downloaded_files:
languages=['en', 'en-US', 'vi'] logger.warning("yt-dlp finished but no subtitle file found.")
) return None
# Fetch
logger.info(f"Fetching transcript with YTFetcher for {video_id}")
results = fetcher.fetch_transcripts()
if results:
data = results[0]
# Check for transcript data
if data.transcripts:
logger.info("YTFetcher: Transcript found.")
text_lines = [t.text.strip() for t in data.transcripts if t.text.strip()]
return " ".join(text_lines)
else:
logger.warning("YTFetcher: No transcript in result.")
# Pick the best file (prefer json3, then vtt)
selected_file = None
for ext in ['.json3', '.vtt', '.ttml', '.srv3']:
for f in downloaded_files:
if f.endswith(ext):
selected_file = f
break
if selected_file: break
if not selected_file:
selected_file = downloaded_files[0]
# Read content
with open(selected_file, 'r', encoding='utf-8') as f:
content = f.read()
# Cleanup
for f in downloaded_files:
try:
os.remove(f)
except:
pass
# Parse
if selected_file.endswith('.json3') or content.strip().startswith('{'):
try:
json_data = json.loads(content)
events = json_data.get('events', [])
text_parts = []
for event in events:
segs = event.get('segs', [])
for seg in segs:
txt = seg.get('utf8', '').strip()
if txt and txt != '\n':
text_parts.append(txt)
return " ".join(text_parts)
except Exception as je:
logger.warning(f"JSON3 parse failed: {je}")
return parse_transcript_content(content)
except Exception as e: except Exception as e:
import traceback logger.error(f"Transcript fetch failed: {e}")
tb = traceback.format_exc()
logger.error(f"YTFetcher Execution Failed: {e}\n{tb}")
return None
return None return None

0
app/routes/pages.py Normal file → Executable file
View file

0
app/routes/streaming.py Normal file → Executable file
View file

2
app/services/__init__.py Normal file → Executable file
View file

@ -1 +1 @@
"""KV-Tube Services Package""" """KV-Tube Services Package"""

434
app/services/cache.py Normal file → Executable file
View file

@ -1,217 +1,217 @@
""" """
Cache Service Module Cache Service Module
SQLite-based caching with connection pooling SQLite-based caching with connection pooling
""" """
import sqlite3 import sqlite3
import json import json
import time import time
import threading import threading
import logging import logging
from typing import Optional, Any, Dict from typing import Optional, Any, Dict
from contextlib import contextmanager from contextlib import contextmanager
from config import Config from config import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ConnectionPool: class ConnectionPool:
"""Thread-safe SQLite connection pool""" """Thread-safe SQLite connection pool"""
def __init__(self, db_path: str, max_connections: int = 5): def __init__(self, db_path: str, max_connections: int = 5):
self.db_path = db_path self.db_path = db_path
self.max_connections = max_connections self.max_connections = max_connections
self._local = threading.local() self._local = threading.local()
self._lock = threading.Lock() self._lock = threading.Lock()
self._init_db() self._init_db()
def _init_db(self): def _init_db(self):
"""Initialize database tables""" """Initialize database tables"""
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
c = conn.cursor() c = conn.cursor()
# Users table # Users table
c.execute('''CREATE TABLE IF NOT EXISTS users ( c.execute('''CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL password TEXT NOT NULL
)''') )''')
# User videos (history/saved) # User videos (history/saved)
c.execute('''CREATE TABLE IF NOT EXISTS user_videos ( c.execute('''CREATE TABLE IF NOT EXISTS user_videos (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER, user_id INTEGER,
video_id TEXT, video_id TEXT,
title TEXT, title TEXT,
thumbnail TEXT, thumbnail TEXT,
type TEXT, type TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
)''') )''')
# Video cache # Video cache
c.execute('''CREATE TABLE IF NOT EXISTS video_cache ( c.execute('''CREATE TABLE IF NOT EXISTS video_cache (
video_id TEXT PRIMARY KEY, video_id TEXT PRIMARY KEY,
data TEXT, data TEXT,
expires_at REAL expires_at REAL
)''') )''')
conn.commit() conn.commit()
conn.close() conn.close()
def get_connection(self) -> sqlite3.Connection: def get_connection(self) -> sqlite3.Connection:
"""Get a thread-local database connection""" """Get a thread-local database connection"""
if not hasattr(self._local, 'connection') or self._local.connection is None: if not hasattr(self._local, 'connection') or self._local.connection is None:
self._local.connection = sqlite3.connect(self.db_path) self._local.connection = sqlite3.connect(self.db_path)
self._local.connection.row_factory = sqlite3.Row self._local.connection.row_factory = sqlite3.Row
return self._local.connection return self._local.connection
@contextmanager @contextmanager
def connection(self): def connection(self):
"""Context manager for database connections""" """Context manager for database connections"""
conn = self.get_connection() conn = self.get_connection()
try: try:
yield conn yield conn
conn.commit() conn.commit()
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
logger.error(f"Database error: {e}") logger.error(f"Database error: {e}")
raise raise
def close(self): def close(self):
"""Close the thread-local connection""" """Close the thread-local connection"""
if hasattr(self._local, 'connection') and self._local.connection: if hasattr(self._local, 'connection') and self._local.connection:
self._local.connection.close() self._local.connection.close()
self._local.connection = None self._local.connection = None
# Global connection pool # Global connection pool
_pool: Optional[ConnectionPool] = None _pool: Optional[ConnectionPool] = None
def get_pool() -> ConnectionPool: def get_pool() -> ConnectionPool:
"""Get or create the global connection pool""" """Get or create the global connection pool"""
global _pool global _pool
if _pool is None: if _pool is None:
_pool = ConnectionPool(Config.DB_NAME) _pool = ConnectionPool(Config.DB_NAME)
return _pool return _pool
def get_db_connection() -> sqlite3.Connection: def get_db_connection() -> sqlite3.Connection:
"""Get a database connection - backward compatibility""" """Get a database connection - backward compatibility"""
return get_pool().get_connection() return get_pool().get_connection()
class CacheService: class CacheService:
"""Service for caching video metadata""" """Service for caching video metadata"""
@staticmethod @staticmethod
def get_video_cache(video_id: str) -> Optional[Dict[str, Any]]: def get_video_cache(video_id: str) -> Optional[Dict[str, Any]]:
""" """
Get cached video data if not expired Get cached video data if not expired
Args: Args:
video_id: YouTube video ID video_id: YouTube video ID
Returns: Returns:
Cached data dict or None if not found/expired Cached data dict or None if not found/expired
""" """
try: try:
pool = get_pool() pool = get_pool()
with pool.connection() as conn: with pool.connection() as conn:
row = conn.execute( row = conn.execute(
'SELECT data, expires_at FROM video_cache WHERE video_id = ?', 'SELECT data, expires_at FROM video_cache WHERE video_id = ?',
(video_id,) (video_id,)
).fetchone() ).fetchone()
if row: if row:
expires_at = float(row['expires_at']) expires_at = float(row['expires_at'])
if time.time() < expires_at: if time.time() < expires_at:
return json.loads(row['data']) return json.loads(row['data'])
else: else:
# Expired, clean it up # Expired, clean it up
conn.execute('DELETE FROM video_cache WHERE video_id = ?', (video_id,)) conn.execute('DELETE FROM video_cache WHERE video_id = ?', (video_id,))
return None return None
except Exception as e: except Exception as e:
logger.error(f"Cache get error for {video_id}: {e}") logger.error(f"Cache get error for {video_id}: {e}")
return None return None
@staticmethod @staticmethod
def set_video_cache(video_id: str, data: Dict[str, Any], ttl: int = None) -> bool: def set_video_cache(video_id: str, data: Dict[str, Any], ttl: int = None) -> bool:
""" """
Cache video data Cache video data
Args: Args:
video_id: YouTube video ID video_id: YouTube video ID
data: Data to cache data: Data to cache
ttl: Time to live in seconds (default from config) ttl: Time to live in seconds (default from config)
Returns: Returns:
True if cached successfully True if cached successfully
""" """
try: try:
if ttl is None: if ttl is None:
ttl = Config.CACHE_VIDEO_TTL ttl = Config.CACHE_VIDEO_TTL
expires_at = time.time() + ttl expires_at = time.time() + ttl
pool = get_pool() pool = get_pool()
with pool.connection() as conn: with pool.connection() as conn:
conn.execute( conn.execute(
'INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)', 'INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)',
(video_id, json.dumps(data), expires_at) (video_id, json.dumps(data), expires_at)
) )
return True return True
except Exception as e: except Exception as e:
logger.error(f"Cache set error for {video_id}: {e}") logger.error(f"Cache set error for {video_id}: {e}")
return False return False
@staticmethod @staticmethod
def clear_expired(): def clear_expired():
"""Remove all expired cache entries""" """Remove all expired cache entries"""
try: try:
pool = get_pool() pool = get_pool()
with pool.connection() as conn: with pool.connection() as conn:
conn.execute('DELETE FROM video_cache WHERE expires_at < ?', (time.time(),)) conn.execute('DELETE FROM video_cache WHERE expires_at < ?', (time.time(),))
except Exception as e: except Exception as e:
logger.error(f"Cache cleanup error: {e}") logger.error(f"Cache cleanup error: {e}")
class HistoryService: class HistoryService:
"""Service for user video history""" """Service for user video history"""
@staticmethod @staticmethod
def get_history(limit: int = 50) -> list: def get_history(limit: int = 50) -> list:
"""Get watch history""" """Get watch history"""
try: try:
pool = get_pool() pool = get_pool()
with pool.connection() as conn: with pool.connection() as conn:
rows = conn.execute( rows = conn.execute(
'SELECT video_id as id, title, thumbnail FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT ?', 'SELECT video_id as id, title, thumbnail FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT ?',
(limit,) (limit,)
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [dict(row) for row in rows]
except Exception as e: except Exception as e:
logger.error(f"History get error: {e}") logger.error(f"History get error: {e}")
return [] return []
@staticmethod @staticmethod
def add_to_history(video_id: str, title: str, thumbnail: str) -> bool: def add_to_history(video_id: str, title: str, thumbnail: str) -> bool:
"""Add a video to history""" """Add a video to history"""
try: try:
pool = get_pool() pool = get_pool()
with pool.connection() as conn: with pool.connection() as conn:
conn.execute( conn.execute(
'INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (?, ?, ?, ?, ?)',
(1, video_id, title, thumbnail, 'history') (1, video_id, title, thumbnail, 'history')
) )
return True return True
except Exception as e: except Exception as e:
logger.error(f"History add error: {e}") logger.error(f"History add error: {e}")
return False return False

270
app/services/gemini_summarizer.py Normal file → Executable file
View file

@ -1,135 +1,135 @@
""" """
AI-powered video summarizer using Google Gemini. AI-powered video summarizer using Google Gemini.
""" """
import os import os
import logging import logging
import base64 import base64
from typing import Optional from typing import Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Obfuscated API key - encoded with app-specific salt # Obfuscated API key - encoded with app-specific salt
# This prevents casual copying but is not cryptographically secure # This prevents casual copying but is not cryptographically secure
_OBFUSCATED_KEY = "QklqYVN5RG9yLWpsdmhtMEVGVkxnV3F4TllFR0MyR21oQUY3Y3Rv" _OBFUSCATED_KEY = "QklqYVN5RG9yLWpsdmhtMEVGVkxnV3F4TllFR0MyR21oQUY3Y3Rv"
_APP_SALT = "KV-Tube-2026" _APP_SALT = "KV-Tube-2026"
def _decode_api_key() -> str: def _decode_api_key() -> str:
"""Decode the obfuscated API key. Only works with correct app context.""" """Decode the obfuscated API key. Only works with correct app context."""
try: try:
# Decode base64 # Decode base64
decoded = base64.b64decode(_OBFUSCATED_KEY).decode('utf-8') decoded = base64.b64decode(_OBFUSCATED_KEY).decode('utf-8')
# Remove prefix added during encoding # Remove prefix added during encoding
if decoded.startswith("Bij"): if decoded.startswith("Bij"):
return "AI" + decoded[3:] # Reconstruct original key return "AI" + decoded[3:] # Reconstruct original key
return decoded return decoded
except: except:
return "" return ""
# Get API key: prefer environment variable, fall back to obfuscated default # Get API key: prefer environment variable, fall back to obfuscated default
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") or _decode_api_key() GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") or _decode_api_key()
def summarize_with_gemini(transcript: str, video_title: str = "") -> Optional[str]: def summarize_with_gemini(transcript: str, video_title: str = "") -> Optional[str]:
""" """
Summarize video transcript using Google Gemini AI. Summarize video transcript using Google Gemini AI.
Args: Args:
transcript: The video transcript text transcript: The video transcript text
video_title: Optional video title for context video_title: Optional video title for context
Returns: Returns:
AI-generated summary or None if failed AI-generated summary or None if failed
""" """
if not GEMINI_API_KEY: if not GEMINI_API_KEY:
logger.warning("GEMINI_API_KEY not set, falling back to TextRank") logger.warning("GEMINI_API_KEY not set, falling back to TextRank")
return None return None
try: try:
logger.info(f"Importing google.generativeai... Key len: {len(GEMINI_API_KEY)}") logger.info(f"Importing google.generativeai... Key len: {len(GEMINI_API_KEY)}")
import google.generativeai as genai import google.generativeai as genai
genai.configure(api_key=GEMINI_API_KEY) genai.configure(api_key=GEMINI_API_KEY)
logger.info("Gemini configured. Creating model...") logger.info("Gemini configured. Creating model...")
model = genai.GenerativeModel('gemini-1.5-flash') model = genai.GenerativeModel('gemini-1.5-flash')
# Limit transcript to avoid token limits # Limit transcript to avoid token limits
max_chars = 8000 max_chars = 8000
if len(transcript) > max_chars: if len(transcript) > max_chars:
transcript = transcript[:max_chars] + "..." transcript = transcript[:max_chars] + "..."
logger.info(f"Generating summary content... Transcript len: {len(transcript)}") logger.info(f"Generating summary content... Transcript len: {len(transcript)}")
# Create prompt for summarization # Create prompt for summarization
prompt = f"""You are a helpful AI assistant. Summarize the following video transcript in 2-3 concise sentences. prompt = f"""You are a helpful AI assistant. Summarize the following video transcript in 2-3 concise sentences.
Focus on the main topic and key points. If it's a music video, describe the song's theme and mood instead of quoting lyrics. Focus on the main topic and key points. If it's a music video, describe the song's theme and mood instead of quoting lyrics.
Video Title: {video_title if video_title else 'Unknown'} Video Title: {video_title if video_title else 'Unknown'}
Transcript: Transcript:
{transcript} {transcript}
Provide a brief, informative summary (2-3 sentences max):""" Provide a brief, informative summary (2-3 sentences max):"""
response = model.generate_content(prompt) response = model.generate_content(prompt)
logger.info("Gemini response received.") logger.info("Gemini response received.")
if response and response.text: if response and response.text:
summary = response.text.strip() summary = response.text.strip()
# Clean up any markdown formatting # Clean up any markdown formatting
summary = summary.replace("**", "").replace("##", "").replace("###", "") summary = summary.replace("**", "").replace("##", "").replace("###", "")
return summary return summary
return None return None
except Exception as e: except Exception as e:
logger.error(f"Gemini summarization error: {e}") logger.error(f"Gemini summarization error: {e}")
return None return None
def extract_key_points_with_gemini(transcript: str, video_title: str = "") -> list: def extract_key_points_with_gemini(transcript: str, video_title: str = "") -> list:
""" """
Extract key points from video transcript using Gemini AI. Extract key points from video transcript using Gemini AI.
Returns: Returns:
List of key points or empty list if failed List of key points or empty list if failed
""" """
if not GEMINI_API_KEY: if not GEMINI_API_KEY:
return [] return []
try: try:
import google.generativeai as genai import google.generativeai as genai
genai.configure(api_key=GEMINI_API_KEY) genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel('gemini-1.5-flash') model = genai.GenerativeModel('gemini-1.5-flash')
# Limit transcript # Limit transcript
max_chars = 6000 max_chars = 6000
if len(transcript) > max_chars: if len(transcript) > max_chars:
transcript = transcript[:max_chars] + "..." transcript = transcript[:max_chars] + "..."
prompt = f"""Extract 3-5 key points from this video transcript. For each point, provide a single short sentence. prompt = f"""Extract 3-5 key points from this video transcript. For each point, provide a single short sentence.
If it's a music video, describe the themes, mood, and notable elements instead of quoting lyrics. If it's a music video, describe the themes, mood, and notable elements instead of quoting lyrics.
Video Title: {video_title if video_title else 'Unknown'} Video Title: {video_title if video_title else 'Unknown'}
Transcript: Transcript:
{transcript} {transcript}
Key points (one per line, no bullet points or numbers):""" Key points (one per line, no bullet points or numbers):"""
response = model.generate_content(prompt) response = model.generate_content(prompt)
if response and response.text: if response and response.text:
lines = response.text.strip().split('\n') lines = response.text.strip().split('\n')
# Clean up and filter # Clean up and filter
points = [] points = []
for line in lines: for line in lines:
line = line.strip().lstrip('•-*123456789.)') line = line.strip().lstrip('•-*123456789.)')
line = line.strip() line = line.strip()
if line and len(line) > 10: if line and len(line) > 10:
points.append(line) points.append(line)
return points[:5] # Max 5 points return points[:5] # Max 5 points
return [] return []
except Exception as e: except Exception as e:
logger.error(f"Gemini key points error: {e}") logger.error(f"Gemini key points error: {e}")
return [] return []

0
app/services/loader_to.py Normal file → Executable file
View file

0
app/services/settings.py Normal file → Executable file
View file

238
app/services/summarizer.py Normal file → Executable file
View file

@ -1,119 +1,119 @@
import re import re
import math import math
import logging import logging
from typing import List from typing import List
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TextRankSummarizer: class TextRankSummarizer:
""" """
Summarizes text using a TextRank-like graph algorithm. Summarizes text using a TextRank-like graph algorithm.
This creates more coherent "whole idea" summaries than random extraction. This creates more coherent "whole idea" summaries than random extraction.
""" """
def __init__(self): def __init__(self):
self.stop_words = set([ self.stop_words = set([
"the", "a", "an", "and", "or", "but", "is", "are", "was", "were", "the", "a", "an", "and", "or", "but", "is", "are", "was", "were",
"to", "of", "in", "on", "at", "for", "width", "that", "this", "it", "to", "of", "in", "on", "at", "for", "width", "that", "this", "it",
"you", "i", "we", "they", "he", "she", "have", "has", "had", "do", "you", "i", "we", "they", "he", "she", "have", "has", "had", "do",
"does", "did", "with", "as", "by", "from", "at", "but", "not", "what", "does", "did", "with", "as", "by", "from", "at", "but", "not", "what",
"all", "were", "when", "can", "said", "there", "use", "an", "each", "all", "were", "when", "can", "said", "there", "use", "an", "each",
"which", "she", "do", "how", "their", "if", "will", "up", "other", "which", "she", "do", "how", "their", "if", "will", "up", "other",
"about", "out", "many", "then", "them", "these", "so", "some", "her", "about", "out", "many", "then", "them", "these", "so", "some", "her",
"would", "make", "like", "him", "into", "time", "has", "look", "two", "would", "make", "like", "him", "into", "time", "has", "look", "two",
"more", "write", "go", "see", "number", "no", "way", "could", "people", "more", "write", "go", "see", "number", "no", "way", "could", "people",
"my", "than", "first", "water", "been", "call", "who", "oil", "its", "my", "than", "first", "water", "been", "call", "who", "oil", "its",
"now", "find", "long", "down", "day", "did", "get", "come", "made", "now", "find", "long", "down", "day", "did", "get", "come", "made",
"may", "part" "may", "part"
]) ])
def summarize(self, text: str, num_sentences: int = 5) -> str: def summarize(self, text: str, num_sentences: int = 5) -> str:
""" """
Generate a summary of the text. Generate a summary of the text.
Args: Args:
text: Input text text: Input text
num_sentences: Number of sentences in the summary num_sentences: Number of sentences in the summary
Returns: Returns:
Summarized text string Summarized text string
""" """
if not text: if not text:
return "" return ""
# 1. Split into sentences # 1. Split into sentences
# Use regex to look for periods/questions/exclamations followed by space or end of string # Use regex to look for periods/questions/exclamations followed by space or end of string
sentences = re.split(r'(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s', text) sentences = re.split(r'(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s', text)
sentences = [s.strip() for s in sentences if len(s.strip()) > 20] # Filter very short fragments sentences = [s.strip() for s in sentences if len(s.strip()) > 20] # Filter very short fragments
if not sentences: if not sentences:
return text[:500] + "..." if len(text) > 500 else text return text[:500] + "..." if len(text) > 500 else text
if len(sentences) <= num_sentences: if len(sentences) <= num_sentences:
return " ".join(sentences) return " ".join(sentences)
# 2. Build Similarity Graph # 2. Build Similarity Graph
# We calculate cosine similarity between all pairs of sentences # We calculate cosine similarity between all pairs of sentences
# graph[i][j] = similarity score # graph[i][j] = similarity score
n = len(sentences) n = len(sentences)
scores = [0.0] * n scores = [0.0] * n
# Pre-process sentences for efficiency # Pre-process sentences for efficiency
# Convert to sets of words # Convert to sets of words
sent_words = [] sent_words = []
for s in sentences: for s in sentences:
words = re.findall(r'\w+', s.lower()) words = re.findall(r'\w+', s.lower())
words = [w for w in words if w not in self.stop_words] words = [w for w in words if w not in self.stop_words]
sent_words.append(words) sent_words.append(words)
# Adjacency matrix (conceptual) - we'll just sum weights for "centrality" # Adjacency matrix (conceptual) - we'll just sum weights for "centrality"
# TextRank logic: a sentence is important if it is similar to other important sentences. # TextRank logic: a sentence is important if it is similar to other important sentences.
# Simplified: weighted degree centrality often works well enough for simple tasks without full iterative convergence # Simplified: weighted degree centrality often works well enough for simple tasks without full iterative convergence
for i in range(n): for i in range(n):
for j in range(i + 1, n): for j in range(i + 1, n):
sim = self._cosine_similarity(sent_words[i], sent_words[j]) sim = self._cosine_similarity(sent_words[i], sent_words[j])
if sim > 0: if sim > 0:
scores[i] += sim scores[i] += sim
scores[j] += sim scores[j] += sim
# 3. Rank and Select # 3. Rank and Select
# Sort by score descending # Sort by score descending
ranked_sentences = sorted(((scores[i], i) for i in range(n)), reverse=True) ranked_sentences = sorted(((scores[i], i) for i in range(n)), reverse=True)
# Pick top N # Pick top N
top_indices = [idx for score, idx in ranked_sentences[:num_sentences]] top_indices = [idx for score, idx in ranked_sentences[:num_sentences]]
# 4. Reorder by appearance in original text for coherence # 4. Reorder by appearance in original text for coherence
top_indices.sort() top_indices.sort()
summary = " ".join([sentences[i] for i in top_indices]) summary = " ".join([sentences[i] for i in top_indices])
return summary return summary
def _cosine_similarity(self, words1: List[str], words2: List[str]) -> float: def _cosine_similarity(self, words1: List[str], words2: List[str]) -> float:
"""Calculate cosine similarity between two word lists.""" """Calculate cosine similarity between two word lists."""
if not words1 or not words2: if not words1 or not words2:
return 0.0 return 0.0
# Unique words in both # Unique words in both
all_words = set(words1) | set(words2) all_words = set(words1) | set(words2)
# Frequency vectors # Frequency vectors
vec1 = {w: 0 for w in all_words} vec1 = {w: 0 for w in all_words}
vec2 = {w: 0 for w in all_words} vec2 = {w: 0 for w in all_words}
for w in words1: vec1[w] += 1 for w in words1: vec1[w] += 1
for w in words2: vec2[w] += 1 for w in words2: vec2[w] += 1
# Dot product # Dot product
dot_product = sum(vec1[w] * vec2[w] for w in all_words) dot_product = sum(vec1[w] * vec2[w] for w in all_words)
# Magnitudes # Magnitudes
mag1 = math.sqrt(sum(v*v for v in vec1.values())) mag1 = math.sqrt(sum(v*v for v in vec1.values()))
mag2 = math.sqrt(sum(v*v for v in vec2.values())) mag2 = math.sqrt(sum(v*v for v in vec2.values()))
if mag1 == 0 or mag2 == 0: if mag1 == 0 or mag2 == 0:
return 0.0 return 0.0
return dot_product / (mag1 * mag2) return dot_product / (mag1 * mag2)

View file

@ -0,0 +1,211 @@
"""
Transcript Service Module
Fetches video transcripts with fallback strategy: yt-dlp -> ytfetcher
"""
import os
import re
import glob
import json
import random
import logging
from typing import Optional
logger = logging.getLogger(__name__)
class TranscriptService:
"""Service for fetching YouTube video transcripts with fallback support."""
@classmethod
def get_transcript(cls, video_id: str) -> Optional[str]:
"""
Get transcript text for a video.
Strategy:
1. Try yt-dlp (current method, handles auto-generated captions)
2. Fallback to ytfetcher library if yt-dlp fails
Args:
video_id: YouTube video ID
Returns:
Transcript text or None if unavailable
"""
video_id = video_id.strip()
# Try yt-dlp first (primary method)
text = cls._fetch_with_ytdlp(video_id)
if text:
logger.info(f"Transcript fetched via yt-dlp for {video_id}")
return text
# Fallback to ytfetcher
logger.info(f"yt-dlp failed, trying ytfetcher for {video_id}")
text = cls._fetch_with_ytfetcher(video_id)
if text:
logger.info(f"Transcript fetched via ytfetcher for {video_id}")
return text
logger.warning(f"All transcript methods failed for {video_id}")
return None
@classmethod
def _fetch_with_ytdlp(cls, video_id: str) -> Optional[str]:
"""Fetch transcript using yt-dlp (downloading subtitles to file)."""
import yt_dlp
try:
logger.info(f"Fetching transcript for {video_id} using yt-dlp")
# Use a temporary filename pattern
temp_prefix = f"transcript_{video_id}_{random.randint(1000, 9999)}"
ydl_opts = {
'skip_download': True,
'quiet': True,
'no_warnings': True,
'cookiefile': os.environ.get('COOKIES_FILE', 'cookies.txt') if os.path.exists(os.environ.get('COOKIES_FILE', 'cookies.txt')) else None,
'writesubtitles': True,
'writeautomaticsub': True,
'subtitleslangs': ['en', 'vi', 'en-US'],
'outtmpl': f"/tmp/{temp_prefix}",
'subtitlesformat': 'json3/vtt/best',
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
# Find the downloaded file
downloaded_files = glob.glob(f"/tmp/{temp_prefix}*")
if not downloaded_files:
logger.warning("yt-dlp finished but no subtitle file found.")
return None
# Pick the best file (prefer json3, then vtt)
selected_file = None
for ext in ['.json3', '.vtt', '.ttml', '.srv3']:
for f in downloaded_files:
if f.endswith(ext):
selected_file = f
break
if selected_file:
break
if not selected_file:
selected_file = downloaded_files[0]
# Read content
with open(selected_file, 'r', encoding='utf-8') as f:
content = f.read()
# Cleanup
for f in downloaded_files:
try:
os.remove(f)
except:
pass
# Parse based on format
if selected_file.endswith('.json3') or content.strip().startswith('{'):
return cls._parse_json3(content)
else:
return cls._parse_vtt(content)
except Exception as e:
logger.error(f"yt-dlp transcript fetch failed: {e}")
return None
@classmethod
def _fetch_with_ytfetcher(cls, video_id: str) -> Optional[str]:
"""Fetch transcript using ytfetcher library as fallback."""
try:
from ytfetcher import YTFetcher
logger.info(f"Using ytfetcher for {video_id}")
# Create fetcher for single video
fetcher = YTFetcher.from_video_ids(video_ids=[video_id])
# Fetch transcripts
data = fetcher.fetch_transcripts()
if not data:
logger.warning(f"ytfetcher returned no data for {video_id}")
return None
# Extract text from transcript objects
text_parts = []
for item in data:
transcripts = getattr(item, 'transcripts', []) or []
for t in transcripts:
txt = getattr(t, 'text', '') or ''
txt = txt.strip()
if txt and txt != '\n':
text_parts.append(txt)
if not text_parts:
logger.warning(f"ytfetcher returned empty transcripts for {video_id}")
return None
return " ".join(text_parts)
except ImportError:
logger.warning("ytfetcher not installed. Run: pip install ytfetcher")
return None
except Exception as e:
logger.error(f"ytfetcher transcript fetch failed: {e}")
return None
@staticmethod
def _parse_json3(content: str) -> Optional[str]:
"""Parse JSON3 subtitle format."""
try:
json_data = json.loads(content)
events = json_data.get('events', [])
text_parts = []
for event in events:
segs = event.get('segs', [])
for seg in segs:
txt = seg.get('utf8', '').strip()
if txt and txt != '\n':
text_parts.append(txt)
return " ".join(text_parts)
except Exception as e:
logger.warning(f"JSON3 parse failed: {e}")
return None
@staticmethod
def _parse_vtt(content: str) -> Optional[str]:
"""Parse VTT/XML subtitle content."""
try:
lines = content.splitlines()
text_lines = []
seen = set()
for line in lines:
line = line.strip()
if not line:
continue
if "-->" in line:
continue
if line.isdigit():
continue
if line.startswith("WEBVTT"):
continue
if line.startswith("Kind:"):
continue
if line.startswith("Language:"):
continue
# Remove tags like <c> or <00:00:00>
clean = re.sub(r'<[^>]+>', '', line)
if clean and clean not in seen:
seen.add(clean)
text_lines.append(clean)
return " ".join(text_lines)
except Exception as e:
logger.error(f"VTT transcript parse error: {e}")
return None

626
app/services/youtube.py Normal file → Executable file
View file

@ -1,313 +1,313 @@
""" """
YouTube Service Module YouTube Service Module
Handles all yt-dlp interactions using the library directly (not subprocess) Handles all yt-dlp interactions using the library directly (not subprocess)
""" """
import yt_dlp import yt_dlp
import logging import logging
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from config import Config from config import Config
from app.services.loader_to import LoaderToService from app.services.loader_to import LoaderToService
from app.services.settings import SettingsService from app.services.settings import SettingsService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class YouTubeService: class YouTubeService:
"""Service for fetching YouTube content using yt-dlp library""" """Service for fetching YouTube content using yt-dlp library"""
# Common yt-dlp options # Common yt-dlp options
BASE_OPTS = { BASE_OPTS = {
'quiet': True, 'quiet': True,
'no_warnings': True, 'no_warnings': True,
'extract_flat': 'in_playlist', 'extract_flat': 'in_playlist',
'force_ipv4': True, 'force_ipv4': True,
'socket_timeout': Config.YTDLP_TIMEOUT, 'socket_timeout': Config.YTDLP_TIMEOUT,
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
} }
@staticmethod @staticmethod
def sanitize_video_data(data: Dict[str, Any]) -> Dict[str, Any]: def sanitize_video_data(data: Dict[str, Any]) -> Dict[str, Any]:
"""Sanitize and format video data from yt-dlp""" """Sanitize and format video data from yt-dlp"""
video_id = data.get('id', '') video_id = data.get('id', '')
duration_secs = data.get('duration') duration_secs = data.get('duration')
# Format duration # Format duration
duration_str = None duration_str = None
if duration_secs: if duration_secs:
mins, secs = divmod(int(duration_secs), 60) mins, secs = divmod(int(duration_secs), 60)
hours, mins = divmod(mins, 60) hours, mins = divmod(mins, 60)
duration_str = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}" duration_str = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
return { return {
'id': video_id, 'id': video_id,
'title': data.get('title', 'Unknown'), 'title': data.get('title', 'Unknown'),
'uploader': data.get('uploader') or data.get('channel') or 'Unknown', 'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
'channel_id': data.get('channel_id'), 'channel_id': data.get('channel_id'),
'uploader_id': data.get('uploader_id'), 'uploader_id': data.get('uploader_id'),
'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg" if video_id else None, 'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg" if video_id else None,
'view_count': data.get('view_count', 0), 'view_count': data.get('view_count', 0),
'upload_date': data.get('upload_date', ''), 'upload_date': data.get('upload_date', ''),
'duration': duration_str, 'duration': duration_str,
'description': data.get('description', ''), 'description': data.get('description', ''),
} }
@classmethod @classmethod
def search_videos(cls, query: str, limit: int = 20, filter_type: Optional[str] = None) -> List[Dict[str, Any]]: def search_videos(cls, query: str, limit: int = 20, filter_type: Optional[str] = None) -> List[Dict[str, Any]]:
""" """
Search for videos using yt-dlp library directly Search for videos using yt-dlp library directly
Args: Args:
query: Search query query: Search query
limit: Maximum number of results limit: Maximum number of results
filter_type: 'video' to exclude shorts, 'short' for only shorts filter_type: 'video' to exclude shorts, 'short' for only shorts
Returns: Returns:
List of sanitized video data dictionaries List of sanitized video data dictionaries
""" """
try: try:
search_url = f"ytsearch{limit}:{query}" search_url = f"ytsearch{limit}:{query}"
ydl_opts = { ydl_opts = {
**cls.BASE_OPTS, **cls.BASE_OPTS,
'extract_flat': True, 'extract_flat': True,
'playlist_items': f'1:{limit}', 'playlist_items': f'1:{limit}',
} }
results = [] results = []
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(search_url, download=False) info = ydl.extract_info(search_url, download=False)
entries = info.get('entries', []) if info else [] entries = info.get('entries', []) if info else []
for entry in entries: for entry in entries:
if not entry or not entry.get('id'): if not entry or not entry.get('id'):
continue continue
# Filter logic # Filter logic
title_lower = (entry.get('title') or '').lower() title_lower = (entry.get('title') or '').lower()
duration_secs = entry.get('duration') duration_secs = entry.get('duration')
if filter_type == 'video': if filter_type == 'video':
# Exclude shorts # Exclude shorts
if '#shorts' in title_lower: if '#shorts' in title_lower:
continue continue
if duration_secs and int(duration_secs) <= 70: if duration_secs and int(duration_secs) <= 70:
continue continue
elif filter_type == 'short': elif filter_type == 'short':
# Only shorts # Only shorts
if duration_secs and int(duration_secs) > 60: if duration_secs and int(duration_secs) > 60:
continue continue
results.append(cls.sanitize_video_data(entry)) results.append(cls.sanitize_video_data(entry))
return results return results
except Exception as e: except Exception as e:
logger.error(f"Search error for '{query}': {e}") logger.error(f"Search error for '{query}': {e}")
return [] return []
@classmethod @classmethod
def get_video_info(cls, video_id: str) -> Optional[Dict[str, Any]]: def get_video_info(cls, video_id: str) -> Optional[Dict[str, Any]]:
""" """
Get detailed video information including stream URL Get detailed video information including stream URL
Args: Args:
video_id: YouTube video ID video_id: YouTube video ID
Returns: Returns:
Video info dict with stream_url, or None on error Video info dict with stream_url, or None on error
""" """
engine = SettingsService.get('youtube_engine', 'auto') engine = SettingsService.get('youtube_engine', 'auto')
# 1. Force Remote # 1. Force Remote
if engine == 'remote': if engine == 'remote':
return cls._get_info_remote(video_id) return cls._get_info_remote(video_id)
# 2. Local (or Auto first attempt) # 2. Local (or Auto first attempt)
info = cls._get_info_local(video_id) info = cls._get_info_local(video_id)
if info: if info:
return info return info
# 3. Failover if Auto # 3. Failover if Auto
if engine == 'auto' and not info: if engine == 'auto' and not info:
logger.warning(f"yt-dlp failed for {video_id}, falling back to remote loader") logger.warning(f"yt-dlp failed for {video_id}, falling back to remote loader")
return cls._get_info_remote(video_id) return cls._get_info_remote(video_id)
return None return None
@classmethod @classmethod
def _get_info_remote(cls, video_id: str) -> Optional[Dict[str, Any]]: def _get_info_remote(cls, video_id: str) -> Optional[Dict[str, Any]]:
"""Fetch info using LoaderToService""" """Fetch info using LoaderToService"""
url = f"https://www.youtube.com/watch?v={video_id}" url = f"https://www.youtube.com/watch?v={video_id}"
return LoaderToService.get_stream_url(url) return LoaderToService.get_stream_url(url)
@classmethod @classmethod
def _get_info_local(cls, video_id: str) -> Optional[Dict[str, Any]]: def _get_info_local(cls, video_id: str) -> Optional[Dict[str, Any]]:
"""Fetch info using yt-dlp (original logic)""" """Fetch info using yt-dlp (original logic)"""
try: try:
url = f"https://www.youtube.com/watch?v={video_id}" url = f"https://www.youtube.com/watch?v={video_id}"
ydl_opts = { ydl_opts = {
**cls.BASE_OPTS, **cls.BASE_OPTS,
'format': Config.YTDLP_FORMAT, 'format': Config.YTDLP_FORMAT,
'noplaylist': True, 'noplaylist': True,
'skip_download': True, 'skip_download': True,
} }
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False) info = ydl.extract_info(url, download=False)
if not info: if not info:
return None return None
stream_url = info.get('url') stream_url = info.get('url')
if not stream_url: if not stream_url:
logger.warning(f"No stream URL found for {video_id}") logger.warning(f"No stream URL found for {video_id}")
return None return None
# Get subtitles # Get subtitles
subtitle_url = cls._extract_subtitle_url(info) subtitle_url = cls._extract_subtitle_url(info)
return { return {
'stream_url': stream_url, 'stream_url': stream_url,
'title': info.get('title', 'Unknown'), 'title': info.get('title', 'Unknown'),
'description': info.get('description', ''), 'description': info.get('description', ''),
'uploader': info.get('uploader', ''), 'uploader': info.get('uploader', ''),
'uploader_id': info.get('uploader_id', ''), 'uploader_id': info.get('uploader_id', ''),
'channel_id': info.get('channel_id', ''), 'channel_id': info.get('channel_id', ''),
'upload_date': info.get('upload_date', ''), 'upload_date': info.get('upload_date', ''),
'view_count': info.get('view_count', 0), 'view_count': info.get('view_count', 0),
'subtitle_url': subtitle_url, 'subtitle_url': subtitle_url,
'duration': info.get('duration'), 'duration': info.get('duration'),
'thumbnail': info.get('thumbnail') or f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg", 'thumbnail': info.get('thumbnail') or f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
'http_headers': info.get('http_headers', {}) 'http_headers': info.get('http_headers', {})
} }
except Exception as e: except Exception as e:
logger.error(f"Error getting local video info for {video_id}: {e}") logger.error(f"Error getting local video info for {video_id}: {e}")
return None return None
@staticmethod @staticmethod
def _extract_subtitle_url(info: Dict[str, Any]) -> Optional[str]: def _extract_subtitle_url(info: Dict[str, Any]) -> Optional[str]:
"""Extract best subtitle URL from video info""" """Extract best subtitle URL from video info"""
subs = info.get('subtitles') or {} subs = info.get('subtitles') or {}
auto_subs = info.get('automatic_captions') or {} auto_subs = info.get('automatic_captions') or {}
# Priority: en manual > vi manual > en auto > vi auto > first available # Priority: en manual > vi manual > en auto > vi auto > first available
for lang in ['en', 'vi']: for lang in ['en', 'vi']:
if lang in subs and subs[lang]: if lang in subs and subs[lang]:
return subs[lang][0].get('url') return subs[lang][0].get('url')
for lang in ['en', 'vi']: for lang in ['en', 'vi']:
if lang in auto_subs and auto_subs[lang]: if lang in auto_subs and auto_subs[lang]:
return auto_subs[lang][0].get('url') return auto_subs[lang][0].get('url')
# Fallback to first available # Fallback to first available
if subs: if subs:
first_key = list(subs.keys())[0] first_key = list(subs.keys())[0]
if subs[first_key]: if subs[first_key]:
return subs[first_key][0].get('url') return subs[first_key][0].get('url')
if auto_subs: if auto_subs:
first_key = list(auto_subs.keys())[0] first_key = list(auto_subs.keys())[0]
if auto_subs[first_key]: if auto_subs[first_key]:
return auto_subs[first_key][0].get('url') return auto_subs[first_key][0].get('url')
return None return None
@classmethod @classmethod
def get_channel_videos(cls, channel_id: str, limit: int = 20) -> List[Dict[str, Any]]: def get_channel_videos(cls, channel_id: str, limit: int = 20) -> List[Dict[str, Any]]:
""" """
Get videos from a YouTube channel Get videos from a YouTube channel
Args: Args:
channel_id: Channel ID, handle (@username), or URL channel_id: Channel ID, handle (@username), or URL
limit: Maximum number of videos limit: Maximum number of videos
Returns: Returns:
List of video data dictionaries List of video data dictionaries
""" """
try: try:
# Construct URL based on ID format # Construct URL based on ID format
if channel_id.startswith('http'): if channel_id.startswith('http'):
url = channel_id url = channel_id
elif channel_id.startswith('@'): elif channel_id.startswith('@'):
url = f"https://www.youtube.com/{channel_id}" url = f"https://www.youtube.com/{channel_id}"
elif len(channel_id) == 24 and channel_id.startswith('UC'): elif len(channel_id) == 24 and channel_id.startswith('UC'):
url = f"https://www.youtube.com/channel/{channel_id}" url = f"https://www.youtube.com/channel/{channel_id}"
else: else:
url = f"https://www.youtube.com/{channel_id}" url = f"https://www.youtube.com/{channel_id}"
ydl_opts = { ydl_opts = {
**cls.BASE_OPTS, **cls.BASE_OPTS,
'extract_flat': True, 'extract_flat': True,
'playlist_items': f'1:{limit}', 'playlist_items': f'1:{limit}',
} }
results = [] results = []
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False) info = ydl.extract_info(url, download=False)
entries = info.get('entries', []) if info else [] entries = info.get('entries', []) if info else []
for entry in entries: for entry in entries:
if entry and entry.get('id'): if entry and entry.get('id'):
results.append(cls.sanitize_video_data(entry)) results.append(cls.sanitize_video_data(entry))
return results return results
except Exception as e: except Exception as e:
logger.error(f"Error getting channel videos for {channel_id}: {e}") logger.error(f"Error getting channel videos for {channel_id}: {e}")
return [] return []
@classmethod @classmethod
def get_related_videos(cls, title: str, limit: int = 10) -> List[Dict[str, Any]]: def get_related_videos(cls, title: str, limit: int = 10) -> List[Dict[str, Any]]:
"""Get videos related to a given title""" """Get videos related to a given title"""
query = f"{title} related" query = f"{title} related"
return cls.search_videos(query, limit=limit, filter_type='video') return cls.search_videos(query, limit=limit, filter_type='video')
@classmethod @classmethod
def get_download_url(cls, video_id: str) -> Optional[Dict[str, str]]: def get_download_url(cls, video_id: str) -> Optional[Dict[str, str]]:
""" """
Get direct download URL (non-HLS) for a video Get direct download URL (non-HLS) for a video
Returns: Returns:
Dict with 'url', 'title', 'ext' or None Dict with 'url', 'title', 'ext' or None
""" """
try: try:
url = f"https://www.youtube.com/watch?v={video_id}" url = f"https://www.youtube.com/watch?v={video_id}"
ydl_opts = { ydl_opts = {
**cls.BASE_OPTS, **cls.BASE_OPTS,
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best[protocol!*=m3u8]/best', 'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best[protocol!*=m3u8]/best',
'noplaylist': True, 'noplaylist': True,
'skip_download': True, 'skip_download': True,
'youtube_include_dash_manifest': False, 'youtube_include_dash_manifest': False,
'youtube_include_hls_manifest': False, 'youtube_include_hls_manifest': False,
} }
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False) info = ydl.extract_info(url, download=False)
download_url = info.get('url', '') download_url = info.get('url', '')
# If m3u8, try to find non-HLS format # If m3u8, try to find non-HLS format
if '.m3u8' in download_url or not download_url: if '.m3u8' in download_url or not download_url:
formats = info.get('formats', []) formats = info.get('formats', [])
for f in reversed(formats): for f in reversed(formats):
f_url = f.get('url', '') f_url = f.get('url', '')
if f_url and 'm3u8' not in f_url and f.get('ext') == 'mp4': if f_url and 'm3u8' not in f_url and f.get('ext') == 'mp4':
download_url = f_url download_url = f_url
break break
if download_url and '.m3u8' not in download_url: if download_url and '.m3u8' not in download_url:
return { return {
'url': download_url, 'url': download_url,
'title': info.get('title', 'video'), 'title': info.get('title', 'video'),
'ext': 'mp4' 'ext': 'mp4'
} }
return None return None
except Exception as e: except Exception as e:
logger.error(f"Error getting download URL for {video_id}: {e}") logger.error(f"Error getting download URL for {video_id}: {e}")
return None return None

2
app/utils/__init__.py Normal file → Executable file
View file

@ -1 +1 @@
"""KV-Tube Utilities Package""" """KV-Tube Utilities Package"""

190
app/utils/formatters.py Normal file → Executable file
View file

@ -1,95 +1,95 @@
""" """
Template Formatters Module Template Formatters Module
Jinja2 template filters for formatting views and dates Jinja2 template filters for formatting views and dates
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta
def format_views(views) -> str: def format_views(views) -> str:
"""Format view count (YouTube style: 1.2M, 3.5K)""" """Format view count (YouTube style: 1.2M, 3.5K)"""
if not views: if not views:
return '0' return '0'
try: try:
num = int(views) num = int(views)
if num >= 1_000_000_000: if num >= 1_000_000_000:
return f"{num / 1_000_000_000:.1f}B" return f"{num / 1_000_000_000:.1f}B"
if num >= 1_000_000: if num >= 1_000_000:
return f"{num / 1_000_000:.1f}M" return f"{num / 1_000_000:.1f}M"
if num >= 1_000: if num >= 1_000:
return f"{num / 1_000:.0f}K" return f"{num / 1_000:.0f}K"
return f"{num:,}" return f"{num:,}"
except (ValueError, TypeError): except (ValueError, TypeError):
return str(views) return str(views)
def format_date(value) -> str: def format_date(value) -> str:
"""Format date to relative time (YouTube style: 2 hours ago, 3 days ago)""" """Format date to relative time (YouTube style: 2 hours ago, 3 days ago)"""
if not value: if not value:
return 'Recently' return 'Recently'
try: try:
# Handle YYYYMMDD format # Handle YYYYMMDD format
if len(str(value)) == 8 and str(value).isdigit(): if len(str(value)) == 8 and str(value).isdigit():
dt = datetime.strptime(str(value), '%Y%m%d') dt = datetime.strptime(str(value), '%Y%m%d')
# Handle timestamp # Handle timestamp
elif isinstance(value, (int, float)): elif isinstance(value, (int, float)):
dt = datetime.fromtimestamp(value) dt = datetime.fromtimestamp(value)
# Handle datetime object # Handle datetime object
elif isinstance(value, datetime): elif isinstance(value, datetime):
dt = value dt = value
# Handle YYYY-MM-DD string # Handle YYYY-MM-DD string
else: else:
try: try:
dt = datetime.strptime(str(value), '%Y-%m-%d') dt = datetime.strptime(str(value), '%Y-%m-%d')
except ValueError: except ValueError:
return str(value) return str(value)
now = datetime.now() now = datetime.now()
diff = now - dt diff = now - dt
if diff.days > 365: if diff.days > 365:
years = diff.days // 365 years = diff.days // 365
return f"{years} year{'s' if years > 1 else ''} ago" return f"{years} year{'s' if years > 1 else ''} ago"
if diff.days > 30: if diff.days > 30:
months = diff.days // 30 months = diff.days // 30
return f"{months} month{'s' if months > 1 else ''} ago" return f"{months} month{'s' if months > 1 else ''} ago"
if diff.days > 7: if diff.days > 7:
weeks = diff.days // 7 weeks = diff.days // 7
return f"{weeks} week{'s' if weeks > 1 else ''} ago" return f"{weeks} week{'s' if weeks > 1 else ''} ago"
if diff.days > 0: if diff.days > 0:
return f"{diff.days} day{'s' if diff.days > 1 else ''} ago" return f"{diff.days} day{'s' if diff.days > 1 else ''} ago"
if diff.seconds > 3600: if diff.seconds > 3600:
hours = diff.seconds // 3600 hours = diff.seconds // 3600
return f"{hours} hour{'s' if hours > 1 else ''} ago" return f"{hours} hour{'s' if hours > 1 else ''} ago"
if diff.seconds > 60: if diff.seconds > 60:
minutes = diff.seconds // 60 minutes = diff.seconds // 60
return f"{minutes} minute{'s' if minutes > 1 else ''} ago" return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
return "Just now" return "Just now"
except Exception: except Exception:
return str(value) return str(value)
def format_duration(seconds) -> str: def format_duration(seconds) -> str:
"""Format duration in seconds to HH:MM:SS or MM:SS""" """Format duration in seconds to HH:MM:SS or MM:SS"""
if not seconds: if not seconds:
return '' return ''
try: try:
secs = int(seconds) secs = int(seconds)
mins, secs = divmod(secs, 60) mins, secs = divmod(secs, 60)
hours, mins = divmod(mins, 60) hours, mins = divmod(mins, 60)
if hours: if hours:
return f"{hours}:{mins:02d}:{secs:02d}" return f"{hours}:{mins:02d}:{secs:02d}"
return f"{mins}:{secs:02d}" return f"{mins}:{secs:02d}"
except (ValueError, TypeError): except (ValueError, TypeError):
return '' return ''
def register_filters(app): def register_filters(app):
"""Register all template filters with Flask app""" """Register all template filters with Flask app"""
app.template_filter('format_views')(format_views) app.template_filter('format_views')(format_views)
app.template_filter('format_date')(format_date) app.template_filter('format_date')(format_date)
app.template_filter('format_duration')(format_duration) app.template_filter('format_duration')(format_duration)

0
bin/ffmpeg Normal file → Executable file
View file

130
config.py Normal file → Executable file
View file

@ -1,65 +1,65 @@
""" """
KV-Tube Configuration Module KV-Tube Configuration Module
Centralizes all configuration with environment variable support Centralizes all configuration with environment variable support
""" """
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
# Load .env file if present # Load .env file if present
load_dotenv() load_dotenv()
class Config: class Config:
"""Base configuration""" """Base configuration"""
SECRET_KEY = os.environ.get('SECRET_KEY', os.urandom(32).hex()) SECRET_KEY = os.environ.get('SECRET_KEY', os.urandom(32).hex())
# Database # Database
DATA_DIR = os.environ.get('KVTUBE_DATA_DIR', 'data') DATA_DIR = os.environ.get('KVTUBE_DATA_DIR', 'data')
DB_NAME = os.path.join(DATA_DIR, 'kvtube.db') DB_NAME = os.path.join(DATA_DIR, 'kvtube.db')
# Video storage # Video storage
VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos') VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos')
# Rate limiting # Rate limiting
RATELIMIT_DEFAULT = "60/minute" RATELIMIT_DEFAULT = "60/minute"
RATELIMIT_SEARCH = "30/minute" RATELIMIT_SEARCH = "30/minute"
RATELIMIT_STREAM = "120/minute" RATELIMIT_STREAM = "120/minute"
# Cache settings (in seconds) # Cache settings (in seconds)
CACHE_VIDEO_TTL = 3600 # 1 hour CACHE_VIDEO_TTL = 3600 # 1 hour
CACHE_CHANNEL_TTL = 1800 # 30 minutes CACHE_CHANNEL_TTL = 1800 # 30 minutes
# yt-dlp settings # yt-dlp settings
# yt-dlp settings - MUST use progressive formats with combined audio+video # yt-dlp settings - MUST use progressive formats with combined audio+video
# Format 22 = 720p mp4, 18 = 360p mp4 (both have audio+video combined) # Format 22 = 720p mp4, 18 = 360p mp4 (both have audio+video combined)
# HLS m3u8 streams have CORS issues with segment proxying, so we avoid them # HLS m3u8 streams have CORS issues with segment proxying, so we avoid them
YTDLP_FORMAT = '22/18/best[protocol^=https][ext=mp4]/best[ext=mp4]/best' YTDLP_FORMAT = '22/18/best[protocol^=https][ext=mp4]/best[ext=mp4]/best'
YTDLP_TIMEOUT = 30 YTDLP_TIMEOUT = 30
# YouTube Engine Settings # YouTube Engine Settings
YOUTUBE_ENGINE = os.environ.get('YOUTUBE_ENGINE', 'auto') # auto, local, remote YOUTUBE_ENGINE = os.environ.get('YOUTUBE_ENGINE', 'auto') # auto, local, remote
LOADER_TO_API_KEY = os.environ.get('LOADER_TO_API_KEY', '') # Optional LOADER_TO_API_KEY = os.environ.get('LOADER_TO_API_KEY', '') # Optional
@staticmethod @staticmethod
def init_app(app): def init_app(app):
"""Initialize app with config""" """Initialize app with config"""
# Ensure data directory exists # Ensure data directory exists
os.makedirs(Config.DATA_DIR, exist_ok=True) os.makedirs(Config.DATA_DIR, exist_ok=True)
class DevelopmentConfig(Config): class DevelopmentConfig(Config):
"""Development configuration""" """Development configuration"""
DEBUG = True DEBUG = True
FLASK_ENV = 'development' FLASK_ENV = 'development'
class ProductionConfig(Config): class ProductionConfig(Config):
"""Production configuration""" """Production configuration"""
DEBUG = False DEBUG = False
FLASK_ENV = 'production' FLASK_ENV = 'production'
config = { config = {
'development': DevelopmentConfig, 'development': DevelopmentConfig,
'production': ProductionConfig, 'production': ProductionConfig,
'default': DevelopmentConfig 'default': DevelopmentConfig
} }

18
cookies.txt Normal file → Executable file
View file

@ -1,19 +1,19 @@
# Netscape HTTP Cookie File # Netscape HTTP Cookie File
# This file is generated by yt-dlp. Do not edit. # This file is generated by yt-dlp. Do not edit.
.youtube.com TRUE / TRUE 1802692356 __Secure-3PSID g.a0005gie1lAkmYZc-EPeGx77pCrXo_Cz5eAi-e9aryb9Qoz967v4Caiou6Tt5ZyLR4iMp5I51wACgYKASISARESFQHGX2MiopTeGBKXybppZWNr7JzmKhoVAUF8yKrgfPx-gEb02gGAV3ZaVOGr0076 .youtube.com TRUE / TRUE 1831894348 __Secure-3PSID g.a0005wie1jXMw44_RGjwtqg21AbatcdNseI3S_qNtsLYC1jS4YSUdfLDtJlA8h-NEmMGebiczgACgYKAX0SARESFQHGX2MiUz2RnkvviMoB7UNylf3SoBoVAUF8yKo0JwXF5B9H9roWaSTRT-QN0076
.youtube.com TRUE / TRUE 1800281710 __Secure-1PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA .youtube.com TRUE / TRUE 1800281710 __Secure-1PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
.youtube.com TRUE / TRUE 1802692356 SAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb .youtube.com TRUE / TRUE 1802692356 SAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1800282680 __Secure-1PSIDCC AKEyXzXvpBScD7r3mqr7aZ0ymWZ7FmsgT0q0C3Ge8hvrjZ9WZ4PU4ZBuBsO0YNYN3A8iX4eV8F8 .youtube.com TRUE / TRUE 1800359997 __Secure-1PSIDCC AKEyXzWh3snkS2XAx8pLOzZCgKTPwXKRai_Pn4KjpsSSc2h7tRpVKMDddMKBYkuIQFhpVlALI84
.youtube.com TRUE / TRUE 1802692356 SSID A4isk9AE9xActvzYy .youtube.com TRUE / TRUE 1802692356 SSID A4isk9AE9xActvzYy
.youtube.com TRUE / TRUE 1802692356 __Secure-1PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb .youtube.com TRUE / TRUE 1831894348 __Secure-1PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1802692356 __Secure-1PSID g.a0005gie1lAkmYZc-EPeGx77pCrXo_Cz5eAi-e9aryb9Qoz967v4-rF3xTavVHrJoyJAqShH6gACgYKAX0SARESFQHGX2MiOdAbUPmCj4MueYyh-2km5RoVAUF8yKp2ehWQC6tX8n-9UNg11RV60076 .youtube.com TRUE / TRUE 1831894348 __Secure-1PSID g.a0005wie1jXMw44_RGjwtqg21AbatcdNseI3S_qNtsLYC1jS4YSUycKC58NH045FOFX6QW8fDwACgYKAacSARESFQHGX2MiA5xeTuJuh8QmBm-DS3l1ghoVAUF8yKr4klCBhb-EJgFQ9T0TGWKk0076
.youtube.com TRUE / TRUE 1802692356 __Secure-3PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb .youtube.com TRUE / TRUE 1831894348 __Secure-3PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1800282680 __Secure-3PSIDCC AKEyXzVcvX-jLLprjZQXoqarG3xsAVpjyLYaN2j0a_iUcsnKnpL88P_5IlcfusJn0We0aaKK7g .youtube.com TRUE / TRUE 1800359997 __Secure-3PSIDCC AKEyXzW3W5Q-e4TIryFWpWS6zVuuVPOvwPIU2tzl1JRdYsGu-7f34g_amk2Xd2ttGtSJ6tOSdA
.youtube.com TRUE / TRUE 1800281710 __Secure-3PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA .youtube.com TRUE / TRUE 1800281710 __Secure-3PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
.youtube.com TRUE / TRUE 1792154873 LOGIN_INFO AFmmF2swRQIgVjJk8Mho4_JuKr6SZzrhBdlL1LdxWxcwDMu4cjaRRgcCIQCTtJpmYKJH54Tiei3at3f4YT3US7gSL0lW_TZ04guKjQ:QUQ3MjNmeWlwRDJSNDl2NE9uX2JWWG5tWllHN0RsNUVZVUhsLVp4N2dWbldaeC14SnNybWVERnNoaXFpanFJczhKTjJSRGN6MEs3c1VkLTE1TGJVeFBPT05BY29NMFh0Q1VPdFU3dUdvSUpET3lQbU1ZMUlHUGltajlXNDllNUQxZHdzZko1WXF1UUJWclNxQVJ0TXVEYnF2bXJRY2V6Vl9n .youtube.com TRUE / TRUE 1792154873 LOGIN_INFO AFmmF2swRQIgVjJk8Mho4_JuKr6SZzrhBdlL1LdxWxcwDMu4cjaRRgcCIQCTtJpmYKJH54Tiei3at3f4YT3US7gSL0lW_TZ04guKjQ:QUQ3MjNmeWlwRDJSNDl2NE9uX2JWWG5tWllHN0RsNUVZVUhsLVp4N2dWbldaeC14SnNybWVERnNoaXFpanFJczhKTjJSRGN6MEs3c1VkLTE1TGJVeFBPT05BY29NMFh0Q1VPdFU3dUdvSUpET3lQbU1ZMUlHUGltajlXNDllNUQxZHdzZko1WXF1UUJWclNxQVJ0TXVEYnF2bXJRY2V6Vl9n
.youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=150&hl=en .youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=150&hl=en
.youtube.com TRUE / TRUE 0 YSC y-oH2BqaUSQ .youtube.com TRUE / TRUE 0 YSC y-oH2BqaUSQ
.youtube.com TRUE / TRUE 1784298680 __Secure-ROLLOUT_TOKEN CPm51pHVjquOTRDw0bnsppWSAxjw0bnsppWSAw%3D%3D .youtube.com TRUE / TRUE 1784333733 __Secure-ROLLOUT_TOKEN CPm51pHVjquOTRDw0bnsppWSAxjzxYe3qZaSAw%3D%3D
.youtube.com TRUE / TRUE 1784298680 VISITOR_INFO1_LIVE ShB1Bvj-rRU .youtube.com TRUE / TRUE 1784375997 VISITOR_INFO1_LIVE ShB1Bvj-rRU
.youtube.com TRUE / TRUE 1784298680 VISITOR_PRIVACY_METADATA CgJWThIEGgAgWA%3D%3D .youtube.com TRUE / TRUE 1784375997 VISITOR_PRIVACY_METADATA CgJWThIEGgAgWA%3D%3D

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,3 +0,0 @@
{
"youtube_engine": "local"
}

0
deploy.py Normal file → Executable file
View file

0
dev.sh Normal file → Executable file
View file

90
doc/Product Requirements Document (PRD) - KV-Tube Normal file → Executable file
View file

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

58
docker-compose.yml Normal file → Executable file
View file

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

0
entrypoint.sh Normal file → Executable file
View file

2157
hydration_debug.txt Normal file → Executable file

File diff suppressed because it is too large Load diff

0
kv_server.py Normal file → Executable file
View file

View file

BIN
kvtube.db

Binary file not shown.

16
requirements.txt Normal file → Executable file
View file

@ -1,7 +1,9 @@
flask flask
requests requests
yt-dlp>=2024.1.0 yt-dlp>=2024.1.0
werkzeug werkzeug
gunicorn gunicorn
python-dotenv python-dotenv
googletrans==4.0.0-rc1
# ytfetcher - optional, requires Python 3.11-3.13

0
start.sh Normal file → Executable file
View file

0
static/css/modules/base.css Normal file → Executable file
View file

0
static/css/modules/cards.css Normal file → Executable file
View file

622
static/css/modules/chat.css Normal file → Executable file
View file

@ -1,312 +1,312 @@
/** /**
* KV-Tube AI Chat Styles * KV-Tube AI Chat Styles
* Styling for the transcript Q&A chatbot panel * Styling for the transcript Q&A chatbot panel
*/ */
/* Floating AI Bubble Button */ /* Floating AI Bubble Button */
.ai-chat-bubble { .ai-chat-bubble {
position: fixed; position: fixed;
bottom: 90px; bottom: 90px;
/* Above the back button */ /* Above the back button */
right: 20px; right: 20px;
width: 56px; width: 56px;
height: 56px; height: 56px;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none; border: none;
color: white; color: white;
font-size: 24px; font-size: 24px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 9998; z-index: 9998;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4); box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
transition: transform 0.3s, box-shadow 0.3s; transition: transform 0.3s, box-shadow 0.3s;
animation: bubble-pulse 2s infinite; animation: bubble-pulse 2s infinite;
} }
.ai-chat-bubble:hover { .ai-chat-bubble:hover {
transform: scale(1.1); transform: scale(1.1);
box-shadow: 0 6px 24px rgba(102, 126, 234, 0.6); box-shadow: 0 6px 24px rgba(102, 126, 234, 0.6);
} }
.ai-chat-bubble.active { .ai-chat-bubble.active {
animation: none; animation: none;
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
} }
@keyframes bubble-pulse { @keyframes bubble-pulse {
0%, 0%,
100% { 100% {
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4); box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
} }
50% { 50% {
box-shadow: 0 4px 24px rgba(102, 126, 234, 0.7); box-shadow: 0 4px 24px rgba(102, 126, 234, 0.7);
} }
} }
/* Hide bubble on desktop when chat is open */ /* Hide bubble on desktop when chat is open */
.ai-chat-panel.visible~.ai-chat-bubble, .ai-chat-panel.visible~.ai-chat-bubble,
body.ai-chat-open .ai-chat-bubble { body.ai-chat-open .ai-chat-bubble {
animation: none; animation: none;
} }
/* Chat Panel Container */ /* Chat Panel Container */
.ai-chat-panel { .ai-chat-panel {
position: fixed; position: fixed;
bottom: 160px; bottom: 160px;
/* Position above the bubble */ /* Position above the bubble */
right: 20px; right: 20px;
width: 380px; width: 380px;
max-height: 500px; max-height: 500px;
background: var(--yt-bg-primary, #0f0f0f); background: var(--yt-bg-primary, #0f0f0f);
border: 1px solid var(--yt-border, #272727); border: 1px solid var(--yt-border, #272727);
border-radius: 16px; border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
z-index: 9999; z-index: 9999;
overflow: hidden; overflow: hidden;
transform: translateY(20px) scale(0.95); transform: translateY(20px) scale(0.95);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s; transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s;
} }
.ai-chat-panel.visible { .ai-chat-panel.visible {
transform: translateY(0) scale(1); transform: translateY(0) scale(1);
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
/* Chat Header */ /* Chat Header */
.ai-chat-header { .ai-chat-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 12px 16px; padding: 12px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
} }
.ai-chat-header h4 { .ai-chat-header h4 {
margin: 0; margin: 0;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.ai-chat-close { .ai-chat-close {
background: none; background: none;
border: none; border: none;
color: white; color: white;
cursor: pointer; cursor: pointer;
padding: 4px; padding: 4px;
opacity: 0.8; opacity: 0.8;
transition: opacity 0.2s; transition: opacity 0.2s;
} }
.ai-chat-close:hover { .ai-chat-close:hover {
opacity: 1; opacity: 1;
} }
/* Model Status */ /* Model Status */
.ai-model-status { .ai-model-status {
font-size: 11px; font-size: 11px;
opacity: 0.9; opacity: 0.9;
margin-top: 2px; margin-top: 2px;
} }
.ai-model-status.loading { .ai-model-status.loading {
color: #ffd700; color: #ffd700;
} }
.ai-model-status.ready { .ai-model-status.ready {
color: #00ff88; color: #00ff88;
} }
/* Messages Container */ /* Messages Container */
.ai-chat-messages { .ai-chat-messages {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 16px; padding: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
max-height: 300px; max-height: 300px;
} }
/* Message Bubbles */ /* Message Bubbles */
.ai-message { .ai-message {
max-width: 85%; max-width: 85%;
padding: 10px 14px; padding: 10px 14px;
border-radius: 16px; border-radius: 16px;
font-size: 13px; font-size: 13px;
line-height: 1.5; line-height: 1.5;
word-wrap: break-word; word-wrap: break-word;
} }
.ai-message.user { .ai-message.user {
align-self: flex-end; align-self: flex-end;
background: #3ea6ff; background: #3ea6ff;
color: white; color: white;
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
} }
.ai-message.assistant { .ai-message.assistant {
align-self: flex-start; align-self: flex-start;
background: var(--yt-bg-secondary, #272727); background: var(--yt-bg-secondary, #272727);
color: var(--yt-text-primary, #fff); color: var(--yt-text-primary, #fff);
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
} }
.ai-message.system { .ai-message.system {
align-self: center; align-self: center;
background: transparent; background: transparent;
color: var(--yt-text-secondary, #aaa); color: var(--yt-text-secondary, #aaa);
font-style: italic; font-style: italic;
font-size: 12px; font-size: 12px;
} }
/* Typing Indicator */ /* Typing Indicator */
.ai-typing { .ai-typing {
display: flex; display: flex;
gap: 4px; gap: 4px;
padding: 10px 14px; padding: 10px 14px;
} }
.ai-typing span { .ai-typing span {
width: 8px; width: 8px;
height: 8px; height: 8px;
background: var(--yt-text-secondary, #aaa); background: var(--yt-text-secondary, #aaa);
border-radius: 50%; border-radius: 50%;
animation: typing 1.2s infinite; animation: typing 1.2s infinite;
} }
.ai-typing span:nth-child(2) { .ai-typing span:nth-child(2) {
animation-delay: 0.2s; animation-delay: 0.2s;
} }
.ai-typing span:nth-child(3) { .ai-typing span:nth-child(3) {
animation-delay: 0.4s; animation-delay: 0.4s;
} }
@keyframes typing { @keyframes typing {
0%, 0%,
60%, 60%,
100% { 100% {
transform: translateY(0); transform: translateY(0);
} }
30% { 30% {
transform: translateY(-8px); transform: translateY(-8px);
} }
} }
/* Input Area */ /* Input Area */
.ai-chat-input { .ai-chat-input {
display: flex; display: flex;
gap: 8px; gap: 8px;
padding: 12px; padding: 12px;
border-top: 1px solid var(--yt-border, #272727); border-top: 1px solid var(--yt-border, #272727);
background: var(--yt-bg-secondary, #181818); background: var(--yt-bg-secondary, #181818);
} }
.ai-chat-input input { .ai-chat-input input {
flex: 1; flex: 1;
background: var(--yt-bg-primary, #0f0f0f); background: var(--yt-bg-primary, #0f0f0f);
border: 1px solid var(--yt-border, #272727); border: 1px solid var(--yt-border, #272727);
border-radius: 20px; border-radius: 20px;
padding: 10px 16px; padding: 10px 16px;
color: var(--yt-text-primary, #fff); color: var(--yt-text-primary, #fff);
font-size: 13px; font-size: 13px;
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
.ai-chat-input input:focus { .ai-chat-input input:focus {
border-color: #3ea6ff; border-color: #3ea6ff;
} }
.ai-chat-input input::placeholder { .ai-chat-input input::placeholder {
color: var(--yt-text-secondary, #aaa); color: var(--yt-text-secondary, #aaa);
} }
.ai-chat-send { .ai-chat-send {
background: #3ea6ff; background: #3ea6ff;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
width: 36px; width: 36px;
height: 36px; height: 36px;
color: white; color: white;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: background 0.2s, transform 0.2s; transition: background 0.2s, transform 0.2s;
} }
.ai-chat-send:hover { .ai-chat-send:hover {
background: #2d8fd9; background: #2d8fd9;
transform: scale(1.05); transform: scale(1.05);
} }
.ai-chat-send:disabled { .ai-chat-send:disabled {
background: #555; background: #555;
cursor: not-allowed; cursor: not-allowed;
} }
/* Download Progress */ /* Download Progress */
.ai-download-progress { .ai-download-progress {
padding: 16px; padding: 16px;
text-align: center; text-align: center;
} }
.ai-download-bar { .ai-download-bar {
width: 100%; width: 100%;
height: 6px; height: 6px;
background: var(--yt-bg-secondary, #272727); background: var(--yt-bg-secondary, #272727);
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
margin-top: 8px; margin-top: 8px;
} }
.ai-download-fill { .ai-download-fill {
height: 100%; height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2); background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 3px; border-radius: 3px;
transition: width 0.3s; transition: width 0.3s;
} }
.ai-download-text { .ai-download-text {
font-size: 12px; font-size: 12px;
color: var(--yt-text-secondary, #aaa); color: var(--yt-text-secondary, #aaa);
margin-top: 8px; margin-top: 8px;
} }
/* Mobile */ /* Mobile */
@media (max-width: 768px) { @media (max-width: 768px) {
.ai-chat-bubble { .ai-chat-bubble {
bottom: 100px; bottom: 100px;
/* More space above back button */ /* More space above back button */
right: 24px; right: 24px;
/* Aligned with back button */ /* Aligned with back button */
width: 48px; width: 48px;
height: 48px; height: 48px;
font-size: 18px; font-size: 18px;
} }
.ai-chat-panel { .ai-chat-panel {
width: calc(100% - 20px); width: calc(100% - 20px);
left: 10px; left: 10px;
right: 10px; right: 10px;
bottom: 160px; bottom: 160px;
max-height: 50vh; max-height: 50vh;
} }
} }

0
static/css/modules/components.css Normal file → Executable file
View file

1390
static/css/modules/downloads.css Normal file → Executable file

File diff suppressed because it is too large Load diff

0
static/css/modules/grid.css Normal file → Executable file
View file

0
static/css/modules/layout.css Normal file → Executable file
View file

0
static/css/modules/pages.css Normal file → Executable file
View file

0
static/css/modules/utils.css Normal file → Executable file
View file

0
static/css/modules/variables.css Normal file → Executable file
View file

1554
static/css/modules/watch.css Normal file → Executable file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,277 @@
/**
* WebLLM Styles - Loading UI and Progress Bar
*/
/* Model loading overlay */
.webllm-loading-overlay {
position: fixed;
bottom: 100px;
right: 20px;
background: linear-gradient(135deg,
rgba(15, 15, 20, 0.95) 0%,
rgba(25, 25, 35, 0.95) 100%);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 20px 24px;
min-width: 320px;
z-index: 9999;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
animation: slideInRight 0.3s ease-out;
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.webllm-loading-overlay.hidden {
display: none;
}
/* Header with icon */
.webllm-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.webllm-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: white;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.webllm-title {
font-size: 14px;
font-weight: 600;
color: #fff;
margin: 0;
}
.webllm-subtitle {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
margin: 2px 0 0 0;
}
/* Progress bar */
.webllm-progress-container {
background: rgba(255, 255, 255, 0.08);
border-radius: 8px;
height: 8px;
overflow: hidden;
margin-bottom: 12px;
}
.webllm-progress-bar {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
background-size: 200% 100%;
border-radius: 8px;
transition: width 0.3s ease;
animation: shimmer 2s infinite linear;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Status text */
.webllm-status {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
}
.webllm-percent {
font-weight: 600;
color: #667eea;
}
/* Ready state */
.webllm-ready-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(16, 185, 129, 0.1) 100%);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 20px;
font-size: 11px;
color: #10b981;
font-weight: 500;
}
.webllm-ready-badge i {
font-size: 10px;
}
/* Summary box WebLLM indicator */
.ai-source-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--yt-text-tertiary, #aaa);
padding: 4px 0;
}
.ai-source-indicator.local {
color: #667eea;
}
.ai-source-indicator.server {
color: #f59e0b;
}
/* Translation button states */
.translate-btn {
padding: 6px 12px;
background: var(--yt-bg-primary, #0f0f0f);
border: 1px solid var(--yt-border, #303030);
border-radius: 20px;
color: var(--yt-text-primary, #fff);
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.translate-btn:hover {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%);
border-color: rgba(102, 126, 234, 0.4);
}
.translate-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: transparent;
color: white;
}
.translate-btn.loading {
opacity: 0.7;
pointer-events: none;
}
.translate-btn .spinner {
width: 12px;
height: 12px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Model selector in settings */
.webllm-model-selector {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.webllm-model-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.webllm-model-option:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(102, 126, 234, 0.3);
}
.webllm-model-option.selected {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
border-color: rgba(102, 126, 234, 0.5);
}
.webllm-model-option input[type="radio"] {
accent-color: #667eea;
}
.webllm-model-info {
flex: 1;
}
.webllm-model-name {
font-size: 13px;
font-weight: 500;
color: #fff;
}
.webllm-model-size {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
}
/* Toast notification for WebLLM status */
.webllm-toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, rgba(102, 126, 234, 0.95) 0%, rgba(118, 75, 162, 0.95) 100%);
color: white;
padding: 12px 24px;
border-radius: 12px;
font-size: 13px;
font-weight: 500;
z-index: 10000;
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.4);
animation: toastIn 0.3s ease-out;
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* Responsive adjustments */
@media (max-width: 480px) {
.webllm-loading-overlay {
left: 10px;
right: 10px;
bottom: 80px;
min-width: unset;
}
}

32
static/css/style.css Normal file → Executable file
View file

@ -1,19 +1,19 @@
/* KV-Tube - YouTube Clone Design System */ /* KV-Tube - YouTube Clone Design System */
/* Core */ /* Core */
@import 'modules/variables.css'; @import 'modules/variables.css';
@import 'modules/base.css'; @import 'modules/base.css';
@import 'modules/utils.css'; @import 'modules/utils.css';
/* Layout & Structure */ /* Layout & Structure */
@import 'modules/layout.css'; @import 'modules/layout.css';
@import 'modules/grid.css'; @import 'modules/grid.css';
/* Components */ /* Components */
@import 'modules/components.css'; @import 'modules/components.css';
@import 'modules/cards.css'; @import 'modules/cards.css';
/* Pages */ /* Pages */
@import 'modules/pages.css'; @import 'modules/pages.css';
/* Hide extension-injected error elements */ /* Hide extension-injected error elements */
*[/onboarding/], *[/onboarding/],

0
static/favicon.ico Normal file → Executable file
View file

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

0
static/icons/icon-192x192.png Normal file → Executable file
View file

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

0
static/icons/icon-512x512.png Normal file → Executable file
View file

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

0
static/js/artplayer.js Normal file → Executable file
View file

1234
static/js/download-manager.js Normal file → Executable file

File diff suppressed because it is too large Load diff

0
static/js/hls.min.js vendored Normal file → Executable file
View file

2086
static/js/main.js Normal file → Executable file

File diff suppressed because it is too large Load diff

408
static/js/navigation-manager.js Normal file → Executable file
View file

@ -1,204 +1,204 @@
/** /**
* KV-Tube Navigation Manager * KV-Tube Navigation Manager
* Handles SPA-style navigation to persist state (like downloads) across pages. * Handles SPA-style navigation to persist state (like downloads) across pages.
*/ */
class NavigationManager { class NavigationManager {
constructor() { constructor() {
this.mainContentId = 'mainContent'; this.mainContentId = 'mainContent';
this.pageCache = new Map(); this.pageCache = new Map();
this.maxCacheSize = 20; this.maxCacheSize = 20;
this.init(); this.init();
} }
init() { init() {
// Handle browser back/forward buttons // Handle browser back/forward buttons
window.addEventListener('popstate', (e) => { window.addEventListener('popstate', (e) => {
if (e.state && e.state.url) { if (e.state && e.state.url) {
this.loadPage(e.state.url, false); this.loadPage(e.state.url, false);
} else { } else {
// Fallback for initial state or external navigation // Fallback for initial state or external navigation
this.loadPage(window.location.href, false); this.loadPage(window.location.href, false);
} }
}); });
// Intercept clicks // Intercept clicks
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
// Find closest anchor tag // Find closest anchor tag
const link = e.target.closest('a'); const link = e.target.closest('a');
// Check if it's an internal link and not a download/special link // Check if it's an internal link and not a download/special link
if (link && if (link &&
link.href && link.href &&
link.href.startsWith(window.location.origin) && link.href.startsWith(window.location.origin) &&
!link.getAttribute('download') && !link.getAttribute('download') &&
!link.getAttribute('target') && !link.getAttribute('target') &&
!link.classList.contains('no-spa') && !link.classList.contains('no-spa') &&
!e.ctrlKey && !e.metaKey && !e.shiftKey // Allow new tab clicks !e.ctrlKey && !e.metaKey && !e.shiftKey // Allow new tab clicks
) { ) {
e.preventDefault(); e.preventDefault();
const url = link.href; const url = link.href;
this.navigateTo(url); this.navigateTo(url);
// Update active state in sidebar // Update active state in sidebar
this.updateSidebarActiveState(link); this.updateSidebarActiveState(link);
} }
}); });
// Save initial state // Save initial state
const currentUrl = window.location.href; const currentUrl = window.location.href;
if (!this.pageCache.has(currentUrl)) { if (!this.pageCache.has(currentUrl)) {
// We don't have the raw HTML, so we can't fully cache the initial page accurately // We don't have the raw HTML, so we can't fully cache the initial page accurately
// without fetching it or serializing current DOM. // without fetching it or serializing current DOM.
// For now, we will cache it upon *leaving* securely or just let the first visit be uncached. // For now, we will cache it upon *leaving* securely or just let the first visit be uncached.
// Better: Cache the current DOM state as the "initial" state. // Better: Cache the current DOM state as the "initial" state.
this.saveCurrentState(currentUrl); this.saveCurrentState(currentUrl);
} }
} }
saveCurrentState(url) { saveCurrentState(url) {
const mainContent = document.getElementById(this.mainContentId); const mainContent = document.getElementById(this.mainContentId);
if (mainContent) { if (mainContent) {
this.pageCache.set(url, { this.pageCache.set(url, {
html: mainContent.innerHTML, html: mainContent.innerHTML,
title: document.title, title: document.title,
scrollY: window.scrollY, scrollY: window.scrollY,
className: mainContent.className className: mainContent.className
}); });
// Prune cache // Prune cache
if (this.pageCache.size > this.maxCacheSize) { if (this.pageCache.size > this.maxCacheSize) {
const firstKey = this.pageCache.keys().next().value; const firstKey = this.pageCache.keys().next().value;
this.pageCache.delete(firstKey); this.pageCache.delete(firstKey);
} }
} }
} }
async navigateTo(url) { async navigateTo(url) {
// Start Progress Bar // Start Progress Bar
const bar = document.getElementById('nprogress-bar'); const bar = document.getElementById('nprogress-bar');
if (bar) { if (bar) {
bar.style.opacity = '1'; bar.style.opacity = '1';
bar.style.width = '30%'; bar.style.width = '30%';
} }
// Save state of current page before leaving // Save state of current page before leaving
this.saveCurrentState(window.location.href); this.saveCurrentState(window.location.href);
// Update history // Update history
history.pushState({ url: url }, '', url); history.pushState({ url: url }, '', url);
await this.loadPage(url); await this.loadPage(url);
} }
async loadPage(url, pushState = true) { async loadPage(url, pushState = true) {
const bar = document.getElementById('nprogress-bar'); const bar = document.getElementById('nprogress-bar');
if (bar) bar.style.width = '60%'; if (bar) bar.style.width = '60%';
const mainContent = document.getElementById(this.mainContentId); const mainContent = document.getElementById(this.mainContentId);
if (!mainContent) return; if (!mainContent) return;
// Check cache // Check cache
if (this.pageCache.has(url)) { if (this.pageCache.has(url)) {
const cached = this.pageCache.get(url); const cached = this.pageCache.get(url);
// Restore content // Restore content
document.title = cached.title; document.title = cached.title;
mainContent.innerHTML = cached.html; mainContent.innerHTML = cached.html;
mainContent.className = cached.className; mainContent.className = cached.className;
// Re-execute scripts // Re-execute scripts
this.executeScripts(mainContent); this.executeScripts(mainContent);
// Re-initialize App // Re-initialize App
if (typeof window.initApp === 'function') { if (typeof window.initApp === 'function') {
window.initApp(); window.initApp();
} }
// Restore scroll // Restore scroll
window.scrollTo(0, cached.scrollY); window.scrollTo(0, cached.scrollY);
return; return;
} }
// Show loading state if needed // Show loading state if needed
mainContent.style.opacity = '0.5'; mainContent.style.opacity = '0.5';
try { try {
const response = await fetch(url); const response = await fetch(url);
const html = await response.text(); const html = await response.text();
// Parse HTML // Parse HTML
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html'); const doc = parser.parseFromString(html, 'text/html');
// Extract new content // Extract new content
const newContent = doc.getElementById(this.mainContentId); const newContent = doc.getElementById(this.mainContentId);
if (!newContent) { if (!newContent) {
// Check if it's a full page not extending layout properly or error // Check if it's a full page not extending layout properly or error
console.error('Could not find mainContent in response'); console.error('Could not find mainContent in response');
window.location.href = url; // Fallback to full reload window.location.href = url; // Fallback to full reload
return; return;
} }
// Update title // Update title
document.title = doc.title; document.title = doc.title;
// Replace content // Replace content
mainContent.innerHTML = newContent.innerHTML; mainContent.innerHTML = newContent.innerHTML;
mainContent.className = newContent.className; // Maintain classes mainContent.className = newContent.className; // Maintain classes
// Execute scripts found in the new content (critical for APP_CONFIG) // Execute scripts found in the new content (critical for APP_CONFIG)
this.executeScripts(mainContent); this.executeScripts(mainContent);
// Re-initialize App logic // Re-initialize App logic
if (typeof window.initApp === 'function') { if (typeof window.initApp === 'function') {
window.initApp(); window.initApp();
} }
// Scroll to top for new pages // Scroll to top for new pages
window.scrollTo(0, 0); window.scrollTo(0, 0);
// Save to cache (initial state of this page) // Save to cache (initial state of this page)
this.pageCache.set(url, { this.pageCache.set(url, {
html: newContent.innerHTML, html: newContent.innerHTML,
title: doc.title, title: doc.title,
scrollY: 0, scrollY: 0,
className: newContent.className className: newContent.className
}); });
} catch (error) { } catch (error) {
console.error('Navigation error:', error); console.error('Navigation error:', error);
// Fallback // Fallback
window.location.href = url; window.location.href = url;
} finally { } finally {
mainContent.style.opacity = '1'; mainContent.style.opacity = '1';
} }
} }
executeScripts(element) { executeScripts(element) {
const scripts = element.querySelectorAll('script'); const scripts = element.querySelectorAll('script');
scripts.forEach(oldScript => { scripts.forEach(oldScript => {
const newScript = document.createElement('script'); const newScript = document.createElement('script');
Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value)); Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value));
newScript.textContent = oldScript.textContent; newScript.textContent = oldScript.textContent;
oldScript.parentNode.replaceChild(newScript, oldScript); oldScript.parentNode.replaceChild(newScript, oldScript);
}); });
} }
updateSidebarActiveState(clickedLink) { updateSidebarActiveState(clickedLink) {
// Remove active class from all items // Remove active class from all items
document.querySelectorAll('.yt-sidebar-item').forEach(item => item.classList.remove('active')); document.querySelectorAll('.yt-sidebar-item').forEach(item => item.classList.remove('active'));
// Add to clicked if it is a sidebar item // Add to clicked if it is a sidebar item
if (clickedLink.classList.contains('yt-sidebar-item')) { if (clickedLink.classList.contains('yt-sidebar-item')) {
clickedLink.classList.add('active'); clickedLink.classList.add('active');
} else { } else {
// Try to find matching sidebar item // Try to find matching sidebar item
const path = new URL(clickedLink.href).pathname; const path = new URL(clickedLink.href).pathname;
const match = document.querySelector(`.yt-sidebar-item[href="${path}"]`); const match = document.querySelector(`.yt-sidebar-item[href="${path}"]`);
if (match) match.classList.add('active'); if (match) match.classList.add('active');
} }
} }
} }
// Initialize // Initialize
window.navigationManager = new NavigationManager(); window.navigationManager = new NavigationManager();

340
static/js/webllm-service.js Normal file
View file

@ -0,0 +1,340 @@
/**
* WebLLM Service - Browser-based AI for Translation & Summarization
* Uses MLC's WebLLM for on-device AI inference via WebGPU
*/
class WebLLMService {
constructor() {
this.engine = null;
this.isLoading = false;
this.loadProgress = 0;
this.currentModel = null;
// Model configurations - Qwen2 chosen for Vietnamese support
this.models = {
'qwen2-0.5b': 'Qwen2-0.5B-Instruct-q4f16_1-MLC',
'phi-3.5-mini': 'Phi-3.5-mini-instruct-q4f16_1-MLC',
'smollm2': 'SmolLM2-360M-Instruct-q4f16_1-MLC'
};
// Default to lightweight Qwen2 for Vietnamese support
this.selectedModel = 'qwen2-0.5b';
// Callbacks
this.onProgressCallback = null;
this.onReadyCallback = null;
this.onErrorCallback = null;
}
/**
* Check if WebGPU is supported
*/
static isSupported() {
return 'gpu' in navigator;
}
/**
* Initialize WebLLM with selected model
* @param {string} modelKey - Model key from this.models
* @param {function} onProgress - Progress callback (percent, status)
* @returns {Promise<boolean>}
*/
async init(modelKey = null, onProgress = null) {
if (!WebLLMService.isSupported()) {
console.warn('WebGPU not supported in this browser');
if (this.onErrorCallback) {
this.onErrorCallback('WebGPU not supported. Using server-side AI.');
}
return false;
}
if (this.engine && this.currentModel === (modelKey || this.selectedModel)) {
console.log('WebLLM already initialized with this model');
return true;
}
this.isLoading = true;
this.onProgressCallback = onProgress;
try {
// Dynamic import of WebLLM
const webllm = await import('https://esm.run/@mlc-ai/web-llm');
const modelId = this.models[modelKey || this.selectedModel];
console.log('Loading WebLLM model:', modelId);
// Progress callback wrapper
const initProgressCallback = (progress) => {
this.loadProgress = Math.round(progress.progress * 100);
const status = progress.text || 'Loading model...';
console.log(`WebLLM: ${this.loadProgress}% - ${status}`);
if (this.onProgressCallback) {
this.onProgressCallback(this.loadProgress, status);
}
};
// Create engine
this.engine = await webllm.CreateMLCEngine(modelId, {
initProgressCallback: initProgressCallback
});
this.currentModel = modelKey || this.selectedModel;
this.isLoading = false;
this.loadProgress = 100;
console.log('WebLLM ready!');
if (this.onReadyCallback) {
this.onReadyCallback();
}
return true;
} catch (error) {
console.error('WebLLM initialization failed:', error);
this.isLoading = false;
if (this.onErrorCallback) {
this.onErrorCallback(error.message);
}
return false;
}
}
/**
* Check if engine is ready
*/
isReady() {
return this.engine !== null && !this.isLoading;
}
/**
* Summarize text using local AI
* @param {string} text - Text to summarize
* @param {string} language - Output language ('en' or 'vi')
* @returns {Promise<string>}
*/
async summarize(text, language = 'en') {
if (!this.isReady()) {
throw new Error('WebLLM not ready. Call init() first.');
}
// Truncate text to avoid token limits
const maxChars = 4000;
if (text.length > maxChars) {
text = text.substring(0, maxChars) + '...';
}
const langInstruction = language === 'vi'
? 'Respond in Vietnamese (Tiếng Việt).'
: 'Respond in English.';
const messages = [
{
role: 'system',
content: `You are a helpful AI assistant that creates detailed, insightful video summaries. ${langInstruction}`
},
{
role: 'user',
content: `Provide a comprehensive summary of this video transcript in 4-6 sentences. Include the main topic, key points discussed, and any important insights or conclusions. Make the summary informative and meaningful:\n\n${text}`
}
];
try {
const response = await this.engine.chat.completions.create({
messages: messages,
temperature: 0.7,
max_tokens: 350
});
return response.choices[0].message.content.trim();
} catch (error) {
console.error('Summarization error:', error);
throw error;
}
}
/**
* Translate text between English and Vietnamese
* @param {string} text - Text to translate
* @param {string} sourceLang - Source language ('en' or 'vi')
* @param {string} targetLang - Target language ('en' or 'vi')
* @returns {Promise<string>}
*/
async translate(text, sourceLang = 'en', targetLang = 'vi') {
if (!this.isReady()) {
throw new Error('WebLLM not ready. Call init() first.');
}
const langNames = {
'en': 'English',
'vi': 'Vietnamese (Tiếng Việt)'
};
const messages = [
{
role: 'system',
content: `You are a professional translator. Translate the following text from ${langNames[sourceLang]} to ${langNames[targetLang]}. Provide only the translation, no explanations.`
},
{
role: 'user',
content: text
}
];
try {
const response = await this.engine.chat.completions.create({
messages: messages,
temperature: 0.3,
max_tokens: 500
});
return response.choices[0].message.content.trim();
} catch (error) {
console.error('Translation error:', error);
throw error;
}
}
/**
* Extract key points from text
* @param {string} text - Text to analyze
* @param {string} language - Output language
* @returns {Promise<string[]>}
*/
async extractKeyPoints(text, language = 'en') {
if (!this.isReady()) {
throw new Error('WebLLM not ready. Call init() first.');
}
const maxChars = 3000;
if (text.length > maxChars) {
text = text.substring(0, maxChars) + '...';
}
const langInstruction = language === 'vi'
? 'Respond in Vietnamese.'
: 'Respond in English.';
const messages = [
{
role: 'system',
content: `You extract the main IDEAS and CONCEPTS from video content. ${langInstruction} Focus on:
- Main topics discussed
- Key insights or takeaways
- Important facts or claims
- Conclusions or recommendations
Do NOT copy sentences from the transcript. Instead, synthesize the core ideas in your own words. List 3-5 key points, one per line, without bullet points or numbers.`
},
{
role: 'user',
content: `What are the main ideas and takeaways from this video transcript?\n\n${text}`
}
];
try {
const response = await this.engine.chat.completions.create({
messages: messages,
temperature: 0.6,
max_tokens: 400
});
const content = response.choices[0].message.content.trim();
const points = content.split('\n')
.map(line => line.replace(/^[\d\.\-\*\•]+\s*/, '').trim())
.filter(line => line.length > 10);
return points.slice(0, 5);
} catch (error) {
console.error('Key points extraction error:', error);
throw error;
}
}
/**
* Stream chat completion for real-time output
* @param {string} prompt - User prompt
* @param {function} onChunk - Callback for each chunk
* @returns {Promise<string>}
*/
async streamChat(prompt, onChunk) {
if (!this.isReady()) {
throw new Error('WebLLM not ready.');
}
const messages = [
{ role: 'user', content: prompt }
];
try {
const chunks = await this.engine.chat.completions.create({
messages: messages,
temperature: 0.7,
stream: true
});
let fullResponse = '';
for await (const chunk of chunks) {
const delta = chunk.choices[0]?.delta?.content || '';
fullResponse += delta;
if (onChunk) {
onChunk(delta, fullResponse);
}
}
return fullResponse;
} catch (error) {
console.error('Stream chat error:', error);
throw error;
}
}
/**
* Get available models
*/
getModels() {
return Object.keys(this.models).map(key => ({
id: key,
name: this.models[key],
selected: key === this.selectedModel
}));
}
/**
* Set selected model (requires re-init)
*/
setModel(modelKey) {
if (this.models[modelKey]) {
this.selectedModel = modelKey;
// Reset engine to force reload with new model
this.engine = null;
this.currentModel = null;
}
}
/**
* Cleanup and release resources
*/
async destroy() {
if (this.engine) {
// WebLLM doesn't have explicit destroy, but we can nullify
this.engine = null;
this.currentModel = null;
this.loadProgress = 0;
}
}
}
// Global singleton instance
window.webLLMService = new WebLLMService();
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = WebLLMService;
}

0
static/manifest.json Normal file → Executable file
View file

0
static/sw.js Normal file → Executable file
View file

970
templates/channel.html Normal file → Executable file
View file

@ -1,486 +1,486 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block content %} {% block content %}
<div class="yt-container yt-channel-page"> <div class="yt-container yt-channel-page">
<!-- Channel Header (No Banner) --> <!-- Channel Header (No Banner) -->
<div class="yt-channel-header"> <div class="yt-channel-header">
<div class="yt-channel-info-row"> <div class="yt-channel-info-row">
<div class="yt-channel-avatar-xl" id="channelAvatarLarge"> <div class="yt-channel-avatar-xl" id="channelAvatarLarge">
{% if channel.avatar %} {% if channel.avatar %}
<img src="{{ channel.avatar }}"> <img src="{{ channel.avatar }}">
{% else %} {% else %}
<span id="channelAvatarLetter">{{ channel.title[0] | upper if channel.title and channel.title != <span id="channelAvatarLetter">{{ channel.title[0] | upper if channel.title and channel.title !=
'Loading...' else 'C' }}</span> 'Loading...' else 'C' }}</span>
{% endif %} {% endif %}
</div> </div>
<div class="yt-channel-meta"> <div class="yt-channel-meta">
<h1 id="channelTitle">{{ channel.title if channel.title and channel.title != 'Loading...' else <h1 id="channelTitle">{{ channel.title if channel.title and channel.title != 'Loading...' else
'Loading...' }}</h1> 'Loading...' }}</h1>
<p class="yt-channel-handle" id="channelHandle"> <p class="yt-channel-handle" id="channelHandle">
{% if channel.title and channel.title != 'Loading...' %}@{{ channel.title|replace(' ', '') }}{% else {% if channel.title and channel.title != 'Loading...' %}@{{ channel.title|replace(' ', '') }}{% else
%}@Loading...{% endif %} %}@Loading...{% endif %}
</p> </p>
<div class="yt-channel-stats"> <div class="yt-channel-stats">
<span id="channelStats"></span> <span id="channelStats"></span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Video Grid --> <!-- Video Grid -->
<div class="yt-section"> <div class="yt-section">
<div class="yt-section-header"> <div class="yt-section-header">
<div class="yt-tabs"> <div class="yt-tabs">
<a href="javascript:void(0)" onclick="changeChannelTab('video', this); return false;" <a href="javascript:void(0)" onclick="changeChannelTab('video', this); return false;"
class="active no-spa"> class="active no-spa">
<i class="fas fa-video"></i> <i class="fas fa-video"></i>
<span>Videos</span> <span>Videos</span>
</a> </a>
<a href="javascript:void(0)" onclick="changeChannelTab('shorts', this); return false;" class="no-spa"> <a href="javascript:void(0)" onclick="changeChannelTab('shorts', this); return false;" class="no-spa">
<i class="fas fa-bolt"></i> <i class="fas fa-bolt"></i>
<span>Shorts</span> <span>Shorts</span>
</a> </a>
</div> </div>
<div class="yt-sort-options"> <div class="yt-sort-options">
<a href="javascript:void(0)" onclick="changeChannelSort('latest', this); return false;" <a href="javascript:void(0)" onclick="changeChannelSort('latest', this); return false;"
class="active no-spa"> class="active no-spa">
<i class="fas fa-clock"></i> <i class="fas fa-clock"></i>
<span>Latest</span> <span>Latest</span>
</a> </a>
<a href="javascript:void(0)" onclick="changeChannelSort('popular', this); return false;" class="no-spa"> <a href="javascript:void(0)" onclick="changeChannelSort('popular', this); return false;" class="no-spa">
<i class="fas fa-fire"></i> <i class="fas fa-fire"></i>
<span>Popular</span> <span>Popular</span>
</a> </a>
<a href="javascript:void(0)" onclick="changeChannelSort('oldest', this); return false;" class="no-spa"> <a href="javascript:void(0)" onclick="changeChannelSort('oldest', this); return false;" class="no-spa">
<i class="fas fa-history"></i> <i class="fas fa-history"></i>
<span>Oldest</span> <span>Oldest</span>
</a> </a>
</div> </div>
</div> </div>
<div class="yt-video-grid" id="channelVideosGrid"> <div class="yt-video-grid" id="channelVideosGrid">
<!-- Videos loaded via JS --> <!-- Videos loaded via JS -->
</div> </div>
<div id="channelLoadingTrigger" style="height: 20px; margin: 20px 0;"></div> <div id="channelLoadingTrigger" style="height: 20px; margin: 20px 0;"></div>
</div> </div>
</div> </div>
<style> <style>
.yt-channel-page { .yt-channel-page {
padding-top: 40px; padding-top: 40px;
padding-bottom: 40px; padding-bottom: 40px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
/* Removed .yt-channel-banner */ /* Removed .yt-channel-banner */
.yt-channel-info-row { .yt-channel-info-row {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 32px; gap: 32px;
margin-bottom: 32px; margin-bottom: 32px;
padding: 0 16px; padding: 0 16px;
} }
.yt-channel-avatar-xl { .yt-channel-avatar-xl {
width: 160px; width: 160px;
height: 160px; height: 160px;
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%); background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%);
/* Simpler color for no-banner look */ /* Simpler color for no-banner look */
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 64px; font-size: 64px;
font-weight: bold; font-weight: bold;
color: white; color: white;
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
.yt-channel-avatar-xl img { .yt-channel-avatar-xl img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
.yt-channel-meta { .yt-channel-meta {
padding-top: 12px; padding-top: 12px;
} }
.yt-channel-meta h1 { .yt-channel-meta h1 {
font-size: 32px; font-size: 32px;
margin-bottom: 8px; margin-bottom: 8px;
font-weight: 700; font-weight: 700;
} }
.yt-channel-handle { .yt-channel-handle {
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
font-size: 16px; font-size: 16px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.yt-channel-stats { .yt-channel-stats {
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
font-size: 14px; font-size: 14px;
margin-bottom: 24px; margin-bottom: 24px;
} }
.yt-section-header { .yt-section-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 20px; gap: 20px;
margin-bottom: 24px; margin-bottom: 24px;
padding: 0 16px; padding: 0 16px;
border-bottom: none; border-bottom: none;
} }
.yt-tabs { .yt-tabs {
display: inline-flex; display: inline-flex;
gap: 8px; gap: 8px;
background: var(--yt-bg-secondary); background: var(--yt-bg-secondary);
padding: 6px; padding: 6px;
border-radius: 16px; border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
} }
.yt-tabs a { .yt-tabs a {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 12px 24px; padding: 12px 24px;
border-radius: 12px; border-radius: 12px;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
text-decoration: none; text-decoration: none;
transition: all 0.25s ease; transition: all 0.25s ease;
border: none; border: none;
background: transparent; background: transparent;
} }
.yt-tabs a:hover { .yt-tabs a:hover {
color: var(--yt-text-primary); color: var(--yt-text-primary);
background: var(--yt-bg-hover); background: var(--yt-bg-hover);
} }
.yt-tabs a.active { .yt-tabs a.active {
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%); background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
color: white; color: white;
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3); box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
} }
.yt-sort-options { .yt-sort-options {
display: inline-flex; display: inline-flex;
gap: 8px; gap: 8px;
background: var(--yt-bg-secondary); background: var(--yt-bg-secondary);
padding: 6px; padding: 6px;
border-radius: 16px; border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
} }
.yt-sort-options a { .yt-sort-options a {
padding: 10px 20px; padding: 10px 20px;
border-radius: 12px; border-radius: 12px;
background: transparent; background: transparent;
border: none; border: none;
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
text-decoration: none; text-decoration: none;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
transition: all 0.25s ease; transition: all 0.25s ease;
} }
.yt-sort-options a:hover { .yt-sort-options a:hover {
background: var(--yt-bg-hover); background: var(--yt-bg-hover);
color: var(--yt-text-primary); color: var(--yt-text-primary);
} }
.yt-sort-options a.active { .yt-sort-options a.active {
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%); background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
color: white; color: white;
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3); box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
} }
/* Shorts Card Styling override for Channel Page grid */ /* Shorts Card Styling override for Channel Page grid */
.yt-channel-short-card { .yt-channel-short-card {
border-radius: var(--yt-radius-lg); border-radius: var(--yt-radius-lg);
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
transition: transform 0.2s; transition: transform 0.2s;
} }
.yt-channel-short-card:hover { .yt-channel-short-card:hover {
transform: scale(1.02); transform: scale(1.02);
} }
.yt-short-thumb-container { .yt-short-thumb-container {
aspect-ratio: 9/16; aspect-ratio: 9/16;
width: 100%; width: 100%;
position: relative; position: relative;
} }
.yt-short-thumb { .yt-short-thumb {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.yt-channel-info-row { .yt-channel-info-row {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
gap: 16px; gap: 16px;
} }
.yt-channel-avatar-xl { .yt-channel-avatar-xl {
width: 100px; width: 100px;
height: 100px; height: 100px;
font-size: 40px; font-size: 40px;
} }
.yt-channel-meta h1 { .yt-channel-meta h1 {
font-size: 24px; font-size: 24px;
} }
.yt-section-header { .yt-section-header {
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
align-items: flex-start; align-items: flex-start;
} }
.yt-tabs { .yt-tabs {
width: 100%; width: 100%;
justify-content: center; justify-content: center;
} }
} }
</style> </style>
<script> <script>
(function () { (function () {
// IIFE to prevent variable redeclaration errors on SPA navigation // IIFE to prevent variable redeclaration errors on SPA navigation
console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}"); console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}");
var currentChannelSort = 'latest'; var currentChannelSort = 'latest';
var currentChannelPage = 1; var currentChannelPage = 1;
var isChannelLoading = false; var isChannelLoading = false;
var hasMoreChannelVideos = true; var hasMoreChannelVideos = true;
var currentFilterType = 'video'; var currentFilterType = 'video';
var channelId = "{{ channel.id }}"; var channelId = "{{ channel.id }}";
// Store initial channel title from server template (don't overwrite with empty API data) // Store initial channel title from server template (don't overwrite with empty API data)
var initialChannelTitle = "{{ channel.title }}"; var initialChannelTitle = "{{ channel.title }}";
function init() { function init() {
console.log("Channel init called, fetching content..."); console.log("Channel init called, fetching content...");
fetchChannelContent(); fetchChannelContent();
setupInfiniteScroll(); setupInfiniteScroll();
} }
// Handle both initial page load and SPA navigation // Handle both initial page load and SPA navigation
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
} else { } else {
// DOM is already ready (SPA navigation) // DOM is already ready (SPA navigation)
init(); init();
} }
function changeChannelTab(type, btn) { function changeChannelTab(type, btn) {
if (type === currentFilterType || isChannelLoading) return; if (type === currentFilterType || isChannelLoading) return;
currentFilterType = type; currentFilterType = type;
currentChannelPage = 1; currentChannelPage = 1;
hasMoreChannelVideos = true; hasMoreChannelVideos = true;
document.getElementById('channelVideosGrid').innerHTML = ''; document.getElementById('channelVideosGrid').innerHTML = '';
// Update Tabs UI // Update Tabs UI
document.querySelectorAll('.yt-tabs a').forEach(a => a.classList.remove('active')); document.querySelectorAll('.yt-tabs a').forEach(a => a.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
// Adjust Grid layout for Shorts vs Videos // Adjust Grid layout for Shorts vs Videos
const grid = document.getElementById('channelVideosGrid'); const grid = document.getElementById('channelVideosGrid');
if (type === 'shorts') { if (type === 'shorts') {
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))'; grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
} else { } else {
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))'; grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))';
} }
fetchChannelContent(); fetchChannelContent();
} }
function changeChannelSort(sort, btn) { function changeChannelSort(sort, btn) {
if (isChannelLoading) return; if (isChannelLoading) return;
currentChannelSort = sort; currentChannelSort = sort;
currentChannelPage = 1; currentChannelPage = 1;
hasMoreChannelVideos = true; hasMoreChannelVideos = true;
document.getElementById('channelVideosGrid').innerHTML = ''; // Clear document.getElementById('channelVideosGrid').innerHTML = ''; // Clear
// Update tabs // Update tabs
document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active')); document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
fetchChannelContent(); fetchChannelContent();
} }
async function fetchChannelContent() { async function fetchChannelContent() {
console.log("fetchChannelContent() called"); console.log("fetchChannelContent() called");
if (isChannelLoading || !hasMoreChannelVideos) { if (isChannelLoading || !hasMoreChannelVideos) {
console.log("Early return:", { isChannelLoading, hasMoreChannelVideos }); console.log("Early return:", { isChannelLoading, hasMoreChannelVideos });
return; return;
} }
isChannelLoading = true; isChannelLoading = true;
const grid = document.getElementById('channelVideosGrid'); const grid = document.getElementById('channelVideosGrid');
// Append Loading indicator // Append Loading indicator
if (typeof renderSkeleton === 'function') { if (typeof renderSkeleton === 'function') {
grid.insertAdjacentHTML('beforeend', renderSkeleton(4)); grid.insertAdjacentHTML('beforeend', renderSkeleton(4));
} else { } else {
grid.insertAdjacentHTML('beforeend', '<div class="loading-text" style="color:var(--yt-text-secondary); padding: 20px;">Loading videos...</div>'); grid.insertAdjacentHTML('beforeend', '<div class="loading-text" style="color:var(--yt-text-secondary); padding: 20px;">Loading videos...</div>');
} }
try { try {
console.log(`Fetching: /api/channel?id=${channelId}&page=${currentChannelPage}`); console.log(`Fetching: /api/channel?id=${channelId}&page=${currentChannelPage}`);
const response = await fetch(`/api/channel?id=${channelId}&page=${currentChannelPage}&sort=${currentChannelSort}&filter_type=${currentFilterType}`); const response = await fetch(`/api/channel?id=${channelId}&page=${currentChannelPage}&sort=${currentChannelSort}&filter_type=${currentFilterType}`);
const videos = await response.json(); const videos = await response.json();
console.log("Channel Videos Response:", videos); console.log("Channel Videos Response:", videos);
// Remove skeletons (simple way: remove last N children or just clear all if page 1? // Remove skeletons (simple way: remove last N children or just clear all if page 1?
// Better: mark skeletons with class and remove) // Better: mark skeletons with class and remove)
// For simplicity in this v1: We just clear skeletons by removing elements with 'skeleton-card' class // For simplicity in this v1: We just clear skeletons by removing elements with 'skeleton-card' class
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove()); document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
// Check if response is an error // Check if response is an error
if (videos.error) { if (videos.error) {
hasMoreChannelVideos = false; hasMoreChannelVideos = false;
grid.innerHTML = `<p style="padding:20px; color:var(--yt-text-secondary);">Error: ${videos.error}</p>`; grid.innerHTML = `<p style="padding:20px; color:var(--yt-text-secondary);">Error: ${videos.error}</p>`;
return; return;
} }
if (!Array.isArray(videos) || videos.length === 0) { if (!Array.isArray(videos) || videos.length === 0) {
hasMoreChannelVideos = false; hasMoreChannelVideos = false;
if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>'; if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>';
} else { } else {
// Update channel header with uploader info from first video (on first page only) // Update channel header with uploader info from first video (on first page only)
if (currentChannelPage === 1 && videos[0]) { if (currentChannelPage === 1 && videos[0]) {
// Use only proper channel/uploader fields - do NOT parse from title // Use only proper channel/uploader fields - do NOT parse from title
let channelName = videos[0].channel || videos[0].uploader || ''; let channelName = videos[0].channel || videos[0].uploader || '';
// Only update header if API returned a meaningful name // Only update header if API returned a meaningful name
// (not empty, not just the channel ID, and not "Loading...") // (not empty, not just the channel ID, and not "Loading...")
if (channelName && channelName !== channelId && if (channelName && channelName !== channelId &&
!channelName.startsWith('UC') && channelName !== 'Loading...') { !channelName.startsWith('UC') && channelName !== 'Loading...') {
document.getElementById('channelTitle').textContent = channelName; document.getElementById('channelTitle').textContent = channelName;
document.getElementById('channelHandle').textContent = '@' + channelName.replace(/\s+/g, ''); document.getElementById('channelHandle').textContent = '@' + channelName.replace(/\s+/g, '');
const avatarLetter = document.getElementById('channelAvatarLetter'); const avatarLetter = document.getElementById('channelAvatarLetter');
if (avatarLetter) avatarLetter.textContent = channelName.charAt(0).toUpperCase(); if (avatarLetter) avatarLetter.textContent = channelName.charAt(0).toUpperCase();
} }
// If no meaningful name from API, keep the initial template-rendered title // If no meaningful name from API, keep the initial template-rendered title
} }
videos.forEach(video => { videos.forEach(video => {
const card = document.createElement('div'); const card = document.createElement('div');
if (currentFilterType === 'shorts') { if (currentFilterType === 'shorts') {
// Render Vertical Short Card // Render Vertical Short Card
card.className = 'yt-channel-short-card'; card.className = 'yt-channel-short-card';
card.onclick = () => window.location.href = `/watch?v=${video.id}`; card.onclick = () => window.location.href = `/watch?v=${video.id}`;
card.innerHTML = ` card.innerHTML = `
<div class="yt-short-thumb-container"> <div class="yt-short-thumb-container">
<img src="${video.thumbnail}" class="yt-short-thumb loaded" loading="lazy" onload="this.classList.add('loaded')"> <img src="${video.thumbnail}" class="yt-short-thumb loaded" loading="lazy" onload="this.classList.add('loaded')">
</div> </div>
<div class="yt-details" style="padding: 8px;"> <div class="yt-details" style="padding: 8px;">
<h3 class="yt-video-title" style="font-size: 14px; margin-bottom: 4px;">${escapeHtml(video.title)}</h3> <h3 class="yt-video-title" style="font-size: 14px; margin-bottom: 4px;">${escapeHtml(video.title)}</h3>
<p class="yt-video-stats">${formatViews(video.view_count)} views</p> <p class="yt-video-stats">${formatViews(video.view_count)} views</p>
</div> </div>
`; `;
} else { } else {
// Render Standard Video Card (Match Home) // Render Standard Video Card (Match Home)
card.className = 'yt-video-card'; card.className = 'yt-video-card';
card.onclick = () => window.location.href = `/watch?v=${video.id}`; card.onclick = () => window.location.href = `/watch?v=${video.id}`;
card.innerHTML = ` card.innerHTML = `
<div class="yt-thumbnail-container"> <div class="yt-thumbnail-container">
<img class="yt-thumbnail loaded" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')"> <img class="yt-thumbnail loaded" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')">
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''} ${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
</div> </div>
<div class="yt-video-details"> <div class="yt-video-details">
<div class="yt-video-meta"> <div class="yt-video-meta">
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3> <h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
<p class="yt-video-stats"> <p class="yt-video-stats">
${formatViews(video.view_count)} views • ${formatDate(video.upload_date)} ${formatViews(video.view_count)} views • ${formatDate(video.upload_date)}
</p> </p>
</div> </div>
</div> </div>
`; `;
} }
grid.appendChild(card); grid.appendChild(card);
}); });
currentChannelPage++; currentChannelPage++;
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally { } finally {
isChannelLoading = false; isChannelLoading = false;
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove()); document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
} }
} }
function setupInfiniteScroll() { function setupInfiniteScroll() {
const trigger = document.getElementById('channelLoadingTrigger'); const trigger = document.getElementById('channelLoadingTrigger');
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) { if (entries[0].isIntersecting) {
fetchChannelContent(); fetchChannelContent();
} }
}, { threshold: 0.1 }); }, { threshold: 0.1 });
observer.observe(trigger); observer.observe(trigger);
} }
// Helpers - Define locally to ensure availability // Helpers - Define locally to ensure availability
function escapeHtml(text) { function escapeHtml(text) {
if (!text) return ''; if (!text) return '';
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
function formatViews(views) { function formatViews(views) {
if (!views) return '0'; if (!views) return '0';
const num = parseInt(views); const num = parseInt(views);
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'; if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K'; if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
return num.toLocaleString(); return num.toLocaleString();
} }
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return 'Recently'; if (!dateStr) return 'Recently';
try { try {
// Format: YYYYMMDD // Format: YYYYMMDD
const year = dateStr.substring(0, 4); const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6); const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8); const day = dateStr.substring(6, 8);
const date = new Date(year, month - 1, day); const date = new Date(year, month - 1, day);
const now = new Date(); const now = new Date();
const diff = now - date; const diff = now - date;
const days = Math.floor(diff / (1000 * 60 * 60 * 24)); const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days < 1) return 'Today'; if (days < 1) return 'Today';
if (days < 7) return `${days} days ago`; if (days < 7) return `${days} days ago`;
if (days < 30) return `${Math.floor(days / 7)} weeks ago`; if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
if (days < 365) return `${Math.floor(days / 30)} months ago`; if (days < 365) return `${Math.floor(days / 30)} months ago`;
return `${Math.floor(days / 365)} years ago`; return `${Math.floor(days / 365)} years ago`;
} catch (e) { } catch (e) {
return 'Recently'; return 'Recently';
} }
} }
// Expose functions globally for onclick handlers // Expose functions globally for onclick handlers
window.changeChannelTab = changeChannelTab; window.changeChannelTab = changeChannelTab;
window.changeChannelSort = changeChannelSort; window.changeChannelSort = changeChannelSort;
})(); })();
</script> </script>
{% endblock %} {% endblock %}

408
templates/downloads.html Normal file → Executable file
View file

@ -1,205 +1,205 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block content %} {% block content %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/modules/downloads.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/modules/downloads.css') }}">
<div class="downloads-page"> <div class="downloads-page">
<div class="downloads-header"> <div class="downloads-header">
<h1><i class="fas fa-download"></i> Downloads</h1> <h1><i class="fas fa-download"></i> Downloads</h1>
<button class="downloads-clear-btn" onclick="clearAllDownloads()"> <button class="downloads-clear-btn" onclick="clearAllDownloads()">
<i class="fas fa-trash"></i> Clear All <i class="fas fa-trash"></i> Clear All
</button> </button>
</div> </div>
<div id="downloadsList" class="downloads-list"> <div id="downloadsList" class="downloads-list">
<!-- Downloads populated by JS --> <!-- Downloads populated by JS -->
</div> </div>
<div id="downloadsEmpty" class="downloads-empty" style="display: none;"> <div id="downloadsEmpty" class="downloads-empty" style="display: none;">
<i class="fas fa-download"></i> <i class="fas fa-download"></i>
<p>No downloads yet</p> <p>No downloads yet</p>
<p>Videos you download will appear here</p> <p>Videos you download will appear here</p>
</div> </div>
</div> </div>
<script> <script>
function renderDownloads() { function renderDownloads() {
const list = document.getElementById('downloadsList'); const list = document.getElementById('downloadsList');
const empty = document.getElementById('downloadsEmpty'); const empty = document.getElementById('downloadsEmpty');
if (!list || !empty) return; if (!list || !empty) return;
// Safety check for download manager // Safety check for download manager
if (!window.downloadManager) { if (!window.downloadManager) {
console.log('Download manager not ready, retrying...'); console.log('Download manager not ready, retrying...');
setTimeout(renderDownloads, 100); setTimeout(renderDownloads, 100);
return; return;
} }
const activeDownloads = window.downloadManager.getActiveDownloads(); const activeDownloads = window.downloadManager.getActiveDownloads();
const library = window.downloadManager.getLibrary(); const library = window.downloadManager.getLibrary();
if (library.length === 0 && activeDownloads.length === 0) { if (library.length === 0 && activeDownloads.length === 0) {
list.style.display = 'none'; list.style.display = 'none';
empty.style.display = 'block'; empty.style.display = 'block';
return; return;
} }
list.style.display = 'flex'; list.style.display = 'flex';
empty.style.display = 'none'; empty.style.display = 'none';
// Render Active Downloads // Render Active Downloads
const activeHtml = activeDownloads.map(item => { const activeHtml = activeDownloads.map(item => {
const specs = item.specs ? const specs = item.specs ?
(item.type === 'video' ? (item.type === 'video' ?
`${item.specs.resolution || ''} ${item.specs.vcodec ? '• ' + item.specs.vcodec : ''}` : `${item.specs.resolution || ''} ${item.specs.vcodec ? '• ' + item.specs.vcodec : ''}` :
`${item.specs.acodec || ''} • ${item.specs.bitrate ? item.specs.bitrate + 'kbps' : ''}` `${item.specs.acodec || ''} • ${item.specs.bitrate ? item.specs.bitrate + 'kbps' : ''}`
).trim() : ''; ).trim() : '';
const isPaused = item.status === 'paused'; const isPaused = item.status === 'paused';
return ` return `
<div class="download-item active ${isPaused ? 'paused' : ''}" data-id="${item.id}"> <div class="download-item active ${isPaused ? 'paused' : ''}" data-id="${item.id}">
<img src="${item.thumbnail || 'https://i.ytimg.com/vi/' + item.videoId + '/mqdefault.jpg'}" <img src="${item.thumbnail || 'https://i.ytimg.com/vi/' + item.videoId + '/mqdefault.jpg'}"
class="download-item-thumb"> class="download-item-thumb">
<div class="download-item-info"> <div class="download-item-info">
<div class="download-item-title">${escapeHtml(item.title)}</div> <div class="download-item-title">${escapeHtml(item.title)}</div>
<div class="download-item-meta"> <div class="download-item-meta">
<span class="status-text"> <span class="status-text">
${isPaused ? '<i class="fas fa-pause-circle"></i> Paused • ' : ''} ${isPaused ? '<i class="fas fa-pause-circle"></i> Paused • ' : ''}
${item.speedDisplay ? `<i class="fas fa-bolt"></i> ${item.speedDisplay} • ` : ''} ${item.speedDisplay ? `<i class="fas fa-bolt"></i> ${item.speedDisplay} • ` : ''}
${item.eta ? `<i class="fas fa-clock"></i> ${item.eta} • ` : ''} ${item.eta ? `<i class="fas fa-clock"></i> ${item.eta} • ` : ''}
${isPaused ? 'Resuming...' : 'Downloading...'} ${item.progress}% ${isPaused ? 'Resuming...' : 'Downloading...'} ${item.progress}%
</span> </span>
</div> </div>
${specs ? `<div class="download-item-specs"><small>${specs}</small></div>` : ''} ${specs ? `<div class="download-item-specs"><small>${specs}</small></div>` : ''}
<div class="download-progress-container"> <div class="download-progress-container">
<div class="download-progress-bar ${isPaused ? 'paused' : ''}" style="width: ${item.progress}%"></div> <div class="download-progress-bar ${isPaused ? 'paused' : ''}" style="width: ${item.progress}%"></div>
</div> </div>
</div> </div>
<div class="download-item-actions"> <div class="download-item-actions">
<button class="download-item-pause" onclick="togglePause('${item.id}')" title="${isPaused ? 'Resume' : 'Pause'}"> <button class="download-item-pause" onclick="togglePause('${item.id}')" title="${isPaused ? 'Resume' : 'Pause'}">
<i class="fas ${isPaused ? 'fa-play' : 'fa-pause'}"></i> <i class="fas ${isPaused ? 'fa-play' : 'fa-pause'}"></i>
</button> </button>
<button class="download-item-remove" onclick="cancelDownload('${item.id}')" title="Cancel"> <button class="download-item-remove" onclick="cancelDownload('${item.id}')" title="Cancel">
<i class="fas fa-stop"></i> <i class="fas fa-stop"></i>
</button> </button>
</div> </div>
</div> </div>
`}).join(''); `}).join('');
// Render History - with playback support // Render History - with playback support
const historyHtml = library.map(item => { const historyHtml = library.map(item => {
const specs = item.specs ? const specs = item.specs ?
(item.type === 'video' ? (item.type === 'video' ?
`${item.specs.resolution || ''} ${item.specs.vcodec ? '• ' + item.specs.vcodec : ''}` : `${item.specs.resolution || ''} ${item.specs.vcodec ? '• ' + item.specs.vcodec : ''}` :
`${item.specs.acodec || ''} • ${item.specs.bitrate ? item.specs.bitrate + 'kbps' : ''}` `${item.specs.acodec || ''} • ${item.specs.bitrate ? item.specs.bitrate + 'kbps' : ''}`
).trim() : ''; ).trim() : '';
return ` return `
<div class="download-item playable" data-id="${item.id}" data-video-id="${item.videoId}" onclick="playDownload('${item.videoId}', event)"> <div class="download-item playable" data-id="${item.id}" data-video-id="${item.videoId}" onclick="playDownload('${item.videoId}', event)">
<div class="download-item-thumb-wrapper"> <div class="download-item-thumb-wrapper">
<img src="${item.thumbnail || 'https://i.ytimg.com/vi/' + item.videoId + '/mqdefault.jpg'}" <img src="${item.thumbnail || 'https://i.ytimg.com/vi/' + item.videoId + '/mqdefault.jpg'}"
class="download-item-thumb" class="download-item-thumb"
onerror="this.src='https://via.placeholder.com/160x90?text=No+Thumbnail'"> onerror="this.src='https://via.placeholder.com/160x90?text=No+Thumbnail'">
<div class="download-thumb-overlay"> <div class="download-thumb-overlay">
<i class="fas fa-play"></i> <i class="fas fa-play"></i>
</div> </div>
</div> </div>
<div class="download-item-info"> <div class="download-item-info">
<div class="download-item-title">${escapeHtml(item.title)}</div> <div class="download-item-title">${escapeHtml(item.title)}</div>
<div class="download-item-meta"> <div class="download-item-meta">
${item.quality} · ${item.type} · ${formatDate(item.downloadedAt)} ${item.quality} · ${item.type} · ${formatDate(item.downloadedAt)}
${specs ? `<span class="meta-specs">• ${specs}</span>` : ''} ${specs ? `<span class="meta-specs">• ${specs}</span>` : ''}
</div> </div>
</div> </div>
<div class="download-item-actions"> <div class="download-item-actions">
<button class="download-item-play" onclick="playDownload('${item.videoId}', event); event.stopPropagation();" title="Play"> <button class="download-item-play" onclick="playDownload('${item.videoId}', event); event.stopPropagation();" title="Play">
<i class="fas fa-play"></i> <i class="fas fa-play"></i>
</button> </button>
<button class="download-item-redownload" onclick="reDownload('${item.videoId}', event); event.stopPropagation();" title="Download Again"> <button class="download-item-redownload" onclick="reDownload('${item.videoId}', event); event.stopPropagation();" title="Download Again">
<i class="fas fa-download"></i> <i class="fas fa-download"></i>
</button> </button>
<button class="download-item-remove" onclick="removeDownload('${item.id}'); event.stopPropagation();" title="Remove"> <button class="download-item-remove" onclick="removeDownload('${item.id}'); event.stopPropagation();" title="Remove">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
</div> </div>
</div> </div>
`}).join(''); `}).join('');
list.innerHTML = activeHtml + historyHtml; list.innerHTML = activeHtml + historyHtml;
} }
function cancelDownload(id) { function cancelDownload(id) {
window.downloadManager.cancelDownload(id); window.downloadManager.cancelDownload(id);
renderDownloads(); renderDownloads();
} }
function removeDownload(id) { function removeDownload(id) {
window.downloadManager.removeFromLibrary(id); window.downloadManager.removeFromLibrary(id);
renderDownloads(); renderDownloads();
} }
function togglePause(id) { function togglePause(id) {
const downloads = window.downloadManager.activeDownloads; const downloads = window.downloadManager.activeDownloads;
const state = downloads.get(id); const state = downloads.get(id);
if (!state) return; if (!state) return;
if (state.item.status === 'paused') { if (state.item.status === 'paused') {
window.downloadManager.resumeDownload(id); window.downloadManager.resumeDownload(id);
} else { } else {
window.downloadManager.pauseDownload(id); window.downloadManager.pauseDownload(id);
} }
// renderDownloads will be called by event listener // renderDownloads will be called by event listener
} }
function clearAllDownloads() { function clearAllDownloads() {
if (confirm('Remove all downloads from history?')) { if (confirm('Remove all downloads from history?')) {
window.downloadManager.clearLibrary(); window.downloadManager.clearLibrary();
renderDownloads(); renderDownloads();
} }
} }
function playDownload(videoId, event) { function playDownload(videoId, event) {
if (event) event.preventDefault(); if (event) event.preventDefault();
// Navigate to watch page for this video // Navigate to watch page for this video
window.location.href = `/watch?v=${videoId}`; window.location.href = `/watch?v=${videoId}`;
} }
function reDownload(videoId, event) { function reDownload(videoId, event) {
if (event) event.preventDefault(); if (event) event.preventDefault();
// Open download modal for this video // Open download modal for this video
if (typeof showDownloadModal === 'function') { if (typeof showDownloadModal === 'function') {
showDownloadModal(videoId); showDownloadModal(videoId);
} else { } else {
// Fallback: navigate to watch page // Fallback: navigate to watch page
window.location.href = `/watch?v=${videoId}`; window.location.href = `/watch?v=${videoId}`;
} }
} }
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return ''; if (!dateStr) return '';
const date = new Date(dateStr); const date = new Date(dateStr);
return date.toLocaleDateString(); return date.toLocaleDateString();
} }
// Render on load with slight delay for download manager // Render on load with slight delay for download manager
document.addEventListener('DOMContentLoaded', () => setTimeout(renderDownloads, 200)); document.addEventListener('DOMContentLoaded', () => setTimeout(renderDownloads, 200));
// Listen for real-time updates // Listen for real-time updates
// Listen for real-time updates - Prevent duplicates // Listen for real-time updates - Prevent duplicates
if (window._kvDownloadListener) { if (window._kvDownloadListener) {
window.removeEventListener('download-updated', window._kvDownloadListener); window.removeEventListener('download-updated', window._kvDownloadListener);
} }
window._kvDownloadListener = renderDownloads; window._kvDownloadListener = renderDownloads;
window.addEventListener('download-updated', renderDownloads); window.addEventListener('download-updated', renderDownloads);
</script> </script>
{% endblock %} {% endblock %}

432
templates/index.html Normal file → Executable file
View file

@ -1,217 +1,217 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block content %} {% block content %}
<script> <script>
window.APP_CONFIG = { window.APP_CONFIG = {
page: '{{ page|default("home") }}', page: '{{ page|default("home") }}',
channelId: '{{ channel_id|default("") }}', channelId: '{{ channel_id|default("") }}',
query: '{{ query|default("") }}' query: '{{ query|default("") }}'
}; };
</script> </script>
<!-- Filters & Categories --> <!-- Filters & Categories -->
<div class="yt-filter-bar"> <div class="yt-filter-bar">
<div class="yt-categories" id="categoryList"> <div class="yt-categories" id="categoryList">
<!-- Pinned Categories --> <!-- Pinned Categories -->
<button class="yt-chip" onclick="switchCategory('suggested', this)"><i class="fas fa-magic"></i> <button class="yt-chip" onclick="switchCategory('suggested', this)"><i class="fas fa-magic"></i>
Suggested</button> Suggested</button>
<!-- Standard Categories --> <!-- Standard Categories -->
<button class="yt-chip" onclick="switchCategory('tech', this)">Tech</button> <button class="yt-chip" onclick="switchCategory('tech', this)">Tech</button>
<button class="yt-chip" onclick="switchCategory('music', this)">Music</button> <button class="yt-chip" onclick="switchCategory('music', this)">Music</button>
<button class="yt-chip" onclick="switchCategory('movies', this)">Movies</button> <button class="yt-chip" onclick="switchCategory('movies', this)">Movies</button>
<button class="yt-chip" onclick="switchCategory('news', this)">News</button> <button class="yt-chip" onclick="switchCategory('news', this)">News</button>
<button class="yt-chip" onclick="switchCategory('trending', this)">Trending</button> <button class="yt-chip" onclick="switchCategory('trending', this)">Trending</button>
<button class="yt-chip" onclick="switchCategory('podcasts', this)">Podcasts</button> <button class="yt-chip" onclick="switchCategory('podcasts', this)">Podcasts</button>
<button class="yt-chip" onclick="switchCategory('live', this)">Live</button> <button class="yt-chip" onclick="switchCategory('live', this)">Live</button>
<button class="yt-chip" onclick="switchCategory('gaming', this)">Gaming</button> <button class="yt-chip" onclick="switchCategory('gaming', this)">Gaming</button>
<button class="yt-chip" onclick="switchCategory('sports', this)">Sports</button> <button class="yt-chip" onclick="switchCategory('sports', this)">Sports</button>
</div> </div>
<div class="yt-filter-actions"> <div class="yt-filter-actions">
<div class="yt-dropdown"> <div class="yt-dropdown">
<button class="yt-icon-btn" id="filterToggleBtn" onclick="toggleFilterMenu()"> <button class="yt-icon-btn" id="filterToggleBtn" onclick="toggleFilterMenu()">
<i class="fas fa-sliders-h"></i> <i class="fas fa-sliders-h"></i>
</button> </button>
<div class="yt-dropdown-menu" id="filterMenu"> <div class="yt-dropdown-menu" id="filterMenu">
<div class="yt-menu-section"> <div class="yt-menu-section">
<h4>Sort By</h4> <h4>Sort By</h4>
<button onclick="changeSort('day')">Today</button> <button onclick="changeSort('day')">Today</button>
<button onclick="changeSort('week')">This Week</button> <button onclick="changeSort('week')">This Week</button>
<button onclick="changeSort('month')">This Month</button> <button onclick="changeSort('month')">This Month</button>
<button onclick="changeSort('3months')">Last 3 Months</button> <button onclick="changeSort('3months')">Last 3 Months</button>
<button onclick="changeSort('year')">This Year</button> <button onclick="changeSort('year')">This Year</button>
</div> </div>
<div class="yt-menu-section"> <div class="yt-menu-section">
<h4>Region</h4> <h4>Region</h4>
<button onclick="changeRegion('vietnam')">Vietnam</button> <button onclick="changeRegion('vietnam')">Vietnam</button>
<button onclick="changeRegion('global')">Global</button> <button onclick="changeRegion('global')">Global</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Shorts Section --> <!-- Shorts Section -->
<!-- Videos Section --> <!-- Videos Section -->
<div id="videosSection" class="yt-section"> <div id="videosSection" class="yt-section">
<div class="yt-section-header" style="display:none;"> <div class="yt-section-header" style="display:none;">
<h2><i class="fas fa-play-circle"></i> Videos</h2> <h2><i class="fas fa-play-circle"></i> Videos</h2>
</div> </div>
<div id="resultsArea" class="yt-video-grid"> <div id="resultsArea" class="yt-video-grid">
<!-- Initial Skeleton State --> <!-- Initial Skeleton State -->
<!-- Initial Skeleton State (12 items) --> <!-- Initial Skeleton State (12 items) -->
<div class="yt-video-card skeleton-card"> <div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div> <div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details"> <div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div> <div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text"> <div class="skeleton-text">
<div class="skeleton-title skeleton"></div> <div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div> <div class="skeleton-meta skeleton"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="yt-video-card skeleton-card"> <div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div> <div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details"> <div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div> <div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text"> <div class="skeleton-text">
<div class="skeleton-title skeleton"></div> <div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div> <div class="skeleton-meta skeleton"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="yt-video-card skeleton-card"> <div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div> <div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details"> <div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div> <div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text"> <div class="skeleton-text">
<div class="skeleton-title skeleton"></div> <div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div> <div class="skeleton-meta skeleton"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="yt-video-card skeleton-card"> <div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div> <div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details"> <div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div> <div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text"> <div class="skeleton-text">
<div class="skeleton-title skeleton"></div> <div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div> <div class="skeleton-meta skeleton"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="yt-video-card skeleton-card"> <div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div> <div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details"> <div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div> <div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text"> <div class="skeleton-text">
<div class="skeleton-title skeleton"></div> <div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div> <div class="skeleton-meta skeleton"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="yt-video-card skeleton-card"> <div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div> <div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details"> <div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div> <div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text"> <div class="skeleton-text">
<div class="skeleton-title skeleton"></div> <div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div> <div class="skeleton-meta skeleton"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="yt-video-card skeleton-card"> <div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div> <div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details"> <div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div> <div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text"> <div class="skeleton-text">
<div class="skeleton-title skeleton"></div> <div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div> <div class="skeleton-meta skeleton"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="yt-video-card skeleton-card"> <div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div> <div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details"> <div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div> <div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text"> <div class="skeleton-text">
<div class="skeleton-title skeleton"></div> <div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div> <div class="skeleton-meta skeleton"></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Styles moved to CSS modules --> <!-- Styles moved to CSS modules -->
<script> <script>
// Global filter state // Global filter state
// Global filter state // Global filter state
var currentSort = 'month'; var currentSort = 'month';
var currentRegion = 'vietnam'; var currentRegion = 'vietnam';
function toggleFilterMenu() { function toggleFilterMenu() {
const menu = document.getElementById('filterMenu'); const menu = document.getElementById('filterMenu');
if (menu) menu.classList.toggle('show'); if (menu) menu.classList.toggle('show');
} }
// Close menu when clicking outside - Prevent multiple listeners // Close menu when clicking outside - Prevent multiple listeners
if (!window.filterMenuListenerAttached) { if (!window.filterMenuListenerAttached) {
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
const menu = document.getElementById('filterMenu'); const menu = document.getElementById('filterMenu');
const btn = document.getElementById('filterToggleBtn'); const btn = document.getElementById('filterToggleBtn');
// Only run if elements exist (we are on home page) // Only run if elements exist (we are on home page)
if (menu && btn && !menu.contains(e.target) && !btn.contains(e.target)) { if (menu && btn && !menu.contains(e.target) && !btn.contains(e.target)) {
menu.classList.remove('show'); menu.classList.remove('show');
} }
}); });
window.filterMenuListenerAttached = true; window.filterMenuListenerAttached = true;
} }
function changeSort(sort) { function changeSort(sort) {
window.currentSort = sort; window.currentSort = sort;
// Global loadTrending from main.js will use this // Global loadTrending from main.js will use this
loadTrending(true); loadTrending(true);
toggleFilterMenu(); toggleFilterMenu();
} }
function changeRegion(region) { function changeRegion(region) {
window.currentRegion = region; window.currentRegion = region;
loadTrending(true); loadTrending(true);
toggleFilterMenu(); toggleFilterMenu();
} }
// Helpers (if main.js not loaded yet or for standalone usage) // Helpers (if main.js not loaded yet or for standalone usage)
function escapeHtml(text) { function escapeHtml(text) {
if (!text) return ''; if (!text) return '';
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
function formatViews(views) { function formatViews(views) {
if (!views) return '0'; if (!views) return '0';
const num = parseInt(views); const num = parseInt(views);
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'; if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K'; if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
return num.toLocaleString(); return num.toLocaleString();
} }
// Init Logic // Init Logic
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Pagination logic removed for infinite scroll // Pagination logic removed for infinite scroll
// Check URL params for category // Check URL params for category
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const category = urlParams.get('category'); const category = urlParams.get('category');
if (category && typeof switchCategory === 'function') { if (category && typeof switchCategory === 'function') {
// Let main.js handle the switch, but we can set UI active state if needed // Let main.js handle the switch, but we can set UI active state if needed
// switchCategory is in main.js // switchCategory is in main.js
switchCategory(category); switchCategory(category);
} }
}); });
</script> </script>
{% endblock %} {% endblock %}

1062
templates/layout.html Normal file → Executable file

File diff suppressed because it is too large Load diff

422
templates/login.html Normal file → Executable file
View file

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

924
templates/my_videos.html Normal file → Executable file
View file

@ -1,463 +1,463 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block content %} {% block content %}
<style> <style>
/* Library Page Premium Styles */ /* Library Page Premium Styles */
.library-container { .library-container {
padding: 24px; padding: 24px;
max-width: 1600px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
} }
.library-header { .library-header {
margin-bottom: 2rem; margin-bottom: 2rem;
text-align: center; text-align: center;
} }
.library-title { .library-title {
font-size: 1.8rem; font-size: 1.8rem;
font-weight: 700; font-weight: 700;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
background: linear-gradient(135deg, var(--yt-text-primary) 0%, var(--yt-text-secondary) 100%); background: linear-gradient(135deg, var(--yt-text-primary) 0%, var(--yt-text-secondary) 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
} }
.library-tabs { .library-tabs {
display: inline-flex; display: inline-flex;
gap: 8px; gap: 8px;
background: var(--yt-bg-secondary); background: var(--yt-bg-secondary);
padding: 6px; padding: 6px;
border-radius: 16px; border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.library-tab { .library-tab {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 12px 24px; padding: 12px 24px;
border-radius: 12px; border-radius: 12px;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
text-decoration: none; text-decoration: none;
transition: all 0.25s ease; transition: all 0.25s ease;
border: none; border: none;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
} }
.library-tab:hover { .library-tab:hover {
color: var(--yt-text-primary); color: var(--yt-text-primary);
background: var(--yt-bg-hover); background: var(--yt-bg-hover);
} }
.library-tab.active { .library-tab.active {
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%); background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
color: white; color: white;
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3); box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
} }
.library-tab i { .library-tab i {
font-size: 1rem; font-size: 1rem;
} }
.library-actions { .library-actions {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 12px; gap: 12px;
margin-top: 16px; margin-top: 16px;
} }
.clear-btn { .clear-btn {
display: none; display: none;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 10px 20px; padding: 10px 20px;
background: transparent; background: transparent;
border: 1px solid var(--yt-border); border: 1px solid var(--yt-border);
border-radius: 24px; border-radius: 24px;
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.clear-btn:hover { .clear-btn:hover {
background: rgba(204, 0, 0, 0.1); background: rgba(204, 0, 0, 0.1);
border-color: #cc0000; border-color: #cc0000;
color: #cc0000; color: #cc0000;
} }
.library-stats { .library-stats {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 24px; gap: 24px;
margin-top: 12px; margin-top: 12px;
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
font-size: 0.85rem; font-size: 0.85rem;
} }
.library-stat { .library-stat {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
} }
.library-stat i { .library-stat i {
opacity: 0.7; opacity: 0.7;
} }
/* Empty State Enhancement */ /* Empty State Enhancement */
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 4rem 2rem; padding: 4rem 2rem;
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
} }
.empty-state-icon { .empty-state-icon {
width: 80px; width: 80px;
height: 80px; height: 80px;
margin: 0 auto 1.5rem; margin: 0 auto 1.5rem;
border-radius: 50%; border-radius: 50%;
background: var(--yt-bg-secondary); background: var(--yt-bg-secondary);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 2rem; font-size: 2rem;
opacity: 0.6; opacity: 0.6;
} }
.empty-state h3 { .empty-state h3 {
font-size: 1.3rem; font-size: 1.3rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: var(--yt-text-primary); color: var(--yt-text-primary);
} }
.empty-state p { .empty-state p {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.browse-btn { .browse-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 12px 24px; padding: 12px 24px;
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%); background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
color: white; color: white;
border: none; border: none;
border-radius: 24px; border-radius: 24px;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
transition: all 0.2s; transition: all 0.2s;
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3); box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
} }
.browse-btn:hover { .browse-btn:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(204, 0, 0, 0.4); box-shadow: 0 6px 16px rgba(204, 0, 0, 0.4);
} }
</style> </style>
<div class="library-container"> <div class="library-container">
<div class="library-header"> <div class="library-header">
<h1 class="library-title">My Library</h1> <h1 class="library-title">My Library</h1>
<div class="library-tabs"> <div class="library-tabs">
<a href="/my-videos?type=history" class="library-tab" id="tab-history"> <a href="/my-videos?type=history" class="library-tab" id="tab-history">
<i class="fas fa-history"></i> <i class="fas fa-history"></i>
<span>History</span> <span>History</span>
</a> </a>
<a href="/my-videos?type=saved" class="library-tab" id="tab-saved"> <a href="/my-videos?type=saved" class="library-tab" id="tab-saved">
<i class="fas fa-bookmark"></i> <i class="fas fa-bookmark"></i>
<span>Saved</span> <span>Saved</span>
</a> </a>
<a href="/my-videos?type=subscriptions" class="library-tab" id="tab-subscriptions"> <a href="/my-videos?type=subscriptions" class="library-tab" id="tab-subscriptions">
<i class="fas fa-users"></i> <i class="fas fa-users"></i>
<span>Subscriptions</span> <span>Subscriptions</span>
</a> </a>
</div> </div>
<div class="library-stats" id="libraryStats" style="display: none;"> <div class="library-stats" id="libraryStats" style="display: none;">
<div class="library-stat"> <div class="library-stat">
<i class="fas fa-video"></i> <i class="fas fa-video"></i>
<span id="videoCount">0 videos</span> <span id="videoCount">0 videos</span>
</div> </div>
</div> </div>
<div class="library-actions"> <div class="library-actions">
<button id="clearBtn" onclick="clearLibrary()" class="clear-btn"> <button id="clearBtn" onclick="clearLibrary()" class="clear-btn">
<i class="fas fa-trash-alt"></i> <i class="fas fa-trash-alt"></i>
<span>Clear <span id="clearType">All</span></span> <span>Clear <span id="clearType">All</span></span>
</button> </button>
</div> </div>
</div> </div>
<!-- Video Grid --> <!-- Video Grid -->
<div id="libraryGrid" class="yt-video-grid"> <div id="libraryGrid" class="yt-video-grid">
<!-- JS will populate this --> <!-- JS will populate this -->
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<div id="emptyState" class="empty-state" style="display: none;"> <div id="emptyState" class="empty-state" style="display: none;">
<div class="empty-state-icon"> <div class="empty-state-icon">
<i class="fas fa-folder-open"></i> <i class="fas fa-folder-open"></i>
</div> </div>
<h3>Nothing here yet</h3> <h3>Nothing here yet</h3>
<p id="emptyMsg">Go watch some videos to fill this up!</p> <p id="emptyMsg">Go watch some videos to fill this up!</p>
<a href="/" class="browse-btn"> <a href="/" class="browse-btn">
<i class="fas fa-play"></i> <i class="fas fa-play"></i>
Browse Content Browse Content
</a> </a>
</div> </div>
</div> </div>
<script> <script>
// Load library content - extracted to function for reuse on pageshow // Load library content - extracted to function for reuse on pageshow
function loadLibraryContent() { function loadLibraryContent() {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
// Default to history if no type or invalid type // Default to history if no type or invalid type
const type = urlParams.get('type') || 'history'; const type = urlParams.get('type') || 'history';
// Reset all tabs first, then activate the correct one // Reset all tabs first, then activate the correct one
document.querySelectorAll('.library-tab').forEach(tab => tab.classList.remove('active')); document.querySelectorAll('.library-tab').forEach(tab => tab.classList.remove('active'));
const activeTab = document.getElementById(`tab-${type}`); const activeTab = document.getElementById(`tab-${type}`);
if (activeTab) { if (activeTab) {
activeTab.classList.add('active'); activeTab.classList.add('active');
} }
const grid = document.getElementById('libraryGrid'); const grid = document.getElementById('libraryGrid');
const empty = document.getElementById('emptyState'); const empty = document.getElementById('emptyState');
const emptyMsg = document.getElementById('emptyMsg'); const emptyMsg = document.getElementById('emptyMsg');
const statsDiv = document.getElementById('libraryStats'); const statsDiv = document.getElementById('libraryStats');
const clearBtn = document.getElementById('clearBtn'); const clearBtn = document.getElementById('clearBtn');
// Reset UI before loading // Reset UI before loading
grid.innerHTML = ''; grid.innerHTML = '';
empty.style.display = 'none'; empty.style.display = 'none';
if (statsDiv) statsDiv.style.display = 'none'; if (statsDiv) statsDiv.style.display = 'none';
if (clearBtn) clearBtn.style.display = 'none'; if (clearBtn) clearBtn.style.display = 'none';
// Mapping URL type to localStorage key suffix // Mapping URL type to localStorage key suffix
// saved -> kv_saved // saved -> kv_saved
// history -> kv_history // history -> kv_history
// subscriptions -> kv_subscriptions // subscriptions -> kv_subscriptions
const storageKey = `kv_${type}`; const storageKey = `kv_${type}`;
const data = JSON.parse(localStorage.getItem(storageKey) || '[]').filter(i => i && i.id); const data = JSON.parse(localStorage.getItem(storageKey) || '[]').filter(i => i && i.id);
// Show stats and Clear Button if there is data // Show stats and Clear Button if there is data
if (data.length > 0) { if (data.length > 0) {
empty.style.display = 'none'; empty.style.display = 'none';
// Update stats // Update stats
const videoCount = document.getElementById('videoCount'); const videoCount = document.getElementById('videoCount');
if (statsDiv && videoCount) { if (statsDiv && videoCount) {
statsDiv.style.display = 'flex'; statsDiv.style.display = 'flex';
const countText = type === 'subscriptions' const countText = type === 'subscriptions'
? `${data.length} channel${data.length !== 1 ? 's' : ''}` ? `${data.length} channel${data.length !== 1 ? 's' : ''}`
: `${data.length} video${data.length !== 1 ? 's' : ''}`; : `${data.length} video${data.length !== 1 ? 's' : ''}`;
videoCount.innerText = countText; videoCount.innerText = countText;
} }
const clearTypeSpan = document.getElementById('clearType'); const clearTypeSpan = document.getElementById('clearType');
if (clearBtn) { if (clearBtn) {
clearBtn.style.display = 'inline-flex'; clearBtn.style.display = 'inline-flex';
// Format type name for display // Format type name for display
const typeName = type.charAt(0).toUpperCase() + type.slice(1); const typeName = type.charAt(0).toUpperCase() + type.slice(1);
clearTypeSpan.innerText = typeName; clearTypeSpan.innerText = typeName;
} }
if (type === 'subscriptions') { if (type === 'subscriptions') {
// Render Channel Cards with improved design // Render Channel Cards with improved design
grid.style.display = 'grid'; grid.style.display = 'grid';
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))'; grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
grid.style.gap = '24px'; grid.style.gap = '24px';
grid.style.padding = '20px 0'; grid.style.padding = '20px 0';
grid.innerHTML = data.map(channel => { grid.innerHTML = data.map(channel => {
const avatarHtml = channel.thumbnail const avatarHtml = channel.thumbnail
? `<img src="${channel.thumbnail}" style="width:120px; height:120px; border-radius:50%; object-fit:cover; border: 3px solid var(--yt-border); transition: transform 0.3s, border-color 0.3s;">` ? `<img src="${channel.thumbnail}" style="width:120px; height:120px; border-radius:50%; object-fit:cover; border: 3px solid var(--yt-border); transition: transform 0.3s, border-color 0.3s;">`
: `<div style="width:120px; height:120px; border-radius:50%; background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%); display:flex; align-items:center; justify-content:center; font-size:48px; font-weight:bold; color:white; border: 3px solid var(--yt-border); transition: transform 0.3s;">${channel.letter || channel.title.charAt(0).toUpperCase()}</div>`; : `<div style="width:120px; height:120px; border-radius:50%; background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%); display:flex; align-items:center; justify-content:center; font-size:48px; font-weight:bold; color:white; border: 3px solid var(--yt-border); transition: transform 0.3s;">${channel.letter || channel.title.charAt(0).toUpperCase()}</div>`;
return ` return `
<div class="subscription-card" onclick="window.location.href='/channel/${channel.id}'" <div class="subscription-card" onclick="window.location.href='/channel/${channel.id}'"
style="text-align:center; cursor:pointer; padding: 24px 16px; background: var(--yt-bg-secondary); border-radius: 16px; transition: all 0.3s; border: 1px solid transparent;" style="text-align:center; cursor:pointer; padding: 24px 16px; background: var(--yt-bg-secondary); border-radius: 16px; transition: all 0.3s; border: 1px solid transparent;"
onmouseenter="this.style.transform='translateY(-4px)'; this.style.boxShadow='0 8px 24px rgba(0,0,0,0.3)'; this.style.borderColor='var(--yt-border)';" onmouseenter="this.style.transform='translateY(-4px)'; this.style.boxShadow='0 8px 24px rgba(0,0,0,0.3)'; this.style.borderColor='var(--yt-border)';"
onmouseleave="this.style.transform='none'; this.style.boxShadow='none'; this.style.borderColor='transparent';"> onmouseleave="this.style.transform='none'; this.style.boxShadow='none'; this.style.borderColor='transparent';">
<div style="display:flex; justify-content:center; margin-bottom:16px;"> <div style="display:flex; justify-content:center; margin-bottom:16px;">
${avatarHtml} ${avatarHtml}
</div> </div>
<h3 style="font-size:1.1rem; margin-bottom:8px; color: var(--yt-text-primary); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${channel.title}</h3> <h3 style="font-size:1.1rem; margin-bottom:8px; color: var(--yt-text-primary); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${channel.title}</h3>
<p style="font-size: 0.85rem; color: var(--yt-text-secondary); margin-bottom: 12px;">@${channel.title.replace(/\s+/g, '')}</p> <p style="font-size: 0.85rem; color: var(--yt-text-secondary); margin-bottom: 12px;">@${channel.title.replace(/\s+/g, '')}</p>
<button onclick="event.stopPropagation(); toggleSubscribe('${channel.id}', '${channel.title.replace(/'/g, "\\'")}', '${channel.thumbnail || ''}', this)" <button onclick="event.stopPropagation(); toggleSubscribe('${channel.id}', '${channel.title.replace(/'/g, "\\'")}', '${channel.thumbnail || ''}', this)"
style="padding:10px 20px; font-size:13px; background: linear-gradient(135deg, #cc0000, #ff4444); color: white; border: none; border-radius: 24px; cursor: pointer; font-weight: 600; transition: all 0.2s; box-shadow: 0 2px 8px rgba(204,0,0,0.3);" style="padding:10px 20px; font-size:13px; background: linear-gradient(135deg, #cc0000, #ff4444); color: white; border: none; border-radius: 24px; cursor: pointer; font-weight: 600; transition: all 0.2s; box-shadow: 0 2px 8px rgba(204,0,0,0.3);"
onmouseenter="this.style.transform='scale(1.05)'; this.style.boxShadow='0 4px 12px rgba(204,0,0,0.5)';" onmouseenter="this.style.transform='scale(1.05)'; this.style.boxShadow='0 4px 12px rgba(204,0,0,0.5)';"
onmouseleave="this.style.transform='scale(1)'; this.style.boxShadow='0 2px 8px rgba(204,0,0,0.3)';"> onmouseleave="this.style.transform='scale(1)'; this.style.boxShadow='0 2px 8px rgba(204,0,0,0.3)';">
<i class="fas fa-user-minus"></i> Unsubscribe <i class="fas fa-user-minus"></i> Unsubscribe
</button> </button>
</div> </div>
`}).join(''); `}).join('');
} else { } else {
// Render Video Cards (History/Saved) // Render Video Cards (History/Saved)
grid.innerHTML = data.map(video => { grid.innerHTML = data.map(video => {
// Robust fallback chain: maxres -> hq -> mq // Robust fallback chain: maxres -> hq -> mq
const thumb = video.thumbnail || `https://i.ytimg.com/vi/${video.id}/maxresdefault.jpg`; const thumb = video.thumbnail || `https://i.ytimg.com/vi/${video.id}/maxresdefault.jpg`;
const showRemove = type === 'saved' || type === 'history'; const showRemove = type === 'saved' || type === 'history';
return ` return `
<div class="yt-video-card" style="position: relative;"> <div class="yt-video-card" style="position: relative;">
<div onclick="window.location.href='/watch?v=${video.id}'" style="cursor: pointer;"> <div onclick="window.location.href='/watch?v=${video.id}'" style="cursor: pointer;">
<div class="yt-thumbnail-container"> <div class="yt-thumbnail-container">
<img src="${thumb}" class="yt-thumbnail" loading="lazy" referrerpolicy="no-referrer" <img src="${thumb}" class="yt-thumbnail" loading="lazy" referrerpolicy="no-referrer"
onload="this.classList.add('loaded')" onload="this.classList.add('loaded')"
onerror=" onerror="
if (this.src.includes('maxresdefault')) this.src='https://i.ytimg.com/vi/${video.id}/hqdefault.jpg'; if (this.src.includes('maxresdefault')) this.src='https://i.ytimg.com/vi/${video.id}/hqdefault.jpg';
else if (this.src.includes('hqdefault')) this.src='https://i.ytimg.com/vi/${video.id}/mqdefault.jpg'; else if (this.src.includes('hqdefault')) this.src='https://i.ytimg.com/vi/${video.id}/mqdefault.jpg';
else this.style.display='none'; else this.style.display='none';
"> ">
<div class="yt-duration">${video.duration || ''}</div> <div class="yt-duration">${video.duration || ''}</div>
</div> </div>
<div class="yt-video-details"> <div class="yt-video-details">
<div class="yt-video-meta"> <div class="yt-video-meta">
<h3 class="yt-video-title">${video.title}</h3> <h3 class="yt-video-title">${video.title}</h3>
<p class="yt-video-stats">${video.uploader}</p> <p class="yt-video-stats">${video.uploader}</p>
</div> </div>
</div> </div>
</div> </div>
${showRemove ? ` ${showRemove ? `
<button onclick="event.stopPropagation(); removeVideo('${video.id}', '${type}', this)" <button onclick="event.stopPropagation(); removeVideo('${video.id}', '${type}', this)"
style="position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; background: rgba(0,0,0,0.7); color: white; border: none; border-radius: 50%; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: all 0.2s; z-index: 10;" style="position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; background: rgba(0,0,0,0.7); color: white; border: none; border-radius: 50%; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: all 0.2s; z-index: 10;"
onmouseenter="this.style.opacity='1'; this.style.background='#cc0000';" onmouseenter="this.style.opacity='1'; this.style.background='#cc0000';"
onmouseleave="this.style.opacity='0.8'; this.style.background='rgba(0,0,0,0.7)';" onmouseleave="this.style.opacity='0.8'; this.style.background='rgba(0,0,0,0.7)';"
title="Remove"> title="Remove">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button>` : ''} </button>` : ''}
</div> </div>
`}).join(''); `}).join('');
} }
} else { } else {
grid.innerHTML = ''; grid.innerHTML = '';
empty.style.display = 'block'; empty.style.display = 'block';
if (type === 'subscriptions') { if (type === 'subscriptions') {
emptyMsg.innerText = "You haven't subscribed to any channels yet."; emptyMsg.innerText = "You haven't subscribed to any channels yet.";
} else if (type === 'saved') { } else if (type === 'saved') {
emptyMsg.innerText = "No saved videos yet."; emptyMsg.innerText = "No saved videos yet.";
} }
} }
} }
// Run on initial page load and SPA navigation // Run on initial page load and SPA navigation
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadLibraryContent(); loadLibraryContent();
initTabs(); initTabs();
}); });
} else { } else {
// Document already loaded (SPA navigation) // Document already loaded (SPA navigation)
loadLibraryContent(); loadLibraryContent();
initTabs(); initTabs();
} }
function initTabs() { function initTabs() {
// Intercept tab clicks for client-side navigation // Intercept tab clicks for client-side navigation
document.querySelectorAll('.library-tab').forEach(tab => { document.querySelectorAll('.library-tab').forEach(tab => {
// Remove old listeners to be safe (optional but good practice in SPA) // Remove old listeners to be safe (optional but good practice in SPA)
const newTab = tab.cloneNode(true); const newTab = tab.cloneNode(true);
tab.parentNode.replaceChild(newTab, tab); tab.parentNode.replaceChild(newTab, tab);
newTab.addEventListener('click', (e) => { newTab.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
const newUrl = newTab.getAttribute('href'); const newUrl = newTab.getAttribute('href');
// Update URL without reloading // Update URL without reloading
history.pushState(null, '', newUrl); history.pushState(null, '', newUrl);
// Immediately load the new content // Immediately load the new content
loadLibraryContent(); loadLibraryContent();
}); });
}); });
} }
// Handle browser back/forward buttons // Handle browser back/forward buttons
window.addEventListener('popstate', () => { window.addEventListener('popstate', () => {
loadLibraryContent(); loadLibraryContent();
}); });
function clearLibrary() { function clearLibrary() {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const type = urlParams.get('type') || 'history'; const type = urlParams.get('type') || 'history';
const typeName = type.charAt(0).toUpperCase() + type.slice(1); const typeName = type.charAt(0).toUpperCase() + type.slice(1);
if (confirm(`Are you sure you want to clear your ${typeName}? This cannot be undone.`)) { if (confirm(`Are you sure you want to clear your ${typeName}? This cannot be undone.`)) {
const storageKey = `kv_${type}`; const storageKey = `kv_${type}`;
localStorage.removeItem(storageKey); localStorage.removeItem(storageKey);
// Reload to reflect changes // Reload to reflect changes
window.location.reload(); window.location.reload();
} }
} }
// Local toggleSubscribe for my_videos page - removes card visually // Local toggleSubscribe for my_videos page - removes card visually
function toggleSubscribe(channelId, channelName, avatar, btnElement) { function toggleSubscribe(channelId, channelName, avatar, btnElement) {
event.stopPropagation(); event.stopPropagation();
// Remove from library // Remove from library
const key = 'kv_subscriptions'; const key = 'kv_subscriptions';
let data = JSON.parse(localStorage.getItem(key) || '[]'); let data = JSON.parse(localStorage.getItem(key) || '[]');
data = data.filter(item => item.id !== channelId); data = data.filter(item => item.id !== channelId);
localStorage.setItem(key, JSON.stringify(data)); localStorage.setItem(key, JSON.stringify(data));
// Remove the card from UI // Remove the card from UI
const card = btnElement.closest('.yt-channel-card'); const card = btnElement.closest('.yt-channel-card');
if (card) { if (card) {
card.style.transition = 'opacity 0.3s, transform 0.3s'; card.style.transition = 'opacity 0.3s, transform 0.3s';
card.style.opacity = '0'; card.style.opacity = '0';
card.style.transform = 'scale(0.8)'; card.style.transform = 'scale(0.8)';
setTimeout(() => card.remove(), 300); setTimeout(() => card.remove(), 300);
} }
// Show empty state if no more subscriptions // Show empty state if no more subscriptions
setTimeout(() => { setTimeout(() => {
const grid = document.getElementById('libraryGrid'); const grid = document.getElementById('libraryGrid');
if (grid && grid.children.length === 0) { if (grid && grid.children.length === 0) {
grid.innerHTML = ''; grid.innerHTML = '';
document.getElementById('emptyState').style.display = 'block'; document.getElementById('emptyState').style.display = 'block';
document.getElementById('emptyMessage').innerText = "You haven't subscribed to any channels yet."; document.getElementById('emptyMessage').innerText = "You haven't subscribed to any channels yet.";
} }
}, 350); }, 350);
} }
// Remove individual video from saved/history // Remove individual video from saved/history
function removeVideo(videoId, type, btnElement) { function removeVideo(videoId, type, btnElement) {
event.stopPropagation(); event.stopPropagation();
const key = `kv_${type}`; const key = `kv_${type}`;
let data = JSON.parse(localStorage.getItem(key) || '[]'); let data = JSON.parse(localStorage.getItem(key) || '[]');
data = data.filter(item => item.id !== videoId); data = data.filter(item => item.id !== videoId);
localStorage.setItem(key, JSON.stringify(data)); localStorage.setItem(key, JSON.stringify(data));
// Remove the card from UI with animation // Remove the card from UI with animation
const card = btnElement.closest('.yt-video-card'); const card = btnElement.closest('.yt-video-card');
if (card) { if (card) {
card.style.transition = 'opacity 0.3s, transform 0.3s'; card.style.transition = 'opacity 0.3s, transform 0.3s';
card.style.opacity = '0'; card.style.opacity = '0';
card.style.transform = 'scale(0.9)'; card.style.transform = 'scale(0.9)';
setTimeout(() => card.remove(), 300); setTimeout(() => card.remove(), 300);
} }
// Show empty state if no more videos // Show empty state if no more videos
setTimeout(() => { setTimeout(() => {
const grid = document.getElementById('libraryGrid'); const grid = document.getElementById('libraryGrid');
if (grid && grid.children.length === 0) { if (grid && grid.children.length === 0) {
grid.innerHTML = ''; grid.innerHTML = '';
document.getElementById('emptyState').style.display = 'block'; document.getElementById('emptyState').style.display = 'block';
const typeName = type === 'saved' ? 'No saved videos yet.' : 'No history yet.'; const typeName = type === 'saved' ? 'No saved videos yet.' : 'No history yet.';
document.getElementById('emptyMessage').innerText = typeName; document.getElementById('emptyMessage').innerText = typeName;
} }
}, 350); }, 350);
} }
</script> </script>
{% endblock %} {% endblock %}

422
templates/register.html Normal file → Executable file
View file

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

708
templates/settings.html Normal file → Executable file
View file

@ -1,355 +1,355 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block content %} {% block content %}
<div class="yt-settings-container"> <div class="yt-settings-container">
<h2 class="yt-settings-title">Settings</h2> <h2 class="yt-settings-title">Settings</h2>
<!-- Appearance & Playback in one card --> <!-- Appearance & Playback in one card -->
<div class="yt-settings-card compact"> <div class="yt-settings-card compact">
<div class="yt-setting-row"> <div class="yt-setting-row">
<span class="yt-setting-label">Theme</span> <span class="yt-setting-label">Theme</span>
<div class="yt-toggle-group"> <div class="yt-toggle-group">
<button type="button" class="yt-toggle-btn" id="themeBtnLight" <button type="button" class="yt-toggle-btn" id="themeBtnLight"
onclick="setTheme('light')">Light</button> onclick="setTheme('light')">Light</button>
<button type="button" class="yt-toggle-btn" id="themeBtnDark" onclick="setTheme('dark')">Dark</button> <button type="button" class="yt-toggle-btn" id="themeBtnDark" onclick="setTheme('dark')">Dark</button>
</div> </div>
</div> </div>
<div class="yt-setting-row"> <div class="yt-setting-row">
<span class="yt-setting-label">Player</span> <span class="yt-setting-label">Player</span>
<div class="yt-toggle-group"> <div class="yt-toggle-group">
<button type="button" class="yt-toggle-btn" id="playerBtnArt" <button type="button" class="yt-toggle-btn" id="playerBtnArt"
onclick="setPlayerPref('artplayer')">Artplayer</button> onclick="setPlayerPref('artplayer')">Artplayer</button>
<button type="button" class="yt-toggle-btn" id="playerBtnNative" <button type="button" class="yt-toggle-btn" id="playerBtnNative"
onclick="setPlayerPref('native')">Native</button> onclick="setPlayerPref('native')">Native</button>
</div> </div>
</div> </div>
</div> </div>
<!-- System Updates --> <!-- System Updates -->
<div class="yt-settings-card"> <div class="yt-settings-card">
<h3>System Updates</h3> <h3>System Updates</h3>
<!-- yt-dlp Stable --> <!-- yt-dlp Stable -->
<div class="yt-update-row"> <div class="yt-update-row">
<div class="yt-update-info"> <div class="yt-update-info">
<strong>yt-dlp</strong> <strong>yt-dlp</strong>
<span class="yt-update-version" id="ytdlpVersion">Stable</span> <span class="yt-update-version" id="ytdlpVersion">Stable</span>
</div> </div>
<button id="updateYtdlpStable" onclick="updatePackage('ytdlp', 'stable')" class="yt-update-btn small"> <button id="updateYtdlpStable" onclick="updatePackage('ytdlp', 'stable')" class="yt-update-btn small">
<i class="fas fa-sync-alt"></i> Update <i class="fas fa-sync-alt"></i> Update
</button> </button>
</div> </div>
<!-- yt-dlp Nightly --> <!-- yt-dlp Nightly -->
<div class="yt-update-row"> <div class="yt-update-row">
<div class="yt-update-info"> <div class="yt-update-info">
<strong>yt-dlp Nightly</strong> <strong>yt-dlp Nightly</strong>
<span class="yt-update-version">Experimental</span> <span class="yt-update-version">Experimental</span>
</div> </div>
<button id="updateYtdlpNightly" onclick="updatePackage('ytdlp', 'nightly')" <button id="updateYtdlpNightly" onclick="updatePackage('ytdlp', 'nightly')"
class="yt-update-btn small nightly"> class="yt-update-btn small nightly">
<i class="fas fa-flask"></i> Install <i class="fas fa-flask"></i> Install
</button> </button>
</div> </div>
<!-- ytfetcher --> <!-- ytfetcher -->
<div class="yt-update-row"> <div class="yt-update-row">
<div class="yt-update-info"> <div class="yt-update-info">
<strong>ytfetcher</strong> <strong>ytfetcher</strong>
<span class="yt-update-version" id="ytfetcherVersion">CC & Transcripts</span> <span class="yt-update-version" id="ytfetcherVersion">CC & Transcripts</span>
</div> </div>
<button id="updateYtfetcher" onclick="updatePackage('ytfetcher', 'latest')" class="yt-update-btn small"> <button id="updateYtfetcher" onclick="updatePackage('ytfetcher', 'latest')" class="yt-update-btn small">
<i class="fas fa-sync-alt"></i> Update <i class="fas fa-sync-alt"></i> Update
</button> </button>
</div> </div>
<div id="updateStatus" class="yt-update-status"></div> <div id="updateStatus" class="yt-update-status"></div>
</div> </div>
{% if session.get('user_id') %} {% if session.get('user_id') %}
<div class="yt-settings-card compact"> <div class="yt-settings-card compact">
<div class="yt-setting-row"> <div class="yt-setting-row">
<span class="yt-setting-label">Display Name</span> <span class="yt-setting-label">Display Name</span>
<form id="profileForm" onsubmit="updateProfile(event)" <form id="profileForm" onsubmit="updateProfile(event)"
style="display: flex; gap: 8px; flex: 1; max-width: 300px;"> style="display: flex; gap: 8px; flex: 1; max-width: 300px;">
<input type="text" class="yt-form-input" id="displayName" value="{{ session.username }}" required <input type="text" class="yt-form-input" id="displayName" value="{{ session.username }}" required
style="flex: 1;"> style="flex: 1;">
<button type="submit" class="yt-update-btn small">Save</button> <button type="submit" class="yt-update-btn small">Save</button>
</form> </form>
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div class="yt-settings-card compact"> <div class="yt-settings-card compact">
<div class="yt-setting-row" style="justify-content: center;"> <div class="yt-setting-row" style="justify-content: center;">
<span class="yt-about-text">KV-Tube v1.0 • YouTube-like streaming</span> <span class="yt-about-text">KV-Tube v1.0 • YouTube-like streaming</span>
</div> </div>
</div> </div>
</div> </div>
<style> <style>
.yt-settings-container { .yt-settings-container {
max-width: 500px; max-width: 500px;
margin: 0 auto; margin: 0 auto;
padding: 16px; padding: 16px;
} }
.yt-settings-title { .yt-settings-title {
font-size: 20px; font-size: 20px;
font-weight: 500; font-weight: 500;
margin-bottom: 16px; margin-bottom: 16px;
text-align: center; text-align: center;
} }
.yt-settings-card { .yt-settings-card {
background: var(--yt-bg-secondary); background: var(--yt-bg-secondary);
border-radius: 12px; border-radius: 12px;
padding: 16px; padding: 16px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.yt-settings-card.compact { .yt-settings-card.compact {
padding: 12px 16px; padding: 12px 16px;
} }
.yt-settings-card h3 { .yt-settings-card h3 {
font-size: 14px; font-size: 14px;
margin-bottom: 12px; margin-bottom: 12px;
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.yt-setting-row { .yt-setting-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 8px 0; padding: 8px 0;
} }
.yt-setting-row:not(:last-child) { .yt-setting-row:not(:last-child) {
border-bottom: 1px solid var(--yt-bg-hover); border-bottom: 1px solid var(--yt-bg-hover);
} }
.yt-setting-label { .yt-setting-label {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
} }
.yt-toggle-group { .yt-toggle-group {
display: flex; display: flex;
background: var(--yt-bg-elevated); background: var(--yt-bg-elevated);
padding: 3px; padding: 3px;
border-radius: 20px; border-radius: 20px;
gap: 2px; gap: 2px;
} }
.yt-toggle-btn { .yt-toggle-btn {
padding: 6px 14px; padding: 6px 14px;
border-radius: 16px; border-radius: 16px;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
background: transparent; background: transparent;
transition: all 0.2s; transition: all 0.2s;
} }
.yt-toggle-btn:hover { .yt-toggle-btn:hover {
color: var(--yt-text-primary); color: var(--yt-text-primary);
} }
.yt-toggle-btn.active { .yt-toggle-btn.active {
background: var(--yt-accent-red); background: var(--yt-accent-red);
color: white; color: white;
} }
.yt-update-row { .yt-update-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 10px 0; padding: 10px 0;
border-bottom: 1px solid var(--yt-bg-hover); border-bottom: 1px solid var(--yt-bg-hover);
} }
.yt-update-row:last-of-type { .yt-update-row:last-of-type {
border-bottom: none; border-bottom: none;
} }
.yt-update-info { .yt-update-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
} }
.yt-update-info strong { .yt-update-info strong {
font-size: 14px; font-size: 14px;
} }
.yt-update-version { .yt-update-version {
font-size: 11px; font-size: 11px;
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
} }
.yt-update-btn { .yt-update-btn {
background: var(--yt-accent-red); background: var(--yt-accent-red);
color: white; color: white;
padding: 8px 16px; padding: 8px 16px;
border-radius: 20px; border-radius: 20px;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
transition: all 0.2s; transition: all 0.2s;
} }
.yt-update-btn.small { .yt-update-btn.small {
padding: 6px 12px; padding: 6px 12px;
font-size: 12px; font-size: 12px;
} }
.yt-update-btn.nightly { .yt-update-btn.nightly {
background: #9c27b0; background: #9c27b0;
} }
.yt-update-btn:hover { .yt-update-btn:hover {
opacity: 0.9; opacity: 0.9;
transform: scale(1.02); transform: scale(1.02);
} }
.yt-update-btn:disabled { .yt-update-btn:disabled {
background: var(--yt-bg-hover) !important; background: var(--yt-bg-hover) !important;
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
} }
.yt-update-status { .yt-update-status {
margin-top: 10px; margin-top: 10px;
font-size: 12px; font-size: 12px;
text-align: center; text-align: center;
min-height: 20px; min-height: 20px;
} }
.yt-form-input { .yt-form-input {
background: var(--yt-bg-elevated); background: var(--yt-bg-elevated);
border: 1px solid var(--yt-bg-hover); border: 1px solid var(--yt-bg-hover);
border-radius: 8px; border-radius: 8px;
padding: 8px 12px; padding: 8px 12px;
color: var(--yt-text-primary); color: var(--yt-text-primary);
font-size: 13px; font-size: 13px;
} }
.yt-about-text { .yt-about-text {
font-size: 12px; font-size: 12px;
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
} }
</style> </style>
<script> <script>
async function fetchVersions() { async function fetchVersions() {
const pkgs = ['ytdlp', 'ytfetcher']; const pkgs = ['ytdlp', 'ytfetcher'];
for (const pkg of pkgs) { for (const pkg of pkgs) {
try { try {
const res = await fetch(`/api/package/version?package=${pkg}`); const res = await fetch(`/api/package/version?package=${pkg}`);
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
const el = document.getElementById(pkg === 'ytdlp' ? 'ytdlpVersion' : 'ytfetcherVersion'); const el = document.getElementById(pkg === 'ytdlp' ? 'ytdlpVersion' : 'ytfetcherVersion');
if (el) { if (el) {
el.innerText = `Installed: ${data.version}`; el.innerText = `Installed: ${data.version}`;
// Highlight if nightly // Highlight if nightly
if (pkg === 'ytdlp' && (data.version.includes('2026') || data.version.includes('.dev'))) { if (pkg === 'ytdlp' && (data.version.includes('2026') || data.version.includes('.dev'))) {
el.style.color = '#9c27b0'; el.style.color = '#9c27b0';
el.innerText += ' (Nightly)'; el.innerText += ' (Nightly)';
} }
} }
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} }
} }
async function updatePackage(pkg, version) { async function updatePackage(pkg, version) {
const btnId = pkg === 'ytdlp' ? const btnId = pkg === 'ytdlp' ?
(version === 'nightly' ? 'updateYtdlpNightly' : 'updateYtdlpStable') : (version === 'nightly' ? 'updateYtdlpNightly' : 'updateYtdlpStable') :
'updateYtfetcher'; 'updateYtfetcher';
const btn = document.getElementById(btnId); const btn = document.getElementById(btnId);
const status = document.getElementById('updateStatus'); const status = document.getElementById('updateStatus');
const originalHTML = btn.innerHTML; const originalHTML = btn.innerHTML;
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Updating...'; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Updating...';
status.style.color = 'var(--yt-text-secondary)'; status.style.color = 'var(--yt-text-secondary)';
status.innerText = `Updating ${pkg}${version === 'nightly' ? ' (nightly)' : ''}...`; status.innerText = `Updating ${pkg}${version === 'nightly' ? ' (nightly)' : ''}...`;
try { try {
const response = await fetch('/api/update_package', { const response = await fetch('/api/update_package', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ package: pkg, version: version }) body: JSON.stringify({ package: pkg, version: version })
}); });
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
status.style.color = '#4caf50'; status.style.color = '#4caf50';
status.innerText = '✓ ' + data.message; status.innerText = '✓ ' + data.message;
btn.innerHTML = '<i class="fas fa-check"></i> Updated'; btn.innerHTML = '<i class="fas fa-check"></i> Updated';
// Refresh versions // Refresh versions
setTimeout(fetchVersions, 1000); setTimeout(fetchVersions, 1000);
setTimeout(() => { setTimeout(() => {
btn.innerHTML = originalHTML; btn.innerHTML = originalHTML;
btn.disabled = false; btn.disabled = false;
}, 3000); }, 3000);
} else { } else {
status.style.color = '#f44336'; status.style.color = '#f44336';
status.innerText = '✗ ' + data.message; status.innerText = '✗ ' + data.message;
btn.innerHTML = originalHTML; btn.innerHTML = originalHTML;
btn.disabled = false; btn.disabled = false;
} }
} catch (e) { } catch (e) {
status.style.color = '#f44336'; status.style.color = '#f44336';
status.innerText = '✗ Error: ' + e.message; status.innerText = '✗ Error: ' + e.message;
btn.innerHTML = originalHTML; btn.innerHTML = originalHTML;
btn.disabled = false; btn.disabled = false;
} }
} }
// --- Player Preference --- // --- Player Preference ---
window.setPlayerPref = function (type) { window.setPlayerPref = function (type) {
localStorage.setItem('kv_player_pref', type); localStorage.setItem('kv_player_pref', type);
updatePlayerButtons(type); updatePlayerButtons(type);
} }
window.updatePlayerButtons = function (type) { window.updatePlayerButtons = function (type) {
const artBtn = document.getElementById('playerBtnArt'); const artBtn = document.getElementById('playerBtnArt');
const natBtn = document.getElementById('playerBtnNative'); const natBtn = document.getElementById('playerBtnNative');
if (artBtn) artBtn.classList.remove('active'); if (artBtn) artBtn.classList.remove('active');
if (natBtn) natBtn.classList.remove('active'); if (natBtn) natBtn.classList.remove('active');
if (type === 'native') { if (type === 'native') {
if (natBtn) natBtn.classList.add('active'); if (natBtn) natBtn.classList.add('active');
} else { } else {
if (artBtn) artBtn.classList.add('active'); if (artBtn) artBtn.classList.add('active');
} }
} }
// Initialize Settings // Initialize Settings
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Theme init // Theme init
const currentTheme = localStorage.getItem('theme') || 'dark'; const currentTheme = localStorage.getItem('theme') || 'dark';
const lightBtn = document.getElementById('themeBtnLight'); const lightBtn = document.getElementById('themeBtnLight');
const darkBtn = document.getElementById('themeBtnDark'); const darkBtn = document.getElementById('themeBtnDark');
if (currentTheme === 'light') { if (currentTheme === 'light') {
if (lightBtn) lightBtn.classList.add('active'); if (lightBtn) lightBtn.classList.add('active');
} else { } else {
if (darkBtn) darkBtn.classList.add('active'); if (darkBtn) darkBtn.classList.add('active');
} }
// Player init - default to artplayer // Player init - default to artplayer
const playerPref = localStorage.getItem('kv_player_pref') || 'artplayer'; const playerPref = localStorage.getItem('kv_player_pref') || 'artplayer';
updatePlayerButtons(playerPref); updatePlayerButtons(playerPref);
// Fetch versions // Fetch versions
fetchVersions(); fetchVersions();
}); });
</script> </script>
{% endblock %} {% endblock %}

4100
templates/watch.html Normal file → Executable file

File diff suppressed because it is too large Load diff

0
tests/test_loader_integration.py Normal file → Executable file
View file

0
tests/test_summarizer_logic.py Normal file → Executable file
View file

@ -0,0 +1 @@
Subproject commit 4b16bebf7d81925131001006231795f38538a928

View file

@ -1,47 +0,0 @@
FROM golang:1.25.3-alpine3.22 AS builder
RUN apk add --no-cache curl
WORKDIR /app
COPY src src
COPY templates templates
COPY go.mod go.mod
COPY go.sum go.sum
RUN go mod download
RUN go build -x -o media-roller ./src
# yt-dlp needs python
FROM python:3.13.7-alpine3.22
# This is where the downloaded files will be saved in the container.
ENV MR_DOWNLOAD_DIR="/download"
RUN apk add --update --no-cache \
# https://github.com/yt-dlp/yt-dlp/issues/14404 \
deno \
curl
# https://hub.docker.com/r/mwader/static-ffmpeg/tags
# https://github.com/wader/static-ffmpeg
COPY --from=mwader/static-ffmpeg:8.0 /ffmpeg /usr/local/bin/
COPY --from=mwader/static-ffmpeg:8.0 /ffprobe /usr/local/bin/
COPY --from=builder /app/media-roller /app/media-roller
COPY templates /app/templates
COPY static /app/static
WORKDIR /app
# Get new releases here https://github.com/yt-dlp/yt-dlp/releases
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/download/2025.09.26/yt-dlp -o /usr/local/bin/yt-dlp && \
echo "9215a371883aea75f0f2102c679333d813d9a5c3bceca212879a4a741a5b4657 /usr/local/bin/yt-dlp" | sha256sum -c - && \
chmod a+rx /usr/local/bin/yt-dlp
RUN yt-dlp --update --update-to nightly
# Sanity check
RUN yt-dlp --version && \
ffmpeg -version
ENTRYPOINT ["/app/media-roller"]

View file

@ -1,59 +0,0 @@
# Media Roller
A mobile friendly tool for downloading videos from social media.
The backend is a Golang server that will take a URL (YouTube, Reddit, Twitter, etc),
download the video file, and return a URL to directly download the video. The video will be transcoded to produce a single mp4 file.
This is built on [yt-dlp](https://github.com/yt-dlp/yt-dlp). yt-dlp will auto update every 12 hours to make sure it's running the latest nightly build.
Note: This was written to run on a home network and should not be exposed to public traffic. There's no auth.
![Screenshot 1](https://i.imgur.com/lxwf1qU.png)
![Screenshot 2](https://i.imgur.com/TWAtM7k.png)
# Running
Make sure you have [yt-dlp](https://github.com/yt-dlp/yt-dlp) and [FFmpeg](https://github.com/FFmpeg/FFmpeg) installed then pull the repo and run:
```bash
./run.sh
```
Or for docker locally:
```bash
./docker-build.sh
./docker-run.sh
```
With Docker, published to both dockerhub and github.
* ghcr: `docker pull ghcr.io/rroller/media-roller:master`
* dockerhub: `docker pull ronnieroller/media-roller`
See:
* https://github.com/rroller/media-roller/pkgs/container/media-roller
* https://hub.docker.com/repository/docker/ronnieroller/media-roller
The files are saved to the /download directory which you can mount as needed.
## Docker Environemnt Variables
* `MR_DOWNLOAD_DIR` where videos are saved. Defaults to `/download`
* `MR_PROXY` will pass the value to yt-dlp witht he `--proxy` argument. Defaults to empty
# API
To download a video directly, use the API endpoint:
```
/api/download?url=SOME_URL
```
Create a bookmarklet, allowing one click downloads (From a PC):
```
javascript:(location.href="http://127.0.0.1:3000/fetch?url="+encodeURIComponent(location.href));
```
# Integrating with mobile
After you have your server up, install this shortcut. Update the endpoint to your server address by editing the shortcut before running it.
https://www.icloud.com/shortcuts/d3b05b78eb434496ab28dd91e1c79615
# Unraid
media-roller is available in Unraid and can be found on the "Apps" tab by searching its name.

View file

@ -1,2 +0,0 @@
#!/usr/bin/env bash
go build -x -o media-roller ./src

View file

@ -1,2 +0,0 @@
#!/usr/bin/env bash
docker build -f Dockerfile -t media-roller .

View file

@ -1,2 +0,0 @@
#!/usr/bin/env bash
docker run -p 3000:3000 -v $(pwd)/download:/download media-roller

View file

@ -1,17 +0,0 @@
module media-roller
go 1.25.3
require (
github.com/dustin/go-humanize v1.0.1
github.com/go-chi/chi/v5 v5.2.3
github.com/matishsiao/goInfo v0.0.0-20241216093258-66a9250504d6
github.com/rs/zerolog v1.34.0
golang.org/x/sync v0.17.0
)
require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.37.0 // indirect
)

View file

@ -1,26 +0,0 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/matishsiao/goInfo v0.0.0-20241216093258-66a9250504d6 h1:BIv50poKtm6s4vUlN6J2qAOARALk4ACAwM9VRmKPyiI=
github.com/matishsiao/goInfo v0.0.0-20241216093258-66a9250504d6/go.mod h1:aEt7p9Rvh67BYApmZwNDPpgircTO2kgdmDUoF/1QmwA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=

View file

@ -1,2 +0,0 @@
#!/usr/bin/env bash
go run ./src

Some files were not shown because too many files have changed in this diff Show more