Compare commits
No commits in common. "f429116ed099738264c3e6377db5b5429c103412" and "aa1a419c35927b81f89cf0a4fb7edb435d27cfe3" have entirely different histories.
f429116ed0
...
aa1a419c35
2618 changed files with 626827 additions and 11958 deletions
|
|
@ -1,13 +0,0 @@
|
|||
.venv/
|
||||
.venv_clean/
|
||||
env/
|
||||
__pycache__/
|
||||
.git/
|
||||
.DS_Store
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.idea/
|
||||
.vscode/
|
||||
videos/
|
||||
data/
|
||||
12
.env.example
12
.env.example
|
|
@ -1,12 +0,0 @@
|
|||
# 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 +0,0 @@
|
|||
Subproject commit 246c4c349d97205eb2b51d7d3999ea846f5b2bdc
|
||||
68
.github/workflows/docker-publish.yml
vendored
68
.github/workflows/docker-publish.yml
vendored
|
|
@ -1,68 +0,0 @@
|
|||
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
12
.gitignore
vendored
|
|
@ -1,12 +0,0 @@
|
|||
.DS_Store
|
||||
__pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
.venv/
|
||||
.venv_clean/
|
||||
.env
|
||||
data/
|
||||
videos/
|
||||
*.db
|
||||
server.log
|
||||
.ruff_cache/
|
||||
0
API_DOCUMENTATION.md
Executable file → Normal file
0
API_DOCUMENTATION.md
Executable file → Normal file
66
Dockerfile
Executable file → Normal file
66
Dockerfile
Executable file → Normal 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
Executable file → Normal file
124
README.md
Executable file → Normal 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
Executable file → Normal file
0
USER_GUIDE.md
Executable file → Normal file
BIN
__pycache__/config.cpython-312.pyc
Normal file
BIN
__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
324
app/__init__.py
Executable file → Normal file
324
app/__init__.py
Executable file → Normal 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")
|
||||
|
|
|
|||
BIN
app/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
18
app/routes/__init__.py
Executable file → Normal file
18
app/routes/__init__.py
Executable file → Normal 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']
|
||||
|
|
|
|||
BIN
app/routes/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/api.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/api.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/pages.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/pages.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/streaming.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/streaming.cpython-312.pyc
Normal file
Binary file not shown.
158
app/routes/api.py
Executable file → Normal file
158
app/routes/api.py
Executable file → Normal 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 using TranscriptService (with ytfetcher fallback)
|
||||
text = TranscriptService.get_transcript(video_id)
|
||||
# 1. Get Transcript Text
|
||||
text = get_transcript_text(video_id)
|
||||
if not text:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "No transcript available to summarize."
|
||||
})
|
||||
|
||||
# 2. Use TextRank Summarizer - generate longer, more meaningful summaries
|
||||
# 2. Use TextRank Summarizer (Gemini removed per user request)
|
||||
summarizer = TextRankSummarizer()
|
||||
summary_text = summarizer.summarize(text, num_sentences=5) # Increased from 3 to 5
|
||||
summary_text = summarizer.summarize(text, num_sentences=3)
|
||||
|
||||
# Allow longer summaries for more meaningful content (600 chars instead of 300)
|
||||
if len(summary_text) > 600:
|
||||
summary_text = summary_text[:597] + "..."
|
||||
# Limit to 300 characters for concise display
|
||||
if len(summary_text) > 300:
|
||||
summary_text = summary_text[:297] + "..."
|
||||
|
||||
# Key points will be extracted by WebLLM on frontend (better quality)
|
||||
# Backend just returns empty list - WebLLM generates conceptual key points
|
||||
key_points = []
|
||||
# Extract key points from summary (heuristic)
|
||||
sentences = [s.strip() for s in summary_text.split('.') if len(s.strip()) > 15]
|
||||
key_points = sentences[:3]
|
||||
|
||||
# Store original versions
|
||||
original_summary = summary_text
|
||||
|
|
@ -1472,90 +1472,78 @@ def translate_text(text, target_lang='vi'):
|
|||
|
||||
def get_transcript_text(video_id):
|
||||
"""
|
||||
Fetch transcript using yt-dlp (downloading subtitles to file).
|
||||
Reliable method that handles auto-generated captions and cookies.
|
||||
Fetch transcript using strictly YTFetcher as requested.
|
||||
Ensure 'ytfetcher' is up to date before usage.
|
||||
"""
|
||||
import yt_dlp
|
||||
import glob
|
||||
from ytfetcher import YTFetcher
|
||||
from ytfetcher.config import HTTPConfig
|
||||
import random
|
||||
import json
|
||||
import os
|
||||
import http.cookiejar
|
||||
|
||||
try:
|
||||
video_id = video_id.strip()
|
||||
logger.info(f"Fetching transcript for {video_id} using yt-dlp")
|
||||
# 1. Prepare Cookies if available
|
||||
# This was key to the previous success!
|
||||
cookie_header = ""
|
||||
cookies_path = os.environ.get('COOKIES_FILE', 'cookies.txt')
|
||||
|
||||
# Use a temporary filename pattern
|
||||
temp_prefix = f"transcript_{video_id}_{random.randint(1000, 9999)}"
|
||||
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"
|
||||
]
|
||||
|
||||
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
|
||||
headers = {
|
||||
"User-Agent": random.choice(user_agents),
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
# 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.")
|
||||
|
||||
# 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:
|
||||
logger.error(f"Transcript fetch failed: {e}")
|
||||
import traceback
|
||||
tb = traceback.format_exc()
|
||||
logger.error(f"YTFetcher Execution Failed: {e}\n{tb}")
|
||||
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
|
|
|||
0
app/routes/pages.py
Executable file → Normal file
0
app/routes/pages.py
Executable file → Normal file
0
app/routes/streaming.py
Executable file → Normal file
0
app/routes/streaming.py
Executable file → Normal file
2
app/services/__init__.py
Executable file → Normal file
2
app/services/__init__.py
Executable file → Normal file
|
|
@ -1 +1 @@
|
|||
"""KV-Tube Services Package"""
|
||||
"""KV-Tube Services Package"""
|
||||
|
|
|
|||
BIN
app/services/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/gemini_summarizer.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/gemini_summarizer.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/loader_to.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/loader_to.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/settings.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/settings.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/summarizer.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/summarizer.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/youtube.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/youtube.cpython-312.pyc
Normal file
Binary file not shown.
434
app/services/cache.py
Executable file → Normal file
434
app/services/cache.py
Executable file → Normal 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
Executable file → Normal file
270
app/services/gemini_summarizer.py
Executable file → Normal 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
Executable file → Normal file
0
app/services/loader_to.py
Executable file → Normal file
0
app/services/settings.py
Executable file → Normal file
0
app/services/settings.py
Executable file → Normal file
238
app/services/summarizer.py
Executable file → Normal file
238
app/services/summarizer.py
Executable file → Normal 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)
|
||||
|
|
|
|||
|
|
@ -1,211 +0,0 @@
|
|||
"""
|
||||
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
Executable file → Normal file
626
app/services/youtube.py
Executable file → Normal 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
Executable file → Normal file
2
app/utils/__init__.py
Executable file → Normal file
|
|
@ -1 +1 @@
|
|||
"""KV-Tube Utilities Package"""
|
||||
"""KV-Tube Utilities Package"""
|
||||
|
|
|
|||
190
app/utils/formatters.py
Executable file → Normal file
190
app/utils/formatters.py
Executable file → Normal 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
Executable file → Normal file
0
bin/ffmpeg
Executable file → Normal file
130
config.py
Executable file → Normal file
130
config.py
Executable file → Normal 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
Executable file → Normal file
18
cookies.txt
Executable file → Normal file
|
|
@ -1,19 +1,19 @@
|
|||
# Netscape HTTP Cookie File
|
||||
# This file is generated by yt-dlp. Do not edit.
|
||||
|
||||
.youtube.com TRUE / TRUE 1831894348 __Secure-3PSID g.a0005wie1jXMw44_RGjwtqg21AbatcdNseI3S_qNtsLYC1jS4YSUdfLDtJlA8h-NEmMGebiczgACgYKAX0SARESFQHGX2MiUz2RnkvviMoB7UNylf3SoBoVAUF8yKo0JwXF5B9H9roWaSTRT-QN0076
|
||||
.youtube.com TRUE / TRUE 1802692356 __Secure-3PSID g.a0005gie1lAkmYZc-EPeGx77pCrXo_Cz5eAi-e9aryb9Qoz967v4Caiou6Tt5ZyLR4iMp5I51wACgYKASISARESFQHGX2MiopTeGBKXybppZWNr7JzmKhoVAUF8yKrgfPx-gEb02gGAV3ZaVOGr0076
|
||||
.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 1800359997 __Secure-1PSIDCC AKEyXzWh3snkS2XAx8pLOzZCgKTPwXKRai_Pn4KjpsSSc2h7tRpVKMDddMKBYkuIQFhpVlALI84
|
||||
.youtube.com TRUE / TRUE 1800282680 __Secure-1PSIDCC AKEyXzXvpBScD7r3mqr7aZ0ymWZ7FmsgT0q0C3Ge8hvrjZ9WZ4PU4ZBuBsO0YNYN3A8iX4eV8F8
|
||||
.youtube.com TRUE / TRUE 1802692356 SSID A4isk9AE9xActvzYy
|
||||
.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 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 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 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
|
||||
.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
|
||||
|
|
|
|||
BIN
data/BpwWnK6n9IQ.m4a
Normal file
BIN
data/BpwWnK6n9IQ.m4a
Normal file
Binary file not shown.
BIN
data/U2oEJKsPdHo.m4a
Normal file
BIN
data/U2oEJKsPdHo.m4a
Normal file
Binary file not shown.
BIN
data/UtGG6u1RBXI.m4a
Normal file
BIN
data/UtGG6u1RBXI.m4a
Normal file
Binary file not shown.
BIN
data/kvtube.db
Normal file
BIN
data/kvtube.db
Normal file
Binary file not shown.
BIN
data/m4xEF92ZPuk.m4a
Normal file
BIN
data/m4xEF92ZPuk.m4a
Normal file
Binary file not shown.
3
data/settings.json
Normal file
3
data/settings.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"youtube_engine": "local"
|
||||
}
|
||||
0
deploy.py
Executable file → Normal file
0
deploy.py
Executable file → Normal file
0
dev.sh
Executable file → Normal file
0
dev.sh
Executable file → Normal file
90
doc/Product Requirements Document (PRD) - KV-Tube
Executable file → Normal file
90
doc/Product Requirements Document (PRD) - KV-Tube
Executable file → Normal 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
Executable file → Normal file
58
docker-compose.yml
Executable file → Normal 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
Executable file → Normal file
0
entrypoint.sh
Executable file → Normal file
2157
hydration_debug.txt
Executable file → Normal file
2157
hydration_debug.txt
Executable file → Normal file
File diff suppressed because it is too large
Load diff
0
kv_server.py
Executable file → Normal file
0
kv_server.py
Executable file → Normal file
0
kv_tube.db
Normal file
0
kv_tube.db
Normal file
BIN
kvtube.db
Normal file
BIN
kvtube.db
Normal file
Binary file not shown.
16
requirements.txt
Executable file → Normal file
16
requirements.txt
Executable file → Normal file
|
|
@ -1,9 +1,7 @@
|
|||
flask
|
||||
requests
|
||||
yt-dlp>=2024.1.0
|
||||
werkzeug
|
||||
gunicorn
|
||||
python-dotenv
|
||||
googletrans==4.0.0-rc1
|
||||
# ytfetcher - optional, requires Python 3.11-3.13
|
||||
|
||||
flask
|
||||
requests
|
||||
yt-dlp>=2024.1.0
|
||||
werkzeug
|
||||
gunicorn
|
||||
python-dotenv
|
||||
|
||||
|
|
|
|||
0
start.sh
Executable file → Normal file
0
start.sh
Executable file → Normal file
0
static/css/modules/base.css
Executable file → Normal file
0
static/css/modules/base.css
Executable file → Normal file
0
static/css/modules/cards.css
Executable file → Normal file
0
static/css/modules/cards.css
Executable file → Normal file
622
static/css/modules/chat.css
Executable file → Normal file
622
static/css/modules/chat.css
Executable file → Normal 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
Executable file → Normal file
0
static/css/modules/components.css
Executable file → Normal file
1390
static/css/modules/downloads.css
Executable file → Normal file
1390
static/css/modules/downloads.css
Executable file → Normal file
File diff suppressed because it is too large
Load diff
0
static/css/modules/grid.css
Executable file → Normal file
0
static/css/modules/grid.css
Executable file → Normal file
0
static/css/modules/layout.css
Executable file → Normal file
0
static/css/modules/layout.css
Executable file → Normal file
0
static/css/modules/pages.css
Executable file → Normal file
0
static/css/modules/pages.css
Executable file → Normal file
0
static/css/modules/utils.css
Executable file → Normal file
0
static/css/modules/utils.css
Executable file → Normal file
0
static/css/modules/variables.css
Executable file → Normal file
0
static/css/modules/variables.css
Executable file → Normal file
1554
static/css/modules/watch.css
Executable file → Normal file
1554
static/css/modules/watch.css
Executable file → Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,277 +0,0 @@
|
|||
/**
|
||||
* 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
Executable file → Normal file
32
static/css/style.css
Executable file → Normal 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
Executable file → Normal file
0
static/favicon.ico
Executable file → Normal file
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
0
static/icons/icon-192x192.png
Executable file → Normal file
0
static/icons/icon-192x192.png
Executable file → Normal file
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
0
static/icons/icon-512x512.png
Executable file → Normal file
0
static/icons/icon-512x512.png
Executable file → Normal file
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
0
static/js/artplayer.js
Executable file → Normal file
0
static/js/artplayer.js
Executable file → Normal file
1234
static/js/download-manager.js
Executable file → Normal file
1234
static/js/download-manager.js
Executable file → Normal file
File diff suppressed because it is too large
Load diff
0
static/js/hls.min.js
vendored
Executable file → Normal file
0
static/js/hls.min.js
vendored
Executable file → Normal file
2086
static/js/main.js
Executable file → Normal file
2086
static/js/main.js
Executable file → Normal file
File diff suppressed because it is too large
Load diff
408
static/js/navigation-manager.js
Executable file → Normal file
408
static/js/navigation-manager.js
Executable file → Normal 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();
|
||||
|
|
|
|||
|
|
@ -1,340 +0,0 @@
|
|||
/**
|
||||
* 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
Executable file → Normal file
0
static/manifest.json
Executable file → Normal file
0
static/sw.js
Executable file → Normal file
0
static/sw.js
Executable file → Normal file
970
templates/channel.html
Executable file → Normal file
970
templates/channel.html
Executable file → Normal 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
Executable file → Normal file
408
templates/downloads.html
Executable file → Normal 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
Executable file → Normal file
432
templates/index.html
Executable file → Normal 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
Executable file → Normal file
1062
templates/layout.html
Executable file → Normal file
File diff suppressed because it is too large
Load diff
422
templates/login.html
Executable file → Normal file
422
templates/login.html
Executable file → Normal 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
Executable file → Normal file
924
templates/my_videos.html
Executable file → Normal 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
Executable file → Normal file
422
templates/register.html
Executable file → Normal 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
Executable file → Normal file
708
templates/settings.html
Executable file → Normal 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
Executable file → Normal file
4100
templates/watch.html
Executable file → Normal file
File diff suppressed because it is too large
Load diff
0
tests/test_loader_integration.py
Executable file → Normal file
0
tests/test_loader_integration.py
Executable file → Normal file
0
tests/test_summarizer_logic.py
Executable file → Normal file
0
tests/test_summarizer_logic.py
Executable file → Normal file
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 4b16bebf7d81925131001006231795f38538a928
|
||||
47
tmp_media_roller_research/Dockerfile
Normal file
47
tmp_media_roller_research/Dockerfile
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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"]
|
||||
59
tmp_media_roller_research/README.md
Normal file
59
tmp_media_roller_research/README.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# 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.
|
||||
2
tmp_media_roller_research/build.sh
Normal file
2
tmp_media_roller_research/build.sh
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
#!/usr/bin/env bash
|
||||
go build -x -o media-roller ./src
|
||||
2
tmp_media_roller_research/docker-build.sh
Normal file
2
tmp_media_roller_research/docker-build.sh
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
#!/usr/bin/env bash
|
||||
docker build -f Dockerfile -t media-roller .
|
||||
2
tmp_media_roller_research/docker-run.sh
Normal file
2
tmp_media_roller_research/docker-run.sh
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
#!/usr/bin/env bash
|
||||
docker run -p 3000:3000 -v $(pwd)/download:/download media-roller
|
||||
17
tmp_media_roller_research/go.mod
Normal file
17
tmp_media_roller_research/go.mod
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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
|
||||
)
|
||||
26
tmp_media_roller_research/go.sum
Normal file
26
tmp_media_roller_research/go.sum
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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=
|
||||
2
tmp_media_roller_research/run.sh
Normal file
2
tmp_media_roller_research/run.sh
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
#!/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