Compare commits

..

No commits in common. "aa1a419c35927b81f89cf0a4fb7edb435d27cfe3" and "f429116ed099738264c3e6377db5b5429c103412" have entirely different histories.

2618 changed files with 11958 additions and 626827 deletions

13
.dockerignore Executable file
View file

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

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

1
.gemini/tmp/ytfetcher Submodule

@ -0,0 +1 @@
Subproject commit 246c4c349d97205eb2b51d7d3999ea846f5b2bdc

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

0
Dockerfile Normal file → Executable file
View file

0
README.md Normal file → Executable file
View file

0
USER_GUIDE.md Normal file → Executable file
View file

Binary file not shown.

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

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

148
app/routes/api.py Normal file → Executable file
View file

@ -15,11 +15,11 @@ import time
import random import random
import concurrent.futures import concurrent.futures
import yt_dlp import yt_dlp
# from ytfetcher import YTFetcher
from app.services.settings import SettingsService from app.services.settings import SettingsService
from app.services.summarizer import TextRankSummarizer from app.services.summarizer import TextRankSummarizer
from app.services.gemini_summarizer import summarize_with_gemini, extract_key_points_with_gemini from app.services.gemini_summarizer import summarize_with_gemini, extract_key_points_with_gemini
from app.services.youtube import YouTubeService from app.services.youtube import YouTubeService
from app.services.transcript_service import TranscriptService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1405,25 +1405,25 @@ def summarize_video():
return jsonify({"error": "No video ID"}), 400 return jsonify({"error": "No video ID"}), 400
try: try:
# 1. Get Transcript Text # 1. Get Transcript Text using TranscriptService (with ytfetcher fallback)
text = get_transcript_text(video_id) text = TranscriptService.get_transcript(video_id)
if not text: if not text:
return jsonify({ return jsonify({
"success": False, "success": False,
"error": "No transcript available to summarize." "error": "No transcript available to summarize."
}) })
# 2. Use TextRank Summarizer (Gemini removed per user request) # 2. Use TextRank Summarizer - generate longer, more meaningful summaries
summarizer = TextRankSummarizer() summarizer = TextRankSummarizer()
summary_text = summarizer.summarize(text, num_sentences=3) summary_text = summarizer.summarize(text, num_sentences=5) # Increased from 3 to 5
# Limit to 300 characters for concise display # Allow longer summaries for more meaningful content (600 chars instead of 300)
if len(summary_text) > 300: if len(summary_text) > 600:
summary_text = summary_text[:297] + "..." summary_text = summary_text[:597] + "..."
# Extract key points from summary (heuristic) # Key points will be extracted by WebLLM on frontend (better quality)
sentences = [s.strip() for s in summary_text.split('.') if len(s.strip()) > 15] # Backend just returns empty list - WebLLM generates conceptual key points
key_points = sentences[:3] key_points = []
# Store original versions # Store original versions
original_summary = summary_text original_summary = summary_text
@ -1472,78 +1472,90 @@ def translate_text(text, target_lang='vi'):
def get_transcript_text(video_id): def get_transcript_text(video_id):
""" """
Fetch transcript using strictly YTFetcher as requested. Fetch transcript using yt-dlp (downloading subtitles to file).
Ensure 'ytfetcher' is up to date before usage. Reliable method that handles auto-generated captions and cookies.
""" """
from ytfetcher import YTFetcher import yt_dlp
from ytfetcher.config import HTTPConfig import glob
import random import random
import json
import os import os
import http.cookiejar
try: try:
# 1. Prepare Cookies if available video_id = video_id.strip()
# This was key to the previous success! logger.info(f"Fetching transcript for {video_id} using yt-dlp")
cookie_header = ""
cookies_path = os.environ.get('COOKIES_FILE', 'cookies.txt')
if os.path.exists(cookies_path): # Use a temporary filename pattern
try: temp_prefix = f"transcript_{video_id}_{random.randint(1000, 9999)}"
cj = http.cookiejar.MozillaCookieJar(cookies_path)
cj.load()
cookies_list = []
for cookie in cj:
cookies_list.append(f"{cookie.name}={cookie.value}")
cookie_header = "; ".join(cookies_list)
logger.info(f"Loaded {len(cookies_list)} cookies for YTFetcher")
except Exception as e:
logger.warning(f"Failed to process cookies: {e}")
# 2. Configuration to look like a real browser ydl_opts = {
user_agents = [ 'skip_download': True,
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", 'quiet': True,
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", 'no_warnings': True,
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0" 'cookiefile': os.environ.get('COOKIES_FILE', 'cookies.txt') if os.path.exists(os.environ.get('COOKIES_FILE', 'cookies.txt')) else None,
] 'writesubtitles': True,
'writeautomaticsub': True,
headers = { 'subtitleslangs': ['en', 'vi', 'en-US'],
"User-Agent": random.choice(user_agents), 'outtmpl': f"/tmp/{temp_prefix}", # Save to /tmp
"Accept-Language": "en-US,en;q=0.9", 'subtitlesformat': 'json3/vtt/best', # Prefer json3 for parsing, then vtt
} }
# Inject cookie header if we have it with yt_dlp.YoutubeDL(ydl_opts) as ydl:
if cookie_header: # This will download the subtitle file to /tmp/
headers["Cookie"] = cookie_header ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
config = HTTPConfig(headers=headers) # Find the downloaded file
# yt-dlp appends language code, e.g. .en.json3
# We look for any file with our prefix
downloaded_files = glob.glob(f"/tmp/{temp_prefix}*")
# Initialize Fetcher if not downloaded_files:
fetcher = YTFetcher.from_video_ids( logger.warning("yt-dlp finished but no subtitle file found.")
video_ids=[video_id], return None
http_config=config,
languages=['en', 'en-US', 'vi']
)
# Fetch # Pick the best file (prefer json3, then vtt)
logger.info(f"Fetching transcript with YTFetcher for {video_id}") selected_file = None
results = fetcher.fetch_transcripts() for ext in ['.json3', '.vtt', '.ttml', '.srv3']:
for f in downloaded_files:
if f.endswith(ext):
selected_file = f
break
if selected_file: break
if results: if not selected_file:
data = results[0] selected_file = downloaded_files[0]
# Check for transcript data
if data.transcripts: # Read content
logger.info("YTFetcher: Transcript found.") with open(selected_file, 'r', encoding='utf-8') as f:
text_lines = [t.text.strip() for t in data.transcripts if t.text.strip()] content = f.read()
return " ".join(text_lines)
else: # Cleanup
logger.warning("YTFetcher: No transcript in result.") for f in downloaded_files:
try:
os.remove(f)
except:
pass
# Parse
if selected_file.endswith('.json3') or content.strip().startswith('{'):
try:
json_data = json.loads(content)
events = json_data.get('events', [])
text_parts = []
for event in events:
segs = event.get('segs', [])
for seg in segs:
txt = seg.get('utf8', '').strip()
if txt and txt != '\n':
text_parts.append(txt)
return " ".join(text_parts)
except Exception as je:
logger.warning(f"JSON3 parse failed: {je}")
return parse_transcript_content(content)
except Exception as e: except Exception as e:
import traceback logger.error(f"Transcript fetch failed: {e}")
tb = traceback.format_exc()
logger.error(f"YTFetcher Execution Failed: {e}\n{tb}")
return None
return None return None

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,211 @@
"""
Transcript Service Module
Fetches video transcripts with fallback strategy: yt-dlp -> ytfetcher
"""
import os
import re
import glob
import json
import random
import logging
from typing import Optional
logger = logging.getLogger(__name__)
class TranscriptService:
"""Service for fetching YouTube video transcripts with fallback support."""
@classmethod
def get_transcript(cls, video_id: str) -> Optional[str]:
"""
Get transcript text for a video.
Strategy:
1. Try yt-dlp (current method, handles auto-generated captions)
2. Fallback to ytfetcher library if yt-dlp fails
Args:
video_id: YouTube video ID
Returns:
Transcript text or None if unavailable
"""
video_id = video_id.strip()
# Try yt-dlp first (primary method)
text = cls._fetch_with_ytdlp(video_id)
if text:
logger.info(f"Transcript fetched via yt-dlp for {video_id}")
return text
# Fallback to ytfetcher
logger.info(f"yt-dlp failed, trying ytfetcher for {video_id}")
text = cls._fetch_with_ytfetcher(video_id)
if text:
logger.info(f"Transcript fetched via ytfetcher for {video_id}")
return text
logger.warning(f"All transcript methods failed for {video_id}")
return None
@classmethod
def _fetch_with_ytdlp(cls, video_id: str) -> Optional[str]:
"""Fetch transcript using yt-dlp (downloading subtitles to file)."""
import yt_dlp
try:
logger.info(f"Fetching transcript for {video_id} using yt-dlp")
# Use a temporary filename pattern
temp_prefix = f"transcript_{video_id}_{random.randint(1000, 9999)}"
ydl_opts = {
'skip_download': True,
'quiet': True,
'no_warnings': True,
'cookiefile': os.environ.get('COOKIES_FILE', 'cookies.txt') if os.path.exists(os.environ.get('COOKIES_FILE', 'cookies.txt')) else None,
'writesubtitles': True,
'writeautomaticsub': True,
'subtitleslangs': ['en', 'vi', 'en-US'],
'outtmpl': f"/tmp/{temp_prefix}",
'subtitlesformat': 'json3/vtt/best',
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
# Find the downloaded file
downloaded_files = glob.glob(f"/tmp/{temp_prefix}*")
if not downloaded_files:
logger.warning("yt-dlp finished but no subtitle file found.")
return None
# Pick the best file (prefer json3, then vtt)
selected_file = None
for ext in ['.json3', '.vtt', '.ttml', '.srv3']:
for f in downloaded_files:
if f.endswith(ext):
selected_file = f
break
if selected_file:
break
if not selected_file:
selected_file = downloaded_files[0]
# Read content
with open(selected_file, 'r', encoding='utf-8') as f:
content = f.read()
# Cleanup
for f in downloaded_files:
try:
os.remove(f)
except:
pass
# Parse based on format
if selected_file.endswith('.json3') or content.strip().startswith('{'):
return cls._parse_json3(content)
else:
return cls._parse_vtt(content)
except Exception as e:
logger.error(f"yt-dlp transcript fetch failed: {e}")
return None
@classmethod
def _fetch_with_ytfetcher(cls, video_id: str) -> Optional[str]:
"""Fetch transcript using ytfetcher library as fallback."""
try:
from ytfetcher import YTFetcher
logger.info(f"Using ytfetcher for {video_id}")
# Create fetcher for single video
fetcher = YTFetcher.from_video_ids(video_ids=[video_id])
# Fetch transcripts
data = fetcher.fetch_transcripts()
if not data:
logger.warning(f"ytfetcher returned no data for {video_id}")
return None
# Extract text from transcript objects
text_parts = []
for item in data:
transcripts = getattr(item, 'transcripts', []) or []
for t in transcripts:
txt = getattr(t, 'text', '') or ''
txt = txt.strip()
if txt and txt != '\n':
text_parts.append(txt)
if not text_parts:
logger.warning(f"ytfetcher returned empty transcripts for {video_id}")
return None
return " ".join(text_parts)
except ImportError:
logger.warning("ytfetcher not installed. Run: pip install ytfetcher")
return None
except Exception as e:
logger.error(f"ytfetcher transcript fetch failed: {e}")
return None
@staticmethod
def _parse_json3(content: str) -> Optional[str]:
"""Parse JSON3 subtitle format."""
try:
json_data = json.loads(content)
events = json_data.get('events', [])
text_parts = []
for event in events:
segs = event.get('segs', [])
for seg in segs:
txt = seg.get('utf8', '').strip()
if txt and txt != '\n':
text_parts.append(txt)
return " ".join(text_parts)
except Exception as e:
logger.warning(f"JSON3 parse failed: {e}")
return None
@staticmethod
def _parse_vtt(content: str) -> Optional[str]:
"""Parse VTT/XML subtitle content."""
try:
lines = content.splitlines()
text_lines = []
seen = set()
for line in lines:
line = line.strip()
if not line:
continue
if "-->" in line:
continue
if line.isdigit():
continue
if line.startswith("WEBVTT"):
continue
if line.startswith("Kind:"):
continue
if line.startswith("Language:"):
continue
# Remove tags like <c> or <00:00:00>
clean = re.sub(r'<[^>]+>', '', line)
if clean and clean not in seen:
seen.add(clean)
text_lines.append(clean)
return " ".join(text_lines)
except Exception as e:
logger.error(f"VTT transcript parse error: {e}")
return None

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

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

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

0
bin/ffmpeg Normal file → Executable file
View file

0
config.py Normal file → Executable file
View file

18
cookies.txt Normal file → Executable file
View file

@ -1,19 +1,19 @@
# Netscape HTTP Cookie File # Netscape HTTP Cookie File
# This file is generated by yt-dlp. Do not edit. # 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 1831894348 __Secure-3PSID g.a0005wie1jXMw44_RGjwtqg21AbatcdNseI3S_qNtsLYC1jS4YSUdfLDtJlA8h-NEmMGebiczgACgYKAX0SARESFQHGX2MiUz2RnkvviMoB7UNylf3SoBoVAUF8yKo0JwXF5B9H9roWaSTRT-QN0076
.youtube.com TRUE / TRUE 1800281710 __Secure-1PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA .youtube.com TRUE / TRUE 1800281710 __Secure-1PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
.youtube.com TRUE / TRUE 1802692356 SAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb .youtube.com TRUE / TRUE 1802692356 SAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1800282680 __Secure-1PSIDCC AKEyXzXvpBScD7r3mqr7aZ0ymWZ7FmsgT0q0C3Ge8hvrjZ9WZ4PU4ZBuBsO0YNYN3A8iX4eV8F8 .youtube.com TRUE / TRUE 1800359997 __Secure-1PSIDCC AKEyXzWh3snkS2XAx8pLOzZCgKTPwXKRai_Pn4KjpsSSc2h7tRpVKMDddMKBYkuIQFhpVlALI84
.youtube.com TRUE / TRUE 1802692356 SSID A4isk9AE9xActvzYy .youtube.com TRUE / TRUE 1802692356 SSID A4isk9AE9xActvzYy
.youtube.com TRUE / TRUE 1802692356 __Secure-1PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb .youtube.com TRUE / TRUE 1831894348 __Secure-1PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1802692356 __Secure-1PSID g.a0005gie1lAkmYZc-EPeGx77pCrXo_Cz5eAi-e9aryb9Qoz967v4-rF3xTavVHrJoyJAqShH6gACgYKAX0SARESFQHGX2MiOdAbUPmCj4MueYyh-2km5RoVAUF8yKp2ehWQC6tX8n-9UNg11RV60076 .youtube.com TRUE / TRUE 1831894348 __Secure-1PSID g.a0005wie1jXMw44_RGjwtqg21AbatcdNseI3S_qNtsLYC1jS4YSUycKC58NH045FOFX6QW8fDwACgYKAacSARESFQHGX2MiA5xeTuJuh8QmBm-DS3l1ghoVAUF8yKr4klCBhb-EJgFQ9T0TGWKk0076
.youtube.com TRUE / TRUE 1802692356 __Secure-3PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb .youtube.com TRUE / TRUE 1831894348 __Secure-3PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
.youtube.com TRUE / TRUE 1800282680 __Secure-3PSIDCC AKEyXzVcvX-jLLprjZQXoqarG3xsAVpjyLYaN2j0a_iUcsnKnpL88P_5IlcfusJn0We0aaKK7g .youtube.com TRUE / TRUE 1800359997 __Secure-3PSIDCC AKEyXzW3W5Q-e4TIryFWpWS6zVuuVPOvwPIU2tzl1JRdYsGu-7f34g_amk2Xd2ttGtSJ6tOSdA
.youtube.com TRUE / TRUE 1800281710 __Secure-3PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA .youtube.com TRUE / TRUE 1800281710 __Secure-3PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
.youtube.com TRUE / TRUE 1792154873 LOGIN_INFO AFmmF2swRQIgVjJk8Mho4_JuKr6SZzrhBdlL1LdxWxcwDMu4cjaRRgcCIQCTtJpmYKJH54Tiei3at3f4YT3US7gSL0lW_TZ04guKjQ:QUQ3MjNmeWlwRDJSNDl2NE9uX2JWWG5tWllHN0RsNUVZVUhsLVp4N2dWbldaeC14SnNybWVERnNoaXFpanFJczhKTjJSRGN6MEs3c1VkLTE1TGJVeFBPT05BY29NMFh0Q1VPdFU3dUdvSUpET3lQbU1ZMUlHUGltajlXNDllNUQxZHdzZko1WXF1UUJWclNxQVJ0TXVEYnF2bXJRY2V6Vl9n .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 / FALSE 0 PREF tz=UTC&f7=150&hl=en
.youtube.com TRUE / TRUE 0 YSC y-oH2BqaUSQ .youtube.com TRUE / TRUE 0 YSC y-oH2BqaUSQ
.youtube.com TRUE / TRUE 1784298680 __Secure-ROLLOUT_TOKEN CPm51pHVjquOTRDw0bnsppWSAxjw0bnsppWSAw%3D%3D .youtube.com TRUE / TRUE 1784333733 __Secure-ROLLOUT_TOKEN CPm51pHVjquOTRDw0bnsppWSAxjzxYe3qZaSAw%3D%3D
.youtube.com TRUE / TRUE 1784298680 VISITOR_INFO1_LIVE ShB1Bvj-rRU .youtube.com TRUE / TRUE 1784375997 VISITOR_INFO1_LIVE ShB1Bvj-rRU
.youtube.com TRUE / TRUE 1784298680 VISITOR_PRIVACY_METADATA CgJWThIEGgAgWA%3D%3D .youtube.com TRUE / TRUE 1784375997 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

0
dev.sh Normal file → Executable file
View file

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

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

0
entrypoint.sh Normal file → Executable file
View file

131
hydration_debug.txt Normal file → Executable file
View file

@ -1011,3 +1011,134 @@ Fetched W6cMfJeIUUU: Date=20240401
Fetched UbCVoeTWTiA: Date=20230106 Fetched UbCVoeTWTiA: Date=20230106
Fetched QbO7Y_NxHAM: Date=20241029 Fetched QbO7Y_NxHAM: Date=20241029
Fetched e0KFtjKzWWk: Date=20251231 Fetched e0KFtjKzWWk: Date=20251231
Fetched 6EkBT8pmFEA: Date=20250103
Fetched vnsLPgcMDec: Date=20251205
Fetched YT4jWl-2Ojs: Date=20250428
Fetched TQH2qk9rmIE: Date=20230308
Fetched ZlBUUBI9XZM: Date=20211204
Fetched FxG1dP6ZHBQ: Date=20251209
Fetched 1GXzDm8PYp8: Date=20251223
Fetched TZfNHV24sR4: Date=20250925
Fetched YT4jWl-2Ojs: Date=20250428
Fetched uQkIZvbbQDA: Date=20260113
Fetched MEoLq8oa2UE: Date=20250720
Fetched GvratpQ6CGE: Date=20250607
Fetched CW384zifGYE: Date=20250303
Fetched ZFn-Q-PZtZU: Date=20251231
Fetched Vv7JIvv1MQU: Date=20251104
Fetched ZlBUUBI9XZM: Date=20211204
Fetched TQH2qk9rmIE: Date=20230308
Fetched FxG1dP6ZHBQ: Date=20251209
Fetched YT4jWl-2Ojs: Date=20250428
Fetched TZfNHV24sR4: Date=20250925
Fetched MEoLq8oa2UE: Date=20250720
Fetched 1GXzDm8PYp8: Date=20251223
Fetched uQkIZvbbQDA: Date=20260113
Fetched GvratpQ6CGE: Date=20250607
Fetched CW384zifGYE: Date=20250303
Fetched ZFn-Q-PZtZU: Date=20251231
Fetched Vv7JIvv1MQU: Date=20251104
Fetched ZsfTZTEqP-E: Date=20250409
Fetched YatN8EQn6MY: Date=20220225
Fetched TQH2qk9rmIE: Date=20230308
Fetched VC1t56agJ7M: Date=20250923
Fetched NnSQTMMM_Cg: Date=20250514
Fetched 1GXzDm8PYp8: Date=20251223
Fetched uQkIZvbbQDA: Date=20260113
Fetched YT4jWl-2Ojs: Date=20250428
Fetched jcAjFukoaFk: Date=20230107
Fetched 1pEHFVTihIo: Date=20260119
Fetched GvratpQ6CGE: Date=20250607
Fetched ZFn-Q-PZtZU: Date=20251231
Fetched sIgchLDoz9A: Date=20250520
Fetched ZlBUUBI9XZM: Date=20211204
Fetched GnxO3gwf8E4: Date=20230806
Fetched TQH2qk9rmIE: Date=20230308
Fetched 8TNHAx64NjQ: Date=20230627
Fetched YT4jWl-2Ojs: Date=20250428
Fetched MEoLq8oa2UE: Date=20250720
Fetched uQkIZvbbQDA: Date=20260113
Fetched 1pEHFVTihIo: Date=20260119
Fetched GvratpQ6CGE: Date=20250607
Fetched CW384zifGYE: Date=20250303
Fetched ZFn-Q-PZtZU: Date=20251231
Fetched YatN8EQn6MY: Date=20220225
Fetched ZlBUUBI9XZM: Date=20211204
Fetched VC1t56agJ7M: Date=20250923
Fetched TQH2qk9rmIE: Date=20230308
Fetched 1GXzDm8PYp8: Date=20251223
Fetched uQkIZvbbQDA: Date=20260113
Fetched MEoLq8oa2UE: Date=20250720
Fetched YT4jWl-2Ojs: Date=20250428
Fetched 1pEHFVTihIo: Date=20260119
Fetched GvratpQ6CGE: Date=20250607
Fetched CW384zifGYE: Date=20250303
Fetched ZFn-Q-PZtZU: Date=20251231
Fetched sIDz0luksFg: Date=20250321
Fetched FxG1dP6ZHBQ: Date=20251209
Fetched YT4jWl-2Ojs: Date=20250428
Fetched uQkIZvbbQDA: Date=20260113
Fetched TZfNHV24sR4: Date=20250925
Fetched 1GXzDm8PYp8: Date=20251223
Fetched ZFn-Q-PZtZU: Date=20251231
Fetched Vv7JIvv1MQU: Date=20251104
Fetched TQH2qk9rmIE: Date=20230308
Fetched GvratpQ6CGE: Date=20250607
Fetched FxG1dP6ZHBQ: Date=20251209
Fetched 1GXzDm8PYp8: Date=20251223
Fetched uQkIZvbbQDA: Date=20260113
Fetched YT4jWl-2Ojs: Date=20250428
Fetched TZfNHV24sR4: Date=20250925
Fetched MEoLq8oa2UE: Date=20250720
Fetched ZlBUUBI9XZM: Date=20211204
Fetched CW384zifGYE: Date=20250303
Fetched ZFn-Q-PZtZU: Date=20251231
Fetched Vv7JIvv1MQU: Date=20251104
Fetched molRFW7YWog: Date=20240201
Fetched VC1t56agJ7M: Date=20250923
Fetched TQH2qk9rmIE: Date=20230308
Fetched GvratpQ6CGE: Date=20250607
Fetched Bu671EegYWY: Date=20260110
Fetched YT4jWl-2Ojs: Date=20250428
Fetched uQkIZvbbQDA: Date=20260113
Fetched TZfNHV24sR4: Date=20250925
Fetched MEoLq8oa2UE: Date=20250720
Fetched ZlBUUBI9XZM: Date=20211204
Fetched CW384zifGYE: Date=20250303
Fetched ZFn-Q-PZtZU: Date=20251231
Fetched HP_mXUuHPbo: Date=20250614
Fetched h313nmgS1mg: Date=20250522
Fetched VC1t56agJ7M: Date=20250923
Fetched B3P2jc8GX_Y: Date=20251222
Fetched FxG1dP6ZHBQ: Date=20251209
Fetched Bu671EegYWY: Date=20260110
Fetched ZJuYLlMq9YE: Date=20250417
Fetched n8VQ9JzY68k: Date=20251202
Fetched j_2jvyqta0s: Date=20260108
Fetched DgZGgeymUNU: Date=20230409
Fetched B3QkragtEfE: Date=20250630
Fetched ldlsHxuBU-8: Date=20241112
Fetched ZlBUUBI9XZM: Date=20211204
Fetched GnxO3gwf8E4: Date=20230806
Fetched TQH2qk9rmIE: Date=20230308
Fetched rvP7or3PPNM: Date=20260105
Fetched 1pEHFVTihIo: Date=20260119
Fetched TZfNHV24sR4: Date=20250925
Fetched uQkIZvbbQDA: Date=20260113
Fetched GkcCHDBYDQM: Date=20251028
Fetched MEoLq8oa2UE: Date=20250720
Fetched YT4jWl-2Ojs: Date=20250428
Fetched GvratpQ6CGE: Date=20250607
Fetched ZFn-Q-PZtZU: Date=20251231
Fetched TQH2qk9rmIE: Date=20230308
Fetched rvP7or3PPNM: Date=20260105
Fetched B3P2jc8GX_Y: Date=20251222
Fetched GvratpQ6CGE: Date=20250607
Fetched FxG1dP6ZHBQ: Date=20251209
Fetched YT4jWl-2Ojs: Date=20250428
Fetched uQkIZvbbQDA: Date=20260113
Fetched MEoLq8oa2UE: Date=20250720
Fetched ZlBUUBI9XZM: Date=20211204
Fetched CW384zifGYE: Date=20250303
Fetched ZFn-Q-PZtZU: Date=20251231
Fetched Vv7JIvv1MQU: Date=20251104

0
kv_server.py Normal file → Executable file
View file

View file

BIN
kvtube.db

Binary file not shown.

2
requirements.txt Normal file → Executable file
View file

@ -4,4 +4,6 @@ yt-dlp>=2024.1.0
werkzeug werkzeug
gunicorn gunicorn
python-dotenv python-dotenv
googletrans==4.0.0-rc1
# ytfetcher - optional, requires Python 3.11-3.13

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

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

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

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

@ -21,39 +21,7 @@ body {
overflow: hidden; overflow: hidden;
} }
/* ========== Mini Player Mode ========== */ /* Mini player removed per user request */
.yt-mini-mode {
position: fixed;
bottom: 20px;
right: 20px;
width: 400px !important;
height: auto !important;
aspect-ratio: 16/9;
z-index: 10000;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
border-radius: 12px;
cursor: grab;
transition: width 0.3s, height 0.3s;
}
.yt-mini-mode:active {
cursor: grabbing;
}
.yt-player-placeholder {
display: none;
width: 100%;
aspect-ratio: 16/9;
background: rgba(0, 0, 0, 0.1);
}
@media (max-width: 768px) {
.yt-mini-mode {
width: 250px !important;
bottom: 80px;
right: 10px;
}
}
/* ========== Skeleton Loading ========== */ /* ========== Skeleton Loading ========== */
@keyframes shimmer { @keyframes shimmer {

View file

@ -0,0 +1,277 @@
/**
* WebLLM Styles - Loading UI and Progress Bar
*/
/* Model loading overlay */
.webllm-loading-overlay {
position: fixed;
bottom: 100px;
right: 20px;
background: linear-gradient(135deg,
rgba(15, 15, 20, 0.95) 0%,
rgba(25, 25, 35, 0.95) 100%);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 20px 24px;
min-width: 320px;
z-index: 9999;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
animation: slideInRight 0.3s ease-out;
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.webllm-loading-overlay.hidden {
display: none;
}
/* Header with icon */
.webllm-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.webllm-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: white;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.webllm-title {
font-size: 14px;
font-weight: 600;
color: #fff;
margin: 0;
}
.webllm-subtitle {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
margin: 2px 0 0 0;
}
/* Progress bar */
.webllm-progress-container {
background: rgba(255, 255, 255, 0.08);
border-radius: 8px;
height: 8px;
overflow: hidden;
margin-bottom: 12px;
}
.webllm-progress-bar {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
background-size: 200% 100%;
border-radius: 8px;
transition: width 0.3s ease;
animation: shimmer 2s infinite linear;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Status text */
.webllm-status {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
}
.webllm-percent {
font-weight: 600;
color: #667eea;
}
/* Ready state */
.webllm-ready-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(16, 185, 129, 0.1) 100%);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 20px;
font-size: 11px;
color: #10b981;
font-weight: 500;
}
.webllm-ready-badge i {
font-size: 10px;
}
/* Summary box WebLLM indicator */
.ai-source-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--yt-text-tertiary, #aaa);
padding: 4px 0;
}
.ai-source-indicator.local {
color: #667eea;
}
.ai-source-indicator.server {
color: #f59e0b;
}
/* Translation button states */
.translate-btn {
padding: 6px 12px;
background: var(--yt-bg-primary, #0f0f0f);
border: 1px solid var(--yt-border, #303030);
border-radius: 20px;
color: var(--yt-text-primary, #fff);
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.translate-btn:hover {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%);
border-color: rgba(102, 126, 234, 0.4);
}
.translate-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: transparent;
color: white;
}
.translate-btn.loading {
opacity: 0.7;
pointer-events: none;
}
.translate-btn .spinner {
width: 12px;
height: 12px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Model selector in settings */
.webllm-model-selector {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.webllm-model-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.webllm-model-option:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(102, 126, 234, 0.3);
}
.webllm-model-option.selected {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%);
border-color: rgba(102, 126, 234, 0.5);
}
.webllm-model-option input[type="radio"] {
accent-color: #667eea;
}
.webllm-model-info {
flex: 1;
}
.webllm-model-name {
font-size: 13px;
font-weight: 500;
color: #fff;
}
.webllm-model-size {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
}
/* Toast notification for WebLLM status */
.webllm-toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, rgba(102, 126, 234, 0.95) 0%, rgba(118, 75, 162, 0.95) 100%);
color: white;
padding: 12px 24px;
border-radius: 12px;
font-size: 13px;
font-weight: 500;
z-index: 10000;
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.4);
animation: toastIn 0.3s ease-out;
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* Responsive adjustments */
@media (max-width: 480px) {
.webllm-loading-overlay {
left: 10px;
right: 10px;
bottom: 80px;
min-width: unset;
}
}

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

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

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

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

@ -169,6 +169,12 @@ function renderNoContent(message = 'Try searching for something else', title = '
// Render homepage with personalized sections // Render homepage with personalized sections
function renderHomepageSections(sections, container, localHistory = []) { function renderHomepageSections(sections, container, localHistory = []) {
// Check if container exists
if (!container) {
console.warn('renderHomepageSections: container is null');
return;
}
// Create a map for quick history lookup // Create a map for quick history lookup
const historyMap = {}; const historyMap = {};
localHistory.forEach(v => { localHistory.forEach(v => {

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

340
static/js/webllm-service.js Normal file
View file

@ -0,0 +1,340 @@
/**
* WebLLM Service - Browser-based AI for Translation & Summarization
* Uses MLC's WebLLM for on-device AI inference via WebGPU
*/
class WebLLMService {
constructor() {
this.engine = null;
this.isLoading = false;
this.loadProgress = 0;
this.currentModel = null;
// Model configurations - Qwen2 chosen for Vietnamese support
this.models = {
'qwen2-0.5b': 'Qwen2-0.5B-Instruct-q4f16_1-MLC',
'phi-3.5-mini': 'Phi-3.5-mini-instruct-q4f16_1-MLC',
'smollm2': 'SmolLM2-360M-Instruct-q4f16_1-MLC'
};
// Default to lightweight Qwen2 for Vietnamese support
this.selectedModel = 'qwen2-0.5b';
// Callbacks
this.onProgressCallback = null;
this.onReadyCallback = null;
this.onErrorCallback = null;
}
/**
* Check if WebGPU is supported
*/
static isSupported() {
return 'gpu' in navigator;
}
/**
* Initialize WebLLM with selected model
* @param {string} modelKey - Model key from this.models
* @param {function} onProgress - Progress callback (percent, status)
* @returns {Promise<boolean>}
*/
async init(modelKey = null, onProgress = null) {
if (!WebLLMService.isSupported()) {
console.warn('WebGPU not supported in this browser');
if (this.onErrorCallback) {
this.onErrorCallback('WebGPU not supported. Using server-side AI.');
}
return false;
}
if (this.engine && this.currentModel === (modelKey || this.selectedModel)) {
console.log('WebLLM already initialized with this model');
return true;
}
this.isLoading = true;
this.onProgressCallback = onProgress;
try {
// Dynamic import of WebLLM
const webllm = await import('https://esm.run/@mlc-ai/web-llm');
const modelId = this.models[modelKey || this.selectedModel];
console.log('Loading WebLLM model:', modelId);
// Progress callback wrapper
const initProgressCallback = (progress) => {
this.loadProgress = Math.round(progress.progress * 100);
const status = progress.text || 'Loading model...';
console.log(`WebLLM: ${this.loadProgress}% - ${status}`);
if (this.onProgressCallback) {
this.onProgressCallback(this.loadProgress, status);
}
};
// Create engine
this.engine = await webllm.CreateMLCEngine(modelId, {
initProgressCallback: initProgressCallback
});
this.currentModel = modelKey || this.selectedModel;
this.isLoading = false;
this.loadProgress = 100;
console.log('WebLLM ready!');
if (this.onReadyCallback) {
this.onReadyCallback();
}
return true;
} catch (error) {
console.error('WebLLM initialization failed:', error);
this.isLoading = false;
if (this.onErrorCallback) {
this.onErrorCallback(error.message);
}
return false;
}
}
/**
* Check if engine is ready
*/
isReady() {
return this.engine !== null && !this.isLoading;
}
/**
* Summarize text using local AI
* @param {string} text - Text to summarize
* @param {string} language - Output language ('en' or 'vi')
* @returns {Promise<string>}
*/
async summarize(text, language = 'en') {
if (!this.isReady()) {
throw new Error('WebLLM not ready. Call init() first.');
}
// Truncate text to avoid token limits
const maxChars = 4000;
if (text.length > maxChars) {
text = text.substring(0, maxChars) + '...';
}
const langInstruction = language === 'vi'
? 'Respond in Vietnamese (Tiếng Việt).'
: 'Respond in English.';
const messages = [
{
role: 'system',
content: `You are a helpful AI assistant that creates detailed, insightful video summaries. ${langInstruction}`
},
{
role: 'user',
content: `Provide a comprehensive summary of this video transcript in 4-6 sentences. Include the main topic, key points discussed, and any important insights or conclusions. Make the summary informative and meaningful:\n\n${text}`
}
];
try {
const response = await this.engine.chat.completions.create({
messages: messages,
temperature: 0.7,
max_tokens: 350
});
return response.choices[0].message.content.trim();
} catch (error) {
console.error('Summarization error:', error);
throw error;
}
}
/**
* Translate text between English and Vietnamese
* @param {string} text - Text to translate
* @param {string} sourceLang - Source language ('en' or 'vi')
* @param {string} targetLang - Target language ('en' or 'vi')
* @returns {Promise<string>}
*/
async translate(text, sourceLang = 'en', targetLang = 'vi') {
if (!this.isReady()) {
throw new Error('WebLLM not ready. Call init() first.');
}
const langNames = {
'en': 'English',
'vi': 'Vietnamese (Tiếng Việt)'
};
const messages = [
{
role: 'system',
content: `You are a professional translator. Translate the following text from ${langNames[sourceLang]} to ${langNames[targetLang]}. Provide only the translation, no explanations.`
},
{
role: 'user',
content: text
}
];
try {
const response = await this.engine.chat.completions.create({
messages: messages,
temperature: 0.3,
max_tokens: 500
});
return response.choices[0].message.content.trim();
} catch (error) {
console.error('Translation error:', error);
throw error;
}
}
/**
* Extract key points from text
* @param {string} text - Text to analyze
* @param {string} language - Output language
* @returns {Promise<string[]>}
*/
async extractKeyPoints(text, language = 'en') {
if (!this.isReady()) {
throw new Error('WebLLM not ready. Call init() first.');
}
const maxChars = 3000;
if (text.length > maxChars) {
text = text.substring(0, maxChars) + '...';
}
const langInstruction = language === 'vi'
? 'Respond in Vietnamese.'
: 'Respond in English.';
const messages = [
{
role: 'system',
content: `You extract the main IDEAS and CONCEPTS from video content. ${langInstruction} Focus on:
- Main topics discussed
- Key insights or takeaways
- Important facts or claims
- Conclusions or recommendations
Do NOT copy sentences from the transcript. Instead, synthesize the core ideas in your own words. List 3-5 key points, one per line, without bullet points or numbers.`
},
{
role: 'user',
content: `What are the main ideas and takeaways from this video transcript?\n\n${text}`
}
];
try {
const response = await this.engine.chat.completions.create({
messages: messages,
temperature: 0.6,
max_tokens: 400
});
const content = response.choices[0].message.content.trim();
const points = content.split('\n')
.map(line => line.replace(/^[\d\.\-\*\•]+\s*/, '').trim())
.filter(line => line.length > 10);
return points.slice(0, 5);
} catch (error) {
console.error('Key points extraction error:', error);
throw error;
}
}
/**
* Stream chat completion for real-time output
* @param {string} prompt - User prompt
* @param {function} onChunk - Callback for each chunk
* @returns {Promise<string>}
*/
async streamChat(prompt, onChunk) {
if (!this.isReady()) {
throw new Error('WebLLM not ready.');
}
const messages = [
{ role: 'user', content: prompt }
];
try {
const chunks = await this.engine.chat.completions.create({
messages: messages,
temperature: 0.7,
stream: true
});
let fullResponse = '';
for await (const chunk of chunks) {
const delta = chunk.choices[0]?.delta?.content || '';
fullResponse += delta;
if (onChunk) {
onChunk(delta, fullResponse);
}
}
return fullResponse;
} catch (error) {
console.error('Stream chat error:', error);
throw error;
}
}
/**
* Get available models
*/
getModels() {
return Object.keys(this.models).map(key => ({
id: key,
name: this.models[key],
selected: key === this.selectedModel
}));
}
/**
* Set selected model (requires re-init)
*/
setModel(modelKey) {
if (this.models[modelKey]) {
this.selectedModel = modelKey;
// Reset engine to force reload with new model
this.engine = null;
this.currentModel = null;
}
}
/**
* Cleanup and release resources
*/
async destroy() {
if (this.engine) {
// WebLLM doesn't have explicit destroy, but we can nullify
this.engine = null;
this.currentModel = null;
this.loadProgress = 0;
}
}
}
// Global singleton instance
window.webLLMService = new WebLLMService();
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = WebLLMService;
}

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

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

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

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

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

552
templates/watch.html Normal file → Executable file
View file

@ -27,8 +27,7 @@
<div id="loading" class="yt-loader"></div> <div id="loading" class="yt-loader"></div>
</div> </div>
<!-- Placeholder for Mini Mode -->
<div id="playerPlaceholder" class="yt-player-placeholder"></div>
<!-- Info Skeleton --> <!-- Info Skeleton -->
<div id="infoSkeleton" style="margin-top:20px;"> <div id="infoSkeleton" style="margin-top:20px;">
@ -136,6 +135,11 @@
</div> </div>
</div> </div>
<div style="display:flex; gap:8px;"> <div style="display:flex; gap:8px;">
<button id="copySummaryBtn" onclick="copySummaryContent()"
style="padding:6px 12px; background:var(--yt-bg-primary); border:1px solid var(--yt-border); border-radius:20px; color:var(--yt-text-primary); cursor:pointer; font-size:12px; display:flex; align-items:center; gap:4px;"
title="Copy summary to clipboard">
<i class="fas fa-copy"></i> <span>Copy</span>
</button>
<button id="translateBtn" onclick="toggleSummaryTranslation()" <button id="translateBtn" onclick="toggleSummaryTranslation()"
style="padding:6px 12px; background:var(--yt-bg-primary); border:1px solid var(--yt-border); border-radius:20px; color:var(--yt-text-primary); cursor:pointer; font-size:12px; display:flex; align-items:center; gap:4px;"> style="padding:6px 12px; background:var(--yt-bg-primary); border:1px solid var(--yt-border); border-radius:20px; color:var(--yt-text-primary); cursor:pointer; font-size:12px; display:flex; align-items:center; gap:4px;">
🇻🇳 <span>Tiếng Việt</span> 🇻🇳 <span>Tiếng Việt</span>
@ -284,6 +288,32 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/modules/downloads.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/modules/downloads.css') }}">
<!-- WebLLM Styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/modules/webllm.css') }}">
<!-- WebLLM Loading Overlay -->
<div id="webllmLoadingOverlay" class="webllm-loading-overlay hidden">
<div class="webllm-header">
<div class="webllm-icon">
<i class="fas fa-robot"></i>
</div>
<div>
<p class="webllm-title">Loading AI Model</p>
<p class="webllm-subtitle">Qwen2-0.5B · Vietnamese Support</p>
</div>
</div>
<div class="webllm-progress-container">
<div class="webllm-progress-bar" id="webllmProgressBar" style="width: 0%"></div>
</div>
<div class="webllm-status">
<span id="webllmStatusText">Initializing...</span>
<span class="webllm-percent" id="webllmPercent">0%</span>
</div>
</div>
<!-- WebLLM Service -->
<script src="{{ url_for('static', filename='js/webllm-service.js') }}"></script>
<script> <script>
@ -475,9 +505,6 @@
}); });
} }
// Setup Mini Player logic
setupMiniPlayer();
return art; return art;
} }
@ -554,9 +581,6 @@
saveToLibrary('history', currentVideoData); saveToLibrary('history', currentVideoData);
}, 2000); }, 2000);
// Setup Mini Player (still works with IFrame container)
setupMiniPlayer();
return window.player; return window.player;
} }
@ -587,135 +611,7 @@
return initYouTubePlayer(videoId); return initYouTubePlayer(videoId);
} }
// --- Movable Mini Player Logic --- // Mini player removed per user request
function setupMiniPlayer() {
const playerContainer = document.querySelector('.yt-player-container');
const placeholder = document.getElementById('playerPlaceholder');
const playerSection = document.querySelector('.yt-player-section');
// Scroll Observer
const observer = new IntersectionObserver((entries) => {
// If player section top is out of view (scrolling down)
const entry = entries[0];
if (!entry.isIntersecting && entry.boundingClientRect.top < 0) {
enableMiniMode();
} else {
disableMiniMode();
}
}, { threshold: 0, rootMargin: '-100px 0px 0px 0px' }); // Trigger when header passes
observer.observe(playerSection);
function enableMiniMode() {
if (playerContainer.classList.contains('yt-mini-mode')) return;
playerContainer.classList.add('yt-mini-mode');
placeholder.style.display = 'block';
// Trigger reflow?
playerContainer.style.position = 'fixed';
playerContainer.style.bottom = '20px';
playerContainer.style.right = '20px';
playerContainer.style.width = '320px';
playerContainer.style.height = '180px';
playerContainer.style.zIndex = '9999';
playerContainer.style.boxShadow = '0 4px 12px rgba(0,0,0,0.5)';
playerContainer.style.borderRadius = '12px';
}
function disableMiniMode() {
if (!playerContainer.classList.contains('yt-mini-mode')) return;
playerContainer.classList.remove('yt-mini-mode');
placeholder.style.display = 'none';
// Reset styles to ensure normal layout
playerContainer.style.top = '';
playerContainer.style.left = '';
playerContainer.style.bottom = '';
playerContainer.style.right = '';
playerContainer.style.transform = '';
}
// Drag Logic
let isDragging = false;
let startX, startY, initialLeft, initialTop;
playerContainer.addEventListener('mousedown', dragStart);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', dragEnd);
// Touch support
playerContainer.addEventListener('touchstart', dragStart, { passive: false });
document.addEventListener('touchmove', drag, { passive: false });
document.addEventListener('touchend', dragEnd);
function dragStart(e) {
if (!playerContainer.classList.contains('yt-mini-mode')) return;
// Don't drag if clicking controls (could be tricky, but basic grab works)
// Filter out clicks on seekbar or buttons if needed, but container grab is ok usually.
if (e.target.closest('.art-controls') || e.target.closest('.art-video')) {
// Allow interaction with controls, but maybe handle drag on edges/title if existed.
// For now, let's allow grab anywhere but maybe standard controls prevent propagation?
// Actually Artplayer captures clicks. We might need a specific handle or overlay.
// But user asked for "movable". Let's try grabbing the container directly.
}
// For better UX, maybe only drag when holding header or empty space?
// Given artplayer fills it, we drag the whole thing.
isDragging = true;
const clientX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
const clientY = e.type === 'touchstart' ? e.touches[0].clientY : e.clientY;
// Get current position
const rect = playerContainer.getBoundingClientRect();
startX = clientX;
startY = clientY;
initialLeft = rect.left;
initialTop = rect.top;
// Unset bottom/right to switch to top/left positioning for dragging
playerContainer.style.bottom = 'auto';
playerContainer.style.right = 'auto';
playerContainer.style.left = initialLeft + 'px';
playerContainer.style.top = initialTop + 'px';
e.preventDefault(); // Prevent text selection
}
function drag(e) {
if (!isDragging) return;
const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
const clientY = e.type === 'touchmove' ? e.touches[0].clientY : e.clientY;
const dx = clientX - startX;
const dy = clientY - startY;
let newLeft = initialLeft + dx;
let newTop = initialTop + dy;
// Boundaries
const winWidth = window.innerWidth;
const winHeight = window.innerHeight;
const rect = playerContainer.getBoundingClientRect();
if (newLeft < 0) newLeft = 0;
if (newTop < 0) newTop = 0;
if (newLeft + rect.width > winWidth) newLeft = winWidth - rect.width;
if (newTop + rect.height > winHeight) newTop = winHeight - rect.height;
playerContainer.style.left = newLeft + 'px';
playerContainer.style.top = newTop + 'px';
e.preventDefault();
}
function dragEnd() {
isDragging = false;
}
} // End setupMiniPlayer
// --- Loop Logic --- // --- Loop Logic ---
function toggleLoop(btn) { function toggleLoop(btn) {
@ -1806,11 +1702,63 @@
</div> </div>
<script> <script>
// Track current language state // Track current language state and WebLLM status
let currentSummaryLang = 'en'; let currentSummaryLang = 'en';
let webllmInitialized = false;
let currentTranscriptText = null;
let currentSummaryData = { en: null, vi: null };
// WebLLM Progress Update Handler - Silent loading (just log to console)
function updateWebLLMProgress(percent, status) {
// Silent loading - only log to console for debugging
console.log(`WebLLM: ${percent}% - ${status}`);
}
// Initialize WebLLM in background (silent, no UI)
async function initWebLLMIfNeeded() {
if (webllmInitialized || !window.webLLMService) return false;
if (!WebLLMService.isSupported()) {
console.log('WebGPU not supported, using server-side AI');
return false;
}
try {
// Silent init - no UI callback
webllmInitialized = await window.webLLMService.init(null, updateWebLLMProgress);
console.log('WebLLM initialized successfully');
return webllmInitialized;
} catch (e) {
console.error('WebLLM init failed:', e);
return false;
}
}
// Fetch transcript text (for WebLLM local processing)
async function fetchTranscript(videoId) {
if (currentTranscriptText) return currentTranscriptText;
try {
const response = await fetch(`/api/transcript?v=${videoId}`);
if (response.ok) {
const text = await response.text();
// Parse VTT to plain text
currentTranscriptText = text
.split('\n')
.filter(line => !line.includes('-->') && !line.startsWith('WEBVTT') && line.trim())
.join(' ')
.replace(/<[^>]+>/g, '')
.substring(0, 8000);
return currentTranscriptText;
}
} catch (e) {
console.error('Transcript fetch error:', e);
}
return null;
}
// Auto-load summary on page load (inline display) // Auto-load summary on page load (inline display)
function loadSummaryInline(lang = 'en') { async function loadSummaryInline(lang = 'en') {
if (!currentVideoData.id) return; if (!currentVideoData.id) return;
const summaryBox = document.getElementById('summaryBox'); const summaryBox = document.getElementById('summaryBox');
@ -1827,82 +1775,279 @@
content.innerHTML = '<div class="loader" style="margin:20px auto;"></div><p style="text-align:center; color:var(--yt-text-secondary);">✨ Generating AI summary...</p>'; content.innerHTML = '<div class="loader" style="margin:20px auto;"></div><p style="text-align:center; color:var(--yt-text-secondary);">✨ Generating AI summary...</p>';
if (keyPointsSection) keyPointsSection.style.display = 'none'; if (keyPointsSection) keyPointsSection.style.display = 'none';
// Build URL with optional translation // Check if WebLLM is ready and try to use it
let url = `/api/summarize?v=${currentVideoData.id}`; let useLocalAI = false;
if (lang === 'vi') url += '&lang=vi'; if (window.webLLMService && window.webLLMService.isReady()) {
useLocalAI = true;
}
fetch(url) if (useLocalAI) {
.then(res => res.json()) // Use Local WebLLM AI
.then(data => { try {
if (data.success && data.summary) { const transcript = await fetchTranscript(currentVideoData.id);
// Update language indicator if (!transcript) {
currentSummaryLang = data.lang || 'en'; throw new Error('No transcript available');
const hasTranslation = data.translated_summary; }
if (summaryLang) { // Generate summary with WebLLM
summaryLang.innerHTML = hasTranslation ? '🇬🇧 + 🇻🇳' : '🇬🇧 English'; const summary = await window.webLLMService.summarize(transcript, lang);
} currentSummaryData[lang] = summary;
if (translateBtn) { currentSummaryLang = lang;
translateBtn.innerHTML = hasTranslation
? '🇬🇧 <span>English Only</span>'
: '🇻🇳 <span>+ Tiếng Việt</span>';
}
// Display summary - show both if translation exists // Update UI
if (hasTranslation) { displaySummary(summary, null, lang, true);
content.innerHTML = `
<div style="margin-bottom:12px;">
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
<span style="font-size:12px; background:#e3f2fd; color:#1976d2; padding:2px 8px; border-radius:4px;">🇬🇧 English</span>
</div>
<p style="margin:0; padding:10px; background:var(--yt-bg-primary); border-radius:8px; border-left:3px solid #1976d2;">${data.summary}</p>
</div>
<div>
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
<span style="font-size:12px; background:#fff3e0; color:#e65100; padding:2px 8px; border-radius:4px;">🇻🇳 Tiếng Việt</span>
</div>
<p style="margin:0; padding:10px; background:var(--yt-bg-primary); border-radius:8px; border-left:3px solid #e65100;">${data.translated_summary}</p>
</div>
`;
} else {
content.innerHTML = `<p style="margin:0;">${data.summary}</p>`;
}
// Display key points - HIDE when translation is active (to avoid redundancy) // Extract key points
if (data.key_points && data.key_points.length > 0 && keyPointsSection && keyPointsList) { if (keyPointsSection && keyPointsList) {
if (hasTranslation) { try {
// Hide key points when showing dual-language to avoid redundancy const keyPoints = await window.webLLMService.extractKeyPoints(transcript, lang);
keyPointsSection.style.display = 'none'; if (keyPoints && keyPoints.length > 0) {
} else { keyPointsList.innerHTML = keyPoints.map(point =>
keyPointsList.innerHTML = data.key_points.map(point =>
`<li style="margin-bottom:6px;">${point}</li>` `<li style="margin-bottom:6px;">${point}</li>`
).join(''); ).join('');
keyPointsSection.style.display = 'block'; keyPointsSection.style.display = 'block';
} }
} catch (e) {
console.error('Key points extraction failed:', e);
} }
} else {
// Show error or hide
content.innerHTML = `<p style="color:var(--yt-text-tertiary); text-align:center;">
<i class="fas fa-info-circle"></i> ${data.error || 'No summary available for this video.'}
</p>`;
if (keyPointsSection) keyPointsSection.style.display = 'none';
} }
})
.catch(err => { return;
console.error('Summary error:', err); } catch (e) {
summaryBox.style.display = 'none'; // Hide on error console.error('WebLLM summarization failed, falling back to server:', e);
}); // Fall through to server API
}
}
// Fallback to Server API
let url = `/api/summarize?v=${currentVideoData.id}`;
if (lang === 'vi') url += '&lang=vi';
try {
const response = await fetch(url);
const data = await response.json();
if (data.success && data.summary) {
currentSummaryLang = data.lang || 'en';
displaySummary(data.summary, data.translated_summary, currentSummaryLang, false);
// Display key points
if (data.key_points && data.key_points.length > 0 && keyPointsSection && keyPointsList) {
keyPointsList.innerHTML = data.key_points.map(point =>
`<li style="margin-bottom:6px;">${point}</li>`
).join('');
keyPointsSection.style.display = 'block';
}
} else {
content.innerHTML = `<p style="color:var(--yt-text-tertiary); text-align:center;">
<i class="fas fa-info-circle"></i> ${data.error || 'No summary available for this video.'}
</p>`;
// Hide key points and disable translate when no summary available
if (keyPointsSection) keyPointsSection.style.display = 'none';
// Disable translate button (no content to translate)
const translateBtn = document.getElementById('translateBtn');
if (translateBtn) {
translateBtn.disabled = true;
translateBtn.style.opacity = '0.5';
translateBtn.style.cursor = 'not-allowed';
translateBtn.classList.remove('loading');
translateBtn.innerHTML = '🇻🇳 <span>Tiếng Việt</span>';
}
}
} catch (err) {
console.error('Summary error:', err);
summaryBox.style.display = 'none';
}
} }
// Keep the button click function for manual trigger // Display summary with proper formatting
function showSummaryModal() { // summary = English content, translatedSummary = Vietnamese content (if available)
loadSummaryInline(); function displaySummary(summary, translatedSummary, lang, isLocal) {
const content = document.getElementById('summaryContent');
const summaryLang = document.getElementById('summaryLang');
const translateBtn = document.getElementById('translateBtn');
if (!content) return;
// Store original English summary for later use
if (lang === 'en' || !currentSummaryData.en) {
currentSummaryData.en = summary;
}
if (translatedSummary) {
currentSummaryData.vi = translatedSummary;
}
// Update language indicator
if (summaryLang) {
const langLabel = translatedSummary ? '🇬🇧 + 🇻🇳' : (lang === 'vi' ? '🇻🇳 Tiếng Việt' : '🇬🇧 English');
const aiLabel = isLocal ? '<span class="ai-source-indicator local"><i class="fas fa-microchip"></i> Local AI</span>' : '';
summaryLang.innerHTML = langLabel + ' ' + aiLabel;
}
if (translateBtn) {
translateBtn.innerHTML = translatedSummary
? '🇬🇧 <span>English Only</span>'
: (lang === 'vi' ? '🇬🇧 <span>English</span>' : '🇻🇳 <span>Tiếng Việt</span>');
}
// Display summary - show dual language if we have translation
if (translatedSummary && summary !== translatedSummary) {
// Dual display: English + Vietnamese (from API with both)
content.innerHTML = `
<div style="margin-bottom:12px;">
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
<span style="font-size:12px; background:#e3f2fd; color:#1976d2; padding:2px 8px; border-radius:4px;">🇬🇧 English</span>
</div>
<p style="margin:0; padding:10px; background:var(--yt-bg-primary); border-radius:8px; border-left:3px solid #1976d2;">${summary}</p>
</div>
<div>
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
<span style="font-size:12px; background:#fff3e0; color:#e65100; padding:2px 8px; border-radius:4px;">🇻🇳 Tiếng Việt</span>
</div>
<p style="margin:0; padding:10px; background:var(--yt-bg-primary); border-radius:8px; border-left:3px solid #e65100;">${translatedSummary}</p>
</div>
`;
currentSummaryData.en = summary;
currentSummaryData.vi = translatedSummary;
} else if (currentSummaryData.en && currentSummaryData.vi && currentSummaryData.en !== currentSummaryData.vi) {
// We have cached both versions - validate and show dual display
// Verify which is actually English vs Vietnamese using character detection
const hasVietnameseInEn = /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]/i.test(currentSummaryData.en);
const hasVietnameseInVi = /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]/i.test(currentSummaryData.vi);
// Correct if labels are swapped (en has Vietnamese chars, vi doesn't)
let actualEn = currentSummaryData.en;
let actualVi = currentSummaryData.vi;
if (hasVietnameseInEn && !hasVietnameseInVi) {
// Labels were swapped - fix them
actualEn = currentSummaryData.vi;
actualVi = currentSummaryData.en;
currentSummaryData.en = actualEn;
currentSummaryData.vi = actualVi;
}
content.innerHTML = `
<div style="margin-bottom:12px;">
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
<span style="font-size:12px; background:#e3f2fd; color:#1976d2; padding:2px 8px; border-radius:4px;">🇬🇧 English</span>
</div>
<p style="margin:0; padding:10px; background:var(--yt-bg-primary); border-radius:8px; border-left:3px solid #1976d2;">${actualEn}</p>
</div>
<div>
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
<span style="font-size:12px; background:#fff3e0; color:#e65100; padding:2px 8px; border-radius:4px;">🇻🇳 Tiếng Việt</span>
</div>
<p style="margin:0; padding:10px; background:var(--yt-bg-primary); border-radius:8px; border-left:3px solid #e65100;">${actualVi}</p>
</div>
`;
} else {
// Single language display - detect language from content
// Simple heuristic: Vietnamese has characters like ạ, ế, ư, ơ, etc.
const hasVietnamese = /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]/i.test(summary);
const actualLang = hasVietnamese ? 'vi' : 'en';
// Store in cache
if (actualLang === 'en') {
currentSummaryData.en = summary;
} else {
currentSummaryData.vi = summary;
}
const labelFlag = actualLang === 'vi' ? '🇻🇳' : '🇬🇧';
const labelText = actualLang === 'vi' ? 'Tiếng Việt' : 'English';
const labelBg = actualLang === 'vi' ? '#fff3e0' : '#e3f2fd';
const labelColor = actualLang === 'vi' ? '#e65100' : '#1976d2';
content.innerHTML = `
<div>
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
<span style="font-size:12px; background:${labelBg}; color:${labelColor}; padding:2px 8px; border-radius:4px;">${labelFlag} ${labelText}</span>
</div>
<p style="margin:0; padding:10px; background:var(--yt-bg-primary); border-radius:8px; border-left:3px solid ${labelColor};">${summary}</p>
</div>
`;
}
} }
// Toggle between English and Vietnamese translation // Toggle between English and Vietnamese translation
function toggleSummaryTranslation() { async function toggleSummaryTranslation() {
const newLang = currentSummaryLang === 'vi' ? 'en' : 'vi'; const newLang = currentSummaryLang === 'vi' ? 'en' : 'vi';
const translateBtn = document.getElementById('translateBtn');
// Show loading state
if (translateBtn) {
translateBtn.innerHTML = '<div class="spinner"></div> Translating...';
translateBtn.classList.add('loading');
}
// Try WebLLM first for instant translation
if (window.webLLMService && window.webLLMService.isReady() && currentSummaryData[currentSummaryLang]) {
try {
if (currentSummaryData[newLang]) {
// Already have translation cached
displaySummary(currentSummaryData[newLang], null, newLang, true);
currentSummaryLang = newLang;
} else {
// Translate with WebLLM
const translated = await window.webLLMService.translate(
currentSummaryData[currentSummaryLang],
currentSummaryLang,
newLang
);
currentSummaryData[newLang] = translated;
displaySummary(translated, null, newLang, true);
currentSummaryLang = newLang;
}
if (translateBtn) translateBtn.classList.remove('loading');
return;
} catch (e) {
console.error('WebLLM translation failed:', e);
}
}
// Fallback to server
loadSummaryInline(newLang); loadSummaryInline(newLang);
if (translateBtn) translateBtn.classList.remove('loading');
}
// Copy summary content to clipboard
function copySummaryContent() {
const content = document.getElementById('summaryContent');
const copyBtn = document.getElementById('copySummaryBtn');
if (!content) return;
// Get text content (strip HTML)
const text = content.innerText || content.textContent;
// Copy to clipboard
navigator.clipboard.writeText(text).then(() => {
// Show success feedback
if (copyBtn) {
const originalHTML = copyBtn.innerHTML;
copyBtn.innerHTML = '<i class="fas fa-check"></i> <span>Copied!</span>';
copyBtn.style.background = 'linear-gradient(135deg, #10b981, #059669)';
copyBtn.style.color = 'white';
copyBtn.style.borderColor = 'transparent';
setTimeout(() => {
copyBtn.innerHTML = originalHTML;
copyBtn.style.background = '';
copyBtn.style.color = '';
copyBtn.style.borderColor = '';
}, 2000);
}
if (typeof showToast === 'function') {
showToast('Summary copied to clipboard!', 'success');
}
}).catch(err => {
console.error('Copy failed:', err);
if (typeof showToast === 'function') {
showToast('Failed to copy', 'error');
}
});
} }
function closeSummaryModal() { function closeSummaryModal() {
@ -1910,13 +2055,22 @@
if (modal) modal.style.display = 'none'; if (modal) modal.style.display = 'none';
} }
// Keep the button click function for manual trigger
function showSummaryModal() {
// Start loading WebLLM in background
initWebLLMIfNeeded();
loadSummaryInline();
}
// Auto-load summary when page loads (after a short delay to ensure video ID is set) // Auto-load summary when page loads (after a short delay to ensure video ID is set)
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
setTimeout(function () { setTimeout(function () {
if (currentVideoData.id) { if (currentVideoData.id) {
// Pre-fetch transcript for WebLLM
fetchTranscript(currentVideoData.id);
loadSummaryInline(); loadSummaryInline();
} }
}, 2000); // Wait 2 seconds for video info to load }, 2000);
}); });
function toggleCaptions() { function toggleCaptions() {

0
tests/test_loader_integration.py Normal file → Executable file
View file

0
tests/test_summarizer_logic.py Normal file → Executable file
View file

@ -0,0 +1 @@
Subproject commit 4b16bebf7d81925131001006231795f38538a928

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

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