Compare commits

..

No commits in common. "eefc5120e6a5cbfedd5c1369d58d99cf05f18b35" and "6c1f459cd6c459fc9563075af552eb850d4506af" have entirely different histories.

2613 changed files with 9238 additions and 628361 deletions

11
.dockerignore Executable file
View file

@ -0,0 +1,11 @@
.venv/
.venv_clean/
env/
__pycache__/
.git/
.DS_Store
*.pyc
*.pyo
*.pyd
.idea/
.vscode/

12
.env.example Executable file
View file

@ -0,0 +1,12 @@
# 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
# Environment: development or production
FLASK_ENV=development
# Local video directory (optional)
KVTUBE_VIDEO_DIR=./videos

68
.github/workflows/docker-publish.yml vendored Executable file
View file

@ -0,0 +1,68 @@
name: Docker Build & Push
on:
push:
tags:
- 'v*'
env:
# Use docker.io for Docker Hub if empty
REGISTRY: docker.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log into Forgejo Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: git.khoavo.myds.me
username: ${{ secrets.FORGEJO_USERNAME }}
password: ${{ secrets.FORGEJO_PASSWORD }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
git.khoavo.myds.me/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ github.ref == 'refs/tags/v*' }}
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max

12
.gitignore vendored Executable file
View file

@ -0,0 +1,12 @@
.DS_Store
__pycache__/
*.pyc
venv/
.venv/
.venv_clean/
.env
data/
videos/
*.db
server.log
.ruff_cache/

0
API_DOCUMENTATION.md Normal file → Executable file
View file

6
Dockerfile Normal file → Executable file
View file

@ -27,7 +27,5 @@ RUN mkdir -p /app/videos /app/data
# Expose port # Expose port
EXPOSE 5000 EXPOSE 5000
# Run with Entrypoint (handles updates) # Run with Gunicorn
COPY entrypoint.sh /app/entrypoint.sh CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--threads", "2", "--timeout", "120", "wsgi:app"]
RUN chmod +x /app/entrypoint.sh
CMD ["/app/entrypoint.sh"]

181
README.md Normal file → Executable file
View file

@ -1,83 +1,110 @@
# KV-Tube v3.0 # KV-Tube
**A Distraction-Free, Privacy-Focused YouTube Client**
> A lightweight, privacy-focused YouTube frontend web application with AI-powered features. > [!NOTE]
> Designed for a premium, cinematic viewing experience.
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. KV-Tube removes the clutter and noise of modern YouTube, focusing purely on the content you love. It strictly enforces a horizontal-first video policy, aggressively filtering out Shorts and vertical "TikTok-style" content to keep your feed clean and high-quality.
## 🚀 Key Features (v3) ### 🚀 **Key Features (v2.0)**
- **Privacy First**: No tracking, no ads. * **🚫 Ads-Free & Privacy-First**: Watch without interruptions. No Google account required. All watch history is stored locally on your device (or self-hosted DB).
- **Clean Interface**: Distraction-free watching experience. * **📺 Horizontal-First Experience**: Say goodbye to "Shorts". The feed only displays horizontal, cinematic content.
- **Efficient Streaming**: Direct video stream extraction using `yt-dlp`. * **🔍 Specialized Feeds**:
- **AI Summary (Experimental)**: Generate concise summaries of videos (Currently disabled due to upstream rate limits). * **Tech & AI**: Clean feed for gadget reviews and deep dives.
- **Multi-Language**: Support for English and Vietnamese (UI & Content). * **Trending**: See what's popular across major categories (Music, Gaming, News).
- **Auto-Update**: Includes `update_deps.py` to easily keep core fetching tools up-to-date. * **Suggested for You**: Personalized recommendations based on your local watch history.
* **🧠 Local AI Integration**:
## 🛠️ Architecture Data Flow * **Auto-Captions**: Automatically enables English subtitles.
* **AI Summary**: (Optional) Generate quick text summaries of videos locally.
```mermaid * **⚡ High Performance**: Optimized for speed with smart caching and rate-limit handling.
graph TD * **📱 PWA Ready**: Install on your phone or tablet with a responsive, app-like interface.
User[User Browser]
Server[KV-Tube Server (Flask)]
YTDLP[yt-dlp Core]
YTFetcher[YTFetcher Lib]
YouTube[YouTube V3 API / HTML]
User -- "1. Search / Watch Request" --> Server
Server -- "2. Extract Video Metadata" --> YTDLP
YTDLP -- "3. Network Requests (Cookies Optional)" --> YouTube
YouTube -- "4. Raw Video/Audio Streams" --> YTDLP
YTDLP -- "5. Stream URL / Metadata" --> Server
subgraph Transcript System [Transcript System (Deferred)]
Server -.-> YTFetcher
YTFetcher -.-> YouTube
YTFetcher -- "No Transcript (429)" -.-> Server
end
Server -- "6. Render HTML / Stream Proxy" --> User
```
## 🔧 Installation & Usage
### Prerequisites
- Python 3.10+
- Git
- Valid `cookies.txt` (Optional, for bypassing age-restrictions or rate limits)
### Local Setup
1. Clone the repository:
```bash
git clone https://git.khoavo.myds.me/vndangkhoa/kv-tube.git
cd kv-tube
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Run the application:
```bash
python wsgi.py
```
4. Access at `http://localhost:5002`
### Docker Deployment (Linux/AMD64)
Built for stability and ease of use.
```bash
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*
## 🛠️ Deployment
You can run KV-Tube easily using Docker (recommended for NAS/Servers) or directly with Python.
### Option A: Docker Compose (Recommended)
Ideal for Synology NAS, Unraid, or casual servers.
1. Create a folder `kv-tube` and add the `docker-compose.yml` file.
2. Run the container:
```bash
docker-compose up -d
```
3. Access the app at: **http://localhost:5011**
**docker-compose.yml**:
```yaml
version: '3.8'
services:
kv-tube:
image: vndangkhoa/kv-tube:latest
container_name: kv-tube
restart: unless-stopped
ports:
- "5011:5000"
volumes:
- ./data:/app/data
environment:
- PYTHONUNBUFFERED=1
- FLASK_ENV=production
```
### Option B: Local Development (Python)
For developers or running locally on a PC.
1. **Clone & Install**:
```bash
git clone https://github.com/vndangkhoa/kv-tube.git
cd kv-tube
python -m venv .venv
# Windows
.venv\Scripts\activate
# Linux/Mac
source .venv/bin/activate
pip install -r requirements.txt
```
2. **Run**:
```bash
python kv_server.py
```
3. Access the app at: **http://localhost:5002**
---
## ⚙️ Configuration
KV-Tube is designed to be "Zero-Config", but you can customize it via Environment Variables (in `.env` or Docker).
| Variable | Default | Description |
| :--- | :--- | :--- |
| `FLASK_ENV` | `production` | Set to `development` for debug mode. |
| `KVTUBE_DATA_DIR` | `./data` | Location for the SQLite database. |
| `KVTUBE_VIDEO_DIR` | `./videos` | (Optional) Location for downloaded videos. |
| `SECRET_KEY` | *(Auto)* | Session security key. Set manually for persistence. |
---
## 🔌 API Endpoints
KV-Tube exposes a RESTful API for its frontend.
| Endpoint | Method | Description |
| :--- | :--- | :--- |
| `/api/search` | `GET` | Search for videos. |
| `/api/stream_info` | `GET` | Get raw stream URLs (HLS/MP4). |
| `/api/suggested` | `GET` | Get recommendations based on history. |
| `/api/download` | `GET` | Get direct download link for a video. |
| `/api/history` | `GET` | Retrieve local watch history. |
---
## 📜 License
Proprietary / Personal Use.
Created by **Khoa N.D**

0
USER_GUIDE.md Normal file → Executable file
View file

Binary file not shown.

7
app/__init__.py Normal file → Executable file
View file

@ -85,13 +85,6 @@ def create_app(config_name=None):
# Register Blueprints # Register Blueprints
register_blueprints(app) 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") logger.info("KV-Tube app created successfully")
return app return app

0
app/routes/__init__.py Normal file → Executable file
View file

1161
app/routes/api.py Normal file → Executable file

File diff suppressed because it is too large Load diff

0
app/routes/pages.py Normal file → Executable file
View file

94
app/routes/streaming.py Normal file → Executable file
View file

@ -29,115 +29,66 @@ def stream_local(filename):
return send_from_directory(VIDEO_DIR, filename) return send_from_directory(VIDEO_DIR, filename)
def add_cors_headers(response): @streaming_bp.route("/video_proxy")
"""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(): def video_proxy():
"""Proxy video streams with HLS manifest rewriting.""" """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") url = request.args.get("url")
if not url: if not url:
return "No URL provided", 400 return "No URL provided", 400
# Forward headers to mimic browser and support seeking # Forward headers to mimic browser and support seeking
headers = { 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", "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",
"Referer": "https://www.youtube.com/", # "Referer": "https://www.youtube.com/", # Removed to test if it fixes 403
"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) # Support Range requests (scrubbing)
range_header = request.headers.get("Range") range_header = request.headers.get("Range")
if range_header: if range_header:
headers["Range"] = range_header headers["Range"] = range_header
try: try:
logger.info(f"Proxying URL: {url[:100]}...") logger.info(f"Proxying URL: {url}")
# logger.info(f"Proxy Request Headers: {headers}")
req = requests.get(url, headers=headers, stream=True, timeout=30) 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')}") logger.info(f"Upstream Status: {req.status_code}")
if req.status_code != 200 and req.status_code != 206: if req.status_code != 200:
logger.error(f"Upstream Error: {req.status_code}") logger.error(f"Upstream Error Body: {req.text[:500]}")
# Handle HLS (M3U8) Rewriting - CRITICAL for 1080p+ and proper sync # Handle HLS (M3U8) Rewriting - CRITICAL for 1080p+ and proper sync
content_type = req.headers.get("content-type", "").lower() content_type = req.headers.get("content-type", "").lower()
url_path = url.split("?")[0] url_path = url.split("?")[0]
# Improved manifest detection - YouTube may send text/plain or octet-stream
is_manifest = ( is_manifest = (
url_path.endswith(".m3u8") url_path.endswith(".m3u8")
or "mpegurl" in content_type or "application/x-mpegurl" in content_type
or "m3u8" in url_path.lower() or "application/vnd.apple.mpegurl" in content_type
or ("/playlist/" in url.lower() and "index.m3u8" in url.lower())
) )
logger.info(f"Is Manifest: {is_manifest}, Status: {req.status_code}") if is_manifest and req.status_code == 200:
# Handle 200 and 206 (partial content) responses for manifests
if is_manifest and req.status_code in [200, 206]:
content = req.text content = req.text
base_url = url.rsplit("/", 1)[0] base_url = url.rsplit("/", 1)[0]
new_lines = [] new_lines = []
logger.info(f"Rewriting manifest with {len(content.splitlines())} lines")
for line in content.splitlines(): for line in content.splitlines():
line_stripped = line.strip() if line.strip() and not line.startswith("#"):
if line_stripped and not line_stripped.startswith("#"): # If relative, make absolute
# URL line - needs rewriting if not line.startswith("http"):
if not line_stripped.startswith("http"): full_url = f"{base_url}/{line}"
# Relative URL - make absolute
full_url = f"{base_url}/{line_stripped}"
else: else:
# Absolute URL full_url = line
full_url = line_stripped
from urllib.parse import quote from urllib.parse import quote
quoted_url = quote(full_url, safe="") quoted_url = quote(full_url, safe="")
new_line = f"/video_proxy?url={quoted_url}" new_lines.append(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: else:
new_lines.append(line) new_lines.append(line)
rewritten_content = "\n".join(new_lines) return Response(
logger.info(f"Manifest rewritten successfully") "\n".join(new_lines), content_type="application/vnd.apple.mpegurl"
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 # Standard Stream Proxy (Binary)
excluded_headers = [ excluded_headers = [
"content-encoding", "content-encoding",
"content-length", "content-length",
@ -150,15 +101,12 @@ def video_proxy():
if name.lower() not in excluded_headers if name.lower() not in excluded_headers
] ]
response = Response( return Response(
stream_with_context(req.iter_content(chunk_size=8192)), stream_with_context(req.iter_content(chunk_size=8192)),
status=req.status_code, status=req.status_code,
headers=response_headers, headers=response_headers,
content_type=req.headers.get("content-type"), content_type=req.headers.get("content-type"),
) )
return add_cors_headers(response)
except Exception as e: except Exception as e:
logger.error(f"Proxy Error: {e}") logger.error(f"Proxy Error: {e}")
return str(e), 500 return str(e), 500

0
app/services/__init__.py Normal file → Executable file
View file

0
app/services/cache.py Normal file → Executable file
View file

View file

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

View file

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

View file

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

173
app/services/summarizer.py Normal file → Executable file
View file

@ -1,119 +1,116 @@
"""
Summarizer Service Module
Extractive text summarization for video transcripts
"""
import re import re
import math import heapq
import logging import logging
from typing import List from typing import List
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TextRankSummarizer: # Stop words for summarization
STOP_WORDS = frozenset([
'the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were',
'to', 'of', 'in', 'on', 'at', 'for', 'with', 'that', 'this', 'it',
'you', 'i', 'we', 'they', 'he', 'she', 'be', 'have', 'has', 'do',
'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might',
'must', 'can', 'not', 'no', 'so', 'as', 'if', 'then', 'than',
'when', 'where', 'what', 'which', 'who', 'how', 'why', 'all',
'each', 'every', 'both', 'few', 'more', 'most', 'other', 'some',
'such', 'any', 'only', 'own', 'same', 'just', 'now', 'also', 'very'
])
def extractive_summary(text: str, num_sentences: int = 5) -> str:
""" """
Summarizes text using a TextRank-like graph algorithm. Generate an extractive summary of text
This creates more coherent "whole idea" summaries than random extraction.
Args:
text: Input text to summarize
num_sentences: Number of sentences to extract
Returns:
Summary string with top-ranked sentences
""" """
if not text or not text.strip():
return "Not enough content to summarize."
def __init__(self): # Clean text - remove metadata like [Music] common in auto-captions
self.stop_words = set([ clean_text = re.sub(r'\[.*?\]', '', text)
"the", "a", "an", "and", "or", "but", "is", "are", "was", "were", clean_text = clean_text.replace('\n', ' ')
"to", "of", "in", "on", "at", "for", "width", "that", "this", "it", clean_text = re.sub(r'\s+', ' ', clean_text).strip()
"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: if len(clean_text) < 100:
""" return clean_text
Generate a summary of the text.
Args: # Split into sentences
text: Input text sentences = _split_sentences(clean_text)
num_sentences: Number of sentences in the summary
Returns: if len(sentences) <= num_sentences:
Summarized text string return clean_text
"""
if not text:
return ""
# 1. Split into sentences # Calculate word frequencies
# Use regex to look for periods/questions/exclamations followed by space or end of string word_frequencies = _calculate_word_frequencies(clean_text)
sentences = re.split(r'(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s', text)
sentences = [s.strip() for s in sentences if len(s.strip()) > 20] # Filter very short fragments
if not sentences: if not word_frequencies:
return text[:500] + "..." if len(text) > 500 else text return "Not enough content to summarize."
if len(sentences) <= num_sentences: # Score sentences
return " ".join(sentences) sentence_scores = _score_sentences(sentences, word_frequencies)
# 2. Build Similarity Graph # Extract top N sentences
# We calculate cosine similarity between all pairs of sentences top_sentences = heapq.nlargest(num_sentences, sentence_scores, key=sentence_scores.get)
# graph[i][j] = similarity score
n = len(sentences)
scores = [0.0] * n
# Pre-process sentences for efficiency # Return in original order
# Convert to sets of words ordered = [s for s in sentences if s in top_sentences]
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" return ' '.join(ordered)
# 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 def _split_sentences(text: str) -> List[str]:
# Sort by score descending """Split text into sentences"""
ranked_sentences = sorted(((scores[i], i) for i in range(n)), reverse=True) # Regex for sentence splitting - handles abbreviations
pattern = r'(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s'
sentences = re.split(pattern, text)
# Pick top N # Filter out very short sentences
top_indices = [idx for score, idx in ranked_sentences[:num_sentences]] return [s.strip() for s in sentences if len(s.strip()) > 20]
# 4. Reorder by appearance in original text for coherence
top_indices.sort()
summary = " ".join([sentences[i] for i in top_indices]) def _calculate_word_frequencies(text: str) -> dict:
return summary """Calculate normalized word frequencies"""
word_frequencies = {}
def _cosine_similarity(self, words1: List[str], words2: List[str]) -> float: words = re.findall(r'\w+', text.lower())
"""Calculate cosine similarity between two word lists."""
if not words1 or not words2:
return 0.0
# Unique words in both for word in words:
all_words = set(words1) | set(words2) if word not in STOP_WORDS and len(word) > 2:
word_frequencies[word] = word_frequencies.get(word, 0) + 1
# Frequency vectors if not word_frequencies:
vec1 = {w: 0 for w in all_words} return {}
vec2 = {w: 0 for w in all_words}
for w in words1: vec1[w] += 1 # Normalize by max frequency
for w in words2: vec2[w] += 1 max_freq = max(word_frequencies.values())
for word in word_frequencies:
word_frequencies[word] = word_frequencies[word] / max_freq
# Dot product return word_frequencies
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: def _score_sentences(sentences: List[str], word_frequencies: dict) -> dict:
return 0.0 """Score sentences based on word frequencies"""
sentence_scores = {}
return dot_product / (mag1 * mag2) for sentence in sentences:
words = re.findall(r'\w+', sentence.lower())
score = sum(word_frequencies.get(word, 0) for word in words)
# Normalize by sentence length to avoid bias toward long sentences
if len(words) > 0:
score = score / (len(words) ** 0.5) # Square root normalization
sentence_scores[sentence] = score
return sentence_scores

35
app/services/youtube.py Normal file → Executable file
View file

@ -6,8 +6,6 @@ import yt_dlp
import logging import logging
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from config import Config from config import Config
from app.services.loader_to import LoaderToService
from app.services.settings import SettingsService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -22,7 +20,6 @@ class YouTubeService:
'extract_flat': 'in_playlist', 'extract_flat': 'in_playlist',
'force_ipv4': True, 'force_ipv4': True,
'socket_timeout': Config.YTDLP_TIMEOUT, '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 @staticmethod
@ -116,34 +113,6 @@ class YouTubeService:
Returns: Returns:
Video info dict with stream_url, or None on error 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: try:
url = f"https://www.youtube.com/watch?v={video_id}" url = f"https://www.youtube.com/watch?v={video_id}"
@ -179,12 +148,10 @@ class YouTubeService:
'view_count': info.get('view_count', 0), 'view_count': info.get('view_count', 0),
'subtitle_url': subtitle_url, 'subtitle_url': subtitle_url,
'duration': info.get('duration'), '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: except Exception as e:
logger.error(f"Error getting local video info for {video_id}: {e}") logger.error(f"Error getting video info for {video_id}: {e}")
return None return None
@staticmethod @staticmethod

0
app/utils/__init__.py Normal file → Executable file
View file

0
app/utils/formatters.py Normal file → Executable file
View file

Binary file not shown.

9
config.py Normal file → Executable file
View file

@ -29,16 +29,9 @@ class Config:
CACHE_CHANNEL_TTL = 1800 # 30 minutes CACHE_CHANNEL_TTL = 1800 # 30 minutes
# yt-dlp settings # yt-dlp settings
# yt-dlp settings - MUST use progressive formats with combined audio+video YTDLP_FORMAT = 'best[ext=mp4]/best'
# 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 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 @staticmethod
def init_app(app): def init_app(app):
"""Initialize app with config""" """Initialize app with config"""

View file

@ -1,19 +0,0 @@
# Netscape HTTP Cookie File
# This file is generated by yt-dlp. Do not edit.
.youtube.com TRUE / TRUE 1802692356 __Secure-3PSID g.a0005gie1lAkmYZc-EPeGx77pCrXo_Cz5eAi-e9aryb9Qoz967v4Caiou6Tt5ZyLR4iMp5I51wACgYKASISARESFQHGX2MiopTeGBKXybppZWNr7JzmKhoVAUF8yKrgfPx-gEb02gGAV3ZaVOGr0076
.youtube.com TRUE / TRUE 1800281710 __Secure-1PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
.youtube.com TRUE / TRUE 1802692356 SAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1800282680 __Secure-1PSIDCC AKEyXzXvpBScD7r3mqr7aZ0ymWZ7FmsgT0q0C3Ge8hvrjZ9WZ4PU4ZBuBsO0YNYN3A8iX4eV8F8
.youtube.com TRUE / TRUE 1802692356 SSID A4isk9AE9xActvzYy
.youtube.com TRUE / TRUE 1802692356 __Secure-1PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1802692356 __Secure-1PSID g.a0005gie1lAkmYZc-EPeGx77pCrXo_Cz5eAi-e9aryb9Qoz967v4-rF3xTavVHrJoyJAqShH6gACgYKAX0SARESFQHGX2MiOdAbUPmCj4MueYyh-2km5RoVAUF8yKp2ehWQC6tX8n-9UNg11RV60076
.youtube.com TRUE / TRUE 1802692356 __Secure-3PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1800282680 __Secure-3PSIDCC AKEyXzVcvX-jLLprjZQXoqarG3xsAVpjyLYaN2j0a_iUcsnKnpL88P_5IlcfusJn0We0aaKK7g
.youtube.com TRUE / TRUE 1800281710 __Secure-3PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
.youtube.com TRUE / TRUE 1792154873 LOGIN_INFO AFmmF2swRQIgVjJk8Mho4_JuKr6SZzrhBdlL1LdxWxcwDMu4cjaRRgcCIQCTtJpmYKJH54Tiei3at3f4YT3US7gSL0lW_TZ04guKjQ:QUQ3MjNmeWlwRDJSNDl2NE9uX2JWWG5tWllHN0RsNUVZVUhsLVp4N2dWbldaeC14SnNybWVERnNoaXFpanFJczhKTjJSRGN6MEs3c1VkLTE1TGJVeFBPT05BY29NMFh0Q1VPdFU3dUdvSUpET3lQbU1ZMUlHUGltajlXNDllNUQxZHdzZko1WXF1UUJWclNxQVJ0TXVEYnF2bXJRY2V6Vl9n
.youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=150&hl=en
.youtube.com TRUE / TRUE 0 YSC y-oH2BqaUSQ
.youtube.com TRUE / TRUE 1784298680 __Secure-ROLLOUT_TOKEN CPm51pHVjquOTRDw0bnsppWSAxjw0bnsppWSAw%3D%3D
.youtube.com TRUE / TRUE 1784298680 VISITOR_INFO1_LIVE ShB1Bvj-rRU
.youtube.com TRUE / TRUE 1784298680 VISITOR_PRIVACY_METADATA CgJWThIEGgAgWA%3D%3D

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

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

0
deploy.py Normal file → Executable file
View file

69
dev.sh
View file

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

0
doc/Product Requirements Document (PRD) - KV-Tube Normal file → Executable file
View file

2
docker-compose.yml Normal file → Executable file
View file

@ -5,7 +5,7 @@ version: '3.8'
services: services:
kv-tube: kv-tube:
build: . # build: .
image: vndangkhoa/kv-tube:latest image: vndangkhoa/kv-tube:latest
container_name: kv-tube container_name: kv-tube
restart: unless-stopped restart: unless-stopped

View file

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

File diff suppressed because it is too large Load diff

0
kv_server.py Normal file → Executable file
View file

View file

BIN
kvtube.db

Binary file not shown.

0
requirements.txt Normal file → Executable file
View file

0
start.sh Normal file → Executable file
View file

0
static/css/modules/base.css Normal file → Executable file
View file

0
static/css/modules/cards.css Normal file → Executable file
View file

0
static/css/modules/chat.css Normal file → Executable file
View file

49
static/css/modules/components.css Normal file → Executable file
View file

@ -266,55 +266,6 @@
background: var(--yt-bg-secondary); background: var(--yt-bg-secondary);
} }
/* --- Homepage Sections --- */
.yt-homepage-section {
margin-bottom: 32px;
}
.yt-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 0 4px;
}
.yt-section-header h2 {
font-size: 20px;
font-weight: 600;
color: var(--yt-text-primary);
margin: 0;
}
.yt-see-all {
color: var(--yt-text-secondary);
font-size: 14px;
background: none;
border: none;
cursor: pointer;
padding: 8px 12px;
border-radius: var(--yt-radius-sm);
transition: background 0.2s;
}
.yt-see-all:hover {
background: var(--yt-bg-hover);
}
@media (max-width: 768px) {
.yt-homepage-section {
margin-bottom: 24px;
}
.yt-section-header {
padding: 0 8px;
}
.yt-section-header h2 {
font-size: 18px;
}
}
/* --- Categories / Pills --- */ /* --- Categories / Pills --- */
.yt-categories { .yt-categories {
display: flex; display: flex;

0
static/css/modules/downloads.css Normal file → Executable file
View file

0
static/css/modules/grid.css Normal file → Executable file
View file

0
static/css/modules/layout.css Normal file → Executable file
View file

0
static/css/modules/pages.css Normal file → Executable file
View file

0
static/css/modules/utils.css Normal file → Executable file
View file

0
static/css/modules/variables.css Normal file → Executable file
View file

0
static/css/modules/watch.css Normal file → Executable file
View file

0
static/css/style.css Normal file → Executable file
View file

0
static/favicon.ico Normal file → Executable file
View file

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

0
static/icons/icon-192x192.png Normal file → Executable file
View file

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

0
static/icons/icon-512x512.png Normal file → Executable file
View file

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

0
static/js/download-manager.js Normal file → Executable file
View file

0
static/js/hls.min.js vendored Normal file → Executable file
View file

232
static/js/main.js Normal file → Executable file
View file

@ -167,132 +167,7 @@ function renderNoContent(message = 'Try searching for something else', title = '
`; `;
} }
// Render homepage with personalized sections // Search YouTube videos
function renderHomepageSections(sections, container, localHistory = []) {
// Create a map for quick history lookup
const historyMap = {};
localHistory.forEach(v => {
if (v && v.id) historyMap[v.id] = v;
});
sections.forEach(section => {
if (!section.videos || section.videos.length === 0) return;
// Create section wrapper
const sectionEl = document.createElement('div');
sectionEl.className = 'yt-homepage-section';
sectionEl.id = `section-${section.id}`;
// Section header
const header = document.createElement('div');
header.className = 'yt-section-header';
header.innerHTML = `
<h2>${escapeHtml(section.title)}</h2>
`;
sectionEl.appendChild(header);
// Video grid for this section
const grid = document.createElement('div');
grid.className = 'yt-video-grid';
// LIMIT VISIBLE VIDEOS TO 8 (2 rows of 4 on desktop)
const INITIAL_LIMIT = 8;
const hasMore = section.videos.length > INITIAL_LIMIT;
section.videos.forEach((video, index) => {
// For continue watching
if (video._from_history && historyMap[video.id]) {
const hist = historyMap[video.id];
video.title = hist.title || video.title;
video.uploader = hist.uploader || video.uploader;
video.thumbnail = hist.thumbnail || video.thumbnail;
}
const card = document.createElement('div');
card.className = 'yt-video-card';
// Hide videos beyond limit initially
if (index >= INITIAL_LIMIT) {
card.classList.add('yt-hidden-video');
card.style.display = 'none';
}
card.innerHTML = `
<div class="yt-thumbnail-container">
<img class="yt-thumbnail" src="${video.thumbnail}" alt="${escapeHtml(video.title || 'Video')}" loading="lazy" onload="this.classList.add('loaded')">
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
</div>
<div class="yt-video-details">
<div class="yt-channel-avatar">
${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'}
</div>
<div class="yt-video-meta">
<h3 class="yt-video-title">${escapeHtml(video.title || 'Unknown')}</h3>
<p class="yt-channel-name">${escapeHtml(video.uploader || 'Unknown')}</p>
<p class="yt-video-stats">${formatViews(video.view_count)} views${video.upload_date ? ' • ' + formatDate(video.upload_date) : ''}</p>
</div>
</div>
`;
card.addEventListener('click', () => {
const params = new URLSearchParams({
v: video.id,
title: video.title || '',
uploader: video.uploader || '',
thumbnail: video.thumbnail || ''
});
const dest = `/watch?${params.toString()}`;
if (window.navigationManager) {
window.navigationManager.navigateTo(dest);
} else {
window.location.href = dest;
}
});
grid.appendChild(card);
});
sectionEl.appendChild(grid);
// ADD LOAD MORE BUTTON IF NEEDED
if (hasMore) {
const btnContainer = document.createElement('div');
btnContainer.className = 'yt-section-footer';
btnContainer.style.textAlign = 'center';
btnContainer.style.padding = '10px';
const btn = document.createElement('button');
btn.className = 'yt-action-btn'; // Re-use existing or generic class
btn.style.padding = '8px 24px';
btn.style.borderRadius = '18px';
btn.style.border = '1px solid var(--yt-border)';
btn.style.background = 'var(--yt-bg-secondary)';
btn.style.color = 'var(--yt-text-primary)';
btn.style.cursor = 'pointer';
btn.style.fontWeight = '500';
btn.innerText = 'Show more';
btn.onmouseover = () => btn.style.background = 'var(--yt-bg-hover)';
btn.onmouseout = () => btn.style.background = 'var(--yt-bg-secondary)';
btn.onclick = function () {
// Reveal hidden videos
const hidden = grid.querySelectorAll('.yt-hidden-video');
hidden.forEach(el => el.style.display = 'flex'); // Restore display
btnContainer.remove(); // Remove button
};
btnContainer.appendChild(btn);
sectionEl.appendChild(btnContainer);
}
container.appendChild(sectionEl);
});
if (window.observeImages) window.observeImages();
}
async function searchYouTube(query) { async function searchYouTube(query) {
if (isLoading) return; if (isLoading) return;
@ -344,15 +219,6 @@ async function switchCategory(category, btn) {
hasMore = true; // Reset infinite scroll hasMore = true; // Reset infinite scroll
const resultsArea = document.getElementById('resultsArea'); const resultsArea = document.getElementById('resultsArea');
const videosSection = document.getElementById('videosSection');
// Show resultsArea (may have been hidden by homepage sections)
resultsArea.style.display = '';
// Remove any homepage sections
if (videosSection) {
videosSection.querySelectorAll('.yt-homepage-section').forEach(el => el.remove());
}
resultsArea.innerHTML = renderSkeleton(); resultsArea.innerHTML = renderSkeleton();
// Hide pagination while loading // Hide pagination while loading
@ -361,7 +227,7 @@ async function switchCategory(category, btn) {
// Handle Shorts Layout // Handle Shorts Layout
const shortsSection = document.getElementById('shortsSection'); const shortsSection = document.getElementById('shortsSection');
// videosSection already declared above const videosSection = document.getElementById('videosSection');
if (shortsSection) { if (shortsSection) {
if (category === 'shorts') { if (category === 'shorts') {
@ -439,80 +305,28 @@ async function loadTrending(reset = true) {
} }
try { try {
// Default to 'newest' for fresh content on main page
const sortValue = window.currentSort || (currentCategory === 'all' ? 'newest' : 'month');
const regionValue = window.currentRegion || 'vietnam'; const regionValue = window.currentRegion || 'vietnam';
// Add cache-buster for home page to ensure fresh content
const cb = reset && currentCategory === 'all' ? `&_=${Date.now()}` : '';
// For 'all' category, use new homepage API with personalization // Include localStorage history for personalized suggestions on home page
let historyParams = '';
if (currentCategory === 'all') { if (currentCategory === 'all') {
// Build personalization params from localStorage
const history = JSON.parse(localStorage.getItem('kv_history') || '[]'); const history = JSON.parse(localStorage.getItem('kv_history') || '[]');
const subscriptions = JSON.parse(localStorage.getItem('kv_subscriptions') || '[]'); if (history.length > 0) {
const titles = history.slice(0, 5).map(v => v.title).filter(Boolean).join(',');
const params = new URLSearchParams(); const channels = history.slice(0, 3).map(v => v.uploader).filter(Boolean).join(',');
params.append('region', regionValue); if (titles) historyParams += `&history_titles=${encodeURIComponent(titles)}`;
params.append('page', currentPage); // Add Pagination if (channels) historyParams += `&history_channels=${encodeURIComponent(channels)}`;
params.append('_', Date.now()); // Cache buster
if (history.length > 0 && reset) { // Only send history on first page for relevance
const historyIds = history.slice(0, 10).map(v => v.id).filter(Boolean);
const historyTitles = history.slice(0, 5).map(v => v.title).filter(Boolean);
const historyChannels = history.slice(0, 5).map(v => v.uploader).filter(Boolean);
if (historyIds.length) params.append('history', historyIds.join(','));
if (historyTitles.length) params.append('titles', historyTitles.join(','));
if (historyChannels.length) params.append('channels', historyChannels.join(','));
}
if (subscriptions.length > 0 && reset) {
const subIds = subscriptions.slice(0, 10).map(s => s.id).filter(Boolean);
if (subIds.length) params.append('subs', subIds.join(','));
}
// Show skeleton for infinite scroll
if (!reset) {
const videosSection = document.getElementById('videosSection');
// Avoid duplicates
if (!document.getElementById('infinite-scroll-skeleton')) {
const skelDiv = document.createElement('div');
skelDiv.id = 'infinite-scroll-skeleton';
skelDiv.className = 'yt-video-grid';
skelDiv.style.marginTop = '20px';
skelDiv.innerHTML = renderSkeleton(); // Reuse existing skeleton generator
videosSection.appendChild(skelDiv);
}
}
const response = await fetch(`/api/homepage?${params.toString()}`);
const data = await response.json();
if (data.mode === 'sections' && data.data) {
// Hide the grid-based resultsArea and render sections to parent
resultsArea.style.display = 'none';
const videosSection = document.getElementById('videosSection');
if (reset) {
// Remove previous sections if reset
videosSection.querySelectorAll('.yt-homepage-section').forEach(el => el.remove());
}
// Remove infinite scroll skeleton if it exists
const existingSkeleton = document.getElementById('infinite-scroll-skeleton');
if (existingSkeleton) existingSkeleton.remove();
// Append new sections (for Infinite Scroll)
renderHomepageSections(data.data, videosSection, history);
isLoading = false;
hasMore = data.data.length > 0; // Continue if we got sections
return;
} }
} }
// Fallback: Original trending logic for category pages const response = await fetch(`/api/trending?category=${currentCategory}&page=${currentPage}&sort=${sortValue}&region=${regionValue}${historyParams}${cb}`);
const sortValue = window.currentSort || 'month';
const cb = reset ? `&_=${Date.now()}` : '';
const response = await fetch(`/api/trending?category=${currentCategory}&page=${currentPage}&sort=${sortValue}&region=${regionValue}${cb}`);
const data = await response.json(); const data = await response.json();
if (data.error) { if (data.error) {
console.error('Trending error:', data.error); console.error('Trending error:', data.error);
if (reset) { if (reset) {
@ -693,10 +507,6 @@ function formatViews(views) {
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return 'Recently'; if (!dateStr) return 'Recently';
// Ensure string
dateStr = String(dateStr);
console.log('[Debug] formatDate input:', dateStr);
// Handle YYYYMMDD format // Handle YYYYMMDD format
if (/^\d{8}$/.test(dateStr)) { if (/^\d{8}$/.test(dateStr)) {
const year = dateStr.substring(0, 4); const year = dateStr.substring(0, 4);
@ -706,9 +516,7 @@ function formatDate(dateStr) {
} }
const date = new Date(dateStr); const date = new Date(dateStr);
console.log('[Debug] Date Logic:', { input: dateStr, parsed: date, valid: !isNaN(date.getTime()) }); if (isNaN(date.getTime())) return 'Recently';
if (isNaN(date.getTime())) return 'Invalid Date';
const now = new Date(); const now = new Date();
const diffMs = now - date; const diffMs = now - date;
@ -924,9 +732,7 @@ function saveToLibrary(type, item) {
if (!lib.some(i => i.id === item.id)) { if (!lib.some(i => i.id === item.id)) {
lib.unshift(item); // Add to top lib.unshift(item); // Add to top
localStorage.setItem(`kv_${type}`, JSON.stringify(lib)); localStorage.setItem(`kv_${type}`, JSON.stringify(lib));
if (type !== 'history') { showToast(`Saved to ${type}`, 'success');
showToast(`Saved to ${type}`, 'success');
}
} }
} }
@ -1019,8 +825,8 @@ async function loadChannelVideos(channelId) {
<div class="yt-video-meta"> <div class="yt-video-meta">
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3> <h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
<div class="yt-video-info"> <div class="yt-video-info">
<span>${formatViews(video.view_count)} views</span> <span>${formatViews(video.views)} views</span>
<span> ${formatDate(video.upload_date)}</span> <span> ${video.uploaded}</span>
</div> </div>
</div> </div>
</div> </div>

0
static/js/navigation-manager.js Normal file → Executable file
View file

0
static/manifest.json Normal file → Executable file
View file

0
static/sw.js Normal file → Executable file
View file

0
templates/channel.html Normal file → Executable file
View file

0
templates/downloads.html Normal file → Executable file
View file

2
templates/index.html Normal file → Executable file
View file

@ -12,6 +12,8 @@
<div class="yt-filter-bar"> <div class="yt-filter-bar">
<div class="yt-categories" id="categoryList"> <div class="yt-categories" id="categoryList">
<!-- Pinned Categories --> <!-- Pinned Categories -->
<button class="yt-chip" onclick="switchCategory('history', this)"><i class="fas fa-history"></i>
Watched</button>
<button class="yt-chip" onclick="switchCategory('suggested', this)"><i class="fas fa-magic"></i> <button class="yt-chip" onclick="switchCategory('suggested', this)"><i class="fas fa-magic"></i>
Suggested</button> Suggested</button>
<!-- Standard Categories --> <!-- Standard Categories -->

200
templates/layout.html Normal file → Executable file
View file

@ -137,7 +137,7 @@
document.documentElement.setAttribute('data-theme', savedTheme); document.documentElement.setAttribute('data-theme', savedTheme);
})(); })();
</script> </script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/modules/chat.css') }}">
</head> </head>
<body> <body>
@ -263,7 +263,10 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<!-- Floating AI Chat Bubble -->
<button id="aiChatBubble" class="ai-chat-bubble" onclick="toggleAIChat()" aria-label="AI Assistant">
<i class="fas fa-robot"></i>
</button>
<!-- Floating Back Button (Mobile) --> <!-- Floating Back Button (Mobile) -->
<button id="floatingBackBtn" class="yt-floating-back" onclick="history.back()" aria-label="Go Back"> <button id="floatingBackBtn" class="yt-floating-back" onclick="history.back()" aria-label="Go Back">
@ -516,7 +519,200 @@
</script> </script>
<!-- Queue Drawer Styles Moved to static/css/modules/components.css --> <!-- Queue Drawer Styles Moved to static/css/modules/components.css -->
<!-- AI Chat Panel -->
<div id="aiChatPanel" class="ai-chat-panel">
<div class="ai-chat-header">
<div>
<h4><i class="fas fa-robot"></i> AI Assistant</h4>
<div id="aiModelStatus" class="ai-model-status">Click to load AI model</div>
</div>
<button class="ai-chat-close" onclick="toggleAIChat()">
<i class="fas fa-times"></i>
</button>
</div>
<div id="aiDownloadArea" class="ai-download-progress" style="display:none;">
<div>Downloading AI Model...</div>
<div class="ai-download-bar">
<div id="aiDownloadFill" class="ai-download-fill" style="width: 0%;"></div>
</div>
<div id="aiDownloadText" class="ai-download-text">Preparing...</div>
</div>
<div id="aiChatMessages" class="ai-chat-messages">
<div class="ai-message system">Ask me anything about this video!</div>
</div>
<div class="ai-chat-input">
<input type="text" id="aiInput" placeholder="Ask about the video..."
onkeypress="if(event.key==='Enter') sendAIMessage()">
<button class="ai-chat-send" onclick="sendAIMessage()" id="aiSendBtn">
<i class="fas fa-paper-plane"></i>
</button>
</div>
</div>
<!-- Chat styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/modules/chat.css') }}">
<!-- WebAI Script -->
<script src="{{ url_for('static', filename='js/webai.js') }}"></script>
<script>
// AI Chat Toggle and Message Handler
var aiChatVisible = false;
var aiInitialized = false;
window.toggleAIChat = function () {
const panel = document.getElementById('aiChatPanel');
const bubble = document.getElementById('aiChatBubble');
if (!panel) return;
aiChatVisible = !aiChatVisible;
if (aiChatVisible) {
panel.classList.add('visible');
if (bubble) {
bubble.classList.add('active');
bubble.innerHTML = '<i class="fas fa-times"></i>';
}
// Initialize AI on first open
if (!aiInitialized && window.transcriptAI && !window.transcriptAI.isModelLoading()) {
initializeAI();
}
} else {
panel.classList.remove('visible');
if (bubble) {
bubble.classList.remove('active');
bubble.innerHTML = '<i class="fas fa-robot"></i>';
}
}
}
async function initializeAI() {
if (aiInitialized || window.transcriptAI.isModelLoading()) return;
const status = document.getElementById('aiModelStatus');
const downloadArea = document.getElementById('aiDownloadArea');
const downloadFill = document.getElementById('aiDownloadFill');
const downloadText = document.getElementById('aiDownloadText');
status.textContent = 'Loading model...';
status.classList.add('loading');
downloadArea.style.display = 'block';
// Set transcript for AI (if available globally)
if (window.transcriptFullText) {
window.transcriptAI.setTranscript(window.transcriptFullText);
}
// Set progress callback
window.transcriptAI.setCallbacks({
onProgress: (report) => {
const progress = report.progress || 0;
downloadFill.style.width = `${progress * 100}%`;
downloadText.textContent = report.text || 'Downloading...';
},
onReady: () => {
status.textContent = 'AI Ready ✓';
status.classList.remove('loading');
status.classList.add('ready');
downloadArea.style.display = 'none';
aiInitialized = true;
// Add welcome message
addAIMessage('assistant', `I'm ready! Ask me anything about this video. Model: ${window.transcriptAI.getModelInfo().name}`);
}
});
try {
await window.transcriptAI.init();
} catch (err) {
status.textContent = 'Failed to load AI';
status.classList.remove('loading');
downloadArea.style.display = 'none';
addAIMessage('system', `Error: ${err.message}. WebGPU may not be supported in your browser.`);
}
}
window.sendAIMessage = async function () {
const input = document.getElementById('aiInput');
const sendBtn = document.getElementById('aiSendBtn');
const question = input.value.trim();
if (!question) return;
// Ensure transcript is set if available
if (window.transcriptFullText) {
window.transcriptAI.setTranscript(window.transcriptFullText);
}
// Initialize if needed
if (!window.transcriptAI.isModelReady()) {
addAIMessage('system', 'Initializing AI...');
await initializeAI();
if (!window.transcriptAI.isModelReady()) {
return;
}
}
// Add user message
addAIMessage('user', question);
input.value = '';
sendBtn.disabled = true;
// Add typing indicator
const typingId = addTypingIndicator();
try {
// Stream response
let response = '';
const responseEl = addAIMessage('assistant', '');
removeTypingIndicator(typingId);
for await (const chunk of window.transcriptAI.askStreaming(question)) {
response += chunk;
responseEl.textContent = response;
scrollChatToBottom();
}
} catch (err) {
removeTypingIndicator(typingId);
addAIMessage('system', `Error: ${err.message}`);
}
sendBtn.disabled = false;
}
function addAIMessage(role, text) {
const messages = document.getElementById('aiChatMessages');
const msg = document.createElement('div');
msg.className = `ai-message ${role}`;
msg.textContent = text;
messages.appendChild(msg);
scrollChatToBottom();
return msg;
}
function addTypingIndicator() {
const messages = document.getElementById('aiChatMessages');
const typing = document.createElement('div');
typing.className = 'ai-message assistant ai-typing';
typing.id = 'ai-typing-' + Date.now();
typing.innerHTML = '<span></span><span></span><span></span>';
messages.appendChild(typing);
scrollChatToBottom();
return typing.id;
}
function removeTypingIndicator(id) {
const el = document.getElementById(id);
if (el) el.remove();
}
function scrollChatToBottom() {
const messages = document.getElementById('aiChatMessages');
messages.scrollTop = messages.scrollHeight;
}
</script>
<!-- Global Download Modal (available on all pages) --> <!-- Global Download Modal (available on all pages) -->
<div id="downloadModal" class="download-modal" onclick="if(event.target===this) closeDownloadModal()"> <div id="downloadModal" class="download-modal" onclick="if(event.target===this) closeDownloadModal()">
<div class="download-modal-content"> <div class="download-modal-content">

0
templates/login.html Normal file → Executable file
View file

0
templates/my_videos.html Normal file → Executable file
View file

0
templates/register.html Normal file → Executable file
View file

337
templates/settings.html Normal file → Executable file
View file

@ -4,212 +4,138 @@
<div class="yt-settings-container"> <div class="yt-settings-container">
<h2 class="yt-settings-title">Settings</h2> <h2 class="yt-settings-title">Settings</h2>
<!-- Appearance & Playback in one card --> <div class="yt-settings-card">
<div class="yt-settings-card compact"> <h3>Appearance</h3>
<p class="yt-settings-desc">Customize how KV-Tube looks on your device.</p>
<div class="yt-setting-row"> <div class="yt-setting-row">
<span class="yt-setting-label">Theme</span> <span>Theme Mode</span>
<div class="yt-toggle-group"> <div class="yt-theme-selector">
<button type="button" class="yt-toggle-btn" id="themeBtnLight" <button type="button" class="yt-theme-btn" id="themeBtnLight" onclick="setTheme('light')">Light</button>
onclick="setTheme('light')">Light</button> <button type="button" class="yt-theme-btn" id="themeBtnDark" onclick="setTheme('dark')">Dark</button>
<button type="button" class="yt-toggle-btn" id="themeBtnDark" onclick="setTheme('dark')">Dark</button>
</div> </div>
</div> </div>
</div>
<div class="yt-settings-card">
<h3>Playback</h3>
<p class="yt-settings-desc">Choose your preferred video player.</p>
<div class="yt-setting-row"> <div class="yt-setting-row">
<span class="yt-setting-label">Player</span> <span>Default Player</span>
<div class="yt-toggle-group"> <div class="yt-theme-selector">
<button type="button" class="yt-toggle-btn" id="playerBtnArt" <button type="button" class="yt-theme-btn" id="playerBtnArt"
onclick="setPlayerPref('artplayer')">Artplayer</button> onclick="setPlayerPref('artplayer')">Artplayer</button>
<button type="button" class="yt-toggle-btn" id="playerBtnNative" <button type="button" class="yt-theme-btn" id="playerBtnNative"
onclick="setPlayerPref('native')">Native</button> onclick="setPlayerPref('native')">Native</button>
</div> </div>
</div> </div>
</div> </div>
<!-- System Updates -->
<div class="yt-settings-card">
<h3>System Updates</h3>
<!-- yt-dlp Stable -->
<div class="yt-update-row">
<div class="yt-update-info">
<strong>yt-dlp</strong>
<span class="yt-update-version" id="ytdlpVersion">Stable</span>
</div>
<button id="updateYtdlpStable" onclick="updatePackage('ytdlp', 'stable')" class="yt-update-btn small">
<i class="fas fa-sync-alt"></i> Update
</button>
</div>
<!-- yt-dlp Nightly -->
<div class="yt-update-row">
<div class="yt-update-info">
<strong>yt-dlp Nightly</strong>
<span class="yt-update-version">Experimental</span>
</div>
<button id="updateYtdlpNightly" onclick="updatePackage('ytdlp', 'nightly')"
class="yt-update-btn small nightly">
<i class="fas fa-flask"></i> Install
</button>
</div>
<!-- ytfetcher -->
<div class="yt-update-row">
<div class="yt-update-info">
<strong>ytfetcher</strong>
<span class="yt-update-version" id="ytfetcherVersion">CC & Transcripts</span>
</div>
<button id="updateYtfetcher" onclick="updatePackage('ytfetcher', 'latest')" class="yt-update-btn small">
<i class="fas fa-sync-alt"></i> Update
</button>
</div>
<div id="updateStatus" class="yt-update-status"></div>
</div>
{% if session.get('user_id') %} {% if session.get('user_id') %}
<div class="yt-settings-card compact"> <div class="yt-settings-card">
<div class="yt-setting-row"> <h3>Profile</h3>
<span class="yt-setting-label">Display Name</span> <p class="yt-settings-desc">Update your public profile information.</p>
<form id="profileForm" onsubmit="updateProfile(event)" <form id="profileForm" onsubmit="updateProfile(event)">
style="display: flex; gap: 8px; flex: 1; max-width: 300px;"> <div class="yt-form-group">
<input type="text" class="yt-form-input" id="displayName" value="{{ session.username }}" required <label>Display Name</label>
style="flex: 1;"> <input type="text" class="yt-form-input" id="displayName" value="{{ session.username }}" required>
<button type="submit" class="yt-update-btn small">Save</button> </div>
</form> <button type="submit" class="yt-update-btn">Save Changes</button>
</div> </form>
</div> </div>
{% endif %} {% endif %}
<div class="yt-settings-card compact"> <div class="yt-settings-card">
<div class="yt-setting-row" style="justify-content: center;"> <h3>System Updates</h3>
<span class="yt-about-text">KV-Tube v1.0 • YouTube-like streaming</span> <p class="yt-settings-desc">Manage core components of KV-Tube.</p>
<div class="yt-update-section">
<div class="yt-update-info">
<div>
<h4>yt-dlp</h4>
<span class="yt-update-subtitle">Core video extraction engine</span>
</div>
<button id="updateBtn" onclick="updateYtDlp()" class="yt-update-btn">
<i class="fas fa-sync-alt"></i> Check for Updates
</button>
</div>
<div id="updateStatus" class="yt-update-status"></div>
</div> </div>
</div> </div>
<div class="yt-settings-card">
<h3>About</h3>
<p class="yt-settings-desc">KV-Tube v1.0</p>
<p class="yt-settings-desc">A YouTube-like streaming application.</p>
</div>
</div> </div>
<style> <style>
.yt-settings-container { .yt-settings-container {
max-width: 500px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
padding: 16px; padding: 24px;
} }
.yt-settings-title { .yt-settings-title {
font-size: 20px; font-size: 24px;
font-weight: 500; font-weight: 500;
margin-bottom: 16px; margin-bottom: 24px;
text-align: center; text-align: center;
} }
.yt-settings-card { .yt-settings-card {
background: var(--yt-bg-secondary); background: var(--yt-bg-secondary);
border-radius: 12px; border-radius: 12px;
padding: 16px; padding: 24px;
margin-bottom: 12px; margin-bottom: 16px;
}
.yt-settings-card.compact {
padding: 12px 16px;
} }
.yt-settings-card h3 { .yt-settings-card h3 {
font-size: 14px; font-size: 18px;
margin-bottom: 12px; margin-bottom: 8px;
}
.yt-settings-desc {
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.yt-setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.yt-setting-row:not(:last-child) {
border-bottom: 1px solid var(--yt-bg-hover);
}
.yt-setting-label {
font-size: 14px; font-size: 14px;
font-weight: 500; margin-bottom: 16px;
} }
.yt-toggle-group { .yt-update-section {
display: flex;
background: var(--yt-bg-elevated); background: var(--yt-bg-elevated);
padding: 3px; border-radius: 8px;
border-radius: 20px; padding: 16px;
gap: 2px;
}
.yt-toggle-btn {
padding: 6px 14px;
border-radius: 16px;
font-size: 13px;
font-weight: 500;
color: var(--yt-text-secondary);
background: transparent;
transition: all 0.2s;
}
.yt-toggle-btn:hover {
color: var(--yt-text-primary);
}
.yt-toggle-btn.active {
background: var(--yt-accent-red);
color: white;
}
.yt-update-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--yt-bg-hover);
}
.yt-update-row:last-of-type {
border-bottom: none;
} }
.yt-update-info { .yt-update-info {
display: flex; display: flex;
flex-direction: column; justify-content: space-between;
gap: 2px; align-items: center;
flex-wrap: wrap;
gap: 12px;
} }
.yt-update-info strong { .yt-update-info h4 {
font-size: 14px; font-size: 16px;
margin-bottom: 4px;
} }
.yt-update-version { .yt-update-subtitle {
font-size: 11px; font-size: 12px;
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
} }
.yt-update-btn { .yt-update-btn {
background: var(--yt-accent-red); background: var(--yt-accent-red);
color: white; color: white;
padding: 8px 16px; padding: 12px 24px;
border-radius: 20px; border-radius: 24px;
font-size: 12px; font-size: 14px;
font-weight: 500; font-weight: 500;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 8px;
transition: all 0.2s; transition: opacity 0.2s, transform 0.2s;
}
.yt-update-btn.small {
padding: 6px 12px;
font-size: 12px;
}
.yt-update-btn.nightly {
background: #9c27b0;
} }
.yt-update-btn:hover { .yt-update-btn:hover {
@ -218,102 +144,84 @@
} }
.yt-update-btn:disabled { .yt-update-btn:disabled {
background: var(--yt-bg-hover) !important; background: var(--yt-bg-hover);
cursor: not-allowed; cursor: not-allowed;
transform: none;
} }
.yt-update-status { .yt-update-status {
margin-top: 10px; margin-top: 12px;
font-size: 12px;
text-align: center;
min-height: 20px;
}
.yt-form-input {
background: var(--yt-bg-elevated);
border: 1px solid var(--yt-bg-hover);
border-radius: 8px;
padding: 8px 12px;
color: var(--yt-text-primary);
font-size: 13px; font-size: 13px;
text-align: center;
} }
.yt-about-text { /* Theme Selector */
font-size: 12px; .yt-theme-selector {
display: flex;
gap: 12px;
background: var(--yt-bg-elevated);
padding: 4px;
border-radius: 24px;
}
.yt-theme-btn {
flex: 1;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 500;
color: var(--yt-text-secondary); color: var(--yt-text-secondary);
background: transparent;
transition: all 0.2s;
border: 2px solid transparent;
}
.yt-theme-btn:hover {
color: var(--yt-text-primary);
background: rgba(255, 255, 255, 0.05);
}
.yt-theme-btn.active {
background: var(--yt-bg-primary);
color: var(--yt-text-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
} }
</style> </style>
<script> <script>
async function fetchVersions() { async function updateYtDlp() {
const pkgs = ['ytdlp', 'ytfetcher']; const btn = document.getElementById('updateBtn');
for (const pkg of pkgs) {
try {
const res = await fetch(`/api/package/version?package=${pkg}`);
const data = await res.json();
if (data.success) {
const el = document.getElementById(pkg === 'ytdlp' ? 'ytdlpVersion' : 'ytfetcherVersion');
if (el) {
el.innerText = `Installed: ${data.version}`;
// Highlight if nightly
if (pkg === 'ytdlp' && (data.version.includes('2026') || data.version.includes('.dev'))) {
el.style.color = '#9c27b0';
el.innerText += ' (Nightly)';
}
}
}
} catch (e) {
console.error(e);
}
}
}
async function updatePackage(pkg, version) {
const btnId = pkg === 'ytdlp' ?
(version === 'nightly' ? 'updateYtdlpNightly' : 'updateYtdlpStable') :
'updateYtfetcher';
const btn = document.getElementById(btnId);
const status = document.getElementById('updateStatus'); const status = document.getElementById('updateStatus');
const originalHTML = btn.innerHTML;
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Updating...'; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Updating...';
status.style.color = 'var(--yt-text-secondary)'; status.style.color = 'var(--yt-text-secondary)';
status.innerText = `Updating ${pkg}${version === 'nightly' ? ' (nightly)' : ''}...`; status.innerText = 'Running pip install -U yt-dlp... This may take a moment.';
try { try {
const response = await fetch('/api/update_package', { const response = await fetch('/api/update_ytdlp', { method: 'POST' });
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ package: pkg, version: version })
});
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
status.style.color = '#4caf50'; status.style.color = '#4caf50';
status.innerText = '✓ ' + data.message; status.innerText = '✓ ' + data.message;
btn.innerHTML = '<i class="fas fa-check"></i> Updated'; btn.innerHTML = '<i class="fas fa-check"></i> Updated';
// Refresh versions
setTimeout(fetchVersions, 1000);
setTimeout(() => {
btn.innerHTML = originalHTML;
btn.disabled = false;
}, 3000);
} else { } else {
status.style.color = '#f44336'; status.style.color = '#f44336';
status.innerText = '✗ ' + data.message; status.innerText = '✗ ' + data.message;
btn.innerHTML = originalHTML; btn.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Failed';
btn.disabled = false; btn.disabled = false;
} }
} catch (e) { } catch (e) {
status.style.color = '#f44336'; status.style.color = '#f44336';
status.innerText = '✗ Error: ' + e.message; status.innerText = '✗ Network error: ' + e.message;
btn.innerHTML = originalHTML;
btn.disabled = false; btn.disabled = false;
btn.innerHTML = 'Retry';
} }
} }
</script>
</script>
<script>
// --- Player Preference --- // --- Player Preference ---
window.setPlayerPref = function (type) { window.setPlayerPref = function (type) {
localStorage.setItem('kv_player_pref', type); localStorage.setItem('kv_player_pref', type);
@ -323,8 +231,12 @@
window.updatePlayerButtons = function (type) { window.updatePlayerButtons = function (type) {
const artBtn = document.getElementById('playerBtnArt'); const artBtn = document.getElementById('playerBtnArt');
const natBtn = document.getElementById('playerBtnNative'); const natBtn = document.getElementById('playerBtnNative');
// Reset classes
if (artBtn) artBtn.classList.remove('active'); if (artBtn) artBtn.classList.remove('active');
if (natBtn) natBtn.classList.remove('active'); if (natBtn) natBtn.classList.remove('active');
// Set active
if (type === 'native') { if (type === 'native') {
if (natBtn) natBtn.classList.add('active'); if (natBtn) natBtn.classList.add('active');
} else { } else {
@ -344,12 +256,9 @@
if (darkBtn) darkBtn.classList.add('active'); if (darkBtn) darkBtn.classList.add('active');
} }
// Player init - default to artplayer // Player init
const playerPref = localStorage.getItem('kv_player_pref') || 'artplayer'; const playerPref = localStorage.getItem('kv_player_pref') || 'artplayer';
updatePlayerButtons(playerPref); updatePlayerButtons(playerPref);
// Fetch versions
fetchVersions();
}); });
</script> </script>
{% endblock %} {% endblock %}

