chore: setup Dockerfiles and CI for Forgejo and Synology
This commit is contained in:
parent
249e4ca415
commit
95cfe06f2c
128 changed files with 14161 additions and 17161 deletions
13
.env.example
13
.env.example
|
|
@ -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
105
.github/workflows/ci.yml
vendored
Executable 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
|
||||
54
.github/workflows/docker-publish.yml
vendored
54
.github/workflows/docker-publish.yml
vendored
|
|
@ -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
40
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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*
|
||||
33
Dockerfile
33
Dockerfile
|
|
@ -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"]
|
||||
62
README.md
62
README.md
|
|
@ -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
|
||||
|
||||

|
||||
|
||||
## 🔧 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*
|
||||
325
USER_GUIDE.md
325
USER_GUIDE.md
|
|
@ -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*
|
||||
162
app/__init__.py
162
app/__init__.py
|
|
@ -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")
|
||||
|
|
@ -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']
|
||||
1785
app/routes/api.py
1785
app/routes/api.py
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""KV-Tube Services Package"""
|
||||
|
|
@ -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
|
||||
|
|
@ -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 []
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""KV-Tube Utilities Package"""
|
||||
|
|
@ -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
27
backend/Dockerfile
Executable 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
42
backend/go.mod
Executable 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
87
backend/go.sum
Executable 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
37
backend/main.go
Executable 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
79
backend/models/database.go
Executable 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
625
backend/routes/api.go
Executable 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
96
backend/services/history.go
Executable 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)
|
||||
}
|
||||
72
backend/services/subscription.go
Executable file
72
backend/services/subscription.go
Executable 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
850
backend/services/ytdlp.go
Executable 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)
|
||||
}
|
||||
BIN
bin/ffmpeg
BIN
bin/ffmpeg
Binary file not shown.
65
config.py
65
config.py
|
|
@ -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
|
||||
}
|
||||
19
cookies.txt
19
cookies.txt
|
|
@ -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
|
||||
28
deploy.py
28
deploy.py
|
|
@ -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
69
dev.sh
|
|
@ -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
|
||||
|
|
@ -1,32 +1,34 @@
|
|||
# KV-Tube Docker Compose for Synology NAS
|
||||
# Usage: docker-compose up -d
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
kv-tube:
|
||||
build: .
|
||||
image: vndangkhoa/kv-tube:latest
|
||||
container_name: kv-tube
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5011:5000"
|
||||
volumes:
|
||||
# Persist data (Easy setup: Just maps a folder)
|
||||
- ./data:/app/data
|
||||
# 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
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://localhost:5000/" ]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
# KV-Tube Docker Compose for Synology NAS
|
||||
# Usage: docker-compose up -d
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
kv-tube-backend:
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-tube-backend:v4.0.0
|
||||
container_name: kv-tube-backend
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- KVTUBE_DATA_DIR=/app/data
|
||||
- GIN_MODE=release
|
||||
healthcheck:
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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
41
frontend/.gitignore
vendored
Executable 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
55
frontend/Dockerfile
Normal 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
36
frontend/README.md
Executable 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
87
frontend/app/actions.ts
Executable 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;
|
||||
}
|
||||
29
frontend/app/api/download/route.ts
Executable file
29
frontend/app/api/download/route.ts
Executable 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 });
|
||||
}
|
||||
}
|
||||
26
frontend/app/api/formats/route.ts
Executable file
26
frontend/app/api/formats/route.ts
Executable 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 });
|
||||
}
|
||||
}
|
||||
40
frontend/app/api/proxy-file/route.ts
Executable file
40
frontend/app/api/proxy-file/route.ts
Executable 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 });
|
||||
}
|
||||
}
|
||||
42
frontend/app/api/proxy-stream/route.ts
Executable file
42
frontend/app/api/proxy-stream/route.ts
Executable 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 });
|
||||
}
|
||||
}
|
||||
31
frontend/app/api/stream/route.ts
Executable file
31
frontend/app/api/stream/route.ts
Executable 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 });
|
||||
}
|
||||
}
|
||||
79
frontend/app/api/subscribe/route.ts
Executable file
79
frontend/app/api/subscribe/route.ts
Executable 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 });
|
||||
}
|
||||
}
|
||||
125
frontend/app/channel/[id]/page.tsx
Executable file
125
frontend/app/channel/[id]/page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
124
frontend/app/components/Header.tsx
Executable file
124
frontend/app/components/Header.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
122
frontend/app/components/InfiniteVideoGrid.tsx
Executable file
122
frontend/app/components/InfiniteVideoGrid.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
52
frontend/app/components/MobileNav.tsx
Executable file
52
frontend/app/components/MobileNav.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
115
frontend/app/components/RegionSelector.tsx
Executable file
115
frontend/app/components/RegionSelector.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
58
frontend/app/components/Sidebar.tsx
Executable file
58
frontend/app/components/Sidebar.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
93
frontend/app/components/SubscribeButton.tsx
Executable file
93
frontend/app/components/SubscribeButton.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
72
frontend/app/components/VideoCard.tsx
Executable file
72
frontend/app/components/VideoCard.tsx
Executable 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
35
frontend/app/constants.ts
Executable 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' },
|
||||
];
|
||||
45
frontend/app/context/ThemeContext.tsx
Executable file
45
frontend/app/context/ThemeContext.tsx
Executable 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
BIN
frontend/app/favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
188
frontend/app/feed/library/page.tsx
Executable file
188
frontend/app/feed/library/page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
151
frontend/app/feed/subscriptions/page.tsx
Executable file
151
frontend/app/feed/subscriptions/page.tsx
Executable 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
1387
frontend/app/globals.css
Executable file
File diff suppressed because it is too large
Load diff
55
frontend/app/layout.tsx
Executable file
55
frontend/app/layout.tsx
Executable 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
116
frontend/app/page.tsx
Executable 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
173
frontend/app/search/page.tsx
Executable 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
510
frontend/app/shorts/page.tsx
Executable 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
44
frontend/app/utils.ts
Executable 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)];
|
||||
}
|
||||
393
frontend/app/watch/VideoPlayer.tsx
Executable file
393
frontend/app/watch/VideoPlayer.tsx
Executable 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' };
|
||||
453
frontend/app/watch/WatchActions.tsx
Executable file
453
frontend/app/watch/WatchActions.tsx
Executable 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
157
frontend/app/watch/page.tsx
Executable 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
18
frontend/eslint.config.mjs
Executable 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
26
frontend/next.config.mjs
Executable 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
6755
frontend/package-lock.json
generated
Executable file
File diff suppressed because it is too large
Load diff
35
frontend/package.json
Executable file
35
frontend/package.json
Executable 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
7
frontend/postcss.config.mjs
Executable file
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
frontend/public/file.svg
Executable file
1
frontend/public/file.svg
Executable 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
1
frontend/public/globe.svg
Executable 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
1
frontend/public/next.svg
Executable 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
1
frontend/public/vercel.svg
Executable 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
1
frontend/public/window.svg
Executable 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 |
1
frontend/tmp/check_main_content.js
Executable file
1
frontend/tmp/check_main_content.js
Executable 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
34
frontend/tsconfig.json
Executable 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"]
|
||||
}
|
||||
1144
hydration_debug.txt
1144
hydration_debug.txt
File diff suppressed because it is too large
Load diff
57
kv_server.py
57
kv_server.py
|
|
@ -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}")
|
||||
|
|
@ -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
173
restart.sh
Executable 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
|
||||
51
start.sh
51
start.sh
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue