chore: setup Dockerfiles and CI for Forgejo and Synology
Some checks failed
CI / lint (push) Failing after 6s
CI / test (push) Failing after 1s
Docker Build & Push / build (push) Failing after 1s
CI / build (push) Has been skipped

This commit is contained in:
KV-Tube Deployer 2026-02-22 17:29:42 +07:00
parent 249e4ca415
commit 95cfe06f2c
128 changed files with 14161 additions and 17161 deletions

View file

@ -1,12 +1,11 @@
# 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
# Server port (default: 8080)
PORT=8080
# Environment: development or production
FLASK_ENV=development
# Data directory for SQLite database
KVTUBE_DATA_DIR=./data
# Local video directory (optional)
KVTUBE_VIDEO_DIR=./videos
# Gin mode: debug or release
GIN_MODE=release

@ -1 +0,0 @@
Subproject commit 246c4c349d97205eb2b51d7d3999ea846f5b2bdc

105
.github/workflows/ci.yml vendored Executable file
View file

@ -0,0 +1,105 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff mypy bandit types-requests
pip install -r requirements.txt
- name: Run Ruff
run: ruff check . --output-format=github
- name: Run MyPy
run: mypy app/ config.py --ignore-missing-imports
continue-on-error: true
- name: Run Bandit
run: bandit -r app/ -x app/routes/api --skip B101,B311
continue-on-error: true
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov
- name: Run tests
run: pytest tests/ -v --tb=short
continue-on-error: true
build:
runs-on: ubuntu-latest
needs: [lint, test]
if: startsWith(github.ref, 'refs/tags/')
steps:
- 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
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log into Forgejo Registry
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: |
docker.io/${{ github.repository }}
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
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -5,12 +5,6 @@ on:
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
@ -28,41 +22,49 @@ jobs:
- 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
- name: Extract metadata (backend)
id: meta-backend
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
git.khoavo.myds.me/${{ github.repository }}
images: git.khoavo.myds.me/${{ github.repository }}-backend
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
- name: Build and push (backend)
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
context: ./backend
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata (frontend)
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: git.khoavo.myds.me/${{ github.repository }}-frontend
tags: |
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ github.ref == 'refs/tags/v*' }}
- name: Build and push (frontend)
uses: docker/build-push-action@v5
with:
context: ./frontend
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max

40
.gitignore vendored
View file

@ -1,12 +1,38 @@
# OS
.DS_Store
__pycache__/
*.pyc
venv/
.venv/
.venv_clean/
# Environment
.env
# Runtime data
data/
videos/
*.db
server.log
.ruff_cache/
logs/
*.pid
# Go
backend/kvtube-go
backend/bin/
*.exe
# Node
node_modules/
# Secrets - NEVER commit these
cookies.txt
*.pem
*.key
credentials.json
# IDE
.idea/
.vscode/
*.swp
# Debug files
*_debug.txt
# Temporary
tmp_*/
.gemini/

View file

@ -1,409 +0,0 @@
# KV-Tube API Documentation
## Base URL
```
http://127.0.0.1:5002
```
## Endpoints Overview
| Endpoint | Method | Status | Description |
|----------|--------|--------|-------------|
| `/` | GET | ✅ 200 | Homepage |
| `/watch?v={video_id}` | GET | ✅ 200 | Video player page |
| `/api/search?q={query}` | GET | ✅ 200 | Search videos |
| `/api/trending` | GET | ✅ 200 | Trending videos |
| `/api/get_stream_info?v={video_id}` | GET | ✅ 200 | Get video stream URL |
| `/api/transcript?v={video_id}` | GET | ✅ 200* | Get video transcript (rate limited) |
| `/api/summarize?v={video_id}` | GET | ✅ 200* | AI summary (rate limited) |
| `/api/history` | GET | ✅ 200 | Get watch history |
| `/api/suggested` | GET | ✅ 200 | Get suggested videos |
| `/api/related?v={video_id}` | GET | ✅ 200 | Get related videos |
| `/api/channel/videos?id={channel_id}` | GET | ✅ 200 | Get channel videos |
| `/api/download?v={video_id}` | GET | ✅ 200 | Get download URL |
| `/api/download/formats?v={video_id}` | GET | ✅ 200 | Get available formats |
| `/video_proxy?url={stream_url}` | GET | ✅ 200 | Proxy video stream |
| `/api/save_video` | POST | ✅ 200 | Save video to history |
| `/settings` | GET | ✅ 200 | Settings page |
| `/my-videos` | GET | ✅ 200 | User videos page |
*Rate limited by YouTube (429 errors expected)
---
## Detailed Endpoint Documentation
### 1. Search Videos
**Endpoint**: `GET /api/search?q={query}`
**Status**: ✅ Working
**Example Request**:
```bash
curl "http://127.0.0.1:5002/api/search?q=python%20tutorial"
```
**Example Response**:
```json
[
{
"id": "K5KVEU3aaeQ",
"title": "Python Full Course for Beginners",
"uploader": "Programming with Mosh",
"thumbnail": "https://i.ytimg.com/vi/K5KVEU3aaeQ/hqdefault.jpg",
"view_count": 4932307,
"duration": "2:02:21",
"upload_date": ""
}
]
```
---
### 2. Get Stream Info
**Endpoint**: `GET /api/get_stream_info?v={video_id}`
**Status**: ✅ Working
**Example Request**:
```bash
curl "http://127.0.0.1:5002/api/get_stream_info?v=dQw4w9WgXcQ"
```
**Example Response**:
```json
{
"original_url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/...",
"stream_url": "/video_proxy?url=...",
"title": "Rick Astley - Never Gonna Give You Up (Official Video)",
"description": "The official video for Never Gonna Give You Up...",
"uploader": "Rick Astley",
"channel_id": "UCuAXFkgsw1L7xaCfnd5JJOw",
"view_count": 1730702525,
"related": [
{
"id": "dQw4w9WgXcQ",
"title": "Rick Astley - Never Gonna Give You Up...",
"view_count": 1730702525
}
],
"subtitle_url": null
}
```
---
### 3. Get Trending Videos
**Endpoint**: `GET /api/trending`
**Status**: ✅ Working
**Example Request**:
```bash
curl "http://127.0.0.1:5002/api/trending"
```
**Example Response**:
```json
{
"data": [
{
"id": "discovery",
"title": "You Might Like",
"icon": "compass",
"videos": [
{
"id": "GKWrOLrp80c",
"title": "Best of: Space Exploration",
"uploader": "The History Guy",
"view_count": 205552,
"duration": "1:02:29"
}
]
}
]
}
```
---
### 4. Get Channel Videos
**Endpoint**: `GET /api/channel/videos?id={channel_id}`
**Status**: ✅ Working
**Supports**:
- Channel ID: `UCuAXFkgsw1L7xaCfnd5JJOw`
- Channel URL: `https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw`
- Channel Handle: `@ProgrammingWithMosh`
**Example Request**:
```bash
curl "http://127.0.0.1:5002/api/channel/videos?id=@ProgrammingWithMosh&limit=5"
```
**Example Response**:
```json
[
{
"id": "naNcmnKskUE",
"title": "Top 5 Programming Languages to Learn in 2026",
"uploader": "",
"channel_id": "@ProgrammingWithMosh",
"view_count": 149264,
"duration": "11:31",
"thumbnail": "https://i.ytimg.com/vi/naNcmnKskUE/mqdefault.jpg"
}
]
```
---
### 5. Get Download URL
**Endpoint**: `GET /api/download?v={video_id}`
**Status**: ✅ Working
**Example Request**:
```bash
curl "http://127.0.0.1:5002/api/download?v=dQw4w9WgXcQ"
```
**Example Response**:
```json
{
"url": "https://rr2---sn-8qj-nbo66.googlevideo.com/videoplayback?...",
"title": "Rick Astley - Never Gonna Give You Up (Official Video) (4K Remaster)",
"ext": "mp4"
}
```
---
### 6. Get Download Formats
**Endpoint**: `GET /api/download/formats?v={video_id}`
**Status**: ✅ Working
**Example Request**:
```bash
curl "http://127.0.0.1:5002/api/download/formats?v=dQw4w9WgXcQ"
```
**Example Response**:
```json
{
"success": true,
"video_id": "dQw4w9WgXcQ",
"title": "Rick Astley - Never Gonna Give You Up",
"duration": 213,
"formats": {
"video": [
{
"quality": "1080p",
"ext": "mp4",
"size": "226.1 MB",
"url": "...",
"type": "video"
}
],
"audio": [
{
"quality": "128kbps",
"ext": "mp3",
"size": "3.2 MB",
"url": "...",
"type": "audio"
}
]
}
}
```
---
### 7. Get Related Videos
**Endpoint**: `GET /api/related?v={video_id}&limit={count}`
**Status**: ✅ Working
**Example Request**:
```bash
curl "http://127.0.0.1:5002/api/related?v=dQw4w9WgXcQ&limit=5"
```
---
### 8. Get Suggested Videos
**Endpoint**: `GET /api/suggested`
**Status**: ✅ Working
Based on user's watch history.
---
### 9. Get Watch History
**Endpoint**: `GET /api/history`
**Status**: ✅ Working
**Example Request**:
```bash
curl "http://127.0.0.1:5002/api/history"
```
**Example Response**:
```json
[
{
"id": "dQw4w9WgXcQ",
"title": "Rick Astley - Never Gonna Give You Up",
"thumbnail": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg"
}
]
```
---
### 10. Video Proxy
**Endpoint**: `GET /video_proxy?url={stream_url}`
**Status**: ✅ Working
Proxies video streams to bypass CORS and enable seeking.
**Example Request**:
```bash
curl "http://127.0.0.1:5002/video_proxy?url=https://manifest.googlevideo.com/api/manifest/hls_playlist/..."
```
---
### 11. Get Transcript ⚠️ RATE LIMITED
**Endpoint**: `GET /api/transcript?v={video_id}`
**Status**: ⚠️ Working but YouTube rate limits (429)
**Example Request**:
```bash
curl "http://127.0.0.1:5002/api/transcript?v=dQw4w9WgXcQ"
```
**Example Response (Success)**:
```json
{
"success": true,
"video_id": "dQw4w9WgXcQ",
"transcript": [
{
"text": "Never gonna give you up",
"start": 0.0,
"duration": 2.5
}
],
"language": "en",
"is_generated": true,
"full_text": "Never gonna give you up..."
}
```
**Example Response (Rate Limited)**:
```json
{
"success": false,
"error": "Could not load transcript: 429 Client Error: Too Many Requests"
}
```
---
### 12. AI Summary ⚠️ RATE LIMITED
**Endpoint**: `GET /api/summarize?v={video_id}`
**Status**: ⚠️ Working but YouTube rate limits (429)
**Example Request**:
```bash
curl "http://127.0.0.1:5002/api/summarize?v=dQw4w9WgXcQ"
```
**Example Response**:
```json
{
"success": true,
"summary": "Rick Astley's official music video for Never Gonna Give You Up..."
}
```
---
## Rate Limiting
**Current Limits**:
- Search: 30 requests/minute
- Transcript: 10 requests/minute
- Channel Videos: 60 requests/minute
- Download: 20 requests/minute
**Note**: YouTube also imposes its own rate limits on transcript/summary requests.
---
## Error Codes
| Code | Meaning | Solution |
|------|---------|----------|
| 200 | Success | - |
| 400 | Bad Request | Check parameters |
| 404 | Not Found | Verify video ID |
| 429 | Rate Limited | Wait before retrying |
| 500 | Server Error | Check server logs |
---
## Testing Commands
```bash
# Homepage
curl http://127.0.0.1:5002/
# Search
curl "http://127.0.0.1:5002/api/search?q=python"
# Get stream
curl "http://127.0.0.1:5002/api/get_stream_info?v=dQw4w9WgXcQ"
# Get download URL
curl "http://127.0.0.1:5002/api/download?v=dQw4w9WgXcQ"
# Get channel videos
curl "http://127.0.0.1:5002/api/channel/videos?id=UCuAXFkgsw1L7xaCfnd5JJOw"
# Get trending
curl http://127.0.0.1:5002/api/trending
# Get history
curl http://127.0.0.1:5002/api/history
```
---
## Server Information
- **URL**: http://127.0.0.1:5002
- **Port**: 5002
- **Mode**: Development (Debug enabled)
- **Python**: 3.12.9
- **Framework**: Flask 3.0.2
- **Rate Limiting**: Flask-Limiter enabled
---
## Known Issues
1. **Transcript API (429)**: YouTube rate limits transcript requests
- Status: Expected behavior
- Resolution: Wait 1-24 hours or use VPN
- Frontend handles gracefully with user messages
2. **CORS Errors**: Direct YouTube API calls blocked
- Status: Expected browser security
- Resolution: Use KV-Tube proxy endpoints
3. **PWA Install Banner**: Chrome requires user interaction
- Status: Expected behavior
- Resolution: Manual install via browser menu
---
*Generated: 2026-01-10*
*Version: KV-Tube 2.0*

View file

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

View file

@ -1,62 +0,0 @@
# KV-Tube v3.0
> A lightweight, privacy-focused YouTube frontend web application with AI-powered features.
KV-Tube removes distractions, tracking, and ads from the YouTube watching experience. It provides a clean interface to search, watch, and discover related content without needing a Google account.
## 🚀 Key Features (v3)
- **Privacy First**: No tracking, no ads.
- **Clean Interface**: Distraction-free watching experience.
- **Efficient Streaming**: Direct video stream extraction using `yt-dlp`.
- **AI Summary (Experimental)**: Generate concise summaries of videos (Currently disabled due to upstream rate limits).
- **Multi-Language**: Support for English and Vietnamese (UI & Content).
- **Auto-Update**: Includes `update_deps.py` to easily keep core fetching tools up-to-date.
## 🛠️ Architecture Data Flow
![Architecture Data Flow](https://mermaid.ink/img/Z3JhcGggVEQKICAgIHN1YmdyYXBoIENsaWVudCBbIkNsaWVudCBTaWRlIl0KICAgICAgICBVc2VyWyJVc2VyIEJyb3dzZXIiXQogICAgZW5kCgogICAgc3ViZ3JhcGggQmFja2VuZCBbIktWVHViZSBCYWNrZW5kIFN5c3RlbSJdCiAgICAgICAgU2VydmVyWyJLVlR1YmUgU2VydmVyIl0KICAgICAgICBZVERMUFsieXRkbHAgQ29yZSJdCiAgICAgICAgWVRGZXRjaGVyWyJZVEZldGNoZXIgTGliIl0KICAgIGVuZAoKICAgIHN1YmdyYXBoIEV4dGVybmFsIFsiRXh0ZXJuYWwgU2VydmljZXMiXQogICAgICAgIFlvdVR1YmVbIllvdVR1YmUgVjMgQVBJIl0KICAgIGVuZAoKICAgICUlIE1haW4gRmxvdwogICAgVXNlciAtLSAiMS4gU2VhcmNoL1dhdGNoIFJlcXVlc3QiIC0tPiBTZXJ2ZXIKICAgIFNlcnZlciAtLSAiMi4gRXh0cmFjdCBNZXRhZGF0YSIgLS0+IFlURExQCiAgICBZVERMUCAtLSAiMy4gTmV0d29yayBSZXEgKENvb2tpZXMpIiAtLT4gWW91VHViZQogICAgWW91VHViZSAtLSAiNC4gUmF3IFN0cmVhbXMiIC0tPiBZVERMUAogICAgWVRETFAgLS0gIjUuIFN0cmVhbSBVUkwiIC0tPiBTZXJ2ZXIKICAgIFNlcnZlciAtLSAiNi4gUmVuZGVyL1Byb3h5IiAtLT4gVXNlcgogICAgCiAgICAlJSBGYWxsYmFjay9TZWNvbmRhcnkgRmxvdwogICAgU2VydmVyIC0uLT4gWVRGZXRjaGVyCiAgICBZVEZldGNoZXIgLS4tPiBZb3VUdWJlCiAgICBZVEZldGNoZXIgLS4gIkVycm9yIC8gTm8gVHJhbnNjcmlwdCIgLi0+IFNlcnZlcgoKICAgICUlIFN0eWxpbmcgdG8gbWFrZSBpdCBwb3AKICAgIHN0eWxlIEJhY2tlbmQgZmlsbDojZjlmOWY5LHN0cm9rZTojMzMzLHN0cm9rZS13aWR0aDoycHgKICAgIHN0eWxlIEV4dGVybmFsIGZpbGw6I2ZmZWJlZSxzdHJva2U6I2YwMCxzdHJva2Utd2lkdGg6MnB4)
## 🔧 Installation & Usage
### Prerequisites
- Python 3.10+
- Git
- Valid `cookies.txt` (Optional, for bypassing age-restrictions or rate limits)
### Local Setup
1. Clone the repository:
```bash
git clone https://git.khoavo.myds.me/vndangkhoa/kv-tube.git
cd kv-tube
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Run the application:
```bash
python wsgi.py
```
4. Access at `http://localhost:5002`
### Docker Deployment (Linux/AMD64)
Built for stability and ease of use.
```bash
docker pull vndangkhoa/kv-tube:latest
docker run -d -p 5002:5002 -v $(pwd)/cookies.txt:/app/cookies.txt vndangkhoa/kv-tube:latest
```
## 📦 Updates
- **v3.0**: Major release.
- Full modularization of backend routes.
- Integrated `ytfetcher` for specialized fetching.
- Added manual dependency update script (`update_deps.py`).
- Enhanced error handling for upstream rate limits.
- Docker `linux/amd64` support verified.
---
*Developed by Khoa Vo*

View file

@ -1,325 +0,0 @@
# KV-Tube Complete User Guide & Status Report
## 🚀 **Quick Start**
### Access KV-Tube
- **URL**: http://127.0.0.1:5002
- **Local**: http://localhost:5002
- **Network**: http://192.168.31.71:5002
### Quick Actions
1. **Search**: Use the search bar to find videos
2. **Watch**: Click any video to start playing
3. **Download**: Click the download button for MP4
4. **History**: Your watch history is saved automatically
---
## ✅ **What's Working (100%)**
### Core Features
- ✅ Video Search (15+ results per query)
- ✅ Video Playback (HLS streaming)
- ✅ Related Videos
- ✅ Channel Videos (@handle, ID, URL)
- ✅ Trending Videos
- ✅ Suggested for You
- ✅ Watch History (saved locally)
- ✅ Video Downloads (direct MP4)
- ✅ Multiple Quality Options
- ✅ Dark/Light Mode
- ✅ PWA (Installable)
- ✅ Mobile Responsive
### API Endpoints (All Working)
| Endpoint | Status | Purpose |
|----------|--------|---------|
| `/api/search` | ✅ Working | Search videos |
| `/api/get_stream_info` | ✅ Working | Get video stream |
| `/api/related` | ✅ Working | Get related videos |
| `/api/channel/videos` | ✅ Working | Get channel uploads |
| `/api/trending` | ✅ Working | Get trending |
| `/api/download` | ✅ Working | Get download URL |
| `/api/download/formats` | ✅ Working | Get quality options |
| `/api/history` | ✅ Working | Get watch history |
| `/api/suggested` | ✅ Working | Get recommendations |
| `/api/transcript` | ⚠️ Rate Limited | Get subtitles |
| `/api/summarize` | ⚠️ Rate Limited | AI summary |
---
## ⚠️ **Known Limitations**
### YouTube Rate Limiting (429 Errors)
**What**: YouTube blocks automated subtitle requests
**Impact**: Transcript & AI summary features temporarily unavailable
**When**: After ~10 requests in a short period
**Duration**: 1-24 hours
**Solution**: Wait for YouTube to reset limits
**User Experience**:
- Feature shows "Transcript temporarily disabled" toast
- No errors in console
- Automatic retry with exponential backoff
- Graceful degradation
---
## 📊 **Performance Stats**
### Response Times
- **Homepage Load**: 15ms
- **Search Results**: 850ms
- **Stream Info**: 1.2s
- **Channel Videos**: 950ms
- **Related Videos**: 700ms
- **Trending**: 1.5s
**Overall Rating**: ⚡ **EXCELLENT** (avg 853ms)
### Server Info
- **Python**: 3.12.9
- **Framework**: Flask 3.0.2
- **Port**: 5002
- **Mode**: Development (Debug enabled)
- **Rate Limiting**: Flask-Limiter active
- **Uptime**: Running continuously
---
## 🎯 **How to Use**
### 1. Search for Videos
1. Go to http://127.0.0.1:5002
2. Type in search bar (e.g., "Python tutorial")
3. Press Enter or click search icon
4. Browse results
### 2. Watch a Video
1. Click any video thumbnail
2. Video loads in ArtPlayer
3. Use controls to play/pause/seek
4. Toggle fullscreen
### 3. Download Video
1. Open video page
2. Click download button
3. Select quality (1080p, 720p, etc.)
4. Download starts automatically
### 4. Browse Channels
1. Click channel name under video
2. View channel uploads
3. Subscribe (bookmark the page)
### 5. View History
1. Click "History" in sidebar
2. See recently watched videos
3. Click to resume watching
---
## 🛠️ **Troubleshooting**
### Server Not Running?
```bash
# Check if running
netstat -ano | findstr :5002
# Restart if needed
.venv/Scripts/python app.py
```
### 429 Rate Limit?
- **Normal**: Expected from YouTube
- **Solution**: Wait 1-24 hours
- **No action needed**: Frontend handles gracefully
### Video Not Loading?
- Check your internet connection
- Try refreshing the page
- Check if YouTube video is available
### Search Not Working?
- Verify server is running (port 5002)
- Check your internet connection
- Try simpler search terms
---
## 📁 **Project Files**
### Created Files
- `API_DOCUMENTATION.md` - Complete API reference
- `TEST_REPORT.md` - Comprehensive test results
- `.env` - Environment configuration
- `server.log` - Server logs
### Key Directories
```
kv-tube/
├── app.py # Main Flask application
├── templates/ # HTML templates
│ ├── index.html # Homepage
│ ├── watch.html # Video player
│ ├── channel.html # Channel page
│ └── ...
├── static/ # Static assets
│ ├── css/ # Stylesheets
│ ├── js/ # JavaScript
│ ├── icons/ # PWA icons
│ └── sw.js # Service Worker
├── data/ # SQLite database
├── .env # Environment config
├── requirements.txt # Dependencies
└── docker-compose.yml # Docker config
```
---
## 🔧 **Configuration**
### Environment Variables
```env
SECRET_KEY=your-secure-key-here
FLASK_ENV=development
KVTUBE_VIDEO_DIR=./videos
```
### Rate Limits
- Search: 30 requests/minute
- Transcript: 10 requests/minute
- Channel: 60 requests/minute
- Download: 20 requests/minute
---
## 🚀 **Deployment Options**
### Local Development (Current)
```bash
.venv/Scripts/python app.py
# Access: http://127.0.0.1:5002
```
### Docker Production
```bash
docker-compose up -d
# Access: http://localhost:5011
```
### Manual Production
```bash
gunicorn --bind 0.0.0.0:5001 --workers 2 --threads 4 app:app
```
---
## 📈 **Feature Roadmap**
### Completed ✅
- Video search and playback
- Channel browsing
- Video downloads
- Watch history
- Dark/Light mode
- PWA support
- Rate limiting
- Mobile responsive
### In Progress
- User authentication
- Playlist support
- Comments
### Planned
- Video recommendations AI
- Offline viewing
- Background playback
- Chromecast support
---
## 🆘 **Support**
### Common Issues
**Q: Video won't play?**
A: Check internet connection, refresh page
**Q: Downloads not working?**
A: Some videos have download restrictions
**Q: Rate limit errors?**
A: Normal - wait and retry
**Q: How to restart server?**
A: Kill python process and rerun app.py
### Logs
- Check `server.log` for detailed logs
- Server outputs to console when running
---
## 🎉 **Success Metrics**
### All Systems Operational
✅ Server Running (Port 5002)
✅ All 15 Core APIs Working
✅ 87.5% Feature Completeness
✅ 0 Critical Errors
✅ Production Ready
### Test Results
- **Total Tests**: 17
- **Passed**: 15 (87.5%)
- **Rate Limited**: 2 (12.5%)
- **Failed**: 0 (0%)
### User Experience
- ✅ Fast page loads (avg 853ms)
- ✅ Smooth video playback
- ✅ Responsive design
- ✅ Intuitive navigation
---
## 📝 **Notes**
### Browser Extensions
Some browser extensions (especially YouTube-related) may show console errors:
- `onboarding.js` errors - External, ignore
- Content script warnings - External, ignore
These don't affect KV-Tube functionality.
### PWA Installation
- Chrome: Menu → Install KV-Tube
- Firefox: Address bar → Install icon
- Safari: Share → Add to Home Screen
### Data Storage
- SQLite database in `data/kvtube.db`
- Watch history persists across sessions
- LocalStorage for preferences
---
## ✅ **Final Verdict**
**Status**: 🏆 **EXCELLENT - FULLY OPERATIONAL**
KV-Tube is running successfully with all core features working perfectly. The only limitations are external YouTube rate limits on transcript features, which are temporary and automatically handled by the frontend.
**Recommended Actions**:
1. ✅ Use KV-Tube for ad-free YouTube
2. ✅ Test video playback and downloads
3. ⚠️ Avoid heavy transcript usage (429 limits)
4. 🎉 Enjoy the privacy-focused experience!
---
*Guide Generated: 2026-01-10*
*KV-Tube Version: 2.0*
*Status: Production Ready*

View file

@ -1,162 +0,0 @@
"""
KV-Tube App Package
Flask application factory pattern
"""
from flask import Flask
import os
import sqlite3
import logging
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Database configuration
DATA_DIR = os.environ.get("KVTUBE_DATA_DIR", "data")
DB_NAME = os.path.join(DATA_DIR, "kvtube.db")
def init_db():
"""Initialize the database with required tables."""
# Ensure data directory exists
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
conn = sqlite3.connect(DB_NAME)
c = conn.cursor()
# Users Table
c.execute("""CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)""")
# User Videos (history/saved)
c.execute("""CREATE TABLE IF NOT EXISTS user_videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
video_id TEXT,
title TEXT,
thumbnail TEXT,
type TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)""")
# Video Cache
c.execute("""CREATE TABLE IF NOT EXISTS video_cache (
video_id TEXT PRIMARY KEY,
data TEXT,
expires_at DATETIME
)""")
conn.commit()
conn.close()
logger.info("Database initialized")
def create_app(config_name=None):
"""
Application factory for creating Flask app instances.
Args:
config_name: Configuration name ('development', 'production', or None for default)
Returns:
Flask application instance
"""
app = Flask(__name__,
template_folder='../templates',
static_folder='../static')
# Load configuration
app.secret_key = "super_secret_key_change_this" # Required for sessions
# Fix for OMP: Error #15
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
# Initialize database
init_db()
# Register Jinja filters
register_filters(app)
# Register Blueprints
register_blueprints(app)
# Start Background Cache Warmer (x5 Speedup)
try:
from app.routes.api import start_background_warmer
start_background_warmer()
except Exception as e:
logger.warning(f"Failed to start background warmer: {e}")
logger.info("KV-Tube app created successfully")
return app
def register_filters(app):
"""Register custom Jinja2 template filters."""
@app.template_filter("format_views")
def format_views(views):
if not views:
return "0"
try:
num = int(views)
if num >= 1000000:
return f"{num / 1000000:.1f}M"
if num >= 1000:
return f"{num / 1000:.0f}K"
return f"{num:,}"
except (ValueError, TypeError) as e:
logger.debug(f"View formatting failed: {e}")
return str(views)
@app.template_filter("format_date")
def format_date(value):
if not value:
return "Recently"
from datetime import datetime
try:
# Handle YYYYMMDD
if len(str(value)) == 8 and str(value).isdigit():
dt = datetime.strptime(str(value), "%Y%m%d")
# Handle Timestamp
elif isinstance(value, (int, float)):
dt = datetime.fromtimestamp(value)
# Handle YYYY-MM-DD
else:
try:
dt = datetime.strptime(str(value), "%Y-%m-%d")
except ValueError:
return str(value)
now = datetime.now()
diff = now - dt
if diff.days > 365:
return f"{diff.days // 365} years ago"
if diff.days > 30:
return f"{diff.days // 30} months ago"
if diff.days > 0:
return f"{diff.days} days ago"
if diff.seconds > 3600:
return f"{diff.seconds // 3600} hours ago"
return "Just now"
except Exception as e:
logger.debug(f"Date formatting failed: {e}")
return str(value)
def register_blueprints(app):
"""Register all application blueprints."""
from app.routes import pages_bp, api_bp, streaming_bp
app.register_blueprint(pages_bp)
app.register_blueprint(api_bp)
app.register_blueprint(streaming_bp)
logger.info("Blueprints registered: pages, api, streaming")

View file

@ -1,9 +0,0 @@
"""
KV-Tube Routes Package
Exports all Blueprints for registration
"""
from app.routes.pages import pages_bp
from app.routes.api import api_bp
from app.routes.streaming import streaming_bp
__all__ = ['pages_bp', 'api_bp', 'streaming_bp']

File diff suppressed because it is too large Load diff

View file

@ -1,172 +0,0 @@
"""
KV-Tube Pages Blueprint
HTML page routes for the web interface
"""
from flask import Blueprint, render_template, request, url_for
pages_bp = Blueprint('pages', __name__)
@pages_bp.route("/")
def index():
"""Home page with trending videos."""
return render_template("index.html", page="home")
@pages_bp.route("/results")
def results():
"""Search results page."""
query = request.args.get("search_query", "")
return render_template("index.html", page="results", query=query)
@pages_bp.route("/my-videos")
def my_videos():
"""User's saved videos page (client-side rendered)."""
return render_template("my_videos.html")
@pages_bp.route("/settings")
def settings():
"""Settings page."""
return render_template("settings.html", page="settings")
@pages_bp.route("/downloads")
def downloads():
"""Downloads page."""
return render_template("downloads.html", page="downloads")
@pages_bp.route("/watch")
def watch():
"""Video watch page."""
from flask import url_for as flask_url_for
video_id = request.args.get("v")
local_file = request.args.get("local")
if local_file:
return render_template(
"watch.html",
video_type="local",
src=flask_url_for("streaming.stream_local", filename=local_file),
title=local_file,
)
if not video_id:
return "No video ID provided", 400
return render_template("watch.html", video_type="youtube", video_id=video_id)
@pages_bp.route("/channel/<channel_id>")
def channel(channel_id):
"""Channel page with videos list."""
import sys
import subprocess
import json
import logging
logger = logging.getLogger(__name__)
if not channel_id:
from flask import redirect, url_for as flask_url_for
return redirect(flask_url_for("pages.index"))
try:
# Robustness: Resolve name to ID if needed
real_id_or_url = channel_id
is_search_fallback = False
# If channel_id is @UCN... format, strip the @ to get the proper UC ID
if channel_id.startswith("@UC"):
real_id_or_url = channel_id[1:]
if not real_id_or_url.startswith("UC") and not real_id_or_url.startswith("@"):
search_cmd = [
sys.executable,
"-m",
"yt_dlp",
f"ytsearch1:{channel_id}",
"--dump-json",
"--default-search",
"ytsearch",
"--no-playlist",
]
try:
proc_search = subprocess.run(search_cmd, capture_output=True, text=True)
if proc_search.returncode == 0:
first_result = json.loads(proc_search.stdout.splitlines()[0])
if first_result.get("channel_id"):
real_id_or_url = first_result.get("channel_id")
is_search_fallback = True
except Exception as e:
logger.debug(f"Channel search fallback failed: {e}")
# Fetch basic channel info
channel_info = {
"id": real_id_or_url,
"title": channel_id if not is_search_fallback else "Loading...",
"avatar": None,
"banner": None,
"subscribers": None,
}
# Determine target URL for metadata fetch
target_url = real_id_or_url
if target_url.startswith("UC"):
target_url = f"https://www.youtube.com/channel/{target_url}"
elif target_url.startswith("@"):
target_url = f"https://www.youtube.com/{target_url}"
cmd = [
sys.executable,
"-m",
"yt_dlp",
target_url,
"--dump-json",
"--flat-playlist",
"--playlist-end",
"1",
"--no-warnings",
]
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
stdout, stderr = proc.communicate()
if stdout:
try:
first = json.loads(stdout.splitlines()[0])
channel_info["title"] = (
first.get("channel")
or first.get("uploader")
or channel_info["title"]
)
channel_info["id"] = first.get("channel_id") or channel_info["id"]
except json.JSONDecodeError as e:
logger.debug(f"Channel JSON parse failed: {e}")
# If title is still just the ID, try to get channel name
if channel_info["title"].startswith("UC") or channel_info["title"].startswith("@"):
try:
name_cmd = [
sys.executable,
"-m",
"yt_dlp",
target_url,
"--print", "channel",
"--playlist-items", "1",
"--no-warnings",
]
name_proc = subprocess.run(name_cmd, capture_output=True, text=True, timeout=15)
if name_proc.returncode == 0 and name_proc.stdout.strip():
channel_info["title"] = name_proc.stdout.strip()
except Exception as e:
logger.debug(f"Channel name fetch failed: {e}")
return render_template("channel.html", channel=channel_info)
except Exception as e:
return f"Error loading channel: {str(e)}", 500

View file

@ -1,164 +0,0 @@
"""
KV-Tube Streaming Blueprint
Video streaming and proxy routes
"""
from flask import Blueprint, request, Response, stream_with_context, send_from_directory
import requests
import os
import logging
import socket
import urllib3.util.connection as urllib3_cn
# Force IPv4 for requests (which uses urllib3)
def allowed_gai_family():
return socket.AF_INET
urllib3_cn.allowed_gai_family = allowed_gai_family
logger = logging.getLogger(__name__)
streaming_bp = Blueprint('streaming', __name__)
# Configuration for local video path
VIDEO_DIR = os.environ.get("KVTUBE_VIDEO_DIR", "./videos")
@streaming_bp.route("/stream/<path:filename>")
def stream_local(filename):
"""Stream local video files."""
return send_from_directory(VIDEO_DIR, filename)
def add_cors_headers(response):
"""Add CORS headers to allow video playback from any origin."""
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "Range, Content-Type"
response.headers["Access-Control-Expose-Headers"] = "Content-Length, Content-Range, Accept-Ranges"
return response
@streaming_bp.route("/video_proxy", methods=["GET", "OPTIONS"])
def video_proxy():
"""Proxy video streams with HLS manifest rewriting."""
# Handle CORS preflight
if request.method == "OPTIONS":
response = Response("")
return add_cors_headers(response)
url = request.args.get("url")
if not url:
return "No URL provided", 400
# Forward headers to mimic browser and support seeking
headers = {
"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",
"Referer": "https://www.youtube.com/",
"Origin": "https://www.youtube.com",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "cross-site",
}
# Override with propagated headers (h_*)
for key, value in request.args.items():
if key.startswith("h_"):
header_name = key[2:] # Remove 'h_' prefix
headers[header_name] = value
# Support Range requests (scrubbing)
range_header = request.headers.get("Range")
if range_header:
headers["Range"] = range_header
try:
logger.info(f"Proxying URL: {url[:100]}...")
req = requests.get(url, headers=headers, stream=True, timeout=30)
logger.info(f"Upstream Status: {req.status_code}, Content-Type: {req.headers.get('content-type', 'unknown')}")
if req.status_code != 200 and req.status_code != 206:
logger.error(f"Upstream Error: {req.status_code}")
# Handle HLS (M3U8) Rewriting - CRITICAL for 1080p+ and proper sync
content_type = req.headers.get("content-type", "").lower()
url_path = url.split("?")[0]
# Improved manifest detection - YouTube may send text/plain or octet-stream
is_manifest = (
url_path.endswith(".m3u8")
or "mpegurl" in content_type
or "m3u8" in url_path.lower()
or ("/playlist/" in url.lower() and "index.m3u8" in url.lower())
)
logger.info(f"Is Manifest: {is_manifest}, Status: {req.status_code}")
# Handle 200 and 206 (partial content) responses for manifests
if is_manifest and req.status_code in [200, 206]:
content = req.text
base_url = url.rsplit("/", 1)[0]
new_lines = []
logger.info(f"Rewriting manifest with {len(content.splitlines())} lines")
for line in content.splitlines():
line_stripped = line.strip()
if line_stripped and not line_stripped.startswith("#"):
# URL line - needs rewriting
if not line_stripped.startswith("http"):
# Relative URL - make absolute
full_url = f"{base_url}/{line_stripped}"
else:
# Absolute URL
full_url = line_stripped
from urllib.parse import quote
quoted_url = quote(full_url, safe="")
new_line = f"/video_proxy?url={quoted_url}"
# Propagate existing h_* params to segments
query_string = request.query_string.decode("utf-8")
h_params = [p for p in query_string.split("&") if p.startswith("h_")]
if h_params:
param_str = "&".join(h_params)
new_line += f"&{param_str}"
new_lines.append(new_line)
else:
new_lines.append(line)
rewritten_content = "\n".join(new_lines)
logger.info(f"Manifest rewritten successfully")
response = Response(
rewritten_content, content_type="application/vnd.apple.mpegurl"
)
return add_cors_headers(response)
# Standard Stream Proxy (Binary) - for video segments and other files
excluded_headers = [
"content-encoding",
"content-length",
"transfer-encoding",
"connection",
]
response_headers = [
(name, value)
for (name, value) in req.headers.items()
if name.lower() not in excluded_headers
]
response = Response(
stream_with_context(req.iter_content(chunk_size=8192)),
status=req.status_code,
headers=response_headers,
content_type=req.headers.get("content-type"),
)
return add_cors_headers(response)
except Exception as e:
logger.error(f"Proxy Error: {e}")
return str(e), 500

View file

@ -1 +0,0 @@
"""KV-Tube Services Package"""

View file

@ -1,217 +0,0 @@
"""
Cache Service Module
SQLite-based caching with connection pooling
"""
import sqlite3
import json
import time
import threading
import logging
from typing import Optional, Any, Dict
from contextlib import contextmanager
from config import Config
logger = logging.getLogger(__name__)
class ConnectionPool:
"""Thread-safe SQLite connection pool"""
def __init__(self, db_path: str, max_connections: int = 5):
self.db_path = db_path
self.max_connections = max_connections
self._local = threading.local()
self._lock = threading.Lock()
self._init_db()
def _init_db(self):
"""Initialize database tables"""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
# Users table
c.execute('''CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
)''')
# User videos (history/saved)
c.execute('''CREATE TABLE IF NOT EXISTS user_videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
video_id TEXT,
title TEXT,
thumbnail TEXT,
type TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)''')
# Video cache
c.execute('''CREATE TABLE IF NOT EXISTS video_cache (
video_id TEXT PRIMARY KEY,
data TEXT,
expires_at REAL
)''')
conn.commit()
conn.close()
def get_connection(self) -> sqlite3.Connection:
"""Get a thread-local database connection"""
if not hasattr(self._local, 'connection') or self._local.connection is None:
self._local.connection = sqlite3.connect(self.db_path)
self._local.connection.row_factory = sqlite3.Row
return self._local.connection
@contextmanager
def connection(self):
"""Context manager for database connections"""
conn = self.get_connection()
try:
yield conn
conn.commit()
except Exception as e:
conn.rollback()
logger.error(f"Database error: {e}")
raise
def close(self):
"""Close the thread-local connection"""
if hasattr(self._local, 'connection') and self._local.connection:
self._local.connection.close()
self._local.connection = None
# Global connection pool
_pool: Optional[ConnectionPool] = None
def get_pool() -> ConnectionPool:
"""Get or create the global connection pool"""
global _pool
if _pool is None:
_pool = ConnectionPool(Config.DB_NAME)
return _pool
def get_db_connection() -> sqlite3.Connection:
"""Get a database connection - backward compatibility"""
return get_pool().get_connection()
class CacheService:
"""Service for caching video metadata"""
@staticmethod
def get_video_cache(video_id: str) -> Optional[Dict[str, Any]]:
"""
Get cached video data if not expired
Args:
video_id: YouTube video ID
Returns:
Cached data dict or None if not found/expired
"""
try:
pool = get_pool()
with pool.connection() as conn:
row = conn.execute(
'SELECT data, expires_at FROM video_cache WHERE video_id = ?',
(video_id,)
).fetchone()
if row:
expires_at = float(row['expires_at'])
if time.time() < expires_at:
return json.loads(row['data'])
else:
# Expired, clean it up
conn.execute('DELETE FROM video_cache WHERE video_id = ?', (video_id,))
return None
except Exception as e:
logger.error(f"Cache get error for {video_id}: {e}")
return None
@staticmethod
def set_video_cache(video_id: str, data: Dict[str, Any], ttl: int = None) -> bool:
"""
Cache video data
Args:
video_id: YouTube video ID
data: Data to cache
ttl: Time to live in seconds (default from config)
Returns:
True if cached successfully
"""
try:
if ttl is None:
ttl = Config.CACHE_VIDEO_TTL
expires_at = time.time() + ttl
pool = get_pool()
with pool.connection() as conn:
conn.execute(
'INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)',
(video_id, json.dumps(data), expires_at)
)
return True
except Exception as e:
logger.error(f"Cache set error for {video_id}: {e}")
return False
@staticmethod
def clear_expired():
"""Remove all expired cache entries"""
try:
pool = get_pool()
with pool.connection() as conn:
conn.execute('DELETE FROM video_cache WHERE expires_at < ?', (time.time(),))
except Exception as e:
logger.error(f"Cache cleanup error: {e}")
class HistoryService:
"""Service for user video history"""
@staticmethod
def get_history(limit: int = 50) -> list:
"""Get watch history"""
try:
pool = get_pool()
with pool.connection() as conn:
rows = conn.execute(
'SELECT video_id as id, title, thumbnail FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT ?',
(limit,)
).fetchall()
return [dict(row) for row in rows]
except Exception as e:
logger.error(f"History get error: {e}")
return []
@staticmethod
def add_to_history(video_id: str, title: str, thumbnail: str) -> bool:
"""Add a video to history"""
try:
pool = get_pool()
with pool.connection() as conn:
conn.execute(
'INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (?, ?, ?, ?, ?)',
(1, video_id, title, thumbnail, 'history')
)
return True
except Exception as e:
logger.error(f"History add error: {e}")
return False

View file

@ -1,135 +0,0 @@
"""
AI-powered video summarizer using Google Gemini.
"""
import os
import logging
import base64
from typing import Optional
logger = logging.getLogger(__name__)
# Obfuscated API key - encoded with app-specific salt
# This prevents casual copying but is not cryptographically secure
_OBFUSCATED_KEY = "QklqYVN5RG9yLWpsdmhtMEVGVkxnV3F4TllFR0MyR21oQUY3Y3Rv"
_APP_SALT = "KV-Tube-2026"
def _decode_api_key() -> str:
"""Decode the obfuscated API key. Only works with correct app context."""
try:
# Decode base64
decoded = base64.b64decode(_OBFUSCATED_KEY).decode('utf-8')
# Remove prefix added during encoding
if decoded.startswith("Bij"):
return "AI" + decoded[3:] # Reconstruct original key
return decoded
except:
return ""
# Get API key: prefer environment variable, fall back to obfuscated default
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") or _decode_api_key()
def summarize_with_gemini(transcript: str, video_title: str = "") -> Optional[str]:
"""
Summarize video transcript using Google Gemini AI.
Args:
transcript: The video transcript text
video_title: Optional video title for context
Returns:
AI-generated summary or None if failed
"""
if not GEMINI_API_KEY:
logger.warning("GEMINI_API_KEY not set, falling back to TextRank")
return None
try:
logger.info(f"Importing google.generativeai... Key len: {len(GEMINI_API_KEY)}")
import google.generativeai as genai
genai.configure(api_key=GEMINI_API_KEY)
logger.info("Gemini configured. Creating model...")
model = genai.GenerativeModel('gemini-1.5-flash')
# Limit transcript to avoid token limits
max_chars = 8000
if len(transcript) > max_chars:
transcript = transcript[:max_chars] + "..."
logger.info(f"Generating summary content... Transcript len: {len(transcript)}")
# Create prompt for summarization
prompt = f"""You are a helpful AI assistant. Summarize the following video transcript in 2-3 concise sentences.
Focus on the main topic and key points. If it's a music video, describe the song's theme and mood instead of quoting lyrics.
Video Title: {video_title if video_title else 'Unknown'}
Transcript:
{transcript}
Provide a brief, informative summary (2-3 sentences max):"""
response = model.generate_content(prompt)
logger.info("Gemini response received.")
if response and response.text:
summary = response.text.strip()
# Clean up any markdown formatting
summary = summary.replace("**", "").replace("##", "").replace("###", "")
return summary
return None
except Exception as e:
logger.error(f"Gemini summarization error: {e}")
return None
def extract_key_points_with_gemini(transcript: str, video_title: str = "") -> list:
"""
Extract key points from video transcript using Gemini AI.
Returns:
List of key points or empty list if failed
"""
if not GEMINI_API_KEY:
return []
try:
import google.generativeai as genai
genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel('gemini-1.5-flash')
# Limit transcript
max_chars = 6000
if len(transcript) > max_chars:
transcript = transcript[:max_chars] + "..."
prompt = f"""Extract 3-5 key points from this video transcript. For each point, provide a single short sentence.
If it's a music video, describe the themes, mood, and notable elements instead of quoting lyrics.
Video Title: {video_title if video_title else 'Unknown'}
Transcript:
{transcript}
Key points (one per line, no bullet points or numbers):"""
response = model.generate_content(prompt)
if response and response.text:
lines = response.text.strip().split('\n')
# Clean up and filter
points = []
for line in lines:
line = line.strip().lstrip('•-*123456789.)')
line = line.strip()
if line and len(line) > 10:
points.append(line)
return points[:5] # Max 5 points
return []
except Exception as e:
logger.error(f"Gemini key points error: {e}")
return []

View file

@ -1,114 +0,0 @@
import requests
import time
import logging
import json
from typing import Optional, Dict, Any
from config import Config
logger = logging.getLogger(__name__)
class LoaderToService:
"""Service for interacting with loader.to / savenow.to API"""
BASE_URL = "https://p.savenow.to"
DOWNLOAD_ENDPOINT = "/ajax/download.php"
PROGRESS_ENDPOINT = "/api/progress"
@classmethod
def get_stream_url(cls, video_url: str, format_id: str = "1080") -> Optional[Dict[str, Any]]:
"""
Get download URL for a video via loader.to
Args:
video_url: Full YouTube URL
format_id: Target format (1080, 720, 4k, etc.)
Returns:
Dict containing 'stream_url' and available metadata, or None
"""
try:
# 1. Initiate Download
params = {
'format': format_id,
'url': video_url,
'api_key': Config.LOADER_TO_API_KEY
}
# Using curl-like headers to avoid bot detection
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://loader.to/',
'Origin': 'https://loader.to'
}
logger.info(f"Initiating Loader.to fetch for {video_url}")
response = requests.get(
f"{cls.BASE_URL}{cls.DOWNLOAD_ENDPOINT}",
params=params,
headers=headers,
timeout=10
)
response.raise_for_status()
data = response.json()
if not data.get('success') and not data.get('id'):
logger.error(f"Loader.to initial request failed: {data}")
return None
task_id = data.get('id')
info = data.get('info', {})
logger.info(f"Loader.to task started: {task_id}")
# 2. Poll for progress
# Timeout after 60 seconds
start_time = time.time()
while time.time() - start_time < 60:
progress_url = data.get('progress_url')
# If progress_url is missing, construct it manually (fallback)
if not progress_url and task_id:
progress_url = f"{cls.BASE_URL}/api/progress?id={task_id}"
if not progress_url:
logger.error("No progress URL found")
return None
p_res = requests.get(progress_url, headers=headers, timeout=10)
if p_res.status_code != 200:
logger.warning(f"Progress check failed: {p_res.status_code}")
time.sleep(2)
continue
p_data = p_res.json()
# Check for success (success can be boolean true or int 1)
is_success = p_data.get('success') in [True, 1, '1']
text_status = p_data.get('text', '').lower()
if is_success and p_data.get('download_url'):
logger.info("Loader.to extraction successful")
return {
'stream_url': p_data['download_url'],
'title': info.get('title') or 'Unknown Title',
'thumbnail': info.get('image'),
# Add basic fields to match yt-dlp dict structure
'description': f"Fetched via Loader.to (Format: {format_id})",
'uploader': 'Unknown',
'duration': None,
'view_count': 0
}
# Check for failure
if 'error' in text_status or 'failed' in text_status:
logger.error(f"Loader.to task failed: {text_status}")
return None
# Wait before next poll
time.sleep(2)
logger.error("Loader.to timed out waiting for video")
return None
except Exception as e:
logger.error(f"Loader.to service error: {e}")
return None

View file

@ -1,55 +0,0 @@
import json
import os
import logging
from config import Config
logger = logging.getLogger(__name__)
class SettingsService:
"""Manage application settings using a JSON file"""
SETTINGS_FILE = os.path.join(Config.DATA_DIR, 'settings.json')
# Default settings
DEFAULTS = {
'youtube_engine': 'auto', # auto, local, remote
}
@classmethod
def _load_settings(cls) -> dict:
"""Load settings from file or return defaults"""
try:
if os.path.exists(cls.SETTINGS_FILE):
with open(cls.SETTINGS_FILE, 'r') as f:
data = json.load(f)
# Merge with defaults to ensure all keys exist
return {**cls.DEFAULTS, **data}
except Exception as e:
logger.error(f"Error loading settings: {e}")
return cls.DEFAULTS.copy()
@classmethod
def get(cls, key: str, default=None):
"""Get a setting value"""
settings = cls._load_settings()
return settings.get(key, default if default is not None else cls.DEFAULTS.get(key))
@classmethod
def set(cls, key: str, value):
"""Set a setting value and persist"""
settings = cls._load_settings()
settings[key] = value
try:
with open(cls.SETTINGS_FILE, 'w') as f:
json.dump(settings, f, indent=2)
except Exception as e:
logger.error(f"Error saving settings: {e}")
raise
@classmethod
def get_all(cls):
"""Get all settings"""
return cls._load_settings()

View file

@ -1,119 +0,0 @@
import re
import math
import logging
from typing import List
logger = logging.getLogger(__name__)
class TextRankSummarizer:
"""
Summarizes text using a TextRank-like graph algorithm.
This creates more coherent "whole idea" summaries than random extraction.
"""
def __init__(self):
self.stop_words = set([
"the", "a", "an", "and", "or", "but", "is", "are", "was", "were",
"to", "of", "in", "on", "at", "for", "width", "that", "this", "it",
"you", "i", "we", "they", "he", "she", "have", "has", "had", "do",
"does", "did", "with", "as", "by", "from", "at", "but", "not", "what",
"all", "were", "when", "can", "said", "there", "use", "an", "each",
"which", "she", "do", "how", "their", "if", "will", "up", "other",
"about", "out", "many", "then", "them", "these", "so", "some", "her",
"would", "make", "like", "him", "into", "time", "has", "look", "two",
"more", "write", "go", "see", "number", "no", "way", "could", "people",
"my", "than", "first", "water", "been", "call", "who", "oil", "its",
"now", "find", "long", "down", "day", "did", "get", "come", "made",
"may", "part"
])
def summarize(self, text: str, num_sentences: int = 5) -> str:
"""
Generate a summary of the text.
Args:
text: Input text
num_sentences: Number of sentences in the summary
Returns:
Summarized text string
"""
if not text:
return ""
# 1. Split into sentences
# Use regex to look for periods/questions/exclamations followed by space or end of string
sentences = re.split(r'(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s', text)
sentences = [s.strip() for s in sentences if len(s.strip()) > 20] # Filter very short fragments
if not sentences:
return text[:500] + "..." if len(text) > 500 else text
if len(sentences) <= num_sentences:
return " ".join(sentences)
# 2. Build Similarity Graph
# We calculate cosine similarity between all pairs of sentences
# graph[i][j] = similarity score
n = len(sentences)
scores = [0.0] * n
# Pre-process sentences for efficiency
# Convert to sets of words
sent_words = []
for s in sentences:
words = re.findall(r'\w+', s.lower())
words = [w for w in words if w not in self.stop_words]
sent_words.append(words)
# Adjacency matrix (conceptual) - we'll just sum weights for "centrality"
# TextRank logic: a sentence is important if it is similar to other important sentences.
# Simplified: weighted degree centrality often works well enough for simple tasks without full iterative convergence
for i in range(n):
for j in range(i + 1, n):
sim = self._cosine_similarity(sent_words[i], sent_words[j])
if sim > 0:
scores[i] += sim
scores[j] += sim
# 3. Rank and Select
# Sort by score descending
ranked_sentences = sorted(((scores[i], i) for i in range(n)), reverse=True)
# Pick top N
top_indices = [idx for score, idx in ranked_sentences[:num_sentences]]
# 4. Reorder by appearance in original text for coherence
top_indices.sort()
summary = " ".join([sentences[i] for i in top_indices])
return summary
def _cosine_similarity(self, words1: List[str], words2: List[str]) -> float:
"""Calculate cosine similarity between two word lists."""
if not words1 or not words2:
return 0.0
# Unique words in both
all_words = set(words1) | set(words2)
# Frequency vectors
vec1 = {w: 0 for w in all_words}
vec2 = {w: 0 for w in all_words}
for w in words1: vec1[w] += 1
for w in words2: vec2[w] += 1
# Dot product
dot_product = sum(vec1[w] * vec2[w] for w in all_words)
# Magnitudes
mag1 = math.sqrt(sum(v*v for v in vec1.values()))
mag2 = math.sqrt(sum(v*v for v in vec2.values()))
if mag1 == 0 or mag2 == 0:
return 0.0
return dot_product / (mag1 * mag2)

View file

@ -1,211 +0,0 @@
"""
Transcript Service Module
Fetches video transcripts with fallback strategy: yt-dlp -> ytfetcher
"""
import os
import re
import glob
import json
import random
import logging
from typing import Optional
logger = logging.getLogger(__name__)
class TranscriptService:
"""Service for fetching YouTube video transcripts with fallback support."""
@classmethod
def get_transcript(cls, video_id: str) -> Optional[str]:
"""
Get transcript text for a video.
Strategy:
1. Try yt-dlp (current method, handles auto-generated captions)
2. Fallback to ytfetcher library if yt-dlp fails
Args:
video_id: YouTube video ID
Returns:
Transcript text or None if unavailable
"""
video_id = video_id.strip()
# Try yt-dlp first (primary method)
text = cls._fetch_with_ytdlp(video_id)
if text:
logger.info(f"Transcript fetched via yt-dlp for {video_id}")
return text
# Fallback to ytfetcher
logger.info(f"yt-dlp failed, trying ytfetcher for {video_id}")
text = cls._fetch_with_ytfetcher(video_id)
if text:
logger.info(f"Transcript fetched via ytfetcher for {video_id}")
return text
logger.warning(f"All transcript methods failed for {video_id}")
return None
@classmethod
def _fetch_with_ytdlp(cls, video_id: str) -> Optional[str]:
"""Fetch transcript using yt-dlp (downloading subtitles to file)."""
import yt_dlp
try:
logger.info(f"Fetching transcript for {video_id} using yt-dlp")
# Use a temporary filename pattern
temp_prefix = f"transcript_{video_id}_{random.randint(1000, 9999)}"
ydl_opts = {
'skip_download': True,
'quiet': True,
'no_warnings': True,
'cookiefile': os.environ.get('COOKIES_FILE', 'cookies.txt') if os.path.exists(os.environ.get('COOKIES_FILE', 'cookies.txt')) else None,
'writesubtitles': True,
'writeautomaticsub': True,
'subtitleslangs': ['en', 'vi', 'en-US'],
'outtmpl': f"/tmp/{temp_prefix}",
'subtitlesformat': 'json3/vtt/best',
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
# Find the downloaded file
downloaded_files = glob.glob(f"/tmp/{temp_prefix}*")
if not downloaded_files:
logger.warning("yt-dlp finished but no subtitle file found.")
return None
# Pick the best file (prefer json3, then vtt)
selected_file = None
for ext in ['.json3', '.vtt', '.ttml', '.srv3']:
for f in downloaded_files:
if f.endswith(ext):
selected_file = f
break
if selected_file:
break
if not selected_file:
selected_file = downloaded_files[0]
# Read content
with open(selected_file, 'r', encoding='utf-8') as f:
content = f.read()
# Cleanup
for f in downloaded_files:
try:
os.remove(f)
except:
pass
# Parse based on format
if selected_file.endswith('.json3') or content.strip().startswith('{'):
return cls._parse_json3(content)
else:
return cls._parse_vtt(content)
except Exception as e:
logger.error(f"yt-dlp transcript fetch failed: {e}")
return None
@classmethod
def _fetch_with_ytfetcher(cls, video_id: str) -> Optional[str]:
"""Fetch transcript using ytfetcher library as fallback."""
try:
from ytfetcher import YTFetcher
logger.info(f"Using ytfetcher for {video_id}")
# Create fetcher for single video
fetcher = YTFetcher.from_video_ids(video_ids=[video_id])
# Fetch transcripts
data = fetcher.fetch_transcripts()
if not data:
logger.warning(f"ytfetcher returned no data for {video_id}")
return None
# Extract text from transcript objects
text_parts = []
for item in data:
transcripts = getattr(item, 'transcripts', []) or []
for t in transcripts:
txt = getattr(t, 'text', '') or ''
txt = txt.strip()
if txt and txt != '\n':
text_parts.append(txt)
if not text_parts:
logger.warning(f"ytfetcher returned empty transcripts for {video_id}")
return None
return " ".join(text_parts)
except ImportError:
logger.warning("ytfetcher not installed. Run: pip install ytfetcher")
return None
except Exception as e:
logger.error(f"ytfetcher transcript fetch failed: {e}")
return None
@staticmethod
def _parse_json3(content: str) -> Optional[str]:
"""Parse JSON3 subtitle format."""
try:
json_data = json.loads(content)
events = json_data.get('events', [])
text_parts = []
for event in events:
segs = event.get('segs', [])
for seg in segs:
txt = seg.get('utf8', '').strip()
if txt and txt != '\n':
text_parts.append(txt)
return " ".join(text_parts)
except Exception as e:
logger.warning(f"JSON3 parse failed: {e}")
return None
@staticmethod
def _parse_vtt(content: str) -> Optional[str]:
"""Parse VTT/XML subtitle content."""
try:
lines = content.splitlines()
text_lines = []
seen = set()
for line in lines:
line = line.strip()
if not line:
continue
if "-->" in line:
continue
if line.isdigit():
continue
if line.startswith("WEBVTT"):
continue
if line.startswith("Kind:"):
continue
if line.startswith("Language:"):
continue
# Remove tags like <c> or <00:00:00>
clean = re.sub(r'<[^>]+>', '', line)
if clean and clean not in seen:
seen.add(clean)
text_lines.append(clean)
return " ".join(text_lines)
except Exception as e:
logger.error(f"VTT transcript parse error: {e}")
return None

View file

@ -1,313 +0,0 @@
"""
YouTube Service Module
Handles all yt-dlp interactions using the library directly (not subprocess)
"""
import yt_dlp
import logging
from typing import Optional, List, Dict, Any
from config import Config
from app.services.loader_to import LoaderToService
from app.services.settings import SettingsService
logger = logging.getLogger(__name__)
class YouTubeService:
"""Service for fetching YouTube content using yt-dlp library"""
# Common yt-dlp options
BASE_OPTS = {
'quiet': True,
'no_warnings': True,
'extract_flat': 'in_playlist',
'force_ipv4': True,
'socket_timeout': Config.YTDLP_TIMEOUT,
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
}
@staticmethod
def sanitize_video_data(data: Dict[str, Any]) -> Dict[str, Any]:
"""Sanitize and format video data from yt-dlp"""
video_id = data.get('id', '')
duration_secs = data.get('duration')
# Format duration
duration_str = None
if duration_secs:
mins, secs = divmod(int(duration_secs), 60)
hours, mins = divmod(mins, 60)
duration_str = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
return {
'id': video_id,
'title': data.get('title', 'Unknown'),
'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
'channel_id': data.get('channel_id'),
'uploader_id': data.get('uploader_id'),
'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg" if video_id else None,
'view_count': data.get('view_count', 0),
'upload_date': data.get('upload_date', ''),
'duration': duration_str,
'description': data.get('description', ''),
}
@classmethod
def search_videos(cls, query: str, limit: int = 20, filter_type: Optional[str] = None) -> List[Dict[str, Any]]:
"""
Search for videos using yt-dlp library directly
Args:
query: Search query
limit: Maximum number of results
filter_type: 'video' to exclude shorts, 'short' for only shorts
Returns:
List of sanitized video data dictionaries
"""
try:
search_url = f"ytsearch{limit}:{query}"
ydl_opts = {
**cls.BASE_OPTS,
'extract_flat': True,
'playlist_items': f'1:{limit}',
}
results = []
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(search_url, download=False)
entries = info.get('entries', []) if info else []
for entry in entries:
if not entry or not entry.get('id'):
continue
# Filter logic
title_lower = (entry.get('title') or '').lower()
duration_secs = entry.get('duration')
if filter_type == 'video':
# Exclude shorts
if '#shorts' in title_lower:
continue
if duration_secs and int(duration_secs) <= 70:
continue
elif filter_type == 'short':
# Only shorts
if duration_secs and int(duration_secs) > 60:
continue
results.append(cls.sanitize_video_data(entry))
return results
except Exception as e:
logger.error(f"Search error for '{query}': {e}")
return []
@classmethod
def get_video_info(cls, video_id: str) -> Optional[Dict[str, Any]]:
"""
Get detailed video information including stream URL
Args:
video_id: YouTube video ID
Returns:
Video info dict with stream_url, or None on error
"""
engine = SettingsService.get('youtube_engine', 'auto')
# 1. Force Remote
if engine == 'remote':
return cls._get_info_remote(video_id)
# 2. Local (or Auto first attempt)
info = cls._get_info_local(video_id)
if info:
return info
# 3. Failover if Auto
if engine == 'auto' and not info:
logger.warning(f"yt-dlp failed for {video_id}, falling back to remote loader")
return cls._get_info_remote(video_id)
return None
@classmethod
def _get_info_remote(cls, video_id: str) -> Optional[Dict[str, Any]]:
"""Fetch info using LoaderToService"""
url = f"https://www.youtube.com/watch?v={video_id}"
return LoaderToService.get_stream_url(url)
@classmethod
def _get_info_local(cls, video_id: str) -> Optional[Dict[str, Any]]:
"""Fetch info using yt-dlp (original logic)"""
try:
url = f"https://www.youtube.com/watch?v={video_id}"
ydl_opts = {
**cls.BASE_OPTS,
'format': Config.YTDLP_FORMAT,
'noplaylist': True,
'skip_download': True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
if not info:
return None
stream_url = info.get('url')
if not stream_url:
logger.warning(f"No stream URL found for {video_id}")
return None
# Get subtitles
subtitle_url = cls._extract_subtitle_url(info)
return {
'stream_url': stream_url,
'title': info.get('title', 'Unknown'),
'description': info.get('description', ''),
'uploader': info.get('uploader', ''),
'uploader_id': info.get('uploader_id', ''),
'channel_id': info.get('channel_id', ''),
'upload_date': info.get('upload_date', ''),
'view_count': info.get('view_count', 0),
'subtitle_url': subtitle_url,
'duration': info.get('duration'),
'thumbnail': info.get('thumbnail') or f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
'http_headers': info.get('http_headers', {})
}
except Exception as e:
logger.error(f"Error getting local video info for {video_id}: {e}")
return None
@staticmethod
def _extract_subtitle_url(info: Dict[str, Any]) -> Optional[str]:
"""Extract best subtitle URL from video info"""
subs = info.get('subtitles') or {}
auto_subs = info.get('automatic_captions') or {}
# Priority: en manual > vi manual > en auto > vi auto > first available
for lang in ['en', 'vi']:
if lang in subs and subs[lang]:
return subs[lang][0].get('url')
for lang in ['en', 'vi']:
if lang in auto_subs and auto_subs[lang]:
return auto_subs[lang][0].get('url')
# Fallback to first available
if subs:
first_key = list(subs.keys())[0]
if subs[first_key]:
return subs[first_key][0].get('url')
if auto_subs:
first_key = list(auto_subs.keys())[0]
if auto_subs[first_key]:
return auto_subs[first_key][0].get('url')
return None
@classmethod
def get_channel_videos(cls, channel_id: str, limit: int = 20) -> List[Dict[str, Any]]:
"""
Get videos from a YouTube channel
Args:
channel_id: Channel ID, handle (@username), or URL
limit: Maximum number of videos
Returns:
List of video data dictionaries
"""
try:
# Construct URL based on ID format
if channel_id.startswith('http'):
url = channel_id
elif channel_id.startswith('@'):
url = f"https://www.youtube.com/{channel_id}"
elif len(channel_id) == 24 and channel_id.startswith('UC'):
url = f"https://www.youtube.com/channel/{channel_id}"
else:
url = f"https://www.youtube.com/{channel_id}"
ydl_opts = {
**cls.BASE_OPTS,
'extract_flat': True,
'playlist_items': f'1:{limit}',
}
results = []
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
entries = info.get('entries', []) if info else []
for entry in entries:
if entry and entry.get('id'):
results.append(cls.sanitize_video_data(entry))
return results
except Exception as e:
logger.error(f"Error getting channel videos for {channel_id}: {e}")
return []
@classmethod
def get_related_videos(cls, title: str, limit: int = 10) -> List[Dict[str, Any]]:
"""Get videos related to a given title"""
query = f"{title} related"
return cls.search_videos(query, limit=limit, filter_type='video')
@classmethod
def get_download_url(cls, video_id: str) -> Optional[Dict[str, str]]:
"""
Get direct download URL (non-HLS) for a video
Returns:
Dict with 'url', 'title', 'ext' or None
"""
try:
url = f"https://www.youtube.com/watch?v={video_id}"
ydl_opts = {
**cls.BASE_OPTS,
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best[protocol!*=m3u8]/best',
'noplaylist': True,
'skip_download': True,
'youtube_include_dash_manifest': False,
'youtube_include_hls_manifest': False,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
download_url = info.get('url', '')
# If m3u8, try to find non-HLS format
if '.m3u8' in download_url or not download_url:
formats = info.get('formats', [])
for f in reversed(formats):
f_url = f.get('url', '')
if f_url and 'm3u8' not in f_url and f.get('ext') == 'mp4':
download_url = f_url
break
if download_url and '.m3u8' not in download_url:
return {
'url': download_url,
'title': info.get('title', 'video'),
'ext': 'mp4'
}
return None
except Exception as e:
logger.error(f"Error getting download URL for {video_id}: {e}")
return None

View file

@ -1 +0,0 @@
"""KV-Tube Utilities Package"""

View file

@ -1,95 +0,0 @@
"""
Template Formatters Module
Jinja2 template filters for formatting views and dates
"""
from datetime import datetime, timedelta
def format_views(views) -> str:
"""Format view count (YouTube style: 1.2M, 3.5K)"""
if not views:
return '0'
try:
num = int(views)
if num >= 1_000_000_000:
return f"{num / 1_000_000_000:.1f}B"
if num >= 1_000_000:
return f"{num / 1_000_000:.1f}M"
if num >= 1_000:
return f"{num / 1_000:.0f}K"
return f"{num:,}"
except (ValueError, TypeError):
return str(views)
def format_date(value) -> str:
"""Format date to relative time (YouTube style: 2 hours ago, 3 days ago)"""
if not value:
return 'Recently'
try:
# Handle YYYYMMDD format
if len(str(value)) == 8 and str(value).isdigit():
dt = datetime.strptime(str(value), '%Y%m%d')
# Handle timestamp
elif isinstance(value, (int, float)):
dt = datetime.fromtimestamp(value)
# Handle datetime object
elif isinstance(value, datetime):
dt = value
# Handle YYYY-MM-DD string
else:
try:
dt = datetime.strptime(str(value), '%Y-%m-%d')
except ValueError:
return str(value)
now = datetime.now()
diff = now - dt
if diff.days > 365:
years = diff.days // 365
return f"{years} year{'s' if years > 1 else ''} ago"
if diff.days > 30:
months = diff.days // 30
return f"{months} month{'s' if months > 1 else ''} ago"
if diff.days > 7:
weeks = diff.days // 7
return f"{weeks} week{'s' if weeks > 1 else ''} ago"
if diff.days > 0:
return f"{diff.days} day{'s' if diff.days > 1 else ''} ago"
if diff.seconds > 3600:
hours = diff.seconds // 3600
return f"{hours} hour{'s' if hours > 1 else ''} ago"
if diff.seconds > 60:
minutes = diff.seconds // 60
return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
return "Just now"
except Exception:
return str(value)
def format_duration(seconds) -> str:
"""Format duration in seconds to HH:MM:SS or MM:SS"""
if not seconds:
return ''
try:
secs = int(seconds)
mins, secs = divmod(secs, 60)
hours, mins = divmod(mins, 60)
if hours:
return f"{hours}:{mins:02d}:{secs:02d}"
return f"{mins}:{secs:02d}"
except (ValueError, TypeError):
return ''
def register_filters(app):
"""Register all template filters with Flask app"""
app.template_filter('format_views')(format_views)
app.template_filter('format_date')(format_date)
app.template_filter('format_duration')(format_duration)

27
backend/Dockerfile Executable file
View file

@ -0,0 +1,27 @@
FROM golang:1.21-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
RUN CGO_ENABLED=0 GOOS=linux go build -o kv-tube .
FROM alpine:latest
RUN apk add --no-cache ca-certificates ffmpeg curl
WORKDIR /app
COPY --from=builder /app/kv-tube .
COPY data ./data
EXPOSE 8080
ENV KVTUBE_DATA_DIR=/app/data
ENV GIN_MODE=release
CMD ["./kv-tube"]

42
backend/go.mod Executable file
View file

@ -0,0 +1,42 @@
module kvtube-go
go 1.25.4
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.11.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/ulule/limiter/v3 v3.11.2 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)

87
backend/go.sum Executable file
View file

@ -0,0 +1,87 @@
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
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/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ulule/limiter/v3 v3.11.2 h1:P4yOrxoEMJbOTfRJR2OzjL90oflzYPPmWg+dvwN2tHA=
github.com/ulule/limiter/v3 v3.11.2/go.mod h1:QG5GnFOCV+k7lrL5Y8kgEeeflPH3+Cviqlqa8SVSQxI=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

37
backend/main.go Executable file
View file

@ -0,0 +1,37 @@
package main
import (
"log"
"os"
"kvtube-go/models"
"kvtube-go/routes"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
)
func main() {
// Load environment variables (ignore if not found)
_ = godotenv.Load()
// Initialize Database
models.InitDB()
// Setup Gin Engine
if os.Getenv("GIN_MODE") == "release" {
gin.SetMode(gin.ReleaseMode)
}
r := routes.SetupRouter()
// Start server
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("KV-Tube Go Backend starting on port %s...", port)
if err := r.Run(":" + port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}

79
backend/models/database.go Executable file
View file

@ -0,0 +1,79 @@
package models
import (
"database/sql"
"log"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
)
var DB *sql.DB
func InitDB() {
dataDir := os.Getenv("KVTUBE_DATA_DIR")
if dataDir == "" {
dataDir = "../data" // Default mapping assuming running from backend
}
if err := os.MkdirAll(dataDir, 0755); err != nil {
log.Fatalf("Failed to create data directory: %v", err)
}
dbPath := filepath.Join(dataDir, "kvtube.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
// Create tables
userTable := `CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL
);`
userVideosTable := `CREATE TABLE IF NOT EXISTS user_videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
video_id TEXT,
title TEXT,
thumbnail TEXT,
type TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
);`
videoCacheTable := `CREATE TABLE IF NOT EXISTS video_cache (
video_id TEXT PRIMARY KEY,
data TEXT,
expires_at DATETIME
);`
subscriptionsTable := `CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
channel_id TEXT NOT NULL,
channel_name TEXT,
channel_avatar TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, channel_id),
FOREIGN KEY(user_id) REFERENCES users(id)
);`
for _, stmt := range []string{userTable, userVideosTable, videoCacheTable, subscriptionsTable} {
if _, err := db.Exec(stmt); err != nil {
log.Fatalf("Failed to create table: %v - Statement: %s", err, stmt)
}
}
// Insert default user for history tracking
_, err = db.Exec(`INSERT OR IGNORE INTO users (id, username, password) VALUES (1, 'default_user', 'password')`)
if err != nil {
log.Printf("Failed to insert default user: %v", err)
}
DB = db
log.Println("Database initialized successfully at", dbPath)
}

625
backend/routes/api.go Executable file
View file

@ -0,0 +1,625 @@
package routes
import (
"bufio"
"io"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"kvtube-go/services"
"github.com/gin-gonic/gin"
)
func SetupRouter() *gin.Engine {
r := gin.Default()
r.Use(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
r.GET("/api/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
// API Routes
api := r.Group("/api")
{
api.GET("/search", handleSearch)
api.GET("/trending", handleTrending)
api.GET("/get_stream_info", handleGetStreamInfo)
api.GET("/download", handleDownload)
api.GET("/transcript", handleTranscript)
api.GET("/comments", handleComments)
api.GET("/channel/videos", handleChannelVideos)
api.GET("/channel/info", handleChannelInfo)
api.GET("/related", handleRelatedVideos)
api.GET("/formats", handleGetFormats)
api.GET("/qualities", handleGetQualities)
api.GET("/stream", handleGetStreamByQuality)
// History routes
api.POST("/history", handlePostHistory)
api.GET("/history", handleGetHistory)
api.GET("/suggestions", handleGetSuggestions)
// Subscription routes
api.POST("/subscribe", handleSubscribe)
api.DELETE("/subscribe", handleUnsubscribe)
api.GET("/subscribe", handleCheckSubscription)
api.GET("/subscriptions", handleGetSubscriptions)
}
r.GET("/video_proxy", handleVideoProxy)
return r
}
func handleSearch(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"})
return
}
limit := 20
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil {
limit = parsed
}
}
results, err := services.SearchVideos(query, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search videos"})
return
}
c.JSON(http.StatusOK, results)
}
func handleTrending(c *gin.Context) {
// Basic mock implementation for now
c.JSON(http.StatusOK, gin.H{
"data": []gin.H{
{
"id": "trending",
"title": "Currently Trending",
"icon": "fire",
"videos": []gin.H{},
},
},
})
}
func handleGetStreamInfo(c *gin.Context) {
videoID := c.Query("v")
if videoID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
return
}
info, err := services.GetVideoInfo(videoID)
if err != nil {
log.Printf("GetVideoInfo Error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video info"})
return
}
// Get available qualities with audio
qualities, audioURL, _ := services.GetVideoQualitiesWithAudio(videoID)
// Build quality options for frontend
var qualityOptions []gin.H
bestURL := info.StreamURL
bestHeight := 0
for _, q := range qualities {
proxyURL := "/video_proxy?url=" + url.QueryEscape(q.URL)
audioProxyURL := ""
if q.AudioURL != "" {
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(q.AudioURL)
}
qualityOptions = append(qualityOptions, gin.H{
"label": q.Label,
"height": q.Height,
"url": proxyURL,
"audio_url": audioProxyURL,
"is_hls": q.IsHLS,
"has_audio": q.HasAudio,
})
if q.Height > bestHeight {
bestHeight = q.Height
bestURL = q.URL
}
}
// If we found qualities, use the best one
streamURL := info.StreamURL
if bestURL != "" {
streamURL = bestURL
}
proxyURL := "/video_proxy?url=" + url.QueryEscape(streamURL)
// Get audio URL for the response
audioProxyURL := ""
if audioURL != "" {
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL)
}
c.JSON(http.StatusOK, gin.H{
"original_url": info.StreamURL,
"stream_url": proxyURL,
"audio_url": audioProxyURL,
"title": info.Title,
"description": info.Description,
"uploader": info.Uploader,
"channel_id": info.ChannelID,
"uploader_id": info.UploaderID,
"view_count": info.ViewCount,
"thumbnail": info.Thumbnail,
"related": []interface{}{},
"subtitle_url": nil,
"qualities": qualityOptions,
"best_quality": bestHeight,
})
}
func handleDownload(c *gin.Context) {
videoID := c.Query("v")
if videoID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
return
}
formatID := c.Query("f")
info, err := services.GetDownloadURL(videoID, formatID)
if err != nil {
log.Printf("GetDownloadURL Error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get download URL"})
return
}
c.JSON(http.StatusOK, info)
}
func handleGetFormats(c *gin.Context) {
videoID := c.Query("v")
if videoID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
return
}
formats, err := services.GetVideoFormats(videoID)
if err != nil {
log.Printf("GetVideoFormats Error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video formats"})
return
}
c.JSON(http.StatusOK, formats)
}
func handleGetQualities(c *gin.Context) {
videoID := c.Query("v")
if videoID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
return
}
qualities, audioURL, err := services.GetVideoQualitiesWithAudio(videoID)
if err != nil {
log.Printf("GetVideoQualities Error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video qualities"})
return
}
var result []gin.H
for _, q := range qualities {
proxyURL := "/video_proxy?url=" + url.QueryEscape(q.URL)
audioProxyURL := ""
if q.AudioURL != "" {
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(q.AudioURL)
}
result = append(result, gin.H{
"format_id": q.FormatID,
"label": q.Label,
"resolution": q.Resolution,
"height": q.Height,
"url": proxyURL,
"audio_url": audioProxyURL,
"is_hls": q.IsHLS,
"vcodec": q.VCodec,
"acodec": q.ACodec,
"filesize": q.Filesize,
"has_audio": q.HasAudio,
})
}
// Also return the best audio URL separately
audioProxyURL := ""
if audioURL != "" {
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"qualities": result,
"audio_url": audioProxyURL,
})
}
func handleGetStreamByQuality(c *gin.Context) {
videoID := c.Query("v")
if videoID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
return
}
heightStr := c.Query("q")
height := 0
if heightStr != "" {
if parsed, err := strconv.Atoi(heightStr); err == nil {
height = parsed
}
}
qualities, audioURL, err := services.GetVideoQualitiesWithAudio(videoID)
if err != nil {
log.Printf("GetVideoQualities Error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video qualities"})
return
}
if len(qualities) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "No qualities available"})
return
}
var selected *services.QualityFormat
for i := range qualities {
if qualities[i].Height == height {
selected = &qualities[i]
break
}
}
if selected == nil {
selected = &qualities[0]
}
proxyURL := "/video_proxy?url=" + url.QueryEscape(selected.URL)
audioProxyURL := ""
if selected.AudioURL != "" {
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(selected.AudioURL)
} else if audioURL != "" {
audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"stream_url": proxyURL,
"audio_url": audioProxyURL,
"has_audio": selected.HasAudio,
"quality": gin.H{
"label": selected.Label,
"height": selected.Height,
"is_hls": selected.IsHLS,
},
})
}
func handleRelatedVideos(c *gin.Context) {
videoID := c.Query("v")
title := c.Query("title")
uploader := c.Query("uploader")
if title == "" && videoID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID or Title required"})
return
}
limitStr := c.Query("limit")
limit := 10
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
videos, err := services.GetRelatedVideos(title, uploader, limit)
if err != nil {
log.Printf("GetRelatedVideos Error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get related videos"})
return
}
c.JSON(http.StatusOK, videos)
}
func handleTranscript(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "Not Implemented"})
}
func handleComments(c *gin.Context) {
videoID := c.Query("v")
if videoID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"})
return
}
limit := 20
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
comments, err := services.GetComments(videoID, limit)
if err != nil {
log.Printf("GetComments Error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get comments"})
return
}
c.JSON(http.StatusOK, comments)
}
func handleChannelInfo(c *gin.Context) {
channelID := c.Query("id")
if channelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID 'id' is required"})
return
}
info, err := services.GetChannelInfo(channelID)
if err != nil {
log.Printf("GetChannelInfo Error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel info"})
return
}
c.JSON(http.StatusOK, info)
}
func handleChannelVideos(c *gin.Context) {
channelID := c.Query("id")
if channelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID 'id' is required"})
return
}
limitStr := c.Query("limit")
limit := 30
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
videos, err := services.GetChannelVideos(channelID, limit)
if err != nil {
log.Printf("GetChannelVideos Error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel videos", "details": err.Error()})
return
}
c.JSON(http.StatusOK, videos)
}
func handleVideoProxy(c *gin.Context) {
targetURL := c.Query("url")
if targetURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "No URL provided"})
return
}
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"})
return
}
// Forward standard headers
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
req.Header.Set("Referer", "https://www.youtube.com/")
req.Header.Set("Origin", "https://www.youtube.com")
if rangeHeader := c.GetHeader("Range"); rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch video stream"})
return
}
defer resp.Body.Close()
contentType := resp.Header.Get("Content-Type")
baseURL := targetURL[:strings.LastIndex(targetURL, "/")]
isManifest := strings.Contains(strings.ToLower(contentType), "mpegurl") ||
strings.HasSuffix(targetURL, ".m3u8") ||
strings.Contains(targetURL, ".m3u8")
if isManifest && (resp.StatusCode == 200 || resp.StatusCode == 206) {
// Rewrite M3U8 Manifest
scanner := bufio.NewScanner(resp.Body)
var newLines []string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" && !strings.HasPrefix(line, "#") {
fullURL := line
if !strings.HasPrefix(line, "http") {
fullURL = baseURL + "/" + line
}
encodedURL := url.QueryEscape(fullURL)
newLines = append(newLines, "/video_proxy?url="+encodedURL)
} else {
newLines = append(newLines, line)
}
}
rewrittenContent := strings.Join(newLines, "\n")
c.Data(resp.StatusCode, "application/vnd.apple.mpegurl", []byte(rewrittenContent))
return
}
// Stream binary video data
for k, v := range resp.Header {
logKey := strings.ToLower(k)
if logKey != "content-encoding" && logKey != "transfer-encoding" && logKey != "connection" && !strings.HasPrefix(logKey, "access-control-") {
c.Writer.Header()[k] = v
}
}
c.Writer.WriteHeader(resp.StatusCode)
io.Copy(c.Writer, resp.Body)
}
func handlePostHistory(c *gin.Context) {
var body struct {
VideoID string `json:"video_id"`
Title string `json:"title"`
Thumbnail string `json:"thumbnail"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if body.VideoID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
return
}
err := services.AddToHistory(body.VideoID, body.Title, body.Thumbnail)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update history"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
func handleGetHistory(c *gin.Context) {
limitStr := c.Query("limit")
limit := 50
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
history, err := services.GetHistory(limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get history"})
return
}
// Make the API response shape match the VideoData shape the frontend expects
// We'll reconstruct a basic VideoData-like array for the frontend
var results []services.VideoData
for _, h := range history {
results = append(results, services.VideoData{
ID: h.ID,
Title: h.Title,
Thumbnail: h.Thumbnail,
Uploader: "History", // Just a placeholder
})
}
c.JSON(http.StatusOK, results)
}
func handleGetSuggestions(c *gin.Context) {
limitStr := c.Query("limit")
limit := 20
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
suggestions, err := services.GetSuggestions(limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get suggestions"})
return
}
c.JSON(http.StatusOK, suggestions)
}
func handleSubscribe(c *gin.Context) {
var body struct {
ChannelID string `json:"channel_id"`
ChannelName string `json:"channel_name"`
ChannelAvatar string `json:"channel_avatar"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if body.ChannelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"})
return
}
err := services.SubscribeChannel(body.ChannelID, body.ChannelName, body.ChannelAvatar)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to subscribe"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "subscribed"})
}
func handleUnsubscribe(c *gin.Context) {
channelID := c.Query("channel_id")
if channelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"})
return
}
err := services.UnsubscribeChannel(channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsubscribe"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "unsubscribed"})
}
func handleCheckSubscription(c *gin.Context) {
channelID := c.Query("channel_id")
if channelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"})
return
}
subscribed, err := services.IsSubscribed(channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check subscription"})
return
}
c.JSON(http.StatusOK, gin.H{"subscribed": subscribed})
}
func handleGetSubscriptions(c *gin.Context) {
subs, err := services.GetSubscriptions()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscriptions"})
return
}
c.JSON(http.StatusOK, subs)
}

96
backend/services/history.go Executable file
View file

@ -0,0 +1,96 @@
package services
import (
"log"
"strings"
"kvtube-go/models"
)
// AddToHistory records a video in the history for the user (default id 1)
func AddToHistory(videoID, title, thumbnail string) error {
// First check if it already exists to just update timestamp, or insert new
var existingId int
err := models.DB.QueryRow("SELECT id FROM user_videos WHERE user_id = 1 AND video_id = ?", videoID).Scan(&existingId)
if err == nil {
// Exists, update timestamp
_, err = models.DB.Exec("UPDATE user_videos SET timestamp = CURRENT_TIMESTAMP WHERE id = ?", existingId)
if err != nil {
log.Printf("Error updating history timestamp: %v", err)
return err
}
return nil
}
// Insert new
_, err = models.DB.Exec(
"INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (1, ?, ?, ?, 'history')",
videoID, title, thumbnail,
)
if err != nil {
log.Printf("Error inserting history: %v", err)
return err
}
return nil
}
// HistoryVideo represents a video in the user's history
type HistoryVideo struct {
ID string `json:"id"`
Title string `json:"title"`
Thumbnail string `json:"thumbnail"`
}
// GetHistory retrieves the most recently watched videos
func GetHistory(limit int) ([]HistoryVideo, error) {
rows, err := models.DB.Query(
"SELECT video_id, title, thumbnail FROM user_videos WHERE user_id = 1 ORDER BY timestamp DESC LIMIT ?", limit,
)
if err != nil {
log.Printf("Error querying history: %v", err)
return nil, err
}
defer rows.Close()
var videos []HistoryVideo
for rows.Next() {
var v HistoryVideo
if err := rows.Scan(&v.ID, &v.Title, &v.Thumbnail); err != nil {
continue
}
videos = append(videos, v)
}
return videos, nil
}
// GetSuggestions retrieves suggestions based on the user's recent history
func GetSuggestions(limit int) ([]VideoData, error) {
// 1. Get the 3 most recently watched videos to extract keywords
history, err := GetHistory(3)
if err != nil || len(history) == 0 {
// Fallback to trending if no history
return SearchVideos("trending videos", limit)
}
// 2. Build a combined query string from titles
var words []string
for _, h := range history {
// take first few words from title
parts := strings.Fields(h.Title)
for i := 0; i < len(parts) && i < 3; i++ {
// clean up some common punctuation if needed, or just let yt-dlp handle it
words = append(words, parts[i])
}
}
query := strings.Join(words, " ")
if query == "" {
query = "popular videos"
}
// 3. Search using yt-dlp
return SearchVideos(query, limit)
}

View file

@ -0,0 +1,72 @@
package services
import (
"log"
"kvtube-go/models"
)
type Subscription struct {
ID int `json:"id"`
ChannelID string `json:"channel_id"`
ChannelName string `json:"channel_name"`
ChannelAvatar string `json:"channel_avatar"`
}
func SubscribeChannel(channelID, channelName, channelAvatar string) error {
_, err := models.DB.Exec(
`INSERT OR IGNORE INTO subscriptions (user_id, channel_id, channel_name, channel_avatar) VALUES (1, ?, ?, ?)`,
channelID, channelName, channelAvatar,
)
if err != nil {
log.Printf("Error subscribing to channel: %v", err)
return err
}
return nil
}
func UnsubscribeChannel(channelID string) error {
_, err := models.DB.Exec(
`DELETE FROM subscriptions WHERE user_id = 1 AND channel_id = ?`,
channelID,
)
if err != nil {
log.Printf("Error unsubscribing from channel: %v", err)
return err
}
return nil
}
func IsSubscribed(channelID string) (bool, error) {
var count int
err := models.DB.QueryRow(
`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1 AND channel_id = ?`,
channelID,
).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func GetSubscriptions() ([]Subscription, error) {
rows, err := models.DB.Query(
`SELECT id, channel_id, channel_name, channel_avatar FROM subscriptions WHERE user_id = 1 ORDER BY timestamp DESC`,
)
if err != nil {
log.Printf("Error querying subscriptions: %v", err)
return nil, err
}
defer rows.Close()
var subs []Subscription
for rows.Next() {
var s Subscription
if err := rows.Scan(&s.ID, &s.ChannelID, &s.ChannelName, &s.ChannelAvatar); err != nil {
continue
}
subs = append(subs, s)
}
return subs, nil
}

850
backend/services/ytdlp.go Executable file
View file

@ -0,0 +1,850 @@
package services
import (
"bytes"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"strings"
)
type VideoData struct {
ID string `json:"id"`
Title string `json:"title"`
Uploader string `json:"uploader"`
ChannelID string `json:"channel_id"`
UploaderID string `json:"uploader_id"`
Thumbnail string `json:"thumbnail"`
ViewCount int64 `json:"view_count"`
UploadDate string `json:"upload_date"`
Duration string `json:"duration"`
Description string `json:"description"`
StreamURL string `json:"stream_url,omitempty"`
}
type VideoFormat struct {
FormatID string `json:"format_id"`
FormatNote string `json:"format_note"`
Ext string `json:"ext"`
Resolution string `json:"resolution"`
Filesize int64 `json:"filesize"`
VCodec string `json:"vcodec"`
ACodec string `json:"acodec"`
Type string `json:"type"` // "video", "audio", or "both"
}
type YtDlpEntry struct {
ID string `json:"id"`
Title string `json:"title"`
Uploader string `json:"uploader"`
Channel string `json:"channel"`
ChannelID string `json:"channel_id"`
UploaderID string `json:"uploader_id"`
ViewCount int64 `json:"view_count"`
UploadDate string `json:"upload_date"`
Duration interface{} `json:"duration"` // Can be float64 or int
Description string `json:"description"`
URL string `json:"url"`
}
func sanitizeVideoData(entry YtDlpEntry) VideoData {
uploader := entry.Uploader
if uploader == "" {
uploader = entry.Channel
}
if uploader == "" {
uploader = "Unknown"
}
var durationStr string
if d, ok := entry.Duration.(float64); ok && d > 0 {
hours := int(d) / 3600
mins := (int(d) % 3600) / 60
secs := int(d) % 60
if hours > 0 {
durationStr = fmt.Sprintf("%d:%02d:%02d", hours, mins, secs)
} else {
durationStr = fmt.Sprintf("%d:%02d", mins, secs)
}
}
thumbnail := ""
if entry.ID != "" {
thumbnail = fmt.Sprintf("https://i.ytimg.com/vi/%s/hqdefault.jpg", entry.ID)
}
return VideoData{
ID: entry.ID,
Title: entry.Title,
Uploader: uploader,
ChannelID: entry.ChannelID,
UploaderID: entry.UploaderID,
Thumbnail: thumbnail,
ViewCount: entry.ViewCount,
UploadDate: entry.UploadDate,
Duration: durationStr,
Description: entry.Description,
}
}
// RunYtDlp securely executes yt-dlp with the given arguments and returns JSON output
func RunYtDlp(args ...string) ([]byte, error) {
cmdArgs := append([]string{
"--dump-json",
"--no-warnings",
"--quiet",
"--force-ipv4",
"--ignore-errors",
"--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",
}, args...)
binPath := "yt-dlp"
// Check common install paths if yt-dlp is not in PATH
if _, err := exec.LookPath("yt-dlp"); err != nil {
fallbacks := []string{
os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"),
os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"),
os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"),
os.ExpandEnv("$HOME/Library/Python/3.11/bin/yt-dlp"),
os.ExpandEnv("$HOME/.local/bin/yt-dlp"),
"/usr/local/bin/yt-dlp",
"/opt/homebrew/bin/yt-dlp",
}
for _, fb := range fallbacks {
if _, err := os.Stat(fb); err == nil {
binPath = fb
break
}
}
}
cmd := exec.Command(binPath, cmdArgs...)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
log.Printf("yt-dlp error: %v, stderr: %s", err, stderr.String())
return nil, err
}
return out.Bytes(), nil
}
func SearchVideos(query string, limit int) ([]VideoData, error) {
searchQuery := fmt.Sprintf("ytsearch%d:%s", limit, query)
args := []string{
"--flat-playlist",
searchQuery,
}
out, err := RunYtDlp(args...)
if err != nil {
return nil, err
}
var results []VideoData
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, line := range lines {
if line == "" {
continue
}
var entry YtDlpEntry
if err := json.Unmarshal([]byte(line), &entry); err == nil {
if entry.ID != "" {
results = append(results, sanitizeVideoData(entry))
}
}
}
return results, nil
}
func GetVideoInfo(videoID string) (*VideoData, error) {
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
args := []string{
"--format", "bestvideo+bestaudio/best",
"--skip-download",
"--no-playlist",
url,
}
out, err := RunYtDlp(args...)
if err != nil {
return nil, err
}
var entry YtDlpEntry
if err := json.Unmarshal(out, &entry); err != nil {
return nil, err
}
data := sanitizeVideoData(entry)
data.StreamURL = entry.URL
return &data, nil
}
type QualityFormat struct {
FormatID string `json:"format_id"`
Label string `json:"label"`
Resolution string `json:"resolution"`
Height int `json:"height"`
URL string `json:"url"`
AudioURL string `json:"audio_url,omitempty"`
IsHLS bool `json:"is_hls"`
VCodec string `json:"vcodec"`
ACodec string `json:"acodec"`
Filesize int64 `json:"filesize"`
HasAudio bool `json:"has_audio"`
}
func GetVideoQualities(videoID string) ([]QualityFormat, error) {
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
cmdArgs := append([]string{
"--dump-json",
"--no-warnings",
"--quiet",
"--force-ipv4",
"--no-playlist",
"--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",
}, url)
binPath := "yt-dlp"
if _, err := exec.LookPath("yt-dlp"); err != nil {
fallbacks := []string{
os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"),
os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"),
os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"),
os.ExpandEnv("$HOME/.local/bin/yt-dlp"),
"/usr/local/bin/yt-dlp",
"/opt/homebrew/bin/yt-dlp",
"/config/.local/bin/yt-dlp",
}
for _, fb := range fallbacks {
if _, err := os.Stat(fb); err == nil {
binPath = fb
break
}
}
}
cmd := exec.Command(binPath, cmdArgs...)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
log.Printf("yt-dlp error: %v, stderr: %s", err, stderr.String())
return nil, err
}
var raw struct {
Formats []struct {
FormatID string `json:"format_id"`
FormatNote string `json:"format_note"`
Ext string `json:"ext"`
Resolution string `json:"resolution"`
Width interface{} `json:"width"`
Height interface{} `json:"height"`
URL string `json:"url"`
ManifestURL string `json:"manifest_url"`
VCodec string `json:"vcodec"`
ACodec string `json:"acodec"`
Filesize interface{} `json:"filesize"`
} `json:"formats"`
}
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
return nil, err
}
var qualities []QualityFormat
seen := make(map[int]int) // height -> index in qualities
for _, f := range raw.Formats {
if f.VCodec == "none" || f.URL == "" {
continue
}
var height int
switch v := f.Height.(type) {
case float64:
height = int(v)
case int:
height = v
}
if height == 0 {
continue
}
hasAudio := f.ACodec != "none" && f.ACodec != ""
var filesize int64
switch v := f.Filesize.(type) {
case float64:
filesize = int64(v)
case int64:
filesize = v
}
isHLS := f.ManifestURL != "" || strings.Contains(f.URL, ".m3u8") || strings.Contains(f.URL, "manifest")
label := f.FormatNote
if label == "" {
switch height {
case 2160:
label = "4K"
case 1440:
label = "1440p"
case 1080:
label = "1080p"
case 720:
label = "720p"
case 480:
label = "480p"
case 360:
label = "360p"
default:
label = fmt.Sprintf("%dp", height)
}
}
streamURL := f.URL
if f.ManifestURL != "" {
streamURL = f.ManifestURL
}
qf := QualityFormat{
FormatID: f.FormatID,
Label: label,
Resolution: f.Resolution,
Height: height,
URL: streamURL,
IsHLS: isHLS,
VCodec: f.VCodec,
ACodec: f.ACodec,
Filesize: filesize,
HasAudio: hasAudio,
}
// Prefer formats with audio, otherwise just add
if idx, exists := seen[height]; exists {
// Replace if this one has audio and the existing one doesn't
if hasAudio && !qualities[idx].HasAudio {
qualities[idx] = qf
}
} else {
seen[height] = len(qualities)
qualities = append(qualities, qf)
}
}
// Sort by height descending
for i := range qualities {
for j := i + 1; j < len(qualities); j++ {
if qualities[j].Height > qualities[i].Height {
qualities[i], qualities[j] = qualities[j], qualities[i]
}
}
}
return qualities, nil
}
func GetBestAudioURL(videoID string) (string, error) {
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
cmdArgs := []string{
"--dump-json",
"--no-warnings",
"--quiet",
"--force-ipv4",
"--no-playlist",
"--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",
url,
}
binPath := "yt-dlp"
if _, err := exec.LookPath("yt-dlp"); err != nil {
fallbacks := []string{
os.ExpandEnv("$HOME/.local/bin/yt-dlp"),
"/usr/local/bin/yt-dlp",
"/opt/homebrew/bin/yt-dlp",
"/config/.local/bin/yt-dlp",
}
for _, fb := range fallbacks {
if _, err := os.Stat(fb); err == nil {
binPath = fb
break
}
}
}
cmd := exec.Command(binPath, cmdArgs...)
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return "", err
}
var raw struct {
Formats []struct {
FormatID string `json:"format_id"`
URL string `json:"url"`
VCodec string `json:"vcodec"`
ACodec string `json:"acodec"`
ABR interface{} `json:"abr"`
FormatNote string `json:"format_note"`
} `json:"formats"`
}
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
return "", err
}
// Find best audio-only stream (prefer highest ABR)
var bestAudio string
var bestABR float64
for _, f := range raw.Formats {
if f.VCodec == "none" && f.ACodec != "none" && f.URL != "" {
var abr float64
switch v := f.ABR.(type) {
case float64:
abr = v
case int:
abr = float64(v)
}
if abr > bestABR {
bestABR = abr
bestAudio = f.URL
}
}
}
return bestAudio, nil
}
func GetVideoQualitiesWithAudio(videoID string) ([]QualityFormat, string, error) {
qualities, err := GetVideoQualities(videoID)
if err != nil {
return nil, "", err
}
// Get best audio URL
audioURL, err := GetBestAudioURL(videoID)
if err != nil {
log.Printf("Warning: could not get audio URL: %v", err)
}
// Attach audio URL to qualities without audio
for i := range qualities {
if !qualities[i].HasAudio && audioURL != "" {
qualities[i].AudioURL = audioURL
}
}
return qualities, audioURL, nil
}
func GetStreamURLForQuality(videoID string, height int) (string, error) {
qualities, err := GetVideoQualities(videoID)
if err != nil {
return "", err
}
for _, q := range qualities {
if q.Height == height {
return q.URL, nil
}
}
if len(qualities) > 0 {
return qualities[0].URL, nil
}
return "", fmt.Errorf("no suitable quality found")
}
func GetRelatedVideos(title, uploader string, limit int) ([]VideoData, error) {
query := title
if uploader != "" {
query = uploader + " " + title
}
// Limit query length to avoid issues
if len(query) > 100 {
query = query[:100]
}
return SearchVideos(query, limit)
}
type DownloadInfo struct {
URL string `json:"url"`
Title string `json:"title"`
Ext string `json:"ext"`
}
func GetDownloadURL(videoID string, formatID string) (*DownloadInfo, error) {
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
formatArgs := "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best"
if formatID != "" {
formatArgs = formatID
if !strings.Contains(formatID, "+") && !strings.Contains(formatID, "best") {
// If it's just a video format, we might want to try adding audio, but for simple direct download links,
// let's stick to what the user requested or what yt-dlp gives for that ID.
formatArgs = formatID + "+bestaudio/best"
}
}
args := []string{
"--format", formatArgs,
"--dump-json",
"--no-playlist",
url,
}
out, err := RunYtDlp(args...)
if err != nil {
return nil, err
}
var raw map[string]interface{}
if err := json.Unmarshal(out, &raw); err != nil {
return nil, err
}
downloadURL, _ := raw["url"].(string)
title, _ := raw["title"].(string)
ext, _ := raw["ext"].(string)
if downloadURL == "" {
formats, ok := raw["formats"].([]interface{})
if ok && len(formats) > 0 {
// Try to find the first mp4 format that is not m3u8
for i := len(formats) - 1; i >= 0; i-- {
fmtMap, ok := formats[i].(map[string]interface{})
if !ok {
continue
}
fUrl, _ := fmtMap["url"].(string)
fExt, _ := fmtMap["ext"].(string)
if fUrl != "" && !strings.Contains(fUrl, ".m3u8") && fExt == "mp4" {
downloadURL = fUrl
ext = fExt
break
}
}
}
}
if title == "" {
title = "video"
}
if ext == "" {
ext = "mp4"
}
return &DownloadInfo{
URL: downloadURL,
Title: title,
Ext: ext,
}, nil
}
func GetVideoFormats(videoID string) ([]VideoFormat, error) {
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
args := []string{
"--dump-json",
"--no-playlist",
url,
}
out, err := RunYtDlp(args...)
if err != nil {
return nil, err
}
var raw struct {
Formats []struct {
FormatID string `json:"format_id"`
FormatNote string `json:"format_note"`
Ext string `json:"ext"`
Resolution string `json:"resolution"`
Filesize float64 `json:"filesize"`
VCodec string `json:"vcodec"`
ACodec string `json:"acodec"`
} `json:"formats"`
}
if err := json.Unmarshal(out, &raw); err != nil {
return nil, err
}
var formats []VideoFormat
for _, f := range raw.Formats {
// Filter out storyboards and other non-media formats
if strings.Contains(f.FormatID, "sb") || f.VCodec == "none" && f.ACodec == "none" {
continue
}
fType := "both"
if f.VCodec == "none" {
fType = "audio"
} else if f.ACodec == "none" {
fType = "video"
}
formats = append(formats, VideoFormat{
FormatID: f.FormatID,
FormatNote: f.FormatNote,
Ext: f.Ext,
Resolution: f.Resolution,
Filesize: int64(f.Filesize),
VCodec: f.VCodec,
ACodec: f.ACodec,
Type: fType,
})
}
return formats, nil
}
type ChannelInfo struct {
ID string `json:"id"`
Title string `json:"title"`
SubscriberCount int64 `json:"subscriber_count"`
Avatar string `json:"avatar"`
}
func GetChannelInfo(channelID string) (*ChannelInfo, error) {
url := fmt.Sprintf("https://www.youtube.com/channel/%s", channelID)
if strings.HasPrefix(channelID, "@") {
url = fmt.Sprintf("https://www.youtube.com/%s", channelID)
}
// Fetch 1 video with full metadata to extract channel info
args := []string{
url + "/videos",
"--dump-json",
"--playlist-end", "1",
"--no-warnings",
"--quiet",
}
out, err := RunYtDlp(args...)
if err != nil || len(out) == 0 {
return nil, fmt.Errorf("failed to get channel info: %v", err)
}
// Parse the first video's JSON
var raw map[string]interface{}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) == 0 {
return nil, fmt.Errorf("no output from yt-dlp")
}
if err := json.Unmarshal([]byte(lines[0]), &raw); err != nil {
return nil, err
}
title, _ := raw["channel"].(string)
if title == "" {
title, _ = raw["uploader"].(string)
}
if title == "" {
title = channelID
}
cID, _ := raw["channel_id"].(string)
if cID == "" {
cID = channelID
}
subCountFloat, _ := raw["channel_follower_count"].(float64)
// Create an avatar based on the first letter of the channel title
avatarStr := "?"
if len(title) > 0 {
avatarStr = strings.ToUpper(string(title[0]))
}
return &ChannelInfo{
ID: cID,
Title: title,
SubscriberCount: int64(subCountFloat),
Avatar: avatarStr, // Simple fallback for now
}, nil
}
func GetChannelVideos(channelID string, limit int) ([]VideoData, error) {
url := fmt.Sprintf("https://www.youtube.com/channel/%s", channelID)
if strings.HasPrefix(channelID, "@") {
url = fmt.Sprintf("https://www.youtube.com/%s", channelID)
}
args := []string{
url + "/videos",
"--flat-playlist",
fmt.Sprintf("--playlist-end=%d", limit),
}
out, err := RunYtDlp(args...)
if err != nil {
return nil, err
}
var results []VideoData
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, line := range lines {
if line == "" {
continue
}
var entry YtDlpEntry
if err := json.Unmarshal([]byte(line), &entry); err == nil {
if entry.ID != "" {
results = append(results, sanitizeVideoData(entry))
}
}
}
return results, nil
}
type Comment struct {
ID string `json:"id"`
Text string `json:"text"`
Author string `json:"author"`
AuthorID string `json:"author_id"`
AuthorThumb string `json:"author_thumbnail"`
Likes int `json:"likes"`
IsReply bool `json:"is_reply"`
Parent string `json:"parent"`
Timestamp string `json:"timestamp"`
}
func GetComments(videoID string, limit int) ([]Comment, error) {
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
cmdArgs := []string{
"--dump-json",
"--no-download",
"--no-playlist",
"--write-comments",
fmt.Sprintf("--comment-limit=%d", limit),
url,
}
cmdArgs = append([]string{
"--no-warnings",
"--quiet",
"--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",
}, cmdArgs...)
binPath := "yt-dlp"
if _, err := exec.LookPath("yt-dlp"); err != nil {
fallbacks := []string{
os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"),
os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"),
os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"),
os.ExpandEnv("$HOME/.local/bin/yt-dlp"),
"/usr/local/bin/yt-dlp",
"/opt/homebrew/bin/yt-dlp",
"/config/.local/bin/yt-dlp",
}
for _, fb := range fallbacks {
if _, err := os.Stat(fb); err == nil {
binPath = fb
break
}
}
}
cmd := exec.Command(binPath, cmdArgs...)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
log.Printf("yt-dlp comments error: %v, stderr: %s", err, stderr.String())
return nil, err
}
var raw struct {
Comments []struct {
ID string `json:"id"`
Text string `json:"text"`
Author string `json:"author"`
AuthorID string `json:"author_id"`
AuthorThumb string `json:"author_thumbnail"`
Likes int `json:"like_count"`
IsReply bool `json:"is_reply"`
Parent string `json:"parent"`
Timestamp int64 `json:"timestamp"`
} `json:"comments"`
}
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
return nil, err
}
var comments []Comment
for _, c := range raw.Comments {
timestamp := ""
if c.Timestamp > 0 {
timestamp = formatCommentTime(c.Timestamp)
}
comments = append(comments, Comment{
ID: c.ID,
Text: c.Text,
Author: c.Author,
AuthorID: c.AuthorID,
AuthorThumb: c.AuthorThumb,
Likes: c.Likes,
IsReply: c.IsReply,
Parent: c.Parent,
Timestamp: timestamp,
})
}
return comments, nil
}
func formatCommentTime(timestamp int64) string {
now := float64(timestamp)
then := float64(0)
diff := int((now - then) / 1000)
if diff < 60 {
return "just now"
} else if diff < 3600 {
return fmt.Sprintf("%dm ago", diff/60)
} else if diff < 86400 {
return fmt.Sprintf("%dh ago", diff/3600)
} else if diff < 604800 {
return fmt.Sprintf("%dd ago", diff/86400)
} else if diff < 2592000 {
return fmt.Sprintf("%dw ago", diff/604800)
} else if diff < 31536000 {
return fmt.Sprintf("%dmo ago", diff/2592000)
}
return fmt.Sprintf("%dy ago", diff/31536000)
}

Binary file not shown.

View file

@ -1,65 +0,0 @@
"""
KV-Tube Configuration Module
Centralizes all configuration with environment variable support
"""
import os
from dotenv import load_dotenv
# Load .env file if present
load_dotenv()
class Config:
"""Base configuration"""
SECRET_KEY = os.environ.get('SECRET_KEY', os.urandom(32).hex())
# Database
DATA_DIR = os.environ.get('KVTUBE_DATA_DIR', 'data')
DB_NAME = os.path.join(DATA_DIR, 'kvtube.db')
# Video storage
VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos')
# Rate limiting
RATELIMIT_DEFAULT = "60/minute"
RATELIMIT_SEARCH = "30/minute"
RATELIMIT_STREAM = "120/minute"
# Cache settings (in seconds)
CACHE_VIDEO_TTL = 3600 # 1 hour
CACHE_CHANNEL_TTL = 1800 # 30 minutes
# yt-dlp settings
# yt-dlp settings - MUST use progressive formats with combined audio+video
# Format 22 = 720p mp4, 18 = 360p mp4 (both have audio+video combined)
# HLS m3u8 streams have CORS issues with segment proxying, so we avoid them
YTDLP_FORMAT = '22/18/best[protocol^=https][ext=mp4]/best[ext=mp4]/best'
YTDLP_TIMEOUT = 30
# YouTube Engine Settings
YOUTUBE_ENGINE = os.environ.get('YOUTUBE_ENGINE', 'auto') # auto, local, remote
LOADER_TO_API_KEY = os.environ.get('LOADER_TO_API_KEY', '') # Optional
@staticmethod
def init_app(app):
"""Initialize app with config"""
# Ensure data directory exists
os.makedirs(Config.DATA_DIR, exist_ok=True)
class DevelopmentConfig(Config):
"""Development configuration"""
DEBUG = True
FLASK_ENV = 'development'
class ProductionConfig(Config):
"""Production configuration"""
DEBUG = False
FLASK_ENV = 'production'
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}

View file

@ -1,19 +0,0 @@
# Netscape HTTP Cookie File
# This file is generated by yt-dlp. Do not edit.
.youtube.com TRUE / TRUE 1831894348 __Secure-3PSID g.a0005wie1jXMw44_RGjwtqg21AbatcdNseI3S_qNtsLYC1jS4YSUdfLDtJlA8h-NEmMGebiczgACgYKAX0SARESFQHGX2MiUz2RnkvviMoB7UNylf3SoBoVAUF8yKo0JwXF5B9H9roWaSTRT-QN0076
.youtube.com TRUE / TRUE 1800281710 __Secure-1PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
.youtube.com TRUE / TRUE 1802692356 SAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1800359997 __Secure-1PSIDCC AKEyXzWh3snkS2XAx8pLOzZCgKTPwXKRai_Pn4KjpsSSc2h7tRpVKMDddMKBYkuIQFhpVlALI84
.youtube.com TRUE / TRUE 1802692356 SSID A4isk9AE9xActvzYy
.youtube.com TRUE / TRUE 1831894348 __Secure-1PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1831894348 __Secure-1PSID g.a0005wie1jXMw44_RGjwtqg21AbatcdNseI3S_qNtsLYC1jS4YSUycKC58NH045FOFX6QW8fDwACgYKAacSARESFQHGX2MiA5xeTuJuh8QmBm-DS3l1ghoVAUF8yKr4klCBhb-EJgFQ9T0TGWKk0076
.youtube.com TRUE / TRUE 1831894348 __Secure-3PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1800359997 __Secure-3PSIDCC AKEyXzW3W5Q-e4TIryFWpWS6zVuuVPOvwPIU2tzl1JRdYsGu-7f34g_amk2Xd2ttGtSJ6tOSdA
.youtube.com TRUE / TRUE 1800281710 __Secure-3PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
.youtube.com TRUE / TRUE 1792154873 LOGIN_INFO AFmmF2swRQIgVjJk8Mho4_JuKr6SZzrhBdlL1LdxWxcwDMu4cjaRRgcCIQCTtJpmYKJH54Tiei3at3f4YT3US7gSL0lW_TZ04guKjQ:QUQ3MjNmeWlwRDJSNDl2NE9uX2JWWG5tWllHN0RsNUVZVUhsLVp4N2dWbldaeC14SnNybWVERnNoaXFpanFJczhKTjJSRGN6MEs3c1VkLTE1TGJVeFBPT05BY29NMFh0Q1VPdFU3dUdvSUpET3lQbU1ZMUlHUGltajlXNDllNUQxZHdzZko1WXF1UUJWclNxQVJ0TXVEYnF2bXJRY2V6Vl9n
.youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=150&hl=en
.youtube.com TRUE / TRUE 0 YSC y-oH2BqaUSQ
.youtube.com TRUE / TRUE 1784333733 __Secure-ROLLOUT_TOKEN CPm51pHVjquOTRDw0bnsppWSAxjzxYe3qZaSAw%3D%3D
.youtube.com TRUE / TRUE 1784375997 VISITOR_INFO1_LIVE ShB1Bvj-rRU
.youtube.com TRUE / TRUE 1784375997 VISITOR_PRIVACY_METADATA CgJWThIEGgAgWA%3D%3D

View file

@ -1,28 +0,0 @@
#!/usr/bin/env python3
"""Build and push multi-platform Docker image."""
import subprocess
def run_cmd(cmd):
print(f"\n>>> {cmd}")
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.stdout:
print(result.stdout)
if result.stderr:
print(result.stderr)
return result.returncode == 0
print("="*50)
print("Building Multi-Platform Docker Image")
print("(linux/amd64 + linux/arm64)")
print("="*50)
# Create buildx builder if it doesn't exist
run_cmd("docker buildx create --name multiplatform --use 2>/dev/null || docker buildx use multiplatform")
# Build and push multi-platform image
print("\nBuilding and pushing...")
run_cmd("docker buildx build --platform linux/amd64,linux/arm64 -t vndangkhoa/kv-tube:latest --push .")
print("\n" + "="*50)
print("DONE! Image now supports both amd64 and arm64")
print("="*50)

69
dev.sh
View file

@ -1,69 +0,0 @@
#!/bin/bash
set -e
echo "--- KV-Tube Local Dev Startup ---"
# 1. Check for FFmpeg (Auto-Install Local Static Binary if missing)
if ! command -v ffmpeg &> /dev/null; then
echo "[Check] FFmpeg not found globally."
# Check local bin
LOCAL_BIN="$(pwd)/bin"
if [ ! -f "$LOCAL_BIN/ffmpeg" ]; then
echo "[Setup] Downloading static FFmpeg for macOS ARM64..."
mkdir -p "$LOCAL_BIN"
# Download from Martin Riedl's static builds (macOS ARM64)
curl -L -o ffmpeg.zip "https://ffmpeg.martin-riedl.de/redirect/latest/macos/arm64/release/ffmpeg.zip"
echo "[Setup] Extracting FFmpeg..."
unzip -o -q ffmpeg.zip -d "$LOCAL_BIN"
rm ffmpeg.zip
# Some zips extract to a subfolder, ensure binary is in bin root
# (This specific source usually dumps 'ffmpeg' directly, but just in case)
if [ ! -f "$LOCAL_BIN/ffmpeg" ]; then
find "$LOCAL_BIN" -name "ffmpeg" -type f -exec mv {} "$LOCAL_BIN" \;
fi
chmod +x "$LOCAL_BIN/ffmpeg"
fi
# Add local bin to PATH
export PATH="$LOCAL_BIN:$PATH"
echo "[Setup] Using local FFmpeg from $LOCAL_BIN"
fi
if ! command -v ffmpeg &> /dev/null; then
echo "Error: FFmpeg installation failed. Please install manually."
exit 1
fi
echo "[Check] FFmpeg found: $(ffmpeg -version | head -n 1)"
# 2. Virtual Environment (Optional but recommended)
if [ ! -d "venv" ]; then
echo "[Setup] Creating python virtual environment..."
python3 -m venv venv
fi
source venv/bin/activate
# 3. Install Dependencies & Force Nightly yt-dlp
echo "[Update] Installing dependencies..."
pip install -r requirements.txt
echo "[Update] Forcing yt-dlp Nightly update..."
# This matches the aggressive update strategy of media-roller
pip install -U --pre "yt-dlp[default]"
# 4. Environment Variables
export FLASK_APP=wsgi.py
export FLASK_ENV=development
export PYTHONUNBUFFERED=1
# 5. Start Application
echo "[Startup] Starting KV-Tube on http://localhost:5011"
echo "Press Ctrl+C to stop."
# Run with Gunicorn (closer to prod) or Flask (better for debugging)
# Using Gunicorn to match Docker behavior, but with reload for dev
exec gunicorn --bind 0.0.0.0:5011 --workers 2 --threads 2 --reload wsgi:app

View file

@ -4,29 +4,31 @@
version: '3.8'
services:
kv-tube:
build: .
image: vndangkhoa/kv-tube:latest
container_name: kv-tube
kv-tube-backend:
image: git.khoavo.myds.me/vndangkhoa/kv-tube-backend:v4.0.0
container_name: kv-tube-backend
restart: unless-stopped
ports:
- "5011:5000"
volumes:
# Persist data (Easy setup: Just maps a folder)
- ./data:/app/data
# Cookies file for YouTube (Required for NAS/Server to fix "Transcript not available")
- ./data/cookies.txt:/app/cookies.txt
# Local videos folder (Optional)
# - ./videos:/app/youtube_downloads
environment:
- PYTHONUNBUFFERED=1
- FLASK_ENV=production
- COOKIES_FILE=/app/cookies.txt
- KVTUBE_DATA_DIR=/app/data
- GIN_MODE=release
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:5000/" ]
test: [ "CMD", "curl", "-f", "http://localhost:8080/api/health" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
labels:
- "com.centurylinklabs.watchtower.enable=true"
kv-tube-frontend:
image: git.khoavo.myds.me/vndangkhoa/kv-tube-frontend:v4.0.0
container_name: kv-tube-frontend
restart: unless-stopped
ports:
- "5011:3000"
depends_on:
- kv-tube-backend
labels:
- "com.centurylinklabs.watchtower.enable=true"

View file

@ -1,21 +0,0 @@
#!/bin/sh
set -e
echo "--- KV-Tube Startup ---"
# 1. Update Core Engines
echo "[Update] Checking for engine updates..."
# Update yt-dlp
echo "[Update] Updating yt-dlp..."
pip install -U yt-dlp || echo "Warning: yt-dlp update failed"
# 2. Check Loader.to Connectivity (Optional verification)
# We won't block startup on this, just log it.
echo "[Update] Engines checked."
# 3. Start Application
echo "[Startup] Launching Gunicorn..."
exec gunicorn --bind 0.0.0.0:5000 --workers 4 --threads 2 --timeout 120 wsgi:app

41
frontend/.gitignore vendored Executable file
View file

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

55
frontend/Dockerfile Normal file
View file

@ -0,0 +1,55 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js telemetry is disabled
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
# set hostname to localhost
ENV HOSTNAME "0.0.0.0"
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]

36
frontend/README.md Executable file
View file

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

87
frontend/app/actions.ts Executable file
View file

@ -0,0 +1,87 @@
"use server";
import { VideoData, CATEGORY_MAP, ALL_CATEGORY_SECTIONS, API_BASE } from './constants';
import { addRegion } from './utils';
export async function getSearchVideos(query: string, limit: number = 20): Promise<VideoData[]> {
try {
const res = await fetch(`${API_BASE}/api/search?q=${encodeURIComponent(query)}&limit=${limit}`, { cache: 'no-store' });
if (!res.ok) return [];
return res.json() as Promise<VideoData[]>;
} catch (e) {
console.error(e);
return [];
}
}
export async function getHistoryVideos(limit: number = 20): Promise<VideoData[]> {
try {
const res = await fetch(`${API_BASE}/api/history?limit=${limit}`, { cache: 'no-store' });
if (!res.ok) return [];
return res.json() as Promise<VideoData[]>;
} catch (e) {
console.error("Failed to get history:", e);
return [];
}
}
export async function getSuggestedVideos(limit: number = 20): Promise<VideoData[]> {
try {
const res = await fetch(`${API_BASE}/api/suggestions?limit=${limit}`, { cache: 'no-store' });
if (!res.ok) return [];
return res.json() as Promise<VideoData[]>;
} catch (e) {
console.error("Failed to get suggestions:", e);
return [];
}
}
export async function fetchMoreVideos(currentCategory: string, regionLabel: string, page: number): Promise<VideoData[]> {
const isAllCategory = currentCategory === 'All';
let newVideos: VideoData[] = [];
// Modify query slightly to simulate getting more pages
const pageModifiers = ["", "", "more", "new", "update", "latest", "part 2", "HD", "review"];
const modifier = page < pageModifiers.length ? pageModifiers[page] : `page ${page}`;
if (isAllCategory) {
const promises = ALL_CATEGORY_SECTIONS.map(async (sec) => {
const q = addRegion(sec.query, regionLabel) + " " + modifier;
// Fetch fewer items per section on subsequent pages to mitigate loading times
return await getSearchVideos(q, 5);
});
const results = await Promise.all(promises);
// Interleave the results
const maxLen = Math.max(...results.map(arr => arr.length));
const interleavedList: VideoData[] = [];
const seenIds = new Set<string>();
for (let i = 0; i < maxLen; i++) {
for (const categoryResult of results) {
if (i < categoryResult.length) {
const video = categoryResult[i];
if (!seenIds.has(video.id)) {
interleavedList.push(video);
seenIds.add(video.id);
}
}
}
}
newVideos = interleavedList;
} else if (currentCategory === 'Watched') {
// Fetch from history, offset by page if desired (backend doesn't support offset yet, so just increase limit)
// If the backend returned all items, we'd normally paginate here. For now just mock it or return empty array to prevent infinite duplicating history scroll
if (page > 1) return []; // History is just 1 page for now
newVideos = await getHistoryVideos(50);
} else if (currentCategory === 'Suggested') {
const q = addRegion("popular videos", regionLabel) + " " + modifier;
newVideos = await getSearchVideos(q, 10); // Or we could make suggestions return more things
} else {
const baseQuery = CATEGORY_MAP[currentCategory] || CATEGORY_MAP['All'];
const q = addRegion(baseQuery, regionLabel) + " " + modifier;
newVideos = await getSearchVideos(q, 20);
}
return newVideos;
}

View file

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080';
export async function GET(request: NextRequest) {
const videoId = request.nextUrl.searchParams.get('v');
const formatId = request.nextUrl.searchParams.get('f');
if (!videoId) {
return NextResponse.json({ error: 'No video ID' }, { status: 400 });
}
try {
const url = `${API_BASE}/api/download?v=${encodeURIComponent(videoId)}${formatId ? `&f=${encodeURIComponent(formatId)}` : ''}`;
const res = await fetch(url, {
cache: 'no-store',
});
const data = await res.json();
if (!res.ok) {
return NextResponse.json({ error: data.error || 'Download failed' }, { status: 500 });
}
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error: 'Failed to get download link' }, { status: 500 });
}
}

View file

@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080';
export async function GET(request: NextRequest) {
const videoId = request.nextUrl.searchParams.get('v');
if (!videoId) {
return NextResponse.json({ error: 'No video ID' }, { status: 400 });
}
try {
const res = await fetch(`${API_BASE}/api/formats?v=${encodeURIComponent(videoId)}`, {
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json({ error: 'Failed to fetch formats' }, { status: 500 });
}
const data = await res.json();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch formats' }, { status: 500 });
}
}

View file

@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const fileUrl = request.nextUrl.searchParams.get('url');
if (!fileUrl) {
return NextResponse.json({ error: 'No URL provided' }, { status: 400 });
}
try {
const res = await fetch(fileUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
});
if (!res.ok) {
return NextResponse.json({ error: 'Failed to fetch file' }, { status: res.status });
}
const contentType = res.headers.get('content-type') || 'application/octet-stream';
const contentLength = res.headers.get('content-length');
const headers: HeadersInit = {
'Content-Type': contentType,
'Access-Control-Allow-Origin': '*',
};
if (contentLength) {
headers['Content-Length'] = contentLength;
}
return new NextResponse(res.body, {
status: 200,
headers,
});
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch file' }, { status: 500 });
}
}

View file

@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const url = request.nextUrl.searchParams.get('url');
if (!url) {
return NextResponse.json({ error: 'No URL provided' }, { status: 400 });
}
try {
const res = await fetch(decodeURIComponent(url), {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://www.youtube.com/',
},
});
if (!res.ok) {
return NextResponse.json({ error: 'Failed to fetch' }, { status: 500 });
}
const contentType = res.headers.get('content-type') || 'video/mp4';
const contentLength = res.headers.get('content-length');
const headers = new Headers({
'Content-Type': contentType,
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=3600',
});
if (contentLength) {
headers.set('Content-Length', contentLength);
}
return new NextResponse(res.body, {
status: 200,
headers,
});
} catch (error) {
return NextResponse.json({ error: 'Proxy failed' }, { status: 500 });
}
}

View file

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const videoId = request.nextUrl.searchParams.get('v');
if (!videoId) {
return NextResponse.json({ error: 'No video ID' }, { status: 400 });
}
try {
const res = await fetch(`http://127.0.0.1:8080/api/get_stream_info?v=${videoId}`, {
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json({ error: 'Failed to fetch' }, { status: 500 });
}
const data = await res.json();
const streamUrl = data.original_url || data.stream_url;
const proxyUrl = streamUrl ? `/api/proxy-stream?url=${encodeURIComponent(streamUrl)}` : null;
return NextResponse.json({
streamUrl: proxyUrl,
title: data.title,
thumbnail: data.thumbnail
});
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch stream' }, { status: 500 });
}
}

View file

@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080';
export async function GET(request: NextRequest) {
const channelId = request.nextUrl.searchParams.get('channel_id');
if (!channelId) {
return NextResponse.json({ error: 'No channel ID' }, { status: 400 });
}
try {
const res = await fetch(`${API_BASE}/api/subscribe?channel_id=${encodeURIComponent(channelId)}`, {
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json({ subscribed: false });
}
const data = await res.json();
return NextResponse.json({ subscribed: data.subscribed || false });
} catch (error) {
return NextResponse.json({ subscribed: false });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { channel_id, channel_name } = body;
if (!channel_id) {
return NextResponse.json({ error: 'No channel ID' }, { status: 400 });
}
const res = await fetch(`${API_BASE}/api/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channel_id,
channel_name: channel_name || channel_id,
}),
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json({ error: 'Failed to subscribe' }, { status: 500 });
}
const data = await res.json();
return NextResponse.json({ success: true, ...data });
} catch (error) {
return NextResponse.json({ error: 'Failed to subscribe' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const channelId = request.nextUrl.searchParams.get('channel_id');
if (!channelId) {
return NextResponse.json({ error: 'No channel ID' }, { status: 400 });
}
try {
const res = await fetch(`${API_BASE}/api/subscribe?channel_id=${encodeURIComponent(channelId)}`, {
method: 'DELETE',
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json({ error: 'Failed to unsubscribe' }, { status: 500 });
}
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: 'Failed to unsubscribe' }, { status: 500 });
}
}

View file

@ -0,0 +1,125 @@
import VideoCard from '../../components/VideoCard';
import { notFound } from 'next/navigation';
export const dynamic = 'force-dynamic';
interface ChannelInfo {
id: string;
title: string;
subscriber_count: number;
avatar: string;
}
interface VideoData {
id: string;
title: string;
uploader: string;
thumbnail: string;
view_count: number;
duration: string;
}
// Helper to format subscribers
function formatSubscribers(count: number): string {
if (count >= 1000000) return (count / 1000000).toFixed(2) + 'M';
if (count >= 1000) return (count / 1000).toFixed(0) + 'K';
return count.toString();
}
// We no longer need getAvatarColor as we now use the global --yt-avatar-bg
async function getChannelInfo(id: string) {
try {
const res = await fetch(`http://127.0.0.1:8080/api/channel/info?id=${id}`, { cache: 'no-store' });
if (!res.ok) return null;
return res.json() as Promise<ChannelInfo>;
} catch (e) {
console.error(e);
return null;
}
}
async function getChannelVideos(id: string) {
try {
const res = await fetch(`http://127.0.0.1:8080/api/channel/videos?id=${id}&limit=30`, { cache: 'no-store' });
if (!res.ok) return [];
return res.json() as Promise<VideoData[]>;
} catch (e) {
console.error(e);
return [];
}
}
export default async function ChannelPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const awaitParams = await params;
let channelId = awaitParams.id;
// Clean up URL encoding issues if any
channelId = decodeURIComponent(channelId);
const [info, videos] = await Promise.all([
getChannelInfo(channelId),
getChannelVideos(channelId)
]);
if (!info) {
return notFound();
}
return (
<div style={{ paddingBottom: '48px' }}>
{/* Channel Header */}
<div className="channel-header">
<div
className="channel-avatar"
style={{ backgroundColor: 'var(--yt-avatar-bg)' }}
>
{info.avatar}
</div>
<div className="channel-meta">
<h1 className="channel-name">
{info.title}
</h1>
<div className="channel-stats">
<span style={{ opacity: 0.7 }}>{info.id}</span>
<span style={{ opacity: 0.5 }}></span>
<span>{formatSubscribers(info.subscriber_count)} subscribers</span>
<span style={{ opacity: 0.5 }}></span>
<span>{videos.length} videos</span>
</div>
<button className="channel-subscribe-btn">
Subscribe
</button>
</div>
</div>
{/* Navigation Tabs */}
<div className="channel-tabs">
<div className="channel-tabs-inner">
<div className="channel-tab active">
Videos
<span className="channel-video-count">{videos.length}</span>
</div>
</div>
</div>
{/* Video Grid */}
<div className="channel-video-grid">
{videos.map((v, i) => {
// Enforce correct channel name
v.uploader = info.title;
const stagger = `stagger-${Math.min(i + 1, 6)}`;
return (
<div key={v.id} className={`fade-in-up ${stagger}`} style={{ opacity: 0 }}>
<VideoCard video={v} hideChannelAvatar={true} />
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,124 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState, useRef, useEffect } from 'react';
import { IoSearchOutline, IoMoonOutline, IoSunnyOutline, IoArrowBack } from 'react-icons/io5';
import RegionSelector from './RegionSelector';
import { useTheme } from '../context/ThemeContext';
export default function Header() {
const [searchQuery, setSearchQuery] = useState('');
const [isMobileSearchActive, setIsMobileSearchActive] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const mobileInputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const { theme, toggleTheme } = useTheme();
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
router.push(`/search?q=${encodeURIComponent(searchQuery)}`);
setIsMobileSearchActive(false);
setIsFocused(false);
}
};
useEffect(() => {
if (isMobileSearchActive && mobileInputRef.current) {
mobileInputRef.current.focus();
}
}, [isMobileSearchActive]);
return (
<header className="yt-header">
{!isMobileSearchActive ? (
<>
{/* Left */}
<div className="yt-header-left">
<Link href="/" style={{ display: 'flex', alignItems: 'center', gap: '4px', marginLeft: '12px' }}>
<span style={{ fontSize: '18px', fontWeight: '700', letterSpacing: '-0.5px', fontFamily: 'YouTube Sans, Roboto, Arial, sans-serif' }} className="hidden-mobile">KV-Tube</span>
</Link>
</div>
{/* Center Search Pill - Desktop */}
<div className="yt-header-center hidden-mobile">
<form className="search-container" onSubmit={handleSearch}>
<div className="search-input-wrapper">
<IoSearchOutline size={18} className="search-input-icon" />
<input
ref={inputRef}
type="text"
placeholder="Search videos, channels, and more..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
{searchQuery && (
<button
type="button"
className="search-btn"
onClick={() => { setSearchQuery(''); inputRef.current?.focus(); }}
title="Clear"
style={{ color: 'var(--yt-text-secondary)' }}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
</button>
)}
<button type="submit" className="search-btn" title="Search">
<IoSearchOutline size={18} />
</button>
</div>
</form>
</div>
{/* Right - Region and Theme */}
<div className="yt-header-right">
<button className="yt-icon-btn visible-mobile" onClick={() => setIsMobileSearchActive(true)} title="Search">
<IoSearchOutline size={22} />
</button>
<button className="yt-icon-btn" onClick={toggleTheme} title="Toggle Theme">
{theme === 'dark' ? <IoSunnyOutline size={22} /> : <IoMoonOutline size={22} />}
</button>
<RegionSelector />
</div>
</>
) : (
/* Mobile Search Overlay */
<div className="mobile-search-bar">
<button className="mobile-search-back" onClick={() => setIsMobileSearchActive(false)}>
<IoArrowBack size={22} />
</button>
<form className="search-container" onSubmit={handleSearch} style={{ flex: 1 }}>
<div className="search-input-wrapper">
<IoSearchOutline size={16} className="search-input-icon" />
<input
ref={mobileInputRef}
type="text"
placeholder="Search KV-Tube"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button
type="button"
className="search-btn"
onClick={() => { setSearchQuery(''); mobileInputRef.current?.focus(); }}
title="Clear"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
</button>
)}
</div>
</form>
</div>
)}
</header>
);
}

View file

@ -0,0 +1,122 @@
"use client";
import { useState, useEffect, useRef, useCallback } from 'react';
import VideoCard from './VideoCard';
import { fetchMoreVideos } from '../actions';
import { VideoData } from '../constants';
interface Props {
initialVideos: VideoData[];
currentCategory: string;
regionLabel: string;
}
export default function InfiniteVideoGrid({ initialVideos, currentCategory, regionLabel }: Props) {
const [videos, setVideos] = useState<VideoData[]>(initialVideos);
const [page, setPage] = useState(2);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observerTarget = useRef<HTMLDivElement>(null);
// Reset state if category or region changes, or initialVideos changes
useEffect(() => {
setVideos(initialVideos);
setPage(2);
setHasMore(true);
}, [initialVideos, currentCategory, regionLabel]);
const loadMore = useCallback(async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
try {
const newVideos = await fetchMoreVideos(currentCategory, regionLabel, page);
if (newVideos.length === 0) {
setHasMore(false);
} else {
setVideos(prev => {
// Deduplicate IDs
const existingIds = new Set(prev.map(v => v.id));
const uniqueNewVideos = newVideos.filter(v => !existingIds.has(v.id));
if (uniqueNewVideos.length === 0) {
return prev;
}
return [...prev, ...uniqueNewVideos];
});
setPage(p => p + 1);
// If we get an extremely small yield, consider it the end
if (newVideos.length < 5) {
setHasMore(false);
}
}
} catch (e) {
console.error('Failed to load more videos:', e);
} finally {
setIsLoading(false);
}
}, [currentCategory, regionLabel, page, isLoading, hasMore]);
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ threshold: 0.1, rootMargin: '200px' }
);
const currentTarget = observerTarget.current;
if (currentTarget) {
observer.observe(currentTarget);
}
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
};
}, [loadMore]);
return (
<div>
<div className="fade-in video-grid-mobile" style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '16px',
paddingBottom: '24px'
}}>
{videos.map((v, i) => {
const staggerClass = i < 12 ? `stagger-${Math.min((i % 12) + 1, 6)}` : '';
return (
<div key={`${v.id}-${i}`} className={i < 12 ? `fade-in-up ${staggerClass}` : 'fade-in'}>
<VideoCard video={v} />
</div>
);
})}
</div>
{hasMore && (
<div ref={observerTarget} style={{ padding: '24px 0', display: 'flex', justifyContent: 'center' }}>
{isLoading && (
<div style={{
width: '40px',
height: '40px',
border: '3px solid var(--yt-border)',
borderTopColor: 'var(--yt-brand-red)',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}}></div>
)}
</div>
)}
{!hasMore && videos.length > 0 && (
<div style={{ textAlign: 'center', padding: '24px 0', color: 'var(--yt-text-secondary)', fontSize: '14px' }}>
No more results
</div>
)}
</div>
);
}

View file

@ -0,0 +1,52 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { MdHomeFilled, MdOutlineSubscriptions, MdOutlineVideoLibrary } from 'react-icons/md';
import { SiYoutubeshorts } from 'react-icons/si';
export default function MobileNav() {
const pathname = usePathname();
const navItems = [
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
{ icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Subscriptions', path: '/feed/subscriptions' },
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
];
return (
<nav className="mobile-nav">
{navItems.map((item) => {
const isActive = pathname === item.path;
return (
<Link
key={item.label}
href={item.path}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
gap: '2px',
color: isActive ? 'var(--yt-text-primary)' : 'var(--yt-text-secondary)',
textDecoration: 'none',
transition: 'var(--yt-transition)'
}}
>
<div style={{ color: isActive ? 'var(--yt-text-primary)' : 'inherit' }}>
{item.icon}
</div>
<span style={{
fontSize: '10px',
fontWeight: isActive ? '500' : '400',
}}>
{item.label}
</span>
</Link>
);
})}
</nav>
);
}

View file

@ -0,0 +1,115 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { MdPublic, MdCheck } from 'react-icons/md';
const REGIONS = [
{ code: 'VN', label: 'Vietnam', flag: '🇻🇳' },
{ code: 'US', label: 'United States', flag: '🇺🇸' },
{ code: 'JP', label: 'Japan', flag: '🇯🇵' },
{ code: 'KR', label: 'South Korea', flag: '🇰🇷' },
{ code: 'IN', label: 'India', flag: '🇮🇳' },
{ code: 'GB', label: 'United Kingdom', flag: '🇬🇧' },
{ code: 'GLOBAL', label: 'Global', flag: '🌐' },
];
function getRegionCookie(): string {
if (typeof document === 'undefined') return 'VN';
const match = document.cookie.match(/(?:^|; )region=([^;]*)/);
return match ? decodeURIComponent(match[1]) : 'VN';
}
function setRegionCookie(code: string) {
document.cookie = `region=${encodeURIComponent(code)}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
}
export default function RegionSelector() {
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState('VN');
const menuRef = useRef<HTMLDivElement>(null);
const router = useRouter();
useEffect(() => {
setSelected(getRegionCookie());
}, []);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (code: string) => {
setSelected(code);
setRegionCookie(code);
setIsOpen(false);
router.refresh();
};
const current = REGIONS.find(r => r.code === selected) || REGIONS[0];
return (
<div ref={menuRef} style={{ position: 'relative' }}>
<button
className="yt-icon-btn"
onClick={() => setIsOpen(!isOpen)}
title={`Region: ${current.label}`}
style={{ fontSize: '18px', width: '40px', height: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<span style={{ fontSize: '20px' }}>{current.flag === '🌐' ? undefined : current.flag}</span>
{current.flag === '🌐' && <MdPublic size={22} />}
</button>
{isOpen && (
<div className="dropdown-animated" style={{
position: 'absolute',
top: '44px',
right: 0,
backgroundColor: 'var(--yt-background)',
border: '1px solid var(--yt-border)',
borderRadius: '12px',
boxShadow: 'var(--yt-shadow-lg)',
padding: '8px 0',
zIndex: 1000,
minWidth: '200px',
overflow: 'hidden',
transformOrigin: 'top right',
}}>
<div style={{ padding: '8px 16px', fontSize: '14px', fontWeight: 'bold', borderBottom: '1px solid var(--yt-border)', marginBottom: '4px', color: 'var(--yt-text-primary)' }}>
Select Region
</div>
{REGIONS.map(r => (
<button
key={r.code}
onClick={() => handleSelect(r.code)}
className="format-item-hover"
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
width: '100%',
padding: '10px 16px',
backgroundColor: r.code === selected ? 'var(--yt-hover)' : 'transparent',
border: 'none',
color: 'var(--yt-text-primary)',
textAlign: 'left',
cursor: 'pointer',
fontSize: '14px',
transition: 'background-color 0.2s'
}}
>
<span style={{ fontSize: '20px' }}>{r.flag}</span>
<span style={{ fontWeight: r.code === selected ? '600' : '400', flex: 1 }}>{r.label}</span>
{r.code === selected && <MdCheck size={18} style={{ color: 'var(--yt-blue)', flexShrink: 0 }} />}
</button>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,58 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { MdHomeFilled, MdOutlineSubscriptions, MdOutlineVideoLibrary } from 'react-icons/md';
import { SiYoutubeshorts } from 'react-icons/si';
export default function Sidebar() {
const pathname = usePathname();
const navItems = [
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
{ icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Subscriptions', path: '/feed/subscriptions' },
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
];
return (
<aside className="yt-sidebar-mini">
{navItems.map((item) => {
const isActive = pathname === item.path;
return (
<Link
key={item.label}
href={item.path}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '16px 0 14px 0',
borderRadius: '10px',
backgroundColor: isActive ? 'var(--yt-hover)' : 'transparent',
marginBottom: '4px',
transition: 'var(--yt-transition)',
gap: '4px',
position: 'relative',
}}
className="yt-sidebar-item"
>
{isActive && <div className="sidebar-active-indicator" />}
<div style={{ color: 'var(--yt-text-primary)', transition: 'transform 0.15s ease' }}>
{item.icon}
</div>
<span style={{
fontSize: '10px',
fontWeight: isActive ? '600' : '400',
color: 'var(--yt-text-primary)',
letterSpacing: '0.3px'
}}>
{item.label}
</span>
</Link>
);
})}
</aside>
);
}

View file

@ -0,0 +1,93 @@
'use client';
import { useState, useEffect } from 'react';
interface SubscribeButtonProps {
channelId?: string;
channelName?: string;
initialSubscribed?: boolean;
}
export default function SubscribeButton({ channelId, channelName, initialSubscribed }: SubscribeButtonProps) {
const [isSubscribed, setIsSubscribed] = useState(initialSubscribed || false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (initialSubscribed !== undefined) return;
if (!channelId) return;
const checkSubscription = async () => {
try {
const res = await fetch(`/api/subscribe?channel_id=${encodeURIComponent(channelId)}`);
if (res.ok) {
const data = await res.json();
setIsSubscribed(data.subscribed);
}
} catch (error) {
console.error('Failed to check subscription:', error);
}
};
checkSubscription();
}, [channelId, initialSubscribed]);
const handleSubscribe = async () => {
if (loading || !channelId) return;
setLoading(true);
if (!isSubscribed) {
try {
const res = await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channel_id: channelId,
channel_name: channelName || channelId,
}),
});
if (res.ok) {
setIsSubscribed(true);
}
} catch (error) {
console.error('Failed to subscribe:', error);
} finally {
setLoading(false);
}
} else {
try {
const res = await fetch(`/api/subscribe?channel_id=${encodeURIComponent(channelId)}`, {
method: 'DELETE',
});
if (res.ok) {
setIsSubscribed(false);
}
} catch (error) {
console.error('Failed to unsubscribe:', error);
} finally {
setLoading(false);
}
}
};
if (!channelId) return null;
return (
<button
onClick={handleSubscribe}
disabled={loading}
style={{
backgroundColor: isSubscribed ? 'var(--yt-hover)' : 'var(--foreground)',
color: isSubscribed ? 'var(--yt-text-primary)' : 'var(--background)',
border: 'none',
borderRadius: '20px',
padding: '0 16px',
height: '36px',
fontSize: '14px',
fontWeight: '500',
cursor: loading ? 'wait' : 'pointer',
transition: 'all 0.2s ease',
minWidth: '120px',
}}
>
{loading ? '...' : isSubscribed ? 'Subscribed' : 'Subscribe'}
</button>
);
}

View file

@ -0,0 +1,72 @@
import Link from 'next/link';
interface VideoData {
id: string;
title: string;
uploader: string;
channel_id?: string;
thumbnail: string;
view_count: number;
duration: string;
uploaded_date?: string;
}
function formatViews(views: number): string {
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
if (views >= 1000) return (views / 1000).toFixed(1) + 'K';
return views.toString();
}
function getRelativeTime(id: string): string {
const times = ['2 hours ago', '5 hours ago', '1 day ago', '3 days ago', '1 week ago', '2 weeks ago', '1 month ago'];
const index = (id.charCodeAt(0) || 0) % times.length;
return times[index];
}
export default function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannelAvatar?: boolean }) {
const relativeTime = video.uploaded_date || getRelativeTime(video.id);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', width: '100%', marginBottom: '12px' }} className="videocard-container">
<Link href={`/watch?v=${video.id}`} style={{ position: 'relative', display: 'block', width: '100%', aspectRatio: '16/9', overflow: 'hidden', borderRadius: '12px' }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={video.thumbnail}
alt={video.title}
style={{ width: '100%', height: '100%', objectFit: 'cover', backgroundColor: 'var(--yt-hover)' }}
className="videocard-thumb"
/>
{video.duration && (
<div className="duration-badge" style={{ position: 'absolute', bottom: '8px', right: '8px' }}>
{video.duration}
</div>
)}
</Link>
<div style={{ display: 'flex', gap: '12px', padding: '0 12px' }} className="videocard-info">
{/* Video Info */}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<Link href={`/watch?v=${video.id}`} style={{ textDecoration: 'none' }}>
<h3 className="truncate-2-lines" style={{ fontSize: '16px', fontWeight: 500, lineHeight: '22px', margin: 0, color: 'var(--yt-text-primary)', transition: 'color 0.2s' }}>
{video.title}
</h3>
</Link>
<div style={{ marginTop: '4px' }}>
{video.channel_id ? (
<Link href={`/channel/${video.channel_id}`} style={{ fontSize: '14px', color: 'var(--yt-text-secondary)', display: 'block', textDecoration: 'none', transition: 'color 0.2s' }} className="channel-link-hover">
{video.uploader}
</Link>
) : (
<div style={{ fontSize: '14px', color: 'var(--yt-text-secondary)', display: 'block' }}>
{video.uploader}
</div>
)}
<div style={{ fontSize: '14px', color: 'var(--yt-text-secondary)', marginTop: '2px' }}>
{formatViews(video.view_count)} views {relativeTime}
</div>
</div>
</div>
</div>
</div>
);
}

35
frontend/app/constants.ts Executable file
View file

@ -0,0 +1,35 @@
export const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080';
export interface VideoData {
id: string;
title: string;
uploader: string;
thumbnail: string;
view_count: number;
duration: string;
avatar_url?: string;
}
export const CATEGORY_MAP: Record<string, string> = {
'All': 'trending videos 2025',
'Watched': 'watched history',
'Suggested': 'suggested videos',
'Tech': 'latest smart technology gadgets reviews',
'Music': 'music hits',
'Movies': 'movie trailers',
'News': 'latest news',
'Trending': 'trending videos',
'Podcasts': 'popular podcasts',
'Live': 'live stream',
'Gaming': 'gaming trending',
'Sports': 'sports highlights'
};
export const ALL_CATEGORY_SECTIONS = [
{ id: 'trending', title: 'Trending Now', query: 'trending videos 2025' },
{ id: 'music', title: 'Music Hits', query: 'music hits 2025' },
{ id: 'tech', title: 'Tech & Gadgets', query: 'latest smart technology gadgets reviews' },
{ id: 'gaming', title: 'Gaming', query: 'gaming trending' },
{ id: 'sports', title: 'Sports Highlights', query: 'sports highlights' },
{ id: 'news', title: 'Latest News', query: 'latest news' },
];

View file

@ -0,0 +1,45 @@
'use client';
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('dark');
useEffect(() => {
const savedTheme = localStorage.getItem('theme') as Theme | null;
if (savedTheme) {
setTheme(savedTheme);
document.documentElement.setAttribute('data-theme', savedTheme);
}
}, []);
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
document.documentElement.setAttribute('data-theme', newTheme);
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

BIN
frontend/app/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,188 @@
import Link from 'next/link';
interface VideoData {
id: string;
title: string;
uploader: string;
thumbnail: string;
view_count: number;
duration: string;
}
interface Subscription {
id: number;
channel_id: string;
channel_name: string;
channel_avatar: string;
}
async function getHistory() {
try {
const res = await fetch('http://127.0.0.1:8080/api/history?limit=20', { cache: 'no-store' });
if (!res.ok) return [];
return res.json() as Promise<VideoData[]>;
} catch {
return [];
}
}
async function getSubscriptions() {
try {
const res = await fetch('http://127.0.0.1:8080/api/subscriptions', { cache: 'no-store' });
if (!res.ok) return [];
return res.json() as Promise<Subscription[]>;
} catch {
return [];
}
}
function formatViews(views: number): string {
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
if (views >= 1000) return (views / 1000).toFixed(1) + 'K';
return views.toString();
}
export default async function LibraryPage() {
const [history, subscriptions] = await Promise.all([getHistory(), getSubscriptions()]);
return (
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
{/* Subscriptions Section */}
{subscriptions.length > 0 && (
<section style={{ marginBottom: '40px' }}>
<h2 className="section-heading" style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
Subscriptions
</h2>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
{subscriptions.map((sub) => (
<Link
key={sub.channel_id}
href={`/channel/${sub.channel_id}`}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
padding: '16px',
borderRadius: '12px',
backgroundColor: 'var(--yt-hover)',
minWidth: '120px',
transition: 'background-color 0.2s',
}}
className="card-hover-lift"
>
<div style={{
width: '64px',
height: '64px',
borderRadius: '50%',
background: 'var(--yt-avatar-bg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '28px',
color: '#fff',
fontWeight: '600',
}}>
{sub.channel_avatar || (sub.channel_name ? sub.channel_name[0].toUpperCase() : '?')}
</div>
<span style={{
fontSize: '14px',
fontWeight: '500',
color: 'var(--yt-text-primary)',
textAlign: 'center',
maxWidth: '100px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{sub.channel_name || sub.channel_id}
</span>
</Link>
))}
</div>
</section>
)}
{/* Watch History Section */}
<section>
<h2 className="section-heading" style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
Watch History
</h2>
{history.length === 0 ? (
<div style={{
padding: '48px',
textAlign: 'center',
color: 'var(--yt-text-secondary)',
backgroundColor: 'var(--yt-hover)',
borderRadius: '12px',
}}>
<p style={{ fontSize: '16px', marginBottom: '8px' }}>No videos watched yet</p>
<p style={{ fontSize: '14px' }}>Videos you watch will appear here</p>
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '16px',
}}>
{history.map((video) => (
<Link
key={video.id}
href={`/watch?v=${video.id}`}
className="videocard-container card-hover-lift"
style={{
display: 'flex',
flexDirection: 'column',
gap: '12px',
borderRadius: '12px',
overflow: 'hidden',
}}
>
<div style={{ position: 'relative', aspectRatio: '16/9', borderRadius: '12px', overflow: 'hidden' }}>
<img
src={video.thumbnail}
alt={video.title}
className="videocard-thumb"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
{video.duration && (
<div className="duration-badge">{video.duration}</div>
)}
</div>
<div className="videocard-info" style={{ padding: '0 4px' }}>
<h3 style={{
fontSize: '14px',
fontWeight: '500',
lineHeight: '20px',
color: 'var(--yt-text-primary)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
marginBottom: '4px',
}}>
{video.title}
</h3>
<p style={{
fontSize: '12px',
color: 'var(--yt-text-secondary)',
}}>
{video.uploader}
</p>
{video.view_count > 0 && (
<p style={{
fontSize: '12px',
color: 'var(--yt-text-secondary)',
}}>
{formatViews(video.view_count)} views
</p>
)}
</div>
</Link>
))}
</div>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,151 @@
import Link from 'next/link';
interface VideoData {
id: string;
title: string;
uploader: string;
channel_id: string;
thumbnail: string;
view_count: number;
duration: string;
}
interface Subscription {
id: number;
channel_id: string;
channel_name: string;
channel_avatar: string;
}
async function getSubscriptions() {
try {
const res = await fetch('http://127.0.0.1:8080/api/subscriptions', { cache: 'no-store' });
if (!res.ok) return [];
return res.json() as Promise<Subscription[]>;
} catch {
return [];
}
}
async function getChannelVideos(channelId: string, limit: number = 5) {
try {
const res = await fetch(`http://127.0.0.1:8080/api/channel/videos?id=${channelId}&limit=${limit}`, { cache: 'no-store' });
if (!res.ok) return [];
return res.json() as Promise<VideoData[]>;
} catch {
return [];
}
}
function formatViews(views: number): string {
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
if (views >= 1000) return (views / 1000).toFixed(1) + 'K';
return views.toString();
}
export default async function SubscriptionsPage() {
const subscriptions = await getSubscriptions();
if (subscriptions.length === 0) {
return (
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--yt-text-secondary)' }}>
<h2 style={{ marginBottom: '16px', color: 'var(--yt-text-primary)' }}>No subscriptions yet</h2>
<p>Subscribe to channels to see their latest videos here</p>
</div>
);
}
const videosPerChannel = await Promise.all(
subscriptions.map(async (sub) => ({
subscription: sub,
videos: await getChannelVideos(sub.channel_id, 5),
}))
);
return (
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
<h1 style={{ fontSize: '24px', fontWeight: '600', marginBottom: '24px' }}>Subscriptions</h1>
{videosPerChannel.map(({ subscription, videos }) => (
<section key={subscription.channel_id} style={{ marginBottom: '32px' }}>
<Link
href={`/channel/${subscription.channel_id}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '16px',
}}
>
<div style={{
width: '40px',
height: '40px',
borderRadius: '50%',
background: 'var(--yt-avatar-bg)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
color: '#fff',
fontWeight: '600',
}}>
{subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?'}
</div>
<h2 style={{ fontSize: '18px', fontWeight: '500' }}>{subscription.channel_name || subscription.channel_id}</h2>
</Link>
{videos.length > 0 ? (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gap: '16px',
}}>
{videos.map((video) => (
<Link
key={video.id}
href={`/watch?v=${video.id}`}
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
borderRadius: '12px',
overflow: 'hidden',
}}
className="card-hover-lift"
>
<div style={{ position: 'relative', aspectRatio: '16/9', borderRadius: '12px', overflow: 'hidden' }}>
<img
src={video.thumbnail}
alt={video.title}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
{video.duration && (
<div className="duration-badge">{video.duration}</div>
)}
</div>
<h3 style={{
fontSize: '14px',
fontWeight: '500',
lineHeight: '20px',
color: 'var(--yt-text-primary)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}>
{video.title}
</h3>
<p style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>
{formatViews(video.view_count)} views
</p>
</Link>
))}
</div>
) : (
<p style={{ color: 'var(--yt-text-secondary)', fontSize: '14px' }}>No videos available</p>
)}
</section>
))}
</div>
);
}

1387
frontend/app/globals.css Executable file

File diff suppressed because it is too large Load diff

55
frontend/app/layout.tsx Executable file
View file

@ -0,0 +1,55 @@
import type { Metadata } from 'next';
import { Roboto } from 'next/font/google';
import './globals.css';
import Header from './components/Header';
import Sidebar from './components/Sidebar';
import MobileNav from './components/MobileNav';
const roboto = Roboto({
weight: ['400', '500', '700'],
subsets: ['latin'],
display: 'swap',
});
export const metadata: Metadata = {
title: 'KV-Tube',
description: 'A pixel perfect YouTube clone',
};
import { ThemeProvider } from './context/ThemeContext';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={roboto.className} suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var theme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {}
})();
`,
}}
/>
</head>
<body>
<ThemeProvider>
<Header />
<Sidebar />
<main className="yt-main-content">
{children}
</main>
<MobileNav />
</ThemeProvider>
</body>
</html>
);
}

116
frontend/app/page.tsx Executable file
View file

@ -0,0 +1,116 @@
import Link from 'next/link';
import { cookies } from 'next/headers';
import InfiniteVideoGrid from './components/InfiniteVideoGrid';
import {
getSearchVideos,
getHistoryVideos,
getSuggestedVideos
} from './actions';
import {
VideoData,
CATEGORY_MAP,
ALL_CATEGORY_SECTIONS,
addRegion,
getRandomModifier
} from './utils';
export const dynamic = 'force-dynamic';
const REGION_LABELS: Record<string, string> = {
VN: 'Vietnam',
US: 'United States',
JP: 'Japan',
KR: 'South Korea',
IN: 'India',
GB: 'United Kingdom',
GLOBAL: '',
};
export default async function Home({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
const awaitParams = await searchParams;
const currentCategory = (awaitParams.category as string) || 'All';
const isAllCategory = currentCategory === 'All';
const cookieStore = await cookies();
const regionCode = cookieStore.get('region')?.value || 'VN';
const regionLabel = REGION_LABELS[regionCode] || '';
let gridVideos: VideoData[] = [];
const randomMod = getRandomModifier();
if (isAllCategory) {
// Fetch top 6 from each category to build a robust recommendation feed
const promises = ALL_CATEGORY_SECTIONS.map(async (sec) => {
return await getSearchVideos(addRegion(sec.query, regionLabel) + ' ' + randomMod, 6);
});
const results = await Promise.all(promises);
// Interleave the results: 1st from Trending, 1st from Music, ... 2nd from Trending, etc.
const maxLen = Math.max(...results.map(arr => arr.length));
const interleavedList: VideoData[] = [];
const seenIds = new Set<string>();
for (let i = 0; i < maxLen; i++) {
for (const categoryResult of results) {
if (i < categoryResult.length) {
const video = categoryResult[i];
if (!seenIds.has(video.id)) {
interleavedList.push(video);
seenIds.add(video.id);
}
}
}
}
gridVideos = interleavedList;
} else if (currentCategory === 'Watched') {
gridVideos = await getHistoryVideos(50);
} else if (currentCategory === 'Suggested') {
gridVideos = await getSuggestedVideos(20);
} else {
const searchQuery = CATEGORY_MAP[currentCategory] || CATEGORY_MAP['All'];
gridVideos = await getSearchVideos(addRegion(searchQuery, regionLabel) + ' ' + randomMod, 30);
}
const categoriesList = Object.keys(CATEGORY_MAP);
return (
<div style={{ paddingTop: '12px' }}>
{/* Category Chips Scrollbar */}
<div style={{ display: 'flex', gap: '12px', padding: '0 12px', marginBottom: '16px', overflowX: 'auto', justifyContent: 'center' }} className="chips-container hide-scrollbox">
{categoriesList.map((cat) => {
const isActive = cat === currentCategory;
return (
<Link key={cat} href={cat === 'All' ? '/' : `/?category=${encodeURIComponent(cat)}`} style={{ textDecoration: 'none' }}>
<button
className={`chip ${isActive ? 'active' : ''}`}
style={{
fontSize: '14px',
whiteSpace: 'nowrap',
transition: 'var(--yt-transition)',
backgroundColor: isActive ? 'var(--foreground)' : 'var(--yt-hover)',
color: isActive ? 'var(--background)' : 'var(--yt-text-primary)'
}}
>
{cat}
</button>
</Link>
);
})}
</div>
<div style={{ padding: '0 12px' }} className="main-container-mobile">
<InfiniteVideoGrid
initialVideos={gridVideos}
currentCategory={currentCategory}
regionLabel={regionLabel}
/>
</div>
</div>
);
}

173
frontend/app/search/page.tsx Executable file
View file

@ -0,0 +1,173 @@
export const dynamic = 'force-dynamic';
import { Suspense } from 'react';
import Link from 'next/link';
import { cookies } from 'next/headers';
interface VideoData {
id: string;
title: string;
uploader: string;
channel_id?: string;
thumbnail: string;
view_count: number;
duration: string;
description: string;
avatar_url?: string;
uploaded_date?: string;
}
function formatViews(views: number): string {
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
if (views >= 1000) return (views / 1000).toFixed(1) + 'K';
return views.toString();
}
async function fetchSearchResults(query: string) {
try {
const res = await fetch(`http://127.0.0.1:8080/api/search?q=${encodeURIComponent(query)}`, { cache: 'no-store' });
if (!res.ok) return [];
return res.json() as Promise<VideoData[]>;
} catch (e) {
console.error(e);
return [];
}
}
function SearchSkeleton() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px', maxWidth: '1096px', margin: '0 auto' }}>
{[1, 2, 3, 4].map(i => (
<div key={i} style={{ display: 'flex', gap: '16px' }} className={`fade-in-up stagger-${i}`}>
<div className="skeleton" style={{ width: '360px', minWidth: '360px', aspectRatio: '16/9', flexShrink: 0 }} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '8px', paddingTop: '4px' }}>
<div className="skeleton skeleton-line" style={{ width: '90%', height: '18px' }} />
<div className="skeleton skeleton-line" style={{ width: '70%', height: '18px' }} />
<div className="skeleton skeleton-line-short" style={{ marginTop: '8px' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '8px' }}>
<div className="skeleton skeleton-avatar" style={{ width: '24px', height: '24px' }} />
<div className="skeleton skeleton-line" style={{ width: '120px' }} />
</div>
<div className="skeleton skeleton-line" style={{ width: '80%', marginTop: '8px' }} />
</div>
</div>
))}
</div>
);
}
async function SearchResults({ query }: { query: string }) {
const videos = await fetchSearchResults(query);
if (videos.length === 0) {
return (
<div className="fade-in" style={{ padding: '48px 24px', color: 'var(--yt-text-secondary)', textAlign: 'center' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🔍</div>
<div style={{ fontSize: '18px', fontWeight: 500, color: 'var(--yt-text-primary)', marginBottom: '8px' }}>
No results found
</div>
<div>Try different keywords or check your spelling</div>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '1096px', margin: '0 auto' }} className="search-results-container">
{videos.map((v, i) => {
const firstLetter = v.uploader ? v.uploader.charAt(0).toUpperCase() : '?';
const relativeTime = v.uploaded_date || '3 weeks ago';
const staggerClass = `stagger-${Math.min(i + 1, 6)}`;
return (
<Link href={`/watch?v=${v.id}`} key={v.id} style={{ display: 'flex', gap: '16px', textDecoration: 'none', color: 'inherit', maxWidth: '1096px', borderRadius: '12px', padding: '8px', margin: '-8px', transition: 'background-color 0.2s ease' }} className={`search-result-item search-result-hover fade-in-up ${staggerClass}`}>
{/* Thumbnail */}
<div style={{ position: 'relative', width: '360px', minWidth: '360px', aspectRatio: '16/9', flexShrink: 0, overflow: 'hidden', borderRadius: '8px' }} className="search-result-thumb-container">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={v.thumbnail}
alt={v.title}
style={{ width: '100%', height: '100%', objectFit: 'cover', backgroundColor: '#272727' }}
className="search-result-thumb"
/>
{v.duration && (
<span className="duration-badge" style={{ position: 'absolute', bottom: '8px', right: '8px' }}>
{v.duration}
</span>
)}
</div>
{/* Search Result Info */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', paddingTop: '0px' }} className="search-result-info">
<h3 style={{ fontSize: '18px', fontWeight: '400', lineHeight: '26px', margin: '0 0 4px 0', color: 'var(--yt-text-primary)' }} className="search-result-title">
{v.title}
</h3>
<div style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', marginBottom: '12px' }}>
{formatViews(v.view_count)} views {relativeTime}
</div>
{/* Channel block inline */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '24px', height: '24px', borderRadius: '50%', background: 'var(--yt-avatar-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '11px', color: '#fff', overflow: 'hidden', fontWeight: 600 }}>
{v.avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={v.avatar_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : firstLetter}
</div>
<span style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>{v.uploader}</span>
</div>
</div>
<div className="truncate-2-lines" style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', lineHeight: '18px' }}>
{v.description || 'No description provided.'}
</div>
</div>
</Link>
);
})}
</div>
);
}
const REGION_LABELS: Record<string, string> = {
VN: 'Vietnam',
US: 'United States',
JP: 'Japan',
KR: 'South Korea',
IN: 'India',
GB: 'United Kingdom',
GLOBAL: '',
};
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
const awaitParams = await searchParams;
const q = awaitParams.q as string;
if (!q) {
return (
<div className="fade-in" style={{ padding: '48px 24px', color: 'var(--yt-text-secondary)', textAlign: 'center' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🔍</div>
<div style={{ fontSize: '18px', fontWeight: 500, color: 'var(--yt-text-primary)', marginBottom: '8px' }}>
Search KV-Tube
</div>
<div>Enter a search term above to find videos</div>
</div>
);
}
const cookieStore = await cookies();
const regionCode = cookieStore.get('region')?.value || 'VN';
const regionLabel = REGION_LABELS[regionCode] || '';
const biasedQuery = regionLabel ? `${q} ${regionLabel}` : q;
return (
<div style={{ padding: '16px 24px 24px 24px' }} className="search-page-container">
<Suspense fallback={<SearchSkeleton />}>
<SearchResults query={biasedQuery} />
</Suspense>
</div>
);
}

510
frontend/app/shorts/page.tsx Executable file
View file

@ -0,0 +1,510 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { IoHeart, IoHeartOutline, IoChatbubbleOutline, IoShareOutline, IoEllipsisHorizontal, IoMusicalNote, IoRefresh, IoPlay, IoVolumeMute, IoVolumeHigh } from 'react-icons/io5';
declare global {
interface Window {
Hls: any;
}
}
interface ShortVideo {
id: string;
title: string;
uploader: string;
thumbnail: string;
view_count: number;
duration?: string;
}
interface StreamInfo {
stream_url: string;
error?: string;
}
const SHORTS_QUERIES = ['#shorts', 'youtube shorts viral', 'tiktok short', 'shorts funny', 'shorts music'];
const RANDOM_MODIFIERS = ['viral', 'popular', 'new', 'best', 'trending', 'hot', 'fresh', '2025'];
function getRandomModifier(): string {
return RANDOM_MODIFIERS[Math.floor(Math.random() * RANDOM_MODIFIERS.length)];
}
function parseDuration(duration: string): number {
if (!duration) return 0;
const parts = duration.split(':').map(Number);
if (parts.length === 2) return parts[0] * 60 + parts[1];
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
return 0;
}
function formatViews(views: number): string {
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
if (views >= 1000) return (views / 1000).toFixed(1) + 'K';
return views.toString();
}
async function fetchShorts(page: number): Promise<ShortVideo[]> {
try {
const query = SHORTS_QUERIES[page % SHORTS_QUERIES.length] + ' ' + getRandomModifier();
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=20`, { cache: 'no-store' });
if (!res.ok) return [];
const data = await res.json();
return data.filter((v: ShortVideo) => parseDuration(v.duration || '') <= 90);
} catch {
return [];
}
}
function ShortCard({ video, isActive }: { video: ShortVideo; isActive: boolean }) {
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(Math.floor(Math.random() * 50000) + 1000);
const [commentCount] = useState(Math.floor(Math.random() * 1000) + 50);
const [muted, setMuted] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [useFallback, setUseFallback] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<any>(null);
const [showControls, setShowControls] = useState(false);
useEffect(() => {
if (!isActive) {
if (videoRef.current) {
videoRef.current.pause();
}
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
return;
}
if (useFallback) return;
const loadStream = async () => {
setLoading(true);
setError(false);
try {
const res = await fetch(`/api/get_stream_info?v=${video.id}`);
const data: StreamInfo = await res.json();
if (data.error || !data.stream_url) {
throw new Error(data.error || 'No stream URL');
}
const videoEl = videoRef.current;
if (!videoEl) return;
const streamUrl = data.stream_url;
const isHLS = streamUrl.includes('.m3u8') || streamUrl.includes('manifest');
if (isHLS && window.Hls && window.Hls.isSupported()) {
if (hlsRef.current) {
hlsRef.current.destroy();
}
const hls = new window.Hls({
xhrSetup: (xhr: XMLHttpRequest) => {
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
},
});
hlsRef.current = hls;
hls.loadSource(streamUrl);
hls.attachMedia(videoEl);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
setLoading(false);
videoEl.muted = muted;
videoEl.play().catch(() => {});
});
hls.on(window.Hls.Events.ERROR, () => {
setError(true);
setUseFallback(true);
});
} else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
videoEl.src = streamUrl;
videoEl.muted = muted;
videoEl.addEventListener('loadedmetadata', () => {
setLoading(false);
videoEl.play().catch(() => {});
}, { once: true });
} else {
videoEl.src = streamUrl;
videoEl.muted = muted;
videoEl.addEventListener('loadeddata', () => {
setLoading(false);
videoEl.play().catch(() => {});
}, { once: true });
}
} catch (err) {
console.error('Stream load error:', err);
setError(true);
setUseFallback(true);
}
};
const timeout = setTimeout(() => {
if (window.Hls) {
loadStream();
} else {
const checkHls = setInterval(() => {
if (window.Hls) {
clearInterval(checkHls);
loadStream();
}
}, 100);
setTimeout(() => {
clearInterval(checkHls);
if (!window.Hls) {
setUseFallback(true);
}
}, 3000);
}
}, 100);
return () => {
clearTimeout(timeout);
};
}, [isActive, video.id, useFallback, muted]);
const toggleMute = () => {
if (videoRef.current) {
videoRef.current.muted = !videoRef.current.muted;
setMuted(videoRef.current.muted);
}
};
const handleShare = async () => {
try {
if (navigator.share) {
await navigator.share({
title: video.title,
url: `${window.location.origin}/watch?v=${video.id}`,
});
} else {
await navigator.clipboard.writeText(`${window.location.origin}/watch?v=${video.id}`);
}
} catch {}
};
const handleRetry = () => {
setUseFallback(false);
setError(false);
setLoading(false);
};
return (
<div
style={cardWrapperStyle}
onMouseEnter={() => setShowControls(true)}
onMouseLeave={() => setShowControls(false)}
>
<div style={cardContainerStyle}>
{useFallback ? (
<iframe
src={isActive ? `https://www.youtube.com/embed/${video.id}?autoplay=1&loop=1&playlist=${video.id}&mute=${muted ? 1 : 0}&rel=0&modestbranding=1&playsinline=1&controls=1` : ''}
style={iframeStyle}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title={video.title}
/>
) : (
<>
<video
ref={videoRef}
style={videoStyle}
loop
playsInline
poster={video.thumbnail}
onClick={() => videoRef.current?.paused ? videoRef.current?.play() : videoRef.current?.pause()}
/>
{loading && (
<div style={loadingOverlayStyle}>
<div style={spinnerStyle}></div>
</div>
)}
{error && !useFallback && (
<div style={errorOverlayStyle}>
<button onClick={handleRetry} style={retryBtnStyle}>
Retry
</button>
<button onClick={() => setUseFallback(true)} style={retryBtnStyle}>
YouTube Player
</button>
</div>
)}
</>
)}
<div style={gradientStyle} />
<div style={infoStyle}>
<div style={channelStyle}>
<div style={avatarStyle}>{video.uploader?.[0]?.toUpperCase() || '?'}</div>
<span style={{ fontWeight: '600', fontSize: '13px' }}>@{video.uploader || 'Unknown'}</span>
</div>
<p style={titleStyle}>{video.title}</p>
<div style={musicStyle}><IoMusicalNote size={12} /><span>Original Sound</span></div>
</div>
<div style={actionsStyle}>
<button onClick={() => { setLiked(!liked); setLikeCount(p => liked ? p - 1 : p + 1); }} style={actionBtnStyle}>
{liked ? <IoHeart size={26} color="#ff0050" /> : <IoHeartOutline size={26} />}
<span style={actionLabelStyle}>{formatViews(likeCount)}</span>
</button>
<button style={actionBtnStyle}>
<IoChatbubbleOutline size={24} />
<span style={actionLabelStyle}>{formatViews(commentCount)}</span>
</button>
<button onClick={handleShare} style={actionBtnStyle}>
<IoShareOutline size={24} />
<span style={actionLabelStyle}>Share</span>
</button>
<button onClick={toggleMute} style={actionBtnStyle}>
{muted ? <IoVolumeMute size={24} /> : <IoVolumeHigh size={24} />}
<span style={actionLabelStyle}>{muted ? 'Unmute' : 'Mute'}</span>
</button>
<a
href={`https://www.youtube.com/watch?v=${video.id}`}
target="_blank"
rel="noopener noreferrer"
style={actionBtnStyle}
>
<IoEllipsisHorizontal size={22} />
</a>
</div>
{showControls && (
<a
href={`https://www.youtube.com/watch?v=${video.id}`}
target="_blank"
rel="noopener noreferrer"
style={openBtnStyle}
>
Open
</a>
)}
</div>
</div>
);
}
const cardWrapperStyle: React.CSSProperties = {
position: 'relative',
width: '100%',
height: '100%',
scrollSnapAlign: 'start',
scrollSnapStop: 'always',
background: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
const cardContainerStyle: React.CSSProperties = {
position: 'relative',
width: '100%',
maxWidth: '400px',
height: '100%',
maxHeight: 'calc(100vh - 120px)',
borderRadius: '12px',
overflow: 'hidden',
background: '#0f0f0f',
};
const videoStyle: React.CSSProperties = {
width: '100%',
height: '100%',
objectFit: 'cover',
background: '#000',
cursor: 'pointer',
};
const iframeStyle: React.CSSProperties = { width: '100%', height: '100%', border: 'none' };
const loadingOverlayStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.5)',
};
const errorOverlayStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
background: 'rgba(0,0,0,0.8)',
};
const retryBtnStyle: React.CSSProperties = {
padding: '8px 16px',
background: '#ff0050',
color: '#fff',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '13px',
};
const gradientStyle: React.CSSProperties = {
position: 'absolute', bottom: 0, left: 0, right: 0, height: '50%',
background: 'linear-gradient(transparent, rgba(0,0,0,0.85))', pointerEvents: 'none',
};
const infoStyle: React.CSSProperties = {
position: 'absolute', bottom: '16px', left: '16px', right: '70px', color: '#fff', pointerEvents: 'none',
};
const channelStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' };
const avatarStyle: React.CSSProperties = {
width: '32px', height: '32px', borderRadius: '50%',
background: 'linear-gradient(135deg, #ff0050, #ff4081)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '13px', fontWeight: '700', color: '#fff', flexShrink: 0,
};
const titleStyle: React.CSSProperties = {
fontSize: '13px', lineHeight: '18px', margin: '0 0 6px 0',
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
};
const musicStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: '4px', fontSize: '11px', opacity: 0.7 };
const actionsStyle: React.CSSProperties = {
position: 'absolute', right: '10px', bottom: '80px',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '16px',
};
const actionBtnStyle: React.CSSProperties = {
background: 'none', border: 'none', color: '#fff', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '2px',
};
const actionLabelStyle: React.CSSProperties = { fontSize: '10px', fontWeight: '500' };
const openBtnStyle: React.CSSProperties = {
position: 'absolute',
top: '10px',
right: '10px',
padding: '6px 10px',
background: 'rgba(0,0,0,0.8)',
color: '#fff',
borderRadius: '4px',
textDecoration: 'none',
fontSize: '11px',
zIndex: 10,
};
const spinnerStyle: React.CSSProperties = {
width: '40px',
height: '40px',
border: '3px solid #333',
borderTopColor: '#ff0050',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
};
export default function ShortsPage() {
const [shorts, setShorts] = useState<ShortVideo[]>([]);
const [activeIndex, setActiveIndex] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [page, setPage] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const activeRef = useRef(0);
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/hls.js@latest';
script.async = true;
if (!document.querySelector('script[src*="hls.js"]')) {
document.head.appendChild(script);
}
}, []);
useEffect(() => { activeRef.current = activeIndex; }, [activeIndex]);
useEffect(() => { fetchShorts(0).then(d => { setShorts(d); setLoading(false); }); }, []);
useEffect(() => {
const c = containerRef.current;
if (!c || !shorts.length) return;
const onScroll = () => {
const idx = Math.round(c.scrollTop / c.clientHeight);
if (idx !== activeRef.current && idx >= 0 && idx < shorts.length) setActiveIndex(idx);
};
c.addEventListener('scroll', onScroll, { passive: true });
return () => c.removeEventListener('scroll', onScroll);
}, [shorts.length]);
useEffect(() => {
if (activeIndex >= shorts.length - 2 && !loadingMore) {
setLoadingMore(true);
fetchShorts(page + 1).then(d => {
if (d.length) {
const exist = new Set(shorts.map(v => v.id));
setShorts(p => [...p, ...d.filter(v => !exist.has(v.id))]);
setPage(p => p + 1);
}
setLoadingMore(false);
});
}
}, [activeIndex, shorts.length, loadingMore, page]);
const refresh = () => { setLoading(true); setPage(0); setActiveIndex(0); fetchShorts(0).then(d => { setShorts(d); setLoading(false); }); };
if (loading) return (
<div style={pageStyle}>
<div style={{ ...spinnerContainerStyle, width: '300px', height: '500px' }}>
<div style={spinnerStyle}></div>
</div>
</div>
);
if (!shorts.length) return (
<div style={{ ...pageStyle, color: '#fff' }}>
<div style={{ textAlign: 'center' }}>
<p style={{ marginBottom: '16px' }}>No shorts found</p>
<button onClick={refresh} style={{ padding: '10px 20px', background: '#ff0050', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', margin: '0 auto' }}>
<IoRefresh size={18} /> Refresh
</button>
</div>
</div>
);
return (
<div ref={containerRef} style={scrollContainerStyle}>
<style>{hideScrollbarCss}</style>
<style>{spinCss}</style>
{shorts.map((v, i) => <ShortCard key={v.id} video={v} isActive={i === activeIndex} />)}
{loadingMore && (
<div style={{ ...pageStyle, height: '100vh' }}>
<div style={spinnerStyle}></div>
</div>
)}
</div>
);
}
const pageStyle: React.CSSProperties = { height: 'calc(100vh - 56px)', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f0f0f' };
const scrollContainerStyle: React.CSSProperties = { height: 'calc(100vh - 56px)', overflowY: 'scroll', scrollSnapType: 'y mandatory', background: '#0f0f0f', scrollbarWidth: 'none' };
const spinnerContainerStyle: React.CSSProperties = { borderRadius: '12px', background: 'linear-gradient(180deg, #1a1a1a 0%, #0f0f0f 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center' };
const spinCss = '@keyframes spin { to { transform: rotate(360deg); } }';
const hideScrollbarCss = 'div::-webkit-scrollbar { display: none; }';

44
frontend/app/utils.ts Executable file
View file

@ -0,0 +1,44 @@
export interface VideoData {
id: string;
title: string;
uploader: string;
thumbnail: string;
view_count: number;
duration: string;
avatar_url?: string;
}
export const CATEGORY_MAP: Record<string, string> = {
'All': 'trending videos 2025',
'Watched': 'watched history',
'Suggested': 'suggested videos',
'Tech': 'latest smart technology gadgets reviews',
'Music': 'music hits',
'Movies': 'movie trailers',
'News': 'latest news',
'Trending': 'trending videos',
'Podcasts': 'popular podcasts',
'Live': 'live stream',
'Gaming': 'gaming trending',
'Sports': 'sports highlights'
};
export const ALL_CATEGORY_SECTIONS = [
{ id: 'trending', title: 'Trending Now', query: 'trending videos 2025' },
{ id: 'music', title: 'Music Hits', query: 'music hits 2025' },
{ id: 'tech', title: 'Tech & Gadgets', query: 'latest smart technology gadgets reviews' },
{ id: 'gaming', title: 'Gaming', query: 'gaming trending' },
{ id: 'sports', title: 'Sports Highlights', query: 'sports highlights' },
{ id: 'news', title: 'Latest News', query: 'latest news' },
];
export function addRegion(query: string, regionLabel: string): string {
if (!regionLabel) return query;
return `${query} ${regionLabel}`;
}
const RANDOM_MODIFIERS = ['viral', 'popular', 'new', 'best', 'top', 'hot', 'fresh', 'amazing', 'awesome', 'cool'];
export function getRandomModifier(): string {
return RANDOM_MODIFIERS[Math.floor(Math.random() * RANDOM_MODIFIERS.length)];
}

View file

@ -0,0 +1,393 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
declare global {
interface Window {
Hls: any;
}
}
interface VideoPlayerProps {
videoId: string;
title?: string;
nextVideoId?: string;
}
interface QualityOption {
label: string;
height: number;
url: string;
audio_url?: string;
is_hls: boolean;
has_audio?: boolean;
}
interface StreamInfo {
stream_url: string;
audio_url?: string;
qualities?: QualityOption[];
best_quality?: number;
error?: string;
}
function PlayerSkeleton() {
return (
<div style={skeletonContainerStyle}>
<div style={skeletonVideoStyle} className="skeleton" />
<div style={skeletonControlsStyle}>
<div style={skeletonProgressStyle} className="skeleton" />
<div style={skeletonButtonsRowStyle}>
<div style={{ ...skeletonButtonStyle, width: '60px' }} className="skeleton" />
<div style={{ ...skeletonButtonStyle, width: '40px' }} className="skeleton" />
<div style={{ ...skeletonButtonStyle, width: '40px' }} className="skeleton" />
<div style={{ ...skeletonButtonStyle, width: '80px' }} className="skeleton" />
<div style={{ flex: 1 }} />
<div style={{ ...skeletonButtonStyle, width: '100px' }} className="skeleton" />
<div style={{ ...skeletonButtonStyle, width: '40px' }} className="skeleton" />
<div style={{ ...skeletonButtonStyle, width: '40px' }} className="skeleton" />
</div>
</div>
<div style={skeletonCenterStyle}>
<div style={skeletonSpinnerStyle} />
</div>
</div>
);
}
export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayerProps) {
const router = useRouter();
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const hlsRef = useRef<any>(null);
const audioHlsRef = useRef<any>(null);
const [error, setError] = useState<string | null>(null);
const [useFallback, setUseFallback] = useState(false);
const [showControls, setShowControls] = useState(false);
const [qualities, setQualities] = useState<QualityOption[]>([]);
const [currentQuality, setCurrentQuality] = useState<number>(0);
const [showQualityMenu, setShowQualityMenu] = useState(false);
const [hasSeparateAudio, setHasSeparateAudio] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isBuffering, setIsBuffering] = useState(false);
const audioUrlRef = useRef<string>('');
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/hls.js@latest';
script.async = true;
if (!document.querySelector('script[src*="hls.js"]')) {
document.head.appendChild(script);
}
}, []);
const syncAudio = () => {
const video = videoRef.current;
const audio = audioRef.current;
if (!video || !audio || !hasSeparateAudio) return;
if (Math.abs(video.currentTime - audio.currentTime) > 0.2) {
audio.currentTime = video.currentTime;
}
if (video.paused && !audio.paused) {
audio.pause();
} else if (!video.paused && audio.paused) {
audio.play().catch(() => {});
}
};
useEffect(() => {
if (useFallback) return;
const loadStream = async () => {
try {
const res = await fetch(`/api/get_stream_info?v=${videoId}`);
const data: StreamInfo = await res.json();
if (data.error || !data.stream_url) {
throw new Error(data.error || 'No stream URL');
}
if (data.qualities && data.qualities.length > 0) {
setQualities(data.qualities);
setCurrentQuality(data.best_quality || data.qualities[0].height);
}
if (data.audio_url) {
audioUrlRef.current = data.audio_url;
}
playStream(data.stream_url, data.audio_url);
} catch (err) {
console.error('Stream load error:', err);
setError('Failed to load stream');
setUseFallback(true);
}
};
const tryLoad = () => {
if (window.Hls) {
loadStream();
} else {
setTimeout(tryLoad, 100);
}
};
tryLoad();
return () => {
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
if (audioHlsRef.current) {
audioHlsRef.current.destroy();
audioHlsRef.current = null;
}
};
}, [videoId]);
useEffect(() => {
if (!hasSeparateAudio) return;
const video = videoRef.current;
if (!video) return;
const handlers = {
play: syncAudio,
pause: syncAudio,
seeked: syncAudio,
timeupdate: syncAudio,
};
Object.entries(handlers).forEach(([event, handler]) => {
video.addEventListener(event, handler);
});
return () => {
Object.entries(handlers).forEach(([event, handler]) => {
video.removeEventListener(event, handler);
});
};
}, [hasSeparateAudio]);
const playStream = (streamUrl: string, audioStreamUrl?: string) => {
const video = videoRef.current;
if (!video) return;
setIsLoading(true);
const isHLS = streamUrl.includes('.m3u8') || streamUrl.includes('manifest');
const needsSeparateAudio = audioStreamUrl && audioStreamUrl !== '';
setHasSeparateAudio(!!needsSeparateAudio);
const handleCanPlay = () => setIsLoading(false);
const handlePlaying = () => { setIsLoading(false); setIsBuffering(false); };
const handleWaiting = () => setIsBuffering(true);
const handleLoadStart = () => setIsLoading(true);
video.addEventListener('canplay', handleCanPlay);
video.addEventListener('playing', handlePlaying);
video.addEventListener('waiting', handleWaiting);
video.addEventListener('loadstart', handleLoadStart);
if (isHLS && window.Hls && window.Hls.isSupported()) {
if (hlsRef.current) hlsRef.current.destroy();
const hls = new window.Hls({
xhrSetup: (xhr: XMLHttpRequest) => {
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
},
});
hlsRef.current = hls;
hls.loadSource(streamUrl);
hls.attachMedia(video);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(() => {});
});
hls.on(window.Hls.Events.ERROR, (_: any, data: any) => {
if (data.fatal) {
setIsLoading(false);
setError('Playback error');
setUseFallback(true);
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = streamUrl;
video.onloadedmetadata = () => video.play().catch(() => {});
} else {
video.src = streamUrl;
video.onloadeddata = () => video.play().catch(() => {});
}
if (needsSeparateAudio) {
const audio = audioRef.current;
if (audio) {
const audioIsHLS = audioStreamUrl!.includes('.m3u8') || audioStreamUrl!.includes('manifest');
if (audioIsHLS && window.Hls && window.Hls.isSupported()) {
if (audioHlsRef.current) audioHlsRef.current.destroy();
const audioHls = new window.Hls({
xhrSetup: (xhr: XMLHttpRequest) => {
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
},
});
audioHlsRef.current = audioHls;
audioHls.loadSource(audioStreamUrl!);
audioHls.attachMedia(audio);
} else {
audio.src = audioStreamUrl!;
}
}
}
video.onended = () => {
setIsLoading(false);
if (nextVideoId) router.push(`/watch?v=${nextVideoId}`);
};
};
const changeQuality = (quality: QualityOption) => {
const video = videoRef.current;
if (!video) return;
const currentTime = video.currentTime;
const wasPlaying = !video.paused;
setShowQualityMenu(false);
const audioUrl = quality.audio_url || audioUrlRef.current;
playStream(quality.url, audioUrl);
setCurrentQuality(quality.height);
video.currentTime = currentTime;
if (wasPlaying) video.play().catch(() => {});
};
useEffect(() => {
if (!useFallback) return;
const handleMessage = (event: MessageEvent) => {
if (event.origin !== 'https://www.youtube.com') return;
try {
const data = JSON.parse(event.data);
if (data.event === 'onStateChange' && data.info === 0 && nextVideoId) {
router.push(`/watch?v=${nextVideoId}`);
}
} catch {}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [useFallback, nextVideoId, router]);
if (!videoId) {
return <div style={noVideoStyle}>No video ID</div>;
}
if (useFallback) {
return (
<div style={containerStyle} onMouseEnter={() => setShowControls(true)} onMouseLeave={() => setShowControls(false)}>
<iframe
src={`https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0&modestbranding=1`}
style={iframeStyle}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title={title || 'Video'}
/>
</div>
);
}
return (
<div style={containerStyle} onMouseEnter={() => setShowControls(true)} onMouseLeave={() => { setShowControls(false); setShowQualityMenu(false); }}>
{isLoading && <PlayerSkeleton />}
<video
ref={videoRef}
style={{ ...videoStyle, visibility: isLoading ? 'hidden' : 'visible' }}
controls
playsInline
poster={`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`}
/>
{hasSeparateAudio && <audio ref={audioRef} style={{ display: 'none' }} />}
{error && (
<div style={errorStyle}>
<span>{error}</span>
<button onClick={() => setUseFallback(true)} style={retryBtnStyle}>Try YouTube Player</button>
</div>
)}
{showControls && !error && !isLoading && (
<>
<a href={`https://www.youtube.com/watch?v=${videoId}`} target="_blank" rel="noopener noreferrer" style={openBtnStyle}>
Open on YouTube
</a>
{qualities.length > 0 && (
<div style={qualityContainerStyle}>
<button onClick={() => setShowQualityMenu(!showQualityMenu)} style={qualityBtnStyle}>
{qualities.find(q => q.height === currentQuality)?.label || 'Auto'}
</button>
{showQualityMenu && (
<div style={qualityMenuStyle}>
{qualities.map((q) => (
<button
key={q.height}
onClick={() => changeQuality(q)}
style={{
...qualityItemStyle,
background: q.height === currentQuality ? 'rgba(255,0,0,0.3)' : 'transparent',
}}
>
{q.label}
{q.height === currentQuality && ' ✓'}
</button>
))}
</div>
)}
</div>
)}
</>
)}
{isBuffering && !isLoading && (
<div style={bufferingOverlayStyle}>
<div style={spinnerStyle} />
</div>
)}
</div>
);
}
const noVideoStyle: React.CSSProperties = { width: '100%', background: '#000', borderRadius: '12px', aspectRatio: '16/9', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' };
const containerStyle: React.CSSProperties = { width: '100%', background: '#000', borderRadius: '12px', overflow: 'hidden', aspectRatio: '16/9', position: 'relative' };
const videoStyle: React.CSSProperties = { width: '100%', height: '100%', background: '#000' };
const iframeStyle: React.CSSProperties = { width: '100%', height: '100%', border: 'none' };
const errorStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '12px', background: 'rgba(0,0,0,0.9)', color: '#ff6b6b' };
const retryBtnStyle: React.CSSProperties = { padding: '8px 16px', background: '#ff0000', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '14px' };
const openBtnStyle: React.CSSProperties = { position: 'absolute', top: '10px', right: '10px', padding: '6px 12px', background: 'rgba(0,0,0,0.8)', color: '#fff', borderRadius: '4px', textDecoration: 'none', fontSize: '12px', zIndex: 10 };
const qualityContainerStyle: React.CSSProperties = { position: 'absolute', bottom: '50px', right: '10px', zIndex: 10 };
const qualityBtnStyle: React.CSSProperties = { padding: '6px 12px', background: 'rgba(0,0,0,0.8)', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px', fontWeight: 500 };
const qualityMenuStyle: React.CSSProperties = { position: 'absolute', bottom: '100%', right: 0, marginBottom: '4px', background: 'rgba(0,0,0,0.95)', borderRadius: '8px', overflow: 'hidden', minWidth: '100px' };
const qualityItemStyle: React.CSSProperties = { display: 'block', width: '100%', padding: '8px 16px', color: '#fff', border: 'none', background: 'transparent', textAlign: 'left', cursor: 'pointer', fontSize: '13px', whiteSpace: 'nowrap' };
const skeletonContainerStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', flexDirection: 'column', background: '#000', zIndex: 5 };
const skeletonVideoStyle: React.CSSProperties = { flex: 1, margin: '8px', borderRadius: '8px' };
const skeletonControlsStyle: React.CSSProperties = { padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: '8px' };
const skeletonProgressStyle: React.CSSProperties = { height: '4px', borderRadius: '2px' };
const skeletonButtonsRowStyle: React.CSSProperties = { display: 'flex', gap: '8px', alignItems: 'center' };
const skeletonButtonStyle: React.CSSProperties = { height: '24px', borderRadius: '4px' };
const skeletonCenterStyle: React.CSSProperties = { position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
const skeletonSpinnerStyle: React.CSSProperties = { width: '48px', height: '48px', border: '4px solid rgba(255,255,255,0.1)', borderTopColor: 'rgba(255,255,255,0.8)', borderRadius: '50%', animation: 'spin 1s linear infinite' };
const bufferingOverlayStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.3)', pointerEvents: 'none', zIndex: 5 };
const spinnerStyle: React.CSSProperties = { width: '40px', height: '40px', border: '3px solid rgba(255,255,255,0.2)', borderTopColor: '#fff', borderRadius: '50%', animation: 'spin 0.8s linear infinite' };

View file

@ -0,0 +1,453 @@
'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
import { PiShareFat } from 'react-icons/pi';
import { TfiDownload } from 'react-icons/tfi';
interface VideoFormat {
format_id: string;
format_note: string;
ext: string;
resolution: string;
filesize: number;
type: string;
has_audio?: boolean;
url?: string;
}
declare global {
interface Window {
FFmpeg: any;
FFmpegWASM: any;
}
}
function getQualityLabel(resolution: string): string {
const height = parseInt(resolution) || 0;
if (height >= 3840) return '4K UHD';
if (height >= 2560) return '2K QHD';
if (height >= 1920) return 'Full HD 1080p';
if (height >= 1280) return 'HD 720p';
if (height >= 854) return 'SD 480p';
if (height >= 640) return 'SD 360p';
if (height >= 426) return 'SD 240p';
if (height >= 256) return 'SD 144p';
return resolution || 'Unknown';
}
function getQualityBadge(height: number): { label: string; color: string } | null {
if (height >= 3840) return { label: '4K', color: '#ff0000' };
if (height >= 2560) return { label: '2K', color: '#ff6b00' };
if (height >= 1920) return { label: 'HD', color: '#00a0ff' };
if (height >= 1280) return { label: 'HD', color: '#00a0ff' };
return null;
}
export default function WatchActions({ videoId }: { videoId: string }) {
const [isDownloading, setIsDownloading] = useState(false);
const [showFormats, setShowFormats] = useState(false);
const [formats, setFormats] = useState<VideoFormat[]>([]);
const [audioFormats, setAudioFormats] = useState<VideoFormat[]>([]);
const [isLoadingFormats, setIsLoadingFormats] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<string>('');
const [progressPercent, setProgressPercent] = useState(0);
const [ffmpegLoaded, setFfmpegLoaded] = useState(false);
const [ffmpegLoading, setFfmpegLoading] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const ffmpegRef = useRef<any>(null);
const loadFFmpeg = useCallback(async () => {
if (ffmpegLoaded || ffmpegLoading) return;
setFfmpegLoading(true);
setDownloadProgress('Loading video processor...');
try {
const script = document.createElement('script');
script.src = 'https://unpkg.com/@ffmpeg/ffmpeg@0.12.7/dist/umd/ffmpeg.js';
script.async = true;
document.head.appendChild(script);
const coreScript = document.createElement('script');
coreScript.src = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js';
coreScript.async = true;
document.head.appendChild(coreScript);
await new Promise<void>((resolve) => {
const checkLoaded = () => {
if (window.FFmpeg && window.FFmpeg.FFmpeg) {
resolve();
} else {
setTimeout(checkLoaded, 100);
}
};
checkLoaded();
});
const { FFmpeg } = window.FFmpeg;
const ffmpeg = new FFmpeg();
await ffmpeg.load({
coreURL: 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js',
wasmURL: 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.wasm',
});
ffmpegRef.current = ffmpeg;
setFfmpegLoaded(true);
} catch (e) {
console.error('Failed to load FFmpeg:', e);
} finally {
setFfmpegLoading(false);
}
}, [ffmpegLoaded, ffmpegLoading]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setShowFormats(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleShare = () => {
if (typeof window !== 'undefined') {
const url = window.location.href;
navigator.clipboard.writeText(url).then(() => {
alert('Link copied to clipboard!');
}).catch(() => {
alert('Failed to copy link');
});
}
};
const fetchFormats = async () => {
if (showFormats) {
setShowFormats(false);
return;
}
setShowFormats(true);
if (formats.length > 0) return;
setIsLoadingFormats(true);
try {
const res = await fetch(`/api/formats?v=${encodeURIComponent(videoId)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (Array.isArray(data)) {
const videoFormats = data.filter((f: VideoFormat) =>
(f.type === 'video' || f.type === 'both') &&
!f.format_note?.toLowerCase().includes('storyboard') &&
f.ext === 'mp4'
).sort((a: VideoFormat, b: VideoFormat) => {
const resA = parseInt(a.resolution) || 0;
const resB = parseInt(b.resolution) || 0;
return resB - resA;
});
const audioOnly = data.filter((f: VideoFormat) =>
f.type === 'audio' || (f.resolution === 'audio only')
).sort((a: VideoFormat, b: VideoFormat) => (b.filesize || 0) - (a.filesize || 0));
setFormats(videoFormats.length > 0 ? videoFormats : data.filter((f: VideoFormat) => f.ext === 'mp4').slice(0, 10));
setAudioFormats(audioOnly);
}
} catch (e) {
console.error('Failed to fetch formats:', e);
} finally {
setIsLoadingFormats(false);
}
};
const fetchFile = async (url: string, label: string): Promise<Uint8Array> => {
setDownloadProgress(`Downloading ${label}...`);
const tryDirect = async (): Promise<Response | null> => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, { signal: controller.signal, mode: 'cors' });
clearTimeout(timeoutId);
return response.ok ? response : null;
} catch {
return null;
}
};
const tryProxy = async (): Promise<Response> => {
return fetch(`/api/proxy-file?url=${encodeURIComponent(url)}`);
};
let response = await tryDirect();
if (!response) {
setDownloadProgress(`Connecting via proxy...`);
response = await tryProxy();
}
if (!response.ok) throw new Error(`Failed to fetch ${label}`);
const contentLength = response.headers.get('content-length');
const total = contentLength ? parseInt(contentLength, 10) : 0;
let loaded = 0;
const reader = response.body?.getReader();
if (!reader) throw new Error('No reader available');
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.length;
if (total > 0) {
const percent = Math.round((loaded / total) * 100);
setProgressPercent(percent);
setDownloadProgress(`${label}: ${percent}%`);
}
}
const result = new Uint8Array(loaded);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
};
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleDownload = async (format?: VideoFormat) => {
setIsDownloading(true);
setShowFormats(false);
setProgressPercent(0);
setDownloadProgress('Preparing download...');
try {
const needsAudioMerge = format && !format.has_audio && format.type !== 'both';
if (!needsAudioMerge) {
setDownloadProgress('Getting download link...');
const res = await fetch(`/api/download?v=${encodeURIComponent(videoId)}${format ? `&f=${encodeURIComponent(format.format_id)}` : ''}`);
const data = await res.json();
if (data.url) {
window.open(data.url, '_blank');
setIsDownloading(false);
setDownloadProgress('');
return;
}
}
await loadFFmpeg();
if (!ffmpegRef.current) {
throw new Error('Video processor failed to load. Please try again.');
}
const ffmpeg = ffmpegRef.current;
setDownloadProgress('Fetching video...');
const videoRes = await fetch(`/api/download?v=${encodeURIComponent(videoId)}${format ? `&f=${encodeURIComponent(format.format_id)}` : ''}`);
const videoData = await videoRes.json();
if (!videoData.url) {
throw new Error(videoData.error || 'Failed to get video URL');
}
let audioUrl: string | null = null;
if (needsAudioMerge && audioFormats.length > 0) {
const audioRes = await fetch(`/api/download?v=${encodeURIComponent(videoId)}&f=${encodeURIComponent(audioFormats[0].format_id)}`);
const audioData = await audioRes.json();
audioUrl = audioData.url;
}
const videoBuffer = await fetchFile(videoData.url, 'Video');
if (audioUrl) {
setProgressPercent(0);
const audioBuffer = await fetchFile(audioUrl, 'Audio');
setDownloadProgress('Merging video & audio...');
setProgressPercent(-1);
const videoExt = format?.ext || 'mp4';
await ffmpeg.writeFile(`input.${videoExt}`, videoBuffer);
await ffmpeg.writeFile('audio.m4a', audioBuffer);
await ffmpeg.exec([
'-i', `input.${videoExt}`,
'-i', 'audio.m4a',
'-c:v', 'copy',
'-c:a', 'aac',
'-map', '0:v',
'-map', '1:a',
'-shortest',
'output.mp4'
]);
setDownloadProgress('Saving file...');
const mergedData = await ffmpeg.readFile('output.mp4');
const mergedBuffer = new Uint8Array(mergedData as ArrayBuffer);
const qualityLabel = getQualityLabel(format?.resolution || '').replace(/\s/g, '_');
const blob = new Blob([mergedBuffer], { type: 'video/mp4' });
downloadBlob(blob, `${videoId}_${qualityLabel}.mp4`);
await ffmpeg.deleteFile(`input.${videoExt}`);
await ffmpeg.deleteFile('audio.m4a');
await ffmpeg.deleteFile('output.mp4');
} else {
const qualityLabel = getQualityLabel(format?.resolution || '').replace(/\s/g, '_');
const blob = new Blob([new Uint8Array(videoBuffer)], { type: 'video/mp4' });
downloadBlob(blob, `${videoId}_${qualityLabel}.mp4`);
}
setDownloadProgress('Download complete!');
setProgressPercent(100);
} catch (e: any) {
console.error(e);
alert(e.message || 'Download failed. Please try again.');
} finally {
setTimeout(() => {
setIsDownloading(false);
setDownloadProgress('');
setProgressPercent(0);
}, 1500);
}
};
const formatFileSize = (bytes: number): string => {
if (!bytes || bytes <= 0) return '';
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / 1024).toFixed(0)} KB`;
};
return (
<div style={{ display: 'flex', gap: '8px', position: 'relative', alignItems: 'center', flexShrink: 0 }}>
<button
type="button"
onClick={handleShare}
className="action-btn-hover"
style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--yt-hover)', border: 'none', borderRadius: '18px', padding: '0 16px', height: '36px', color: 'var(--yt-text-primary)', fontSize: '14px', fontWeight: '500', cursor: 'pointer' }}
>
<PiShareFat size={20} style={{ marginRight: '6px' }} />
Share
</button>
<div ref={menuRef} style={{ position: 'relative' }}>
<button
type="button"
onClick={fetchFormats}
disabled={isDownloading}
className="action-btn-hover"
style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--yt-hover)', border: 'none', borderRadius: '18px', padding: '0 16px', height: '36px', color: 'var(--yt-text-primary)', fontSize: '14px', fontWeight: '500', cursor: isDownloading ? 'wait' : 'pointer', opacity: isDownloading ? 0.7 : 1, minWidth: '120px' }}
>
<TfiDownload size={18} style={{ marginRight: '6px' }} />
{isDownloading ? (
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{progressPercent > 0 ? `${progressPercent}%` : ''}
<span style={{ width: '12px', height: '12px', border: '2px solid currentColor', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite', display: 'inline-block' }} />
</span>
) : 'Download'}
</button>
{showFormats && (
<div style={{
position: 'absolute',
top: '42px',
right: 0,
backgroundColor: 'var(--yt-background)',
borderRadius: '12px',
boxShadow: 'var(--yt-shadow-lg)',
padding: '8px 0',
zIndex: 1000,
minWidth: '240px',
maxHeight: '360px',
overflowY: 'auto',
border: '1px solid var(--yt-border)',
}}>
<div style={{ padding: '10px 16px', fontSize: '13px', fontWeight: '600', color: 'var(--yt-text-primary)', borderBottom: '1px solid var(--yt-border)' }}>
Select Quality
</div>
{isLoadingFormats ? (
<div style={{ padding: '20px 16px', textAlign: 'center', color: 'var(--yt-text-secondary)', fontSize: '14px' }}>
<span style={{ width: '20px', height: '20px', border: '2px solid var(--yt-text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite', display: 'inline-block', marginRight: '8px' }} />
Loading...
</div>
) : formats.length === 0 ? (
<div style={{ padding: '16px', textAlign: 'center', color: 'var(--yt-text-secondary)', fontSize: '14px' }}>
No formats available
</div>
) : (
formats.map(f => {
const height = parseInt(f.resolution) || 0;
const badge = getQualityBadge(height);
return (
<button
key={f.format_id}
onClick={() => handleDownload(f)}
className="format-item-hover"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '12px 16px',
backgroundColor: 'transparent',
border: 'none',
color: 'var(--yt-text-primary)',
cursor: 'pointer',
fontSize: '14px',
transition: 'background-color 0.15s',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{badge && (
<span style={{
fontSize: '10px',
fontWeight: '700',
color: '#fff',
background: badge.color,
padding: '3px 6px',
borderRadius: '4px',
letterSpacing: '0.5px'
}}>
{badge.label}
</span>
)}
<span style={{ fontWeight: '500' }}>{getQualityLabel(f.resolution)}</span>
</div>
<span style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>
{formatFileSize(f.filesize) || 'Unknown size'}
</span>
</button>
);
})
)}
</div>
)}
</div>
{isDownloading && downloadProgress && (
<span style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', minWidth: '150px' }}>
{downloadProgress}
</span>
)}
</div>
);
}

157
frontend/app/watch/page.tsx Executable file
View file

@ -0,0 +1,157 @@
import VideoPlayer from './VideoPlayer';
import Link from 'next/link';
import WatchActions from './WatchActions';
import SubscribeButton from '../components/SubscribeButton';
import { API_BASE } from '../constants';
interface VideoData {
id: string;
title: string;
uploader: string;
channel_id?: string;
thumbnail: string;
view_count: number;
duration: string;
}
interface VideoInfo {
title: string;
description: string;
uploader: string;
channel_id: string;
view_count: number;
thumbnail?: string;
}
async function getVideoInfo(id: string): Promise<VideoInfo | null> {
try {
const res = await fetch(`${API_BASE}/api/get_stream_info?v=${id}`, { cache: 'no-store' });
if (!res.ok) return null;
const data = await res.json();
return {
title: data.title || `Video ${id}`,
description: data.description || '',
uploader: data.uploader || 'Unknown',
channel_id: data.channel_id || '',
view_count: data.view_count || 0,
thumbnail: data.thumbnail || `https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
};
} catch (e) {
console.error(e);
return null;
}
}
async function getRelatedVideos(videoId: string, title: string, uploader: string) {
try {
const params = new URLSearchParams({ v: videoId, title: title || '', uploader: uploader || '', limit: '15' });
const res = await fetch(`${API_BASE}/api/related?${params.toString()}`, { cache: 'no-store' });
if (!res.ok) return [];
return res.json() as Promise<VideoData[]>;
} catch (e) {
console.error(e);
return [];
}
}
function formatViews(views: number): string {
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
if (views >= 1000) return (views / 1000).toFixed(1) + 'K';
return views.toString();
}
function formatNumber(num: number): string {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
return num.toString();
}
export default async function WatchPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
const awaitParams = await searchParams;
const v = awaitParams.v as string;
if (!v) {
return <div style={{ padding: '2rem' }}>No video ID provided</div>;
}
const info = await getVideoInfo(v);
const relatedVideos = await getRelatedVideos(v, info?.title || '', info?.uploader || '');
const nextVideoId = relatedVideos.length > 0 ? relatedVideos[0].id : undefined;
return (
<div className="watch-container fade-in">
<div className="watch-primary">
<div className="watch-player-wrapper">
<VideoPlayer
videoId={v}
title={info?.title}
nextVideoId={nextVideoId}
/>
</div>
<h1 className="watch-title">
{info?.title || `Video ${v}`}
</h1>
{info && (
<div className="watch-meta-row">
<div className="watch-channel-info">
<Link href={info.channel_id ? `/channel/${info.channel_id}` : '#'} className="watch-channel-link">
<div className="watch-channel-text">
<span className="watch-channel-name">{info.uploader}</span>
</div>
</Link>
<SubscribeButton channelId={info.channel_id} channelName={info.uploader} />
</div>
<div className="watch-actions-row">
<WatchActions videoId={v} />
</div>
</div>
)}
{info && (
<div className="watch-description-box">
<div className="watch-description-stats">
{formatNumber(info.view_count)} views
</div>
<div className="watch-description-text">
{info.description || 'No description available.'}
</div>
</div>
)}
</div>
<div className="watch-secondary">
<div className="watch-related-list">
{relatedVideos.map((video, i) => {
const views = formatViews(video.view_count);
const staggerClass = `stagger-${Math.min(i + 1, 6)}`;
return (
<Link key={video.id} href={`/watch?v=${video.id}`} className={`related-video-item fade-in-up ${staggerClass}`} style={{ opacity: 0 }}>
<div className="related-thumb-container">
<img src={video.thumbnail} alt={video.title} className="related-thumb-img" />
{video.duration && (
<div className="duration-badge">
{video.duration}
</div>
)}
</div>
<div className="related-video-info">
<span className="related-video-title">{video.title}</span>
<span className="related-video-channel">{video.uploader}</span>
<span className="related-video-meta">{views} views</span>
</div>
</Link>
);
})}
</div>
</div>
</div>
);
}

18
frontend/eslint.config.mjs Executable file
View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

26
frontend/next.config.mjs Executable file
View file

@ -0,0 +1,26 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'i.ytimg.com',
},
],
},
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://kv-tube-backend:8080/api/:path*',
},
{
source: '/video_proxy',
destination: 'http://kv-tube-backend:8080/video_proxy',
},
];
},
};
export default nextConfig;

6755
frontend/package-lock.json generated Executable file

File diff suppressed because it is too large Load diff

35
frontend/package.json Executable file
View file

@ -0,0 +1,35 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@clappr/core": "^0.13.2",
"@clappr/player": "^0.11.16",
"@fontsource/roboto": "^5.2.9",
"@vidstack/react": "^1.12.13",
"artplayer": "^5.3.0",
"clappr": "^0.3.13",
"hls.js": "^1.6.15",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-icons": "^5.5.0",
"vidstack": "^1.12.13"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
frontend/postcss.config.mjs Executable file
View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
frontend/public/file.svg Executable file
View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
frontend/public/globe.svg Executable file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

1
frontend/public/next.svg Executable file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
frontend/public/vercel.svg Executable file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
frontend/public/window.svg Executable file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View file

@ -0,0 +1 @@
console.log(document.querySelector(".yt-main-content").style.marginLeft); console.log(window.getComputedStyle(document.querySelector(".yt-main-content")).marginLeft);

34
frontend/tsconfig.json Executable file
View file

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

File diff suppressed because it is too large Load diff

View file

@ -1,57 +0,0 @@
import os
import sys
import site
# Try to find and activate the virtual environment
try:
base_dir = os.path.dirname(os.path.abspath(__file__))
except NameError:
base_dir = os.getcwd()
venv_dirs = ['.venv', 'env']
activated = False
for venv_name in venv_dirs:
venv_path = os.path.join(base_dir, venv_name)
if os.path.isdir(venv_path):
# Determine site-packages path
if sys.platform == 'win32':
site_packages = os.path.join(venv_path, 'Lib', 'site-packages')
else:
# Check for python version in lib
lib_path = os.path.join(venv_path, 'lib')
if os.path.exists(lib_path):
for item in os.listdir(lib_path):
if item.startswith('python'):
site_packages = os.path.join(lib_path, item, 'site-packages')
break
if site_packages and os.path.exists(site_packages):
print(f"Adding virtual environment to path: {site_packages}")
site.addsitedir(site_packages)
sys.path.insert(0, site_packages)
activated = True
break
if not activated:
print("WARNING: Could not find or activate a virtual environment (env or .venv).")
print("Attempting to run anyway (system packages might be used)...")
# Add current directory to path so 'app' can be imported
sys.path.insert(0, base_dir)
try:
print("Importing app factory...")
from app import create_app
print("Creating app...")
app = create_app()
print("Starting KV-Tube Server on port 5002...")
app.run(debug=True, host="0.0.0.0", port=5002, use_reloader=False)
except ImportError as e:
print("\nCRITICAL ERROR: Could not import Flask or required dependencies.")
print(f"Error details: {e}")
print("\nPlease ensure you are running this script with the correct Python environment.")
print("If you are stuck in a '>>>' prompt, try typing exit() first, then run:")
print(" source env/bin/activate && python kv_server.py")
except Exception as e:
print(f"\nAn error occurred while starting the server: {e}")

View file

@ -1,9 +0,0 @@
flask
requests
yt-dlp>=2024.1.0
werkzeug
gunicorn
python-dotenv
googletrans==4.0.0-rc1
# ytfetcher - optional, requires Python 3.11-3.13

173
restart.sh Executable file
View file

@ -0,0 +1,173 @@
#!/bin/bash
cd "$(dirname "$0")"
# Add user local bin to PATH for yt-dlp and cloudflared
export PATH="$PATH:/config/.local/bin:$HOME/.local/bin:/config/docker-bin"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Mode: dev or prod
MODE=${1:-dev}
echo -e "${BLUE}╔═══════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ KV-Tube Restart Script ║${NC}"
echo -e "${BLUE}╚═══════════════════════════════════════╝${NC}"
echo ""
echo -e "${YELLOW}Mode: ${MODE}${NC}"
echo ""
# Stop existing processes
echo -e "${YELLOW}[1/5] Stopping existing processes...${NC}"
# Kill backend processes
pkill -f "kvtube-go" 2>/dev/null
pkill -f "backend/bin/kv-tube" 2>/dev/null
pkill -f "go run.*backend" 2>/dev/null
# Kill frontend processes
pkill -f "next dev" 2>/dev/null
pkill -f "next start" 2>/dev/null
pkill -f "node.*next" 2>/dev/null
# Kill cloudflared/ngrok
pkill -f "cloudflared" 2>/dev/null
pkill -f "ngrok" 2>/dev/null
# Kill any processes on our ports
fkill() {
local port=$1
local pid=$(lsof -t -i:$port 2>/dev/null)
if [ ! -z "$pid" ]; then
kill -9 $pid 2>/dev/null
echo -e " Killed process on port $port (PID: $pid)"
fi
}
fkill 8080
fkill 3003
sleep 1
# Check if yt-dlp is installed
echo -e "${YELLOW}[2/5] Checking dependencies...${NC}"
if ! command -v yt-dlp &> /dev/null; then
echo -e "${RED}Error: yt-dlp is not installed!${NC}"
echo -e "Install with: ${YELLOW}pip install yt-dlp${NC} or ${YELLOW}brew install yt-dlp${NC}"
exit 1
fi
echo -e " ${GREEN}${NC} yt-dlp: $(yt-dlp --version 2>/dev/null || echo 'installed')"
# Check cloudflared
if command -v cloudflared &> /dev/null; then
echo -e " ${GREEN}${NC} cloudflared: available"
TUNNEL_OK=true
else
echo -e " ${YELLOW}!${NC} cloudflared: not found (will skip tunnel)"
TUNNEL_OK=false
fi
# Start backend
echo -e "${YELLOW}[3/5] Starting backend...${NC}"
cd backend
if [ "$MODE" = "prod" ]; then
# Build and run production binary
echo " Building backend..."
go build -o bin/kv-tube .
GIN_MODE=release ./bin/kv-tube > ../logs/backend.log 2>&1 &
else
go run main.go > ../logs/backend.log 2>&1 &
fi
BACKEND_PID=$!
cd ..
echo -e " ${GREEN}${NC} Backend started (PID: $BACKEND_PID)"
# Wait for backend to be ready
echo -e " Waiting for backend..."
for i in {1..15}; do
if curl -s http://localhost:8080/api/health > /dev/null 2>&1; then
echo -e " ${GREEN}${NC} Backend is healthy"
break
fi
if [ $i -eq 15 ]; then
echo -e "${RED}Backend failed to start. Check logs/backend.log${NC}"
exit 1
fi
sleep 1
done
# Start frontend
echo -e "${YELLOW}[4/5] Starting frontend...${NC}"
cd frontend
if [ "$MODE" = "prod" ]; then
# Build and run production
echo " Building frontend..."
npm run build > ../logs/frontend-build.log 2>&1
PORT=3003 npm run start > ../logs/frontend.log 2>&1 &
else
PORT=3003 npm run dev > ../logs/frontend.log 2>&1 &
fi
FRONTEND_PID=$!
cd ..
echo -e " ${GREEN}${NC} Frontend started (PID: $FRONTEND_PID)"
# Save PIDs to file
echo "$BACKEND_PID" > logs/backend.pid
echo "$FRONTEND_PID" > logs/frontend.pid
# Start cloudflared tunnel
TUNNEL_URL=""
if [ "$TUNNEL_OK" = true ]; then
echo -e "${YELLOW}[5/5] Starting cloudflare tunnel...${NC}"
cloudflared tunnel --url http://localhost:3003 --no-autoupdate > logs/tunnel.log 2>&1 &
TUNNEL_PID=$!
echo "$TUNNEL_PID" > logs/tunnel.pid
# Wait for tunnel to start and get URL
sleep 5
TUNNEL_URL=$(grep -o 'https://[^.]*\.trycloudflare\.com' logs/tunnel.log 2>/dev/null | head -1)
if [ ! -z "$TUNNEL_URL" ]; then
echo -e " ${GREEN}${NC} Tunnel: ${CYAN}${TUNNEL_URL}${NC}"
else
echo -e " ${YELLOW}!${NC} Tunnel started, check logs/tunnel.log for URL"
fi
else
echo -e "${YELLOW}[5/5] Skipping tunnel (cloudflared not installed)${NC}"
fi
echo ""
echo -e "${GREEN}╔═══════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ KV-Tube is running! ║${NC}"
echo -e "${GREEN}╚═══════════════════════════════════════╝${NC}"
echo ""
echo -e " ${BLUE}Backend:${NC} http://localhost:8080"
echo -e " ${BLUE}Frontend:${NC} http://localhost:3003"
if [ ! -z "$TUNNEL_URL" ]; then
echo -e " ${CYAN}Public:${NC} ${TUNNEL_URL}"
fi
echo ""
echo -e " ${YELLOW}Logs:${NC}"
echo -e " Backend: logs/backend.log"
echo -e " Frontend: logs/frontend.log"
if [ "$TUNNEL_OK" = true ]; then
echo -e " Tunnel: logs/tunnel.log"
fi
echo ""
echo -e " ${YELLOW}To stop:${NC} Ctrl+C or ./stop.sh"
echo ""
# Handle Ctrl+C gracefully
trap "echo ''; echo -e '${YELLOW}Stopping servers...${NC}'; kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; pkill -f cloudflared 2>/dev/null; rm -f logs/backend.pid logs/frontend.pid logs/tunnel.pid; echo -e '${GREEN}Stopped.${NC}'; exit 0" SIGINT SIGTERM
wait

View file

@ -1,51 +0,0 @@
#!/bin/bash
cd "$(dirname "$0")"
echo "=== Diagnostic Start Script ==="
# Activate env
# Activate env
if [ -d ".venv_clean" ]; then
echo "Activating .venv_clean..."
export PYTHONPATH="$(pwd)/.venv_clean/lib/python3.14/site-packages"
# Use system python with PYTHONPATH if bindir is missing/broken
PYTHON_EXEC="/Library/Frameworks/Python.framework/Versions/3.14/bin/python3"
export FLASK_APP=wsgi.py
export FLASK_RUN_PORT=5002
echo "--- Starting with System Python + PYTHONPATH ---"
$PYTHON_EXEC -m flask run --host=0.0.0.0 --port=5002
exit 0
elif [ -d ".venv" ]; then
echo "Activating .venv..."
source .venv/bin/activate
elif [ -d "env" ]; then
echo "Activating env..."
source env/bin/activate
else
echo "No '.venv' or 'env' directory found!"
exit 1
fi
echo "Python path: $(which python)"
echo "Python ls: $(ls -l $(which python))"
echo "--- Test 1: Simple Print ---"
python -c "print('Python is executing commands properly')"
if [ $? -eq 0 ]; then
echo "Test 1 PASSED"
else
echo "Test 1 FAILED (Entered REPL?)"
fi
echo "--- Attempting to start with Gunicorn ---"
echo "--- Attempting to start with Gunicorn ---"
if command -v gunicorn &> /dev/null; then
gunicorn -b 0.0.0.0:5002 wsgi:app
else
echo "Gunicorn not found in path."
fi
echo "--- Attempting to start with Flask explicitly ---"
export FLASK_APP=wsgi.py
export FLASK_RUN_PORT=5002
python -m flask run --host=0.0.0.0

View file

@ -1,56 +0,0 @@
/* ===== Reset & Base ===== */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--yt-bg-primary);
/* Fix white bar issue */
}
body {
font-family: 'Roboto', 'Arial', sans-serif;
background-color: var(--yt-bg-primary);
color: var(--yt-text-primary);
line-height: 1.4;
overflow-x: hidden;
min-height: 100vh;
}
a {
color: inherit;
text-decoration: none;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: none;
}
/* Hide scrollbar globally but allow scroll */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--yt-bg-secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--yt-bg-hover);
}

View file

@ -1,325 +0,0 @@
/* ===== Video Card (Standard) ===== */
.yt-video-card {
cursor: pointer;
border-radius: var(--yt-radius-lg);
overflow: hidden;
transition: transform 0.1s;
animation: fadeIn 0.3s ease forwards;
/* Animation from style.css */
}
/* Stagger animation */
.yt-video-card:nth-child(1) {
animation-delay: 0.05s;
}
.yt-video-card:nth-child(2) {
animation-delay: 0.1s;
}
.yt-video-card:nth-child(3) {
animation-delay: 0.15s;
}
.yt-video-card:nth-child(4) {
animation-delay: 0.2s;
}
.yt-video-card:nth-child(5) {
animation-delay: 0.25s;
}
.yt-video-card:nth-child(6) {
animation-delay: 0.3s;
}
.yt-video-card:hover {
transform: scale(1.02);
}
.yt-thumbnail-container {
position: relative;
width: 100%;
aspect-ratio: 16/9;
border-radius: var(--yt-radius-lg);
overflow: hidden;
background: var(--yt-bg-secondary);
}
.yt-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.5s ease, transform 0.3s ease;
}
.yt-thumbnail.loaded {
opacity: 1;
}
.yt-video-card:hover .yt-thumbnail {
transform: scale(1.05);
}
.yt-duration {
position: absolute;
bottom: 4px;
right: 4px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 2px 4px;
border-radius: var(--yt-radius-sm);
font-size: 12px;
font-weight: 500;
}
.yt-video-details {
display: flex;
gap: 12px;
padding: 12px 0;
}
.yt-video-meta {
flex: 1;
min-width: 0;
}
.yt-video-title {
font-size: 14px;
font-weight: 500;
line-height: 1.4;
color: var(--yt-text-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 4px;
}
.yt-channel-name {
font-size: 12px;
color: var(--yt-text-secondary);
margin-bottom: 2px;
}
.yt-channel-name:hover {
color: var(--yt-text-primary);
}
.yt-video-stats {
font-size: 12px;
color: var(--yt-text-secondary);
}
/* ===== Shorts Card & Container ===== */
.yt-section {
margin-bottom: 32px;
padding: 0 16px;
}
.yt-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.yt-section-header h2 {
font-size: 20px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.yt-section-header h2 i {
color: var(--yt-accent-red);
}
.yt-section-title-link:hover {
color: var(--yt-text-primary);
opacity: 0.8;
}
.yt-shorts-container {
position: relative;
display: flex;
align-items: center;
}
.yt-shorts-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--yt-bg-primary);
border: 1px solid var(--yt-border);
color: var(--yt-text-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.yt-shorts-arrow:hover {
background: var(--yt-bg-secondary);
transform: translateY(-50%) scale(1.1);
}
.yt-shorts-left {
left: -20px;
}
.yt-shorts-right {
right: -20px;
}
.yt-shorts-grid {
display: flex;
gap: 12px;
overflow-x: auto;
padding: 8px 0;
scroll-behavior: smooth;
scrollbar-width: none;
flex: 1;
}
.yt-shorts-grid::-webkit-scrollbar {
display: none;
}
.yt-short-card {
flex-shrink: 0;
width: 180px;
cursor: pointer;
transition: transform 0.2s;
}
.yt-short-card:hover {
transform: scale(1.02);
}
.yt-short-thumb {
width: 180px;
height: 320px;
border-radius: 12px;
object-fit: cover;
background: var(--yt-bg-secondary);
opacity: 0;
transition: opacity 0.5s ease;
}
.yt-short-thumb.loaded {
opacity: 1;
}
.yt-short-title {
font-size: 14px;
font-weight: 500;
margin-top: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.yt-short-views {
font-size: 12px;
color: var(--yt-text-secondary);
margin-top: 4px;
}
/* ===== Horizontal Video Card ===== */
.yt-video-card-horizontal {
display: flex;
gap: 8px;
margin-bottom: 8px;
cursor: pointer;
border-radius: var(--yt-radius-md);
transition: background 0.2s;
padding: 6px;
}
.yt-video-card-horizontal:hover {
background: var(--yt-bg-hover);
}
.yt-thumb-container-h {
position: relative;
width: 140px;
aspect-ratio: 16/9;
border-radius: var(--yt-radius-md);
overflow: hidden;
flex-shrink: 0;
background: var(--yt-bg-secondary);
}
.yt-thumb-container-h img {
width: 100%;
height: 100%;
object-fit: cover;
}
.yt-details-h {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.yt-title-h {
font-size: 14px;
font-weight: 500;
line-height: 1.3;
color: var(--yt-text-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.yt-meta-h {
font-size: 12px;
color: var(--yt-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 768px) {
.yt-video-card {
border-radius: 0;
padding: 4px !important;
margin-bottom: 4px !important;
}
.yt-thumbnail-container {
border-radius: 6px !important;
/* V4 Override */
}
.yt-video-details {
padding: 6px 8px 12px !important;
}
.yt-video-title {
font-size: 13px !important;
line-height: 1.2 !important;
}
.yt-shorts-arrow {
display: none;
}
}

View file

@ -1,312 +0,0 @@
/**
* KV-Tube AI Chat Styles
* Styling for the transcript Q&A chatbot panel
*/
/* Floating AI Bubble Button */
.ai-chat-bubble {
position: fixed;
bottom: 90px;
/* Above the back button */
right: 20px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 9998;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
transition: transform 0.3s, box-shadow 0.3s;
animation: bubble-pulse 2s infinite;
}
.ai-chat-bubble:hover {
transform: scale(1.1);
box-shadow: 0 6px 24px rgba(102, 126, 234, 0.6);
}
.ai-chat-bubble.active {
animation: none;
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
}
@keyframes bubble-pulse {
0%,
100% {
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
}
50% {
box-shadow: 0 4px 24px rgba(102, 126, 234, 0.7);
}
}
/* Hide bubble on desktop when chat is open */
.ai-chat-panel.visible~.ai-chat-bubble,
body.ai-chat-open .ai-chat-bubble {
animation: none;
}
/* Chat Panel Container */
.ai-chat-panel {
position: fixed;
bottom: 160px;
/* Position above the bubble */
right: 20px;
width: 380px;
max-height: 500px;
background: var(--yt-bg-primary, #0f0f0f);
border: 1px solid var(--yt-border, #272727);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
z-index: 9999;
overflow: hidden;
transform: translateY(20px) scale(0.95);
opacity: 0;
pointer-events: none;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s;
}
.ai-chat-panel.visible {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: auto;
}
/* Chat Header */
.ai-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.ai-chat-header h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.ai-chat-close {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 4px;
opacity: 0.8;
transition: opacity 0.2s;
}
.ai-chat-close:hover {
opacity: 1;
}
/* Model Status */
.ai-model-status {
font-size: 11px;
opacity: 0.9;
margin-top: 2px;
}
.ai-model-status.loading {
color: #ffd700;
}
.ai-model-status.ready {
color: #00ff88;
}
/* Messages Container */
.ai-chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
max-height: 300px;
}
/* Message Bubbles */
.ai-message {
max-width: 85%;
padding: 10px 14px;
border-radius: 16px;
font-size: 13px;
line-height: 1.5;
word-wrap: break-word;
}
.ai-message.user {
align-self: flex-end;
background: #3ea6ff;
color: white;
border-bottom-right-radius: 4px;
}
.ai-message.assistant {
align-self: flex-start;
background: var(--yt-bg-secondary, #272727);
color: var(--yt-text-primary, #fff);
border-bottom-left-radius: 4px;
}
.ai-message.system {
align-self: center;
background: transparent;
color: var(--yt-text-secondary, #aaa);
font-style: italic;
font-size: 12px;
}
/* Typing Indicator */
.ai-typing {
display: flex;
gap: 4px;
padding: 10px 14px;
}
.ai-typing span {
width: 8px;
height: 8px;
background: var(--yt-text-secondary, #aaa);
border-radius: 50%;
animation: typing 1.2s infinite;
}
.ai-typing span:nth-child(2) {
animation-delay: 0.2s;
}
.ai-typing span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%,
60%,
100% {
transform: translateY(0);
}
30% {
transform: translateY(-8px);
}
}
/* Input Area */
.ai-chat-input {
display: flex;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--yt-border, #272727);
background: var(--yt-bg-secondary, #181818);
}
.ai-chat-input input {
flex: 1;
background: var(--yt-bg-primary, #0f0f0f);
border: 1px solid var(--yt-border, #272727);
border-radius: 20px;
padding: 10px 16px;
color: var(--yt-text-primary, #fff);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.ai-chat-input input:focus {
border-color: #3ea6ff;
}
.ai-chat-input input::placeholder {
color: var(--yt-text-secondary, #aaa);
}
.ai-chat-send {
background: #3ea6ff;
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, transform 0.2s;
}
.ai-chat-send:hover {
background: #2d8fd9;
transform: scale(1.05);
}
.ai-chat-send:disabled {
background: #555;
cursor: not-allowed;
}
/* Download Progress */
.ai-download-progress {
padding: 16px;
text-align: center;
}
.ai-download-bar {
width: 100%;
height: 6px;
background: var(--yt-bg-secondary, #272727);
border-radius: 3px;
overflow: hidden;
margin-top: 8px;
}
.ai-download-fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 3px;
transition: width 0.3s;
}
.ai-download-text {
font-size: 12px;
color: var(--yt-text-secondary, #aaa);
margin-top: 8px;
}
/* Mobile */
@media (max-width: 768px) {
.ai-chat-bubble {
bottom: 100px;
/* More space above back button */
right: 24px;
/* Aligned with back button */
width: 48px;
height: 48px;
font-size: 18px;
}
.ai-chat-panel {
width: calc(100% - 20px);
left: 10px;
right: 10px;
bottom: 160px;
max-height: 50vh;
}
}

View file

@ -1,567 +0,0 @@
/* ===== Components ===== */
/* --- Buttons --- */
.yt-menu-btn,
.yt-icon-btn {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--yt-text-primary);
transition: background 0.2s;
font-size: 20px;
}
.yt-menu-btn:hover,
.yt-icon-btn:hover {
background: var(--yt-bg-hover);
}
.yt-back-btn {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--yt-text-primary);
}
/* Search Button */
.yt-search-btn {
width: 64px;
height: 40px;
background: var(--yt-bg-secondary);
border: 1px solid var(--yt-border);
border-radius: 0 20px 20px 0;
color: var(--yt-text-primary);
display: flex;
align-items: center;
justify-content: center;
}
.yt-search-btn:hover {
background: var(--yt-bg-hover);
}
/* Sign In Button */
.yt-signin-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid var(--yt-border);
border-radius: var(--yt-radius-pill);
color: var(--yt-accent-blue);
font-size: 14px;
font-weight: 500;
transition: background 0.2s;
}
.yt-signin-btn:hover {
background: rgba(62, 166, 255, 0.1);
}
/* Primary Button */
.yt-btn-primary {
width: 100%;
padding: 12px 24px;
background: var(--yt-accent-blue);
color: var(--yt-bg-primary);
border-radius: var(--yt-radius-md);
font-size: 16px;
font-weight: 500;
transition: opacity 0.2s;
}
.yt-btn-primary:hover {
opacity: 0.9;
}
/* Floating Back Button */
.yt-floating-back {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
background: var(--yt-accent-blue);
color: white;
border-radius: 50%;
display: none;
/* Hidden on desktop */
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 2000;
cursor: pointer;
transition: transform 0.2s, background 0.2s;
border: none;
}
.yt-floating-back:active {
transform: scale(0.95);
background: #2c95dd;
}
@media (max-width: 768px) {
.yt-floating-back {
display: flex;
/* Show only on mobile */
width: 48px;
height: 48px;
font-size: 18px;
bottom: 24px;
right: 24px;
/* Aligned with AI bubble */
}
.yt-floating-back {
background: var(--yt-accent-red) !important;
}
.yt-floating-back:active {
background: #cc0000 !important;
}
}
/* --- Inputs --- */
.yt-search-form {
display: flex;
flex: 1;
max-width: 600px;
}
.yt-search-input {
flex: 1;
height: 40px;
background: var(--yt-bg-secondary);
border: 1px solid var(--yt-border);
border-right: none;
border-radius: 20px 0 0 20px;
padding: 0 16px;
font-size: 16px;
color: var(--yt-text-primary);
outline: none;
}
.yt-search-input:focus {
border-color: var(--yt-accent-blue);
}
.yt-search-input::placeholder {
color: var(--yt-text-disabled);
}
.yt-form-group {
margin-bottom: 16px;
text-align: left;
}
.yt-form-group label {
display: block;
font-size: 14px;
margin-bottom: 8px;
color: var(--yt-text-secondary);
}
.yt-form-input {
width: 100%;
padding: 12px 16px;
background: var(--yt-bg-primary);
border: 1px solid var(--yt-border);
border-radius: var(--yt-radius-md);
font-size: 16px;
color: var(--yt-text-primary);
outline: none;
transition: border-color 0.2s;
}
.yt-form-input:focus {
border-color: var(--yt-accent-blue);
}
@media (max-width: 768px) {
.yt-search-input {
padding: 0 12px;
font-size: 14px;
border-radius: 18px 0 0 18px;
}
.yt-search-btn {
width: 48px;
border-radius: 0 18px 18px 0;
}
}
/* Mobile Search Bar */
.yt-mobile-search-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--yt-header-height);
background: var(--yt-bg-primary);
display: none;
align-items: center;
gap: 12px;
padding: 0 12px;
z-index: 1100;
}
.yt-mobile-search-bar.active {
display: flex;
}
.yt-mobile-search-bar input {
flex: 1;
height: 40px;
background: var(--yt-bg-secondary);
border: none;
border-radius: 20px;
padding: 0 16px;
font-size: 16px;
color: var(--yt-text-primary);
outline: none;
}
.yt-mobile-search {
display: none;
}
/* --- Avatars --- */
.yt-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--yt-accent-blue);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.yt-channel-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--yt-bg-secondary);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--yt-text-primary);
}
.yt-channel-avatar-lg {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--yt-bg-secondary);
}
/* --- Homepage Sections --- */
.yt-homepage-section {
margin-bottom: 32px;
}
.yt-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 0 4px;
}
.yt-section-header h2 {
font-size: 20px;
font-weight: 600;
color: var(--yt-text-primary);
margin: 0;
}
.yt-see-all {
color: var(--yt-text-secondary);
font-size: 14px;
background: none;
border: none;
cursor: pointer;
padding: 8px 12px;
border-radius: var(--yt-radius-sm);
transition: background 0.2s;
}
.yt-see-all:hover {
background: var(--yt-bg-hover);
}
@media (max-width: 768px) {
.yt-homepage-section {
margin-bottom: 24px;
}
.yt-section-header {
padding: 0 8px;
}
.yt-section-header h2 {
font-size: 18px;
}
}
/* --- Categories / Pills --- */
.yt-categories {
display: flex;
gap: 12px;
padding: 12px 0 24px;
overflow-x: auto;
scrollbar-width: none;
flex-wrap: nowrap;
-ms-overflow-style: none;
/* IE/Edge */
}
.yt-categories::-webkit-scrollbar {
display: none;
}
.yt-chip,
.yt-category-pill {
padding: 0.5rem 1rem;
border-radius: 8px;
background: var(--yt-bg-secondary);
color: var(--yt-text-primary);
border: none;
white-space: nowrap;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background 0.2s;
}
.yt-category-pill {
padding: 8px 12px;
/* style.css match */
border-radius: var(--yt-radius-pill);
}
.yt-chip:hover,
.yt-category-pill:hover {
background: var(--yt-bg-hover);
}
.yt-chip-active,
.yt-category-pill.active {
background: var(--yt-text-primary);
color: var(--yt-bg-primary);
}
.yt-chip-active:hover {
background: var(--yt-text-primary);
opacity: 0.9;
}
@media (max-width: 768px) {
.yt-categories {
padding: 8px 0 8px 8px !important;
gap: 8px;
display: flex !important;
flex-wrap: nowrap !important;
width: 100% !important;
mask-image: linear-gradient(to right, black 95%, transparent 100%);
-webkit-mask-image: linear-gradient(to right, black 95%, transparent 100%);
}
.yt-chip,
.yt-category-pill {
font-size: 12px !important;
padding: 6px 12px !important;
height: 30px !important;
border-radius: 6px !important;
}
}
/* --- Dropdowns --- */
.yt-filter-actions {
flex-shrink: 0;
position: relative;
}
.yt-dropdown-menu {
display: none;
position: absolute;
top: 100%;
right: 0;
width: 200px;
background: var(--yt-bg-secondary);
border-radius: 12px;
padding: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
margin-top: 0.5rem;
z-index: 100;
border: 1px solid var(--yt-border);
}
.yt-dropdown-menu.show {
display: block;
}
.yt-menu-section {
margin-bottom: 1rem;
}
.yt-menu-section:last-child {
margin-bottom: 0;
}
.yt-menu-section h4 {
font-size: 0.8rem;
color: var(--yt-text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.yt-menu-section button {
display: block;
width: 100%;
text-align: left;
padding: 0.5rem;
background: none;
border: none;
color: var(--yt-text-primary);
cursor: pointer;
border-radius: 6px;
}
.yt-menu-section button:hover {
background: var(--yt-bg-hover);
}
/* --- Queue Drawer --- */
.yt-queue-drawer {
position: fixed;
top: 0;
right: -350px;
width: 350px;
height: 100vh;
background: var(--yt-bg-secondary);
z-index: 10000;
transition: right 0.3s ease;
display: flex;
flex-direction: column;
box-shadow: none;
}
.yt-queue-drawer.open {
right: 0;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.5);
}
.yt-queue-header {
padding: 16px;
border-bottom: 1px solid var(--yt-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.yt-queue-header h3 {
font-size: 18px;
font-weight: 600;
}
.yt-queue-list {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.yt-queue-footer {
padding: 16px;
border-top: 1px solid var(--yt-border);
text-align: center;
}
.yt-queue-clear-btn {
background: transparent;
border: 1px solid var(--yt-border);
color: var(--yt-text-primary);
padding: 8px 16px;
border-radius: 18px;
cursor: pointer;
}
.yt-queue-clear-btn:hover {
background: var(--yt-bg-hover);
}
.yt-queue-item {
display: flex;
gap: 12px;
margin-bottom: 12px;
align-items: center;
}
.yt-queue-thumb {
width: 100px;
height: 56px;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
flex-shrink: 0;
}
.yt-queue-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.yt-queue-info {
flex: 1;
overflow: hidden;
}
.yt-queue-title {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.yt-queue-title:hover {
text-decoration: underline;
}
.yt-queue-uploader {
font-size: 12px;
color: var(--yt-text-secondary);
}
.yt-queue-remove {
background: none;
border: none;
color: var(--yt-text-secondary);
cursor: pointer;
padding: 4px;
}
.yt-queue-remove:hover {
color: #ff4e45;
}
@media (max-width: 480px) {
.yt-queue-drawer {
width: 85%;
right: -85%;
}
}

View file

@ -1,696 +0,0 @@
/**
* KV-Tube Download Styles
* Styling for download modal, progress, and library
*/
/* Download Modal */
.download-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.download-modal.visible {
opacity: 1;
visibility: visible;
}
.download-modal-content {
background: var(--yt-bg-primary, #0f0f0f);
border: 1px solid var(--yt-border, #272727);
border-radius: 16px;
width: 90%;
max-width: 450px;
max-height: 80vh;
overflow-y: auto;
padding: 20px;
transform: scale(0.9);
transition: transform 0.3s ease;
}
.download-modal.visible .download-modal-content {
transform: scale(1);
}
/* Header */
.download-header {
display: flex;
gap: 16px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--yt-border, #272727);
}
.download-thumb {
width: 120px;
aspect-ratio: 16/9;
object-fit: cover;
border-radius: 8px;
}
.download-info h4 {
font-size: 14px;
font-weight: 500;
margin: 0 0 8px 0;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.download-info span {
color: var(--yt-text-secondary, #aaa);
font-size: 12px;
}
/* Options */
.download-options h5 {
font-size: 13px;
color: var(--yt-text-secondary, #aaa);
margin: 16px 0 12px 0;
display: flex;
align-items: center;
gap: 8px;
}
.format-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.format-btn {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: var(--yt-bg-secondary, #272727);
border: 1px solid transparent;
border-radius: 8px;
color: var(--yt-text-primary, #fff);
cursor: pointer;
transition: all 0.2s;
flex: 1;
min-width: 120px;
}
.format-btn:hover {
background: var(--yt-bg-hover, #3a3a3a);
border-color: var(--yt-accent-blue, #3ea6ff);
}
.format-btn.audio {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2), rgba(118, 75, 162, 0.2));
}
.format-btn.audio:hover {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.3), rgba(118, 75, 162, 0.3));
}
.format-quality {
font-weight: 600;
font-size: 14px;
}
.format-size {
color: var(--yt-text-secondary, #aaa);
font-size: 12px;
flex: 1;
}
.format-btn i {
color: var(--yt-accent-blue, #3ea6ff);
}
/* Recommended format styling */
.format-btn.recommended {
position: relative;
background: linear-gradient(135deg, rgba(255, 0, 0, 0.15), rgba(255, 68, 68, 0.1));
border: 2px solid #ff4444;
flex-direction: column;
align-items: flex-start;
padding: 16px;
min-width: 160px;
}
.format-btn.recommended:hover {
background: linear-gradient(135deg, rgba(255, 0, 0, 0.25), rgba(255, 68, 68, 0.15));
border-color: #ff6666;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 0, 0, 0.2);
}
.format-btn.recommended .format-quality {
font-size: 18px;
}
.format-btn.recommended .fa-download {
position: absolute;
right: 12px;
bottom: 12px;
}
.format-badge {
background: linear-gradient(135deg, #ff0000, #cc0000);
color: #fff;
font-size: 10px;
font-weight: 600;
padding: 3px 8px;
border-radius: 10px;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Toggle button for advanced options */
.format-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px;
margin-top: 16px;
background: transparent;
border: 1px dashed var(--yt-border, #3a3a3a);
border-radius: 8px;
color: var(--yt-text-secondary, #aaa);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.format-toggle:hover {
border-color: var(--yt-accent-blue, #3ea6ff);
color: var(--yt-accent-blue, #3ea6ff);
background: rgba(62, 166, 255, 0.05);
}
.format-advanced {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--yt-border, #272727);
}
/* Recommended dot indicator in full list */
.format-btn.is-recommended {
border-color: rgba(255, 0, 0, 0.3);
}
.rec-dot {
width: 6px;
height: 6px;
background: #ff4444;
border-radius: 50%;
flex-shrink: 0;
}
/* Loading & Error */
.download-loading,
.download-error {
text-align: center;
padding: 40px;
color: var(--yt-text-secondary, #aaa);
}
.download-loading i,
.download-error i {
font-size: 24px;
margin-bottom: 12px;
display: block;
}
.download-error {
color: #ff4444;
}
/* Close button */
.download-close {
position: absolute;
top: 16px;
right: 16px;
background: none;
border: none;
color: var(--yt-text-secondary, #aaa);
cursor: pointer;
padding: 8px;
font-size: 18px;
transition: color 0.2s;
}
.download-close:hover {
color: #fff;
}
/* Progress indicator inline */
.download-progress-inline {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--yt-bg-secondary, #272727);
border-radius: 8px;
margin-top: 12px;
}
.download-progress-bar {
flex: 1;
height: 4px;
background: var(--yt-border, #3a3a3a);
border-radius: 2px;
overflow: hidden;
}
.download-progress-fill {
height: 100%;
background: linear-gradient(90deg, #3ea6ff, #667eea);
border-radius: 2px;
transition: width 0.3s;
}
.download-progress-text {
font-size: 12px;
color: var(--yt-text-secondary, #aaa);
min-width: 40px;
text-align: right;
}
/* Downloads Library Page */
.downloads-page {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.downloads-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.downloads-header h1 {
font-size: 24px;
font-weight: 600;
}
.downloads-clear-btn {
padding: 10px 20px;
background: rgba(255, 68, 68, 0.1);
border: 1px solid #ff4444;
border-radius: 20px;
color: #ff4444;
cursor: pointer;
transition: all 0.2s;
}
.downloads-clear-btn:hover {
background: rgba(255, 68, 68, 0.2);
}
.downloads-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.download-item {
display: flex;
gap: 16px;
padding: 16px;
background: var(--yt-bg-secondary, #181818);
border-radius: 12px;
transition: background 0.2s;
}
.download-item:hover {
background: var(--yt-bg-hover, #272727);
}
.download-item-thumb {
width: 160px;
aspect-ratio: 16/9;
object-fit: cover;
border-radius: 8px;
}
/* Thumbnail wrapper with play overlay */
.download-item-thumb-wrapper {
position: relative;
width: 160px;
flex-shrink: 0;
}
.download-item-thumb-wrapper .download-item-thumb {
width: 100%;
}
.download-thumb-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
border-radius: 8px;
}
.download-thumb-overlay i {
font-size: 32px;
color: #fff;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
}
.download-item.playable {
cursor: pointer;
}
.download-item.playable:hover .download-thumb-overlay {
opacity: 1;
}
/* Play button in actions */
.download-item-play {
padding: 10px 16px;
background: linear-gradient(135deg, #ff0000, #cc0000);
border: none;
border-radius: 20px;
color: #fff;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.download-item-play:hover {
background: linear-gradient(135deg, #ff3333, #ff0000);
transform: scale(1.05);
}
/* Re-download button in actions */
.download-item-redownload {
padding: 8px 12px;
background: linear-gradient(135deg, #3ea6ff, #2196f3);
border: none;
border-radius: 16px;
color: #fff;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
}
.download-item-redownload:hover {
background: linear-gradient(135deg, #5bb5ff, #3ea6ff);
transform: scale(1.05);
}
.download-item-info {
flex: 1;
}
.download-item-title {
font-size: 15px;
font-weight: 500;
margin-bottom: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.download-item-meta {
font-size: 12px;
color: var(--yt-text-secondary, #aaa);
}
.download-item-actions {
display: flex;
gap: 8px;
align-items: center;
}
.download-item-remove {
padding: 8px;
background: none;
border: none;
color: var(--yt-text-secondary, #aaa);
cursor: pointer;
border-radius: 50%;
transition: all 0.2s;
}
.download-item-remove:hover {
background: rgba(255, 68, 68, 0.1);
color: #ff4444;
}
/* Active download progress bar container */
.download-progress-container {
width: 100%;
height: 6px;
background: var(--yt-border, #3a3a3a);
border-radius: 3px;
overflow: hidden;
margin-top: 10px;
}
.download-progress-bar {
height: 100%;
background: linear-gradient(90deg, #ff0000, #ff4444);
border-radius: 3px;
transition: width 0.3s ease;
box-shadow: 0 0 8px rgba(255, 68, 68, 0.5);
}
/* Active download item styling */
.download-item.active {
background: linear-gradient(135deg, rgba(255, 0, 0, 0.12), rgba(255, 68, 68, 0.08));
border: 1px solid rgba(255, 0, 0, 0.3);
animation: pulse-active 2s infinite;
}
@keyframes pulse-active {
0%,
100% {
box-shadow: 0 0 0 0 rgba(255, 68, 68, 0);
}
50% {
box-shadow: 0 0 12px 2px rgba(255, 68, 68, 0.15);
}
}
.download-item.active .status-text {
color: #ff4444;
font-weight: 600;
font-size: 13px;
}
.downloads-empty {
text-align: center;
padding: 60px 20px;
color: var(--yt-text-secondary, #aaa);
}
.downloads-empty i {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
/* Mobile */
@media (max-width: 768px) {
.download-modal-content {
width: 95%;
padding: 16px;
}
.download-header {
flex-direction: column;
}
.download-thumb {
width: 100%;
}
.format-btn {
min-width: 100%;
}
.download-item {
flex-direction: column;
}
.download-item-thumb,
.download-item-thumb-wrapper {
width: 100%;
}
}
/* ===== Floating Download Progress Widget ===== */
.download-widget {
position: fixed;
bottom: 24px;
right: 24px;
width: 300px;
background: var(--yt-bg-primary, #0f0f0f);
border: 1px solid var(--yt-border, #272727);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
z-index: 9999;
overflow: hidden;
}
.download-widget-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--yt-bg-secondary, #181818);
border-bottom: 1px solid var(--yt-border, #272727);
}
.download-widget-left {
display: flex;
align-items: center;
gap: 10px;
}
.download-widget-left i {
color: #ff4444;
font-size: 16px;
}
.download-widget-title {
font-size: 13px;
font-weight: 500;
color: var(--yt-text-primary, #fff);
}
.download-widget-actions {
display: flex;
align-items: center;
gap: 12px;
}
.download-widget-btn {
background: none;
border: none;
color: var(--yt-text-secondary, #aaa);
cursor: pointer;
padding: 8px;
border-radius: 4px;
transition: all 0.2s;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
}
.download-widget-btn:hover {
background: var(--yt-bg-hover, #272727);
color: var(--yt-text-primary, #fff);
}
.download-widget-btn.close:hover {
background: rgba(255, 68, 68, 0.2);
color: #ff4444;
}
.download-widget-content {
padding: 12px 16px;
}
.download-widget-item {
/* Container for single download item - no additional styles needed */
display: block;
}
.download-widget-info {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
font-size: 12px;
}
.download-widget-info #downloadWidgetName {
color: var(--yt-text-primary, #fff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
font-weight: 500;
}
.download-widget-meta {
display: flex;
justify-content: space-between;
width: 100%;
align-items: center;
}
.download-widget-meta #downloadWidgetPercent {
color: #ff4444;
font-weight: 600;
}
.download-speed {
color: #4caf50;
font-weight: 500;
}
/* Specs Styling */
.download-item-specs {
margin-top: 4px;
color: var(--yt-text-secondary);
font-size: 12px;
font-family: monospace;
opacity: 0.8;
}
.meta-specs {
color: var(--yt-text-secondary);
opacity: 0.7;
margin-left: 4px;
}
.download-widget-bar {
height: 4px;
background: var(--yt-border, #3a3a3a);
border-radius: 2px;
overflow: hidden;
}
.download-widget-fill {
height: 100%;
background: linear-gradient(90deg, #ff0000, #ff4444);
border-radius: 2px;
transition: width 0.3s ease;
}
/* Mobile responsiveness for widget */
@media (max-width: 480px) {
.download-widget {
display: none !important;
}
}

View file

@ -1,86 +0,0 @@
/* ===== Video Grid ===== */
.yt-video-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
@media (max-width: 1400px) {
.yt-video-grid,
.yt-section-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 1100px) {
.yt-video-grid,
.yt-section-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.yt-video-grid,
.yt-section-grid {
grid-template-columns: 1fr;
}
}
/* Grid Layout for Sections (4 rows x 4 columns = 16 videos) */
.yt-section-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
padding-bottom: 24px;
}
.yt-section-grid .yt-video-card {
width: 100%;
min-width: 0;
}
/* Scrollbar hiding */
.yt-section-grid::-webkit-scrollbar {
display: none;
}
/* Mobile Grid Overrides */
@media (max-width: 768px) {
/* Main Grid - Single column for mobile */
.yt-video-grid {
grid-template-columns: 1fr !important;
gap: 16px !important;
padding: 0 12px !important;
background: var(--yt-bg-primary);
}
/* Section Grid - Single column vertical scroll */
.yt-section-grid {
display: grid;
grid-template-columns: 1fr !important;
gap: 16px;
padding-bottom: 12px;
overflow: visible;
}
.yt-section-grid::-webkit-scrollbar {
display: none;
}
/* Adjust video card size for single column */
.yt-section-grid .yt-video-card {
width: 100%;
margin: 0;
}
}
/* Tablet Grid */
@media (min-width: 769px) and (max-width: 1024px) {
.yt-video-grid {
grid-template-columns: repeat(2, 1fr);
}
}

View file

@ -1,342 +0,0 @@
/* ===== App Layout ===== */
.app-wrapper {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* ===== Header (YouTube Style) ===== */
.yt-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--yt-header-height);
background: var(--yt-bg-primary);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
z-index: 1000;
border-bottom: 1px solid var(--yt-border);
}
.yt-header-start {
display: flex;
align-items: center;
gap: 16px;
min-width: 200px;
}
.yt-header-center {
flex: 1;
display: flex;
justify-content: center;
max-width: 728px;
margin: 0 40px;
}
.yt-header-end {
display: flex;
align-items: center;
gap: 8px;
min-width: 200px;
justify-content: flex-end;
}
/* Logo */
.yt-logo {
display: flex;
align-items: center;
gap: 4px;
font-size: 20px;
font-weight: 600;
color: var(--yt-text-primary);
text-decoration: none;
}
.yt-logo-icon {
width: 90px;
height: 20px;
background: var(--yt-accent-red);
border-radius: var(--yt-radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
letter-spacing: -0.5px;
}
/* ===== Sidebar (YouTube Style) ===== */
.yt-sidebar {
position: fixed;
top: var(--yt-header-height);
left: 0;
bottom: 0;
width: var(--yt-sidebar-width);
background: var(--yt-bg-primary);
overflow-y: auto;
overflow-x: hidden;
padding: 12px 0;
z-index: 900;
transition: width 0.2s, transform 0.2s;
}
.yt-sidebar.collapsed {
width: var(--yt-sidebar-mini);
}
.yt-sidebar-item {
display: flex;
align-items: center;
gap: 24px;
padding: 10px 12px 10px 24px;
color: var(--yt-text-primary);
font-size: 14px;
border-radius: var(--yt-radius-lg);
margin: 0 12px;
transition: background 0.2s;
}
.yt-sidebar-item:hover {
background: var(--yt-bg-hover);
}
.yt-sidebar-item.active {
background: var(--yt-bg-secondary);
font-weight: 500;
}
.yt-sidebar-item i {
font-size: 18px;
width: 22px;
text-align: center;
}
.yt-sidebar-item span {
white-space: nowrap;
}
.yt-sidebar.collapsed .yt-sidebar-item {
flex-direction: column;
gap: 0;
padding: 16px 0;
margin: 0;
border-radius: 0;
justify-content: center;
align-items: center;
text-align: center;
}
/* Hide text labels in collapsed mode - icons only */
.yt-sidebar.collapsed .yt-sidebar-item span {
display: none;
}
/* Center icons in collapsed mode */
.yt-sidebar.collapsed .yt-sidebar-item i {
font-size: 20px;
width: 100%;
text-align: center;
}
/* Hide Saved, Subscriptions, dividers and titles in collapsed mode */
.yt-sidebar.collapsed .yt-sidebar-title,
.yt-sidebar.collapsed .yt-sidebar-divider {
display: none;
}
/* Hide Saved and Subscriptions globally (both full and collapsed sidebar) */
.yt-sidebar a[data-category="saved"],
.yt-sidebar a[data-category="subscriptions"] {
display: none;
}
.yt-sidebar-divider {
height: 1px;
background: var(--yt-border);
margin: 12px 0;
}
.yt-sidebar-title {
padding: 8px 24px;
font-size: 14px;
color: var(--yt-text-secondary);
font-weight: 500;
}
/* Sidebar Overlay (Mobile) */
.yt-sidebar-overlay {
position: fixed;
top: var(--yt-header-height);
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 899;
display: none;
}
.yt-sidebar-overlay.active {
display: block;
}
/* ===== Main Content ===== */
.yt-main {
margin-top: var(--yt-header-height);
margin-left: var(--yt-sidebar-width);
padding: 24px;
min-height: calc(100vh - var(--yt-header-height));
transition: margin-left 0.2s;
}
.yt-main.sidebar-collapsed {
margin-left: var(--yt-sidebar-mini);
}
/* ===== Filter Bar ===== */
/* From index.html originally */
.yt-filter-bar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0 1rem;
margin-bottom: 1rem;
position: sticky;
top: 56px;
/* Adjust based on header height */
z-index: 99;
background: var(--yt-bg-primary);
border-bottom: 1px solid var(--yt-border);
}
/* ===== Responsive Layout Overrides ===== */
@media (max-width: 1024px) {
/* Hide sidebar completely on mobile - it slides in as overlay when opened */
.yt-sidebar {
transform: translateX(-100%);
width: var(--yt-sidebar-width);
/* Full width when shown */
z-index: 1000;
/* Above main content */
}
.yt-sidebar.open {
transform: translateX(0);
}
/* Main content takes full width on mobile - no margin for sidebar */
.yt-main {
margin-left: 0 !important;
/* Override any sidebar-collapsed state */
width: 100%;
}
/* Ignore sidebar-collapsed class on mobile */
.yt-main.sidebar-collapsed {
margin-left: 0 !important;
}
.yt-header-center {
margin: 0 20px;
}
}
@media (max-width: 768px) {
.yt-header-center {
display: flex;
/* Show search on mobile */
margin: 0 8px;
max-width: none;
flex: 1;
justify-content: center;
}
.yt-header-start,
.yt-header-end {
min-width: auto;
}
.yt-logo span:last-child {
display: none;
}
.yt-main {
padding: 12px;
}
/* Reduce header padding and make search fill space */
.yt-header {
padding: 0 8px !important;
gap: 8px;
}
.yt-header-start {
gap: 4px;
}
.yt-header-end {
display: none;
/* Hide empty header end on mobile */
}
/* Filter bar spacing */
.yt-filter-bar {
padding-left: 0 !important;
padding-right: 0 !important;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
.yt-main {
padding: 16px;
}
}
/* ===== Download Badge ===== */
.yt-badge {
position: absolute;
top: 2px;
right: 8px;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: #ff0000;
color: #fff;
font-size: 11px;
font-weight: 600;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
animation: badge-pulse 2s infinite;
}
@keyframes badge-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
/* Make sidebar item relative for badge positioning */
.yt-sidebar-item {
position: relative;
}
/* When sidebar is collapsed, adjust badge position */
.yt-sidebar.collapsed .yt-badge {
top: 6px;
right: 12px;
min-width: 16px;
height: 16px;
font-size: 10px;
}

View file

@ -1,249 +0,0 @@
/* ===== Watch Page ===== */
/* Layout rules moved to watch.css - this is kept for compatibility */
.yt-watch-layout {
display: grid;
grid-template-columns: 1fr 400px;
gap: 24px;
}
.yt-player-section {
width: 100%;
}
.yt-player-container {
width: 100%;
aspect-ratio: 16/9;
background: #000;
border-radius: var(--yt-radius-lg);
overflow: hidden;
}
.yt-video-info {
padding: 16px 0;
}
.yt-video-info h1 {
font-size: 20px;
font-weight: 600;
line-height: 1.4;
margin-bottom: 12px;
}
.yt-video-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.yt-action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--yt-bg-secondary);
border-radius: var(--yt-radius-pill);
font-size: 14px;
font-weight: 500;
color: var(--yt-text-primary);
transition: background 0.2s;
}
.yt-action-btn:hover {
background: var(--yt-bg-hover);
}
.yt-action-btn.active {
background: var(--yt-text-primary);
color: var(--yt-bg-primary);
}
.yt-channel-info {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid var(--yt-border);
}
.yt-channel-details {
display: flex;
align-items: center;
gap: 12px;
}
.yt-subscribe-btn {
padding: 10px 16px;
background: var(--yt-text-primary);
color: var(--yt-bg-primary);
border-radius: var(--yt-radius-pill);
font-size: 14px;
font-weight: 500;
transition: opacity 0.2s;
}
.yt-subscribe-btn:hover {
opacity: 0.9;
}
.yt-subscribe-btn.subscribed {
background: var(--yt-bg-secondary);
color: var(--yt-text-primary);
}
.yt-description-box {
background: var(--yt-bg-secondary);
border-radius: var(--yt-radius-lg);
padding: 12px;
margin-top: 16px;
cursor: pointer;
}
.yt-description-box:hover {
background: var(--yt-bg-hover);
}
.yt-description-stats {
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
}
.yt-description-text {
font-size: 14px;
color: var(--yt-text-primary);
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Suggested Videos */
.yt-suggested {
display: flex;
flex-direction: column;
gap: 8px;
max-height: calc(100vh - 100px);
overflow-y: auto;
position: sticky;
top: 80px;
padding-right: 8px;
}
/* Custom scrollbar for suggested videos */
.yt-suggested::-webkit-scrollbar {
width: 6px;
}
.yt-suggested::-webkit-scrollbar-track {
background: transparent;
}
.yt-suggested::-webkit-scrollbar-thumb {
background: var(--yt-border);
border-radius: 3px;
}
.yt-suggested::-webkit-scrollbar-thumb:hover {
background: var(--yt-text-secondary);
}
.yt-suggested-card {
display: flex;
gap: 8px;
cursor: pointer;
padding: 4px;
border-radius: var(--yt-radius-md);
transition: background 0.2s;
}
.yt-suggested-card:hover {
background: var(--yt-bg-secondary);
}
.yt-suggested-thumb {
width: 168px;
aspect-ratio: 16/9;
border-radius: var(--yt-radius-md);
object-fit: cover;
flex-shrink: 0;
}
.yt-suggested-info {
flex: 1;
min-width: 0;
}
.yt-suggested-title {
font-size: 14px;
font-weight: 500;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 4px;
}
.yt-suggested-channel {
font-size: 12px;
color: var(--yt-text-secondary);
}
.yt-suggested-stats {
font-size: 12px;
color: var(--yt-text-secondary);
}
@media (max-width: 1200px) {
.yt-watch-layout {
grid-template-columns: 1fr;
}
.yt-suggested {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
max-height: none;
/* Allow full height on mobile/tablet */
position: static;
overflow-y: visible;
}
.yt-suggested-card {
flex-direction: column;
}
.yt-suggested-thumb {
width: 100%;
}
}
/* ===== Auth Pages ===== */
.yt-auth-container {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - var(--yt-header-height) - 100px);
}
.yt-auth-card {
background: var(--yt-bg-secondary);
border-radius: var(--yt-radius-lg);
padding: 48px;
width: 100%;
max-width: 400px;
text-align: center;
}
.yt-auth-card h2 {
font-size: 24px;
margin-bottom: 8px;
}
.yt-auth-card p {
color: var(--yt-text-secondary);
margin-bottom: 24px;
}

View file

@ -1,212 +0,0 @@
/* ===== Animations ===== */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ===== Skeleton Loader (Shimmer) ===== */
.skeleton {
background: var(--yt-bg-secondary);
background: linear-gradient(90deg,
var(--yt-bg-secondary) 25%,
var(--yt-bg-hover) 50%,
var(--yt-bg-secondary) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.skeleton-card {
display: flex;
flex-direction: column;
gap: 12px;
}
.skeleton-thumb {
width: 100%;
aspect-ratio: 16/9;
border-radius: var(--yt-radius-lg);
}
.skeleton-details {
display: flex;
gap: 12px;
}
.skeleton-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
}
.skeleton-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.skeleton-title {
height: 14px;
width: 90%;
}
.skeleton-meta {
height: 12px;
width: 60%;
}
.skeleton-short {
width: 180px;
height: 320px;
border-radius: 12px;
background: var(--yt-bg-secondary);
background: linear-gradient(90deg,
var(--yt-bg-secondary) 25%,
var(--yt-bg-hover) 50%,
var(--yt-bg-secondary) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
flex-shrink: 0;
}
.skeleton-comment {
display: flex;
gap: 16px;
margin-bottom: 20px;
}
.skeleton-comment-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--yt-bg-secondary);
}
.skeleton-comment-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.skeleton-line {
height: 12px;
border-radius: 4px;
background: var(--yt-bg-secondary);
background: linear-gradient(90deg,
var(--yt-bg-secondary) 25%,
var(--yt-bg-hover) 50%,
var(--yt-bg-secondary) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
/* ===== Loader ===== */
.yt-loader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 300px;
color: var(--yt-text-secondary);
background: transparent;
}
/* ===== Friendly Empty State ===== */
.yt-empty-state {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: var(--yt-text-secondary);
}
.yt-empty-icon {
font-size: 48px;
margin-bottom: 24px;
opacity: 0.5;
}
.yt-empty-title {
font-size: 18px;
font-weight: 500;
margin-bottom: 8px;
color: var(--yt-text-primary);
}
.yt-empty-desc {
font-size: 14px;
margin-bottom: 24px;
}
/* ===== Toasts ===== */
.yt-toast-container {
position: fixed;
bottom: 24px;
left: 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: none;
}
.yt-toast {
background: #1f1f1f;
color: #fff;
padding: 12px 24px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
font-size: 14px;
animation: slideUp 0.3s ease;
pointer-events: auto;
display: flex;
align-items: center;
gap: 12px;
min-width: 280px;
border-left: 4px solid #3ea6ff;
}
.yt-toast.error {
border-left-color: #ff4e45;
}
.yt-toast.success {
border-left-color: #2ba640;
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View file

@ -1,43 +0,0 @@
/* ===== YouTube Dark Theme Colors ===== */
:root {
--yt-bg-primary: #0f0f0f;
--yt-bg-secondary: #333333;
--yt-bg-elevated: #282828;
--yt-bg-hover: #444444;
--yt-bg-active: #3ea6ff;
--yt-text-primary: #f1f1f1;
--yt-text-secondary: #aaaaaa;
--yt-text-disabled: #717171;
--yt-static-white: #ffffff;
--yt-accent-red: #ff0000;
--yt-accent-blue: #3ea6ff;
--yt-border: rgba(255, 255, 255, 0.1);
--yt-divider: rgba(255, 255, 255, 0.2);
--yt-header-height: 56px;
--yt-sidebar-width: 240px;
--yt-sidebar-mini: 72px;
--yt-radius-sm: 4px;
--yt-radius-md: 8px;
--yt-radius-lg: 12px;
--yt-radius-xl: 16px;
--yt-radius-pill: 9999px;
}
[data-theme="light"] {
--yt-bg-primary: #ffffff;
--yt-bg-secondary: #f2f2f2;
--yt-bg-elevated: #e5e5e5;
--yt-bg-hover: #e5e5e5;
--yt-text-primary: #0f0f0f;
--yt-text-secondary: #606060;
--yt-text-disabled: #909090;
--yt-border: rgba(0, 0, 0, 0.1);
--yt-divider: rgba(0, 0, 0, 0.1);
}

View file

@ -1,762 +0,0 @@
/**
* KV-Tube Watch Page Styles
* Extracted from watch.html for better maintainability
*/
/* ========== Base Reset ========== */
html,
body {
overflow-x: hidden;
width: 100%;
max-width: 100vw;
}
/* ========== Player Container ========== */
.yt-player-container {
position: relative;
width: 100%;
aspect-ratio: 16/9;
background: #000;
border-radius: 12px;
overflow: hidden;
}
/* Mini player removed per user request */
/* ========== Skeleton Loading ========== */
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.skeleton {
background: linear-gradient(90deg, var(--yt-bg-secondary) 25%, var(--yt-bg-hover) 50%, var(--yt-bg-secondary) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
.skeleton-line {
height: 20px;
margin-bottom: 8px;
}
.skeleton-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.skeleton-block {
display: block;
width: 100%;
}
/* ========== Watch Page Layout ========== */
/* Only apply these overrides when the watch layout is present */
.yt-main:has(.yt-watch-layout) {
padding: 0 !important;
/* Auto-collapse main content margin on watch page to match collapsed sidebar */
margin-left: var(--yt-sidebar-mini) !important;
}
/* Auto-collapse sidebar on watch page */
.yt-sidebar:has(~ .yt-sidebar-overlay ~ .yt-main .yt-watch-layout),
body:has(.yt-watch-layout) .yt-sidebar {
width: var(--yt-sidebar-mini);
}
/* Sidebar item styling for mini mode on watch page */
body:has(.yt-watch-layout) .yt-sidebar .yt-sidebar-item {
flex-direction: column;
gap: 0;
padding: 16px 0;
margin: 0;
border-radius: 0;
justify-content: center;
align-items: center;
text-align: center;
}
/* Hide text labels in mini mode - icons only */
body:has(.yt-watch-layout) .yt-sidebar .yt-sidebar-item span {
display: none;
}
/* Center the icons */
body:has(.yt-watch-layout) .yt-sidebar .yt-sidebar-item i {
font-size: 20px;
width: 100%;
text-align: center;
}
/* Hide Saved, Subscriptions, and dividers/titles on watch page */
body:has(.yt-watch-layout) .yt-sidebar .yt-sidebar-title,
body:has(.yt-watch-layout) .yt-sidebar .yt-sidebar-divider,
body:has(.yt-watch-layout) .yt-sidebar a[data-category="saved"],
body:has(.yt-watch-layout) .yt-sidebar a[data-category="subscriptions"] {
display: none;
}
/* Theater Mode (Default) - Full width video with sidebar below */
.yt-watch-layout {
display: flex;
flex-direction: column;
width: 100%;
padding: 8px 24px 24px;
box-sizing: border-box;
}
/* Default Mode - 2 column layout */
.yt-watch-layout.default-mode {
display: grid;
grid-template-columns: 1fr 400px;
gap: 24px;
max-width: 100%;
}
.yt-watch-layout.default-mode .yt-watch-sidebar {
position: sticky;
top: 80px;
align-self: start;
max-height: calc(100vh - 100px);
}
/* Theater mode sidebar moves below */
.yt-watch-layout:not(.default-mode) .yt-watch-sidebar {
margin-top: 24px;
}
.yt-watch-sidebar {
display: flex;
flex-direction: column;
gap: 0;
overflow: visible;
}
/* View Mode Button Styles */
.view-mode-buttons {
display: flex;
gap: 8px;
margin-left: auto;
}
.view-mode-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: var(--yt-bg-secondary);
color: var(--yt-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.view-mode-btn:hover {
background: var(--yt-bg-hover);
color: var(--yt-text-primary);
}
.view-mode-btn.active {
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
color: white;
}
.yt-channel-avatar-lg {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: white;
}
/* ========== Comments Section ========== */
.yt-comments-section {
margin-top: 24px;
border-top: 1px solid var(--yt-border);
padding-top: 16px;
}
.yt-comments-toggle {
width: 100%;
background: var(--yt-bg-secondary);
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: background 0.2s;
}
.yt-comments-toggle:hover {
background: var(--yt-bg-hover);
}
.yt-comments-preview {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--yt-text-primary);
font-size: 14px;
font-weight: 500;
}
.yt-comments-preview i {
transition: transform 0.3s;
}
.yt-comments-preview i.rotated {
transform: rotate(180deg);
}
.yt-comments-content {
margin-top: 16px;
animation: fadeIn 0.3s ease;
}
.yt-comments-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.yt-comments-header h3 {
font-size: 16px;
font-weight: 500;
}
.yt-comments-list {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 500px;
overflow-y: auto;
}
/* Hide in shorts mode */
.shorts-mode .yt-video-info,
.shorts-mode .yt-suggested {
display: none !important;
}
@media (max-width: 768px) {
.art-control-time {
display: none !important;
}
}
/* ========== Comment Styles ========== */
.yt-comment {
display: flex;
gap: 12px;
}
.yt-comment-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--yt-bg-hover);
flex-shrink: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--yt-text-primary);
}
.yt-comment-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.yt-comment-content {
flex: 1;
}
.yt-comment-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
flex-wrap: wrap;
}
.yt-comment-author {
font-size: 13px;
font-weight: 500;
color: var(--yt-text-primary);
}
.yt-comment-time {
font-size: 12px;
color: var(--yt-text-secondary);
}
.yt-comment-text {
font-size: 14px;
line-height: 1.5;
color: var(--yt-text-primary);
margin-bottom: 8px;
white-space: pre-wrap;
word-wrap: break-word;
}
.yt-comment-actions {
display: flex;
align-items: center;
gap: 8px;
}
.yt-comment-action {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--yt-text-secondary);
padding: 4px 8px;
border-radius: 20px;
}
/* ========== Action Buttons ========== */
.yt-video-actions {
display: flex;
align-items: center;
gap: 8px;
/* Reduced gap */
flex-wrap: wrap;
margin-top: 12px;
}
.yt-action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 0 12px;
/* Compact padding */
height: 32px;
/* Compact height */
border-radius: 16px;
/* Pill shape */
border: none;
background: var(--yt-bg-secondary);
font-size: 13px;
font-weight: 500;
color: var(--yt-text-primary);
cursor: pointer;
transition: background 0.2s;
}
.yt-action-btn i {
font-size: 14px;
}
.yt-action-btn:hover {
background: var(--yt-bg-hover);
}
.yt-action-btn.active {
color: #fff !important;
background: #ff0000 !important;
border-color: #ff0000 !important;
box-shadow: 0 0 10px rgba(255, 0, 0, 0.4);
}
/* Queue Badge */
.queue-badge {
position: absolute;
top: -6px;
right: -6px;
background: #ff0000;
color: #fff;
font-size: 10px;
font-weight: 600;
min-width: 18px;
height: 18px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
.yt-pinned-badge {
background: var(--yt-bg-secondary);
font-size: 11px;
padding: 2px 8px;
border-radius: 2px;
color: var(--yt-text-secondary);
}
.yt-no-comments {
text-align: center;
color: var(--yt-text-secondary);
padding: 24px;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 1200px) {
.yt-watch-layout {
grid-template-columns: 1fr;
}
}
/* ========== Queue Dropdown ========== */
.yt-queue-dropdown {
position: relative;
background: var(--yt-bg-secondary);
border-radius: var(--yt-radius-md);
margin-bottom: 12px;
}
.yt-queue-dropdown-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--yt-text-primary);
transition: background 0.2s;
}
.yt-queue-dropdown-header:hover {
background: var(--yt-bg-hover);
border-radius: var(--yt-radius-md);
}
.yt-queue-dropdown-header span {
display: flex;
align-items: center;
gap: 8px;
}
.yt-queue-dropdown-header i.fa-chevron-down {
font-size: 12px;
transition: transform 0.3s;
}
.yt-queue-dropdown-header i.fa-chevron-down.rotated {
transform: rotate(180deg);
}
.yt-queue-dropdown-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: var(--yt-bg-secondary);
border-radius: 0 0 var(--yt-radius-md) var(--yt-radius-md);
will-change: max-height;
}
.yt-queue-dropdown-content.expanded {
max-height: 500px;
overflow-y: auto;
}
#queueList {
padding: 8px;
}
.yt-queue-item {
display: flex;
gap: 10px;
padding: 8px;
border-radius: var(--yt-radius-md);
cursor: pointer;
transition: background 0.2s;
}
.yt-queue-item:hover {
background: var(--yt-bg-hover);
}
.yt-queue-item img {
width: 100px;
aspect-ratio: 16/9;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.yt-queue-item-info {
flex: 1;
min-width: 0;
}
.yt-queue-item-title {
font-size: 13px;
font-weight: 500;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 4px;
}
.yt-queue-item-uploader {
font-size: 11px;
color: var(--yt-text-secondary);
}
.yt-queue-remove-btn {
background: none;
border: none;
color: var(--yt-text-secondary);
cursor: pointer;
padding: 8px;
opacity: 0;
transition: opacity 0.2s, color 0.2s;
border-radius: 50%;
}
.yt-queue-item:hover .yt-queue-remove-btn {
opacity: 1;
}
.yt-queue-remove-btn:hover {
color: var(--yt-accent-red);
background: rgba(255, 0, 0, 0.1);
}
.yt-queue-empty {
text-align: center;
color: var(--yt-text-secondary);
padding: 12px;
font-size: 12px;
}
/* ========== Mobile/Tablet Responsiveness ========== */
@media (max-width: 1024px) {
/* Ensure full width layout on mobile - no sidebar margin/gap */
.yt-main:has(.yt-watch-layout) {
margin-left: 0 !important;
margin-top: 56px !important;
/* Exactly header height */
padding: 0 !important;
width: 100% !important;
max-width: 100vw !important;
box-sizing: border-box !important;
background: var(--yt-bg-primary);
}
.yt-watch-layout {
display: block;
padding: 0;
margin: 0;
width: 100%;
max-width: 100vw;
box-sizing: border-box;
background: var(--yt-bg-primary);
}
/* Player section - only player container should be black */
.yt-player-section {
width: 100%;
max-width: 100vw;
background: var(--yt-bg-primary);
margin: 0;
padding: 0;
}
.yt-player-container {
border-radius: 0;
width: 100%;
}
.yt-video-info {
padding: 12px 16px;
width: 100%;
box-sizing: border-box;
}
/* Video title - more prominent on mobile */
.yt-video-info h1 {
font-size: 16px;
line-height: 1.3;
margin-bottom: 12px;
}
/* Action buttons - responsive wrap on mobile */
.yt-video-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-start;
align-items: center;
margin: 12px 0;
}
/* Hide like and dislike buttons on mobile */
#likeBtn,
#dislikeBtn {
display: none !important;
}
/* Icon-only style for action buttons on mobile */
.yt-action-btn {
flex-shrink: 0;
width: 40px;
height: 40px;
padding: 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0;
/* Hide text */
overflow: hidden;
}
/* Hide text in action buttons on mobile */
.yt-action-btn span {
display: none;
}
.yt-action-btn i {
font-size: 16px !important;
/* Show icon */
}
/* Hide Default view button on mobile - Theater is default */
#defaultModeBtn {
display: none !important;
}
/* View mode buttons - compact on mobile */
.view-mode-buttons {
gap: 4px;
margin-left: 8px;
}
.view-mode-btn {
width: 32px;
height: 32px;
}
/* Channel info - cleaner mobile layout */
.yt-channel-info {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-top: 1px solid var(--yt-border);
margin-top: 12px;
}
.yt-channel-details {
display: flex;
align-items: center;
gap: 10px;
}
.yt-channel-avatar-lg {
width: 36px;
height: 36px;
font-size: 14px;
}
.yt-subscribe-btn {
padding: 8px 16px;
font-size: 13px;
}
/* Description box - collapsible style */
.yt-description-box {
background: var(--yt-bg-secondary);
border-radius: 12px;
padding: 12px;
margin-top: 12px;
}
.yt-watch-sidebar {
position: static;
width: 100%;
max-height: none;
padding: 0 16px 120px;
/* Extra bottom padding for floating buttons */
box-sizing: border-box;
}
#queueSection {
margin-top: 8px;
}
.yt-comments-toggle {
padding: 12px;
margin-top: 8px;
}
/* Suggested videos - compact cards */
.yt-suggested h3 {
font-size: 14px;
margin-bottom: 12px;
}
}
/* Extra small mobile screens */
@media (max-width: 480px) {
.yt-video-info {
padding: 10px 12px;
}
.yt-video-info h1 {
font-size: 15px;
}
.yt-watch-sidebar {
padding: 0 12px 120px;
}
.yt-action-btn {
width: 36px;
height: 36px;
}
.yt-action-btn i {
font-size: 14px;
}
.view-mode-btn {
width: 30px;
height: 30px;
}
.yt-channel-avatar-lg {
width: 32px;
height: 32px;
font-size: 12px;
}
.yt-subscribe-btn {
padding: 6px 12px;
font-size: 12px;
}
}

Some files were not shown because too many files have changed in this diff Show more