916
templates/watch.html Normal file → Executable file

File diff suppressed because it is too large Load diff

View file

@ -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()

View file

@ -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()

View file

@ -1,47 +0,0 @@
FROM golang:1.25.3-alpine3.22 AS builder
RUN apk add --no-cache curl
WORKDIR /app
COPY src src
COPY templates templates
COPY go.mod go.mod
COPY go.sum go.sum
RUN go mod download
RUN go build -x -o media-roller ./src
# yt-dlp needs python
FROM python:3.13.7-alpine3.22
# This is where the downloaded files will be saved in the container.
ENV MR_DOWNLOAD_DIR="/download"
RUN apk add --update --no-cache \
# https://github.com/yt-dlp/yt-dlp/issues/14404 \
deno \
curl
# https://hub.docker.com/r/mwader/static-ffmpeg/tags
# https://github.com/wader/static-ffmpeg
COPY --from=mwader/static-ffmpeg:8.0 /ffmpeg /usr/local/bin/
COPY --from=mwader/static-ffmpeg:8.0 /ffprobe /usr/local/bin/
COPY --from=builder /app/media-roller /app/media-roller
COPY templates /app/templates
COPY static /app/static
WORKDIR /app
# Get new releases here https://github.com/yt-dlp/yt-dlp/releases
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/download/2025.09.26/yt-dlp -o /usr/local/bin/yt-dlp && \
echo "9215a371883aea75f0f2102c679333d813d9a5c3bceca212879a4a741a5b4657 /usr/local/bin/yt-dlp" | sha256sum -c - && \
chmod a+rx /usr/local/bin/yt-dlp
RUN yt-dlp --update --update-to nightly
# Sanity check
RUN yt-dlp --version && \
ffmpeg -version
ENTRYPOINT ["/app/media-roller"]

