Compare commits
99 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
970c2f920a | ||
|
|
eb011f720b | ||
|
|
5d2e28dd99 | ||
|
|
1bdbffbc99 | ||
|
|
e44d1b8b5a | ||
|
|
ccee56aff2 | ||
|
|
3b2078b203 | ||
|
|
601fce07f8 | ||
|
|
61a627483d | ||
|
|
971d3e4b8f | ||
|
|
6e766a28ae | ||
|
|
cfcf0a2800 | ||
|
|
729bfbe49e | ||
|
|
871d506f83 | ||
|
|
6a70fc8438 | ||
|
|
cbb487d307 | ||
|
|
ab37e8408a | ||
|
|
494b672aba | ||
|
|
f289ff86b0 | ||
|
|
f14f0ac299 | ||
|
|
cd8a69f1ad | ||
|
|
db7619b975 | ||
|
|
dce0f4c5f6 | ||
|
|
9b23ba183b | ||
|
|
77418ef60b | ||
|
|
24f6d524e2 | ||
|
|
1c867043fc | ||
|
|
59e97f805e | ||
|
|
8c1c7ec764 | ||
|
|
9c184b666c | ||
|
|
7fcff298c2 | ||
|
|
0bbe4fe015 | ||
|
|
f13e988e17 | ||
|
|
ca3a64539b | ||
|
|
f5f356d695 | ||
|
|
454ef227eb | ||
|
|
17912515a7 | ||
|
|
9ce894523e | ||
|
|
efa0c6ff30 | ||
|
|
a7faaf82e2 | ||
|
|
70c1c337bf | ||
|
|
a4acfacc49 | ||
|
|
40f7276086 | ||
|
|
d4530682f9 | ||
|
|
6433532a28 | ||
|
|
e255bfea22 | ||
|
|
f2f28735a0 | ||
|
|
01eca0d7d5 | ||
|
|
c179e7dc33 | ||
|
|
364f4d8780 | ||
|
|
376c5a9bed | ||
|
|
e0f9fe6842 | ||
|
|
b1bd08ba7a | ||
|
|
c99a772b54 | ||
|
|
df02bc9801 | ||
|
|
68824d70ff | ||
|
|
f3ac7c99b8 | ||
|
|
acdafcfe8c | ||
|
|
f6bbfc981a | ||
|
|
d78d4a6b66 | ||
|
|
799a3ffb15 | ||
|
|
787e42d435 | ||
|
|
468b2b08fc | ||
|
|
82a51b7ee4 | ||
|
|
1a04f8f486 | ||
|
|
86913861f2 | ||
|
|
16e146ce11 | ||
|
|
b079c426d7 | ||
|
|
bdfd537c6e | ||
|
|
b7de4adc00 | ||
|
|
80bfc4f602 | ||
|
|
0c51e3c888 | ||
|
|
589c104694 | ||
|
|
8ea5f2b09f | ||
|
|
1cb7a73a61 | ||
|
|
729c5440ad | ||
|
|
42f8eaff27 | ||
|
|
80f2f3725f | ||
|
|
8844007f18 | ||
|
|
714534389c | ||
|
|
e05c4b9654 | ||
|
|
a8a562544e | ||
|
|
fd449cce45 | ||
|
|
657f54855b | ||
|
|
7c00855c4c | ||
|
|
b7ea9165a1 | ||
|
|
21df1d1b8c | ||
|
|
ddb64e2ce3 | ||
|
|
4c5bccbd61 | ||
|
|
c49d827296 | ||
|
|
66d95e0fb4 | ||
|
|
bc1be07967 | ||
|
|
57d8fc31ab | ||
|
|
95cfe06f2c | ||
|
|
249e4ca415 | ||
|
|
79f69772a0 | ||
|
|
663ef6ba44 | ||
|
|
a3600c0976 | ||
|
|
727be56491 |
35
.dockerignore
Executable file → Normal file
|
|
@ -1,13 +1,22 @@
|
||||||
.venv/
|
frontend/node_modules
|
||||||
.venv_clean/
|
frontend/.next
|
||||||
env/
|
frontend/dist
|
||||||
__pycache__/
|
frontend/build
|
||||||
.git/
|
backend/bin
|
||||||
.DS_Store
|
backend/logs
|
||||||
*.pyc
|
node_modules
|
||||||
*.pyo
|
.next
|
||||||
*.pyd
|
.git
|
||||||
.idea/
|
.DS_Store
|
||||||
.vscode/
|
videos
|
||||||
videos/
|
data
|
||||||
data/
|
venv
|
||||||
|
.gemini
|
||||||
|
tmp*
|
||||||
|
*.exe
|
||||||
|
*.mac
|
||||||
|
*-mac
|
||||||
|
*-new
|
||||||
|
page.html
|
||||||
|
build-temp
|
||||||
|
.dockerignore.bak
|
||||||
|
|
|
||||||
32
.env.example
Executable file → Normal file
|
|
@ -1,12 +1,30 @@
|
||||||
# KV-Tube Environment Configuration
|
# KV-Tube Environment Configuration
|
||||||
# Copy this file to .env and customize as needed
|
# Copy this file to .env and customize as needed
|
||||||
|
|
||||||
# Secret key for Flask sessions (required for production)
|
# Server port (default: 8080)
|
||||||
# Generate a secure key: python -c "import os; print(os.urandom(32).hex())"
|
PORT=8080
|
||||||
SECRET_KEY=your-secure-secret-key-here
|
|
||||||
|
|
||||||
# Environment: development or production
|
# Data directory for SQLite database
|
||||||
FLASK_ENV=development
|
KVTUBE_DATA_DIR=./data
|
||||||
|
|
||||||
# Local video directory (optional)
|
# Gin mode: debug or release
|
||||||
KVTUBE_VIDEO_DIR=./videos
|
GIN_MODE=release
|
||||||
|
|
||||||
|
# CORS allowed origins (comma-separated, or * for all)
|
||||||
|
# Example: CORS_ALLOWED_ORIGINS=http://localhost:3000,https://yourdomain.com
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||||
|
|
||||||
|
# Database configuration
|
||||||
|
DB_MAX_OPEN_CONNS=25
|
||||||
|
DB_MAX_IDLE_CONNS=5
|
||||||
|
DB_CONN_MAX_LIFETIME=5m
|
||||||
|
|
||||||
|
# Cache configuration
|
||||||
|
CACHE_TTL=3600
|
||||||
|
CACHE_ENABLED=true
|
||||||
|
|
||||||
|
# HTTP client configuration
|
||||||
|
HTTP_CLIENT_TIMEOUT=30s
|
||||||
|
|
||||||
|
# Security
|
||||||
|
# Note: SSRF protection is enabled for video proxy - only YouTube/Google domains allowed
|
||||||
|
|
|
||||||
4
.forgejo/test.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
test
|
||||||
|
test
|
||||||
|
ci test Sat Mar 28 11:26:45 +07 2026
|
||||||
|
test Sat Mar 28 14:46:11 +07 2026
|
||||||
26
.forgejo/workflows/docker-build.yml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
name: Build & Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
cd /tmp
|
||||||
|
rm -rf kv-tube
|
||||||
|
git clone https://vndangkhoa:b14bc4938aeb5f4014fa15186985a0a625f7e9b4@nas:3050/vndangkhoa/kv-tube.git
|
||||||
|
cd kv-tube
|
||||||
|
git checkout ${GITEA_SHA:-main}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
run: |
|
||||||
|
cd /tmp/kv-tube
|
||||||
|
SHA_SHORT=$(git rev-parse --short HEAD)
|
||||||
|
IMAGE="git.khoavo.myds.me/vndangkhoa/kv-tube"
|
||||||
|
docker build -t ${IMAGE}:${SHA_SHORT} .
|
||||||
|
docker push ${IMAGE}:${SHA_SHORT}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit 246c4c349d97205eb2b51d7d3999ea846f5b2bdc
|
|
||||||
68
.github/workflows/docker-publish.yml
vendored
|
|
@ -1,68 +0,0 @@
|
||||||
name: Docker Build & Push
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
env:
|
|
||||||
# Use docker.io for Docker Hub if empty
|
|
||||||
REGISTRY: docker.io
|
|
||||||
# github.repository as <account>/<repo>
|
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log into Docker Hub
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Log into Forgejo Registry
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.khoavo.myds.me
|
|
||||||
username: ${{ secrets.FORGEJO_USERNAME }}
|
|
||||||
password: ${{ secrets.FORGEJO_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Extract Docker metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
git.khoavo.myds.me/${{ github.repository }}
|
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/tags/v*' }}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
id: build-and-push
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
platforms: linux/amd64
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
40
.gitignore
vendored
Executable file → Normal file
|
|
@ -1,12 +1,38 @@
|
||||||
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
# Environment
|
||||||
venv/
|
|
||||||
.venv/
|
|
||||||
.venv_clean/
|
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
data/
|
data/
|
||||||
videos/
|
videos/
|
||||||
*.db
|
*.db
|
||||||
server.log
|
logs/
|
||||||
.ruff_cache/
|
*.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*
|
|
||||||
105
Dockerfile
Executable file → Normal file
|
|
@ -1,33 +1,72 @@
|
||||||
# Build stage
|
# ---- Backend Builder ----
|
||||||
FROM python:3.11-slim
|
FROM golang:1.25-alpine AS backend-builder
|
||||||
|
ENV GOTOOLCHAIN=local
|
||||||
WORKDIR /app
|
ENV GOPROXY=https://proxy.golang.org,direct
|
||||||
|
WORKDIR /app
|
||||||
# Install system dependencies (ffmpeg is critical for yt-dlp)
|
RUN apk add --no-cache git gcc musl-dev
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
COPY backend/go.mod backend/go.sum ./
|
||||||
ffmpeg \
|
RUN (echo "module kvtube-go"; echo ""; echo "go 1.24.0"; tail -n +4 go.mod) > go.mod.new && mv go.mod.new go.mod && go mod tidy
|
||||||
curl \
|
RUN go mod download
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
COPY backend/ ./
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o kv-tube .
|
||||||
# Install Python dependencies
|
|
||||||
COPY requirements.txt .
|
# ---- Frontend Builder ----
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
FROM node:20-alpine AS frontend-deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
# Copy application code
|
WORKDIR /app
|
||||||
COPY . .
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
# Environment variables
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
FROM node:20-alpine AS frontend-builder
|
||||||
ENV FLASK_APP=wsgi.py
|
ARG NEXT_PUBLIC_API_URL=http://127.0.0.1:8080
|
||||||
ENV FLASK_ENV=production
|
WORKDIR /app
|
||||||
|
COPY --from=frontend-deps /app/node_modules ./node_modules
|
||||||
# Create directories for data persistence
|
COPY frontend/ ./
|
||||||
RUN mkdir -p /app/videos /app/data
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||||
# Expose port
|
RUN echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL" && npm run build
|
||||||
EXPOSE 5000
|
|
||||||
|
# ---- Final Unified Image ----
|
||||||
# Run with Entrypoint (handles updates)
|
FROM alpine:latest
|
||||||
COPY entrypoint.sh /app/entrypoint.sh
|
|
||||||
RUN chmod +x /app/entrypoint.sh
|
# Install dependencies for Go backend, Node.js frontend, and Supervisord
|
||||||
CMD ["/app/entrypoint.sh"]
|
RUN apk add --no-cache nodejs
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
RUN apk add --no-cache python3
|
||||||
|
RUN apk add --no-cache py3-pip
|
||||||
|
RUN apk add --no-cache supervisor
|
||||||
|
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \
|
||||||
|
&& chmod a+rx /usr/local/bin/yt-dlp
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy Backend Binary
|
||||||
|
COPY --from=backend-builder /app/kv-tube /app/kv-tube
|
||||||
|
|
||||||
|
# Copy Frontend Standalone App - include server.js for standalone mode
|
||||||
|
COPY --from=frontend-builder /app/.next/standalone /app/frontend/
|
||||||
|
COPY --from=frontend-builder /app/.next/static /app/frontend/.next/static
|
||||||
|
COPY --from=frontend-builder /app/public /app/frontend/public
|
||||||
|
COPY --from=frontend-builder /app/package.json /app/frontend/package.json
|
||||||
|
COPY --from=frontend-builder /app/next.config.mjs /app/frontend/next.config.mjs
|
||||||
|
COPY --from=frontend-builder /app/next-env.d.ts /app/frontend/next-env.d.ts
|
||||||
|
|
||||||
|
# Create required directories for Next.js
|
||||||
|
RUN mkdir -p /app/frontend/.next/cache
|
||||||
|
|
||||||
|
# Copy Supervisord Config
|
||||||
|
COPY supervisord.conf /etc/supervisord.conf
|
||||||
|
|
||||||
|
# Setup Environment
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV KVTUBE_DATA_DIR=/app/data
|
||||||
|
ENV GIN_MODE=release
|
||||||
|
ARG NEXT_PUBLIC_API_URL=http://127.0.0.1:8080
|
||||||
|
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||||
|
|
||||||
|
EXPOSE 3000 8080
|
||||||
|
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
|
||||||
|
|
|
||||||
4
Dockerfile.diag
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
FROM alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN ls -laR
|
||||||
192
README.md
Executable file → Normal file
|
|
@ -1,62 +1,130 @@
|
||||||
# KV-Tube v3.0
|
# KV-Tube
|
||||||
|
|
||||||
> A lightweight, privacy-focused YouTube frontend web application with AI-powered features.
|
A modern, fast, and fully-featured YouTube-like video streaming platform. Built with a robust Go backend and a highly responsive Next.js frontend, KV-Tube is designed for seamless deployment on systems like Synology NAS via Docker.
|
||||||
|
|
||||||
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.
|
## Features
|
||||||
|
|
||||||
## 🚀 Key Features (v3)
|
- **Modern Video Player**: High-resolution video playback with HLS support and quality selection.
|
||||||
|
- **Fast Navigation**: Instant click feedback with skeleton loaders for related videos.
|
||||||
- **Privacy First**: No tracking, no ads.
|
- **Infinite Scrolling**: Scroll seamlessly through a dynamic video grid on the homepage.
|
||||||
- **Clean Interface**: Distraction-free watching experience.
|
- **Watch History & Suggestions**: Keep track of what you've watched seamlessly! Fully integrated library history tracking.
|
||||||
- **Efficient Streaming**: Direct video stream extraction using `yt-dlp`.
|
- **Subscriptions Management**: Keep up to date with seamless subscription updates for YouTube channels.
|
||||||
- **AI Summary (Experimental)**: Generate concise summaries of videos (Currently disabled due to upstream rate limits).
|
- **Optimized for Safari**: Stutter-free playback algorithms and high-tolerance Hls.js configurations tailored for macOS users.
|
||||||
- **Multi-Language**: Support for English and Vietnamese (UI & Content).
|
- **Background Audio**: Allows videos to continue playing audio when the browser tab is hidden or device locked (perfect for music).
|
||||||
- **Auto-Update**: Includes `update_deps.py` to easily keep core fetching tools up-to-date.
|
- **Progressive Web App**: Fully installable PWA out of the box with offline fallbacks and custom vector iconography.
|
||||||
|
- **Region Selection**: Tailor your content to specific regions (e.g., Vietnam).
|
||||||
## 🛠️ Architecture Data Flow
|
- **Responsive Design**: Beautiful, mobile-friendly interface with light and dark theme support.
|
||||||
|
- **Containerized**: Fully Dockerized for easy setup using `docker-compose`.
|
||||||

|
|
||||||
|
## Architecture
|
||||||
## 🔧 Installation & Usage
|
- **Backend & Frontend**: Go (Gin framework) and Next.js are combined into a single unified Docker container using a multi-stage `Dockerfile`.
|
||||||
|
- **Process Management**: `supervisord` manages the concurrent execution of the backend API and Next.js frontend within the same network namespace.
|
||||||
### Prerequisites
|
- **Data storage**: SQLite is used for watch history, optimized for `linux/amd64`.
|
||||||
- Python 3.10+
|
|
||||||
- Git
|
## Docker Deployment (v5)
|
||||||
- Valid `cookies.txt` (Optional, for bypassing age-restrictions or rate limits)
|
|
||||||
|
### Quick Start
|
||||||
### Local Setup
|
|
||||||
1. Clone the repository:
|
1. Clone or download this repository
|
||||||
```bash
|
2. Create a `data` folder in the project directory
|
||||||
git clone https://git.khoavo.myds.me/vndangkhoa/kv-tube.git
|
3. Run the container:
|
||||||
cd kv-tube
|
|
||||||
```
|
```bash
|
||||||
2. Install dependencies:
|
docker-compose up -d
|
||||||
```bash
|
```
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
### Building the Image
|
||||||
3. Run the application:
|
|
||||||
```bash
|
To build the image locally:
|
||||||
python wsgi.py
|
|
||||||
```
|
```bash
|
||||||
4. Access at `http://localhost:5002`
|
docker build -t git.khoavo.myds.me/vndangkhoa/kv-tube:v5 .
|
||||||
|
```
|
||||||
### Docker Deployment (Linux/AMD64)
|
|
||||||
|
To build and push to your registry:
|
||||||
Built for stability and ease of use.
|
|
||||||
|
```bash
|
||||||
```bash
|
docker build -t git.khoavo.myds.me/vndangkhoa/kv-tube:v5 .
|
||||||
docker pull vndangkhoa/kv-tube:latest
|
docker push git.khoavo.myds.me/vndangkhoa/kv-tube:v5
|
||||||
docker run -d -p 5002:5002 -v $(pwd)/cookies.txt:/app/cookies.txt vndangkhoa/kv-tube:latest
|
```
|
||||||
```
|
|
||||||
|
## Deployment on Synology NAS
|
||||||
## 📦 Updates
|
|
||||||
|
We recommend using **Container Manager** (DSM 7.2+) or **Docker** (DSM 6/7.1) for a robust and easily manageable deployment.
|
||||||
- **v3.0**: Major release.
|
|
||||||
- Full modularization of backend routes.
|
### 1. Prerequisites
|
||||||
- Integrated `ytfetcher` for specialized fetching.
|
- **Container Manager** or **Docker** package installed from Package Center.
|
||||||
- Added manual dependency update script (`update_deps.py`).
|
- Ensure ports `5011` (frontend) and `8981` (backend API) are available on your NAS.
|
||||||
- Enhanced error handling for upstream rate limits.
|
- Create a folder named `kv-tube` in your `docker` shared folder (e.g., `/volume1/docker/kv-tube`).
|
||||||
- Docker `linux/amd64` support verified.
|
|
||||||
|
### 2. Using Container Manager (Recommended)
|
||||||
---
|
|
||||||
*Developed by Khoa Vo*
|
1. Open **Container Manager** > **Project** > **Create**.
|
||||||
|
2. Set a Project Name (e.g., `kv-tube`).
|
||||||
|
3. Set Path to `/volume1/docker/kv-tube`.
|
||||||
|
4. Source: Select **Create docker-compose.yml** and paste the following:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
kv-tube:
|
||||||
|
image: git.khoavo.myds.me/vndangkhoa/kv-tube:v5
|
||||||
|
container_name: kv-tube
|
||||||
|
platform: linux/amd64
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5011:3000"
|
||||||
|
- "8981:8080"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- KVTUBE_DATA_DIR=/app/data
|
||||||
|
- GIN_MODE=release
|
||||||
|
- NODE_ENV=production
|
||||||
|
- NEXT_PUBLIC_API_URL=http://127.0.0.1:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Click **Next** until the end and **Done**. The container will build and start automatically.
|
||||||
|
|
||||||
|
### 3. Accessing the App
|
||||||
|
The application will be accessible at:
|
||||||
|
- **Frontend**: `http://<your-nas-ip>:5011`
|
||||||
|
- **Backend API**: `http://<your-nas-ip>:8981`
|
||||||
|
- **Mobile Users**: Add to Home Screen via Safari for the full PWA experience with background playback.
|
||||||
|
|
||||||
|
### 4. Volume Permissions (If Needed)
|
||||||
|
|
||||||
|
If you encounter permission issues with the data folder, SSH into your NAS and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create the data folder with proper permissions
|
||||||
|
sudo mkdir -p /volume1/docker/kv-tube/data
|
||||||
|
sudo chmod 755 /volume1/docker/kv-tube/data
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Updating the Container
|
||||||
|
|
||||||
|
To update to a new version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull the latest image
|
||||||
|
docker pull git.khoavo.myds.me/vndangkhoa/kv-tube:v5
|
||||||
|
|
||||||
|
# Restart the container
|
||||||
|
docker-compose down && docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use Container Manager's built-in image update feature.
|
||||||
|
|
||||||
|
### 6. Troubleshooting
|
||||||
|
|
||||||
|
- **Container won't start**: Check logs via Container Manager or `docker logs kv-tube`
|
||||||
|
- **Port conflicts**: Ensure ports 5011 and 8080 are not used by other services
|
||||||
|
- **Permission denied**: Check the data folder permissions on your NAS
|
||||||
|
- **Slow playback**: Try lowering video quality or ensure sufficient network bandwidth
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
- Frontend builds can be started in `frontend/` via `npm run dev`.
|
||||||
|
- Backend server starts in `backend/` via `go run main.go`.
|
||||||
|
|
|
||||||
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
|
|
@ -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
|
|
@ -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)
|
|
||||||
28
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache git gcc musl-dev
|
||||||
|
|
||||||
|
COPY backend/go.mod backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY backend/ ./
|
||||||
|
RUN CGO_ENABLED=1 GOOS=linux go build -o kv-tube .
|
||||||
|
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates ffmpeg curl python3 py3-pip && \
|
||||||
|
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
|
||||||
|
chmod a+rx /usr/local/bin/yt-dlp
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/kv-tube .
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENV KVTUBE_DATA_DIR=/app/data
|
||||||
|
ENV GIN_MODE=release
|
||||||
|
|
||||||
|
CMD ["./kv-tube"]
|
||||||
77
backend/backend.log
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
2026/03/26 07:59:34 Database initialized successfully at ../data/kvtube.db
|
||||||
|
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
|
||||||
|
|
||||||
|
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
|
||||||
|
- using env: export GIN_MODE=release
|
||||||
|
- using code: gin.SetMode(gin.ReleaseMode)
|
||||||
|
|
||||||
|
[GIN-debug] GET /api/health --> kvtube-go/routes.SetupRouter.func2 (4 handlers)
|
||||||
|
[GIN-debug] GET /api/search --> kvtube-go/routes.handleSearch (4 handlers)
|
||||||
|
[GIN-debug] GET /api/trending --> kvtube-go/routes.handleTrending (4 handlers)
|
||||||
|
[GIN-debug] GET /api/video/:id --> kvtube-go/routes.handleGetVideoInfo (4 handlers)
|
||||||
|
[GIN-debug] GET /api/video/:id/qualities --> kvtube-go/routes.handleGetQualities (4 handlers)
|
||||||
|
[GIN-debug] GET /api/video/:id/related --> kvtube-go/routes.handleRelatedVideos (4 handlers)
|
||||||
|
[GIN-debug] GET /api/video/:id/comments --> kvtube-go/routes.handleComments (4 handlers)
|
||||||
|
[GIN-debug] GET /api/video/:id/download --> kvtube-go/routes.handleDownload (4 handlers)
|
||||||
|
[GIN-debug] GET /api/channel/info --> kvtube-go/routes.handleChannelInfo (4 handlers)
|
||||||
|
[GIN-debug] GET /api/channel/videos --> kvtube-go/routes.handleChannelVideos (4 handlers)
|
||||||
|
[GIN-debug] POST /api/history --> kvtube-go/routes.handlePostHistory (4 handlers)
|
||||||
|
[GIN-debug] GET /api/history --> kvtube-go/routes.handleGetHistory (4 handlers)
|
||||||
|
[GIN-debug] GET /api/suggestions --> kvtube-go/routes.handleGetSuggestions (4 handlers)
|
||||||
|
[GIN-debug] POST /api/subscribe --> kvtube-go/routes.handleSubscribe (4 handlers)
|
||||||
|
[GIN-debug] DELETE /api/subscribe --> kvtube-go/routes.handleUnsubscribe (4 handlers)
|
||||||
|
[GIN-debug] GET /api/subscribe --> kvtube-go/routes.handleCheckSubscription (4 handlers)
|
||||||
|
[GIN-debug] GET /api/subscriptions --> kvtube-go/routes.handleGetSubscriptions (4 handlers)
|
||||||
|
2026/03/26 07:59:34 KV-Tube Go Backend starting on port 8080...
|
||||||
|
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
|
||||||
|
Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.
|
||||||
|
[GIN-debug] Listening and serving HTTP on :8080
|
||||||
|
[GIN] 2026/03/26 - 07:59:42 | 200 | 1.383093916s | ::1 | GET "/api/channel/videos?id=UCQnw0PycCRlSsT8fQlTDyBA&limit=2"
|
||||||
|
[GIN] 2026/03/26 - 08:00:04 | 200 | 2.159542ms | 127.0.0.1 | GET "/api/subscriptions"
|
||||||
|
[GIN] 2026/03/26 - 08:00:04 | 200 | 6.37675ms | 127.0.0.1 | GET "/api/subscriptions"
|
||||||
|
[GIN] 2026/03/26 - 08:00:06 | 200 | 2.0681615s | 127.0.0.1 | GET "/api/channel/videos?id=UCQnw0PycCRlSsT8fQlTDyBA&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:06 | 200 | 2.127086416s | 127.0.0.1 | GET "/api/channel/videos?id=UCQnw0PycCRlSsT8fQlTDyBA&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:08 | 200 | 1.710409125s | 127.0.0.1 | GET "/api/channel/videos?id=UCaO6TYtlC8U5ttz62hTrZgg&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:08 | 200 | 1.7510695s | 127.0.0.1 | GET "/api/channel/videos?id=UCaO6TYtlC8U5ttz62hTrZgg&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:09 | 200 | 1.762290709s | 127.0.0.1 | GET "/api/channel/videos?id=UCNgCserSzcAWFOY-7_f3iug&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:10 | 200 | 1.843944666s | 127.0.0.1 | GET "/api/channel/videos?id=UCNgCserSzcAWFOY-7_f3iug&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:11 | 200 | 1.952155291s | 127.0.0.1 | GET "/api/channel/videos?id=UCWZ8SezQ2EFEYtiHZOjxqRw&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:11 | 200 | 1.786566833s | 127.0.0.1 | GET "/api/channel/videos?id=UCWZ8SezQ2EFEYtiHZOjxqRw&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:13 | 200 | 1.371072416s | 127.0.0.1 | GET "/api/channel/videos?id=UCmJt1f8uJKf7pB7qPNlH6qg&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:13 | 200 | 1.403493s | 127.0.0.1 | GET "/api/channel/videos?id=UCmJt1f8uJKf7pB7qPNlH6qg&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:16 | 200 | 1.530959ms | 127.0.0.1 | GET "/api/subscriptions"
|
||||||
|
[GIN] 2026/03/26 - 08:00:17 | 200 | 4.158150916s | 127.0.0.1 | GET "/api/channel/videos?id=UCdWmirD6MzuTFeUAtAv4TKQ&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:17 | 200 | 4.167733833s | 127.0.0.1 | GET "/api/channel/videos?id=UCdWmirD6MzuTFeUAtAv4TKQ&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:19 | 200 | 3.131014s | 127.0.0.1 | GET "/api/channel/videos?id=UCQnw0PycCRlSsT8fQlTDyBA&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:21 | 200 | 1.744236s | 127.0.0.1 | GET "/api/channel/videos?id=UCaO6TYtlC8U5ttz62hTrZgg&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:22 | 200 | 1.842939625s | 127.0.0.1 | GET "/api/channel/videos?id=UCNgCserSzcAWFOY-7_f3iug&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:24 | 200 | 1.716655791s | 127.0.0.1 | GET "/api/channel/videos?id=UCWZ8SezQ2EFEYtiHZOjxqRw&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:26 | 200 | 1.886865792s | 127.0.0.1 | GET "/api/channel/videos?id=UCmJt1f8uJKf7pB7qPNlH6qg&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:28 | 200 | 2.076937541s | 127.0.0.1 | GET "/api/channel/videos?id=UCdWmirD6MzuTFeUAtAv4TKQ&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:30 | 200 | 1.39052025s | 127.0.0.1 | GET "/api/channel/videos?id=UCuAXFkgsw1L7xaCfnd5JJOw&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:50 | 200 | 1.3275ms | 127.0.0.1 | GET "/api/subscriptions"
|
||||||
|
[GIN] 2026/03/26 - 08:00:51 | 200 | 1.774724625s | 127.0.0.1 | GET "/api/channel/videos?id=UCQnw0PycCRlSsT8fQlTDyBA&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:53 | 200 | 1.141853916s | 127.0.0.1 | GET "/api/channel/videos?id=UCaO6TYtlC8U5ttz62hTrZgg&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:54 | 200 | 1.30642975s | 127.0.0.1 | GET "/api/channel/videos?id=UCNgCserSzcAWFOY-7_f3iug&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:55 | 200 | 1.333133042s | 127.0.0.1 | GET "/api/channel/videos?id=UCWZ8SezQ2EFEYtiHZOjxqRw&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:56 | 200 | 1.002488958s | 127.0.0.1 | GET "/api/channel/videos?id=UCmJt1f8uJKf7pB7qPNlH6qg&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:57 | 200 | 1.093311292s | 127.0.0.1 | GET "/api/channel/videos?id=UCdWmirD6MzuTFeUAtAv4TKQ&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:00:59 | 200 | 1.127070708s | 127.0.0.1 | GET "/api/channel/videos?id=UCuAXFkgsw1L7xaCfnd5JJOw&limit=25"
|
||||||
|
[GIN] 2026/03/26 - 08:01:05 | 200 | 3.016874625s | 127.0.0.1 | GET "/api/video/X8dM9elNhAM"
|
||||||
|
2026/03/26 08:01:05 GetVideoInfo error: json: cannot unmarshal string into Go value of type services.YtDlpEntry
|
||||||
|
[GIN] 2026/03/26 - 08:01:05 | 500 | 6.829375ms | 127.0.0.1 | GET "/api/video/X8dM9elNhAM"
|
||||||
|
[GIN] 2026/03/26 - 08:01:07 | 200 | 1.475739208s | 127.0.0.1 | GET "/api/search?q=ERIK%20H%C6%B0%C6%A1ng%20%E2%80%99B%E1%BA%AFc%20(Kim)%20Bling%E2%80%99%20mix%20compilation&limit=20"
|
||||||
|
[GIN] 2026/03/26 - 08:01:07 | 200 | 1.595221875s | 127.0.0.1 | GET "/api/search?q=ERIK%20Official%20ERIK%20H%C6%B0%C6%A1ng%20%E2%80%99B%E1%BA%AFc%20(Kim)%20Bling%E2%80%99&limit=20"
|
||||||
|
[GIN] 2026/03/26 - 08:01:07 | 200 | 2.286978084s | 127.0.0.1 | GET "/api/search?q=music%20mix%20compilation&limit=20"
|
||||||
|
[GIN] 2026/03/26 - 08:01:09 | 200 | 2.543206084s | 127.0.0.1 | GET "/api/search?q=music%20popular&limit=20"
|
||||||
|
[GIN] 2026/03/26 - 08:01:10 | 200 | 4.544714333s | 127.0.0.1 | GET "/api/search?q=%20music&limit=20"
|
||||||
|
[GIN] 2026/03/26 - 08:01:14 | 200 | 4.508561208s | 127.0.0.1 | GET "/api/video/X8dM9elNhAM/comments?limit=50"
|
||||||
|
[GIN] 2026/03/26 - 08:01:18 | 200 | 4.418404958s | 127.0.0.1 | GET "/api/video/X8dM9elNhAM/comments?limit=50"
|
||||||
|
[GIN] 2026/03/26 - 08:02:59 | 200 | 68.458µs | 127.0.0.1 | GET "/api/health"
|
||||||
|
2026/03/26 08:03:12 GetVideoInfo error: json: cannot unmarshal string into Go value of type services.YtDlpEntry
|
||||||
|
[GIN] 2026/03/26 - 08:03:12 | 500 | 27.501791ms | 127.0.0.1 | GET "/api/video/X8dM9elNhAM"
|
||||||
|
[GIN] 2026/03/26 - 08:03:15 | 200 | 3.318515s | 127.0.0.1 | GET "/api/search?q=music%20mix%20compilation&limit=20"
|
||||||
|
[GIN] 2026/03/26 - 08:03:17 | 200 | 5.183273125s | 127.0.0.1 | GET "/api/search?q=%20music&limit=20"
|
||||||
|
[GIN] 2026/03/26 - 08:03:22 | 200 | 5.159578625s | 127.0.0.1 | GET "/api/video/X8dM9elNhAM/comments?limit=50"
|
||||||
|
[GIN] 2026/03/26 - 08:03:26 | 200 | 4.13119725s | 127.0.0.1 | GET "/api/video/X8dM9elNhAM/comments?limit=50"
|
||||||
|
[GIN] 2026/03/26 - 08:03:56 | 200 | 29.25µs | 127.0.0.1 | GET "/api/health"
|
||||||
50
backend/go.mod
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
module kvtube-go
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.11.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
modernc.org/sqlite v1.47.0
|
||||||
|
)
|
||||||
|
|
||||||
|
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/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.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/google/uuid v1.6.0 // 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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
|
golang.org/x/net v0.50.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
|
modernc.org/libc v1.70.0 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
130
backend/go.sum
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/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/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
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/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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
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=
|
||||||
|
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
|
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||||
|
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||||
|
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
37
backend/main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
91
backend/models/cache.go
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CacheEntry struct {
|
||||||
|
VideoID string
|
||||||
|
Data []byte
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCachedVideo retrieves cached video data by video ID
|
||||||
|
func GetCachedVideo(videoID string) ([]byte, error) {
|
||||||
|
if DB == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var data []byte
|
||||||
|
var expiresAt time.Time
|
||||||
|
err := DB.QueryRow(
|
||||||
|
`SELECT data, expires_at FROM video_cache WHERE video_id = ? AND expires_at > ?`,
|
||||||
|
videoID, time.Now(),
|
||||||
|
).Scan(&data, &expiresAt)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Cache query error: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCachedVideo stores video data in cache with TTL
|
||||||
|
func SetCachedVideo(videoID string, data interface{}, ttlSeconds int) error {
|
||||||
|
if DB == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().Add(time.Duration(ttlSeconds) * time.Second)
|
||||||
|
|
||||||
|
_, err = DB.Exec(
|
||||||
|
`INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)`,
|
||||||
|
videoID, string(jsonData), expiresAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Cache store error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanExpiredCache removes expired cache entries
|
||||||
|
func CleanExpiredCache() {
|
||||||
|
if DB == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := DB.Exec(`DELETE FROM video_cache WHERE expires_at < ?`, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Cache cleanup error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
if rows > 0 {
|
||||||
|
log.Printf("Cleaned %d expired cache entries", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartCacheCleanupScheduler runs periodic cache cleanup
|
||||||
|
func StartCacheCleanupScheduler() {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(1 * time.Hour)
|
||||||
|
for range ticker.C {
|
||||||
|
CleanExpiredCache()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
92
backend/models/database.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
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("sqlite", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create performance indexes
|
||||||
|
indexes := []string{
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_user_videos_user_timestamp ON user_videos(user_id, timestamp DESC)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_videos_user_video ON user_videos(user_id, video_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_video_cache_expires ON video_cache(expires_at)`,
|
||||||
|
}
|
||||||
|
for _, idx := range indexes {
|
||||||
|
if _, err := db.Exec(idx); err != nil {
|
||||||
|
log.Printf("Warning: Failed to create index: %v - Statement: %s", err, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert default user for history tracking (password is not used for authentication)
|
||||||
|
_, err = db.Exec(`INSERT OR IGNORE INTO users (id, username, password) VALUES (1, 'default_user', '')`)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to insert default user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
DB = db
|
||||||
|
log.Println("Database initialized successfully at", dbPath)
|
||||||
|
}
|
||||||
446
backend/routes/api.go
Normal file
|
|
@ -0,0 +1,446 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"kvtube-go/services"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getAllowedOrigins returns allowed CORS origins from environment variable or defaults
|
||||||
|
func getAllowedOrigins() []string {
|
||||||
|
originsEnv := os.Getenv("CORS_ALLOWED_ORIGINS")
|
||||||
|
if originsEnv == "" {
|
||||||
|
// Default: allow localhost for development
|
||||||
|
return []string{
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://127.0.0.1:3000",
|
||||||
|
"http://localhost:5011",
|
||||||
|
"http://127.0.0.1:5011",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
origins := strings.Split(originsEnv, ",")
|
||||||
|
for i := range origins {
|
||||||
|
origins[i] = strings.TrimSpace(origins[i])
|
||||||
|
}
|
||||||
|
return origins
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllowedOrigin checks if the given origin is in the allowed list
|
||||||
|
func isAllowedOrigin(origin string, allowedOrigins []string) bool {
|
||||||
|
for _, allowed := range allowedOrigins {
|
||||||
|
if allowed == "*" || allowed == origin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetupRouter() *gin.Engine {
|
||||||
|
r := gin.Default()
|
||||||
|
|
||||||
|
// CORS middleware - restrict to specific origins from environment variable
|
||||||
|
allowedOrigins := getAllowedOrigins()
|
||||||
|
r.Use(func(c *gin.Context) {
|
||||||
|
origin := c.GetHeader("Origin")
|
||||||
|
if origin != "" && isAllowedOrigin(origin, allowedOrigins) {
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
}
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// API Routes - Using yt-dlp for video operations
|
||||||
|
api := r.Group("/api")
|
||||||
|
{
|
||||||
|
// Health check
|
||||||
|
api.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Video endpoints
|
||||||
|
api.GET("/search", handleSearch)
|
||||||
|
api.GET("/trending", handleTrending)
|
||||||
|
api.GET("/video/:id", handleGetVideoInfo)
|
||||||
|
api.GET("/video/:id/qualities", handleGetQualities)
|
||||||
|
api.GET("/video/:id/related", handleRelatedVideos)
|
||||||
|
api.GET("/video/:id/comments", handleComments)
|
||||||
|
api.GET("/video/:id/download", handleDownload)
|
||||||
|
|
||||||
|
// Channel endpoints
|
||||||
|
api.GET("/channel/info", handleChannelInfo)
|
||||||
|
api.GET("/channel/videos", handleChannelVideos)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video search endpoint
|
||||||
|
func handleSearch(c *gin.Context) {
|
||||||
|
query := c.Query("q")
|
||||||
|
if query == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limitStr := c.Query("limit")
|
||||||
|
limit := 20
|
||||||
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := services.SearchVideos(query, limit)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Search error: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search videos"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trending videos endpoint
|
||||||
|
func handleTrending(c *gin.Context) {
|
||||||
|
limitStr := c.Query("limit")
|
||||||
|
limit := 20
|
||||||
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use popular music search as trending
|
||||||
|
results, err := services.SearchVideos("popular music trending", limit)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Trending error: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get trending videos"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get video info
|
||||||
|
func handleGetVideoInfo(c *gin.Context) {
|
||||||
|
videoID := c.Param("id")
|
||||||
|
if videoID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
video, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, video)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get video qualities
|
||||||
|
func handleGetQualities(c *gin.Context) {
|
||||||
|
videoID := c.Param("id")
|
||||||
|
if videoID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
qualities, audioURL, err := services.GetVideoQualitiesWithAudio(videoID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GetQualities error: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video qualities"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"qualities": qualities,
|
||||||
|
"audio_url": audioURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get related videos
|
||||||
|
func handleRelatedVideos(c *gin.Context) {
|
||||||
|
videoID := c.Param("id")
|
||||||
|
if videoID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limitStr := c.Query("limit")
|
||||||
|
limit := 15
|
||||||
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
|
||||||
|
// First get video info to get title and uploader
|
||||||
|
video, err := services.GetVideoInfo(videoID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GetVideoInfo for related error: %v", err)
|
||||||
|
// Fallback: search for similar content
|
||||||
|
results, err := services.SearchVideos("music", limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get related videos"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, results)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
related, err := services.GetRelatedVideos(video.Title, video.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, related)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get video comments
|
||||||
|
func handleComments(c *gin.Context) {
|
||||||
|
videoID := c.Param("id")
|
||||||
|
if videoID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limitStr := c.Query("limit")
|
||||||
|
limit := 20
|
||||||
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
|
||||||
|
comments, err := services.GetComments(videoID, limit)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GetComments error: %v", err)
|
||||||
|
c.JSON(http.StatusOK, []interface{}{}) // Return empty array instead of error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, comments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get download URL
|
||||||
|
func handleDownload(c *gin.Context) {
|
||||||
|
videoID := c.Param("id")
|
||||||
|
if videoID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
formatID := c.Query("format")
|
||||||
|
|
||||||
|
downloadInfo, 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, downloadInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get channel info
|
||||||
|
func handleChannelInfo(c *gin.Context) {
|
||||||
|
channelID := c.Query("id")
|
||||||
|
if channelID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
channelInfo, 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, channelInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get channel videos
|
||||||
|
func handleChannelVideos(c *gin.Context) {
|
||||||
|
channelID := c.Query("id")
|
||||||
|
if channelID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limitStr := c.Query("limit")
|
||||||
|
limit := 30
|
||||||
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||||
|
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"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, videos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// History handlers
|
||||||
|
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
|
||||||
|
var results []services.VideoData
|
||||||
|
for _, h := range history {
|
||||||
|
results = append(results, services.VideoData{
|
||||||
|
ID: h.ID,
|
||||||
|
Title: h.Title,
|
||||||
|
Thumbnail: h.Thumbnail,
|
||||||
|
Uploader: "History",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription handlers
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logPrintf(format string, v ...interface{}) {
|
||||||
|
log.Printf(format, v...)
|
||||||
|
}
|
||||||
75
backend/services/history.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"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
|
||||||
|
// NOTE: This function now returns empty results since we're using client-side YouTube API
|
||||||
|
// The frontend should use the YouTube API directly for suggestions
|
||||||
|
func GetSuggestions(limit int) ([]VideoData, error) {
|
||||||
|
// Return empty results - suggestions are now handled client-side
|
||||||
|
// Frontend should use YouTube API for suggestions
|
||||||
|
return []VideoData{}, nil
|
||||||
|
}
|
||||||
72
backend/services/subscription.go
Normal 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
|
||||||
|
}
|
||||||
1085
backend/services/ytdlp.go
Normal file
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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
||||||
0
doc/Product Requirements Document (PRD) - KV-Tube
Executable file → Normal file
53
docker-compose.forgejo.yml
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
services:
|
||||||
|
forgejo:
|
||||||
|
image: codeberg.org/forgejo/forgejo:7.0.16
|
||||||
|
container_name: forgejo
|
||||||
|
environment:
|
||||||
|
- USER_UID=1026
|
||||||
|
- USER_GID=100
|
||||||
|
- GITEA__database__DB_TYPE=sqlite3
|
||||||
|
- TZ=Asia/Ho_Chi_Minh
|
||||||
|
- GITEA__actions__ENABLED=true
|
||||||
|
- INSTALL_LOCK=true
|
||||||
|
- FORGEJO__server__ROOT_URL=http://nas:3050/
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./forgejo-data:/data
|
||||||
|
ports:
|
||||||
|
- "3050:3000"
|
||||||
|
- "2222:22"
|
||||||
|
networks:
|
||||||
|
- kv-tube_default
|
||||||
|
|
||||||
|
forgejo-runner:
|
||||||
|
image: code.forgejo.org/forgejo/runner:latest
|
||||||
|
container_name: forgejo_runner
|
||||||
|
restart: always
|
||||||
|
user: "0:0"
|
||||||
|
privileged: true
|
||||||
|
depends_on:
|
||||||
|
- forgejo
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./forgejo-runner-data:/data
|
||||||
|
entrypoint:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
apt-get update && apt-get install -y docker.io
|
||||||
|
if [ ! -f /data/.runner ]; then
|
||||||
|
forgejo-runner register --no-interactive \
|
||||||
|
--instance http://forgejo:3000 \
|
||||||
|
--token d5XKhmpu4lTR7P516juCjEes6QsI4qFvVean3zqT \
|
||||||
|
--name synology-runner \
|
||||||
|
--labels ubuntu-latest,ubuntu-22.04,docker:host
|
||||||
|
fi
|
||||||
|
forgejo-runner daemon
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Ho_Chi_Minh
|
||||||
|
networks:
|
||||||
|
- kv-tube_default
|
||||||
|
|
||||||
|
networks:
|
||||||
|
kv-tube_default:
|
||||||
|
external: true
|
||||||
16
docker-compose.local.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
kv-tube-app-local:
|
||||||
|
build: .
|
||||||
|
container_name: kv-tube-app-local
|
||||||
|
platform: linux/amd64
|
||||||
|
ports:
|
||||||
|
- "5012:3000"
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- KVTUBE_DATA_DIR=/app/data
|
||||||
|
- GIN_MODE=release
|
||||||
|
- NODE_ENV=production
|
||||||
23
docker-compose.synology.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# KV-Tube Docker Compose for Synology NAS
|
||||||
|
# Usage: docker-compose up -d
|
||||||
|
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
kv-tube:
|
||||||
|
image: git.khoavo.myds.me/vndangkhoa/kv-tube:v9
|
||||||
|
container_name: kv-tube
|
||||||
|
platform: linux/amd64
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5011:3000"
|
||||||
|
- "8981:8080"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- KVTUBE_DATA_DIR=/app/data
|
||||||
|
- GIN_MODE=release
|
||||||
|
- NODE_ENV=production
|
||||||
|
- CORS_ALLOWED_ORIGINS=https://ut.khoavo.myds.me,http://ut.khoavo.myds.me:5011,http://localhost:3000,http://127.0.0.1:3000
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
57
docker-compose.yml
Executable file → Normal file
|
|
@ -1,29 +1,28 @@
|
||||||
# KV-Tube Docker Compose for Synology NAS
|
# KV-Tube Docker Compose for Synology NAS
|
||||||
# Usage: docker-compose up -d
|
# Usage: docker-compose up -d
|
||||||
|
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
kv-tube:
|
kv-tube:
|
||||||
build: .
|
build:
|
||||||
image: vndangkhoa/kv-tube:latest
|
context: .
|
||||||
container_name: kv-tube
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
args:
|
||||||
ports:
|
- NEXT_PUBLIC_API_URL=http://ut.khoavo.myds.me:8981/api
|
||||||
- "5011:5000"
|
image: git.khoavo.myds.me/vndangkhoa/kv-tube:v9
|
||||||
volumes:
|
container_name: kv-tube
|
||||||
# Persist data (Easy setup: Just maps a folder)
|
platform: linux/amd64
|
||||||
- ./data:/app/data
|
restart: unless-stopped
|
||||||
# Local videos folder (Optional)
|
ports:
|
||||||
# - ./videos:/app/youtube_downloads
|
- "5011:3000"
|
||||||
environment:
|
- "8981:8080"
|
||||||
- PYTHONUNBUFFERED=1
|
volumes:
|
||||||
- FLASK_ENV=production
|
- ./data:/app/data
|
||||||
healthcheck:
|
environment:
|
||||||
test: [ "CMD", "curl", "-f", "http://localhost:5000/" ]
|
- KVTUBE_DATA_DIR=/app/data
|
||||||
interval: 30s
|
- GIN_MODE=release
|
||||||
timeout: 10s
|
- NODE_ENV=production
|
||||||
retries: 3
|
- CORS_ALLOWED_ORIGINS=https://ut.khoavo.myds.me,http://ut.khoavo.myds.me:5011,http://localhost:3000,http://127.0.0.1:3000
|
||||||
start_period: 10s
|
labels:
|
||||||
labels:
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
- "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
Normal 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
|
|
@ -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
Normal 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.
|
||||||
621
frontend/app/ClientHomePage.tsx
Normal file
|
|
@ -0,0 +1,621 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { searchVideosClient, getTrendingVideosClient } from './clientActions';
|
||||||
|
import { VideoData } from './constants';
|
||||||
|
import LoadingSpinner from './components/LoadingSpinner';
|
||||||
|
|
||||||
|
// Format view count
|
||||||
|
function formatViews(views: number): string {
|
||||||
|
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M views';
|
||||||
|
if (views >= 1000) return (views / 1000).toFixed(0) + 'K views';
|
||||||
|
return views === 0 ? '' : `${views} views`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stable time ago based on video ID (deterministic, not random)
|
||||||
|
function getStableTimeAgo(videoId: string): string {
|
||||||
|
const times = ['2 hours ago', '5 hours ago', '1 day ago', '2 days ago', '3 days ago', '1 week ago', '2 weeks ago', '1 month ago'];
|
||||||
|
const hash = videoId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||||
|
return times[hash % times.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get fallback thumbnail URL (always works)
|
||||||
|
function getFallbackThumbnail(videoId: string): string {
|
||||||
|
return `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video Card Component
|
||||||
|
function VideoCard({ video }: { video: VideoData }) {
|
||||||
|
const [imgError, setImgError] = useState(false);
|
||||||
|
const [imgLoaded, setImgLoaded] = useState(false);
|
||||||
|
|
||||||
|
// Use multiple thumbnail sources for fallback
|
||||||
|
const thumbnailSources = [
|
||||||
|
`https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`,
|
||||||
|
`https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`,
|
||||||
|
`https://i.ytimg.com/vi/${video.id}/sddefault.jpg`,
|
||||||
|
`https://i.ytimg.com/vi/${video.id}/default.jpg`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const [currentSrcIndex, setCurrentSrcIndex] = useState(0);
|
||||||
|
const currentSrc = thumbnailSources[currentSrcIndex];
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
if (currentSrcIndex < thumbnailSources.length - 1) {
|
||||||
|
setCurrentSrcIndex(prev => prev + 1);
|
||||||
|
} else {
|
||||||
|
setImgError(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/watch?v=${video.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div style={{
|
||||||
|
position: 'relative',
|
||||||
|
aspectRatio: '16/9',
|
||||||
|
marginBottom: '12px',
|
||||||
|
backgroundColor: '#272727',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{!imgLoaded && !imgError && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundColor: '#272727',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<LoadingSpinner size="small" color="white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!imgError ? (
|
||||||
|
<img
|
||||||
|
src={currentSrc}
|
||||||
|
alt={video.title}
|
||||||
|
onError={handleError}
|
||||||
|
onLoad={() => setImgLoaded(true)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: imgLoaded ? 'block' : 'none',
|
||||||
|
transition: 'opacity 0.2s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#333',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#666',
|
||||||
|
}}>
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Duration badge */}
|
||||||
|
{video.duration && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '8px',
|
||||||
|
right: '8px',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '3px 6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}>
|
||||||
|
{video.duration}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hover overlay */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0)',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Info */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{/* Title - max 2 lines */}
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
margin: '0 0 4px 0',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
}}>
|
||||||
|
{video.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Channel name */}
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
marginBottom: '2px',
|
||||||
|
}}>
|
||||||
|
{video.uploader}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Views and time */}
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '4px',
|
||||||
|
}}>
|
||||||
|
{(video.view_count ?? 0) > 0 && <span>{formatViews(video.view_count ?? 0)}</span>}
|
||||||
|
{(video.view_count ?? 0) > 0 && <span>•</span>}
|
||||||
|
<span>{video.upload_date || video.publishedAt || getStableTimeAgo(video.id)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category Pills Component
|
||||||
|
function CategoryPills({
|
||||||
|
categories,
|
||||||
|
currentCategory,
|
||||||
|
onCategoryChange
|
||||||
|
}: {
|
||||||
|
categories: string[];
|
||||||
|
currentCategory: string;
|
||||||
|
onCategoryChange: (category: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
overflowX: 'auto',
|
||||||
|
padding: '16px 0',
|
||||||
|
borderBottom: '1px solid var(--yt-border)',
|
||||||
|
marginBottom: '24px',
|
||||||
|
msOverflowStyle: 'none',
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
}}>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() => onCategoryChange(category)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: currentCategory === category ? 'var(--yt-text-primary)' : 'var(--yt-hover)',
|
||||||
|
color: currentCategory === category ? 'var(--yt-background)' : 'var(--yt-text-primary)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: '500',
|
||||||
|
fontSize: '14px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (currentCategory !== category) {
|
||||||
|
(e.target as HTMLElement).style.backgroundColor = 'var(--yt-active)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (currentCategory !== category) {
|
||||||
|
(e.target as HTMLElement).style.backgroundColor = 'var(--yt-hover)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading Skeleton
|
||||||
|
function VideoSkeleton() {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<div style={{
|
||||||
|
aspectRatio: '16/9',
|
||||||
|
backgroundColor: '#272727',
|
||||||
|
borderRadius: '12px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
animation: 'pulse 1.5s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: '#272727',
|
||||||
|
animation: 'pulse 1.5s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{
|
||||||
|
height: '14px',
|
||||||
|
backgroundColor: '#272727',
|
||||||
|
borderRadius: '4px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
width: '90%',
|
||||||
|
animation: 'pulse 1.5s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
height: '12px',
|
||||||
|
backgroundColor: '#272727',
|
||||||
|
borderRadius: '4px',
|
||||||
|
width: '60%',
|
||||||
|
animation: 'pulse 1.5s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get region from cookie
|
||||||
|
function getRegionFromCookie(): string {
|
||||||
|
if (typeof document === 'undefined') return 'VN';
|
||||||
|
const match = document.cookie.match(/(?:^|; )region=([^;]*)/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : 'VN';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if thumbnail URL is valid (not a 404 placeholder)
|
||||||
|
function isValidThumbnail(thumbnail: string | undefined): boolean {
|
||||||
|
if (!thumbnail) return false;
|
||||||
|
// YouTube default thumbnails that are usually available
|
||||||
|
const validPatterns = [
|
||||||
|
'i.ytimg.com/vi/',
|
||||||
|
'i.ytimg.com/vi_webp/',
|
||||||
|
];
|
||||||
|
return validPatterns.some(pattern => thumbnail.includes(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientHomePage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const categoryParam = searchParams.get('category') || 'All';
|
||||||
|
const [videos, setVideos] = useState<VideoData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [currentCategory, setCurrentCategory] = useState(categoryParam);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [regionCode, setRegionCode] = useState('VN');
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
|
||||||
|
// Use refs to track state for the observer callback
|
||||||
|
const loadingMoreRef = useRef(false);
|
||||||
|
const loadingRef = useRef(true);
|
||||||
|
const hasMoreRef = useRef(true);
|
||||||
|
const pageRef = useRef(1);
|
||||||
|
|
||||||
|
useEffect(() => { loadingMoreRef.current = loadingMore; }, [loadingMore]);
|
||||||
|
useEffect(() => { loadingRef.current = loading; }, [loading]);
|
||||||
|
useEffect(() => { hasMoreRef.current = hasMore; }, [hasMore]);
|
||||||
|
useEffect(() => { pageRef.current = page; }, [page]);
|
||||||
|
|
||||||
|
const categories = ['All', 'Trending', 'Music', 'Gaming', 'News', 'Sports', 'Live', 'New'];
|
||||||
|
|
||||||
|
// Region mapping for YouTube API
|
||||||
|
const REGION_MAP: Record<string, string> = {
|
||||||
|
'VN': 'Vietnam',
|
||||||
|
'US': 'United States',
|
||||||
|
'JP': 'Japan',
|
||||||
|
'KR': 'South Korea',
|
||||||
|
'IN': 'India',
|
||||||
|
'GB': 'United Kingdom',
|
||||||
|
'GLOBAL': '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize region from cookie
|
||||||
|
useEffect(() => {
|
||||||
|
const region = getRegionFromCookie();
|
||||||
|
setRegionCode(region);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load videos when category or region changes
|
||||||
|
useEffect(() => {
|
||||||
|
loadVideos(currentCategory, 1);
|
||||||
|
}, [currentCategory, regionCode]);
|
||||||
|
|
||||||
|
// Listen for region changes
|
||||||
|
useEffect(() => {
|
||||||
|
const checkRegionChange = () => {
|
||||||
|
const newRegion = getRegionFromCookie();
|
||||||
|
setRegionCode(prev => {
|
||||||
|
if (newRegion !== prev) {
|
||||||
|
return newRegion;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for custom event from RegionSelector
|
||||||
|
const handleRegionChange = (e: CustomEvent) => {
|
||||||
|
if (e.detail?.region) {
|
||||||
|
setRegionCode(e.detail.region);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check when tab becomes visible
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
checkRegionChange();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check when window gets focus
|
||||||
|
const handleFocus = () => {
|
||||||
|
checkRegionChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('regionchange', handleRegionChange as EventListener);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
window.addEventListener('focus', handleFocus);
|
||||||
|
|
||||||
|
// Also poll every 3 seconds as backup
|
||||||
|
const interval = setInterval(checkRegionChange, 3000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('regionchange', handleRegionChange as EventListener);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
window.removeEventListener('focus', handleFocus);
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []); // Run once on mount
|
||||||
|
|
||||||
|
const loadVideos = async (category: string, pageNum: number) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
let results: VideoData[] = [];
|
||||||
|
const regionLabel = REGION_MAP[regionCode] || '';
|
||||||
|
const regionSuffix = regionLabel ? ` ${regionLabel}` : '';
|
||||||
|
|
||||||
|
// All categories use region-specific search
|
||||||
|
if (category === 'Trending') {
|
||||||
|
results = await getTrendingVideosClient(regionCode, 30);
|
||||||
|
} else if (category === 'All') {
|
||||||
|
// Use region-specific trending for "All"
|
||||||
|
results = await getTrendingVideosClient(regionCode, 30);
|
||||||
|
} else {
|
||||||
|
// Category-specific search with region
|
||||||
|
const query = `${category}${regionSuffix}`;
|
||||||
|
results = await searchVideosClient(query, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove duplicates and filter out videos without thumbnails
|
||||||
|
const uniqueResults = results.filter((video, index, self) => {
|
||||||
|
const isUnique = index === self.findIndex(v => v.id === video.id);
|
||||||
|
const hasThumbnail = isValidThumbnail(video.thumbnail);
|
||||||
|
return isUnique && hasThumbnail;
|
||||||
|
});
|
||||||
|
|
||||||
|
setVideos(uniqueResults);
|
||||||
|
setPage(pageNum);
|
||||||
|
setHasMore(true);
|
||||||
|
hasMoreRef.current = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load videos:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryChange = (category: string) => {
|
||||||
|
setCurrentCategory(category);
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('category', category);
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (loadingMoreRef.current || loadingRef.current || !hasMoreRef.current) return;
|
||||||
|
|
||||||
|
setLoadingMore(true);
|
||||||
|
const nextPage = pageRef.current + 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const regionLabel = REGION_MAP[regionCode] || '';
|
||||||
|
const regionSuffix = regionLabel ? ` ${regionLabel}` : '';
|
||||||
|
|
||||||
|
// Generate varied search queries - ALL include region
|
||||||
|
const searchVariations = [
|
||||||
|
`trending${regionSuffix}`,
|
||||||
|
`popular videos${regionSuffix}`,
|
||||||
|
`viral 2026${regionSuffix}`,
|
||||||
|
`music${regionSuffix}`,
|
||||||
|
`entertainment${regionSuffix}`,
|
||||||
|
`gaming${regionSuffix}`,
|
||||||
|
`funny${regionSuffix}`,
|
||||||
|
`news${regionSuffix}`,
|
||||||
|
`sports${regionSuffix}`,
|
||||||
|
`new videos${regionSuffix}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const queryIndex = (nextPage - 1) % searchVariations.length;
|
||||||
|
const searchQuery = searchVariations[queryIndex];
|
||||||
|
|
||||||
|
// Always use search for variety - trending API returns same results
|
||||||
|
const moreVideos = await searchVideosClient(searchQuery, 30);
|
||||||
|
|
||||||
|
// Remove duplicates and filter out videos without thumbnails
|
||||||
|
setVideos(prev => {
|
||||||
|
const existingIds = new Set(prev.map(v => v.id));
|
||||||
|
const uniqueNewVideos = moreVideos.filter(v =>
|
||||||
|
!existingIds.has(v.id) && isValidThumbnail(v.thumbnail)
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no new videos after filtering, stop infinite scroll
|
||||||
|
if (uniqueNewVideos.length < 3) {
|
||||||
|
setHasMore(false);
|
||||||
|
hasMoreRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...prev, ...uniqueNewVideos];
|
||||||
|
});
|
||||||
|
|
||||||
|
setPage(nextPage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load more videos:', error);
|
||||||
|
// Don't stop infinite scroll on error - allow retry on next scroll
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [currentCategory, regionCode]);
|
||||||
|
|
||||||
|
// Ref for the loadMore function to avoid stale closures
|
||||||
|
const loadMoreCallbackRef = useRef(loadMore);
|
||||||
|
useEffect(() => {
|
||||||
|
loadMoreCallbackRef.current = loadMore;
|
||||||
|
}, [loadMore]);
|
||||||
|
|
||||||
|
// Infinite scroll using Intersection Observer
|
||||||
|
useEffect(() => {
|
||||||
|
// Don't set up observer while loading or if no videos
|
||||||
|
if (loading || videos.length === 0) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (entry.isIntersecting && !loadingMoreRef.current && !loadingRef.current && hasMoreRef.current) {
|
||||||
|
console.log('Sentinel intersecting, loading more...');
|
||||||
|
loadMoreCallbackRef.current();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin: '600px',
|
||||||
|
threshold: 0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Small delay to ensure DOM is ready
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const sentinel = document.getElementById('scroll-sentinel');
|
||||||
|
console.log('Sentinel element:', sentinel);
|
||||||
|
if (sentinel) {
|
||||||
|
observer.observe(sentinel);
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [loading, videos.length]); // Re-run when loading finishes or videos change
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'var(--yt-background)',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
minHeight: '100vh',
|
||||||
|
padding: '0 24px 24px',
|
||||||
|
}}>
|
||||||
|
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
|
||||||
|
{/* Category Pills */}
|
||||||
|
<CategoryPills
|
||||||
|
categories={categories}
|
||||||
|
currentCategory={currentCategory}
|
||||||
|
onCategoryChange={handleCategoryChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Video Grid */}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||||
|
gap: '0 24px',
|
||||||
|
}}>
|
||||||
|
{[...Array(12)].map((_, i) => (
|
||||||
|
<VideoSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||||
|
gap: '0 24px',
|
||||||
|
}}>
|
||||||
|
{videos.map((video) => (
|
||||||
|
<VideoCard key={video.id} video={video} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll Sentinel for Infinite Scroll */}
|
||||||
|
<div id="scroll-sentinel" style={{ height: '100px', width: '100%' }} />
|
||||||
|
|
||||||
|
{/* Loading More Indicator */}
|
||||||
|
{loadingMore && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '48px 0',
|
||||||
|
}}>
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* End of Results */}
|
||||||
|
{!hasMore && videos.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '48px 0',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
You've reached the end
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{videos.length === 0 && !loading && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '400px',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
}}>
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" style={{ marginBottom: '16px', opacity: 0.5 }}>
|
||||||
|
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 style={{ fontSize: '16px', marginBottom: '8px' }}>No videos found</h3>
|
||||||
|
<p style={{ fontSize: '14px' }}>Try selecting a different category</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Animations */}
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
frontend/app/actions.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
"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 getRelatedVideos(videoId: string, limit: number = 10): Promise<VideoData[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/related?video_id=${encodeURIComponent(videoId)}&limit=${limit}`, { cache: 'no-store' });
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return res.json() as Promise<VideoData[]>;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to get related videos:", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecentHistory(): Promise<VideoData | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/history?limit=1`, { cache: 'no-store' });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const history: VideoData[] = await res.json();
|
||||||
|
return history.length > 0 ? history[0] : null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to get recent history:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMoreVideos(currentCategory: string, regionLabel: string, page: number, contextVideoId?: string): 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 recentVideo = await getRecentHistory();
|
||||||
|
if (recentVideo) {
|
||||||
|
const promises = [
|
||||||
|
getSearchVideos(addRegion("recommended for you", regionLabel) + " " + modifier, 8),
|
||||||
|
getSearchVideos(addRegion(recentVideo.title, regionLabel) + " " + modifier, 8),
|
||||||
|
getSearchVideos(addRegion("trending", regionLabel) + " " + modifier, 4)
|
||||||
|
];
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
const interleavedList: VideoData[] = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
let sIdx = 0, rIdx = 0, tIdx = 0;
|
||||||
|
const suggestedRes = results[0];
|
||||||
|
const relatedRes = results[1];
|
||||||
|
const trendingRes = results[2];
|
||||||
|
|
||||||
|
while (sIdx < suggestedRes.length || rIdx < relatedRes.length || tIdx < trendingRes.length) {
|
||||||
|
for (let i = 0; i < 2 && sIdx < suggestedRes.length; i++) {
|
||||||
|
const v = suggestedRes[sIdx++];
|
||||||
|
if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.id); }
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 2 && rIdx < relatedRes.length; i++) {
|
||||||
|
const v = relatedRes[rIdx++];
|
||||||
|
if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.id); }
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 1 && tIdx < trendingRes.length; i++) {
|
||||||
|
const v = trendingRes[tIdx++];
|
||||||
|
if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newVideos = interleavedList;
|
||||||
|
} else {
|
||||||
|
const promises = ALL_CATEGORY_SECTIONS.map(async (sec) => {
|
||||||
|
const q = addRegion(sec.query, regionLabel) + " " + modifier;
|
||||||
|
return await getSearchVideos(q, 5);
|
||||||
|
});
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
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 === 'WatchRelated' && contextVideoId) {
|
||||||
|
// Mock infinite pagination for related
|
||||||
|
const q = addRegion("related to " + contextVideoId, regionLabel) + " " + modifier;
|
||||||
|
newVideos = await getSearchVideos(q, 20);
|
||||||
|
} else if (currentCategory === 'WatchForYou') {
|
||||||
|
const q = addRegion("recommended for you", regionLabel) + " " + modifier;
|
||||||
|
newVideos = await getSearchVideos(q, 20);
|
||||||
|
} else if (currentCategory === 'WatchAll' && contextVideoId) {
|
||||||
|
// Implement 40:40:20 mix logic for watch page
|
||||||
|
const promises = [
|
||||||
|
getSearchVideos(addRegion("recommended for you", regionLabel) + " " + modifier, 8),
|
||||||
|
getSearchVideos(addRegion("related to " + contextVideoId, regionLabel) + " " + modifier, 8),
|
||||||
|
getSearchVideos(addRegion("trending", regionLabel) + " " + modifier, 4)
|
||||||
|
];
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
const interleavedList: VideoData[] = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
let sIdx = 0, rIdx = 0, tIdx = 0;
|
||||||
|
const suggestedRes = results[0];
|
||||||
|
const relatedRes = results[1];
|
||||||
|
const trendingRes = results[2];
|
||||||
|
|
||||||
|
while (sIdx < suggestedRes.length || rIdx < relatedRes.length || tIdx < trendingRes.length) {
|
||||||
|
for (let i = 0; i < 2 && sIdx < suggestedRes.length; i++) {
|
||||||
|
const v = suggestedRes[sIdx++];
|
||||||
|
if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.id); }
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 2 && rIdx < relatedRes.length; i++) {
|
||||||
|
const v = relatedRes[rIdx++];
|
||||||
|
if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.id); }
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 1 && tIdx < trendingRes.length; i++) {
|
||||||
|
const v = trendingRes[tIdx++];
|
||||||
|
if (!seenIds.has(v.id)) { interleavedList.push(v); seenIds.add(v.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentData {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
author: string;
|
||||||
|
author_id: string;
|
||||||
|
author_thumbnail: string;
|
||||||
|
likes: number;
|
||||||
|
is_reply: boolean;
|
||||||
|
parent: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVideoComments(videoId: string, limit: number = 30): Promise<CommentData[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/comments?v=${videoId}&limit=${limit}`, { cache: 'no-store' });
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('Comments API error:', res.status, res.statusText);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
console.error('Comments API returned non-array:', data);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch comments:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
127
frontend/app/channel/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080/api';
|
||||||
|
|
||||||
|
async function getChannelInfo(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/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(`${API_BASE}/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
frontend/app/clientActions.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { VideoData } from './constants';
|
||||||
|
|
||||||
|
// Use relative URLs - Next.js rewrites will proxy to backend
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
// Transform backend response to our VideoData format
|
||||||
|
function transformVideo(item: any): VideoData {
|
||||||
|
return {
|
||||||
|
id: item.id || '',
|
||||||
|
title: item.title || 'Untitled',
|
||||||
|
thumbnail: item.thumbnail || `https://i.ytimg.com/vi/${item.id}/hqdefault.jpg`,
|
||||||
|
channelTitle: item.uploader || item.channelTitle || 'Unknown',
|
||||||
|
channelId: item.channel_id || item.channelId || '',
|
||||||
|
viewCount: formatViews(item.view_count || 0),
|
||||||
|
publishedAt: formatRelativeTime(item.upload_date || item.uploaded),
|
||||||
|
duration: item.duration || '',
|
||||||
|
description: item.description || '',
|
||||||
|
uploader: item.uploader,
|
||||||
|
uploader_id: item.uploader_id,
|
||||||
|
channel_id: item.channel_id,
|
||||||
|
view_count: item.view_count || 0,
|
||||||
|
upload_date: item.upload_date,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatViews(views: number): string {
|
||||||
|
if (!views) return '0';
|
||||||
|
if (views >= 1000000000) return (views / 1000000000).toFixed(1) + 'B';
|
||||||
|
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
|
||||||
|
if (views >= 1000) return (views / 1000).toFixed(1) + 'K';
|
||||||
|
return views.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(input: any): string {
|
||||||
|
if (!input) return 'recently';
|
||||||
|
if (typeof input === 'string' && input.includes('ago')) return input;
|
||||||
|
|
||||||
|
const date = new Date(input);
|
||||||
|
if (isNaN(date.getTime())) return 'recently';
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (days === 0) return 'today';
|
||||||
|
if (days === 1) return 'yesterday';
|
||||||
|
if (days < 7) return `${days} days ago`;
|
||||||
|
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
|
||||||
|
if (days < 365) return `${Math.floor(days / 30)} months ago`;
|
||||||
|
return `${Math.floor(days / 365)} years ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search videos using backend API
|
||||||
|
export async function searchVideosClient(query: string, limit: number = 20): Promise<VideoData[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/search?q=${encodeURIComponent(query)}&limit=${limit}`, {
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!Array.isArray(data)) return [];
|
||||||
|
|
||||||
|
return data.map(transformVideo).filter((v: VideoData) => v.id && v.title);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get video details using backend API
|
||||||
|
export async function getVideoDetailsClient(videoId: string): Promise<VideoData | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/video/${videoId}`, {
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return transformVideo(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get video details failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get related videos using backend API
|
||||||
|
export async function getRelatedVideosClient(videoId: string, limit: number = 15): Promise<VideoData[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/video/${videoId}/related?limit=${limit}`, {
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!Array.isArray(data)) return [];
|
||||||
|
|
||||||
|
return data.map(transformVideo).filter((v: VideoData) => v.id && v.title).slice(0, limit);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get related videos failed:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get trending videos using backend API with region support
|
||||||
|
export async function getTrendingVideosClient(regionCode: string = 'US', limit: number = 20): Promise<VideoData[]> {
|
||||||
|
// Map region codes to search queries for region-specific trending
|
||||||
|
const regionNames: Record<string, string> = {
|
||||||
|
'VN': 'Vietnam',
|
||||||
|
'US': 'United States',
|
||||||
|
'JP': 'Japan',
|
||||||
|
'KR': 'South Korea',
|
||||||
|
'IN': 'India',
|
||||||
|
'GB': 'United Kingdom',
|
||||||
|
'DE': 'Germany',
|
||||||
|
'FR': 'France',
|
||||||
|
'BR': 'Brazil',
|
||||||
|
'MX': 'Mexico',
|
||||||
|
'CA': 'Canada',
|
||||||
|
'AU': 'Australia',
|
||||||
|
'GLOBAL': '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const regionName = regionNames[regionCode] || '';
|
||||||
|
const searchQuery = regionName
|
||||||
|
? `trending ${regionName} 2026`
|
||||||
|
: 'trending videos 2026';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/search?q=${encodeURIComponent(searchQuery)}&limit=${limit}`, {
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!Array.isArray(data)) return [];
|
||||||
|
|
||||||
|
return data.map(transformVideo).filter((v: VideoData) => v.id && v.title).slice(0, limit);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get trending videos failed:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get comments using backend API
|
||||||
|
export async function getCommentsClient(videoId: string, limit: number = 20): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/video/${videoId}/comments?limit=${limit}`, {
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!Array.isArray(data)) return [];
|
||||||
|
|
||||||
|
return data.map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
text: c.text || c.content,
|
||||||
|
author: c.author,
|
||||||
|
authorId: c.author_id,
|
||||||
|
authorThumbnail: c.author_thumbnail,
|
||||||
|
likes: c.likes || 0,
|
||||||
|
published: c.timestamp || 'recently',
|
||||||
|
isReply: c.is_reply || false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get comments failed:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get channel info using backend API
|
||||||
|
export async function getChannelInfoClient(channelId: string): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/channel/info?id=${channelId}`, {
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return {
|
||||||
|
id: data.id || channelId,
|
||||||
|
title: data.title || 'Unknown Channel',
|
||||||
|
avatar: data.avatar || '',
|
||||||
|
banner: data.banner || '',
|
||||||
|
subscriberCount: data.subscriber_count || 0,
|
||||||
|
description: data.description || '',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get channel info failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get channel videos using backend API
|
||||||
|
export async function getChannelVideosClient(channelId: string, limit: number = 30): Promise<VideoData[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/channel/videos?id=${channelId}&limit=${limit}`, {
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!Array.isArray(data)) return [];
|
||||||
|
|
||||||
|
return data.map(transformVideo).filter((v: VideoData) => v.id && v.title);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get channel videos failed:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch more videos for pagination
|
||||||
|
export async function fetchMoreVideosClient(
|
||||||
|
currentCategory: string,
|
||||||
|
regionLabel: string,
|
||||||
|
page: number,
|
||||||
|
contextVideoId?: string
|
||||||
|
): Promise<VideoData[]> {
|
||||||
|
const modifiers = ['', 'more', 'new', 'update', 'latest', 'part 2'];
|
||||||
|
const modifier = page < modifiers.length ? modifiers[page] : `page ${page}`;
|
||||||
|
|
||||||
|
let searchQuery = '';
|
||||||
|
|
||||||
|
switch (currentCategory) {
|
||||||
|
case 'All':
|
||||||
|
case 'Trending':
|
||||||
|
searchQuery = `trending ${modifier}`;
|
||||||
|
break;
|
||||||
|
case 'Music':
|
||||||
|
searchQuery = `music ${modifier}`;
|
||||||
|
break;
|
||||||
|
case 'Gaming':
|
||||||
|
searchQuery = `gaming ${modifier}`;
|
||||||
|
break;
|
||||||
|
case 'News':
|
||||||
|
searchQuery = `news ${modifier}`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
searchQuery = `${currentCategory.toLowerCase()} ${modifier}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regionLabel && regionLabel !== 'Global') {
|
||||||
|
searchQuery = `${regionLabel} ${searchQuery}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchVideosClient(searchQuery, 20);
|
||||||
|
}
|
||||||
70
frontend/app/components/HamburgerMenu.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { MdHomeFilled, MdOutlineSubscriptions, MdOutlineVideoLibrary, MdClose } from 'react-icons/md';
|
||||||
|
import { useSidebar } from '../context/SidebarContext';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function HamburgerMenu() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { isMobileMenuOpen, closeMobileMenu, isSidebarOpen, openSidebar } = useSidebar();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
|
||||||
|
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Sub', path: '/feed/subscriptions' },
|
||||||
|
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Close menu on route change
|
||||||
|
useEffect(() => {
|
||||||
|
closeMobileMenu();
|
||||||
|
}, [pathname, closeMobileMenu]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className={`drawer-backdrop ${isMobileMenuOpen ? 'open' : ''}`}
|
||||||
|
onClick={closeMobileMenu}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Menu Drawer */}
|
||||||
|
<div className={`hamburger-drawer ${isMobileMenuOpen ? 'open' : ''}`}>
|
||||||
|
<div className="drawer-header">
|
||||||
|
<button className="yt-icon-btn" onClick={closeMobileMenu} title="Close Menu">
|
||||||
|
<MdClose size={24} />
|
||||||
|
</button>
|
||||||
|
<Link href="/" style={{ display: 'flex', alignItems: 'center', gap: '4px', marginLeft: '12px' }} onClick={closeMobileMenu}>
|
||||||
|
<span style={{ fontSize: '18px', fontWeight: '700', letterSpacing: '-0.5px', fontFamily: 'YouTube Sans, Roboto, Arial, sans-serif' }}>KV-Tube</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="drawer-content">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = pathname === item.path;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
href={item.path}
|
||||||
|
className={`drawer-nav-item ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={closeMobileMenu}
|
||||||
|
>
|
||||||
|
<div className="drawer-nav-icon">
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<span className="drawer-nav-label">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="drawer-divider" />
|
||||||
|
<div style={{ padding: '16px 24px', fontSize: '13px', color: 'var(--yt-text-secondary)' }}>
|
||||||
|
Made with ♡ locally
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
frontend/app/components/Header.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { IoSearchOutline, IoMoonOutline, IoSunnyOutline, IoArrowBack, IoMenuOutline } from 'react-icons/io5';
|
||||||
|
import RegionSelector from './RegionSelector';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
import { useSidebar } from '../context/SidebarContext';
|
||||||
|
|
||||||
|
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 { toggleSidebar, toggleMobileMenu } = useSidebar();
|
||||||
|
|
||||||
|
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">
|
||||||
|
<button className="yt-icon-btn hamburger-btn" onClick={() => {
|
||||||
|
toggleSidebar();
|
||||||
|
toggleMobileMenu();
|
||||||
|
}} title="Menu">
|
||||||
|
<IoMenuOutline size={22} />
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
frontend/app/components/HeaderDebug.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
export default function HeaderDebug() {
|
||||||
|
console.log('HeaderDebug rendered');
|
||||||
|
return (
|
||||||
|
<header style={{ height: 56, backgroundColor: 'blue', position: 'fixed', top: 0, left: 0, right: 0, zIndex: 500, display: 'flex', alignItems: 'center', padding: '0 16px' }}>
|
||||||
|
<button style={{ background: 'red', width: 40, height: 40, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
<span style={{ color: 'white', marginLeft: 12 }}>KV-Tube Debug</span>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
frontend/app/components/InfiniteVideoGrid.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import VideoCard from './VideoCard';
|
||||||
|
import { fetchMoreVideos } from '../actions';
|
||||||
|
import { VideoData } from '../constants';
|
||||||
|
import LoadingSpinner from './LoadingSpinner';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialVideos: VideoData[];
|
||||||
|
currentCategory: string;
|
||||||
|
regionLabel: string;
|
||||||
|
contextVideoId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InfiniteVideoGrid({ initialVideos, currentCategory, regionLabel, contextVideoId }: 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, contextVideoId);
|
||||||
|
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, contextVideoId]);
|
||||||
|
|
||||||
|
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 && <LoadingSpinner />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasMore && videos.length > 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '24px 0', color: 'var(--yt-text-secondary)', fontSize: '14px' }}>
|
||||||
|
No more results
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
frontend/app/components/LoadingSpinner.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
fullScreen?: boolean;
|
||||||
|
text?: string;
|
||||||
|
color?: 'primary' | 'white';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap = {
|
||||||
|
small: { spinner: 24, border: 2 },
|
||||||
|
medium: { spinner: 36, border: 3 },
|
||||||
|
large: { spinner: 48, border: 4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LoadingSpinner({
|
||||||
|
size = 'medium',
|
||||||
|
fullScreen = false,
|
||||||
|
text,
|
||||||
|
color = 'primary'
|
||||||
|
}: LoadingSpinnerProps) {
|
||||||
|
const { spinner, border } = sizeMap[size];
|
||||||
|
|
||||||
|
const spinnerColor = color === 'white' ? '#fff' : 'var(--yt-text-primary)';
|
||||||
|
const borderColor = color === 'white' ? 'rgba(255,255,255,0.2)' : 'var(--yt-border)';
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: `${spinner}px`,
|
||||||
|
height: `${spinner}px`,
|
||||||
|
border: `${border}px solid ${borderColor}`,
|
||||||
|
borderTop: `${border}px solid ${spinnerColor}`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
}} />
|
||||||
|
{text && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
}}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fullScreen) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
height: '100vh',
|
||||||
|
backgroundColor: 'var(--yt-background)',
|
||||||
|
}}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
14
frontend/app/components/MainContent.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSidebar } from '../context/SidebarContext';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export default function MainContent({ children }: { children: ReactNode }) {
|
||||||
|
const { isSidebarOpen } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className={`yt-main-content ${isSidebarOpen ? 'sidebar-open' : ''}`}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
frontend/app/components/MobileNav.tsx
Normal 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: 'Sub', 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
frontend/app/components/RegionSelector.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
'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);
|
||||||
|
// Dispatch custom event for immediate notification
|
||||||
|
window.dispatchEvent(new CustomEvent('regionchange', { detail: { region: code } }));
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
frontend/app/components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
'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';
|
||||||
|
import { useSidebar } from '../context/SidebarContext';
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { isSidebarOpen } = useSidebar();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ icon: <MdHomeFilled size={24} />, label: 'Home', path: '/' },
|
||||||
|
// { icon: <SiYoutubeshorts size={24} />, label: 'Shorts', path: '/shorts' },
|
||||||
|
{ icon: <MdOutlineSubscriptions size={24} />, label: 'Sub', path: '/feed/subscriptions' },
|
||||||
|
{ icon: <MdOutlineVideoLibrary size={24} />, label: 'You', path: '/feed/library' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={`yt-sidebar-mini ${isSidebarOpen ? 'sidebar-open' : 'sidebar-collapsed'}`}
|
||||||
|
style={{ transition: 'transform 0.3s ease, width 0.3s ease, opacity 0.3s ease' }}
|
||||||
|
>
|
||||||
|
{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: 'transparent',
|
||||||
|
marginBottom: '4px',
|
||||||
|
transition: 'var(--yt-transition)',
|
||||||
|
gap: '4px',
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%'
|
||||||
|
}}
|
||||||
|
className="yt-sidebar-item"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
frontend/app/components/SubscribeButton.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
'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,
|
||||||
|
channel_avatar: channelName ? channelName[0].toUpperCase() : '?',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
frontend/app/components/VideoCard.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { VideoData } from '@/app/constants';
|
||||||
|
import LoadingSpinner from './LoadingSpinner';
|
||||||
|
|
||||||
|
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 getStableRelativeTime(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 hash = id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||||
|
return times[hash % times.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
|
||||||
|
|
||||||
|
function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannelAvatar?: boolean }) {
|
||||||
|
const relativeTime = video.upload_date || video.publishedAt || getStableRelativeTime(video.id);
|
||||||
|
const [isNavigating, setIsNavigating] = useState(false);
|
||||||
|
const destination = video.list_id ? `/watch?v=${video.id}&list=${video.list_id}` : `/watch?v=${video.id}`;
|
||||||
|
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
|
||||||
|
|
||||||
|
const handleImageError = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||||
|
const img = e.target as HTMLImageElement;
|
||||||
|
if (img.src !== DEFAULT_THUMBNAIL) {
|
||||||
|
img.src = DEFAULT_THUMBNAIL;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', width: '100%', marginBottom: '12px' }} className="videocard-container">
|
||||||
|
<Link
|
||||||
|
href={destination}
|
||||||
|
onClick={() => setIsNavigating(true)}
|
||||||
|
style={{ position: 'relative', display: 'block', width: '100%', aspectRatio: '16/9', overflow: 'hidden', borderRadius: '12px' }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={thumbnailSrc}
|
||||||
|
alt={video.title}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
|
style={{ objectFit: 'cover', backgroundColor: 'var(--yt-hover)' }}
|
||||||
|
className="videocard-thumb"
|
||||||
|
priority={false}
|
||||||
|
onError={handleImageError}
|
||||||
|
/>
|
||||||
|
{video.duration && !video.is_mix && (
|
||||||
|
<div className="duration-badge" style={{ position: 'absolute', bottom: '8px', right: '8px' }}>
|
||||||
|
{video.duration}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{video.is_mix && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 0, right: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)', color: 'white',
|
||||||
|
padding: '4px 8px', fontSize: '12px', fontWeight: 500,
|
||||||
|
borderTopLeftRadius: '8px', zIndex: 5,
|
||||||
|
display: 'flex', alignItems: 'center', gap: '4px'
|
||||||
|
}}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M22 7H2v1h20V7zm-9 5H2v-1h11v1zm0 4H2v-1h11v1zm2 3v-8l7 4-7 4z"></path></svg>
|
||||||
|
Mix
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isNavigating && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
zIndex: 10
|
||||||
|
}}>
|
||||||
|
<LoadingSpinner color="white" />
|
||||||
|
</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={destination} 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 || video.channelTitle || 'Unknown'}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: '14px', color: 'var(--yt-text-secondary)', display: 'block' }}>
|
||||||
|
{video.uploader || video.channelTitle || 'Unknown'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: '14px', color: 'var(--yt-text-secondary)', marginTop: '2px' }}>
|
||||||
|
{formatViews(video.view_count ?? 0)} views • {relativeTime}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(VideoCard);
|
||||||
46
frontend/app/constants.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
export const API_BASE = ''; // No backend needed - using public APIs
|
||||||
|
|
||||||
|
export interface VideoData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
thumbnail: string;
|
||||||
|
channelTitle?: string;
|
||||||
|
channelId?: string;
|
||||||
|
viewCount?: string;
|
||||||
|
publishedAt?: string;
|
||||||
|
duration: string;
|
||||||
|
description?: string;
|
||||||
|
// Legacy fields for compatibility
|
||||||
|
uploader?: string;
|
||||||
|
uploader_id?: string;
|
||||||
|
channel_id?: string;
|
||||||
|
view_count?: number;
|
||||||
|
upload_date?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
list_id?: string;
|
||||||
|
is_mix?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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' },
|
||||||
|
];
|
||||||
72
frontend/app/context/SidebarContext.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface SidebarContextType {
|
||||||
|
isSidebarOpen: boolean;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
openSidebar: () => void;
|
||||||
|
closeSidebar: () => void;
|
||||||
|
isMobileMenuOpen: boolean;
|
||||||
|
toggleMobileMenu: () => void;
|
||||||
|
openMobileMenu: () => void;
|
||||||
|
closeMobileMenu: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||||
|
// Sidebar is collapsed by default on desktop
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
// Load saved preference from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('sidebarOpen');
|
||||||
|
if (saved !== null) {
|
||||||
|
setIsSidebarOpen(saved === 'true');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save preference to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('sidebarOpen', isSidebarOpen.toString());
|
||||||
|
}, [isSidebarOpen]);
|
||||||
|
|
||||||
|
const toggleSidebar = () => setIsSidebarOpen(prev => !prev);
|
||||||
|
const openSidebar = () => setIsSidebarOpen(true);
|
||||||
|
const closeSidebar = () => setIsSidebarOpen(false);
|
||||||
|
|
||||||
|
const toggleMobileMenu = () => setIsMobileMenuOpen(prev => !prev);
|
||||||
|
const openMobileMenu = () => setIsMobileMenuOpen(true);
|
||||||
|
const closeMobileMenu = () => setIsMobileMenuOpen(false);
|
||||||
|
|
||||||
|
// Prevent body scroll when mobile menu is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobileMenuOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [isMobileMenuOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={{
|
||||||
|
isSidebarOpen, toggleSidebar, openSidebar, closeSidebar,
|
||||||
|
isMobileMenuOpen, toggleMobileMenu, openMobileMenu, closeMobileMenu
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebar() {
|
||||||
|
const context = useContext(SidebarContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useSidebar must be used within a SidebarProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
45
frontend/app/context/ThemeContext.tsx
Normal 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
Normal file
|
After Width: | Height: | Size: 25 KiB |
316
frontend/app/feed/library/page.tsx
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { getSavedVideos, type SavedVideo } from '../../storage';
|
||||||
|
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||||
|
|
||||||
|
const DEFAULT_THUMBNAIL = 'https://i.ytimg.com/vi/default/hqdefault.jpg';
|
||||||
|
|
||||||
|
interface VideoData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
uploader: string;
|
||||||
|
thumbnail: string;
|
||||||
|
view_count: number;
|
||||||
|
duration: string;
|
||||||
|
uploaded_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Subscription {
|
||||||
|
id: number;
|
||||||
|
channel_id: string;
|
||||||
|
channel_name: string;
|
||||||
|
channel_avatar: 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryVideoCard({ video }: { video: VideoData }) {
|
||||||
|
const relativeTime = video.uploaded_date || getRelativeTime(video.id);
|
||||||
|
const destination = `/watch?v=${video.id}`;
|
||||||
|
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
|
||||||
|
|
||||||
|
const handleImageError = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||||
|
const img = e.target as HTMLImageElement;
|
||||||
|
if (img.src !== DEFAULT_THUMBNAIL) {
|
||||||
|
img.src = DEFAULT_THUMBNAIL;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={destination}
|
||||||
|
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={thumbnailSrc}
|
||||||
|
alt={video.title}
|
||||||
|
className="videocard-thumb"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
onError={handleImageError}
|
||||||
|
/>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubscriptionCard({ subscription }: { subscription: Subscription }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/channel/${subscription.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',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
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',
|
||||||
|
}}>
|
||||||
|
{subscription.channel_avatar || (subscription.channel_name ? subscription.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',
|
||||||
|
}}>
|
||||||
|
{subscription.channel_name || subscription.channel_id}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SavedVideoCard({ video }: { video: SavedVideo }) {
|
||||||
|
const destination = `/watch?v=${video.videoId}`;
|
||||||
|
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
|
||||||
|
|
||||||
|
const handleImageError = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||||
|
const img = e.target as HTMLImageElement;
|
||||||
|
if (img.src !== DEFAULT_THUMBNAIL) {
|
||||||
|
img.src = DEFAULT_THUMBNAIL;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={destination}
|
||||||
|
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={thumbnailSrc}
|
||||||
|
alt={video.title}
|
||||||
|
className="videocard-thumb"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
onError={handleImageError}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '8px',
|
||||||
|
right: '8px',
|
||||||
|
background: 'rgba(0,0,0,0.8)',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}>
|
||||||
|
Saved
|
||||||
|
</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.channelTitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LibraryPage() {
|
||||||
|
const [history, setHistory] = useState<VideoData[]>([]);
|
||||||
|
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
|
||||||
|
const [savedVideos, setSavedVideos] = useState<SavedVideo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080/api';
|
||||||
|
const [historyRes, subsRes] = await Promise.all([
|
||||||
|
fetch(`${apiBase}/history?limit=20`, { cache: 'no-store' }),
|
||||||
|
fetch(`${apiBase}/subscriptions`, { cache: 'no-store' })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const historyData = await historyRes.json();
|
||||||
|
const subsData = await subsRes.json();
|
||||||
|
const savedData = getSavedVideos(20);
|
||||||
|
|
||||||
|
setHistory(Array.isArray(historyData) ? historyData : []);
|
||||||
|
setSubscriptions(Array.isArray(subsData) ? subsData : []);
|
||||||
|
setSavedVideos(savedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch library data:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '48px', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
|
||||||
|
{subscriptions.length > 0 && (
|
||||||
|
<section style={{ marginBottom: '40px' }}>
|
||||||
|
<h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
|
||||||
|
Sub
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
||||||
|
{subscriptions.map((sub) => (
|
||||||
|
<SubscriptionCard key={sub.channel_id} subscription={sub} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{savedVideos.length > 0 && (
|
||||||
|
<section style={{ marginBottom: '40px' }}>
|
||||||
|
<h2 style={{ marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
|
||||||
|
Saved Videos
|
||||||
|
</h2>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
|
gap: '16px',
|
||||||
|
}}>
|
||||||
|
{savedVideos.map((video) => (
|
||||||
|
<SavedVideoCard key={video.videoId} video={video} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 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) => (
|
||||||
|
<HistoryVideoCard key={video.id} video={video} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
277
frontend/app/feed/subscriptions/page.tsx
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { getChannelVideosClient, getChannelInfoClient } from '../../clientActions';
|
||||||
|
import { VideoData } from '../../constants';
|
||||||
|
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080/api';
|
||||||
|
|
||||||
|
interface Subscription {
|
||||||
|
channel_id: string;
|
||||||
|
channel_name: string;
|
||||||
|
channel_avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_THUMBNAIL = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180"><rect fill="%23333" width="320" height="180"/><text x="160" y="90" text-anchor="middle" fill="%23666" font-family="Arial" font-size="14">No thumbnail</text></svg>';
|
||||||
|
|
||||||
|
interface ChannelVideos {
|
||||||
|
subscription: Subscription;
|
||||||
|
videos: VideoData[];
|
||||||
|
channelInfo: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch subscriptions from backend API
|
||||||
|
async function fetchSubscriptions(): Promise<Subscription[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/subscriptions`, { cache: 'no-store' });
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch subscriptions:', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_ROWS = 2;
|
||||||
|
const VIDEOS_PER_ROW = 5;
|
||||||
|
const MAX_ROWS = 5;
|
||||||
|
|
||||||
|
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 ChannelSection({ channelVideos, defaultExpanded = false }: { channelVideos: ChannelVideos; defaultExpanded?: boolean }) {
|
||||||
|
const { subscription, videos } = channelVideos;
|
||||||
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||||
|
|
||||||
|
const handleImageError = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||||
|
const img = e.target as HTMLImageElement;
|
||||||
|
if (img.src !== DEFAULT_THUMBNAIL) {
|
||||||
|
img.src = DEFAULT_THUMBNAIL;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (videos.length === 0) return null;
|
||||||
|
|
||||||
|
const initialCount = INITIAL_ROWS * VIDEOS_PER_ROW;
|
||||||
|
const maxCount = MAX_ROWS * VIDEOS_PER_ROW;
|
||||||
|
const displayedVideos = expanded ? videos.slice(0, maxCount) : videos.slice(0, initialCount);
|
||||||
|
const hasMore = videos.length > initialCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ marginBottom: '32px' }}>
|
||||||
|
<Link
|
||||||
|
href={`/channel/${subscription.channel_id}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
padding: '0 12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{subscription.channel_avatar ? (
|
||||||
|
<img src={subscription.channel_avatar} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
) : (
|
||||||
|
subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: '500', color: 'var(--yt-text-primary)', textAlign: 'center' }}>
|
||||||
|
{subscription.channel_name || subscription.channel_id}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '0 12px',
|
||||||
|
}}>
|
||||||
|
{displayedVideos.map((video) => {
|
||||||
|
const relativeTime = video.publishedAt || video.upload_date || 'recently';
|
||||||
|
const destination = `/watch?v=${video.id}`;
|
||||||
|
const thumbnailSrc = video.thumbnail || DEFAULT_THUMBNAIL;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={video.id}
|
||||||
|
href={destination}
|
||||||
|
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={thumbnailSrc}
|
||||||
|
alt={video.title}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
onError={handleImageError}
|
||||||
|
/>
|
||||||
|
{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',
|
||||||
|
margin: 0,
|
||||||
|
}}>
|
||||||
|
{video.title}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', margin: 0 }}>
|
||||||
|
{video.viewCount || formatViews(video.view_count || 0)} views • {relativeTime}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<div style={{ padding: '16px 12px 0', textAlign: 'left' }}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setExpanded(!expanded);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '18px',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--yt-hover)';
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? 'Show less' : `Show more (${videos.length - initialCount} more)`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubscriptionsPage() {
|
||||||
|
const [channelsVideos, setChannelsVideos] = useState<ChannelVideos[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const subs = await fetchSubscriptions();
|
||||||
|
|
||||||
|
const channelVideos: ChannelVideos[] = [];
|
||||||
|
|
||||||
|
// Fetch videos for each subscription in parallel
|
||||||
|
const promises = subs.map(async (sub) => {
|
||||||
|
try {
|
||||||
|
const channelId = sub.channel_id;
|
||||||
|
const videos = await getChannelVideosClient(channelId, MAX_ROWS * VIDEOS_PER_ROW);
|
||||||
|
const channelInfo = await getChannelInfoClient(channelId);
|
||||||
|
|
||||||
|
if (videos.length > 0) {
|
||||||
|
return {
|
||||||
|
subscription: sub,
|
||||||
|
videos: videos,
|
||||||
|
channelInfo: channelInfo || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to fetch videos for ${sub.channel_id}:`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
const validResults = results.filter((r): r is ChannelVideos => r !== null);
|
||||||
|
|
||||||
|
setChannelsVideos(validResults);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch subscriptions:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '48px', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelsVideos.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>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: 'var(--yt-brand-red)',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '20px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Discover videos
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '12px', maxWidth: '1400px', margin: '0 auto' }}>
|
||||||
|
<h1 style={{ fontSize: '24px', fontWeight: '600', marginBottom: '24px', padding: '0 12px' }}>Sub</h1>
|
||||||
|
|
||||||
|
{channelsVideos.map((channelData) => (
|
||||||
|
<ChannelSection key={channelData.subscription.channel_id} channelVideos={channelData} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1656
frontend/app/globals.css
Normal file
94
frontend/app/layout.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
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';
|
||||||
|
import HamburgerMenu from './components/HamburgerMenu';
|
||||||
|
import MainContent from './components/MainContent';
|
||||||
|
|
||||||
|
const roboto = Roboto({
|
||||||
|
weight: ['400', '500', '700'],
|
||||||
|
subsets: ['latin'],
|
||||||
|
display: 'swap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'KV-Tube',
|
||||||
|
description: 'A modern YouTube-like video streaming platform with background playback',
|
||||||
|
manifest: '/manifest.json',
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: 'black-translucent',
|
||||||
|
title: 'KV-Tube',
|
||||||
|
startupImage: [
|
||||||
|
{
|
||||||
|
url: '/icons/icon-512x512.png',
|
||||||
|
media: '(device-width: 1024px)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
'mobile-web-app-capable': 'yes',
|
||||||
|
'apple-mobile-web-app-capable': 'yes',
|
||||||
|
'apple-mobile-web-app-status-bar-style': 'black-translucent',
|
||||||
|
'theme-color': '#ff0000',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport = {
|
||||||
|
themeColor: '#000000',
|
||||||
|
};
|
||||||
|
|
||||||
|
import { ThemeProvider } from './context/ThemeContext';
|
||||||
|
import { SidebarProvider } from './context/SidebarContext';
|
||||||
|
|
||||||
|
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) {}
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
navigator.serviceWorker.register('/sw.js');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<ThemeProvider>
|
||||||
|
<SidebarProvider>
|
||||||
|
<Header />
|
||||||
|
<Sidebar />
|
||||||
|
<HamburgerMenu />
|
||||||
|
<MainContent>
|
||||||
|
{children}
|
||||||
|
</MainContent>
|
||||||
|
<MobileNav />
|
||||||
|
</SidebarProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
frontend/app/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import ClientHomePage from './ClientHomePage';
|
||||||
|
import LoadingSpinner from './components/LoadingSpinner';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingSpinner fullScreen text="Loading videos..." />}>
|
||||||
|
<ClientHomePage />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
frontend/app/search/ClientSearchPage.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { searchVideosClient } from '../clientActions';
|
||||||
|
import { VideoData } from '../constants';
|
||||||
|
import VideoCard from '../components/VideoCard';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
|
function SearchSkeleton() {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
|
gap: '16px',
|
||||||
|
}}>
|
||||||
|
{[...Array(12)].map((_, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<div style={{
|
||||||
|
aspectRatio: '16/9',
|
||||||
|
backgroundColor: 'var(--yt-hover)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
animation: 'pulse 1.5s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
<div style={{ display: 'flex', gap: '12px', padding: '0' }}>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
<div style={{ width: '90%', height: '16px', backgroundColor: 'var(--yt-hover)', borderRadius: '4px' }} />
|
||||||
|
<div style={{ width: '60%', height: '12px', backgroundColor: 'var(--yt-hover)', borderRadius: '4px' }} />
|
||||||
|
<div style={{ width: '40%', height: '12px', backgroundColor: 'var(--yt-hover)', borderRadius: '4px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientSearchPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const query = searchParams.get('q') || '';
|
||||||
|
const [videos, setVideos] = useState<VideoData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [searchPage, setSearchPage] = useState(0);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const observerTarget = useRef<HTMLDivElement>(null);
|
||||||
|
const loadingMoreRef = useRef(false);
|
||||||
|
const hasMoreRef = useRef(true);
|
||||||
|
const searchPageRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => { loadingMoreRef.current = loadingMore; }, [loadingMore]);
|
||||||
|
useEffect(() => { hasMoreRef.current = hasMore; }, [hasMore]);
|
||||||
|
useEffect(() => { searchPageRef.current = searchPage; }, [searchPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query) {
|
||||||
|
performSearch(query);
|
||||||
|
}
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
const performSearch = async (q: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setSearchPage(0);
|
||||||
|
searchPageRef.current = 0;
|
||||||
|
setHasMore(true);
|
||||||
|
hasMoreRef.current = true;
|
||||||
|
|
||||||
|
const results = await searchVideosClient(q, 50);
|
||||||
|
const uniqueResults = results.filter((video, index, self) =>
|
||||||
|
index === self.findIndex(v => v.id === video.id)
|
||||||
|
);
|
||||||
|
setVideos(uniqueResults);
|
||||||
|
setHasMore(uniqueResults.length >= 40);
|
||||||
|
hasMoreRef.current = uniqueResults.length >= 40;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (loadingMoreRef.current || !hasMoreRef.current || !query) return;
|
||||||
|
|
||||||
|
setLoadingMore(true);
|
||||||
|
const nextPage = searchPageRef.current + 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use different search variations to get more results
|
||||||
|
const variations = [
|
||||||
|
`${query}`,
|
||||||
|
`${query} official`,
|
||||||
|
`${query} video`,
|
||||||
|
`${query} review`,
|
||||||
|
`${query} tutorial`,
|
||||||
|
`${query} 2026`,
|
||||||
|
`${query} new`,
|
||||||
|
`${query} best`,
|
||||||
|
];
|
||||||
|
const searchVariation = variations[nextPage % variations.length];
|
||||||
|
|
||||||
|
const results = await searchVideosClient(searchVariation, 50);
|
||||||
|
|
||||||
|
setVideos(prev => {
|
||||||
|
const existingIds = new Set(prev.map(v => v.id));
|
||||||
|
const uniqueNewVideos = results.filter(v => !existingIds.has(v.id));
|
||||||
|
|
||||||
|
// Stop loading if we get very few new videos
|
||||||
|
if (uniqueNewVideos.length < 3) {
|
||||||
|
setHasMore(false);
|
||||||
|
hasMoreRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...prev, ...uniqueNewVideos];
|
||||||
|
});
|
||||||
|
|
||||||
|
setSearchPage(nextPage);
|
||||||
|
searchPageRef.current = nextPage;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load more:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
// Infinite scroll observer
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && !loadingMoreRef.current && hasMoreRef.current) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '500px', threshold: 0.1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (observerTarget.current) {
|
||||||
|
observer.observe(observerTarget.current);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [loadMore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'var(--yt-background)',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
minHeight: '100vh',
|
||||||
|
padding: '0 24px 24px',
|
||||||
|
}}>
|
||||||
|
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
|
||||||
|
{/* Results Header */}
|
||||||
|
{query && !loading && (
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<span style={{ fontSize: '14px', color: 'var(--yt-text-secondary)' }}>
|
||||||
|
{videos.length > 0 ? `${videos.length} results for "${query}"` : `No results for "${query}"`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results Grid */}
|
||||||
|
{loading ? (
|
||||||
|
<SearchSkeleton />
|
||||||
|
) : videos.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '80px 24px',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
}}>
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" style={{ marginBottom: '16px', opacity: 0.5 }}>
|
||||||
|
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 style={{ fontSize: '18px', marginBottom: '8px', color: 'var(--yt-text-primary)' }}>
|
||||||
|
No results found
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '14px' }}>Try different keywords or check your spelling</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
|
gap: '16px',
|
||||||
|
}}>
|
||||||
|
{videos.map((video) => (
|
||||||
|
<VideoCard key={video.id} video={video} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Infinite scroll sentinel */}
|
||||||
|
<div ref={observerTarget} style={{ padding: '24px 0', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
{loadingMore && <LoadingSpinner />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End of results */}
|
||||||
|
{!hasMore && videos.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '24px 0',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
End of results
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/app/search/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import ClientSearchPage from './ClientSearchPage';
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
backgroundColor: '#0f0f0f',
|
||||||
|
color: '#fff',
|
||||||
|
}}>
|
||||||
|
Searching...
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<ClientSearchPage />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
320
frontend/app/services/youtube.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
// Client-side YouTube API Service
|
||||||
|
// Uses YouTube Data API v3 for metadata and search
|
||||||
|
|
||||||
|
const YOUTUBE_API_KEY = process.env.NEXT_PUBLIC_YOUTUBE_API_KEY || '';
|
||||||
|
const YOUTUBE_API_BASE = 'https://www.googleapis.com/youtube/v3';
|
||||||
|
|
||||||
|
export interface YouTubeVideo {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
thumbnail: string;
|
||||||
|
channelTitle: string;
|
||||||
|
channelId: string;
|
||||||
|
publishedAt: string;
|
||||||
|
viewCount: string;
|
||||||
|
likeCount: string;
|
||||||
|
commentCount: string;
|
||||||
|
duration: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YouTubeSearchResult {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
thumbnail: string;
|
||||||
|
channelTitle: string;
|
||||||
|
channelId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YouTubeChannel {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
thumbnail: string;
|
||||||
|
subscriberCount: string;
|
||||||
|
videoCount: string;
|
||||||
|
customUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YouTubeComment {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
author: string;
|
||||||
|
authorProfileImage: string;
|
||||||
|
publishedAt: string;
|
||||||
|
likeCount: number;
|
||||||
|
isReply: boolean;
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to format ISO 8601 duration to human readable
|
||||||
|
function formatDuration(isoDuration: string): string {
|
||||||
|
const match = isoDuration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
|
||||||
|
if (!match) return isoDuration;
|
||||||
|
|
||||||
|
const hours = parseInt(match[1] || '0', 10);
|
||||||
|
const minutes = parseInt(match[2] || '0', 10);
|
||||||
|
const seconds = parseInt(match[3] || '0', 10);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format numbers with K, M suffixes
|
||||||
|
function formatNumber(num: string | number): string {
|
||||||
|
const n = typeof num === 'string' ? parseInt(num, 10) : num;
|
||||||
|
if (isNaN(n)) return '0';
|
||||||
|
|
||||||
|
if (n >= 1000000) {
|
||||||
|
return (n / 1000000).toFixed(1) + 'M';
|
||||||
|
}
|
||||||
|
if (n >= 1000) {
|
||||||
|
return (n / 1000).toFixed(0) + 'K';
|
||||||
|
}
|
||||||
|
return n.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YouTubeAPI {
|
||||||
|
private apiKey: string;
|
||||||
|
|
||||||
|
constructor(apiKey?: string) {
|
||||||
|
this.apiKey = apiKey || YOUTUBE_API_KEY;
|
||||||
|
if (!this.apiKey) {
|
||||||
|
console.warn('YouTube API key not set. Set NEXT_PUBLIC_YOUTUBE_API_KEY in .env.local');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetch(endpoint: string, params: Record<string, string> = {}): Promise<any> {
|
||||||
|
const url = new URL(`${YOUTUBE_API_BASE}${endpoint}`);
|
||||||
|
url.searchParams.set('key', this.apiKey);
|
||||||
|
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
// Handle specific quota exceeded error
|
||||||
|
if (response.status === 403 && errorData?.error?.reason === 'quotaExceeded') {
|
||||||
|
throw new Error('YouTube API quota exceeded. Please try again later or request a quota increase.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle API key expired error
|
||||||
|
if (response.status === 400 && errorData?.error?.reason === 'API_KEY_INVALID') {
|
||||||
|
throw new Error('YouTube API key is invalid or expired. Please check your API key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`YouTube API error: ${response.status} ${response.statusText} ${JSON.stringify(errorData)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for videos
|
||||||
|
async searchVideos(query: string, maxResults: number = 20): Promise<YouTubeSearchResult[]> {
|
||||||
|
const data = await this.fetch('/search', {
|
||||||
|
part: 'snippet',
|
||||||
|
q: query,
|
||||||
|
type: 'video',
|
||||||
|
maxResults: maxResults.toString(),
|
||||||
|
order: 'relevance',
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.items?.map((item: any) => ({
|
||||||
|
id: item.id.videoId,
|
||||||
|
title: item.snippet.title,
|
||||||
|
thumbnail: `https://i.ytimg.com/vi/${item.id.videoId}/mqdefault.jpg`,
|
||||||
|
channelTitle: item.snippet.channelTitle,
|
||||||
|
channelId: item.snippet.channelId,
|
||||||
|
})) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get video details
|
||||||
|
async getVideoDetails(videoId: string): Promise<YouTubeVideo | null> {
|
||||||
|
const data = await this.fetch('/videos', {
|
||||||
|
part: 'snippet,statistics,contentDetails',
|
||||||
|
id: videoId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const video = data.items?.[0];
|
||||||
|
if (!video) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: video.id,
|
||||||
|
title: video.snippet.title,
|
||||||
|
description: video.snippet.description,
|
||||||
|
thumbnail: `https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`,
|
||||||
|
channelTitle: video.snippet.channelTitle,
|
||||||
|
channelId: video.snippet.channelId,
|
||||||
|
publishedAt: video.snippet.publishedAt,
|
||||||
|
viewCount: formatNumber(video.statistics?.viewCount || '0'),
|
||||||
|
likeCount: formatNumber(video.statistics?.likeCount || '0'),
|
||||||
|
commentCount: formatNumber(video.statistics?.commentCount || '0'),
|
||||||
|
duration: formatDuration(video.contentDetails?.duration || ''),
|
||||||
|
tags: video.snippet.tags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get multiple video details
|
||||||
|
async getVideosDetails(videoIds: string[]): Promise<YouTubeVideo[]> {
|
||||||
|
if (videoIds.length === 0) return [];
|
||||||
|
|
||||||
|
// API allows max 50 IDs per request
|
||||||
|
const batchSize = 50;
|
||||||
|
const results: YouTubeVideo[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < videoIds.length; i += batchSize) {
|
||||||
|
const batch = videoIds.slice(i, i + batchSize).join(',');
|
||||||
|
const data = await this.fetch('/videos', {
|
||||||
|
part: 'snippet,statistics,contentDetails',
|
||||||
|
id: batch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const videos = data.items?.map((video: any) => ({
|
||||||
|
id: video.id,
|
||||||
|
title: video.snippet.title,
|
||||||
|
description: video.snippet.description,
|
||||||
|
thumbnail: `https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`,
|
||||||
|
channelTitle: video.snippet.channelTitle,
|
||||||
|
channelId: video.snippet.channelId,
|
||||||
|
publishedAt: video.snippet.publishedAt,
|
||||||
|
viewCount: formatNumber(video.statistics?.viewCount || '0'),
|
||||||
|
likeCount: formatNumber(video.statistics?.likeCount || '0'),
|
||||||
|
commentCount: formatNumber(video.statistics?.commentCount || '0'),
|
||||||
|
duration: formatDuration(video.contentDetails?.duration || ''),
|
||||||
|
tags: video.snippet.tags,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
results.push(...videos);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get channel details
|
||||||
|
async getChannelDetails(channelId: string): Promise<YouTubeChannel | null> {
|
||||||
|
const data = await this.fetch('/channels', {
|
||||||
|
part: 'snippet,statistics',
|
||||||
|
id: channelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const channel = data.items?.[0];
|
||||||
|
if (!channel) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: channel.id,
|
||||||
|
title: channel.snippet.title,
|
||||||
|
description: channel.snippet.description,
|
||||||
|
thumbnail: channel.snippet.thumbnails?.high?.url || channel.snippet.thumbnails?.default?.url,
|
||||||
|
subscriberCount: formatNumber(channel.statistics?.subscriberCount || '0'),
|
||||||
|
videoCount: formatNumber(channel.statistics?.videoCount || '0'),
|
||||||
|
customUrl: channel.snippet.customUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get channel videos
|
||||||
|
async getChannelVideos(channelId: string, maxResults: number = 30): Promise<YouTubeSearchResult[]> {
|
||||||
|
// First get uploads playlist ID
|
||||||
|
const channelData = await this.fetch('/channels', {
|
||||||
|
part: 'contentDetails',
|
||||||
|
id: channelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadsPlaylistId = channelData.items?.[0]?.contentDetails?.relatedPlaylists?.uploads;
|
||||||
|
if (!uploadsPlaylistId) return [];
|
||||||
|
|
||||||
|
// Then get videos from that playlist
|
||||||
|
const playlistData = await this.fetch('/playlistItems', {
|
||||||
|
part: 'snippet',
|
||||||
|
playlistId: uploadsPlaylistId,
|
||||||
|
maxResults: maxResults.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return playlistData.items?.map((item: any) => ({
|
||||||
|
id: item.snippet.resourceId.videoId,
|
||||||
|
title: item.snippet.title,
|
||||||
|
thumbnail: item.snippet.thumbnails?.high?.url || item.snippet.thumbnails?.default?.url,
|
||||||
|
channelTitle: item.snippet.channelTitle,
|
||||||
|
channelId: item.snippet.channelId,
|
||||||
|
})) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get comments for a video
|
||||||
|
async getComments(videoId: string, maxResults: number = 20): Promise<YouTubeComment[]> {
|
||||||
|
try {
|
||||||
|
const data = await this.fetch('/commentThreads', {
|
||||||
|
part: 'snippet,replies',
|
||||||
|
videoId: videoId,
|
||||||
|
maxResults: maxResults.toString(),
|
||||||
|
order: 'relevance',
|
||||||
|
textFormat: 'plainText',
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.items?.map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
text: item.snippet.topLevelComment.snippet.textDisplay,
|
||||||
|
author: item.snippet.topLevelComment.snippet.authorDisplayName,
|
||||||
|
authorProfileImage: item.snippet.topLevelComment.snippet.authorProfileImageUrl,
|
||||||
|
publishedAt: item.snippet.topLevelComment.snippet.publishedAt,
|
||||||
|
likeCount: item.snippet.topLevelComment.snippet.likeCount || 0,
|
||||||
|
isReply: false,
|
||||||
|
})) || [];
|
||||||
|
} catch (error) {
|
||||||
|
// Comments might be disabled
|
||||||
|
console.warn('Failed to fetch comments:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get trending videos
|
||||||
|
async getTrendingVideos(regionCode: string = 'US', maxResults: number = 20): Promise<YouTubeVideo[]> {
|
||||||
|
const data = await this.fetch('/videos', {
|
||||||
|
part: 'snippet,statistics,contentDetails',
|
||||||
|
chart: 'mostPopular',
|
||||||
|
regionCode: regionCode,
|
||||||
|
maxResults: maxResults.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.items?.map((video: any) => ({
|
||||||
|
id: video.id,
|
||||||
|
title: video.snippet.title,
|
||||||
|
description: video.snippet.description,
|
||||||
|
thumbnail: `https://i.ytimg.com/vi/${video.id}/hqdefault.jpg`,
|
||||||
|
channelTitle: video.snippet.channelTitle,
|
||||||
|
channelId: video.snippet.channelId,
|
||||||
|
publishedAt: video.snippet.publishedAt,
|
||||||
|
viewCount: formatNumber(video.statistics?.viewCount || '0'),
|
||||||
|
likeCount: formatNumber(video.statistics?.likeCount || '0'),
|
||||||
|
commentCount: formatNumber(video.statistics?.commentCount || '0'),
|
||||||
|
duration: formatDuration(video.contentDetails?.duration || ''),
|
||||||
|
tags: video.snippet.tags,
|
||||||
|
})) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get related videos (using search with related query)
|
||||||
|
async getRelatedVideos(videoId: string, maxResults: number = 10): Promise<YouTubeSearchResult[]> {
|
||||||
|
// First get video details to get title for related search
|
||||||
|
const videoDetails = await this.getVideoDetails(videoId);
|
||||||
|
if (!videoDetails) return [];
|
||||||
|
|
||||||
|
// Use related query based on video title and channel
|
||||||
|
const query = `${videoDetails.channelTitle} ${videoDetails.title.split(' ').slice(0, 5).join(' ')}`;
|
||||||
|
|
||||||
|
return this.searchVideos(query, maxResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get suggestions for search
|
||||||
|
async getSuggestions(query: string): Promise<string[]> {
|
||||||
|
// YouTube doesn't have a suggestions API, so we'll return empty array
|
||||||
|
// Could implement with autocomplete API if available
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const youtubeAPI = new YouTubeAPI();
|
||||||
500
frontend/app/shorts/page.tsx
Normal file
|
|
@ -0,0 +1,500 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { IoHeart, IoHeartOutline, IoChatbubbleOutline, IoShareOutline, IoEllipsisHorizontal, IoMusicalNote, IoRefresh, IoPlay, IoVolumeMute, IoVolumeHigh } from 'react-icons/io5';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
|
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}>
|
||||||
|
<LoadingSpinner color="white" />
|
||||||
|
</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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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' }}>
|
||||||
|
<LoadingSpinner color="white" />
|
||||||
|
</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>
|
||||||
|
{shorts.map((v, i) => <ShortCard key={v.id} video={v} isActive={i === activeIndex} />)}
|
||||||
|
{loadingMore && (
|
||||||
|
<div style={{ ...pageStyle, height: '100vh' }}>
|
||||||
|
<LoadingSpinner color="white" />
|
||||||
|
</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 hideScrollbarCss = 'div::-webkit-scrollbar { display: none; }';
|
||||||
178
frontend/app/storage.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
// Local storage keys
|
||||||
|
const HISTORY_KEY = 'kvtube_history';
|
||||||
|
const SUBSCRIPTIONS_KEY = 'kvtube_subscriptions';
|
||||||
|
const SAVED_VIDEOS_KEY = 'kvtube_saved_videos';
|
||||||
|
|
||||||
|
export interface HistoryItem {
|
||||||
|
videoId: string;
|
||||||
|
title: string;
|
||||||
|
thumbnail: string;
|
||||||
|
channelTitle: string;
|
||||||
|
watchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Subscription {
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
channelAvatar: string;
|
||||||
|
subscribedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedVideo {
|
||||||
|
videoId: string;
|
||||||
|
title: string;
|
||||||
|
thumbnail: string;
|
||||||
|
channelTitle: string;
|
||||||
|
savedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get items from localStorage
|
||||||
|
function getFromStorage<T>(key: string): T[] {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(key);
|
||||||
|
return data ? JSON.parse(data) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save items to localStorage
|
||||||
|
function saveToStorage<T>(key: string, items: T[]): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(items));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Storage error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== HISTORY ====================
|
||||||
|
|
||||||
|
export function getHistory(limit: number = 50): HistoryItem[] {
|
||||||
|
const history = getFromStorage<HistoryItem>(HISTORY_KEY);
|
||||||
|
// Sort by most recent first
|
||||||
|
return history.sort((a, b) => b.watchedAt - a.watchedAt).slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addToHistory(video: { videoId: string; title: string; thumbnail: string; channelTitle?: string }): void {
|
||||||
|
const history = getFromStorage<HistoryItem>(HISTORY_KEY);
|
||||||
|
|
||||||
|
// Remove duplicate if exists
|
||||||
|
const filtered = history.filter(h => h.videoId !== video.videoId);
|
||||||
|
|
||||||
|
// Add new entry at the beginning
|
||||||
|
const newItem: HistoryItem = {
|
||||||
|
videoId: video.videoId,
|
||||||
|
title: video.title,
|
||||||
|
thumbnail: video.thumbnail,
|
||||||
|
channelTitle: video.channelTitle || 'Unknown',
|
||||||
|
watchedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep only last 100 items
|
||||||
|
const updated = [newItem, ...filtered].slice(0, 100);
|
||||||
|
saveToStorage(HISTORY_KEY, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeFromHistory(videoId: string): void {
|
||||||
|
const history = getFromStorage<HistoryItem>(HISTORY_KEY);
|
||||||
|
const filtered = history.filter(h => h.videoId !== videoId);
|
||||||
|
saveToStorage(HISTORY_KEY, filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHistory(): void {
|
||||||
|
saveToStorage(HISTORY_KEY, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SUBSCRIPTIONS ====================
|
||||||
|
|
||||||
|
export function getSubscriptions(): Subscription[] {
|
||||||
|
return getFromStorage<Subscription>(SUBSCRIPTIONS_KEY)
|
||||||
|
.sort((a, b) => b.subscribedAt - a.subscribedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe(channel: { channelId: string; channelName: string; channelAvatar?: string }): void {
|
||||||
|
const subs = getFromStorage<Subscription>(SUBSCRIPTIONS_KEY);
|
||||||
|
|
||||||
|
// Check if already subscribed
|
||||||
|
if (subs.some(s => s.channelId === channel.channelId)) return;
|
||||||
|
|
||||||
|
const newSub: Subscription = {
|
||||||
|
channelId: channel.channelId,
|
||||||
|
channelName: channel.channelName,
|
||||||
|
channelAvatar: channel.channelAvatar || '',
|
||||||
|
subscribedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
saveToStorage(SUBSCRIPTIONS_KEY, [...subs, newSub]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unsubscribe(channelId: string): void {
|
||||||
|
const subs = getFromStorage<Subscription>(SUBSCRIPTIONS_KEY);
|
||||||
|
const filtered = subs.filter(s => s.channelId !== channelId);
|
||||||
|
saveToStorage(SUBSCRIPTIONS_KEY, filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSubscribed(channelId: string): boolean {
|
||||||
|
const subs = getFromStorage<Subscription>(SUBSCRIPTIONS_KEY);
|
||||||
|
return subs.some(s => s.channelId === channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleSubscription(channel: { channelId: string; channelName: string; channelAvatar?: string }): boolean {
|
||||||
|
if (isSubscribed(channel.channelId)) {
|
||||||
|
unsubscribe(channel.channelId);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
subscribe(channel);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SAVED VIDEOS ====================
|
||||||
|
|
||||||
|
export function getSavedVideos(limit: number = 50): SavedVideo[] {
|
||||||
|
const saved = getFromStorage<SavedVideo>(SAVED_VIDEOS_KEY);
|
||||||
|
return saved.sort((a, b) => b.savedAt - a.savedAt).slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveVideo(video: { videoId: string; title: string; thumbnail: string; channelTitle?: string }): void {
|
||||||
|
const saved = getFromStorage<SavedVideo>(SAVED_VIDEOS_KEY);
|
||||||
|
|
||||||
|
// Remove duplicate if exists
|
||||||
|
const filtered = saved.filter(v => v.videoId !== video.videoId);
|
||||||
|
|
||||||
|
const newVideo: SavedVideo = {
|
||||||
|
videoId: video.videoId,
|
||||||
|
title: video.title,
|
||||||
|
thumbnail: video.thumbnail,
|
||||||
|
channelTitle: video.channelTitle || 'Unknown',
|
||||||
|
savedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated = [newVideo, ...filtered];
|
||||||
|
saveToStorage(SAVED_VIDEOS_KEY, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unsaveVideo(videoId: string): void {
|
||||||
|
const saved = getFromStorage<SavedVideo>(SAVED_VIDEOS_KEY);
|
||||||
|
const filtered = saved.filter(v => v.videoId !== videoId);
|
||||||
|
saveToStorage(SAVED_VIDEOS_KEY, filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVideoSaved(videoId: string): boolean {
|
||||||
|
const saved = getFromStorage<SavedVideo>(SAVED_VIDEOS_KEY);
|
||||||
|
return saved.some(v => v.videoId === videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleSaveVideo(video: { videoId: string; title: string; thumbnail: string; channelTitle?: string }): boolean {
|
||||||
|
if (isVideoSaved(video.videoId)) {
|
||||||
|
unsaveVideo(video.videoId);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
saveVideo(video);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
frontend/app/utils.ts
Normal 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)];
|
||||||
|
}
|
||||||
996
frontend/app/watch/ClientWatchPage.tsx
Normal file
|
|
@ -0,0 +1,996 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import YouTubePlayer from './YouTubePlayer';
|
||||||
|
import { getVideoDetailsClient, getRelatedVideosClient, getCommentsClient, searchVideosClient } from '../clientActions';
|
||||||
|
import { VideoData } from '../constants';
|
||||||
|
import { isSubscribed, toggleSubscription, addToHistory, isVideoSaved, toggleSaveVideo } from '../storage';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
// Simple cache for API responses to reduce quota usage
|
||||||
|
const apiCache = new Map<string, { data: any; timestamp: number }>();
|
||||||
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
function getCachedData(key: string) {
|
||||||
|
const cached = apiCache.get(key);
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedData(key: string, data: any) {
|
||||||
|
apiCache.set(key, { data, timestamp: Date.now() });
|
||||||
|
// Clean up old cache entries
|
||||||
|
if (apiCache.size > 100) {
|
||||||
|
const oldestKey = apiCache.keys().next().value;
|
||||||
|
if (oldestKey) {
|
||||||
|
apiCache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video Info Section
|
||||||
|
function VideoInfo({ video }: { video: any }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [subscribed, setSubscribed] = useState(false);
|
||||||
|
const [isSaved, setIsSaved] = useState(false);
|
||||||
|
const [subscribing, setSubscribing] = useState(false);
|
||||||
|
|
||||||
|
// Check subscription and save status on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (video?.channelId) {
|
||||||
|
setSubscribed(isSubscribed(video.channelId));
|
||||||
|
}
|
||||||
|
if (video?.id) {
|
||||||
|
setIsSaved(isVideoSaved(video.id));
|
||||||
|
}
|
||||||
|
}, [video?.channelId, video?.id]);
|
||||||
|
|
||||||
|
const handleSubscribe = useCallback(() => {
|
||||||
|
if (!video?.channelId || subscribing) return;
|
||||||
|
|
||||||
|
setSubscribing(true);
|
||||||
|
try {
|
||||||
|
const nowSubscribed = toggleSubscription({
|
||||||
|
channelId: video.channelId,
|
||||||
|
channelName: video.channelTitle,
|
||||||
|
channelAvatar: '',
|
||||||
|
});
|
||||||
|
setSubscribed(nowSubscribed);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Subscribe error:', error);
|
||||||
|
} finally {
|
||||||
|
setSubscribing(false);
|
||||||
|
}
|
||||||
|
}, [video?.channelId, video?.channelTitle, subscribing]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
if (!video?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nowSaved = toggleSaveVideo({
|
||||||
|
videoId: video.id,
|
||||||
|
title: video.title,
|
||||||
|
thumbnail: video.thumbnail,
|
||||||
|
channelTitle: video.channelTitle,
|
||||||
|
});
|
||||||
|
setIsSaved(nowSaved);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save error:', error);
|
||||||
|
}
|
||||||
|
}, [video?.id, video?.title, video?.thumbnail, video?.channelTitle]);
|
||||||
|
|
||||||
|
if (!video) return null;
|
||||||
|
|
||||||
|
const description = video.description || '';
|
||||||
|
const hasDescription = description.length > 0;
|
||||||
|
const shouldTruncate = description.length > 300;
|
||||||
|
const displayDescription = expanded ? description : description.slice(0, 300) + (shouldTruncate ? '...' : '');
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr || dateStr === 'Invalid Date') return '';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
if (isNaN(date.getTime())) return '';
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format view count
|
||||||
|
const formatViews = (views: string) => {
|
||||||
|
if (!views || views === '0') return 'No views';
|
||||||
|
const num = parseInt(views.replace(/[^0-9]/g, '') || '0');
|
||||||
|
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M views';
|
||||||
|
if (num >= 1000) return (num / 1000).toFixed(0) + 'K views';
|
||||||
|
return num.toLocaleString() + ' views';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '12px 0' }}>
|
||||||
|
{/* Title */}
|
||||||
|
<h1 style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: '8px',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
lineHeight: '1.3',
|
||||||
|
}}>
|
||||||
|
{video.title || 'Untitled Video'}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Channel Info & Actions Row */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '12px',
|
||||||
|
paddingBottom: '12px',
|
||||||
|
borderBottom: '1px solid var(--yt-border)',
|
||||||
|
}}>
|
||||||
|
{/* Channel - only show name, no avatar */}
|
||||||
|
<div style={{
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
fontWeight: '500',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
{video.channelTitle || 'Unknown Channel'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons - Subscribe, Share, Save */}
|
||||||
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
{/* Subscribe Button with Toggle State */}
|
||||||
|
<button
|
||||||
|
onClick={handleSubscribe}
|
||||||
|
disabled={subscribing}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: subscribed ? 'var(--yt-hover)' : '#cc0000',
|
||||||
|
color: subscribed ? 'var(--yt-text-primary)' : '#fff',
|
||||||
|
border: subscribed ? '1px solid var(--yt-border)' : 'none',
|
||||||
|
borderRadius: '18px',
|
||||||
|
cursor: subscribing ? 'wait' : 'pointer',
|
||||||
|
fontWeight: '500',
|
||||||
|
fontSize: '13px',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
opacity: subscribing ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subscribing ? (
|
||||||
|
'...'
|
||||||
|
) : subscribed ? (
|
||||||
|
<>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
||||||
|
</svg>
|
||||||
|
Subscribed
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Subscribe'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Share Button */}
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: video.title || 'Check out this video',
|
||||||
|
url: window.location.href,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (shareErr: any) {
|
||||||
|
if (shareErr.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(window.location.href);
|
||||||
|
alert('Link copied to clipboard!');
|
||||||
|
} catch (err) {
|
||||||
|
alert('Could not share or copy link');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: 'var(--yt-hover)',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '18px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '500',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9.41 15.95L12 13.36l2.59 2.59L16 14.54l-2.59-2.59L16 9.36l-1.41-1.41L12 10.54 9.41 7.95 8 9.36l2.59 2.59L8 14.54z"/>
|
||||||
|
</svg>
|
||||||
|
Share
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Save Button with Toggle State */}
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: isSaved ? 'var(--yt-blue)' : 'var(--yt-hover)',
|
||||||
|
color: isSaved ? '#fff' : 'var(--yt-text-primary)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '18px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '500',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSaved ? (
|
||||||
|
<>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
Saved
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M14 10H2v2h12v-2zm0-4H2v2h12V6zm4 8v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zM2 16h8v-2H2v2z"/>
|
||||||
|
</svg>
|
||||||
|
Save
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description Box */}
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'var(--yt-hover)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '12px',
|
||||||
|
marginTop: '12px',
|
||||||
|
}}>
|
||||||
|
{/* Views and Date */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: 'var(--yt-text-primary)'
|
||||||
|
}}>
|
||||||
|
<span>{formatViews(video.viewCount)}</span>
|
||||||
|
{video.publishedAt && formatDate(video.publishedAt) && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatDate(video.publishedAt)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{hasDescription ? (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
}}>
|
||||||
|
{displayDescription}
|
||||||
|
{shouldTruncate && (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--yt-blue)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: '500',
|
||||||
|
padding: 0,
|
||||||
|
marginLeft: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? ' Show less' : ' ...more'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{video.tags && video.tags.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '12px' }}>
|
||||||
|
{video.tags.slice(0, 10).map((tag: string, i: number) => (
|
||||||
|
<span key={i} style={{
|
||||||
|
backgroundColor: 'var(--yt-background)',
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: '14px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--yt-blue)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mix Playlist Component
|
||||||
|
function MixPlaylist({ videos, currentIndex, onVideoSelect, title }: {
|
||||||
|
videos: VideoData[];
|
||||||
|
currentIndex: number;
|
||||||
|
onVideoSelect: (index: number) => void;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'var(--yt-hover)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: '1px solid var(--yt-border)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: '14px', fontWeight: '600', margin: 0, color: 'var(--yt-text-primary)' }}>
|
||||||
|
{title || 'Mix Playlist'}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '11px', color: 'var(--yt-text-secondary)', margin: '2px 0 0 0' }}>
|
||||||
|
{videos.length} videos • Auto-play is on
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video List */}
|
||||||
|
<div style={{ maxHeight: '360px', overflowY: 'auto' }}>
|
||||||
|
{videos.map((video, index) => (
|
||||||
|
<div
|
||||||
|
key={video.id}
|
||||||
|
onClick={() => onVideoSelect(index)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: index === currentIndex ? 'var(--yt-active)' : 'transparent',
|
||||||
|
borderLeft: index === currentIndex ? '3px solid var(--yt-blue)' : '3px solid transparent',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (index !== currentIndex) {
|
||||||
|
(e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.05)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (index !== currentIndex) {
|
||||||
|
(e.currentTarget as HTMLElement).style.backgroundColor = 'transparent';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Thumbnail with index */}
|
||||||
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
|
<img
|
||||||
|
src={video.thumbnail || `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`}
|
||||||
|
alt={video.title}
|
||||||
|
style={{
|
||||||
|
width: '100px',
|
||||||
|
height: '56px',
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: '6px',
|
||||||
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = `https://i.ytimg.com/vi/${video.id}/default.jpg`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '3px',
|
||||||
|
left: '3px',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '1px 4px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
fontSize: '10px',
|
||||||
|
}}>
|
||||||
|
{index + 1}/{videos.length}
|
||||||
|
</div>
|
||||||
|
{index === currentIndex && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
padding: '6px',
|
||||||
|
}}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="white">
|
||||||
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: index === currentIndex ? '600' : '500',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
lineHeight: '1.2',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{video.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--yt-text-secondary)', marginTop: '2px' }}>
|
||||||
|
{video.uploader}
|
||||||
|
</div>
|
||||||
|
{video.duration && (
|
||||||
|
<div style={{ fontSize: '10px', color: 'var(--yt-text-secondary)', marginTop: '1px' }}>
|
||||||
|
{video.duration}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comment Section
|
||||||
|
function CommentSection({ videoId }: { videoId: string }) {
|
||||||
|
const [comments, setComments] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadComments = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getCommentsClient(videoId, 50);
|
||||||
|
setComments(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load comments:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadComments();
|
||||||
|
}, [videoId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px 0', color: 'var(--yt-text-secondary)' }}>
|
||||||
|
Loading comments...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayedComments = showAll ? comments : comments.slice(0, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px 0', borderTop: '1px solid var(--yt-border)' }}>
|
||||||
|
<h2 style={{ fontSize: '16px', fontWeight: '600', marginBottom: '16px', color: 'var(--yt-text-primary)' }}>
|
||||||
|
{comments.length} Comments
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Sort dropdown */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '24px' }}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--yt-text-secondary)">
|
||||||
|
<path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/>
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontSize: '14px', color: 'var(--yt-text-secondary)' }}>Sort by</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments List */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||||
|
{displayedComments.map((comment) => (
|
||||||
|
<div key={comment.id} style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
{comment.author_thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={comment.author_thumbnail}
|
||||||
|
alt={comment.author}
|
||||||
|
style={{ width: '40px', height: '40px', borderRadius: '50%', backgroundColor: 'var(--yt-hover)', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ fontSize: '13px', fontWeight: '500', color: 'var(--yt-text-primary)' }}>
|
||||||
|
{comment.author}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--yt-text-secondary)' }}>
|
||||||
|
{comment.timestamp}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: 'var(--yt-text-primary)', marginTop: '4px', lineHeight: '1.5' }}>
|
||||||
|
{comment.text}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginTop: '8px' }}>
|
||||||
|
<button style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/>
|
||||||
|
</svg>
|
||||||
|
{comment.likes}
|
||||||
|
</button>
|
||||||
|
<button style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--yt-blue)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}>
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{comments.length > 5 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAll(!showAll)}
|
||||||
|
style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--yt-blue)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
padding: '8px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showAll ? 'Show less' : `Show all ${comments.length} comments`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientWatchPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const videoId = searchParams.get('v');
|
||||||
|
const [videoInfo, setVideoInfo] = useState<any>(null);
|
||||||
|
const [relatedVideos, setRelatedVideos] = useState<VideoData[]>([]);
|
||||||
|
const [mixPlaylist, setMixPlaylist] = useState<VideoData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(-1);
|
||||||
|
const [activeTab, setActiveTab] = useState<'upnext' | 'mix'>('upnext');
|
||||||
|
const [apiError, setApiError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Scroll to top when video changes or page loads
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||||
|
}, [videoId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!videoId) return;
|
||||||
|
|
||||||
|
const loadVideoData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setApiError(null);
|
||||||
|
|
||||||
|
// Check cache for video details
|
||||||
|
let video = getCachedData(`video_${videoId}`);
|
||||||
|
if (!video) {
|
||||||
|
video = await getVideoDetailsClient(videoId);
|
||||||
|
if (video) setCachedData(`video_${videoId}`, video);
|
||||||
|
}
|
||||||
|
setVideoInfo(video);
|
||||||
|
|
||||||
|
// Add to watch history (localStorage)
|
||||||
|
if (video) {
|
||||||
|
addToHistory({
|
||||||
|
videoId: videoId,
|
||||||
|
title: video.title,
|
||||||
|
thumbnail: video.thumbnail,
|
||||||
|
channelTitle: video.channelTitle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get related videos - use channel name and video title for better results
|
||||||
|
// Even if video is null, we can still try to get related videos
|
||||||
|
const searchTerms = video?.title?.split(' ').filter((w: string) => w.length > 3).slice(0, 5).join(' ') || 'music';
|
||||||
|
const channelName = video?.channelTitle || '';
|
||||||
|
|
||||||
|
// Check cache for related videos
|
||||||
|
const cacheKey = `related_${videoId}_${searchTerms}`;
|
||||||
|
let relatedResults = getCachedData(cacheKey);
|
||||||
|
let mixResults = getCachedData(`mix_${videoId}_${searchTerms}`);
|
||||||
|
|
||||||
|
if (!relatedResults || !mixResults) {
|
||||||
|
// Optimized: Use just 2 search requests instead of 5 to save API quota
|
||||||
|
[relatedResults, mixResults] = await Promise.all([
|
||||||
|
searchVideosClient(`${channelName} ${searchTerms}`, 20),
|
||||||
|
searchVideosClient(`${searchTerms} mix compilation`, 20),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (relatedResults && relatedResults.length > 0) setCachedData(cacheKey, relatedResults);
|
||||||
|
if (mixResults && mixResults.length > 0) setCachedData(`mix_${videoId}_${searchTerms}`, mixResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate and filter related videos - ensure arrays
|
||||||
|
const uniqueRelated = Array.isArray(relatedResults) ? relatedResults.filter((v, index, self) =>
|
||||||
|
index === self.findIndex(item => item.id === v.id) && v.id !== videoId
|
||||||
|
) : [];
|
||||||
|
|
||||||
|
setCurrentIndex(0);
|
||||||
|
setRelatedVideos(uniqueRelated);
|
||||||
|
|
||||||
|
// Use remaining videos for mix playlist - ensure array
|
||||||
|
const uniqueMix = Array.isArray(mixResults) ? mixResults.filter((v, index, self) =>
|
||||||
|
index === self.findIndex(item => item.id === v.id) &&
|
||||||
|
v.id !== videoId &&
|
||||||
|
!uniqueRelated.some(r => r.id === v.id)
|
||||||
|
) : [];
|
||||||
|
|
||||||
|
setMixPlaylist(uniqueMix.slice(0, 20));
|
||||||
|
|
||||||
|
// Set error message if video details failed but we have related videos
|
||||||
|
if (!video) {
|
||||||
|
setApiError('Video info unavailable, but you can still browse related videos.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load video data:', error);
|
||||||
|
// Fallback with fewer requests
|
||||||
|
try {
|
||||||
|
const fallbackResults = await searchVideosClient('music popular', 20);
|
||||||
|
setRelatedVideos(Array.isArray(fallbackResults) ? fallbackResults.slice(0, 10) : []);
|
||||||
|
setMixPlaylist(Array.isArray(fallbackResults) ? fallbackResults.slice(10, 20) : []);
|
||||||
|
setApiError('Unable to load video details. Showing suggested videos instead.');
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Fallback also failed:', e);
|
||||||
|
// Set empty arrays to show user-friendly message
|
||||||
|
setRelatedVideos([]);
|
||||||
|
setMixPlaylist([]);
|
||||||
|
|
||||||
|
// Set user-friendly error message
|
||||||
|
if (e?.message?.includes('quota exceeded')) {
|
||||||
|
setApiError('YouTube API quota exceeded. Please try again later.');
|
||||||
|
} else if (e?.message?.includes('API key')) {
|
||||||
|
setApiError('API key issue. Please check configuration.');
|
||||||
|
} else {
|
||||||
|
setApiError('Unable to load related videos. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadVideoData();
|
||||||
|
}, [videoId]);
|
||||||
|
|
||||||
|
const handleVideoSelect = (index: number) => {
|
||||||
|
const video = activeTab === 'upnext' ? relatedVideos[index] : mixPlaylist[index];
|
||||||
|
if (video) {
|
||||||
|
router.push(`/watch?v=${video.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
const prevVideo = relatedVideos[currentIndex - 1];
|
||||||
|
router.push(`/watch?v=${prevVideo.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
const playlist = activeTab === 'mix' ? mixPlaylist : relatedVideos;
|
||||||
|
if (currentIndex < playlist.length - 1) {
|
||||||
|
const nextVideo = playlist[currentIndex + 1];
|
||||||
|
router.push(`/watch?v=${nextVideo.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoEnd = () => {
|
||||||
|
const playlist = activeTab === 'mix' ? mixPlaylist : relatedVideos;
|
||||||
|
if (currentIndex < playlist.length - 1) {
|
||||||
|
handleNext();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!videoId) {
|
||||||
|
return <div style={{ padding: '2rem', color: 'var(--yt-text-primary)' }}>No video ID provided</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSpinner fullScreen size="large" text="Loading video..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPlaylist = activeTab === 'mix' ? mixPlaylist : relatedVideos;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'var(--yt-background)',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
minHeight: '100vh',
|
||||||
|
}}>
|
||||||
|
<div className="watch-page-container" style={{
|
||||||
|
maxWidth: '1800px',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: '24px',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 400px',
|
||||||
|
gap: '24px',
|
||||||
|
}}>
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="watch-main">
|
||||||
|
{/* Video Player */}
|
||||||
|
<div style={{ position: 'relative', width: '100%' }}>
|
||||||
|
<YouTubePlayer
|
||||||
|
videoId={videoId}
|
||||||
|
title={videoInfo?.title}
|
||||||
|
autoplay={true}
|
||||||
|
onVideoEnd={handleVideoEnd}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Player Controls */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '8px 0',
|
||||||
|
gap: '8px',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={handlePrevious}
|
||||||
|
disabled={currentIndex <= 0}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: currentIndex > 0 ? 'var(--yt-hover)' : 'transparent',
|
||||||
|
color: currentIndex > 0 ? 'var(--yt-text-primary)' : 'var(--yt-text-secondary)',
|
||||||
|
border: '1px solid var(--yt-border)',
|
||||||
|
borderRadius: '18px',
|
||||||
|
cursor: currentIndex > 0 ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '500',
|
||||||
|
opacity: currentIndex > 0 ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||||
|
</svg>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={currentIndex >= currentPlaylist.length - 1}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: currentIndex < currentPlaylist.length - 1 ? 'var(--yt-blue)' : 'var(--yt-hover)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '18px',
|
||||||
|
cursor: currentIndex < currentPlaylist.length - 1 ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Info */}
|
||||||
|
<VideoInfo video={videoInfo} />
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
<CommentSection videoId={videoId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="watch-sidebar" style={{
|
||||||
|
position: 'sticky',
|
||||||
|
top: '70px',
|
||||||
|
height: 'fit-content',
|
||||||
|
maxHeight: 'calc(100vh - 80px)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '12px',
|
||||||
|
}}>
|
||||||
|
{/* Mix Playlist - Always on top */}
|
||||||
|
<MixPlaylist
|
||||||
|
videos={mixPlaylist}
|
||||||
|
currentIndex={currentIndex}
|
||||||
|
onVideoSelect={handleVideoSelect}
|
||||||
|
title={videoInfo?.title ? `Mix - ${videoInfo.title.split(' ').slice(0, 3).join(' ')}` : 'Mix Playlist'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* API Error Message */}
|
||||||
|
{apiError && (
|
||||||
|
<div style={{
|
||||||
|
padding: '10px',
|
||||||
|
backgroundColor: 'rgba(255, 0, 0, 0.1)',
|
||||||
|
border: '1px solid rgba(255, 0, 0, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'var(--yt-text-secondary)',
|
||||||
|
fontSize: '12px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
{apiError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Up Next Section */}
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'var(--yt-hover)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: '1px solid var(--yt-border)',
|
||||||
|
}}>
|
||||||
|
<h3 style={{ fontSize: '14px', fontWeight: '600', margin: 0, color: 'var(--yt-text-primary)' }}>
|
||||||
|
Up Next
|
||||||
|
</h3>
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--yt-text-secondary)' }}>
|
||||||
|
{relatedVideos.length} videos
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||||
|
{relatedVideos.slice(0, 8).map((video, index) => (
|
||||||
|
<div
|
||||||
|
key={video.id}
|
||||||
|
onClick={() => handleVideoSelect(index)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: index === currentIndex ? 'var(--yt-active)' : 'transparent',
|
||||||
|
borderLeft: index === currentIndex ? '3px solid var(--yt-blue)' : '3px solid transparent',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (index !== currentIndex) {
|
||||||
|
(e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.05)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (index !== currentIndex) {
|
||||||
|
(e.currentTarget as HTMLElement).style.backgroundColor = 'transparent';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
|
<img
|
||||||
|
src={video.thumbnail || `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`}
|
||||||
|
alt={video.title}
|
||||||
|
style={{ width: '120px', height: '68px', objectFit: 'cover', borderRadius: '6px' }}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).src = `https://i.ytimg.com/vi/${video.id}/mqdefault.jpg`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{video.duration && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '3px',
|
||||||
|
right: '3px',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '1px 4px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
fontSize: '10px',
|
||||||
|
}}>
|
||||||
|
{video.duration}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: 'var(--yt-text-primary)',
|
||||||
|
lineHeight: '1.2',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{video.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--yt-text-secondary)', marginTop: '2px' }}>
|
||||||
|
{video.uploader}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Responsive styles */}
|
||||||
|
<style jsx>{`
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.watch-page-container {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
.watch-sidebar {
|
||||||
|
position: relative !important;
|
||||||
|
top: 0 !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.watch-page-container {
|
||||||
|
padding: 12px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
frontend/app/watch/YouTubePlayer.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
YT: any;
|
||||||
|
onYouTubeIframeAPIReady: () => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YouTubePlayerProps {
|
||||||
|
videoId: string;
|
||||||
|
title?: string;
|
||||||
|
autoplay?: boolean;
|
||||||
|
onVideoEnd?: () => void;
|
||||||
|
onVideoReady?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayerSkeleton() {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: '16/9',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '12px',
|
||||||
|
}}>
|
||||||
|
<LoadingSpinner color="white" size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function YouTubePlayer({
|
||||||
|
videoId,
|
||||||
|
title,
|
||||||
|
autoplay = true,
|
||||||
|
onVideoEnd,
|
||||||
|
onVideoReady
|
||||||
|
}: YouTubePlayerProps) {
|
||||||
|
const playerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const playerInstanceRef = useRef<any>(null);
|
||||||
|
const [isApiReady, setIsApiReady] = useState(false);
|
||||||
|
const [isPlayerReady, setIsPlayerReady] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Load YouTube IFrame API
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.YT && window.YT.Player) {
|
||||||
|
setIsApiReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if script already exists
|
||||||
|
const existingScript = document.querySelector('script[src*="youtube.com/iframe_api"]');
|
||||||
|
if (existingScript) {
|
||||||
|
// Script exists, wait for it to load
|
||||||
|
const checkYT = setInterval(() => {
|
||||||
|
if (window.YT && window.YT.Player) {
|
||||||
|
setIsApiReady(true);
|
||||||
|
clearInterval(checkYT);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return () => clearInterval(checkYT);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = document.createElement('script');
|
||||||
|
tag.src = 'https://www.youtube.com/iframe_api';
|
||||||
|
tag.async = true;
|
||||||
|
document.head.appendChild(tag);
|
||||||
|
|
||||||
|
window.onYouTubeIframeAPIReady = () => {
|
||||||
|
console.log('YouTube IFrame API ready');
|
||||||
|
setIsApiReady(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Clean up
|
||||||
|
window.onYouTubeIframeAPIReady = () => {};
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize player when API is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isApiReady || !playerRef.current || !videoId) return;
|
||||||
|
|
||||||
|
// Destroy previous player instance if exists
|
||||||
|
if (playerInstanceRef.current) {
|
||||||
|
try {
|
||||||
|
playerInstanceRef.current.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error destroying player:', e);
|
||||||
|
}
|
||||||
|
playerInstanceRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const player = new window.YT.Player(playerRef.current, {
|
||||||
|
videoId: videoId,
|
||||||
|
playerVars: {
|
||||||
|
autoplay: autoplay ? 1 : 0,
|
||||||
|
controls: 1,
|
||||||
|
rel: 0,
|
||||||
|
modestbranding: 0,
|
||||||
|
playsinline: 1,
|
||||||
|
enablejsapi: 1,
|
||||||
|
origin: window.location.origin,
|
||||||
|
widget_referrer: window.location.href,
|
||||||
|
iv_load_policy: 3,
|
||||||
|
fs: 0,
|
||||||
|
disablekb: 0,
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
onReady: (event: any) => {
|
||||||
|
console.log('YouTube Player ready for video:', videoId);
|
||||||
|
setIsPlayerReady(true);
|
||||||
|
if (onVideoReady) onVideoReady();
|
||||||
|
|
||||||
|
// Auto-play if enabled
|
||||||
|
if (autoplay) {
|
||||||
|
try {
|
||||||
|
event.target.playVideo();
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Autoplay prevented:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStateChange: (event: any) => {
|
||||||
|
// Video ended
|
||||||
|
if (event.data === window.YT.PlayerState.ENDED) {
|
||||||
|
if (onVideoEnd) {
|
||||||
|
onVideoEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (event: any) => {
|
||||||
|
console.error('YouTube Player Error:', event.data);
|
||||||
|
setError(`Failed to load video (Error ${event.data})`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
playerInstanceRef.current = player;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create YouTube player:', error);
|
||||||
|
setError('Failed to initialize video player');
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (playerInstanceRef.current) {
|
||||||
|
try {
|
||||||
|
playerInstanceRef.current.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error cleaning up player:', e);
|
||||||
|
}
|
||||||
|
playerInstanceRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isApiReady, videoId, autoplay]);
|
||||||
|
|
||||||
|
// Handle video end
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlayerReady || !onVideoEnd) return;
|
||||||
|
|
||||||
|
const handleVideoEnd = () => {
|
||||||
|
onVideoEnd();
|
||||||
|
};
|
||||||
|
|
||||||
|
// The onStateChange event handler already handles this
|
||||||
|
}, [isPlayerReady, onVideoEnd]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: '16/9',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '12px',
|
||||||
|
color: '#fff',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '16px',
|
||||||
|
}}>
|
||||||
|
<div>{error}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => window.open(`https://www.youtube.com/watch?v=${videoId}`, '_blank')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#ff0000',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Watch on YouTube
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9', backgroundColor: '#000', borderRadius: '12px', overflow: 'hidden' }}>
|
||||||
|
{!isPlayerReady && !error && <PlayerSkeleton />}
|
||||||
|
<div
|
||||||
|
ref={playerRef}
|
||||||
|
id={`youtube-player-${videoId}`}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function to play a video
|
||||||
|
export function playVideo(videoId: string) {
|
||||||
|
if (window.YT && window.YT.Player) {
|
||||||
|
// Could create a new player instance or use existing one
|
||||||
|
console.log('Playing video:', videoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function to pause video
|
||||||
|
export function pauseVideo() {
|
||||||
|
// Would need to reference player instance
|
||||||
|
}
|
||||||
11
frontend/app/watch/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import ClientWatchPage from './ClientWatchPage';
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
|
|
||||||
|
export default function WatchPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingSpinner fullScreen text="Loading video..." />}>
|
||||||
|
<ClientWatchPage />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
frontend/eslint.config.mjs
Normal 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;
|
||||||
1938
frontend/frontend.log
Normal file
28
frontend/next.config.mjs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'i.ytimg.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'yt3.ggpht.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
async rewrites() {
|
||||||
|
// Backend runs on port 8080 inside the container
|
||||||
|
const apiBase = 'http://localhost:8080';
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/:path*',
|
||||||
|
destination: `${apiBase}/api/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6755
frontend/package-lock.json
generated
Normal file
29
frontend/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/roboto": "^5.2.9",
|
||||||
|
"hls.js": "^1.6.15",
|
||||||
|
"next": "16.1.6",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-icons": "^5.5.0"
|
||||||
|
},
|
||||||
|
"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
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
1
frontend/public/file.svg
Normal 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
Normal 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 |
BIN
frontend/public/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
frontend/public/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
frontend/public/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
frontend/public/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
frontend/public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |