Compare commits
No commits in common. "aa1a419c35927b81f89cf0a4fb7edb435d27cfe3" and "f429116ed099738264c3e6377db5b5429c103412" have entirely different histories.
aa1a419c35
...
f429116ed0
2618 changed files with 11958 additions and 626827 deletions
13
.dockerignore
Executable file
13
.dockerignore
Executable 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
12
.env.example
Executable 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
1
.gemini/tmp/ytfetcher
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 246c4c349d97205eb2b51d7d3999ea846f5b2bdc
|
||||
68
.github/workflows/docker-publish.yml
vendored
Executable file
68
.github/workflows/docker-publish.yml
vendored
Executable 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
12
.gitignore
vendored
Executable 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
0
API_DOCUMENTATION.md
Normal file → Executable file
66
Dockerfile
Normal file → Executable file
66
Dockerfile
Normal file → Executable 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
124
README.md
Normal file → Executable 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
|
||||
|
||||

|
||||
|
||||
## 🔧 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
|
||||
|
||||

|
||||
|
||||
## 🔧 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
0
USER_GUIDE.md
Normal file → Executable file
Binary file not shown.
324
app/__init__.py
Normal file → Executable file
324
app/__init__.py
Normal file → Executable 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")
|
||||
|
|
|
|||
Binary file not shown.
18
app/routes/__init__.py
Normal file → Executable file
18
app/routes/__init__.py
Normal file → Executable 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']
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
158
app/routes/api.py
Normal file → Executable file
158
app/routes/api.py
Normal file → Executable 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
0
app/routes/pages.py
Normal file → Executable file
0
app/routes/streaming.py
Normal file → Executable file
0
app/routes/streaming.py
Normal file → Executable file
2
app/services/__init__.py
Normal file → Executable file
2
app/services/__init__.py
Normal file → Executable file
|
|
@ -1 +1 @@
|
|||
"""KV-Tube Services Package"""
|
||||
"""KV-Tube Services Package"""
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
434
app/services/cache.py
Normal file → Executable file
434
app/services/cache.py
Normal file → Executable 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
270
app/services/gemini_summarizer.py
Normal file → Executable 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
0
app/services/loader_to.py
Normal file → Executable file
0
app/services/settings.py
Normal file → Executable file
0
app/services/settings.py
Normal file → Executable file
238
app/services/summarizer.py
Normal file → Executable file
238
app/services/summarizer.py
Normal file → Executable 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)
|
||||
|
|
|
|||
211
app/services/transcript_service.py
Executable file
211
app/services/transcript_service.py
Executable 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
626
app/services/youtube.py
Normal file → Executable 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
2
app/utils/__init__.py
Normal file → Executable file
|
|
@ -1 +1 @@
|
|||
"""KV-Tube Utilities Package"""
|
||||
"""KV-Tube Utilities Package"""
|
||||
|
|
|
|||
190
app/utils/formatters.py
Normal file → Executable file
190
app/utils/formatters.py
Normal file → Executable 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
0
bin/ffmpeg
Normal file → Executable file
130
config.py
Normal file → Executable file
130
config.py
Normal file → Executable 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
18
cookies.txt
Normal file → Executable 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.
BIN
data/kvtube.db
BIN
data/kvtube.db
Binary file not shown.
Binary file not shown.
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"youtube_engine": "local"
|
||||
}
|
||||
0
deploy.py
Normal file → Executable file
0
deploy.py
Normal file → Executable file
0
dev.sh
Normal file → Executable file
0
dev.sh
Normal file → Executable file
90
doc/Product Requirements Document (PRD) - KV-Tube
Normal file → Executable file
90
doc/Product Requirements Document (PRD) - KV-Tube
Normal file → Executable 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
58
docker-compose.yml
Normal file → Executable 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
0
entrypoint.sh
Normal file → Executable file
2157
hydration_debug.txt
Normal file → Executable 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
0
kv_server.py
Normal file → Executable file
BIN
kvtube.db
BIN
kvtube.db
Binary file not shown.
16
requirements.txt
Normal file → Executable file
16
requirements.txt
Normal file → Executable 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
0
start.sh
Normal file → Executable file
0
static/css/modules/base.css
Normal file → Executable file
0
static/css/modules/base.css
Normal file → Executable file
0
static/css/modules/cards.css
Normal file → Executable file
0
static/css/modules/cards.css
Normal file → Executable file
622
static/css/modules/chat.css
Normal file → Executable file
622
static/css/modules/chat.css
Normal file → Executable 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
0
static/css/modules/components.css
Normal file → Executable file
1390
static/css/modules/downloads.css
Normal file → Executable 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
0
static/css/modules/grid.css
Normal file → Executable file
0
static/css/modules/layout.css
Normal file → Executable file
0
static/css/modules/layout.css
Normal file → Executable file
0
static/css/modules/pages.css
Normal file → Executable file
0
static/css/modules/pages.css
Normal file → Executable file
0
static/css/modules/utils.css
Normal file → Executable file
0
static/css/modules/utils.css
Normal file → Executable file
0
static/css/modules/variables.css
Normal file → Executable file
0
static/css/modules/variables.css
Normal file → Executable file
1554
static/css/modules/watch.css
Normal file → Executable file
1554
static/css/modules/watch.css
Normal file → Executable file
File diff suppressed because it is too large
Load diff
277
static/css/modules/webllm.css
Normal file
277
static/css/modules/webllm.css
Normal 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
32
static/css/style.css
Normal file → Executable 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
0
static/favicon.ico
Normal file → Executable 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
0
static/icons/icon-192x192.png
Normal file → Executable 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
0
static/icons/icon-512x512.png
Normal file → Executable file
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
0
static/js/artplayer.js
Normal file → Executable file
0
static/js/artplayer.js
Normal file → Executable file
1234
static/js/download-manager.js
Normal file → Executable 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
0
static/js/hls.min.js
vendored
Normal file → Executable file
2086
static/js/main.js
Normal file → Executable 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
408
static/js/navigation-manager.js
Normal file → Executable 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
340
static/js/webllm-service.js
Normal 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
0
static/manifest.json
Normal file → Executable file
0
static/sw.js
Normal file → Executable file
0
static/sw.js
Normal file → Executable file
970
templates/channel.html
Normal file → Executable file
970
templates/channel.html
Normal file → Executable 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
408
templates/downloads.html
Normal file → Executable 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
432
templates/index.html
Normal file → Executable 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
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
422
templates/login.html
Normal file → Executable 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
924
templates/my_videos.html
Normal file → Executable 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
422
templates/register.html
Normal file → Executable 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
708
templates/settings.html
Normal file → Executable 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
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
0
tests/test_loader_integration.py
Normal file → Executable file
0
tests/test_summarizer_logic.py
Normal file → Executable file
0
tests/test_summarizer_logic.py
Normal file → Executable file
1
tmp_media_roller_research
Submodule
1
tmp_media_roller_research
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 4b16bebf7d81925131001006231795f38538a928
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
# 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.
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
go build -x -o media-roller ./src
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
docker build -f Dockerfile -t media-roller .
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
docker run -p 3000:3000 -v $(pwd)/download:/download media-roller
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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
Loading…
Reference in a new issue