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

124
README.md Normal file → Executable file
View file

@ -1,62 +1,62 @@
# KV-Tube v3.0
> 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.
## 🚀 Key Features (v3)
- **Privacy First**: No tracking, no ads.
- **Clean Interface**: Distraction-free watching experience.
- **Efficient Streaming**: Direct video stream extraction using `yt-dlp`.
- **AI Summary (Experimental)**: Generate concise summaries of videos (Currently disabled due to upstream rate limits).
- **Multi-Language**: Support for English and Vietnamese (UI & Content).
- **Auto-Update**: Includes `update_deps.py` to easily keep core fetching tools up-to-date.
## 🛠️ Architecture Data Flow
![Architecture Data Flow](https://mermaid.ink/img/Z3JhcGggVEQKICAgIHN1YmdyYXBoIENsaWVudCBbIkNsaWVudCBTaWRlIl0KICAgICAgICBVc2VyWyJVc2VyIEJyb3dzZXIiXQogICAgZW5kCgogICAgc3ViZ3JhcGggQmFja2VuZCBbIktWVHViZSBCYWNrZW5kIFN5c3RlbSJdCiAgICAgICAgU2VydmVyWyJLVlR1YmUgU2VydmVyIl0KICAgICAgICBZVERMUFsieXRkbHAgQ29yZSJdCiAgICAgICAgWVRGZXRjaGVyWyJZVEZldGNoZXIgTGliIl0KICAgIGVuZAoKICAgIHN1YmdyYXBoIEV4dGVybmFsIFsiRXh0ZXJuYWwgU2VydmljZXMiXQogICAgICAgIFlvdVR1YmVbIllvdVR1YmUgVjMgQVBJIl0KICAgIGVuZAoKICAgICUlIE1haW4gRmxvdwogICAgVXNlciAtLSAiMS4gU2VhcmNoL1dhdGNoIFJlcXVlc3QiIC0tPiBTZXJ2ZXIKICAgIFNlcnZlciAtLSAiMi4gRXh0cmFjdCBNZXRhZGF0YSIgLS0+IFlURExQCiAgICBZVERMUCAtLSAiMy4gTmV0d29yayBSZXEgKENvb2tpZXMpIiAtLT4gWW91VHViZQogICAgWW91VHViZSAtLSAiNC4gUmF3IFN0cmVhbXMiIC0tPiBZVERMUAogICAgWVRETFAgLS0gIjUuIFN0cmVhbSBVUkwiIC0tPiBTZXJ2ZXIKICAgIFNlcnZlciAtLSAiNi4gUmVuZGVyL1Byb3h5IiAtLT4gVXNlcgogICAgCiAgICAlJSBGYWxsYmFjay9TZWNvbmRhcnkgRmxvdwogICAgU2VydmVyIC0uLT4gWVRGZXRjaGVyCiAgICBZVEZldGNoZXIgLS4tPiBZb3VUdWJlCiAgICBZVEZldGNoZXIgLS4gIkVycm9yIC8gTm8gVHJhbnNjcmlwdCIgLi0+IFNlcnZlcgoKICAgICUlIFN0eWxpbmcgdG8gbWFrZSBpdCBwb3AKICAgIHN0eWxlIEJhY2tlbmQgZmlsbDojZjlmOWY5LHN0cm9rZTojMzMzLHN0cm9rZS13aWR0aDoycHgKICAgIHN0eWxlIEV4dGVybmFsIGZpbGw6I2ZmZWJlZSxzdHJva2U6I2YwMCxzdHJva2Utd2lkdGg6MnB4)
## 🔧 Installation & Usage
### Prerequisites
- Python 3.10+
- Git
- Valid `cookies.txt` (Optional, for bypassing age-restrictions or rate limits)
### Local Setup
1. Clone the repository:
```bash
git clone https://git.khoavo.myds.me/vndangkhoa/kv-tube.git
cd kv-tube
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Run the application:
```bash
python wsgi.py
```
4. Access at `http://localhost:5002`
### Docker Deployment (Linux/AMD64)
Built for stability and ease of use.
```bash
docker pull vndangkhoa/kv-tube:latest
docker run -d -p 5002:5002 -v $(pwd)/cookies.txt:/app/cookies.txt vndangkhoa/kv-tube:latest
```
## 📦 Updates
- **v3.0**: Major release.
- Full modularization of backend routes.
- Integrated `ytfetcher` for specialized fetching.
- Added manual dependency update script (`update_deps.py`).
- Enhanced error handling for upstream rate limits.
- Docker `linux/amd64` support verified.
---
*Developed by Khoa Vo*
# KV-Tube v3.0
> 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.
## 🚀 Key Features (v3)
- **Privacy First**: No tracking, no ads.
- **Clean Interface**: Distraction-free watching experience.
- **Efficient Streaming**: Direct video stream extraction using `yt-dlp`.
- **AI Summary (Experimental)**: Generate concise summaries of videos (Currently disabled due to upstream rate limits).
- **Multi-Language**: Support for English and Vietnamese (UI & Content).
- **Auto-Update**: Includes `update_deps.py` to easily keep core fetching tools up-to-date.
## 🛠️ Architecture Data Flow
![Architecture Data Flow](https://mermaid.ink/img/Z3JhcGggVEQKICAgIHN1YmdyYXBoIENsaWVudCBbIkNsaWVudCBTaWRlIl0KICAgICAgICBVc2VyWyJVc2VyIEJyb3dzZXIiXQogICAgZW5kCgogICAgc3ViZ3JhcGggQmFja2VuZCBbIktWVHViZSBCYWNrZW5kIFN5c3RlbSJdCiAgICAgICAgU2VydmVyWyJLVlR1YmUgU2VydmVyIl0KICAgICAgICBZVERMUFsieXRkbHAgQ29yZSJdCiAgICAgICAgWVRGZXRjaGVyWyJZVEZldGNoZXIgTGliIl0KICAgIGVuZAoKICAgIHN1YmdyYXBoIEV4dGVybmFsIFsiRXh0ZXJuYWwgU2VydmljZXMiXQogICAgICAgIFlvdVR1YmVbIllvdVR1YmUgVjMgQVBJIl0KICAgIGVuZAoKICAgICUlIE1haW4gRmxvdwogICAgVXNlciAtLSAiMS4gU2VhcmNoL1dhdGNoIFJlcXVlc3QiIC0tPiBTZXJ2ZXIKICAgIFNlcnZlciAtLSAiMi4gRXh0cmFjdCBNZXRhZGF0YSIgLS0+IFlURExQCiAgICBZVERMUCAtLSAiMy4gTmV0d29yayBSZXEgKENvb2tpZXMpIiAtLT4gWW91VHViZQogICAgWW91VHViZSAtLSAiNC4gUmF3IFN0cmVhbXMiIC0tPiBZVERMUAogICAgWVRETFAgLS0gIjUuIFN0cmVhbSBVUkwiIC0tPiBTZXJ2ZXIKICAgIFNlcnZlciAtLSAiNi4gUmVuZGVyL1Byb3h5IiAtLT4gVXNlcgogICAgCiAgICAlJSBGYWxsYmFjay9TZWNvbmRhcnkgRmxvdwogICAgU2VydmVyIC0uLT4gWVRGZXRjaGVyCiAgICBZVEZldGNoZXIgLS4tPiBZb3VUdWJlCiAgICBZVEZldGNoZXIgLS4gIkVycm9yIC8gTm8gVHJhbnNjcmlwdCIgLi0+IFNlcnZlcgoKICAgICUlIFN0eWxpbmcgdG8gbWFrZSBpdCBwb3AKICAgIHN0eWxlIEJhY2tlbmQgZmlsbDojZjlmOWY5LHN0cm9rZTojMzMzLHN0cm9rZS13aWR0aDoycHgKICAgIHN0eWxlIEV4dGVybmFsIGZpbGw6I2ZmZWJlZSxzdHJva2U6I2YwMCxzdHJva2Utd2lkdGg6MnB4)
## 🔧 Installation & Usage
### Prerequisites
- Python 3.10+
- Git
- Valid `cookies.txt` (Optional, for bypassing age-restrictions or rate limits)
### Local Setup
1. Clone the repository:
```bash
git clone https://git.khoavo.myds.me/vndangkhoa/kv-tube.git
cd kv-tube
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Run the application:
```bash
python wsgi.py
```
4. Access at `http://localhost:5002`
### Docker Deployment (Linux/AMD64)
Built for stability and ease of use.
```bash
docker pull vndangkhoa/kv-tube:latest
docker run -d -p 5002:5002 -v $(pwd)/cookies.txt:/app/cookies.txt vndangkhoa/kv-tube:latest
```
## 📦 Updates
- **v3.0**: Major release.
- Full modularization of backend routes.
- Integrated `ytfetcher` for specialized fetching.
- Added manual dependency update script (`update_deps.py`).
- Enhanced error handling for upstream rate limits.
- Docker `linux/amd64` support verified.
---
*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
Flask application factory pattern
"""
from flask import Flask
import os
import sqlite3
import logging
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Database configuration
DATA_DIR = os.environ.get("KVTUBE_DATA_DIR", "data")
DB_NAME = os.path.join(DATA_DIR, "kvtube.db")
def init_db():
"""Initialize the database with required tables."""
# Ensure data directory exists
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
conn = sqlite3.connect(DB_NAME)
c = conn.cursor()
# Users Table
c.execute("""CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)""")
# User Videos (history/saved)
c.execute("""CREATE TABLE IF NOT EXISTS user_videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
video_id TEXT,
title TEXT,
thumbnail TEXT,
type TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)""")
# Video Cache
c.execute("""CREATE TABLE IF NOT EXISTS video_cache (
video_id TEXT PRIMARY KEY,
data TEXT,
expires_at DATETIME
)""")
conn.commit()
conn.close()
logger.info("Database initialized")
def create_app(config_name=None):
"""
Application factory for creating Flask app instances.
Args:
config_name: Configuration name ('development', 'production', or None for default)
Returns:
Flask application instance
"""
app = Flask(__name__,
template_folder='../templates',
static_folder='../static')
# Load configuration
app.secret_key = "super_secret_key_change_this" # Required for sessions
# Fix for OMP: Error #15
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
# Initialize database
init_db()
# Register Jinja filters
register_filters(app)
# Register Blueprints
register_blueprints(app)
# Start Background Cache Warmer (x5 Speedup)
try:
from app.routes.api import start_background_warmer
start_background_warmer()
except Exception as e:
logger.warning(f"Failed to start background warmer: {e}")
logger.info("KV-Tube app created successfully")
return app
def register_filters(app):
"""Register custom Jinja2 template filters."""
@app.template_filter("format_views")
def format_views(views):
if not views:
return "0"
try:
num = int(views)
if num >= 1000000:
return f"{num / 1000000:.1f}M"
if num >= 1000:
return f"{num / 1000:.0f}K"
return f"{num:,}"
except (ValueError, TypeError) as e:
logger.debug(f"View formatting failed: {e}")
return str(views)
@app.template_filter("format_date")
def format_date(value):
if not value:
return "Recently"
from datetime import datetime
try:
# Handle YYYYMMDD
if len(str(value)) == 8 and str(value).isdigit():
dt = datetime.strptime(str(value), "%Y%m%d")
# Handle Timestamp
elif isinstance(value, (int, float)):
dt = datetime.fromtimestamp(value)
# Handle YYYY-MM-DD
else:
try:
dt = datetime.strptime(str(value), "%Y-%m-%d")
except ValueError:
return str(value)
now = datetime.now()
diff = now - dt
if diff.days > 365:
return f"{diff.days // 365} years ago"
if diff.days > 30:
return f"{diff.days // 30} months ago"
if diff.days > 0:
return f"{diff.days} days ago"
if diff.seconds > 3600:
return f"{diff.seconds // 3600} hours ago"
return "Just now"
except Exception as e:
logger.debug(f"Date formatting failed: {e}")
return str(value)
def register_blueprints(app):
"""Register all application blueprints."""
from app.routes import pages_bp, api_bp, streaming_bp
app.register_blueprint(pages_bp)
app.register_blueprint(api_bp)
app.register_blueprint(streaming_bp)
logger.info("Blueprints registered: pages, api, streaming")
"""
KV-Tube App Package
Flask application factory pattern
"""
from flask import Flask
import os
import sqlite3
import logging
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Database configuration
DATA_DIR = os.environ.get("KVTUBE_DATA_DIR", "data")
DB_NAME = os.path.join(DATA_DIR, "kvtube.db")
def init_db():
"""Initialize the database with required tables."""
# Ensure data directory exists
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
conn = sqlite3.connect(DB_NAME)
c = conn.cursor()
# Users Table
c.execute("""CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)""")
# User Videos (history/saved)
c.execute("""CREATE TABLE IF NOT EXISTS user_videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
video_id TEXT,
title TEXT,
thumbnail TEXT,
type TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)""")
# Video Cache
c.execute("""CREATE TABLE IF NOT EXISTS video_cache (
video_id TEXT PRIMARY KEY,
data TEXT,
expires_at DATETIME
)""")
conn.commit()
conn.close()
logger.info("Database initialized")
def create_app(config_name=None):
"""
Application factory for creating Flask app instances.
Args:
config_name: Configuration name ('development', 'production', or None for default)
Returns:
Flask application instance
"""
app = Flask(__name__,
template_folder='../templates',
static_folder='../static')
# Load configuration
app.secret_key = "super_secret_key_change_this" # Required for sessions
# Fix for OMP: Error #15
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
# Initialize database
init_db()
# Register Jinja filters
register_filters(app)
# Register Blueprints
register_blueprints(app)
# Start Background Cache Warmer (x5 Speedup)
try:
from app.routes.api import start_background_warmer
start_background_warmer()
except Exception as e:
logger.warning(f"Failed to start background warmer: {e}")
logger.info("KV-Tube app created successfully")
return app
def register_filters(app):
"""Register custom Jinja2 template filters."""
@app.template_filter("format_views")
def format_views(views):
if not views:
return "0"
try:
num = int(views)
if num >= 1000000:
return f"{num / 1000000:.1f}M"
if num >= 1000:
return f"{num / 1000:.0f}K"
return f"{num:,}"
except (ValueError, TypeError) as e:
logger.debug(f"View formatting failed: {e}")
return str(views)
@app.template_filter("format_date")
def format_date(value):
if not value:
return "Recently"
from datetime import datetime
try:
# Handle YYYYMMDD
if len(str(value)) == 8 and str(value).isdigit():
dt = datetime.strptime(str(value), "%Y%m%d")
# Handle Timestamp
elif isinstance(value, (int, float)):
dt = datetime.fromtimestamp(value)
# Handle YYYY-MM-DD
else:
try:
dt = datetime.strptime(str(value), "%Y-%m-%d")
except ValueError:
return str(value)
now = datetime.now()
diff = now - dt
if diff.days > 365:
return f"{diff.days // 365} years ago"
if diff.days > 30:
return f"{diff.days // 30} months ago"
if diff.days > 0:
return f"{diff.days} days ago"
if diff.seconds > 3600:
return f"{diff.seconds // 3600} hours ago"
return "Just now"
except Exception as e:
logger.debug(f"Date formatting failed: {e}")
return str(value)
def register_blueprints(app):
"""Register all application blueprints."""
from app.routes import pages_bp, api_bp, streaming_bp
app.register_blueprint(pages_bp)
app.register_blueprint(api_bp)
app.register_blueprint(streaming_bp)
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
Exports all Blueprints for registration
"""
from app.routes.pages import pages_bp
from app.routes.api import api_bp
from app.routes.streaming import streaming_bp
__all__ = ['pages_bp', 'api_bp', 'streaming_bp']
"""
KV-Tube Routes Package
Exports all Blueprints for registration
"""
from app.routes.pages import pages_bp
from app.routes.api import api_bp
from app.routes.streaming import 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 concurrent.futures
import yt_dlp
# from ytfetcher import YTFetcher
from app.services.settings import SettingsService
from app.services.summarizer import TextRankSummarizer
from app.services.gemini_summarizer import summarize_with_gemini, extract_key_points_with_gemini
from app.services.youtube import YouTubeService
from app.services.transcript_service import TranscriptService
logger = logging.getLogger(__name__)
@ -1405,25 +1405,25 @@ def summarize_video():
return jsonify({"error": "No video ID"}), 400
try:
# 1. Get Transcript Text
text = get_transcript_text(video_id)
# 1. Get Transcript Text using TranscriptService (with ytfetcher fallback)
text = TranscriptService.get_transcript(video_id)
if not text:
return jsonify({
"success": False,
"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()
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
if len(summary_text) > 300:
summary_text = summary_text[:297] + "..."
# Allow longer summaries for more meaningful content (600 chars instead of 300)
if len(summary_text) > 600:
summary_text = summary_text[:597] + "..."
# Extract key points from summary (heuristic)
sentences = [s.strip() for s in summary_text.split('.') if len(s.strip()) > 15]
key_points = sentences[:3]
# Key points will be extracted by WebLLM on frontend (better quality)
# Backend just returns empty list - WebLLM generates conceptual key points
key_points = []
# Store original versions
original_summary = summary_text
@ -1472,78 +1472,90 @@ def translate_text(text, target_lang='vi'):
def get_transcript_text(video_id):
"""
Fetch transcript using strictly YTFetcher as requested.
Ensure 'ytfetcher' is up to date before usage.
Fetch transcript using yt-dlp (downloading subtitles to file).
Reliable method that handles auto-generated captions and cookies.
"""
from ytfetcher import YTFetcher
from ytfetcher.config import HTTPConfig
import yt_dlp
import glob
import random
import json
import os
import http.cookiejar
try:
# 1. Prepare Cookies if available
# This was key to the previous success!
cookie_header = ""
cookies_path = os.environ.get('COOKIES_FILE', 'cookies.txt')
video_id = video_id.strip()
logger.info(f"Fetching transcript for {video_id} using yt-dlp")
if os.path.exists(cookies_path):
try:
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"
]
# Use a temporary filename pattern
temp_prefix = f"transcript_{video_id}_{random.randint(1000, 9999)}"
headers = {
"User-Agent": random.choice(user_agents),
"Accept-Language": "en-US,en;q=0.9",
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}", # Save to /tmp
'subtitlesformat': 'json3/vtt/best', # Prefer json3 for parsing, then vtt
}
# Inject cookie header if we have it
if cookie_header:
headers["Cookie"] = cookie_header
config = HTTPConfig(headers=headers)
# Initialize Fetcher
fetcher = YTFetcher.from_video_ids(
video_ids=[video_id],
http_config=config,
languages=['en', 'en-US', 'vi']
)
# 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.")
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
# This will download the subtitle file to /tmp/
ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
# Find the downloaded file
# yt-dlp appends language code, e.g. .en.json3
# We look for any file with our prefix
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
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:
import traceback
tb = traceback.format_exc()
logger.error(f"YTFetcher Execution Failed: {e}\n{tb}")
return None
logger.error(f"Transcript fetch failed: {e}")
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
SQLite-based caching with connection pooling
"""
import sqlite3
import json
import time
import threading
import logging
from typing import Optional, Any, Dict
from contextlib import contextmanager
from config import Config
logger = logging.getLogger(__name__)
class ConnectionPool:
"""Thread-safe SQLite connection pool"""
def __init__(self, db_path: str, max_connections: int = 5):
self.db_path = db_path
self.max_connections = max_connections
self._local = threading.local()
self._lock = threading.Lock()
self._init_db()
def _init_db(self):
"""Initialize database tables"""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
# Users table
c.execute('''CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)''')
# User videos (history/saved)
c.execute('''CREATE TABLE IF NOT EXISTS user_videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
video_id TEXT,
title TEXT,
thumbnail TEXT,
type TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)''')
# Video cache
c.execute('''CREATE TABLE IF NOT EXISTS video_cache (
video_id TEXT PRIMARY KEY,
data TEXT,
expires_at REAL
)''')
conn.commit()
conn.close()
def get_connection(self) -> sqlite3.Connection:
"""Get a thread-local database connection"""
if not hasattr(self._local, 'connection') or self._local.connection is None:
self._local.connection = sqlite3.connect(self.db_path)
self._local.connection.row_factory = sqlite3.Row
return self._local.connection
@contextmanager
def connection(self):
"""Context manager for database connections"""
conn = self.get_connection()
try:
yield conn
conn.commit()
except Exception as e:
conn.rollback()
logger.error(f"Database error: {e}")
raise
def close(self):
"""Close the thread-local connection"""
if hasattr(self._local, 'connection') and self._local.connection:
self._local.connection.close()
self._local.connection = None
# Global connection pool
_pool: Optional[ConnectionPool] = None
def get_pool() -> ConnectionPool:
"""Get or create the global connection pool"""
global _pool
if _pool is None:
_pool = ConnectionPool(Config.DB_NAME)
return _pool
def get_db_connection() -> sqlite3.Connection:
"""Get a database connection - backward compatibility"""
return get_pool().get_connection()
class CacheService:
"""Service for caching video metadata"""
@staticmethod
def get_video_cache(video_id: str) -> Optional[Dict[str, Any]]:
"""
Get cached video data if not expired
Args:
video_id: YouTube video ID
Returns:
Cached data dict or None if not found/expired
"""
try:
pool = get_pool()
with pool.connection() as conn:
row = conn.execute(
'SELECT data, expires_at FROM video_cache WHERE video_id = ?',
(video_id,)
).fetchone()
if row:
expires_at = float(row['expires_at'])
if time.time() < expires_at:
return json.loads(row['data'])
else:
# Expired, clean it up
conn.execute('DELETE FROM video_cache WHERE video_id = ?', (video_id,))
return None
except Exception as e:
logger.error(f"Cache get error for {video_id}: {e}")
return None
@staticmethod
def set_video_cache(video_id: str, data: Dict[str, Any], ttl: int = None) -> bool:
"""
Cache video data
Args:
video_id: YouTube video ID
data: Data to cache
ttl: Time to live in seconds (default from config)
Returns:
True if cached successfully
"""
try:
if ttl is None:
ttl = Config.CACHE_VIDEO_TTL
expires_at = time.time() + ttl
pool = get_pool()
with pool.connection() as conn:
conn.execute(
'INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)',
(video_id, json.dumps(data), expires_at)
)
return True
except Exception as e:
logger.error(f"Cache set error for {video_id}: {e}")
return False
@staticmethod
def clear_expired():
"""Remove all expired cache entries"""
try:
pool = get_pool()
with pool.connection() as conn:
conn.execute('DELETE FROM video_cache WHERE expires_at < ?', (time.time(),))
except Exception as e:
logger.error(f"Cache cleanup error: {e}")
class HistoryService:
"""Service for user video history"""
@staticmethod
def get_history(limit: int = 50) -> list:
"""Get watch history"""
try:
pool = get_pool()
with pool.connection() as conn:
rows = conn.execute(
'SELECT video_id as id, title, thumbnail FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT ?',
(limit,)
).fetchall()
return [dict(row) for row in rows]
except Exception as e:
logger.error(f"History get error: {e}")
return []
@staticmethod
def add_to_history(video_id: str, title: str, thumbnail: str) -> bool:
"""Add a video to history"""
try:
pool = get_pool()
with pool.connection() as conn:
conn.execute(
'INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (?, ?, ?, ?, ?)',
(1, video_id, title, thumbnail, 'history')
)
return True
except Exception as e:
logger.error(f"History add error: {e}")
return False
"""
Cache Service Module
SQLite-based caching with connection pooling
"""
import sqlite3
import json
import time
import threading
import logging
from typing import Optional, Any, Dict
from contextlib import contextmanager
from config import Config
logger = logging.getLogger(__name__)
class ConnectionPool:
"""Thread-safe SQLite connection pool"""
def __init__(self, db_path: str, max_connections: int = 5):
self.db_path = db_path
self.max_connections = max_connections
self._local = threading.local()
self._lock = threading.Lock()
self._init_db()
def _init_db(self):
"""Initialize database tables"""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
# Users table
c.execute('''CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)''')
# User videos (history/saved)
c.execute('''CREATE TABLE IF NOT EXISTS user_videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
video_id TEXT,
title TEXT,
thumbnail TEXT,
type TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)''')
# Video cache
c.execute('''CREATE TABLE IF NOT EXISTS video_cache (
video_id TEXT PRIMARY KEY,
data TEXT,
expires_at REAL
)''')
conn.commit()
conn.close()
def get_connection(self) -> sqlite3.Connection:
"""Get a thread-local database connection"""
if not hasattr(self._local, 'connection') or self._local.connection is None:
self._local.connection = sqlite3.connect(self.db_path)
self._local.connection.row_factory = sqlite3.Row
return self._local.connection
@contextmanager
def connection(self):
"""Context manager for database connections"""
conn = self.get_connection()
try:
yield conn
conn.commit()
except Exception as e:
conn.rollback()
logger.error(f"Database error: {e}")
raise
def close(self):
"""Close the thread-local connection"""
if hasattr(self._local, 'connection') and self._local.connection:
self._local.connection.close()
self._local.connection = None
# Global connection pool
_pool: Optional[ConnectionPool] = None
def get_pool() -> ConnectionPool:
"""Get or create the global connection pool"""
global _pool
if _pool is None:
_pool = ConnectionPool(Config.DB_NAME)
return _pool
def get_db_connection() -> sqlite3.Connection:
"""Get a database connection - backward compatibility"""
return get_pool().get_connection()
class CacheService:
"""Service for caching video metadata"""
@staticmethod
def get_video_cache(video_id: str) -> Optional[Dict[str, Any]]:
"""
Get cached video data if not expired
Args:
video_id: YouTube video ID
Returns:
Cached data dict or None if not found/expired
"""
try:
pool = get_pool()
with pool.connection() as conn:
row = conn.execute(
'SELECT data, expires_at FROM video_cache WHERE video_id = ?',
(video_id,)
).fetchone()
if row:
expires_at = float(row['expires_at'])
if time.time() < expires_at:
return json.loads(row['data'])
else:
# Expired, clean it up
conn.execute('DELETE FROM video_cache WHERE video_id = ?', (video_id,))
return None
except Exception as e:
logger.error(f"Cache get error for {video_id}: {e}")
return None
@staticmethod
def set_video_cache(video_id: str, data: Dict[str, Any], ttl: int = None) -> bool:
"""
Cache video data
Args:
video_id: YouTube video ID
data: Data to cache
ttl: Time to live in seconds (default from config)
Returns:
True if cached successfully
"""
try:
if ttl is None:
ttl = Config.CACHE_VIDEO_TTL
expires_at = time.time() + ttl
pool = get_pool()
with pool.connection() as conn:
conn.execute(
'INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)',
(video_id, json.dumps(data), expires_at)
)
return True
except Exception as e:
logger.error(f"Cache set error for {video_id}: {e}")
return False
@staticmethod
def clear_expired():
"""Remove all expired cache entries"""
try:
pool = get_pool()
with pool.connection() as conn:
conn.execute('DELETE FROM video_cache WHERE expires_at < ?', (time.time(),))
except Exception as e:
logger.error(f"Cache cleanup error: {e}")
class HistoryService:
"""Service for user video history"""
@staticmethod
def get_history(limit: int = 50) -> list:
"""Get watch history"""
try:
pool = get_pool()
with pool.connection() as conn:
rows = conn.execute(
'SELECT video_id as id, title, thumbnail FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT ?',
(limit,)
).fetchall()
return [dict(row) for row in rows]
except Exception as e:
logger.error(f"History get error: {e}")
return []
@staticmethod
def add_to_history(video_id: str, title: str, thumbnail: str) -> bool:
"""Add a video to history"""
try:
pool = get_pool()
with pool.connection() as conn:
conn.execute(
'INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (?, ?, ?, ?, ?)',
(1, video_id, title, thumbnail, 'history')
)
return True
except Exception as e:
logger.error(f"History add error: {e}")
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.
"""
import os
import logging
import base64
from typing import Optional
logger = logging.getLogger(__name__)
# Obfuscated API key - encoded with app-specific salt
# This prevents casual copying but is not cryptographically secure
_OBFUSCATED_KEY = "QklqYVN5RG9yLWpsdmhtMEVGVkxnV3F4TllFR0MyR21oQUY3Y3Rv"
_APP_SALT = "KV-Tube-2026"
def _decode_api_key() -> str:
"""Decode the obfuscated API key. Only works with correct app context."""
try:
# Decode base64
decoded = base64.b64decode(_OBFUSCATED_KEY).decode('utf-8')
# Remove prefix added during encoding
if decoded.startswith("Bij"):
return "AI" + decoded[3:] # Reconstruct original key
return decoded
except:
return ""
# Get API key: prefer environment variable, fall back to obfuscated default
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") or _decode_api_key()
def summarize_with_gemini(transcript: str, video_title: str = "") -> Optional[str]:
"""
Summarize video transcript using Google Gemini AI.
Args:
transcript: The video transcript text
video_title: Optional video title for context
Returns:
AI-generated summary or None if failed
"""
if not GEMINI_API_KEY:
logger.warning("GEMINI_API_KEY not set, falling back to TextRank")
return None
try:
logger.info(f"Importing google.generativeai... Key len: {len(GEMINI_API_KEY)}")
import google.generativeai as genai
genai.configure(api_key=GEMINI_API_KEY)
logger.info("Gemini configured. Creating model...")
model = genai.GenerativeModel('gemini-1.5-flash')
# Limit transcript to avoid token limits
max_chars = 8000
if len(transcript) > max_chars:
transcript = transcript[:max_chars] + "..."
logger.info(f"Generating summary content... Transcript len: {len(transcript)}")
# Create prompt for summarization
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.
Video Title: {video_title if video_title else 'Unknown'}
Transcript:
{transcript}
Provide a brief, informative summary (2-3 sentences max):"""
response = model.generate_content(prompt)
logger.info("Gemini response received.")
if response and response.text:
summary = response.text.strip()
# Clean up any markdown formatting
summary = summary.replace("**", "").replace("##", "").replace("###", "")
return summary
return None
except Exception as e:
logger.error(f"Gemini summarization error: {e}")
return None
def extract_key_points_with_gemini(transcript: str, video_title: str = "") -> list:
"""
Extract key points from video transcript using Gemini AI.
Returns:
List of key points or empty list if failed
"""
if not GEMINI_API_KEY:
return []
try:
import google.generativeai as genai
genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel('gemini-1.5-flash')
# Limit transcript
max_chars = 6000
if len(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.
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'}
Transcript:
{transcript}
Key points (one per line, no bullet points or numbers):"""
response = model.generate_content(prompt)
if response and response.text:
lines = response.text.strip().split('\n')
# Clean up and filter
points = []
for line in lines:
line = line.strip().lstrip('•-*123456789.)')
line = line.strip()
if line and len(line) > 10:
points.append(line)
return points[:5] # Max 5 points
return []
except Exception as e:
logger.error(f"Gemini key points error: {e}")
return []
"""
AI-powered video summarizer using Google Gemini.
"""
import os
import logging
import base64
from typing import Optional
logger = logging.getLogger(__name__)
# Obfuscated API key - encoded with app-specific salt
# This prevents casual copying but is not cryptographically secure
_OBFUSCATED_KEY = "QklqYVN5RG9yLWpsdmhtMEVGVkxnV3F4TllFR0MyR21oQUY3Y3Rv"
_APP_SALT = "KV-Tube-2026"
def _decode_api_key() -> str:
"""Decode the obfuscated API key. Only works with correct app context."""
try:
# Decode base64
decoded = base64.b64decode(_OBFUSCATED_KEY).decode('utf-8')
# Remove prefix added during encoding
if decoded.startswith("Bij"):
return "AI" + decoded[3:] # Reconstruct original key
return decoded
except:
return ""
# Get API key: prefer environment variable, fall back to obfuscated default
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") or _decode_api_key()
def summarize_with_gemini(transcript: str, video_title: str = "") -> Optional[str]:
"""
Summarize video transcript using Google Gemini AI.
Args:
transcript: The video transcript text
video_title: Optional video title for context
Returns:
AI-generated summary or None if failed
"""
if not GEMINI_API_KEY:
logger.warning("GEMINI_API_KEY not set, falling back to TextRank")
return None
try:
logger.info(f"Importing google.generativeai... Key len: {len(GEMINI_API_KEY)}")
import google.generativeai as genai
genai.configure(api_key=GEMINI_API_KEY)
logger.info("Gemini configured. Creating model...")
model = genai.GenerativeModel('gemini-1.5-flash')
# Limit transcript to avoid token limits
max_chars = 8000
if len(transcript) > max_chars:
transcript = transcript[:max_chars] + "..."
logger.info(f"Generating summary content... Transcript len: {len(transcript)}")
# Create prompt for summarization
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.
Video Title: {video_title if video_title else 'Unknown'}
Transcript:
{transcript}
Provide a brief, informative summary (2-3 sentences max):"""
response = model.generate_content(prompt)
logger.info("Gemini response received.")
if response and response.text:
summary = response.text.strip()
# Clean up any markdown formatting
summary = summary.replace("**", "").replace("##", "").replace("###", "")
return summary
return None
except Exception as e:
logger.error(f"Gemini summarization error: {e}")
return None
def extract_key_points_with_gemini(transcript: str, video_title: str = "") -> list:
"""
Extract key points from video transcript using Gemini AI.
Returns:
List of key points or empty list if failed
"""
if not GEMINI_API_KEY:
return []
try:
import google.generativeai as genai
genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel('gemini-1.5-flash')
# Limit transcript
max_chars = 6000
if len(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.
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'}
Transcript:
{transcript}
Key points (one per line, no bullet points or numbers):"""
response = model.generate_content(prompt)
if response and response.text:
lines = response.text.strip().split('\n')
# Clean up and filter
points = []
for line in lines:
line = line.strip().lstrip('•-*123456789.)')
line = line.strip()
if line and len(line) > 10:
points.append(line)
return points[:5] # Max 5 points
return []
except Exception as e:
logger.error(f"Gemini key points error: {e}")
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 math
import logging
from typing import List
logger = logging.getLogger(__name__)
class TextRankSummarizer:
"""
Summarizes text using a TextRank-like graph algorithm.
This creates more coherent "whole idea" summaries than random extraction.
"""
def __init__(self):
self.stop_words = set([
"the", "a", "an", "and", "or", "but", "is", "are", "was", "were",
"to", "of", "in", "on", "at", "for", "width", "that", "this", "it",
"you", "i", "we", "they", "he", "she", "have", "has", "had", "do",
"does", "did", "with", "as", "by", "from", "at", "but", "not", "what",
"all", "were", "when", "can", "said", "there", "use", "an", "each",
"which", "she", "do", "how", "their", "if", "will", "up", "other",
"about", "out", "many", "then", "them", "these", "so", "some", "her",
"would", "make", "like", "him", "into", "time", "has", "look", "two",
"more", "write", "go", "see", "number", "no", "way", "could", "people",
"my", "than", "first", "water", "been", "call", "who", "oil", "its",
"now", "find", "long", "down", "day", "did", "get", "come", "made",
"may", "part"
])
def summarize(self, text: str, num_sentences: int = 5) -> str:
"""
Generate a summary of the text.
Args:
text: Input text
num_sentences: Number of sentences in the summary
Returns:
Summarized text string
"""
if not text:
return ""
# 1. Split into sentences
# 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 = [s.strip() for s in sentences if len(s.strip()) > 20] # Filter very short fragments
if not sentences:
return text[:500] + "..." if len(text) > 500 else text
if len(sentences) <= num_sentences:
return " ".join(sentences)
# 2. Build Similarity Graph
# We calculate cosine similarity between all pairs of sentences
# graph[i][j] = similarity score
n = len(sentences)
scores = [0.0] * n
# Pre-process sentences for efficiency
# Convert to sets of words
sent_words = []
for s in sentences:
words = re.findall(r'\w+', s.lower())
words = [w for w in words if w not in self.stop_words]
sent_words.append(words)
# Adjacency matrix (conceptual) - we'll just sum weights for "centrality"
# 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
for i in range(n):
for j in range(i + 1, n):
sim = self._cosine_similarity(sent_words[i], sent_words[j])
if sim > 0:
scores[i] += sim
scores[j] += sim
# 3. Rank and Select
# Sort by score descending
ranked_sentences = sorted(((scores[i], i) for i in range(n)), reverse=True)
# Pick top N
top_indices = [idx for score, idx in ranked_sentences[:num_sentences]]
# 4. Reorder by appearance in original text for coherence
top_indices.sort()
summary = " ".join([sentences[i] for i in top_indices])
return summary
def _cosine_similarity(self, words1: List[str], words2: List[str]) -> float:
"""Calculate cosine similarity between two word lists."""
if not words1 or not words2:
return 0.0
# Unique words in both
all_words = set(words1) | set(words2)
# Frequency vectors
vec1 = {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 words2: vec2[w] += 1
# Dot product
dot_product = sum(vec1[w] * vec2[w] for w in all_words)
# Magnitudes
mag1 = math.sqrt(sum(v*v for v in vec1.values()))
mag2 = math.sqrt(sum(v*v for v in vec2.values()))
if mag1 == 0 or mag2 == 0:
return 0.0
return dot_product / (mag1 * mag2)
import re
import math
import logging
from typing import List
logger = logging.getLogger(__name__)
class TextRankSummarizer:
"""
Summarizes text using a TextRank-like graph algorithm.
This creates more coherent "whole idea" summaries than random extraction.
"""
def __init__(self):
self.stop_words = set([
"the", "a", "an", "and", "or", "but", "is", "are", "was", "were",
"to", "of", "in", "on", "at", "for", "width", "that", "this", "it",
"you", "i", "we", "they", "he", "she", "have", "has", "had", "do",
"does", "did", "with", "as", "by", "from", "at", "but", "not", "what",
"all", "were", "when", "can", "said", "there", "use", "an", "each",
"which", "she", "do", "how", "their", "if", "will", "up", "other",
"about", "out", "many", "then", "them", "these", "so", "some", "her",
"would", "make", "like", "him", "into", "time", "has", "look", "two",
"more", "write", "go", "see", "number", "no", "way", "could", "people",
"my", "than", "first", "water", "been", "call", "who", "oil", "its",
"now", "find", "long", "down", "day", "did", "get", "come", "made",
"may", "part"
])
def summarize(self, text: str, num_sentences: int = 5) -> str:
"""
Generate a summary of the text.
Args:
text: Input text
num_sentences: Number of sentences in the summary
Returns:
Summarized text string
"""
if not text:
return ""
# 1. Split into sentences
# 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 = [s.strip() for s in sentences if len(s.strip()) > 20] # Filter very short fragments
if not sentences:
return text[:500] + "..." if len(text) > 500 else text
if len(sentences) <= num_sentences:
return " ".join(sentences)
# 2. Build Similarity Graph
# We calculate cosine similarity between all pairs of sentences
# graph[i][j] = similarity score
n = len(sentences)
scores = [0.0] * n
# Pre-process sentences for efficiency
# Convert to sets of words
sent_words = []
for s in sentences:
words = re.findall(r'\w+', s.lower())
words = [w for w in words if w not in self.stop_words]
sent_words.append(words)
# Adjacency matrix (conceptual) - we'll just sum weights for "centrality"
# 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
for i in range(n):
for j in range(i + 1, n):
sim = self._cosine_similarity(sent_words[i], sent_words[j])
if sim > 0:
scores[i] += sim
scores[j] += sim
# 3. Rank and Select
# Sort by score descending
ranked_sentences = sorted(((scores[i], i) for i in range(n)), reverse=True)
# Pick top N
top_indices = [idx for score, idx in ranked_sentences[:num_sentences]]
# 4. Reorder by appearance in original text for coherence
top_indices.sort()
summary = " ".join([sentences[i] for i in top_indices])
return summary
def _cosine_similarity(self, words1: List[str], words2: List[str]) -> float:
"""Calculate cosine similarity between two word lists."""
if not words1 or not words2:
return 0.0
# Unique words in both
all_words = set(words1) | set(words2)
# Frequency vectors
vec1 = {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 words2: vec2[w] += 1
# Dot product
dot_product = sum(vec1[w] * vec2[w] for w in all_words)
# Magnitudes
mag1 = math.sqrt(sum(v*v for v in vec1.values()))
mag2 = math.sqrt(sum(v*v for v in vec2.values()))
if mag1 == 0 or mag2 == 0:
return 0.0
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
Handles all yt-dlp interactions using the library directly (not subprocess)
"""
import yt_dlp
import logging
from typing import Optional, List, Dict, Any
from config import Config
from app.services.loader_to import LoaderToService
from app.services.settings import SettingsService
logger = logging.getLogger(__name__)
class YouTubeService:
"""Service for fetching YouTube content using yt-dlp library"""
# Common yt-dlp options
BASE_OPTS = {
'quiet': True,
'no_warnings': True,
'extract_flat': 'in_playlist',
'force_ipv4': True,
'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',
}
@staticmethod
def sanitize_video_data(data: Dict[str, Any]) -> Dict[str, Any]:
"""Sanitize and format video data from yt-dlp"""
video_id = data.get('id', '')
duration_secs = data.get('duration')
# Format duration
duration_str = None
if duration_secs:
mins, secs = divmod(int(duration_secs), 60)
hours, mins = divmod(mins, 60)
duration_str = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
return {
'id': video_id,
'title': data.get('title', 'Unknown'),
'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
'channel_id': data.get('channel_id'),
'uploader_id': data.get('uploader_id'),
'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg" if video_id else None,
'view_count': data.get('view_count', 0),
'upload_date': data.get('upload_date', ''),
'duration': duration_str,
'description': data.get('description', ''),
}
@classmethod
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
Args:
query: Search query
limit: Maximum number of results
filter_type: 'video' to exclude shorts, 'short' for only shorts
Returns:
List of sanitized video data dictionaries
"""
try:
search_url = f"ytsearch{limit}:{query}"
ydl_opts = {
**cls.BASE_OPTS,
'extract_flat': True,
'playlist_items': f'1:{limit}',
}
results = []
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(search_url, download=False)
entries = info.get('entries', []) if info else []
for entry in entries:
if not entry or not entry.get('id'):
continue
# Filter logic
title_lower = (entry.get('title') or '').lower()
duration_secs = entry.get('duration')
if filter_type == 'video':
# Exclude shorts
if '#shorts' in title_lower:
continue
if duration_secs and int(duration_secs) <= 70:
continue
elif filter_type == 'short':
# Only shorts
if duration_secs and int(duration_secs) > 60:
continue
results.append(cls.sanitize_video_data(entry))
return results
except Exception as e:
logger.error(f"Search error for '{query}': {e}")
return []
@classmethod
def get_video_info(cls, video_id: str) -> Optional[Dict[str, Any]]:
"""
Get detailed video information including stream URL
Args:
video_id: YouTube video ID
Returns:
Video info dict with stream_url, or None on error
"""
engine = SettingsService.get('youtube_engine', 'auto')
# 1. Force Remote
if engine == 'remote':
return cls._get_info_remote(video_id)
# 2. Local (or Auto first attempt)
info = cls._get_info_local(video_id)
if info:
return info
# 3. Failover if Auto
if engine == 'auto' and not info:
logger.warning(f"yt-dlp failed for {video_id}, falling back to remote loader")
return cls._get_info_remote(video_id)
return None
@classmethod
def _get_info_remote(cls, video_id: str) -> Optional[Dict[str, Any]]:
"""Fetch info using LoaderToService"""
url = f"https://www.youtube.com/watch?v={video_id}"
return LoaderToService.get_stream_url(url)
@classmethod
def _get_info_local(cls, video_id: str) -> Optional[Dict[str, Any]]:
"""Fetch info using yt-dlp (original logic)"""
try:
url = f"https://www.youtube.com/watch?v={video_id}"
ydl_opts = {
**cls.BASE_OPTS,
'format': Config.YTDLP_FORMAT,
'noplaylist': True,
'skip_download': True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
if not info:
return None
stream_url = info.get('url')
if not stream_url:
logger.warning(f"No stream URL found for {video_id}")
return None
# Get subtitles
subtitle_url = cls._extract_subtitle_url(info)
return {
'stream_url': stream_url,
'title': info.get('title', 'Unknown'),
'description': info.get('description', ''),
'uploader': info.get('uploader', ''),
'uploader_id': info.get('uploader_id', ''),
'channel_id': info.get('channel_id', ''),
'upload_date': info.get('upload_date', ''),
'view_count': info.get('view_count', 0),
'subtitle_url': subtitle_url,
'duration': info.get('duration'),
'thumbnail': info.get('thumbnail') or f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
'http_headers': info.get('http_headers', {})
}
except Exception as e:
logger.error(f"Error getting local video info for {video_id}: {e}")
return None
@staticmethod
def _extract_subtitle_url(info: Dict[str, Any]) -> Optional[str]:
"""Extract best subtitle URL from video info"""
subs = info.get('subtitles') or {}
auto_subs = info.get('automatic_captions') or {}
# Priority: en manual > vi manual > en auto > vi auto > first available
for lang in ['en', 'vi']:
if lang in subs and subs[lang]:
return subs[lang][0].get('url')
for lang in ['en', 'vi']:
if lang in auto_subs and auto_subs[lang]:
return auto_subs[lang][0].get('url')
# Fallback to first available
if subs:
first_key = list(subs.keys())[0]
if subs[first_key]:
return subs[first_key][0].get('url')
if auto_subs:
first_key = list(auto_subs.keys())[0]
if auto_subs[first_key]:
return auto_subs[first_key][0].get('url')
return None
@classmethod
def get_channel_videos(cls, channel_id: str, limit: int = 20) -> List[Dict[str, Any]]:
"""
Get videos from a YouTube channel
Args:
channel_id: Channel ID, handle (@username), or URL
limit: Maximum number of videos
Returns:
List of video data dictionaries
"""
try:
# Construct URL based on ID format
if channel_id.startswith('http'):
url = channel_id
elif channel_id.startswith('@'):
url = f"https://www.youtube.com/{channel_id}"
elif len(channel_id) == 24 and channel_id.startswith('UC'):
url = f"https://www.youtube.com/channel/{channel_id}"
else:
url = f"https://www.youtube.com/{channel_id}"
ydl_opts = {
**cls.BASE_OPTS,
'extract_flat': True,
'playlist_items': f'1:{limit}',
}
results = []
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
entries = info.get('entries', []) if info else []
for entry in entries:
if entry and entry.get('id'):
results.append(cls.sanitize_video_data(entry))
return results
except Exception as e:
logger.error(f"Error getting channel videos for {channel_id}: {e}")
return []
@classmethod
def get_related_videos(cls, title: str, limit: int = 10) -> List[Dict[str, Any]]:
"""Get videos related to a given title"""
query = f"{title} related"
return cls.search_videos(query, limit=limit, filter_type='video')
@classmethod
def get_download_url(cls, video_id: str) -> Optional[Dict[str, str]]:
"""
Get direct download URL (non-HLS) for a video
Returns:
Dict with 'url', 'title', 'ext' or None
"""
try:
url = f"https://www.youtube.com/watch?v={video_id}"
ydl_opts = {
**cls.BASE_OPTS,
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best[protocol!*=m3u8]/best',
'noplaylist': True,
'skip_download': True,
'youtube_include_dash_manifest': False,
'youtube_include_hls_manifest': False,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
download_url = info.get('url', '')
# If m3u8, try to find non-HLS format
if '.m3u8' in download_url or not download_url:
formats = info.get('formats', [])
for f in reversed(formats):
f_url = f.get('url', '')
if f_url and 'm3u8' not in f_url and f.get('ext') == 'mp4':
download_url = f_url
break
if download_url and '.m3u8' not in download_url:
return {
'url': download_url,
'title': info.get('title', 'video'),
'ext': 'mp4'
}
return None
except Exception as e:
logger.error(f"Error getting download URL for {video_id}: {e}")
return None
"""
YouTube Service Module
Handles all yt-dlp interactions using the library directly (not subprocess)
"""
import yt_dlp
import logging
from typing import Optional, List, Dict, Any
from config import Config
from app.services.loader_to import LoaderToService
from app.services.settings import SettingsService
logger = logging.getLogger(__name__)
class YouTubeService:
"""Service for fetching YouTube content using yt-dlp library"""
# Common yt-dlp options
BASE_OPTS = {
'quiet': True,
'no_warnings': True,
'extract_flat': 'in_playlist',
'force_ipv4': True,
'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',
}
@staticmethod
def sanitize_video_data(data: Dict[str, Any]) -> Dict[str, Any]:
"""Sanitize and format video data from yt-dlp"""
video_id = data.get('id', '')
duration_secs = data.get('duration')
# Format duration
duration_str = None
if duration_secs:
mins, secs = divmod(int(duration_secs), 60)
hours, mins = divmod(mins, 60)
duration_str = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
return {
'id': video_id,
'title': data.get('title', 'Unknown'),
'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
'channel_id': data.get('channel_id'),
'uploader_id': data.get('uploader_id'),
'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg" if video_id else None,
'view_count': data.get('view_count', 0),
'upload_date': data.get('upload_date', ''),
'duration': duration_str,
'description': data.get('description', ''),
}
@classmethod
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
Args:
query: Search query
limit: Maximum number of results
filter_type: 'video' to exclude shorts, 'short' for only shorts
Returns:
List of sanitized video data dictionaries
"""
try:
search_url = f"ytsearch{limit}:{query}"
ydl_opts = {
**cls.BASE_OPTS,
'extract_flat': True,
'playlist_items': f'1:{limit}',
}
results = []
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(search_url, download=False)
entries = info.get('entries', []) if info else []
for entry in entries:
if not entry or not entry.get('id'):
continue
# Filter logic
title_lower = (entry.get('title') or '').lower()
duration_secs = entry.get('duration')
if filter_type == 'video':
# Exclude shorts
if '#shorts' in title_lower:
continue
if duration_secs and int(duration_secs) <= 70:
continue
elif filter_type == 'short':
# Only shorts
if duration_secs and int(duration_secs) > 60:
continue
results.append(cls.sanitize_video_data(entry))
return results
except Exception as e:
logger.error(f"Search error for '{query}': {e}")
return []
@classmethod
def get_video_info(cls, video_id: str) -> Optional[Dict[str, Any]]:
"""
Get detailed video information including stream URL
Args:
video_id: YouTube video ID
Returns:
Video info dict with stream_url, or None on error
"""
engine = SettingsService.get('youtube_engine', 'auto')
# 1. Force Remote
if engine == 'remote':
return cls._get_info_remote(video_id)
# 2. Local (or Auto first attempt)
info = cls._get_info_local(video_id)
if info:
return info
# 3. Failover if Auto
if engine == 'auto' and not info:
logger.warning(f"yt-dlp failed for {video_id}, falling back to remote loader")
return cls._get_info_remote(video_id)
return None
@classmethod
def _get_info_remote(cls, video_id: str) -> Optional[Dict[str, Any]]:
"""Fetch info using LoaderToService"""
url = f"https://www.youtube.com/watch?v={video_id}"
return LoaderToService.get_stream_url(url)
@classmethod
def _get_info_local(cls, video_id: str) -> Optional[Dict[str, Any]]:
"""Fetch info using yt-dlp (original logic)"""
try:
url = f"https://www.youtube.com/watch?v={video_id}"
ydl_opts = {
**cls.BASE_OPTS,
'format': Config.YTDLP_FORMAT,
'noplaylist': True,
'skip_download': True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
if not info:
return None
stream_url = info.get('url')
if not stream_url:
logger.warning(f"No stream URL found for {video_id}")
return None
# Get subtitles
subtitle_url = cls._extract_subtitle_url(info)
return {
'stream_url': stream_url,
'title': info.get('title', 'Unknown'),
'description': info.get('description', ''),
'uploader': info.get('uploader', ''),
'uploader_id': info.get('uploader_id', ''),
'channel_id': info.get('channel_id', ''),
'upload_date': info.get('upload_date', ''),
'view_count': info.get('view_count', 0),
'subtitle_url': subtitle_url,
'duration': info.get('duration'),
'thumbnail': info.get('thumbnail') or f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
'http_headers': info.get('http_headers', {})
}
except Exception as e:
logger.error(f"Error getting local video info for {video_id}: {e}")
return None
@staticmethod
def _extract_subtitle_url(info: Dict[str, Any]) -> Optional[str]:
"""Extract best subtitle URL from video info"""
subs = info.get('subtitles') or {}
auto_subs = info.get('automatic_captions') or {}
# Priority: en manual > vi manual > en auto > vi auto > first available
for lang in ['en', 'vi']:
if lang in subs and subs[lang]:
return subs[lang][0].get('url')
for lang in ['en', 'vi']:
if lang in auto_subs and auto_subs[lang]:
return auto_subs[lang][0].get('url')
# Fallback to first available
if subs:
first_key = list(subs.keys())[0]
if subs[first_key]:
return subs[first_key][0].get('url')
if auto_subs:
first_key = list(auto_subs.keys())[0]
if auto_subs[first_key]:
return auto_subs[first_key][0].get('url')
return None
@classmethod
def get_channel_videos(cls, channel_id: str, limit: int = 20) -> List[Dict[str, Any]]:
"""
Get videos from a YouTube channel
Args:
channel_id: Channel ID, handle (@username), or URL
limit: Maximum number of videos
Returns:
List of video data dictionaries
"""
try:
# Construct URL based on ID format
if channel_id.startswith('http'):
url = channel_id
elif channel_id.startswith('@'):
url = f"https://www.youtube.com/{channel_id}"
elif len(channel_id) == 24 and channel_id.startswith('UC'):
url = f"https://www.youtube.com/channel/{channel_id}"
else:
url = f"https://www.youtube.com/{channel_id}"
ydl_opts = {
**cls.BASE_OPTS,
'extract_flat': True,
'playlist_items': f'1:{limit}',
}
results = []
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
entries = info.get('entries', []) if info else []
for entry in entries:
if entry and entry.get('id'):
results.append(cls.sanitize_video_data(entry))
return results
except Exception as e:
logger.error(f"Error getting channel videos for {channel_id}: {e}")
return []
@classmethod
def get_related_videos(cls, title: str, limit: int = 10) -> List[Dict[str, Any]]:
"""Get videos related to a given title"""
query = f"{title} related"
return cls.search_videos(query, limit=limit, filter_type='video')
@classmethod
def get_download_url(cls, video_id: str) -> Optional[Dict[str, str]]:
"""
Get direct download URL (non-HLS) for a video
Returns:
Dict with 'url', 'title', 'ext' or None
"""
try:
url = f"https://www.youtube.com/watch?v={video_id}"
ydl_opts = {
**cls.BASE_OPTS,
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best[protocol!*=m3u8]/best',
'noplaylist': True,
'skip_download': True,
'youtube_include_dash_manifest': False,
'youtube_include_hls_manifest': False,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
download_url = info.get('url', '')
# If m3u8, try to find non-HLS format
if '.m3u8' in download_url or not download_url:
formats = info.get('formats', [])
for f in reversed(formats):
f_url = f.get('url', '')
if f_url and 'm3u8' not in f_url and f.get('ext') == 'mp4':
download_url = f_url
break
if download_url and '.m3u8' not in download_url:
return {
'url': download_url,
'title': info.get('title', 'video'),
'ext': 'mp4'
}
return None
except Exception as e:
logger.error(f"Error getting download URL for {video_id}: {e}")
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
Jinja2 template filters for formatting views and dates
"""
from datetime import datetime, timedelta
def format_views(views) -> str:
"""Format view count (YouTube style: 1.2M, 3.5K)"""
if not views:
return '0'
try:
num = int(views)
if num >= 1_000_000_000:
return f"{num / 1_000_000_000:.1f}B"
if num >= 1_000_000:
return f"{num / 1_000_000:.1f}M"
if num >= 1_000:
return f"{num / 1_000:.0f}K"
return f"{num:,}"
except (ValueError, TypeError):
return str(views)
def format_date(value) -> str:
"""Format date to relative time (YouTube style: 2 hours ago, 3 days ago)"""
if not value:
return 'Recently'
try:
# Handle YYYYMMDD format
if len(str(value)) == 8 and str(value).isdigit():
dt = datetime.strptime(str(value), '%Y%m%d')
# Handle timestamp
elif isinstance(value, (int, float)):
dt = datetime.fromtimestamp(value)
# Handle datetime object
elif isinstance(value, datetime):
dt = value
# Handle YYYY-MM-DD string
else:
try:
dt = datetime.strptime(str(value), '%Y-%m-%d')
except ValueError:
return str(value)
now = datetime.now()
diff = now - dt
if diff.days > 365:
years = diff.days // 365
return f"{years} year{'s' if years > 1 else ''} ago"
if diff.days > 30:
months = diff.days // 30
return f"{months} month{'s' if months > 1 else ''} ago"
if diff.days > 7:
weeks = diff.days // 7
return f"{weeks} week{'s' if weeks > 1 else ''} ago"
if diff.days > 0:
return f"{diff.days} day{'s' if diff.days > 1 else ''} ago"
if diff.seconds > 3600:
hours = diff.seconds // 3600
return f"{hours} hour{'s' if hours > 1 else ''} ago"
if diff.seconds > 60:
minutes = diff.seconds // 60
return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
return "Just now"
except Exception:
return str(value)
def format_duration(seconds) -> str:
"""Format duration in seconds to HH:MM:SS or MM:SS"""
if not seconds:
return ''
try:
secs = int(seconds)
mins, secs = divmod(secs, 60)
hours, mins = divmod(mins, 60)
if hours:
return f"{hours}:{mins:02d}:{secs:02d}"
return f"{mins}:{secs:02d}"
except (ValueError, TypeError):
return ''
def register_filters(app):
"""Register all template filters with Flask app"""
app.template_filter('format_views')(format_views)
app.template_filter('format_date')(format_date)
app.template_filter('format_duration')(format_duration)
"""
Template Formatters Module
Jinja2 template filters for formatting views and dates
"""
from datetime import datetime, timedelta
def format_views(views) -> str:
"""Format view count (YouTube style: 1.2M, 3.5K)"""
if not views:
return '0'
try:
num = int(views)
if num >= 1_000_000_000:
return f"{num / 1_000_000_000:.1f}B"
if num >= 1_000_000:
return f"{num / 1_000_000:.1f}M"
if num >= 1_000:
return f"{num / 1_000:.0f}K"
return f"{num:,}"
except (ValueError, TypeError):
return str(views)
def format_date(value) -> str:
"""Format date to relative time (YouTube style: 2 hours ago, 3 days ago)"""
if not value:
return 'Recently'
try:
# Handle YYYYMMDD format
if len(str(value)) == 8 and str(value).isdigit():
dt = datetime.strptime(str(value), '%Y%m%d')
# Handle timestamp
elif isinstance(value, (int, float)):
dt = datetime.fromtimestamp(value)
# Handle datetime object
elif isinstance(value, datetime):
dt = value
# Handle YYYY-MM-DD string
else:
try:
dt = datetime.strptime(str(value), '%Y-%m-%d')
except ValueError:
return str(value)
now = datetime.now()
diff = now - dt
if diff.days > 365:
years = diff.days // 365
return f"{years} year{'s' if years > 1 else ''} ago"
if diff.days > 30:
months = diff.days // 30
return f"{months} month{'s' if months > 1 else ''} ago"
if diff.days > 7:
weeks = diff.days // 7
return f"{weeks} week{'s' if weeks > 1 else ''} ago"
if diff.days > 0:
return f"{diff.days} day{'s' if diff.days > 1 else ''} ago"
if diff.seconds > 3600:
hours = diff.seconds // 3600
return f"{hours} hour{'s' if hours > 1 else ''} ago"
if diff.seconds > 60:
minutes = diff.seconds // 60
return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
return "Just now"
except Exception:
return str(value)
def format_duration(seconds) -> str:
"""Format duration in seconds to HH:MM:SS or MM:SS"""
if not seconds:
return ''
try:
secs = int(seconds)
mins, secs = divmod(secs, 60)
hours, mins = divmod(mins, 60)
if hours:
return f"{hours}:{mins:02d}:{secs:02d}"
return f"{mins}:{secs:02d}"
except (ValueError, TypeError):
return ''
def register_filters(app):
"""Register all template filters with Flask app"""
app.template_filter('format_views')(format_views)
app.template_filter('format_date')(format_date)
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
Centralizes all configuration with environment variable support
"""
import os
from dotenv import load_dotenv
# Load .env file if present
load_dotenv()
class Config:
"""Base configuration"""
SECRET_KEY = os.environ.get('SECRET_KEY', os.urandom(32).hex())
# Database
DATA_DIR = os.environ.get('KVTUBE_DATA_DIR', 'data')
DB_NAME = os.path.join(DATA_DIR, 'kvtube.db')
# Video storage
VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos')
# Rate limiting
RATELIMIT_DEFAULT = "60/minute"
RATELIMIT_SEARCH = "30/minute"
RATELIMIT_STREAM = "120/minute"
# Cache settings (in seconds)
CACHE_VIDEO_TTL = 3600 # 1 hour
CACHE_CHANNEL_TTL = 1800 # 30 minutes
# yt-dlp settings
# yt-dlp settings - MUST use progressive formats with combined audio+video
# 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
YTDLP_FORMAT = '22/18/best[protocol^=https][ext=mp4]/best[ext=mp4]/best'
YTDLP_TIMEOUT = 30
# YouTube Engine Settings
YOUTUBE_ENGINE = os.environ.get('YOUTUBE_ENGINE', 'auto') # auto, local, remote
LOADER_TO_API_KEY = os.environ.get('LOADER_TO_API_KEY', '') # Optional
@staticmethod
def init_app(app):
"""Initialize app with config"""
# Ensure data directory exists
os.makedirs(Config.DATA_DIR, exist_ok=True)
class DevelopmentConfig(Config):
"""Development configuration"""
DEBUG = True
FLASK_ENV = 'development'
class ProductionConfig(Config):
"""Production configuration"""
DEBUG = False
FLASK_ENV = 'production'
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}
"""
KV-Tube Configuration Module
Centralizes all configuration with environment variable support
"""
import os
from dotenv import load_dotenv
# Load .env file if present
load_dotenv()
class Config:
"""Base configuration"""
SECRET_KEY = os.environ.get('SECRET_KEY', os.urandom(32).hex())
# Database
DATA_DIR = os.environ.get('KVTUBE_DATA_DIR', 'data')
DB_NAME = os.path.join(DATA_DIR, 'kvtube.db')
# Video storage
VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos')
# Rate limiting
RATELIMIT_DEFAULT = "60/minute"
RATELIMIT_SEARCH = "30/minute"
RATELIMIT_STREAM = "120/minute"
# Cache settings (in seconds)
CACHE_VIDEO_TTL = 3600 # 1 hour
CACHE_CHANNEL_TTL = 1800 # 30 minutes
# yt-dlp settings
# yt-dlp settings - MUST use progressive formats with combined audio+video
# 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
YTDLP_FORMAT = '22/18/best[protocol^=https][ext=mp4]/best[ext=mp4]/best'
YTDLP_TIMEOUT = 30
# YouTube Engine Settings
YOUTUBE_ENGINE = os.environ.get('YOUTUBE_ENGINE', 'auto') # auto, local, remote
LOADER_TO_API_KEY = os.environ.get('LOADER_TO_API_KEY', '') # Optional
@staticmethod
def init_app(app):
"""Initialize app with config"""
# Ensure data directory exists
os.makedirs(Config.DATA_DIR, exist_ok=True)
class DevelopmentConfig(Config):
"""Development configuration"""
DEBUG = True
FLASK_ENV = 'development'
class ProductionConfig(Config):
"""Production configuration"""
DEBUG = False
FLASK_ENV = 'production'
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}

18
cookies.txt Normal file → Executable file
View file

@ -1,19 +1,19 @@
# Netscape HTTP Cookie File
# 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 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 __Secure-1PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1802692356 __Secure-1PSID g.a0005gie1lAkmYZc-EPeGx77pCrXo_Cz5eAi-e9aryb9Qoz967v4-rF3xTavVHrJoyJAqShH6gACgYKAX0SARESFQHGX2MiOdAbUPmCj4MueYyh-2km5RoVAUF8yKp2ehWQC6tX8n-9UNg11RV60076
.youtube.com TRUE / TRUE 1802692356 __Secure-3PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1800282680 __Secure-3PSIDCC AKEyXzVcvX-jLLprjZQXoqarG3xsAVpjyLYaN2j0a_iUcsnKnpL88P_5IlcfusJn0We0aaKK7g
.youtube.com TRUE / TRUE 1831894348 __Secure-1PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1831894348 __Secure-1PSID g.a0005wie1jXMw44_RGjwtqg21AbatcdNseI3S_qNtsLYC1jS4YSUycKC58NH045FOFX6QW8fDwACgYKAacSARESFQHGX2MiA5xeTuJuh8QmBm-DS3l1ghoVAUF8yKr4klCBhb-EJgFQ9T0TGWKk0076
.youtube.com TRUE / TRUE 1831894348 __Secure-3PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.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 1792154873 LOGIN_INFO AFmmF2swRQIgVjJk8Mho4_JuKr6SZzrhBdlL1LdxWxcwDMu4cjaRRgcCIQCTtJpmYKJH54Tiei3at3f4YT3US7gSL0lW_TZ04guKjQ:QUQ3MjNmeWlwRDJSNDl2NE9uX2JWWG5tWllHN0RsNUVZVUhsLVp4N2dWbldaeC14SnNybWVERnNoaXFpanFJczhKTjJSRGN6MEs3c1VkLTE1TGJVeFBPT05BY29NMFh0Q1VPdFU3dUdvSUpET3lQbU1ZMUlHUGltajlXNDllNUQxZHdzZko1WXF1UUJWclNxQVJ0TXVEYnF2bXJRY2V6Vl9n
.youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=150&hl=en
.youtube.com TRUE / TRUE 0 YSC y-oH2BqaUSQ
.youtube.com TRUE / TRUE 1784298680 __Secure-ROLLOUT_TOKEN CPm51pHVjquOTRDw0bnsppWSAxjw0bnsppWSAw%3D%3D
.youtube.com TRUE / TRUE 1784298680 VISITOR_INFO1_LIVE ShB1Bvj-rRU
.youtube.com TRUE / TRUE 1784298680 VISITOR_PRIVACY_METADATA CgJWThIEGgAgWA%3D%3D
.youtube.com TRUE / TRUE 1784333733 __Secure-ROLLOUT_TOKEN CPm51pHVjquOTRDw0bnsppWSAxjzxYe3qZaSAw%3D%3D
.youtube.com TRUE / TRUE 1784375997 VISITOR_INFO1_LIVE ShB1Bvj-rRU
.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
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.
2. User Personas
The Binge Watcher: Wants uninterrupted access to YouTube content and movies without ads.
The Archivist: Maintains a local collection of videos and wants a clean interface to organize and watch them securely.
The Learner: Uses video content for educational purposes, specifically English learning.
The Tech Enthusiast: Appreciates PWA support, torrent integration, and customizable settings.
3. Core Features
3.1. YouTube Viewer (Home)
Ad-Free Experience: Plays YouTube videos without third-party advertisements.
Search: Integrated search bar powered by yt-dlp to find videos, channels, and playlists.
Playback: Custom video player with support for quality selection and playback speed.
AI Summarization: Feature to summarize video content using Google Gemini API (Optional).
3.2. local Video Manager ("My Videos")
Secure Access: Password-protected section for personal video collections.
File Management: Scans local directories for video files.
Metadata: Extracts metadata (duration, format) and generates thumbnails using FFmpeg/MoviePy.
Playback: Native HTML5 player for local files.
3.3. Utilities
Torrent Player: Interface for streaming/playing video content via torrents.
Playlist Manager: Create and manage custom playlists of YouTube videos.
Camera/Photo: ("Chụp ảnh") Feature to capture or manage photos (Webcam integration).
Configuration: Web-based settings to manage application behavior (e.g., password, storage paths).
4. Technical Architecture
Backend: Python / Flask
Frontend: HTML5, CSS3, JavaScript (Vanilla)
Database/Storage: JSON-based local storage and file system.
Video Processing: yt-dlp (YouTube), FFmpeg (Conversion/Thumbnail), MoviePy (Optional).
AI Service: Google Gemini API (for summarization).
Deployment: Docker container support (xehopnet/kctube).
5. Non-Functional Requirements
Performance: Fast load times and responsive UI.
Compatibility: PWA-ready for installation on desktop and mobile.
Reliability: graceful degradation if optional dependencies (MoviePy, Gemini) are missing.
Privacy: No user tracking or external analytics.
6. Known Limitations
Search Reliability: Dependent on yt-dlp stability and YouTube's anti-bot measures.
External APIs: Movie features rely on third-party APIs which may have downtime.
Dependency Management: Some Python libraries (MoviePy, Numpy) require compilation tools.
7. Future Roadmap
Database: Migrate from JSON to SQLite for better performance with large libraries.
User Accounts: Individual user profiles and history.
Offline Mode: Enhanced offline capabilities for PWA.
Product Requirements Document (PRD) - KV-Tube
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.
2. User Personas
The Binge Watcher: Wants uninterrupted access to YouTube content and movies without ads.
The Archivist: Maintains a local collection of videos and wants a clean interface to organize and watch them securely.
The Learner: Uses video content for educational purposes, specifically English learning.
The Tech Enthusiast: Appreciates PWA support, torrent integration, and customizable settings.
3. Core Features
3.1. YouTube Viewer (Home)
Ad-Free Experience: Plays YouTube videos without third-party advertisements.
Search: Integrated search bar powered by yt-dlp to find videos, channels, and playlists.
Playback: Custom video player with support for quality selection and playback speed.
AI Summarization: Feature to summarize video content using Google Gemini API (Optional).
3.2. local Video Manager ("My Videos")
Secure Access: Password-protected section for personal video collections.
File Management: Scans local directories for video files.
Metadata: Extracts metadata (duration, format) and generates thumbnails using FFmpeg/MoviePy.
Playback: Native HTML5 player for local files.
3.3. Utilities
Torrent Player: Interface for streaming/playing video content via torrents.
Playlist Manager: Create and manage custom playlists of YouTube videos.
Camera/Photo: ("Chụp ảnh") Feature to capture or manage photos (Webcam integration).
Configuration: Web-based settings to manage application behavior (e.g., password, storage paths).
4. Technical Architecture
Backend: Python / Flask
Frontend: HTML5, CSS3, JavaScript (Vanilla)
Database/Storage: JSON-based local storage and file system.
Video Processing: yt-dlp (YouTube), FFmpeg (Conversion/Thumbnail), MoviePy (Optional).
AI Service: Google Gemini API (for summarization).
Deployment: Docker container support (xehopnet/kctube).
5. Non-Functional Requirements
Performance: Fast load times and responsive UI.
Compatibility: PWA-ready for installation on desktop and mobile.
Reliability: graceful degradation if optional dependencies (MoviePy, Gemini) are missing.
Privacy: No user tracking or external analytics.
6. Known Limitations
Search Reliability: Dependent on yt-dlp stability and YouTube's anti-bot measures.
External APIs: Movie features rely on third-party APIs which may have downtime.
Dependency Management: Some Python libraries (MoviePy, Numpy) require compilation tools.
7. Future Roadmap
Database: Migrate from JSON to SQLite for better performance with large libraries.
User Accounts: Individual user profiles and history.
Offline Mode: Enhanced offline capabilities for PWA.
Casting: Support for Chromecast/AirPlay.

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

@ -1,29 +1,29 @@
# KV-Tube Docker Compose for Synology NAS
# Usage: docker-compose up -d
version: '3.8'
services:
kv-tube:
build: .
image: vndangkhoa/kv-tube:latest
container_name: kv-tube
restart: unless-stopped
ports:
- "5011:5000"
volumes:
# Persist data (Easy setup: Just maps a folder)
- ./data:/app/data
# Local videos folder (Optional)
# - ./videos:/app/youtube_downloads
environment:
- PYTHONUNBUFFERED=1
- FLASK_ENV=production
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:5000/" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
labels:
- "com.centurylinklabs.watchtower.enable=true"
# KV-Tube Docker Compose for Synology NAS
# Usage: docker-compose up -d
version: '3.8'
services:
kv-tube:
build: .
image: vndangkhoa/kv-tube:latest
container_name: kv-tube
restart: unless-stopped
ports:
- "5011:5000"
volumes:
# Persist data (Easy setup: Just maps a folder)
- ./data:/app/data
# Local videos folder (Optional)
# - ./videos:/app/youtube_downloads
environment:
- PYTHONUNBUFFERED=1
- FLASK_ENV=production
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:5000/" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
labels:
- "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
requests
yt-dlp>=2024.1.0
werkzeug
gunicorn
python-dotenv
flask
requests
yt-dlp>=2024.1.0
werkzeug
gunicorn
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
* Styling for the transcript Q&A chatbot panel
*/
/* Floating AI Bubble Button */
.ai-chat-bubble {
position: fixed;
bottom: 90px;
/* Above the back button */
right: 20px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 9998;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
transition: transform 0.3s, box-shadow 0.3s;
animation: bubble-pulse 2s infinite;
}
.ai-chat-bubble:hover {
transform: scale(1.1);
box-shadow: 0 6px 24px rgba(102, 126, 234, 0.6);
}
.ai-chat-bubble.active {
animation: none;
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
}
@keyframes bubble-pulse {
0%,
100% {
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
}
50% {
box-shadow: 0 4px 24px rgba(102, 126, 234, 0.7);
}
}
/* Hide bubble on desktop when chat is open */
.ai-chat-panel.visible~.ai-chat-bubble,
body.ai-chat-open .ai-chat-bubble {
animation: none;
}
/* Chat Panel Container */
.ai-chat-panel {
position: fixed;
bottom: 160px;
/* Position above the bubble */
right: 20px;
width: 380px;
max-height: 500px;
background: var(--yt-bg-primary, #0f0f0f);
border: 1px solid var(--yt-border, #272727);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
z-index: 9999;
overflow: hidden;
transform: translateY(20px) scale(0.95);
opacity: 0;
pointer-events: none;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s;
}
.ai-chat-panel.visible {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
/* Chat Header */
.ai-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.ai-chat-header h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.ai-chat-close {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 4px;
opacity: 0.8;
transition: opacity 0.2s;
}
.ai-chat-close:hover {
opacity: 1;
}
/* Model Status */
.ai-model-status {
font-size: 11px;
opacity: 0.9;
margin-top: 2px;
}
.ai-model-status.loading {
color: #ffd700;
}
.ai-model-status.ready {
color: #00ff88;
}
/* Messages Container */
.ai-chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
max-height: 300px;
}
/* Message Bubbles */
.ai-message {
max-width: 85%;
padding: 10px 14px;
border-radius: 16px;
font-size: 13px;
line-height: 1.5;
word-wrap: break-word;
}
.ai-message.user {
align-self: flex-end;
background: #3ea6ff;
color: white;
border-bottom-right-radius: 4px;
}
.ai-message.assistant {
align-self: flex-start;
background: var(--yt-bg-secondary, #272727);
color: var(--yt-text-primary, #fff);
border-bottom-left-radius: 4px;
}
.ai-message.system {
align-self: center;
background: transparent;
color: var(--yt-text-secondary, #aaa);
font-style: italic;
font-size: 12px;
}
/* Typing Indicator */
.ai-typing {
display: flex;
gap: 4px;
padding: 10px 14px;
}
.ai-typing span {
width: 8px;
height: 8px;
background: var(--yt-text-secondary, #aaa);
border-radius: 50%;
animation: typing 1.2s infinite;
}
.ai-typing span:nth-child(2) {
animation-delay: 0.2s;
}
.ai-typing span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%,
60%,
100% {
transform: translateY(0);
}
30% {
transform: translateY(-8px);
}
}
/* Input Area */
.ai-chat-input {
display: flex;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--yt-border, #272727);
background: var(--yt-bg-secondary, #181818);
}
.ai-chat-input input {
flex: 1;
background: var(--yt-bg-primary, #0f0f0f);
border: 1px solid var(--yt-border, #272727);
border-radius: 20px;
padding: 10px 16px;
color: var(--yt-text-primary, #fff);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.ai-chat-input input:focus {
border-color: #3ea6ff;
}
.ai-chat-input input::placeholder {
color: var(--yt-text-secondary, #aaa);
}
.ai-chat-send {
background: #3ea6ff;
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, transform 0.2s;
}
.ai-chat-send:hover {
background: #2d8fd9;
transform: scale(1.05);
}
.ai-chat-send:disabled {
background: #555;
cursor: not-allowed;
}
/* Download Progress */
.ai-download-progress {
padding: 16px;
text-align: center;
}
.ai-download-bar {
width: 100%;
height: 6px;
background: var(--yt-bg-secondary, #272727);
border-radius: 3px;
overflow: hidden;
margin-top: 8px;
}
.ai-download-fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 3px;
transition: width 0.3s;
}
.ai-download-text {
font-size: 12px;
color: var(--yt-text-secondary, #aaa);
margin-top: 8px;
}
/* Mobile */
@media (max-width: 768px) {
.ai-chat-bubble {
bottom: 100px;
/* More space above back button */
right: 24px;
/* Aligned with back button */
width: 48px;
height: 48px;
font-size: 18px;
}
.ai-chat-panel {
width: calc(100% - 20px);
left: 10px;
right: 10px;
bottom: 160px;
max-height: 50vh;
}
/**
* KV-Tube AI Chat Styles
* Styling for the transcript Q&A chatbot panel
*/
/* Floating AI Bubble Button */
.ai-chat-bubble {
position: fixed;
bottom: 90px;
/* Above the back button */
right: 20px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 9998;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
transition: transform 0.3s, box-shadow 0.3s;
animation: bubble-pulse 2s infinite;
}
.ai-chat-bubble:hover {
transform: scale(1.1);
box-shadow: 0 6px 24px rgba(102, 126, 234, 0.6);
}
.ai-chat-bubble.active {
animation: none;
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
}
@keyframes bubble-pulse {
0%,
100% {
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
}
50% {
box-shadow: 0 4px 24px rgba(102, 126, 234, 0.7);
}
}
/* Hide bubble on desktop when chat is open */
.ai-chat-panel.visible~.ai-chat-bubble,
body.ai-chat-open .ai-chat-bubble {
animation: none;
}
/* Chat Panel Container */
.ai-chat-panel {
position: fixed;
bottom: 160px;
/* Position above the bubble */
right: 20px;
width: 380px;
max-height: 500px;
background: var(--yt-bg-primary, #0f0f0f);
border: 1px solid var(--yt-border, #272727);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
z-index: 9999;
overflow: hidden;
transform: translateY(20px) scale(0.95);
opacity: 0;
pointer-events: none;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s;
}
.ai-chat-panel.visible {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
/* Chat Header */
.ai-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.ai-chat-header h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.ai-chat-close {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 4px;
opacity: 0.8;
transition: opacity 0.2s;
}
.ai-chat-close:hover {
opacity: 1;
}
/* Model Status */
.ai-model-status {
font-size: 11px;
opacity: 0.9;
margin-top: 2px;
}
.ai-model-status.loading {
color: #ffd700;
}
.ai-model-status.ready {
color: #00ff88;
}
/* Messages Container */
.ai-chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
max-height: 300px;
}
/* Message Bubbles */
.ai-message {
max-width: 85%;
padding: 10px 14px;
border-radius: 16px;
font-size: 13px;
line-height: 1.5;
word-wrap: break-word;
}
.ai-message.user {
align-self: flex-end;
background: #3ea6ff;
color: white;
border-bottom-right-radius: 4px;
}
.ai-message.assistant {
align-self: flex-start;
background: var(--yt-bg-secondary, #272727);
color: var(--yt-text-primary, #fff);
border-bottom-left-radius: 4px;
}
.ai-message.system {
align-self: center;
background: transparent;
color: var(--yt-text-secondary, #aaa);
font-style: italic;
font-size: 12px;
}
/* Typing Indicator */
.ai-typing {
display: flex;
gap: 4px;
padding: 10px 14px;
}
.ai-typing span {
width: 8px;
height: 8px;
background: var(--yt-text-secondary, #aaa);
border-radius: 50%;
animation: typing 1.2s infinite;
}
.ai-typing span:nth-child(2) {
animation-delay: 0.2s;
}
.ai-typing span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%,
60%,
100% {
transform: translateY(0);
}
30% {
transform: translateY(-8px);
}
}
/* Input Area */
.ai-chat-input {
display: flex;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--yt-border, #272727);
background: var(--yt-bg-secondary, #181818);
}
.ai-chat-input input {
flex: 1;
background: var(--yt-bg-primary, #0f0f0f);
border: 1px solid var(--yt-border, #272727);
border-radius: 20px;
padding: 10px 16px;
color: var(--yt-text-primary, #fff);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.ai-chat-input input:focus {
border-color: #3ea6ff;
}
.ai-chat-input input::placeholder {
color: var(--yt-text-secondary, #aaa);
}
.ai-chat-send {
background: #3ea6ff;
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, transform 0.2s;
}
.ai-chat-send:hover {
background: #2d8fd9;
transform: scale(1.05);
}
.ai-chat-send:disabled {
background: #555;
cursor: not-allowed;
}
/* Download Progress */
.ai-download-progress {
padding: 16px;
text-align: center;
}
.ai-download-bar {
width: 100%;
height: 6px;
background: var(--yt-bg-secondary, #272727);
border-radius: 3px;
overflow: hidden;
margin-top: 8px;
}
.ai-download-fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 3px;
transition: width 0.3s;
}
.ai-download-text {
font-size: 12px;
color: var(--yt-text-secondary, #aaa);
margin-top: 8px;
}
/* Mobile */
@media (max-width: 768px) {
.ai-chat-bubble {
bottom: 100px;
/* More space above back button */
right: 24px;
/* Aligned with back button */
width: 48px;
height: 48px;
font-size: 18px;
}
.ai-chat-panel {
width: calc(100% - 20px);
left: 10px;
right: 10px;
bottom: 160px;
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 */
/* Core */
@import 'modules/variables.css';
@import 'modules/base.css';
@import 'modules/utils.css';
/* Layout & Structure */
@import 'modules/layout.css';
@import 'modules/grid.css';
/* Components */
@import 'modules/components.css';
@import 'modules/cards.css';
/* Pages */
/* KV-Tube - YouTube Clone Design System */
/* Core */
@import 'modules/variables.css';
@import 'modules/base.css';
@import 'modules/utils.css';
/* Layout & Structure */
@import 'modules/layout.css';
@import 'modules/grid.css';
/* Components */
@import 'modules/components.css';
@import 'modules/cards.css';
/* Pages */
@import 'modules/pages.css';
/* Hide extension-injected error elements */
*[/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
* Handles SPA-style navigation to persist state (like downloads) across pages.
*/
class NavigationManager {
constructor() {
this.mainContentId = 'mainContent';
this.pageCache = new Map();
this.maxCacheSize = 20;
this.init();
}
init() {
// Handle browser back/forward buttons
window.addEventListener('popstate', (e) => {
if (e.state && e.state.url) {
this.loadPage(e.state.url, false);
} else {
// Fallback for initial state or external navigation
this.loadPage(window.location.href, false);
}
});
// Intercept clicks
document.addEventListener('click', (e) => {
// Find closest anchor tag
const link = e.target.closest('a');
// Check if it's an internal link and not a download/special link
if (link &&
link.href &&
link.href.startsWith(window.location.origin) &&
!link.getAttribute('download') &&
!link.getAttribute('target') &&
!link.classList.contains('no-spa') &&
!e.ctrlKey && !e.metaKey && !e.shiftKey // Allow new tab clicks
) {
e.preventDefault();
const url = link.href;
this.navigateTo(url);
// Update active state in sidebar
this.updateSidebarActiveState(link);
}
});
// Save initial state
const currentUrl = window.location.href;
if (!this.pageCache.has(currentUrl)) {
// We don't have the raw HTML, so we can't fully cache the initial page accurately
// without fetching it or serializing current DOM.
// 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.
this.saveCurrentState(currentUrl);
}
}
saveCurrentState(url) {
const mainContent = document.getElementById(this.mainContentId);
if (mainContent) {
this.pageCache.set(url, {
html: mainContent.innerHTML,
title: document.title,
scrollY: window.scrollY,
className: mainContent.className
});
// Prune cache
if (this.pageCache.size > this.maxCacheSize) {
const firstKey = this.pageCache.keys().next().value;
this.pageCache.delete(firstKey);
}
}
}
async navigateTo(url) {
// Start Progress Bar
const bar = document.getElementById('nprogress-bar');
if (bar) {
bar.style.opacity = '1';
bar.style.width = '30%';
}
// Save state of current page before leaving
this.saveCurrentState(window.location.href);
// Update history
history.pushState({ url: url }, '', url);
await this.loadPage(url);
}
async loadPage(url, pushState = true) {
const bar = document.getElementById('nprogress-bar');
if (bar) bar.style.width = '60%';
const mainContent = document.getElementById(this.mainContentId);
if (!mainContent) return;
// Check cache
if (this.pageCache.has(url)) {
const cached = this.pageCache.get(url);
// Restore content
document.title = cached.title;
mainContent.innerHTML = cached.html;
mainContent.className = cached.className;
// Re-execute scripts
this.executeScripts(mainContent);
// Re-initialize App
if (typeof window.initApp === 'function') {
window.initApp();
}
// Restore scroll
window.scrollTo(0, cached.scrollY);
return;
}
// Show loading state if needed
mainContent.style.opacity = '0.5';
try {
const response = await fetch(url);
const html = await response.text();
// Parse HTML
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Extract new content
const newContent = doc.getElementById(this.mainContentId);
if (!newContent) {
// Check if it's a full page not extending layout properly or error
console.error('Could not find mainContent in response');
window.location.href = url; // Fallback to full reload
return;
}
// Update title
document.title = doc.title;
// Replace content
mainContent.innerHTML = newContent.innerHTML;
mainContent.className = newContent.className; // Maintain classes
// Execute scripts found in the new content (critical for APP_CONFIG)
this.executeScripts(mainContent);
// Re-initialize App logic
if (typeof window.initApp === 'function') {
window.initApp();
}
// Scroll to top for new pages
window.scrollTo(0, 0);
// Save to cache (initial state of this page)
this.pageCache.set(url, {
html: newContent.innerHTML,
title: doc.title,
scrollY: 0,
className: newContent.className
});
} catch (error) {
console.error('Navigation error:', error);
// Fallback
window.location.href = url;
} finally {
mainContent.style.opacity = '1';
}
}
executeScripts(element) {
const scripts = element.querySelectorAll('script');
scripts.forEach(oldScript => {
const newScript = document.createElement('script');
Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value));
newScript.textContent = oldScript.textContent;
oldScript.parentNode.replaceChild(newScript, oldScript);
});
}
updateSidebarActiveState(clickedLink) {
// Remove active class from all items
document.querySelectorAll('.yt-sidebar-item').forEach(item => item.classList.remove('active'));
// Add to clicked if it is a sidebar item
if (clickedLink.classList.contains('yt-sidebar-item')) {
clickedLink.classList.add('active');
} else {
// Try to find matching sidebar item
const path = new URL(clickedLink.href).pathname;
const match = document.querySelector(`.yt-sidebar-item[href="${path}"]`);
if (match) match.classList.add('active');
}
}
}
// Initialize
window.navigationManager = new NavigationManager();
/**
* KV-Tube Navigation Manager
* Handles SPA-style navigation to persist state (like downloads) across pages.
*/
class NavigationManager {
constructor() {
this.mainContentId = 'mainContent';
this.pageCache = new Map();
this.maxCacheSize = 20;
this.init();
}
init() {
// Handle browser back/forward buttons
window.addEventListener('popstate', (e) => {
if (e.state && e.state.url) {
this.loadPage(e.state.url, false);
} else {
// Fallback for initial state or external navigation
this.loadPage(window.location.href, false);
}
});
// Intercept clicks
document.addEventListener('click', (e) => {
// Find closest anchor tag
const link = e.target.closest('a');
// Check if it's an internal link and not a download/special link
if (link &&
link.href &&
link.href.startsWith(window.location.origin) &&
!link.getAttribute('download') &&
!link.getAttribute('target') &&
!link.classList.contains('no-spa') &&
!e.ctrlKey && !e.metaKey && !e.shiftKey // Allow new tab clicks
) {
e.preventDefault();
const url = link.href;
this.navigateTo(url);
// Update active state in sidebar
this.updateSidebarActiveState(link);
}
});
// Save initial state
const currentUrl = window.location.href;
if (!this.pageCache.has(currentUrl)) {
// We don't have the raw HTML, so we can't fully cache the initial page accurately
// without fetching it or serializing current DOM.
// 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.
this.saveCurrentState(currentUrl);
}
}
saveCurrentState(url) {
const mainContent = document.getElementById(this.mainContentId);
if (mainContent) {
this.pageCache.set(url, {
html: mainContent.innerHTML,
title: document.title,
scrollY: window.scrollY,
className: mainContent.className
});
// Prune cache
if (this.pageCache.size > this.maxCacheSize) {
const firstKey = this.pageCache.keys().next().value;
this.pageCache.delete(firstKey);
}
}
}
async navigateTo(url) {
// Start Progress Bar
const bar = document.getElementById('nprogress-bar');
if (bar) {
bar.style.opacity = '1';
bar.style.width = '30%';
}
// Save state of current page before leaving
this.saveCurrentState(window.location.href);
// Update history
history.pushState({ url: url }, '', url);
await this.loadPage(url);
}
async loadPage(url, pushState = true) {
const bar = document.getElementById('nprogress-bar');
if (bar) bar.style.width = '60%';
const mainContent = document.getElementById(this.mainContentId);
if (!mainContent) return;
// Check cache
if (this.pageCache.has(url)) {
const cached = this.pageCache.get(url);
// Restore content
document.title = cached.title;
mainContent.innerHTML = cached.html;
mainContent.className = cached.className;
// Re-execute scripts
this.executeScripts(mainContent);
// Re-initialize App
if (typeof window.initApp === 'function') {
window.initApp();
}
// Restore scroll
window.scrollTo(0, cached.scrollY);
return;
}
// Show loading state if needed
mainContent.style.opacity = '0.5';
try {
const response = await fetch(url);
const html = await response.text();
// Parse HTML
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Extract new content
const newContent = doc.getElementById(this.mainContentId);
if (!newContent) {
// Check if it's a full page not extending layout properly or error
console.error('Could not find mainContent in response');
window.location.href = url; // Fallback to full reload
return;
}
// Update title
document.title = doc.title;
// Replace content
mainContent.innerHTML = newContent.innerHTML;
mainContent.className = newContent.className; // Maintain classes
// Execute scripts found in the new content (critical for APP_CONFIG)
this.executeScripts(mainContent);
// Re-initialize App logic
if (typeof window.initApp === 'function') {
window.initApp();
}
// Scroll to top for new pages
window.scrollTo(0, 0);
// Save to cache (initial state of this page)
this.pageCache.set(url, {
html: newContent.innerHTML,
title: doc.title,
scrollY: 0,
className: newContent.className
});
} catch (error) {
console.error('Navigation error:', error);
// Fallback
window.location.href = url;
} finally {
mainContent.style.opacity = '1';
}
}
executeScripts(element) {
const scripts = element.querySelectorAll('script');
scripts.forEach(oldScript => {
const newScript = document.createElement('script');
Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value));
newScript.textContent = oldScript.textContent;
oldScript.parentNode.replaceChild(newScript, oldScript);
});
}
updateSidebarActiveState(clickedLink) {
// Remove active class from all items
document.querySelectorAll('.yt-sidebar-item').forEach(item => item.classList.remove('active'));
// Add to clicked if it is a sidebar item
if (clickedLink.classList.contains('yt-sidebar-item')) {
clickedLink.classList.add('active');
} else {
// Try to find matching sidebar item
const path = new URL(clickedLink.href).pathname;
const match = document.querySelector(`.yt-sidebar-item[href="${path}"]`);
if (match) match.classList.add('active');
}
}
}
// Initialize
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" %}
{% block content %}
<div class="yt-container yt-channel-page">
<!-- Channel Header (No Banner) -->
<div class="yt-channel-header">
<div class="yt-channel-info-row">
<div class="yt-channel-avatar-xl" id="channelAvatarLarge">
{% if channel.avatar %}
<img src="{{ channel.avatar }}">
{% else %}
<span id="channelAvatarLetter">{{ channel.title[0] | upper if channel.title and channel.title !=
'Loading...' else 'C' }}</span>
{% endif %}
</div>
<div class="yt-channel-meta">
<h1 id="channelTitle">{{ channel.title if channel.title and channel.title != 'Loading...' else
'Loading...' }}</h1>
<p class="yt-channel-handle" id="channelHandle">
{% if channel.title and channel.title != 'Loading...' %}@{{ channel.title|replace(' ', '') }}{% else
%}@Loading...{% endif %}
</p>
<div class="yt-channel-stats">
<span id="channelStats"></span>
</div>
</div>
</div>
</div>
<!-- Video Grid -->
<div class="yt-section">
<div class="yt-section-header">
<div class="yt-tabs">
<a href="javascript:void(0)" onclick="changeChannelTab('video', this); return false;"
class="active no-spa">
<i class="fas fa-video"></i>
<span>Videos</span>
</a>
<a href="javascript:void(0)" onclick="changeChannelTab('shorts', this); return false;" class="no-spa">
<i class="fas fa-bolt"></i>
<span>Shorts</span>
</a>
</div>
<div class="yt-sort-options">
<a href="javascript:void(0)" onclick="changeChannelSort('latest', this); return false;"
class="active no-spa">
<i class="fas fa-clock"></i>
<span>Latest</span>
</a>
<a href="javascript:void(0)" onclick="changeChannelSort('popular', this); return false;" class="no-spa">
<i class="fas fa-fire"></i>
<span>Popular</span>
</a>
<a href="javascript:void(0)" onclick="changeChannelSort('oldest', this); return false;" class="no-spa">
<i class="fas fa-history"></i>
<span>Oldest</span>
</a>
</div>
</div>
<div class="yt-video-grid" id="channelVideosGrid">
<!-- Videos loaded via JS -->
</div>
<div id="channelLoadingTrigger" style="height: 20px; margin: 20px 0;"></div>
</div>
</div>
<style>
.yt-channel-page {
padding-top: 40px;
padding-bottom: 40px;
max-width: 1200px;
margin: 0 auto;
}
/* Removed .yt-channel-banner */
.yt-channel-info-row {
display: flex;
align-items: flex-start;
gap: 32px;
margin-bottom: 32px;
padding: 0 16px;
}
.yt-channel-avatar-xl {
width: 160px;
height: 160px;
border-radius: 50%;
overflow: hidden;
background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%);
/* Simpler color for no-banner look */
display: flex;
align-items: center;
justify-content: center;
font-size: 64px;
font-weight: bold;
color: white;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.yt-channel-avatar-xl img {
width: 100%;
height: 100%;
object-fit: cover;
}
.yt-channel-meta {
padding-top: 12px;
}
.yt-channel-meta h1 {
font-size: 32px;
margin-bottom: 8px;
font-weight: 700;
}
.yt-channel-handle {
color: var(--yt-text-secondary);
font-size: 16px;
margin-bottom: 12px;
}
.yt-channel-stats {
color: var(--yt-text-secondary);
font-size: 14px;
margin-bottom: 24px;
}
.yt-section-header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
margin-bottom: 24px;
padding: 0 16px;
border-bottom: none;
}
.yt-tabs {
display: inline-flex;
gap: 8px;
background: var(--yt-bg-secondary);
padding: 6px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.yt-tabs a {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
color: var(--yt-text-secondary);
text-decoration: none;
transition: all 0.25s ease;
border: none;
background: transparent;
}
.yt-tabs a:hover {
color: var(--yt-text-primary);
background: var(--yt-bg-hover);
}
.yt-tabs a.active {
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
color: white;
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
}
.yt-sort-options {
display: inline-flex;
gap: 8px;
background: var(--yt-bg-secondary);
padding: 6px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.yt-sort-options a {
padding: 10px 20px;
border-radius: 12px;
background: transparent;
border: none;
color: var(--yt-text-secondary);
text-decoration: none;
font-size: 0.9rem;
font-weight: 600;
transition: all 0.25s ease;
}
.yt-sort-options a:hover {
background: var(--yt-bg-hover);
color: var(--yt-text-primary);
}
.yt-sort-options a.active {
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
color: white;
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
}
/* Shorts Card Styling override for Channel Page grid */
.yt-channel-short-card {
border-radius: var(--yt-radius-lg);
overflow: hidden;
cursor: pointer;
transition: transform 0.2s;
}
.yt-channel-short-card:hover {
transform: scale(1.02);
}
.yt-short-thumb-container {
aspect-ratio: 9/16;
width: 100%;
position: relative;
}
.yt-short-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (max-width: 768px) {
.yt-channel-info-row {
flex-direction: column;
align-items: center;
text-align: center;
gap: 16px;
}
.yt-channel-avatar-xl {
width: 100px;
height: 100px;
font-size: 40px;
}
.yt-channel-meta h1 {
font-size: 24px;
}
.yt-section-header {
flex-direction: column;
gap: 16px;
align-items: flex-start;
}
.yt-tabs {
width: 100%;
justify-content: center;
}
}
</style>
<script>
(function () {
// IIFE to prevent variable redeclaration errors on SPA navigation
console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}");
var currentChannelSort = 'latest';
var currentChannelPage = 1;
var isChannelLoading = false;
var hasMoreChannelVideos = true;
var currentFilterType = 'video';
var channelId = "{{ channel.id }}";
// Store initial channel title from server template (don't overwrite with empty API data)
var initialChannelTitle = "{{ channel.title }}";
function init() {
console.log("Channel init called, fetching content...");
fetchChannelContent();
setupInfiniteScroll();
}
// Handle both initial page load and SPA navigation
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
// DOM is already ready (SPA navigation)
init();
}
function changeChannelTab(type, btn) {
if (type === currentFilterType || isChannelLoading) return;
currentFilterType = type;
currentChannelPage = 1;
hasMoreChannelVideos = true;
document.getElementById('channelVideosGrid').innerHTML = '';
// Update Tabs UI
document.querySelectorAll('.yt-tabs a').forEach(a => a.classList.remove('active'));
btn.classList.add('active');
// Adjust Grid layout for Shorts vs Videos
const grid = document.getElementById('channelVideosGrid');
if (type === 'shorts') {
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
} else {
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))';
}
fetchChannelContent();
}
function changeChannelSort(sort, btn) {
if (isChannelLoading) return;
currentChannelSort = sort;
currentChannelPage = 1;
hasMoreChannelVideos = true;
document.getElementById('channelVideosGrid').innerHTML = ''; // Clear
// Update tabs
document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active'));
btn.classList.add('active');
fetchChannelContent();
}
async function fetchChannelContent() {
console.log("fetchChannelContent() called");
if (isChannelLoading || !hasMoreChannelVideos) {
console.log("Early return:", { isChannelLoading, hasMoreChannelVideos });
return;
}
isChannelLoading = true;
const grid = document.getElementById('channelVideosGrid');
// Append Loading indicator
if (typeof renderSkeleton === 'function') {
grid.insertAdjacentHTML('beforeend', renderSkeleton(4));
} else {
grid.insertAdjacentHTML('beforeend', '<div class="loading-text" style="color:var(--yt-text-secondary); padding: 20px;">Loading videos...</div>');
}
try {
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 videos = await response.json();
console.log("Channel Videos Response:", videos);
// Remove skeletons (simple way: remove last N children or just clear all if page 1?
// Better: mark skeletons with class and remove)
// 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());
// Check if response is an error
if (videos.error) {
hasMoreChannelVideos = false;
grid.innerHTML = `<p style="padding:20px; color:var(--yt-text-secondary);">Error: ${videos.error}</p>`;
return;
}
if (!Array.isArray(videos) || videos.length === 0) {
hasMoreChannelVideos = false;
if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>';
} else {
// Update channel header with uploader info from first video (on first page only)
if (currentChannelPage === 1 && videos[0]) {
// Use only proper channel/uploader fields - do NOT parse from title
let channelName = videos[0].channel || videos[0].uploader || '';
// Only update header if API returned a meaningful name
// (not empty, not just the channel ID, and not "Loading...")
if (channelName && channelName !== channelId &&
!channelName.startsWith('UC') && channelName !== 'Loading...') {
document.getElementById('channelTitle').textContent = channelName;
document.getElementById('channelHandle').textContent = '@' + channelName.replace(/\s+/g, '');
const avatarLetter = document.getElementById('channelAvatarLetter');
if (avatarLetter) avatarLetter.textContent = channelName.charAt(0).toUpperCase();
}
// If no meaningful name from API, keep the initial template-rendered title
}
videos.forEach(video => {
const card = document.createElement('div');
if (currentFilterType === 'shorts') {
// Render Vertical Short Card
card.className = 'yt-channel-short-card';
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
card.innerHTML = `
<div class="yt-short-thumb-container">
<img src="${video.thumbnail}" class="yt-short-thumb loaded" loading="lazy" onload="this.classList.add('loaded')">
</div>
<div class="yt-details" style="padding: 8px;">
<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>
</div>
`;
} else {
// Render Standard Video Card (Match Home)
card.className = 'yt-video-card';
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
card.innerHTML = `
<div class="yt-thumbnail-container">
<img class="yt-thumbnail loaded" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')">
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
</div>
<div class="yt-video-details">
<div class="yt-video-meta">
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
<p class="yt-video-stats">
${formatViews(video.view_count)} views • ${formatDate(video.upload_date)}
</p>
</div>
</div>
`;
}
grid.appendChild(card);
});
currentChannelPage++;
}
} catch (e) {
console.error(e);
} finally {
isChannelLoading = false;
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
}
}
function setupInfiniteScroll() {
const trigger = document.getElementById('channelLoadingTrigger');
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
fetchChannelContent();
}
}, { threshold: 0.1 });
observer.observe(trigger);
}
// Helpers - Define locally to ensure availability
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatViews(views) {
if (!views) return '0';
const num = parseInt(views);
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
return num.toLocaleString();
}
function formatDate(dateStr) {
if (!dateStr) return 'Recently';
try {
// Format: YYYYMMDD
const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8);
const date = new Date(year, month - 1, day);
const now = new Date();
const diff = now - date;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days < 1) return 'Today';
if (days < 7) return `${days} days ago`;
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
if (days < 365) return `${Math.floor(days / 30)} months ago`;
return `${Math.floor(days / 365)} years ago`;
} catch (e) {
return 'Recently';
}
}
// Expose functions globally for onclick handlers
window.changeChannelTab = changeChannelTab;
window.changeChannelSort = changeChannelSort;
})();
</script>
{% extends "layout.html" %}
{% block content %}
<div class="yt-container yt-channel-page">
<!-- Channel Header (No Banner) -->
<div class="yt-channel-header">
<div class="yt-channel-info-row">
<div class="yt-channel-avatar-xl" id="channelAvatarLarge">
{% if channel.avatar %}
<img src="{{ channel.avatar }}">
{% else %}
<span id="channelAvatarLetter">{{ channel.title[0] | upper if channel.title and channel.title !=
'Loading...' else 'C' }}</span>
{% endif %}
</div>
<div class="yt-channel-meta">
<h1 id="channelTitle">{{ channel.title if channel.title and channel.title != 'Loading...' else
'Loading...' }}</h1>
<p class="yt-channel-handle" id="channelHandle">
{% if channel.title and channel.title != 'Loading...' %}@{{ channel.title|replace(' ', '') }}{% else
%}@Loading...{% endif %}
</p>
<div class="yt-channel-stats">
<span id="channelStats"></span>
</div>
</div>
</div>
</div>
<!-- Video Grid -->
<div class="yt-section">
<div class="yt-section-header">
<div class="yt-tabs">
<a href="javascript:void(0)" onclick="changeChannelTab('video', this); return false;"
class="active no-spa">
<i class="fas fa-video"></i>
<span>Videos</span>
</a>
<a href="javascript:void(0)" onclick="changeChannelTab('shorts', this); return false;" class="no-spa">
<i class="fas fa-bolt"></i>
<span>Shorts</span>
</a>
</div>
<div class="yt-sort-options">
<a href="javascript:void(0)" onclick="changeChannelSort('latest', this); return false;"
class="active no-spa">
<i class="fas fa-clock"></i>
<span>Latest</span>
</a>
<a href="javascript:void(0)" onclick="changeChannelSort('popular', this); return false;" class="no-spa">
<i class="fas fa-fire"></i>
<span>Popular</span>
</a>
<a href="javascript:void(0)" onclick="changeChannelSort('oldest', this); return false;" class="no-spa">
<i class="fas fa-history"></i>
<span>Oldest</span>
</a>
</div>
</div>
<div class="yt-video-grid" id="channelVideosGrid">
<!-- Videos loaded via JS -->
</div>
<div id="channelLoadingTrigger" style="height: 20px; margin: 20px 0;"></div>
</div>
</div>
<style>
.yt-channel-page {
padding-top: 40px;
padding-bottom: 40px;
max-width: 1200px;
margin: 0 auto;
}
/* Removed .yt-channel-banner */
.yt-channel-info-row {
display: flex;
align-items: flex-start;
gap: 32px;
margin-bottom: 32px;
padding: 0 16px;
}
.yt-channel-avatar-xl {
width: 160px;
height: 160px;
border-radius: 50%;
overflow: hidden;
background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%);
/* Simpler color for no-banner look */
display: flex;
align-items: center;
justify-content: center;
font-size: 64px;
font-weight: bold;
color: white;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.yt-channel-avatar-xl img {
width: 100%;
height: 100%;
object-fit: cover;
}
.yt-channel-meta {
padding-top: 12px;
}
.yt-channel-meta h1 {
font-size: 32px;
margin-bottom: 8px;
font-weight: 700;
}
.yt-channel-handle {
color: var(--yt-text-secondary);
font-size: 16px;
margin-bottom: 12px;
}
.yt-channel-stats {
color: var(--yt-text-secondary);
font-size: 14px;
margin-bottom: 24px;
}
.yt-section-header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
margin-bottom: 24px;
padding: 0 16px;
border-bottom: none;
}
.yt-tabs {
display: inline-flex;
gap: 8px;
background: var(--yt-bg-secondary);
padding: 6px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.yt-tabs a {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
color: var(--yt-text-secondary);
text-decoration: none;
transition: all 0.25s ease;
border: none;
background: transparent;
}
.yt-tabs a:hover {
color: var(--yt-text-primary);
background: var(--yt-bg-hover);
}
.yt-tabs a.active {
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
color: white;
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
}
.yt-sort-options {
display: inline-flex;
gap: 8px;
background: var(--yt-bg-secondary);
padding: 6px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.yt-sort-options a {
padding: 10px 20px;
border-radius: 12px;
background: transparent;
border: none;
color: var(--yt-text-secondary);
text-decoration: none;
font-size: 0.9rem;
font-weight: 600;
transition: all 0.25s ease;
}
.yt-sort-options a:hover {
background: var(--yt-bg-hover);
color: var(--yt-text-primary);
}
.yt-sort-options a.active {
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
color: white;
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
}
/* Shorts Card Styling override for Channel Page grid */
.yt-channel-short-card {
border-radius: var(--yt-radius-lg);
overflow: hidden;
cursor: pointer;
transition: transform 0.2s;
}
.yt-channel-short-card:hover {
transform: scale(1.02);
}
.yt-short-thumb-container {
aspect-ratio: 9/16;
width: 100%;
position: relative;
}
.yt-short-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (max-width: 768px) {
.yt-channel-info-row {
flex-direction: column;
align-items: center;
text-align: center;
gap: 16px;
}
.yt-channel-avatar-xl {
width: 100px;
height: 100px;
font-size: 40px;
}
.yt-channel-meta h1 {
font-size: 24px;
}
.yt-section-header {
flex-direction: column;
gap: 16px;
align-items: flex-start;
}
.yt-tabs {
width: 100%;
justify-content: center;
}
}
</style>
<script>
(function () {
// IIFE to prevent variable redeclaration errors on SPA navigation
console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}");
var currentChannelSort = 'latest';
var currentChannelPage = 1;
var isChannelLoading = false;
var hasMoreChannelVideos = true;
var currentFilterType = 'video';
var channelId = "{{ channel.id }}";
// Store initial channel title from server template (don't overwrite with empty API data)
var initialChannelTitle = "{{ channel.title }}";
function init() {
console.log("Channel init called, fetching content...");
fetchChannelContent();
setupInfiniteScroll();
}
// Handle both initial page load and SPA navigation
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
// DOM is already ready (SPA navigation)
init();
}
function changeChannelTab(type, btn) {
if (type === currentFilterType || isChannelLoading) return;
currentFilterType = type;
currentChannelPage = 1;
hasMoreChannelVideos = true;
document.getElementById('channelVideosGrid').innerHTML = '';
// Update Tabs UI
document.querySelectorAll('.yt-tabs a').forEach(a => a.classList.remove('active'));
btn.classList.add('active');
// Adjust Grid layout for Shorts vs Videos
const grid = document.getElementById('channelVideosGrid');
if (type === 'shorts') {
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
} else {
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))';
}
fetchChannelContent();
}
function changeChannelSort(sort, btn) {
if (isChannelLoading) return;
currentChannelSort = sort;
currentChannelPage = 1;
hasMoreChannelVideos = true;
document.getElementById('channelVideosGrid').innerHTML = ''; // Clear
// Update tabs
document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active'));
btn.classList.add('active');
fetchChannelContent();
}
async function fetchChannelContent() {
console.log("fetchChannelContent() called");
if (isChannelLoading || !hasMoreChannelVideos) {
console.log("Early return:", { isChannelLoading, hasMoreChannelVideos });
return;
}
isChannelLoading = true;
const grid = document.getElementById('channelVideosGrid');
// Append Loading indicator
if (typeof renderSkeleton === 'function') {
grid.insertAdjacentHTML('beforeend', renderSkeleton(4));
} else {
grid.insertAdjacentHTML('beforeend', '<div class="loading-text" style="color:var(--yt-text-secondary); padding: 20px;">Loading videos...</div>');
}
try {
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 videos = await response.json();
console.log("Channel Videos Response:", videos);
// Remove skeletons (simple way: remove last N children or just clear all if page 1?
// Better: mark skeletons with class and remove)
// 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());
// Check if response is an error
if (videos.error) {
hasMoreChannelVideos = false;
grid.innerHTML = `<p style="padding:20px; color:var(--yt-text-secondary);">Error: ${videos.error}</p>`;
return;
}
if (!Array.isArray(videos) || videos.length === 0) {
hasMoreChannelVideos = false;
if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>';
} else {
// Update channel header with uploader info from first video (on first page only)
if (currentChannelPage === 1 && videos[0]) {
// Use only proper channel/uploader fields - do NOT parse from title
let channelName = videos[0].channel || videos[0].uploader || '';
// Only update header if API returned a meaningful name
// (not empty, not just the channel ID, and not "Loading...")
if (channelName && channelName !== channelId &&
!channelName.startsWith('UC') && channelName !== 'Loading...') {
document.getElementById('channelTitle').textContent = channelName;
document.getElementById('channelHandle').textContent = '@' + channelName.replace(/\s+/g, '');
const avatarLetter = document.getElementById('channelAvatarLetter');
if (avatarLetter) avatarLetter.textContent = channelName.charAt(0).toUpperCase();
}
// If no meaningful name from API, keep the initial template-rendered title
}
videos.forEach(video => {
const card = document.createElement('div');
if (currentFilterType === 'shorts') {
// Render Vertical Short Card
card.className = 'yt-channel-short-card';
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
card.innerHTML = `
<div class="yt-short-thumb-container">
<img src="${video.thumbnail}" class="yt-short-thumb loaded" loading="lazy" onload="this.classList.add('loaded')">
</div>
<div class="yt-details" style="padding: 8px;">
<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>
</div>
`;
} else {
// Render Standard Video Card (Match Home)
card.className = 'yt-video-card';
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
card.innerHTML = `
<div class="yt-thumbnail-container">
<img class="yt-thumbnail loaded" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')">
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
</div>
<div class="yt-video-details">
<div class="yt-video-meta">
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
<p class="yt-video-stats">
${formatViews(video.view_count)} views • ${formatDate(video.upload_date)}
</p>
</div>
</div>
`;
}
grid.appendChild(card);
});
currentChannelPage++;
}
} catch (e) {
console.error(e);
} finally {
isChannelLoading = false;
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
}
}
function setupInfiniteScroll() {
const trigger = document.getElementById('channelLoadingTrigger');
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
fetchChannelContent();
}
}, { threshold: 0.1 });
observer.observe(trigger);
}
// Helpers - Define locally to ensure availability
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatViews(views) {
if (!views) return '0';
const num = parseInt(views);
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
return num.toLocaleString();
}
function formatDate(dateStr) {
if (!dateStr) return 'Recently';
try {
// Format: YYYYMMDD
const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8);
const date = new Date(year, month - 1, day);
const now = new Date();
const diff = now - date;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days < 1) return 'Today';
if (days < 7) return `${days} days ago`;
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
if (days < 365) return `${Math.floor(days / 30)} months ago`;
return `${Math.floor(days / 365)} years ago`;
} catch (e) {
return 'Recently';
}
}
// Expose functions globally for onclick handlers
window.changeChannelTab = changeChannelTab;
window.changeChannelSort = changeChannelSort;
})();
</script>
{% endblock %}

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

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

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

@ -1,217 +1,217 @@
{% extends "layout.html" %}
{% block content %}
<script>
window.APP_CONFIG = {
page: '{{ page|default("home") }}',
channelId: '{{ channel_id|default("") }}',
query: '{{ query|default("") }}'
};
</script>
<!-- Filters & Categories -->
<div class="yt-filter-bar">
<div class="yt-categories" id="categoryList">
<!-- Pinned Categories -->
<button class="yt-chip" onclick="switchCategory('suggested', this)"><i class="fas fa-magic"></i>
Suggested</button>
<!-- Standard Categories -->
<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('movies', this)">Movies</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('podcasts', this)">Podcasts</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('sports', this)">Sports</button>
</div>
<div class="yt-filter-actions">
<div class="yt-dropdown">
<button class="yt-icon-btn" id="filterToggleBtn" onclick="toggleFilterMenu()">
<i class="fas fa-sliders-h"></i>
</button>
<div class="yt-dropdown-menu" id="filterMenu">
<div class="yt-menu-section">
<h4>Sort By</h4>
<button onclick="changeSort('day')">Today</button>
<button onclick="changeSort('week')">This Week</button>
<button onclick="changeSort('month')">This Month</button>
<button onclick="changeSort('3months')">Last 3 Months</button>
<button onclick="changeSort('year')">This Year</button>
</div>
<div class="yt-menu-section">
<h4>Region</h4>
<button onclick="changeRegion('vietnam')">Vietnam</button>
<button onclick="changeRegion('global')">Global</button>
</div>
</div>
</div>
</div>
</div>
<!-- Shorts Section -->
<!-- Videos Section -->
<div id="videosSection" class="yt-section">
<div class="yt-section-header" style="display:none;">
<h2><i class="fas fa-play-circle"></i> Videos</h2>
</div>
<div id="resultsArea" class="yt-video-grid">
<!-- Initial Skeleton State -->
<!-- Initial Skeleton State (12 items) -->
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Styles moved to CSS modules -->
<script>
// Global filter state
// Global filter state
var currentSort = 'month';
var currentRegion = 'vietnam';
function toggleFilterMenu() {
const menu = document.getElementById('filterMenu');
if (menu) menu.classList.toggle('show');
}
// Close menu when clicking outside - Prevent multiple listeners
if (!window.filterMenuListenerAttached) {
document.addEventListener('click', function (e) {
const menu = document.getElementById('filterMenu');
const btn = document.getElementById('filterToggleBtn');
// Only run if elements exist (we are on home page)
if (menu && btn && !menu.contains(e.target) && !btn.contains(e.target)) {
menu.classList.remove('show');
}
});
window.filterMenuListenerAttached = true;
}
function changeSort(sort) {
window.currentSort = sort;
// Global loadTrending from main.js will use this
loadTrending(true);
toggleFilterMenu();
}
function changeRegion(region) {
window.currentRegion = region;
loadTrending(true);
toggleFilterMenu();
}
// Helpers (if main.js not loaded yet or for standalone usage)
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatViews(views) {
if (!views) return '0';
const num = parseInt(views);
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
return num.toLocaleString();
}
// Init Logic
document.addEventListener('DOMContentLoaded', () => {
// Pagination logic removed for infinite scroll
// Check URL params for category
const urlParams = new URLSearchParams(window.location.search);
const category = urlParams.get('category');
if (category && typeof switchCategory === 'function') {
// Let main.js handle the switch, but we can set UI active state if needed
// switchCategory is in main.js
switchCategory(category);
}
});
</script>
{% extends "layout.html" %}
{% block content %}
<script>
window.APP_CONFIG = {
page: '{{ page|default("home") }}',
channelId: '{{ channel_id|default("") }}',
query: '{{ query|default("") }}'
};
</script>
<!-- Filters & Categories -->
<div class="yt-filter-bar">
<div class="yt-categories" id="categoryList">
<!-- Pinned Categories -->
<button class="yt-chip" onclick="switchCategory('suggested', this)"><i class="fas fa-magic"></i>
Suggested</button>
<!-- Standard Categories -->
<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('movies', this)">Movies</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('podcasts', this)">Podcasts</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('sports', this)">Sports</button>
</div>
<div class="yt-filter-actions">
<div class="yt-dropdown">
<button class="yt-icon-btn" id="filterToggleBtn" onclick="toggleFilterMenu()">
<i class="fas fa-sliders-h"></i>
</button>
<div class="yt-dropdown-menu" id="filterMenu">
<div class="yt-menu-section">
<h4>Sort By</h4>
<button onclick="changeSort('day')">Today</button>
<button onclick="changeSort('week')">This Week</button>
<button onclick="changeSort('month')">This Month</button>
<button onclick="changeSort('3months')">Last 3 Months</button>
<button onclick="changeSort('year')">This Year</button>
</div>
<div class="yt-menu-section">
<h4>Region</h4>
<button onclick="changeRegion('vietnam')">Vietnam</button>
<button onclick="changeRegion('global')">Global</button>
</div>
</div>
</div>
</div>
</div>
<!-- Shorts Section -->
<!-- Videos Section -->
<div id="videosSection" class="yt-section">
<div class="yt-section-header" style="display:none;">
<h2><i class="fas fa-play-circle"></i> Videos</h2>
</div>
<div id="resultsArea" class="yt-video-grid">
<!-- Initial Skeleton State -->
<!-- Initial Skeleton State (12 items) -->
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
<div class="yt-video-card skeleton-card">
<div class="skeleton-thumb skeleton"></div>
<div class="skeleton-details">
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-text">
<div class="skeleton-title skeleton"></div>
<div class="skeleton-meta skeleton"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Styles moved to CSS modules -->
<script>
// Global filter state
// Global filter state
var currentSort = 'month';
var currentRegion = 'vietnam';
function toggleFilterMenu() {
const menu = document.getElementById('filterMenu');
if (menu) menu.classList.toggle('show');
}
// Close menu when clicking outside - Prevent multiple listeners
if (!window.filterMenuListenerAttached) {
document.addEventListener('click', function (e) {
const menu = document.getElementById('filterMenu');
const btn = document.getElementById('filterToggleBtn');
// Only run if elements exist (we are on home page)
if (menu && btn && !menu.contains(e.target) && !btn.contains(e.target)) {
menu.classList.remove('show');
}
});
window.filterMenuListenerAttached = true;
}
function changeSort(sort) {
window.currentSort = sort;
// Global loadTrending from main.js will use this
loadTrending(true);
toggleFilterMenu();
}
function changeRegion(region) {
window.currentRegion = region;
loadTrending(true);
toggleFilterMenu();
}
// Helpers (if main.js not loaded yet or for standalone usage)
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatViews(views) {
if (!views) return '0';
const num = parseInt(views);
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
return num.toLocaleString();
}
// Init Logic
document.addEventListener('DOMContentLoaded', () => {
// Pagination logic removed for infinite scroll
// Check URL params for category
const urlParams = new URLSearchParams(window.location.search);
const category = urlParams.get('category');
if (category && typeof switchCategory === 'function') {
// Let main.js handle the switch, but we can set UI active state if needed
// switchCategory is in main.js
switchCategory(category);
}
});
</script>
{% 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" %}
{% block content %}
<div class="yt-auth-container">
<div class="yt-auth-card">
<!-- Logo -->
<div class="yt-auth-logo">
<div class="yt-logo-icon" style="font-size: 24px;">KV-Tube</div>
</div>
<h2>Sign in</h2>
<p>to continue to KV-Tube</p>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="yt-auth-alert">
<i class="fas fa-exclamation-circle"></i>
{{ messages[0] }}
</div>
{% endif %}
{% endwith %}
<form method="POST" action="/login" class="yt-auth-form">
<div class="yt-form-group">
<input type="text" name="username" class="yt-form-input" placeholder=" " required id="username">
<label for="username" class="yt-form-label">Username</label>
</div>
<div class="yt-form-group">
<input type="password" name="password" class="yt-form-input" placeholder=" " required id="password">
<label for="password" class="yt-form-label">Password</label>
</div>
<button type="submit" class="yt-auth-submit">
<i class="fas fa-sign-in-alt"></i>
Sign In
</button>
</form>
<div class="yt-auth-divider">
<span>or</span>
</div>
<p class="yt-auth-footer">
New to KV-Tube?
<a href="/register" class="yt-auth-link">Create an account</a>
</p>
</div>
</div>
<style>
.yt-auth-container {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - var(--yt-header-height) - 100px);
padding: 24px;
}
.yt-auth-card {
background: var(--yt-bg-secondary);
border-radius: 16px;
padding: 48px 40px;
width: 100%;
max-width: 420px;
text-align: center;
border: 1px solid var(--yt-border);
}
.yt-auth-logo {
margin-bottom: 24px;
}
.yt-auth-card h2 {
font-size: 24px;
font-weight: 500;
margin-bottom: 8px;
color: var(--yt-text-primary);
}
.yt-auth-card>p {
color: var(--yt-text-secondary);
font-size: 14px;
margin-bottom: 32px;
}
.yt-auth-alert {
background: rgba(244, 67, 54, 0.1);
color: #f44336;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 24px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
text-align: left;
}
.yt-auth-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.yt-form-group {
position: relative;
text-align: left;
}
.yt-form-input {
width: 100%;
padding: 16px 14px;
font-size: 16px;
color: var(--yt-text-primary);
background: var(--yt-bg-primary);
border: 1px solid var(--yt-border);
border-radius: 8px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.yt-form-input:focus {
border-color: var(--yt-accent-blue);
box-shadow: 0 0 0 3px rgba(62, 166, 255, 0.2);
}
.yt-form-label {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 16px;
color: var(--yt-text-secondary);
pointer-events: none;
transition: all 0.2s;
background: var(--yt-bg-primary);
padding: 0 4px;
}
.yt-form-input:focus+.yt-form-label,
.yt-form-input:not(:placeholder-shown)+.yt-form-label {
top: 0;
font-size: 12px;
color: var(--yt-accent-blue);
}
.yt-auth-submit {
background: var(--yt-accent-blue);
color: white;
padding: 14px 24px;
border-radius: 24px;
font-size: 15px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
transition: background 0.2s, transform 0.1s;
}
.yt-auth-submit:hover {
background: #258fd9;
transform: scale(1.02);
}
.yt-auth-submit:active {
transform: scale(0.98);
}
.yt-auth-divider {
display: flex;
align-items: center;
margin: 24px 0;
}
.yt-auth-divider::before,
.yt-auth-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--yt-border);
}
.yt-auth-divider span {
padding: 0 16px;
color: var(--yt-text-secondary);
font-size: 13px;
}
.yt-auth-footer {
color: var(--yt-text-secondary);
font-size: 14px;
}
.yt-auth-link {
color: var(--yt-accent-blue);
font-weight: 500;
}
.yt-auth-link:hover {
text-decoration: underline;
}
@media (max-width: 480px) {
.yt-auth-card {
padding: 32px 24px;
}
}
</style>
{% extends "layout.html" %}
{% block content %}
<div class="yt-auth-container">
<div class="yt-auth-card">
<!-- Logo -->
<div class="yt-auth-logo">
<div class="yt-logo-icon" style="font-size: 24px;">KV-Tube</div>
</div>
<h2>Sign in</h2>
<p>to continue to KV-Tube</p>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="yt-auth-alert">
<i class="fas fa-exclamation-circle"></i>
{{ messages[0] }}
</div>
{% endif %}
{% endwith %}
<form method="POST" action="/login" class="yt-auth-form">
<div class="yt-form-group">
<input type="text" name="username" class="yt-form-input" placeholder=" " required id="username">
<label for="username" class="yt-form-label">Username</label>
</div>
<div class="yt-form-group">
<input type="password" name="password" class="yt-form-input" placeholder=" " required id="password">
<label for="password" class="yt-form-label">Password</label>
</div>
<button type="submit" class="yt-auth-submit">
<i class="fas fa-sign-in-alt"></i>
Sign In
</button>
</form>
<div class="yt-auth-divider">
<span>or</span>
</div>
<p class="yt-auth-footer">
New to KV-Tube?
<a href="/register" class="yt-auth-link">Create an account</a>
</p>
</div>
</div>
<style>
.yt-auth-container {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - var(--yt-header-height) - 100px);
padding: 24px;
}
.yt-auth-card {
background: var(--yt-bg-secondary);
border-radius: 16px;
padding: 48px 40px;
width: 100%;
max-width: 420px;
text-align: center;
border: 1px solid var(--yt-border);
}
.yt-auth-logo {
margin-bottom: 24px;
}
.yt-auth-card h2 {
font-size: 24px;
font-weight: 500;
margin-bottom: 8px;
color: var(--yt-text-primary);
}
.yt-auth-card>p {
color: var(--yt-text-secondary);
font-size: 14px;
margin-bottom: 32px;
}
.yt-auth-alert {
background: rgba(244, 67, 54, 0.1);
color: #f44336;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 24px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
text-align: left;
}
.yt-auth-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.yt-form-group {
position: relative;
text-align: left;
}
.yt-form-input {
width: 100%;
padding: 16px 14px;
font-size: 16px;
color: var(--yt-text-primary);
background: var(--yt-bg-primary);
border: 1px solid var(--yt-border);
border-radius: 8px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.yt-form-input:focus {
border-color: var(--yt-accent-blue);
box-shadow: 0 0 0 3px rgba(62, 166, 255, 0.2);
}
.yt-form-label {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 16px;
color: var(--yt-text-secondary);
pointer-events: none;
transition: all 0.2s;
background: var(--yt-bg-primary);
padding: 0 4px;
}
.yt-form-input:focus+.yt-form-label,
.yt-form-input:not(:placeholder-shown)+.yt-form-label {
top: 0;
font-size: 12px;
color: var(--yt-accent-blue);
}
.yt-auth-submit {
background: var(--yt-accent-blue);
color: white;
padding: 14px 24px;
border-radius: 24px;
font-size: 15px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
transition: background 0.2s, transform 0.1s;
}
.yt-auth-submit:hover {
background: #258fd9;
transform: scale(1.02);
}
.yt-auth-submit:active {
transform: scale(0.98);
}
.yt-auth-divider {
display: flex;
align-items: center;
margin: 24px 0;
}
.yt-auth-divider::before,
.yt-auth-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--yt-border);
}
.yt-auth-divider span {
padding: 0 16px;
color: var(--yt-text-secondary);
font-size: 13px;
}
.yt-auth-footer {
color: var(--yt-text-secondary);
font-size: 14px;
}
.yt-auth-link {
color: var(--yt-accent-blue);
font-weight: 500;
}
.yt-auth-link:hover {
text-decoration: underline;
}
@media (max-width: 480px) {
.yt-auth-card {
padding: 32px 24px;
}
}
</style>
{% endblock %}

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

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

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

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

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

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