Compare commits
No commits in common. "aa1a419c35927b81f89cf0a4fb7edb435d27cfe3" and "f429116ed099738264c3e6377db5b5429c103412" have entirely different histories.
aa1a419c35
...
f429116ed0
2618 changed files with 11958 additions and 626827 deletions
13
.dockerignore
Executable file
13
.dockerignore
Executable 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
12
.env.example
Executable 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
1
.gemini/tmp/ytfetcher
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 246c4c349d97205eb2b51d7d3999ea846f5b2bdc
|
||||
68
.github/workflows/docker-publish.yml
vendored
Executable file
68
.github/workflows/docker-publish.yml
vendored
Executable 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
12
.gitignore
vendored
Executable 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
0
API_DOCUMENTATION.md
Normal file → Executable file
0
Dockerfile
Normal file → Executable file
0
Dockerfile
Normal file → Executable file
0
README.md
Normal file → Executable file
0
README.md
Normal file → Executable file
0
USER_GUIDE.md
Normal file → Executable file
0
USER_GUIDE.md
Normal file → Executable file
Binary file not shown.
0
app/__init__.py
Normal file → Executable file
0
app/__init__.py
Normal file → Executable file
Binary file not shown.
0
app/routes/__init__.py
Normal file → Executable file
0
app/routes/__init__.py
Normal file → Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
148
app/routes/api.py
Normal file → Executable file
148
app/routes/api.py
Normal file → Executable file
|
|
@ -15,11 +15,11 @@ import time
|
|||
import random
|
||||
import concurrent.futures
|
||||
import yt_dlp
|
||||
# from ytfetcher import YTFetcher
|
||||
from app.services.settings import SettingsService
|
||||
from app.services.summarizer import TextRankSummarizer
|
||||
from app.services.gemini_summarizer import summarize_with_gemini, extract_key_points_with_gemini
|
||||
from app.services.youtube import YouTubeService
|
||||
from app.services.transcript_service import TranscriptService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -1405,25 +1405,25 @@ def summarize_video():
|
|||
return jsonify({"error": "No video ID"}), 400
|
||||
|
||||
try:
|
||||
# 1. Get Transcript Text
|
||||
text = get_transcript_text(video_id)
|
||||
# 1. Get Transcript Text using TranscriptService (with ytfetcher fallback)
|
||||
text = TranscriptService.get_transcript(video_id)
|
||||
if not text:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "No transcript available to summarize."
|
||||
})
|
||||
|
||||
# 2. Use TextRank Summarizer (Gemini removed per user request)
|
||||
# 2. Use TextRank Summarizer - generate longer, more meaningful summaries
|
||||
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
|
||||
if len(summary_text) > 300:
|
||||
summary_text = summary_text[:297] + "..."
|
||||
# Allow longer summaries for more meaningful content (600 chars instead of 300)
|
||||
if len(summary_text) > 600:
|
||||
summary_text = summary_text[:597] + "..."
|
||||
|
||||
# Extract key points from summary (heuristic)
|
||||
sentences = [s.strip() for s in summary_text.split('.') if len(s.strip()) > 15]
|
||||
key_points = sentences[:3]
|
||||
# Key points will be extracted by WebLLM on frontend (better quality)
|
||||
# Backend just returns empty list - WebLLM generates conceptual key points
|
||||
key_points = []
|
||||
|
||||
# Store original versions
|
||||
original_summary = summary_text
|
||||
|
|
@ -1472,78 +1472,90 @@ def translate_text(text, target_lang='vi'):
|
|||
|
||||
def get_transcript_text(video_id):
|
||||
"""
|
||||
Fetch transcript using strictly YTFetcher as requested.
|
||||
Ensure 'ytfetcher' is up to date before usage.
|
||||
Fetch transcript using yt-dlp (downloading subtitles to file).
|
||||
Reliable method that handles auto-generated captions and cookies.
|
||||
"""
|
||||
from ytfetcher import YTFetcher
|
||||
from ytfetcher.config import HTTPConfig
|
||||
import yt_dlp
|
||||
import glob
|
||||
import random
|
||||
import json
|
||||
import os
|
||||
import http.cookiejar
|
||||
|
||||
try:
|
||||
# 1. Prepare Cookies if available
|
||||
# This was key to the previous success!
|
||||
cookie_header = ""
|
||||
cookies_path = os.environ.get('COOKIES_FILE', 'cookies.txt')
|
||||
video_id = video_id.strip()
|
||||
logger.info(f"Fetching transcript for {video_id} using yt-dlp")
|
||||
|
||||
if os.path.exists(cookies_path):
|
||||
try:
|
||||
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}")
|
||||
# Use a temporary filename pattern
|
||||
temp_prefix = f"transcript_{video_id}_{random.randint(1000, 9999)}"
|
||||
|
||||
# 2. Configuration to look like a real browser
|
||||
user_agents = [
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"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",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
|
||||
]
|
||||
|
||||
headers = {
|
||||
"User-Agent": random.choice(user_agents),
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
ydl_opts = {
|
||||
'skip_download': True,
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'cookiefile': os.environ.get('COOKIES_FILE', 'cookies.txt') if os.path.exists(os.environ.get('COOKIES_FILE', 'cookies.txt')) else None,
|
||||
'writesubtitles': True,
|
||||
'writeautomaticsub': True,
|
||||
'subtitleslangs': ['en', 'vi', 'en-US'],
|
||||
'outtmpl': f"/tmp/{temp_prefix}", # Save to /tmp
|
||||
'subtitlesformat': 'json3/vtt/best', # Prefer json3 for parsing, then vtt
|
||||
}
|
||||
|
||||
# Inject cookie header if we have it
|
||||
if cookie_header:
|
||||
headers["Cookie"] = cookie_header
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
# This will download the subtitle file to /tmp/
|
||||
ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
|
||||
|
||||
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
|
||||
fetcher = YTFetcher.from_video_ids(
|
||||
video_ids=[video_id],
|
||||
http_config=config,
|
||||
languages=['en', 'en-US', 'vi']
|
||||
)
|
||||
if not downloaded_files:
|
||||
logger.warning("yt-dlp finished but no subtitle file found.")
|
||||
return None
|
||||
|
||||
# Fetch
|
||||
logger.info(f"Fetching transcript with YTFetcher for {video_id}")
|
||||
results = fetcher.fetch_transcripts()
|
||||
# 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 results:
|
||||
data = results[0]
|
||||
# Check for transcript data
|
||||
if data.transcripts:
|
||||
logger.info("YTFetcher: Transcript found.")
|
||||
text_lines = [t.text.strip() for t in data.transcripts if t.text.strip()]
|
||||
return " ".join(text_lines)
|
||||
else:
|
||||
logger.warning("YTFetcher: No transcript in result.")
|
||||
if not selected_file:
|
||||
selected_file = downloaded_files[0]
|
||||
|
||||
# Read content
|
||||
with open(selected_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Cleanup
|
||||
for f in downloaded_files:
|
||||
try:
|
||||
os.remove(f)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Parse
|
||||
if selected_file.endswith('.json3') or content.strip().startswith('{'):
|
||||
try:
|
||||
json_data = json.loads(content)
|
||||
events = json_data.get('events', [])
|
||||
text_parts = []
|
||||
for event in events:
|
||||
segs = event.get('segs', [])
|
||||
for seg in segs:
|
||||
txt = seg.get('utf8', '').strip()
|
||||
if txt and txt != '\n':
|
||||
text_parts.append(txt)
|
||||
return " ".join(text_parts)
|
||||
except Exception as je:
|
||||
logger.warning(f"JSON3 parse failed: {je}")
|
||||
|
||||
return parse_transcript_content(content)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
tb = traceback.format_exc()
|
||||
logger.error(f"YTFetcher Execution Failed: {e}\n{tb}")
|
||||
|
||||
return None
|
||||
logger.error(f"Transcript fetch failed: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
|
|
|||
0
app/routes/pages.py
Normal file → Executable file
0
app/routes/pages.py
Normal file → Executable file
0
app/routes/streaming.py
Normal file → Executable file
0
app/routes/streaming.py
Normal file → Executable file
0
app/services/__init__.py
Normal file → Executable file
0
app/services/__init__.py
Normal file → Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
0
app/services/cache.py
Normal file → Executable file
0
app/services/cache.py
Normal file → Executable file
0
app/services/gemini_summarizer.py
Normal file → Executable file
0
app/services/gemini_summarizer.py
Normal file → Executable file
0
app/services/loader_to.py
Normal file → Executable file
0
app/services/loader_to.py
Normal file → Executable file
0
app/services/settings.py
Normal file → Executable file
0
app/services/settings.py
Normal file → Executable file
0
app/services/summarizer.py
Normal file → Executable file
0
app/services/summarizer.py
Normal file → Executable file
211
app/services/transcript_service.py
Executable file
211
app/services/transcript_service.py
Executable 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
0
app/services/youtube.py
Normal file → Executable file
0
app/utils/__init__.py
Normal file → Executable file
0
app/utils/__init__.py
Normal file → Executable file
0
app/utils/formatters.py
Normal file → Executable file
0
app/utils/formatters.py
Normal file → Executable file
0
bin/ffmpeg
Normal file → Executable file
0
bin/ffmpeg
Normal file → Executable file
0
config.py
Normal file → Executable file
0
config.py
Normal file → Executable file
18
cookies.txt
Normal file → Executable file
18
cookies.txt
Normal file → Executable file
|
|
@ -1,19 +1,19 @@
|
|||
# Netscape HTTP Cookie File
|
||||
# This file is generated by yt-dlp. Do not edit.
|
||||
|
||||
.youtube.com TRUE / TRUE 1802692356 __Secure-3PSID g.a0005gie1lAkmYZc-EPeGx77pCrXo_Cz5eAi-e9aryb9Qoz967v4Caiou6Tt5ZyLR4iMp5I51wACgYKASISARESFQHGX2MiopTeGBKXybppZWNr7JzmKhoVAUF8yKrgfPx-gEb02gGAV3ZaVOGr0076
|
||||
.youtube.com TRUE / TRUE 1831894348 __Secure-3PSID g.a0005wie1jXMw44_RGjwtqg21AbatcdNseI3S_qNtsLYC1jS4YSUdfLDtJlA8h-NEmMGebiczgACgYKAX0SARESFQHGX2MiUz2RnkvviMoB7UNylf3SoBoVAUF8yKo0JwXF5B9H9roWaSTRT-QN0076
|
||||
.youtube.com TRUE / TRUE 1800281710 __Secure-1PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
|
||||
.youtube.com TRUE / TRUE 1802692356 SAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
||||
.youtube.com TRUE / TRUE 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 __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 1831894348 __Secure-1PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
||||
.youtube.com TRUE / TRUE 1831894348 __Secure-1PSID g.a0005wie1jXMw44_RGjwtqg21AbatcdNseI3S_qNtsLYC1jS4YSUycKC58NH045FOFX6QW8fDwACgYKAacSARESFQHGX2MiA5xeTuJuh8QmBm-DS3l1ghoVAUF8yKr4klCBhb-EJgFQ9T0TGWKk0076
|
||||
.youtube.com TRUE / TRUE 1831894348 __Secure-3PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
||||
.youtube.com TRUE / TRUE 1800359997 __Secure-3PSIDCC AKEyXzW3W5Q-e4TIryFWpWS6zVuuVPOvwPIU2tzl1JRdYsGu-7f34g_amk2Xd2ttGtSJ6tOSdA
|
||||
.youtube.com TRUE / TRUE 1800281710 __Secure-3PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
|
||||
.youtube.com TRUE / TRUE 1792154873 LOGIN_INFO AFmmF2swRQIgVjJk8Mho4_JuKr6SZzrhBdlL1LdxWxcwDMu4cjaRRgcCIQCTtJpmYKJH54Tiei3at3f4YT3US7gSL0lW_TZ04guKjQ:QUQ3MjNmeWlwRDJSNDl2NE9uX2JWWG5tWllHN0RsNUVZVUhsLVp4N2dWbldaeC14SnNybWVERnNoaXFpanFJczhKTjJSRGN6MEs3c1VkLTE1TGJVeFBPT05BY29NMFh0Q1VPdFU3dUdvSUpET3lQbU1ZMUlHUGltajlXNDllNUQxZHdzZko1WXF1UUJWclNxQVJ0TXVEYnF2bXJRY2V6Vl9n
|
||||
.youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=150&hl=en
|
||||
.youtube.com TRUE / TRUE 0 YSC y-oH2BqaUSQ
|
||||
.youtube.com TRUE / TRUE 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
|
||||
.youtube.com TRUE / TRUE 1784333733 __Secure-ROLLOUT_TOKEN CPm51pHVjquOTRDw0bnsppWSAxjzxYe3qZaSAw%3D%3D
|
||||
.youtube.com TRUE / TRUE 1784375997 VISITOR_INFO1_LIVE ShB1Bvj-rRU
|
||||
.youtube.com TRUE / TRUE 1784375997 VISITOR_PRIVACY_METADATA CgJWThIEGgAgWA%3D%3D
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
data/kvtube.db
BIN
data/kvtube.db
Binary file not shown.
Binary file not shown.
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"youtube_engine": "local"
|
||||
}
|
||||
0
deploy.py
Normal file → Executable file
0
deploy.py
Normal file → Executable file
0
dev.sh
Normal file → Executable file
0
dev.sh
Normal file → Executable file
0
doc/Product Requirements Document (PRD) - KV-Tube
Normal file → Executable file
0
doc/Product Requirements Document (PRD) - KV-Tube
Normal file → Executable file
0
docker-compose.yml
Normal file → Executable file
0
docker-compose.yml
Normal file → Executable file
0
entrypoint.sh
Normal file → Executable file
0
entrypoint.sh
Normal file → Executable file
131
hydration_debug.txt
Normal file → Executable file
131
hydration_debug.txt
Normal file → Executable file
|
|
@ -1011,3 +1011,134 @@ Fetched W6cMfJeIUUU: Date=20240401
|
|||
Fetched UbCVoeTWTiA: Date=20230106
|
||||
Fetched QbO7Y_NxHAM: Date=20241029
|
||||
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
0
kv_server.py
Normal file → Executable file
BIN
kvtube.db
BIN
kvtube.db
Binary file not shown.
2
requirements.txt
Normal file → Executable file
2
requirements.txt
Normal file → Executable file
|
|
@ -4,4 +4,6 @@ yt-dlp>=2024.1.0
|
|||
werkzeug
|
||||
gunicorn
|
||||
python-dotenv
|
||||
googletrans==4.0.0-rc1
|
||||
# ytfetcher - optional, requires Python 3.11-3.13
|
||||
|
||||
|
|
|
|||
0
start.sh
Normal file → Executable file
0
start.sh
Normal file → Executable file
0
static/css/modules/base.css
Normal file → Executable file
0
static/css/modules/base.css
Normal file → Executable file
0
static/css/modules/cards.css
Normal file → Executable file
0
static/css/modules/cards.css
Normal file → Executable file
0
static/css/modules/chat.css
Normal file → Executable file
0
static/css/modules/chat.css
Normal file → Executable file
0
static/css/modules/components.css
Normal file → Executable file
0
static/css/modules/components.css
Normal file → Executable file
0
static/css/modules/downloads.css
Normal file → Executable file
0
static/css/modules/downloads.css
Normal file → Executable file
0
static/css/modules/grid.css
Normal file → Executable file
0
static/css/modules/grid.css
Normal file → Executable file
0
static/css/modules/layout.css
Normal file → Executable file
0
static/css/modules/layout.css
Normal file → Executable file
0
static/css/modules/pages.css
Normal file → Executable file
0
static/css/modules/pages.css
Normal file → Executable file
0
static/css/modules/utils.css
Normal file → Executable file
0
static/css/modules/utils.css
Normal file → Executable file
0
static/css/modules/variables.css
Normal file → Executable file
0
static/css/modules/variables.css
Normal file → Executable file
34
static/css/modules/watch.css
Normal file → Executable file
34
static/css/modules/watch.css
Normal file → Executable file
|
|
@ -21,39 +21,7 @@ body {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ========== Mini Player Mode ========== */
|
||||
.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;
|
||||
}
|
||||
}
|
||||
/* Mini player removed per user request */
|
||||
|
||||
/* ========== Skeleton Loading ========== */
|
||||
@keyframes shimmer {
|
||||
|
|
|
|||
277
static/css/modules/webllm.css
Normal file
277
static/css/modules/webllm.css
Normal 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
0
static/css/style.css
Normal file → Executable file
0
static/favicon.ico
Normal file → Executable file
0
static/favicon.ico
Normal file → Executable 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
0
static/icons/icon-192x192.png
Normal file → Executable 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
0
static/icons/icon-512x512.png
Normal file → Executable file
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
0
static/js/artplayer.js
Normal file → Executable file
0
static/js/artplayer.js
Normal file → Executable file
0
static/js/download-manager.js
Normal file → Executable file
0
static/js/download-manager.js
Normal file → Executable file
0
static/js/hls.min.js
vendored
Normal file → Executable file
0
static/js/hls.min.js
vendored
Normal file → Executable file
6
static/js/main.js
Normal file → Executable file
6
static/js/main.js
Normal file → Executable file
|
|
@ -169,6 +169,12 @@ function renderNoContent(message = 'Try searching for something else', title = '
|
|||
|
||||
// Render homepage with personalized sections
|
||||
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
|
||||
const historyMap = {};
|
||||
localHistory.forEach(v => {
|
||||
|
|
|
|||
0
static/js/navigation-manager.js
Normal file → Executable file
0
static/js/navigation-manager.js
Normal file → Executable file
340
static/js/webllm-service.js
Normal file
340
static/js/webllm-service.js
Normal 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
0
static/manifest.json
Normal file → Executable file
0
static/sw.js
Normal file → Executable file
0
static/sw.js
Normal file → Executable file
0
templates/channel.html
Normal file → Executable file
0
templates/channel.html
Normal file → Executable file
0
templates/downloads.html
Normal file → Executable file
0
templates/downloads.html
Normal file → Executable file
0
templates/index.html
Normal file → Executable file
0
templates/index.html
Normal file → Executable file
0
templates/layout.html
Normal file → Executable file
0
templates/layout.html
Normal file → Executable file
0
templates/login.html
Normal file → Executable file
0
templates/login.html
Normal file → Executable file
0
templates/my_videos.html
Normal file → Executable file
0
templates/my_videos.html
Normal file → Executable file
0
templates/register.html
Normal file → Executable file
0
templates/register.html
Normal file → Executable file
0
templates/settings.html
Normal file → Executable file
0
templates/settings.html
Normal file → Executable file
552
templates/watch.html
Normal file → Executable file
552
templates/watch.html
Normal file → Executable file
|
|
@ -27,8 +27,7 @@
|
|||
<div id="loading" class="yt-loader"></div>
|
||||
|
||||
</div>
|
||||
<!-- Placeholder for Mini Mode -->
|
||||
<div id="playerPlaceholder" class="yt-player-placeholder"></div>
|
||||
|
||||
|
||||
<!-- Info Skeleton -->
|
||||
<div id="infoSkeleton" style="margin-top:20px;">
|
||||
|
|
@ -136,6 +135,11 @@
|
|||
</div>
|
||||
</div>
|
||||
<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()"
|
||||
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>
|
||||
|
|
@ -284,6 +288,32 @@
|
|||
|
||||
<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>
|
||||
|
|
@ -475,9 +505,6 @@
|
|||
});
|
||||
}
|
||||
|
||||
// Setup Mini Player logic
|
||||
setupMiniPlayer();
|
||||
|
||||
return art;
|
||||
}
|
||||
|
||||
|
|
@ -554,9 +581,6 @@
|
|||
saveToLibrary('history', currentVideoData);
|
||||
}, 2000);
|
||||
|
||||
// Setup Mini Player (still works with IFrame container)
|
||||
setupMiniPlayer();
|
||||
|
||||
return window.player;
|
||||
}
|
||||
|
||||
|
|
@ -587,135 +611,7 @@
|
|||
return initYouTubePlayer(videoId);
|
||||
}
|
||||
|
||||
// --- Movable Mini Player Logic ---
|
||||
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
|
||||
// Mini player removed per user request
|
||||
|
||||
// --- Loop Logic ---
|
||||
function toggleLoop(btn) {
|
||||
|
|
@ -1806,11 +1702,63 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
// Track current language state
|
||||
// Track current language state and WebLLM status
|
||||
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)
|
||||
function loadSummaryInline(lang = 'en') {
|
||||
async function loadSummaryInline(lang = 'en') {
|
||||
if (!currentVideoData.id) return;
|
||||
|
||||
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>';
|
||||
if (keyPointsSection) keyPointsSection.style.display = 'none';
|
||||
|
||||
// Build URL with optional translation
|
||||
let url = `/api/summarize?v=${currentVideoData.id}`;
|
||||
if (lang === 'vi') url += '&lang=vi';
|
||||
// Check if WebLLM is ready and try to use it
|
||||
let useLocalAI = false;
|
||||
if (window.webLLMService && window.webLLMService.isReady()) {
|
||||
useLocalAI = true;
|
||||
}
|
||||
|
||||
fetch(url)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success && data.summary) {
|
||||
// Update language indicator
|
||||
currentSummaryLang = data.lang || 'en';
|
||||
const hasTranslation = data.translated_summary;
|
||||
if (useLocalAI) {
|
||||
// Use Local WebLLM AI
|
||||
try {
|
||||
const transcript = await fetchTranscript(currentVideoData.id);
|
||||
if (!transcript) {
|
||||
throw new Error('No transcript available');
|
||||
}
|
||||
|
||||
if (summaryLang) {
|
||||
summaryLang.innerHTML = hasTranslation ? '🇬🇧 + 🇻🇳' : '🇬🇧 English';
|
||||
}
|
||||
if (translateBtn) {
|
||||
translateBtn.innerHTML = hasTranslation
|
||||
? '🇬🇧 <span>English Only</span>'
|
||||
: '🇻🇳 <span>+ Tiếng Việt</span>';
|
||||
}
|
||||
// Generate summary with WebLLM
|
||||
const summary = await window.webLLMService.summarize(transcript, lang);
|
||||
currentSummaryData[lang] = summary;
|
||||
currentSummaryLang = lang;
|
||||
|
||||
// Display summary - show both if translation exists
|
||||
if (hasTranslation) {
|
||||
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>`;
|
||||
}
|
||||
// Update UI
|
||||
displaySummary(summary, null, lang, true);
|
||||
|
||||
// Display key points - HIDE when translation is active (to avoid redundancy)
|
||||
if (data.key_points && data.key_points.length > 0 && keyPointsSection && keyPointsList) {
|
||||
if (hasTranslation) {
|
||||
// Hide key points when showing dual-language to avoid redundancy
|
||||
keyPointsSection.style.display = 'none';
|
||||
} else {
|
||||
keyPointsList.innerHTML = data.key_points.map(point =>
|
||||
// Extract key points
|
||||
if (keyPointsSection && keyPointsList) {
|
||||
try {
|
||||
const keyPoints = await window.webLLMService.extractKeyPoints(transcript, lang);
|
||||
if (keyPoints && keyPoints.length > 0) {
|
||||
keyPointsList.innerHTML = keyPoints.map(point =>
|
||||
`<li style="margin-bottom:6px;">${point}</li>`
|
||||
).join('');
|
||||
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 => {
|
||||
console.error('Summary error:', err);
|
||||
summaryBox.style.display = 'none'; // Hide on error
|
||||
});
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
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
|
||||
function showSummaryModal() {
|
||||
loadSummaryInline();
|
||||
// Display summary with proper formatting
|
||||
// summary = English content, translatedSummary = Vietnamese content (if available)
|
||||
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
|
||||
function toggleSummaryTranslation() {
|
||||
async function toggleSummaryTranslation() {
|
||||
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);
|
||||
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() {
|
||||
|
|
@ -1910,13 +2055,22 @@
|
|||
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)
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
setTimeout(function () {
|
||||
if (currentVideoData.id) {
|
||||
// Pre-fetch transcript for WebLLM
|
||||
fetchTranscript(currentVideoData.id);
|
||||
loadSummaryInline();
|
||||
}
|
||||
}, 2000); // Wait 2 seconds for video info to load
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
function toggleCaptions() {
|
||||
|
|
|
|||
0
tests/test_loader_integration.py
Normal file → Executable file
0
tests/test_loader_integration.py
Normal file → Executable file
0
tests/test_summarizer_logic.py
Normal file → Executable file
0
tests/test_summarizer_logic.py
Normal file → Executable file
1
tmp_media_roller_research
Submodule
1
tmp_media_roller_research
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 4b16bebf7d81925131001006231795f38538a928
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
# 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.
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
go build -x -o media-roller ./src
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
docker build -f Dockerfile -t media-roller .
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
docker run -p 3000:3000 -v $(pwd)/download:/download media-roller
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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
Loading…
Reference in a new issue