Compare commits
No commits in common. "aa1a419c35927b81f89cf0a4fb7edb435d27cfe3" and "f429116ed099738264c3e6377db5b5429c103412" have entirely different histories.
aa1a419c35
...
f429116ed0
2618 changed files with 11958 additions and 626827 deletions
13
.dockerignore
Executable file
13
.dockerignore
Executable file
|
|
@ -0,0 +1,13 @@
|
||||||
|
.venv/
|
||||||
|
.venv_clean/
|
||||||
|
env/
|
||||||
|
__pycache__/
|
||||||
|
.git/
|
||||||
|
.DS_Store
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
videos/
|
||||||
|
data/
|
||||||
12
.env.example
Executable file
12
.env.example
Executable file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# KV-Tube Environment Configuration
|
||||||
|
# Copy this file to .env and customize as needed
|
||||||
|
|
||||||
|
# Secret key for Flask sessions (required for production)
|
||||||
|
# Generate a secure key: python -c "import os; print(os.urandom(32).hex())"
|
||||||
|
SECRET_KEY=your-secure-secret-key-here
|
||||||
|
|
||||||
|
# Environment: development or production
|
||||||
|
FLASK_ENV=development
|
||||||
|
|
||||||
|
# Local video directory (optional)
|
||||||
|
KVTUBE_VIDEO_DIR=./videos
|
||||||
1
.gemini/tmp/ytfetcher
Submodule
1
.gemini/tmp/ytfetcher
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 246c4c349d97205eb2b51d7d3999ea846f5b2bdc
|
||||||
68
.github/workflows/docker-publish.yml
vendored
Executable file
68
.github/workflows/docker-publish.yml
vendored
Executable file
|
|
@ -0,0 +1,68 @@
|
||||||
|
name: Docker Build & Push
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Use docker.io for Docker Hub if empty
|
||||||
|
REGISTRY: docker.io
|
||||||
|
# github.repository as <account>/<repo>
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log into Docker Hub
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Log into Forgejo Registry
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.khoavo.myds.me
|
||||||
|
username: ${{ secrets.FORGEJO_USERNAME }}
|
||||||
|
password: ${{ secrets.FORGEJO_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
git.khoavo.myds.me/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=raw,value=latest,enable=${{ github.ref == 'refs/tags/v*' }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: build-and-push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
platforms: linux/amd64
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
12
.gitignore
vendored
Executable file
12
.gitignore
vendored
Executable file
|
|
@ -0,0 +1,12 @@
|
||||||
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
.venv_clean/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
videos/
|
||||||
|
*.db
|
||||||
|
server.log
|
||||||
|
.ruff_cache/
|
||||||
0
API_DOCUMENTATION.md
Normal file → Executable file
0
API_DOCUMENTATION.md
Normal file → Executable file
66
Dockerfile
Normal file → Executable file
66
Dockerfile
Normal file → Executable file
|
|
@ -1,33 +1,33 @@
|
||||||
# Build stage
|
# Build stage
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies (ffmpeg is critical for yt-dlp)
|
# Install system dependencies (ffmpeg is critical for yt-dlp)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
curl \
|
curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Python dependencies
|
# Install Python dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
ENV FLASK_APP=wsgi.py
|
ENV FLASK_APP=wsgi.py
|
||||||
ENV FLASK_ENV=production
|
ENV FLASK_ENV=production
|
||||||
|
|
||||||
# Create directories for data persistence
|
# Create directories for data persistence
|
||||||
RUN mkdir -p /app/videos /app/data
|
RUN mkdir -p /app/videos /app/data
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
# Run with Entrypoint (handles updates)
|
# Run with Entrypoint (handles updates)
|
||||||
COPY entrypoint.sh /app/entrypoint.sh
|
COPY entrypoint.sh /app/entrypoint.sh
|
||||||
RUN chmod +x /app/entrypoint.sh
|
RUN chmod +x /app/entrypoint.sh
|
||||||
CMD ["/app/entrypoint.sh"]
|
CMD ["/app/entrypoint.sh"]
|
||||||
|
|
|
||||||
124
README.md
Normal file → Executable file
124
README.md
Normal file → Executable 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
Normal file → Executable file
0
USER_GUIDE.md
Normal file → Executable file
Binary file not shown.
324
app/__init__.py
Normal file → Executable file
324
app/__init__.py
Normal file → Executable file
|
|
@ -1,162 +1,162 @@
|
||||||
"""
|
"""
|
||||||
KV-Tube App Package
|
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")
|
||||||
|
|
|
||||||
Binary file not shown.
18
app/routes/__init__.py
Normal file → Executable file
18
app/routes/__init__.py
Normal file → Executable file
|
|
@ -1,9 +1,9 @@
|
||||||
"""
|
"""
|
||||||
KV-Tube Routes Package
|
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']
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
158
app/routes/api.py
Normal file → Executable file
158
app/routes/api.py
Normal file → Executable file
|
|
@ -15,11 +15,11 @@ import time
|
||||||
import random
|
import random
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
# from ytfetcher import YTFetcher
|
|
||||||
from app.services.settings import SettingsService
|
from app.services.settings import SettingsService
|
||||||
from app.services.summarizer import TextRankSummarizer
|
from app.services.summarizer import TextRankSummarizer
|
||||||
from app.services.gemini_summarizer import summarize_with_gemini, extract_key_points_with_gemini
|
from app.services.gemini_summarizer import summarize_with_gemini, extract_key_points_with_gemini
|
||||||
from app.services.youtube import YouTubeService
|
from app.services.youtube import YouTubeService
|
||||||
|
from app.services.transcript_service import TranscriptService
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -1405,25 +1405,25 @@ def summarize_video():
|
||||||
return jsonify({"error": "No video ID"}), 400
|
return jsonify({"error": "No video ID"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. Get Transcript Text
|
# 1. Get Transcript Text using TranscriptService (with ytfetcher fallback)
|
||||||
text = get_transcript_text(video_id)
|
text = TranscriptService.get_transcript(video_id)
|
||||||
if not text:
|
if not text:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": "No transcript available to summarize."
|
"error": "No transcript available to summarize."
|
||||||
})
|
})
|
||||||
|
|
||||||
# 2. Use TextRank Summarizer (Gemini removed per user request)
|
# 2. Use TextRank Summarizer - generate longer, more meaningful summaries
|
||||||
summarizer = TextRankSummarizer()
|
summarizer = TextRankSummarizer()
|
||||||
summary_text = summarizer.summarize(text, num_sentences=3)
|
summary_text = summarizer.summarize(text, num_sentences=5) # Increased from 3 to 5
|
||||||
|
|
||||||
# Limit to 300 characters for concise display
|
# Allow longer summaries for more meaningful content (600 chars instead of 300)
|
||||||
if len(summary_text) > 300:
|
if len(summary_text) > 600:
|
||||||
summary_text = summary_text[:297] + "..."
|
summary_text = summary_text[:597] + "..."
|
||||||
|
|
||||||
# Extract key points from summary (heuristic)
|
# Key points will be extracted by WebLLM on frontend (better quality)
|
||||||
sentences = [s.strip() for s in summary_text.split('.') if len(s.strip()) > 15]
|
# Backend just returns empty list - WebLLM generates conceptual key points
|
||||||
key_points = sentences[:3]
|
key_points = []
|
||||||
|
|
||||||
# Store original versions
|
# Store original versions
|
||||||
original_summary = summary_text
|
original_summary = summary_text
|
||||||
|
|
@ -1472,78 +1472,90 @@ def translate_text(text, target_lang='vi'):
|
||||||
|
|
||||||
def get_transcript_text(video_id):
|
def get_transcript_text(video_id):
|
||||||
"""
|
"""
|
||||||
Fetch transcript using strictly YTFetcher as requested.
|
Fetch transcript using yt-dlp (downloading subtitles to file).
|
||||||
Ensure 'ytfetcher' is up to date before usage.
|
Reliable method that handles auto-generated captions and cookies.
|
||||||
"""
|
"""
|
||||||
from ytfetcher import YTFetcher
|
import yt_dlp
|
||||||
from ytfetcher.config import HTTPConfig
|
import glob
|
||||||
import random
|
import random
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import http.cookiejar
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. Prepare Cookies if available
|
video_id = video_id.strip()
|
||||||
# This was key to the previous success!
|
logger.info(f"Fetching transcript for {video_id} using yt-dlp")
|
||||||
cookie_header = ""
|
|
||||||
cookies_path = os.environ.get('COOKIES_FILE', 'cookies.txt')
|
|
||||||
|
|
||||||
if os.path.exists(cookies_path):
|
# Use a temporary filename pattern
|
||||||
try:
|
temp_prefix = f"transcript_{video_id}_{random.randint(1000, 9999)}"
|
||||||
cj = http.cookiejar.MozillaCookieJar(cookies_path)
|
|
||||||
cj.load()
|
|
||||||
cookies_list = []
|
|
||||||
for cookie in cj:
|
|
||||||
cookies_list.append(f"{cookie.name}={cookie.value}")
|
|
||||||
cookie_header = "; ".join(cookies_list)
|
|
||||||
logger.info(f"Loaded {len(cookies_list)} cookies for YTFetcher")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to process cookies: {e}")
|
|
||||||
|
|
||||||
# 2. Configuration to look like a real browser
|
|
||||||
user_agents = [
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
|
|
||||||
]
|
|
||||||
|
|
||||||
headers = {
|
ydl_opts = {
|
||||||
"User-Agent": random.choice(user_agents),
|
'skip_download': True,
|
||||||
"Accept-Language": "en-US,en;q=0.9",
|
'quiet': True,
|
||||||
|
'no_warnings': True,
|
||||||
|
'cookiefile': os.environ.get('COOKIES_FILE', 'cookies.txt') if os.path.exists(os.environ.get('COOKIES_FILE', 'cookies.txt')) else None,
|
||||||
|
'writesubtitles': True,
|
||||||
|
'writeautomaticsub': True,
|
||||||
|
'subtitleslangs': ['en', 'vi', 'en-US'],
|
||||||
|
'outtmpl': f"/tmp/{temp_prefix}", # Save to /tmp
|
||||||
|
'subtitlesformat': 'json3/vtt/best', # Prefer json3 for parsing, then vtt
|
||||||
}
|
}
|
||||||
|
|
||||||
# Inject cookie header if we have it
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
if cookie_header:
|
# This will download the subtitle file to /tmp/
|
||||||
headers["Cookie"] = cookie_header
|
ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
|
||||||
|
|
||||||
config = HTTPConfig(headers=headers)
|
# Find the downloaded file
|
||||||
|
# yt-dlp appends language code, e.g. .en.json3
|
||||||
# Initialize Fetcher
|
# We look for any file with our prefix
|
||||||
fetcher = YTFetcher.from_video_ids(
|
downloaded_files = glob.glob(f"/tmp/{temp_prefix}*")
|
||||||
video_ids=[video_id],
|
|
||||||
http_config=config,
|
if not downloaded_files:
|
||||||
languages=['en', 'en-US', 'vi']
|
logger.warning("yt-dlp finished but no subtitle file found.")
|
||||||
)
|
return None
|
||||||
|
|
||||||
# Fetch
|
|
||||||
logger.info(f"Fetching transcript with YTFetcher for {video_id}")
|
|
||||||
results = fetcher.fetch_transcripts()
|
|
||||||
|
|
||||||
if results:
|
|
||||||
data = results[0]
|
|
||||||
# Check for transcript data
|
|
||||||
if data.transcripts:
|
|
||||||
logger.info("YTFetcher: Transcript found.")
|
|
||||||
text_lines = [t.text.strip() for t in data.transcripts if t.text.strip()]
|
|
||||||
return " ".join(text_lines)
|
|
||||||
else:
|
|
||||||
logger.warning("YTFetcher: No transcript in result.")
|
|
||||||
|
|
||||||
|
# Pick the best file (prefer json3, then vtt)
|
||||||
|
selected_file = None
|
||||||
|
for ext in ['.json3', '.vtt', '.ttml', '.srv3']:
|
||||||
|
for f in downloaded_files:
|
||||||
|
if f.endswith(ext):
|
||||||
|
selected_file = f
|
||||||
|
break
|
||||||
|
if selected_file: break
|
||||||
|
|
||||||
|
if not selected_file:
|
||||||
|
selected_file = downloaded_files[0]
|
||||||
|
|
||||||
|
# Read content
|
||||||
|
with open(selected_file, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
for f in downloaded_files:
|
||||||
|
try:
|
||||||
|
os.remove(f)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Parse
|
||||||
|
if selected_file.endswith('.json3') or content.strip().startswith('{'):
|
||||||
|
try:
|
||||||
|
json_data = json.loads(content)
|
||||||
|
events = json_data.get('events', [])
|
||||||
|
text_parts = []
|
||||||
|
for event in events:
|
||||||
|
segs = event.get('segs', [])
|
||||||
|
for seg in segs:
|
||||||
|
txt = seg.get('utf8', '').strip()
|
||||||
|
if txt and txt != '\n':
|
||||||
|
text_parts.append(txt)
|
||||||
|
return " ".join(text_parts)
|
||||||
|
except Exception as je:
|
||||||
|
logger.warning(f"JSON3 parse failed: {je}")
|
||||||
|
|
||||||
|
return parse_transcript_content(content)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
logger.error(f"Transcript fetch failed: {e}")
|
||||||
tb = traceback.format_exc()
|
|
||||||
logger.error(f"YTFetcher Execution Failed: {e}\n{tb}")
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
0
app/routes/pages.py
Normal file → Executable file
0
app/routes/pages.py
Normal file → Executable file
0
app/routes/streaming.py
Normal file → Executable file
0
app/routes/streaming.py
Normal file → Executable file
2
app/services/__init__.py
Normal file → Executable file
2
app/services/__init__.py
Normal file → Executable file
|
|
@ -1 +1 @@
|
||||||
"""KV-Tube Services Package"""
|
"""KV-Tube Services Package"""
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
434
app/services/cache.py
Normal file → Executable file
434
app/services/cache.py
Normal file → Executable file
|
|
@ -1,217 +1,217 @@
|
||||||
"""
|
"""
|
||||||
Cache Service Module
|
Cache Service Module
|
||||||
SQLite-based caching with connection pooling
|
SQLite-based caching with connection pooling
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Any, Dict
|
from typing import Optional, Any, Dict
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from config import Config
|
from config import Config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ConnectionPool:
|
class ConnectionPool:
|
||||||
"""Thread-safe SQLite connection pool"""
|
"""Thread-safe SQLite connection pool"""
|
||||||
|
|
||||||
def __init__(self, db_path: str, max_connections: int = 5):
|
def __init__(self, db_path: str, max_connections: int = 5):
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self.max_connections = max_connections
|
self.max_connections = max_connections
|
||||||
self._local = threading.local()
|
self._local = threading.local()
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._init_db()
|
self._init_db()
|
||||||
|
|
||||||
def _init_db(self):
|
def _init_db(self):
|
||||||
"""Initialize database tables"""
|
"""Initialize database tables"""
|
||||||
conn = sqlite3.connect(self.db_path)
|
conn = sqlite3.connect(self.db_path)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
|
|
||||||
# Users table
|
# Users table
|
||||||
c.execute('''CREATE TABLE IF NOT EXISTS users (
|
c.execute('''CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
password TEXT NOT NULL
|
password TEXT NOT NULL
|
||||||
)''')
|
)''')
|
||||||
|
|
||||||
# User videos (history/saved)
|
# User videos (history/saved)
|
||||||
c.execute('''CREATE TABLE IF NOT EXISTS user_videos (
|
c.execute('''CREATE TABLE IF NOT EXISTS user_videos (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER,
|
user_id INTEGER,
|
||||||
video_id TEXT,
|
video_id TEXT,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
thumbnail TEXT,
|
thumbnail TEXT,
|
||||||
type TEXT,
|
type TEXT,
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
)''')
|
)''')
|
||||||
|
|
||||||
# Video cache
|
# Video cache
|
||||||
c.execute('''CREATE TABLE IF NOT EXISTS video_cache (
|
c.execute('''CREATE TABLE IF NOT EXISTS video_cache (
|
||||||
video_id TEXT PRIMARY KEY,
|
video_id TEXT PRIMARY KEY,
|
||||||
data TEXT,
|
data TEXT,
|
||||||
expires_at REAL
|
expires_at REAL
|
||||||
)''')
|
)''')
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def get_connection(self) -> sqlite3.Connection:
|
def get_connection(self) -> sqlite3.Connection:
|
||||||
"""Get a thread-local database connection"""
|
"""Get a thread-local database connection"""
|
||||||
if not hasattr(self._local, 'connection') or self._local.connection is None:
|
if not hasattr(self._local, 'connection') or self._local.connection is None:
|
||||||
self._local.connection = sqlite3.connect(self.db_path)
|
self._local.connection = sqlite3.connect(self.db_path)
|
||||||
self._local.connection.row_factory = sqlite3.Row
|
self._local.connection.row_factory = sqlite3.Row
|
||||||
return self._local.connection
|
return self._local.connection
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def connection(self):
|
def connection(self):
|
||||||
"""Context manager for database connections"""
|
"""Context manager for database connections"""
|
||||||
conn = self.get_connection()
|
conn = self.get_connection()
|
||||||
try:
|
try:
|
||||||
yield conn
|
yield conn
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
logger.error(f"Database error: {e}")
|
logger.error(f"Database error: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Close the thread-local connection"""
|
"""Close the thread-local connection"""
|
||||||
if hasattr(self._local, 'connection') and self._local.connection:
|
if hasattr(self._local, 'connection') and self._local.connection:
|
||||||
self._local.connection.close()
|
self._local.connection.close()
|
||||||
self._local.connection = None
|
self._local.connection = None
|
||||||
|
|
||||||
|
|
||||||
# Global connection pool
|
# Global connection pool
|
||||||
_pool: Optional[ConnectionPool] = None
|
_pool: Optional[ConnectionPool] = None
|
||||||
|
|
||||||
|
|
||||||
def get_pool() -> ConnectionPool:
|
def get_pool() -> ConnectionPool:
|
||||||
"""Get or create the global connection pool"""
|
"""Get or create the global connection pool"""
|
||||||
global _pool
|
global _pool
|
||||||
if _pool is None:
|
if _pool is None:
|
||||||
_pool = ConnectionPool(Config.DB_NAME)
|
_pool = ConnectionPool(Config.DB_NAME)
|
||||||
return _pool
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
def get_db_connection() -> sqlite3.Connection:
|
def get_db_connection() -> sqlite3.Connection:
|
||||||
"""Get a database connection - backward compatibility"""
|
"""Get a database connection - backward compatibility"""
|
||||||
return get_pool().get_connection()
|
return get_pool().get_connection()
|
||||||
|
|
||||||
|
|
||||||
class CacheService:
|
class CacheService:
|
||||||
"""Service for caching video metadata"""
|
"""Service for caching video metadata"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_video_cache(video_id: str) -> Optional[Dict[str, Any]]:
|
def get_video_cache(video_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get cached video data if not expired
|
Get cached video data if not expired
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
video_id: YouTube video ID
|
video_id: YouTube video ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Cached data dict or None if not found/expired
|
Cached data dict or None if not found/expired
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
pool = get_pool()
|
pool = get_pool()
|
||||||
with pool.connection() as conn:
|
with pool.connection() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
'SELECT data, expires_at FROM video_cache WHERE video_id = ?',
|
'SELECT data, expires_at FROM video_cache WHERE video_id = ?',
|
||||||
(video_id,)
|
(video_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
expires_at = float(row['expires_at'])
|
expires_at = float(row['expires_at'])
|
||||||
if time.time() < expires_at:
|
if time.time() < expires_at:
|
||||||
return json.loads(row['data'])
|
return json.loads(row['data'])
|
||||||
else:
|
else:
|
||||||
# Expired, clean it up
|
# Expired, clean it up
|
||||||
conn.execute('DELETE FROM video_cache WHERE video_id = ?', (video_id,))
|
conn.execute('DELETE FROM video_cache WHERE video_id = ?', (video_id,))
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Cache get error for {video_id}: {e}")
|
logger.error(f"Cache get error for {video_id}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def set_video_cache(video_id: str, data: Dict[str, Any], ttl: int = None) -> bool:
|
def set_video_cache(video_id: str, data: Dict[str, Any], ttl: int = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Cache video data
|
Cache video data
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
video_id: YouTube video ID
|
video_id: YouTube video ID
|
||||||
data: Data to cache
|
data: Data to cache
|
||||||
ttl: Time to live in seconds (default from config)
|
ttl: Time to live in seconds (default from config)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if cached successfully
|
True if cached successfully
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if ttl is None:
|
if ttl is None:
|
||||||
ttl = Config.CACHE_VIDEO_TTL
|
ttl = Config.CACHE_VIDEO_TTL
|
||||||
|
|
||||||
expires_at = time.time() + ttl
|
expires_at = time.time() + ttl
|
||||||
|
|
||||||
pool = get_pool()
|
pool = get_pool()
|
||||||
with pool.connection() as conn:
|
with pool.connection() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
'INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)',
|
'INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)',
|
||||||
(video_id, json.dumps(data), expires_at)
|
(video_id, json.dumps(data), expires_at)
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Cache set error for {video_id}: {e}")
|
logger.error(f"Cache set error for {video_id}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def clear_expired():
|
def clear_expired():
|
||||||
"""Remove all expired cache entries"""
|
"""Remove all expired cache entries"""
|
||||||
try:
|
try:
|
||||||
pool = get_pool()
|
pool = get_pool()
|
||||||
with pool.connection() as conn:
|
with pool.connection() as conn:
|
||||||
conn.execute('DELETE FROM video_cache WHERE expires_at < ?', (time.time(),))
|
conn.execute('DELETE FROM video_cache WHERE expires_at < ?', (time.time(),))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Cache cleanup error: {e}")
|
logger.error(f"Cache cleanup error: {e}")
|
||||||
|
|
||||||
|
|
||||||
class HistoryService:
|
class HistoryService:
|
||||||
"""Service for user video history"""
|
"""Service for user video history"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_history(limit: int = 50) -> list:
|
def get_history(limit: int = 50) -> list:
|
||||||
"""Get watch history"""
|
"""Get watch history"""
|
||||||
try:
|
try:
|
||||||
pool = get_pool()
|
pool = get_pool()
|
||||||
with pool.connection() as conn:
|
with pool.connection() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
'SELECT video_id as id, title, thumbnail FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT ?',
|
'SELECT video_id as id, title, thumbnail FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT ?',
|
||||||
(limit,)
|
(limit,)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"History get error: {e}")
|
logger.error(f"History get error: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def add_to_history(video_id: str, title: str, thumbnail: str) -> bool:
|
def add_to_history(video_id: str, title: str, thumbnail: str) -> bool:
|
||||||
"""Add a video to history"""
|
"""Add a video to history"""
|
||||||
try:
|
try:
|
||||||
pool = get_pool()
|
pool = get_pool()
|
||||||
with pool.connection() as conn:
|
with pool.connection() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
'INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (?, ?, ?, ?, ?)',
|
||||||
(1, video_id, title, thumbnail, 'history')
|
(1, video_id, title, thumbnail, 'history')
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"History add error: {e}")
|
logger.error(f"History add error: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
270
app/services/gemini_summarizer.py
Normal file → Executable file
270
app/services/gemini_summarizer.py
Normal file → Executable file
|
|
@ -1,135 +1,135 @@
|
||||||
"""
|
"""
|
||||||
AI-powered video summarizer using Google Gemini.
|
AI-powered video summarizer using Google Gemini.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import base64
|
import base64
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Obfuscated API key - encoded with app-specific salt
|
# Obfuscated API key - encoded with app-specific salt
|
||||||
# This prevents casual copying but is not cryptographically secure
|
# This prevents casual copying but is not cryptographically secure
|
||||||
_OBFUSCATED_KEY = "QklqYVN5RG9yLWpsdmhtMEVGVkxnV3F4TllFR0MyR21oQUY3Y3Rv"
|
_OBFUSCATED_KEY = "QklqYVN5RG9yLWpsdmhtMEVGVkxnV3F4TllFR0MyR21oQUY3Y3Rv"
|
||||||
_APP_SALT = "KV-Tube-2026"
|
_APP_SALT = "KV-Tube-2026"
|
||||||
|
|
||||||
def _decode_api_key() -> str:
|
def _decode_api_key() -> str:
|
||||||
"""Decode the obfuscated API key. Only works with correct app context."""
|
"""Decode the obfuscated API key. Only works with correct app context."""
|
||||||
try:
|
try:
|
||||||
# Decode base64
|
# Decode base64
|
||||||
decoded = base64.b64decode(_OBFUSCATED_KEY).decode('utf-8')
|
decoded = base64.b64decode(_OBFUSCATED_KEY).decode('utf-8')
|
||||||
# Remove prefix added during encoding
|
# Remove prefix added during encoding
|
||||||
if decoded.startswith("Bij"):
|
if decoded.startswith("Bij"):
|
||||||
return "AI" + decoded[3:] # Reconstruct original key
|
return "AI" + decoded[3:] # Reconstruct original key
|
||||||
return decoded
|
return decoded
|
||||||
except:
|
except:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# Get API key: prefer environment variable, fall back to obfuscated default
|
# Get API key: prefer environment variable, fall back to obfuscated default
|
||||||
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") or _decode_api_key()
|
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") or _decode_api_key()
|
||||||
|
|
||||||
def summarize_with_gemini(transcript: str, video_title: str = "") -> Optional[str]:
|
def summarize_with_gemini(transcript: str, video_title: str = "") -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Summarize video transcript using Google Gemini AI.
|
Summarize video transcript using Google Gemini AI.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
transcript: The video transcript text
|
transcript: The video transcript text
|
||||||
video_title: Optional video title for context
|
video_title: Optional video title for context
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AI-generated summary or None if failed
|
AI-generated summary or None if failed
|
||||||
"""
|
"""
|
||||||
if not GEMINI_API_KEY:
|
if not GEMINI_API_KEY:
|
||||||
logger.warning("GEMINI_API_KEY not set, falling back to TextRank")
|
logger.warning("GEMINI_API_KEY not set, falling back to TextRank")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Importing google.generativeai... Key len: {len(GEMINI_API_KEY)}")
|
logger.info(f"Importing google.generativeai... Key len: {len(GEMINI_API_KEY)}")
|
||||||
import google.generativeai as genai
|
import google.generativeai as genai
|
||||||
|
|
||||||
genai.configure(api_key=GEMINI_API_KEY)
|
genai.configure(api_key=GEMINI_API_KEY)
|
||||||
logger.info("Gemini configured. Creating model...")
|
logger.info("Gemini configured. Creating model...")
|
||||||
model = genai.GenerativeModel('gemini-1.5-flash')
|
model = genai.GenerativeModel('gemini-1.5-flash')
|
||||||
|
|
||||||
# Limit transcript to avoid token limits
|
# Limit transcript to avoid token limits
|
||||||
max_chars = 8000
|
max_chars = 8000
|
||||||
if len(transcript) > max_chars:
|
if len(transcript) > max_chars:
|
||||||
transcript = transcript[:max_chars] + "..."
|
transcript = transcript[:max_chars] + "..."
|
||||||
|
|
||||||
logger.info(f"Generating summary content... Transcript len: {len(transcript)}")
|
logger.info(f"Generating summary content... Transcript len: {len(transcript)}")
|
||||||
# Create prompt for summarization
|
# Create prompt for summarization
|
||||||
prompt = f"""You are a helpful AI assistant. Summarize the following video transcript in 2-3 concise sentences.
|
prompt = f"""You are a helpful AI assistant. Summarize the following video transcript in 2-3 concise sentences.
|
||||||
Focus on the main topic and key points. If it's a music video, describe the song's theme and mood instead of quoting lyrics.
|
Focus on the main topic and key points. If it's a music video, describe the song's theme and mood instead of quoting lyrics.
|
||||||
|
|
||||||
Video Title: {video_title if video_title else 'Unknown'}
|
Video Title: {video_title if video_title else 'Unknown'}
|
||||||
|
|
||||||
Transcript:
|
Transcript:
|
||||||
{transcript}
|
{transcript}
|
||||||
|
|
||||||
Provide a brief, informative summary (2-3 sentences max):"""
|
Provide a brief, informative summary (2-3 sentences max):"""
|
||||||
|
|
||||||
response = model.generate_content(prompt)
|
response = model.generate_content(prompt)
|
||||||
logger.info("Gemini response received.")
|
logger.info("Gemini response received.")
|
||||||
|
|
||||||
if response and response.text:
|
if response and response.text:
|
||||||
summary = response.text.strip()
|
summary = response.text.strip()
|
||||||
# Clean up any markdown formatting
|
# Clean up any markdown formatting
|
||||||
summary = summary.replace("**", "").replace("##", "").replace("###", "")
|
summary = summary.replace("**", "").replace("##", "").replace("###", "")
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Gemini summarization error: {e}")
|
logger.error(f"Gemini summarization error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def extract_key_points_with_gemini(transcript: str, video_title: str = "") -> list:
|
def extract_key_points_with_gemini(transcript: str, video_title: str = "") -> list:
|
||||||
"""
|
"""
|
||||||
Extract key points from video transcript using Gemini AI.
|
Extract key points from video transcript using Gemini AI.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of key points or empty list if failed
|
List of key points or empty list if failed
|
||||||
"""
|
"""
|
||||||
if not GEMINI_API_KEY:
|
if not GEMINI_API_KEY:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import google.generativeai as genai
|
import google.generativeai as genai
|
||||||
|
|
||||||
genai.configure(api_key=GEMINI_API_KEY)
|
genai.configure(api_key=GEMINI_API_KEY)
|
||||||
model = genai.GenerativeModel('gemini-1.5-flash')
|
model = genai.GenerativeModel('gemini-1.5-flash')
|
||||||
|
|
||||||
# Limit transcript
|
# Limit transcript
|
||||||
max_chars = 6000
|
max_chars = 6000
|
||||||
if len(transcript) > max_chars:
|
if len(transcript) > max_chars:
|
||||||
transcript = transcript[:max_chars] + "..."
|
transcript = transcript[:max_chars] + "..."
|
||||||
|
|
||||||
prompt = f"""Extract 3-5 key points from this video transcript. For each point, provide a single short sentence.
|
prompt = f"""Extract 3-5 key points from this video transcript. For each point, provide a single short sentence.
|
||||||
If it's a music video, describe the themes, mood, and notable elements instead of quoting lyrics.
|
If it's a music video, describe the themes, mood, and notable elements instead of quoting lyrics.
|
||||||
|
|
||||||
Video Title: {video_title if video_title else 'Unknown'}
|
Video Title: {video_title if video_title else 'Unknown'}
|
||||||
|
|
||||||
Transcript:
|
Transcript:
|
||||||
{transcript}
|
{transcript}
|
||||||
|
|
||||||
Key points (one per line, no bullet points or numbers):"""
|
Key points (one per line, no bullet points or numbers):"""
|
||||||
|
|
||||||
response = model.generate_content(prompt)
|
response = model.generate_content(prompt)
|
||||||
|
|
||||||
if response and response.text:
|
if response and response.text:
|
||||||
lines = response.text.strip().split('\n')
|
lines = response.text.strip().split('\n')
|
||||||
# Clean up and filter
|
# Clean up and filter
|
||||||
points = []
|
points = []
|
||||||
for line in lines:
|
for line in lines:
|
||||||
line = line.strip().lstrip('•-*123456789.)')
|
line = line.strip().lstrip('•-*123456789.)')
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line and len(line) > 10:
|
if line and len(line) > 10:
|
||||||
points.append(line)
|
points.append(line)
|
||||||
return points[:5] # Max 5 points
|
return points[:5] # Max 5 points
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Gemini key points error: {e}")
|
logger.error(f"Gemini key points error: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
0
app/services/loader_to.py
Normal file → Executable file
0
app/services/loader_to.py
Normal file → Executable file
0
app/services/settings.py
Normal file → Executable file
0
app/services/settings.py
Normal file → Executable file
238
app/services/summarizer.py
Normal file → Executable file
238
app/services/summarizer.py
Normal file → Executable file
|
|
@ -1,119 +1,119 @@
|
||||||
|
|
||||||
import re
|
import 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)
|
||||||
|
|
|
||||||
211
app/services/transcript_service.py
Executable file
211
app/services/transcript_service.py
Executable file
|
|
@ -0,0 +1,211 @@
|
||||||
|
"""
|
||||||
|
Transcript Service Module
|
||||||
|
Fetches video transcripts with fallback strategy: yt-dlp -> ytfetcher
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptService:
|
||||||
|
"""Service for fetching YouTube video transcripts with fallback support."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_transcript(cls, video_id: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get transcript text for a video.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. Try yt-dlp (current method, handles auto-generated captions)
|
||||||
|
2. Fallback to ytfetcher library if yt-dlp fails
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_id: YouTube video ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transcript text or None if unavailable
|
||||||
|
"""
|
||||||
|
video_id = video_id.strip()
|
||||||
|
|
||||||
|
# Try yt-dlp first (primary method)
|
||||||
|
text = cls._fetch_with_ytdlp(video_id)
|
||||||
|
if text:
|
||||||
|
logger.info(f"Transcript fetched via yt-dlp for {video_id}")
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Fallback to ytfetcher
|
||||||
|
logger.info(f"yt-dlp failed, trying ytfetcher for {video_id}")
|
||||||
|
text = cls._fetch_with_ytfetcher(video_id)
|
||||||
|
if text:
|
||||||
|
logger.info(f"Transcript fetched via ytfetcher for {video_id}")
|
||||||
|
return text
|
||||||
|
|
||||||
|
logger.warning(f"All transcript methods failed for {video_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _fetch_with_ytdlp(cls, video_id: str) -> Optional[str]:
|
||||||
|
"""Fetch transcript using yt-dlp (downloading subtitles to file)."""
|
||||||
|
import yt_dlp
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Fetching transcript for {video_id} using yt-dlp")
|
||||||
|
|
||||||
|
# Use a temporary filename pattern
|
||||||
|
temp_prefix = f"transcript_{video_id}_{random.randint(1000, 9999)}"
|
||||||
|
|
||||||
|
ydl_opts = {
|
||||||
|
'skip_download': True,
|
||||||
|
'quiet': True,
|
||||||
|
'no_warnings': True,
|
||||||
|
'cookiefile': os.environ.get('COOKIES_FILE', 'cookies.txt') if os.path.exists(os.environ.get('COOKIES_FILE', 'cookies.txt')) else None,
|
||||||
|
'writesubtitles': True,
|
||||||
|
'writeautomaticsub': True,
|
||||||
|
'subtitleslangs': ['en', 'vi', 'en-US'],
|
||||||
|
'outtmpl': f"/tmp/{temp_prefix}",
|
||||||
|
'subtitlesformat': 'json3/vtt/best',
|
||||||
|
}
|
||||||
|
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
|
||||||
|
|
||||||
|
# Find the downloaded file
|
||||||
|
downloaded_files = glob.glob(f"/tmp/{temp_prefix}*")
|
||||||
|
|
||||||
|
if not downloaded_files:
|
||||||
|
logger.warning("yt-dlp finished but no subtitle file found.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Pick the best file (prefer json3, then vtt)
|
||||||
|
selected_file = None
|
||||||
|
for ext in ['.json3', '.vtt', '.ttml', '.srv3']:
|
||||||
|
for f in downloaded_files:
|
||||||
|
if f.endswith(ext):
|
||||||
|
selected_file = f
|
||||||
|
break
|
||||||
|
if selected_file:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not selected_file:
|
||||||
|
selected_file = downloaded_files[0]
|
||||||
|
|
||||||
|
# Read content
|
||||||
|
with open(selected_file, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
for f in downloaded_files:
|
||||||
|
try:
|
||||||
|
os.remove(f)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Parse based on format
|
||||||
|
if selected_file.endswith('.json3') or content.strip().startswith('{'):
|
||||||
|
return cls._parse_json3(content)
|
||||||
|
else:
|
||||||
|
return cls._parse_vtt(content)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"yt-dlp transcript fetch failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _fetch_with_ytfetcher(cls, video_id: str) -> Optional[str]:
|
||||||
|
"""Fetch transcript using ytfetcher library as fallback."""
|
||||||
|
try:
|
||||||
|
from ytfetcher import YTFetcher
|
||||||
|
|
||||||
|
logger.info(f"Using ytfetcher for {video_id}")
|
||||||
|
|
||||||
|
# Create fetcher for single video
|
||||||
|
fetcher = YTFetcher.from_video_ids(video_ids=[video_id])
|
||||||
|
|
||||||
|
# Fetch transcripts
|
||||||
|
data = fetcher.fetch_transcripts()
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
logger.warning(f"ytfetcher returned no data for {video_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract text from transcript objects
|
||||||
|
text_parts = []
|
||||||
|
for item in data:
|
||||||
|
transcripts = getattr(item, 'transcripts', []) or []
|
||||||
|
for t in transcripts:
|
||||||
|
txt = getattr(t, 'text', '') or ''
|
||||||
|
txt = txt.strip()
|
||||||
|
if txt and txt != '\n':
|
||||||
|
text_parts.append(txt)
|
||||||
|
|
||||||
|
if not text_parts:
|
||||||
|
logger.warning(f"ytfetcher returned empty transcripts for {video_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return " ".join(text_parts)
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("ytfetcher not installed. Run: pip install ytfetcher")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"ytfetcher transcript fetch failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_json3(content: str) -> Optional[str]:
|
||||||
|
"""Parse JSON3 subtitle format."""
|
||||||
|
try:
|
||||||
|
json_data = json.loads(content)
|
||||||
|
events = json_data.get('events', [])
|
||||||
|
text_parts = []
|
||||||
|
for event in events:
|
||||||
|
segs = event.get('segs', [])
|
||||||
|
for seg in segs:
|
||||||
|
txt = seg.get('utf8', '').strip()
|
||||||
|
if txt and txt != '\n':
|
||||||
|
text_parts.append(txt)
|
||||||
|
return " ".join(text_parts)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"JSON3 parse failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_vtt(content: str) -> Optional[str]:
|
||||||
|
"""Parse VTT/XML subtitle content."""
|
||||||
|
try:
|
||||||
|
lines = content.splitlines()
|
||||||
|
text_lines = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if "-->" in line:
|
||||||
|
continue
|
||||||
|
if line.isdigit():
|
||||||
|
continue
|
||||||
|
if line.startswith("WEBVTT"):
|
||||||
|
continue
|
||||||
|
if line.startswith("Kind:"):
|
||||||
|
continue
|
||||||
|
if line.startswith("Language:"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Remove tags like <c> or <00:00:00>
|
||||||
|
clean = re.sub(r'<[^>]+>', '', line)
|
||||||
|
if clean and clean not in seen:
|
||||||
|
seen.add(clean)
|
||||||
|
text_lines.append(clean)
|
||||||
|
|
||||||
|
return " ".join(text_lines)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"VTT transcript parse error: {e}")
|
||||||
|
return None
|
||||||
626
app/services/youtube.py
Normal file → Executable file
626
app/services/youtube.py
Normal file → Executable file
|
|
@ -1,313 +1,313 @@
|
||||||
"""
|
"""
|
||||||
YouTube Service Module
|
YouTube Service Module
|
||||||
Handles all yt-dlp interactions using the library directly (not subprocess)
|
Handles all yt-dlp interactions using the library directly (not subprocess)
|
||||||
"""
|
"""
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from config import Config
|
from config import Config
|
||||||
from app.services.loader_to import LoaderToService
|
from app.services.loader_to import LoaderToService
|
||||||
from app.services.settings import SettingsService
|
from app.services.settings import SettingsService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class YouTubeService:
|
class YouTubeService:
|
||||||
"""Service for fetching YouTube content using yt-dlp library"""
|
"""Service for fetching YouTube content using yt-dlp library"""
|
||||||
|
|
||||||
# Common yt-dlp options
|
# Common yt-dlp options
|
||||||
BASE_OPTS = {
|
BASE_OPTS = {
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
'no_warnings': True,
|
'no_warnings': True,
|
||||||
'extract_flat': 'in_playlist',
|
'extract_flat': 'in_playlist',
|
||||||
'force_ipv4': True,
|
'force_ipv4': True,
|
||||||
'socket_timeout': Config.YTDLP_TIMEOUT,
|
'socket_timeout': Config.YTDLP_TIMEOUT,
|
||||||
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def sanitize_video_data(data: Dict[str, Any]) -> Dict[str, Any]:
|
def sanitize_video_data(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Sanitize and format video data from yt-dlp"""
|
"""Sanitize and format video data from yt-dlp"""
|
||||||
video_id = data.get('id', '')
|
video_id = data.get('id', '')
|
||||||
duration_secs = data.get('duration')
|
duration_secs = data.get('duration')
|
||||||
|
|
||||||
# Format duration
|
# Format duration
|
||||||
duration_str = None
|
duration_str = None
|
||||||
if duration_secs:
|
if duration_secs:
|
||||||
mins, secs = divmod(int(duration_secs), 60)
|
mins, secs = divmod(int(duration_secs), 60)
|
||||||
hours, mins = divmod(mins, 60)
|
hours, mins = divmod(mins, 60)
|
||||||
duration_str = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
|
duration_str = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': video_id,
|
'id': video_id,
|
||||||
'title': data.get('title', 'Unknown'),
|
'title': data.get('title', 'Unknown'),
|
||||||
'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
|
'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
|
||||||
'channel_id': data.get('channel_id'),
|
'channel_id': data.get('channel_id'),
|
||||||
'uploader_id': data.get('uploader_id'),
|
'uploader_id': data.get('uploader_id'),
|
||||||
'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg" if video_id else None,
|
'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg" if video_id else None,
|
||||||
'view_count': data.get('view_count', 0),
|
'view_count': data.get('view_count', 0),
|
||||||
'upload_date': data.get('upload_date', ''),
|
'upload_date': data.get('upload_date', ''),
|
||||||
'duration': duration_str,
|
'duration': duration_str,
|
||||||
'description': data.get('description', ''),
|
'description': data.get('description', ''),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def search_videos(cls, query: str, limit: int = 20, filter_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
def search_videos(cls, query: str, limit: int = 20, filter_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Search for videos using yt-dlp library directly
|
Search for videos using yt-dlp library directly
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Search query
|
query: Search query
|
||||||
limit: Maximum number of results
|
limit: Maximum number of results
|
||||||
filter_type: 'video' to exclude shorts, 'short' for only shorts
|
filter_type: 'video' to exclude shorts, 'short' for only shorts
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of sanitized video data dictionaries
|
List of sanitized video data dictionaries
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
search_url = f"ytsearch{limit}:{query}"
|
search_url = f"ytsearch{limit}:{query}"
|
||||||
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
**cls.BASE_OPTS,
|
**cls.BASE_OPTS,
|
||||||
'extract_flat': True,
|
'extract_flat': True,
|
||||||
'playlist_items': f'1:{limit}',
|
'playlist_items': f'1:{limit}',
|
||||||
}
|
}
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(search_url, download=False)
|
info = ydl.extract_info(search_url, download=False)
|
||||||
entries = info.get('entries', []) if info else []
|
entries = info.get('entries', []) if info else []
|
||||||
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if not entry or not entry.get('id'):
|
if not entry or not entry.get('id'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Filter logic
|
# Filter logic
|
||||||
title_lower = (entry.get('title') or '').lower()
|
title_lower = (entry.get('title') or '').lower()
|
||||||
duration_secs = entry.get('duration')
|
duration_secs = entry.get('duration')
|
||||||
|
|
||||||
if filter_type == 'video':
|
if filter_type == 'video':
|
||||||
# Exclude shorts
|
# Exclude shorts
|
||||||
if '#shorts' in title_lower:
|
if '#shorts' in title_lower:
|
||||||
continue
|
continue
|
||||||
if duration_secs and int(duration_secs) <= 70:
|
if duration_secs and int(duration_secs) <= 70:
|
||||||
continue
|
continue
|
||||||
elif filter_type == 'short':
|
elif filter_type == 'short':
|
||||||
# Only shorts
|
# Only shorts
|
||||||
if duration_secs and int(duration_secs) > 60:
|
if duration_secs and int(duration_secs) > 60:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
results.append(cls.sanitize_video_data(entry))
|
results.append(cls.sanitize_video_data(entry))
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Search error for '{query}': {e}")
|
logger.error(f"Search error for '{query}': {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_video_info(cls, video_id: str) -> Optional[Dict[str, Any]]:
|
def get_video_info(cls, video_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get detailed video information including stream URL
|
Get detailed video information including stream URL
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
video_id: YouTube video ID
|
video_id: YouTube video ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Video info dict with stream_url, or None on error
|
Video info dict with stream_url, or None on error
|
||||||
"""
|
"""
|
||||||
engine = SettingsService.get('youtube_engine', 'auto')
|
engine = SettingsService.get('youtube_engine', 'auto')
|
||||||
|
|
||||||
# 1. Force Remote
|
# 1. Force Remote
|
||||||
if engine == 'remote':
|
if engine == 'remote':
|
||||||
return cls._get_info_remote(video_id)
|
return cls._get_info_remote(video_id)
|
||||||
|
|
||||||
# 2. Local (or Auto first attempt)
|
# 2. Local (or Auto first attempt)
|
||||||
info = cls._get_info_local(video_id)
|
info = cls._get_info_local(video_id)
|
||||||
|
|
||||||
if info:
|
if info:
|
||||||
return info
|
return info
|
||||||
|
|
||||||
# 3. Failover if Auto
|
# 3. Failover if Auto
|
||||||
if engine == 'auto' and not info:
|
if engine == 'auto' and not info:
|
||||||
logger.warning(f"yt-dlp failed for {video_id}, falling back to remote loader")
|
logger.warning(f"yt-dlp failed for {video_id}, falling back to remote loader")
|
||||||
return cls._get_info_remote(video_id)
|
return cls._get_info_remote(video_id)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_info_remote(cls, video_id: str) -> Optional[Dict[str, Any]]:
|
def _get_info_remote(cls, video_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Fetch info using LoaderToService"""
|
"""Fetch info using LoaderToService"""
|
||||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||||
return LoaderToService.get_stream_url(url)
|
return LoaderToService.get_stream_url(url)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_info_local(cls, video_id: str) -> Optional[Dict[str, Any]]:
|
def _get_info_local(cls, video_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Fetch info using yt-dlp (original logic)"""
|
"""Fetch info using yt-dlp (original logic)"""
|
||||||
try:
|
try:
|
||||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||||
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
**cls.BASE_OPTS,
|
**cls.BASE_OPTS,
|
||||||
'format': Config.YTDLP_FORMAT,
|
'format': Config.YTDLP_FORMAT,
|
||||||
'noplaylist': True,
|
'noplaylist': True,
|
||||||
'skip_download': True,
|
'skip_download': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(url, download=False)
|
info = ydl.extract_info(url, download=False)
|
||||||
|
|
||||||
if not info:
|
if not info:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
stream_url = info.get('url')
|
stream_url = info.get('url')
|
||||||
if not stream_url:
|
if not stream_url:
|
||||||
logger.warning(f"No stream URL found for {video_id}")
|
logger.warning(f"No stream URL found for {video_id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get subtitles
|
# Get subtitles
|
||||||
subtitle_url = cls._extract_subtitle_url(info)
|
subtitle_url = cls._extract_subtitle_url(info)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'stream_url': stream_url,
|
'stream_url': stream_url,
|
||||||
'title': info.get('title', 'Unknown'),
|
'title': info.get('title', 'Unknown'),
|
||||||
'description': info.get('description', ''),
|
'description': info.get('description', ''),
|
||||||
'uploader': info.get('uploader', ''),
|
'uploader': info.get('uploader', ''),
|
||||||
'uploader_id': info.get('uploader_id', ''),
|
'uploader_id': info.get('uploader_id', ''),
|
||||||
'channel_id': info.get('channel_id', ''),
|
'channel_id': info.get('channel_id', ''),
|
||||||
'upload_date': info.get('upload_date', ''),
|
'upload_date': info.get('upload_date', ''),
|
||||||
'view_count': info.get('view_count', 0),
|
'view_count': info.get('view_count', 0),
|
||||||
'subtitle_url': subtitle_url,
|
'subtitle_url': subtitle_url,
|
||||||
'duration': info.get('duration'),
|
'duration': info.get('duration'),
|
||||||
'thumbnail': info.get('thumbnail') or f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
|
'thumbnail': info.get('thumbnail') or f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
|
||||||
'http_headers': info.get('http_headers', {})
|
'http_headers': info.get('http_headers', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting local video info for {video_id}: {e}")
|
logger.error(f"Error getting local video info for {video_id}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_subtitle_url(info: Dict[str, Any]) -> Optional[str]:
|
def _extract_subtitle_url(info: Dict[str, Any]) -> Optional[str]:
|
||||||
"""Extract best subtitle URL from video info"""
|
"""Extract best subtitle URL from video info"""
|
||||||
subs = info.get('subtitles') or {}
|
subs = info.get('subtitles') or {}
|
||||||
auto_subs = info.get('automatic_captions') or {}
|
auto_subs = info.get('automatic_captions') or {}
|
||||||
|
|
||||||
# Priority: en manual > vi manual > en auto > vi auto > first available
|
# Priority: en manual > vi manual > en auto > vi auto > first available
|
||||||
for lang in ['en', 'vi']:
|
for lang in ['en', 'vi']:
|
||||||
if lang in subs and subs[lang]:
|
if lang in subs and subs[lang]:
|
||||||
return subs[lang][0].get('url')
|
return subs[lang][0].get('url')
|
||||||
|
|
||||||
for lang in ['en', 'vi']:
|
for lang in ['en', 'vi']:
|
||||||
if lang in auto_subs and auto_subs[lang]:
|
if lang in auto_subs and auto_subs[lang]:
|
||||||
return auto_subs[lang][0].get('url')
|
return auto_subs[lang][0].get('url')
|
||||||
|
|
||||||
# Fallback to first available
|
# Fallback to first available
|
||||||
if subs:
|
if subs:
|
||||||
first_key = list(subs.keys())[0]
|
first_key = list(subs.keys())[0]
|
||||||
if subs[first_key]:
|
if subs[first_key]:
|
||||||
return subs[first_key][0].get('url')
|
return subs[first_key][0].get('url')
|
||||||
|
|
||||||
if auto_subs:
|
if auto_subs:
|
||||||
first_key = list(auto_subs.keys())[0]
|
first_key = list(auto_subs.keys())[0]
|
||||||
if auto_subs[first_key]:
|
if auto_subs[first_key]:
|
||||||
return auto_subs[first_key][0].get('url')
|
return auto_subs[first_key][0].get('url')
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_channel_videos(cls, channel_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
def get_channel_videos(cls, channel_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get videos from a YouTube channel
|
Get videos from a YouTube channel
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
channel_id: Channel ID, handle (@username), or URL
|
channel_id: Channel ID, handle (@username), or URL
|
||||||
limit: Maximum number of videos
|
limit: Maximum number of videos
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of video data dictionaries
|
List of video data dictionaries
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Construct URL based on ID format
|
# Construct URL based on ID format
|
||||||
if channel_id.startswith('http'):
|
if channel_id.startswith('http'):
|
||||||
url = channel_id
|
url = channel_id
|
||||||
elif channel_id.startswith('@'):
|
elif channel_id.startswith('@'):
|
||||||
url = f"https://www.youtube.com/{channel_id}"
|
url = f"https://www.youtube.com/{channel_id}"
|
||||||
elif len(channel_id) == 24 and channel_id.startswith('UC'):
|
elif len(channel_id) == 24 and channel_id.startswith('UC'):
|
||||||
url = f"https://www.youtube.com/channel/{channel_id}"
|
url = f"https://www.youtube.com/channel/{channel_id}"
|
||||||
else:
|
else:
|
||||||
url = f"https://www.youtube.com/{channel_id}"
|
url = f"https://www.youtube.com/{channel_id}"
|
||||||
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
**cls.BASE_OPTS,
|
**cls.BASE_OPTS,
|
||||||
'extract_flat': True,
|
'extract_flat': True,
|
||||||
'playlist_items': f'1:{limit}',
|
'playlist_items': f'1:{limit}',
|
||||||
}
|
}
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(url, download=False)
|
info = ydl.extract_info(url, download=False)
|
||||||
entries = info.get('entries', []) if info else []
|
entries = info.get('entries', []) if info else []
|
||||||
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if entry and entry.get('id'):
|
if entry and entry.get('id'):
|
||||||
results.append(cls.sanitize_video_data(entry))
|
results.append(cls.sanitize_video_data(entry))
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting channel videos for {channel_id}: {e}")
|
logger.error(f"Error getting channel videos for {channel_id}: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_related_videos(cls, title: str, limit: int = 10) -> List[Dict[str, Any]]:
|
def get_related_videos(cls, title: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||||
"""Get videos related to a given title"""
|
"""Get videos related to a given title"""
|
||||||
query = f"{title} related"
|
query = f"{title} related"
|
||||||
return cls.search_videos(query, limit=limit, filter_type='video')
|
return cls.search_videos(query, limit=limit, filter_type='video')
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_download_url(cls, video_id: str) -> Optional[Dict[str, str]]:
|
def get_download_url(cls, video_id: str) -> Optional[Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Get direct download URL (non-HLS) for a video
|
Get direct download URL (non-HLS) for a video
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with 'url', 'title', 'ext' or None
|
Dict with 'url', 'title', 'ext' or None
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||||
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
**cls.BASE_OPTS,
|
**cls.BASE_OPTS,
|
||||||
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best[protocol!*=m3u8]/best',
|
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best[protocol!*=m3u8]/best',
|
||||||
'noplaylist': True,
|
'noplaylist': True,
|
||||||
'skip_download': True,
|
'skip_download': True,
|
||||||
'youtube_include_dash_manifest': False,
|
'youtube_include_dash_manifest': False,
|
||||||
'youtube_include_hls_manifest': False,
|
'youtube_include_hls_manifest': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(url, download=False)
|
info = ydl.extract_info(url, download=False)
|
||||||
|
|
||||||
download_url = info.get('url', '')
|
download_url = info.get('url', '')
|
||||||
|
|
||||||
# If m3u8, try to find non-HLS format
|
# If m3u8, try to find non-HLS format
|
||||||
if '.m3u8' in download_url or not download_url:
|
if '.m3u8' in download_url or not download_url:
|
||||||
formats = info.get('formats', [])
|
formats = info.get('formats', [])
|
||||||
for f in reversed(formats):
|
for f in reversed(formats):
|
||||||
f_url = f.get('url', '')
|
f_url = f.get('url', '')
|
||||||
if f_url and 'm3u8' not in f_url and f.get('ext') == 'mp4':
|
if f_url and 'm3u8' not in f_url and f.get('ext') == 'mp4':
|
||||||
download_url = f_url
|
download_url = f_url
|
||||||
break
|
break
|
||||||
|
|
||||||
if download_url and '.m3u8' not in download_url:
|
if download_url and '.m3u8' not in download_url:
|
||||||
return {
|
return {
|
||||||
'url': download_url,
|
'url': download_url,
|
||||||
'title': info.get('title', 'video'),
|
'title': info.get('title', 'video'),
|
||||||
'ext': 'mp4'
|
'ext': 'mp4'
|
||||||
}
|
}
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting download URL for {video_id}: {e}")
|
logger.error(f"Error getting download URL for {video_id}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
2
app/utils/__init__.py
Normal file → Executable file
2
app/utils/__init__.py
Normal file → Executable file
|
|
@ -1 +1 @@
|
||||||
"""KV-Tube Utilities Package"""
|
"""KV-Tube Utilities Package"""
|
||||||
|
|
|
||||||
190
app/utils/formatters.py
Normal file → Executable file
190
app/utils/formatters.py
Normal file → Executable file
|
|
@ -1,95 +1,95 @@
|
||||||
"""
|
"""
|
||||||
Template Formatters Module
|
Template Formatters Module
|
||||||
Jinja2 template filters for formatting views and dates
|
Jinja2 template filters for formatting views and dates
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
def format_views(views) -> str:
|
def format_views(views) -> str:
|
||||||
"""Format view count (YouTube style: 1.2M, 3.5K)"""
|
"""Format view count (YouTube style: 1.2M, 3.5K)"""
|
||||||
if not views:
|
if not views:
|
||||||
return '0'
|
return '0'
|
||||||
try:
|
try:
|
||||||
num = int(views)
|
num = int(views)
|
||||||
if num >= 1_000_000_000:
|
if num >= 1_000_000_000:
|
||||||
return f"{num / 1_000_000_000:.1f}B"
|
return f"{num / 1_000_000_000:.1f}B"
|
||||||
if num >= 1_000_000:
|
if num >= 1_000_000:
|
||||||
return f"{num / 1_000_000:.1f}M"
|
return f"{num / 1_000_000:.1f}M"
|
||||||
if num >= 1_000:
|
if num >= 1_000:
|
||||||
return f"{num / 1_000:.0f}K"
|
return f"{num / 1_000:.0f}K"
|
||||||
return f"{num:,}"
|
return f"{num:,}"
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return str(views)
|
return str(views)
|
||||||
|
|
||||||
|
|
||||||
def format_date(value) -> str:
|
def format_date(value) -> str:
|
||||||
"""Format date to relative time (YouTube style: 2 hours ago, 3 days ago)"""
|
"""Format date to relative time (YouTube style: 2 hours ago, 3 days ago)"""
|
||||||
if not value:
|
if not value:
|
||||||
return 'Recently'
|
return 'Recently'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Handle YYYYMMDD format
|
# Handle YYYYMMDD format
|
||||||
if len(str(value)) == 8 and str(value).isdigit():
|
if len(str(value)) == 8 and str(value).isdigit():
|
||||||
dt = datetime.strptime(str(value), '%Y%m%d')
|
dt = datetime.strptime(str(value), '%Y%m%d')
|
||||||
# Handle timestamp
|
# Handle timestamp
|
||||||
elif isinstance(value, (int, float)):
|
elif isinstance(value, (int, float)):
|
||||||
dt = datetime.fromtimestamp(value)
|
dt = datetime.fromtimestamp(value)
|
||||||
# Handle datetime object
|
# Handle datetime object
|
||||||
elif isinstance(value, datetime):
|
elif isinstance(value, datetime):
|
||||||
dt = value
|
dt = value
|
||||||
# Handle YYYY-MM-DD string
|
# Handle YYYY-MM-DD string
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
dt = datetime.strptime(str(value), '%Y-%m-%d')
|
dt = datetime.strptime(str(value), '%Y-%m-%d')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
diff = now - dt
|
diff = now - dt
|
||||||
|
|
||||||
if diff.days > 365:
|
if diff.days > 365:
|
||||||
years = diff.days // 365
|
years = diff.days // 365
|
||||||
return f"{years} year{'s' if years > 1 else ''} ago"
|
return f"{years} year{'s' if years > 1 else ''} ago"
|
||||||
if diff.days > 30:
|
if diff.days > 30:
|
||||||
months = diff.days // 30
|
months = diff.days // 30
|
||||||
return f"{months} month{'s' if months > 1 else ''} ago"
|
return f"{months} month{'s' if months > 1 else ''} ago"
|
||||||
if diff.days > 7:
|
if diff.days > 7:
|
||||||
weeks = diff.days // 7
|
weeks = diff.days // 7
|
||||||
return f"{weeks} week{'s' if weeks > 1 else ''} ago"
|
return f"{weeks} week{'s' if weeks > 1 else ''} ago"
|
||||||
if diff.days > 0:
|
if diff.days > 0:
|
||||||
return f"{diff.days} day{'s' if diff.days > 1 else ''} ago"
|
return f"{diff.days} day{'s' if diff.days > 1 else ''} ago"
|
||||||
if diff.seconds > 3600:
|
if diff.seconds > 3600:
|
||||||
hours = diff.seconds // 3600
|
hours = diff.seconds // 3600
|
||||||
return f"{hours} hour{'s' if hours > 1 else ''} ago"
|
return f"{hours} hour{'s' if hours > 1 else ''} ago"
|
||||||
if diff.seconds > 60:
|
if diff.seconds > 60:
|
||||||
minutes = diff.seconds // 60
|
minutes = diff.seconds // 60
|
||||||
return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
|
return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
|
||||||
return "Just now"
|
return "Just now"
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
def format_duration(seconds) -> str:
|
def format_duration(seconds) -> str:
|
||||||
"""Format duration in seconds to HH:MM:SS or MM:SS"""
|
"""Format duration in seconds to HH:MM:SS or MM:SS"""
|
||||||
if not seconds:
|
if not seconds:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
try:
|
try:
|
||||||
secs = int(seconds)
|
secs = int(seconds)
|
||||||
mins, secs = divmod(secs, 60)
|
mins, secs = divmod(secs, 60)
|
||||||
hours, mins = divmod(mins, 60)
|
hours, mins = divmod(mins, 60)
|
||||||
|
|
||||||
if hours:
|
if hours:
|
||||||
return f"{hours}:{mins:02d}:{secs:02d}"
|
return f"{hours}:{mins:02d}:{secs:02d}"
|
||||||
return f"{mins}:{secs:02d}"
|
return f"{mins}:{secs:02d}"
|
||||||
|
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def register_filters(app):
|
def register_filters(app):
|
||||||
"""Register all template filters with Flask app"""
|
"""Register all template filters with Flask app"""
|
||||||
app.template_filter('format_views')(format_views)
|
app.template_filter('format_views')(format_views)
|
||||||
app.template_filter('format_date')(format_date)
|
app.template_filter('format_date')(format_date)
|
||||||
app.template_filter('format_duration')(format_duration)
|
app.template_filter('format_duration')(format_duration)
|
||||||
|
|
|
||||||
0
bin/ffmpeg
Normal file → Executable file
0
bin/ffmpeg
Normal file → Executable file
130
config.py
Normal file → Executable file
130
config.py
Normal file → Executable file
|
|
@ -1,65 +1,65 @@
|
||||||
"""
|
"""
|
||||||
KV-Tube Configuration Module
|
KV-Tube Configuration Module
|
||||||
Centralizes all configuration with environment variable support
|
Centralizes all configuration with environment variable support
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# Load .env file if present
|
# Load .env file if present
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
"""Base configuration"""
|
"""Base configuration"""
|
||||||
SECRET_KEY = os.environ.get('SECRET_KEY', os.urandom(32).hex())
|
SECRET_KEY = os.environ.get('SECRET_KEY', os.urandom(32).hex())
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATA_DIR = os.environ.get('KVTUBE_DATA_DIR', 'data')
|
DATA_DIR = os.environ.get('KVTUBE_DATA_DIR', 'data')
|
||||||
DB_NAME = os.path.join(DATA_DIR, 'kvtube.db')
|
DB_NAME = os.path.join(DATA_DIR, 'kvtube.db')
|
||||||
|
|
||||||
# Video storage
|
# Video storage
|
||||||
VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos')
|
VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos')
|
||||||
|
|
||||||
# Rate limiting
|
# Rate limiting
|
||||||
RATELIMIT_DEFAULT = "60/minute"
|
RATELIMIT_DEFAULT = "60/minute"
|
||||||
RATELIMIT_SEARCH = "30/minute"
|
RATELIMIT_SEARCH = "30/minute"
|
||||||
RATELIMIT_STREAM = "120/minute"
|
RATELIMIT_STREAM = "120/minute"
|
||||||
|
|
||||||
# Cache settings (in seconds)
|
# Cache settings (in seconds)
|
||||||
CACHE_VIDEO_TTL = 3600 # 1 hour
|
CACHE_VIDEO_TTL = 3600 # 1 hour
|
||||||
CACHE_CHANNEL_TTL = 1800 # 30 minutes
|
CACHE_CHANNEL_TTL = 1800 # 30 minutes
|
||||||
|
|
||||||
# yt-dlp settings
|
# yt-dlp settings
|
||||||
# yt-dlp settings - MUST use progressive formats with combined audio+video
|
# yt-dlp settings - MUST use progressive formats with combined audio+video
|
||||||
# Format 22 = 720p mp4, 18 = 360p mp4 (both have audio+video combined)
|
# Format 22 = 720p mp4, 18 = 360p mp4 (both have audio+video combined)
|
||||||
# HLS m3u8 streams have CORS issues with segment proxying, so we avoid them
|
# HLS m3u8 streams have CORS issues with segment proxying, so we avoid them
|
||||||
YTDLP_FORMAT = '22/18/best[protocol^=https][ext=mp4]/best[ext=mp4]/best'
|
YTDLP_FORMAT = '22/18/best[protocol^=https][ext=mp4]/best[ext=mp4]/best'
|
||||||
YTDLP_TIMEOUT = 30
|
YTDLP_TIMEOUT = 30
|
||||||
|
|
||||||
# YouTube Engine Settings
|
# YouTube Engine Settings
|
||||||
YOUTUBE_ENGINE = os.environ.get('YOUTUBE_ENGINE', 'auto') # auto, local, remote
|
YOUTUBE_ENGINE = os.environ.get('YOUTUBE_ENGINE', 'auto') # auto, local, remote
|
||||||
LOADER_TO_API_KEY = os.environ.get('LOADER_TO_API_KEY', '') # Optional
|
LOADER_TO_API_KEY = os.environ.get('LOADER_TO_API_KEY', '') # Optional
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def init_app(app):
|
def init_app(app):
|
||||||
"""Initialize app with config"""
|
"""Initialize app with config"""
|
||||||
# Ensure data directory exists
|
# Ensure data directory exists
|
||||||
os.makedirs(Config.DATA_DIR, exist_ok=True)
|
os.makedirs(Config.DATA_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
class DevelopmentConfig(Config):
|
class DevelopmentConfig(Config):
|
||||||
"""Development configuration"""
|
"""Development configuration"""
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
FLASK_ENV = 'development'
|
FLASK_ENV = 'development'
|
||||||
|
|
||||||
|
|
||||||
class ProductionConfig(Config):
|
class ProductionConfig(Config):
|
||||||
"""Production configuration"""
|
"""Production configuration"""
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
FLASK_ENV = 'production'
|
FLASK_ENV = 'production'
|
||||||
|
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
'development': DevelopmentConfig,
|
'development': DevelopmentConfig,
|
||||||
'production': ProductionConfig,
|
'production': ProductionConfig,
|
||||||
'default': DevelopmentConfig
|
'default': DevelopmentConfig
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
cookies.txt
Normal file → Executable file
18
cookies.txt
Normal file → Executable file
|
|
@ -1,19 +1,19 @@
|
||||||
# Netscape HTTP Cookie File
|
# Netscape HTTP Cookie File
|
||||||
# This file is generated by yt-dlp. Do not edit.
|
# This file is generated by yt-dlp. Do not edit.
|
||||||
|
|
||||||
.youtube.com TRUE / TRUE 1802692356 __Secure-3PSID g.a0005gie1lAkmYZc-EPeGx77pCrXo_Cz5eAi-e9aryb9Qoz967v4Caiou6Tt5ZyLR4iMp5I51wACgYKASISARESFQHGX2MiopTeGBKXybppZWNr7JzmKhoVAUF8yKrgfPx-gEb02gGAV3ZaVOGr0076
|
.youtube.com TRUE / TRUE 1831894348 __Secure-3PSID g.a0005wie1jXMw44_RGjwtqg21AbatcdNseI3S_qNtsLYC1jS4YSUdfLDtJlA8h-NEmMGebiczgACgYKAX0SARESFQHGX2MiUz2RnkvviMoB7UNylf3SoBoVAUF8yKo0JwXF5B9H9roWaSTRT-QN0076
|
||||||
.youtube.com TRUE / TRUE 1800281710 __Secure-1PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
|
.youtube.com TRUE / TRUE 1800281710 __Secure-1PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
|
||||||
.youtube.com TRUE / TRUE 1802692356 SAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
.youtube.com TRUE / TRUE 1802692356 SAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
||||||
.youtube.com TRUE / TRUE 1800282680 __Secure-1PSIDCC AKEyXzXvpBScD7r3mqr7aZ0ymWZ7FmsgT0q0C3Ge8hvrjZ9WZ4PU4ZBuBsO0YNYN3A8iX4eV8F8
|
.youtube.com TRUE / TRUE 1800359997 __Secure-1PSIDCC AKEyXzWh3snkS2XAx8pLOzZCgKTPwXKRai_Pn4KjpsSSc2h7tRpVKMDddMKBYkuIQFhpVlALI84
|
||||||
.youtube.com TRUE / TRUE 1802692356 SSID A4isk9AE9xActvzYy
|
.youtube.com TRUE / TRUE 1802692356 SSID A4isk9AE9xActvzYy
|
||||||
.youtube.com TRUE / TRUE 1802692356 __Secure-1PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
.youtube.com TRUE / TRUE 1831894348 __Secure-1PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
||||||
.youtube.com TRUE / TRUE 1802692356 __Secure-1PSID g.a0005gie1lAkmYZc-EPeGx77pCrXo_Cz5eAi-e9aryb9Qoz967v4-rF3xTavVHrJoyJAqShH6gACgYKAX0SARESFQHGX2MiOdAbUPmCj4MueYyh-2km5RoVAUF8yKp2ehWQC6tX8n-9UNg11RV60076
|
.youtube.com TRUE / TRUE 1831894348 __Secure-1PSID g.a0005wie1jXMw44_RGjwtqg21AbatcdNseI3S_qNtsLYC1jS4YSUycKC58NH045FOFX6QW8fDwACgYKAacSARESFQHGX2MiA5xeTuJuh8QmBm-DS3l1ghoVAUF8yKr4klCBhb-EJgFQ9T0TGWKk0076
|
||||||
.youtube.com TRUE / TRUE 1802692356 __Secure-3PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
.youtube.com TRUE / TRUE 1831894348 __Secure-3PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
||||||
.youtube.com TRUE / TRUE 1800282680 __Secure-3PSIDCC AKEyXzVcvX-jLLprjZQXoqarG3xsAVpjyLYaN2j0a_iUcsnKnpL88P_5IlcfusJn0We0aaKK7g
|
.youtube.com TRUE / TRUE 1800359997 __Secure-3PSIDCC AKEyXzW3W5Q-e4TIryFWpWS6zVuuVPOvwPIU2tzl1JRdYsGu-7f34g_amk2Xd2ttGtSJ6tOSdA
|
||||||
.youtube.com TRUE / TRUE 1800281710 __Secure-3PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
|
.youtube.com TRUE / TRUE 1800281710 __Secure-3PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
|
||||||
.youtube.com TRUE / TRUE 1792154873 LOGIN_INFO AFmmF2swRQIgVjJk8Mho4_JuKr6SZzrhBdlL1LdxWxcwDMu4cjaRRgcCIQCTtJpmYKJH54Tiei3at3f4YT3US7gSL0lW_TZ04guKjQ:QUQ3MjNmeWlwRDJSNDl2NE9uX2JWWG5tWllHN0RsNUVZVUhsLVp4N2dWbldaeC14SnNybWVERnNoaXFpanFJczhKTjJSRGN6MEs3c1VkLTE1TGJVeFBPT05BY29NMFh0Q1VPdFU3dUdvSUpET3lQbU1ZMUlHUGltajlXNDllNUQxZHdzZko1WXF1UUJWclNxQVJ0TXVEYnF2bXJRY2V6Vl9n
|
.youtube.com TRUE / TRUE 1792154873 LOGIN_INFO AFmmF2swRQIgVjJk8Mho4_JuKr6SZzrhBdlL1LdxWxcwDMu4cjaRRgcCIQCTtJpmYKJH54Tiei3at3f4YT3US7gSL0lW_TZ04guKjQ:QUQ3MjNmeWlwRDJSNDl2NE9uX2JWWG5tWllHN0RsNUVZVUhsLVp4N2dWbldaeC14SnNybWVERnNoaXFpanFJczhKTjJSRGN6MEs3c1VkLTE1TGJVeFBPT05BY29NMFh0Q1VPdFU3dUdvSUpET3lQbU1ZMUlHUGltajlXNDllNUQxZHdzZko1WXF1UUJWclNxQVJ0TXVEYnF2bXJRY2V6Vl9n
|
||||||
.youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=150&hl=en
|
.youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=150&hl=en
|
||||||
.youtube.com TRUE / TRUE 0 YSC y-oH2BqaUSQ
|
.youtube.com TRUE / TRUE 0 YSC y-oH2BqaUSQ
|
||||||
.youtube.com TRUE / TRUE 1784298680 __Secure-ROLLOUT_TOKEN CPm51pHVjquOTRDw0bnsppWSAxjw0bnsppWSAw%3D%3D
|
.youtube.com TRUE / TRUE 1784333733 __Secure-ROLLOUT_TOKEN CPm51pHVjquOTRDw0bnsppWSAxjzxYe3qZaSAw%3D%3D
|
||||||
.youtube.com TRUE / TRUE 1784298680 VISITOR_INFO1_LIVE ShB1Bvj-rRU
|
.youtube.com TRUE / TRUE 1784375997 VISITOR_INFO1_LIVE ShB1Bvj-rRU
|
||||||
.youtube.com TRUE / TRUE 1784298680 VISITOR_PRIVACY_METADATA CgJWThIEGgAgWA%3D%3D
|
.youtube.com TRUE / TRUE 1784375997 VISITOR_PRIVACY_METADATA CgJWThIEGgAgWA%3D%3D
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
data/kvtube.db
BIN
data/kvtube.db
Binary file not shown.
Binary file not shown.
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"youtube_engine": "local"
|
|
||||||
}
|
|
||||||
0
deploy.py
Normal file → Executable file
0
deploy.py
Normal file → Executable file
0
dev.sh
Normal file → Executable file
0
dev.sh
Normal file → Executable file
90
doc/Product Requirements Document (PRD) - KV-Tube
Normal file → Executable file
90
doc/Product Requirements Document (PRD) - KV-Tube
Normal file → Executable file
|
|
@ -1,46 +1,46 @@
|
||||||
Product Requirements Document (PRD) - KV-Tube
|
Product Requirements Document (PRD) - KV-Tube
|
||||||
1. Product Overview
|
1. Product Overview
|
||||||
Product Name: KV-Tube Version: 1.0 (In Development) Description: KV-Tube is a comprehensive media center web application designed to provide an ad-free YouTube experience, a curated movie streaming service, and a local video management system. It emphasizes privacy, absence of advertisements, and utility features like AI summarization and language learning tools.
|
Product Name: KV-Tube Version: 1.0 (In Development) Description: KV-Tube is a comprehensive media center web application designed to provide an ad-free YouTube experience, a curated movie streaming service, and a local video management system. It emphasizes privacy, absence of advertisements, and utility features like AI summarization and language learning tools.
|
||||||
|
|
||||||
2. User Personas
|
2. User Personas
|
||||||
The Binge Watcher: Wants uninterrupted access to YouTube content and movies without ads.
|
The Binge Watcher: Wants uninterrupted access to YouTube content and movies without ads.
|
||||||
The Archivist: Maintains a local collection of videos and wants a clean interface to organize and watch them securely.
|
The Archivist: Maintains a local collection of videos and wants a clean interface to organize and watch them securely.
|
||||||
The Learner: Uses video content for educational purposes, specifically English learning.
|
The Learner: Uses video content for educational purposes, specifically English learning.
|
||||||
The Tech Enthusiast: Appreciates PWA support, torrent integration, and customizable settings.
|
The Tech Enthusiast: Appreciates PWA support, torrent integration, and customizable settings.
|
||||||
3. Core Features
|
3. Core Features
|
||||||
3.1. YouTube Viewer (Home)
|
3.1. YouTube Viewer (Home)
|
||||||
Ad-Free Experience: Plays YouTube videos without third-party advertisements.
|
Ad-Free Experience: Plays YouTube videos without third-party advertisements.
|
||||||
Search: Integrated search bar powered by yt-dlp to find videos, channels, and playlists.
|
Search: Integrated search bar powered by yt-dlp to find videos, channels, and playlists.
|
||||||
Playback: Custom video player with support for quality selection and playback speed.
|
Playback: Custom video player with support for quality selection and playback speed.
|
||||||
AI Summarization: Feature to summarize video content using Google Gemini API (Optional).
|
AI Summarization: Feature to summarize video content using Google Gemini API (Optional).
|
||||||
3.2. local Video Manager ("My Videos")
|
3.2. local Video Manager ("My Videos")
|
||||||
Secure Access: Password-protected section for personal video collections.
|
Secure Access: Password-protected section for personal video collections.
|
||||||
File Management: Scans local directories for video files.
|
File Management: Scans local directories for video files.
|
||||||
Metadata: Extracts metadata (duration, format) and generates thumbnails using FFmpeg/MoviePy.
|
Metadata: Extracts metadata (duration, format) and generates thumbnails using FFmpeg/MoviePy.
|
||||||
Playback: Native HTML5 player for local files.
|
Playback: Native HTML5 player for local files.
|
||||||
3.3. Utilities
|
3.3. Utilities
|
||||||
Torrent Player: Interface for streaming/playing video content via torrents.
|
Torrent Player: Interface for streaming/playing video content via torrents.
|
||||||
Playlist Manager: Create and manage custom playlists of YouTube videos.
|
Playlist Manager: Create and manage custom playlists of YouTube videos.
|
||||||
Camera/Photo: ("Chụp ảnh") Feature to capture or manage photos (Webcam integration).
|
Camera/Photo: ("Chụp ảnh") Feature to capture or manage photos (Webcam integration).
|
||||||
Configuration: Web-based settings to manage application behavior (e.g., password, storage paths).
|
Configuration: Web-based settings to manage application behavior (e.g., password, storage paths).
|
||||||
4. Technical Architecture
|
4. Technical Architecture
|
||||||
Backend: Python / Flask
|
Backend: Python / Flask
|
||||||
Frontend: HTML5, CSS3, JavaScript (Vanilla)
|
Frontend: HTML5, CSS3, JavaScript (Vanilla)
|
||||||
Database/Storage: JSON-based local storage and file system.
|
Database/Storage: JSON-based local storage and file system.
|
||||||
Video Processing: yt-dlp (YouTube), FFmpeg (Conversion/Thumbnail), MoviePy (Optional).
|
Video Processing: yt-dlp (YouTube), FFmpeg (Conversion/Thumbnail), MoviePy (Optional).
|
||||||
AI Service: Google Gemini API (for summarization).
|
AI Service: Google Gemini API (for summarization).
|
||||||
Deployment: Docker container support (xehopnet/kctube).
|
Deployment: Docker container support (xehopnet/kctube).
|
||||||
5. Non-Functional Requirements
|
5. Non-Functional Requirements
|
||||||
Performance: Fast load times and responsive UI.
|
Performance: Fast load times and responsive UI.
|
||||||
Compatibility: PWA-ready for installation on desktop and mobile.
|
Compatibility: PWA-ready for installation on desktop and mobile.
|
||||||
Reliability: graceful degradation if optional dependencies (MoviePy, Gemini) are missing.
|
Reliability: graceful degradation if optional dependencies (MoviePy, Gemini) are missing.
|
||||||
Privacy: No user tracking or external analytics.
|
Privacy: No user tracking or external analytics.
|
||||||
6. Known Limitations
|
6. Known Limitations
|
||||||
Search Reliability: Dependent on yt-dlp stability and YouTube's anti-bot measures.
|
Search Reliability: Dependent on yt-dlp stability and YouTube's anti-bot measures.
|
||||||
External APIs: Movie features rely on third-party APIs which may have downtime.
|
External APIs: Movie features rely on third-party APIs which may have downtime.
|
||||||
Dependency Management: Some Python libraries (MoviePy, Numpy) require compilation tools.
|
Dependency Management: Some Python libraries (MoviePy, Numpy) require compilation tools.
|
||||||
7. Future Roadmap
|
7. Future Roadmap
|
||||||
Database: Migrate from JSON to SQLite for better performance with large libraries.
|
Database: Migrate from JSON to SQLite for better performance with large libraries.
|
||||||
User Accounts: Individual user profiles and history.
|
User Accounts: Individual user profiles and history.
|
||||||
Offline Mode: Enhanced offline capabilities for PWA.
|
Offline Mode: Enhanced offline capabilities for PWA.
|
||||||
Casting: Support for Chromecast/AirPlay.
|
Casting: Support for Chromecast/AirPlay.
|
||||||
58
docker-compose.yml
Normal file → Executable file
58
docker-compose.yml
Normal file → Executable file
|
|
@ -1,29 +1,29 @@
|
||||||
# KV-Tube Docker Compose for Synology NAS
|
# KV-Tube Docker Compose for Synology NAS
|
||||||
# Usage: docker-compose up -d
|
# Usage: docker-compose up -d
|
||||||
|
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
kv-tube:
|
kv-tube:
|
||||||
build: .
|
build: .
|
||||||
image: vndangkhoa/kv-tube:latest
|
image: vndangkhoa/kv-tube:latest
|
||||||
container_name: kv-tube
|
container_name: kv-tube
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "5011:5000"
|
- "5011:5000"
|
||||||
volumes:
|
volumes:
|
||||||
# Persist data (Easy setup: Just maps a folder)
|
# Persist data (Easy setup: Just maps a folder)
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
# Local videos folder (Optional)
|
# Local videos folder (Optional)
|
||||||
# - ./videos:/app/youtube_downloads
|
# - ./videos:/app/youtube_downloads
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- FLASK_ENV=production
|
- FLASK_ENV=production
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD", "curl", "-f", "http://localhost:5000/" ]
|
test: [ "CMD", "curl", "-f", "http://localhost:5000/" ]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
|
|
|
||||||
0
entrypoint.sh
Normal file → Executable file
0
entrypoint.sh
Normal file → Executable file
2157
hydration_debug.txt
Normal file → Executable file
2157
hydration_debug.txt
Normal file → Executable file
File diff suppressed because it is too large
Load diff
0
kv_server.py
Normal file → Executable file
0
kv_server.py
Normal file → Executable file
BIN
kvtube.db
BIN
kvtube.db
Binary file not shown.
16
requirements.txt
Normal file → Executable file
16
requirements.txt
Normal file → Executable file
|
|
@ -1,7 +1,9 @@
|
||||||
flask
|
flask
|
||||||
requests
|
requests
|
||||||
yt-dlp>=2024.1.0
|
yt-dlp>=2024.1.0
|
||||||
werkzeug
|
werkzeug
|
||||||
gunicorn
|
gunicorn
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
googletrans==4.0.0-rc1
|
||||||
|
# ytfetcher - optional, requires Python 3.11-3.13
|
||||||
|
|
||||||
|
|
|
||||||
0
start.sh
Normal file → Executable file
0
start.sh
Normal file → Executable file
0
static/css/modules/base.css
Normal file → Executable file
0
static/css/modules/base.css
Normal file → Executable file
0
static/css/modules/cards.css
Normal file → Executable file
0
static/css/modules/cards.css
Normal file → Executable file
622
static/css/modules/chat.css
Normal file → Executable file
622
static/css/modules/chat.css
Normal file → Executable file
|
|
@ -1,312 +1,312 @@
|
||||||
/**
|
/**
|
||||||
* KV-Tube AI Chat Styles
|
* KV-Tube AI Chat Styles
|
||||||
* Styling for the transcript Q&A chatbot panel
|
* Styling for the transcript Q&A chatbot panel
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Floating AI Bubble Button */
|
/* Floating AI Bubble Button */
|
||||||
.ai-chat-bubble {
|
.ai-chat-bubble {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 90px;
|
bottom: 90px;
|
||||||
/* Above the back button */
|
/* Above the back button */
|
||||||
right: 20px;
|
right: 20px;
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 9998;
|
z-index: 9998;
|
||||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
|
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
|
||||||
transition: transform 0.3s, box-shadow 0.3s;
|
transition: transform 0.3s, box-shadow 0.3s;
|
||||||
animation: bubble-pulse 2s infinite;
|
animation: bubble-pulse 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-bubble:hover {
|
.ai-chat-bubble:hover {
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
box-shadow: 0 6px 24px rgba(102, 126, 234, 0.6);
|
box-shadow: 0 6px 24px rgba(102, 126, 234, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-bubble.active {
|
.ai-chat-bubble.active {
|
||||||
animation: none;
|
animation: none;
|
||||||
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes bubble-pulse {
|
@keyframes bubble-pulse {
|
||||||
|
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
|
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
box-shadow: 0 4px 24px rgba(102, 126, 234, 0.7);
|
box-shadow: 0 4px 24px rgba(102, 126, 234, 0.7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide bubble on desktop when chat is open */
|
/* Hide bubble on desktop when chat is open */
|
||||||
.ai-chat-panel.visible~.ai-chat-bubble,
|
.ai-chat-panel.visible~.ai-chat-bubble,
|
||||||
body.ai-chat-open .ai-chat-bubble {
|
body.ai-chat-open .ai-chat-bubble {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chat Panel Container */
|
/* Chat Panel Container */
|
||||||
.ai-chat-panel {
|
.ai-chat-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 160px;
|
bottom: 160px;
|
||||||
/* Position above the bubble */
|
/* Position above the bubble */
|
||||||
right: 20px;
|
right: 20px;
|
||||||
width: 380px;
|
width: 380px;
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
background: var(--yt-bg-primary, #0f0f0f);
|
background: var(--yt-bg-primary, #0f0f0f);
|
||||||
border: 1px solid var(--yt-border, #272727);
|
border: 1px solid var(--yt-border, #272727);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transform: translateY(20px) scale(0.95);
|
transform: translateY(20px) scale(0.95);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s;
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-panel.visible {
|
.ai-chat-panel.visible {
|
||||||
transform: translateY(0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chat Header */
|
/* Chat Header */
|
||||||
.ai-chat-header {
|
.ai-chat-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-header h4 {
|
.ai-chat-header h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-close {
|
.ai-chat-close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-close:hover {
|
.ai-chat-close:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Model Status */
|
/* Model Status */
|
||||||
.ai-model-status {
|
.ai-model-status {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-model-status.loading {
|
.ai-model-status.loading {
|
||||||
color: #ffd700;
|
color: #ffd700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-model-status.ready {
|
.ai-model-status.ready {
|
||||||
color: #00ff88;
|
color: #00ff88;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Messages Container */
|
/* Messages Container */
|
||||||
.ai-chat-messages {
|
.ai-chat-messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Message Bubbles */
|
/* Message Bubbles */
|
||||||
.ai-message {
|
.ai-message {
|
||||||
max-width: 85%;
|
max-width: 85%;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-message.user {
|
.ai-message.user {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
background: #3ea6ff;
|
background: #3ea6ff;
|
||||||
color: white;
|
color: white;
|
||||||
border-bottom-right-radius: 4px;
|
border-bottom-right-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-message.assistant {
|
.ai-message.assistant {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
background: var(--yt-bg-secondary, #272727);
|
background: var(--yt-bg-secondary, #272727);
|
||||||
color: var(--yt-text-primary, #fff);
|
color: var(--yt-text-primary, #fff);
|
||||||
border-bottom-left-radius: 4px;
|
border-bottom-left-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-message.system {
|
.ai-message.system {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--yt-text-secondary, #aaa);
|
color: var(--yt-text-secondary, #aaa);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Typing Indicator */
|
/* Typing Indicator */
|
||||||
.ai-typing {
|
.ai-typing {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-typing span {
|
.ai-typing span {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
background: var(--yt-text-secondary, #aaa);
|
background: var(--yt-text-secondary, #aaa);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: typing 1.2s infinite;
|
animation: typing 1.2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-typing span:nth-child(2) {
|
.ai-typing span:nth-child(2) {
|
||||||
animation-delay: 0.2s;
|
animation-delay: 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-typing span:nth-child(3) {
|
.ai-typing span:nth-child(3) {
|
||||||
animation-delay: 0.4s;
|
animation-delay: 0.4s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes typing {
|
@keyframes typing {
|
||||||
|
|
||||||
0%,
|
0%,
|
||||||
60%,
|
60%,
|
||||||
100% {
|
100% {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
30% {
|
30% {
|
||||||
transform: translateY(-8px);
|
transform: translateY(-8px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Input Area */
|
/* Input Area */
|
||||||
.ai-chat-input {
|
.ai-chat-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-top: 1px solid var(--yt-border, #272727);
|
border-top: 1px solid var(--yt-border, #272727);
|
||||||
background: var(--yt-bg-secondary, #181818);
|
background: var(--yt-bg-secondary, #181818);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-input input {
|
.ai-chat-input input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--yt-bg-primary, #0f0f0f);
|
background: var(--yt-bg-primary, #0f0f0f);
|
||||||
border: 1px solid var(--yt-border, #272727);
|
border: 1px solid var(--yt-border, #272727);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
color: var(--yt-text-primary, #fff);
|
color: var(--yt-text-primary, #fff);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-input input:focus {
|
.ai-chat-input input:focus {
|
||||||
border-color: #3ea6ff;
|
border-color: #3ea6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-input input::placeholder {
|
.ai-chat-input input::placeholder {
|
||||||
color: var(--yt-text-secondary, #aaa);
|
color: var(--yt-text-secondary, #aaa);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-send {
|
.ai-chat-send {
|
||||||
background: #3ea6ff;
|
background: #3ea6ff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
color: white;
|
color: white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: background 0.2s, transform 0.2s;
|
transition: background 0.2s, transform 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-send:hover {
|
.ai-chat-send:hover {
|
||||||
background: #2d8fd9;
|
background: #2d8fd9;
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-send:disabled {
|
.ai-chat-send:disabled {
|
||||||
background: #555;
|
background: #555;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Download Progress */
|
/* Download Progress */
|
||||||
.ai-download-progress {
|
.ai-download-progress {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-download-bar {
|
.ai-download-bar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
background: var(--yt-bg-secondary, #272727);
|
background: var(--yt-bg-secondary, #272727);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-download-fill {
|
.ai-download-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
transition: width 0.3s;
|
transition: width 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-download-text {
|
.ai-download-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--yt-text-secondary, #aaa);
|
color: var(--yt-text-secondary, #aaa);
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile */
|
/* Mobile */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.ai-chat-bubble {
|
.ai-chat-bubble {
|
||||||
bottom: 100px;
|
bottom: 100px;
|
||||||
/* More space above back button */
|
/* More space above back button */
|
||||||
right: 24px;
|
right: 24px;
|
||||||
/* Aligned with back button */
|
/* Aligned with back button */
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-panel {
|
.ai-chat-panel {
|
||||||
width: calc(100% - 20px);
|
width: calc(100% - 20px);
|
||||||
left: 10px;
|
left: 10px;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
bottom: 160px;
|
bottom: 160px;
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
0
static/css/modules/components.css
Normal file → Executable file
0
static/css/modules/components.css
Normal file → Executable file
1390
static/css/modules/downloads.css
Normal file → Executable file
1390
static/css/modules/downloads.css
Normal file → Executable file
File diff suppressed because it is too large
Load diff
0
static/css/modules/grid.css
Normal file → Executable file
0
static/css/modules/grid.css
Normal file → Executable file
0
static/css/modules/layout.css
Normal file → Executable file
0
static/css/modules/layout.css
Normal file → Executable file
0
static/css/modules/pages.css
Normal file → Executable file
0
static/css/modules/pages.css
Normal file → Executable file
0
static/css/modules/utils.css
Normal file → Executable file
0
static/css/modules/utils.css
Normal file → Executable file
0
static/css/modules/variables.css
Normal file → Executable file
0
static/css/modules/variables.css
Normal file → Executable file
1554
static/css/modules/watch.css
Normal file → Executable file
1554
static/css/modules/watch.css
Normal file → Executable file
File diff suppressed because it is too large
Load diff
277
static/css/modules/webllm.css
Normal file
277
static/css/modules/webllm.css
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
/**
|
||||||
|
* WebLLM Styles - Loading UI and Progress Bar
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Model loading overlay */
|
||||||
|
.webllm-loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 100px;
|
||||||
|
right: 20px;
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
rgba(15, 15, 20, 0.95) 0%,
|
||||||
|
rgba(25, 25, 35, 0.95) 100%);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
min-width: 320px;
|
||||||
|
z-index: 9999;
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||||
|
animation: slideInRight 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.webllm-loading-overlay.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header with icon */
|
||||||
|
.webllm-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webllm-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webllm-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webllm-subtitle {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
margin: 2px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
.webllm-progress-container {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webllm-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
animation: shimmer 2s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status text */
|
||||||
|
.webllm-status {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webllm-percent {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ready state */
|
||||||
|
.webllm-ready-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(16, 185, 129, 0.1) 100%);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #10b981;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webllm-ready-badge i {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary box WebLLM indicator */
|
||||||
|
.ai-source-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--yt-text-tertiary, #aaa);
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-source-indicator.local {
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-source-indicator.server {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Translation button states */
|
||||||
|
.translate-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--yt-bg-primary, #0f0f0f);
|
||||||
|
border: 1px solid var(--yt-border, #303030);
|
||||||
|
border-radius: 20px;
|
||||||
|
color: var(--yt-text-primary, #fff);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translate-btn:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%);
|
||||||
|
border-color: rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translate-btn.active {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-color: transparent;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translate-btn.loading {
|
||||||
|
opacity: 0.7;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translate-btn .spinner {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-top-color: currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Model selector in settings */
|
||||||
|
.webllm-model-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webllm-model-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webllm-model-option:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webllm-model-option.selected {
|
||||||
|
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
|
||||||
|
border-color: rgba(102, 126, 234, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webllm-model-option input[type="radio"] {
|
||||||
|
accent-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webllm-model-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webllm-model-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webllm-model-size {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast notification for WebLLM status */
|
||||||
|
.webllm-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: linear-gradient(135deg, rgba(102, 126, 234, 0.95) 0%, rgba(118, 75, 162, 0.95) 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 10000;
|
||||||
|
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.4);
|
||||||
|
animation: toastIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toastIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.webllm-loading-overlay {
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 80px;
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
static/css/style.css
Normal file → Executable file
32
static/css/style.css
Normal file → Executable file
|
|
@ -1,19 +1,19 @@
|
||||||
/* KV-Tube - YouTube Clone Design System */
|
/* KV-Tube - YouTube Clone Design System */
|
||||||
|
|
||||||
/* Core */
|
/* Core */
|
||||||
@import 'modules/variables.css';
|
@import 'modules/variables.css';
|
||||||
@import 'modules/base.css';
|
@import 'modules/base.css';
|
||||||
@import 'modules/utils.css';
|
@import 'modules/utils.css';
|
||||||
|
|
||||||
/* Layout & Structure */
|
/* Layout & Structure */
|
||||||
@import 'modules/layout.css';
|
@import 'modules/layout.css';
|
||||||
@import 'modules/grid.css';
|
@import 'modules/grid.css';
|
||||||
|
|
||||||
/* Components */
|
/* Components */
|
||||||
@import 'modules/components.css';
|
@import 'modules/components.css';
|
||||||
@import 'modules/cards.css';
|
@import 'modules/cards.css';
|
||||||
|
|
||||||
/* Pages */
|
/* Pages */
|
||||||
@import 'modules/pages.css';
|
@import 'modules/pages.css';
|
||||||
/* Hide extension-injected error elements */
|
/* Hide extension-injected error elements */
|
||||||
*[/onboarding/],
|
*[/onboarding/],
|
||||||
|
|
|
||||||
0
static/favicon.ico
Normal file → Executable file
0
static/favicon.ico
Normal file → Executable file
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
0
static/icons/icon-192x192.png
Normal file → Executable file
0
static/icons/icon-192x192.png
Normal file → Executable file
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
0
static/icons/icon-512x512.png
Normal file → Executable file
0
static/icons/icon-512x512.png
Normal file → Executable file
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
0
static/js/artplayer.js
Normal file → Executable file
0
static/js/artplayer.js
Normal file → Executable file
1234
static/js/download-manager.js
Normal file → Executable file
1234
static/js/download-manager.js
Normal file → Executable file
File diff suppressed because it is too large
Load diff
0
static/js/hls.min.js
vendored
Normal file → Executable file
0
static/js/hls.min.js
vendored
Normal file → Executable file
2086
static/js/main.js
Normal file → Executable file
2086
static/js/main.js
Normal file → Executable file
File diff suppressed because it is too large
Load diff
408
static/js/navigation-manager.js
Normal file → Executable file
408
static/js/navigation-manager.js
Normal file → Executable file
|
|
@ -1,204 +1,204 @@
|
||||||
/**
|
/**
|
||||||
* KV-Tube Navigation Manager
|
* KV-Tube Navigation Manager
|
||||||
* Handles SPA-style navigation to persist state (like downloads) across pages.
|
* Handles SPA-style navigation to persist state (like downloads) across pages.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class NavigationManager {
|
class NavigationManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.mainContentId = 'mainContent';
|
this.mainContentId = 'mainContent';
|
||||||
this.pageCache = new Map();
|
this.pageCache = new Map();
|
||||||
this.maxCacheSize = 20;
|
this.maxCacheSize = 20;
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Handle browser back/forward buttons
|
// Handle browser back/forward buttons
|
||||||
window.addEventListener('popstate', (e) => {
|
window.addEventListener('popstate', (e) => {
|
||||||
if (e.state && e.state.url) {
|
if (e.state && e.state.url) {
|
||||||
this.loadPage(e.state.url, false);
|
this.loadPage(e.state.url, false);
|
||||||
} else {
|
} else {
|
||||||
// Fallback for initial state or external navigation
|
// Fallback for initial state or external navigation
|
||||||
this.loadPage(window.location.href, false);
|
this.loadPage(window.location.href, false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Intercept clicks
|
// Intercept clicks
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
// Find closest anchor tag
|
// Find closest anchor tag
|
||||||
const link = e.target.closest('a');
|
const link = e.target.closest('a');
|
||||||
|
|
||||||
// Check if it's an internal link and not a download/special link
|
// Check if it's an internal link and not a download/special link
|
||||||
if (link &&
|
if (link &&
|
||||||
link.href &&
|
link.href &&
|
||||||
link.href.startsWith(window.location.origin) &&
|
link.href.startsWith(window.location.origin) &&
|
||||||
!link.getAttribute('download') &&
|
!link.getAttribute('download') &&
|
||||||
!link.getAttribute('target') &&
|
!link.getAttribute('target') &&
|
||||||
!link.classList.contains('no-spa') &&
|
!link.classList.contains('no-spa') &&
|
||||||
!e.ctrlKey && !e.metaKey && !e.shiftKey // Allow new tab clicks
|
!e.ctrlKey && !e.metaKey && !e.shiftKey // Allow new tab clicks
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const url = link.href;
|
const url = link.href;
|
||||||
this.navigateTo(url);
|
this.navigateTo(url);
|
||||||
|
|
||||||
// Update active state in sidebar
|
// Update active state in sidebar
|
||||||
this.updateSidebarActiveState(link);
|
this.updateSidebarActiveState(link);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save initial state
|
// Save initial state
|
||||||
const currentUrl = window.location.href;
|
const currentUrl = window.location.href;
|
||||||
if (!this.pageCache.has(currentUrl)) {
|
if (!this.pageCache.has(currentUrl)) {
|
||||||
// We don't have the raw HTML, so we can't fully cache the initial page accurately
|
// We don't have the raw HTML, so we can't fully cache the initial page accurately
|
||||||
// without fetching it or serializing current DOM.
|
// without fetching it or serializing current DOM.
|
||||||
// For now, we will cache it upon *leaving* securely or just let the first visit be uncached.
|
// For now, we will cache it upon *leaving* securely or just let the first visit be uncached.
|
||||||
// Better: Cache the current DOM state as the "initial" state.
|
// Better: Cache the current DOM state as the "initial" state.
|
||||||
this.saveCurrentState(currentUrl);
|
this.saveCurrentState(currentUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saveCurrentState(url) {
|
saveCurrentState(url) {
|
||||||
const mainContent = document.getElementById(this.mainContentId);
|
const mainContent = document.getElementById(this.mainContentId);
|
||||||
if (mainContent) {
|
if (mainContent) {
|
||||||
this.pageCache.set(url, {
|
this.pageCache.set(url, {
|
||||||
html: mainContent.innerHTML,
|
html: mainContent.innerHTML,
|
||||||
title: document.title,
|
title: document.title,
|
||||||
scrollY: window.scrollY,
|
scrollY: window.scrollY,
|
||||||
className: mainContent.className
|
className: mainContent.className
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prune cache
|
// Prune cache
|
||||||
if (this.pageCache.size > this.maxCacheSize) {
|
if (this.pageCache.size > this.maxCacheSize) {
|
||||||
const firstKey = this.pageCache.keys().next().value;
|
const firstKey = this.pageCache.keys().next().value;
|
||||||
this.pageCache.delete(firstKey);
|
this.pageCache.delete(firstKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async navigateTo(url) {
|
async navigateTo(url) {
|
||||||
// Start Progress Bar
|
// Start Progress Bar
|
||||||
const bar = document.getElementById('nprogress-bar');
|
const bar = document.getElementById('nprogress-bar');
|
||||||
if (bar) {
|
if (bar) {
|
||||||
bar.style.opacity = '1';
|
bar.style.opacity = '1';
|
||||||
bar.style.width = '30%';
|
bar.style.width = '30%';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save state of current page before leaving
|
// Save state of current page before leaving
|
||||||
this.saveCurrentState(window.location.href);
|
this.saveCurrentState(window.location.href);
|
||||||
|
|
||||||
// Update history
|
// Update history
|
||||||
history.pushState({ url: url }, '', url);
|
history.pushState({ url: url }, '', url);
|
||||||
await this.loadPage(url);
|
await this.loadPage(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadPage(url, pushState = true) {
|
async loadPage(url, pushState = true) {
|
||||||
const bar = document.getElementById('nprogress-bar');
|
const bar = document.getElementById('nprogress-bar');
|
||||||
if (bar) bar.style.width = '60%';
|
if (bar) bar.style.width = '60%';
|
||||||
|
|
||||||
const mainContent = document.getElementById(this.mainContentId);
|
const mainContent = document.getElementById(this.mainContentId);
|
||||||
if (!mainContent) return;
|
if (!mainContent) return;
|
||||||
|
|
||||||
// Check cache
|
// Check cache
|
||||||
if (this.pageCache.has(url)) {
|
if (this.pageCache.has(url)) {
|
||||||
const cached = this.pageCache.get(url);
|
const cached = this.pageCache.get(url);
|
||||||
|
|
||||||
// Restore content
|
// Restore content
|
||||||
document.title = cached.title;
|
document.title = cached.title;
|
||||||
mainContent.innerHTML = cached.html;
|
mainContent.innerHTML = cached.html;
|
||||||
mainContent.className = cached.className;
|
mainContent.className = cached.className;
|
||||||
|
|
||||||
// Re-execute scripts
|
// Re-execute scripts
|
||||||
this.executeScripts(mainContent);
|
this.executeScripts(mainContent);
|
||||||
|
|
||||||
// Re-initialize App
|
// Re-initialize App
|
||||||
if (typeof window.initApp === 'function') {
|
if (typeof window.initApp === 'function') {
|
||||||
window.initApp();
|
window.initApp();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore scroll
|
// Restore scroll
|
||||||
window.scrollTo(0, cached.scrollY);
|
window.scrollTo(0, cached.scrollY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading state if needed
|
// Show loading state if needed
|
||||||
mainContent.style.opacity = '0.5';
|
mainContent.style.opacity = '0.5';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
|
|
||||||
// Parse HTML
|
// Parse HTML
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(html, 'text/html');
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
|
|
||||||
// Extract new content
|
// Extract new content
|
||||||
const newContent = doc.getElementById(this.mainContentId);
|
const newContent = doc.getElementById(this.mainContentId);
|
||||||
if (!newContent) {
|
if (!newContent) {
|
||||||
// Check if it's a full page not extending layout properly or error
|
// Check if it's a full page not extending layout properly or error
|
||||||
console.error('Could not find mainContent in response');
|
console.error('Could not find mainContent in response');
|
||||||
window.location.href = url; // Fallback to full reload
|
window.location.href = url; // Fallback to full reload
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update title
|
// Update title
|
||||||
document.title = doc.title;
|
document.title = doc.title;
|
||||||
|
|
||||||
// Replace content
|
// Replace content
|
||||||
mainContent.innerHTML = newContent.innerHTML;
|
mainContent.innerHTML = newContent.innerHTML;
|
||||||
mainContent.className = newContent.className; // Maintain classes
|
mainContent.className = newContent.className; // Maintain classes
|
||||||
|
|
||||||
// Execute scripts found in the new content (critical for APP_CONFIG)
|
// Execute scripts found in the new content (critical for APP_CONFIG)
|
||||||
this.executeScripts(mainContent);
|
this.executeScripts(mainContent);
|
||||||
|
|
||||||
// Re-initialize App logic
|
// Re-initialize App logic
|
||||||
if (typeof window.initApp === 'function') {
|
if (typeof window.initApp === 'function') {
|
||||||
window.initApp();
|
window.initApp();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to top for new pages
|
// Scroll to top for new pages
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
|
|
||||||
// Save to cache (initial state of this page)
|
// Save to cache (initial state of this page)
|
||||||
this.pageCache.set(url, {
|
this.pageCache.set(url, {
|
||||||
html: newContent.innerHTML,
|
html: newContent.innerHTML,
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
scrollY: 0,
|
scrollY: 0,
|
||||||
className: newContent.className
|
className: newContent.className
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Navigation error:', error);
|
console.error('Navigation error:', error);
|
||||||
// Fallback
|
// Fallback
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
} finally {
|
} finally {
|
||||||
mainContent.style.opacity = '1';
|
mainContent.style.opacity = '1';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
executeScripts(element) {
|
executeScripts(element) {
|
||||||
const scripts = element.querySelectorAll('script');
|
const scripts = element.querySelectorAll('script');
|
||||||
scripts.forEach(oldScript => {
|
scripts.forEach(oldScript => {
|
||||||
const newScript = document.createElement('script');
|
const newScript = document.createElement('script');
|
||||||
Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value));
|
Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value));
|
||||||
newScript.textContent = oldScript.textContent;
|
newScript.textContent = oldScript.textContent;
|
||||||
oldScript.parentNode.replaceChild(newScript, oldScript);
|
oldScript.parentNode.replaceChild(newScript, oldScript);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSidebarActiveState(clickedLink) {
|
updateSidebarActiveState(clickedLink) {
|
||||||
// Remove active class from all items
|
// Remove active class from all items
|
||||||
document.querySelectorAll('.yt-sidebar-item').forEach(item => item.classList.remove('active'));
|
document.querySelectorAll('.yt-sidebar-item').forEach(item => item.classList.remove('active'));
|
||||||
|
|
||||||
// Add to clicked if it is a sidebar item
|
// Add to clicked if it is a sidebar item
|
||||||
if (clickedLink.classList.contains('yt-sidebar-item')) {
|
if (clickedLink.classList.contains('yt-sidebar-item')) {
|
||||||
clickedLink.classList.add('active');
|
clickedLink.classList.add('active');
|
||||||
} else {
|
} else {
|
||||||
// Try to find matching sidebar item
|
// Try to find matching sidebar item
|
||||||
const path = new URL(clickedLink.href).pathname;
|
const path = new URL(clickedLink.href).pathname;
|
||||||
const match = document.querySelector(`.yt-sidebar-item[href="${path}"]`);
|
const match = document.querySelector(`.yt-sidebar-item[href="${path}"]`);
|
||||||
if (match) match.classList.add('active');
|
if (match) match.classList.add('active');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
window.navigationManager = new NavigationManager();
|
window.navigationManager = new NavigationManager();
|
||||||
|
|
|
||||||
340
static/js/webllm-service.js
Normal file
340
static/js/webllm-service.js
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
/**
|
||||||
|
* WebLLM Service - Browser-based AI for Translation & Summarization
|
||||||
|
* Uses MLC's WebLLM for on-device AI inference via WebGPU
|
||||||
|
*/
|
||||||
|
|
||||||
|
class WebLLMService {
|
||||||
|
constructor() {
|
||||||
|
this.engine = null;
|
||||||
|
this.isLoading = false;
|
||||||
|
this.loadProgress = 0;
|
||||||
|
this.currentModel = null;
|
||||||
|
|
||||||
|
// Model configurations - Qwen2 chosen for Vietnamese support
|
||||||
|
this.models = {
|
||||||
|
'qwen2-0.5b': 'Qwen2-0.5B-Instruct-q4f16_1-MLC',
|
||||||
|
'phi-3.5-mini': 'Phi-3.5-mini-instruct-q4f16_1-MLC',
|
||||||
|
'smollm2': 'SmolLM2-360M-Instruct-q4f16_1-MLC'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default to lightweight Qwen2 for Vietnamese support
|
||||||
|
this.selectedModel = 'qwen2-0.5b';
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
this.onProgressCallback = null;
|
||||||
|
this.onReadyCallback = null;
|
||||||
|
this.onErrorCallback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if WebGPU is supported
|
||||||
|
*/
|
||||||
|
static isSupported() {
|
||||||
|
return 'gpu' in navigator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize WebLLM with selected model
|
||||||
|
* @param {string} modelKey - Model key from this.models
|
||||||
|
* @param {function} onProgress - Progress callback (percent, status)
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async init(modelKey = null, onProgress = null) {
|
||||||
|
if (!WebLLMService.isSupported()) {
|
||||||
|
console.warn('WebGPU not supported in this browser');
|
||||||
|
if (this.onErrorCallback) {
|
||||||
|
this.onErrorCallback('WebGPU not supported. Using server-side AI.');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.engine && this.currentModel === (modelKey || this.selectedModel)) {
|
||||||
|
console.log('WebLLM already initialized with this model');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
this.onProgressCallback = onProgress;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Dynamic import of WebLLM
|
||||||
|
const webllm = await import('https://esm.run/@mlc-ai/web-llm');
|
||||||
|
|
||||||
|
const modelId = this.models[modelKey || this.selectedModel];
|
||||||
|
console.log('Loading WebLLM model:', modelId);
|
||||||
|
|
||||||
|
// Progress callback wrapper
|
||||||
|
const initProgressCallback = (progress) => {
|
||||||
|
this.loadProgress = Math.round(progress.progress * 100);
|
||||||
|
const status = progress.text || 'Loading model...';
|
||||||
|
console.log(`WebLLM: ${this.loadProgress}% - ${status}`);
|
||||||
|
|
||||||
|
if (this.onProgressCallback) {
|
||||||
|
this.onProgressCallback(this.loadProgress, status);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create engine
|
||||||
|
this.engine = await webllm.CreateMLCEngine(modelId, {
|
||||||
|
initProgressCallback: initProgressCallback
|
||||||
|
});
|
||||||
|
|
||||||
|
this.currentModel = modelKey || this.selectedModel;
|
||||||
|
this.isLoading = false;
|
||||||
|
this.loadProgress = 100;
|
||||||
|
|
||||||
|
console.log('WebLLM ready!');
|
||||||
|
if (this.onReadyCallback) {
|
||||||
|
this.onReadyCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WebLLM initialization failed:', error);
|
||||||
|
this.isLoading = false;
|
||||||
|
|
||||||
|
if (this.onErrorCallback) {
|
||||||
|
this.onErrorCallback(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if engine is ready
|
||||||
|
*/
|
||||||
|
isReady() {
|
||||||
|
return this.engine !== null && !this.isLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarize text using local AI
|
||||||
|
* @param {string} text - Text to summarize
|
||||||
|
* @param {string} language - Output language ('en' or 'vi')
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async summarize(text, language = 'en') {
|
||||||
|
if (!this.isReady()) {
|
||||||
|
throw new Error('WebLLM not ready. Call init() first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate text to avoid token limits
|
||||||
|
const maxChars = 4000;
|
||||||
|
if (text.length > maxChars) {
|
||||||
|
text = text.substring(0, maxChars) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
const langInstruction = language === 'vi'
|
||||||
|
? 'Respond in Vietnamese (Tiếng Việt).'
|
||||||
|
: 'Respond in English.';
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `You are a helpful AI assistant that creates detailed, insightful video summaries. ${langInstruction}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `Provide a comprehensive summary of this video transcript in 4-6 sentences. Include the main topic, key points discussed, and any important insights or conclusions. Make the summary informative and meaningful:\n\n${text}`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.engine.chat.completions.create({
|
||||||
|
messages: messages,
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 350
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.choices[0].message.content.trim();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Summarization error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate text between English and Vietnamese
|
||||||
|
* @param {string} text - Text to translate
|
||||||
|
* @param {string} sourceLang - Source language ('en' or 'vi')
|
||||||
|
* @param {string} targetLang - Target language ('en' or 'vi')
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async translate(text, sourceLang = 'en', targetLang = 'vi') {
|
||||||
|
if (!this.isReady()) {
|
||||||
|
throw new Error('WebLLM not ready. Call init() first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const langNames = {
|
||||||
|
'en': 'English',
|
||||||
|
'vi': 'Vietnamese (Tiếng Việt)'
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `You are a professional translator. Translate the following text from ${langNames[sourceLang]} to ${langNames[targetLang]}. Provide only the translation, no explanations.`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: text
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.engine.chat.completions.create({
|
||||||
|
messages: messages,
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 500
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.choices[0].message.content.trim();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Translation error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract key points from text
|
||||||
|
* @param {string} text - Text to analyze
|
||||||
|
* @param {string} language - Output language
|
||||||
|
* @returns {Promise<string[]>}
|
||||||
|
*/
|
||||||
|
async extractKeyPoints(text, language = 'en') {
|
||||||
|
if (!this.isReady()) {
|
||||||
|
throw new Error('WebLLM not ready. Call init() first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxChars = 3000;
|
||||||
|
if (text.length > maxChars) {
|
||||||
|
text = text.substring(0, maxChars) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
const langInstruction = language === 'vi'
|
||||||
|
? 'Respond in Vietnamese.'
|
||||||
|
: 'Respond in English.';
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `You extract the main IDEAS and CONCEPTS from video content. ${langInstruction} Focus on:
|
||||||
|
- Main topics discussed
|
||||||
|
- Key insights or takeaways
|
||||||
|
- Important facts or claims
|
||||||
|
- Conclusions or recommendations
|
||||||
|
|
||||||
|
Do NOT copy sentences from the transcript. Instead, synthesize the core ideas in your own words. List 3-5 key points, one per line, without bullet points or numbers.`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `What are the main ideas and takeaways from this video transcript?\n\n${text}`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.engine.chat.completions.create({
|
||||||
|
messages: messages,
|
||||||
|
temperature: 0.6,
|
||||||
|
max_tokens: 400
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = response.choices[0].message.content.trim();
|
||||||
|
const points = content.split('\n')
|
||||||
|
.map(line => line.replace(/^[\d\.\-\*\•]+\s*/, '').trim())
|
||||||
|
.filter(line => line.length > 10);
|
||||||
|
|
||||||
|
return points.slice(0, 5);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Key points extraction error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream chat completion for real-time output
|
||||||
|
* @param {string} prompt - User prompt
|
||||||
|
* @param {function} onChunk - Callback for each chunk
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async streamChat(prompt, onChunk) {
|
||||||
|
if (!this.isReady()) {
|
||||||
|
throw new Error('WebLLM not ready.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{ role: 'user', content: prompt }
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chunks = await this.engine.chat.completions.create({
|
||||||
|
messages: messages,
|
||||||
|
temperature: 0.7,
|
||||||
|
stream: true
|
||||||
|
});
|
||||||
|
|
||||||
|
let fullResponse = '';
|
||||||
|
for await (const chunk of chunks) {
|
||||||
|
const delta = chunk.choices[0]?.delta?.content || '';
|
||||||
|
fullResponse += delta;
|
||||||
|
if (onChunk) {
|
||||||
|
onChunk(delta, fullResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullResponse;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Stream chat error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available models
|
||||||
|
*/
|
||||||
|
getModels() {
|
||||||
|
return Object.keys(this.models).map(key => ({
|
||||||
|
id: key,
|
||||||
|
name: this.models[key],
|
||||||
|
selected: key === this.selectedModel
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set selected model (requires re-init)
|
||||||
|
*/
|
||||||
|
setModel(modelKey) {
|
||||||
|
if (this.models[modelKey]) {
|
||||||
|
this.selectedModel = modelKey;
|
||||||
|
// Reset engine to force reload with new model
|
||||||
|
this.engine = null;
|
||||||
|
this.currentModel = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup and release resources
|
||||||
|
*/
|
||||||
|
async destroy() {
|
||||||
|
if (this.engine) {
|
||||||
|
// WebLLM doesn't have explicit destroy, but we can nullify
|
||||||
|
this.engine = null;
|
||||||
|
this.currentModel = null;
|
||||||
|
this.loadProgress = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global singleton instance
|
||||||
|
window.webLLMService = new WebLLMService();
|
||||||
|
|
||||||
|
// Export for module usage
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = WebLLMService;
|
||||||
|
}
|
||||||
0
static/manifest.json
Normal file → Executable file
0
static/manifest.json
Normal file → Executable file
0
static/sw.js
Normal file → Executable file
0
static/sw.js
Normal file → Executable file
970
templates/channel.html
Normal file → Executable file
970
templates/channel.html
Normal file → Executable file
|
|
@ -1,486 +1,486 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="yt-container yt-channel-page">
|
<div class="yt-container yt-channel-page">
|
||||||
|
|
||||||
<!-- Channel Header (No Banner) -->
|
<!-- Channel Header (No Banner) -->
|
||||||
<div class="yt-channel-header">
|
<div class="yt-channel-header">
|
||||||
<div class="yt-channel-info-row">
|
<div class="yt-channel-info-row">
|
||||||
<div class="yt-channel-avatar-xl" id="channelAvatarLarge">
|
<div class="yt-channel-avatar-xl" id="channelAvatarLarge">
|
||||||
{% if channel.avatar %}
|
{% if channel.avatar %}
|
||||||
<img src="{{ channel.avatar }}">
|
<img src="{{ channel.avatar }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<span id="channelAvatarLetter">{{ channel.title[0] | upper if channel.title and channel.title !=
|
<span id="channelAvatarLetter">{{ channel.title[0] | upper if channel.title and channel.title !=
|
||||||
'Loading...' else 'C' }}</span>
|
'Loading...' else 'C' }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-channel-meta">
|
<div class="yt-channel-meta">
|
||||||
<h1 id="channelTitle">{{ channel.title if channel.title and channel.title != 'Loading...' else
|
<h1 id="channelTitle">{{ channel.title if channel.title and channel.title != 'Loading...' else
|
||||||
'Loading...' }}</h1>
|
'Loading...' }}</h1>
|
||||||
<p class="yt-channel-handle" id="channelHandle">
|
<p class="yt-channel-handle" id="channelHandle">
|
||||||
{% if channel.title and channel.title != 'Loading...' %}@{{ channel.title|replace(' ', '') }}{% else
|
{% if channel.title and channel.title != 'Loading...' %}@{{ channel.title|replace(' ', '') }}{% else
|
||||||
%}@Loading...{% endif %}
|
%}@Loading...{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<div class="yt-channel-stats">
|
<div class="yt-channel-stats">
|
||||||
<span id="channelStats"></span>
|
<span id="channelStats"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video Grid -->
|
<!-- Video Grid -->
|
||||||
<div class="yt-section">
|
<div class="yt-section">
|
||||||
<div class="yt-section-header">
|
<div class="yt-section-header">
|
||||||
<div class="yt-tabs">
|
<div class="yt-tabs">
|
||||||
<a href="javascript:void(0)" onclick="changeChannelTab('video', this); return false;"
|
<a href="javascript:void(0)" onclick="changeChannelTab('video', this); return false;"
|
||||||
class="active no-spa">
|
class="active no-spa">
|
||||||
<i class="fas fa-video"></i>
|
<i class="fas fa-video"></i>
|
||||||
<span>Videos</span>
|
<span>Videos</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="javascript:void(0)" onclick="changeChannelTab('shorts', this); return false;" class="no-spa">
|
<a href="javascript:void(0)" onclick="changeChannelTab('shorts', this); return false;" class="no-spa">
|
||||||
<i class="fas fa-bolt"></i>
|
<i class="fas fa-bolt"></i>
|
||||||
<span>Shorts</span>
|
<span>Shorts</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="yt-sort-options">
|
<div class="yt-sort-options">
|
||||||
<a href="javascript:void(0)" onclick="changeChannelSort('latest', this); return false;"
|
<a href="javascript:void(0)" onclick="changeChannelSort('latest', this); return false;"
|
||||||
class="active no-spa">
|
class="active no-spa">
|
||||||
<i class="fas fa-clock"></i>
|
<i class="fas fa-clock"></i>
|
||||||
<span>Latest</span>
|
<span>Latest</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="javascript:void(0)" onclick="changeChannelSort('popular', this); return false;" class="no-spa">
|
<a href="javascript:void(0)" onclick="changeChannelSort('popular', this); return false;" class="no-spa">
|
||||||
<i class="fas fa-fire"></i>
|
<i class="fas fa-fire"></i>
|
||||||
<span>Popular</span>
|
<span>Popular</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="javascript:void(0)" onclick="changeChannelSort('oldest', this); return false;" class="no-spa">
|
<a href="javascript:void(0)" onclick="changeChannelSort('oldest', this); return false;" class="no-spa">
|
||||||
<i class="fas fa-history"></i>
|
<i class="fas fa-history"></i>
|
||||||
<span>Oldest</span>
|
<span>Oldest</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="yt-video-grid" id="channelVideosGrid">
|
<div class="yt-video-grid" id="channelVideosGrid">
|
||||||
<!-- Videos loaded via JS -->
|
<!-- Videos loaded via JS -->
|
||||||
</div>
|
</div>
|
||||||
<div id="channelLoadingTrigger" style="height: 20px; margin: 20px 0;"></div>
|
<div id="channelLoadingTrigger" style="height: 20px; margin: 20px 0;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.yt-channel-page {
|
.yt-channel-page {
|
||||||
padding-top: 40px;
|
padding-top: 40px;
|
||||||
padding-bottom: 40px;
|
padding-bottom: 40px;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Removed .yt-channel-banner */
|
/* Removed .yt-channel-banner */
|
||||||
|
|
||||||
.yt-channel-info-row {
|
.yt-channel-info-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 32px;
|
gap: 32px;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-avatar-xl {
|
.yt-channel-avatar-xl {
|
||||||
width: 160px;
|
width: 160px;
|
||||||
height: 160px;
|
height: 160px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%);
|
background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%);
|
||||||
/* Simpler color for no-banner look */
|
/* Simpler color for no-banner look */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 64px;
|
font-size: 64px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: white;
|
color: white;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-avatar-xl img {
|
.yt-channel-avatar-xl img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-meta {
|
.yt-channel-meta {
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-meta h1 {
|
.yt-channel-meta h1 {
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-handle {
|
.yt-channel-handle {
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-stats {
|
.yt-channel-stats {
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-section-header {
|
.yt-section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-tabs {
|
.yt-tabs {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: var(--yt-bg-secondary);
|
background: var(--yt-bg-secondary);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-tabs a {
|
.yt-tabs a {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: all 0.25s ease;
|
transition: all 0.25s ease;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-tabs a:hover {
|
.yt-tabs a:hover {
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
background: var(--yt-bg-hover);
|
background: var(--yt-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-tabs a.active {
|
.yt-tabs a.active {
|
||||||
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-sort-options {
|
.yt-sort-options {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: var(--yt-bg-secondary);
|
background: var(--yt-bg-secondary);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-sort-options a {
|
.yt-sort-options a {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: all 0.25s ease;
|
transition: all 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-sort-options a:hover {
|
.yt-sort-options a:hover {
|
||||||
background: var(--yt-bg-hover);
|
background: var(--yt-bg-hover);
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-sort-options a.active {
|
.yt-sort-options a.active {
|
||||||
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shorts Card Styling override for Channel Page grid */
|
/* Shorts Card Styling override for Channel Page grid */
|
||||||
.yt-channel-short-card {
|
.yt-channel-short-card {
|
||||||
border-radius: var(--yt-radius-lg);
|
border-radius: var(--yt-radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-short-card:hover {
|
.yt-channel-short-card:hover {
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-short-thumb-container {
|
.yt-short-thumb-container {
|
||||||
aspect-ratio: 9/16;
|
aspect-ratio: 9/16;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-short-thumb {
|
.yt-short-thumb {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.yt-channel-info-row {
|
.yt-channel-info-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-avatar-xl {
|
.yt-channel-avatar-xl {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-meta h1 {
|
.yt-channel-meta h1 {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-section-header {
|
.yt-section-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-tabs {
|
.yt-tabs {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
// IIFE to prevent variable redeclaration errors on SPA navigation
|
// IIFE to prevent variable redeclaration errors on SPA navigation
|
||||||
console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}");
|
console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}");
|
||||||
|
|
||||||
var currentChannelSort = 'latest';
|
var currentChannelSort = 'latest';
|
||||||
var currentChannelPage = 1;
|
var currentChannelPage = 1;
|
||||||
var isChannelLoading = false;
|
var isChannelLoading = false;
|
||||||
var hasMoreChannelVideos = true;
|
var hasMoreChannelVideos = true;
|
||||||
var currentFilterType = 'video';
|
var currentFilterType = 'video';
|
||||||
var channelId = "{{ channel.id }}";
|
var channelId = "{{ channel.id }}";
|
||||||
// Store initial channel title from server template (don't overwrite with empty API data)
|
// Store initial channel title from server template (don't overwrite with empty API data)
|
||||||
var initialChannelTitle = "{{ channel.title }}";
|
var initialChannelTitle = "{{ channel.title }}";
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
console.log("Channel init called, fetching content...");
|
console.log("Channel init called, fetching content...");
|
||||||
fetchChannelContent();
|
fetchChannelContent();
|
||||||
setupInfiniteScroll();
|
setupInfiniteScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle both initial page load and SPA navigation
|
// Handle both initial page load and SPA navigation
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
} else {
|
} else {
|
||||||
// DOM is already ready (SPA navigation)
|
// DOM is already ready (SPA navigation)
|
||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeChannelTab(type, btn) {
|
function changeChannelTab(type, btn) {
|
||||||
if (type === currentFilterType || isChannelLoading) return;
|
if (type === currentFilterType || isChannelLoading) return;
|
||||||
currentFilterType = type;
|
currentFilterType = type;
|
||||||
currentChannelPage = 1;
|
currentChannelPage = 1;
|
||||||
hasMoreChannelVideos = true;
|
hasMoreChannelVideos = true;
|
||||||
document.getElementById('channelVideosGrid').innerHTML = '';
|
document.getElementById('channelVideosGrid').innerHTML = '';
|
||||||
|
|
||||||
// Update Tabs UI
|
// Update Tabs UI
|
||||||
document.querySelectorAll('.yt-tabs a').forEach(a => a.classList.remove('active'));
|
document.querySelectorAll('.yt-tabs a').forEach(a => a.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
|
|
||||||
// Adjust Grid layout for Shorts vs Videos
|
// Adjust Grid layout for Shorts vs Videos
|
||||||
const grid = document.getElementById('channelVideosGrid');
|
const grid = document.getElementById('channelVideosGrid');
|
||||||
if (type === 'shorts') {
|
if (type === 'shorts') {
|
||||||
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
|
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
|
||||||
} else {
|
} else {
|
||||||
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))';
|
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))';
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchChannelContent();
|
fetchChannelContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeChannelSort(sort, btn) {
|
function changeChannelSort(sort, btn) {
|
||||||
if (isChannelLoading) return;
|
if (isChannelLoading) return;
|
||||||
currentChannelSort = sort;
|
currentChannelSort = sort;
|
||||||
currentChannelPage = 1;
|
currentChannelPage = 1;
|
||||||
hasMoreChannelVideos = true;
|
hasMoreChannelVideos = true;
|
||||||
document.getElementById('channelVideosGrid').innerHTML = ''; // Clear
|
document.getElementById('channelVideosGrid').innerHTML = ''; // Clear
|
||||||
|
|
||||||
// Update tabs
|
// Update tabs
|
||||||
document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active'));
|
document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
|
|
||||||
fetchChannelContent();
|
fetchChannelContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchChannelContent() {
|
async function fetchChannelContent() {
|
||||||
console.log("fetchChannelContent() called");
|
console.log("fetchChannelContent() called");
|
||||||
if (isChannelLoading || !hasMoreChannelVideos) {
|
if (isChannelLoading || !hasMoreChannelVideos) {
|
||||||
console.log("Early return:", { isChannelLoading, hasMoreChannelVideos });
|
console.log("Early return:", { isChannelLoading, hasMoreChannelVideos });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isChannelLoading = true;
|
isChannelLoading = true;
|
||||||
|
|
||||||
const grid = document.getElementById('channelVideosGrid');
|
const grid = document.getElementById('channelVideosGrid');
|
||||||
|
|
||||||
// Append Loading indicator
|
// Append Loading indicator
|
||||||
if (typeof renderSkeleton === 'function') {
|
if (typeof renderSkeleton === 'function') {
|
||||||
grid.insertAdjacentHTML('beforeend', renderSkeleton(4));
|
grid.insertAdjacentHTML('beforeend', renderSkeleton(4));
|
||||||
} else {
|
} else {
|
||||||
grid.insertAdjacentHTML('beforeend', '<div class="loading-text" style="color:var(--yt-text-secondary); padding: 20px;">Loading videos...</div>');
|
grid.insertAdjacentHTML('beforeend', '<div class="loading-text" style="color:var(--yt-text-secondary); padding: 20px;">Loading videos...</div>');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`Fetching: /api/channel?id=${channelId}&page=${currentChannelPage}`);
|
console.log(`Fetching: /api/channel?id=${channelId}&page=${currentChannelPage}`);
|
||||||
const response = await fetch(`/api/channel?id=${channelId}&page=${currentChannelPage}&sort=${currentChannelSort}&filter_type=${currentFilterType}`);
|
const response = await fetch(`/api/channel?id=${channelId}&page=${currentChannelPage}&sort=${currentChannelSort}&filter_type=${currentFilterType}`);
|
||||||
const videos = await response.json();
|
const videos = await response.json();
|
||||||
console.log("Channel Videos Response:", videos);
|
console.log("Channel Videos Response:", videos);
|
||||||
|
|
||||||
// Remove skeletons (simple way: remove last N children or just clear all if page 1?
|
// Remove skeletons (simple way: remove last N children or just clear all if page 1?
|
||||||
// Better: mark skeletons with class and remove)
|
// Better: mark skeletons with class and remove)
|
||||||
// For simplicity in this v1: We just clear skeletons by removing elements with 'skeleton-card' class
|
// For simplicity in this v1: We just clear skeletons by removing elements with 'skeleton-card' class
|
||||||
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
|
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
|
||||||
|
|
||||||
// Check if response is an error
|
// Check if response is an error
|
||||||
if (videos.error) {
|
if (videos.error) {
|
||||||
hasMoreChannelVideos = false;
|
hasMoreChannelVideos = false;
|
||||||
grid.innerHTML = `<p style="padding:20px; color:var(--yt-text-secondary);">Error: ${videos.error}</p>`;
|
grid.innerHTML = `<p style="padding:20px; color:var(--yt-text-secondary);">Error: ${videos.error}</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(videos) || videos.length === 0) {
|
if (!Array.isArray(videos) || videos.length === 0) {
|
||||||
hasMoreChannelVideos = false;
|
hasMoreChannelVideos = false;
|
||||||
if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>';
|
if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>';
|
||||||
} else {
|
} else {
|
||||||
// Update channel header with uploader info from first video (on first page only)
|
// Update channel header with uploader info from first video (on first page only)
|
||||||
if (currentChannelPage === 1 && videos[0]) {
|
if (currentChannelPage === 1 && videos[0]) {
|
||||||
// Use only proper channel/uploader fields - do NOT parse from title
|
// Use only proper channel/uploader fields - do NOT parse from title
|
||||||
let channelName = videos[0].channel || videos[0].uploader || '';
|
let channelName = videos[0].channel || videos[0].uploader || '';
|
||||||
|
|
||||||
// Only update header if API returned a meaningful name
|
// Only update header if API returned a meaningful name
|
||||||
// (not empty, not just the channel ID, and not "Loading...")
|
// (not empty, not just the channel ID, and not "Loading...")
|
||||||
if (channelName && channelName !== channelId &&
|
if (channelName && channelName !== channelId &&
|
||||||
!channelName.startsWith('UC') && channelName !== 'Loading...') {
|
!channelName.startsWith('UC') && channelName !== 'Loading...') {
|
||||||
|
|
||||||
document.getElementById('channelTitle').textContent = channelName;
|
document.getElementById('channelTitle').textContent = channelName;
|
||||||
document.getElementById('channelHandle').textContent = '@' + channelName.replace(/\s+/g, '');
|
document.getElementById('channelHandle').textContent = '@' + channelName.replace(/\s+/g, '');
|
||||||
const avatarLetter = document.getElementById('channelAvatarLetter');
|
const avatarLetter = document.getElementById('channelAvatarLetter');
|
||||||
if (avatarLetter) avatarLetter.textContent = channelName.charAt(0).toUpperCase();
|
if (avatarLetter) avatarLetter.textContent = channelName.charAt(0).toUpperCase();
|
||||||
}
|
}
|
||||||
// If no meaningful name from API, keep the initial template-rendered title
|
// If no meaningful name from API, keep the initial template-rendered title
|
||||||
}
|
}
|
||||||
|
|
||||||
videos.forEach(video => {
|
videos.forEach(video => {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
|
|
||||||
if (currentFilterType === 'shorts') {
|
if (currentFilterType === 'shorts') {
|
||||||
// Render Vertical Short Card
|
// Render Vertical Short Card
|
||||||
card.className = 'yt-channel-short-card';
|
card.className = 'yt-channel-short-card';
|
||||||
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="yt-short-thumb-container">
|
<div class="yt-short-thumb-container">
|
||||||
<img src="${video.thumbnail}" class="yt-short-thumb loaded" loading="lazy" onload="this.classList.add('loaded')">
|
<img src="${video.thumbnail}" class="yt-short-thumb loaded" loading="lazy" onload="this.classList.add('loaded')">
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-details" style="padding: 8px;">
|
<div class="yt-details" style="padding: 8px;">
|
||||||
<h3 class="yt-video-title" style="font-size: 14px; margin-bottom: 4px;">${escapeHtml(video.title)}</h3>
|
<h3 class="yt-video-title" style="font-size: 14px; margin-bottom: 4px;">${escapeHtml(video.title)}</h3>
|
||||||
<p class="yt-video-stats">${formatViews(video.view_count)} views</p>
|
<p class="yt-video-stats">${formatViews(video.view_count)} views</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
// Render Standard Video Card (Match Home)
|
// Render Standard Video Card (Match Home)
|
||||||
card.className = 'yt-video-card';
|
card.className = 'yt-video-card';
|
||||||
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="yt-thumbnail-container">
|
<div class="yt-thumbnail-container">
|
||||||
<img class="yt-thumbnail loaded" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')">
|
<img class="yt-thumbnail loaded" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')">
|
||||||
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
|
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-details">
|
<div class="yt-video-details">
|
||||||
<div class="yt-video-meta">
|
<div class="yt-video-meta">
|
||||||
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
|
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
|
||||||
<p class="yt-video-stats">
|
<p class="yt-video-stats">
|
||||||
${formatViews(video.view_count)} views • ${formatDate(video.upload_date)}
|
${formatViews(video.view_count)} views • ${formatDate(video.upload_date)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
grid.appendChild(card);
|
grid.appendChild(card);
|
||||||
});
|
});
|
||||||
currentChannelPage++;
|
currentChannelPage++;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
isChannelLoading = false;
|
isChannelLoading = false;
|
||||||
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
|
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupInfiniteScroll() {
|
function setupInfiniteScroll() {
|
||||||
const trigger = document.getElementById('channelLoadingTrigger');
|
const trigger = document.getElementById('channelLoadingTrigger');
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
if (entries[0].isIntersecting) {
|
if (entries[0].isIntersecting) {
|
||||||
fetchChannelContent();
|
fetchChannelContent();
|
||||||
}
|
}
|
||||||
}, { threshold: 0.1 });
|
}, { threshold: 0.1 });
|
||||||
observer.observe(trigger);
|
observer.observe(trigger);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helpers - Define locally to ensure availability
|
// Helpers - Define locally to ensure availability
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatViews(views) {
|
function formatViews(views) {
|
||||||
if (!views) return '0';
|
if (!views) return '0';
|
||||||
const num = parseInt(views);
|
const num = parseInt(views);
|
||||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||||
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
|
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
|
||||||
return num.toLocaleString();
|
return num.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
if (!dateStr) return 'Recently';
|
if (!dateStr) return 'Recently';
|
||||||
try {
|
try {
|
||||||
// Format: YYYYMMDD
|
// Format: YYYYMMDD
|
||||||
const year = dateStr.substring(0, 4);
|
const year = dateStr.substring(0, 4);
|
||||||
const month = dateStr.substring(4, 6);
|
const month = dateStr.substring(4, 6);
|
||||||
const day = dateStr.substring(6, 8);
|
const day = dateStr.substring(6, 8);
|
||||||
const date = new Date(year, month - 1, day);
|
const date = new Date(year, month - 1, day);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = now - date;
|
const diff = now - date;
|
||||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
if (days < 1) return 'Today';
|
if (days < 1) return 'Today';
|
||||||
if (days < 7) return `${days} days ago`;
|
if (days < 7) return `${days} days ago`;
|
||||||
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
|
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
|
||||||
if (days < 365) return `${Math.floor(days / 30)} months ago`;
|
if (days < 365) return `${Math.floor(days / 30)} months ago`;
|
||||||
return `${Math.floor(days / 365)} years ago`;
|
return `${Math.floor(days / 365)} years ago`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return 'Recently';
|
return 'Recently';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose functions globally for onclick handlers
|
// Expose functions globally for onclick handlers
|
||||||
window.changeChannelTab = changeChannelTab;
|
window.changeChannelTab = changeChannelTab;
|
||||||
window.changeChannelSort = changeChannelSort;
|
window.changeChannelSort = changeChannelSort;
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
408
templates/downloads.html
Normal file → Executable file
408
templates/downloads.html
Normal file → Executable file
|
|
@ -1,205 +1,205 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modules/downloads.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modules/downloads.css') }}">
|
||||||
|
|
||||||
<div class="downloads-page">
|
<div class="downloads-page">
|
||||||
<div class="downloads-header">
|
<div class="downloads-header">
|
||||||
<h1><i class="fas fa-download"></i> Downloads</h1>
|
<h1><i class="fas fa-download"></i> Downloads</h1>
|
||||||
<button class="downloads-clear-btn" onclick="clearAllDownloads()">
|
<button class="downloads-clear-btn" onclick="clearAllDownloads()">
|
||||||
<i class="fas fa-trash"></i> Clear All
|
<i class="fas fa-trash"></i> Clear All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="downloadsList" class="downloads-list">
|
<div id="downloadsList" class="downloads-list">
|
||||||
<!-- Downloads populated by JS -->
|
<!-- Downloads populated by JS -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="downloadsEmpty" class="downloads-empty" style="display: none;">
|
<div id="downloadsEmpty" class="downloads-empty" style="display: none;">
|
||||||
<i class="fas fa-download"></i>
|
<i class="fas fa-download"></i>
|
||||||
<p>No downloads yet</p>
|
<p>No downloads yet</p>
|
||||||
<p>Videos you download will appear here</p>
|
<p>Videos you download will appear here</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function renderDownloads() {
|
function renderDownloads() {
|
||||||
const list = document.getElementById('downloadsList');
|
const list = document.getElementById('downloadsList');
|
||||||
const empty = document.getElementById('downloadsEmpty');
|
const empty = document.getElementById('downloadsEmpty');
|
||||||
|
|
||||||
if (!list || !empty) return;
|
if (!list || !empty) return;
|
||||||
|
|
||||||
// Safety check for download manager
|
// Safety check for download manager
|
||||||
if (!window.downloadManager) {
|
if (!window.downloadManager) {
|
||||||
console.log('Download manager not ready, retrying...');
|
console.log('Download manager not ready, retrying...');
|
||||||
setTimeout(renderDownloads, 100);
|
setTimeout(renderDownloads, 100);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeDownloads = window.downloadManager.getActiveDownloads();
|
const activeDownloads = window.downloadManager.getActiveDownloads();
|
||||||
const library = window.downloadManager.getLibrary();
|
const library = window.downloadManager.getLibrary();
|
||||||
|
|
||||||
if (library.length === 0 && activeDownloads.length === 0) {
|
if (library.length === 0 && activeDownloads.length === 0) {
|
||||||
list.style.display = 'none';
|
list.style.display = 'none';
|
||||||
empty.style.display = 'block';
|
empty.style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.style.display = 'flex';
|
list.style.display = 'flex';
|
||||||
empty.style.display = 'none';
|
empty.style.display = 'none';
|
||||||
|
|
||||||
// Render Active Downloads
|
// Render Active Downloads
|
||||||
const activeHtml = activeDownloads.map(item => {
|
const activeHtml = activeDownloads.map(item => {
|
||||||
const specs = item.specs ?
|
const specs = item.specs ?
|
||||||
(item.type === 'video' ?
|
(item.type === 'video' ?
|
||||||
`${item.specs.resolution || ''} ${item.specs.vcodec ? '• ' + item.specs.vcodec : ''}` :
|
`${item.specs.resolution || ''} ${item.specs.vcodec ? '• ' + item.specs.vcodec : ''}` :
|
||||||
`${item.specs.acodec || ''} • ${item.specs.bitrate ? item.specs.bitrate + 'kbps' : ''}`
|
`${item.specs.acodec || ''} • ${item.specs.bitrate ? item.specs.bitrate + 'kbps' : ''}`
|
||||||
).trim() : '';
|
).trim() : '';
|
||||||
|
|
||||||
const isPaused = item.status === 'paused';
|
const isPaused = item.status === 'paused';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="download-item active ${isPaused ? 'paused' : ''}" data-id="${item.id}">
|
<div class="download-item active ${isPaused ? 'paused' : ''}" data-id="${item.id}">
|
||||||
<img src="${item.thumbnail || 'https://i.ytimg.com/vi/' + item.videoId + '/mqdefault.jpg'}"
|
<img src="${item.thumbnail || 'https://i.ytimg.com/vi/' + item.videoId + '/mqdefault.jpg'}"
|
||||||
class="download-item-thumb">
|
class="download-item-thumb">
|
||||||
<div class="download-item-info">
|
<div class="download-item-info">
|
||||||
<div class="download-item-title">${escapeHtml(item.title)}</div>
|
<div class="download-item-title">${escapeHtml(item.title)}</div>
|
||||||
<div class="download-item-meta">
|
<div class="download-item-meta">
|
||||||
<span class="status-text">
|
<span class="status-text">
|
||||||
${isPaused ? '<i class="fas fa-pause-circle"></i> Paused • ' : ''}
|
${isPaused ? '<i class="fas fa-pause-circle"></i> Paused • ' : ''}
|
||||||
${item.speedDisplay ? `<i class="fas fa-bolt"></i> ${item.speedDisplay} • ` : ''}
|
${item.speedDisplay ? `<i class="fas fa-bolt"></i> ${item.speedDisplay} • ` : ''}
|
||||||
${item.eta ? `<i class="fas fa-clock"></i> ${item.eta} • ` : ''}
|
${item.eta ? `<i class="fas fa-clock"></i> ${item.eta} • ` : ''}
|
||||||
${isPaused ? 'Resuming...' : 'Downloading...'} ${item.progress}%
|
${isPaused ? 'Resuming...' : 'Downloading...'} ${item.progress}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
${specs ? `<div class="download-item-specs"><small>${specs}</small></div>` : ''}
|
${specs ? `<div class="download-item-specs"><small>${specs}</small></div>` : ''}
|
||||||
<div class="download-progress-container">
|
<div class="download-progress-container">
|
||||||
<div class="download-progress-bar ${isPaused ? 'paused' : ''}" style="width: ${item.progress}%"></div>
|
<div class="download-progress-bar ${isPaused ? 'paused' : ''}" style="width: ${item.progress}%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="download-item-actions">
|
<div class="download-item-actions">
|
||||||
<button class="download-item-pause" onclick="togglePause('${item.id}')" title="${isPaused ? 'Resume' : 'Pause'}">
|
<button class="download-item-pause" onclick="togglePause('${item.id}')" title="${isPaused ? 'Resume' : 'Pause'}">
|
||||||
<i class="fas ${isPaused ? 'fa-play' : 'fa-pause'}"></i>
|
<i class="fas ${isPaused ? 'fa-play' : 'fa-pause'}"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="download-item-remove" onclick="cancelDownload('${item.id}')" title="Cancel">
|
<button class="download-item-remove" onclick="cancelDownload('${item.id}')" title="Cancel">
|
||||||
<i class="fas fa-stop"></i>
|
<i class="fas fa-stop"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`}).join('');
|
`}).join('');
|
||||||
|
|
||||||
// Render History - with playback support
|
// Render History - with playback support
|
||||||
const historyHtml = library.map(item => {
|
const historyHtml = library.map(item => {
|
||||||
const specs = item.specs ?
|
const specs = item.specs ?
|
||||||
(item.type === 'video' ?
|
(item.type === 'video' ?
|
||||||
`${item.specs.resolution || ''} ${item.specs.vcodec ? '• ' + item.specs.vcodec : ''}` :
|
`${item.specs.resolution || ''} ${item.specs.vcodec ? '• ' + item.specs.vcodec : ''}` :
|
||||||
`${item.specs.acodec || ''} • ${item.specs.bitrate ? item.specs.bitrate + 'kbps' : ''}`
|
`${item.specs.acodec || ''} • ${item.specs.bitrate ? item.specs.bitrate + 'kbps' : ''}`
|
||||||
).trim() : '';
|
).trim() : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="download-item playable" data-id="${item.id}" data-video-id="${item.videoId}" onclick="playDownload('${item.videoId}', event)">
|
<div class="download-item playable" data-id="${item.id}" data-video-id="${item.videoId}" onclick="playDownload('${item.videoId}', event)">
|
||||||
<div class="download-item-thumb-wrapper">
|
<div class="download-item-thumb-wrapper">
|
||||||
<img src="${item.thumbnail || 'https://i.ytimg.com/vi/' + item.videoId + '/mqdefault.jpg'}"
|
<img src="${item.thumbnail || 'https://i.ytimg.com/vi/' + item.videoId + '/mqdefault.jpg'}"
|
||||||
class="download-item-thumb"
|
class="download-item-thumb"
|
||||||
onerror="this.src='https://via.placeholder.com/160x90?text=No+Thumbnail'">
|
onerror="this.src='https://via.placeholder.com/160x90?text=No+Thumbnail'">
|
||||||
<div class="download-thumb-overlay">
|
<div class="download-thumb-overlay">
|
||||||
<i class="fas fa-play"></i>
|
<i class="fas fa-play"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="download-item-info">
|
<div class="download-item-info">
|
||||||
<div class="download-item-title">${escapeHtml(item.title)}</div>
|
<div class="download-item-title">${escapeHtml(item.title)}</div>
|
||||||
<div class="download-item-meta">
|
<div class="download-item-meta">
|
||||||
${item.quality} · ${item.type} · ${formatDate(item.downloadedAt)}
|
${item.quality} · ${item.type} · ${formatDate(item.downloadedAt)}
|
||||||
${specs ? `<span class="meta-specs">• ${specs}</span>` : ''}
|
${specs ? `<span class="meta-specs">• ${specs}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="download-item-actions">
|
<div class="download-item-actions">
|
||||||
<button class="download-item-play" onclick="playDownload('${item.videoId}', event); event.stopPropagation();" title="Play">
|
<button class="download-item-play" onclick="playDownload('${item.videoId}', event); event.stopPropagation();" title="Play">
|
||||||
<i class="fas fa-play"></i>
|
<i class="fas fa-play"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="download-item-redownload" onclick="reDownload('${item.videoId}', event); event.stopPropagation();" title="Download Again">
|
<button class="download-item-redownload" onclick="reDownload('${item.videoId}', event); event.stopPropagation();" title="Download Again">
|
||||||
<i class="fas fa-download"></i>
|
<i class="fas fa-download"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="download-item-remove" onclick="removeDownload('${item.id}'); event.stopPropagation();" title="Remove">
|
<button class="download-item-remove" onclick="removeDownload('${item.id}'); event.stopPropagation();" title="Remove">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`}).join('');
|
`}).join('');
|
||||||
|
|
||||||
|
|
||||||
list.innerHTML = activeHtml + historyHtml;
|
list.innerHTML = activeHtml + historyHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelDownload(id) {
|
function cancelDownload(id) {
|
||||||
window.downloadManager.cancelDownload(id);
|
window.downloadManager.cancelDownload(id);
|
||||||
renderDownloads();
|
renderDownloads();
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDownload(id) {
|
function removeDownload(id) {
|
||||||
window.downloadManager.removeFromLibrary(id);
|
window.downloadManager.removeFromLibrary(id);
|
||||||
renderDownloads();
|
renderDownloads();
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePause(id) {
|
function togglePause(id) {
|
||||||
const downloads = window.downloadManager.activeDownloads;
|
const downloads = window.downloadManager.activeDownloads;
|
||||||
const state = downloads.get(id);
|
const state = downloads.get(id);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
if (state.item.status === 'paused') {
|
if (state.item.status === 'paused') {
|
||||||
window.downloadManager.resumeDownload(id);
|
window.downloadManager.resumeDownload(id);
|
||||||
} else {
|
} else {
|
||||||
window.downloadManager.pauseDownload(id);
|
window.downloadManager.pauseDownload(id);
|
||||||
}
|
}
|
||||||
// renderDownloads will be called by event listener
|
// renderDownloads will be called by event listener
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAllDownloads() {
|
function clearAllDownloads() {
|
||||||
if (confirm('Remove all downloads from history?')) {
|
if (confirm('Remove all downloads from history?')) {
|
||||||
window.downloadManager.clearLibrary();
|
window.downloadManager.clearLibrary();
|
||||||
renderDownloads();
|
renderDownloads();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function playDownload(videoId, event) {
|
function playDownload(videoId, event) {
|
||||||
if (event) event.preventDefault();
|
if (event) event.preventDefault();
|
||||||
// Navigate to watch page for this video
|
// Navigate to watch page for this video
|
||||||
window.location.href = `/watch?v=${videoId}`;
|
window.location.href = `/watch?v=${videoId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function reDownload(videoId, event) {
|
function reDownload(videoId, event) {
|
||||||
if (event) event.preventDefault();
|
if (event) event.preventDefault();
|
||||||
// Open download modal for this video
|
// Open download modal for this video
|
||||||
if (typeof showDownloadModal === 'function') {
|
if (typeof showDownloadModal === 'function') {
|
||||||
showDownloadModal(videoId);
|
showDownloadModal(videoId);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: navigate to watch page
|
// Fallback: navigate to watch page
|
||||||
window.location.href = `/watch?v=${videoId}`;
|
window.location.href = `/watch?v=${videoId}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return '';
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render on load with slight delay for download manager
|
// Render on load with slight delay for download manager
|
||||||
document.addEventListener('DOMContentLoaded', () => setTimeout(renderDownloads, 200));
|
document.addEventListener('DOMContentLoaded', () => setTimeout(renderDownloads, 200));
|
||||||
|
|
||||||
// Listen for real-time updates
|
// Listen for real-time updates
|
||||||
// Listen for real-time updates - Prevent duplicates
|
// Listen for real-time updates - Prevent duplicates
|
||||||
if (window._kvDownloadListener) {
|
if (window._kvDownloadListener) {
|
||||||
window.removeEventListener('download-updated', window._kvDownloadListener);
|
window.removeEventListener('download-updated', window._kvDownloadListener);
|
||||||
}
|
}
|
||||||
window._kvDownloadListener = renderDownloads;
|
window._kvDownloadListener = renderDownloads;
|
||||||
window.addEventListener('download-updated', renderDownloads);
|
window.addEventListener('download-updated', renderDownloads);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
432
templates/index.html
Normal file → Executable file
432
templates/index.html
Normal file → Executable file
|
|
@ -1,217 +1,217 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<script>
|
<script>
|
||||||
window.APP_CONFIG = {
|
window.APP_CONFIG = {
|
||||||
page: '{{ page|default("home") }}',
|
page: '{{ page|default("home") }}',
|
||||||
channelId: '{{ channel_id|default("") }}',
|
channelId: '{{ channel_id|default("") }}',
|
||||||
query: '{{ query|default("") }}'
|
query: '{{ query|default("") }}'
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<!-- Filters & Categories -->
|
<!-- Filters & Categories -->
|
||||||
<div class="yt-filter-bar">
|
<div class="yt-filter-bar">
|
||||||
<div class="yt-categories" id="categoryList">
|
<div class="yt-categories" id="categoryList">
|
||||||
<!-- Pinned Categories -->
|
<!-- Pinned Categories -->
|
||||||
<button class="yt-chip" onclick="switchCategory('suggested', this)"><i class="fas fa-magic"></i>
|
<button class="yt-chip" onclick="switchCategory('suggested', this)"><i class="fas fa-magic"></i>
|
||||||
Suggested</button>
|
Suggested</button>
|
||||||
<!-- Standard Categories -->
|
<!-- Standard Categories -->
|
||||||
<button class="yt-chip" onclick="switchCategory('tech', this)">Tech</button>
|
<button class="yt-chip" onclick="switchCategory('tech', this)">Tech</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('music', this)">Music</button>
|
<button class="yt-chip" onclick="switchCategory('music', this)">Music</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('movies', this)">Movies</button>
|
<button class="yt-chip" onclick="switchCategory('movies', this)">Movies</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('news', this)">News</button>
|
<button class="yt-chip" onclick="switchCategory('news', this)">News</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('trending', this)">Trending</button>
|
<button class="yt-chip" onclick="switchCategory('trending', this)">Trending</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('podcasts', this)">Podcasts</button>
|
<button class="yt-chip" onclick="switchCategory('podcasts', this)">Podcasts</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('live', this)">Live</button>
|
<button class="yt-chip" onclick="switchCategory('live', this)">Live</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('gaming', this)">Gaming</button>
|
<button class="yt-chip" onclick="switchCategory('gaming', this)">Gaming</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('sports', this)">Sports</button>
|
<button class="yt-chip" onclick="switchCategory('sports', this)">Sports</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="yt-filter-actions">
|
<div class="yt-filter-actions">
|
||||||
<div class="yt-dropdown">
|
<div class="yt-dropdown">
|
||||||
<button class="yt-icon-btn" id="filterToggleBtn" onclick="toggleFilterMenu()">
|
<button class="yt-icon-btn" id="filterToggleBtn" onclick="toggleFilterMenu()">
|
||||||
<i class="fas fa-sliders-h"></i>
|
<i class="fas fa-sliders-h"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="yt-dropdown-menu" id="filterMenu">
|
<div class="yt-dropdown-menu" id="filterMenu">
|
||||||
<div class="yt-menu-section">
|
<div class="yt-menu-section">
|
||||||
<h4>Sort By</h4>
|
<h4>Sort By</h4>
|
||||||
<button onclick="changeSort('day')">Today</button>
|
<button onclick="changeSort('day')">Today</button>
|
||||||
<button onclick="changeSort('week')">This Week</button>
|
<button onclick="changeSort('week')">This Week</button>
|
||||||
<button onclick="changeSort('month')">This Month</button>
|
<button onclick="changeSort('month')">This Month</button>
|
||||||
<button onclick="changeSort('3months')">Last 3 Months</button>
|
<button onclick="changeSort('3months')">Last 3 Months</button>
|
||||||
<button onclick="changeSort('year')">This Year</button>
|
<button onclick="changeSort('year')">This Year</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-menu-section">
|
<div class="yt-menu-section">
|
||||||
<h4>Region</h4>
|
<h4>Region</h4>
|
||||||
<button onclick="changeRegion('vietnam')">Vietnam</button>
|
<button onclick="changeRegion('vietnam')">Vietnam</button>
|
||||||
<button onclick="changeRegion('global')">Global</button>
|
<button onclick="changeRegion('global')">Global</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Shorts Section -->
|
<!-- Shorts Section -->
|
||||||
|
|
||||||
|
|
||||||
<!-- Videos Section -->
|
<!-- Videos Section -->
|
||||||
<div id="videosSection" class="yt-section">
|
<div id="videosSection" class="yt-section">
|
||||||
<div class="yt-section-header" style="display:none;">
|
<div class="yt-section-header" style="display:none;">
|
||||||
<h2><i class="fas fa-play-circle"></i> Videos</h2>
|
<h2><i class="fas fa-play-circle"></i> Videos</h2>
|
||||||
</div>
|
</div>
|
||||||
<div id="resultsArea" class="yt-video-grid">
|
<div id="resultsArea" class="yt-video-grid">
|
||||||
<!-- Initial Skeleton State -->
|
<!-- Initial Skeleton State -->
|
||||||
<!-- Initial Skeleton State (12 items) -->
|
<!-- Initial Skeleton State (12 items) -->
|
||||||
<div class="yt-video-card skeleton-card">
|
<div class="yt-video-card skeleton-card">
|
||||||
<div class="skeleton-thumb skeleton"></div>
|
<div class="skeleton-thumb skeleton"></div>
|
||||||
<div class="skeleton-details">
|
<div class="skeleton-details">
|
||||||
<div class="skeleton-avatar skeleton"></div>
|
<div class="skeleton-avatar skeleton"></div>
|
||||||
<div class="skeleton-text">
|
<div class="skeleton-text">
|
||||||
<div class="skeleton-title skeleton"></div>
|
<div class="skeleton-title skeleton"></div>
|
||||||
<div class="skeleton-meta skeleton"></div>
|
<div class="skeleton-meta skeleton"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-card skeleton-card">
|
<div class="yt-video-card skeleton-card">
|
||||||
<div class="skeleton-thumb skeleton"></div>
|
<div class="skeleton-thumb skeleton"></div>
|
||||||
<div class="skeleton-details">
|
<div class="skeleton-details">
|
||||||
<div class="skeleton-avatar skeleton"></div>
|
<div class="skeleton-avatar skeleton"></div>
|
||||||
<div class="skeleton-text">
|
<div class="skeleton-text">
|
||||||
<div class="skeleton-title skeleton"></div>
|
<div class="skeleton-title skeleton"></div>
|
||||||
<div class="skeleton-meta skeleton"></div>
|
<div class="skeleton-meta skeleton"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-card skeleton-card">
|
<div class="yt-video-card skeleton-card">
|
||||||
<div class="skeleton-thumb skeleton"></div>
|
<div class="skeleton-thumb skeleton"></div>
|
||||||
<div class="skeleton-details">
|
<div class="skeleton-details">
|
||||||
<div class="skeleton-avatar skeleton"></div>
|
<div class="skeleton-avatar skeleton"></div>
|
||||||
<div class="skeleton-text">
|
<div class="skeleton-text">
|
||||||
<div class="skeleton-title skeleton"></div>
|
<div class="skeleton-title skeleton"></div>
|
||||||
<div class="skeleton-meta skeleton"></div>
|
<div class="skeleton-meta skeleton"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-card skeleton-card">
|
<div class="yt-video-card skeleton-card">
|
||||||
<div class="skeleton-thumb skeleton"></div>
|
<div class="skeleton-thumb skeleton"></div>
|
||||||
<div class="skeleton-details">
|
<div class="skeleton-details">
|
||||||
<div class="skeleton-avatar skeleton"></div>
|
<div class="skeleton-avatar skeleton"></div>
|
||||||
<div class="skeleton-text">
|
<div class="skeleton-text">
|
||||||
<div class="skeleton-title skeleton"></div>
|
<div class="skeleton-title skeleton"></div>
|
||||||
<div class="skeleton-meta skeleton"></div>
|
<div class="skeleton-meta skeleton"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-card skeleton-card">
|
<div class="yt-video-card skeleton-card">
|
||||||
<div class="skeleton-thumb skeleton"></div>
|
<div class="skeleton-thumb skeleton"></div>
|
||||||
<div class="skeleton-details">
|
<div class="skeleton-details">
|
||||||
<div class="skeleton-avatar skeleton"></div>
|
<div class="skeleton-avatar skeleton"></div>
|
||||||
<div class="skeleton-text">
|
<div class="skeleton-text">
|
||||||
<div class="skeleton-title skeleton"></div>
|
<div class="skeleton-title skeleton"></div>
|
||||||
<div class="skeleton-meta skeleton"></div>
|
<div class="skeleton-meta skeleton"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-card skeleton-card">
|
<div class="yt-video-card skeleton-card">
|
||||||
<div class="skeleton-thumb skeleton"></div>
|
<div class="skeleton-thumb skeleton"></div>
|
||||||
<div class="skeleton-details">
|
<div class="skeleton-details">
|
||||||
<div class="skeleton-avatar skeleton"></div>
|
<div class="skeleton-avatar skeleton"></div>
|
||||||
<div class="skeleton-text">
|
<div class="skeleton-text">
|
||||||
<div class="skeleton-title skeleton"></div>
|
<div class="skeleton-title skeleton"></div>
|
||||||
<div class="skeleton-meta skeleton"></div>
|
<div class="skeleton-meta skeleton"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-card skeleton-card">
|
<div class="yt-video-card skeleton-card">
|
||||||
<div class="skeleton-thumb skeleton"></div>
|
<div class="skeleton-thumb skeleton"></div>
|
||||||
<div class="skeleton-details">
|
<div class="skeleton-details">
|
||||||
<div class="skeleton-avatar skeleton"></div>
|
<div class="skeleton-avatar skeleton"></div>
|
||||||
<div class="skeleton-text">
|
<div class="skeleton-text">
|
||||||
<div class="skeleton-title skeleton"></div>
|
<div class="skeleton-title skeleton"></div>
|
||||||
<div class="skeleton-meta skeleton"></div>
|
<div class="skeleton-meta skeleton"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-card skeleton-card">
|
<div class="yt-video-card skeleton-card">
|
||||||
<div class="skeleton-thumb skeleton"></div>
|
<div class="skeleton-thumb skeleton"></div>
|
||||||
<div class="skeleton-details">
|
<div class="skeleton-details">
|
||||||
<div class="skeleton-avatar skeleton"></div>
|
<div class="skeleton-avatar skeleton"></div>
|
||||||
<div class="skeleton-text">
|
<div class="skeleton-text">
|
||||||
<div class="skeleton-title skeleton"></div>
|
<div class="skeleton-title skeleton"></div>
|
||||||
<div class="skeleton-meta skeleton"></div>
|
<div class="skeleton-meta skeleton"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Styles moved to CSS modules -->
|
<!-- Styles moved to CSS modules -->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Global filter state
|
// Global filter state
|
||||||
// Global filter state
|
// Global filter state
|
||||||
var currentSort = 'month';
|
var currentSort = 'month';
|
||||||
var currentRegion = 'vietnam';
|
var currentRegion = 'vietnam';
|
||||||
|
|
||||||
function toggleFilterMenu() {
|
function toggleFilterMenu() {
|
||||||
const menu = document.getElementById('filterMenu');
|
const menu = document.getElementById('filterMenu');
|
||||||
if (menu) menu.classList.toggle('show');
|
if (menu) menu.classList.toggle('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close menu when clicking outside - Prevent multiple listeners
|
// Close menu when clicking outside - Prevent multiple listeners
|
||||||
if (!window.filterMenuListenerAttached) {
|
if (!window.filterMenuListenerAttached) {
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
const menu = document.getElementById('filterMenu');
|
const menu = document.getElementById('filterMenu');
|
||||||
const btn = document.getElementById('filterToggleBtn');
|
const btn = document.getElementById('filterToggleBtn');
|
||||||
// Only run if elements exist (we are on home page)
|
// Only run if elements exist (we are on home page)
|
||||||
if (menu && btn && !menu.contains(e.target) && !btn.contains(e.target)) {
|
if (menu && btn && !menu.contains(e.target) && !btn.contains(e.target)) {
|
||||||
menu.classList.remove('show');
|
menu.classList.remove('show');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.filterMenuListenerAttached = true;
|
window.filterMenuListenerAttached = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeSort(sort) {
|
function changeSort(sort) {
|
||||||
window.currentSort = sort;
|
window.currentSort = sort;
|
||||||
// Global loadTrending from main.js will use this
|
// Global loadTrending from main.js will use this
|
||||||
loadTrending(true);
|
loadTrending(true);
|
||||||
toggleFilterMenu();
|
toggleFilterMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeRegion(region) {
|
function changeRegion(region) {
|
||||||
window.currentRegion = region;
|
window.currentRegion = region;
|
||||||
loadTrending(true);
|
loadTrending(true);
|
||||||
toggleFilterMenu();
|
toggleFilterMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helpers (if main.js not loaded yet or for standalone usage)
|
// Helpers (if main.js not loaded yet or for standalone usage)
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatViews(views) {
|
function formatViews(views) {
|
||||||
if (!views) return '0';
|
if (!views) return '0';
|
||||||
const num = parseInt(views);
|
const num = parseInt(views);
|
||||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||||
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
|
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
|
||||||
return num.toLocaleString();
|
return num.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init Logic
|
// Init Logic
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Pagination logic removed for infinite scroll
|
// Pagination logic removed for infinite scroll
|
||||||
|
|
||||||
// Check URL params for category
|
// Check URL params for category
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const category = urlParams.get('category');
|
const category = urlParams.get('category');
|
||||||
if (category && typeof switchCategory === 'function') {
|
if (category && typeof switchCategory === 'function') {
|
||||||
// Let main.js handle the switch, but we can set UI active state if needed
|
// Let main.js handle the switch, but we can set UI active state if needed
|
||||||
// switchCategory is in main.js
|
// switchCategory is in main.js
|
||||||
switchCategory(category);
|
switchCategory(category);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
1062
templates/layout.html
Normal file → Executable file
1062
templates/layout.html
Normal file → Executable file
File diff suppressed because it is too large
Load diff
422
templates/login.html
Normal file → Executable file
422
templates/login.html
Normal file → Executable file
|
|
@ -1,212 +1,212 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="yt-auth-container">
|
<div class="yt-auth-container">
|
||||||
<div class="yt-auth-card">
|
<div class="yt-auth-card">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="yt-auth-logo">
|
<div class="yt-auth-logo">
|
||||||
<div class="yt-logo-icon" style="font-size: 24px;">KV-Tube</div>
|
<div class="yt-logo-icon" style="font-size: 24px;">KV-Tube</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Sign in</h2>
|
<h2>Sign in</h2>
|
||||||
<p>to continue to KV-Tube</p>
|
<p>to continue to KV-Tube</p>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages() %}
|
{% with messages = get_flashed_messages() %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<div class="yt-auth-alert">
|
<div class="yt-auth-alert">
|
||||||
<i class="fas fa-exclamation-circle"></i>
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
{{ messages[0] }}
|
{{ messages[0] }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<form method="POST" action="/login" class="yt-auth-form">
|
<form method="POST" action="/login" class="yt-auth-form">
|
||||||
<div class="yt-form-group">
|
<div class="yt-form-group">
|
||||||
<input type="text" name="username" class="yt-form-input" placeholder=" " required id="username">
|
<input type="text" name="username" class="yt-form-input" placeholder=" " required id="username">
|
||||||
<label for="username" class="yt-form-label">Username</label>
|
<label for="username" class="yt-form-label">Username</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="yt-form-group">
|
<div class="yt-form-group">
|
||||||
<input type="password" name="password" class="yt-form-input" placeholder=" " required id="password">
|
<input type="password" name="password" class="yt-form-input" placeholder=" " required id="password">
|
||||||
<label for="password" class="yt-form-label">Password</label>
|
<label for="password" class="yt-form-label">Password</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="yt-auth-submit">
|
<button type="submit" class="yt-auth-submit">
|
||||||
<i class="fas fa-sign-in-alt"></i>
|
<i class="fas fa-sign-in-alt"></i>
|
||||||
Sign In
|
Sign In
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="yt-auth-divider">
|
<div class="yt-auth-divider">
|
||||||
<span>or</span>
|
<span>or</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="yt-auth-footer">
|
<p class="yt-auth-footer">
|
||||||
New to KV-Tube?
|
New to KV-Tube?
|
||||||
<a href="/register" class="yt-auth-link">Create an account</a>
|
<a href="/register" class="yt-auth-link">Create an account</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.yt-auth-container {
|
.yt-auth-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: calc(100vh - var(--yt-header-height) - 100px);
|
min-height: calc(100vh - var(--yt-header-height) - 100px);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-card {
|
.yt-auth-card {
|
||||||
background: var(--yt-bg-secondary);
|
background: var(--yt-bg-secondary);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 48px 40px;
|
padding: 48px 40px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1px solid var(--yt-border);
|
border: 1px solid var(--yt-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-logo {
|
.yt-auth-logo {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-card h2 {
|
.yt-auth-card h2 {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-card>p {
|
.yt-auth-card>p {
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-alert {
|
.yt-auth-alert {
|
||||||
background: rgba(244, 67, 54, 0.1);
|
background: rgba(244, 67, 54, 0.1);
|
||||||
color: #f44336;
|
color: #f44336;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-form {
|
.yt-auth-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-group {
|
.yt-form-group {
|
||||||
position: relative;
|
position: relative;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-input {
|
.yt-form-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 16px 14px;
|
padding: 16px 14px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
background: var(--yt-bg-primary);
|
background: var(--yt-bg-primary);
|
||||||
border: 1px solid var(--yt-border);
|
border: 1px solid var(--yt-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-input:focus {
|
.yt-form-input:focus {
|
||||||
border-color: var(--yt-accent-blue);
|
border-color: var(--yt-accent-blue);
|
||||||
box-shadow: 0 0 0 3px rgba(62, 166, 255, 0.2);
|
box-shadow: 0 0 0 3px rgba(62, 166, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-label {
|
.yt-form-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 14px;
|
left: 14px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
background: var(--yt-bg-primary);
|
background: var(--yt-bg-primary);
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-input:focus+.yt-form-label,
|
.yt-form-input:focus+.yt-form-label,
|
||||||
.yt-form-input:not(:placeholder-shown)+.yt-form-label {
|
.yt-form-input:not(:placeholder-shown)+.yt-form-label {
|
||||||
top: 0;
|
top: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--yt-accent-blue);
|
color: var(--yt-accent-blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-submit {
|
.yt-auth-submit {
|
||||||
background: var(--yt-accent-blue);
|
background: var(--yt-accent-blue);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 14px 24px;
|
padding: 14px 24px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
transition: background 0.2s, transform 0.1s;
|
transition: background 0.2s, transform 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-submit:hover {
|
.yt-auth-submit:hover {
|
||||||
background: #258fd9;
|
background: #258fd9;
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-submit:active {
|
.yt-auth-submit:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-divider {
|
.yt-auth-divider {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 24px 0;
|
margin: 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-divider::before,
|
.yt-auth-divider::before,
|
||||||
.yt-auth-divider::after {
|
.yt-auth-divider::after {
|
||||||
content: '';
|
content: '';
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--yt-border);
|
background: var(--yt-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-divider span {
|
.yt-auth-divider span {
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-footer {
|
.yt-auth-footer {
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-link {
|
.yt-auth-link {
|
||||||
color: var(--yt-accent-blue);
|
color: var(--yt-accent-blue);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-link:hover {
|
.yt-auth-link:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.yt-auth-card {
|
.yt-auth-card {
|
||||||
padding: 32px 24px;
|
padding: 32px 24px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
924
templates/my_videos.html
Normal file → Executable file
924
templates/my_videos.html
Normal file → Executable file
|
|
@ -1,463 +1,463 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<style>
|
<style>
|
||||||
/* Library Page Premium Styles */
|
/* Library Page Premium Styles */
|
||||||
.library-container {
|
.library-container {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
max-width: 1600px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-header {
|
.library-header {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-title {
|
.library-title {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
background: linear-gradient(135deg, var(--yt-text-primary) 0%, var(--yt-text-secondary) 100%);
|
background: linear-gradient(135deg, var(--yt-text-primary) 0%, var(--yt-text-secondary) 100%);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-tabs {
|
.library-tabs {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: var(--yt-bg-secondary);
|
background: var(--yt-bg-secondary);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-tab {
|
.library-tab {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: all 0.25s ease;
|
transition: all 0.25s ease;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-tab:hover {
|
.library-tab:hover {
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
background: var(--yt-bg-hover);
|
background: var(--yt-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-tab.active {
|
.library-tab.active {
|
||||||
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-tab i {
|
.library-tab i {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-actions {
|
.library-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-btn {
|
.clear-btn {
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--yt-border);
|
border: 1px solid var(--yt-border);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-btn:hover {
|
.clear-btn:hover {
|
||||||
background: rgba(204, 0, 0, 0.1);
|
background: rgba(204, 0, 0, 0.1);
|
||||||
border-color: #cc0000;
|
border-color: #cc0000;
|
||||||
color: #cc0000;
|
color: #cc0000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-stats {
|
.library-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-stat {
|
.library-stat {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-stat i {
|
.library-stat i {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty State Enhancement */
|
/* Empty State Enhancement */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 4rem 2rem;
|
padding: 4rem 2rem;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state-icon {
|
.empty-state-icon {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
margin: 0 auto 1.5rem;
|
margin: 0 auto 1.5rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--yt-bg-secondary);
|
background: var(--yt-bg-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state h3 {
|
.empty-state h3 {
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state p {
|
.empty-state p {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.browse-btn {
|
.browse-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.browse-btn:hover {
|
.browse-btn:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 6px 16px rgba(204, 0, 0, 0.4);
|
box-shadow: 0 6px 16px rgba(204, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="library-container">
|
<div class="library-container">
|
||||||
<div class="library-header">
|
<div class="library-header">
|
||||||
<h1 class="library-title">My Library</h1>
|
<h1 class="library-title">My Library</h1>
|
||||||
|
|
||||||
<div class="library-tabs">
|
<div class="library-tabs">
|
||||||
<a href="/my-videos?type=history" class="library-tab" id="tab-history">
|
<a href="/my-videos?type=history" class="library-tab" id="tab-history">
|
||||||
<i class="fas fa-history"></i>
|
<i class="fas fa-history"></i>
|
||||||
<span>History</span>
|
<span>History</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/my-videos?type=saved" class="library-tab" id="tab-saved">
|
<a href="/my-videos?type=saved" class="library-tab" id="tab-saved">
|
||||||
<i class="fas fa-bookmark"></i>
|
<i class="fas fa-bookmark"></i>
|
||||||
<span>Saved</span>
|
<span>Saved</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/my-videos?type=subscriptions" class="library-tab" id="tab-subscriptions">
|
<a href="/my-videos?type=subscriptions" class="library-tab" id="tab-subscriptions">
|
||||||
<i class="fas fa-users"></i>
|
<i class="fas fa-users"></i>
|
||||||
<span>Subscriptions</span>
|
<span>Subscriptions</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="library-stats" id="libraryStats" style="display: none;">
|
<div class="library-stats" id="libraryStats" style="display: none;">
|
||||||
<div class="library-stat">
|
<div class="library-stat">
|
||||||
<i class="fas fa-video"></i>
|
<i class="fas fa-video"></i>
|
||||||
<span id="videoCount">0 videos</span>
|
<span id="videoCount">0 videos</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="library-actions">
|
<div class="library-actions">
|
||||||
<button id="clearBtn" onclick="clearLibrary()" class="clear-btn">
|
<button id="clearBtn" onclick="clearLibrary()" class="clear-btn">
|
||||||
<i class="fas fa-trash-alt"></i>
|
<i class="fas fa-trash-alt"></i>
|
||||||
<span>Clear <span id="clearType">All</span></span>
|
<span>Clear <span id="clearType">All</span></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video Grid -->
|
<!-- Video Grid -->
|
||||||
<div id="libraryGrid" class="yt-video-grid">
|
<div id="libraryGrid" class="yt-video-grid">
|
||||||
<!-- JS will populate this -->
|
<!-- JS will populate this -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div id="emptyState" class="empty-state" style="display: none;">
|
<div id="emptyState" class="empty-state" style="display: none;">
|
||||||
<div class="empty-state-icon">
|
<div class="empty-state-icon">
|
||||||
<i class="fas fa-folder-open"></i>
|
<i class="fas fa-folder-open"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3>Nothing here yet</h3>
|
<h3>Nothing here yet</h3>
|
||||||
<p id="emptyMsg">Go watch some videos to fill this up!</p>
|
<p id="emptyMsg">Go watch some videos to fill this up!</p>
|
||||||
<a href="/" class="browse-btn">
|
<a href="/" class="browse-btn">
|
||||||
<i class="fas fa-play"></i>
|
<i class="fas fa-play"></i>
|
||||||
Browse Content
|
Browse Content
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Load library content - extracted to function for reuse on pageshow
|
// Load library content - extracted to function for reuse on pageshow
|
||||||
function loadLibraryContent() {
|
function loadLibraryContent() {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
// Default to history if no type or invalid type
|
// Default to history if no type or invalid type
|
||||||
const type = urlParams.get('type') || 'history';
|
const type = urlParams.get('type') || 'history';
|
||||||
|
|
||||||
// Reset all tabs first, then activate the correct one
|
// Reset all tabs first, then activate the correct one
|
||||||
document.querySelectorAll('.library-tab').forEach(tab => tab.classList.remove('active'));
|
document.querySelectorAll('.library-tab').forEach(tab => tab.classList.remove('active'));
|
||||||
const activeTab = document.getElementById(`tab-${type}`);
|
const activeTab = document.getElementById(`tab-${type}`);
|
||||||
if (activeTab) {
|
if (activeTab) {
|
||||||
activeTab.classList.add('active');
|
activeTab.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
const grid = document.getElementById('libraryGrid');
|
const grid = document.getElementById('libraryGrid');
|
||||||
const empty = document.getElementById('emptyState');
|
const empty = document.getElementById('emptyState');
|
||||||
const emptyMsg = document.getElementById('emptyMsg');
|
const emptyMsg = document.getElementById('emptyMsg');
|
||||||
const statsDiv = document.getElementById('libraryStats');
|
const statsDiv = document.getElementById('libraryStats');
|
||||||
const clearBtn = document.getElementById('clearBtn');
|
const clearBtn = document.getElementById('clearBtn');
|
||||||
|
|
||||||
// Reset UI before loading
|
// Reset UI before loading
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
empty.style.display = 'none';
|
empty.style.display = 'none';
|
||||||
if (statsDiv) statsDiv.style.display = 'none';
|
if (statsDiv) statsDiv.style.display = 'none';
|
||||||
if (clearBtn) clearBtn.style.display = 'none';
|
if (clearBtn) clearBtn.style.display = 'none';
|
||||||
|
|
||||||
// Mapping URL type to localStorage key suffix
|
// Mapping URL type to localStorage key suffix
|
||||||
// saved -> kv_saved
|
// saved -> kv_saved
|
||||||
// history -> kv_history
|
// history -> kv_history
|
||||||
// subscriptions -> kv_subscriptions
|
// subscriptions -> kv_subscriptions
|
||||||
const storageKey = `kv_${type}`;
|
const storageKey = `kv_${type}`;
|
||||||
const data = JSON.parse(localStorage.getItem(storageKey) || '[]').filter(i => i && i.id);
|
const data = JSON.parse(localStorage.getItem(storageKey) || '[]').filter(i => i && i.id);
|
||||||
|
|
||||||
// Show stats and Clear Button if there is data
|
// Show stats and Clear Button if there is data
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
empty.style.display = 'none';
|
empty.style.display = 'none';
|
||||||
|
|
||||||
// Update stats
|
// Update stats
|
||||||
const videoCount = document.getElementById('videoCount');
|
const videoCount = document.getElementById('videoCount');
|
||||||
if (statsDiv && videoCount) {
|
if (statsDiv && videoCount) {
|
||||||
statsDiv.style.display = 'flex';
|
statsDiv.style.display = 'flex';
|
||||||
const countText = type === 'subscriptions'
|
const countText = type === 'subscriptions'
|
||||||
? `${data.length} channel${data.length !== 1 ? 's' : ''}`
|
? `${data.length} channel${data.length !== 1 ? 's' : ''}`
|
||||||
: `${data.length} video${data.length !== 1 ? 's' : ''}`;
|
: `${data.length} video${data.length !== 1 ? 's' : ''}`;
|
||||||
videoCount.innerText = countText;
|
videoCount.innerText = countText;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearTypeSpan = document.getElementById('clearType');
|
const clearTypeSpan = document.getElementById('clearType');
|
||||||
|
|
||||||
if (clearBtn) {
|
if (clearBtn) {
|
||||||
clearBtn.style.display = 'inline-flex';
|
clearBtn.style.display = 'inline-flex';
|
||||||
|
|
||||||
// Format type name for display
|
// Format type name for display
|
||||||
const typeName = type.charAt(0).toUpperCase() + type.slice(1);
|
const typeName = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
clearTypeSpan.innerText = typeName;
|
clearTypeSpan.innerText = typeName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'subscriptions') {
|
if (type === 'subscriptions') {
|
||||||
// Render Channel Cards with improved design
|
// Render Channel Cards with improved design
|
||||||
grid.style.display = 'grid';
|
grid.style.display = 'grid';
|
||||||
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
|
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
|
||||||
grid.style.gap = '24px';
|
grid.style.gap = '24px';
|
||||||
grid.style.padding = '20px 0';
|
grid.style.padding = '20px 0';
|
||||||
|
|
||||||
grid.innerHTML = data.map(channel => {
|
grid.innerHTML = data.map(channel => {
|
||||||
const avatarHtml = channel.thumbnail
|
const avatarHtml = channel.thumbnail
|
||||||
? `<img src="${channel.thumbnail}" style="width:120px; height:120px; border-radius:50%; object-fit:cover; border: 3px solid var(--yt-border); transition: transform 0.3s, border-color 0.3s;">`
|
? `<img src="${channel.thumbnail}" style="width:120px; height:120px; border-radius:50%; object-fit:cover; border: 3px solid var(--yt-border); transition: transform 0.3s, border-color 0.3s;">`
|
||||||
: `<div style="width:120px; height:120px; border-radius:50%; background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%); display:flex; align-items:center; justify-content:center; font-size:48px; font-weight:bold; color:white; border: 3px solid var(--yt-border); transition: transform 0.3s;">${channel.letter || channel.title.charAt(0).toUpperCase()}</div>`;
|
: `<div style="width:120px; height:120px; border-radius:50%; background: linear-gradient(135deg, #FF6B6B 0%, #d62d2d 100%); display:flex; align-items:center; justify-content:center; font-size:48px; font-weight:bold; color:white; border: 3px solid var(--yt-border); transition: transform 0.3s;">${channel.letter || channel.title.charAt(0).toUpperCase()}</div>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="subscription-card" onclick="window.location.href='/channel/${channel.id}'"
|
<div class="subscription-card" onclick="window.location.href='/channel/${channel.id}'"
|
||||||
style="text-align:center; cursor:pointer; padding: 24px 16px; background: var(--yt-bg-secondary); border-radius: 16px; transition: all 0.3s; border: 1px solid transparent;"
|
style="text-align:center; cursor:pointer; padding: 24px 16px; background: var(--yt-bg-secondary); border-radius: 16px; transition: all 0.3s; border: 1px solid transparent;"
|
||||||
onmouseenter="this.style.transform='translateY(-4px)'; this.style.boxShadow='0 8px 24px rgba(0,0,0,0.3)'; this.style.borderColor='var(--yt-border)';"
|
onmouseenter="this.style.transform='translateY(-4px)'; this.style.boxShadow='0 8px 24px rgba(0,0,0,0.3)'; this.style.borderColor='var(--yt-border)';"
|
||||||
onmouseleave="this.style.transform='none'; this.style.boxShadow='none'; this.style.borderColor='transparent';">
|
onmouseleave="this.style.transform='none'; this.style.boxShadow='none'; this.style.borderColor='transparent';">
|
||||||
<div style="display:flex; justify-content:center; margin-bottom:16px;">
|
<div style="display:flex; justify-content:center; margin-bottom:16px;">
|
||||||
${avatarHtml}
|
${avatarHtml}
|
||||||
</div>
|
</div>
|
||||||
<h3 style="font-size:1.1rem; margin-bottom:8px; color: var(--yt-text-primary); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${channel.title}</h3>
|
<h3 style="font-size:1.1rem; margin-bottom:8px; color: var(--yt-text-primary); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${channel.title}</h3>
|
||||||
<p style="font-size: 0.85rem; color: var(--yt-text-secondary); margin-bottom: 12px;">@${channel.title.replace(/\s+/g, '')}</p>
|
<p style="font-size: 0.85rem; color: var(--yt-text-secondary); margin-bottom: 12px;">@${channel.title.replace(/\s+/g, '')}</p>
|
||||||
<button onclick="event.stopPropagation(); toggleSubscribe('${channel.id}', '${channel.title.replace(/'/g, "\\'")}', '${channel.thumbnail || ''}', this)"
|
<button onclick="event.stopPropagation(); toggleSubscribe('${channel.id}', '${channel.title.replace(/'/g, "\\'")}', '${channel.thumbnail || ''}', this)"
|
||||||
style="padding:10px 20px; font-size:13px; background: linear-gradient(135deg, #cc0000, #ff4444); color: white; border: none; border-radius: 24px; cursor: pointer; font-weight: 600; transition: all 0.2s; box-shadow: 0 2px 8px rgba(204,0,0,0.3);"
|
style="padding:10px 20px; font-size:13px; background: linear-gradient(135deg, #cc0000, #ff4444); color: white; border: none; border-radius: 24px; cursor: pointer; font-weight: 600; transition: all 0.2s; box-shadow: 0 2px 8px rgba(204,0,0,0.3);"
|
||||||
onmouseenter="this.style.transform='scale(1.05)'; this.style.boxShadow='0 4px 12px rgba(204,0,0,0.5)';"
|
onmouseenter="this.style.transform='scale(1.05)'; this.style.boxShadow='0 4px 12px rgba(204,0,0,0.5)';"
|
||||||
onmouseleave="this.style.transform='scale(1)'; this.style.boxShadow='0 2px 8px rgba(204,0,0,0.3)';">
|
onmouseleave="this.style.transform='scale(1)'; this.style.boxShadow='0 2px 8px rgba(204,0,0,0.3)';">
|
||||||
<i class="fas fa-user-minus"></i> Unsubscribe
|
<i class="fas fa-user-minus"></i> Unsubscribe
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`}).join('');
|
`}).join('');
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Render Video Cards (History/Saved)
|
// Render Video Cards (History/Saved)
|
||||||
grid.innerHTML = data.map(video => {
|
grid.innerHTML = data.map(video => {
|
||||||
// Robust fallback chain: maxres -> hq -> mq
|
// Robust fallback chain: maxres -> hq -> mq
|
||||||
const thumb = video.thumbnail || `https://i.ytimg.com/vi/${video.id}/maxresdefault.jpg`;
|
const thumb = video.thumbnail || `https://i.ytimg.com/vi/${video.id}/maxresdefault.jpg`;
|
||||||
const showRemove = type === 'saved' || type === 'history';
|
const showRemove = type === 'saved' || type === 'history';
|
||||||
return `
|
return `
|
||||||
<div class="yt-video-card" style="position: relative;">
|
<div class="yt-video-card" style="position: relative;">
|
||||||
<div onclick="window.location.href='/watch?v=${video.id}'" style="cursor: pointer;">
|
<div onclick="window.location.href='/watch?v=${video.id}'" style="cursor: pointer;">
|
||||||
<div class="yt-thumbnail-container">
|
<div class="yt-thumbnail-container">
|
||||||
<img src="${thumb}" class="yt-thumbnail" loading="lazy" referrerpolicy="no-referrer"
|
<img src="${thumb}" class="yt-thumbnail" loading="lazy" referrerpolicy="no-referrer"
|
||||||
onload="this.classList.add('loaded')"
|
onload="this.classList.add('loaded')"
|
||||||
onerror="
|
onerror="
|
||||||
if (this.src.includes('maxresdefault')) this.src='https://i.ytimg.com/vi/${video.id}/hqdefault.jpg';
|
if (this.src.includes('maxresdefault')) this.src='https://i.ytimg.com/vi/${video.id}/hqdefault.jpg';
|
||||||
else if (this.src.includes('hqdefault')) this.src='https://i.ytimg.com/vi/${video.id}/mqdefault.jpg';
|
else if (this.src.includes('hqdefault')) this.src='https://i.ytimg.com/vi/${video.id}/mqdefault.jpg';
|
||||||
else this.style.display='none';
|
else this.style.display='none';
|
||||||
">
|
">
|
||||||
<div class="yt-duration">${video.duration || ''}</div>
|
<div class="yt-duration">${video.duration || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-video-details">
|
<div class="yt-video-details">
|
||||||
<div class="yt-video-meta">
|
<div class="yt-video-meta">
|
||||||
<h3 class="yt-video-title">${video.title}</h3>
|
<h3 class="yt-video-title">${video.title}</h3>
|
||||||
<p class="yt-video-stats">${video.uploader}</p>
|
<p class="yt-video-stats">${video.uploader}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${showRemove ? `
|
${showRemove ? `
|
||||||
<button onclick="event.stopPropagation(); removeVideo('${video.id}', '${type}', this)"
|
<button onclick="event.stopPropagation(); removeVideo('${video.id}', '${type}', this)"
|
||||||
style="position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; background: rgba(0,0,0,0.7); color: white; border: none; border-radius: 50%; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: all 0.2s; z-index: 10;"
|
style="position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; background: rgba(0,0,0,0.7); color: white; border: none; border-radius: 50%; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: all 0.2s; z-index: 10;"
|
||||||
onmouseenter="this.style.opacity='1'; this.style.background='#cc0000';"
|
onmouseenter="this.style.opacity='1'; this.style.background='#cc0000';"
|
||||||
onmouseleave="this.style.opacity='0.8'; this.style.background='rgba(0,0,0,0.7)';"
|
onmouseleave="this.style.opacity='0.8'; this.style.background='rgba(0,0,0,0.7)';"
|
||||||
title="Remove">
|
title="Remove">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`}).join('');
|
`}).join('');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
empty.style.display = 'block';
|
empty.style.display = 'block';
|
||||||
if (type === 'subscriptions') {
|
if (type === 'subscriptions') {
|
||||||
emptyMsg.innerText = "You haven't subscribed to any channels yet.";
|
emptyMsg.innerText = "You haven't subscribed to any channels yet.";
|
||||||
} else if (type === 'saved') {
|
} else if (type === 'saved') {
|
||||||
emptyMsg.innerText = "No saved videos yet.";
|
emptyMsg.innerText = "No saved videos yet.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run on initial page load and SPA navigation
|
// Run on initial page load and SPA navigation
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadLibraryContent();
|
loadLibraryContent();
|
||||||
initTabs();
|
initTabs();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Document already loaded (SPA navigation)
|
// Document already loaded (SPA navigation)
|
||||||
loadLibraryContent();
|
loadLibraryContent();
|
||||||
initTabs();
|
initTabs();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTabs() {
|
function initTabs() {
|
||||||
// Intercept tab clicks for client-side navigation
|
// Intercept tab clicks for client-side navigation
|
||||||
document.querySelectorAll('.library-tab').forEach(tab => {
|
document.querySelectorAll('.library-tab').forEach(tab => {
|
||||||
// Remove old listeners to be safe (optional but good practice in SPA)
|
// Remove old listeners to be safe (optional but good practice in SPA)
|
||||||
const newTab = tab.cloneNode(true);
|
const newTab = tab.cloneNode(true);
|
||||||
tab.parentNode.replaceChild(newTab, tab);
|
tab.parentNode.replaceChild(newTab, tab);
|
||||||
|
|
||||||
newTab.addEventListener('click', (e) => {
|
newTab.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const newUrl = newTab.getAttribute('href');
|
const newUrl = newTab.getAttribute('href');
|
||||||
// Update URL without reloading
|
// Update URL without reloading
|
||||||
history.pushState(null, '', newUrl);
|
history.pushState(null, '', newUrl);
|
||||||
// Immediately load the new content
|
// Immediately load the new content
|
||||||
loadLibraryContent();
|
loadLibraryContent();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle browser back/forward buttons
|
// Handle browser back/forward buttons
|
||||||
window.addEventListener('popstate', () => {
|
window.addEventListener('popstate', () => {
|
||||||
loadLibraryContent();
|
loadLibraryContent();
|
||||||
});
|
});
|
||||||
|
|
||||||
function clearLibrary() {
|
function clearLibrary() {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const type = urlParams.get('type') || 'history';
|
const type = urlParams.get('type') || 'history';
|
||||||
const typeName = type.charAt(0).toUpperCase() + type.slice(1);
|
const typeName = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
|
||||||
if (confirm(`Are you sure you want to clear your ${typeName}? This cannot be undone.`)) {
|
if (confirm(`Are you sure you want to clear your ${typeName}? This cannot be undone.`)) {
|
||||||
const storageKey = `kv_${type}`;
|
const storageKey = `kv_${type}`;
|
||||||
localStorage.removeItem(storageKey);
|
localStorage.removeItem(storageKey);
|
||||||
// Reload to reflect changes
|
// Reload to reflect changes
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local toggleSubscribe for my_videos page - removes card visually
|
// Local toggleSubscribe for my_videos page - removes card visually
|
||||||
function toggleSubscribe(channelId, channelName, avatar, btnElement) {
|
function toggleSubscribe(channelId, channelName, avatar, btnElement) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
// Remove from library
|
// Remove from library
|
||||||
const key = 'kv_subscriptions';
|
const key = 'kv_subscriptions';
|
||||||
let data = JSON.parse(localStorage.getItem(key) || '[]');
|
let data = JSON.parse(localStorage.getItem(key) || '[]');
|
||||||
data = data.filter(item => item.id !== channelId);
|
data = data.filter(item => item.id !== channelId);
|
||||||
localStorage.setItem(key, JSON.stringify(data));
|
localStorage.setItem(key, JSON.stringify(data));
|
||||||
|
|
||||||
// Remove the card from UI
|
// Remove the card from UI
|
||||||
const card = btnElement.closest('.yt-channel-card');
|
const card = btnElement.closest('.yt-channel-card');
|
||||||
if (card) {
|
if (card) {
|
||||||
card.style.transition = 'opacity 0.3s, transform 0.3s';
|
card.style.transition = 'opacity 0.3s, transform 0.3s';
|
||||||
card.style.opacity = '0';
|
card.style.opacity = '0';
|
||||||
card.style.transform = 'scale(0.8)';
|
card.style.transform = 'scale(0.8)';
|
||||||
setTimeout(() => card.remove(), 300);
|
setTimeout(() => card.remove(), 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show empty state if no more subscriptions
|
// Show empty state if no more subscriptions
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const grid = document.getElementById('libraryGrid');
|
const grid = document.getElementById('libraryGrid');
|
||||||
if (grid && grid.children.length === 0) {
|
if (grid && grid.children.length === 0) {
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
document.getElementById('emptyState').style.display = 'block';
|
document.getElementById('emptyState').style.display = 'block';
|
||||||
document.getElementById('emptyMessage').innerText = "You haven't subscribed to any channels yet.";
|
document.getElementById('emptyMessage').innerText = "You haven't subscribed to any channels yet.";
|
||||||
}
|
}
|
||||||
}, 350);
|
}, 350);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove individual video from saved/history
|
// Remove individual video from saved/history
|
||||||
function removeVideo(videoId, type, btnElement) {
|
function removeVideo(videoId, type, btnElement) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
const key = `kv_${type}`;
|
const key = `kv_${type}`;
|
||||||
let data = JSON.parse(localStorage.getItem(key) || '[]');
|
let data = JSON.parse(localStorage.getItem(key) || '[]');
|
||||||
data = data.filter(item => item.id !== videoId);
|
data = data.filter(item => item.id !== videoId);
|
||||||
localStorage.setItem(key, JSON.stringify(data));
|
localStorage.setItem(key, JSON.stringify(data));
|
||||||
|
|
||||||
// Remove the card from UI with animation
|
// Remove the card from UI with animation
|
||||||
const card = btnElement.closest('.yt-video-card');
|
const card = btnElement.closest('.yt-video-card');
|
||||||
if (card) {
|
if (card) {
|
||||||
card.style.transition = 'opacity 0.3s, transform 0.3s';
|
card.style.transition = 'opacity 0.3s, transform 0.3s';
|
||||||
card.style.opacity = '0';
|
card.style.opacity = '0';
|
||||||
card.style.transform = 'scale(0.9)';
|
card.style.transform = 'scale(0.9)';
|
||||||
setTimeout(() => card.remove(), 300);
|
setTimeout(() => card.remove(), 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show empty state if no more videos
|
// Show empty state if no more videos
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const grid = document.getElementById('libraryGrid');
|
const grid = document.getElementById('libraryGrid');
|
||||||
if (grid && grid.children.length === 0) {
|
if (grid && grid.children.length === 0) {
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
document.getElementById('emptyState').style.display = 'block';
|
document.getElementById('emptyState').style.display = 'block';
|
||||||
const typeName = type === 'saved' ? 'No saved videos yet.' : 'No history yet.';
|
const typeName = type === 'saved' ? 'No saved videos yet.' : 'No history yet.';
|
||||||
document.getElementById('emptyMessage').innerText = typeName;
|
document.getElementById('emptyMessage').innerText = typeName;
|
||||||
}
|
}
|
||||||
}, 350);
|
}, 350);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
422
templates/register.html
Normal file → Executable file
422
templates/register.html
Normal file → Executable file
|
|
@ -1,212 +1,212 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="yt-auth-container">
|
<div class="yt-auth-container">
|
||||||
<div class="yt-auth-card">
|
<div class="yt-auth-card">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="yt-auth-logo">
|
<div class="yt-auth-logo">
|
||||||
<div class="yt-logo-icon" style="font-size: 24px;">KV-Tube</div>
|
<div class="yt-logo-icon" style="font-size: 24px;">KV-Tube</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Create account</h2>
|
<h2>Create account</h2>
|
||||||
<p>to start watching on KV-Tube</p>
|
<p>to start watching on KV-Tube</p>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages() %}
|
{% with messages = get_flashed_messages() %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<div class="yt-auth-alert">
|
<div class="yt-auth-alert">
|
||||||
<i class="fas fa-exclamation-circle"></i>
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
{{ messages[0] }}
|
{{ messages[0] }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<form method="POST" action="/register" class="yt-auth-form">
|
<form method="POST" action="/register" class="yt-auth-form">
|
||||||
<div class="yt-form-group">
|
<div class="yt-form-group">
|
||||||
<input type="text" name="username" class="yt-form-input" placeholder=" " required id="username">
|
<input type="text" name="username" class="yt-form-input" placeholder=" " required id="username">
|
||||||
<label for="username" class="yt-form-label">Username</label>
|
<label for="username" class="yt-form-label">Username</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="yt-form-group">
|
<div class="yt-form-group">
|
||||||
<input type="password" name="password" class="yt-form-input" placeholder=" " required id="password">
|
<input type="password" name="password" class="yt-form-input" placeholder=" " required id="password">
|
||||||
<label for="password" class="yt-form-label">Password</label>
|
<label for="password" class="yt-form-label">Password</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="yt-auth-submit">
|
<button type="submit" class="yt-auth-submit">
|
||||||
<i class="fas fa-user-plus"></i>
|
<i class="fas fa-user-plus"></i>
|
||||||
Create Account
|
Create Account
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="yt-auth-divider">
|
<div class="yt-auth-divider">
|
||||||
<span>or</span>
|
<span>or</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="yt-auth-footer">
|
<p class="yt-auth-footer">
|
||||||
Already have an account?
|
Already have an account?
|
||||||
<a href="/login" class="yt-auth-link">Sign in</a>
|
<a href="/login" class="yt-auth-link">Sign in</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.yt-auth-container {
|
.yt-auth-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: calc(100vh - var(--yt-header-height) - 100px);
|
min-height: calc(100vh - var(--yt-header-height) - 100px);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-card {
|
.yt-auth-card {
|
||||||
background: var(--yt-bg-secondary);
|
background: var(--yt-bg-secondary);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 48px 40px;
|
padding: 48px 40px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1px solid var(--yt-border);
|
border: 1px solid var(--yt-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-logo {
|
.yt-auth-logo {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-card h2 {
|
.yt-auth-card h2 {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-card>p {
|
.yt-auth-card>p {
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-alert {
|
.yt-auth-alert {
|
||||||
background: rgba(244, 67, 54, 0.1);
|
background: rgba(244, 67, 54, 0.1);
|
||||||
color: #f44336;
|
color: #f44336;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-form {
|
.yt-auth-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-group {
|
.yt-form-group {
|
||||||
position: relative;
|
position: relative;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-input {
|
.yt-form-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 16px 14px;
|
padding: 16px 14px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
background: var(--yt-bg-primary);
|
background: var(--yt-bg-primary);
|
||||||
border: 1px solid var(--yt-border);
|
border: 1px solid var(--yt-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-input:focus {
|
.yt-form-input:focus {
|
||||||
border-color: var(--yt-accent-blue);
|
border-color: var(--yt-accent-blue);
|
||||||
box-shadow: 0 0 0 3px rgba(62, 166, 255, 0.2);
|
box-shadow: 0 0 0 3px rgba(62, 166, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-label {
|
.yt-form-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 14px;
|
left: 14px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
background: var(--yt-bg-primary);
|
background: var(--yt-bg-primary);
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-input:focus+.yt-form-label,
|
.yt-form-input:focus+.yt-form-label,
|
||||||
.yt-form-input:not(:placeholder-shown)+.yt-form-label {
|
.yt-form-input:not(:placeholder-shown)+.yt-form-label {
|
||||||
top: 0;
|
top: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--yt-accent-blue);
|
color: var(--yt-accent-blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-submit {
|
.yt-auth-submit {
|
||||||
background: var(--yt-accent-blue);
|
background: var(--yt-accent-blue);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 14px 24px;
|
padding: 14px 24px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
transition: background 0.2s, transform 0.1s;
|
transition: background 0.2s, transform 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-submit:hover {
|
.yt-auth-submit:hover {
|
||||||
background: #258fd9;
|
background: #258fd9;
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-submit:active {
|
.yt-auth-submit:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-divider {
|
.yt-auth-divider {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 24px 0;
|
margin: 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-divider::before,
|
.yt-auth-divider::before,
|
||||||
.yt-auth-divider::after {
|
.yt-auth-divider::after {
|
||||||
content: '';
|
content: '';
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--yt-border);
|
background: var(--yt-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-divider span {
|
.yt-auth-divider span {
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-footer {
|
.yt-auth-footer {
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-link {
|
.yt-auth-link {
|
||||||
color: var(--yt-accent-blue);
|
color: var(--yt-accent-blue);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-auth-link:hover {
|
.yt-auth-link:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.yt-auth-card {
|
.yt-auth-card {
|
||||||
padding: 32px 24px;
|
padding: 32px 24px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
708
templates/settings.html
Normal file → Executable file
708
templates/settings.html
Normal file → Executable file
|
|
@ -1,355 +1,355 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="yt-settings-container">
|
<div class="yt-settings-container">
|
||||||
<h2 class="yt-settings-title">Settings</h2>
|
<h2 class="yt-settings-title">Settings</h2>
|
||||||
|
|
||||||
<!-- Appearance & Playback in one card -->
|
<!-- Appearance & Playback in one card -->
|
||||||
<div class="yt-settings-card compact">
|
<div class="yt-settings-card compact">
|
||||||
<div class="yt-setting-row">
|
<div class="yt-setting-row">
|
||||||
<span class="yt-setting-label">Theme</span>
|
<span class="yt-setting-label">Theme</span>
|
||||||
<div class="yt-toggle-group">
|
<div class="yt-toggle-group">
|
||||||
<button type="button" class="yt-toggle-btn" id="themeBtnLight"
|
<button type="button" class="yt-toggle-btn" id="themeBtnLight"
|
||||||
onclick="setTheme('light')">Light</button>
|
onclick="setTheme('light')">Light</button>
|
||||||
<button type="button" class="yt-toggle-btn" id="themeBtnDark" onclick="setTheme('dark')">Dark</button>
|
<button type="button" class="yt-toggle-btn" id="themeBtnDark" onclick="setTheme('dark')">Dark</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="yt-setting-row">
|
<div class="yt-setting-row">
|
||||||
<span class="yt-setting-label">Player</span>
|
<span class="yt-setting-label">Player</span>
|
||||||
<div class="yt-toggle-group">
|
<div class="yt-toggle-group">
|
||||||
<button type="button" class="yt-toggle-btn" id="playerBtnArt"
|
<button type="button" class="yt-toggle-btn" id="playerBtnArt"
|
||||||
onclick="setPlayerPref('artplayer')">Artplayer</button>
|
onclick="setPlayerPref('artplayer')">Artplayer</button>
|
||||||
<button type="button" class="yt-toggle-btn" id="playerBtnNative"
|
<button type="button" class="yt-toggle-btn" id="playerBtnNative"
|
||||||
onclick="setPlayerPref('native')">Native</button>
|
onclick="setPlayerPref('native')">Native</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System Updates -->
|
<!-- System Updates -->
|
||||||
<div class="yt-settings-card">
|
<div class="yt-settings-card">
|
||||||
<h3>System Updates</h3>
|
<h3>System Updates</h3>
|
||||||
|
|
||||||
<!-- yt-dlp Stable -->
|
<!-- yt-dlp Stable -->
|
||||||
<div class="yt-update-row">
|
<div class="yt-update-row">
|
||||||
<div class="yt-update-info">
|
<div class="yt-update-info">
|
||||||
<strong>yt-dlp</strong>
|
<strong>yt-dlp</strong>
|
||||||
<span class="yt-update-version" id="ytdlpVersion">Stable</span>
|
<span class="yt-update-version" id="ytdlpVersion">Stable</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="updateYtdlpStable" onclick="updatePackage('ytdlp', 'stable')" class="yt-update-btn small">
|
<button id="updateYtdlpStable" onclick="updatePackage('ytdlp', 'stable')" class="yt-update-btn small">
|
||||||
<i class="fas fa-sync-alt"></i> Update
|
<i class="fas fa-sync-alt"></i> Update
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- yt-dlp Nightly -->
|
<!-- yt-dlp Nightly -->
|
||||||
<div class="yt-update-row">
|
<div class="yt-update-row">
|
||||||
<div class="yt-update-info">
|
<div class="yt-update-info">
|
||||||
<strong>yt-dlp Nightly</strong>
|
<strong>yt-dlp Nightly</strong>
|
||||||
<span class="yt-update-version">Experimental</span>
|
<span class="yt-update-version">Experimental</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="updateYtdlpNightly" onclick="updatePackage('ytdlp', 'nightly')"
|
<button id="updateYtdlpNightly" onclick="updatePackage('ytdlp', 'nightly')"
|
||||||
class="yt-update-btn small nightly">
|
class="yt-update-btn small nightly">
|
||||||
<i class="fas fa-flask"></i> Install
|
<i class="fas fa-flask"></i> Install
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ytfetcher -->
|
<!-- ytfetcher -->
|
||||||
<div class="yt-update-row">
|
<div class="yt-update-row">
|
||||||
<div class="yt-update-info">
|
<div class="yt-update-info">
|
||||||
<strong>ytfetcher</strong>
|
<strong>ytfetcher</strong>
|
||||||
<span class="yt-update-version" id="ytfetcherVersion">CC & Transcripts</span>
|
<span class="yt-update-version" id="ytfetcherVersion">CC & Transcripts</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="updateYtfetcher" onclick="updatePackage('ytfetcher', 'latest')" class="yt-update-btn small">
|
<button id="updateYtfetcher" onclick="updatePackage('ytfetcher', 'latest')" class="yt-update-btn small">
|
||||||
<i class="fas fa-sync-alt"></i> Update
|
<i class="fas fa-sync-alt"></i> Update
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="updateStatus" class="yt-update-status"></div>
|
<div id="updateStatus" class="yt-update-status"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if session.get('user_id') %}
|
{% if session.get('user_id') %}
|
||||||
<div class="yt-settings-card compact">
|
<div class="yt-settings-card compact">
|
||||||
<div class="yt-setting-row">
|
<div class="yt-setting-row">
|
||||||
<span class="yt-setting-label">Display Name</span>
|
<span class="yt-setting-label">Display Name</span>
|
||||||
<form id="profileForm" onsubmit="updateProfile(event)"
|
<form id="profileForm" onsubmit="updateProfile(event)"
|
||||||
style="display: flex; gap: 8px; flex: 1; max-width: 300px;">
|
style="display: flex; gap: 8px; flex: 1; max-width: 300px;">
|
||||||
<input type="text" class="yt-form-input" id="displayName" value="{{ session.username }}" required
|
<input type="text" class="yt-form-input" id="displayName" value="{{ session.username }}" required
|
||||||
style="flex: 1;">
|
style="flex: 1;">
|
||||||
<button type="submit" class="yt-update-btn small">Save</button>
|
<button type="submit" class="yt-update-btn small">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="yt-settings-card compact">
|
<div class="yt-settings-card compact">
|
||||||
<div class="yt-setting-row" style="justify-content: center;">
|
<div class="yt-setting-row" style="justify-content: center;">
|
||||||
<span class="yt-about-text">KV-Tube v1.0 • YouTube-like streaming</span>
|
<span class="yt-about-text">KV-Tube v1.0 • YouTube-like streaming</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.yt-settings-container {
|
.yt-settings-container {
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-settings-title {
|
.yt-settings-title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-settings-card {
|
.yt-settings-card {
|
||||||
background: var(--yt-bg-secondary);
|
background: var(--yt-bg-secondary);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-settings-card.compact {
|
.yt-settings-card.compact {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-settings-card h3 {
|
.yt-settings-card h3 {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-setting-row {
|
.yt-setting-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-setting-row:not(:last-child) {
|
.yt-setting-row:not(:last-child) {
|
||||||
border-bottom: 1px solid var(--yt-bg-hover);
|
border-bottom: 1px solid var(--yt-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-setting-label {
|
.yt-setting-label {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-toggle-group {
|
.yt-toggle-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--yt-bg-elevated);
|
background: var(--yt-bg-elevated);
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-toggle-btn {
|
.yt-toggle-btn {
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-toggle-btn:hover {
|
.yt-toggle-btn:hover {
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-toggle-btn.active {
|
.yt-toggle-btn.active {
|
||||||
background: var(--yt-accent-red);
|
background: var(--yt-accent-red);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-row {
|
.yt-update-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
border-bottom: 1px solid var(--yt-bg-hover);
|
border-bottom: 1px solid var(--yt-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-row:last-of-type {
|
.yt-update-row:last-of-type {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-info {
|
.yt-update-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-info strong {
|
.yt-update-info strong {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-version {
|
.yt-update-version {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-btn {
|
.yt-update-btn {
|
||||||
background: var(--yt-accent-red);
|
background: var(--yt-accent-red);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-btn.small {
|
.yt-update-btn.small {
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-btn.nightly {
|
.yt-update-btn.nightly {
|
||||||
background: #9c27b0;
|
background: #9c27b0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-btn:hover {
|
.yt-update-btn:hover {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-btn:disabled {
|
.yt-update-btn:disabled {
|
||||||
background: var(--yt-bg-hover) !important;
|
background: var(--yt-bg-hover) !important;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-update-status {
|
.yt-update-status {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-form-input {
|
.yt-form-input {
|
||||||
background: var(--yt-bg-elevated);
|
background: var(--yt-bg-elevated);
|
||||||
border: 1px solid var(--yt-bg-hover);
|
border: 1px solid var(--yt-bg-hover);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-about-text {
|
.yt-about-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--yt-text-secondary);
|
color: var(--yt-text-secondary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
async function fetchVersions() {
|
async function fetchVersions() {
|
||||||
const pkgs = ['ytdlp', 'ytfetcher'];
|
const pkgs = ['ytdlp', 'ytfetcher'];
|
||||||
for (const pkg of pkgs) {
|
for (const pkg of pkgs) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/package/version?package=${pkg}`);
|
const res = await fetch(`/api/package/version?package=${pkg}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
const el = document.getElementById(pkg === 'ytdlp' ? 'ytdlpVersion' : 'ytfetcherVersion');
|
const el = document.getElementById(pkg === 'ytdlp' ? 'ytdlpVersion' : 'ytfetcherVersion');
|
||||||
if (el) {
|
if (el) {
|
||||||
el.innerText = `Installed: ${data.version}`;
|
el.innerText = `Installed: ${data.version}`;
|
||||||
// Highlight if nightly
|
// Highlight if nightly
|
||||||
if (pkg === 'ytdlp' && (data.version.includes('2026') || data.version.includes('.dev'))) {
|
if (pkg === 'ytdlp' && (data.version.includes('2026') || data.version.includes('.dev'))) {
|
||||||
el.style.color = '#9c27b0';
|
el.style.color = '#9c27b0';
|
||||||
el.innerText += ' (Nightly)';
|
el.innerText += ' (Nightly)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updatePackage(pkg, version) {
|
async function updatePackage(pkg, version) {
|
||||||
const btnId = pkg === 'ytdlp' ?
|
const btnId = pkg === 'ytdlp' ?
|
||||||
(version === 'nightly' ? 'updateYtdlpNightly' : 'updateYtdlpStable') :
|
(version === 'nightly' ? 'updateYtdlpNightly' : 'updateYtdlpStable') :
|
||||||
'updateYtfetcher';
|
'updateYtfetcher';
|
||||||
const btn = document.getElementById(btnId);
|
const btn = document.getElementById(btnId);
|
||||||
const status = document.getElementById('updateStatus');
|
const status = document.getElementById('updateStatus');
|
||||||
const originalHTML = btn.innerHTML;
|
const originalHTML = btn.innerHTML;
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Updating...';
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Updating...';
|
||||||
status.style.color = 'var(--yt-text-secondary)';
|
status.style.color = 'var(--yt-text-secondary)';
|
||||||
status.innerText = `Updating ${pkg}${version === 'nightly' ? ' (nightly)' : ''}...`;
|
status.innerText = `Updating ${pkg}${version === 'nightly' ? ' (nightly)' : ''}...`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/update_package', {
|
const response = await fetch('/api/update_package', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ package: pkg, version: version })
|
body: JSON.stringify({ package: pkg, version: version })
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
status.style.color = '#4caf50';
|
status.style.color = '#4caf50';
|
||||||
status.innerText = '✓ ' + data.message;
|
status.innerText = '✓ ' + data.message;
|
||||||
btn.innerHTML = '<i class="fas fa-check"></i> Updated';
|
btn.innerHTML = '<i class="fas fa-check"></i> Updated';
|
||||||
// Refresh versions
|
// Refresh versions
|
||||||
setTimeout(fetchVersions, 1000);
|
setTimeout(fetchVersions, 1000);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.innerHTML = originalHTML;
|
btn.innerHTML = originalHTML;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else {
|
} else {
|
||||||
status.style.color = '#f44336';
|
status.style.color = '#f44336';
|
||||||
status.innerText = '✗ ' + data.message;
|
status.innerText = '✗ ' + data.message;
|
||||||
btn.innerHTML = originalHTML;
|
btn.innerHTML = originalHTML;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.style.color = '#f44336';
|
status.style.color = '#f44336';
|
||||||
status.innerText = '✗ Error: ' + e.message;
|
status.innerText = '✗ Error: ' + e.message;
|
||||||
btn.innerHTML = originalHTML;
|
btn.innerHTML = originalHTML;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Player Preference ---
|
// --- Player Preference ---
|
||||||
window.setPlayerPref = function (type) {
|
window.setPlayerPref = function (type) {
|
||||||
localStorage.setItem('kv_player_pref', type);
|
localStorage.setItem('kv_player_pref', type);
|
||||||
updatePlayerButtons(type);
|
updatePlayerButtons(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.updatePlayerButtons = function (type) {
|
window.updatePlayerButtons = function (type) {
|
||||||
const artBtn = document.getElementById('playerBtnArt');
|
const artBtn = document.getElementById('playerBtnArt');
|
||||||
const natBtn = document.getElementById('playerBtnNative');
|
const natBtn = document.getElementById('playerBtnNative');
|
||||||
if (artBtn) artBtn.classList.remove('active');
|
if (artBtn) artBtn.classList.remove('active');
|
||||||
if (natBtn) natBtn.classList.remove('active');
|
if (natBtn) natBtn.classList.remove('active');
|
||||||
if (type === 'native') {
|
if (type === 'native') {
|
||||||
if (natBtn) natBtn.classList.add('active');
|
if (natBtn) natBtn.classList.add('active');
|
||||||
} else {
|
} else {
|
||||||
if (artBtn) artBtn.classList.add('active');
|
if (artBtn) artBtn.classList.add('active');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Settings
|
// Initialize Settings
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Theme init
|
// Theme init
|
||||||
const currentTheme = localStorage.getItem('theme') || 'dark';
|
const currentTheme = localStorage.getItem('theme') || 'dark';
|
||||||
const lightBtn = document.getElementById('themeBtnLight');
|
const lightBtn = document.getElementById('themeBtnLight');
|
||||||
const darkBtn = document.getElementById('themeBtnDark');
|
const darkBtn = document.getElementById('themeBtnDark');
|
||||||
if (currentTheme === 'light') {
|
if (currentTheme === 'light') {
|
||||||
if (lightBtn) lightBtn.classList.add('active');
|
if (lightBtn) lightBtn.classList.add('active');
|
||||||
} else {
|
} else {
|
||||||
if (darkBtn) darkBtn.classList.add('active');
|
if (darkBtn) darkBtn.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Player init - default to artplayer
|
// Player init - default to artplayer
|
||||||
const playerPref = localStorage.getItem('kv_player_pref') || 'artplayer';
|
const playerPref = localStorage.getItem('kv_player_pref') || 'artplayer';
|
||||||
updatePlayerButtons(playerPref);
|
updatePlayerButtons(playerPref);
|
||||||
|
|
||||||
// Fetch versions
|
// Fetch versions
|
||||||
fetchVersions();
|
fetchVersions();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
4100
templates/watch.html
Normal file → Executable file
4100
templates/watch.html
Normal file → Executable file
File diff suppressed because it is too large
Load diff
0
tests/test_loader_integration.py
Normal file → Executable file
0
tests/test_loader_integration.py
Normal file → Executable file
0
tests/test_summarizer_logic.py
Normal file → Executable file
0
tests/test_summarizer_logic.py
Normal file → Executable file
1
tmp_media_roller_research
Submodule
1
tmp_media_roller_research
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 4b16bebf7d81925131001006231795f38538a928
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
FROM golang:1.25.3-alpine3.22 AS builder
|
|
||||||
|
|
||||||
RUN apk add --no-cache curl
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY src src
|
|
||||||
COPY templates templates
|
|
||||||
COPY go.mod go.mod
|
|
||||||
COPY go.sum go.sum
|
|
||||||
|
|
||||||
RUN go mod download
|
|
||||||
RUN go build -x -o media-roller ./src
|
|
||||||
|
|
||||||
# yt-dlp needs python
|
|
||||||
FROM python:3.13.7-alpine3.22
|
|
||||||
|
|
||||||
# This is where the downloaded files will be saved in the container.
|
|
||||||
ENV MR_DOWNLOAD_DIR="/download"
|
|
||||||
|
|
||||||
RUN apk add --update --no-cache \
|
|
||||||
# https://github.com/yt-dlp/yt-dlp/issues/14404 \
|
|
||||||
deno \
|
|
||||||
curl
|
|
||||||
|
|
||||||
# https://hub.docker.com/r/mwader/static-ffmpeg/tags
|
|
||||||
# https://github.com/wader/static-ffmpeg
|
|
||||||
COPY --from=mwader/static-ffmpeg:8.0 /ffmpeg /usr/local/bin/
|
|
||||||
COPY --from=mwader/static-ffmpeg:8.0 /ffprobe /usr/local/bin/
|
|
||||||
COPY --from=builder /app/media-roller /app/media-roller
|
|
||||||
COPY templates /app/templates
|
|
||||||
COPY static /app/static
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Get new releases here https://github.com/yt-dlp/yt-dlp/releases
|
|
||||||
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/download/2025.09.26/yt-dlp -o /usr/local/bin/yt-dlp && \
|
|
||||||
echo "9215a371883aea75f0f2102c679333d813d9a5c3bceca212879a4a741a5b4657 /usr/local/bin/yt-dlp" | sha256sum -c - && \
|
|
||||||
chmod a+rx /usr/local/bin/yt-dlp
|
|
||||||
|
|
||||||
RUN yt-dlp --update --update-to nightly
|
|
||||||
|
|
||||||
# Sanity check
|
|
||||||
RUN yt-dlp --version && \
|
|
||||||
ffmpeg -version
|
|
||||||
|
|
||||||
ENTRYPOINT ["/app/media-roller"]
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
# Media Roller
|
|
||||||
A mobile friendly tool for downloading videos from social media.
|
|
||||||
The backend is a Golang server that will take a URL (YouTube, Reddit, Twitter, etc),
|
|
||||||
download the video file, and return a URL to directly download the video. The video will be transcoded to produce a single mp4 file.
|
|
||||||
|
|
||||||
This is built on [yt-dlp](https://github.com/yt-dlp/yt-dlp). yt-dlp will auto update every 12 hours to make sure it's running the latest nightly build.
|
|
||||||
|
|
||||||
Note: This was written to run on a home network and should not be exposed to public traffic. There's no auth.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
# Running
|
|
||||||
Make sure you have [yt-dlp](https://github.com/yt-dlp/yt-dlp) and [FFmpeg](https://github.com/FFmpeg/FFmpeg) installed then pull the repo and run:
|
|
||||||
```bash
|
|
||||||
./run.sh
|
|
||||||
```
|
|
||||||
Or for docker locally:
|
|
||||||
```bash
|
|
||||||
./docker-build.sh
|
|
||||||
./docker-run.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
With Docker, published to both dockerhub and github.
|
|
||||||
* ghcr: `docker pull ghcr.io/rroller/media-roller:master`
|
|
||||||
* dockerhub: `docker pull ronnieroller/media-roller`
|
|
||||||
|
|
||||||
See:
|
|
||||||
* https://github.com/rroller/media-roller/pkgs/container/media-roller
|
|
||||||
* https://hub.docker.com/repository/docker/ronnieroller/media-roller
|
|
||||||
|
|
||||||
The files are saved to the /download directory which you can mount as needed.
|
|
||||||
|
|
||||||
## Docker Environemnt Variables
|
|
||||||
* `MR_DOWNLOAD_DIR` where videos are saved. Defaults to `/download`
|
|
||||||
* `MR_PROXY` will pass the value to yt-dlp witht he `--proxy` argument. Defaults to empty
|
|
||||||
|
|
||||||
# API
|
|
||||||
To download a video directly, use the API endpoint:
|
|
||||||
|
|
||||||
```
|
|
||||||
/api/download?url=SOME_URL
|
|
||||||
```
|
|
||||||
|
|
||||||
Create a bookmarklet, allowing one click downloads (From a PC):
|
|
||||||
|
|
||||||
```
|
|
||||||
javascript:(location.href="http://127.0.0.1:3000/fetch?url="+encodeURIComponent(location.href));
|
|
||||||
```
|
|
||||||
|
|
||||||
# Integrating with mobile
|
|
||||||
After you have your server up, install this shortcut. Update the endpoint to your server address by editing the shortcut before running it.
|
|
||||||
|
|
||||||
https://www.icloud.com/shortcuts/d3b05b78eb434496ab28dd91e1c79615
|
|
||||||
|
|
||||||
# Unraid
|
|
||||||
media-roller is available in Unraid and can be found on the "Apps" tab by searching its name.
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
go build -x -o media-roller ./src
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
docker build -f Dockerfile -t media-roller .
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
docker run -p 3000:3000 -v $(pwd)/download:/download media-roller
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
module media-roller
|
|
||||||
|
|
||||||
go 1.25.3
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/dustin/go-humanize v1.0.1
|
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
|
||||||
github.com/matishsiao/goInfo v0.0.0-20241216093258-66a9250504d6
|
|
||||||
github.com/rs/zerolog v1.34.0
|
|
||||||
golang.org/x/sync v0.17.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
golang.org/x/sys v0.37.0 // indirect
|
|
||||||
)
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
|
||||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
|
||||||
github.com/matishsiao/goInfo v0.0.0-20241216093258-66a9250504d6 h1:BIv50poKtm6s4vUlN6J2qAOARALk4ACAwM9VRmKPyiI=
|
|
||||||
github.com/matishsiao/goInfo v0.0.0-20241216093258-66a9250504d6/go.mod h1:aEt7p9Rvh67BYApmZwNDPpgircTO2kgdmDUoF/1QmwA=
|
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
|
||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
|
||||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
go run ./src
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue