diff --git a/.env.example b/.env.example index 2fbdbb4..540cddb 100755 --- a/.env.example +++ b/.env.example @@ -1,12 +1,11 @@ # KV-Tube Environment Configuration # Copy this file to .env and customize as needed -# Secret key for Flask sessions (required for production) -# Generate a secure key: python -c "import os; print(os.urandom(32).hex())" -SECRET_KEY=your-secure-secret-key-here +# Server port (default: 8080) +PORT=8080 -# Environment: development or production -FLASK_ENV=development +# Data directory for SQLite database +KVTUBE_DATA_DIR=./data -# Local video directory (optional) -KVTUBE_VIDEO_DIR=./videos +# Gin mode: debug or release +GIN_MODE=release diff --git a/.gemini/tmp/ytfetcher b/.gemini/tmp/ytfetcher deleted file mode 160000 index 246c4c3..0000000 --- a/.gemini/tmp/ytfetcher +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 246c4c349d97205eb2b51d7d3999ea846f5b2bdc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100755 index 0000000..a956ea8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,105 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ruff mypy bandit types-requests + pip install -r requirements.txt + + - name: Run Ruff + run: ruff check . --output-format=github + + - name: Run MyPy + run: mypy app/ config.py --ignore-missing-imports + continue-on-error: true + + - name: Run Bandit + run: bandit -r app/ -x app/routes/api --skip B101,B311 + continue-on-error: true + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov + + - name: Run tests + run: pytest tests/ -v --tb=short + continue-on-error: true + + build: + runs-on: ubuntu-latest + needs: [lint, test] + if: startsWith(github.ref, 'refs/tags/') + + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log into Docker Hub + uses: docker/login-action@v3 + with: + registry: docker.io + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Log into Forgejo Registry + uses: docker/login-action@v3 + with: + registry: git.khoavo.myds.me + username: ${{ secrets.FORGEJO_USERNAME }} + password: ${{ secrets.FORGEJO_PASSWORD }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + docker.io/${{ github.repository }} + git.khoavo.myds.me/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=raw,value=latest,enable=${{ github.ref == 'refs/tags/v*' }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64 + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 70a0e32..607bc70 100755 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -5,12 +5,6 @@ on: tags: - 'v*' -env: - # Use docker.io for Docker Hub if empty - REGISTRY: docker.io - # github.repository as / - IMAGE_NAME: ${{ github.repository }} - jobs: build: runs-on: ubuntu-latest @@ -28,41 +22,49 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log into Docker Hub - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Log into Forgejo Registry - if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: git.khoavo.myds.me username: ${{ secrets.FORGEJO_USERNAME }} password: ${{ secrets.FORGEJO_PASSWORD }} - - name: Extract Docker metadata - id: meta + - name: Extract metadata (backend) + id: meta-backend uses: docker/metadata-action@v5 with: - images: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - git.khoavo.myds.me/${{ github.repository }} + images: git.khoavo.myds.me/${{ github.repository }}-backend tags: | type=semver,pattern={{version}} type=raw,value=latest,enable=${{ github.ref == 'refs/tags/v*' }} - - name: Build and push Docker image - id: build-and-push + - name: Build and push (backend) uses: docker/build-push-action@v5 with: - context: . - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + context: ./backend + push: true + tags: ${{ steps.meta-backend.outputs.tags }} + labels: ${{ steps.meta-backend.outputs.labels }} + platforms: linux/amd64 + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Extract metadata (frontend) + id: meta-frontend + uses: docker/metadata-action@v5 + with: + images: git.khoavo.myds.me/${{ github.repository }}-frontend + tags: | + type=semver,pattern={{version}} + type=raw,value=latest,enable=${{ github.ref == 'refs/tags/v*' }} + + - name: Build and push (frontend) + uses: docker/build-push-action@v5 + with: + context: ./frontend + push: true + tags: ${{ steps.meta-frontend.outputs.tags }} + labels: ${{ steps.meta-frontend.outputs.labels }} platforms: linux/amd64 cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 90b501b..c534ce0 100755 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,38 @@ +# OS .DS_Store -__pycache__/ -*.pyc -venv/ -.venv/ -.venv_clean/ + +# Environment .env + +# Runtime data data/ videos/ *.db -server.log -.ruff_cache/ +logs/ +*.pid + +# Go +backend/kvtube-go +backend/bin/ +*.exe + +# Node +node_modules/ + +# Secrets - NEVER commit these +cookies.txt +*.pem +*.key +credentials.json + +# IDE +.idea/ +.vscode/ +*.swp + +# Debug files +*_debug.txt + +# Temporary +tmp_*/ +.gemini/ diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md deleted file mode 100755 index 7797723..0000000 --- a/API_DOCUMENTATION.md +++ /dev/null @@ -1,409 +0,0 @@ -# KV-Tube API Documentation - -## Base URL -``` -http://127.0.0.1:5002 -``` - -## Endpoints Overview - -| Endpoint | Method | Status | Description | -|----------|--------|--------|-------------| -| `/` | GET | ✅ 200 | Homepage | -| `/watch?v={video_id}` | GET | ✅ 200 | Video player page | -| `/api/search?q={query}` | GET | ✅ 200 | Search videos | -| `/api/trending` | GET | ✅ 200 | Trending videos | -| `/api/get_stream_info?v={video_id}` | GET | ✅ 200 | Get video stream URL | -| `/api/transcript?v={video_id}` | GET | ✅ 200* | Get video transcript (rate limited) | -| `/api/summarize?v={video_id}` | GET | ✅ 200* | AI summary (rate limited) | -| `/api/history` | GET | ✅ 200 | Get watch history | -| `/api/suggested` | GET | ✅ 200 | Get suggested videos | -| `/api/related?v={video_id}` | GET | ✅ 200 | Get related videos | -| `/api/channel/videos?id={channel_id}` | GET | ✅ 200 | Get channel videos | -| `/api/download?v={video_id}` | GET | ✅ 200 | Get download URL | -| `/api/download/formats?v={video_id}` | GET | ✅ 200 | Get available formats | -| `/video_proxy?url={stream_url}` | GET | ✅ 200 | Proxy video stream | -| `/api/save_video` | POST | ✅ 200 | Save video to history | -| `/settings` | GET | ✅ 200 | Settings page | -| `/my-videos` | GET | ✅ 200 | User videos page | - -*Rate limited by YouTube (429 errors expected) - ---- - -## Detailed Endpoint Documentation - -### 1. Search Videos -**Endpoint**: `GET /api/search?q={query}` -**Status**: ✅ Working - -**Example Request**: -```bash -curl "http://127.0.0.1:5002/api/search?q=python%20tutorial" -``` - -**Example Response**: -```json -[ - { - "id": "K5KVEU3aaeQ", - "title": "Python Full Course for Beginners", - "uploader": "Programming with Mosh", - "thumbnail": "https://i.ytimg.com/vi/K5KVEU3aaeQ/hqdefault.jpg", - "view_count": 4932307, - "duration": "2:02:21", - "upload_date": "" - } -] -``` - ---- - -### 2. Get Stream Info -**Endpoint**: `GET /api/get_stream_info?v={video_id}` -**Status**: ✅ Working - -**Example Request**: -```bash -curl "http://127.0.0.1:5002/api/get_stream_info?v=dQw4w9WgXcQ" -``` - -**Example Response**: -```json -{ - "original_url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/...", - "stream_url": "/video_proxy?url=...", - "title": "Rick Astley - Never Gonna Give You Up (Official Video)", - "description": "The official video for Never Gonna Give You Up...", - "uploader": "Rick Astley", - "channel_id": "UCuAXFkgsw1L7xaCfnd5JJOw", - "view_count": 1730702525, - "related": [ - { - "id": "dQw4w9WgXcQ", - "title": "Rick Astley - Never Gonna Give You Up...", - "view_count": 1730702525 - } - ], - "subtitle_url": null -} -``` - ---- - -### 3. Get Trending Videos -**Endpoint**: `GET /api/trending` -**Status**: ✅ Working - -**Example Request**: -```bash -curl "http://127.0.0.1:5002/api/trending" -``` - -**Example Response**: -```json -{ - "data": [ - { - "id": "discovery", - "title": "You Might Like", - "icon": "compass", - "videos": [ - { - "id": "GKWrOLrp80c", - "title": "Best of: Space Exploration", - "uploader": "The History Guy", - "view_count": 205552, - "duration": "1:02:29" - } - ] - } - ] -} -``` - ---- - -### 4. Get Channel Videos -**Endpoint**: `GET /api/channel/videos?id={channel_id}` -**Status**: ✅ Working - -**Supports**: -- Channel ID: `UCuAXFkgsw1L7xaCfnd5JJOw` -- Channel URL: `https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw` -- Channel Handle: `@ProgrammingWithMosh` - -**Example Request**: -```bash -curl "http://127.0.0.1:5002/api/channel/videos?id=@ProgrammingWithMosh&limit=5" -``` - -**Example Response**: -```json -[ - { - "id": "naNcmnKskUE", - "title": "Top 5 Programming Languages to Learn in 2026", - "uploader": "", - "channel_id": "@ProgrammingWithMosh", - "view_count": 149264, - "duration": "11:31", - "thumbnail": "https://i.ytimg.com/vi/naNcmnKskUE/mqdefault.jpg" - } -] -``` - ---- - -### 5. Get Download URL -**Endpoint**: `GET /api/download?v={video_id}` -**Status**: ✅ Working - -**Example Request**: -```bash -curl "http://127.0.0.1:5002/api/download?v=dQw4w9WgXcQ" -``` - -**Example Response**: -```json -{ - "url": "https://rr2---sn-8qj-nbo66.googlevideo.com/videoplayback?...", - "title": "Rick Astley - Never Gonna Give You Up (Official Video) (4K Remaster)", - "ext": "mp4" -} -``` - ---- - -### 6. Get Download Formats -**Endpoint**: `GET /api/download/formats?v={video_id}` -**Status**: ✅ Working - -**Example Request**: -```bash -curl "http://127.0.0.1:5002/api/download/formats?v=dQw4w9WgXcQ" -``` - -**Example Response**: -```json -{ - "success": true, - "video_id": "dQw4w9WgXcQ", - "title": "Rick Astley - Never Gonna Give You Up", - "duration": 213, - "formats": { - "video": [ - { - "quality": "1080p", - "ext": "mp4", - "size": "226.1 MB", - "url": "...", - "type": "video" - } - ], - "audio": [ - { - "quality": "128kbps", - "ext": "mp3", - "size": "3.2 MB", - "url": "...", - "type": "audio" - } - ] - } -} -``` - ---- - -### 7. Get Related Videos -**Endpoint**: `GET /api/related?v={video_id}&limit={count}` -**Status**: ✅ Working - -**Example Request**: -```bash -curl "http://127.0.0.1:5002/api/related?v=dQw4w9WgXcQ&limit=5" -``` - ---- - -### 8. Get Suggested Videos -**Endpoint**: `GET /api/suggested` -**Status**: ✅ Working - -Based on user's watch history. - ---- - -### 9. Get Watch History -**Endpoint**: `GET /api/history` -**Status**: ✅ Working - -**Example Request**: -```bash -curl "http://127.0.0.1:5002/api/history" -``` - -**Example Response**: -```json -[ - { - "id": "dQw4w9WgXcQ", - "title": "Rick Astley - Never Gonna Give You Up", - "thumbnail": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg" - } -] -``` - ---- - -### 10. Video Proxy -**Endpoint**: `GET /video_proxy?url={stream_url}` -**Status**: ✅ Working - -Proxies video streams to bypass CORS and enable seeking. - -**Example Request**: -```bash -curl "http://127.0.0.1:5002/video_proxy?url=https://manifest.googlevideo.com/api/manifest/hls_playlist/..." -``` - ---- - -### 11. Get Transcript ⚠️ RATE LIMITED -**Endpoint**: `GET /api/transcript?v={video_id}` -**Status**: ⚠️ Working but YouTube rate limits (429) - -**Example Request**: -```bash -curl "http://127.0.0.1:5002/api/transcript?v=dQw4w9WgXcQ" -``` - -**Example Response (Success)**: -```json -{ - "success": true, - "video_id": "dQw4w9WgXcQ", - "transcript": [ - { - "text": "Never gonna give you up", - "start": 0.0, - "duration": 2.5 - } - ], - "language": "en", - "is_generated": true, - "full_text": "Never gonna give you up..." -} -``` - -**Example Response (Rate Limited)**: -```json -{ - "success": false, - "error": "Could not load transcript: 429 Client Error: Too Many Requests" -} -``` - ---- - -### 12. AI Summary ⚠️ RATE LIMITED -**Endpoint**: `GET /api/summarize?v={video_id}` -**Status**: ⚠️ Working but YouTube rate limits (429) - -**Example Request**: -```bash -curl "http://127.0.0.1:5002/api/summarize?v=dQw4w9WgXcQ" -``` - -**Example Response**: -```json -{ - "success": true, - "summary": "Rick Astley's official music video for Never Gonna Give You Up..." -} -``` - ---- - -## Rate Limiting - -**Current Limits**: -- Search: 30 requests/minute -- Transcript: 10 requests/minute -- Channel Videos: 60 requests/minute -- Download: 20 requests/minute - -**Note**: YouTube also imposes its own rate limits on transcript/summary requests. - ---- - -## Error Codes - -| Code | Meaning | Solution | -|------|---------|----------| -| 200 | Success | - | -| 400 | Bad Request | Check parameters | -| 404 | Not Found | Verify video ID | -| 429 | Rate Limited | Wait before retrying | -| 500 | Server Error | Check server logs | - ---- - -## Testing Commands - -```bash -# Homepage -curl http://127.0.0.1:5002/ - -# Search -curl "http://127.0.0.1:5002/api/search?q=python" - -# Get stream -curl "http://127.0.0.1:5002/api/get_stream_info?v=dQw4w9WgXcQ" - -# Get download URL -curl "http://127.0.0.1:5002/api/download?v=dQw4w9WgXcQ" - -# Get channel videos -curl "http://127.0.0.1:5002/api/channel/videos?id=UCuAXFkgsw1L7xaCfnd5JJOw" - -# Get trending -curl http://127.0.0.1:5002/api/trending - -# Get history -curl http://127.0.0.1:5002/api/history -``` - ---- - -## Server Information - -- **URL**: http://127.0.0.1:5002 -- **Port**: 5002 -- **Mode**: Development (Debug enabled) -- **Python**: 3.12.9 -- **Framework**: Flask 3.0.2 -- **Rate Limiting**: Flask-Limiter enabled - ---- - -## Known Issues - -1. **Transcript API (429)**: YouTube rate limits transcript requests - - Status: Expected behavior - - Resolution: Wait 1-24 hours or use VPN - - Frontend handles gracefully with user messages - -2. **CORS Errors**: Direct YouTube API calls blocked - - Status: Expected browser security - - Resolution: Use KV-Tube proxy endpoints - -3. **PWA Install Banner**: Chrome requires user interaction - - Status: Expected behavior - - Resolution: Manual install via browser menu - ---- - -*Generated: 2026-01-10* -*Version: KV-Tube 2.0* diff --git a/Dockerfile b/Dockerfile deleted file mode 100755 index 46bb94c..0000000 --- a/Dockerfile +++ /dev/null @@ -1,33 +0,0 @@ -# Build stage -FROM python:3.11-slim - -WORKDIR /app - -# Install system dependencies (ffmpeg is critical for yt-dlp) -RUN apt-get update && apt-get install -y --no-install-recommends \ - ffmpeg \ - curl \ - && rm -rf /var/lib/apt/lists/* - -# Install Python dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application code -COPY . . - -# Environment variables -ENV PYTHONUNBUFFERED=1 -ENV FLASK_APP=wsgi.py -ENV FLASK_ENV=production - -# Create directories for data persistence -RUN mkdir -p /app/videos /app/data - -# Expose port -EXPOSE 5000 - -# Run with Entrypoint (handles updates) -COPY entrypoint.sh /app/entrypoint.sh -RUN chmod +x /app/entrypoint.sh -CMD ["/app/entrypoint.sh"] diff --git a/README.md b/README.md deleted file mode 100755 index 090b475..0000000 --- a/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# KV-Tube v3.0 - -> A lightweight, privacy-focused YouTube frontend web application with AI-powered features. - -KV-Tube removes distractions, tracking, and ads from the YouTube watching experience. It provides a clean interface to search, watch, and discover related content without needing a Google account. - -## 🚀 Key Features (v3) - -- **Privacy First**: No tracking, no ads. -- **Clean Interface**: Distraction-free watching experience. -- **Efficient Streaming**: Direct video stream extraction using `yt-dlp`. -- **AI Summary (Experimental)**: Generate concise summaries of videos (Currently disabled due to upstream rate limits). -- **Multi-Language**: Support for English and Vietnamese (UI & Content). -- **Auto-Update**: Includes `update_deps.py` to easily keep core fetching tools up-to-date. - -## 🛠️ Architecture Data Flow - -![Architecture Data Flow](https://mermaid.ink/img/Z3JhcGggVEQKICAgIHN1YmdyYXBoIENsaWVudCBbIkNsaWVudCBTaWRlIl0KICAgICAgICBVc2VyWyJVc2VyIEJyb3dzZXIiXQogICAgZW5kCgogICAgc3ViZ3JhcGggQmFja2VuZCBbIktWVHViZSBCYWNrZW5kIFN5c3RlbSJdCiAgICAgICAgU2VydmVyWyJLVlR1YmUgU2VydmVyIl0KICAgICAgICBZVERMUFsieXRkbHAgQ29yZSJdCiAgICAgICAgWVRGZXRjaGVyWyJZVEZldGNoZXIgTGliIl0KICAgIGVuZAoKICAgIHN1YmdyYXBoIEV4dGVybmFsIFsiRXh0ZXJuYWwgU2VydmljZXMiXQogICAgICAgIFlvdVR1YmVbIllvdVR1YmUgVjMgQVBJIl0KICAgIGVuZAoKICAgICUlIE1haW4gRmxvdwogICAgVXNlciAtLSAiMS4gU2VhcmNoL1dhdGNoIFJlcXVlc3QiIC0tPiBTZXJ2ZXIKICAgIFNlcnZlciAtLSAiMi4gRXh0cmFjdCBNZXRhZGF0YSIgLS0+IFlURExQCiAgICBZVERMUCAtLSAiMy4gTmV0d29yayBSZXEgKENvb2tpZXMpIiAtLT4gWW91VHViZQogICAgWW91VHViZSAtLSAiNC4gUmF3IFN0cmVhbXMiIC0tPiBZVERMUAogICAgWVRETFAgLS0gIjUuIFN0cmVhbSBVUkwiIC0tPiBTZXJ2ZXIKICAgIFNlcnZlciAtLSAiNi4gUmVuZGVyL1Byb3h5IiAtLT4gVXNlcgogICAgCiAgICAlJSBGYWxsYmFjay9TZWNvbmRhcnkgRmxvdwogICAgU2VydmVyIC0uLT4gWVRGZXRjaGVyCiAgICBZVEZldGNoZXIgLS4tPiBZb3VUdWJlCiAgICBZVEZldGNoZXIgLS4gIkVycm9yIC8gTm8gVHJhbnNjcmlwdCIgLi0+IFNlcnZlcgoKICAgICUlIFN0eWxpbmcgdG8gbWFrZSBpdCBwb3AKICAgIHN0eWxlIEJhY2tlbmQgZmlsbDojZjlmOWY5LHN0cm9rZTojMzMzLHN0cm9rZS13aWR0aDoycHgKICAgIHN0eWxlIEV4dGVybmFsIGZpbGw6I2ZmZWJlZSxzdHJva2U6I2YwMCxzdHJva2Utd2lkdGg6MnB4) - -## 🔧 Installation & Usage - -### Prerequisites -- Python 3.10+ -- Git -- Valid `cookies.txt` (Optional, for bypassing age-restrictions or rate limits) - -### Local Setup -1. Clone the repository: - ```bash - git clone https://git.khoavo.myds.me/vndangkhoa/kv-tube.git - cd kv-tube - ``` -2. Install dependencies: - ```bash - pip install -r requirements.txt - ``` -3. Run the application: - ```bash - python wsgi.py - ``` -4. Access at `http://localhost:5002` - -### Docker Deployment (Linux/AMD64) - -Built for stability and ease of use. - -```bash -docker pull vndangkhoa/kv-tube:latest -docker run -d -p 5002:5002 -v $(pwd)/cookies.txt:/app/cookies.txt vndangkhoa/kv-tube:latest -``` - -## 📦 Updates - -- **v3.0**: Major release. - - Full modularization of backend routes. - - Integrated `ytfetcher` for specialized fetching. - - Added manual dependency update script (`update_deps.py`). - - Enhanced error handling for upstream rate limits. - - Docker `linux/amd64` support verified. - ---- -*Developed by Khoa Vo* diff --git a/USER_GUIDE.md b/USER_GUIDE.md deleted file mode 100755 index 5d02fef..0000000 --- a/USER_GUIDE.md +++ /dev/null @@ -1,325 +0,0 @@ -# KV-Tube Complete User Guide & Status Report - -## 🚀 **Quick Start** - -### Access KV-Tube -- **URL**: http://127.0.0.1:5002 -- **Local**: http://localhost:5002 -- **Network**: http://192.168.31.71:5002 - -### Quick Actions -1. **Search**: Use the search bar to find videos -2. **Watch**: Click any video to start playing -3. **Download**: Click the download button for MP4 -4. **History**: Your watch history is saved automatically - ---- - -## ✅ **What's Working (100%)** - -### Core Features -- ✅ Video Search (15+ results per query) -- ✅ Video Playback (HLS streaming) -- ✅ Related Videos -- ✅ Channel Videos (@handle, ID, URL) -- ✅ Trending Videos -- ✅ Suggested for You -- ✅ Watch History (saved locally) -- ✅ Video Downloads (direct MP4) -- ✅ Multiple Quality Options -- ✅ Dark/Light Mode -- ✅ PWA (Installable) -- ✅ Mobile Responsive - -### API Endpoints (All Working) -| Endpoint | Status | Purpose | -|----------|--------|---------| -| `/api/search` | ✅ Working | Search videos | -| `/api/get_stream_info` | ✅ Working | Get video stream | -| `/api/related` | ✅ Working | Get related videos | -| `/api/channel/videos` | ✅ Working | Get channel uploads | -| `/api/trending` | ✅ Working | Get trending | -| `/api/download` | ✅ Working | Get download URL | -| `/api/download/formats` | ✅ Working | Get quality options | -| `/api/history` | ✅ Working | Get watch history | -| `/api/suggested` | ✅ Working | Get recommendations | -| `/api/transcript` | ⚠️ Rate Limited | Get subtitles | -| `/api/summarize` | ⚠️ Rate Limited | AI summary | - ---- - -## ⚠️ **Known Limitations** - -### YouTube Rate Limiting (429 Errors) -**What**: YouTube blocks automated subtitle requests -**Impact**: Transcript & AI summary features temporarily unavailable -**When**: After ~10 requests in a short period -**Duration**: 1-24 hours -**Solution**: Wait for YouTube to reset limits - -**User Experience**: -- Feature shows "Transcript temporarily disabled" toast -- No errors in console -- Automatic retry with exponential backoff -- Graceful degradation - ---- - -## 📊 **Performance Stats** - -### Response Times -- **Homepage Load**: 15ms -- **Search Results**: 850ms -- **Stream Info**: 1.2s -- **Channel Videos**: 950ms -- **Related Videos**: 700ms -- **Trending**: 1.5s - -**Overall Rating**: ⚡ **EXCELLENT** (avg 853ms) - -### Server Info -- **Python**: 3.12.9 -- **Framework**: Flask 3.0.2 -- **Port**: 5002 -- **Mode**: Development (Debug enabled) -- **Rate Limiting**: Flask-Limiter active -- **Uptime**: Running continuously - ---- - -## 🎯 **How to Use** - -### 1. Search for Videos -1. Go to http://127.0.0.1:5002 -2. Type in search bar (e.g., "Python tutorial") -3. Press Enter or click search icon -4. Browse results - -### 2. Watch a Video -1. Click any video thumbnail -2. Video loads in ArtPlayer -3. Use controls to play/pause/seek -4. Toggle fullscreen - -### 3. Download Video -1. Open video page -2. Click download button -3. Select quality (1080p, 720p, etc.) -4. Download starts automatically - -### 4. Browse Channels -1. Click channel name under video -2. View channel uploads -3. Subscribe (bookmark the page) - -### 5. View History -1. Click "History" in sidebar -2. See recently watched videos -3. Click to resume watching - ---- - -## 🛠️ **Troubleshooting** - -### Server Not Running? -```bash -# Check if running -netstat -ano | findstr :5002 - -# Restart if needed -.venv/Scripts/python app.py -``` - -### 429 Rate Limit? -- **Normal**: Expected from YouTube -- **Solution**: Wait 1-24 hours -- **No action needed**: Frontend handles gracefully - -### Video Not Loading? -- Check your internet connection -- Try refreshing the page -- Check if YouTube video is available - -### Search Not Working? -- Verify server is running (port 5002) -- Check your internet connection -- Try simpler search terms - ---- - -## 📁 **Project Files** - -### Created Files -- `API_DOCUMENTATION.md` - Complete API reference -- `TEST_REPORT.md` - Comprehensive test results -- `.env` - Environment configuration -- `server.log` - Server logs - -### Key Directories -``` -kv-tube/ -├── app.py # Main Flask application -├── templates/ # HTML templates -│ ├── index.html # Homepage -│ ├── watch.html # Video player -│ ├── channel.html # Channel page -│ └── ... -├── static/ # Static assets -│ ├── css/ # Stylesheets -│ ├── js/ # JavaScript -│ ├── icons/ # PWA icons -│ └── sw.js # Service Worker -├── data/ # SQLite database -├── .env # Environment config -├── requirements.txt # Dependencies -└── docker-compose.yml # Docker config -``` - ---- - -## 🔧 **Configuration** - -### Environment Variables -```env -SECRET_KEY=your-secure-key-here -FLASK_ENV=development -KVTUBE_VIDEO_DIR=./videos -``` - -### Rate Limits -- Search: 30 requests/minute -- Transcript: 10 requests/minute -- Channel: 60 requests/minute -- Download: 20 requests/minute - ---- - -## 🚀 **Deployment Options** - -### Local Development (Current) -```bash -.venv/Scripts/python app.py -# Access: http://127.0.0.1:5002 -``` - -### Docker Production -```bash -docker-compose up -d -# Access: http://localhost:5011 -``` - -### Manual Production -```bash -gunicorn --bind 0.0.0.0:5001 --workers 2 --threads 4 app:app -``` - ---- - -## 📈 **Feature Roadmap** - -### Completed ✅ -- Video search and playback -- Channel browsing -- Video downloads -- Watch history -- Dark/Light mode -- PWA support -- Rate limiting -- Mobile responsive - -### In Progress -- User authentication -- Playlist support -- Comments - -### Planned -- Video recommendations AI -- Offline viewing -- Background playback -- Chromecast support - ---- - -## 🆘 **Support** - -### Common Issues - -**Q: Video won't play?** -A: Check internet connection, refresh page - -**Q: Downloads not working?** -A: Some videos have download restrictions - -**Q: Rate limit errors?** -A: Normal - wait and retry - -**Q: How to restart server?** -A: Kill python process and rerun app.py - -### Logs -- Check `server.log` for detailed logs -- Server outputs to console when running - ---- - -## 🎉 **Success Metrics** - -### All Systems Operational -✅ Server Running (Port 5002) -✅ All 15 Core APIs Working -✅ 87.5% Feature Completeness -✅ 0 Critical Errors -✅ Production Ready - -### Test Results -- **Total Tests**: 17 -- **Passed**: 15 (87.5%) -- **Rate Limited**: 2 (12.5%) -- **Failed**: 0 (0%) - -### User Experience -- ✅ Fast page loads (avg 853ms) -- ✅ Smooth video playback -- ✅ Responsive design -- ✅ Intuitive navigation - ---- - -## 📝 **Notes** - -### Browser Extensions -Some browser extensions (especially YouTube-related) may show console errors: -- `onboarding.js` errors - External, ignore -- Content script warnings - External, ignore - -These don't affect KV-Tube functionality. - -### PWA Installation -- Chrome: Menu → Install KV-Tube -- Firefox: Address bar → Install icon -- Safari: Share → Add to Home Screen - -### Data Storage -- SQLite database in `data/kvtube.db` -- Watch history persists across sessions -- LocalStorage for preferences - ---- - -## ✅ **Final Verdict** - -**Status**: 🏆 **EXCELLENT - FULLY OPERATIONAL** - -KV-Tube is running successfully with all core features working perfectly. The only limitations are external YouTube rate limits on transcript features, which are temporary and automatically handled by the frontend. - -**Recommended Actions**: -1. ✅ Use KV-Tube for ad-free YouTube -2. ✅ Test video playback and downloads -3. ⚠️ Avoid heavy transcript usage (429 limits) -4. 🎉 Enjoy the privacy-focused experience! - ---- - -*Guide Generated: 2026-01-10* -*KV-Tube Version: 2.0* -*Status: Production Ready* diff --git a/app/__init__.py b/app/__init__.py deleted file mode 100755 index 896022b..0000000 --- a/app/__init__.py +++ /dev/null @@ -1,162 +0,0 @@ -""" -KV-Tube App Package -Flask application factory pattern -""" -from flask import Flask -import os -import sqlite3 -import logging - -# Setup logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# Database configuration -DATA_DIR = os.environ.get("KVTUBE_DATA_DIR", "data") -DB_NAME = os.path.join(DATA_DIR, "kvtube.db") - - -def init_db(): - """Initialize the database with required tables.""" - # Ensure data directory exists - if not os.path.exists(DATA_DIR): - os.makedirs(DATA_DIR) - - conn = sqlite3.connect(DB_NAME) - c = conn.cursor() - - # Users Table - c.execute("""CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password TEXT NOT NULL - )""") - - # User Videos (history/saved) - c.execute("""CREATE TABLE IF NOT EXISTS user_videos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, - video_id TEXT, - title TEXT, - thumbnail TEXT, - type TEXT, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(user_id) REFERENCES users(id) - )""") - - # Video Cache - c.execute("""CREATE TABLE IF NOT EXISTS video_cache ( - video_id TEXT PRIMARY KEY, - data TEXT, - expires_at DATETIME - )""") - - conn.commit() - conn.close() - logger.info("Database initialized") - - -def create_app(config_name=None): - """ - Application factory for creating Flask app instances. - - Args: - config_name: Configuration name ('development', 'production', or None for default) - - Returns: - Flask application instance - """ - app = Flask(__name__, - template_folder='../templates', - static_folder='../static') - - # Load configuration - app.secret_key = "super_secret_key_change_this" # Required for sessions - - # Fix for OMP: Error #15 - os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE" - - # Initialize database - init_db() - - # Register Jinja filters - register_filters(app) - - # Register Blueprints - register_blueprints(app) - - # Start Background Cache Warmer (x5 Speedup) - try: - from app.routes.api import start_background_warmer - start_background_warmer() - except Exception as e: - logger.warning(f"Failed to start background warmer: {e}") - - logger.info("KV-Tube app created successfully") - return app - - -def register_filters(app): - """Register custom Jinja2 template filters.""" - - @app.template_filter("format_views") - def format_views(views): - if not views: - return "0" - try: - num = int(views) - if num >= 1000000: - return f"{num / 1000000:.1f}M" - if num >= 1000: - return f"{num / 1000:.0f}K" - return f"{num:,}" - except (ValueError, TypeError) as e: - logger.debug(f"View formatting failed: {e}") - return str(views) - - @app.template_filter("format_date") - def format_date(value): - if not value: - return "Recently" - from datetime import datetime - - try: - # Handle YYYYMMDD - if len(str(value)) == 8 and str(value).isdigit(): - dt = datetime.strptime(str(value), "%Y%m%d") - # Handle Timestamp - elif isinstance(value, (int, float)): - dt = datetime.fromtimestamp(value) - # Handle YYYY-MM-DD - else: - try: - dt = datetime.strptime(str(value), "%Y-%m-%d") - except ValueError: - return str(value) - - now = datetime.now() - diff = now - dt - - if diff.days > 365: - return f"{diff.days // 365} years ago" - if diff.days > 30: - return f"{diff.days // 30} months ago" - if diff.days > 0: - return f"{diff.days} days ago" - if diff.seconds > 3600: - return f"{diff.seconds // 3600} hours ago" - return "Just now" - except Exception as e: - logger.debug(f"Date formatting failed: {e}") - return str(value) - - -def register_blueprints(app): - """Register all application blueprints.""" - from app.routes import pages_bp, api_bp, streaming_bp - - app.register_blueprint(pages_bp) - app.register_blueprint(api_bp) - app.register_blueprint(streaming_bp) - - logger.info("Blueprints registered: pages, api, streaming") diff --git a/app/routes/__init__.py b/app/routes/__init__.py deleted file mode 100755 index 90bae12..0000000 --- a/app/routes/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -KV-Tube Routes Package -Exports all Blueprints for registration -""" -from app.routes.pages import pages_bp -from app.routes.api import api_bp -from app.routes.streaming import streaming_bp - -__all__ = ['pages_bp', 'api_bp', 'streaming_bp'] diff --git a/app/routes/api.py b/app/routes/api.py deleted file mode 100755 index c33f826..0000000 --- a/app/routes/api.py +++ /dev/null @@ -1,1785 +0,0 @@ -""" -KV-Tube API Blueprint -All JSON API endpoints for the frontend -""" -from flask import Blueprint, request, jsonify, Response -import os -import sys -import subprocess -import json -import sqlite3 -import re -import heapq -import logging -import time -import random -import concurrent.futures -import yt_dlp -from app.services.settings import SettingsService -from app.services.summarizer import TextRankSummarizer -from app.services.gemini_summarizer import summarize_with_gemini, extract_key_points_with_gemini -from app.services.youtube import YouTubeService -from app.services.transcript_service import TranscriptService - - -logger = logging.getLogger(__name__) - -api_bp = Blueprint('api', __name__, url_prefix='/api') - -# Database path -DATA_DIR = os.environ.get("KVTUBE_DATA_DIR", "data") -DB_NAME = os.path.join(DATA_DIR, "kvtube.db") - -# Caching -API_CACHE = {} -CACHE_TIMEOUT = 60 # 1 minute for fresher content - - - -def get_db_connection(): - """Get database connection with row factory.""" - conn = sqlite3.connect(DB_NAME) - conn.row_factory = sqlite3.Row - return conn - - -# --- Helper Functions --- - -def extractive_summary(text, num_sentences=5): - """Extract key sentences from text using word frequency.""" - # Clean text - clean_text = re.sub(r"\[.*?\]", "", text) - clean_text = clean_text.replace("\n", " ") - - # Split into sentences - sentences = re.split(r"(? 1024**3: - size_str = f"{f_filesize / 1024**3:.1f} GB" - elif f_filesize > 1024**2: - size_str = f"{f_filesize / 1024**2:.1f} MB" - elif f_filesize > 1024: - size_str = f"{f_filesize / 1024:.1f} KB" - - if f_ext in ["mp4", "webm"]: - vcodec = f.get("vcodec", "none") - acodec = f.get("acodec", "none") - - if vcodec != "none" and acodec != "none": - video_formats.append({ - "quality": f"{quality} (with audio)", - "ext": f_ext, - "size": size_str, - "url": f_url, - "type": "combined", - "has_audio": True, - }) - elif vcodec != "none": - video_formats.append({ - "quality": quality, - "ext": f_ext, - "size": size_str, - "url": f_url, - "type": "video", - "has_audio": False, - }) - elif acodec != "none": - audio_formats.append({ - "quality": quality, - "ext": f_ext, - "size": size_str, - "url": f_url, - "type": "audio", - }) - - def parse_quality(f): - q = f["quality"].lower() - for i, res in enumerate(["4k", "2160", "1080", "720", "480", "360", "240", "144"]): - if res in q: - return i - return 99 - - video_formats.sort(key=parse_quality) - audio_formats.sort(key=parse_quality) - - return jsonify({ - "success": True, - "video_id": video_id, - "title": title, - "duration": duration, - "thumbnail": thumbnail, - "formats": {"video": video_formats[:10], "audio": audio_formats[:5]}, - }) - - except Exception as e: - logger.error(f"Download formats error: {e}") - return jsonify({"success": False, "error": str(e)}), 500 - - -@api_bp.route("/get_stream_info") -def get_stream_info(): - """Get video stream info with caching.""" - video_id = request.args.get("v") - if not video_id: - return jsonify({"error": "No video ID"}), 400 - - try: - conn = get_db_connection() - cached = conn.execute( - "SELECT data, expires_at FROM video_cache WHERE video_id = ?", (video_id,) - ).fetchone() - - current_time = time.time() - if cached: - try: - expires_at = float(cached["expires_at"]) - if current_time < expires_at: - data = json.loads(cached["data"]) - conn.close() - from urllib.parse import quote - proxied_url = f"/video_proxy?url={quote(data['original_url'], safe='')}" - data["stream_url"] = proxied_url - response = jsonify(data) - response.headers["X-Cache"] = "HIT" - return response - except (ValueError, KeyError): - pass - - # Use YouTubeService which handles failover (Local -> Remote) - info = YouTubeService.get_video_info(video_id) - - if not info: - return jsonify({"error": "Failed to fetch video info from all engines"}), 500 - - stream_url = info.get("stream_url") - if not stream_url: - return jsonify({"error": "No stream URL found"}), 500 - - response_data = { - "original_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": info.get("subtitle_url"), - "related": [], - } - - from urllib.parse import quote - - # Encode headers into the proxy URL - http_headers = info.get("http_headers", {}) - header_params = "" - for k, v in http_headers.items(): - # Only pass critical headers that might affect access - if k.lower() in ['user-agent', 'cookie', 'referer', 'origin']: - header_params += f"&h_{quote(k)}={quote(v)}" - - proxied_url = f"/video_proxy?url={quote(stream_url, safe='')}{header_params}" - response_data["stream_url"] = proxied_url - - - - # Cache it - expiry = current_time + 3600 - conn.execute( - "INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)", - (video_id, json.dumps(response_data), expiry), - ) - conn.commit() - conn.close() - - response = jsonify(response_data) - response.headers["X-Cache"] = "MISS" - return response - - except Exception as e: - return jsonify({"error": str(e)}), 500 - - -@api_bp.route("/stream/qualities") -def get_stream_qualities(): - """Get available stream qualities for a video with proxied URLs.""" - video_id = request.args.get("v") - if not video_id: - return jsonify({"success": False, "error": "No video ID"}), 400 - - try: - url = f"https://www.youtube.com/watch?v={video_id}" - ydl_opts = { - "format": "best", - "noplaylist": True, - "quiet": True, - "no_warnings": 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) - - qualities = [] - seen_resolutions = set() - - # Sort formats by quality (highest first) - formats = info.get("formats", []) - - for f in formats: - f_url = f.get("url", "") - if not f_url or "m3u8" in f_url: - continue - - # Only include formats with both video and audio (progressive) - vcodec = f.get("vcodec", "none") - acodec = f.get("acodec", "none") - - if vcodec == "none" or acodec == "none": - continue - - f_ext = f.get("ext", "") - if f_ext not in ["mp4", "webm"]: - continue - - # Get resolution label - height = f.get("height", 0) - format_note = f.get("format_note", "") - - if height: - label = f"{height}p" - elif format_note: - label = format_note - else: - continue - - # Skip duplicates - if label in seen_resolutions: - continue - seen_resolutions.add(label) - - # Create proxied URL - from urllib.parse import quote - proxied_url = f"/video_proxy?url={quote(f_url, safe='')}" - - qualities.append({ - "label": label, - "height": height, - "url": proxied_url, - "ext": f_ext, - }) - - # Sort by height descending (best first) - qualities.sort(key=lambda x: x.get("height", 0), reverse=True) - - # Add "Auto" option at the beginning (uses best available) - if qualities: - auto_quality = { - "label": "Auto", - "height": 9999, # Highest priority - "url": qualities[0]["url"], # Use best quality - "ext": qualities[0]["ext"], - "default": True, - } - qualities.insert(0, auto_quality) - - return jsonify({ - "success": True, - "video_id": video_id, - "qualities": qualities[:8], # Limit to 8 options - }) - - except Exception as e: - logger.error(f"Stream qualities error: {e}") - return jsonify({"success": False, "error": str(e)}), 500 - - -@api_bp.route("/search") -def search(): - """Search for videos.""" - query = request.args.get("q") - if not query: - return jsonify({"error": "No query provided"}), 400 - - try: - # Check if URL - url_match = re.match(r"(?:https?://)?(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})", query) - if url_match: - video_id = url_match.group(1) - # Fetch single video info - ydl_opts = { - "quiet": True, - "no_warnings": True, - "noplaylist": True, - "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", - } - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(f"https://www.youtube.com/watch?v={video_id}", download=False) - return jsonify([{ - "id": video_id, - "title": info.get("title", "Unknown"), - "uploader": info.get("uploader", "Unknown"), - "thumbnail": f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg", - "view_count": info.get("view_count", 0), - "upload_date": info.get("upload_date", ""), - "duration": None, - }]) - - # Standard search - results = fetch_videos(query, limit=20, filter_type="video") - return jsonify(results) - - except Exception as e: - logger.error(f"Search Error: {e}") - return jsonify({"error": str(e)}), 500 - - -@api_bp.route("/channel") -def get_channel_videos_simple(): - """Get videos from a channel.""" - channel_id = request.args.get("id") - filter_type = request.args.get("filter_type", "video") - if not channel_id: - return jsonify({"error": "No channel ID provided"}), 400 - - try: - # Construct URL - suffix = "shorts" if filter_type == "shorts" else "videos" - - if channel_id.startswith("UC"): - url = f"https://www.youtube.com/channel/{channel_id}/{suffix}" - elif channel_id.startswith("@"): - url = f"https://www.youtube.com/{channel_id}/{suffix}" - else: - url = f"https://www.youtube.com/channel/{channel_id}/{suffix}" - - cmd = [ - sys.executable, "-m", "yt_dlp", - url, - "--dump-json", - "--flat-playlist", - "--playlist-end", "20", - "--no-warnings", - ] - - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - stdout, stderr = proc.communicate() - - videos = [] - for line in stdout.splitlines(): - try: - v = json.loads(line) - dur_str = None - if v.get("duration"): - m, s = divmod(int(v["duration"]), 60) - h, m = divmod(m, 60) - dur_str = f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}" - - videos.append({ - "id": v.get("id"), - "title": v.get("title"), - "thumbnail": f"https://i.ytimg.com/vi/{v.get('id')}/mqdefault.jpg", - "view_count": v.get("view_count") or 0, - "duration": dur_str, - "upload_date": v.get("upload_date"), - "uploader": v.get("uploader") or v.get("channel") or "", - }) - except json.JSONDecodeError: - continue - - return jsonify(videos) - - except Exception as e: - logger.error(f"Channel Fetch Error: {e}") - return jsonify({"error": str(e)}), 500 - - -@api_bp.route("/trending") -def trending(): - """Get trending videos.""" - from flask import current_app - - category = request.args.get("category", "all") - page = int(request.args.get("page", 1)) - sort = request.args.get("sort", "newest") - region = request.args.get("region", "vietnam") - - cache_key = f"trending_{category}_{page}_{sort}_{region}" - - # Check cache - if cache_key in API_CACHE: - cached_time, cached_data = API_CACHE[cache_key] - if time.time() - cached_time < CACHE_TIMEOUT: - return jsonify(cached_data) - - try: - # Category search queries - queries = { - "all": "trending videos 2024", - "music": "music trending", - "gaming": "gaming trending", - "news": "news today", - "tech": "technology reviews 2024", - "movies": "movie trailers 2024", - "sports": "sports highlights", - } - - # For 'all' category, always fetch from multiple categories for diverse content - if category == "all": - region_suffix = " vietnam" if region == "vietnam" else "" - - # Rotate through different queries based on page for variety - query_sets = [ - [f"trending videos 2024{region_suffix}", f"music trending{region_suffix}", f"tech reviews 2024{region_suffix}"], - [f"movie trailers 2024{region_suffix}", f"gaming trending{region_suffix}", f"sports highlights{region_suffix}"], - [f"trending music 2024{region_suffix}", f"viral videos{region_suffix}", f"entertainment news{region_suffix}"], - [f"tech gadgets{region_suffix}", f"comedy videos{region_suffix}", f"documentary{region_suffix}"], - ] - - # Use different query set based on page to get variety - query_index = (page - 1) % len(query_sets) - current_queries = query_sets[query_index] - - # Calculate offset within query set - start_offset = ((page - 1) // len(query_sets)) * 7 + 1 - - # Fetch from multiple categories in parallel - with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: - futures = [ - executor.submit(fetch_videos, q, limit=7, filter_type="video", playlist_start=start_offset) - for q in current_queries - ] - results = [f.result() for f in futures] - - # Combine all videos and deduplicate - all_videos = [] - seen_ids = set() - - for video_list in results: - for vid in video_list: - if vid['id'] not in seen_ids: - seen_ids.add(vid['id']) - all_videos.append(vid) - - # Shuffle for variety - random.shuffle(all_videos) - - # Cache result - API_CACHE[cache_key] = (time.time(), all_videos) - return jsonify(all_videos) - - # Single category - support proper pagination - query = queries.get(category, queries["all"]) - if region == "vietnam": - query += " vietnam" - - videos = fetch_videos(query, limit=20, filter_type="video", playlist_start=(page-1)*20+1) - - # Cache result - API_CACHE[cache_key] = (time.time(), videos) - - return jsonify(videos) - - except Exception as e: - return jsonify({"error": str(e)}), 500 - - -@api_bp.route("/transcript") -def get_transcript(): - """Get video transcript (VTT).""" - video_id = request.args.get("v") - if not video_id: - return "No video ID", 400 - - try: - url = f"https://www.youtube.com/watch?v={video_id}" - # Use yt-dlp to get subtitles - cmd = [ - sys.executable, "-m", "yt_dlp", - url, - "--write-auto-sub", - "--sub-lang", "en,vi", - "--skip-download", - "--no-warnings", - "--quiet", - "--sub-format", "vtt", - "--output", "CAPTIONS_%(id)s" - ] - - # We need to run this in a temp dir or handle output names - # Simplified: fetch info and get subtitle URL - - # Better approach: Get subtitle URL from extract_info - with yt_dlp.YoutubeDL({'quiet': True, 'skip_download': True}) as ydl: - info = ydl.extract_info(url, download=False) - subtitles = info.get('subtitles') or info.get('automatic_captions') or {} - - # Prefer English, then Vietnamese, then any - lang = 'en' - if 'en' not in subtitles and 'vi' in subtitles: - lang = 'vi' - elif 'en' not in subtitles: - # Pick first available - langs = list(subtitles.keys()) - if langs: - lang = langs[0] - - if lang and lang in subtitles: - subs_list = subtitles[lang] - # Find vtt - vtt_url = next((s['url'] for s in subs_list if s.get('ext') == 'vtt'), None) - if not vtt_url: - vtt_url = subs_list[0]['url'] # Fallback - - # Fetch the VTT content - import requests - res = requests.get(vtt_url) - return Response(res.content, mimetype="text/vtt") - - return "No transcript available", 404 - - except Exception as e: - logger.error(f"Transcript error: {e}") - return str(e), 500 - - -@api_bp.route("/summarize") -def summarize_video(): - """Get video summary from transcript using AI (Gemini) or TextRank fallback.""" - video_id = request.args.get("v") - video_title = request.args.get("title", "") - translate_to = request.args.get("lang") # Optional: 'vi' for Vietnamese - - if not video_id: - return jsonify({"error": "No video ID"}), 400 - - try: - # 1. Get Transcript Text using TranscriptService (with ytfetcher fallback) - text = TranscriptService.get_transcript(video_id) - if not text: - return jsonify({ - "success": False, - "error": "No transcript available to summarize." - }) - - # 2. Use TextRank Summarizer - generate longer, more meaningful summaries - summarizer = TextRankSummarizer() - summary_text = summarizer.summarize(text, num_sentences=5) # Increased from 3 to 5 - - # Allow longer summaries for more meaningful content (600 chars instead of 300) - if len(summary_text) > 600: - summary_text = summary_text[:597] + "..." - - # Key points will be extracted by WebLLM on frontend (better quality) - # Backend just returns empty list - WebLLM generates conceptual key points - key_points = [] - - # Store original versions - original_summary = summary_text - original_key_points = key_points.copy() if key_points else [] - - # 3. Translate if requested - translated_summary = None - translated_key_points = None - - if translate_to == 'vi': - try: - translated_summary = translate_text(summary_text, 'vi') - translated_key_points = [translate_text(p, 'vi') for p in key_points] if key_points else [] - except Exception as te: - logger.warning(f"Translation failed: {te}") - - # 4. Return structured data - return jsonify({ - "success": True, - "summary": original_summary, - "key_points": original_key_points, - "translated_summary": translated_summary, - "translated_key_points": translated_key_points, - "lang": translate_to or "en", - "video_id": video_id, - "ai_powered": False - }) - except Exception as e: - logger.error(f"Summarization error: {e}") - return jsonify({"success": False, "error": str(e)}) - - -def translate_text(text, target_lang='vi'): - """Translate text to target language using Google Translate.""" - try: - from googletrans import Translator - - translator = Translator() - result = translator.translate(text, dest=target_lang) - return result.text - - except Exception as e: - logger.error(f"Translation error: {e}") - return text # Return original text if translation fails - - -def get_transcript_text(video_id): - """ - Fetch transcript using yt-dlp (downloading subtitles to file). - Reliable method that handles auto-generated captions and cookies. - """ - import yt_dlp - import glob - import random - import json - import os - - try: - video_id = video_id.strip() - logger.info(f"Fetching transcript for {video_id} using yt-dlp") - - # Use a temporary filename pattern - temp_prefix = f"transcript_{video_id}_{random.randint(1000, 9999)}" - - ydl_opts = { - 'skip_download': True, - 'quiet': True, - 'no_warnings': True, - 'cookiefile': os.environ.get('COOKIES_FILE', 'cookies.txt') if os.path.exists(os.environ.get('COOKIES_FILE', 'cookies.txt')) else None, - 'writesubtitles': True, - 'writeautomaticsub': True, - 'subtitleslangs': ['en', 'vi', 'en-US'], - 'outtmpl': f"/tmp/{temp_prefix}", # Save to /tmp - 'subtitlesformat': 'json3/vtt/best', # Prefer json3 for parsing, then vtt - } - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - # This will download the subtitle file to /tmp/ - ydl.download([f"https://www.youtube.com/watch?v={video_id}"]) - - # Find the downloaded file - # yt-dlp appends language code, e.g. .en.json3 - # We look for any file with our prefix - downloaded_files = glob.glob(f"/tmp/{temp_prefix}*") - - if not downloaded_files: - logger.warning("yt-dlp finished but no subtitle file found.") - return None - - # Pick the best file (prefer json3, then vtt) - selected_file = None - for ext in ['.json3', '.vtt', '.ttml', '.srv3']: - for f in downloaded_files: - if f.endswith(ext): - selected_file = f - break - if selected_file: break - - if not selected_file: - selected_file = downloaded_files[0] - - # Read content - with open(selected_file, 'r', encoding='utf-8') as f: - content = f.read() - - # Cleanup - for f in downloaded_files: - try: - os.remove(f) - except: - pass - - # Parse - if selected_file.endswith('.json3') or content.strip().startswith('{'): - try: - json_data = json.loads(content) - events = json_data.get('events', []) - text_parts = [] - for event in events: - segs = event.get('segs', []) - for seg in segs: - txt = seg.get('utf8', '').strip() - if txt and txt != '\n': - text_parts.append(txt) - return " ".join(text_parts) - except Exception as je: - logger.warning(f"JSON3 parse failed: {je}") - - return parse_transcript_content(content) - - except Exception as e: - logger.error(f"Transcript fetch failed: {e}") - - return None - -def parse_transcript_content(content): - """Helper to parse VTT/XML content.""" - try: - # Simple VTT cleaner - lines = content.splitlines() - text_lines = [] - seen = set() - - for line in lines: - line = line.strip() - if not line: continue - if "-->" in line: continue - if line.isdigit(): continue - if line.startswith("WEBVTT"): continue - if line.startswith("Kind:"): continue - if line.startswith("Language:"): continue - - # Remove tags like or <00:00:00> - clean = re.sub(r'<[^>]+>', '', line) - if clean and clean not in seen: - seen.add(clean) - text_lines.append(clean) - - return " ".join(text_lines) - - except Exception as e: - logger.error(f"Transcript parse error: {e}") - return None - - - - - - - - -@api_bp.route("/update_ytdlp", methods=["POST"]) -def update_ytdlp(): - """Update yt-dlp to latest version.""" - try: - cmd = [sys.executable, "-m", "pip", "install", "-U", "yt-dlp"] - result = subprocess.run(cmd, capture_output=True, text=True) - - if result.returncode == 0: - ver_cmd = [sys.executable, "-m", "yt_dlp", "--version"] - ver_result = subprocess.run(ver_cmd, capture_output=True, text=True) - version = ver_result.stdout.strip() - return jsonify({"success": True, "message": f"Updated to {version}"}) - else: - return jsonify({"success": False, "message": f"Update failed: {result.stderr}"}), 500 - except Exception as e: - return jsonify({"success": False, "message": str(e)}), 500 - - -@api_bp.route("/update_package", methods=["POST"]) -def update_package(): - """Update a Python package (yt-dlp stable/nightly, ytfetcher).""" - try: - data = request.json or {} - pkg = data.get("package", "ytdlp") - version = data.get("version", "stable") - - if pkg == "ytdlp": - if version == "nightly": - # Install nightly/master from GitHub - # Force reinstall and NO CACHE to ensure we get the latest commit - cmd = [sys.executable, "-m", "pip", "install", - "--no-cache-dir", "--force-reinstall", "-U", - "https://github.com/yt-dlp/yt-dlp/archive/master.tar.gz"] - else: - # Install stable from PyPI - cmd = [sys.executable, "-m", "pip", "install", "-U", "yt-dlp"] - - result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) - - if result.returncode == 0: - ver_cmd = [sys.executable, "-m", "yt_dlp", "--version"] - ver_result = subprocess.run(ver_cmd, capture_output=True, text=True) - ver_str = ver_result.stdout.strip() - suffix = " (nightly)" if version == "nightly" else "" - return jsonify({"success": True, "message": f"yt-dlp updated to {ver_str}{suffix}"}) - else: - return jsonify({"success": False, "message": f"Update failed: {result.stderr[:200]}"}), 500 - - elif pkg == "ytfetcher": - # Install/update ytfetcher from GitHub - cmd = [sys.executable, "-m", "pip", "install", "-U", - "git+https://github.com/kaya70875/ytfetcher.git"] - result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) - - if result.returncode == 0: - return jsonify({"success": True, "message": "ytfetcher updated successfully"}) - else: - return jsonify({"success": False, "message": f"Update failed: {result.stderr[:200]}"}), 500 - else: - return jsonify({"success": False, "message": f"Unknown package: {pkg}"}), 400 - - except subprocess.TimeoutExpired: - return jsonify({"success": False, "message": "Update timed out"}), 500 - except Exception as e: - return jsonify({"success": False, "message": str(e)}), 500 - - -@api_bp.route("/comments") -def get_comments(): - """Get comments for a video.""" - video_id = request.args.get("v") - if not video_id: - return jsonify({"error": "No video ID"}), 400 - - try: - url = f"https://www.youtube.com/watch?v={video_id}" - cmd = [ - sys.executable, "-m", "yt_dlp", - url, - "--write-comments", - "--skip-download", - "--dump-json", - ] - - result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) - - if result.returncode == 0: - data = json.loads(result.stdout) - comments_data = data.get("comments", []) - - comments = [] - for c in comments_data[:50]: - comments.append({ - "author": c.get("author", "Unknown"), - "author_thumbnail": c.get("author_thumbnail", ""), - "text": c.get("text", ""), - "likes": c.get("like_count", 0), - "time": c.get("time_text", ""), - "is_pinned": c.get("is_pinned", False), - }) - - return jsonify({"comments": comments, "count": data.get("comment_count", len(comments))}) - else: - return jsonify({"comments": [], "count": 0, "error": "Could not load comments"}) - - except subprocess.TimeoutExpired: - return jsonify({"comments": [], "count": 0, "error": "Comments loading timed out"}) - except Exception as e: - return jsonify({"comments": [], "count": 0, "error": str(e)}) - - - - -@api_bp.route("/settings", methods=["GET"]) -def get_settings(): - """Get all settings.""" - return jsonify(SettingsService.get_all()) - - -@api_bp.route("/package/version") -def get_package_version(): - """Get version of a package.""" - pkg = request.args.get("package", "yt_dlp") - - try: - if pkg == "yt_dlp" or pkg == "ytdlp": - import yt_dlp - version = yt_dlp.version.__version__ - # Check if it looks like nightly (contains dev or current date) - return jsonify({"success": True, "package": "yt-dlp", "version": version}) - elif pkg == "ytfetcher": - try: - import ytfetcher - # ytfetcher might not have __version__ exposed easily, but let's try - version = getattr(ytfetcher, "__version__", "installed") - return jsonify({"success": True, "package": "ytfetcher", "version": version}) - except ImportError: - return jsonify({"success": False, "package": "ytfetcher", "version": "not installed"}) - else: - return jsonify({"error": "Unknown package"}), 400 - except Exception as e: - return jsonify({"success": False, "error": str(e)}), 500 - - -@api_bp.route("/settings", methods=["POST"]) -def update_settings(): - """Update a setting.""" - data = request.json - if not data or 'key' not in data or 'value' not in data: - return jsonify({"error": "Invalid request"}), 400 - - try: - SettingsService.set(data['key'], data['value']) - return jsonify({"success": True}) - except Exception as e: - return jsonify({"error": str(e)}), 500 - - -@api_bp.route("/settings/test", methods=["POST"]) -def test_engine(): - """Test the current engine configuration.""" - from app.services.youtube import YouTubeService - - # Use a known safe video (Me at the zoo) - TEST_VID = "jNQXAC9IVRw" - - try: - # Force a fresh fetch ignoring cache logic if possible - # We just call get_video_info which uses the current SettingsService engine - info = YouTubeService.get_video_info(TEST_VID) - - if info and info.get('stream_url'): - return jsonify({ - "success": True, - "message": f"Successfully fetched via {SettingsService.get('youtube_engine', 'auto')}", - "details": { - "title": info.get('title'), - "engine": SettingsService.get('youtube_engine', 'auto') - } - }) - else: - return jsonify({ - "success": False, - "message": "Fetch returned no data" - }) - - except Exception as e: - return jsonify({"success": False, "message": str(e)}) diff --git a/app/routes/pages.py b/app/routes/pages.py deleted file mode 100755 index bf06eb8..0000000 --- a/app/routes/pages.py +++ /dev/null @@ -1,172 +0,0 @@ -""" -KV-Tube Pages Blueprint -HTML page routes for the web interface -""" -from flask import Blueprint, render_template, request, url_for - -pages_bp = Blueprint('pages', __name__) - - -@pages_bp.route("/") -def index(): - """Home page with trending videos.""" - return render_template("index.html", page="home") - - -@pages_bp.route("/results") -def results(): - """Search results page.""" - query = request.args.get("search_query", "") - return render_template("index.html", page="results", query=query) - - -@pages_bp.route("/my-videos") -def my_videos(): - """User's saved videos page (client-side rendered).""" - return render_template("my_videos.html") - - -@pages_bp.route("/settings") -def settings(): - """Settings page.""" - return render_template("settings.html", page="settings") - - -@pages_bp.route("/downloads") -def downloads(): - """Downloads page.""" - return render_template("downloads.html", page="downloads") - - -@pages_bp.route("/watch") -def watch(): - """Video watch page.""" - from flask import url_for as flask_url_for - - video_id = request.args.get("v") - local_file = request.args.get("local") - - if local_file: - return render_template( - "watch.html", - video_type="local", - src=flask_url_for("streaming.stream_local", filename=local_file), - title=local_file, - ) - - if not video_id: - return "No video ID provided", 400 - return render_template("watch.html", video_type="youtube", video_id=video_id) - - -@pages_bp.route("/channel/") -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 diff --git a/app/routes/streaming.py b/app/routes/streaming.py deleted file mode 100755 index 2c38365..0000000 --- a/app/routes/streaming.py +++ /dev/null @@ -1,164 +0,0 @@ -""" -KV-Tube Streaming Blueprint -Video streaming and proxy routes -""" -from flask import Blueprint, request, Response, stream_with_context, send_from_directory -import requests -import os -import logging -import socket -import urllib3.util.connection as urllib3_cn - -# Force IPv4 for requests (which uses urllib3) -def allowed_gai_family(): - return socket.AF_INET - -urllib3_cn.allowed_gai_family = allowed_gai_family - -logger = logging.getLogger(__name__) - -streaming_bp = Blueprint('streaming', __name__) - -# Configuration for local video path -VIDEO_DIR = os.environ.get("KVTUBE_VIDEO_DIR", "./videos") - - -@streaming_bp.route("/stream/") -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 - diff --git a/app/services/__init__.py b/app/services/__init__.py deleted file mode 100755 index c0a71cc..0000000 --- a/app/services/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""KV-Tube Services Package""" diff --git a/app/services/cache.py b/app/services/cache.py deleted file mode 100755 index d73d459..0000000 --- a/app/services/cache.py +++ /dev/null @@ -1,217 +0,0 @@ -""" -Cache Service Module -SQLite-based caching with connection pooling -""" -import sqlite3 -import json -import time -import threading -import logging -from typing import Optional, Any, Dict -from contextlib import contextmanager -from config import Config - -logger = logging.getLogger(__name__) - - -class ConnectionPool: - """Thread-safe SQLite connection pool""" - - def __init__(self, db_path: str, max_connections: int = 5): - self.db_path = db_path - self.max_connections = max_connections - self._local = threading.local() - self._lock = threading.Lock() - self._init_db() - - def _init_db(self): - """Initialize database tables""" - conn = sqlite3.connect(self.db_path) - c = conn.cursor() - - # Users table - c.execute('''CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password TEXT NOT NULL - )''') - - # User videos (history/saved) - c.execute('''CREATE TABLE IF NOT EXISTS user_videos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, - video_id TEXT, - title TEXT, - thumbnail TEXT, - type TEXT, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(user_id) REFERENCES users(id) - )''') - - # Video cache - c.execute('''CREATE TABLE IF NOT EXISTS video_cache ( - video_id TEXT PRIMARY KEY, - data TEXT, - expires_at REAL - )''') - - conn.commit() - conn.close() - - def get_connection(self) -> sqlite3.Connection: - """Get a thread-local database connection""" - if not hasattr(self._local, 'connection') or self._local.connection is None: - self._local.connection = sqlite3.connect(self.db_path) - self._local.connection.row_factory = sqlite3.Row - return self._local.connection - - @contextmanager - def connection(self): - """Context manager for database connections""" - conn = self.get_connection() - try: - yield conn - conn.commit() - except Exception as e: - conn.rollback() - logger.error(f"Database error: {e}") - raise - - def close(self): - """Close the thread-local connection""" - if hasattr(self._local, 'connection') and self._local.connection: - self._local.connection.close() - self._local.connection = None - - -# Global connection pool -_pool: Optional[ConnectionPool] = None - - -def get_pool() -> ConnectionPool: - """Get or create the global connection pool""" - global _pool - if _pool is None: - _pool = ConnectionPool(Config.DB_NAME) - return _pool - - -def get_db_connection() -> sqlite3.Connection: - """Get a database connection - backward compatibility""" - return get_pool().get_connection() - - -class CacheService: - """Service for caching video metadata""" - - @staticmethod - def get_video_cache(video_id: str) -> Optional[Dict[str, Any]]: - """ - Get cached video data if not expired - - Args: - video_id: YouTube video ID - - Returns: - Cached data dict or None if not found/expired - """ - try: - pool = get_pool() - with pool.connection() as conn: - row = conn.execute( - 'SELECT data, expires_at FROM video_cache WHERE video_id = ?', - (video_id,) - ).fetchone() - - if row: - expires_at = float(row['expires_at']) - if time.time() < expires_at: - return json.loads(row['data']) - else: - # Expired, clean it up - conn.execute('DELETE FROM video_cache WHERE video_id = ?', (video_id,)) - - return None - - except Exception as e: - logger.error(f"Cache get error for {video_id}: {e}") - return None - - @staticmethod - def set_video_cache(video_id: str, data: Dict[str, Any], ttl: int = None) -> bool: - """ - Cache video data - - Args: - video_id: YouTube video ID - data: Data to cache - ttl: Time to live in seconds (default from config) - - Returns: - True if cached successfully - """ - try: - if ttl is None: - ttl = Config.CACHE_VIDEO_TTL - - expires_at = time.time() + ttl - - pool = get_pool() - with pool.connection() as conn: - conn.execute( - 'INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)', - (video_id, json.dumps(data), expires_at) - ) - - return True - - except Exception as e: - logger.error(f"Cache set error for {video_id}: {e}") - return False - - @staticmethod - def clear_expired(): - """Remove all expired cache entries""" - try: - pool = get_pool() - with pool.connection() as conn: - conn.execute('DELETE FROM video_cache WHERE expires_at < ?', (time.time(),)) - - except Exception as e: - logger.error(f"Cache cleanup error: {e}") - - -class HistoryService: - """Service for user video history""" - - @staticmethod - def get_history(limit: int = 50) -> list: - """Get watch history""" - try: - pool = get_pool() - with pool.connection() as conn: - rows = conn.execute( - 'SELECT video_id as id, title, thumbnail FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT ?', - (limit,) - ).fetchall() - return [dict(row) for row in rows] - - except Exception as e: - logger.error(f"History get error: {e}") - return [] - - @staticmethod - def add_to_history(video_id: str, title: str, thumbnail: str) -> bool: - """Add a video to history""" - try: - pool = get_pool() - with pool.connection() as conn: - conn.execute( - 'INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (?, ?, ?, ?, ?)', - (1, video_id, title, thumbnail, 'history') - ) - return True - - except Exception as e: - logger.error(f"History add error: {e}") - return False diff --git a/app/services/gemini_summarizer.py b/app/services/gemini_summarizer.py deleted file mode 100755 index 827a21c..0000000 --- a/app/services/gemini_summarizer.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -AI-powered video summarizer using Google Gemini. -""" -import os -import logging -import base64 -from typing import Optional - -logger = logging.getLogger(__name__) - -# Obfuscated API key - encoded with app-specific salt -# This prevents casual copying but is not cryptographically secure -_OBFUSCATED_KEY = "QklqYVN5RG9yLWpsdmhtMEVGVkxnV3F4TllFR0MyR21oQUY3Y3Rv" -_APP_SALT = "KV-Tube-2026" - -def _decode_api_key() -> str: - """Decode the obfuscated API key. Only works with correct app context.""" - try: - # Decode base64 - decoded = base64.b64decode(_OBFUSCATED_KEY).decode('utf-8') - # Remove prefix added during encoding - if decoded.startswith("Bij"): - return "AI" + decoded[3:] # Reconstruct original key - return decoded - except: - return "" - -# Get API key: prefer environment variable, fall back to obfuscated default -GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") or _decode_api_key() - -def summarize_with_gemini(transcript: str, video_title: str = "") -> Optional[str]: - """ - Summarize video transcript using Google Gemini AI. - - Args: - transcript: The video transcript text - video_title: Optional video title for context - - Returns: - AI-generated summary or None if failed - """ - if not GEMINI_API_KEY: - logger.warning("GEMINI_API_KEY not set, falling back to TextRank") - return None - - try: - logger.info(f"Importing google.generativeai... Key len: {len(GEMINI_API_KEY)}") - import google.generativeai as genai - - genai.configure(api_key=GEMINI_API_KEY) - logger.info("Gemini configured. Creating model...") - model = genai.GenerativeModel('gemini-1.5-flash') - - # Limit transcript to avoid token limits - max_chars = 8000 - if len(transcript) > max_chars: - transcript = transcript[:max_chars] + "..." - - logger.info(f"Generating summary content... Transcript len: {len(transcript)}") - # Create prompt for summarization - prompt = f"""You are a helpful AI assistant. Summarize the following video transcript in 2-3 concise sentences. -Focus on the main topic and key points. If it's a music video, describe the song's theme and mood instead of quoting lyrics. - -Video Title: {video_title if video_title else 'Unknown'} - -Transcript: -{transcript} - -Provide a brief, informative summary (2-3 sentences max):""" - - response = model.generate_content(prompt) - logger.info("Gemini response received.") - - if response and response.text: - summary = response.text.strip() - # Clean up any markdown formatting - summary = summary.replace("**", "").replace("##", "").replace("###", "") - return summary - - return None - - except Exception as e: - logger.error(f"Gemini summarization error: {e}") - return None - - -def extract_key_points_with_gemini(transcript: str, video_title: str = "") -> list: - """ - Extract key points from video transcript using Gemini AI. - - Returns: - List of key points or empty list if failed - """ - if not GEMINI_API_KEY: - return [] - - try: - import google.generativeai as genai - - genai.configure(api_key=GEMINI_API_KEY) - model = genai.GenerativeModel('gemini-1.5-flash') - - # Limit transcript - max_chars = 6000 - if len(transcript) > max_chars: - transcript = transcript[:max_chars] + "..." - - prompt = f"""Extract 3-5 key points from this video transcript. For each point, provide a single short sentence. -If it's a music video, describe the themes, mood, and notable elements instead of quoting lyrics. - -Video Title: {video_title if video_title else 'Unknown'} - -Transcript: -{transcript} - -Key points (one per line, no bullet points or numbers):""" - - response = model.generate_content(prompt) - - if response and response.text: - lines = response.text.strip().split('\n') - # Clean up and filter - points = [] - for line in lines: - line = line.strip().lstrip('•-*123456789.)') - line = line.strip() - if line and len(line) > 10: - points.append(line) - return points[:5] # Max 5 points - - return [] - - except Exception as e: - logger.error(f"Gemini key points error: {e}") - return [] diff --git a/app/services/loader_to.py b/app/services/loader_to.py deleted file mode 100755 index 7d926fe..0000000 --- a/app/services/loader_to.py +++ /dev/null @@ -1,114 +0,0 @@ - -import requests -import time -import logging -import json -from typing import Optional, Dict, Any -from config import Config - -logger = logging.getLogger(__name__) - -class LoaderToService: - """Service for interacting with loader.to / savenow.to API""" - - BASE_URL = "https://p.savenow.to" - DOWNLOAD_ENDPOINT = "/ajax/download.php" - PROGRESS_ENDPOINT = "/api/progress" - - @classmethod - def get_stream_url(cls, video_url: str, format_id: str = "1080") -> Optional[Dict[str, Any]]: - """ - Get download URL for a video via loader.to - - Args: - video_url: Full YouTube URL - format_id: Target format (1080, 720, 4k, etc.) - - Returns: - Dict containing 'stream_url' and available metadata, or None - """ - try: - # 1. Initiate Download - params = { - 'format': format_id, - 'url': video_url, - 'api_key': Config.LOADER_TO_API_KEY - } - - # Using curl-like headers to avoid bot detection - headers = { - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Referer': 'https://loader.to/', - 'Origin': 'https://loader.to' - } - - logger.info(f"Initiating Loader.to fetch for {video_url}") - response = requests.get( - f"{cls.BASE_URL}{cls.DOWNLOAD_ENDPOINT}", - params=params, - headers=headers, - timeout=10 - ) - response.raise_for_status() - data = response.json() - - if not data.get('success') and not data.get('id'): - logger.error(f"Loader.to initial request failed: {data}") - return None - - task_id = data.get('id') - info = data.get('info', {}) - logger.info(f"Loader.to task started: {task_id}") - - # 2. Poll for progress - # Timeout after 60 seconds - start_time = time.time() - while time.time() - start_time < 60: - progress_url = data.get('progress_url') - # If progress_url is missing, construct it manually (fallback) - if not progress_url and task_id: - progress_url = f"{cls.BASE_URL}/api/progress?id={task_id}" - - if not progress_url: - logger.error("No progress URL found") - return None - - p_res = requests.get(progress_url, headers=headers, timeout=10) - if p_res.status_code != 200: - logger.warning(f"Progress check failed: {p_res.status_code}") - time.sleep(2) - continue - - p_data = p_res.json() - - # Check for success (success can be boolean true or int 1) - is_success = p_data.get('success') in [True, 1, '1'] - text_status = p_data.get('text', '').lower() - - if is_success and p_data.get('download_url'): - logger.info("Loader.to extraction successful") - return { - 'stream_url': p_data['download_url'], - 'title': info.get('title') or 'Unknown Title', - 'thumbnail': info.get('image'), - # Add basic fields to match yt-dlp dict structure - 'description': f"Fetched via Loader.to (Format: {format_id})", - 'uploader': 'Unknown', - 'duration': None, - 'view_count': 0 - } - - # Check for failure - if 'error' in text_status or 'failed' in text_status: - logger.error(f"Loader.to task failed: {text_status}") - return None - - # Wait before next poll - time.sleep(2) - - logger.error("Loader.to timed out waiting for video") - return None - - except Exception as e: - logger.error(f"Loader.to service error: {e}") - return None diff --git a/app/services/settings.py b/app/services/settings.py deleted file mode 100755 index 158e040..0000000 --- a/app/services/settings.py +++ /dev/null @@ -1,55 +0,0 @@ - -import json -import os -import logging -from config import Config - -logger = logging.getLogger(__name__) - -class SettingsService: - """Manage application settings using a JSON file""" - - SETTINGS_FILE = os.path.join(Config.DATA_DIR, 'settings.json') - - # Default settings - DEFAULTS = { - 'youtube_engine': 'auto', # auto, local, remote - } - - @classmethod - def _load_settings(cls) -> dict: - """Load settings from file or return defaults""" - try: - if os.path.exists(cls.SETTINGS_FILE): - with open(cls.SETTINGS_FILE, 'r') as f: - data = json.load(f) - # Merge with defaults to ensure all keys exist - return {**cls.DEFAULTS, **data} - except Exception as e: - logger.error(f"Error loading settings: {e}") - - return cls.DEFAULTS.copy() - - @classmethod - def get(cls, key: str, default=None): - """Get a setting value""" - settings = cls._load_settings() - return settings.get(key, default if default is not None else cls.DEFAULTS.get(key)) - - @classmethod - def set(cls, key: str, value): - """Set a setting value and persist""" - settings = cls._load_settings() - settings[key] = value - - try: - with open(cls.SETTINGS_FILE, 'w') as f: - json.dump(settings, f, indent=2) - except Exception as e: - logger.error(f"Error saving settings: {e}") - raise - - @classmethod - def get_all(cls): - """Get all settings""" - return cls._load_settings() diff --git a/app/services/summarizer.py b/app/services/summarizer.py deleted file mode 100755 index 26ba3bd..0000000 --- a/app/services/summarizer.py +++ /dev/null @@ -1,119 +0,0 @@ - -import re -import math -import logging -from typing import List - -logger = logging.getLogger(__name__) - -class TextRankSummarizer: - """ - Summarizes text using a TextRank-like graph algorithm. - This creates more coherent "whole idea" summaries than random extraction. - """ - - def __init__(self): - self.stop_words = set([ - "the", "a", "an", "and", "or", "but", "is", "are", "was", "were", - "to", "of", "in", "on", "at", "for", "width", "that", "this", "it", - "you", "i", "we", "they", "he", "she", "have", "has", "had", "do", - "does", "did", "with", "as", "by", "from", "at", "but", "not", "what", - "all", "were", "when", "can", "said", "there", "use", "an", "each", - "which", "she", "do", "how", "their", "if", "will", "up", "other", - "about", "out", "many", "then", "them", "these", "so", "some", "her", - "would", "make", "like", "him", "into", "time", "has", "look", "two", - "more", "write", "go", "see", "number", "no", "way", "could", "people", - "my", "than", "first", "water", "been", "call", "who", "oil", "its", - "now", "find", "long", "down", "day", "did", "get", "come", "made", - "may", "part" - ]) - - def summarize(self, text: str, num_sentences: int = 5) -> str: - """ - Generate a summary of the text. - - Args: - text: Input text - num_sentences: Number of sentences in the summary - - Returns: - Summarized text string - """ - if not text: - return "" - - # 1. Split into sentences - # Use regex to look for periods/questions/exclamations followed by space or end of string - sentences = re.split(r'(? 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) diff --git a/app/services/transcript_service.py b/app/services/transcript_service.py deleted file mode 100755 index 445dffb..0000000 --- a/app/services/transcript_service.py +++ /dev/null @@ -1,211 +0,0 @@ -""" -Transcript Service Module -Fetches video transcripts with fallback strategy: yt-dlp -> ytfetcher -""" -import os -import re -import glob -import json -import random -import logging -from typing import Optional - -logger = logging.getLogger(__name__) - - -class TranscriptService: - """Service for fetching YouTube video transcripts with fallback support.""" - - @classmethod - def get_transcript(cls, video_id: str) -> Optional[str]: - """ - Get transcript text for a video. - - Strategy: - 1. Try yt-dlp (current method, handles auto-generated captions) - 2. Fallback to ytfetcher library if yt-dlp fails - - Args: - video_id: YouTube video ID - - Returns: - Transcript text or None if unavailable - """ - video_id = video_id.strip() - - # Try yt-dlp first (primary method) - text = cls._fetch_with_ytdlp(video_id) - if text: - logger.info(f"Transcript fetched via yt-dlp for {video_id}") - return text - - # Fallback to ytfetcher - logger.info(f"yt-dlp failed, trying ytfetcher for {video_id}") - text = cls._fetch_with_ytfetcher(video_id) - if text: - logger.info(f"Transcript fetched via ytfetcher for {video_id}") - return text - - logger.warning(f"All transcript methods failed for {video_id}") - return None - - @classmethod - def _fetch_with_ytdlp(cls, video_id: str) -> Optional[str]: - """Fetch transcript using yt-dlp (downloading subtitles to file).""" - import yt_dlp - - try: - logger.info(f"Fetching transcript for {video_id} using yt-dlp") - - # Use a temporary filename pattern - temp_prefix = f"transcript_{video_id}_{random.randint(1000, 9999)}" - - ydl_opts = { - 'skip_download': True, - 'quiet': True, - 'no_warnings': True, - 'cookiefile': os.environ.get('COOKIES_FILE', 'cookies.txt') if os.path.exists(os.environ.get('COOKIES_FILE', 'cookies.txt')) else None, - 'writesubtitles': True, - 'writeautomaticsub': True, - 'subtitleslangs': ['en', 'vi', 'en-US'], - 'outtmpl': f"/tmp/{temp_prefix}", - 'subtitlesformat': 'json3/vtt/best', - } - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - ydl.download([f"https://www.youtube.com/watch?v={video_id}"]) - - # Find the downloaded file - downloaded_files = glob.glob(f"/tmp/{temp_prefix}*") - - if not downloaded_files: - logger.warning("yt-dlp finished but no subtitle file found.") - return None - - # Pick the best file (prefer json3, then vtt) - selected_file = None - for ext in ['.json3', '.vtt', '.ttml', '.srv3']: - for f in downloaded_files: - if f.endswith(ext): - selected_file = f - break - if selected_file: - break - - if not selected_file: - selected_file = downloaded_files[0] - - # Read content - with open(selected_file, 'r', encoding='utf-8') as f: - content = f.read() - - # Cleanup - for f in downloaded_files: - try: - os.remove(f) - except: - pass - - # Parse based on format - if selected_file.endswith('.json3') or content.strip().startswith('{'): - return cls._parse_json3(content) - else: - return cls._parse_vtt(content) - - except Exception as e: - logger.error(f"yt-dlp transcript fetch failed: {e}") - return None - - @classmethod - def _fetch_with_ytfetcher(cls, video_id: str) -> Optional[str]: - """Fetch transcript using ytfetcher library as fallback.""" - try: - from ytfetcher import YTFetcher - - logger.info(f"Using ytfetcher for {video_id}") - - # Create fetcher for single video - fetcher = YTFetcher.from_video_ids(video_ids=[video_id]) - - # Fetch transcripts - data = fetcher.fetch_transcripts() - - if not data: - logger.warning(f"ytfetcher returned no data for {video_id}") - return None - - # Extract text from transcript objects - text_parts = [] - for item in data: - transcripts = getattr(item, 'transcripts', []) or [] - for t in transcripts: - txt = getattr(t, 'text', '') or '' - txt = txt.strip() - if txt and txt != '\n': - text_parts.append(txt) - - if not text_parts: - logger.warning(f"ytfetcher returned empty transcripts for {video_id}") - return None - - return " ".join(text_parts) - - except ImportError: - logger.warning("ytfetcher not installed. Run: pip install ytfetcher") - return None - except Exception as e: - logger.error(f"ytfetcher transcript fetch failed: {e}") - return None - - @staticmethod - def _parse_json3(content: str) -> Optional[str]: - """Parse JSON3 subtitle format.""" - try: - json_data = json.loads(content) - events = json_data.get('events', []) - text_parts = [] - for event in events: - segs = event.get('segs', []) - for seg in segs: - txt = seg.get('utf8', '').strip() - if txt and txt != '\n': - text_parts.append(txt) - return " ".join(text_parts) - except Exception as e: - logger.warning(f"JSON3 parse failed: {e}") - return None - - @staticmethod - def _parse_vtt(content: str) -> Optional[str]: - """Parse VTT/XML subtitle content.""" - try: - lines = content.splitlines() - text_lines = [] - seen = set() - - for line in lines: - line = line.strip() - if not line: - continue - if "-->" in line: - continue - if line.isdigit(): - continue - if line.startswith("WEBVTT"): - continue - if line.startswith("Kind:"): - continue - if line.startswith("Language:"): - continue - - # Remove tags like or <00:00:00> - clean = re.sub(r'<[^>]+>', '', line) - if clean and clean not in seen: - seen.add(clean) - text_lines.append(clean) - - return " ".join(text_lines) - - except Exception as e: - logger.error(f"VTT transcript parse error: {e}") - return None diff --git a/app/services/youtube.py b/app/services/youtube.py deleted file mode 100755 index 8bec92c..0000000 --- a/app/services/youtube.py +++ /dev/null @@ -1,313 +0,0 @@ -""" -YouTube Service Module -Handles all yt-dlp interactions using the library directly (not subprocess) -""" -import yt_dlp -import logging -from typing import Optional, List, Dict, Any -from config import Config -from app.services.loader_to import LoaderToService -from app.services.settings import SettingsService - -logger = logging.getLogger(__name__) - - -class YouTubeService: - """Service for fetching YouTube content using yt-dlp library""" - - # Common yt-dlp options - BASE_OPTS = { - 'quiet': True, - 'no_warnings': True, - 'extract_flat': 'in_playlist', - 'force_ipv4': True, - 'socket_timeout': Config.YTDLP_TIMEOUT, - 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - } - - @staticmethod - def sanitize_video_data(data: Dict[str, Any]) -> Dict[str, Any]: - """Sanitize and format video data from yt-dlp""" - video_id = data.get('id', '') - duration_secs = data.get('duration') - - # Format duration - duration_str = None - if duration_secs: - mins, secs = divmod(int(duration_secs), 60) - hours, mins = divmod(mins, 60) - duration_str = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}" - - return { - 'id': video_id, - 'title': data.get('title', 'Unknown'), - 'uploader': data.get('uploader') or data.get('channel') or 'Unknown', - 'channel_id': data.get('channel_id'), - 'uploader_id': data.get('uploader_id'), - 'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg" if video_id else None, - 'view_count': data.get('view_count', 0), - 'upload_date': data.get('upload_date', ''), - 'duration': duration_str, - 'description': data.get('description', ''), - } - - @classmethod - def search_videos(cls, query: str, limit: int = 20, filter_type: Optional[str] = None) -> List[Dict[str, Any]]: - """ - Search for videos using yt-dlp library directly - - Args: - query: Search query - limit: Maximum number of results - filter_type: 'video' to exclude shorts, 'short' for only shorts - - Returns: - List of sanitized video data dictionaries - """ - try: - search_url = f"ytsearch{limit}:{query}" - - ydl_opts = { - **cls.BASE_OPTS, - 'extract_flat': True, - 'playlist_items': f'1:{limit}', - } - - results = [] - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(search_url, download=False) - entries = info.get('entries', []) if info else [] - - for entry in entries: - if not entry or not entry.get('id'): - continue - - # Filter logic - title_lower = (entry.get('title') or '').lower() - duration_secs = entry.get('duration') - - if filter_type == 'video': - # Exclude shorts - if '#shorts' in title_lower: - continue - if duration_secs and int(duration_secs) <= 70: - continue - elif filter_type == 'short': - # Only shorts - if duration_secs and int(duration_secs) > 60: - continue - - results.append(cls.sanitize_video_data(entry)) - - return results - - except Exception as e: - logger.error(f"Search error for '{query}': {e}") - return [] - - @classmethod - def get_video_info(cls, video_id: str) -> Optional[Dict[str, Any]]: - """ - Get detailed video information including stream URL - - Args: - video_id: YouTube video ID - - Returns: - Video info dict with stream_url, or None on error - """ - engine = SettingsService.get('youtube_engine', 'auto') - - # 1. Force Remote - if engine == 'remote': - return cls._get_info_remote(video_id) - - # 2. Local (or Auto first attempt) - info = cls._get_info_local(video_id) - - if info: - return info - - # 3. Failover if Auto - if engine == 'auto' and not info: - logger.warning(f"yt-dlp failed for {video_id}, falling back to remote loader") - return cls._get_info_remote(video_id) - - return None - - @classmethod - def _get_info_remote(cls, video_id: str) -> Optional[Dict[str, Any]]: - """Fetch info using LoaderToService""" - url = f"https://www.youtube.com/watch?v={video_id}" - return LoaderToService.get_stream_url(url) - - @classmethod - def _get_info_local(cls, video_id: str) -> Optional[Dict[str, Any]]: - """Fetch info using yt-dlp (original logic)""" - try: - url = f"https://www.youtube.com/watch?v={video_id}" - - ydl_opts = { - **cls.BASE_OPTS, - 'format': Config.YTDLP_FORMAT, - 'noplaylist': True, - 'skip_download': True, - } - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(url, download=False) - - if not info: - return None - - stream_url = info.get('url') - if not stream_url: - logger.warning(f"No stream URL found for {video_id}") - return None - - # Get subtitles - subtitle_url = cls._extract_subtitle_url(info) - - return { - 'stream_url': stream_url, - 'title': info.get('title', 'Unknown'), - 'description': info.get('description', ''), - 'uploader': info.get('uploader', ''), - 'uploader_id': info.get('uploader_id', ''), - 'channel_id': info.get('channel_id', ''), - 'upload_date': info.get('upload_date', ''), - 'view_count': info.get('view_count', 0), - 'subtitle_url': subtitle_url, - 'duration': info.get('duration'), - 'thumbnail': info.get('thumbnail') or f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg", - 'http_headers': info.get('http_headers', {}) - } - - except Exception as e: - logger.error(f"Error getting local video info for {video_id}: {e}") - return None - - @staticmethod - def _extract_subtitle_url(info: Dict[str, Any]) -> Optional[str]: - """Extract best subtitle URL from video info""" - subs = info.get('subtitles') or {} - auto_subs = info.get('automatic_captions') or {} - - # Priority: en manual > vi manual > en auto > vi auto > first available - for lang in ['en', 'vi']: - if lang in subs and subs[lang]: - return subs[lang][0].get('url') - - for lang in ['en', 'vi']: - if lang in auto_subs and auto_subs[lang]: - return auto_subs[lang][0].get('url') - - # Fallback to first available - if subs: - first_key = list(subs.keys())[0] - if subs[first_key]: - return subs[first_key][0].get('url') - - if auto_subs: - first_key = list(auto_subs.keys())[0] - if auto_subs[first_key]: - return auto_subs[first_key][0].get('url') - - return None - - @classmethod - def get_channel_videos(cls, channel_id: str, limit: int = 20) -> List[Dict[str, Any]]: - """ - Get videos from a YouTube channel - - Args: - channel_id: Channel ID, handle (@username), or URL - limit: Maximum number of videos - - Returns: - List of video data dictionaries - """ - try: - # Construct URL based on ID format - if channel_id.startswith('http'): - url = channel_id - elif channel_id.startswith('@'): - url = f"https://www.youtube.com/{channel_id}" - elif len(channel_id) == 24 and channel_id.startswith('UC'): - url = f"https://www.youtube.com/channel/{channel_id}" - else: - url = f"https://www.youtube.com/{channel_id}" - - ydl_opts = { - **cls.BASE_OPTS, - 'extract_flat': True, - 'playlist_items': f'1:{limit}', - } - - results = [] - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(url, download=False) - entries = info.get('entries', []) if info else [] - - for entry in entries: - if entry and entry.get('id'): - results.append(cls.sanitize_video_data(entry)) - - return results - - except Exception as e: - logger.error(f"Error getting channel videos for {channel_id}: {e}") - return [] - - @classmethod - def get_related_videos(cls, title: str, limit: int = 10) -> List[Dict[str, Any]]: - """Get videos related to a given title""" - query = f"{title} related" - return cls.search_videos(query, limit=limit, filter_type='video') - - @classmethod - def get_download_url(cls, video_id: str) -> Optional[Dict[str, str]]: - """ - Get direct download URL (non-HLS) for a video - - Returns: - Dict with 'url', 'title', 'ext' or None - """ - try: - url = f"https://www.youtube.com/watch?v={video_id}" - - ydl_opts = { - **cls.BASE_OPTS, - 'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best[protocol!*=m3u8]/best', - 'noplaylist': True, - 'skip_download': True, - 'youtube_include_dash_manifest': False, - 'youtube_include_hls_manifest': False, - } - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(url, download=False) - - download_url = info.get('url', '') - - # If m3u8, try to find non-HLS format - if '.m3u8' in download_url or not download_url: - formats = info.get('formats', []) - for f in reversed(formats): - f_url = f.get('url', '') - if f_url and 'm3u8' not in f_url and f.get('ext') == 'mp4': - download_url = f_url - break - - if download_url and '.m3u8' not in download_url: - return { - 'url': download_url, - 'title': info.get('title', 'video'), - 'ext': 'mp4' - } - - return None - - except Exception as e: - logger.error(f"Error getting download URL for {video_id}: {e}") - return None diff --git a/app/utils/__init__.py b/app/utils/__init__.py deleted file mode 100755 index 40f0d0f..0000000 --- a/app/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""KV-Tube Utilities Package""" diff --git a/app/utils/formatters.py b/app/utils/formatters.py deleted file mode 100755 index 7e3c08e..0000000 --- a/app/utils/formatters.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -Template Formatters Module -Jinja2 template filters for formatting views and dates -""" -from datetime import datetime, timedelta - - -def format_views(views) -> str: - """Format view count (YouTube style: 1.2M, 3.5K)""" - if not views: - return '0' - try: - num = int(views) - if num >= 1_000_000_000: - return f"{num / 1_000_000_000:.1f}B" - if num >= 1_000_000: - return f"{num / 1_000_000:.1f}M" - if num >= 1_000: - return f"{num / 1_000:.0f}K" - return f"{num:,}" - except (ValueError, TypeError): - return str(views) - - -def format_date(value) -> str: - """Format date to relative time (YouTube style: 2 hours ago, 3 days ago)""" - if not value: - return 'Recently' - - try: - # Handle YYYYMMDD format - if len(str(value)) == 8 and str(value).isdigit(): - dt = datetime.strptime(str(value), '%Y%m%d') - # Handle timestamp - elif isinstance(value, (int, float)): - dt = datetime.fromtimestamp(value) - # Handle datetime object - elif isinstance(value, datetime): - dt = value - # Handle YYYY-MM-DD string - else: - try: - dt = datetime.strptime(str(value), '%Y-%m-%d') - except ValueError: - return str(value) - - now = datetime.now() - diff = now - dt - - if diff.days > 365: - years = diff.days // 365 - return f"{years} year{'s' if years > 1 else ''} ago" - if diff.days > 30: - months = diff.days // 30 - return f"{months} month{'s' if months > 1 else ''} ago" - if diff.days > 7: - weeks = diff.days // 7 - return f"{weeks} week{'s' if weeks > 1 else ''} ago" - if diff.days > 0: - return f"{diff.days} day{'s' if diff.days > 1 else ''} ago" - if diff.seconds > 3600: - hours = diff.seconds // 3600 - return f"{hours} hour{'s' if hours > 1 else ''} ago" - if diff.seconds > 60: - minutes = diff.seconds // 60 - return f"{minutes} minute{'s' if minutes > 1 else ''} ago" - return "Just now" - - except Exception: - return str(value) - - -def format_duration(seconds) -> str: - """Format duration in seconds to HH:MM:SS or MM:SS""" - if not seconds: - return '' - - try: - secs = int(seconds) - mins, secs = divmod(secs, 60) - hours, mins = divmod(mins, 60) - - if hours: - return f"{hours}:{mins:02d}:{secs:02d}" - return f"{mins}:{secs:02d}" - - except (ValueError, TypeError): - return '' - - -def register_filters(app): - """Register all template filters with Flask app""" - app.template_filter('format_views')(format_views) - app.template_filter('format_date')(format_date) - app.template_filter('format_duration')(format_duration) diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100755 index 0000000..b67a6f9 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,27 @@ +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache git + +COPY backend/go.mod backend/go.sum ./ +RUN go mod download + +COPY backend/ ./ +RUN CGO_ENABLED=0 GOOS=linux go build -o kv-tube . + +FROM alpine:latest + +RUN apk add --no-cache ca-certificates ffmpeg curl + +WORKDIR /app + +COPY --from=builder /app/kv-tube . +COPY data ./data + +EXPOSE 8080 + +ENV KVTUBE_DATA_DIR=/app/data +ENV GIN_MODE=release + +CMD ["./kv-tube"] diff --git a/backend/go.mod b/backend/go.mod new file mode 100755 index 0000000..689ae00 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,42 @@ +module kvtube-go + +go 1.25.4 + +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gin-gonic/gin v1.11.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.34 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + github.com/ulule/limiter/v3 v3.11.2 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/tools v0.34.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100755 index 0000000..c04e83e --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,87 @@ +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ulule/limiter/v3 v3.11.2 h1:P4yOrxoEMJbOTfRJR2OzjL90oflzYPPmWg+dvwN2tHA= +github.com/ulule/limiter/v3 v3.11.2/go.mod h1:QG5GnFOCV+k7lrL5Y8kgEeeflPH3+Cviqlqa8SVSQxI= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/main.go b/backend/main.go new file mode 100755 index 0000000..e4e9aee --- /dev/null +++ b/backend/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "log" + "os" + + "kvtube-go/models" + "kvtube-go/routes" + + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" +) + +func main() { + // Load environment variables (ignore if not found) + _ = godotenv.Load() + + // Initialize Database + models.InitDB() + + // Setup Gin Engine + if os.Getenv("GIN_MODE") == "release" { + gin.SetMode(gin.ReleaseMode) + } + r := routes.SetupRouter() + + // Start server + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + log.Printf("KV-Tube Go Backend starting on port %s...", port) + if err := r.Run(":" + port); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/backend/models/database.go b/backend/models/database.go new file mode 100755 index 0000000..f17360b --- /dev/null +++ b/backend/models/database.go @@ -0,0 +1,79 @@ +package models + +import ( + "database/sql" + "log" + "os" + "path/filepath" + + _ "github.com/mattn/go-sqlite3" +) + +var DB *sql.DB + +func InitDB() { + dataDir := os.Getenv("KVTUBE_DATA_DIR") + if dataDir == "" { + dataDir = "../data" // Default mapping assuming running from backend + } + + if err := os.MkdirAll(dataDir, 0755); err != nil { + log.Fatalf("Failed to create data directory: %v", err) + } + + dbPath := filepath.Join(dataDir, "kvtube.db") + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + log.Fatalf("Failed to open database: %v", err) + } + + // Create tables + userTable := `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL + );` + + userVideosTable := `CREATE TABLE IF NOT EXISTS user_videos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + video_id TEXT, + title TEXT, + thumbnail TEXT, + type TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) + );` + + videoCacheTable := `CREATE TABLE IF NOT EXISTS video_cache ( + video_id TEXT PRIMARY KEY, + data TEXT, + expires_at DATETIME + );` + + subscriptionsTable := `CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + channel_id TEXT NOT NULL, + channel_name TEXT, + channel_avatar TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, channel_id), + FOREIGN KEY(user_id) REFERENCES users(id) + );` + + for _, stmt := range []string{userTable, userVideosTable, videoCacheTable, subscriptionsTable} { + if _, err := db.Exec(stmt); err != nil { + log.Fatalf("Failed to create table: %v - Statement: %s", err, stmt) + } + } + + // Insert default user for history tracking + _, err = db.Exec(`INSERT OR IGNORE INTO users (id, username, password) VALUES (1, 'default_user', 'password')`) + if err != nil { + log.Printf("Failed to insert default user: %v", err) + } + + DB = db + log.Println("Database initialized successfully at", dbPath) +} diff --git a/backend/routes/api.go b/backend/routes/api.go new file mode 100755 index 0000000..5cc72da --- /dev/null +++ b/backend/routes/api.go @@ -0,0 +1,625 @@ +package routes + +import ( + "bufio" + "io" + "log" + "net/http" + "net/url" + "strconv" + "strings" + + "kvtube-go/services" + + "github.com/gin-gonic/gin" +) + +func SetupRouter() *gin.Engine { + r := gin.Default() + + r.Use(func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + c.Next() + }) + + r.GET("/api/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + // API Routes + api := r.Group("/api") + { + api.GET("/search", handleSearch) + api.GET("/trending", handleTrending) + api.GET("/get_stream_info", handleGetStreamInfo) + api.GET("/download", handleDownload) + api.GET("/transcript", handleTranscript) + api.GET("/comments", handleComments) + api.GET("/channel/videos", handleChannelVideos) + api.GET("/channel/info", handleChannelInfo) + api.GET("/related", handleRelatedVideos) + api.GET("/formats", handleGetFormats) + api.GET("/qualities", handleGetQualities) + api.GET("/stream", handleGetStreamByQuality) + + // History routes + api.POST("/history", handlePostHistory) + api.GET("/history", handleGetHistory) + api.GET("/suggestions", handleGetSuggestions) + + // Subscription routes + api.POST("/subscribe", handleSubscribe) + api.DELETE("/subscribe", handleUnsubscribe) + api.GET("/subscribe", handleCheckSubscription) + api.GET("/subscriptions", handleGetSubscriptions) + } + + r.GET("/video_proxy", handleVideoProxy) + + return r +} + +func handleSearch(c *gin.Context) { + query := c.Query("q") + if query == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"}) + return + } + + limit := 20 + if l := c.Query("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil { + limit = parsed + } + } + + results, err := services.SearchVideos(query, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search videos"}) + return + } + + c.JSON(http.StatusOK, results) +} + +func handleTrending(c *gin.Context) { + // Basic mock implementation for now + c.JSON(http.StatusOK, gin.H{ + "data": []gin.H{ + { + "id": "trending", + "title": "Currently Trending", + "icon": "fire", + "videos": []gin.H{}, + }, + }, + }) +} + +func handleGetStreamInfo(c *gin.Context) { + videoID := c.Query("v") + if videoID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) + return + } + + info, err := services.GetVideoInfo(videoID) + if err != nil { + log.Printf("GetVideoInfo Error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video info"}) + return + } + + // Get available qualities with audio + qualities, audioURL, _ := services.GetVideoQualitiesWithAudio(videoID) + + // Build quality options for frontend + var qualityOptions []gin.H + bestURL := info.StreamURL + bestHeight := 0 + + for _, q := range qualities { + proxyURL := "/video_proxy?url=" + url.QueryEscape(q.URL) + audioProxyURL := "" + if q.AudioURL != "" { + audioProxyURL = "/video_proxy?url=" + url.QueryEscape(q.AudioURL) + } + qualityOptions = append(qualityOptions, gin.H{ + "label": q.Label, + "height": q.Height, + "url": proxyURL, + "audio_url": audioProxyURL, + "is_hls": q.IsHLS, + "has_audio": q.HasAudio, + }) + if q.Height > bestHeight { + bestHeight = q.Height + bestURL = q.URL + } + } + + // If we found qualities, use the best one + streamURL := info.StreamURL + if bestURL != "" { + streamURL = bestURL + } + + proxyURL := "/video_proxy?url=" + url.QueryEscape(streamURL) + + // Get audio URL for the response + audioProxyURL := "" + if audioURL != "" { + audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL) + } + + c.JSON(http.StatusOK, gin.H{ + "original_url": info.StreamURL, + "stream_url": proxyURL, + "audio_url": audioProxyURL, + "title": info.Title, + "description": info.Description, + "uploader": info.Uploader, + "channel_id": info.ChannelID, + "uploader_id": info.UploaderID, + "view_count": info.ViewCount, + "thumbnail": info.Thumbnail, + "related": []interface{}{}, + "subtitle_url": nil, + "qualities": qualityOptions, + "best_quality": bestHeight, + }) +} + +func handleDownload(c *gin.Context) { + videoID := c.Query("v") + if videoID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) + return + } + + formatID := c.Query("f") + + info, err := services.GetDownloadURL(videoID, formatID) + if err != nil { + log.Printf("GetDownloadURL Error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get download URL"}) + return + } + + c.JSON(http.StatusOK, info) +} + +func handleGetFormats(c *gin.Context) { + videoID := c.Query("v") + if videoID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) + return + } + + formats, err := services.GetVideoFormats(videoID) + if err != nil { + log.Printf("GetVideoFormats Error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video formats"}) + return + } + + c.JSON(http.StatusOK, formats) +} + +func handleGetQualities(c *gin.Context) { + videoID := c.Query("v") + if videoID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) + return + } + + qualities, audioURL, err := services.GetVideoQualitiesWithAudio(videoID) + if err != nil { + log.Printf("GetVideoQualities Error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video qualities"}) + return + } + + var result []gin.H + for _, q := range qualities { + proxyURL := "/video_proxy?url=" + url.QueryEscape(q.URL) + audioProxyURL := "" + if q.AudioURL != "" { + audioProxyURL = "/video_proxy?url=" + url.QueryEscape(q.AudioURL) + } + result = append(result, gin.H{ + "format_id": q.FormatID, + "label": q.Label, + "resolution": q.Resolution, + "height": q.Height, + "url": proxyURL, + "audio_url": audioProxyURL, + "is_hls": q.IsHLS, + "vcodec": q.VCodec, + "acodec": q.ACodec, + "filesize": q.Filesize, + "has_audio": q.HasAudio, + }) + } + + // Also return the best audio URL separately + audioProxyURL := "" + if audioURL != "" { + audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "qualities": result, + "audio_url": audioProxyURL, + }) +} + +func handleGetStreamByQuality(c *gin.Context) { + videoID := c.Query("v") + if videoID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) + return + } + + heightStr := c.Query("q") + height := 0 + if heightStr != "" { + if parsed, err := strconv.Atoi(heightStr); err == nil { + height = parsed + } + } + + qualities, audioURL, err := services.GetVideoQualitiesWithAudio(videoID) + if err != nil { + log.Printf("GetVideoQualities Error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video qualities"}) + return + } + + if len(qualities) == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "No qualities available"}) + return + } + + var selected *services.QualityFormat + for i := range qualities { + if qualities[i].Height == height { + selected = &qualities[i] + break + } + } + + if selected == nil { + selected = &qualities[0] + } + + proxyURL := "/video_proxy?url=" + url.QueryEscape(selected.URL) + + audioProxyURL := "" + if selected.AudioURL != "" { + audioProxyURL = "/video_proxy?url=" + url.QueryEscape(selected.AudioURL) + } else if audioURL != "" { + audioProxyURL = "/video_proxy?url=" + url.QueryEscape(audioURL) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "stream_url": proxyURL, + "audio_url": audioProxyURL, + "has_audio": selected.HasAudio, + "quality": gin.H{ + "label": selected.Label, + "height": selected.Height, + "is_hls": selected.IsHLS, + }, + }) +} + +func handleRelatedVideos(c *gin.Context) { + videoID := c.Query("v") + title := c.Query("title") + uploader := c.Query("uploader") + + if title == "" && videoID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID or Title required"}) + return + } + + limitStr := c.Query("limit") + limit := 10 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + + videos, err := services.GetRelatedVideos(title, uploader, limit) + if err != nil { + log.Printf("GetRelatedVideos Error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get related videos"}) + return + } + + c.JSON(http.StatusOK, videos) +} + +func handleTranscript(c *gin.Context) { + c.JSON(http.StatusNotImplemented, gin.H{"error": "Not Implemented"}) +} + +func handleComments(c *gin.Context) { + videoID := c.Query("v") + if videoID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID 'v' is required"}) + return + } + + limit := 20 + if l := c.Query("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { + limit = parsed + } + } + + comments, err := services.GetComments(videoID, limit) + if err != nil { + log.Printf("GetComments Error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get comments"}) + return + } + + c.JSON(http.StatusOK, comments) +} + +func handleChannelInfo(c *gin.Context) { + channelID := c.Query("id") + if channelID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID 'id' is required"}) + return + } + + info, err := services.GetChannelInfo(channelID) + if err != nil { + log.Printf("GetChannelInfo Error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel info"}) + return + } + + c.JSON(http.StatusOK, info) +} + +func handleChannelVideos(c *gin.Context) { + channelID := c.Query("id") + if channelID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID 'id' is required"}) + return + } + + limitStr := c.Query("limit") + limit := 30 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + + videos, err := services.GetChannelVideos(channelID, limit) + if err != nil { + log.Printf("GetChannelVideos Error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get channel videos", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, videos) +} + +func handleVideoProxy(c *gin.Context) { + targetURL := c.Query("url") + if targetURL == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "No URL provided"}) + return + } + + req, err := http.NewRequest("GET", targetURL, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"}) + return + } + + // Forward standard headers + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") + req.Header.Set("Referer", "https://www.youtube.com/") + req.Header.Set("Origin", "https://www.youtube.com") + + if rangeHeader := c.GetHeader("Range"); rangeHeader != "" { + req.Header.Set("Range", rangeHeader) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch video stream"}) + return + } + defer resp.Body.Close() + + contentType := resp.Header.Get("Content-Type") + baseURL := targetURL[:strings.LastIndex(targetURL, "/")] + + isManifest := strings.Contains(strings.ToLower(contentType), "mpegurl") || + strings.HasSuffix(targetURL, ".m3u8") || + strings.Contains(targetURL, ".m3u8") + + if isManifest && (resp.StatusCode == 200 || resp.StatusCode == 206) { + // Rewrite M3U8 Manifest + scanner := bufio.NewScanner(resp.Body) + var newLines []string + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" && !strings.HasPrefix(line, "#") { + fullURL := line + if !strings.HasPrefix(line, "http") { + fullURL = baseURL + "/" + line + } + encodedURL := url.QueryEscape(fullURL) + newLines = append(newLines, "/video_proxy?url="+encodedURL) + } else { + newLines = append(newLines, line) + } + } + + rewrittenContent := strings.Join(newLines, "\n") + c.Data(resp.StatusCode, "application/vnd.apple.mpegurl", []byte(rewrittenContent)) + return + } + + // Stream binary video data + for k, v := range resp.Header { + logKey := strings.ToLower(k) + if logKey != "content-encoding" && logKey != "transfer-encoding" && logKey != "connection" && !strings.HasPrefix(logKey, "access-control-") { + c.Writer.Header()[k] = v + } + } + c.Writer.WriteHeader(resp.StatusCode) + io.Copy(c.Writer, resp.Body) +} + +func handlePostHistory(c *gin.Context) { + var body struct { + VideoID string `json:"video_id"` + Title string `json:"title"` + Thumbnail string `json:"thumbnail"` + } + + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + if body.VideoID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Video ID is required"}) + return + } + + err := services.AddToHistory(body.VideoID, body.Title, body.Thumbnail) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update history"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "success"}) +} + +func handleGetHistory(c *gin.Context) { + limitStr := c.Query("limit") + limit := 50 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + + history, err := services.GetHistory(limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get history"}) + return + } + + // Make the API response shape match the VideoData shape the frontend expects + // We'll reconstruct a basic VideoData-like array for the frontend + var results []services.VideoData + for _, h := range history { + results = append(results, services.VideoData{ + ID: h.ID, + Title: h.Title, + Thumbnail: h.Thumbnail, + Uploader: "History", // Just a placeholder + }) + } + + c.JSON(http.StatusOK, results) +} + +func handleGetSuggestions(c *gin.Context) { + limitStr := c.Query("limit") + limit := 20 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + + suggestions, err := services.GetSuggestions(limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get suggestions"}) + return + } + + c.JSON(http.StatusOK, suggestions) +} + +func handleSubscribe(c *gin.Context) { + var body struct { + ChannelID string `json:"channel_id"` + ChannelName string `json:"channel_name"` + ChannelAvatar string `json:"channel_avatar"` + } + + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + if body.ChannelID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"}) + return + } + + err := services.SubscribeChannel(body.ChannelID, body.ChannelName, body.ChannelAvatar) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to subscribe"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "subscribed"}) +} + +func handleUnsubscribe(c *gin.Context) { + channelID := c.Query("channel_id") + if channelID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"}) + return + } + + err := services.UnsubscribeChannel(channelID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsubscribe"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "unsubscribed"}) +} + +func handleCheckSubscription(c *gin.Context) { + channelID := c.Query("channel_id") + if channelID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Channel ID is required"}) + return + } + + subscribed, err := services.IsSubscribed(channelID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check subscription"}) + return + } + + c.JSON(http.StatusOK, gin.H{"subscribed": subscribed}) +} + +func handleGetSubscriptions(c *gin.Context) { + subs, err := services.GetSubscriptions() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscriptions"}) + return + } + + c.JSON(http.StatusOK, subs) +} diff --git a/backend/services/history.go b/backend/services/history.go new file mode 100755 index 0000000..8e9b290 --- /dev/null +++ b/backend/services/history.go @@ -0,0 +1,96 @@ +package services + +import ( + "log" + "strings" + + "kvtube-go/models" +) + +// AddToHistory records a video in the history for the user (default id 1) +func AddToHistory(videoID, title, thumbnail string) error { + // First check if it already exists to just update timestamp, or insert new + var existingId int + err := models.DB.QueryRow("SELECT id FROM user_videos WHERE user_id = 1 AND video_id = ?", videoID).Scan(&existingId) + + if err == nil { + // Exists, update timestamp + _, err = models.DB.Exec("UPDATE user_videos SET timestamp = CURRENT_TIMESTAMP WHERE id = ?", existingId) + if err != nil { + log.Printf("Error updating history timestamp: %v", err) + return err + } + return nil + } + + // Insert new + _, err = models.DB.Exec( + "INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (1, ?, ?, ?, 'history')", + videoID, title, thumbnail, + ) + if err != nil { + log.Printf("Error inserting history: %v", err) + return err + } + + return nil +} + +// HistoryVideo represents a video in the user's history +type HistoryVideo struct { + ID string `json:"id"` + Title string `json:"title"` + Thumbnail string `json:"thumbnail"` +} + +// GetHistory retrieves the most recently watched videos +func GetHistory(limit int) ([]HistoryVideo, error) { + rows, err := models.DB.Query( + "SELECT video_id, title, thumbnail FROM user_videos WHERE user_id = 1 ORDER BY timestamp DESC LIMIT ?", limit, + ) + if err != nil { + log.Printf("Error querying history: %v", err) + return nil, err + } + defer rows.Close() + + var videos []HistoryVideo + for rows.Next() { + var v HistoryVideo + if err := rows.Scan(&v.ID, &v.Title, &v.Thumbnail); err != nil { + continue + } + videos = append(videos, v) + } + + return videos, nil +} + +// GetSuggestions retrieves suggestions based on the user's recent history +func GetSuggestions(limit int) ([]VideoData, error) { + // 1. Get the 3 most recently watched videos to extract keywords + history, err := GetHistory(3) + if err != nil || len(history) == 0 { + // Fallback to trending if no history + return SearchVideos("trending videos", limit) + } + + // 2. Build a combined query string from titles + var words []string + for _, h := range history { + // take first few words from title + parts := strings.Fields(h.Title) + for i := 0; i < len(parts) && i < 3; i++ { + // clean up some common punctuation if needed, or just let yt-dlp handle it + words = append(words, parts[i]) + } + } + + query := strings.Join(words, " ") + if query == "" { + query = "popular videos" + } + + // 3. Search using yt-dlp + return SearchVideos(query, limit) +} diff --git a/backend/services/subscription.go b/backend/services/subscription.go new file mode 100755 index 0000000..5edddf8 --- /dev/null +++ b/backend/services/subscription.go @@ -0,0 +1,72 @@ +package services + +import ( + "log" + + "kvtube-go/models" +) + +type Subscription struct { + ID int `json:"id"` + ChannelID string `json:"channel_id"` + ChannelName string `json:"channel_name"` + ChannelAvatar string `json:"channel_avatar"` +} + +func SubscribeChannel(channelID, channelName, channelAvatar string) error { + _, err := models.DB.Exec( + `INSERT OR IGNORE INTO subscriptions (user_id, channel_id, channel_name, channel_avatar) VALUES (1, ?, ?, ?)`, + channelID, channelName, channelAvatar, + ) + if err != nil { + log.Printf("Error subscribing to channel: %v", err) + return err + } + return nil +} + +func UnsubscribeChannel(channelID string) error { + _, err := models.DB.Exec( + `DELETE FROM subscriptions WHERE user_id = 1 AND channel_id = ?`, + channelID, + ) + if err != nil { + log.Printf("Error unsubscribing from channel: %v", err) + return err + } + return nil +} + +func IsSubscribed(channelID string) (bool, error) { + var count int + err := models.DB.QueryRow( + `SELECT COUNT(*) FROM subscriptions WHERE user_id = 1 AND channel_id = ?`, + channelID, + ).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +func GetSubscriptions() ([]Subscription, error) { + rows, err := models.DB.Query( + `SELECT id, channel_id, channel_name, channel_avatar FROM subscriptions WHERE user_id = 1 ORDER BY timestamp DESC`, + ) + if err != nil { + log.Printf("Error querying subscriptions: %v", err) + return nil, err + } + defer rows.Close() + + var subs []Subscription + for rows.Next() { + var s Subscription + if err := rows.Scan(&s.ID, &s.ChannelID, &s.ChannelName, &s.ChannelAvatar); err != nil { + continue + } + subs = append(subs, s) + } + + return subs, nil +} diff --git a/backend/services/ytdlp.go b/backend/services/ytdlp.go new file mode 100755 index 0000000..23b1305 --- /dev/null +++ b/backend/services/ytdlp.go @@ -0,0 +1,850 @@ +package services + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "strings" +) + +type VideoData struct { + ID string `json:"id"` + Title string `json:"title"` + Uploader string `json:"uploader"` + ChannelID string `json:"channel_id"` + UploaderID string `json:"uploader_id"` + Thumbnail string `json:"thumbnail"` + ViewCount int64 `json:"view_count"` + UploadDate string `json:"upload_date"` + Duration string `json:"duration"` + Description string `json:"description"` + StreamURL string `json:"stream_url,omitempty"` +} + +type VideoFormat struct { + FormatID string `json:"format_id"` + FormatNote string `json:"format_note"` + Ext string `json:"ext"` + Resolution string `json:"resolution"` + Filesize int64 `json:"filesize"` + VCodec string `json:"vcodec"` + ACodec string `json:"acodec"` + Type string `json:"type"` // "video", "audio", or "both" +} + +type YtDlpEntry struct { + ID string `json:"id"` + Title string `json:"title"` + Uploader string `json:"uploader"` + Channel string `json:"channel"` + ChannelID string `json:"channel_id"` + UploaderID string `json:"uploader_id"` + ViewCount int64 `json:"view_count"` + UploadDate string `json:"upload_date"` + Duration interface{} `json:"duration"` // Can be float64 or int + Description string `json:"description"` + URL string `json:"url"` +} + +func sanitizeVideoData(entry YtDlpEntry) VideoData { + uploader := entry.Uploader + if uploader == "" { + uploader = entry.Channel + } + if uploader == "" { + uploader = "Unknown" + } + + var durationStr string + if d, ok := entry.Duration.(float64); ok && d > 0 { + hours := int(d) / 3600 + mins := (int(d) % 3600) / 60 + secs := int(d) % 60 + if hours > 0 { + durationStr = fmt.Sprintf("%d:%02d:%02d", hours, mins, secs) + } else { + durationStr = fmt.Sprintf("%d:%02d", mins, secs) + } + } + + thumbnail := "" + if entry.ID != "" { + thumbnail = fmt.Sprintf("https://i.ytimg.com/vi/%s/hqdefault.jpg", entry.ID) + } + + return VideoData{ + ID: entry.ID, + Title: entry.Title, + Uploader: uploader, + ChannelID: entry.ChannelID, + UploaderID: entry.UploaderID, + Thumbnail: thumbnail, + ViewCount: entry.ViewCount, + UploadDate: entry.UploadDate, + Duration: durationStr, + Description: entry.Description, + } +} + +// RunYtDlp securely executes yt-dlp with the given arguments and returns JSON output +func RunYtDlp(args ...string) ([]byte, error) { + cmdArgs := append([]string{ + "--dump-json", + "--no-warnings", + "--quiet", + "--force-ipv4", + "--ignore-errors", + "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + }, args...) + + binPath := "yt-dlp" + // Check common install paths if yt-dlp is not in PATH + if _, err := exec.LookPath("yt-dlp"); err != nil { + fallbacks := []string{ + os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"), + os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"), + os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"), + os.ExpandEnv("$HOME/Library/Python/3.11/bin/yt-dlp"), + os.ExpandEnv("$HOME/.local/bin/yt-dlp"), + "/usr/local/bin/yt-dlp", + "/opt/homebrew/bin/yt-dlp", + } + for _, fb := range fallbacks { + if _, err := os.Stat(fb); err == nil { + binPath = fb + break + } + } + } + + cmd := exec.Command(binPath, cmdArgs...) + + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + log.Printf("yt-dlp error: %v, stderr: %s", err, stderr.String()) + return nil, err + } + + return out.Bytes(), nil +} + +func SearchVideos(query string, limit int) ([]VideoData, error) { + searchQuery := fmt.Sprintf("ytsearch%d:%s", limit, query) + + args := []string{ + "--flat-playlist", + searchQuery, + } + + out, err := RunYtDlp(args...) + if err != nil { + return nil, err + } + + var results []VideoData + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + for _, line := range lines { + if line == "" { + continue + } + var entry YtDlpEntry + if err := json.Unmarshal([]byte(line), &entry); err == nil { + if entry.ID != "" { + results = append(results, sanitizeVideoData(entry)) + } + } + } + + return results, nil +} + +func GetVideoInfo(videoID string) (*VideoData, error) { + url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID) + + args := []string{ + "--format", "bestvideo+bestaudio/best", + "--skip-download", + "--no-playlist", + url, + } + + out, err := RunYtDlp(args...) + if err != nil { + return nil, err + } + + var entry YtDlpEntry + if err := json.Unmarshal(out, &entry); err != nil { + return nil, err + } + + data := sanitizeVideoData(entry) + data.StreamURL = entry.URL + + return &data, nil +} + +type QualityFormat struct { + FormatID string `json:"format_id"` + Label string `json:"label"` + Resolution string `json:"resolution"` + Height int `json:"height"` + URL string `json:"url"` + AudioURL string `json:"audio_url,omitempty"` + IsHLS bool `json:"is_hls"` + VCodec string `json:"vcodec"` + ACodec string `json:"acodec"` + Filesize int64 `json:"filesize"` + HasAudio bool `json:"has_audio"` +} + +func GetVideoQualities(videoID string) ([]QualityFormat, error) { + url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID) + + cmdArgs := append([]string{ + "--dump-json", + "--no-warnings", + "--quiet", + "--force-ipv4", + "--no-playlist", + "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + }, url) + + binPath := "yt-dlp" + if _, err := exec.LookPath("yt-dlp"); err != nil { + fallbacks := []string{ + os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"), + os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"), + os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"), + os.ExpandEnv("$HOME/.local/bin/yt-dlp"), + "/usr/local/bin/yt-dlp", + "/opt/homebrew/bin/yt-dlp", + "/config/.local/bin/yt-dlp", + } + for _, fb := range fallbacks { + if _, err := os.Stat(fb); err == nil { + binPath = fb + break + } + } + } + + cmd := exec.Command(binPath, cmdArgs...) + + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + log.Printf("yt-dlp error: %v, stderr: %s", err, stderr.String()) + return nil, err + } + + var raw struct { + Formats []struct { + FormatID string `json:"format_id"` + FormatNote string `json:"format_note"` + Ext string `json:"ext"` + Resolution string `json:"resolution"` + Width interface{} `json:"width"` + Height interface{} `json:"height"` + URL string `json:"url"` + ManifestURL string `json:"manifest_url"` + VCodec string `json:"vcodec"` + ACodec string `json:"acodec"` + Filesize interface{} `json:"filesize"` + } `json:"formats"` + } + + if err := json.Unmarshal(out.Bytes(), &raw); err != nil { + return nil, err + } + + var qualities []QualityFormat + seen := make(map[int]int) // height -> index in qualities + + for _, f := range raw.Formats { + if f.VCodec == "none" || f.URL == "" { + continue + } + + var height int + switch v := f.Height.(type) { + case float64: + height = int(v) + case int: + height = v + } + + if height == 0 { + continue + } + + hasAudio := f.ACodec != "none" && f.ACodec != "" + + var filesize int64 + switch v := f.Filesize.(type) { + case float64: + filesize = int64(v) + case int64: + filesize = v + } + + isHLS := f.ManifestURL != "" || strings.Contains(f.URL, ".m3u8") || strings.Contains(f.URL, "manifest") + + label := f.FormatNote + if label == "" { + switch height { + case 2160: + label = "4K" + case 1440: + label = "1440p" + case 1080: + label = "1080p" + case 720: + label = "720p" + case 480: + label = "480p" + case 360: + label = "360p" + default: + label = fmt.Sprintf("%dp", height) + } + } + + streamURL := f.URL + if f.ManifestURL != "" { + streamURL = f.ManifestURL + } + + qf := QualityFormat{ + FormatID: f.FormatID, + Label: label, + Resolution: f.Resolution, + Height: height, + URL: streamURL, + IsHLS: isHLS, + VCodec: f.VCodec, + ACodec: f.ACodec, + Filesize: filesize, + HasAudio: hasAudio, + } + + // Prefer formats with audio, otherwise just add + if idx, exists := seen[height]; exists { + // Replace if this one has audio and the existing one doesn't + if hasAudio && !qualities[idx].HasAudio { + qualities[idx] = qf + } + } else { + seen[height] = len(qualities) + qualities = append(qualities, qf) + } + } + + // Sort by height descending + for i := range qualities { + for j := i + 1; j < len(qualities); j++ { + if qualities[j].Height > qualities[i].Height { + qualities[i], qualities[j] = qualities[j], qualities[i] + } + } + } + + return qualities, nil +} + +func GetBestAudioURL(videoID string) (string, error) { + url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID) + + cmdArgs := []string{ + "--dump-json", + "--no-warnings", + "--quiet", + "--force-ipv4", + "--no-playlist", + "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + url, + } + + binPath := "yt-dlp" + if _, err := exec.LookPath("yt-dlp"); err != nil { + fallbacks := []string{ + os.ExpandEnv("$HOME/.local/bin/yt-dlp"), + "/usr/local/bin/yt-dlp", + "/opt/homebrew/bin/yt-dlp", + "/config/.local/bin/yt-dlp", + } + for _, fb := range fallbacks { + if _, err := os.Stat(fb); err == nil { + binPath = fb + break + } + } + } + + cmd := exec.Command(binPath, cmdArgs...) + + var out bytes.Buffer + cmd.Stdout = &out + + if err := cmd.Run(); err != nil { + return "", err + } + + var raw struct { + Formats []struct { + FormatID string `json:"format_id"` + URL string `json:"url"` + VCodec string `json:"vcodec"` + ACodec string `json:"acodec"` + ABR interface{} `json:"abr"` + FormatNote string `json:"format_note"` + } `json:"formats"` + } + + if err := json.Unmarshal(out.Bytes(), &raw); err != nil { + return "", err + } + + // Find best audio-only stream (prefer highest ABR) + var bestAudio string + var bestABR float64 + for _, f := range raw.Formats { + if f.VCodec == "none" && f.ACodec != "none" && f.URL != "" { + var abr float64 + switch v := f.ABR.(type) { + case float64: + abr = v + case int: + abr = float64(v) + } + if abr > bestABR { + bestABR = abr + bestAudio = f.URL + } + } + } + + return bestAudio, nil +} + +func GetVideoQualitiesWithAudio(videoID string) ([]QualityFormat, string, error) { + qualities, err := GetVideoQualities(videoID) + if err != nil { + return nil, "", err + } + + // Get best audio URL + audioURL, err := GetBestAudioURL(videoID) + if err != nil { + log.Printf("Warning: could not get audio URL: %v", err) + } + + // Attach audio URL to qualities without audio + for i := range qualities { + if !qualities[i].HasAudio && audioURL != "" { + qualities[i].AudioURL = audioURL + } + } + + return qualities, audioURL, nil +} + +func GetStreamURLForQuality(videoID string, height int) (string, error) { + qualities, err := GetVideoQualities(videoID) + if err != nil { + return "", err + } + + for _, q := range qualities { + if q.Height == height { + return q.URL, nil + } + } + + if len(qualities) > 0 { + return qualities[0].URL, nil + } + + return "", fmt.Errorf("no suitable quality found") +} + +func GetRelatedVideos(title, uploader string, limit int) ([]VideoData, error) { + query := title + if uploader != "" { + query = uploader + " " + title + } + // Limit query length to avoid issues + if len(query) > 100 { + query = query[:100] + } + return SearchVideos(query, limit) +} + +type DownloadInfo struct { + URL string `json:"url"` + Title string `json:"title"` + Ext string `json:"ext"` +} + +func GetDownloadURL(videoID string, formatID string) (*DownloadInfo, error) { + url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID) + + formatArgs := "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" + if formatID != "" { + formatArgs = formatID + if !strings.Contains(formatID, "+") && !strings.Contains(formatID, "best") { + // If it's just a video format, we might want to try adding audio, but for simple direct download links, + // let's stick to what the user requested or what yt-dlp gives for that ID. + formatArgs = formatID + "+bestaudio/best" + } + } + + args := []string{ + "--format", formatArgs, + "--dump-json", + "--no-playlist", + url, + } + + out, err := RunYtDlp(args...) + if err != nil { + return nil, err + } + + var raw map[string]interface{} + if err := json.Unmarshal(out, &raw); err != nil { + return nil, err + } + + downloadURL, _ := raw["url"].(string) + title, _ := raw["title"].(string) + ext, _ := raw["ext"].(string) + + if downloadURL == "" { + formats, ok := raw["formats"].([]interface{}) + if ok && len(formats) > 0 { + // Try to find the first mp4 format that is not m3u8 + for i := len(formats) - 1; i >= 0; i-- { + fmtMap, ok := formats[i].(map[string]interface{}) + if !ok { + continue + } + fUrl, _ := fmtMap["url"].(string) + fExt, _ := fmtMap["ext"].(string) + if fUrl != "" && !strings.Contains(fUrl, ".m3u8") && fExt == "mp4" { + downloadURL = fUrl + ext = fExt + break + } + } + } + } + + if title == "" { + title = "video" + } + if ext == "" { + ext = "mp4" + } + + return &DownloadInfo{ + URL: downloadURL, + Title: title, + Ext: ext, + }, nil +} + +func GetVideoFormats(videoID string) ([]VideoFormat, error) { + url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID) + + args := []string{ + "--dump-json", + "--no-playlist", + url, + } + + out, err := RunYtDlp(args...) + if err != nil { + return nil, err + } + + var raw struct { + Formats []struct { + FormatID string `json:"format_id"` + FormatNote string `json:"format_note"` + Ext string `json:"ext"` + Resolution string `json:"resolution"` + Filesize float64 `json:"filesize"` + VCodec string `json:"vcodec"` + ACodec string `json:"acodec"` + } `json:"formats"` + } + + if err := json.Unmarshal(out, &raw); err != nil { + return nil, err + } + + var formats []VideoFormat + for _, f := range raw.Formats { + // Filter out storyboards and other non-media formats + if strings.Contains(f.FormatID, "sb") || f.VCodec == "none" && f.ACodec == "none" { + continue + } + + fType := "both" + if f.VCodec == "none" { + fType = "audio" + } else if f.ACodec == "none" { + fType = "video" + } + + formats = append(formats, VideoFormat{ + FormatID: f.FormatID, + FormatNote: f.FormatNote, + Ext: f.Ext, + Resolution: f.Resolution, + Filesize: int64(f.Filesize), + VCodec: f.VCodec, + ACodec: f.ACodec, + Type: fType, + }) + } + + return formats, nil +} + +type ChannelInfo struct { + ID string `json:"id"` + Title string `json:"title"` + SubscriberCount int64 `json:"subscriber_count"` + Avatar string `json:"avatar"` +} + +func GetChannelInfo(channelID string) (*ChannelInfo, error) { + url := fmt.Sprintf("https://www.youtube.com/channel/%s", channelID) + if strings.HasPrefix(channelID, "@") { + url = fmt.Sprintf("https://www.youtube.com/%s", channelID) + } + + // Fetch 1 video with full metadata to extract channel info + args := []string{ + url + "/videos", + "--dump-json", + "--playlist-end", "1", + "--no-warnings", + "--quiet", + } + + out, err := RunYtDlp(args...) + if err != nil || len(out) == 0 { + return nil, fmt.Errorf("failed to get channel info: %v", err) + } + + // Parse the first video's JSON + var raw map[string]interface{} + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) == 0 { + return nil, fmt.Errorf("no output from yt-dlp") + } + + if err := json.Unmarshal([]byte(lines[0]), &raw); err != nil { + return nil, err + } + + title, _ := raw["channel"].(string) + if title == "" { + title, _ = raw["uploader"].(string) + } + if title == "" { + title = channelID + } + + cID, _ := raw["channel_id"].(string) + if cID == "" { + cID = channelID + } + + subCountFloat, _ := raw["channel_follower_count"].(float64) + + // Create an avatar based on the first letter of the channel title + avatarStr := "?" + if len(title) > 0 { + avatarStr = strings.ToUpper(string(title[0])) + } + + return &ChannelInfo{ + ID: cID, + Title: title, + SubscriberCount: int64(subCountFloat), + Avatar: avatarStr, // Simple fallback for now + }, nil +} + +func GetChannelVideos(channelID string, limit int) ([]VideoData, error) { + url := fmt.Sprintf("https://www.youtube.com/channel/%s", channelID) + if strings.HasPrefix(channelID, "@") { + url = fmt.Sprintf("https://www.youtube.com/%s", channelID) + } + + args := []string{ + url + "/videos", + "--flat-playlist", + fmt.Sprintf("--playlist-end=%d", limit), + } + + out, err := RunYtDlp(args...) + if err != nil { + return nil, err + } + + var results []VideoData + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + for _, line := range lines { + if line == "" { + continue + } + var entry YtDlpEntry + if err := json.Unmarshal([]byte(line), &entry); err == nil { + if entry.ID != "" { + results = append(results, sanitizeVideoData(entry)) + } + } + } + + return results, nil +} + +type Comment struct { + ID string `json:"id"` + Text string `json:"text"` + Author string `json:"author"` + AuthorID string `json:"author_id"` + AuthorThumb string `json:"author_thumbnail"` + Likes int `json:"likes"` + IsReply bool `json:"is_reply"` + Parent string `json:"parent"` + Timestamp string `json:"timestamp"` +} + +func GetComments(videoID string, limit int) ([]Comment, error) { + url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID) + + cmdArgs := []string{ + "--dump-json", + "--no-download", + "--no-playlist", + "--write-comments", + fmt.Sprintf("--comment-limit=%d", limit), + url, + } + + cmdArgs = append([]string{ + "--no-warnings", + "--quiet", + "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + }, cmdArgs...) + + binPath := "yt-dlp" + if _, err := exec.LookPath("yt-dlp"); err != nil { + fallbacks := []string{ + os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"), + os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"), + os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"), + os.ExpandEnv("$HOME/.local/bin/yt-dlp"), + "/usr/local/bin/yt-dlp", + "/opt/homebrew/bin/yt-dlp", + "/config/.local/bin/yt-dlp", + } + for _, fb := range fallbacks { + if _, err := os.Stat(fb); err == nil { + binPath = fb + break + } + } + } + + cmd := exec.Command(binPath, cmdArgs...) + + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + log.Printf("yt-dlp comments error: %v, stderr: %s", err, stderr.String()) + return nil, err + } + + var raw struct { + Comments []struct { + ID string `json:"id"` + Text string `json:"text"` + Author string `json:"author"` + AuthorID string `json:"author_id"` + AuthorThumb string `json:"author_thumbnail"` + Likes int `json:"like_count"` + IsReply bool `json:"is_reply"` + Parent string `json:"parent"` + Timestamp int64 `json:"timestamp"` + } `json:"comments"` + } + + if err := json.Unmarshal(out.Bytes(), &raw); err != nil { + return nil, err + } + + var comments []Comment + for _, c := range raw.Comments { + timestamp := "" + if c.Timestamp > 0 { + timestamp = formatCommentTime(c.Timestamp) + } + comments = append(comments, Comment{ + ID: c.ID, + Text: c.Text, + Author: c.Author, + AuthorID: c.AuthorID, + AuthorThumb: c.AuthorThumb, + Likes: c.Likes, + IsReply: c.IsReply, + Parent: c.Parent, + Timestamp: timestamp, + }) + } + + return comments, nil +} + +func formatCommentTime(timestamp int64) string { + now := float64(timestamp) + then := float64(0) + diff := int((now - then) / 1000) + + if diff < 60 { + return "just now" + } else if diff < 3600 { + return fmt.Sprintf("%dm ago", diff/60) + } else if diff < 86400 { + return fmt.Sprintf("%dh ago", diff/3600) + } else if diff < 604800 { + return fmt.Sprintf("%dd ago", diff/86400) + } else if diff < 2592000 { + return fmt.Sprintf("%dw ago", diff/604800) + } else if diff < 31536000 { + return fmt.Sprintf("%dmo ago", diff/2592000) + } + return fmt.Sprintf("%dy ago", diff/31536000) +} diff --git a/bin/ffmpeg b/bin/ffmpeg deleted file mode 100755 index 592923a..0000000 Binary files a/bin/ffmpeg and /dev/null differ diff --git a/config.py b/config.py deleted file mode 100755 index 7a4b8b2..0000000 --- a/config.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -KV-Tube Configuration Module -Centralizes all configuration with environment variable support -""" -import os -from dotenv import load_dotenv - -# Load .env file if present -load_dotenv() - -class Config: - """Base configuration""" - SECRET_KEY = os.environ.get('SECRET_KEY', os.urandom(32).hex()) - - # Database - DATA_DIR = os.environ.get('KVTUBE_DATA_DIR', 'data') - DB_NAME = os.path.join(DATA_DIR, 'kvtube.db') - - # Video storage - VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos') - - # Rate limiting - RATELIMIT_DEFAULT = "60/minute" - RATELIMIT_SEARCH = "30/minute" - RATELIMIT_STREAM = "120/minute" - - # Cache settings (in seconds) - CACHE_VIDEO_TTL = 3600 # 1 hour - CACHE_CHANNEL_TTL = 1800 # 30 minutes - - # yt-dlp settings - # yt-dlp settings - MUST use progressive formats with combined audio+video - # Format 22 = 720p mp4, 18 = 360p mp4 (both have audio+video combined) - # HLS m3u8 streams have CORS issues with segment proxying, so we avoid them - YTDLP_FORMAT = '22/18/best[protocol^=https][ext=mp4]/best[ext=mp4]/best' - YTDLP_TIMEOUT = 30 - - # YouTube Engine Settings - YOUTUBE_ENGINE = os.environ.get('YOUTUBE_ENGINE', 'auto') # auto, local, remote - LOADER_TO_API_KEY = os.environ.get('LOADER_TO_API_KEY', '') # Optional - - @staticmethod - def init_app(app): - """Initialize app with config""" - # Ensure data directory exists - os.makedirs(Config.DATA_DIR, exist_ok=True) - - -class DevelopmentConfig(Config): - """Development configuration""" - DEBUG = True - FLASK_ENV = 'development' - - -class ProductionConfig(Config): - """Production configuration""" - DEBUG = False - FLASK_ENV = 'production' - - -config = { - 'development': DevelopmentConfig, - 'production': ProductionConfig, - 'default': DevelopmentConfig -} diff --git a/cookies.txt b/cookies.txt deleted file mode 100755 index 6856a3b..0000000 --- a/cookies.txt +++ /dev/null @@ -1,19 +0,0 @@ -# Netscape HTTP Cookie File -# This file is generated by yt-dlp. Do not edit. - -.youtube.com TRUE / TRUE 1831894348 __Secure-3PSID g.a0005wie1jXMw44_RGjwtqg21AbatcdNseI3S_qNtsLYC1jS4YSUdfLDtJlA8h-NEmMGebiczgACgYKAX0SARESFQHGX2MiUz2RnkvviMoB7UNylf3SoBoVAUF8yKo0JwXF5B9H9roWaSTRT-QN0076 -.youtube.com TRUE / TRUE 1800281710 __Secure-1PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA -.youtube.com TRUE / TRUE 1802692356 SAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb -.youtube.com TRUE / TRUE 1800359997 __Secure-1PSIDCC AKEyXzWh3snkS2XAx8pLOzZCgKTPwXKRai_Pn4KjpsSSc2h7tRpVKMDddMKBYkuIQFhpVlALI84 -.youtube.com TRUE / TRUE 1802692356 SSID A4isk9AE9xActvzYy -.youtube.com TRUE / TRUE 1831894348 __Secure-1PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb -.youtube.com TRUE / TRUE 1831894348 __Secure-1PSID g.a0005wie1jXMw44_RGjwtqg21AbatcdNseI3S_qNtsLYC1jS4YSUycKC58NH045FOFX6QW8fDwACgYKAacSARESFQHGX2MiA5xeTuJuh8QmBm-DS3l1ghoVAUF8yKr4klCBhb-EJgFQ9T0TGWKk0076 -.youtube.com TRUE / TRUE 1831894348 __Secure-3PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb -.youtube.com TRUE / TRUE 1800359997 __Secure-3PSIDCC AKEyXzW3W5Q-e4TIryFWpWS6zVuuVPOvwPIU2tzl1JRdYsGu-7f34g_amk2Xd2ttGtSJ6tOSdA -.youtube.com TRUE / TRUE 1800281710 __Secure-3PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA -.youtube.com TRUE / TRUE 1792154873 LOGIN_INFO AFmmF2swRQIgVjJk8Mho4_JuKr6SZzrhBdlL1LdxWxcwDMu4cjaRRgcCIQCTtJpmYKJH54Tiei3at3f4YT3US7gSL0lW_TZ04guKjQ:QUQ3MjNmeWlwRDJSNDl2NE9uX2JWWG5tWllHN0RsNUVZVUhsLVp4N2dWbldaeC14SnNybWVERnNoaXFpanFJczhKTjJSRGN6MEs3c1VkLTE1TGJVeFBPT05BY29NMFh0Q1VPdFU3dUdvSUpET3lQbU1ZMUlHUGltajlXNDllNUQxZHdzZko1WXF1UUJWclNxQVJ0TXVEYnF2bXJRY2V6Vl9n -.youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=150&hl=en -.youtube.com TRUE / TRUE 0 YSC y-oH2BqaUSQ -.youtube.com TRUE / TRUE 1784333733 __Secure-ROLLOUT_TOKEN CPm51pHVjquOTRDw0bnsppWSAxjzxYe3qZaSAw%3D%3D -.youtube.com TRUE / TRUE 1784375997 VISITOR_INFO1_LIVE ShB1Bvj-rRU -.youtube.com TRUE / TRUE 1784375997 VISITOR_PRIVACY_METADATA CgJWThIEGgAgWA%3D%3D diff --git a/deploy.py b/deploy.py deleted file mode 100755 index 5cd72b3..0000000 --- a/deploy.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -"""Build and push multi-platform Docker image.""" -import subprocess - -def run_cmd(cmd): - print(f"\n>>> {cmd}") - result = subprocess.run(cmd, shell=True, capture_output=True, text=True) - if result.stdout: - print(result.stdout) - if result.stderr: - print(result.stderr) - return result.returncode == 0 - -print("="*50) -print("Building Multi-Platform Docker Image") -print("(linux/amd64 + linux/arm64)") -print("="*50) - -# Create buildx builder if it doesn't exist -run_cmd("docker buildx create --name multiplatform --use 2>/dev/null || docker buildx use multiplatform") - -# Build and push multi-platform image -print("\nBuilding and pushing...") -run_cmd("docker buildx build --platform linux/amd64,linux/arm64 -t vndangkhoa/kv-tube:latest --push .") - -print("\n" + "="*50) -print("DONE! Image now supports both amd64 and arm64") -print("="*50) diff --git a/dev.sh b/dev.sh deleted file mode 100755 index 43d3da1..0000000 --- a/dev.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/bin/bash -set -e - -echo "--- KV-Tube Local Dev Startup ---" - -# 1. Check for FFmpeg (Auto-Install Local Static Binary if missing) -if ! command -v ffmpeg &> /dev/null; then - echo "[Check] FFmpeg not found globally." - - # Check local bin - LOCAL_BIN="$(pwd)/bin" - if [ ! -f "$LOCAL_BIN/ffmpeg" ]; then - echo "[Setup] Downloading static FFmpeg for macOS ARM64..." - mkdir -p "$LOCAL_BIN" - - # Download from Martin Riedl's static builds (macOS ARM64) - curl -L -o ffmpeg.zip "https://ffmpeg.martin-riedl.de/redirect/latest/macos/arm64/release/ffmpeg.zip" - - echo "[Setup] Extracting FFmpeg..." - unzip -o -q ffmpeg.zip -d "$LOCAL_BIN" - rm ffmpeg.zip - - # Some zips extract to a subfolder, ensure binary is in bin root - # (This specific source usually dumps 'ffmpeg' directly, but just in case) - if [ ! -f "$LOCAL_BIN/ffmpeg" ]; then - find "$LOCAL_BIN" -name "ffmpeg" -type f -exec mv {} "$LOCAL_BIN" \; - fi - - chmod +x "$LOCAL_BIN/ffmpeg" - fi - - # Add local bin to PATH - export PATH="$LOCAL_BIN:$PATH" - echo "[Setup] Using local FFmpeg from $LOCAL_BIN" -fi - -if ! command -v ffmpeg &> /dev/null; then - echo "Error: FFmpeg installation failed. Please install manually." - exit 1 -fi -echo "[Check] FFmpeg found: $(ffmpeg -version | head -n 1)" - -# 2. Virtual Environment (Optional but recommended) -if [ ! -d "venv" ]; then - echo "[Setup] Creating python virtual environment..." - python3 -m venv venv -fi -source venv/bin/activate - -# 3. Install Dependencies & Force Nightly yt-dlp -echo "[Update] Installing dependencies..." -pip install -r requirements.txt - -echo "[Update] Forcing yt-dlp Nightly update..." -# This matches the aggressive update strategy of media-roller -pip install -U --pre "yt-dlp[default]" - -# 4. Environment Variables -export FLASK_APP=wsgi.py -export FLASK_ENV=development -export PYTHONUNBUFFERED=1 - -# 5. Start Application -echo "[Startup] Starting KV-Tube on http://localhost:5011" -echo "Press Ctrl+C to stop." - -# Run with Gunicorn (closer to prod) or Flask (better for debugging) -# Using Gunicorn to match Docker behavior, but with reload for dev -exec gunicorn --bind 0.0.0.0:5011 --workers 2 --threads 2 --reload wsgi:app diff --git a/docker-compose.yml b/docker-compose.yml index 5169e6e..beffa9b 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,32 +1,34 @@ -# KV-Tube Docker Compose for Synology NAS -# Usage: docker-compose up -d - -version: '3.8' - -services: - kv-tube: - build: . - image: vndangkhoa/kv-tube:latest - container_name: kv-tube - restart: unless-stopped - ports: - - "5011:5000" - volumes: - # Persist data (Easy setup: Just maps a folder) - - ./data:/app/data - # Cookies file for YouTube (Required for NAS/Server to fix "Transcript not available") - - ./data/cookies.txt:/app/cookies.txt - # Local videos folder (Optional) - # - ./videos:/app/youtube_downloads - environment: - - PYTHONUNBUFFERED=1 - - FLASK_ENV=production - - COOKIES_FILE=/app/cookies.txt - healthcheck: - test: [ "CMD", "curl", "-f", "http://localhost:5000/" ] - interval: 30s - timeout: 10s - retries: 3 - start_period: 10s - labels: - - "com.centurylinklabs.watchtower.enable=true" +# KV-Tube Docker Compose for Synology NAS +# Usage: docker-compose up -d + +version: '3.8' + +services: + kv-tube-backend: + image: git.khoavo.myds.me/vndangkhoa/kv-tube-backend:v4.0.0 + container_name: kv-tube-backend + restart: unless-stopped + volumes: + - ./data:/app/data + environment: + - KVTUBE_DATA_DIR=/app/data + - GIN_MODE=release + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8080/api/health" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + labels: + - "com.centurylinklabs.watchtower.enable=true" + + kv-tube-frontend: + image: git.khoavo.myds.me/vndangkhoa/kv-tube-frontend:v4.0.0 + container_name: kv-tube-frontend + restart: unless-stopped + ports: + - "5011:3000" + depends_on: + - kv-tube-backend + labels: + - "com.centurylinklabs.watchtower.enable=true" diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100755 index fe94d8f..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/sh -set -e - -echo "--- KV-Tube Startup ---" - -# 1. Update Core Engines -echo "[Update] Checking for engine updates..." - -# Update yt-dlp -echo "[Update] Updating yt-dlp..." -pip install -U yt-dlp || echo "Warning: yt-dlp update failed" - - - -# 2. Check Loader.to Connectivity (Optional verification) -# We won't block startup on this, just log it. -echo "[Update] Engines checked." - -# 3. Start Application -echo "[Startup] Launching Gunicorn..." -exec gunicorn --bind 0.0.0.0:5000 --workers 4 --threads 2 --timeout 120 wsgi:app diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100755 index 0000000..5ef6a52 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..19c90dc --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,55 @@ +FROM node:20-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json package-lock.json ./ +RUN npm ci + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js telemetry is disabled +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN npm run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV production +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT 3000 +# set hostname to localhost +ENV HOSTNAME "0.0.0.0" + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +CMD ["node", "server.js"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100755 index 0000000..e215bc4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/app/actions.ts b/frontend/app/actions.ts new file mode 100755 index 0000000..3d6bb9b --- /dev/null +++ b/frontend/app/actions.ts @@ -0,0 +1,87 @@ +"use server"; + +import { VideoData, CATEGORY_MAP, ALL_CATEGORY_SECTIONS, API_BASE } from './constants'; +import { addRegion } from './utils'; + +export async function getSearchVideos(query: string, limit: number = 20): Promise { + 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; + } catch (e) { + console.error(e); + return []; + } +} + +export async function getHistoryVideos(limit: number = 20): Promise { + try { + const res = await fetch(`${API_BASE}/api/history?limit=${limit}`, { cache: 'no-store' }); + if (!res.ok) return []; + return res.json() as Promise; + } catch (e) { + console.error("Failed to get history:", e); + return []; + } +} + +export async function getSuggestedVideos(limit: number = 20): Promise { + try { + const res = await fetch(`${API_BASE}/api/suggestions?limit=${limit}`, { cache: 'no-store' }); + if (!res.ok) return []; + return res.json() as Promise; + } catch (e) { + console.error("Failed to get suggestions:", e); + return []; + } +} + +export async function fetchMoreVideos(currentCategory: string, regionLabel: string, page: number): Promise { + const isAllCategory = currentCategory === 'All'; + let newVideos: VideoData[] = []; + + // Modify query slightly to simulate getting more pages + const pageModifiers = ["", "", "more", "new", "update", "latest", "part 2", "HD", "review"]; + const modifier = page < pageModifiers.length ? pageModifiers[page] : `page ${page}`; + + if (isAllCategory) { + const promises = ALL_CATEGORY_SECTIONS.map(async (sec) => { + const q = addRegion(sec.query, regionLabel) + " " + modifier; + // Fetch fewer items per section on subsequent pages to mitigate loading times + return await getSearchVideos(q, 5); + }); + const results = await Promise.all(promises); + + // Interleave the results + const maxLen = Math.max(...results.map(arr => arr.length)); + const interleavedList: VideoData[] = []; + const seenIds = new Set(); + + for (let i = 0; i < maxLen; i++) { + for (const categoryResult of results) { + if (i < categoryResult.length) { + const video = categoryResult[i]; + if (!seenIds.has(video.id)) { + interleavedList.push(video); + seenIds.add(video.id); + } + } + } + } + newVideos = interleavedList; + } else if (currentCategory === 'Watched') { + // Fetch from history, offset by page if desired (backend doesn't support offset yet, so just increase limit) + // If the backend returned all items, we'd normally paginate here. For now just mock it or return empty array to prevent infinite duplicating history scroll + if (page > 1) return []; // History is just 1 page for now + newVideos = await getHistoryVideos(50); + } else if (currentCategory === 'Suggested') { + const q = addRegion("popular videos", regionLabel) + " " + modifier; + newVideos = await getSearchVideos(q, 10); // Or we could make suggestions return more things + } else { + const baseQuery = CATEGORY_MAP[currentCategory] || CATEGORY_MAP['All']; + const q = addRegion(baseQuery, regionLabel) + " " + modifier; + newVideos = await getSearchVideos(q, 20); + } + + return newVideos; +} diff --git a/frontend/app/api/download/route.ts b/frontend/app/api/download/route.ts new file mode 100755 index 0000000..d24195e --- /dev/null +++ b/frontend/app/api/download/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'; + +export async function GET(request: NextRequest) { + const videoId = request.nextUrl.searchParams.get('v'); + const formatId = request.nextUrl.searchParams.get('f'); + + if (!videoId) { + return NextResponse.json({ error: 'No video ID' }, { status: 400 }); + } + + try { + const url = `${API_BASE}/api/download?v=${encodeURIComponent(videoId)}${formatId ? `&f=${encodeURIComponent(formatId)}` : ''}`; + const res = await fetch(url, { + cache: 'no-store', + }); + + const data = await res.json(); + + if (!res.ok) { + return NextResponse.json({ error: data.error || 'Download failed' }, { status: 500 }); + } + + return NextResponse.json(data); + } catch (error) { + return NextResponse.json({ error: 'Failed to get download link' }, { status: 500 }); + } +} diff --git a/frontend/app/api/formats/route.ts b/frontend/app/api/formats/route.ts new file mode 100755 index 0000000..988679b --- /dev/null +++ b/frontend/app/api/formats/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'; + +export async function GET(request: NextRequest) { + const videoId = request.nextUrl.searchParams.get('v'); + + if (!videoId) { + return NextResponse.json({ error: 'No video ID' }, { status: 400 }); + } + + try { + const res = await fetch(`${API_BASE}/api/formats?v=${encodeURIComponent(videoId)}`, { + cache: 'no-store', + }); + + if (!res.ok) { + return NextResponse.json({ error: 'Failed to fetch formats' }, { status: 500 }); + } + + const data = await res.json(); + return NextResponse.json(data); + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch formats' }, { status: 500 }); + } +} diff --git a/frontend/app/api/proxy-file/route.ts b/frontend/app/api/proxy-file/route.ts new file mode 100755 index 0000000..4bd8979 --- /dev/null +++ b/frontend/app/api/proxy-file/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + const fileUrl = request.nextUrl.searchParams.get('url'); + + if (!fileUrl) { + return NextResponse.json({ error: 'No URL provided' }, { status: 400 }); + } + + try { + const res = await fetch(fileUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + }); + + if (!res.ok) { + return NextResponse.json({ error: 'Failed to fetch file' }, { status: res.status }); + } + + const contentType = res.headers.get('content-type') || 'application/octet-stream'; + const contentLength = res.headers.get('content-length'); + + const headers: HeadersInit = { + 'Content-Type': contentType, + 'Access-Control-Allow-Origin': '*', + }; + + if (contentLength) { + headers['Content-Length'] = contentLength; + } + + return new NextResponse(res.body, { + status: 200, + headers, + }); + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch file' }, { status: 500 }); + } +} diff --git a/frontend/app/api/proxy-stream/route.ts b/frontend/app/api/proxy-stream/route.ts new file mode 100755 index 0000000..f9347f7 --- /dev/null +++ b/frontend/app/api/proxy-stream/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + const url = request.nextUrl.searchParams.get('url'); + + if (!url) { + return NextResponse.json({ error: 'No URL provided' }, { status: 400 }); + } + + try { + const res = await fetch(decodeURIComponent(url), { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'https://www.youtube.com/', + }, + }); + + if (!res.ok) { + return NextResponse.json({ error: 'Failed to fetch' }, { status: 500 }); + } + + const contentType = res.headers.get('content-type') || 'video/mp4'; + const contentLength = res.headers.get('content-length'); + + const headers = new Headers({ + 'Content-Type': contentType, + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'public, max-age=3600', + }); + + if (contentLength) { + headers.set('Content-Length', contentLength); + } + + return new NextResponse(res.body, { + status: 200, + headers, + }); + } catch (error) { + return NextResponse.json({ error: 'Proxy failed' }, { status: 500 }); + } +} diff --git a/frontend/app/api/stream/route.ts b/frontend/app/api/stream/route.ts new file mode 100755 index 0000000..2e1052a --- /dev/null +++ b/frontend/app/api/stream/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + const videoId = request.nextUrl.searchParams.get('v'); + + if (!videoId) { + return NextResponse.json({ error: 'No video ID' }, { status: 400 }); + } + + try { + const res = await fetch(`http://127.0.0.1:8080/api/get_stream_info?v=${videoId}`, { + cache: 'no-store', + }); + + if (!res.ok) { + return NextResponse.json({ error: 'Failed to fetch' }, { status: 500 }); + } + + const data = await res.json(); + const streamUrl = data.original_url || data.stream_url; + const proxyUrl = streamUrl ? `/api/proxy-stream?url=${encodeURIComponent(streamUrl)}` : null; + + return NextResponse.json({ + streamUrl: proxyUrl, + title: data.title, + thumbnail: data.thumbnail + }); + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch stream' }, { status: 500 }); + } +} diff --git a/frontend/app/api/subscribe/route.ts b/frontend/app/api/subscribe/route.ts new file mode 100755 index 0000000..1a2766b --- /dev/null +++ b/frontend/app/api/subscribe/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'; + +export async function GET(request: NextRequest) { + const channelId = request.nextUrl.searchParams.get('channel_id'); + + if (!channelId) { + return NextResponse.json({ error: 'No channel ID' }, { status: 400 }); + } + + try { + const res = await fetch(`${API_BASE}/api/subscribe?channel_id=${encodeURIComponent(channelId)}`, { + cache: 'no-store', + }); + + if (!res.ok) { + return NextResponse.json({ subscribed: false }); + } + + const data = await res.json(); + return NextResponse.json({ subscribed: data.subscribed || false }); + } catch (error) { + return NextResponse.json({ subscribed: false }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { channel_id, channel_name } = body; + + if (!channel_id) { + return NextResponse.json({ error: 'No channel ID' }, { status: 400 }); + } + + const res = await fetch(`${API_BASE}/api/subscribe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + channel_id, + channel_name: channel_name || channel_id, + }), + cache: 'no-store', + }); + + if (!res.ok) { + return NextResponse.json({ error: 'Failed to subscribe' }, { status: 500 }); + } + + const data = await res.json(); + return NextResponse.json({ success: true, ...data }); + } catch (error) { + return NextResponse.json({ error: 'Failed to subscribe' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest) { + const channelId = request.nextUrl.searchParams.get('channel_id'); + + if (!channelId) { + return NextResponse.json({ error: 'No channel ID' }, { status: 400 }); + } + + try { + const res = await fetch(`${API_BASE}/api/subscribe?channel_id=${encodeURIComponent(channelId)}`, { + method: 'DELETE', + cache: 'no-store', + }); + + if (!res.ok) { + return NextResponse.json({ error: 'Failed to unsubscribe' }, { status: 500 }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json({ error: 'Failed to unsubscribe' }, { status: 500 }); + } +} diff --git a/frontend/app/channel/[id]/page.tsx b/frontend/app/channel/[id]/page.tsx new file mode 100755 index 0000000..79cc8db --- /dev/null +++ b/frontend/app/channel/[id]/page.tsx @@ -0,0 +1,125 @@ +import VideoCard from '../../components/VideoCard'; +import { notFound } from 'next/navigation'; +export const dynamic = 'force-dynamic'; + +interface ChannelInfo { + id: string; + title: string; + subscriber_count: number; + avatar: string; +} + +interface VideoData { + id: string; + title: string; + uploader: string; + thumbnail: string; + view_count: number; + duration: string; +} + +// Helper to format subscribers +function formatSubscribers(count: number): string { + if (count >= 1000000) return (count / 1000000).toFixed(2) + 'M'; + if (count >= 1000) return (count / 1000).toFixed(0) + 'K'; + return count.toString(); +} + +// We no longer need getAvatarColor as we now use the global --yt-avatar-bg + +async function getChannelInfo(id: string) { + try { + const res = await fetch(`http://127.0.0.1:8080/api/channel/info?id=${id}`, { cache: 'no-store' }); + if (!res.ok) return null; + return res.json() as Promise; + } catch (e) { + console.error(e); + return null; + } +} + +async function getChannelVideos(id: string) { + try { + const res = await fetch(`http://127.0.0.1:8080/api/channel/videos?id=${id}&limit=30`, { cache: 'no-store' }); + if (!res.ok) return []; + return res.json() as Promise; + } 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 ( +
+ {/* Channel Header */} +
+
+ {info.avatar} +
+ +
+

+ {info.title} +

+
+ {info.id} + + {formatSubscribers(info.subscriber_count)} subscribers + + {videos.length} videos +
+ +
+
+ + {/* Navigation Tabs */} +
+
+
+ Videos + {videos.length} +
+
+
+ + {/* Video Grid */} +
+ {videos.map((v, i) => { + // Enforce correct channel name + v.uploader = info.title; + const stagger = `stagger-${Math.min(i + 1, 6)}`; + return ( +
+ +
+ ); + })} +
+
+ ); +} diff --git a/frontend/app/components/Header.tsx b/frontend/app/components/Header.tsx new file mode 100755 index 0000000..a75928a --- /dev/null +++ b/frontend/app/components/Header.tsx @@ -0,0 +1,124 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useState, useRef, useEffect } from 'react'; +import { IoSearchOutline, IoMoonOutline, IoSunnyOutline, IoArrowBack } from 'react-icons/io5'; +import RegionSelector from './RegionSelector'; +import { useTheme } from '../context/ThemeContext'; + +export default function Header() { + const [searchQuery, setSearchQuery] = useState(''); + const [isMobileSearchActive, setIsMobileSearchActive] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const inputRef = useRef(null); + const mobileInputRef = useRef(null); + const router = useRouter(); + const { theme, toggleTheme } = useTheme(); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + if (searchQuery.trim()) { + router.push(`/search?q=${encodeURIComponent(searchQuery)}`); + setIsMobileSearchActive(false); + setIsFocused(false); + } + }; + + useEffect(() => { + if (isMobileSearchActive && mobileInputRef.current) { + mobileInputRef.current.focus(); + } + }, [isMobileSearchActive]); + + return ( +
+ {!isMobileSearchActive ? ( + <> + {/* Left */} +
+ + KV-Tube + +
+ + {/* Center Search Pill - Desktop */} +
+
+
+ + setSearchQuery(e.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + /> + {searchQuery && ( + + )} + +
+
+
+ + {/* Right - Region and Theme */} +
+ + + +
+ + ) : ( + /* Mobile Search Overlay */ +
+ +
+
+ + setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + )} +
+
+
+ )} +
+ ); +} diff --git a/frontend/app/components/InfiniteVideoGrid.tsx b/frontend/app/components/InfiniteVideoGrid.tsx new file mode 100755 index 0000000..62aedea --- /dev/null +++ b/frontend/app/components/InfiniteVideoGrid.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import VideoCard from './VideoCard'; +import { fetchMoreVideos } from '../actions'; +import { VideoData } from '../constants'; + +interface Props { + initialVideos: VideoData[]; + currentCategory: string; + regionLabel: string; +} + +export default function InfiniteVideoGrid({ initialVideos, currentCategory, regionLabel }: Props) { + const [videos, setVideos] = useState(initialVideos); + const [page, setPage] = useState(2); + const [isLoading, setIsLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + const observerTarget = useRef(null); + + // Reset state if category or region changes, or initialVideos changes + useEffect(() => { + setVideos(initialVideos); + setPage(2); + setHasMore(true); + }, [initialVideos, currentCategory, regionLabel]); + + const loadMore = useCallback(async () => { + if (isLoading || !hasMore) return; + setIsLoading(true); + + try { + const newVideos = await fetchMoreVideos(currentCategory, regionLabel, page); + if (newVideos.length === 0) { + setHasMore(false); + } else { + setVideos(prev => { + // Deduplicate IDs + const existingIds = new Set(prev.map(v => v.id)); + const uniqueNewVideos = newVideos.filter(v => !existingIds.has(v.id)); + if (uniqueNewVideos.length === 0) { + return prev; + } + return [...prev, ...uniqueNewVideos]; + }); + setPage(p => p + 1); + + // If we get an extremely small yield, consider it the end + if (newVideos.length < 5) { + setHasMore(false); + } + } + } catch (e) { + console.error('Failed to load more videos:', e); + } finally { + setIsLoading(false); + } + }, [currentCategory, regionLabel, page, isLoading, hasMore]); + + useEffect(() => { + const observer = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting) { + loadMore(); + } + }, + { threshold: 0.1, rootMargin: '200px' } + ); + + const currentTarget = observerTarget.current; + if (currentTarget) { + observer.observe(currentTarget); + } + + return () => { + if (currentTarget) { + observer.unobserve(currentTarget); + } + }; + }, [loadMore]); + + return ( +
+
+ {videos.map((v, i) => { + const staggerClass = i < 12 ? `stagger-${Math.min((i % 12) + 1, 6)}` : ''; + return ( +
+ +
+ ); + })} +
+ + {hasMore && ( +
+ {isLoading && ( +
+ )} +
+ )} + + {!hasMore && videos.length > 0 && ( +
+ No more results +
+ )} +
+ ); +} diff --git a/frontend/app/components/MobileNav.tsx b/frontend/app/components/MobileNav.tsx new file mode 100755 index 0000000..894bfbc --- /dev/null +++ b/frontend/app/components/MobileNav.tsx @@ -0,0 +1,52 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { MdHomeFilled, MdOutlineSubscriptions, MdOutlineVideoLibrary } from 'react-icons/md'; +import { SiYoutubeshorts } from 'react-icons/si'; + +export default function MobileNav() { + const pathname = usePathname(); + + const navItems = [ + { icon: , label: 'Home', path: '/' }, + { icon: , label: 'Shorts', path: '/shorts' }, + { icon: , label: 'Subscriptions', path: '/feed/subscriptions' }, + { icon: , label: 'You', path: '/feed/library' }, + ]; + + return ( + + ); +} diff --git a/frontend/app/components/RegionSelector.tsx b/frontend/app/components/RegionSelector.tsx new file mode 100755 index 0000000..a6cb5a2 --- /dev/null +++ b/frontend/app/components/RegionSelector.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { MdPublic, MdCheck } from 'react-icons/md'; + +const REGIONS = [ + { code: 'VN', label: 'Vietnam', flag: '🇻🇳' }, + { code: 'US', label: 'United States', flag: '🇺🇸' }, + { code: 'JP', label: 'Japan', flag: '🇯🇵' }, + { code: 'KR', label: 'South Korea', flag: '🇰🇷' }, + { code: 'IN', label: 'India', flag: '🇮🇳' }, + { code: 'GB', label: 'United Kingdom', flag: '🇬🇧' }, + { code: 'GLOBAL', label: 'Global', flag: '🌐' }, +]; + +function getRegionCookie(): string { + if (typeof document === 'undefined') return 'VN'; + const match = document.cookie.match(/(?:^|; )region=([^;]*)/); + return match ? decodeURIComponent(match[1]) : 'VN'; +} + +function setRegionCookie(code: string) { + document.cookie = `region=${encodeURIComponent(code)}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`; +} + +export default function RegionSelector() { + const [isOpen, setIsOpen] = useState(false); + const [selected, setSelected] = useState('VN'); + const menuRef = useRef(null); + const router = useRouter(); + + useEffect(() => { + setSelected(getRegionCookie()); + }, []); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleSelect = (code: string) => { + setSelected(code); + setRegionCookie(code); + setIsOpen(false); + router.refresh(); + }; + + const current = REGIONS.find(r => r.code === selected) || REGIONS[0]; + + return ( +
+ + + {isOpen && ( +
+
+ Select Region +
+ {REGIONS.map(r => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/app/components/Sidebar.tsx b/frontend/app/components/Sidebar.tsx new file mode 100755 index 0000000..cd7f6e5 --- /dev/null +++ b/frontend/app/components/Sidebar.tsx @@ -0,0 +1,58 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { MdHomeFilled, MdOutlineSubscriptions, MdOutlineVideoLibrary } from 'react-icons/md'; +import { SiYoutubeshorts } from 'react-icons/si'; + +export default function Sidebar() { + const pathname = usePathname(); + + const navItems = [ + { icon: , label: 'Home', path: '/' }, + { icon: , label: 'Shorts', path: '/shorts' }, + { icon: , label: 'Subscriptions', path: '/feed/subscriptions' }, + { icon: , label: 'You', path: '/feed/library' }, + ]; + + return ( + + ); +} diff --git a/frontend/app/components/SubscribeButton.tsx b/frontend/app/components/SubscribeButton.tsx new file mode 100755 index 0000000..c66b86f --- /dev/null +++ b/frontend/app/components/SubscribeButton.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +interface SubscribeButtonProps { + channelId?: string; + channelName?: string; + initialSubscribed?: boolean; +} + +export default function SubscribeButton({ channelId, channelName, initialSubscribed }: SubscribeButtonProps) { + const [isSubscribed, setIsSubscribed] = useState(initialSubscribed || false); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (initialSubscribed !== undefined) return; + if (!channelId) return; + const checkSubscription = async () => { + try { + const res = await fetch(`/api/subscribe?channel_id=${encodeURIComponent(channelId)}`); + if (res.ok) { + const data = await res.json(); + setIsSubscribed(data.subscribed); + } + } catch (error) { + console.error('Failed to check subscription:', error); + } + }; + checkSubscription(); + }, [channelId, initialSubscribed]); + + const handleSubscribe = async () => { + if (loading || !channelId) return; + setLoading(true); + + if (!isSubscribed) { + try { + const res = await fetch('/api/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + channel_id: channelId, + channel_name: channelName || channelId, + }), + }); + if (res.ok) { + setIsSubscribed(true); + } + } catch (error) { + console.error('Failed to subscribe:', error); + } finally { + setLoading(false); + } + } else { + try { + const res = await fetch(`/api/subscribe?channel_id=${encodeURIComponent(channelId)}`, { + method: 'DELETE', + }); + if (res.ok) { + setIsSubscribed(false); + } + } catch (error) { + console.error('Failed to unsubscribe:', error); + } finally { + setLoading(false); + } + } + }; + + if (!channelId) return null; + + return ( + + ); +} diff --git a/frontend/app/components/VideoCard.tsx b/frontend/app/components/VideoCard.tsx new file mode 100755 index 0000000..1bb925c --- /dev/null +++ b/frontend/app/components/VideoCard.tsx @@ -0,0 +1,72 @@ +import Link from 'next/link'; + +interface VideoData { + id: string; + title: string; + uploader: string; + channel_id?: string; + thumbnail: string; + view_count: number; + duration: string; + uploaded_date?: string; +} + +function formatViews(views: number): string { + if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M'; + if (views >= 1000) return (views / 1000).toFixed(1) + 'K'; + return views.toString(); +} + +function getRelativeTime(id: string): string { + const times = ['2 hours ago', '5 hours ago', '1 day ago', '3 days ago', '1 week ago', '2 weeks ago', '1 month ago']; + const index = (id.charCodeAt(0) || 0) % times.length; + return times[index]; +} + +export default function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannelAvatar?: boolean }) { + const relativeTime = video.uploaded_date || getRelativeTime(video.id); + + return ( +
+ + {/* eslint-disable-next-line @next/next/no-img-element */} + {video.title} + {video.duration && ( +
+ {video.duration} +
+ )} + + +
+ {/* Video Info */} +
+ +

+ {video.title} +

+ +
+ {video.channel_id ? ( + + {video.uploader} + + ) : ( +
+ {video.uploader} +
+ )} +
+ {formatViews(video.view_count)} views • {relativeTime} +
+
+
+
+
+ ); +} diff --git a/frontend/app/constants.ts b/frontend/app/constants.ts new file mode 100755 index 0000000..d9bf44d --- /dev/null +++ b/frontend/app/constants.ts @@ -0,0 +1,35 @@ +export const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080'; + +export interface VideoData { + id: string; + title: string; + uploader: string; + thumbnail: string; + view_count: number; + duration: string; + avatar_url?: string; +} + +export const CATEGORY_MAP: Record = { + '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' }, +]; diff --git a/frontend/app/context/ThemeContext.tsx b/frontend/app/context/ThemeContext.tsx new file mode 100755 index 0000000..0a021d6 --- /dev/null +++ b/frontend/app/context/ThemeContext.tsx @@ -0,0 +1,45 @@ +'use client'; + +import React, { createContext, useContext, useEffect, useState } from 'react'; + +type Theme = 'light' | 'dark'; + +interface ThemeContextType { + theme: Theme; + toggleTheme: () => void; +} + +const ThemeContext = createContext(undefined); + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [theme, setTheme] = useState('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 ( + + {children} + + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +} diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico new file mode 100755 index 0000000..718d6fe Binary files /dev/null and b/frontend/app/favicon.ico differ diff --git a/frontend/app/feed/library/page.tsx b/frontend/app/feed/library/page.tsx new file mode 100755 index 0000000..070a647 --- /dev/null +++ b/frontend/app/feed/library/page.tsx @@ -0,0 +1,188 @@ +import Link from 'next/link'; + +interface VideoData { + id: string; + title: string; + uploader: string; + thumbnail: string; + view_count: number; + duration: string; +} + +interface Subscription { + id: number; + channel_id: string; + channel_name: string; + channel_avatar: string; +} + +async function getHistory() { + try { + const res = await fetch('http://127.0.0.1:8080/api/history?limit=20', { cache: 'no-store' }); + if (!res.ok) return []; + return res.json() as Promise; + } catch { + return []; + } +} + +async function getSubscriptions() { + try { + const res = await fetch('http://127.0.0.1:8080/api/subscriptions', { cache: 'no-store' }); + if (!res.ok) return []; + return res.json() as Promise; + } catch { + return []; + } +} + +function formatViews(views: number): string { + if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M'; + if (views >= 1000) return (views / 1000).toFixed(1) + 'K'; + return views.toString(); +} + +export default async function LibraryPage() { + const [history, subscriptions] = await Promise.all([getHistory(), getSubscriptions()]); + + return ( +
+ {/* Subscriptions Section */} + {subscriptions.length > 0 && ( +
+

+ Subscriptions +

+
+ {subscriptions.map((sub) => ( + +
+ {sub.channel_avatar || (sub.channel_name ? sub.channel_name[0].toUpperCase() : '?')} +
+ + {sub.channel_name || sub.channel_id} + + + ))} +
+
+ )} + + {/* Watch History Section */} +
+

+ Watch History +

+ {history.length === 0 ? ( +
+

No videos watched yet

+

Videos you watch will appear here

+
+ ) : ( +
+ {history.map((video) => ( + +
+ {video.title} + {video.duration && ( +
{video.duration}
+ )} +
+
+

+ {video.title} +

+

+ {video.uploader} +

+ {video.view_count > 0 && ( +

+ {formatViews(video.view_count)} views +

+ )} +
+ + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/app/feed/subscriptions/page.tsx b/frontend/app/feed/subscriptions/page.tsx new file mode 100755 index 0000000..d84c137 --- /dev/null +++ b/frontend/app/feed/subscriptions/page.tsx @@ -0,0 +1,151 @@ +import Link from 'next/link'; + +interface VideoData { + id: string; + title: string; + uploader: string; + channel_id: string; + thumbnail: string; + view_count: number; + duration: string; +} + +interface Subscription { + id: number; + channel_id: string; + channel_name: string; + channel_avatar: string; +} + +async function getSubscriptions() { + try { + const res = await fetch('http://127.0.0.1:8080/api/subscriptions', { cache: 'no-store' }); + if (!res.ok) return []; + return res.json() as Promise; + } catch { + return []; + } +} + +async function getChannelVideos(channelId: string, limit: number = 5) { + try { + const res = await fetch(`http://127.0.0.1:8080/api/channel/videos?id=${channelId}&limit=${limit}`, { cache: 'no-store' }); + if (!res.ok) return []; + return res.json() as Promise; + } catch { + return []; + } +} + +function formatViews(views: number): string { + if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M'; + if (views >= 1000) return (views / 1000).toFixed(1) + 'K'; + return views.toString(); +} + +export default async function SubscriptionsPage() { + const subscriptions = await getSubscriptions(); + + if (subscriptions.length === 0) { + return ( +
+

No subscriptions yet

+

Subscribe to channels to see their latest videos here

+
+ ); + } + + const videosPerChannel = await Promise.all( + subscriptions.map(async (sub) => ({ + subscription: sub, + videos: await getChannelVideos(sub.channel_id, 5), + })) + ); + + return ( +
+

Subscriptions

+ + {videosPerChannel.map(({ subscription, videos }) => ( +
+ +
+ {subscription.channel_name ? subscription.channel_name[0].toUpperCase() : '?'} +
+

{subscription.channel_name || subscription.channel_id}

+ + + {videos.length > 0 ? ( +
+ {videos.map((video) => ( + +
+ {video.title} + {video.duration && ( +
{video.duration}
+ )} +
+

+ {video.title} +

+

+ {formatViews(video.view_count)} views +

+ + ))} +
+ ) : ( +

No videos available

+ )} +
+ ))} +
+ ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100755 index 0000000..4528fc5 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,1387 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --yt-background: #0f0f0f; + --yt-surface: #0f0f0f; + --yt-hover: #272727; + --yt-active: #3f3f3f; + --yt-border: #3f3f3f; + + --yt-text-primary: #f1f1f1; + --yt-text-secondary: #aaaaaa; + + --yt-brand-red: #ff0000; + --yt-blue: #3ea6ff; + + --yt-search-bg: #121212; + --yt-search-border: #303030; + --yt-search-btn: #222222; + + --yt-header-height: 56px; + --yt-sidebar-width-full: 240px; + --yt-sidebar-width-mini: 72px; + + --background: var(--yt-background); + --foreground: var(--yt-text-primary); + + --yt-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + --yt-shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6); + --yt-transition: all 0.2s cubic-bezier(0.05, 0, 0, 1); + + --yt-card-hover-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + --yt-avatar-bg: #808080; + } + + [data-theme='light'] { + --yt-background: #ffffff; + --yt-surface: #ffffff; + --yt-hover: #f2f2f2; + --yt-active: #e5e5e5; + --yt-border: #e2e2e2; + + --yt-text-primary: #0f0f0f; + --yt-text-secondary: #606060; + + --yt-search-bg: #ffffff; + --yt-search-border: #cccccc; + --yt-search-btn: #f8f8f8; + + --yt-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + --yt-shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15); + + --background: var(--yt-background); + --foreground: var(--yt-text-primary); + + --yt-card-hover-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + } + + * { + transition: background-color 0.3s ease, border-color 0.3s ease; + } + + html { + scroll-behavior: smooth; + } + + body { + background-color: var(--yt-background); + color: var(--yt-text-primary); + font-family: 'Roboto', Arial, sans-serif; + margin: 0; + padding: 0; + overflow-x: hidden; + } + + ::-webkit-scrollbar { + display: none; + } + + * { + -ms-overflow-style: none; + scrollbar-width: none; + } +} + +/* ===== KEYFRAME ANIMATIONS ===== */ + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(12px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes skeletonPulse { + + 0%, + 100% { + opacity: 0.4; + } + + 50% { + opacity: 0.8; + } +} + +@keyframes dropdownOpen { + from { + opacity: 0; + transform: scale(0.95) translateY(-4px); + } + + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + + 100% { + background-position: 200% 0; + } +} + +/* ===== UTILITY CLASSES ===== */ + +.fade-in-up { + animation: fadeInUp 0.4s ease-out forwards; +} + +.fade-in { + animation: fadeIn 0.3s ease-out forwards; +} + +.skeleton { + background: linear-gradient(90deg, var(--yt-hover) 25%, var(--yt-active) 50%, var(--yt-hover) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: 8px; +} + +.dropdown-animated { + animation: dropdownOpen 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.card-hover-lift { + transition: transform 0.25s cubic-bezier(0, 0, 0.2, 1), box-shadow 0.25s ease; +} + +.card-hover-lift:hover { + transform: translateY(-2px); + box-shadow: var(--yt-card-hover-shadow); +} + +/* ===== BASE LAYOUT ===== */ + +.yt-header { + height: var(--yt-header-height); + padding: 0 16px; + display: flex; + align-items: center; + justify-content: space-between; + position: fixed; + top: 0; + left: 0; + right: 0; + background-color: var(--yt-background); + z-index: 500; + border-bottom: 1px solid var(--yt-border); + transition: all 0.2s ease; +} + +[data-theme='light'] .yt-header { + background-color: var(--yt-background); + border-bottom: 1px solid var(--yt-border); +} + +@media (max-width: 768px) { + .yt-header { + padding: 0 8px; + } +} + +.yt-header-left { + display: flex; + align-items: center; + min-width: 168px; +} + +.yt-header-center { + flex: 0 1 732px; + display: flex; + align-items: center; + justify-content: center; + margin: 0 40px; +} + +.yt-header-right { + display: flex; + align-items: center; + gap: 8px; + min-width: 156px; + justify-content: flex-end; +} + +@media (max-width: 768px) { + .yt-header-left { + min-width: unset; + } + + .yt-header-center { + margin: 0 8px; + } + + .yt-header-right { + min-width: unset; + gap: 4px; + } +} + +.yt-sidebar-mini { + width: var(--yt-sidebar-width-mini); + position: fixed; + top: var(--yt-header-height); + bottom: 0; + left: 0; + background-color: var(--yt-background); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 12px 4px; + z-index: 400; + gap: 4px; +} + +@media (max-width: 768px) { + .yt-sidebar-mini { + display: none; + } +} + +.yt-main-content { + margin-top: var(--yt-header-height); + margin-left: var(--yt-sidebar-width-mini); + min-height: calc(100vh - var(--yt-header-height)); + background-color: var(--yt-background); +} + +@media (max-width: 768px) { + .yt-main-content { + margin-left: 0; + padding-bottom: 56px; + } +} + +/* ===== VISIBILITY UTILITIES ===== */ + +.hidden-mobile { + display: flex !important; +} + +@media (max-width: 768px) { + .hidden-mobile { + display: none !important; + } +} + +.visible-mobile { + display: none !important; +} + +@media (max-width: 768px) { + .visible-mobile { + display: flex !important; + } +} + +/* ===== ICON BUTTONS ===== */ + +.yt-icon-btn { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + background: transparent; + border: none; + color: var(--yt-text-primary); + transition: background-color 0.2s ease, transform 0.15s ease; + position: relative; +} + +.yt-icon-btn:hover { + background-color: var(--yt-hover); +} + +.yt-icon-btn:active { + background-color: var(--yt-active); + transform: scale(0.92); +} + +/* ===== TYPOGRAPHY ===== */ + +h1, +h2, +h3, +h4, +h5, +h6 { + margin: 0; + font-weight: 500; +} + +a { + color: inherit; + text-decoration: none; +} + +/* ===== SEARCH BAR ===== */ + +@keyframes searchFocusGlow { + + 0%, + 100% { + box-shadow: 0 0 0 2px rgba(62, 166, 255, 0.15); + } + + 50% { + box-shadow: 0 0 0 4px rgba(62, 166, 255, 0.08); + } +} + +.search-container { + display: flex; + align-items: center; + width: 100%; + position: relative; +} + +.search-input-wrapper { + flex: 1; + display: flex; + align-items: center; + gap: 10px; + background-color: var(--yt-search-bg); + border: 1.5px solid var(--yt-search-border); + border-radius: 40px; + padding: 0 6px 0 16px; + height: 44px; + transition: border-color 0.3s ease, box-shadow 0.3s ease, background-color 0.3s ease, width 0.3s ease; +} + +.search-input-wrapper:hover { + border-color: var(--yt-text-secondary); +} + +.search-input-wrapper:focus-within { + border-color: var(--yt-blue); + box-shadow: 0 0 0 3px rgba(62, 166, 255, 0.15); + animation: searchFocusGlow 2s ease-in-out infinite; + background-color: var(--yt-background); +} + +.search-input-icon { + color: var(--yt-text-secondary); + flex-shrink: 0; + transition: color 0.3s ease, transform 0.3s ease; +} + +.search-input-wrapper:focus-within .search-input-icon { + color: var(--yt-blue); + transform: scale(1.1); +} + +.search-input-wrapper input { + background: transparent; + border: none; + outline: none; + color: var(--yt-text-primary); + width: 100%; + font-size: 15px; + font-weight: 400; + letter-spacing: 0.01em; +} + +.search-input-wrapper input::placeholder { + color: var(--yt-text-secondary); + font-weight: 400; + opacity: 0.7; + transition: opacity 0.3s ease; +} + +.search-input-wrapper:focus-within input::placeholder { + opacity: 0.4; +} + +.search-btn { + height: 34px; + width: 34px; + min-width: 34px; + background-color: transparent; + border: none; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--yt-text-secondary); + transition: background-color 0.2s ease, color 0.2s ease, transform 0.15s ease; +} + +.search-btn:hover { + background-color: var(--yt-hover); + color: var(--yt-text-primary); + transform: scale(1.08); +} + +.search-btn:active { + transform: scale(0.92); + background-color: var(--yt-active); +} + +/* ===== MOBILE SEARCH OVERLAY ===== */ + +@keyframes mobileSearchSlideIn { + from { + opacity: 0; + transform: translateY(-8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.mobile-search-bar { + display: flex; + align-items: center; + width: 100%; + gap: 8px; + padding: 0 8px; + animation: mobileSearchSlideIn 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.mobile-search-bar .search-input-wrapper { + flex: 1; + height: 40px; + padding: 0 12px; + background-color: var(--yt-search-bg); + border: 1.5px solid var(--yt-search-border); +} + +.mobile-search-bar .search-input-wrapper:focus-within { + border-color: var(--yt-blue); + box-shadow: 0 0 0 2px rgba(62, 166, 255, 0.12); +} + +.mobile-search-back { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + background: transparent; + border: none; + color: var(--yt-text-primary); + transition: background-color 0.2s ease, transform 0.15s ease; + flex-shrink: 0; +} + +.mobile-search-back:hover { + background-color: var(--yt-hover); +} + +.mobile-search-back:active { + transform: scale(0.9); + background-color: var(--yt-active); +} + +/* ===== CHIPS ===== */ + +.chip { + background-color: var(--yt-hover); + color: var(--yt-text-primary); + border-radius: 8px; + padding: 0 12px; + height: 32px; + display: inline-flex; + align-items: center; + font-size: 14px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.2s, color 0.2s, transform 0.15s ease; + border: none; +} + +.chip.active { + background-color: var(--yt-text-primary); + color: var(--yt-background); +} + +.chip:hover:not(.active) { + background-color: var(--yt-active); + transform: scale(1.03); +} + +.chip:active:not(.active) { + transform: scale(0.97); +} + +/* ===== TEXT UTILITIES ===== */ + +.truncate-2-lines { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + line-clamp: 2; +} + +/* ===== MOBILE NAV ===== */ + +.mobile-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 56px; + background-color: var(--yt-background); + border-top: 1px solid var(--yt-border); + display: none; + justify-content: space-around; + align-items: center; + z-index: 100; + padding: 0 4px; +} + +.mobile-search-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--yt-background); + z-index: 1000; + display: flex; + flex-direction: column; +} + +@media (max-width: 768px) { + .mobile-nav { + display: flex; + } +} + +/* ===== SIDEBAR ITEMS ===== */ + +.yt-sidebar-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 16px 0 14px 0; + border-radius: 10px; + background-color: transparent; + margin-bottom: 4px; + transition: background-color 0.2s ease, transform 0.15s ease; + gap: 4px; + position: relative; +} + +.yt-sidebar-item:hover { + background-color: var(--yt-hover); +} + +.yt-sidebar-item:hover svg { + transform: scale(1.08); + transition: transform 0.15s ease; +} + +.sidebar-active-indicator { + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 18px; + border-radius: 0 3px 3px 0; + background-color: var(--yt-text-primary); + animation: fadeIn 0.2s ease-out; +} + +/* ===== WATCH PAGE ===== */ + +.watch-container { + display: flex; + gap: 24px; + max-width: 1750px; + width: 100%; + margin: 0 auto; + padding: 24px; + justify-content: center; +} + +.watch-primary { + flex: 1; + min-width: 0; +} + +.watch-secondary { + width: 402px; + flex-shrink: 0; + position: sticky; + top: 80px; + max-height: calc(100vh - 104px); + overflow-y: auto; + padding-right: 12px; + padding-left: 6px; + /* Hide scrollbar for clean look */ + -ms-overflow-style: none; + /* IE and Edge */ + scrollbar-width: none; + /* Firefox */ +} + +.watch-secondary::-webkit-scrollbar { + display: none; +} + +/* Player Wrapper */ +.watch-player-wrapper { + width: 100%; + margin-bottom: 16px; +} + +/* Title */ +.watch-title { + font-size: 20px; + font-weight: 700; + margin: 0 0 16px 0; + line-height: 28px; + color: var(--yt-text-primary); + letter-spacing: -0.2px; +} + +/* Meta Row: channel + actions */ +.watch-meta-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +/* Channel Info */ +.watch-channel-info { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.watch-channel-link { + display: flex; + align-items: center; + text-decoration: none; + gap: 12px; + min-width: 0; +} + +.watch-channel-link:hover .watch-channel-name { + color: var(--yt-text-primary); + opacity: 0.85; +} + +.watch-channel-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--yt-avatar-bg); + display: flex; + align-items: center; + justify-content: center; + font-size: 17px; + color: #fff; + flex-shrink: 0; + font-weight: 600; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.watch-channel-link:hover .watch-channel-avatar { + transform: scale(1.05); + box-shadow: 0 3px 12px rgba(0, 0, 0, 0.25); +} + +.watch-channel-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.watch-channel-name { + font-size: 16px; + font-weight: 600; + color: var(--yt-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: opacity 0.2s ease; +} + +/* Actions Row */ +.watch-actions-row { + display: flex; + gap: 8px; + align-items: center; + flex-shrink: 0; +} + +/* Description Box */ +.watch-description-box { + background-color: var(--yt-hover); + border-radius: 12px; + padding: 16px; + cursor: default; + transition: background-color 0.2s ease; +} + +.watch-description-box:hover { + background-color: var(--yt-active); +} + +.watch-description-stats { + font-size: 14px; + font-weight: 600; + margin-bottom: 8px; + color: var(--yt-text-primary); + letter-spacing: 0.1px; +} + +.watch-description-text { + font-size: 14px; + white-space: pre-wrap; + line-height: 22px; + color: var(--yt-text-secondary); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + line-clamp: 3; +} + +/* Related Videos */ +.watch-related-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.related-video-item { + display: flex; + gap: 8px; + border-radius: 10px; + padding: 6px; + margin: -6px; + text-decoration: none; + transition: background-color 0.2s ease; +} + +.related-video-item:hover { + background-color: var(--yt-hover); +} + +.related-thumb-container { + width: 168px; + height: 94px; + background-color: #1a1a1a; + border-radius: 10px; + flex-shrink: 0; + position: relative; + overflow: hidden; +} + +[data-theme='light'] .related-thumb-container { + background-color: #e8e8e8; +} + +.related-thumb-img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s cubic-bezier(0, 0, 0.2, 1); +} + +.related-video-item:hover .related-thumb-img { + transform: scale(1.05); +} + +.related-video-info { + display: flex; + flex-direction: column; + min-width: 0; + padding-top: 2px; + gap: 3px; +} + +.related-video-title { + font-size: 14px; + font-weight: 500; + line-height: 20px; + color: var(--yt-text-primary); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + line-clamp: 2; +} + +.related-video-channel { + font-size: 12px; + color: var(--yt-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.related-video-meta { + font-size: 12px; + color: var(--yt-text-secondary); +} + +.duration-badge { + position: absolute; + bottom: 4px; + right: 4px; + background-color: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + color: white; + padding: 3px 4px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + letter-spacing: 0.3px; +} + +/* Responsive Watch Page */ +@media (max-width: 768px) { + .watch-container { + padding: 0; + gap: 0; + } + + .watch-title { + font-size: 18px; + line-height: 24px; + padding: 0 12px; + margin-bottom: 12px; + } + + .watch-meta-row { + padding: 0 12px; + gap: 12px; + } + + .watch-actions-row { + width: 100%; + overflow-x: auto; + padding-bottom: 4px; + } + + .watch-description-box { + margin: 0 12px; + padding: 12px; + } + + .watch-related-list { + padding: 0 12px; + } + + .related-thumb-container { + width: 140px; + height: 79px; + } +} + +@media (max-width: 1024px) { + .watch-container { + flex-direction: column; + padding: 12px; + } + + .watch-secondary { + width: 100%; + margin-top: 24px; + position: static; + max-height: none; + overflow-y: visible; + padding-right: 0; + padding-left: 0; + } + + .watch-meta-row { + flex-wrap: wrap; + } +} + +@media (min-width: 769px) { + .watch-meta-row { + flex-wrap: nowrap; + } +} + +/* ===== ACTION BUTTONS ===== */ + +.action-btn-hover { + transition: background-color 0.2s ease, transform 0.1s ease !important; +} + +.action-btn-hover:hover { + background-color: var(--yt-active) !important; +} + +.action-btn-hover:active { + transform: scale(0.95) !important; +} + +.yt-button-hover:hover { + background-color: var(--yt-active) !important; +} + +.format-item-hover:hover { + background-color: var(--yt-active) !important; +} + +/* ===== CHANNEL LINK HOVER ===== */ + +.channel-link-hover:hover { + color: var(--yt-text-primary) !important; +} + +/* ===== SEARCH RESULT HOVER ===== */ + +.search-result-hover { + border-radius: 12px; + padding: 12px; + margin: -12px; + transition: background-color 0.2s ease; +} + +.search-result-hover:hover { + background-color: var(--yt-hover); +} + +.search-result-hover:hover .search-result-thumb { + transform: scale(1.02); +} + +.search-result-thumb { + transition: transform 0.3s cubic-bezier(0, 0, 0.2, 1) !important; +} + +/* ===== HOME PAGE ===== */ + +.section-heading { + position: relative; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.section-heading::before { + content: ''; + width: 4px; + height: 20px; + background: var(--yt-brand-red); + border-radius: 2px; +} + +/* ===== VIDEOCARD ===== */ + +.videocard-thumb { + transition: transform 0.3s cubic-bezier(0, 0, 0.2, 1), border-radius 0.3s ease; +} + +.videocard-container:hover .videocard-thumb { + transform: scale(1.02); +} + + + +/* ===== LOADING SKELETON ===== */ + +.skeleton-card { + display: flex; + flex-direction: column; + gap: 12px; +} + +.skeleton-thumb { + width: 100%; + aspect-ratio: 16/9; + border-radius: 12px; +} + +.skeleton-line { + height: 14px; + border-radius: 4px; +} + +.skeleton-line-short { + width: 60%; + height: 12px; + border-radius: 4px; +} + +.skeleton-avatar { + width: 36px; + height: 36px; + border-radius: 50%; +} + +/* ===== MOBILE RESPONSIVE ===== */ + +@media (max-width: 768px) { + .mobile-video-item { + min-width: 85vw !important; + max-width: 85vw !important; + } + + .main-container-mobile { + padding: 0 8px !important; + } + + .video-grid-mobile { + grid-template-columns: repeat(2, 1fr) !important; + gap: 8px !important; + } + + .videocard-container { + margin-bottom: 0 !important; + gap: 4px !important; + } + + .videocard-thumb { + border-radius: 8px !important; + } + + .videocard-info { + padding: 4px 0 !important; + } + + .videocard-container:hover .videocard-thumb { + transform: none; + } + + .search-results-container { + gap: 12px !important; + } + + .search-result-item { + flex-direction: column !important; + gap: 8px !important; + } + + .search-result-thumb-container { + width: 100% !important; + min-width: unset !important; + } + + .search-result-thumb { + border-radius: 0 !important; + } + + .search-result-info { + padding: 0 12px !important; + } + + .search-result-title { + font-size: 16px !important; + line-height: 22px !important; + -webkit-line-clamp: 2 !important; + display: -webkit-box !important; + -webkit-box-orient: vertical !important; + overflow: hidden !important; + } + + .search-page-container { + padding: 0 !important; + } +} + +@media (min-width: 769px) and (max-width: 1024px) { + .video-grid-mobile { + grid-template-columns: repeat(3, 1fr) !important; + } +} + +@media (min-width: 1025px) and (max-width: 1400px) { + .video-grid-mobile { + grid-template-columns: repeat(4, 1fr) !important; + } +} + +@media (min-width: 1401px) { + .video-grid-mobile { + grid-template-columns: repeat(5, 1fr) !important; + } +} + +@media (min-width: 769px) { + .mobile-video-item { + min-width: 300px !important; + max-width: 300px !important; + } + + .videocard-info { + padding: 0 !important; + } +} + +/* ===== STAGGER ANIMATION DELAYS ===== */ + +.stagger-1 { + animation-delay: 0s; +} + +.stagger-2 { + animation-delay: 0.05s; +} + +.stagger-3 { + animation-delay: 0.1s; +} + +.stagger-4 { + animation-delay: 0.15s; +} + +.stagger-5 { + animation-delay: 0.2s; +} + +.stagger-6 { + animation-delay: 0.25s; +} + +/* ===== HIDE SCROLLBAR ===== */ + +.hide-scrollbox::-webkit-scrollbar { + display: none; +} + +.hide-scrollbox { + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* ===== SECTION DIVIDER ===== */ + +.section-divider { + height: 1px; + background: linear-gradient(to right, + transparent 0%, + var(--yt-border) 20%, + var(--yt-border) 80%, + transparent 100%); + margin: 8px 0 24px; + opacity: 0.5; +} + +/* ===== CHANNEL PAGE ===== */ + +.channel-header { + max-width: 1284px; + margin: 24px auto 0; + padding: 0 32px; + display: flex; + align-items: center; + gap: 24px; + position: relative; + z-index: 2; + animation: fadeInUp 0.5s ease-out 0.1s both; +} + +.channel-avatar { + width: 80px; + height: 80px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 36px; + color: #fff; + flex-shrink: 0; + font-weight: 500; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15); + border: 2px solid var(--yt-background); + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.channel-avatar:hover { + transform: scale(1.04); + box-shadow: 0 6px 28px rgba(0, 0, 0, 0.5); +} + +.channel-meta { + flex: 1; + padding-bottom: 8px; + min-width: 0; +} + +.channel-name { + font-size: 36px; + font-weight: 700; + color: var(--yt-text-primary); + margin: 0 0 4px 0; + line-height: 1.2; +} + +.channel-stats { + font-size: 14px; + color: var(--yt-text-secondary); + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.channel-subscribe-btn { + background-color: var(--foreground); + color: var(--background); + border: none; + border-radius: 20px; + padding: 0 20px; + height: 38px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + outline: none; + position: relative; + overflow: hidden; + transition: transform 0.15s ease, box-shadow 0.2s ease; + letter-spacing: 0.2px; +} + +.channel-subscribe-btn:hover { + transform: scale(1.04); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); +} + +.channel-subscribe-btn:active { + transform: scale(0.97); +} + +.channel-tabs { + border-bottom: 1px solid var(--yt-border); + margin: 24px auto 0; + max-width: 1284px; + animation: fadeInUp 0.5s ease-out 0.2s both; +} + +.channel-tabs-inner { + padding: 0 32px; + display: flex; + gap: 0; +} + +.channel-tab { + padding: 14px 24px; + font-weight: 500; + font-size: 15px; + cursor: pointer; + color: var(--yt-text-secondary); + position: relative; + transition: color 0.2s ease; + letter-spacing: 0.3px; + text-transform: uppercase; +} + +.channel-tab:hover { + color: var(--yt-text-primary); +} + +.channel-tab.active { + color: var(--yt-text-primary); +} + +.channel-tab.active::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 3px; + background: var(--yt-text-primary); + border-radius: 3px 3px 0 0; +} + +.channel-video-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; + padding: 24px 32px 0; + max-width: 1284px; + margin: 0 auto; +} + +.channel-video-count { + font-size: 13px; + color: var(--yt-text-secondary); + font-weight: 400; + margin-left: 6px; +} + +/* Channel page mobile responsive */ +@media (max-width: 768px) { + .channel-header { + margin-top: 16px; + padding: 0 16px; + gap: 16px; + } + + .channel-avatar { + width: 80px; + height: 80px; + font-size: 36px; + border-width: 3px; + } + + .channel-name { + font-size: 22px; + } + + .channel-stats { + font-size: 13px; + } + + .channel-tabs-inner { + padding: 0 16px; + } + + .channel-tab { + padding: 12px 16px; + font-size: 14px; + } + + .channel-video-grid { + padding: 16px 0 0; + grid-template-columns: 1fr; + gap: 0; + } +}@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100755 index 0000000..6a76954 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from 'next'; +import { Roboto } from 'next/font/google'; +import './globals.css'; + +import Header from './components/Header'; +import Sidebar from './components/Sidebar'; +import MobileNav from './components/MobileNav'; + +const roboto = Roboto({ + weight: ['400', '500', '700'], + subsets: ['latin'], + display: 'swap', +}); + +export const metadata: Metadata = { + title: 'KV-Tube', + description: 'A pixel perfect YouTube clone', +}; + +import { ThemeProvider } from './context/ThemeContext'; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + -{% endblock %} \ No newline at end of file diff --git a/templates/downloads.html b/templates/downloads.html deleted file mode 100755 index b5044b8..0000000 --- a/templates/downloads.html +++ /dev/null @@ -1,205 +0,0 @@ -{% extends "layout.html" %} - -{% block content %} - - -
-
-

Downloads

- -
- -
- -
- - -
- - - -{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html deleted file mode 100755 index f9a6328..0000000 --- a/templates/index.html +++ /dev/null @@ -1,217 +0,0 @@ -{% extends "layout.html" %} - -{% block content %} - - -
-
- - - - - - - - - - - - -
- -
-
- -
-
-

Sort By

- - - - - -
-
-

Region

- - -
-
-
-
-
- - - - - - -
- -
- - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - - - -{% endblock %} \ No newline at end of file diff --git a/templates/layout.html b/templates/layout.html deleted file mode 100755 index b1e6e0f..0000000 --- a/templates/layout.html +++ /dev/null @@ -1,532 +0,0 @@ - - - - - - - - - - - - KV-Tube - - - - - - - - - - - - - - - - - - - -
- -
-
- - -
- -
-
- - -
-
- -
- - -
-
- - - - - - - - -
- - -
- {% block content %}{% endblock %} -
- - - - - -
- - - - - - -
- - - - -
-
-

Queue (0)

- -
-
- -
- -
-
- - - - - - - - -
-
- -
- -
-
-
- - \ No newline at end of file diff --git a/templates/login.html b/templates/login.html deleted file mode 100755 index cc9c6b8..0000000 --- a/templates/login.html +++ /dev/null @@ -1,212 +0,0 @@ -{% extends "layout.html" %} - -{% block content %} -
-
- - - -

Sign in

-

to continue to KV-Tube

- - {% with messages = get_flashed_messages() %} - {% if messages %} -
- - {{ messages[0] }} -
- {% endif %} - {% endwith %} - -
-
- - -
- -
- - -
- - -
- -
- or -
- - -
-
- - -{% endblock %} \ No newline at end of file diff --git a/templates/my_videos.html b/templates/my_videos.html deleted file mode 100755 index db68704..0000000 --- a/templates/my_videos.html +++ /dev/null @@ -1,463 +0,0 @@ -{% extends "layout.html" %} - -{% block content %} - - -
-
-

My Library

- - - - - -
- -
-
- - -
- -
- - - -
- - -{% endblock %} \ No newline at end of file diff --git a/templates/register.html b/templates/register.html deleted file mode 100755 index 9136387..0000000 --- a/templates/register.html +++ /dev/null @@ -1,212 +0,0 @@ -{% extends "layout.html" %} - -{% block content %} -
-
- - - -

Create account

-

to start watching on KV-Tube

- - {% with messages = get_flashed_messages() %} - {% if messages %} -
- - {{ messages[0] }} -
- {% endif %} - {% endwith %} - -
-
- - -
- -
- - -
- - -
- -
- or -
- - -
-
- - -{% endblock %} \ No newline at end of file diff --git a/templates/settings.html b/templates/settings.html deleted file mode 100755 index 984d74b..0000000 --- a/templates/settings.html +++ /dev/null @@ -1,355 +0,0 @@ -{% extends "layout.html" %} - -{% block content %} -
-

Settings

- - -
-
- Theme -
- - -
-
-
- Player -
- - -
-
-
- - -
-

System Updates

- - -
-
- yt-dlp - Stable -
- -
- - -
-
- yt-dlp Nightly - Experimental -
- -
- - -
-
- ytfetcher - CC & Transcripts -
- -
- -
-
- - {% if session.get('user_id') %} -
-
- Display Name -
- - -
-
-
- {% endif %} - -
-
- KV-Tube v1.0 • YouTube-like streaming -
-
-
- - - - -{% endblock %} \ No newline at end of file diff --git a/templates/watch.html b/templates/watch.html deleted file mode 100755 index 31515fd..0000000 --- a/templates/watch.html +++ /dev/null @@ -1,2129 +0,0 @@ -{% extends "layout.html" %} - -{% block content %} - - - -
- - - - - -
-
-
- - -
- -
- - - -
-
-
-
-
-
-
-
-
-
-
- - - -
- - - -
- -
-
- Queue (0) - -
-
-
- -
-
-
- - -
-

Related Videos

- - - - - - - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- -
- -
-
-
- - -
-
- -

Video Summary

-
-
-
-
-
- - - - - {% endblock %} \ No newline at end of file diff --git a/tests/test_loader_integration.py b/tests/test_loader_integration.py deleted file mode 100755 index a02b4fd..0000000 --- a/tests/test_loader_integration.py +++ /dev/null @@ -1,69 +0,0 @@ - -import unittest -import os -import sys - -# Add parent dir to path so we can import app -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from app.services.loader_to import LoaderToService -from app.services.settings import SettingsService -from app.services.youtube import YouTubeService -from config import Config - -class TestIntegration(unittest.TestCase): - - def test_settings_persistence(self): - """Test if settings can be saved and retrieved""" - print("\n--- Testing Settings Persistence ---") - - # Save original value - original = SettingsService.get('youtube_engine', 'auto') - - try: - # Change value - SettingsService.set('youtube_engine', 'test_mode') - val = SettingsService.get('youtube_engine') - self.assertEqual(val, 'test_mode') - print("✓ Settings saved and retrieved successfully") - - finally: - # Restore original - SettingsService.set('youtube_engine', original) - - def test_loader_service_basic(self): - """Test Loader.to service with a known short video""" - print("\n--- Testing LoaderToService (Remote) ---") - print("Note: This performs a real API call. It might take 10-20s.") - - # 'Me at the zoo' - Shortest youtube video - url = "https://www.youtube.com/watch?v=jNQXAC9IVRw" - - result = LoaderToService.get_stream_url(url, format_id="360") - - if result: - print(f"✓ Success! Got URL: {result.get('stream_url')}") - print(f" Title: {result.get('title')}") - self.assertIsNotNone(result.get('stream_url')) - else: - print("✗ Check failedor service is down/blocking us.") - # We don't fail the test strictly because external services can be flaky - # but we warn - - def test_youtube_service_failover_simulation(self): - """Simulate how YouTubeService picks the engine""" - print("\n--- Testing YouTubeService Engine Selection ---") - - # 1. Force Local - SettingsService.set('youtube_engine', 'local') - # We assume local might fail if we are blocked, so we just check if it TRIES - # In a real unit test we would mock _get_info_local - - # 2. Force Remote - SettingsService.set('youtube_engine', 'remote') - # This should call _get_info_remote - - print("✓ Engine switching logic verified (by static analysis of code paths)") - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_summarizer_logic.py b/tests/test_summarizer_logic.py deleted file mode 100755 index 919ba2e..0000000 --- a/tests/test_summarizer_logic.py +++ /dev/null @@ -1,37 +0,0 @@ - -import sys -import os - -# Add parent path (project root) -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from app.services.summarizer import TextRankSummarizer - -def test_summarization(): - print("\n--- Testing TextRank Summarizer Logic (Offline) ---") - - text = """ - The HTTP protocol is the foundation of data communication for the World Wide Web. - Hypertext documents include hyperlinks to other resources that the user can easily access, for example, by a mouse click or by tapping the screen in a web browser. - HTTP is an application layer protocol for distributed, collaborative, hypermedia information systems. - Development of HTTP was initiated by Tim Berners-Lee at CERN in 1989. - Standards development of HTTP was coordinated by the Internet Engineering Task Force (IETF) and the World Wide Web Consortium (W3C), culminating in the publication of a series of Requests for Comments (RFCs). - The first definition of HTTP/1.1, the version of HTTP in common use, occurred in RFC 2068 in 1997, although this was deprecated by RFC 2616 in 1999 and then again by the RFC 7230 family of RFCs in 2014. - A later version, the successor HTTP/2, was standardized in 2015, and is now supported by major web servers and browsers over TLS using an ALPN extension. - HTTP/3 is the proposed successor to HTTP/2, which is already in use on the web, using QUIC instead of TCP for the underlying transport protocol. - """ - - summarizer = TextRankSummarizer() - summary = summarizer.summarize(text, num_sentences=2) - - print(f"Original Length: {len(text)} chars") - print(f"Summary Length: {len(summary)} chars") - print(f"Summary:\n{summary}") - - if len(summary) > 0 and len(summary) < len(text): - print("✓ Logic Verification Passed") - else: - print("✗ Logic Verification Failed") - -if __name__ == "__main__": - test_summarization() diff --git a/tmp_media_roller_research b/tmp_media_roller_research deleted file mode 160000 index 4b16beb..0000000 --- a/tmp_media_roller_research +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4b16bebf7d81925131001006231795f38538a928 diff --git a/update_deps.py b/update_deps.py deleted file mode 100755 index 753e7c9..0000000 --- a/update_deps.py +++ /dev/null @@ -1,27 +0,0 @@ -import subprocess -import sys - -def update_dependencies(): - print("--- Updating Dependencies ---") - try: - # Update ytfetcher - print("Updating ytfetcher...") - subprocess.check_call([ - sys.executable, "-m", "pip", "install", "--upgrade", - "git+https://github.com/kaya70875/ytfetcher.git" - ]) - print("--- ytfetcher updated successfully ---") - - # Update yt-dlp (nightly) - print("Updating yt-dlp (nightly)...") - subprocess.check_call([ - sys.executable, "-m", "pip", "install", "--upgrade", - "git+https://github.com/yt-dlp/yt-dlp.git" - ]) - print("--- yt-dlp (nightly) updated successfully ---") - - except Exception as e: - print(f"--- Failed to update dependencies: {e} ---") - -if __name__ == "__main__": - update_dependencies() diff --git a/wsgi.py b/wsgi.py deleted file mode 100755 index d5cc54e..0000000 --- a/wsgi.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -KV-Tube WSGI Entry Point -Slim entry point that uses the app factory -""" -from app import create_app - -# Create the Flask application -app = create_app() - -if __name__ == "__main__": - print(f"Starting KV-Tube Server on port 5002") - app.run(debug=True, host="0.0.0.0", port=5002, use_reloader=False)