View file

@ -1,59 +0,0 @@
# Media Roller
A mobile friendly tool for downloading videos from social media.
The backend is a Golang server that will take a URL (YouTube, Reddit, Twitter, etc),
download the video file, and return a URL to directly download the video. The video will be transcoded to produce a single mp4 file.
This is built on [yt-dlp](https://github.com/yt-dlp/yt-dlp). yt-dlp will auto update every 12 hours to make sure it's running the latest nightly build.
Note: This was written to run on a home network and should not be exposed to public traffic. There's no auth.
![Screenshot 1](https://i.imgur.com/lxwf1qU.png)
![Screenshot 2](https://i.imgur.com/TWAtM7k.png)
# Running
Make sure you have [yt-dlp](https://github.com/yt-dlp/yt-dlp) and [FFmpeg](https://github.com/FFmpeg/FFmpeg) installed then pull the repo and run:
```bash
./run.sh
```
Or for docker locally:
```bash
./docker-build.sh
./docker-run.sh
```
With Docker, published to both dockerhub and github.
* ghcr: `docker pull ghcr.io/rroller/media-roller:master`
* dockerhub: `docker pull ronnieroller/media-roller`
See:
* https://github.com/rroller/media-roller/pkgs/container/media-roller
* https://hub.docker.com/repository/docker/ronnieroller/media-roller
The files are saved to the /download directory which you can mount as needed.
## Docker Environemnt Variables
* `MR_DOWNLOAD_DIR` where videos are saved. Defaults to `/download`
* `MR_PROXY` will pass the value to yt-dlp witht he `--proxy` argument. Defaults to empty
# API
To download a video directly, use the API endpoint:
```
/api/download?url=SOME_URL
```
Create a bookmarklet, allowing one click downloads (From a PC):
```
javascript:(location.href="http://127.0.0.1:3000/fetch?url="+encodeURIComponent(location.href));
```
# Integrating with mobile
After you have your server up, install this shortcut. Update the endpoint to your server address by editing the shortcut before running it.
https://www.icloud.com/shortcuts/d3b05b78eb434496ab28dd91e1c79615
# Unraid
media-roller is available in Unraid and can be found on the "Apps" tab by searching its name.

View file

@ -1,2 +0,0 @@
#!/usr/bin/env bash
go build -x -o media-roller ./src

View file

@ -1,2 +0,0 @@
#!/usr/bin/env bash
docker build -f Dockerfile -t media-roller .

View file

@ -1,2 +0,0 @@
#!/usr/bin/env bash
docker run -p 3000:3000 -v $(pwd)/download:/download media-roller

View file

@ -1,17 +0,0 @@
module media-roller
go 1.25.3
require (
github.com/dustin/go-humanize v1.0.1
github.com/go-chi/chi/v5 v5.2.3
github.com/matishsiao/goInfo v0.0.0-20241216093258-66a9250504d6
github.com/rs/zerolog v1.34.0
golang.org/x/sync v0.17.0
)
require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.37.0 // indirect
)

View file

@ -1,26 +0,0 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/matishsiao/goInfo v0.0.0-20241216093258-66a9250504d6 h1:BIv50poKtm6s4vUlN6J2qAOARALk4ACAwM9VRmKPyiI=
github.com/matishsiao/goInfo v0.0.0-20241216093258-66a9250504d6/go.mod h1:aEt7p9Rvh67BYApmZwNDPpgircTO2kgdmDUoF/1QmwA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=

View file

@ -1,2 +0,0 @@
#!/usr/bin/env bash
go run ./src

View file

@ -1,15 +0,0 @@
package extractors
import (
"regexp"
)
// https://streamff.com/v/e70b90d8
var streamffRe = regexp.MustCompile(`^(?:https?://)?(?:www)?\.?streamff\.com/v/([A-Za-z0-9]+)/?`)
func GetUrl(url string) string {
if matches := streamffRe.FindStringSubmatch(url); len(matches) == 2 {
return "https://ffedge.streamff.com/uploads/" + matches[1] + ".mp4"
}
return ""
}

View file

@ -1,26 +0,0 @@
package extractors
import "testing"
func TestGetUrl(t *testing.T) {
tests := []struct {
name string
url string
want string
}{
{name: "t1", url: "https://streamff.com/v/e70b90d8", want: "https://ffedge.streamff.com/uploads/e70b90d8.mp4"},
{name: "t2", url: "https://streamff.com/v/e70b90d8/", want: "https://ffedge.streamff.com/uploads/e70b90d8.mp4"},
{name: "t3", url: "https://streamff.com/v/e70b90d8/test", want: "https://ffedge.streamff.com/uploads/e70b90d8.mp4"},
{name: "t4", url: "https://streamff.com/v/e70b90d8?test", want: "https://ffedge.streamff.com/uploads/e70b90d8.mp4"},
{name: "t5", url: "https://www.streamff.com/v/e70b90d8", want: "https://ffedge.streamff.com/uploads/e70b90d8.mp4"},
{name: "t6", url: "streamff.com/v/e70b90d8?test", want: "https://ffedge.streamff.com/uploads/e70b90d8.mp4"},
{name: "t7", url: "www.streamff.com/v/e70b90d8?test", want: "https://ffedge.streamff.com/uploads/e70b90d8.mp4"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetUrl(tt.url); got != tt.want {
t.Errorf("GetUrl() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -1,135 +0,0 @@
package main
import (
"context"
"errors"
"media-roller/src/media"
"net/http"
"os"
"os/signal"
"path"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
)
func main() {
// setup routes
router := chi.NewRouter()
router.Route("/", func(r chi.Router) {
router.Get("/", media.Index)
router.Get("/fetch", media.FetchMedia)
router.Get("/api/download", media.FetchMediaApi)
router.Get("/download", media.ServeMedia)
router.Get("/about", media.AboutIndex)
})
fileServer(router, "/static", "static/")
// Print out all routes
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
log.Info().Msgf("%s %s", method, route)
return nil
}
// Panic if there is an error
if err := chi.Walk(router, walkFunc); err != nil {
log.Panic().Msgf("%s\n", err.Error())
}
media.GetInstalledVersion()
go startYtDlpUpdater()
// The HTTP Server
server := &http.Server{Addr: ":3000", Handler: router}
// Server run context
serverCtx, serverStopCtx := context.WithCancel(context.Background())
// Listen for syscall signals for process to interrupt/quit
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
go func() {
<-sig
// Shutdown signal with grace period of 30 seconds
shutdownCtx, cancel := context.WithTimeout(serverCtx, 30*time.Second)
defer cancel()
go func() {
<-shutdownCtx.Done()
if errors.Is(shutdownCtx.Err(), context.DeadlineExceeded) {
log.Fatal().Msg("graceful shutdown timed out.. forcing exit.")
}
}()
// Trigger graceful shutdown
err := server.Shutdown(shutdownCtx)
if err != nil {
log.Fatal().Err(err)
}
serverStopCtx()
}()
// Run the server
err := server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal().Err(err)
}
// Wait for server context to be stopped
<-serverCtx.Done()
log.Info().Msgf("Shutdown complete")
}
// startYtDlpUpdater will update the yt-dlp to the latest nightly version ever few hours
func startYtDlpUpdater() {
log.Info().Msgf("yt-dlp version: %s", media.GetInstalledVersion())
ticker := time.NewTicker(12 * time.Hour)
// Do one update now
_ = media.UpdateYtDlp()
quit := make(chan struct{})
go func() {
for {
select {
case <-ticker.C:
_ = media.UpdateYtDlp()
log.Info().Msgf("yt-dlp version: %s", media.GetInstalledVersion())
case <-quit:
ticker.Stop()
return
}
}
}()
}
func fileServer(r chi.Router, public string, static string) {
if strings.ContainsAny(public, "{}*") {
panic("FileServer does not permit URL parameters.")
}
root, _ := filepath.Abs(static)
if _, err := os.Stat(root); os.IsNotExist(err) {
panic("Static Documents Directory Not Found")
}
fs := http.StripPrefix(public, http.FileServer(http.Dir(root)))
if public != "/" && public[len(public)-1] != '/' {
r.Get(public, http.RedirectHandler(public+"/", http.StatusMovedPermanently).ServeHTTP)
public += "/"
}
r.Get(public+"*", func(w http.ResponseWriter, r *http.Request) {
file := strings.Replace(r.RequestURI, public, "/", 1)
if _, err := os.Stat(root + file); os.IsNotExist(err) {
http.ServeFile(w, r, path.Join(root, "index.html"))
return
}
fs.ServeHTTP(w, r)
})
}

View file

@ -1,45 +0,0 @@
package media
import (
"html/template"
"media-roller/src/utils"
"net/http"
"regexp"
"strings"
"github.com/matishsiao/goInfo"
"github.com/rs/zerolog/log"
)
var aboutIndexTmpl = template.Must(template.ParseFiles("templates/media/about.html"))
var newlineRegex = regexp.MustCompile("\r?\n")
func AboutIndex(w http.ResponseWriter, _ *http.Request) {
pythonVersion := utils.RunCommand("python3", "--version")
if pythonVersion == "" {
pythonVersion = utils.RunCommand("python", "--version")
}
gi, _ := goInfo.GetInfo()
data := map[string]interface{}{
"ytDlpVersion": CachedYtDlpVersion,
"goVersion": strings.TrimPrefix(utils.RunCommand("go", "version"), "go version "),
"pythonVersion": strings.TrimPrefix(pythonVersion, "Python "),
"ffmpegVersion": newlineRegex.Split(utils.RunCommand("ffmpeg", "-version"), -1),
"ffprobeVersion": newlineRegex.Split(utils.RunCommand("ffprobe", "-version"), -1),
"deno": strings.TrimPrefix(utils.RunCommand("deno", "--version"), "go version "),
"os": gi.OS,
"kernel": gi.Kernel,
"core": gi.Core,
"platform": gi.Platform,
"hostname": gi.Hostname,
"cpus": gi.CPUs,
}
if err := aboutIndexTmpl.Execute(w, data); err != nil {
log.Error().Msgf("Error rendering template: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}

View file

@ -1,340 +0,0 @@
package media
import (
"crypto/md5"
"errors"
"fmt"
"github.com/dustin/go-humanize"
"golang.org/x/sync/errgroup"
"html/template"
"media-roller/src/utils"
"net/http"
"path/filepath"
"regexp"
"sort"
"strings"
)
/**
This file will download the media from a URL and save it to disk.
*/
import (
"bytes"
"github.com/rs/zerolog/log"
"io"
"os"
"os/exec"
)
type Media struct {
Id string
Name string
SizeInBytes int64
HumanSize string
}
var fetchIndexTmpl = template.Must(template.ParseFiles("templates/media/index.html"))
// Where the media files are saved. Always has a trailing slash
var downloadDir = getDownloadDir()
var idCharSet = regexp.MustCompile(`^[a-zA-Z0-9]+$`).MatchString
func Index(w http.ResponseWriter, _ *http.Request) {
data := map[string]string{
"ytDlpVersion": CachedYtDlpVersion,
}
if err := fetchIndexTmpl.Execute(w, data); err != nil {
log.Error().Msgf("Error rendering template: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}
func FetchMedia(w http.ResponseWriter, r *http.Request) {
url, args := getUrl(r)
media, ytdlpErrorMessage, err := getMediaResults(url, args)
data := map[string]interface{}{
"url": url,
"media": media,
"error": ytdlpErrorMessage,
"ytDlpVersion": CachedYtDlpVersion,
}
if err != nil {
_ = fetchIndexTmpl.Execute(w, data)
return
}
if err = fetchIndexTmpl.Execute(w, data); err != nil {
log.Error().Msgf("Error rendering template: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}
func FetchMediaApi(w http.ResponseWriter, r *http.Request) {
url, args := getUrl(r)
medias, _, err := getMediaResults(url, args)
if err != nil {
log.Error().Msgf("error getting media results: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(medias) == 0 {
log.Error().Msgf("not media found")
http.Error(w, "Media not found", http.StatusBadRequest)
return
}
// just take the first one
streamFileToClientById(w, r, medias[0].Id)
}
func getUrl(r *http.Request) (string, map[string]string) {
u := strings.TrimSpace(r.URL.Query().Get("url"))
// Support yt-dlp arguments passed in via the url. We'll assume anything starting with a dash - is an argument
args := make(map[string]string)
for k, v := range r.URL.Query() {
if strings.HasPrefix(k, "-") {
if len(v) > 0 {
args[k] = v[0]
} else {
args[k] = ""
}
}
}
return u, args
}
func getMediaResults(inputUrl string, args map[string]string) ([]Media, string, error) {
if inputUrl == "" {
return nil, "", errors.New("missing URL")
}
url := utils.NormalizeUrl(inputUrl)
log.Info().Msgf("Got input '%s' and extracted '%s' with args %v", inputUrl, url, args)
// NOTE: This system is for a simple use case, meant to run at home. This is not a great design for a robust system.
// We are hashing the URL here and writing files to disk to a consistent directory based on the ID. You can imagine
// concurrent users would break this for the same URL. That's fine given this is for a simple home system.
// Future work can make this more sophisticated.
id := GetMD5Hash(url, args)
// Look to see if we already have the media on disk
medias, err := getAllFilesForId(id)
if err != nil {
return nil, "", err
}
if len(medias) == 0 {
// We don't, so go fetch it
errMessage := ""
id, errMessage, err = downloadMedia(url, args)
if err != nil {
return nil, errMessage, err
}
medias, err = getAllFilesForId(id)
if err != nil {
return nil, "", err
}
}
return medias, "", nil
}
// returns the ID of the file, and error message, and an error
func downloadMedia(url string, requestArgs map[string]string) (string, string, error) {
// The id will be used as the name of the parent directory of the output files
id := GetMD5Hash(url, requestArgs)
name := getMediaDirectory(id) + "%(id)s.%(ext)s"
log.Info().Msgf("Downloading %s to %s", url, name)
defaultArgs := map[string]string{
"--format": "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
"--merge-output-format": "mp4",
"--trim-filenames": "100",
"--recode-video": "mp4",
"--format-sort": "codec:h264",
"--restrict-filenames": "",
"--write-info-json": "",
"--verbose": "",
"--output": name,
}
args := make([]string, 0)
// First add all default arguments that were not supplied as request level arguments
for arg, value := range defaultArgs {
if _, has := requestArgs[arg]; !has {
args = append(args, arg)
if value != "" {
args = append(args, value)
}
}
}
// Now add all request level arguments
for arg, value := range requestArgs {
args = append(args, arg)
if value != "" {
args = append(args, value)
}
}
// And finally add any environment level arguments not supplied as request level args
for arg, value := range getEnvVars() {
if _, has := requestArgs[arg]; !has {
args = append(args, arg)
if value != "" {
args = append(args, value)
}
}
}
args = append(args, url)
cmd := exec.Command("yt-dlp", args...)
var stdoutBuf, stderrBuf bytes.Buffer
stdoutIn, _ := cmd.StdoutPipe()
stderrIn, _ := cmd.StderrPipe()
var errStdout, errStderr error
stdout := io.MultiWriter(os.Stdout, &stdoutBuf)
stderr := io.MultiWriter(os.Stderr, &stderrBuf)
err := cmd.Start()
if err != nil {
log.Error().Msgf("Error starting command: %v", err)
return "", err.Error(), err
}
eg := errgroup.Group{}
eg.Go(func() error {
_, errStdout = io.Copy(stdout, stdoutIn)
return nil
})
_, errStderr = io.Copy(stderr, stderrIn)
_ = eg.Wait()
log.Info().Msgf("Done with %s", id)
err = cmd.Wait()
if err != nil {
log.Error().Err(err).Msgf("cmd.Run() failed with %s", err)
return "", strings.TrimSpace(stderrBuf.String()), err
} else if errStdout != nil {
log.Error().Msgf("failed to capture stdout: %v", errStdout)
} else if errStderr != nil {
log.Error().Msgf("failed to capture stderr: %v", errStderr)
}
return id, "", nil
}
// Returns the relative directory containing the media file, with a trailing slash.
// Id is expected to be pre validated
func getMediaDirectory(id string) string {
return downloadDir + id + "/"
}
// id is expected to be validated prior to calling this func
func getAllFilesForId(id string) ([]Media, error) {
root := getMediaDirectory(id)
file, err := os.Open(root)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
files, _ := file.Readdirnames(0) // 0 to read all files and folders
if len(files) == 0 {
return nil, errors.New("ID not found: " + id)
}
var medias []Media
// We expect two files to be produced for each video, a json manifest and an mp4.
for _, f := range files {
if !strings.HasSuffix(f, ".json") {
fi, err2 := os.Stat(root + f)
var size int64 = 0
if err2 == nil {
size = fi.Size()
}
media := Media{
Id: id,
Name: filepath.Base(f),
SizeInBytes: size,
HumanSize: humanize.Bytes(uint64(size)),
}
medias = append(medias, media)
}
}
return medias, nil
}
// id is expected to be validated prior to calling this func
// TODO: This needs to handle multiple files in the directory
func getFileFromId(id string) (string, error) {
root := getMediaDirectory(id)
file, err := os.Open(root)
if err != nil {
return "", err
}
files, _ := file.Readdirnames(0) // 0 to read all files and folders
if len(files) == 0 {
return "", errors.New("ID not found")
}
// We expect two files to be produced, a json manifest and an mp4. We want to return the mp4
// Sometimes the video file might not have an mp4 extension, so filter out the json file
for _, f := range files {
if !strings.HasSuffix(f, ".json") {
// TODO: This is just returning the first file found. We need to handle multiple
return root + f, nil
}
}
return "", errors.New("unable to find file")
}
func GetMD5Hash(url string, args map[string]string) string {
id := url
if len(args) > 0 {
tmp := make([]string, 0)
for k, v := range args {
tmp = append(tmp, k, v)
}
sort.Strings(tmp)
id += ":" + strings.Join(tmp, ",")
}
return fmt.Sprintf("%x", md5.Sum([]byte(id)))
}
func isValidId(id string) bool {
return idCharSet(id)
}
func getDownloadDir() string {
dir := os.Getenv("MR_DOWNLOAD_DIR")
if dir != "" {
if !strings.HasSuffix(dir, "/") {
return dir + "/"
}
return dir
}
return "downloads/"
}
func getEnvVars() map[string]string {
vars := make(map[string]string)
if ev := strings.TrimSpace(os.Getenv("MR_PROXY")); ev != "" {
vars["--proxy"] = ev
}
return vars
}

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