Compare commits

...

No commits in common. "main" and "master" have entirely different histories.
main ... master

2700 changed files with 632115 additions and 19380 deletions

View file

@ -1,22 +0,0 @@
frontend/node_modules
frontend/.next
frontend/dist
frontend/build
backend/bin
backend/logs
node_modules
.next
.git
.DS_Store
videos
data
venv
.gemini
tmp*
*.exe
*.mac
*-mac
*-new
page.html
build-temp
.dockerignore.bak

View file

@ -1,30 +0,0 @@
# KV-Tube Environment Configuration
# Copy this file to .env and customize as needed
# Server port (default: 8080)
PORT=8080
# Data directory for SQLite database
KVTUBE_DATA_DIR=./data
# Gin mode: debug or release
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

View file

@ -1,4 +0,0 @@
test
test
ci test Sat Mar 28 11:26:45 +07 2026
test Sat Mar 28 14:46:11 +07 2026

View file

@ -1,26 +0,0 @@
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}

38
.gitignore vendored
View file

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

409
API_DOCUMENTATION.md Normal file
View file

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

View file

@ -1,72 +1,33 @@
# ---- Backend Builder ---- # Build stage
FROM golang:1.25-alpine AS backend-builder FROM python:3.11-slim
ENV GOTOOLCHAIN=local
ENV GOPROXY=https://proxy.golang.org,direct
WORKDIR /app
RUN apk add --no-cache git gcc musl-dev
COPY backend/go.mod backend/go.sum ./
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
RUN go mod download
COPY backend/ ./
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o kv-tube .
# ---- Frontend Builder ----
FROM node:20-alpine AS frontend-deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
FROM node:20-alpine AS frontend-builder
ARG NEXT_PUBLIC_API_URL=http://127.0.0.1:8080
WORKDIR /app
COPY --from=frontend-deps /app/node_modules ./node_modules
COPY frontend/ ./
ENV NEXT_TELEMETRY_DISABLED 1
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
RUN echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL" && npm run build
# ---- Final Unified Image ----
FROM alpine:latest
# Install dependencies for Go backend, Node.js frontend, and Supervisord
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 WORKDIR /app
# Copy Backend Binary # Install system dependencies (ffmpeg is critical for yt-dlp)
COPY --from=backend-builder /app/kv-tube /app/kv-tube RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy Frontend Standalone App - include server.js for standalone mode # Install Python dependencies
COPY --from=frontend-builder /app/.next/standalone /app/frontend/ COPY requirements.txt .
COPY --from=frontend-builder /app/.next/static /app/frontend/.next/static RUN pip install --no-cache-dir -r requirements.txt
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 # Copy application code
RUN mkdir -p /app/frontend/.next/cache COPY . .
# Copy Supervisord Config # Environment variables
COPY supervisord.conf /etc/supervisord.conf ENV PYTHONUNBUFFERED=1
ENV FLASK_APP=wsgi.py
ENV FLASK_ENV=production
# Setup Environment # Create directories for data persistence
ENV NODE_ENV=production RUN mkdir -p /app/videos /app/data
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 # Expose port
EXPOSE 5000
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] # Run with Entrypoint (handles updates)
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
CMD ["/app/entrypoint.sh"]

View file

@ -1,4 +0,0 @@
FROM alpine
WORKDIR /app
COPY . .
RUN ls -laR

187
README.md
View file

@ -1,130 +1,83 @@
# KV-Tube # KV-Tube v3.0
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. > A lightweight, privacy-focused YouTube frontend web application with AI-powered features.
## Features KV-Tube removes distractions, tracking, and ads from the YouTube watching experience. It provides a clean interface to search, watch, and discover related content without needing a Google account.
- **Modern Video Player**: High-resolution video playback with HLS support and quality selection. ## 🚀 Key Features (v3)
- **Fast Navigation**: Instant click feedback with skeleton loaders for related videos.
- **Infinite Scrolling**: Scroll seamlessly through a dynamic video grid on the homepage.
- **Watch History & Suggestions**: Keep track of what you've watched seamlessly! Fully integrated library history tracking.
- **Subscriptions Management**: Keep up to date with seamless subscription updates for YouTube channels.
- **Optimized for Safari**: Stutter-free playback algorithms and high-tolerance Hls.js configurations tailored for macOS users.
- **Background Audio**: Allows videos to continue playing audio when the browser tab is hidden or device locked (perfect for music).
- **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).
- **Responsive Design**: Beautiful, mobile-friendly interface with light and dark theme support.
- **Containerized**: Fully Dockerized for easy setup using `docker-compose`.
## Architecture - **Privacy First**: No tracking, no ads.
- **Backend & Frontend**: Go (Gin framework) and Next.js are combined into a single unified Docker container using a multi-stage `Dockerfile`. - **Clean Interface**: Distraction-free watching experience.
- **Process Management**: `supervisord` manages the concurrent execution of the backend API and Next.js frontend within the same network namespace. - **Efficient Streaming**: Direct video stream extraction using `yt-dlp`.
- **Data storage**: SQLite is used for watch history, optimized for `linux/amd64`. - **AI Summary (Experimental)**: Generate concise summaries of videos (Currently disabled due to upstream rate limits).
- **Multi-Language**: Support for English and Vietnamese (UI & Content).
- **Auto-Update**: Includes `update_deps.py` to easily keep core fetching tools up-to-date.
## Docker Deployment (v5) ## 🛠️ Architecture Data Flow
### Quick Start ```mermaid
graph TD
User[User Browser]
Server[KV-Tube Server (Flask)]
YTDLP[yt-dlp Core]
YTFetcher[YTFetcher Lib]
YouTube[YouTube V3 API / HTML]
1. Clone or download this repository User -- "1. Search / Watch Request" --> Server
2. Create a `data` folder in the project directory Server -- "2. Extract Video Metadata" --> YTDLP
3. Run the container: YTDLP -- "3. Network Requests (Cookies Optional)" --> YouTube
YouTube -- "4. Raw Video/Audio Streams" --> YTDLP
YTDLP -- "5. Stream URL / Metadata" --> Server
subgraph Transcript System [Transcript System (Deferred)]
Server -.-> YTFetcher
YTFetcher -.-> YouTube
YTFetcher -- "No Transcript (429)" -.-> Server
end
Server -- "6. Render HTML / Stream Proxy" --> User
```
## 🔧 Installation & Usage
### Prerequisites
- Python 3.10+
- Git
- Valid `cookies.txt` (Optional, for bypassing age-restrictions or rate limits)
### Local Setup
1. Clone the repository:
```bash
git clone https://git.khoavo.myds.me/vndangkhoa/kv-tube.git
cd kv-tube
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Run the application:
```bash
python wsgi.py
```
4. Access at `http://localhost:5002`
### Docker Deployment (Linux/AMD64)
Built for stability and ease of use.
```bash ```bash
docker-compose up -d docker pull vndangkhoa/kv-tube:latest
docker run -d -p 5002:5002 -v $(pwd)/cookies.txt:/app/cookies.txt vndangkhoa/kv-tube:latest
``` ```
### Building the Image ## 📦 Updates
To build the image locally: - **v3.0**: Major release.
- Full modularization of backend routes.
- Integrated `ytfetcher` for specialized fetching.
- Added manual dependency update script (`update_deps.py`).
- Enhanced error handling for upstream rate limits.
- Docker `linux/amd64` support verified.
```bash ---
docker build -t git.khoavo.myds.me/vndangkhoa/kv-tube:v5 . *Developed by Khoa Vo*
```
To build and push to your registry:
```bash
docker build -t git.khoavo.myds.me/vndangkhoa/kv-tube:v5 .
docker push git.khoavo.myds.me/vndangkhoa/kv-tube:v5
```
## Deployment on Synology NAS
We recommend using **Container Manager** (DSM 7.2+) or **Docker** (DSM 6/7.1) for a robust and easily manageable deployment.
### 1. Prerequisites
- **Container Manager** or **Docker** package installed from Package Center.
- Ensure ports `5011` (frontend) and `8981` (backend API) are available on your NAS.
- Create a folder named `kv-tube` in your `docker` shared folder (e.g., `/volume1/docker/kv-tube`).
### 2. Using Container Manager (Recommended)
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 Normal file
View file

@ -0,0 +1,325 @@
# 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*

Binary file not shown.

162
app/__init__.py Normal file
View file

@ -0,0 +1,162 @@
"""
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")

Binary file not shown.

9
app/routes/__init__.py Normal file
View file

@ -0,0 +1,9 @@
"""
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']

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1773
app/routes/api.py Normal file

File diff suppressed because it is too large Load diff

172
app/routes/pages.py Normal file
View file

@ -0,0 +1,172 @@
"""
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

164
app/routes/streaming.py Normal file
View file

@ -0,0 +1,164 @@
"""
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
app/services/__init__.py Normal file
View file

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

217
app/services/cache.py Normal file
View file

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

View file

@ -0,0 +1,135 @@
"""
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 []

114
app/services/loader_to.py Normal file
View file

@ -0,0 +1,114 @@
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

55
app/services/settings.py Normal file
View file

@ -0,0 +1,55 @@
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()

119
app/services/summarizer.py Normal file
View file

@ -0,0 +1,119 @@
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)

313
app/services/youtube.py Normal file
View file

@ -0,0 +1,313 @@
"""
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
app/utils/__init__.py Normal file
View file

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

95
app/utils/formatters.py Normal file
View file

@ -0,0 +1,95 @@
"""
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)

View file

@ -1,28 +0,0 @@
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"]

View file

@ -1,77 +0,0 @@
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"

View file

@ -1,50 +0,0 @@
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
)

View file

@ -1,130 +0,0 @@
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=

View file

@ -1,37 +0,0 @@
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)
}
}

View file

@ -1,91 +0,0 @@
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()
}
}()
}

View file

@ -1,92 +0,0 @@
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)
}

View file

@ -1,446 +0,0 @@
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...)
}

View file

@ -1,75 +0,0 @@
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
}

View file

@ -1,72 +0,0 @@
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
}

File diff suppressed because it is too large Load diff

BIN
backend/kv-tube → bin/ffmpeg Executable file → Normal file

Binary file not shown.

65
config.py Normal file
View file

@ -0,0 +1,65 @@
"""
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 Normal file
View file

@ -0,0 +1,19 @@
# Netscape HTTP Cookie File
# This file is generated by yt-dlp. Do not edit.
.youtube.com TRUE / TRUE 1802692356 __Secure-3PSID g.a0005gie1lAkmYZc-EPeGx77pCrXo_Cz5eAi-e9aryb9Qoz967v4Caiou6Tt5ZyLR4iMp5I51wACgYKASISARESFQHGX2MiopTeGBKXybppZWNr7JzmKhoVAUF8yKrgfPx-gEb02gGAV3ZaVOGr0076
.youtube.com TRUE / TRUE 1800281710 __Secure-1PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
.youtube.com TRUE / TRUE 1802692356 SAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1800282680 __Secure-1PSIDCC AKEyXzXvpBScD7r3mqr7aZ0ymWZ7FmsgT0q0C3Ge8hvrjZ9WZ4PU4ZBuBsO0YNYN3A8iX4eV8F8
.youtube.com TRUE / TRUE 1802692356 SSID A4isk9AE9xActvzYy
.youtube.com TRUE / TRUE 1802692356 __Secure-1PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1802692356 __Secure-1PSID g.a0005gie1lAkmYZc-EPeGx77pCrXo_Cz5eAi-e9aryb9Qoz967v4-rF3xTavVHrJoyJAqShH6gACgYKAX0SARESFQHGX2MiOdAbUPmCj4MueYyh-2km5RoVAUF8yKp2ehWQC6tX8n-9UNg11RV60076
.youtube.com TRUE / TRUE 1802692356 __Secure-3PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1800282680 __Secure-3PSIDCC AKEyXzVcvX-jLLprjZQXoqarG3xsAVpjyLYaN2j0a_iUcsnKnpL88P_5IlcfusJn0We0aaKK7g
.youtube.com TRUE / TRUE 1800281710 __Secure-3PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
.youtube.com TRUE / TRUE 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 1784298680 __Secure-ROLLOUT_TOKEN CPm51pHVjquOTRDw0bnsppWSAxjw0bnsppWSAw%3D%3D
.youtube.com TRUE / TRUE 1784298680 VISITOR_INFO1_LIVE ShB1Bvj-rRU
.youtube.com TRUE / TRUE 1784298680 VISITOR_PRIVACY_METADATA CgJWThIEGgAgWA%3D%3D

BIN
data/BpwWnK6n9IQ.m4a Normal file

Binary file not shown.

BIN
data/U2oEJKsPdHo.m4a Normal file

Binary file not shown.

BIN
data/UtGG6u1RBXI.m4a Normal file

Binary file not shown.

BIN
data/kvtube.db Normal file

Binary file not shown.

BIN
data/m4xEF92ZPuk.m4a Normal file

Binary file not shown.

3
data/settings.json Normal file
View file

@ -0,0 +1,3 @@
{
"youtube_engine": "local"
}

28
deploy.py Normal file
View file

@ -0,0 +1,28 @@
#!/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 Normal file
View file

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

View file

@ -1,46 +1,46 @@
Product Requirements Document (PRD) - KV-Tube Product Requirements Document (PRD) - KV-Tube
1. Product Overview 1. Product Overview
Product Name: KV-Tube Version: 1.0 (In Development) Description: KV-Tube is a comprehensive media center web application designed to provide an ad-free YouTube experience, a curated movie streaming service, and a local video management system. It emphasizes privacy, absence of advertisements, and utility features like AI summarization and language learning tools. Product Name: KV-Tube Version: 1.0 (In Development) Description: KV-Tube is a comprehensive media center web application designed to provide an ad-free YouTube experience, a curated movie streaming service, and a local video management system. It emphasizes privacy, absence of advertisements, and utility features like AI summarization and language learning tools.
2. User Personas 2. User Personas
The Binge Watcher: Wants uninterrupted access to YouTube content and movies without ads. The Binge Watcher: Wants uninterrupted access to YouTube content and movies without ads.
The Archivist: Maintains a local collection of videos and wants a clean interface to organize and watch them securely. The Archivist: Maintains a local collection of videos and wants a clean interface to organize and watch them securely.
The Learner: Uses video content for educational purposes, specifically English learning. The Learner: Uses video content for educational purposes, specifically English learning.
The Tech Enthusiast: Appreciates PWA support, torrent integration, and customizable settings. The Tech Enthusiast: Appreciates PWA support, torrent integration, and customizable settings.
3. Core Features 3. Core Features
3.1. YouTube Viewer (Home) 3.1. YouTube Viewer (Home)
Ad-Free Experience: Plays YouTube videos without third-party advertisements. Ad-Free Experience: Plays YouTube videos without third-party advertisements.
Search: Integrated search bar powered by yt-dlp to find videos, channels, and playlists. Search: Integrated search bar powered by yt-dlp to find videos, channels, and playlists.
Playback: Custom video player with support for quality selection and playback speed. Playback: Custom video player with support for quality selection and playback speed.
AI Summarization: Feature to summarize video content using Google Gemini API (Optional). AI Summarization: Feature to summarize video content using Google Gemini API (Optional).
3.2. local Video Manager ("My Videos") 3.2. local Video Manager ("My Videos")
Secure Access: Password-protected section for personal video collections. Secure Access: Password-protected section for personal video collections.
File Management: Scans local directories for video files. File Management: Scans local directories for video files.
Metadata: Extracts metadata (duration, format) and generates thumbnails using FFmpeg/MoviePy. Metadata: Extracts metadata (duration, format) and generates thumbnails using FFmpeg/MoviePy.
Playback: Native HTML5 player for local files. Playback: Native HTML5 player for local files.
3.3. Utilities 3.3. Utilities
Torrent Player: Interface for streaming/playing video content via torrents. Torrent Player: Interface for streaming/playing video content via torrents.
Playlist Manager: Create and manage custom playlists of YouTube videos. Playlist Manager: Create and manage custom playlists of YouTube videos.
Camera/Photo: ("Chụp ảnh") Feature to capture or manage photos (Webcam integration). Camera/Photo: ("Chụp ảnh") Feature to capture or manage photos (Webcam integration).
Configuration: Web-based settings to manage application behavior (e.g., password, storage paths). Configuration: Web-based settings to manage application behavior (e.g., password, storage paths).
4. Technical Architecture 4. Technical Architecture
Backend: Python / Flask Backend: Python / Flask
Frontend: HTML5, CSS3, JavaScript (Vanilla) Frontend: HTML5, CSS3, JavaScript (Vanilla)
Database/Storage: JSON-based local storage and file system. Database/Storage: JSON-based local storage and file system.
Video Processing: yt-dlp (YouTube), FFmpeg (Conversion/Thumbnail), MoviePy (Optional). Video Processing: yt-dlp (YouTube), FFmpeg (Conversion/Thumbnail), MoviePy (Optional).
AI Service: Google Gemini API (for summarization). AI Service: Google Gemini API (for summarization).
Deployment: Docker container support (xehopnet/kctube). Deployment: Docker container support (xehopnet/kctube).
5. Non-Functional Requirements 5. Non-Functional Requirements
Performance: Fast load times and responsive UI. Performance: Fast load times and responsive UI.
Compatibility: PWA-ready for installation on desktop and mobile. Compatibility: PWA-ready for installation on desktop and mobile.
Reliability: graceful degradation if optional dependencies (MoviePy, Gemini) are missing. Reliability: graceful degradation if optional dependencies (MoviePy, Gemini) are missing.
Privacy: No user tracking or external analytics. Privacy: No user tracking or external analytics.
6. Known Limitations 6. Known Limitations
Search Reliability: Dependent on yt-dlp stability and YouTube's anti-bot measures. Search Reliability: Dependent on yt-dlp stability and YouTube's anti-bot measures.
External APIs: Movie features rely on third-party APIs which may have downtime. External APIs: Movie features rely on third-party APIs which may have downtime.
Dependency Management: Some Python libraries (MoviePy, Numpy) require compilation tools. Dependency Management: Some Python libraries (MoviePy, Numpy) require compilation tools.
7. Future Roadmap 7. Future Roadmap
Database: Migrate from JSON to SQLite for better performance with large libraries. Database: Migrate from JSON to SQLite for better performance with large libraries.
User Accounts: Individual user profiles and history. User Accounts: Individual user profiles and history.
Offline Mode: Enhanced offline capabilities for PWA. Offline Mode: Enhanced offline capabilities for PWA.
Casting: Support for Chromecast/AirPlay. Casting: Support for Chromecast/AirPlay.

