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
|
# Build stage
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies (ffmpeg is critical for yt-dlp)
|
# Install system dependencies (ffmpeg is critical for yt-dlp)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
curl \
|
curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Python dependencies
|
# Install Python dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
ENV FLASK_APP=wsgi.py
|
ENV FLASK_APP=wsgi.py
|
||||||
ENV FLASK_ENV=production
|
ENV FLASK_ENV=production
|
||||||
|
|
||||||
# Create directories for data persistence
|
# Create directories for data persistence
|
||||||
RUN mkdir -p /app/videos /app/data
|
RUN mkdir -p /app/videos /app/data
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
# Run with Entrypoint (handles updates)
|
# Run with Entrypoint (handles updates)
|
||||||
COPY entrypoint.sh /app/entrypoint.sh
|
COPY entrypoint.sh /app/entrypoint.sh
|
||||||
RUN chmod +x /app/entrypoint.sh
|
RUN chmod +x /app/entrypoint.sh
|
||||||
CMD ["/app/entrypoint.sh"]
|
CMD ["/app/entrypoint.sh"]
|
||||||
|
|
|
||||||
124
README.md
Executable file → Normal file
124
README.md
Executable file → Normal file
|
|
@ -1,62 +1,62 @@
|
||||||
# KV-Tube v3.0
|
# KV-Tube v3.0
|
||||||
|
|
||||||
> A lightweight, privacy-focused YouTube frontend web application with AI-powered features.
|
> A lightweight, privacy-focused YouTube frontend web application with AI-powered features.
|
||||||
|
|
||||||
KV-Tube removes distractions, tracking, and ads from the YouTube watching experience. It provides a clean interface to search, watch, and discover related content without needing a Google account.
|
KV-Tube removes distractions, tracking, and ads from the YouTube watching experience. It provides a clean interface to search, watch, and discover related content without needing a Google account.
|
||||||
|
|
||||||
## 🚀 Key Features (v3)
|
## 🚀 Key Features (v3)
|
||||||
|
|
||||||
- **Privacy First**: No tracking, no ads.
|
- **Privacy First**: No tracking, no ads.
|
||||||
- **Clean Interface**: Distraction-free watching experience.
|
- **Clean Interface**: Distraction-free watching experience.
|
||||||
- **Efficient Streaming**: Direct video stream extraction using `yt-dlp`.
|
- **Efficient Streaming**: Direct video stream extraction using `yt-dlp`.
|
||||||
- **AI Summary (Experimental)**: Generate concise summaries of videos (Currently disabled due to upstream rate limits).
|
- **AI Summary (Experimental)**: Generate concise summaries of videos (Currently disabled due to upstream rate limits).
|
||||||
- **Multi-Language**: Support for English and Vietnamese (UI & Content).
|
- **Multi-Language**: Support for English and Vietnamese (UI & Content).
|
||||||
- **Auto-Update**: Includes `update_deps.py` to easily keep core fetching tools up-to-date.
|
- **Auto-Update**: Includes `update_deps.py` to easily keep core fetching tools up-to-date.
|
||||||
|
|
||||||
## 🛠️ Architecture Data Flow
|
## 🛠️ Architecture Data Flow
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 🔧 Installation & Usage
|
## 🔧 Installation & Usage
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- Git
|
- Git
|
||||||
- Valid `cookies.txt` (Optional, for bypassing age-restrictions or rate limits)
|
- Valid `cookies.txt` (Optional, for bypassing age-restrictions or rate limits)
|
||||||
|
|
||||||
### Local Setup
|
### Local Setup
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.khoavo.myds.me/vndangkhoa/kv-tube.git
|
git clone https://git.khoavo.myds.me/vndangkhoa/kv-tube.git
|
||||||
cd kv-tube
|
cd kv-tube
|
||||||
```
|
```
|
||||||
2. Install dependencies:
|
2. Install dependencies:
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
3. Run the application:
|
3. Run the application:
|
||||||
```bash
|
```bash
|
||||||
python wsgi.py
|
python wsgi.py
|
||||||
```
|
```
|
||||||
4. Access at `http://localhost:5002`
|
4. Access at `http://localhost:5002`
|
||||||
|
|
||||||
### Docker Deployment (Linux/AMD64)
|
### Docker Deployment (Linux/AMD64)
|
||||||
|
|
||||||
Built for stability and ease of use.
|
Built for stability and ease of use.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull vndangkhoa/kv-tube:latest
|
docker pull vndangkhoa/kv-tube:latest
|
||||||
docker run -d -p 5002:5002 -v $(pwd)/cookies.txt:/app/cookies.txt vndangkhoa/kv-tube:latest
|
docker run -d -p 5002:5002 -v $(pwd)/cookies.txt:/app/cookies.txt vndangkhoa/kv-tube:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📦 Updates
|
## 📦 Updates
|
||||||
|
|
||||||
- **v3.0**: Major release.
|
- **v3.0**: Major release.
|
||||||
- Full modularization of backend routes.
|
- Full modularization of backend routes.
|
||||||
- Integrated `ytfetcher` for specialized fetching.
|
- Integrated `ytfetcher` for specialized fetching.
|
||||||
- Added manual dependency update script (`update_deps.py`).
|
- Added manual dependency update script (`update_deps.py`).
|
||||||
- Enhanced error handling for upstream rate limits.
|
- Enhanced error handling for upstream rate limits.
|
||||||
- Docker `linux/amd64` support verified.
|
- Docker `linux/amd64` support verified.
|
||||||
|
|
||||||
---
|
---
|
||||||
*Developed by Khoa Vo*
|
*Developed by Khoa Vo*
|
||||||
|
|
|
||||||
0
USER_GUIDE.md
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
|
KV-Tube App Package
|
||||||
Flask application factory pattern
|
Flask application factory pattern
|
||||||
"""
|
"""
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# Setup logging
|
# Setup logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Database configuration
|
# Database configuration
|
||||||
DATA_DIR = os.environ.get("KVTUBE_DATA_DIR", "data")
|
DATA_DIR = os.environ.get("KVTUBE_DATA_DIR", "data")
|
||||||
DB_NAME = os.path.join(DATA_DIR, "kvtube.db")
|
DB_NAME = os.path.join(DATA_DIR, "kvtube.db")
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""Initialize the database with required tables."""
|
"""Initialize the database with required tables."""
|
||||||
# Ensure data directory exists
|
# Ensure data directory exists
|
||||||
if not os.path.exists(DATA_DIR):
|
if not os.path.exists(DATA_DIR):
|
||||||
os.makedirs(DATA_DIR)
|
os.makedirs(DATA_DIR)
|
||||||
|
|
||||||
conn = sqlite3.connect(DB_NAME)
|
conn = sqlite3.connect(DB_NAME)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
|
|
||||||
# Users Table
|
# Users Table
|
||||||
c.execute("""CREATE TABLE IF NOT EXISTS users (
|
c.execute("""CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
password TEXT NOT NULL
|
password TEXT NOT NULL
|
||||||
)""")
|
)""")
|
||||||
|
|
||||||
# User Videos (history/saved)
|
# User Videos (history/saved)
|
||||||
c.execute("""CREATE TABLE IF NOT EXISTS user_videos (
|
c.execute("""CREATE TABLE IF NOT EXISTS user_videos (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER,
|
user_id INTEGER,
|
||||||
video_id TEXT,
|
video_id TEXT,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
thumbnail TEXT,
|
thumbnail TEXT,
|
||||||
type TEXT,
|
type TEXT,
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
)""")
|
)""")
|
||||||
|
|
||||||
# Video Cache
|
# Video Cache
|
||||||
c.execute("""CREATE TABLE IF NOT EXISTS video_cache (
|
c.execute("""CREATE TABLE IF NOT EXISTS video_cache (
|
||||||
video_id TEXT PRIMARY KEY,
|
video_id TEXT PRIMARY KEY,
|
||||||
data TEXT,
|
data TEXT,
|
||||||
expires_at DATETIME
|
expires_at DATETIME
|
||||||
)""")
|
)""")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
logger.info("Database initialized")
|
logger.info("Database initialized")
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_name=None):
|
def create_app(config_name=None):
|
||||||
"""
|
"""
|
||||||
Application factory for creating Flask app instances.
|
Application factory for creating Flask app instances.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config_name: Configuration name ('development', 'production', or None for default)
|
config_name: Configuration name ('development', 'production', or None for default)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Flask application instance
|
Flask application instance
|
||||||
"""
|
"""
|
||||||
app = Flask(__name__,
|
app = Flask(__name__,
|
||||||
template_folder='../templates',
|
template_folder='../templates',
|
||||||
static_folder='../static')
|
static_folder='../static')
|
||||||
|
|
||||||
# Load configuration
|
# Load configuration
|
||||||
app.secret_key = "super_secret_key_change_this" # Required for sessions
|
app.secret_key = "super_secret_key_change_this" # Required for sessions
|
||||||
|
|
||||||
# Fix for OMP: Error #15
|
# Fix for OMP: Error #15
|
||||||
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
|
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
|
||||||
|
|
||||||
# Initialize database
|
# Initialize database
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
# Register Jinja filters
|
# Register Jinja filters
|
||||||
register_filters(app)
|
register_filters(app)
|
||||||
|
|
||||||
# Register Blueprints
|
# Register Blueprints
|
||||||
register_blueprints(app)
|
register_blueprints(app)
|
||||||
|
|
||||||
# Start Background Cache Warmer (x5 Speedup)
|
# Start Background Cache Warmer (x5 Speedup)
|
||||||
try:
|
try:
|
||||||
from app.routes.api import start_background_warmer
|
from app.routes.api import start_background_warmer
|
||||||
start_background_warmer()
|
start_background_warmer()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to start background warmer: {e}")
|
logger.warning(f"Failed to start background warmer: {e}")
|
||||||
|
|
||||||
logger.info("KV-Tube app created successfully")
|
logger.info("KV-Tube app created successfully")
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def register_filters(app):
|
def register_filters(app):
|
||||||
"""Register custom Jinja2 template filters."""
|
"""Register custom Jinja2 template filters."""
|
||||||
|
|
||||||
@app.template_filter("format_views")
|
@app.template_filter("format_views")
|
||||||
def format_views(views):
|
def format_views(views):
|
||||||
if not views:
|
if not views:
|
||||||
return "0"
|
return "0"
|
||||||
try:
|
try:
|
||||||
num = int(views)
|
num = int(views)
|
||||||
if num >= 1000000:
|
if num >= 1000000:
|
||||||
return f"{num / 1000000:.1f}M"
|
return f"{num / 1000000:.1f}M"
|
||||||
if num >= 1000:
|
if num >= 1000:
|
||||||
return f"{num / 1000:.0f}K"
|
return f"{num / 1000:.0f}K"
|
||||||
return f"{num:,}"
|
return f"{num:,}"
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
logger.debug(f"View formatting failed: {e}")
|
logger.debug(f"View formatting failed: {e}")
|
||||||
return str(views)
|
return str(views)
|
||||||
|
|
||||||
@app.template_filter("format_date")
|
@app.template_filter("format_date")
|
||||||
def format_date(value):
|
def format_date(value):
|
||||||
if not value:
|
if not value:
|
||||||
return "Recently"
|
return "Recently"
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Handle YYYYMMDD
|
# Handle YYYYMMDD
|
||||||
if len(str(value)) == 8 and str(value).isdigit():
|
if len(str(value)) == 8 and str(value).isdigit():
|
||||||
dt = datetime.strptime(str(value), "%Y%m%d")
|
dt = datetime.strptime(str(value), "%Y%m%d")
|
||||||
# Handle Timestamp
|
# Handle Timestamp
|
||||||
elif isinstance(value, (int, float)):
|
elif isinstance(value, (int, float)):
|
||||||
dt = datetime.fromtimestamp(value)
|
dt = datetime.fromtimestamp(value)
|
||||||
# Handle YYYY-MM-DD
|
# Handle YYYY-MM-DD
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
dt = datetime.strptime(str(value), "%Y-%m-%d")
|
dt = datetime.strptime(str(value), "%Y-%m-%d")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
diff = now - dt
|
diff = now - dt
|
||||||
|
|
||||||
if diff.days > 365:
|
if diff.days > 365:
|
||||||
return f"{diff.days // 365} years ago"
|
return f"{diff.days // 365} years ago"
|
||||||
if diff.days > 30:
|
if diff.days > 30:
|
||||||
return f"{diff.days // 30} months ago"
|
return f"{diff.days // 30} months ago"
|
||||||
if diff.days > 0:
|
if diff.days > 0:
|
||||||
return f"{diff.days} days ago"
|
return f"{diff.days} days ago"
|
||||||
if diff.seconds > 3600:
|
if diff.seconds > 3600:
|
||||||
return f"{diff.seconds // 3600} hours ago"
|
return f"{diff.seconds // 3600} hours ago"
|
||||||
return "Just now"
|
return "Just now"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Date formatting failed: {e}")
|
logger.debug(f"Date formatting failed: {e}")
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
def register_blueprints(app):
|
def register_blueprints(app):
|
||||||
"""Register all application blueprints."""
|
"""Register all application blueprints."""
|
||||||
from app.routes import pages_bp, api_bp, streaming_bp
|
from app.routes import pages_bp, api_bp, streaming_bp
|
||||||
|
|
||||||
app.register_blueprint(pages_bp)
|
app.register_blueprint(pages_bp)
|
||||||
app.register_blueprint(api_bp)
|
app.register_blueprint(api_bp)
|
||||||
app.register_blueprint(streaming_bp)
|
app.register_blueprint(streaming_bp)
|
||||||
|
|
||||||
logger.info("Blueprints registered: pages, api, streaming")
|
logger.info("Blueprints registered: pages, api, streaming")
|
||||||
|
|
|
||||||
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
|
KV-Tube Routes Package
|
||||||
Exports all Blueprints for registration
|
Exports all Blueprints for registration
|
||||||
"""
|
"""
|
||||||
from app.routes.pages import pages_bp
|
from app.routes.pages import pages_bp
|
||||||
from app.routes.api import api_bp
|
from app.routes.api import api_bp
|
||||||
from app.routes.streaming import streaming_bp
|
from app.routes.streaming import streaming_bp
|
||||||
|
|
||||||
__all__ = ['pages_bp', 'api_bp', 'streaming_bp']
|
__all__ = ['pages_bp', 'api_bp', 'streaming_bp']
|
||||||
|
|
|
||||||
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 random
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
|
# from ytfetcher import YTFetcher
|
||||||
from app.services.settings import SettingsService
|
from app.services.settings import SettingsService
|
||||||
from app.services.summarizer import TextRankSummarizer
|
from app.services.summarizer import TextRankSummarizer
|
||||||
from app.services.gemini_summarizer import summarize_with_gemini, extract_key_points_with_gemini
|
from app.services.gemini_summarizer import summarize_with_gemini, extract_key_points_with_gemini
|
||||||
from app.services.youtube import YouTubeService
|
from app.services.youtube import YouTubeService
|
||||||
from app.services.transcript_service import TranscriptService
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -1405,25 +1405,25 @@ def summarize_video():
|
||||||
return jsonify({"error": "No video ID"}), 400
|
return jsonify({"error": "No video ID"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. Get Transcript Text using TranscriptService (with ytfetcher fallback)
|
# 1. Get Transcript Text
|
||||||
text = TranscriptService.get_transcript(video_id)
|
text = get_transcript_text(video_id)
|
||||||
if not text:
|
if not text:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": "No transcript available to summarize."
|
"error": "No transcript available to summarize."
|
||||||
})
|
})
|
||||||
|
|
||||||
# 2. Use TextRank Summarizer - generate longer, more meaningful summaries
|
# 2. Use TextRank Summarizer (Gemini removed per user request)
|
||||||
summarizer = TextRankSummarizer()
|
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)
|
# Limit to 300 characters for concise display
|
||||||
if len(summary_text) > 600:
|
if len(summary_text) > 300:
|
||||||
summary_text = summary_text[:597] + "..."
|
summary_text = summary_text[:297] + "..."
|
||||||
|
|
||||||
# Key points will be extracted by WebLLM on frontend (better quality)
|
# Extract key points from summary (heuristic)
|
||||||
# Backend just returns empty list - WebLLM generates conceptual key points
|
sentences = [s.strip() for s in summary_text.split('.') if len(s.strip()) > 15]
|
||||||
key_points = []
|
key_points = sentences[:3]
|
||||||
|
|
||||||
# Store original versions
|
# Store original versions
|
||||||
original_summary = summary_text
|
original_summary = summary_text
|
||||||
|
|
@ -1472,90 +1472,78 @@ def translate_text(text, target_lang='vi'):
|
||||||
|
|
||||||
def get_transcript_text(video_id):
|
def get_transcript_text(video_id):
|
||||||
"""
|
"""
|
||||||
Fetch transcript using yt-dlp (downloading subtitles to file).
|
Fetch transcript using strictly YTFetcher as requested.
|
||||||
Reliable method that handles auto-generated captions and cookies.
|
Ensure 'ytfetcher' is up to date before usage.
|
||||||
"""
|
"""
|
||||||
import yt_dlp
|
from ytfetcher import YTFetcher
|
||||||
import glob
|
from ytfetcher.config import HTTPConfig
|
||||||
import random
|
import random
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
|
import http.cookiejar
|
||||||
|
|
||||||
try:
|
try:
|
||||||
video_id = video_id.strip()
|
# 1. Prepare Cookies if available
|
||||||
logger.info(f"Fetching transcript for {video_id} using yt-dlp")
|
# This was key to the previous success!
|
||||||
|
cookie_header = ""
|
||||||
|
cookies_path = os.environ.get('COOKIES_FILE', 'cookies.txt')
|
||||||
|
|
||||||
# Use a temporary filename pattern
|
if os.path.exists(cookies_path):
|
||||||
temp_prefix = f"transcript_{video_id}_{random.randint(1000, 9999)}"
|
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 = {
|
headers = {
|
||||||
'skip_download': True,
|
"User-Agent": random.choice(user_agents),
|
||||||
'quiet': True,
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
'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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
# Inject cookie header if we have it
|
||||||
# This will download the subtitle file to /tmp/
|
if cookie_header:
|
||||||
ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
|
headers["Cookie"] = cookie_header
|
||||||
|
|
||||||
# Find the downloaded file
|
config = HTTPConfig(headers=headers)
|
||||||
# yt-dlp appends language code, e.g. .en.json3
|
|
||||||
# We look for any file with our prefix
|
# Initialize Fetcher
|
||||||
downloaded_files = glob.glob(f"/tmp/{temp_prefix}*")
|
fetcher = YTFetcher.from_video_ids(
|
||||||
|
video_ids=[video_id],
|
||||||
if not downloaded_files:
|
http_config=config,
|
||||||
logger.warning("yt-dlp finished but no subtitle file found.")
|
languages=['en', 'en-US', 'vi']
|
||||||
return None
|
)
|
||||||
|
|
||||||
|
# Fetch
|
||||||
|
logger.info(f"Fetching transcript with YTFetcher for {video_id}")
|
||||||
|
results = fetcher.fetch_transcripts()
|
||||||
|
|
||||||
|
if results:
|
||||||
|
data = results[0]
|
||||||
|
# Check for transcript data
|
||||||
|
if data.transcripts:
|
||||||
|
logger.info("YTFetcher: Transcript found.")
|
||||||
|
text_lines = [t.text.strip() for t in data.transcripts if t.text.strip()]
|
||||||
|
return " ".join(text_lines)
|
||||||
|
else:
|
||||||
|
logger.warning("YTFetcher: No transcript in result.")
|
||||||
|
|
||||||
# Pick the best file (prefer json3, then vtt)
|
|
||||||
selected_file = None
|
|
||||||
for ext in ['.json3', '.vtt', '.ttml', '.srv3']:
|
|
||||||
for f in downloaded_files:
|
|
||||||
if f.endswith(ext):
|
|
||||||
selected_file = f
|
|
||||||
break
|
|
||||||
if selected_file: break
|
|
||||||
|
|
||||||
if not selected_file:
|
|
||||||
selected_file = downloaded_files[0]
|
|
||||||
|
|
||||||
# Read content
|
|
||||||
with open(selected_file, 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
for f in downloaded_files:
|
|
||||||
try:
|
|
||||||
os.remove(f)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Parse
|
|
||||||
if selected_file.endswith('.json3') or content.strip().startswith('{'):
|
|
||||||
try:
|
|
||||||
json_data = json.loads(content)
|
|
||||||
events = json_data.get('events', [])
|
|
||||||
text_parts = []
|
|
||||||
for event in events:
|
|
||||||
segs = event.get('segs', [])
|
|
||||||
for seg in segs:
|
|
||||||
txt = seg.get('utf8', '').strip()
|
|
||||||
if txt and txt != '\n':
|
|
||||||
text_parts.append(txt)
|
|
||||||
return " ".join(text_parts)
|
|
||||||
except Exception as je:
|
|
||||||
logger.warning(f"JSON3 parse failed: {je}")
|
|
||||||
|
|
||||||
return parse_transcript_content(content)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
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
|
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
|
Cache Service Module
|
||||||
SQLite-based caching with connection pooling
|
SQLite-based caching with connection pooling
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Any, Dict
|
from typing import Optional, Any, Dict
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from config import Config
|
from config import Config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ConnectionPool:
|
class ConnectionPool:
|
||||||
"""Thread-safe SQLite connection pool"""
|
"""Thread-safe SQLite connection pool"""
|
||||||
|
|
||||||
def __init__(self, db_path: str, max_connections: int = 5):
|
def __init__(self, db_path: str, max_connections: int = 5):
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self.max_connections = max_connections
|
self.max_connections = max_connections
|
||||||
self._local = threading.local()
|
self._local = threading.local()
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._init_db()
|
self._init_db()
|
||||||
|
|
||||||
def _init_db(self):
|
def _init_db(self):
|
||||||
"""Initialize database tables"""
|
"""Initialize database tables"""
|
||||||
conn = sqlite3.connect(self.db_path)
|
conn = sqlite3.connect(self.db_path)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
|
|
||||||
# Users table
|
# Users table
|
||||||
c.execute('''CREATE TABLE IF NOT EXISTS users (
|
c.execute('''CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
password TEXT NOT NULL
|
password TEXT NOT NULL
|
||||||
)''')
|
)''')
|
||||||
|
|
||||||
# User videos (history/saved)
|
# User videos (history/saved)
|
||||||
c.execute('''CREATE TABLE IF NOT EXISTS user_videos (
|
c.execute('''CREATE TABLE IF NOT EXISTS user_videos (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER,
|
user_id INTEGER,
|
||||||
video_id TEXT,
|
video_id TEXT,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
thumbnail TEXT,
|
thumbnail TEXT,
|
||||||
type TEXT,
|
type TEXT,
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
)''')
|
)''')
|
||||||
|
|
||||||
# Video cache
|
# Video cache
|
||||||
c.execute('''CREATE TABLE IF NOT EXISTS video_cache (
|
c.execute('''CREATE TABLE IF NOT EXISTS video_cache (
|
||||||
video_id TEXT PRIMARY KEY,
|
video_id TEXT PRIMARY KEY,
|
||||||
data TEXT,
|
data TEXT,
|
||||||
expires_at REAL
|
expires_at REAL
|
||||||
)''')
|
)''')
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def get_connection(self) -> sqlite3.Connection:
|
def get_connection(self) -> sqlite3.Connection:
|
||||||
"""Get a thread-local database connection"""
|
"""Get a thread-local database connection"""
|
||||||
if not hasattr(self._local, 'connection') or self._local.connection is None:
|
if not hasattr(self._local, 'connection') or self._local.connection is None:
|
||||||
self._local.connection = sqlite3.connect(self.db_path)
|
self._local.connection = sqlite3.connect(self.db_path)
|
||||||
self._local.connection.row_factory = sqlite3.Row
|
self._local.connection.row_factory = sqlite3.Row
|
||||||
return self._local.connection
|
return self._local.connection
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def connection(self):
|
def connection(self):
|
||||||
"""Context manager for database connections"""
|
"""Context manager for database connections"""
|
||||||
conn = self.get_connection()
|
conn = self.get_connection()
|
||||||
try:
|
try:
|
||||||
yield conn
|
yield conn
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
logger.error(f"Database error: {e}")
|
logger.error(f"Database error: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Close the thread-local connection"""
|
"""Close the thread-local connection"""
|
||||||
if hasattr(self._local, 'connection') and self._local.connection:
|
if hasattr(self._local, 'connection') and self._local.connection:
|
||||||
self._local.connection.close()
|
self._local.connection.close()
|
||||||
self._local.connection = None
|
self._local.connection = None
|
||||||
|
|
||||||
|
|
||||||
# Global connection pool
|
# Global connection pool
|
||||||
_pool: Optional[ConnectionPool] = None
|
_pool: Optional[ConnectionPool] = None
|
||||||
|
|
||||||
|
|
||||||
def get_pool() -> ConnectionPool:
|
def get_pool() -> ConnectionPool:
|
||||||
"""Get or create the global connection pool"""
|
"""Get or create the global connection pool"""
|
||||||
global _pool
|
global _pool
|
||||||
if _pool is None:
|
if _pool is None:
|
||||||
_pool = ConnectionPool(Config.DB_NAME)
|
_pool = ConnectionPool(Config.DB_NAME)
|
||||||
return _pool
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
def get_db_connection() -> sqlite3.Connection:
|
def get_db_connection() -> sqlite3.Connection:
|
||||||
"""Get a database connection - backward compatibility"""
|
"""Get a database connection - backward compatibility"""
|
||||||
return get_pool().get_connection()
|
return get_pool().get_connection()
|
||||||
|
|
||||||
|
|
||||||
class CacheService:
|
class CacheService:
|
||||||
"""Service for caching video metadata"""
|
"""Service for caching video metadata"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_video_cache(video_id: str) -> Optional[Dict[str, Any]]:
|
def get_video_cache(video_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get cached video data if not expired
|
Get cached video data if not expired
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
video_id: YouTube video ID
|
video_id: YouTube video ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Cached data dict or None if not found/expired
|
Cached data dict or None if not found/expired
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
pool = get_pool()
|
pool = get_pool()
|
||||||
with pool.connection() as conn:
|
with pool.connection() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
'SELECT data, expires_at FROM video_cache WHERE video_id = ?',
|
'SELECT data, expires_at FROM video_cache WHERE video_id = ?',
|
||||||
(video_id,)
|
(video_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
expires_at = float(row['expires_at'])
|
expires_at = float(row['expires_at'])
|
||||||
if time.time() < expires_at:
|
if time.time() < expires_at:
|
||||||
return json.loads(row['data'])
|
return json.loads(row['data'])
|
||||||
else:
|
else:
|
||||||
# Expired, clean it up
|
# Expired, clean it up
|
||||||
conn.execute('DELETE FROM video_cache WHERE video_id = ?', (video_id,))
|
conn.execute('DELETE FROM video_cache WHERE video_id = ?', (video_id,))
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Cache get error for {video_id}: {e}")
|
logger.error(f"Cache get error for {video_id}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def set_video_cache(video_id: str, data: Dict[str, Any], ttl: int = None) -> bool:
|
def set_video_cache(video_id: str, data: Dict[str, Any], ttl: int = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Cache video data
|
Cache video data
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
video_id: YouTube video ID
|
video_id: YouTube video ID
|
||||||
data: Data to cache
|
data: Data to cache
|
||||||
ttl: Time to live in seconds (default from config)
|
ttl: Time to live in seconds (default from config)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if cached successfully
|
True if cached successfully
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if ttl is None:
|
if ttl is None:
|
||||||
ttl = Config.CACHE_VIDEO_TTL
|
ttl = Config.CACHE_VIDEO_TTL
|
||||||
|
|
||||||
expires_at = time.time() + ttl
|
expires_at = time.time() + ttl
|
||||||
|
|
||||||
pool = get_pool()
|
pool = get_pool()
|
||||||
with pool.connection() as conn:
|
with pool.connection() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
'INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)',
|
'INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)',
|
||||||
(video_id, json.dumps(data), expires_at)
|
(video_id, json.dumps(data), expires_at)
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Cache set error for {video_id}: {e}")
|
logger.error(f"Cache set error for {video_id}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def clear_expired():
|
def clear_expired():
|
||||||
"""Remove all expired cache entries"""
|
"""Remove all expired cache entries"""
|
||||||
try:
|
try:
|
||||||
pool = get_pool()
|
pool = get_pool()
|
||||||
with pool.connection() as conn:
|
with pool.connection() as conn:
|
||||||
conn.execute('DELETE FROM video_cache WHERE expires_at < ?', (time.time(),))
|
conn.execute('DELETE FROM video_cache WHERE expires_at < ?', (time.time(),))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Cache cleanup error: {e}")
|
logger.error(f"Cache cleanup error: {e}")
|
||||||
|
|
||||||
|
|
||||||
class HistoryService:
|
class HistoryService:
|
||||||
"""Service for user video history"""
|
"""Service for user video history"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_history(limit: int = 50) -> list:
|
def get_history(limit: int = 50) -> list:
|
||||||
"""Get watch history"""
|
"""Get watch history"""
|
||||||
try:
|
try:
|
||||||
pool = get_pool()
|
pool = get_pool()
|
||||||
with pool.connection() as conn:
|
with pool.connection() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
'SELECT video_id as id, title, thumbnail FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT ?',
|
'SELECT video_id as id, title, thumbnail FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT ?',
|
||||||
(limit,)
|
(limit,)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"History get error: {e}")
|
logger.error(f"History get error: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def add_to_history(video_id: str, title: str, thumbnail: str) -> bool:
|
def add_to_history(video_id: str, title: str, thumbnail: str) -> bool:
|
||||||
"""Add a video to history"""
|
"""Add a video to history"""
|
||||||
try:
|
try:
|
||||||
pool = get_pool()
|
pool = get_pool()
|
||||||
with pool.connection() as conn:
|
with pool.connection() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
'INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (?, ?, ?, ?, ?)',
|
||||||
(1, video_id, title, thumbnail, 'history')
|
(1, video_id, title, thumbnail, 'history')
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"History add error: {e}")
|
logger.error(f"History add error: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
270
app/services/gemini_summarizer.py
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.
|
AI-powered video summarizer using Google Gemini.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import base64
|
import base64
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Obfuscated API key - encoded with app-specific salt
|
# Obfuscated API key - encoded with app-specific salt
|
||||||
# This prevents casual copying but is not cryptographically secure
|
# This prevents casual copying but is not cryptographically secure
|
||||||
_OBFUSCATED_KEY = "QklqYVN5RG9yLWpsdmhtMEVGVkxnV3F4TllFR0MyR21oQUY3Y3Rv"
|
_OBFUSCATED_KEY = "QklqYVN5RG9yLWpsdmhtMEVGVkxnV3F4TllFR0MyR21oQUY3Y3Rv"
|
||||||
_APP_SALT = "KV-Tube-2026"
|
_APP_SALT = "KV-Tube-2026"
|
||||||
|
|
||||||
def _decode_api_key() -> str:
|
def _decode_api_key() -> str:
|
||||||
"""Decode the obfuscated API key. Only works with correct app context."""
|
"""Decode the obfuscated API key. Only works with correct app context."""
|
||||||
try:
|
try:
|
||||||
# Decode base64
|
# Decode base64
|
||||||
decoded = base64.b64decode(_OBFUSCATED_KEY).decode('utf-8')
|
decoded = base64.b64decode(_OBFUSCATED_KEY).decode('utf-8')
|
||||||
# Remove prefix added during encoding
|
# Remove prefix added during encoding
|
||||||
if decoded.startswith("Bij"):
|
if decoded.startswith("Bij"):
|
||||||
return "AI" + decoded[3:] # Reconstruct original key
|
return "AI" + decoded[3:] # Reconstruct original key
|
||||||
return decoded
|
return decoded
|
||||||
except:
|
except:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# Get API key: prefer environment variable, fall back to obfuscated default
|
# Get API key: prefer environment variable, fall back to obfuscated default
|
||||||
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") or _decode_api_key()
|
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") or _decode_api_key()
|
||||||
|
|
||||||
def summarize_with_gemini(transcript: str, video_title: str = "") -> Optional[str]:
|
def summarize_with_gemini(transcript: str, video_title: str = "") -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Summarize video transcript using Google Gemini AI.
|
Summarize video transcript using Google Gemini AI.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
transcript: The video transcript text
|
transcript: The video transcript text
|
||||||
video_title: Optional video title for context
|
video_title: Optional video title for context
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AI-generated summary or None if failed
|
AI-generated summary or None if failed
|
||||||
"""
|
"""
|
||||||
if not GEMINI_API_KEY:
|
if not GEMINI_API_KEY:
|
||||||
logger.warning("GEMINI_API_KEY not set, falling back to TextRank")
|
logger.warning("GEMINI_API_KEY not set, falling back to TextRank")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Importing google.generativeai... Key len: {len(GEMINI_API_KEY)}")
|
logger.info(f"Importing google.generativeai... Key len: {len(GEMINI_API_KEY)}")
|
||||||
import google.generativeai as genai
|
import google.generativeai as genai
|
||||||
|
|
||||||
genai.configure(api_key=GEMINI_API_KEY)
|
genai.configure(api_key=GEMINI_API_KEY)
|
||||||
logger.info("Gemini configured. Creating model...")
|
logger.info("Gemini configured. Creating model...")
|
||||||
model = genai.GenerativeModel('gemini-1.5-flash')
|
model = genai.GenerativeModel('gemini-1.5-flash')
|
||||||
|
|
||||||
# Limit transcript to avoid token limits
|
# Limit transcript to avoid token limits
|
||||||
max_chars = 8000
|
max_chars = 8000
|
||||||
if len(transcript) > max_chars:
|
if len(transcript) > max_chars:
|
||||||
transcript = transcript[:max_chars] + "..."
|
transcript = transcript[:max_chars] + "..."
|
||||||
|
|
||||||
logger.info(f"Generating summary content... Transcript len: {len(transcript)}")
|
logger.info(f"Generating summary content... Transcript len: {len(transcript)}")
|
||||||
# Create prompt for summarization
|
# Create prompt for summarization
|
||||||
prompt = f"""You are a helpful AI assistant. Summarize the following video transcript in 2-3 concise sentences.
|
prompt = f"""You are a helpful AI assistant. Summarize the following video transcript in 2-3 concise sentences.
|
||||||
Focus on the main topic and key points. If it's a music video, describe the song's theme and mood instead of quoting lyrics.
|
Focus on the main topic and key points. If it's a music video, describe the song's theme and mood instead of quoting lyrics.
|
||||||
|
|
||||||
Video Title: {video_title if video_title else 'Unknown'}
|
Video Title: {video_title if video_title else 'Unknown'}
|
||||||
|
|
||||||
Transcript:
|
Transcript:
|
||||||
{transcript}
|
{transcript}
|
||||||
|
|
||||||
Provide a brief, informative summary (2-3 sentences max):"""
|
Provide a brief, informative summary (2-3 sentences max):"""
|
||||||
|
|
||||||
response = model.generate_content(prompt)
|
response = model.generate_content(prompt)
|
||||||
logger.info("Gemini response received.")
|
logger.info("Gemini response received.")
|
||||||
|
|
||||||
if response and response.text:
|
if response and response.text:
|
||||||
summary = response.text.strip()
|
summary = response.text.strip()
|
||||||
# Clean up any markdown formatting
|
# Clean up any markdown formatting
|
||||||
summary = summary.replace("**", "").replace("##", "").replace("###", "")
|
summary = summary.replace("**", "").replace("##", "").replace("###", "")
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Gemini summarization error: {e}")
|
logger.error(f"Gemini summarization error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def extract_key_points_with_gemini(transcript: str, video_title: str = "") -> list:
|
def extract_key_points_with_gemini(transcript: str, video_title: str = "") -> list:
|
||||||
"""
|
"""
|
||||||
Extract key points from video transcript using Gemini AI.
|
Extract key points from video transcript using Gemini AI.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of key points or empty list if failed
|
List of key points or empty list if failed
|
||||||
"""
|
"""
|
||||||
if not GEMINI_API_KEY:
|
if not GEMINI_API_KEY:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import google.generativeai as genai
|
import google.generativeai as genai
|
||||||
|
|
||||||
genai.configure(api_key=GEMINI_API_KEY)
|
genai.configure(api_key=GEMINI_API_KEY)
|
||||||
model = genai.GenerativeModel('gemini-1.5-flash')
|
model = genai.GenerativeModel('gemini-1.5-flash')
|
||||||
|
|
||||||
# Limit transcript
|
# Limit transcript
|
||||||
max_chars = 6000
|
max_chars = 6000
|
||||||
if len(transcript) > max_chars:
|
if len(transcript) > max_chars:
|
||||||
transcript = transcript[:max_chars] + "..."
|
transcript = transcript[:max_chars] + "..."
|
||||||
|
|
||||||
prompt = f"""Extract 3-5 key points from this video transcript. For each point, provide a single short sentence.
|
prompt = f"""Extract 3-5 key points from this video transcript. For each point, provide a single short sentence.
|
||||||
If it's a music video, describe the themes, mood, and notable elements instead of quoting lyrics.
|
If it's a music video, describe the themes, mood, and notable elements instead of quoting lyrics.
|
||||||
|
|
||||||
Video Title: {video_title if video_title else 'Unknown'}
|
Video Title: {video_title if video_title else 'Unknown'}
|
||||||
|
|
||||||
Transcript:
|
Transcript:
|
||||||
{transcript}
|
{transcript}
|
||||||
|
|
||||||
Key points (one per line, no bullet points or numbers):"""
|
Key points (one per line, no bullet points or numbers):"""
|
||||||
|
|
||||||
response = model.generate_content(prompt)
|
response = model.generate_content(prompt)
|
||||||
|
|
||||||
if response and response.text:
|
if response and response.text:
|
||||||
lines = response.text.strip().split('\n')
|
lines = response.text.strip().split('\n')
|
||||||
# Clean up and filter
|
# Clean up and filter
|
||||||
points = []
|
points = []
|
||||||
for line in lines:
|
for line in lines:
|
||||||
line = line.strip().lstrip('•-*123456789.)')
|
line = line.strip().lstrip('•-*123456789.)')
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line and len(line) > 10:
|
if line and len(line) > 10:
|
||||||
points.append(line)
|
points.append(line)
|
||||||
return points[:5] # Max 5 points
|
return points[:5] # Max 5 points
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Gemini key points error: {e}")
|
logger.error(f"Gemini key points error: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
0
app/services/loader_to.py
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 re
|
||||||
import math
|
import math
|
||||||
import logging
|
import logging
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class TextRankSummarizer:
|
class TextRankSummarizer:
|
||||||
"""
|
"""
|
||||||
Summarizes text using a TextRank-like graph algorithm.
|
Summarizes text using a TextRank-like graph algorithm.
|
||||||
This creates more coherent "whole idea" summaries than random extraction.
|
This creates more coherent "whole idea" summaries than random extraction.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.stop_words = set([
|
self.stop_words = set([
|
||||||
"the", "a", "an", "and", "or", "but", "is", "are", "was", "were",
|
"the", "a", "an", "and", "or", "but", "is", "are", "was", "were",
|
||||||
"to", "of", "in", "on", "at", "for", "width", "that", "this", "it",
|
"to", "of", "in", "on", "at", "for", "width", "that", "this", "it",
|
||||||
"you", "i", "we", "they", "he", "she", "have", "has", "had", "do",
|
"you", "i", "we", "they", "he", "she", "have", "has", "had", "do",
|
||||||
"does", "did", "with", "as", "by", "from", "at", "but", "not", "what",
|
"does", "did", "with", "as", "by", "from", "at", "but", "not", "what",
|
||||||
"all", "were", "when", "can", "said", "there", "use", "an", "each",
|
"all", "were", "when", "can", "said", "there", "use", "an", "each",
|
||||||
"which", "she", "do", "how", "their", "if", "will", "up", "other",
|
"which", "she", "do", "how", "their", "if", "will", "up", "other",
|
||||||
"about", "out", "many", "then", "them", "these", "so", "some", "her",
|
"about", "out", "many", "then", "them", "these", "so", "some", "her",
|
||||||
"would", "make", "like", "him", "into", "time", "has", "look", "two",
|
"would", "make", "like", "him", "into", "time", "has", "look", "two",
|
||||||
"more", "write", "go", "see", "number", "no", "way", "could", "people",
|
"more", "write", "go", "see", "number", "no", "way", "could", "people",
|
||||||
"my", "than", "first", "water", "been", "call", "who", "oil", "its",
|
"my", "than", "first", "water", "been", "call", "who", "oil", "its",
|
||||||
"now", "find", "long", "down", "day", "did", "get", "come", "made",
|
"now", "find", "long", "down", "day", "did", "get", "come", "made",
|
||||||
"may", "part"
|
"may", "part"
|
||||||
])
|
])
|
||||||
|
|
||||||
def summarize(self, text: str, num_sentences: int = 5) -> str:
|
def summarize(self, text: str, num_sentences: int = 5) -> str:
|
||||||
"""
|
"""
|
||||||
Generate a summary of the text.
|
Generate a summary of the text.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Input text
|
text: Input text
|
||||||
num_sentences: Number of sentences in the summary
|
num_sentences: Number of sentences in the summary
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Summarized text string
|
Summarized text string
|
||||||
"""
|
"""
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# 1. Split into sentences
|
# 1. Split into sentences
|
||||||
# Use regex to look for periods/questions/exclamations followed by space or end of string
|
# Use regex to look for periods/questions/exclamations followed by space or end of string
|
||||||
sentences = re.split(r'(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s', text)
|
sentences = re.split(r'(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s', text)
|
||||||
sentences = [s.strip() for s in sentences if len(s.strip()) > 20] # Filter very short fragments
|
sentences = [s.strip() for s in sentences if len(s.strip()) > 20] # Filter very short fragments
|
||||||
|
|
||||||
if not sentences:
|
if not sentences:
|
||||||
return text[:500] + "..." if len(text) > 500 else text
|
return text[:500] + "..." if len(text) > 500 else text
|
||||||
|
|
||||||
if len(sentences) <= num_sentences:
|
if len(sentences) <= num_sentences:
|
||||||
return " ".join(sentences)
|
return " ".join(sentences)
|
||||||
|
|
||||||
# 2. Build Similarity Graph
|
# 2. Build Similarity Graph
|
||||||
# We calculate cosine similarity between all pairs of sentences
|
# We calculate cosine similarity between all pairs of sentences
|
||||||
# graph[i][j] = similarity score
|
# graph[i][j] = similarity score
|
||||||
n = len(sentences)
|
n = len(sentences)
|
||||||
scores = [0.0] * n
|
scores = [0.0] * n
|
||||||
|
|
||||||
# Pre-process sentences for efficiency
|
# Pre-process sentences for efficiency
|
||||||
# Convert to sets of words
|
# Convert to sets of words
|
||||||
sent_words = []
|
sent_words = []
|
||||||
for s in sentences:
|
for s in sentences:
|
||||||
words = re.findall(r'\w+', s.lower())
|
words = re.findall(r'\w+', s.lower())
|
||||||
words = [w for w in words if w not in self.stop_words]
|
words = [w for w in words if w not in self.stop_words]
|
||||||
sent_words.append(words)
|
sent_words.append(words)
|
||||||
|
|
||||||
# Adjacency matrix (conceptual) - we'll just sum weights for "centrality"
|
# Adjacency matrix (conceptual) - we'll just sum weights for "centrality"
|
||||||
# TextRank logic: a sentence is important if it is similar to other important sentences.
|
# TextRank logic: a sentence is important if it is similar to other important sentences.
|
||||||
# Simplified: weighted degree centrality often works well enough for simple tasks without full iterative convergence
|
# Simplified: weighted degree centrality often works well enough for simple tasks without full iterative convergence
|
||||||
|
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
for j in range(i + 1, n):
|
for j in range(i + 1, n):
|
||||||
sim = self._cosine_similarity(sent_words[i], sent_words[j])
|
sim = self._cosine_similarity(sent_words[i], sent_words[j])
|
||||||
if sim > 0:
|
if sim > 0:
|
||||||
scores[i] += sim
|
scores[i] += sim
|
||||||
scores[j] += sim
|
scores[j] += sim
|
||||||
|
|
||||||
# 3. Rank and Select
|
# 3. Rank and Select
|
||||||
# Sort by score descending
|
# Sort by score descending
|
||||||
ranked_sentences = sorted(((scores[i], i) for i in range(n)), reverse=True)
|
ranked_sentences = sorted(((scores[i], i) for i in range(n)), reverse=True)
|
||||||
|
|
||||||
# Pick top N
|
# Pick top N
|
||||||
top_indices = [idx for score, idx in ranked_sentences[:num_sentences]]
|
top_indices = [idx for score, idx in ranked_sentences[:num_sentences]]
|
||||||
|
|
||||||
# 4. Reorder by appearance in original text for coherence
|
# 4. Reorder by appearance in original text for coherence
|
||||||
top_indices.sort()
|
top_indices.sort()
|
||||||
|
|
||||||
summary = " ".join([sentences[i] for i in top_indices])
|
summary = " ".join([sentences[i] for i in top_indices])
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
def _cosine_similarity(self, words1: List[str], words2: List[str]) -> float:
|
def _cosine_similarity(self, words1: List[str], words2: List[str]) -> float:
|
||||||
"""Calculate cosine similarity between two word lists."""
|
"""Calculate cosine similarity between two word lists."""
|
||||||
if not words1 or not words2:
|
if not words1 or not words2:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
# Unique words in both
|
# Unique words in both
|
||||||
all_words = set(words1) | set(words2)
|
all_words = set(words1) | set(words2)
|
||||||
|
|
||||||
# Frequency vectors
|
# Frequency vectors
|
||||||
vec1 = {w: 0 for w in all_words}
|
vec1 = {w: 0 for w in all_words}
|
||||||
vec2 = {w: 0 for w in all_words}
|
vec2 = {w: 0 for w in all_words}
|
||||||
|
|
||||||
for w in words1: vec1[w] += 1
|
for w in words1: vec1[w] += 1
|
||||||
for w in words2: vec2[w] += 1
|
for w in words2: vec2[w] += 1
|
||||||
|
|
||||||
# Dot product
|
# Dot product
|
||||||
dot_product = sum(vec1[w] * vec2[w] for w in all_words)
|
dot_product = sum(vec1[w] * vec2[w] for w in all_words)
|
||||||
|
|
||||||
# Magnitudes
|
# Magnitudes
|
||||||
mag1 = math.sqrt(sum(v*v for v in vec1.values()))
|
mag1 = math.sqrt(sum(v*v for v in vec1.values()))
|
||||||
mag2 = math.sqrt(sum(v*v for v in vec2.values()))
|
mag2 = math.sqrt(sum(v*v for v in vec2.values()))
|
||||||
|
|
||||||
if mag1 == 0 or mag2 == 0:
|
if mag1 == 0 or mag2 == 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
return dot_product / (mag1 * mag2)
|
return dot_product / (mag1 * mag2)
|
||||||
|
|
|
||||||
|
|
@ -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
|
YouTube Service Module
|
||||||
Handles all yt-dlp interactions using the library directly (not subprocess)
|
Handles all yt-dlp interactions using the library directly (not subprocess)
|
||||||
"""
|
"""
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from config import Config
|
from config import Config
|
||||||
from app.services.loader_to import LoaderToService
|
from app.services.loader_to import LoaderToService
|
||||||
from app.services.settings import SettingsService
|
from app.services.settings import SettingsService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class YouTubeService:
|
class YouTubeService:
|
||||||
"""Service for fetching YouTube content using yt-dlp library"""
|
"""Service for fetching YouTube content using yt-dlp library"""
|
||||||
|
|
||||||
# Common yt-dlp options
|
# Common yt-dlp options
|
||||||
BASE_OPTS = {
|
BASE_OPTS = {
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
'no_warnings': True,
|
'no_warnings': True,
|
||||||
'extract_flat': 'in_playlist',
|
'extract_flat': 'in_playlist',
|
||||||
'force_ipv4': True,
|
'force_ipv4': True,
|
||||||
'socket_timeout': Config.YTDLP_TIMEOUT,
|
'socket_timeout': Config.YTDLP_TIMEOUT,
|
||||||
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def sanitize_video_data(data: Dict[str, Any]) -> Dict[str, Any]:
|
def sanitize_video_data(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Sanitize and format video data from yt-dlp"""
|
"""Sanitize and format video data from yt-dlp"""
|
||||||
video_id = data.get('id', '')
|
video_id = data.get('id', '')
|
||||||
duration_secs = data.get('duration')
|
duration_secs = data.get('duration')
|
||||||
|
|
||||||
# Format duration
|
# Format duration
|
||||||
duration_str = None
|
duration_str = None
|
||||||
if duration_secs:
|
if duration_secs:
|
||||||
mins, secs = divmod(int(duration_secs), 60)
|
mins, secs = divmod(int(duration_secs), 60)
|
||||||
hours, mins = divmod(mins, 60)
|
hours, mins = divmod(mins, 60)
|
||||||
duration_str = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
|
duration_str = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': video_id,
|
'id': video_id,
|
||||||
'title': data.get('title', 'Unknown'),
|
'title': data.get('title', 'Unknown'),
|
||||||
'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
|
'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
|
||||||
'channel_id': data.get('channel_id'),
|
'channel_id': data.get('channel_id'),
|
||||||
'uploader_id': data.get('uploader_id'),
|
'uploader_id': data.get('uploader_id'),
|
||||||
'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg" if video_id else None,
|
'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg" if video_id else None,
|
||||||
'view_count': data.get('view_count', 0),
|
'view_count': data.get('view_count', 0),
|
||||||
'upload_date': data.get('upload_date', ''),
|
'upload_date': data.get('upload_date', ''),
|
||||||
'duration': duration_str,
|
'duration': duration_str,
|
||||||
'description': data.get('description', ''),
|
'description': data.get('description', ''),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def search_videos(cls, query: str, limit: int = 20, filter_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
def search_videos(cls, query: str, limit: int = 20, filter_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Search for videos using yt-dlp library directly
|
Search for videos using yt-dlp library directly
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Search query
|
query: Search query
|
||||||
limit: Maximum number of results
|
limit: Maximum number of results
|
||||||
filter_type: 'video' to exclude shorts, 'short' for only shorts
|
filter_type: 'video' to exclude shorts, 'short' for only shorts
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of sanitized video data dictionaries
|
List of sanitized video data dictionaries
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
search_url = f"ytsearch{limit}:{query}"
|
search_url = f"ytsearch{limit}:{query}"
|
||||||
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
**cls.BASE_OPTS,
|
**cls.BASE_OPTS,
|
||||||
'extract_flat': True,
|
'extract_flat': True,
|
||||||
'playlist_items': f'1:{limit}',
|
'playlist_items': f'1:{limit}',
|
||||||
}
|
}
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(search_url, download=False)
|
info = ydl.extract_info(search_url, download=False)
|
||||||
entries = info.get('entries', []) if info else []
|
entries = info.get('entries', []) if info else []
|
||||||
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if not entry or not entry.get('id'):
|
if not entry or not entry.get('id'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Filter logic
|
# Filter logic
|
||||||
title_lower = (entry.get('title') or '').lower()
|
title_lower = (entry.get('title') or '').lower()
|
||||||
duration_secs = entry.get('duration')
|
duration_secs = entry.get('duration')
|
||||||
|
|
||||||
if filter_type == 'video':
|
if filter_type == 'video':
|
||||||
# Exclude shorts
|
# Exclude shorts
|
||||||
if '#shorts' in title_lower:
|
if '#shorts' in title_lower:
|
||||||
continue
|
continue
|
||||||
if duration_secs and int(duration_secs) <= 70:
|
if duration_secs and int(duration_secs) <= 70:
|
||||||
continue
|
continue
|
||||||
elif filter_type == 'short':
|
elif filter_type == 'short':
|
||||||
# Only shorts
|
# Only shorts
|
||||||
if duration_secs and int(duration_secs) > 60:
|
if duration_secs and int(duration_secs) > 60:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
results.append(cls.sanitize_video_data(entry))
|
results.append(cls.sanitize_video_data(entry))
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Search error for '{query}': {e}")
|
logger.error(f"Search error for '{query}': {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_video_info(cls, video_id: str) -> Optional[Dict[str, Any]]:
|
def get_video_info(cls, video_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get detailed video information including stream URL
|
Get detailed video information including stream URL
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
video_id: YouTube video ID
|
video_id: YouTube video ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Video info dict with stream_url, or None on error
|
Video info dict with stream_url, or None on error
|
||||||
"""
|
"""
|
||||||
engine = SettingsService.get('youtube_engine', 'auto')
|
engine = SettingsService.get('youtube_engine', 'auto')
|
||||||
|
|
||||||
# 1. Force Remote
|
# 1. Force Remote
|
||||||
if engine == 'remote':
|
if engine == 'remote':
|
||||||
return cls._get_info_remote(video_id)
|
return cls._get_info_remote(video_id)
|
||||||
|
|
||||||
# 2. Local (or Auto first attempt)
|
# 2. Local (or Auto first attempt)
|
||||||
info = cls._get_info_local(video_id)
|
info = cls._get_info_local(video_id)
|
||||||
|
|
||||||
if info:
|
if info:
|
||||||
return info
|
return info
|
||||||
|
|
||||||
# 3. Failover if Auto
|
# 3. Failover if Auto
|
||||||
if engine == 'auto' and not info:
|
if engine == 'auto' and not info:
|
||||||
logger.warning(f"yt-dlp failed for {video_id}, falling back to remote loader")
|
logger.warning(f"yt-dlp failed for {video_id}, falling back to remote loader")
|
||||||
return cls._get_info_remote(video_id)
|
return cls._get_info_remote(video_id)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_info_remote(cls, video_id: str) -> Optional[Dict[str, Any]]:
|
def _get_info_remote(cls, video_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Fetch info using LoaderToService"""
|
"""Fetch info using LoaderToService"""
|
||||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||||
return LoaderToService.get_stream_url(url)
|
return LoaderToService.get_stream_url(url)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_info_local(cls, video_id: str) -> Optional[Dict[str, Any]]:
|
def _get_info_local(cls, video_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Fetch info using yt-dlp (original logic)"""
|
"""Fetch info using yt-dlp (original logic)"""
|
||||||
try:
|
try:
|
||||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||||
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
**cls.BASE_OPTS,
|
**cls.BASE_OPTS,
|
||||||
'format': Config.YTDLP_FORMAT,
|
'format': Config.YTDLP_FORMAT,
|
||||||
'noplaylist': True,
|
'noplaylist': True,
|
||||||
'skip_download': True,
|
'skip_download': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(url, download=False)
|
info = ydl.extract_info(url, download=False)
|
||||||
|
|
||||||
if not info:
|
if not info:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
stream_url = info.get('url')
|
stream_url = info.get('url')
|
||||||
if not stream_url:
|
if not stream_url:
|
||||||
logger.warning(f"No stream URL found for {video_id}")
|
logger.warning(f"No stream URL found for {video_id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get subtitles
|
# Get subtitles
|
||||||
subtitle_url = cls._extract_subtitle_url(info)
|
subtitle_url = cls._extract_subtitle_url(info)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'stream_url': stream_url,
|
'stream_url': stream_url,
|
||||||
'title': info.get('title', 'Unknown'),
|
'title': info.get('title', 'Unknown'),
|
||||||
'description': info.get('description', ''),
|
'description': info.get('description', ''),
|
||||||
'uploader': info.get('uploader', ''),
|
'uploader': info.get('uploader', ''),
|
||||||
'uploader_id': info.get('uploader_id', ''),
|
'uploader_id': info.get('uploader_id', ''),
|
||||||
'channel_id': info.get('channel_id', ''),
|
'channel_id': info.get('channel_id', ''),
|
||||||
'upload_date': info.get('upload_date', ''),
|
'upload_date': info.get('upload_date', ''),
|
||||||
'view_count': info.get('view_count', 0),
|
'view_count': info.get('view_count', 0),
|
||||||
'subtitle_url': subtitle_url,
|
'subtitle_url': subtitle_url,
|
||||||
'duration': info.get('duration'),
|
'duration': info.get('duration'),
|
||||||
'thumbnail': info.get('thumbnail') or f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
|
'thumbnail': info.get('thumbnail') or f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
|
||||||
'http_headers': info.get('http_headers', {})
|
'http_headers': info.get('http_headers', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting local video info for {video_id}: {e}")
|
logger.error(f"Error getting local video info for {video_id}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_subtitle_url(info: Dict[str, Any]) -> Optional[str]:
|
def _extract_subtitle_url(info: Dict[str, Any]) -> Optional[str]:
|
||||||
"""Extract best subtitle URL from video info"""
|
"""Extract best subtitle URL from video info"""
|
||||||
subs = info.get('subtitles') or {}
|
subs = info.get('subtitles') or {}
|
||||||
auto_subs = info.get('automatic_captions') or {}
|
auto_subs = info.get('automatic_captions') or {}
|
||||||
|
|
||||||
# Priority: en manual > vi manual > en auto > vi auto > first available
|
# Priority: en manual > vi manual > en auto > vi auto > first available
|
||||||
for lang in ['en', 'vi']:
|
for lang in ['en', 'vi']:
|
||||||
if lang in subs and subs[lang]:
|
if lang in subs and subs[lang]:
|
||||||
return subs[lang][0].get('url')
|
return subs[lang][0].get('url')
|
||||||
|
|
||||||
for lang in ['en', 'vi']:
|
for lang in ['en', 'vi']:
|
||||||
if lang in auto_subs and auto_subs[lang]:
|
if lang in auto_subs and auto_subs[lang]:
|
||||||
return auto_subs[lang][0].get('url')
|
return auto_subs[lang][0].get('url')
|
||||||
|
|
||||||
# Fallback to first available
|
# Fallback to first available
|
||||||
if subs:
|
if subs:
|
||||||
first_key = list(subs.keys())[0]
|
first_key = list(subs.keys())[0]
|
||||||
if subs[first_key]:
|
if subs[first_key]:
|
||||||
return subs[first_key][0].get('url')
|
return subs[first_key][0].get('url')
|
||||||
|
|
||||||
if auto_subs:
|
if auto_subs:
|
||||||
first_key = list(auto_subs.keys())[0]
|
first_key = list(auto_subs.keys())[0]
|
||||||
if auto_subs[first_key]:
|
if auto_subs[first_key]:
|
||||||
return auto_subs[first_key][0].get('url')
|
return auto_subs[first_key][0].get('url')
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_channel_videos(cls, channel_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
def get_channel_videos(cls, channel_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get videos from a YouTube channel
|
Get videos from a YouTube channel
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
channel_id: Channel ID, handle (@username), or URL
|
channel_id: Channel ID, handle (@username), or URL
|
||||||
limit: Maximum number of videos
|
limit: Maximum number of videos
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of video data dictionaries
|
List of video data dictionaries
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Construct URL based on ID format
|
# Construct URL based on ID format
|
||||||
if channel_id.startswith('http'):
|
if channel_id.startswith('http'):
|
||||||
url = channel_id
|
url = channel_id
|
||||||
elif channel_id.startswith('@'):
|
elif channel_id.startswith('@'):
|
||||||
url = f"https://www.youtube.com/{channel_id}"
|
url = f"https://www.youtube.com/{channel_id}"
|
||||||
elif len(channel_id) == 24 and channel_id.startswith('UC'):
|
elif len(channel_id) == 24 and channel_id.startswith('UC'):
|
||||||
url = f"https://www.youtube.com/channel/{channel_id}"
|
url = f"https://www.youtube.com/channel/{channel_id}"
|
||||||
else:
|
else:
|
||||||
url = f"https://www.youtube.com/{channel_id}"
|
url = f"https://www.youtube.com/{channel_id}"
|
||||||
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
**cls.BASE_OPTS,
|
**cls.BASE_OPTS,
|
||||||
'extract_flat': True,
|
'extract_flat': True,
|
||||||
'playlist_items': f'1:{limit}',
|
'playlist_items': f'1:{limit}',
|
||||||
}
|
}
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(url, download=False)
|
info = ydl.extract_info(url, download=False)
|
||||||
entries = info.get('entries', []) if info else []
|
entries = info.get('entries', []) if info else []
|
||||||
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if entry and entry.get('id'):
|
if entry and entry.get('id'):
|
||||||
results.append(cls.sanitize_video_data(entry))
|
results.append(cls.sanitize_video_data(entry))
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting channel videos for {channel_id}: {e}")
|
logger.error(f"Error getting channel videos for {channel_id}: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_related_videos(cls, title: str, limit: int = 10) -> List[Dict[str, Any]]:
|
def get_related_videos(cls, title: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||||
"""Get videos related to a given title"""
|
"""Get videos related to a given title"""
|
||||||
query = f"{title} related"
|
query = f"{title} related"
|
||||||
return cls.search_videos(query, limit=limit, filter_type='video')
|
return cls.search_videos(query, limit=limit, filter_type='video')
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_download_url(cls, video_id: str) -> Optional[Dict[str, str]]:
|
def get_download_url(cls, video_id: str) -> Optional[Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Get direct download URL (non-HLS) for a video
|
Get direct download URL (non-HLS) for a video
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with 'url', 'title', 'ext' or None
|
Dict with 'url', 'title', 'ext' or None
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||||
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
**cls.BASE_OPTS,
|
**cls.BASE_OPTS,
|
||||||
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best[protocol!*=m3u8]/best',
|
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best[protocol!*=m3u8]/best',
|
||||||
'noplaylist': True,
|
'noplaylist': True,
|
||||||
'skip_download': True,
|
'skip_download': True,
|
||||||
'youtube_include_dash_manifest': False,
|
'youtube_include_dash_manifest': False,
|
||||||
'youtube_include_hls_manifest': False,
|
'youtube_include_hls_manifest': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(url, download=False)
|
info = ydl.extract_info(url, download=False)
|
||||||
|
|
||||||
download_url = info.get('url', '')
|
download_url = info.get('url', '')
|
||||||
|
|
||||||
# If m3u8, try to find non-HLS format
|
# If m3u8, try to find non-HLS format
|
||||||
if '.m3u8' in download_url or not download_url:
|
if '.m3u8' in download_url or not download_url:
|
||||||
formats = info.get('formats', [])
|
formats = info.get('formats', [])
|
||||||
for f in reversed(formats):
|
for f in reversed(formats):
|
||||||
f_url = f.get('url', '')
|
f_url = f.get('url', '')
|
||||||
if f_url and 'm3u8' not in f_url and f.get('ext') == 'mp4':
|
if f_url and 'm3u8' not in f_url and f.get('ext') == 'mp4':
|
||||||
download_url = f_url
|
download_url = f_url
|
||||||
break
|
break
|
||||||
|
|
||||||
if download_url and '.m3u8' not in download_url:
|
if download_url and '.m3u8' not in download_url:
|
||||||
return {
|
return {
|
||||||
'url': download_url,
|
'url': download_url,
|
||||||
'title': info.get('title', 'video'),
|
'title': info.get('title', 'video'),
|
||||||
'ext': 'mp4'
|
'ext': 'mp4'
|
||||||
}
|
}
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting download URL for {video_id}: {e}")
|
logger.error(f"Error getting download URL for {video_id}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
2
app/utils/__init__.py
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
|
Template Formatters Module
|
||||||
Jinja2 template filters for formatting views and dates
|
Jinja2 template filters for formatting views and dates
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
def format_views(views) -> str:
|
def format_views(views) -> str:
|
||||||
"""Format view count (YouTube style: 1.2M, 3.5K)"""
|
"""Format view count (YouTube style: 1.2M, 3.5K)"""
|
||||||
if not views:
|
if not views:
|
||||||
return '0'
|
return '0'
|
||||||
try:
|
try:
|
||||||
num = int(views)
|
num = int(views)
|
||||||
if num >= 1_000_000_000:
|
if num >= 1_000_000_000:
|
||||||
return f"{num / 1_000_000_000:.1f}B"
|
return f"{num / 1_000_000_000:.1f}B"
|
||||||
if num >= 1_000_000:
|
if num >= 1_000_000:
|
||||||
return f"{num / 1_000_000:.1f}M"
|
return f"{num / 1_000_000:.1f}M"
|
||||||
if num >= 1_000:
|
if num >= 1_000:
|
||||||
return f"{num / 1_000:.0f}K"
|
return f"{num / 1_000:.0f}K"
|
||||||
return f"{num:,}"
|
return f"{num:,}"
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return str(views)
|
return str(views)
|
||||||
|
|
||||||
|
|
||||||
def format_date(value) -> str:
|
def format_date(value) -> str:
|
||||||
"""Format date to relative time (YouTube style: 2 hours ago, 3 days ago)"""
|
"""Format date to relative time (YouTube style: 2 hours ago, 3 days ago)"""
|
||||||
if not value:
|
if not value:
|
||||||
return 'Recently'
|
return 'Recently'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Handle YYYYMMDD format
|
# Handle YYYYMMDD format
|
||||||
if len(str(value)) == 8 and str(value).isdigit():
|
if len(str(value)) == 8 and str(value).isdigit():
|
||||||
dt = datetime.strptime(str(value), '%Y%m%d')
|
dt = datetime.strptime(str(value), '%Y%m%d')
|
||||||
# Handle timestamp
|
# Handle timestamp
|
||||||
elif isinstance(value, (int, float)):
|
elif isinstance(value, (int, float)):
|
||||||
dt = datetime.fromtimestamp(value)
|
dt = datetime.fromtimestamp(value)
|
||||||
# Handle datetime object
|
# Handle datetime object
|
||||||
elif isinstance(value, datetime):
|
elif isinstance(value, datetime):
|
||||||
dt = value
|
dt = value
|
||||||
# Handle YYYY-MM-DD string
|
# Handle YYYY-MM-DD string
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
dt = datetime.strptime(str(value), '%Y-%m-%d')
|
dt = datetime.strptime(str(value), '%Y-%m-%d')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
diff = now - dt
|
diff = now - dt
|
||||||
|
|
||||||
if diff.days > 365:
|
if diff.days > 365:
|
||||||
years = diff.days // 365
|
years = diff.days // 365
|
||||||
return f"{years} year{'s' if years > 1 else ''} ago"
|
return f"{years} year{'s' if years > 1 else ''} ago"
|
||||||
if diff.days > 30:
|
if diff.days > 30:
|
||||||
months = diff.days // 30
|
months = diff.days // 30
|
||||||
return f"{months} month{'s' if months > 1 else ''} ago"
|
return f"{months} month{'s' if months > 1 else ''} ago"
|
||||||
if diff.days > 7:
|
if diff.days > 7:
|
||||||
weeks = diff.days // 7
|
weeks = diff.days // 7
|
||||||
return f"{weeks} week{'s' if weeks > 1 else ''} ago"
|
return f"{weeks} week{'s' if weeks > 1 else ''} ago"
|
||||||
if diff.days > 0:
|
if diff.days > 0:
|
||||||
return f"{diff.days} day{'s' if diff.days > 1 else ''} ago"
|
return f"{diff.days} day{'s' if diff.days > 1 else ''} ago"
|
||||||
if diff.seconds > 3600:
|
if diff.seconds > 3600:
|
||||||
hours = diff.seconds // 3600
|
hours = diff.seconds // 3600
|
||||||
return f"{hours} hour{'s' if hours > 1 else ''} ago"
|
return f"{hours} hour{'s' if hours > 1 else ''} ago"
|
||||||
if diff.seconds > 60:
|
if diff.seconds > 60:
|
||||||
minutes = diff.seconds // 60
|
minutes = diff.seconds // 60
|
||||||
return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
|
return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
|
||||||
return "Just now"
|
return "Just now"
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
def format_duration(seconds) -> str:
|
def format_duration(seconds) -> str:
|
||||||
"""Format duration in seconds to HH:MM:SS or MM:SS"""
|
"""Format duration in seconds to HH:MM:SS or MM:SS"""
|
||||||
if not seconds:
|
if not seconds:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
try:
|
try:
|
||||||
secs = int(seconds)
|
secs = int(seconds)
|
||||||
mins, secs = divmod(secs, 60)
|
mins, secs = divmod(secs, 60)
|
||||||
hours, mins = divmod(mins, 60)
|
hours, mins = divmod(mins, 60)
|
||||||
|
|
||||||
if hours:
|
if hours:
|
||||||
return f"{hours}:{mins:02d}:{secs:02d}"
|
return f"{hours}:{mins:02d}:{secs:02d}"
|
||||||
return f"{mins}:{secs:02d}"
|
return f"{mins}:{secs:02d}"
|
||||||
|
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def register_filters(app):
|
def register_filters(app):
|
||||||
"""Register all template filters with Flask app"""
|
"""Register all template filters with Flask app"""
|
||||||
app.template_filter('format_views')(format_views)
|
app.template_filter('format_views')(format_views)
|
||||||
app.template_filter('format_date')(format_date)
|
app.template_filter('format_date')(format_date)
|
||||||
app.template_filter('format_duration')(format_duration)
|
app.template_filter('format_duration')(format_duration)
|
||||||
|
|
|
||||||
0
bin/ffmpeg
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
|
KV-Tube Configuration Module
|
||||||
Centralizes all configuration with environment variable support
|
Centralizes all configuration with environment variable support
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# Load .env file if present
|
# Load .env file if present
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
"""Base configuration"""
|
"""Base configuration"""
|
||||||
SECRET_KEY = os.environ.get('SECRET_KEY', os.urandom(32).hex())
|
SECRET_KEY = os.environ.get('SECRET_KEY', os.urandom(32).hex())
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATA_DIR = os.environ.get('KVTUBE_DATA_DIR', 'data')
|
DATA_DIR = os.environ.get('KVTUBE_DATA_DIR', 'data')
|
||||||
DB_NAME = os.path.join(DATA_DIR, 'kvtube.db')
|
DB_NAME = os.path.join(DATA_DIR, 'kvtube.db')
|
||||||
|
|
||||||
# Video storage
|
# Video storage
|
||||||
VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos')
|
VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos')
|
||||||
|
|
||||||
# Rate limiting
|
# Rate limiting
|
||||||
RATELIMIT_DEFAULT = "60/minute"
|
RATELIMIT_DEFAULT = "60/minute"
|
||||||
RATELIMIT_SEARCH = "30/minute"
|
RATELIMIT_SEARCH = "30/minute"
|
||||||
RATELIMIT_STREAM = "120/minute"
|
RATELIMIT_STREAM = "120/minute"
|
||||||
|
|
||||||
# Cache settings (in seconds)
|
# Cache settings (in seconds)
|
||||||
CACHE_VIDEO_TTL = 3600 # 1 hour
|
CACHE_VIDEO_TTL = 3600 # 1 hour
|
||||||
CACHE_CHANNEL_TTL = 1800 # 30 minutes
|
CACHE_CHANNEL_TTL = 1800 # 30 minutes
|
||||||
|
|
||||||
# yt-dlp settings
|
# yt-dlp settings
|
||||||
# yt-dlp settings - MUST use progressive formats with combined audio+video
|
# yt-dlp settings - MUST use progressive formats with combined audio+video
|
||||||
# Format 22 = 720p mp4, 18 = 360p mp4 (both have audio+video combined)
|
# Format 22 = 720p mp4, 18 = 360p mp4 (both have audio+video combined)
|
||||||
# HLS m3u8 streams have CORS issues with segment proxying, so we avoid them
|
# HLS m3u8 streams have CORS issues with segment proxying, so we avoid them
|
||||||
YTDLP_FORMAT = '22/18/best[protocol^=https][ext=mp4]/best[ext=mp4]/best'
|
YTDLP_FORMAT = '22/18/best[protocol^=https][ext=mp4]/best[ext=mp4]/best'
|
||||||
YTDLP_TIMEOUT = 30
|
YTDLP_TIMEOUT = 30
|
||||||
|
|
||||||
# YouTube Engine Settings
|
# YouTube Engine Settings
|
||||||
YOUTUBE_ENGINE = os.environ.get('YOUTUBE_ENGINE', 'auto') # auto, local, remote
|
YOUTUBE_ENGINE = os.environ.get('YOUTUBE_ENGINE', 'auto') # auto, local, remote
|
||||||
LOADER_TO_API_KEY = os.environ.get('LOADER_TO_API_KEY', '') # Optional
|
LOADER_TO_API_KEY = os.environ.get('LOADER_TO_API_KEY', '') # Optional
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def init_app(app):
|
def init_app(app):
|
||||||
"""Initialize app with config"""
|
"""Initialize app with config"""
|
||||||
# Ensure data directory exists
|
# Ensure data directory exists
|
||||||
os.makedirs(Config.DATA_DIR, exist_ok=True)
|
os.makedirs(Config.DATA_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
class DevelopmentConfig(Config):
|
class DevelopmentConfig(Config):
|
||||||
"""Development configuration"""
|
"""Development configuration"""
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
FLASK_ENV = 'development'
|
FLASK_ENV = 'development'
|
||||||
|
|
||||||
|
|
||||||
class ProductionConfig(Config):
|
class ProductionConfig(Config):
|
||||||
"""Production configuration"""
|
"""Production configuration"""
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
FLASK_ENV = 'production'
|
FLASK_ENV = 'production'
|
||||||
|
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
'development': DevelopmentConfig,
|
'development': DevelopmentConfig,
|
||||||
'production': ProductionConfig,
|
'production': ProductionConfig,
|
||||||
'default': DevelopmentConfig
|
'default': DevelopmentConfig
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
cookies.txt
Executable file → Normal file
18
cookies.txt
Executable file → Normal file
|
|
@ -1,19 +1,19 @@
|
||||||
# Netscape HTTP Cookie File
|
# Netscape HTTP Cookie File
|
||||||
# This file is generated by yt-dlp. Do not edit.
|
# This file is generated by yt-dlp. Do not edit.
|
||||||
|
|
||||||
.youtube.com TRUE / TRUE 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 1800281710 __Secure-1PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
|
||||||
.youtube.com TRUE / TRUE 1802692356 SAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
.youtube.com TRUE / TRUE 1802692356 SAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
||||||
.youtube.com TRUE / TRUE 1800359997 __Secure-1PSIDCC AKEyXzWh3snkS2XAx8pLOzZCgKTPwXKRai_Pn4KjpsSSc2h7tRpVKMDddMKBYkuIQFhpVlALI84
|
.youtube.com TRUE / TRUE 1800282680 __Secure-1PSIDCC AKEyXzXvpBScD7r3mqr7aZ0ymWZ7FmsgT0q0C3Ge8hvrjZ9WZ4PU4ZBuBsO0YNYN3A8iX4eV8F8
|
||||||
.youtube.com TRUE / TRUE 1802692356 SSID A4isk9AE9xActvzYy
|
.youtube.com TRUE / TRUE 1802692356 SSID A4isk9AE9xActvzYy
|
||||||
.youtube.com TRUE / TRUE 1831894348 __Secure-1PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
.youtube.com TRUE / TRUE 1802692356 __Secure-1PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
||||||
.youtube.com TRUE / TRUE 1831894348 __Secure-1PSID g.a0005wie1jXMw44_RGjwtqg21AbatcdNseI3S_qNtsLYC1jS4YSUycKC58NH045FOFX6QW8fDwACgYKAacSARESFQHGX2MiA5xeTuJuh8QmBm-DS3l1ghoVAUF8yKr4klCBhb-EJgFQ9T0TGWKk0076
|
.youtube.com TRUE / TRUE 1802692356 __Secure-1PSID g.a0005gie1lAkmYZc-EPeGx77pCrXo_Cz5eAi-e9aryb9Qoz967v4-rF3xTavVHrJoyJAqShH6gACgYKAX0SARESFQHGX2MiOdAbUPmCj4MueYyh-2km5RoVAUF8yKp2ehWQC6tX8n-9UNg11RV60076
|
||||||
.youtube.com TRUE / TRUE 1831894348 __Secure-3PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
.youtube.com TRUE / TRUE 1802692356 __Secure-3PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
||||||
.youtube.com TRUE / TRUE 1800359997 __Secure-3PSIDCC AKEyXzW3W5Q-e4TIryFWpWS6zVuuVPOvwPIU2tzl1JRdYsGu-7f34g_amk2Xd2ttGtSJ6tOSdA
|
.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 1800281710 __Secure-3PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
|
||||||
.youtube.com TRUE / TRUE 1792154873 LOGIN_INFO AFmmF2swRQIgVjJk8Mho4_JuKr6SZzrhBdlL1LdxWxcwDMu4cjaRRgcCIQCTtJpmYKJH54Tiei3at3f4YT3US7gSL0lW_TZ04guKjQ:QUQ3MjNmeWlwRDJSNDl2NE9uX2JWWG5tWllHN0RsNUVZVUhsLVp4N2dWbldaeC14SnNybWVERnNoaXFpanFJczhKTjJSRGN6MEs3c1VkLTE1TGJVeFBPT05BY29NMFh0Q1VPdFU3dUdvSUpET3lQbU1ZMUlHUGltajlXNDllNUQxZHdzZko1WXF1UUJWclNxQVJ0TXVEYnF2bXJRY2V6Vl9n
|
.youtube.com TRUE / TRUE 1792154873 LOGIN_INFO AFmmF2swRQIgVjJk8Mho4_JuKr6SZzrhBdlL1LdxWxcwDMu4cjaRRgcCIQCTtJpmYKJH54Tiei3at3f4YT3US7gSL0lW_TZ04guKjQ:QUQ3MjNmeWlwRDJSNDl2NE9uX2JWWG5tWllHN0RsNUVZVUhsLVp4N2dWbldaeC14SnNybWVERnNoaXFpanFJczhKTjJSRGN6MEs3c1VkLTE1TGJVeFBPT05BY29NMFh0Q1VPdFU3dUdvSUpET3lQbU1ZMUlHUGltajlXNDllNUQxZHdzZko1WXF1UUJWclNxQVJ0TXVEYnF2bXJRY2V6Vl9n
|
||||||
.youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=150&hl=en
|
.youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=150&hl=en
|
||||||
.youtube.com TRUE / TRUE 0 YSC y-oH2BqaUSQ
|
.youtube.com TRUE / TRUE 0 YSC y-oH2BqaUSQ
|
||||||
.youtube.com TRUE / TRUE 1784333733 __Secure-ROLLOUT_TOKEN CPm51pHVjquOTRDw0bnsppWSAxjzxYe3qZaSAw%3D%3D
|
.youtube.com TRUE / TRUE 1784298680 __Secure-ROLLOUT_TOKEN CPm51pHVjquOTRDw0bnsppWSAxjw0bnsppWSAw%3D%3D
|
||||||
.youtube.com TRUE / TRUE 1784375997 VISITOR_INFO1_LIVE ShB1Bvj-rRU
|
.youtube.com TRUE / TRUE 1784298680 VISITOR_INFO1_LIVE ShB1Bvj-rRU
|
||||||
.youtube.com TRUE / TRUE 1784375997 VISITOR_PRIVACY_METADATA CgJWThIEGgAgWA%3D%3D
|
.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
|
Product Requirements Document (PRD) - KV-Tube
|
||||||
1. Product Overview
|
1. Product Overview
|
||||||
Product Name: KV-Tube Version: 1.0 (In Development) Description: KV-Tube is a comprehensive media center web application designed to provide an ad-free YouTube experience, a curated movie streaming service, and a local video management system. It emphasizes privacy, absence of advertisements, and utility features like AI summarization and language learning tools.
|
Product Name: KV-Tube Version: 1.0 (In Development) Description: KV-Tube is a comprehensive media center web application designed to provide an ad-free YouTube experience, a curated movie streaming service, and a local video management system. It emphasizes privacy, absence of advertisements, and utility features like AI summarization and language learning tools.
|
||||||
|
|
||||||
2. User Personas
|
2. User Personas
|
||||||
The Binge Watcher: Wants uninterrupted access to YouTube content and movies without ads.
|
The Binge Watcher: Wants uninterrupted access to YouTube content and movies without ads.
|
||||||
The Archivist: Maintains a local collection of videos and wants a clean interface to organize and watch them securely.
|
The Archivist: Maintains a local collection of videos and wants a clean interface to organize and watch them securely.
|
||||||
The Learner: Uses video content for educational purposes, specifically English learning.
|
The Learner: Uses video content for educational purposes, specifically English learning.
|
||||||
The Tech Enthusiast: Appreciates PWA support, torrent integration, and customizable settings.
|
The Tech Enthusiast: Appreciates PWA support, torrent integration, and customizable settings.
|
||||||
3. Core Features
|
3. Core Features
|
||||||
3.1. YouTube Viewer (Home)
|
3.1. YouTube Viewer (Home)
|
||||||
Ad-Free Experience: Plays YouTube videos without third-party advertisements.
|
Ad-Free Experience: Plays YouTube videos without third-party advertisements.
|
||||||
Search: Integrated search bar powered by yt-dlp to find videos, channels, and playlists.
|
Search: Integrated search bar powered by yt-dlp to find videos, channels, and playlists.
|
||||||
Playback: Custom video player with support for quality selection and playback speed.
|
Playback: Custom video player with support for quality selection and playback speed.
|
||||||
AI Summarization: Feature to summarize video content using Google Gemini API (Optional).
|
AI Summarization: Feature to summarize video content using Google Gemini API (Optional).
|
||||||
3.2. local Video Manager ("My Videos")
|
3.2. local Video Manager ("My Videos")
|
||||||
Secure Access: Password-protected section for personal video collections.
|
Secure Access: Password-protected section for personal video collections.
|
||||||
File Management: Scans local directories for video files.
|
File Management: Scans local directories for video files.
|
||||||
Metadata: Extracts metadata (duration, format) and generates thumbnails using FFmpeg/MoviePy.
|
Metadata: Extracts metadata (duration, format) and generates thumbnails using FFmpeg/MoviePy.
|
||||||
Playback: Native HTML5 player for local files.
|
Playback: Native HTML5 player for local files.
|
||||||
3.3. Utilities
|
3.3. Utilities
|
||||||
Torrent Player: Interface for streaming/playing video content via torrents.
|
Torrent Player: Interface for streaming/playing video content via torrents.
|
||||||
Playlist Manager: Create and manage custom playlists of YouTube videos.
|
Playlist Manager: Create and manage custom playlists of YouTube videos.
|
||||||
Camera/Photo: ("Chụp ảnh") Feature to capture or manage photos (Webcam integration).
|
Camera/Photo: ("Chụp ảnh") Feature to capture or manage photos (Webcam integration).
|
||||||
Configuration: Web-based settings to manage application behavior (e.g., password, storage paths).
|
Configuration: Web-based settings to manage application behavior (e.g., password, storage paths).
|
||||||
4. Technical Architecture
|
4. Technical Architecture
|
||||||
Backend: Python / Flask
|
Backend: Python / Flask
|
||||||
Frontend: HTML5, CSS3, JavaScript (Vanilla)
|
Frontend: HTML5, CSS3, JavaScript (Vanilla)
|
||||||
Database/Storage: JSON-based local storage and file system.
|
Database/Storage: JSON-based local storage and file system.
|
||||||
Video Processing: yt-dlp (YouTube), FFmpeg (Conversion/Thumbnail), MoviePy (Optional).
|
Video Processing: yt-dlp (YouTube), FFmpeg (Conversion/Thumbnail), MoviePy (Optional).
|
||||||
AI Service: Google Gemini API (for summarization).
|
AI Service: Google Gemini API (for summarization).
|
||||||
Deployment: Docker container support (xehopnet/kctube).
|
Deployment: Docker container support (xehopnet/kctube).
|
||||||
5. Non-Functional Requirements
|
5. Non-Functional Requirements
|
||||||
Performance: Fast load times and responsive UI.
|
Performance: Fast load times and responsive UI.
|
||||||
Compatibility: PWA-ready for installation on desktop and mobile.
|
Compatibility: PWA-ready for installation on desktop and mobile.
|
||||||
Reliability: graceful degradation if optional dependencies (MoviePy, Gemini) are missing.
|
Reliability: graceful degradation if optional dependencies (MoviePy, Gemini) are missing.
|
||||||
Privacy: No user tracking or external analytics.
|
Privacy: No user tracking or external analytics.
|
||||||
6. Known Limitations
|
6. Known Limitations
|
||||||
Search Reliability: Dependent on yt-dlp stability and YouTube's anti-bot measures.
|
Search Reliability: Dependent on yt-dlp stability and YouTube's anti-bot measures.
|
||||||
External APIs: Movie features rely on third-party APIs which may have downtime.
|
External APIs: Movie features rely on third-party APIs which may have downtime.
|
||||||
Dependency Management: Some Python libraries (MoviePy, Numpy) require compilation tools.
|
Dependency Management: Some Python libraries (MoviePy, Numpy) require compilation tools.
|
||||||
7. Future Roadmap
|
7. Future Roadmap
|
||||||
Database: Migrate from JSON to SQLite for better performance with large libraries.
|
Database: Migrate from JSON to SQLite for better performance with large libraries.
|
||||||
User Accounts: Individual user profiles and history.
|
User Accounts: Individual user profiles and history.
|
||||||
Offline Mode: Enhanced offline capabilities for PWA.
|
Offline Mode: Enhanced offline capabilities for PWA.
|
||||||
Casting: Support for Chromecast/AirPlay.
|
Casting: Support for Chromecast/AirPlay.
|
||||||
58
docker-compose.yml
Executable file → Normal file
58
docker-compose.yml
Executable file → Normal file
|
|
@ -1,29 +1,29 @@
|
||||||
# KV-Tube Docker Compose for Synology NAS
|
# KV-Tube Docker Compose for Synology NAS
|
||||||
# Usage: docker-compose up -d
|
# Usage: docker-compose up -d
|
||||||
|
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
kv-tube:
|
kv-tube:
|
||||||
build: .
|
build: .
|
||||||
image: vndangkhoa/kv-tube:latest
|
image: vndangkhoa/kv-tube:latest
|
||||||
container_name: kv-tube
|
container_name: kv-tube
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "5011:5000"
|
- "5011:5000"
|
||||||
volumes:
|
volumes:
|
||||||
# Persist data (Easy setup: Just maps a folder)
|
# Persist data (Easy setup: Just maps a folder)
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
# Local videos folder (Optional)
|
# Local videos folder (Optional)
|
||||||
# - ./videos:/app/youtube_downloads
|
# - ./videos:/app/youtube_downloads
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- FLASK_ENV=production
|
- FLASK_ENV=production
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD", "curl", "-f", "http://localhost:5000/" ]
|
test: [ "CMD", "curl", "-f", "http://localhost:5000/" ]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
|
|
|
||||||
0
entrypoint.sh
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
|
flask
|
||||||
requests
|
requests
|
||||||
yt-dlp>=2024.1.0
|
yt-dlp>=2024.1.0
|
||||||
werkzeug
|
werkzeug
|
||||||
gunicorn
|
gunicorn
|
||||||
python-dotenv
|
python-dotenv
|
||||||
googletrans==4.0.0-rc1
|
|
||||||
# ytfetcher - optional, requires Python 3.11-3.13
|
|
||||||
|
|
||||||
|
|
|
||||||
0
start.sh
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
|
* KV-Tube AI Chat Styles
|
||||||
* Styling for the transcript Q&A chatbot panel
|
* Styling for the transcript Q&A chatbot panel
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Floating AI Bubble Button */
|
/* Floating AI Bubble Button */
|
||||||
.ai-chat-bubble {
|
.ai-chat-bubble {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 90px;
|
bottom: 90px;
|
||||||
/* Above the back button */
|
/* Above the back button */
|
||||||
right: 20px;
|
right: 20px;
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 9998;
|
z-index: 9998;
|
||||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
|
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
|
||||||
transition: transform 0.3s, box-shadow 0.3s;
|
transition: transform 0.3s, box-shadow 0.3s;
|
||||||
animation: bubble-pulse 2s infinite;
|
animation: bubble-pulse 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-bubble:hover {
|
.ai-chat-bubble:hover {
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
box-shadow: 0 6px 24px rgba(102, 126, 234, 0.6);
|
box-shadow: 0 6px 24px rgba(102, 126, 234, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-bubble.active {
|
.ai-chat-bubble.active {
|
||||||
animation: none;
|
animation: none;
|
||||||
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes bubble-pulse {
|
@keyframes bubble-pulse {
|
||||||
|
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
|
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
box-shadow: 0 4px 24px rgba(102, 126, 234, 0.7);
|
box-shadow: 0 4px 24px rgba(102, 126, 234, 0.7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide bubble on desktop when chat is open */
|
/* Hide bubble on desktop when chat is open */
|
||||||
.ai-chat-panel.visible~.ai-chat-bubble,
|
.ai-chat-panel.visible~.ai-chat-bubble,
|
||||||
body.ai-chat-open .ai-chat-bubble {
|
body.ai-chat-open .ai-chat-bubble {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chat Panel Container */
|
/* Chat Panel Container */
|
||||||
.ai-chat-panel {
|
.ai-chat-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 160px;
|
bottom: 160px;
|
||||||
/* Position above the bubble */
|
/* Position above the bubble */
|
||||||
right: 20px;
|
right: 20px;
|
||||||
width: 380px;
|
width: 380px;
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
background: var(--yt-bg-primary, #0f0f0f);
|
background: var(--yt-bg-primary, #0f0f0f);
|
||||||
border: 1px solid var(--yt-border, #272727);
|
border: 1px solid var(--yt-border, #272727);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transform: translateY(20px) scale(0.95);
|
transform: translateY(20px) scale(0.95);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s;
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-panel.visible {
|
.ai-chat-panel.visible {
|
||||||
transform: translateY(0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chat Header */
|
/* Chat Header */
|
||||||
.ai-chat-header {
|
.ai-chat-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-header h4 {
|
.ai-chat-header h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-close {
|
.ai-chat-close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-close:hover {
|
.ai-chat-close:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Model Status */
|
/* Model Status */
|
||||||
.ai-model-status {
|
.ai-model-status {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-model-status.loading {
|
.ai-model-status.loading {
|
||||||
color: #ffd700;
|
color: #ffd700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-model-status.ready {
|
.ai-model-status.ready {
|
||||||
color: #00ff88;
|
color: #00ff88;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Messages Container */
|
/* Messages Container */
|
||||||
.ai-chat-messages {
|
.ai-chat-messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Message Bubbles */
|
/* Message Bubbles */
|
||||||
.ai-message {
|
.ai-message {
|
||||||
max-width: 85%;
|
max-width: 85%;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-message.user {
|
.ai-message.user {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
background: #3ea6ff;
|
background: #3ea6ff;
|
||||||
color: white;
|
color: white;
|
||||||
border-bottom-right-radius: 4px;
|
border-bottom-right-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-message.assistant {
|
.ai-message.assistant {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
background: var(--yt-bg-secondary, #272727);
|
background: var(--yt-bg-secondary, #272727);
|
||||||
color: var(--yt-text-primary, #fff);
|
color: var(--yt-text-primary, #fff);
|
||||||
border-bottom-left-radius: 4px;
|
border-bottom-left-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-message.system {
|
.ai-message.system {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--yt-text-secondary, #aaa);
|
color: var(--yt-text-secondary, #aaa);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Typing Indicator */
|
/* Typing Indicator */
|
||||||
.ai-typing {
|
.ai-typing {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-typing span {
|
.ai-typing span {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
background: var(--yt-text-secondary, #aaa);
|
background: var(--yt-text-secondary, #aaa);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: typing 1.2s infinite;
|
animation: typing 1.2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-typing span:nth-child(2) {
|
.ai-typing span:nth-child(2) {
|
||||||
animation-delay: 0.2s;
|
animation-delay: 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-typing span:nth-child(3) {
|
.ai-typing span:nth-child(3) {
|
||||||
animation-delay: 0.4s;
|
animation-delay: 0.4s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes typing {
|
@keyframes typing {
|
||||||
|
|
||||||
0%,
|
0%,
|
||||||
60%,
|
60%,
|
||||||
100% {
|
100% {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
30% {
|
30% {
|
||||||
transform: translateY(-8px);
|
transform: translateY(-8px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Input Area */
|
/* Input Area */
|
||||||
.ai-chat-input {
|
.ai-chat-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-top: 1px solid var(--yt-border, #272727);
|
border-top: 1px solid var(--yt-border, #272727);
|
||||||
background: var(--yt-bg-secondary, #181818);
|
background: var(--yt-bg-secondary, #181818);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-input input {
|
.ai-chat-input input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--yt-bg-primary, #0f0f0f);
|
background: var(--yt-bg-primary, #0f0f0f);
|
||||||
border: 1px solid var(--yt-border, #272727);
|
border: 1px solid var(--yt-border, #272727);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
color: var(--yt-text-primary, #fff);
|
color: var(--yt-text-primary, #fff);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-input input:focus {
|
.ai-chat-input input:focus {
|
||||||
border-color: #3ea6ff;
|
border-color: #3ea6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-input input::placeholder {
|
.ai-chat-input input::placeholder {
|
||||||
color: var(--yt-text-secondary, #aaa);
|
color: var(--yt-text-secondary, #aaa);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-send {
|
.ai-chat-send {
|
||||||
background: #3ea6ff;
|
background: #3ea6ff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
color: white;
|
color: white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: background 0.2s, transform 0.2s;
|
transition: background 0.2s, transform 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-send:hover {
|
.ai-chat-send:hover {
|
||||||
background: #2d8fd9;
|
background: #2d8fd9;
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-send:disabled {
|
.ai-chat-send:disabled {
|
||||||
background: #555;
|
background: #555;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Download Progress */
|
/* Download Progress */
|
||||||
.ai-download-progress {
|
.ai-download-progress {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-download-bar {
|
.ai-download-bar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
background: var(--yt-bg-secondary, #272727);
|
background: var(--yt-bg-secondary, #272727);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-download-fill {
|
.ai-download-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
transition: width 0.3s;
|
transition: width 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-download-text {
|
.ai-download-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--yt-text-secondary, #aaa);
|
color: var(--yt-text-secondary, #aaa);
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile */
|
/* Mobile */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.ai-chat-bubble {
|
.ai-chat-bubble {
|
||||||
bottom: 100px;
|
bottom: 100px;
|
||||||
/* More space above back button */
|
/* More space above back button */
|
||||||
right: 24px;
|
right: 24px;
|
||||||
/* Aligned with back button */
|
/* Aligned with back button */
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-panel {
|
.ai-chat-panel {
|
||||||
width: calc(100% - 20px);
|
width: calc(100% - 20px);
|
||||||
left: 10px;
|
left: 10px;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
bottom: 160px;
|
bottom: 160px;
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
0
static/css/modules/components.css
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 */
|
/* KV-Tube - YouTube Clone Design System */
|
||||||
|
|
||||||
/* Core */
|
/* Core */
|
||||||
@import 'modules/variables.css';
|
@import 'modules/variables.css';
|
||||||
@import 'modules/base.css';
|
@import 'modules/base.css';
|
||||||
@import 'modules/utils.css';
|
@import 'modules/utils.css';
|
||||||
|
|
||||||
/* Layout & Structure */
|
/* Layout & Structure */
|
||||||
@import 'modules/layout.css';
|
@import 'modules/layout.css';
|
||||||
@import 'modules/grid.css';
|
@import 'modules/grid.css';
|
||||||
|
|
||||||
/* Components */
|
/* Components */
|
||||||
@import 'modules/components.css';
|
@import 'modules/components.css';
|
||||||
@import 'modules/cards.css';
|
@import 'modules/cards.css';
|
||||||
|
|
||||||
/* Pages */
|
/* Pages */
|
||||||
@import 'modules/pages.css';
|
@import 'modules/pages.css';
|
||||||
/* Hide extension-injected error elements */
|
/* Hide extension-injected error elements */
|
||||||
*[/onboarding/],
|
*[/onboarding/],
|
||||||
|
|
|
||||||
0
static/favicon.ico
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
|
* KV-Tube Navigation Manager
|
||||||
* Handles SPA-style navigation to persist state (like downloads) across pages.
|
* Handles SPA-style navigation to persist state (like downloads) across pages.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class NavigationManager {
|
class NavigationManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.mainContentId = 'mainContent';
|
this.mainContentId = 'mainContent';
|
||||||
this.pageCache = new Map();
|
this.pageCache = new Map();
|
||||||
this.maxCacheSize = 20;
|
this.maxCacheSize = 20;
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Handle browser back/forward buttons
|
// Handle browser back/forward buttons
|
||||||
window.addEventListener('popstate', (e) => {
|
window.addEventListener('popstate', (e) => {
|
||||||
if (e.state && e.state.url) {
|
if (e.state && e.state.url) {
|
||||||
this.loadPage(e.state.url, false);
|
this.loadPage(e.state.url, false);
|
||||||
} else {
|
} else {
|
||||||
// Fallback for initial state or external navigation
|
// Fallback for initial state or external navigation
|
||||||
this.loadPage(window.location.href, false);
|
this.loadPage(window.location.href, false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Intercept clicks
|
// Intercept clicks
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
// Find closest anchor tag
|
// Find closest anchor tag
|
||||||
const link = e.target.closest('a');
|
const link = e.target.closest('a');
|
||||||
|
|
||||||
// Check if it's an internal link and not a download/special link
|
// Check if it's an internal link and not a download/special link
|
||||||
if (link &&
|
if (link &&
|
||||||
link.href &&
|
link.href &&
|
||||||
link.href.startsWith(window.location.origin) &&
|
link.href.startsWith(window.location.origin) &&
|
||||||
!link.getAttribute('download') &&
|
!link.getAttribute('download') &&
|
||||||
!link.getAttribute('target') &&
|
!link.getAttribute('target') &&
|
||||||
!link.classList.contains('no-spa') &&
|
!link.classList.contains('no-spa') &&
|
||||||
!e.ctrlKey && !e.metaKey && !e.shiftKey // Allow new tab clicks
|
!e.ctrlKey && !e.metaKey && !e.shiftKey // Allow new tab clicks
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const url = link.href;
|
const url = link.href;
|
||||||
this.navigateTo(url);
|
this.navigateTo(url);
|
||||||
|
|
||||||
// Update active state in sidebar
|
// Update active state in sidebar
|
||||||
this.updateSidebarActiveState(link);
|
this.updateSidebarActiveState(link);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save initial state
|
// Save initial state
|
||||||
const currentUrl = window.location.href;
|
const currentUrl = window.location.href;
|
||||||
if (!this.pageCache.has(currentUrl)) {
|
if (!this.pageCache.has(currentUrl)) {
|
||||||
// We don't have the raw HTML, so we can't fully cache the initial page accurately
|
// We don't have the raw HTML, so we can't fully cache the initial page accurately
|
||||||
// without fetching it or serializing current DOM.
|
// without fetching it or serializing current DOM.
|
||||||
// For now, we will cache it upon *leaving* securely or just let the first visit be uncached.
|
// For now, we will cache it upon *leaving* securely or just let the first visit be uncached.
|
||||||
// Better: Cache the current DOM state as the "initial" state.
|
// Better: Cache the current DOM state as the "initial" state.
|
||||||
this.saveCurrentState(currentUrl);
|
this.saveCurrentState(currentUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saveCurrentState(url) {
|
saveCurrentState(url) {
|
||||||
const mainContent = document.getElementById(this.mainContentId);
|
const mainContent = document.getElementById(this.mainContentId);
|
||||||
if (mainContent) {
|
if (mainContent) {
|
||||||
this.pageCache.set(url, {
|
this.pageCache.set(url, {
|
||||||
html: mainContent.innerHTML,
|
html: mainContent.innerHTML,
|
||||||
title: document.title,
|
title: document.title,
|
||||||
scrollY: window.scrollY,
|
scrollY: window.scrollY,
|
||||||
className: mainContent.className
|
className: mainContent.className
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prune cache
|
// Prune cache
|
||||||
if (this.pageCache.size > this.maxCacheSize) {
|
if (this.pageCache.size > this.maxCacheSize) {
|
||||||
const firstKey = this.pageCache.keys().next().value;
|
const firstKey = this.pageCache.keys().next().value;
|
||||||
this.pageCache.delete(firstKey);
|
this.pageCache.delete(firstKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async navigateTo(url) {
|
async navigateTo(url) {
|
||||||
// Start Progress Bar
|
// Start Progress Bar
|
||||||
const bar = document.getElementById('nprogress-bar');
|
const bar = document.getElementById('nprogress-bar');
|
||||||
if (bar) {
|
if (bar) {
|
||||||
bar.style.opacity = '1';
|
bar.style.opacity = '1';
|
||||||
bar.style.width = '30%';
|
bar.style.width = '30%';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save state of current page before leaving
|
// Save state of current page before leaving
|
||||||
this.saveCurrentState(window.location.href);
|
this.saveCurrentState(window.location.href);
|
||||||
|
|
||||||
// Update history
|
// Update history
|
||||||
history.pushState({ url: url }, '', url);
|
history.pushState({ url: url }, '', url);
|
||||||
await this.loadPage(url);
|
await this.loadPage(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadPage(url, pushState = true) {
|
async loadPage(url, pushState = true) {
|
||||||
const bar = document.getElementById('nprogress-bar');
|
const bar = document.getElementById('nprogress-bar');
|
||||||
if (bar) bar.style.width = '60%';
|
if (bar) bar.style.width = '60%';
|
||||||
|
|
||||||
const mainContent = document.getElementById(this.mainContentId);
|
const mainContent = document.getElementById(this.mainContentId);
|
||||||
if (!mainContent) return;
|
if (!mainContent) return;
|
||||||
|
|
||||||
// Check cache
|
// Check cache
|
||||||
if (this.pageCache.has(url)) {
|
if (this.pageCache.has(url)) {
|
||||||
const cached = this.pageCache.get(url);
|
const cached = this.pageCache.get(url);
|
||||||
|
|
||||||
// Restore content
|
// Restore content
|
||||||
document.title = cached.title;
|
document.title = cached.title;
|
||||||
mainContent.innerHTML = cached.html;
|
mainContent.innerHTML = cached.html;
|
||||||
mainContent.className = cached.className;
|
mainContent.className = cached.className;
|
||||||
|
|
||||||
// Re-execute scripts
|
// Re-execute scripts
|
||||||
this.executeScripts(mainContent);
|
this.executeScripts(mainContent);
|
||||||
|
|
||||||
// Re-initialize App
|
// Re-initialize App
|
||||||
if (typeof window.initApp === 'function') {
|
if (typeof window.initApp === 'function') {
|
||||||
window.initApp();
|
window.initApp();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore scroll
|
// Restore scroll
|
||||||
window.scrollTo(0, cached.scrollY);
|
window.scrollTo(0, cached.scrollY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading state if needed
|
// Show loading state if needed
|
||||||
mainContent.style.opacity = '0.5';
|
mainContent.style.opacity = '0.5';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
|
|
||||||
// Parse HTML
|
// Parse HTML
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(html, 'text/html');
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
|
|
||||||
// Extract new content
|
// Extract new content
|
||||||
const newContent = doc.getElementById(this.mainContentId);
|
const newContent = doc.getElementById(this.mainContentId);
|
||||||
if (!newContent) {
|
if (!newContent) {
|
||||||
// Check if it's a full page not extending layout properly or error
|
// Check if it's a full page not extending layout properly or error
|
||||||
console.error('Could not find mainContent in response');
|
console.error('Could not find mainContent in response');
|
||||||
window.location.href = url; // Fallback to full reload
|
window.location.href = url; // Fallback to full reload
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update title
|
// Update title
|
||||||
document.title = doc.title;
|
document.title = doc.title;
|
||||||
|
|
||||||
// Replace content
|
// Replace content
|
||||||
mainContent.innerHTML = newContent.innerHTML;
|
mainContent.innerHTML = newContent.innerHTML;
|
||||||
mainContent.className = newContent.className; // Maintain classes
|
mainContent.className = newContent.className; // Maintain classes
|
||||||
|
|
||||||
// Execute scripts found in the new content (critical for APP_CONFIG)
|
// Execute scripts found in the new content (critical for APP_CONFIG)
|
||||||
this.executeScripts(mainContent);
|
this.executeScripts(mainContent);
|
||||||
|
|
||||||
// Re-initialize App logic
|
// Re-initialize App logic
|
||||||
if (typeof window.initApp === 'function') {
|
if (typeof window.initApp === 'function') {
|
||||||
window.initApp();
|
window.initApp();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to top for new pages
|
// Scroll to top for new pages
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
|
|
||||||
// Save to cache (initial state of this page)
|
// Save to cache (initial state of this page)
|
||||||
this.pageCache.set(url, {
|
this.pageCache.set(url, {
|
||||||
html: newContent.innerHTML,
|
html: newContent.innerHTML,
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
scrollY: 0,
|
scrollY: 0,
|
||||||
className: newContent.className
|
className: newContent.className
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Navigation error:', error);
|
console.error('Navigation error:', error);
|
||||||
// Fallback
|
// Fallback
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
} finally {
|
} finally {
|
||||||
mainContent.style.opacity = '1';
|
mainContent.style.opacity = '1';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
executeScripts(element) {
|
executeScripts(element) {
|
||||||
const scripts = element.querySelectorAll('script');
|
const scripts = element.querySelectorAll('script');
|
||||||
scripts.forEach(oldScript => {
|
scripts.forEach(oldScript => {
|
||||||
const newScript = document.createElement('script');
|
const newScript = document.createElement('script');
|
||||||
Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value));
|
Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value));
|
||||||
newScript.textContent = oldScript.textContent;
|
newScript.textContent = oldScript.textContent;
|
||||||
oldScript.parentNode.replaceChild(newScript, oldScript);
|
oldScript.parentNode.replaceChild(newScript, oldScript);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSidebarActiveState(clickedLink) {
|
updateSidebarActiveState(clickedLink) {
|
||||||
// Remove active class from all items
|
// Remove active class from all items
|
||||||
document.querySelectorAll('.yt-sidebar-item').forEach(item => item.classList.remove('active'));
|
document.querySelectorAll('.yt-sidebar-item').forEach(item => item.classList.remove('active'));
|
||||||
|
|
||||||
// Add to clicked if it is a sidebar item
|
// Add to clicked if it is a sidebar item
|
||||||
if (clickedLink.classList.contains('yt-sidebar-item')) {
|
if (clickedLink.classList.contains('yt-sidebar-item')) {
|
||||||
clickedLink.classList.add('active');
|
clickedLink.classList.add('active');
|
||||||
} else {
|
} else {
|
||||||
// Try to find matching sidebar item
|
// Try to find matching sidebar item
|
||||||
const path = new URL(clickedLink.href).pathname;
|
const path = new URL(clickedLink.href).pathname;
|
||||||
const match = document.querySelector(`.yt-sidebar-item[href="${path}"]`);
|
const match = document.querySelector(`.yt-sidebar-item[href="${path}"]`);
|
||||||
if (match) match.classList.add('active');
|
if (match) match.classList.add('active');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
window.navigationManager = new NavigationManager();
|
window.navigationManager = new NavigationManager();
|
||||||
|
|
|
||||||
|
|
@ -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" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="yt-container yt-channel-page">
|
<div class="yt-container yt-channel-page">
|
||||||
|
|
||||||
<!-- Channel Header (No Banner) -->
|
<!-- Channel Header (No Banner) -->
|
||||||
<div class="yt-channel-header">
|
<div class="yt-channel-header">
|
||||||
<div class="yt-channel-info-row">
|
<div class="yt-channel-info-row">
|
||||||
<div class="yt-channel-avatar-xl" id="channelAvatarLarge">
|
<div class="yt-channel-avatar-xl" id="channelAvatarLarge">
|
||||||
{% if channel.avatar %}
|
{% if channel.avatar %}
|
||||||
<img src="{{ channel.avatar }}">
|
<img src="{{ channel.avatar }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<span id="channelAvatarLetter">{{ channel.title[0] | upper if channel.title and channel.title !=
|
<span id="channelAvatarLetter">{{ channel.title[0] | upper if channel.title and channel.title !=
|
||||||
'Loading...' else 'C' }}</span>
|
'Loading...' else 'C' }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-channel-meta">
|
<div class="yt-channel-meta">
|
||||||
<h1 id="channelTitle">{{ channel.title if channel.title and channel.title != 'Loading...' else
|
<h1 id="channelTitle">{{ channel.title if channel.title and channel.title != 'Loading...' else
|
||||||
'Loading...' }}</h1>
|
'Loading...' }}</h1>
|
||||||
<p class="yt-channel-handle" id="channelHandle">
|
<p class="yt-channel-handle" id="channelHandle">
|
||||||
{% if channel.title and channel.title != 'Loading...' %}@{{ channel.title|replace(' ', '') }}{% else
|
{% if channel.title and channel.title != 'Loading...' %}@{{ channel.title|replace(' ', '') }}{% else
|
||||||
%}@Loading...{% endif %}
|
%}@Loading...{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<div class="yt-channel-stats">
|
<div class="yt-channel-stats">
|
||||||
<span id="channelStats"></span>
|
<span id="channelStats"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video Grid -->
|
<!-- Video Grid -->
|
||||||
<div class="yt-section">
|
<div class="yt-section">
|
||||||
<div class="yt-section-header">
|
<div class="yt-section-header">
|
||||||
<div class="yt-tabs">
|
<div class="yt-tabs">
|
||||||
<a href="javascript:void(0)" onclick="changeChannelTab('video', this); return false;"
|
<a href="javascript:void(0)" onclick="changeChannelTab('video', this); return false;"
|
||||||
class="active no-spa">
|
class="active no-spa">
|
||||||
<i class="fas fa-video"></i>
|
<i class="fas fa-video"></i>
|
||||||
<span>Videos</span>
|
<span>Videos</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="javascript:void(0)" onclick="changeChannelTab('shorts', this); return false;" class="no-spa">
|
<a href="javascript:void(0)" onclick="changeChannelTab('shorts', this); return false;" class="no-spa">
|
||||||
<i class="fas fa-bolt"></i>
|
<i class="fas fa-bolt"></i>
|
||||||
<span>Shorts</span>
|
<span>Shorts</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="yt-sort-options">
|
<div class="yt-sort-options">
|
||||||
<a href="javascript:void(0)" onclick="changeChannelSort('latest', this); return false;"
|
<a href="javascript:void(0)" onclick="changeChannelSort('latest', this); return false;"
|
||||||
class="active no-spa">
|
class="active no-spa">
|
||||||
<i class="fas fa-clock"></i>
|
<i class="fas fa-clock"></i>
|
||||||
<span>Latest</span>
|
<span>Latest</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="javascript:void(0)" onclick="changeChannelSort('popular', this); return false;" class="no-spa">
|
<a href="javascript:void(0)" onclick="changeChannelSort('popular', this); return false;" class="no-spa">
|
||||||
<i class="fas fa-fire"></i>
|
<i class="fas fa-fire"></i>
|
||||||
<span>Popular</span>
|
<span>Popular</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="javascript:void(0)" onclick="changeChannelSort('oldest', this); return false;" class="no-spa">
|
<a href="javascript:void(0)" onclick="changeChannelSort('oldest', this); return false;" class="no-spa">
|
||||||
<i class="fas fa-history"></i>
|
<i class="fas fa-history"></i>
|
||||||
<span>Oldest</span>
|
<span>Oldest</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="yt-video-grid" id="channelVideosGrid">
|
<div class="yt-video-grid" id="channelVideosGrid">
|
||||||
<!-- Videos loaded via JS -->
|
<!-- Videos loaded via JS -->
|
||||||
</div>
|
</div>
|
||||||
<div id="channelLoadingTrigger" style="height: 20px; margin: 20px 0;"></div>
|
<div id="channelLoadingTrigger" style="height: 20px; margin: 20px 0;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.yt-channel-page {
|
.yt-channel-page {
|
||||||
padding-top: 40px;
|
padding-top: 40px;
|
||||||
padding-bottom: 40px;
|
padding-bottom: 40px;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Removed .yt-channel-banner */
|
/* Removed .yt-channel-banner */
|
||||||
|
|
||||||
.yt-channel-info-row {
|
.yt-channel-info-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 32px;
|
gap: 32px;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-avatar-xl {
|
.yt-channel-avatar-xl {
|
||||||
width: 160px;
|
width: 160px;
|
||||||
height: 160px;
|
height: 160px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%);
|
background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%);
|
||||||
/* Simpler color for no-banner look */
|
/* Simpler color for no-banner look */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 64px;
|
font-size: 64px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: white;
|
color: white;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-avatar-xl img {
|
.yt-channel-avatar-xl img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-meta {
|
.yt-channel-meta {
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-meta h1 {
|
.yt-channel-meta h1 {
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-handle {
|
.yt-channel-handle {
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-stats {
|
.yt-channel-stats {
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-section-header {
|
.yt-section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-tabs {
|
.yt-tabs {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: var(--yt-bg-secondary);
|
background: var(--yt-bg-secondary);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-tabs a {
|
.yt-tabs a {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: all 0.25s ease;
|
transition: all 0.25s ease;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-tabs a:hover {
|
.yt-tabs a:hover {
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
background: var(--yt-bg-hover);
|
background: var(--yt-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-tabs a.active {
|
.yt-tabs a.active {
|
||||||
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-sort-options {
|
.yt-sort-options {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: var(--yt-bg-secondary);
|
background: var(--yt-bg-secondary);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-sort-options a {
|
.yt-sort-options a {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: all 0.25s ease;
|
transition: all 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-sort-options a:hover {
|
.yt-sort-options a:hover {
|
||||||
background: var(--yt-bg-hover);
|
background: var(--yt-bg-hover);
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-sort-options a.active {
|
.yt-sort-options a.active {
|
||||||
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shorts Card Styling override for Channel Page grid */
|
/* Shorts Card Styling override for Channel Page grid */
|
||||||
.yt-channel-short-card {
|
.yt-channel-short-card {
|
||||||
border-radius: var(--yt-radius-lg);
|
border-radius: var(--yt-radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-short-card:hover {
|
.yt-channel-short-card:hover {
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-short-thumb-container {
|
.yt-short-thumb-container {
|
||||||
aspect-ratio: 9/16;
|
aspect-ratio: 9/16;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-short-thumb {
|
.yt-short-thumb {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.yt-channel-info-row {
|
.yt-channel-info-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-avatar-xl {
|
.yt-channel-avatar-xl {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-meta h1 {
|
.yt-channel-meta h1 {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-section-header {
|
.yt-section-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-tabs {
|
.yt-tabs {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
// IIFE to prevent variable redeclaration errors on SPA navigation
|
// IIFE to prevent variable redeclaration errors on SPA navigation
|
||||||
console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}");
|
console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}");
|
||||||
|
|
||||||
var currentChannelSort = 'latest';
|
var currentChannelSort = 'latest';
|
||||||
var currentChannelPage = 1;
|
var currentChannelPage = 1;
|
||||||
var isChannelLoading = false;
|
var isChannelLoading = false;
|
||||||
var hasMoreChannelVideos = true;
|
var hasMoreChannelVideos = true;
|
||||||
var currentFilterType = 'video';
|
var currentFilterType = 'video';
|
||||||
var channelId = "{{ channel.id }}";
|
var channelId = "{{ channel.id }}";
|
||||||
// Store initial channel title from server template (don't overwrite with empty API data)
|
// Store initial channel title from server template (don't overwrite with empty API data)
|
||||||
var initialChannelTitle = "{{ channel.title }}";
|
var initialChannelTitle = "{{ channel.title }}";
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
console.log("Channel init called, fetching content...");
|
console.log("Channel init called, fetching content...");
|
||||||
fetchChannelContent();
|
fetchChannelContent();
|
||||||
setupInfiniteScroll();
|
setupInfiniteScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle both initial page load and SPA navigation
|
// Handle both initial page load and SPA navigation
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
} else {
|
} else {
|
||||||
// DOM is already ready (SPA navigation)
|
// DOM is already ready (SPA navigation)
|
||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeChannelTab(type, btn) {
|
function changeChannelTab(type, btn) {
|
||||||
if (type === currentFilterType || isChannelLoading) return;
|
if (type === currentFilterType || isChannelLoading) return;
|
||||||
currentFilterType = type;
|
currentFilterType = type;
|
||||||
currentChannelPage = 1;
|
currentChannelPage = 1;
|
||||||
hasMoreChannelVideos = true;
|
hasMoreChannelVideos = true;
|
||||||
document.getElementById('channelVideosGrid').innerHTML = '';
|
document.getElementById('channelVideosGrid').innerHTML = '';
|
||||||
|
|
||||||
// Update Tabs UI
|
// Update Tabs UI
|
||||||
document.querySelectorAll('.yt-tabs a').forEach(a => a.classList.remove('active'));
|
document.querySelectorAll('.yt-tabs a').forEach(a => a.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
|
|
||||||
// Adjust Grid layout for Shorts vs Videos
|
// Adjust Grid layout for Shorts vs Videos
|
||||||
const grid = document.getElementById('channelVideosGrid');
|
const grid = document.getElementById('channelVideosGrid');
|
||||||
if (type === 'shorts') {
|
if (type === 'shorts') {
|
||||||
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
|
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
|
||||||
} else {
|
} else {
|
||||||
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))';
|
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))';
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchChannelContent();
|
fetchChannelContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeChannelSort(sort, btn) {
|
function changeChannelSort(sort, btn) {
|
||||||
if (isChannelLoading) return;
|
if (isChannelLoading) return;
|
||||||
currentChannelSort = sort;
|
currentChannelSort = sort;
|
||||||
currentChannelPage = 1;
|
currentChannelPage = 1;
|
||||||
hasMoreChannelVideos = true;
|
hasMoreChannelVideos = true;
|
||||||
document.getElementById('channelVideosGrid').innerHTML = ''; // Clear
|
document.getElementById('channelVideosGrid').innerHTML = ''; // Clear
|
||||||
|
|
||||||
// Update tabs
|
// Update tabs
|
||||||
document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active'));
|
document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
|
|
||||||
fetchChannelContent();
|
fetchChannelContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchChannelContent() {
|
async function fetchChannelContent() {
|
||||||
console.log("fetchChannelContent() called");
|
console.log("fetchChannelContent() called");
|
||||||
if (isChannelLoading || !hasMoreChannelVideos) {
|
if (isChannelLoading || !hasMoreChannelVideos) {
|
||||||
console.log("Early return:", { isChannelLoading, hasMoreChannelVideos });
|
console.log("Early return:", { isChannelLoading, hasMoreChannelVideos });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isChannelLoading = true;
|
isChannelLoading = true;
|
||||||
|
|
||||||
const grid = document.getElementById('channelVideosGrid');
|
const grid = document.getElementById('channelVideosGrid');
|
||||||
|
|
||||||
// Append Loading indicator
|
// Append Loading indicator
|
||||||
if (typeof renderSkeleton === 'function') {
|
if (typeof renderSkeleton === 'function') {
|
||||||
grid.insertAdjacentHTML('beforeend', renderSkeleton(4));
|
grid.insertAdjacentHTML('beforeend', renderSkeleton(4));
|
||||||
} else {
|
} else {
|
||||||
grid.insertAdjacentHTML('beforeend', '<div class="loading-text" style="color:var(--yt-text-secondary); padding: 20px;">Loading videos...</div>');
|
grid.insertAdjacentHTML('beforeend', '<div class="loading-text" style="color:var(--yt-text-secondary); padding: 20px;">Loading videos...</div>');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`Fetching: /api/channel?id=${channelId}&page=${currentChannelPage}`);
|
console.log(`Fetching: /api/channel?id=${channelId}&page=${currentChannelPage}`);
|
||||||
const response = await fetch(`/api/channel?id=${channelId}&page=${currentChannelPage}&sort=${currentChannelSort}&filter_type=${currentFilterType}`);
|
const response = await fetch(`/api/channel?id=${channelId}&page=${currentChannelPage}&sort=${currentChannelSort}&filter_type=${currentFilterType}`);
|
||||||
const videos = await response.json();
|
const videos = await response.json();
|
||||||
console.log("Channel Videos Response:", videos);
|
console.log("Channel Videos Response:", videos);
|
||||||
|
|
||||||
// Remove skeletons (simple way: remove last N children or just clear all if page 1?
|
// Remove skeletons (simple way: remove last N children or just clear all if page 1?
|
||||||
// Better: mark skeletons with class and remove)
|
// Better: mark skeletons with class and remove)
|
||||||
// For simplicity in this v1: We just clear skeletons by removing elements with 'skeleton-card' class
|
// For simplicity in this v1: We just clear skeletons by removing elements with 'skeleton-card' class
|
||||||
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
|
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
|
||||||
|
|
||||||
// Check if response is an error
|
// Check if response is an error
|
||||||
if (videos.error) {
|
if (videos.error) {
|
||||||
hasMoreChannelVideos = false;
|
hasMoreChannelVideos = false;
|
||||||
grid.innerHTML = `<p style="padding:20px; color:var(--yt-text-secondary);">Error: ${videos.error}</p>`;
|
grid.innerHTML = `<p style="padding:20px; color:var(--yt-text-secondary);">Error: ${videos.error}</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(videos) || videos.length === 0) {
|
if (!Array.isArray(videos) || videos.length === 0) {
|
||||||
hasMoreChannelVideos = false;
|
hasMoreChannelVideos = false;
|
||||||
if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>';
|
if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>';
|
||||||
} else {
|
} else {
|
||||||
// Update channel header with uploader info from first video (on first page only)
|
// Update channel header with uploader info from first video (on first page only)
|
||||||
if (currentChannelPage === 1 && videos[0]) {
|
if (currentChannelPage === 1 && videos[0]) {
|
||||||
// Use only proper channel/uploader fields - do NOT parse from title
|
// Use only proper channel/uploader fields - do NOT parse from title
|
||||||
let channelName = videos[0].channel || videos[0].uploader || '';
|
let channelName = videos[0].channel || videos[0].uploader || '';
|
||||||
|
|
||||||
// Only update header if API returned a meaningful name
|
// Only update header if API returned a meaningful name
|
||||||
// (not empty, not just the channel ID, and not "Loading...")
|
// (not empty, not just the channel ID, and not "Loading...")
|
||||||
if (channelName && channelName !== channelId &&
|
if (channelName && channelName !== channelId &&
|
||||||
!channelName.startsWith('UC') && channelName !== 'Loading...') {
|
!channelName.startsWith('UC') && channelName !== 'Loading...') {
|
||||||
|
|
||||||
document.getElementById('channelTitle').textContent = channelName;
|
document.getElementById('channelTitle').textContent = channelName;
|
||||||
document.getElementById('channelHandle').textContent = '@' + channelName.replace(/\s+/g, '');
|
document.getElementById('channelHandle').textContent = '@' + channelName.replace(/\s+/g, '');
|
||||||
const avatarLetter = document.getElementById('channelAvatarLetter');
|
const avatarLetter = document.getElementById('channelAvatarLetter');
|
||||||
if (avatarLetter) avatarLetter.textContent = channelName.charAt(0).toUpperCase();
|
if (avatarLetter) avatarLetter.textContent = channelName.charAt(0).toUpperCase();
|
||||||
}
|
}
|
||||||
// If no meaningful name from API, keep the initial template-rendered title
|
// If no meaningful name from API, keep the initial template-rendered title
|
||||||
}
|
}
|
||||||
|
|
||||||
videos.forEach(video => {
|
videos.forEach(video => {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
|
|
||||||
if (currentFilterType === 'shorts') {
|
if (currentFilterType === 'shorts') {
|
||||||
// Render Vertical Short Card
|
// Render Vertical Short Card
|
||||||
card.className = 'yt-channel-short-card';
|
card.className = 'yt-channel-short-card';
|
||||||
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="yt-short-thumb-container">
|
<div class="yt-short-thumb-container">
|
||||||
<img src="${video.thumbnail}" class="yt-short-thumb loaded" loading="lazy" onload="this.classList.add('loaded')">
|
<img src="${video.thumbnail}" class="yt-short-thumb loaded" loading="lazy" onload="this.classList.add('loaded')">
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-details" style="padding: 8px;">
|
<div class="yt-details" style="padding: 8px;">
|
||||||
<h3 class="yt-video-title" style="font-size: 14px; margin-bottom: 4px;">${escapeHtml(video.title)}</h3>
|
<h3 class="yt-video-title" style="font-size: 14px; margin-bottom: 4px;">${escapeHtml(video.title)}</h3>
|
||||||
<p class="yt-video-stats">${formatViews(video.view_count)} views</p>
|
<p class="yt-video-stats">${formatViews(video.view_count)} views</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
// Render Standard Video Card (Match Home)
|
// Render Standard Video Card (Match Home)
|
||||||
card.className = 'yt-video-card';
|
card.className = 'yt-video-card';
|
||||||
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="yt-thumbnail-container">
|
<div class="yt-thumbnail-container">
|
||||||
<img class="yt-thumbnail loaded" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')">
|
<img class="yt-thumbnail loaded" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')">
|
||||||
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
|
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-details">
|
<div class="yt-video-details">
|
||||||
<div class="yt-video-meta">
|
<div class="yt-video-meta">
|
||||||
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
|
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
|
||||||
<p class="yt-video-stats">
|
<p class="yt-video-stats">
|
||||||
${formatViews(video.view_count)} views • ${formatDate(video.upload_date)}
|
${formatViews(video.view_count)} views • ${formatDate(video.upload_date)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
grid.appendChild(card);
|
grid.appendChild(card);
|
||||||
});
|
});
|
||||||
currentChannelPage++;
|
currentChannelPage++;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
isChannelLoading = false;
|
isChannelLoading = false;
|
||||||
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
|
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupInfiniteScroll() {
|
function setupInfiniteScroll() {
|
||||||
const trigger = document.getElementById('channelLoadingTrigger');
|
const trigger = document.getElementById('channelLoadingTrigger');
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
if (entries[0].isIntersecting) {
|
if (entries[0].isIntersecting) {
|
||||||
fetchChannelContent();
|
fetchChannelContent();
|
||||||
}
|
}
|
||||||
}, { threshold: 0.1 });
|
}, { threshold: 0.1 });
|
||||||
observer.observe(trigger);
|
observer.observe(trigger);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helpers - Define locally to ensure availability
|
// Helpers - Define locally to ensure availability
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatViews(views) {
|
function formatViews(views) {
|
||||||
if (!views) return '0';
|
if (!views) return '0';
|
||||||
const num = parseInt(views);
|
const num = parseInt(views);
|
||||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||||
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
|
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
|
||||||
return num.toLocaleString();
|
return num.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
if (!dateStr) return 'Recently';
|
if (!dateStr) return 'Recently';
|
||||||
try {
|
try {
|
||||||
// Format: YYYYMMDD
|
// Format: YYYYMMDD
|
||||||
const year = dateStr.substring(0, 4);
|
const year = dateStr.substring(0, 4);
|
||||||
const month = dateStr.substring(4, 6);
|
const month = dateStr.substring(4, 6);
|
||||||
const day = dateStr.substring(6, 8);
|
const day = dateStr.substring(6, 8);
|
||||||
const date = new Date(year, month - 1, day);
|
const date = new Date(year, month - 1, day);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = now - date;
|
const diff = now - date;
|
||||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
if (days < 1) return 'Today';
|
if (days < 1) return 'Today';
|
||||||
if (days < 7) return `${days} days ago`;
|
if (days < 7) return `${days} days ago`;
|
||||||
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
|
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
|
||||||
if (days < 365) return `${Math.floor(days / 30)} months ago`;
|
if (days < 365) return `${Math.floor(days / 30)} months ago`;
|
||||||
return `${Math.floor(days / 365)} years ago`;
|
return `${Math.floor(days / 365)} years ago`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return 'Recently';
|
return 'Recently';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose functions globally for onclick handlers
|
// Expose functions globally for onclick handlers
|
||||||
window.changeChannelTab = changeChannelTab;
|
window.changeChannelTab = changeChannelTab;
|
||||||
window.changeChannelSort = changeChannelSort;
|
window.changeChannelSort = changeChannelSort;
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
408
templates/downloads.html
Executable file → Normal file
408
templates/downloads.html
Executable file → Normal file
|
|
@ -1,205 +1,205 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modules/downloads.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modules/downloads.css') }}">
|
||||||
|
|
||||||
<div class="downloads-page">
|
<div class="downloads-page">
|
||||||
<div class="downloads-header">
|
<div class="downloads-header">
|
||||||
<h1><i class="fas fa-download"></i> Downloads</h1>
|
<h1><i class="fas fa-download"></i> Downloads</h1>
|
||||||
<button class="downloads-clear-btn" onclick="clearAllDownloads()">
|
<button class="downloads-clear-btn" onclick="clearAllDownloads()">
|
||||||
<i class="fas fa-trash"></i> Clear All
|
<i class="fas fa-trash"></i> Clear All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="downloadsList" class="downloads-list">
|
<div id="downloadsList" class="downloads-list">
|
||||||
<!-- Downloads populated by JS -->
|
<!-- Downloads populated by JS -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="downloadsEmpty" class="downloads-empty" style="display: none;">
|
<div id="downloadsEmpty" class="downloads-empty" style="display: none;">
|
||||||
<i class="fas fa-download"></i>
|
<i class="fas fa-download"></i>
|
||||||
<p>No downloads yet</p>
|
<p>No downloads yet</p>
|
||||||
<p>Videos you download will appear here</p>
|
<p>Videos you download will appear here</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function renderDownloads() {
|
function renderDownloads() {
|
||||||
const list = document.getElementById('downloadsList');
|
const list = document.getElementById('downloadsList');
|
||||||
const empty = document.getElementById('downloadsEmpty');
|
const empty = document.getElementById('downloadsEmpty');
|
||||||
|
|
||||||
if (!list || !empty) return;
|
if (!list || !empty) return;
|
||||||
|
|
||||||
// Safety check for download manager
|
// Safety check for download manager
|
||||||
if (!window.downloadManager) {
|
if (!window.downloadManager) {
|
||||||
console.log('Download manager not ready, retrying...');
|
console.log('Download manager not ready, retrying...');
|
||||||
setTimeout(renderDownloads, 100);
|
setTimeout(renderDownloads, 100);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeDownloads = window.downloadManager.getActiveDownloads();
|
const activeDownloads = window.downloadManager.getActiveDownloads();
|
||||||
const library = window.downloadManager.getLibrary();
|
const library = window.downloadManager.getLibrary();
|
||||||
|
|
||||||
if (library.length === 0 && activeDownloads.length === 0) {
|
if (library.length === 0 && activeDownloads.length === 0) {
|
||||||
list.style.display = 'none';
|
list.style.display = 'none';
|
||||||
empty.style.display = 'block';
|
empty.style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.style.display = 'flex';
|
list.style.display = 'flex';
|
||||||
empty.style.display = 'none';
|
empty.style.display = 'none';
|
||||||
|
|
||||||
// Render Active Downloads
|
// Render Active Downloads
|
||||||
const activeHtml = activeDownloads.map(item => {
|
const activeHtml = activeDownloads.map(item => {
|
||||||
const specs = item.specs ?
|
const specs = item.specs ?
|
||||||
(item.type === 'video' ?
|
(item.type === 'video' ?
|
||||||
`${item.specs.resolution || ''} ${item.specs.vcodec ? '• ' + item.specs.vcodec : ''}` :
|
`${item.specs.resolution || ''} ${item.specs.vcodec ? '• ' + item.specs.vcodec : ''}` :
|
||||||
`${item.specs.acodec || ''} • ${item.specs.bitrate ? item.specs.bitrate + 'kbps' : ''}`
|
`${item.specs.acodec || ''} • ${item.specs.bitrate ? item.specs.bitrate + 'kbps' : ''}`
|
||||||
).trim() : '';
|
).trim() : '';
|
||||||
|
|
||||||
const isPaused = item.status === 'paused';
|
const isPaused = item.status === 'paused';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="download-item active ${isPaused ? 'paused' : ''}" data-id="${item.id}">
|
<div class="download-item active ${isPaused ? 'paused' : ''}" data-id="${item.id}">
|
||||||
<img src="${item.thumbnail || 'https://i.ytimg.com/vi/' + item.videoId + '/mqdefault.jpg'}"
|
<img src="${item.thumbnail || 'https://i.ytimg.com/vi/' + item.videoId + '/mqdefault.jpg'}"
|
||||||
class="download-item-thumb">
|
class="download-item-thumb">
|
||||||
<div class="download-item-info">
|
<div class="download-item-info">
|
||||||
<div class="download-item-title">${escapeHtml(item.title)}</div>
|
<div class="download-item-title">${escapeHtml(item.title)}</div>
|
||||||
<div class="download-item-meta">
|
<div class="download-item-meta">
|
||||||
<span class="status-text">
|
<span class="status-text">
|
||||||
${isPaused ? '<i class="fas fa-pause-circle"></i> Paused • ' : ''}
|
${isPaused ? '<i class="fas fa-pause-circle"></i> Paused • ' : ''}
|
||||||
${item.speedDisplay ? `<i class="fas fa-bolt"></i> ${item.speedDisplay} • ` : ''}
|
${item.speedDisplay ? `<i class="fas fa-bolt"></i> ${item.speedDisplay} • ` : ''}
|
||||||
${item.eta ? `<i class="fas fa-clock"></i> ${item.eta} • ` : ''}
|
${item.eta ? `<i class="fas fa-clock"></i> ${item.eta} • ` : ''}
|
||||||
${isPaused ? 'Resuming...' : 'Downloading...'} ${item.progress}%
|
${isPaused ? 'Resuming...' : 'Downloading...'} ${item.progress}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
${specs ? `<div class="download-item-specs"><small>${specs}</small></div>` : ''}
|
${specs ? `<div class="download-item-specs"><small>${specs}</small></div>` : ''}
|
||||||
<div class="download-progress-container">
|
<div class="download-progress-container">
|
||||||
<div class="download-progress-bar ${isPaused ? 'paused' : ''}" style="width: ${item.progress}%"></div>
|
<div class="download-progress-bar ${isPaused ? 'paused' : ''}" style="width: ${item.progress}%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="download-item-actions">
|
<div class="download-item-actions">
|
||||||
<button class="download-item-pause" onclick="togglePause('${item.id}')" title="${isPaused ? 'Resume' : 'Pause'}">
|
<button class="download-item-pause" onclick="togglePause('${item.id}')" title="${isPaused ? 'Resume' : 'Pause'}">
|
||||||
<i class="fas ${isPaused ? 'fa-play' : 'fa-pause'}"></i>
|
<i class="fas ${isPaused ? 'fa-play' : 'fa-pause'}"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="download-item-remove" onclick="cancelDownload('${item.id}')" title="Cancel">
|
<button class="download-item-remove" onclick="cancelDownload('${item.id}')" title="Cancel">
|
||||||
<i class="fas fa-stop"></i>
|
<i class="fas fa-stop"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`}).join('');
|
`}).join('');
|
||||||
|
|
||||||
// Render History - with playback support
|
// Render History - with playback support
|
||||||
const historyHtml = library.map(item => {
|
const historyHtml = library.map(item => {
|
||||||
const specs = item.specs ?
|
const specs = item.specs ?
|
||||||
(item.type === 'video' ?
|
(item.type === 'video' ?
|
||||||
`${item.specs.resolution || ''} ${item.specs.vcodec ? '• ' + item.specs.vcodec : ''}` :
|
`${item.specs.resolution || ''} ${item.specs.vcodec ? '• ' + item.specs.vcodec : ''}` :
|
||||||
`${item.specs.acodec || ''} • ${item.specs.bitrate ? item.specs.bitrate + 'kbps' : ''}`
|
`${item.specs.acodec || ''} • ${item.specs.bitrate ? item.specs.bitrate + 'kbps' : ''}`
|
||||||
).trim() : '';
|
).trim() : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="download-item playable" data-id="${item.id}" data-video-id="${item.videoId}" onclick="playDownload('${item.videoId}', event)">
|
<div class="download-item playable" data-id="${item.id}" data-video-id="${item.videoId}" onclick="playDownload('${item.videoId}', event)">
|
||||||
<div class="download-item-thumb-wrapper">
|
<div class="download-item-thumb-wrapper">
|
||||||
<img src="${item.thumbnail || 'https://i.ytimg.com/vi/' + item.videoId + '/mqdefault.jpg'}"
|
<img src="${item.thumbnail || 'https://i.ytimg.com/vi/' + item.videoId + '/mqdefault.jpg'}"
|
||||||
class="download-item-thumb"
|
class="download-item-thumb"
|
||||||
onerror="this.src='https://via.placeholder.com/160x90?text=No+Thumbnail'">
|
onerror="this.src='https://via.placeholder.com/160x90?text=No+Thumbnail'">
|
||||||
<div class="download-thumb-overlay">
|
<div class="download-thumb-overlay">
|
||||||
<i class="fas fa-play"></i>
|
<i class="fas fa-play"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="download-item-info">
|
<div class="download-item-info">
|
||||||
<div class="download-item-title">${escapeHtml(item.title)}</div>
|
<div class="download-item-title">${escapeHtml(item.title)}</div>
|
||||||
<div class="download-item-meta">
|
<div class="download-item-meta">
|
||||||
${item.quality} · ${item.type} · ${formatDate(item.downloadedAt)}
|
${item.quality} · ${item.type} · ${formatDate(item.downloadedAt)}
|
||||||
${specs ? `<span class="meta-specs">• ${specs}</span>` : ''}
|
${specs ? `<span class="meta-specs">• ${specs}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="download-item-actions">
|
<div class="download-item-actions">
|
||||||
<button class="download-item-play" onclick="playDownload('${item.videoId}', event); event.stopPropagation();" title="Play">
|
<button class="download-item-play" onclick="playDownload('${item.videoId}', event); event.stopPropagation();" title="Play">
|
||||||
<i class="fas fa-play"></i>
|
<i class="fas fa-play"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="download-item-redownload" onclick="reDownload('${item.videoId}', event); event.stopPropagation();" title="Download Again">
|
<button class="download-item-redownload" onclick="reDownload('${item.videoId}', event); event.stopPropagation();" title="Download Again">
|
||||||
<i class="fas fa-download"></i>
|
<i class="fas fa-download"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="download-item-remove" onclick="removeDownload('${item.id}'); event.stopPropagation();" title="Remove">
|
<button class="download-item-remove" onclick="removeDownload('${item.id}'); event.stopPropagation();" title="Remove">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`}).join('');
|
`}).join('');
|
||||||
|
|
||||||
|
|
||||||
list.innerHTML = activeHtml + historyHtml;
|
list.innerHTML = activeHtml + historyHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelDownload(id) {
|
function cancelDownload(id) {
|
||||||
window.downloadManager.cancelDownload(id);
|
window.downloadManager.cancelDownload(id);
|
||||||
renderDownloads();
|
renderDownloads();
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDownload(id) {
|
function removeDownload(id) {
|
||||||
window.downloadManager.removeFromLibrary(id);
|
window.downloadManager.removeFromLibrary(id);
|
||||||
renderDownloads();
|
renderDownloads();
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePause(id) {
|
function togglePause(id) {
|
||||||
const downloads = window.downloadManager.activeDownloads;
|
const downloads = window.downloadManager.activeDownloads;
|
||||||
const state = downloads.get(id);
|
const state = downloads.get(id);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
if (state.item.status === 'paused') {
|
if (state.item.status === 'paused') {
|
||||||
window.downloadManager.resumeDownload(id);
|
window.downloadManager.resumeDownload(id);
|
||||||
} else {
|
} else {
|
||||||
window.downloadManager.pauseDownload(id);
|
window.downloadManager.pauseDownload(id);
|
||||||
}
|
}
|
||||||
// renderDownloads will be called by event listener
|
// renderDownloads will be called by event listener
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAllDownloads() {
|
function clearAllDownloads() {
|
||||||
if (confirm('Remove all downloads from history?')) {
|
if (confirm('Remove all downloads from history?')) {
|
||||||
window.downloadManager.clearLibrary();
|
window.downloadManager.clearLibrary();
|
||||||
renderDownloads();
|
renderDownloads();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function playDownload(videoId, event) {
|
function playDownload(videoId, event) {
|
||||||
if (event) event.preventDefault();
|
if (event) event.preventDefault();
|
||||||
// Navigate to watch page for this video
|
// Navigate to watch page for this video
|
||||||
window.location.href = `/watch?v=${videoId}`;
|
window.location.href = `/watch?v=${videoId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function reDownload(videoId, event) {
|
function reDownload(videoId, event) {
|
||||||
if (event) event.preventDefault();
|
if (event) event.preventDefault();
|
||||||
// Open download modal for this video
|
// Open download modal for this video
|
||||||
if (typeof showDownloadModal === 'function') {
|
if (typeof showDownloadModal === 'function') {
|
||||||
showDownloadModal(videoId);
|
showDownloadModal(videoId);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: navigate to watch page
|
// Fallback: navigate to watch page
|
||||||
window.location.href = `/watch?v=${videoId}`;
|
window.location.href = `/watch?v=${videoId}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return '';
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render on load with slight delay for download manager
|
// Render on load with slight delay for download manager
|
||||||
document.addEventListener('DOMContentLoaded', () => setTimeout(renderDownloads, 200));
|
document.addEventListener('DOMContentLoaded', () => setTimeout(renderDownloads, 200));
|
||||||
|
|
||||||
// Listen for real-time updates
|
// Listen for real-time updates
|
||||||
// Listen for real-time updates - Prevent duplicates
|
// Listen for real-time updates - Prevent duplicates
|
||||||
if (window._kvDownloadListener) {
|
if (window._kvDownloadListener) {
|
||||||
window.removeEventListener('download-updated', window._kvDownloadListener);
|
window.removeEventListener('download-updated', window._kvDownloadListener);
|
||||||
}
|
}
|
||||||
window._kvDownloadListener = renderDownloads;
|
window._kvDownloadListener = renderDownloads;
|
||||||
window.addEventListener('download-updated', renderDownloads);
|
window.addEventListener('download-updated', renderDownloads);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
432
templates/index.html
Executable file → Normal file
432
templates/index.html
Executable file → Normal file
|
|
@ -1,217 +1,217 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<script>
|
<script>
|
||||||
window.APP_CONFIG = {
|
window.APP_CONFIG = {
|
||||||
page: '{{ page|default("home") }}',
|
page: '{{ page|default("home") }}',
|
||||||
channelId: '{{ channel_id|default("") }}',
|
channelId: '{{ channel_id|default("") }}',
|
||||||
query: '{{ query|default("") }}'
|
query: '{{ query|default("") }}'
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<!-- Filters & Categories -->
|
<!-- Filters & Categories -->
|
||||||
<div class="yt-filter-bar">
|
<div class="yt-filter-bar">
|
||||||
<div class="yt-categories" id="categoryList">
|
<div class="yt-categories" id="categoryList">
|
||||||
<!-- Pinned Categories -->
|
<!-- Pinned Categories -->
|
||||||
<button class="yt-chip" onclick="switchCategory('suggested', this)"><i class="fas fa-magic"></i>
|
<button class="yt-chip" onclick="switchCategory('suggested', this)"><i class="fas fa-magic"></i>
|
||||||
Suggested</button>
|
Suggested</button>
|
||||||
<!-- Standard Categories -->
|
<!-- Standard Categories -->
|
||||||
<button class="yt-chip" onclick="switchCategory('tech', this)">Tech</button>
|
<button class="yt-chip" onclick="switchCategory('tech', this)">Tech</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('music', this)">Music</button>
|
<button class="yt-chip" onclick="switchCategory('music', this)">Music</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('movies', this)">Movies</button>
|
<button class="yt-chip" onclick="switchCategory('movies', this)">Movies</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('news', this)">News</button>
|
<button class="yt-chip" onclick="switchCategory('news', this)">News</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('trending', this)">Trending</button>
|
<button class="yt-chip" onclick="switchCategory('trending', this)">Trending</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('podcasts', this)">Podcasts</button>
|
<button class="yt-chip" onclick="switchCategory('podcasts', this)">Podcasts</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('live', this)">Live</button>
|
<button class="yt-chip" onclick="switchCategory('live', this)">Live</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('gaming', this)">Gaming</button>
|
<button class="yt-chip" onclick="switchCategory('gaming', this)">Gaming</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('sports', this)">Sports</button>
|
<button class="yt-chip" onclick="switchCategory('sports', this)">Sports</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="yt-filter-actions">
|
<div class="yt-filter-actions">
|
||||||
<div class="yt-dropdown">
|
<div class="yt-dropdown">
|
||||||
<button class="yt-icon-btn" id="filterToggleBtn" onclick="toggleFilterMenu()">
|
<button class="yt-icon-btn" id="filterToggleBtn" onclick="toggleFilterMenu()">
|
||||||
<i class="fas fa-sliders-h"></i>
|
<i class="fas fa-sliders-h"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="yt-dropdown-menu" id="filterMenu">
|
<div class="yt-dropdown-menu" id="filterMenu">
|
||||||
<div class="yt-menu-section">
|
<div class="yt-menu-section">
|
||||||
<h4>Sort By</h4>
|
<h4>Sort By</h4>
|
||||||
<button onclick="changeSort('day')">Today</button>
|
<button onclick="changeSort('day')">Today</button>
|
||||||
<button onclick="changeSort('week')">This Week</button>
|
<button onclick="changeSort('week')">This Week</button>
|
||||||
<button onclick="changeSort('month')">This Month</button>
|
<button onclick="changeSort('month')">This Month</button>
|
||||||
<button onclick="changeSort('3months')">Last 3 Months</button>
|
<button onclick="changeSort('3months')">Last 3 Months</button>
|
||||||
<button onclick="changeSort('year')">This Year</button>
|
<button onclick="changeSort('year')">This Year</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-menu-section">
|
<div class="yt-menu-section">
|
||||||
<h4>Region</h4>
|
<h4>Region</h4>
|
||||||
<button onclick="changeRegion('vietnam')">Vietnam</button>
|
<button onclick="changeRegion('vietnam')">Vietnam</button>
|
||||||
<button onclick="changeRegion('global')">Global</button>
|
<button onclick="changeRegion('global')">Global</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Shorts Section -->
|
<!-- Shorts Section -->
|
||||||
|
|
||||||
|
|
||||||
<!-- Videos Section -->
|
<!-- Videos Section -->
|
||||||
<div id="videosSection" class="yt-section">
|
<div id="videosSection" class="yt-section">
|
||||||
<div class="yt-section-header" style="display:none;">
|
<div class="yt-section-header" style="display:none;">
|
||||||
<h2><i class="fas fa-play-circle"></i> Videos</h2>
|
<h2><i class="fas fa-play-circle"></i> Videos</h2>
|
||||||
</div>
|
</div>
|
||||||
<div id="resultsArea" class="yt-video-grid">
|
<div id="resultsArea" class="yt-video-grid">
|
||||||
<!-- Initial Skeleton State -->
|
<!-- Initial Skeleton State -->
|
||||||
<!-- Initial Skeleton State (12 items) -->
|
<!-- Initial Skeleton State (12 items) -->
|
||||||
<div class="yt-video-card skeleton-card">
|
<div class="yt-video-card skeleton-card">
|
||||||
<div class="skeleton-thumb skeleton"></div>
|
<div class="skeleton-thumb skeleton"></div>
|
||||||
<div class="skeleton-details">
|
<div class="skeleton-details">
|
||||||
<div class="skeleton-avatar skeleton"></div>
|
<div class="skeleton-avatar skeleton"></div>
|
||||||
<div class="skeleton-text">
|
<div class="skeleton-text">
|
||||||
<div class="skeleton-title skeleton"></div>
|
<div class="skeleton-title skeleton"></div>
|
||||||
<div class="skeleton-meta skeleton"></div>
|
<div class="skeleton-meta skeleton"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-card skeleton-card">
|
<div class="yt-video-card skeleton-card">
|
||||||
<div class="skeleton-thumb skeleton"></div>
|
<div class="skeleton-thumb skeleton"></div>
|
||||||
<div class="skeleton-details">
|
<div class="skeleton-details">
|
||||||
<div class="skeleton-avatar skeleton"></div>
|
<div class="skeleton-avatar skeleton"></div>
|
||||||
<div class="skeleton-text">
|
<div class="skeleton-text">
|
||||||
<div class="skeleton-title skeleton"></div>
|
<div class="skeleton-title skeleton"></div>
|
||||||
<div class="skeleton-meta skeleton"></div>
|
<div class="skeleton-meta skeleton"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-card skeleton-card">
|
<div class="yt-video-card skeleton-card">
|
||||||
<div class="skeleton-thumb skeleton"></div>
|
<div class="skeleton-thumb skeleton"></div>
|
||||||
<div class="skeleton-details">
|
<div class="skeleton-details">
|
||||||
<div class="skeleton-avatar skeleton"></div>
|
<div class="skeleton-avatar skeleton"></div>
|
||||||
<div class="skeleton-text">
|
<div class="skeleton-text">
|
||||||
<div class="skeleton-title skeleton"></div>
|
<div class="skeleton-title skeleton"></div>
|
||||||
<div class="skeleton-meta skeleton"></div>
|
<div class="skeleton-meta skeleton"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-card skeleton-card">
|
<div class="yt-video-card skeleton-card">
|
||||||
<div class="skeleton-thumb skeleton"></div>
|
<div class="skeleton-thumb skeleton"></div>
|
||||||
<div class="skeleton-details">
|
<div class="skeleton-details">
|
||||||
<div class="skeleton-avatar skeleton"></div>
|
<div class="skeleton-avatar skeleton"></div>
|
||||||
<div class="skeleton-text">
|
<div class="skeleton-text">
|
||||||
<div class="skeleton-title skeleton"></div>
|
<div class="skeleton-title skeleton"></div>
|
||||||
<div class="skeleton-meta skeleton"></div>
|
<div class="skeleton-meta skeleton"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-card skeleton-card">
|
<div class="yt-video-card skeleton-card">
|
||||||
<div class="skeleton-thumb skeleton"></div>
|
<div class="skeleton-thumb skeleton"></div>
|
||||||
<div class="skeleton-details">
|
<div class="skeleton-details">
|
||||||
<div class="skeleton-avatar skeleton"></div>
|
<div class="skeleton-avatar skeleton"></div>
|
||||||
<div class="skeleton-text">
|
<div class="skeleton-text">
|
||||||
<div class="skeleton-title skeleton"></div>
|
<div class="skeleton-title skeleton"></div>
|
||||||
<div class="skeleton-meta skeleton"></div>
|
<div class="skeleton-meta skeleton"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-card skeleton-card">
|
<div class="yt-video-card skeleton-card">
|
||||||
<div class="skeleton-thumb skeleton"></div>
|
<div class="skeleton-thumb skeleton"></div>
|
||||||
<div class="skeleton-details">
|
<div class="skeleton-details">
|
||||||
<div class="skeleton-avatar skeleton"></div>
|
<div class="skeleton-avatar skeleton"></div>
|
||||||
<div class="skeleton-text">
|
<div class="skeleton-text">
|
||||||
<div class="skeleton-title skeleton"></div>
|
<div class="skeleton-title skeleton"></div>
|
||||||
<div class="skeleton-meta skeleton"></div>
|
<div class="skeleton-meta skeleton"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-card skeleton-card">
|
<div class="yt-video-card skeleton-card">
|
||||||
<div class="skeleton-thumb skeleton"></div>
|
<div class="skeleton-thumb skeleton"></div>
|
||||||
<div class="skeleton-details">
|
<div class="skeleton-details">
|
||||||
<div class="skeleton-avatar skeleton"></div>
|
<div class="skeleton-avatar skeleton"></div>
|
||||||
<div class="skeleton-text">
|
<div class="skeleton-text">
|
||||||
<div class="skeleton-title skeleton"></div>
|
<div class="skeleton-title skeleton"></div>
|
||||||
<div class="skeleton-meta skeleton"></div>
|
<div class="skeleton-meta skeleton"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-card skeleton-card">
|
<div class="yt-video-card skeleton-card">
|
||||||
<div class="skeleton-thumb skeleton"></div>
|
<div class="skeleton-thumb skeleton"></div>
|
||||||
<div class="skeleton-details">
|
<div class="skeleton-details">
|
||||||
<div class="skeleton-avatar skeleton"></div>
|
<div class="skeleton-avatar skeleton"></div>
|
||||||
<div class="skeleton-text">
|
<div class="skeleton-text">
|
||||||
<div class="skeleton-title skeleton"></div>
|
<div class="skeleton-title skeleton"></div>
|
||||||
<div class="skeleton-meta skeleton"></div>
|
<div class="skeleton-meta skeleton"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Styles moved to CSS modules -->
|
<!-- Styles moved to CSS modules -->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Global filter state
|
// Global filter state
|
||||||
// Global filter state
|
// Global filter state
|
||||||
var currentSort = 'month';
|
var currentSort = 'month';
|
||||||
var currentRegion = 'vietnam';
|
var currentRegion = 'vietnam';
|
||||||
|
|
||||||
function toggleFilterMenu() {
|
function toggleFilterMenu() {
|
||||||
const menu = document.getElementById('filterMenu');
|
const menu = document.getElementById('filterMenu');
|
||||||
if (menu) menu.classList.toggle('show');
|
if (menu) menu.classList.toggle('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close menu when clicking outside - Prevent multiple listeners
|
// Close menu when clicking outside - Prevent multiple listeners
|
||||||
if (!window.filterMenuListenerAttached) {
|
if (!window.filterMenuListenerAttached) {
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
const menu = document.getElementById('filterMenu');
|
const menu = document.getElementById('filterMenu');
|
||||||
const btn = document.getElementById('filterToggleBtn');
|
const btn = document.getElementById('filterToggleBtn');
|
||||||
// Only run if elements exist (we are on home page)
|
// Only run if elements exist (we are on home page)
|
||||||
if (menu && btn && !menu.contains(e.target) && !btn.contains(e.target)) {
|
if (menu && btn && !menu.contains(e.target) && !btn.contains(e.target)) {
|
||||||
menu.classList.remove('show');
|
menu.classList.remove('show');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.filterMenuListenerAttached = true;
|
window.filterMenuListenerAttached = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeSort(sort) {
|
function changeSort(sort) {
|
||||||
window.currentSort = sort;
|
window.currentSort = sort;
|
||||||
// Global loadTrending from main.js will use this
|
// Global loadTrending from main.js will use this
|
||||||
loadTrending(true);
|
loadTrending(true);
|
||||||
toggleFilterMenu();
|
toggleFilterMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeRegion(region) {
|
function changeRegion(region) {
|
||||||
window.currentRegion = region;
|
window.currentRegion = region;
|
||||||
loadTrending(true);
|
loadTrending(true);
|
||||||
toggleFilterMenu();
|
toggleFilterMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helpers (if main.js not loaded yet or for standalone usage)
|
// Helpers (if main.js not loaded yet or for standalone usage)
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatViews(views) {
|
function formatViews(views) {
|
||||||
if (!views) return '0';
|
if (!views) return '0';
|
||||||
const num = parseInt(views);
|
const num = parseInt(views);
|
||||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||||
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
|
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
|
||||||
return num.toLocaleString();
|
return num.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init Logic
|
// Init Logic
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Pagination logic removed for infinite scroll
|
// Pagination logic removed for infinite scroll
|
||||||
|
|
||||||
// Check URL params for category
|
// Check URL params for category
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const category = urlParams.get('category');
|
const category = urlParams.get('category');
|
||||||
if (category && typeof switchCategory === 'function') {
|
if (category && typeof switchCategory === 'function') {
|
||||||
// Let main.js handle the switch, but we can set UI active state if needed
|
// Let main.js handle the switch, but we can set UI active state if needed
|
||||||
// switchCategory is in main.js
|
// switchCategory is in main.js
|
||||||
switchCategory(category);
|
switchCategory(category);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
1062
templates/layout.html
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" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="yt-auth-container">
|
<div class="yt-auth-container">
|
||||||
<div class="yt-auth-card">
|
<div class="yt-auth-card">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="yt-auth-logo">
|
<div class="yt-auth-logo">
|
||||||
<div class="yt-logo-icon" style="font-size: 24px;">KV-Tube</div>
|
<div class="yt-logo-icon" style="font-size: 24px;">KV-Tube</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Sign in</h2>
|
<h2>Sign in</h2>
|
||||||
<p>to continue to KV-Tube</p>
|
<p>to continue to KV-Tube</p>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages() %}
|
{% with messages = get_flashed_messages() %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<div class="yt-auth-alert">
|
<div class="yt-auth-alert">
|
||||||
<i class="fas fa-exclamation-circle"></i>
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
{{ messages[0] }}
|
{{ messages[0] }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<form method="POST" action="/login" class="yt-auth-form">
|
<form method="POST" action="/login" class="yt-auth-form">
|
||||||
<div class="yt-form-group">
|
<div class="yt-form-group">
|
||||||
<input type="text" name="username" class="yt-form-input" placeholder=" " required id="username">
|
<input type="text" name="username" class="yt-form-input" placeholder=" " required id="username">
|
||||||
<label for="username" class="yt-form-label">Username</label>
|
<label for="username" class="yt-form-label">Username</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="yt-form-group">
|
<div class="yt-form-group">
|
||||||
<input type="password" name="password" class="yt-form-input" placeholder=" " required id="password">
|
<input type="password" name="password" class="yt-form-input" placeholder=" " required id="password">
|
||||||
<label for="password" class="yt-form-label">Password</label>
|
<label for="password" class="yt-form-label">Password</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="yt-auth-submit">
|
<button type="submit" class="yt-auth-submit">
|
||||||
<i class="fas fa-sign-in-alt"></i>
|
<i class="fas fa-sign-in-alt"></i>
|
||||||
Sign In
|
Sign In
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="yt-auth-divider">
|
<div class="yt-auth-divider">
|
||||||
<span>or</span>
|
<span>or</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="yt-auth-footer">
|
<p class="yt-auth-footer">
|
||||||
New to KV-Tube?
|
New to KV-Tube?
|
||||||
<a href="/register" class="yt-auth-link">Create an account</a>
|
<a href="/register" class="yt-auth-link">Create an account</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.yt-auth-container {
|
.yt-auth-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: calc(100vh - var(--yt-header-height) - 100px);
|
min-height: calc(100vh - var(--yt-header-height) - 100px);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-card {
|
.yt-auth-card {
|
||||||
background: var(--yt-bg-secondary);
|
background: var(--yt-bg-secondary);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 48px 40px;
|
padding: 48px 40px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1px solid var(--yt-border);
|
border: 1px solid var(--yt-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-logo {
|
.yt-auth-logo {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-card h2 {
|
.yt-auth-card h2 {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-card>p {
|
.yt-auth-card>p {
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-alert {
|
.yt-auth-alert {
|
||||||
background: rgba(244, 67, 54, 0.1);
|
background: rgba(244, 67, 54, 0.1);
|
||||||
color: #f44336;
|
color: #f44336;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-form {
|
.yt-auth-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-group {
|
.yt-form-group {
|
||||||
position: relative;
|
position: relative;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-input {
|
.yt-form-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 16px 14px;
|
padding: 16px 14px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
background: var(--yt-bg-primary);
|
background: var(--yt-bg-primary);
|
||||||
border: 1px solid var(--yt-border);
|
border: 1px solid var(--yt-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-input:focus {
|
.yt-form-input:focus {
|
||||||
border-color: var(--yt-accent-blue);
|
border-color: var(--yt-accent-blue);
|
||||||
box-shadow: 0 0 0 3px rgba(62, 166, 255, 0.2);
|
box-shadow: 0 0 0 3px rgba(62, 166, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-label {
|
.yt-form-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 14px;
|
left: 14px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
background: var(--yt-bg-primary);
|
background: var(--yt-bg-primary);
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-input:focus+.yt-form-label,
|
.yt-form-input:focus+.yt-form-label,
|
||||||
.yt-form-input:not(:placeholder-shown)+.yt-form-label {
|
.yt-form-input:not(:placeholder-shown)+.yt-form-label {
|
||||||
top: 0;
|
top: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--yt-accent-blue);
|
color: var(--yt-accent-blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-submit {
|
.yt-auth-submit {
|
||||||
background: var(--yt-accent-blue);
|
background: var(--yt-accent-blue);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 14px 24px;
|
padding: 14px 24px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
transition: background 0.2s, transform 0.1s;
|
transition: background 0.2s, transform 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-submit:hover {
|
.yt-auth-submit:hover {
|
||||||
background: #258fd9;
|
background: #258fd9;
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-submit:active {
|
.yt-auth-submit:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-divider {
|
.yt-auth-divider {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 24px 0;
|
margin: 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-divider::before,
|
.yt-auth-divider::before,
|
||||||
.yt-auth-divider::after {
|
.yt-auth-divider::after {
|
||||||
content: '';
|
content: '';
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--yt-border);
|
background: var(--yt-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-divider span {
|
.yt-auth-divider span {
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-footer {
|
.yt-auth-footer {
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-link {
|
.yt-auth-link {
|
||||||
color: var(--yt-accent-blue);
|
color: var(--yt-accent-blue);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-link:hover {
|
.yt-auth-link:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.yt-auth-card {
|
.yt-auth-card {
|
||||||
padding: 32px 24px;
|
padding: 32px 24px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
924
templates/my_videos.html
Executable file → Normal file
924
templates/my_videos.html
Executable file → Normal file
|
|
@ -1,463 +1,463 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<style>
|
<style>
|
||||||
/* Library Page Premium Styles */
|
/* Library Page Premium Styles */
|
||||||
.library-container {
|
.library-container {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
max-width: 1600px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-header {
|
.library-header {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-title {
|
.library-title {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
background: linear-gradient(135deg, var(--yt-text-primary) 0%, var(--yt-text-secondary) 100%);
|
background: linear-gradient(135deg, var(--yt-text-primary) 0%, var(--yt-text-secondary) 100%);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-tabs {
|
.library-tabs {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: var(--yt-bg-secondary);
|
background: var(--yt-bg-secondary);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-tab {
|
.library-tab {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: all 0.25s ease;
|
transition: all 0.25s ease;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-tab:hover {
|
.library-tab:hover {
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
background: var(--yt-bg-hover);
|
background: var(--yt-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-tab.active {
|
.library-tab.active {
|
||||||
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-tab i {
|
.library-tab i {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-actions {
|
.library-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-btn {
|
.clear-btn {
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--yt-border);
|
border: 1px solid var(--yt-border);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-btn:hover {
|
.clear-btn:hover {
|
||||||
background: rgba(204, 0, 0, 0.1);
|
background: rgba(204, 0, 0, 0.1);
|
||||||
border-color: #cc0000;
|
border-color: #cc0000;
|
||||||
color: #cc0000;
|
color: #cc0000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-stats {
|
.library-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-stat {
|
.library-stat {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-stat i {
|
.library-stat i {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty State Enhancement */
|
/* Empty State Enhancement */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 4rem 2rem;
|
padding: 4rem 2rem;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state-icon {
|
.empty-state-icon {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
margin: 0 auto 1.5rem;
|
margin: 0 auto 1.5rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--yt-bg-secondary);
|
background: var(--yt-bg-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state h3 {
|
.empty-state h3 {
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state p {
|
.empty-state p {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.browse-btn {
|
.browse-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.browse-btn:hover {
|
.browse-btn:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 6px 16px rgba(204, 0, 0, 0.4);
|
box-shadow: 0 6px 16px rgba(204, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="library-container">
|
<div class="library-container">
|
||||||
<div class="library-header">
|
<div class="library-header">
|
||||||
<h1 class="library-title">My Library</h1>
|
<h1 class="library-title">My Library</h1>
|
||||||
|
|
||||||
<div class="library-tabs">
|
<div class="library-tabs">
|
||||||
<a href="/my-videos?type=history" class="library-tab" id="tab-history">
|
<a href="/my-videos?type=history" class="library-tab" id="tab-history">
|
||||||
<i class="fas fa-history"></i>
|
<i class="fas fa-history"></i>
|
||||||
<span>History</span>
|
<span>History</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/my-videos?type=saved" class="library-tab" id="tab-saved">
|
<a href="/my-videos?type=saved" class="library-tab" id="tab-saved">
|
||||||
<i class="fas fa-bookmark"></i>
|
<i class="fas fa-bookmark"></i>
|
||||||
<span>Saved</span>
|
<span>Saved</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/my-videos?type=subscriptions" class="library-tab" id="tab-subscriptions">
|
<a href="/my-videos?type=subscriptions" class="library-tab" id="tab-subscriptions">
|
||||||
<i class="fas fa-users"></i>
|
<i class="fas fa-users"></i>
|
||||||
<span>Subscriptions</span>
|
<span>Subscriptions</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="library-stats" id="libraryStats" style="display: none;">
|
<div class="library-stats" id="libraryStats" style="display: none;">
|
||||||
<div class="library-stat">
|
<div class="library-stat">
|
||||||
<i class="fas fa-video"></i>
|
<i class="fas fa-video"></i>
|
||||||
<span id="videoCount">0 videos</span>
|
<span id="videoCount">0 videos</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="library-actions">
|
<div class="library-actions">
|
||||||
<button id="clearBtn" onclick="clearLibrary()" class="clear-btn">
|
<button id="clearBtn" onclick="clearLibrary()" class="clear-btn">
|
||||||
<i class="fas fa-trash-alt"></i>
|
<i class="fas fa-trash-alt"></i>
|
||||||
<span>Clear <span id="clearType">All</span></span>
|
<span>Clear <span id="clearType">All</span></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video Grid -->
|
<!-- Video Grid -->
|
||||||
<div id="libraryGrid" class="yt-video-grid">
|
<div id="libraryGrid" class="yt-video-grid">
|
||||||
<!-- JS will populate this -->
|
<!-- JS will populate this -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div id="emptyState" class="empty-state" style="display: none;">
|
<div id="emptyState" class="empty-state" style="display: none;">
|
||||||
<div class="empty-state-icon">
|
<div class="empty-state-icon">
|
||||||
<i class="fas fa-folder-open"></i>
|
<i class="fas fa-folder-open"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3>Nothing here yet</h3>
|
<h3>Nothing here yet</h3>
|
||||||
<p id="emptyMsg">Go watch some videos to fill this up!</p>
|
<p id="emptyMsg">Go watch some videos to fill this up!</p>
|
||||||
<a href="/" class="browse-btn">
|
<a href="/" class="browse-btn">
|
||||||
<i class="fas fa-play"></i>
|
<i class="fas fa-play"></i>
|
||||||
Browse Content
|
Browse Content
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Load library content - extracted to function for reuse on pageshow
|
// Load library content - extracted to function for reuse on pageshow
|
||||||
function loadLibraryContent() {
|
function loadLibraryContent() {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
// Default to history if no type or invalid type
|
// Default to history if no type or invalid type
|
||||||
const type = urlParams.get('type') || 'history';
|
const type = urlParams.get('type') || 'history';
|
||||||
|
|
||||||
// Reset all tabs first, then activate the correct one
|
// Reset all tabs first, then activate the correct one
|
||||||
document.querySelectorAll('.library-tab').forEach(tab => tab.classList.remove('active'));
|
document.querySelectorAll('.library-tab').forEach(tab => tab.classList.remove('active'));
|
||||||
const activeTab = document.getElementById(`tab-${type}`);
|
const activeTab = document.getElementById(`tab-${type}`);
|
||||||
if (activeTab) {
|
if (activeTab) {
|
||||||
activeTab.classList.add('active');
|
activeTab.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
const grid = document.getElementById('libraryGrid');
|
const grid = document.getElementById('libraryGrid');
|
||||||
const empty = document.getElementById('emptyState');
|
const empty = document.getElementById('emptyState');
|
||||||
const emptyMsg = document.getElementById('emptyMsg');
|
const emptyMsg = document.getElementById('emptyMsg');
|
||||||
const statsDiv = document.getElementById('libraryStats');
|
const statsDiv = document.getElementById('libraryStats');
|
||||||
const clearBtn = document.getElementById('clearBtn');
|
const clearBtn = document.getElementById('clearBtn');
|
||||||
|
|
||||||
// Reset UI before loading
|
// Reset UI before loading
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
empty.style.display = 'none';
|
empty.style.display = 'none';
|
||||||
if (statsDiv) statsDiv.style.display = 'none';
|
if (statsDiv) statsDiv.style.display = 'none';
|
||||||
if (clearBtn) clearBtn.style.display = 'none';
|
if (clearBtn) clearBtn.style.display = 'none';
|
||||||
|
|
||||||
// Mapping URL type to localStorage key suffix
|
// Mapping URL type to localStorage key suffix
|
||||||
// saved -> kv_saved
|
// saved -> kv_saved
|
||||||
// history -> kv_history
|
// history -> kv_history
|
||||||
// subscriptions -> kv_subscriptions
|
// subscriptions -> kv_subscriptions
|
||||||
const storageKey = `kv_${type}`;
|
const storageKey = `kv_${type}`;
|
||||||
const data = JSON.parse(localStorage.getItem(storageKey) || '[]').filter(i => i && i.id);
|
const data = JSON.parse(localStorage.getItem(storageKey) || '[]').filter(i => i && i.id);
|
||||||
|
|
||||||
// Show stats and Clear Button if there is data
|
// Show stats and Clear Button if there is data
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
empty.style.display = 'none';
|
empty.style.display = 'none';
|
||||||
|
|
||||||
// Update stats
|
// Update stats
|
||||||
const videoCount = document.getElementById('videoCount');
|
const videoCount = document.getElementById('videoCount');
|
||||||
if (statsDiv && videoCount) {
|
if (statsDiv && videoCount) {
|
||||||
statsDiv.style.display = 'flex';
|
statsDiv.style.display = 'flex';
|
||||||
const countText = type === 'subscriptions'
|
const countText = type === 'subscriptions'
|
||||||
? `${data.length} channel${data.length !== 1 ? 's' : ''}`
|
? `${data.length} channel${data.length !== 1 ? 's' : ''}`
|
||||||
: `${data.length} video${data.length !== 1 ? 's' : ''}`;
|
: `${data.length} video${data.length !== 1 ? 's' : ''}`;
|
||||||
videoCount.innerText = countText;
|
videoCount.innerText = countText;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearTypeSpan = document.getElementById('clearType');
|
const clearTypeSpan = document.getElementById('clearType');
|
||||||
|
|
||||||
if (clearBtn) {
|
if (clearBtn) {
|
||||||
clearBtn.style.display = 'inline-flex';
|
clearBtn.style.display = 'inline-flex';
|
||||||
|
|
||||||
// Format type name for display
|
// Format type name for display
|
||||||
const typeName = type.charAt(0).toUpperCase() + type.slice(1);
|
const typeName = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
clearTypeSpan.innerText = typeName;
|
clearTypeSpan.innerText = typeName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'subscriptions') {
|
if (type === 'subscriptions') {
|
||||||
// Render Channel Cards with improved design
|
// Render Channel Cards with improved design
|
||||||
grid.style.display = 'grid';
|
grid.style.display = 'grid';
|
||||||
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
|
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
|
||||||
grid.style.gap = '24px';
|
grid.style.gap = '24px';
|
||||||
grid.style.padding = '20px 0';
|
grid.style.padding = '20px 0';
|
||||||
|
|
||||||
grid.innerHTML = data.map(channel => {
|
grid.innerHTML = data.map(channel => {
|
||||||
const avatarHtml = channel.thumbnail
|
const avatarHtml = channel.thumbnail
|
||||||
? `<img src="${channel.thumbnail}" style="width:120px; height:120px; border-radius:50%; object-fit:cover; border: 3px solid var(--yt-border); transition: transform 0.3s, border-color 0.3s;">`
|
? `<img src="${channel.thumbnail}" style="width:120px; height:120px; border-radius:50%; object-fit:cover; border: 3px solid var(--yt-border); transition: transform 0.3s, border-color 0.3s;">`
|
||||||
: `<div style="width:120px; height:120px; border-radius:50%; background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%); display:flex; align-items:center; justify-content:center; font-size:48px; font-weight:bold; color:white; border: 3px solid var(--yt-border); transition: transform 0.3s;">${channel.letter || channel.title.charAt(0).toUpperCase()}</div>`;
|
: `<div style="width:120px; height:120px; border-radius:50%; background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%); display:flex; align-items:center; justify-content:center; font-size:48px; font-weight:bold; color:white; border: 3px solid var(--yt-border); transition: transform 0.3s;">${channel.letter || channel.title.charAt(0).toUpperCase()}</div>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="subscription-card" onclick="window.location.href='/channel/${channel.id}'"
|
<div class="subscription-card" onclick="window.location.href='/channel/${channel.id}'"
|
||||||
style="text-align:center; cursor:pointer; padding: 24px 16px; background: var(--yt-bg-secondary); border-radius: 16px; transition: all 0.3s; border: 1px solid transparent;"
|
style="text-align:center; cursor:pointer; padding: 24px 16px; background: var(--yt-bg-secondary); border-radius: 16px; transition: all 0.3s; border: 1px solid transparent;"
|
||||||
onmouseenter="this.style.transform='translateY(-4px)'; this.style.boxShadow='0 8px 24px rgba(0,0,0,0.3)'; this.style.borderColor='var(--yt-border)';"
|
onmouseenter="this.style.transform='translateY(-4px)'; this.style.boxShadow='0 8px 24px rgba(0,0,0,0.3)'; this.style.borderColor='var(--yt-border)';"
|
||||||
onmouseleave="this.style.transform='none'; this.style.boxShadow='none'; this.style.borderColor='transparent';">
|
onmouseleave="this.style.transform='none'; this.style.boxShadow='none'; this.style.borderColor='transparent';">
|
||||||
<div style="display:flex; justify-content:center; margin-bottom:16px;">
|
<div style="display:flex; justify-content:center; margin-bottom:16px;">
|
||||||
${avatarHtml}
|
${avatarHtml}
|
||||||
</div>
|
</div>
|
||||||
<h3 style="font-size:1.1rem; margin-bottom:8px; color: var(--yt-text-primary); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${channel.title}</h3>
|
<h3 style="font-size:1.1rem; margin-bottom:8px; color: var(--yt-text-primary); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${channel.title}</h3>
|
||||||
<p style="font-size: 0.85rem; color: var(--yt-text-secondary); margin-bottom: 12px;">@${channel.title.replace(/\s+/g, '')}</p>
|
<p style="font-size: 0.85rem; color: var(--yt-text-secondary); margin-bottom: 12px;">@${channel.title.replace(/\s+/g, '')}</p>
|
||||||
<button onclick="event.stopPropagation(); toggleSubscribe('${channel.id}', '${channel.title.replace(/'/g, "\\'")}', '${channel.thumbnail || ''}', this)"
|
<button onclick="event.stopPropagation(); toggleSubscribe('${channel.id}', '${channel.title.replace(/'/g, "\\'")}', '${channel.thumbnail || ''}', this)"
|
||||||
style="padding:10px 20px; font-size:13px; background: linear-gradient(135deg, #cc0000, #ff4444); color: white; border: none; border-radius: 24px; cursor: pointer; font-weight: 600; transition: all 0.2s; box-shadow: 0 2px 8px rgba(204,0,0,0.3);"
|
style="padding:10px 20px; font-size:13px; background: linear-gradient(135deg, #cc0000, #ff4444); color: white; border: none; border-radius: 24px; cursor: pointer; font-weight: 600; transition: all 0.2s; box-shadow: 0 2px 8px rgba(204,0,0,0.3);"
|
||||||
onmouseenter="this.style.transform='scale(1.05)'; this.style.boxShadow='0 4px 12px rgba(204,0,0,0.5)';"
|
onmouseenter="this.style.transform='scale(1.05)'; this.style.boxShadow='0 4px 12px rgba(204,0,0,0.5)';"
|
||||||
onmouseleave="this.style.transform='scale(1)'; this.style.boxShadow='0 2px 8px rgba(204,0,0,0.3)';">
|
onmouseleave="this.style.transform='scale(1)'; this.style.boxShadow='0 2px 8px rgba(204,0,0,0.3)';">
|
||||||
<i class="fas fa-user-minus"></i> Unsubscribe
|
<i class="fas fa-user-minus"></i> Unsubscribe
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`}).join('');
|
`}).join('');
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Render Video Cards (History/Saved)
|
// Render Video Cards (History/Saved)
|
||||||
grid.innerHTML = data.map(video => {
|
grid.innerHTML = data.map(video => {
|
||||||
// Robust fallback chain: maxres -> hq -> mq
|
// Robust fallback chain: maxres -> hq -> mq
|
||||||
const thumb = video.thumbnail || `https://i.ytimg.com/vi/${video.id}/maxresdefault.jpg`;
|
const thumb = video.thumbnail || `https://i.ytimg.com/vi/${video.id}/maxresdefault.jpg`;
|
||||||
const showRemove = type === 'saved' || type === 'history';
|
const showRemove = type === 'saved' || type === 'history';
|
||||||
return `
|
return `
|
||||||
<div class="yt-video-card" style="position: relative;">
|
<div class="yt-video-card" style="position: relative;">
|
||||||
<div onclick="window.location.href='/watch?v=${video.id}'" style="cursor: pointer;">
|
<div onclick="window.location.href='/watch?v=${video.id}'" style="cursor: pointer;">
|
||||||
<div class="yt-thumbnail-container">
|
<div class="yt-thumbnail-container">
|
||||||
<img src="${thumb}" class="yt-thumbnail" loading="lazy" referrerpolicy="no-referrer"
|
<img src="${thumb}" class="yt-thumbnail" loading="lazy" referrerpolicy="no-referrer"
|
||||||
onload="this.classList.add('loaded')"
|
onload="this.classList.add('loaded')"
|
||||||
onerror="
|
onerror="
|
||||||
if (this.src.includes('maxresdefault')) this.src='https://i.ytimg.com/vi/${video.id}/hqdefault.jpg';
|
if (this.src.includes('maxresdefault')) this.src='https://i.ytimg.com/vi/${video.id}/hqdefault.jpg';
|
||||||
else if (this.src.includes('hqdefault')) this.src='https://i.ytimg.com/vi/${video.id}/mqdefault.jpg';
|
else if (this.src.includes('hqdefault')) this.src='https://i.ytimg.com/vi/${video.id}/mqdefault.jpg';
|
||||||
else this.style.display='none';
|
else this.style.display='none';
|
||||||
">
|
">
|
||||||
<div class="yt-duration">${video.duration || ''}</div>
|
<div class="yt-duration">${video.duration || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-details">
|
<div class="yt-video-details">
|
||||||
<div class="yt-video-meta">
|
<div class="yt-video-meta">
|
||||||
<h3 class="yt-video-title">${video.title}</h3>
|
<h3 class="yt-video-title">${video.title}</h3>
|
||||||
<p class="yt-video-stats">${video.uploader}</p>
|
<p class="yt-video-stats">${video.uploader}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${showRemove ? `
|
${showRemove ? `
|
||||||
<button onclick="event.stopPropagation(); removeVideo('${video.id}', '${type}', this)"
|
<button onclick="event.stopPropagation(); removeVideo('${video.id}', '${type}', this)"
|
||||||
style="position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; background: rgba(0,0,0,0.7); color: white; border: none; border-radius: 50%; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: all 0.2s; z-index: 10;"
|
style="position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; background: rgba(0,0,0,0.7); color: white; border: none; border-radius: 50%; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: all 0.2s; z-index: 10;"
|
||||||
onmouseenter="this.style.opacity='1'; this.style.background='#cc0000';"
|
onmouseenter="this.style.opacity='1'; this.style.background='#cc0000';"
|
||||||
onmouseleave="this.style.opacity='0.8'; this.style.background='rgba(0,0,0,0.7)';"
|
onmouseleave="this.style.opacity='0.8'; this.style.background='rgba(0,0,0,0.7)';"
|
||||||
title="Remove">
|
title="Remove">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`}).join('');
|
`}).join('');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
empty.style.display = 'block';
|
empty.style.display = 'block';
|
||||||
if (type === 'subscriptions') {
|
if (type === 'subscriptions') {
|
||||||
emptyMsg.innerText = "You haven't subscribed to any channels yet.";
|
emptyMsg.innerText = "You haven't subscribed to any channels yet.";
|
||||||
} else if (type === 'saved') {
|
} else if (type === 'saved') {
|
||||||
emptyMsg.innerText = "No saved videos yet.";
|
emptyMsg.innerText = "No saved videos yet.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run on initial page load and SPA navigation
|
// Run on initial page load and SPA navigation
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadLibraryContent();
|
loadLibraryContent();
|
||||||
initTabs();
|
initTabs();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Document already loaded (SPA navigation)
|
// Document already loaded (SPA navigation)
|
||||||
loadLibraryContent();
|
loadLibraryContent();
|
||||||
initTabs();
|
initTabs();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTabs() {
|
function initTabs() {
|
||||||
// Intercept tab clicks for client-side navigation
|
// Intercept tab clicks for client-side navigation
|
||||||
document.querySelectorAll('.library-tab').forEach(tab => {
|
document.querySelectorAll('.library-tab').forEach(tab => {
|
||||||
// Remove old listeners to be safe (optional but good practice in SPA)
|
// Remove old listeners to be safe (optional but good practice in SPA)
|
||||||
const newTab = tab.cloneNode(true);
|
const newTab = tab.cloneNode(true);
|
||||||
tab.parentNode.replaceChild(newTab, tab);
|
tab.parentNode.replaceChild(newTab, tab);
|
||||||
|
|
||||||
newTab.addEventListener('click', (e) => {
|
newTab.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const newUrl = newTab.getAttribute('href');
|
const newUrl = newTab.getAttribute('href');
|
||||||
// Update URL without reloading
|
// Update URL without reloading
|
||||||
history.pushState(null, '', newUrl);
|
history.pushState(null, '', newUrl);
|
||||||
// Immediately load the new content
|
// Immediately load the new content
|
||||||
loadLibraryContent();
|
loadLibraryContent();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle browser back/forward buttons
|
// Handle browser back/forward buttons
|
||||||
window.addEventListener('popstate', () => {
|
window.addEventListener('popstate', () => {
|
||||||
loadLibraryContent();
|
loadLibraryContent();
|
||||||
});
|
});
|
||||||
|
|
||||||
function clearLibrary() {
|
function clearLibrary() {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const type = urlParams.get('type') || 'history';
|
const type = urlParams.get('type') || 'history';
|
||||||
const typeName = type.charAt(0).toUpperCase() + type.slice(1);
|
const typeName = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
|
||||||
if (confirm(`Are you sure you want to clear your ${typeName}? This cannot be undone.`)) {
|
if (confirm(`Are you sure you want to clear your ${typeName}? This cannot be undone.`)) {
|
||||||
const storageKey = `kv_${type}`;
|
const storageKey = `kv_${type}`;
|
||||||
localStorage.removeItem(storageKey);
|
localStorage.removeItem(storageKey);
|
||||||
// Reload to reflect changes
|
// Reload to reflect changes
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local toggleSubscribe for my_videos page - removes card visually
|
// Local toggleSubscribe for my_videos page - removes card visually
|
||||||
function toggleSubscribe(channelId, channelName, avatar, btnElement) {
|
function toggleSubscribe(channelId, channelName, avatar, btnElement) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
// Remove from library
|
// Remove from library
|
||||||
const key = 'kv_subscriptions';
|
const key = 'kv_subscriptions';
|
||||||
let data = JSON.parse(localStorage.getItem(key) || '[]');
|
let data = JSON.parse(localStorage.getItem(key) || '[]');
|
||||||
data = data.filter(item => item.id !== channelId);
|
data = data.filter(item => item.id !== channelId);
|
||||||
localStorage.setItem(key, JSON.stringify(data));
|
localStorage.setItem(key, JSON.stringify(data));
|
||||||
|
|
||||||
// Remove the card from UI
|
// Remove the card from UI
|
||||||
const card = btnElement.closest('.yt-channel-card');
|
const card = btnElement.closest('.yt-channel-card');
|
||||||
if (card) {
|
if (card) {
|
||||||
card.style.transition = 'opacity 0.3s, transform 0.3s';
|
card.style.transition = 'opacity 0.3s, transform 0.3s';
|
||||||
card.style.opacity = '0';
|
card.style.opacity = '0';
|
||||||
card.style.transform = 'scale(0.8)';
|
card.style.transform = 'scale(0.8)';
|
||||||
setTimeout(() => card.remove(), 300);
|
setTimeout(() => card.remove(), 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show empty state if no more subscriptions
|
// Show empty state if no more subscriptions
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const grid = document.getElementById('libraryGrid');
|
const grid = document.getElementById('libraryGrid');
|
||||||
if (grid && grid.children.length === 0) {
|
if (grid && grid.children.length === 0) {
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
document.getElementById('emptyState').style.display = 'block';
|
document.getElementById('emptyState').style.display = 'block';
|
||||||
document.getElementById('emptyMessage').innerText = "You haven't subscribed to any channels yet.";
|
document.getElementById('emptyMessage').innerText = "You haven't subscribed to any channels yet.";
|
||||||
}
|
}
|
||||||
}, 350);
|
}, 350);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove individual video from saved/history
|
// Remove individual video from saved/history
|
||||||
function removeVideo(videoId, type, btnElement) {
|
function removeVideo(videoId, type, btnElement) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
const key = `kv_${type}`;
|
const key = `kv_${type}`;
|
||||||
let data = JSON.parse(localStorage.getItem(key) || '[]');
|
let data = JSON.parse(localStorage.getItem(key) || '[]');
|
||||||
data = data.filter(item => item.id !== videoId);
|
data = data.filter(item => item.id !== videoId);
|
||||||
localStorage.setItem(key, JSON.stringify(data));
|
localStorage.setItem(key, JSON.stringify(data));
|
||||||
|
|
||||||
// Remove the card from UI with animation
|
// Remove the card from UI with animation
|
||||||
const card = btnElement.closest('.yt-video-card');
|
const card = btnElement.closest('.yt-video-card');
|
||||||
if (card) {
|
if (card) {
|
||||||
card.style.transition = 'opacity 0.3s, transform 0.3s';
|
card.style.transition = 'opacity 0.3s, transform 0.3s';
|
||||||
card.style.opacity = '0';
|
card.style.opacity = '0';
|
||||||
card.style.transform = 'scale(0.9)';
|
card.style.transform = 'scale(0.9)';
|
||||||
setTimeout(() => card.remove(), 300);
|
setTimeout(() => card.remove(), 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show empty state if no more videos
|
// Show empty state if no more videos
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const grid = document.getElementById('libraryGrid');
|
const grid = document.getElementById('libraryGrid');
|
||||||
if (grid && grid.children.length === 0) {
|
if (grid && grid.children.length === 0) {
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
document.getElementById('emptyState').style.display = 'block';
|
document.getElementById('emptyState').style.display = 'block';
|
||||||
const typeName = type === 'saved' ? 'No saved videos yet.' : 'No history yet.';
|
const typeName = type === 'saved' ? 'No saved videos yet.' : 'No history yet.';
|
||||||
document.getElementById('emptyMessage').innerText = typeName;
|
document.getElementById('emptyMessage').innerText = typeName;
|
||||||
}
|
}
|
||||||
}, 350);
|
}, 350);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
422
templates/register.html
Executable file → Normal file
422
templates/register.html
Executable file → Normal file
|
|
@ -1,212 +1,212 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="yt-auth-container">
|
<div class="yt-auth-container">
|
||||||
<div class="yt-auth-card">
|
<div class="yt-auth-card">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="yt-auth-logo">
|
<div class="yt-auth-logo">
|
||||||
<div class="yt-logo-icon" style="font-size: 24px;">KV-Tube</div>
|
<div class="yt-logo-icon" style="font-size: 24px;">KV-Tube</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Create account</h2>
|
<h2>Create account</h2>
|
||||||
<p>to start watching on KV-Tube</p>
|
<p>to start watching on KV-Tube</p>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages() %}
|
{% with messages = get_flashed_messages() %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<div class="yt-auth-alert">
|
<div class="yt-auth-alert">
|
||||||
<i class="fas fa-exclamation-circle"></i>
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
{{ messages[0] }}
|
{{ messages[0] }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<form method="POST" action="/register" class="yt-auth-form">
|
<form method="POST" action="/register" class="yt-auth-form">
|
||||||
<div class="yt-form-group">
|
<div class="yt-form-group">
|
||||||
<input type="text" name="username" class="yt-form-input" placeholder=" " required id="username">
|
<input type="text" name="username" class="yt-form-input" placeholder=" " required id="username">
|
||||||
<label for="username" class="yt-form-label">Username</label>
|
<label for="username" class="yt-form-label">Username</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="yt-form-group">
|
<div class="yt-form-group">
|
||||||
<input type="password" name="password" class="yt-form-input" placeholder=" " required id="password">
|
<input type="password" name="password" class="yt-form-input" placeholder=" " required id="password">
|
||||||
<label for="password" class="yt-form-label">Password</label>
|
<label for="password" class="yt-form-label">Password</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="yt-auth-submit">
|
<button type="submit" class="yt-auth-submit">
|
||||||
<i class="fas fa-user-plus"></i>
|
<i class="fas fa-user-plus"></i>
|
||||||
Create Account
|
Create Account
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="yt-auth-divider">
|
<div class="yt-auth-divider">
|
||||||
<span>or</span>
|
<span>or</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="yt-auth-footer">
|
<p class="yt-auth-footer">
|
||||||
Already have an account?
|
Already have an account?
|
||||||
<a href="/login" class="yt-auth-link">Sign in</a>
|
<a href="/login" class="yt-auth-link">Sign in</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.yt-auth-container {
|
.yt-auth-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: calc(100vh - var(--yt-header-height) - 100px);
|
min-height: calc(100vh - var(--yt-header-height) - 100px);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-card {
|
.yt-auth-card {
|
||||||
background: var(--yt-bg-secondary);
|
background: var(--yt-bg-secondary);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 48px 40px;
|
padding: 48px 40px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1px solid var(--yt-border);
|
border: 1px solid var(--yt-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-logo {
|
.yt-auth-logo {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-card h2 {
|
.yt-auth-card h2 {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-card>p {
|
.yt-auth-card>p {
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-alert {
|
.yt-auth-alert {
|
||||||
background: rgba(244, 67, 54, 0.1);
|
background: rgba(244, 67, 54, 0.1);
|
||||||
color: #f44336;
|
color: #f44336;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-form {
|
.yt-auth-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-group {
|
.yt-form-group {
|
||||||
position: relative;
|
position: relative;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-input {
|
.yt-form-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 16px 14px;
|
padding: 16px 14px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
background: var(--yt-bg-primary);
|
background: var(--yt-bg-primary);
|
||||||
border: 1px solid var(--yt-border);
|
border: 1px solid var(--yt-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-input:focus {
|
.yt-form-input:focus {
|
||||||
border-color: var(--yt-accent-blue);
|
border-color: var(--yt-accent-blue);
|
||||||
box-shadow: 0 0 0 3px rgba(62, 166, 255, 0.2);
|
box-shadow: 0 0 0 3px rgba(62, 166, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-label {
|
.yt-form-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 14px;
|
left: 14px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
background: var(--yt-bg-primary);
|
background: var(--yt-bg-primary);
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-input:focus+.yt-form-label,
|
.yt-form-input:focus+.yt-form-label,
|
||||||
.yt-form-input:not(:placeholder-shown)+.yt-form-label {
|
.yt-form-input:not(:placeholder-shown)+.yt-form-label {
|
||||||
top: 0;
|
top: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--yt-accent-blue);
|
color: var(--yt-accent-blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-submit {
|
.yt-auth-submit {
|
||||||
background: var(--yt-accent-blue);
|
background: var(--yt-accent-blue);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 14px 24px;
|
padding: 14px 24px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
transition: background 0.2s, transform 0.1s;
|
transition: background 0.2s, transform 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-submit:hover {
|
.yt-auth-submit:hover {
|
||||||
background: #258fd9;
|
background: #258fd9;
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-submit:active {
|
.yt-auth-submit:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-divider {
|
.yt-auth-divider {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 24px 0;
|
margin: 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-divider::before,
|
.yt-auth-divider::before,
|
||||||
.yt-auth-divider::after {
|
.yt-auth-divider::after {
|
||||||
content: '';
|
content: '';
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--yt-border);
|
background: var(--yt-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-divider span {
|
.yt-auth-divider span {
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-footer {
|
.yt-auth-footer {
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-link {
|
.yt-auth-link {
|
||||||
color: var(--yt-accent-blue);
|
color: var(--yt-accent-blue);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-link:hover {
|
.yt-auth-link:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.yt-auth-card {
|
.yt-auth-card {
|
||||||
padding: 32px 24px;
|
padding: 32px 24px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
708
templates/settings.html
Executable file → Normal file
708
templates/settings.html
Executable file → Normal file
|
|
@ -1,355 +1,355 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="yt-settings-container">
|
<div class="yt-settings-container">
|
||||||
<h2 class="yt-settings-title">Settings</h2>
|
<h2 class="yt-settings-title">Settings</h2>
|
||||||
|
|
||||||
<!-- Appearance & Playback in one card -->
|
<!-- Appearance & Playback in one card -->
|
||||||
<div class="yt-settings-card compact">
|
<div class="yt-settings-card compact">
|
||||||
<div class="yt-setting-row">
|
<div class="yt-setting-row">
|
||||||
<span class="yt-setting-label">Theme</span>
|
<span class="yt-setting-label">Theme</span>
|
||||||
<div class="yt-toggle-group">
|
<div class="yt-toggle-group">
|
||||||
<button type="button" class="yt-toggle-btn" id="themeBtnLight"
|
<button type="button" class="yt-toggle-btn" id="themeBtnLight"
|
||||||
onclick="setTheme('light')">Light</button>
|
onclick="setTheme('light')">Light</button>
|
||||||
<button type="button" class="yt-toggle-btn" id="themeBtnDark" onclick="setTheme('dark')">Dark</button>
|
<button type="button" class="yt-toggle-btn" id="themeBtnDark" onclick="setTheme('dark')">Dark</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-setting-row">
|
<div class="yt-setting-row">
|
||||||
<span class="yt-setting-label">Player</span>
|
<span class="yt-setting-label">Player</span>
|
||||||
<div class="yt-toggle-group">
|
<div class="yt-toggle-group">
|
||||||
<button type="button" class="yt-toggle-btn" id="playerBtnArt"
|
<button type="button" class="yt-toggle-btn" id="playerBtnArt"
|
||||||
onclick="setPlayerPref('artplayer')">Artplayer</button>
|
onclick="setPlayerPref('artplayer')">Artplayer</button>
|
||||||
<button type="button" class="yt-toggle-btn" id="playerBtnNative"
|
<button type="button" class="yt-toggle-btn" id="playerBtnNative"
|
||||||
onclick="setPlayerPref('native')">Native</button>
|
onclick="setPlayerPref('native')">Native</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System Updates -->
|
<!-- System Updates -->
|
||||||
<div class="yt-settings-card">
|
<div class="yt-settings-card">
|
||||||
<h3>System Updates</h3>
|
<h3>System Updates</h3>
|
||||||
|
|
||||||
<!-- yt-dlp Stable -->
|
<!-- yt-dlp Stable -->
|
||||||
<div class="yt-update-row">
|
<div class="yt-update-row">
|
||||||
<div class="yt-update-info">
|
<div class="yt-update-info">
|
||||||
<strong>yt-dlp</strong>
|
<strong>yt-dlp</strong>
|
||||||
<span class="yt-update-version" id="ytdlpVersion">Stable</span>
|
<span class="yt-update-version" id="ytdlpVersion">Stable</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="updateYtdlpStable" onclick="updatePackage('ytdlp', 'stable')" class="yt-update-btn small">
|
<button id="updateYtdlpStable" onclick="updatePackage('ytdlp', 'stable')" class="yt-update-btn small">
|
||||||
<i class="fas fa-sync-alt"></i> Update
|
<i class="fas fa-sync-alt"></i> Update
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- yt-dlp Nightly -->
|
<!-- yt-dlp Nightly -->
|
||||||
<div class="yt-update-row">
|
<div class="yt-update-row">
|
||||||
<div class="yt-update-info">
|
<div class="yt-update-info">
|
||||||
<strong>yt-dlp Nightly</strong>
|
<strong>yt-dlp Nightly</strong>
|
||||||
<span class="yt-update-version">Experimental</span>
|
<span class="yt-update-version">Experimental</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="updateYtdlpNightly" onclick="updatePackage('ytdlp', 'nightly')"
|
<button id="updateYtdlpNightly" onclick="updatePackage('ytdlp', 'nightly')"
|
||||||
class="yt-update-btn small nightly">
|
class="yt-update-btn small nightly">
|
||||||
<i class="fas fa-flask"></i> Install
|
<i class="fas fa-flask"></i> Install
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ytfetcher -->
|
<!-- ytfetcher -->
|
||||||
<div class="yt-update-row">
|
<div class="yt-update-row">
|
||||||
<div class="yt-update-info">
|
<div class="yt-update-info">
|
||||||
<strong>ytfetcher</strong>
|
<strong>ytfetcher</strong>
|
||||||
<span class="yt-update-version" id="ytfetcherVersion">CC & Transcripts</span>
|
<span class="yt-update-version" id="ytfetcherVersion">CC & Transcripts</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="updateYtfetcher" onclick="updatePackage('ytfetcher', 'latest')" class="yt-update-btn small">
|
<button id="updateYtfetcher" onclick="updatePackage('ytfetcher', 'latest')" class="yt-update-btn small">
|
||||||
<i class="fas fa-sync-alt"></i> Update
|
<i class="fas fa-sync-alt"></i> Update
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="updateStatus" class="yt-update-status"></div>
|
<div id="updateStatus" class="yt-update-status"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if session.get('user_id') %}
|
{% if session.get('user_id') %}
|
||||||
<div class="yt-settings-card compact">
|
<div class="yt-settings-card compact">
|
||||||
<div class="yt-setting-row">
|
<div class="yt-setting-row">
|
||||||
<span class="yt-setting-label">Display Name</span>
|
<span class="yt-setting-label">Display Name</span>
|
||||||
<form id="profileForm" onsubmit="updateProfile(event)"
|
<form id="profileForm" onsubmit="updateProfile(event)"
|
||||||
style="display: flex; gap: 8px; flex: 1; max-width: 300px;">
|
style="display: flex; gap: 8px; flex: 1; max-width: 300px;">
|
||||||
<input type="text" class="yt-form-input" id="displayName" value="{{ session.username }}" required
|
<input type="text" class="yt-form-input" id="displayName" value="{{ session.username }}" required
|
||||||
style="flex: 1;">
|
style="flex: 1;">
|
||||||
<button type="submit" class="yt-update-btn small">Save</button>
|
<button type="submit" class="yt-update-btn small">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="yt-settings-card compact">
|
<div class="yt-settings-card compact">
|
||||||
<div class="yt-setting-row" style="justify-content: center;">
|
<div class="yt-setting-row" style="justify-content: center;">
|
||||||
<span class="yt-about-text">KV-Tube v1.0 • YouTube-like streaming</span>
|
<span class="yt-about-text">KV-Tube v1.0 • YouTube-like streaming</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.yt-settings-container {
|
.yt-settings-container {
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-settings-title {
|
.yt-settings-title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-settings-card {
|
.yt-settings-card {
|
||||||
background: var(--yt-bg-secondary);
|
background: var(--yt-bg-secondary);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-settings-card.compact {
|
.yt-settings-card.compact {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-settings-card h3 {
|
.yt-settings-card h3 {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-setting-row {
|
.yt-setting-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-setting-row:not(:last-child) {
|
.yt-setting-row:not(:last-child) {
|
||||||
border-bottom: 1px solid var(--yt-bg-hover);
|
border-bottom: 1px solid var(--yt-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-setting-label {
|
.yt-setting-label {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-toggle-group {
|
.yt-toggle-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--yt-bg-elevated);
|
background: var(--yt-bg-elevated);
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-toggle-btn {
|
.yt-toggle-btn {
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-toggle-btn:hover {
|
.yt-toggle-btn:hover {
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-toggle-btn.active {
|
.yt-toggle-btn.active {
|
||||||
background: var(--yt-accent-red);
|
background: var(--yt-accent-red);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-row {
|
.yt-update-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
border-bottom: 1px solid var(--yt-bg-hover);
|
border-bottom: 1px solid var(--yt-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-row:last-of-type {
|
.yt-update-row:last-of-type {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-info {
|
.yt-update-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-info strong {
|
.yt-update-info strong {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-version {
|
.yt-update-version {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-btn {
|
.yt-update-btn {
|
||||||
background: var(--yt-accent-red);
|
background: var(--yt-accent-red);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-btn.small {
|
.yt-update-btn.small {
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-btn.nightly {
|
.yt-update-btn.nightly {
|
||||||
background: #9c27b0;
|
background: #9c27b0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-btn:hover {
|
.yt-update-btn:hover {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-btn:disabled {
|
.yt-update-btn:disabled {
|
||||||
background: var(--yt-bg-hover) !important;
|
background: var(--yt-bg-hover) !important;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-status {
|
.yt-update-status {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-input {
|
.yt-form-input {
|
||||||
background: var(--yt-bg-elevated);
|
background: var(--yt-bg-elevated);
|
||||||
border: 1px solid var(--yt-bg-hover);
|
border: 1px solid var(--yt-bg-hover);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-about-text {
|
.yt-about-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
async function fetchVersions() {
|
async function fetchVersions() {
|
||||||
const pkgs = ['ytdlp', 'ytfetcher'];
|
const pkgs = ['ytdlp', 'ytfetcher'];
|
||||||
for (const pkg of pkgs) {
|
for (const pkg of pkgs) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/package/version?package=${pkg}`);
|
const res = await fetch(`/api/package/version?package=${pkg}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
const el = document.getElementById(pkg === 'ytdlp' ? 'ytdlpVersion' : 'ytfetcherVersion');
|
const el = document.getElementById(pkg === 'ytdlp' ? 'ytdlpVersion' : 'ytfetcherVersion');
|
||||||
if (el) {
|
if (el) {
|
||||||
el.innerText = `Installed: ${data.version}`;
|
el.innerText = `Installed: ${data.version}`;
|
||||||
// Highlight if nightly
|
// Highlight if nightly
|
||||||
if (pkg === 'ytdlp' && (data.version.includes('2026') || data.version.includes('.dev'))) {
|
if (pkg === 'ytdlp' && (data.version.includes('2026') || data.version.includes('.dev'))) {
|
||||||
el.style.color = '#9c27b0';
|
el.style.color = '#9c27b0';
|
||||||
el.innerText += ' (Nightly)';
|
el.innerText += ' (Nightly)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updatePackage(pkg, version) {
|
async function updatePackage(pkg, version) {
|
||||||
const btnId = pkg === 'ytdlp' ?
|
const btnId = pkg === 'ytdlp' ?
|
||||||
(version === 'nightly' ? 'updateYtdlpNightly' : 'updateYtdlpStable') :
|
(version === 'nightly' ? 'updateYtdlpNightly' : 'updateYtdlpStable') :
|
||||||
'updateYtfetcher';
|
'updateYtfetcher';
|
||||||
const btn = document.getElementById(btnId);
|
const btn = document.getElementById(btnId);
|
||||||
const status = document.getElementById('updateStatus');
|
const status = document.getElementById('updateStatus');
|
||||||
const originalHTML = btn.innerHTML;
|
const originalHTML = btn.innerHTML;
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Updating...';
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Updating...';
|
||||||
status.style.color = 'var(--yt-text-secondary)';
|
status.style.color = 'var(--yt-text-secondary)';
|
||||||
status.innerText = `Updating ${pkg}${version === 'nightly' ? ' (nightly)' : ''}...`;
|
status.innerText = `Updating ${pkg}${version === 'nightly' ? ' (nightly)' : ''}...`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/update_package', {
|
const response = await fetch('/api/update_package', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ package: pkg, version: version })
|
body: JSON.stringify({ package: pkg, version: version })
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
status.style.color = '#4caf50';
|
status.style.color = '#4caf50';
|
||||||
status.innerText = '✓ ' + data.message;
|
status.innerText = '✓ ' + data.message;
|
||||||
btn.innerHTML = '<i class="fas fa-check"></i> Updated';
|
btn.innerHTML = '<i class="fas fa-check"></i> Updated';
|
||||||
// Refresh versions
|
// Refresh versions
|
||||||
setTimeout(fetchVersions, 1000);
|
setTimeout(fetchVersions, 1000);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.innerHTML = originalHTML;
|
btn.innerHTML = originalHTML;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else {
|
} else {
|
||||||
status.style.color = '#f44336';
|
status.style.color = '#f44336';
|
||||||
status.innerText = '✗ ' + data.message;
|
status.innerText = '✗ ' + data.message;
|
||||||
btn.innerHTML = originalHTML;
|
btn.innerHTML = originalHTML;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.style.color = '#f44336';
|
status.style.color = '#f44336';
|
||||||
status.innerText = '✗ Error: ' + e.message;
|
status.innerText = '✗ Error: ' + e.message;
|
||||||
btn.innerHTML = originalHTML;
|
btn.innerHTML = originalHTML;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Player Preference ---
|
// --- Player Preference ---
|
||||||
window.setPlayerPref = function (type) {
|
window.setPlayerPref = function (type) {
|
||||||
localStorage.setItem('kv_player_pref', type);
|
localStorage.setItem('kv_player_pref', type);
|
||||||
updatePlayerButtons(type);
|
updatePlayerButtons(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.updatePlayerButtons = function (type) {
|
window.updatePlayerButtons = function (type) {
|
||||||
const artBtn = document.getElementById('playerBtnArt');
|
const artBtn = document.getElementById('playerBtnArt');
|
||||||
const natBtn = document.getElementById('playerBtnNative');
|
const natBtn = document.getElementById('playerBtnNative');
|
||||||
if (artBtn) artBtn.classList.remove('active');
|
if (artBtn) artBtn.classList.remove('active');
|
||||||
if (natBtn) natBtn.classList.remove('active');
|
if (natBtn) natBtn.classList.remove('active');
|
||||||
if (type === 'native') {
|
if (type === 'native') {
|
||||||
if (natBtn) natBtn.classList.add('active');
|
if (natBtn) natBtn.classList.add('active');
|
||||||
} else {
|
} else {
|
||||||
if (artBtn) artBtn.classList.add('active');
|
if (artBtn) artBtn.classList.add('active');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Settings
|
// Initialize Settings
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Theme init
|
// Theme init
|
||||||
const currentTheme = localStorage.getItem('theme') || 'dark';
|
const currentTheme = localStorage.getItem('theme') || 'dark';
|
||||||
const lightBtn = document.getElementById('themeBtnLight');
|
const lightBtn = document.getElementById('themeBtnLight');
|
||||||
const darkBtn = document.getElementById('themeBtnDark');
|
const darkBtn = document.getElementById('themeBtnDark');
|
||||||
if (currentTheme === 'light') {
|
if (currentTheme === 'light') {
|
||||||
if (lightBtn) lightBtn.classList.add('active');
|
if (lightBtn) lightBtn.classList.add('active');
|
||||||
} else {
|
} else {
|
||||||
if (darkBtn) darkBtn.classList.add('active');
|
if (darkBtn) darkBtn.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Player init - default to artplayer
|
// Player init - default to artplayer
|
||||||
const playerPref = localStorage.getItem('kv_player_pref') || 'artplayer';
|
const playerPref = localStorage.getItem('kv_player_pref') || 'artplayer';
|
||||||
updatePlayerButtons(playerPref);
|
updatePlayerButtons(playerPref);
|
||||||
|
|
||||||
// Fetch versions
|
// Fetch versions
|
||||||
fetchVersions();
|
fetchVersions();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
4100
templates/watch.html
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