View file

@ -1,53 +0,0 @@
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

View file

@ -1,16 +0,0 @@
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

View file

@ -1,23 +0,0 @@
# 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"

View file

@ -5,24 +5,25 @@ version: '3.8'
services: services:
kv-tube: kv-tube:
build: build: .
context: . image: vndangkhoa/kv-tube:latest
dockerfile: Dockerfile
args:
- NEXT_PUBLIC_API_URL=http://ut.khoavo.myds.me:8981/api
image: git.khoavo.myds.me/vndangkhoa/kv-tube:v9
container_name: kv-tube container_name: kv-tube
platform: linux/amd64
restart: unless-stopped restart: unless-stopped
ports: ports:
- "5011:3000" - "5011:5000"
- "8981:8080"
volumes: volumes:
# Persist data (Easy setup: Just maps a folder)
- ./data:/app/data - ./data:/app/data
# Local videos folder (Optional)
# - ./videos:/app/youtube_downloads
environment: environment:
- KVTUBE_DATA_DIR=/app/data - PYTHONUNBUFFERED=1
- GIN_MODE=release - FLASK_ENV=production
- NODE_ENV=production healthcheck:
- CORS_ALLOWED_ORIGINS=https://ut.khoavo.myds.me,http://ut.khoavo.myds.me:5011,http://localhost:3000,http://127.0.0.1:3000 test: [ "CMD", "curl", "-f", "http://localhost:5000/" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
labels: labels:
- "com.centurylinklabs.watchtower.enable=true" - "com.centurylinklabs.watchtower.enable=true"

21
entrypoint.sh Normal file
View file

@ -0,0 +1,21 @@
#!/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
View file

@ -1,41 +0,0 @@
# 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

View file

@ -1,55 +0,0 @@
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"]

View file

@ -1,36 +0,0 @@
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.

View file

@ -1,621 +0,0 @@
'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>
);
}

View file

@ -1,210 +0,0 @@
"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 [];
}
}

View file

@ -1,127 +0,0 @@
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>
);
}

View file

@ -1,271 +0,0 @@
'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);
}

View file

@ -1,70 +0,0 @@
'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 &#9825; locally
</div>
</div>
</div>
</>
);
}

View file

@ -1,132 +0,0 @@
'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>
);
}

View file

@ -1,13 +0,0 @@
'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>
);
}

View file

@ -1,115 +0,0 @@
"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>
);
}

View file

@ -1,75 +0,0 @@
'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;
}

View file

@ -1,14 +0,0 @@
'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>
);
}

View file

@ -1,52 +0,0 @@
'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>
);
}

View file

@ -1,117 +0,0 @@
'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>
);
}

View file

@ -1,63 +0,0 @@
'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>
);
}

View file

@ -1,94 +0,0 @@
'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>
);
}

View file

@ -1,114 +0,0 @@
'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);

View file

@ -1,46 +0,0 @@
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' },
];

View file

@ -1,72 +0,0 @@
'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;
}

View file

@ -1,45 +0,0 @@
'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;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View file

@ -1,316 +0,0 @@
'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>
);
}

View file

@ -1,277 +0,0 @@
'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>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,94 +0,0 @@
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>
);
}

View file

@ -1,11 +0,0 @@
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>
);
}

View file

@ -1,223 +0,0 @@
'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>
);
}

View file

@ -1,21 +0,0 @@
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>
);
}

View file

@ -1,320 +0,0 @@
// 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();

View file

@ -1,500 +0,0 @@
'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; }';

View file

@ -1,178 +0,0 @@
'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;
}
}

View file

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

View file

@ -1,996 +0,0 @@
'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>
);
}

View file

@ -1,239 +0,0 @@
'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
}

View file

@ -1,11 +0,0 @@
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>
);
}

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