Compare commits
No commits in common. "f429116ed099738264c3e6377db5b5429c103412" and "aa1a419c35927b81f89cf0a4fb7edb435d27cfe3" have entirely different histories.
f429116ed0
...
aa1a419c35
2618 changed files with 626827 additions and 11958 deletions
|
|
@ -1,13 +0,0 @@
|
||||||
.venv/
|
|
||||||
.venv_clean/
|
|
||||||
env/
|
|
||||||
__pycache__/
|
|
||||||
.git/
|
|
||||||
.DS_Store
|
|
||||||
*.pyc
|
|
||||||
*.pyo
|
|
||||||
*.pyd
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
videos/
|
|
||||||
data/
|
|
||||||
12
.env.example
12
.env.example
|
|
@ -1,12 +0,0 @@
|
||||||
# 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 +0,0 @@
|
||||||
Subproject commit 246c4c349d97205eb2b51d7d3999ea846f5b2bdc
|
|
||||||
68
.github/workflows/docker-publish.yml
vendored
68
.github/workflows/docker-publish.yml
vendored
|
|
@ -1,68 +0,0 @@
|
||||||
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
12
.gitignore
vendored
|
|
@ -1,12 +0,0 @@
|
||||||
.DS_Store
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
venv/
|
|
||||||
.venv/
|
|
||||||
.venv_clean/
|
|
||||||
.env
|
|
||||||
data/
|
|
||||||
videos/
|
|
||||||
*.db
|
|
||||||
server.log
|
|
||||||
.ruff_cache/
|
|
||||||
0
API_DOCUMENTATION.md
Executable file → Normal file
0
API_DOCUMENTATION.md
Executable file → Normal file
0
Dockerfile
Executable file → Normal file
0
Dockerfile
Executable file → Normal file
0
README.md
Executable file → Normal file
0
README.md
Executable file → Normal file
0
USER_GUIDE.md
Executable file → Normal file
0
USER_GUIDE.md
Executable file → Normal file
BIN
__pycache__/config.cpython-312.pyc
Normal file
BIN
__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
0
app/__init__.py
Executable file → Normal file
0
app/__init__.py
Executable file → Normal file
BIN
app/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
0
app/routes/__init__.py
Executable file → Normal file
0
app/routes/__init__.py
Executable file → Normal file
BIN
app/routes/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/api.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/api.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/pages.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/pages.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/streaming.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/streaming.cpython-312.pyc
Normal file
Binary file not shown.
148
app/routes/api.py
Executable file → Normal file
148
app/routes/api.py
Executable file → Normal 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 using TranscriptService (with ytfetcher fallback)
|
# 1. Get Transcript Text
|
||||||
text = TranscriptService.get_transcript(video_id)
|
text = get_transcript_text(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 - generate longer, more meaningful summaries
|
# 2. Use TextRank Summarizer (Gemini removed per user request)
|
||||||
summarizer = TextRankSummarizer()
|
summarizer = TextRankSummarizer()
|
||||||
summary_text = summarizer.summarize(text, num_sentences=5) # Increased from 3 to 5
|
summary_text = summarizer.summarize(text, num_sentences=3)
|
||||||
|
|
||||||
# Allow longer summaries for more meaningful content (600 chars instead of 300)
|
# Limit to 300 characters for concise display
|
||||||
if len(summary_text) > 600:
|
if len(summary_text) > 300:
|
||||||
summary_text = summary_text[:597] + "..."
|
summary_text = summary_text[:297] + "..."
|
||||||
|
|
||||||
# Key points will be extracted by WebLLM on frontend (better quality)
|
# Extract key points from summary (heuristic)
|
||||||
# Backend just returns empty list - WebLLM generates conceptual key points
|
sentences = [s.strip() for s in summary_text.split('.') if len(s.strip()) > 15]
|
||||||
key_points = []
|
key_points = sentences[:3]
|
||||||
|
|
||||||
# Store original versions
|
# Store original versions
|
||||||
original_summary = summary_text
|
original_summary = summary_text
|
||||||
|
|
@ -1472,90 +1472,78 @@ def translate_text(text, target_lang='vi'):
|
||||||
|
|
||||||
def get_transcript_text(video_id):
|
def get_transcript_text(video_id):
|
||||||
"""
|
"""
|
||||||
Fetch transcript using yt-dlp (downloading subtitles to file).
|
Fetch transcript using strictly YTFetcher as requested.
|
||||||
Reliable method that handles auto-generated captions and cookies.
|
Ensure 'ytfetcher' is up to date before usage.
|
||||||
"""
|
"""
|
||||||
import yt_dlp
|
from ytfetcher import YTFetcher
|
||||||
import glob
|
from ytfetcher.config import HTTPConfig
|
||||||
import random
|
import random
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
|
import http.cookiejar
|
||||||
|
|
||||||
try:
|
try:
|
||||||
video_id = video_id.strip()
|
# 1. Prepare Cookies if available
|
||||||
logger.info(f"Fetching transcript for {video_id} using yt-dlp")
|
# This was key to the previous success!
|
||||||
|
cookie_header = ""
|
||||||
|
cookies_path = os.environ.get('COOKIES_FILE', 'cookies.txt')
|
||||||
|
|
||||||
# Use a temporary filename pattern
|
if os.path.exists(cookies_path):
|
||||||
temp_prefix = f"transcript_{video_id}_{random.randint(1000, 9999)}"
|
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}")
|
||||||
|
|
||||||
ydl_opts = {
|
# 2. Configuration to look like a real browser
|
||||||
'skip_download': True,
|
user_agents = [
|
||||||
'quiet': True,
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
'no_warnings': 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",
|
||||||
'cookiefile': os.environ.get('COOKIES_FILE', 'cookies.txt') if os.path.exists(os.environ.get('COOKIES_FILE', 'cookies.txt')) else None,
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
|
||||||
'writesubtitles': True,
|
]
|
||||||
'writeautomaticsub': True,
|
|
||||||
'subtitleslangs': ['en', 'vi', 'en-US'],
|
headers = {
|
||||||
'outtmpl': f"/tmp/{temp_prefix}", # Save to /tmp
|
"User-Agent": random.choice(user_agents),
|
||||||
'subtitlesformat': 'json3/vtt/best', # Prefer json3 for parsing, then vtt
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
}
|
}
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
# Inject cookie header if we have it
|
||||||
# This will download the subtitle file to /tmp/
|
if cookie_header:
|
||||||
ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
|
headers["Cookie"] = cookie_header
|
||||||
|
|
||||||
# Find the downloaded file
|
config = HTTPConfig(headers=headers)
|
||||||
# yt-dlp appends language code, e.g. .en.json3
|
|
||||||
# We look for any file with our prefix
|
|
||||||
downloaded_files = glob.glob(f"/tmp/{temp_prefix}*")
|
|
||||||
|
|
||||||
if not downloaded_files:
|
# Initialize Fetcher
|
||||||
logger.warning("yt-dlp finished but no subtitle file found.")
|
fetcher = YTFetcher.from_video_ids(
|
||||||
return None
|
video_ids=[video_id],
|
||||||
|
http_config=config,
|
||||||
|
languages=['en', 'en-US', 'vi']
|
||||||
|
)
|
||||||
|
|
||||||
# Pick the best file (prefer json3, then vtt)
|
# Fetch
|
||||||
selected_file = None
|
logger.info(f"Fetching transcript with YTFetcher for {video_id}")
|
||||||
for ext in ['.json3', '.vtt', '.ttml', '.srv3']:
|
results = fetcher.fetch_transcripts()
|
||||||
for f in downloaded_files:
|
|
||||||
if f.endswith(ext):
|
|
||||||
selected_file = f
|
|
||||||
break
|
|
||||||
if selected_file: break
|
|
||||||
|
|
||||||
if not selected_file:
|
if results:
|
||||||
selected_file = downloaded_files[0]
|
data = results[0]
|
||||||
|
# Check for transcript data
|
||||||
# Read content
|
if data.transcripts:
|
||||||
with open(selected_file, 'r', encoding='utf-8') as f:
|
logger.info("YTFetcher: Transcript found.")
|
||||||
content = f.read()
|
text_lines = [t.text.strip() for t in data.transcripts if t.text.strip()]
|
||||||
|
return " ".join(text_lines)
|
||||||
# Cleanup
|
else:
|
||||||
for f in downloaded_files:
|
logger.warning("YTFetcher: No transcript in result.")
|
||||||
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:
|
||||||
logger.error(f"Transcript fetch failed: {e}")
|
import traceback
|
||||||
|
tb = traceback.format_exc()
|
||||||
|
logger.error(f"YTFetcher Execution Failed: {e}\n{tb}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
0
app/routes/pages.py
Executable file → Normal file
0
app/routes/pages.py
Executable file → Normal file
0
app/routes/streaming.py
Executable file → Normal file
0
app/routes/streaming.py
Executable file → Normal file
0
app/services/__init__.py
Executable file → Normal file
0
app/services/__init__.py
Executable file → Normal file
BIN
app/services/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/gemini_summarizer.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/gemini_summarizer.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/loader_to.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/loader_to.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/settings.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/settings.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/summarizer.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/summarizer.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/youtube.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/youtube.cpython-312.pyc
Normal file
Binary file not shown.
0
app/services/cache.py
Executable file → Normal file
0
app/services/cache.py
Executable file → Normal file
0
app/services/gemini_summarizer.py
Executable file → Normal file
0
app/services/gemini_summarizer.py
Executable file → Normal file
0
app/services/loader_to.py
Executable file → Normal file
0
app/services/loader_to.py
Executable file → Normal file
0
app/services/settings.py
Executable file → Normal file
0
app/services/settings.py
Executable file → Normal file
0
app/services/summarizer.py
Executable file → Normal file
0
app/services/summarizer.py
Executable file → Normal file
|
|
@ -1,211 +0,0 @@
|
||||||
"""
|
|
||||||
Transcript Service Module
|
|
||||||
Fetches video transcripts with fallback strategy: yt-dlp -> ytfetcher
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import glob
|
|
||||||
import json
|
|
||||||
import random
|
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TranscriptService:
|
|
||||||
"""Service for fetching YouTube video transcripts with fallback support."""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_transcript(cls, video_id: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Get transcript text for a video.
|
|
||||||
|
|
||||||
Strategy:
|
|
||||||
1. Try yt-dlp (current method, handles auto-generated captions)
|
|
||||||
2. Fallback to ytfetcher library if yt-dlp fails
|
|
||||||
|
|
||||||
Args:
|
|
||||||
video_id: YouTube video ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Transcript text or None if unavailable
|
|
||||||
"""
|
|
||||||
video_id = video_id.strip()
|
|
||||||
|
|
||||||
# Try yt-dlp first (primary method)
|
|
||||||
text = cls._fetch_with_ytdlp(video_id)
|
|
||||||
if text:
|
|
||||||
logger.info(f"Transcript fetched via yt-dlp for {video_id}")
|
|
||||||
return text
|
|
||||||
|
|
||||||
# Fallback to ytfetcher
|
|
||||||
logger.info(f"yt-dlp failed, trying ytfetcher for {video_id}")
|
|
||||||
text = cls._fetch_with_ytfetcher(video_id)
|
|
||||||
if text:
|
|
||||||
logger.info(f"Transcript fetched via ytfetcher for {video_id}")
|
|
||||||
return text
|
|
||||||
|
|
||||||
logger.warning(f"All transcript methods failed for {video_id}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _fetch_with_ytdlp(cls, video_id: str) -> Optional[str]:
|
|
||||||
"""Fetch transcript using yt-dlp (downloading subtitles to file)."""
|
|
||||||
import yt_dlp
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.info(f"Fetching transcript for {video_id} using yt-dlp")
|
|
||||||
|
|
||||||
# Use a temporary filename pattern
|
|
||||||
temp_prefix = f"transcript_{video_id}_{random.randint(1000, 9999)}"
|
|
||||||
|
|
||||||
ydl_opts = {
|
|
||||||
'skip_download': True,
|
|
||||||
'quiet': True,
|
|
||||||
'no_warnings': True,
|
|
||||||
'cookiefile': os.environ.get('COOKIES_FILE', 'cookies.txt') if os.path.exists(os.environ.get('COOKIES_FILE', 'cookies.txt')) else None,
|
|
||||||
'writesubtitles': True,
|
|
||||||
'writeautomaticsub': True,
|
|
||||||
'subtitleslangs': ['en', 'vi', 'en-US'],
|
|
||||||
'outtmpl': f"/tmp/{temp_prefix}",
|
|
||||||
'subtitlesformat': 'json3/vtt/best',
|
|
||||||
}
|
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
||||||
ydl.download([f"https://www.youtube.com/watch?v={video_id}"])
|
|
||||||
|
|
||||||
# Find the downloaded file
|
|
||||||
downloaded_files = glob.glob(f"/tmp/{temp_prefix}*")
|
|
||||||
|
|
||||||
if not downloaded_files:
|
|
||||||
logger.warning("yt-dlp finished but no subtitle file found.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Pick the best file (prefer json3, then vtt)
|
|
||||||
selected_file = None
|
|
||||||
for ext in ['.json3', '.vtt', '.ttml', '.srv3']:
|
|
||||||
for f in downloaded_files:
|
|
||||||
if f.endswith(ext):
|
|
||||||
selected_file = f
|
|
||||||
break
|
|
||||||
if selected_file:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not selected_file:
|
|
||||||
selected_file = downloaded_files[0]
|
|
||||||
|
|
||||||
# Read content
|
|
||||||
with open(selected_file, 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
for f in downloaded_files:
|
|
||||||
try:
|
|
||||||
os.remove(f)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Parse based on format
|
|
||||||
if selected_file.endswith('.json3') or content.strip().startswith('{'):
|
|
||||||
return cls._parse_json3(content)
|
|
||||||
else:
|
|
||||||
return cls._parse_vtt(content)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"yt-dlp transcript fetch failed: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _fetch_with_ytfetcher(cls, video_id: str) -> Optional[str]:
|
|
||||||
"""Fetch transcript using ytfetcher library as fallback."""
|
|
||||||
try:
|
|
||||||
from ytfetcher import YTFetcher
|
|
||||||
|
|
||||||
logger.info(f"Using ytfetcher for {video_id}")
|
|
||||||
|
|
||||||
# Create fetcher for single video
|
|
||||||
fetcher = YTFetcher.from_video_ids(video_ids=[video_id])
|
|
||||||
|
|
||||||
# Fetch transcripts
|
|
||||||
data = fetcher.fetch_transcripts()
|
|
||||||
|
|
||||||
if not data:
|
|
||||||
logger.warning(f"ytfetcher returned no data for {video_id}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Extract text from transcript objects
|
|
||||||
text_parts = []
|
|
||||||
for item in data:
|
|
||||||
transcripts = getattr(item, 'transcripts', []) or []
|
|
||||||
for t in transcripts:
|
|
||||||
txt = getattr(t, 'text', '') or ''
|
|
||||||
txt = txt.strip()
|
|
||||||
if txt and txt != '\n':
|
|
||||||
text_parts.append(txt)
|
|
||||||
|
|
||||||
if not text_parts:
|
|
||||||
logger.warning(f"ytfetcher returned empty transcripts for {video_id}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return " ".join(text_parts)
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
logger.warning("ytfetcher not installed. Run: pip install ytfetcher")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"ytfetcher transcript fetch failed: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_json3(content: str) -> Optional[str]:
|
|
||||||
"""Parse JSON3 subtitle format."""
|
|
||||||
try:
|
|
||||||
json_data = json.loads(content)
|
|
||||||
events = json_data.get('events', [])
|
|
||||||
text_parts = []
|
|
||||||
for event in events:
|
|
||||||
segs = event.get('segs', [])
|
|
||||||
for seg in segs:
|
|
||||||
txt = seg.get('utf8', '').strip()
|
|
||||||
if txt and txt != '\n':
|
|
||||||
text_parts.append(txt)
|
|
||||||
return " ".join(text_parts)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"JSON3 parse failed: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_vtt(content: str) -> Optional[str]:
|
|
||||||
"""Parse VTT/XML subtitle content."""
|
|
||||||
try:
|
|
||||||
lines = content.splitlines()
|
|
||||||
text_lines = []
|
|
||||||
seen = set()
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
if "-->" in line:
|
|
||||||
continue
|
|
||||||
if line.isdigit():
|
|
||||||
continue
|
|
||||||
if line.startswith("WEBVTT"):
|
|
||||||
continue
|
|
||||||
if line.startswith("Kind:"):
|
|
||||||
continue
|
|
||||||
if line.startswith("Language:"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Remove tags like <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
Executable file → Normal file
0
app/services/youtube.py
Executable file → Normal file
0
app/utils/__init__.py
Executable file → Normal file
0
app/utils/__init__.py
Executable file → Normal file
0
app/utils/formatters.py
Executable file → Normal file
0
app/utils/formatters.py
Executable file → Normal file
0
bin/ffmpeg
Executable file → Normal file
0
bin/ffmpeg
Executable file → Normal file
0
config.py
Executable file → Normal file
0
config.py
Executable file → Normal file
18
cookies.txt
Executable file → Normal file
18
cookies.txt
Executable file → Normal 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 1831894348 __Secure-3PSID g.a0005wie1jXMw44_RGjwtqg21AbatcdNseI3S_qNtsLYC1jS4YSUdfLDtJlA8h-NEmMGebiczgACgYKAX0SARESFQHGX2MiUz2RnkvviMoB7UNylf3SoBoVAUF8yKo0JwXF5B9H9roWaSTRT-QN0076
|
.youtube.com TRUE / TRUE 1802692356 __Secure-3PSID g.a0005gie1lAkmYZc-EPeGx77pCrXo_Cz5eAi-e9aryb9Qoz967v4Caiou6Tt5ZyLR4iMp5I51wACgYKASISARESFQHGX2MiopTeGBKXybppZWNr7JzmKhoVAUF8yKrgfPx-gEb02gGAV3ZaVOGr0076
|
||||||
.youtube.com TRUE / TRUE 1800281710 __Secure-1PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
|
.youtube.com TRUE / TRUE 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 1800359997 __Secure-1PSIDCC AKEyXzWh3snkS2XAx8pLOzZCgKTPwXKRai_Pn4KjpsSSc2h7tRpVKMDddMKBYkuIQFhpVlALI84
|
.youtube.com TRUE / TRUE 1800282680 __Secure-1PSIDCC AKEyXzXvpBScD7r3mqr7aZ0ymWZ7FmsgT0q0C3Ge8hvrjZ9WZ4PU4ZBuBsO0YNYN3A8iX4eV8F8
|
||||||
.youtube.com TRUE / TRUE 1802692356 SSID A4isk9AE9xActvzYy
|
.youtube.com TRUE / TRUE 1802692356 SSID A4isk9AE9xActvzYy
|
||||||
.youtube.com TRUE / TRUE 1831894348 __Secure-1PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
.youtube.com TRUE / TRUE 1802692356 __Secure-1PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
||||||
.youtube.com TRUE / TRUE 1831894348 __Secure-1PSID g.a0005wie1jXMw44_RGjwtqg21AbatcdNseI3S_qNtsLYC1jS4YSUycKC58NH045FOFX6QW8fDwACgYKAacSARESFQHGX2MiA5xeTuJuh8QmBm-DS3l1ghoVAUF8yKr4klCBhb-EJgFQ9T0TGWKk0076
|
.youtube.com TRUE / TRUE 1802692356 __Secure-1PSID g.a0005gie1lAkmYZc-EPeGx77pCrXo_Cz5eAi-e9aryb9Qoz967v4-rF3xTavVHrJoyJAqShH6gACgYKAX0SARESFQHGX2MiOdAbUPmCj4MueYyh-2km5RoVAUF8yKp2ehWQC6tX8n-9UNg11RV60076
|
||||||
.youtube.com TRUE / TRUE 1831894348 __Secure-3PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
.youtube.com TRUE / TRUE 1802692356 __Secure-3PAPISID DP6iRyLCM_cFV1Gw/AN2nemkVrvJ2p8MWb
|
||||||
.youtube.com TRUE / TRUE 1800359997 __Secure-3PSIDCC AKEyXzW3W5Q-e4TIryFWpWS6zVuuVPOvwPIU2tzl1JRdYsGu-7f34g_amk2Xd2ttGtSJ6tOSdA
|
.youtube.com TRUE / TRUE 1800282680 __Secure-3PSIDCC AKEyXzVcvX-jLLprjZQXoqarG3xsAVpjyLYaN2j0a_iUcsnKnpL88P_5IlcfusJn0We0aaKK7g
|
||||||
.youtube.com TRUE / TRUE 1800281710 __Secure-3PSIDTS sidts-CjQB7I_69DRJdiQQGddE6tt-GHilv2IjDZd8S6FlWCjx2iReOoNtQMUkb55vaBdl8vBK7J_DEAA
|
.youtube.com TRUE / TRUE 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 1784333733 __Secure-ROLLOUT_TOKEN CPm51pHVjquOTRDw0bnsppWSAxjzxYe3qZaSAw%3D%3D
|
.youtube.com TRUE / TRUE 1784298680 __Secure-ROLLOUT_TOKEN CPm51pHVjquOTRDw0bnsppWSAxjw0bnsppWSAw%3D%3D
|
||||||
.youtube.com TRUE / TRUE 1784375997 VISITOR_INFO1_LIVE ShB1Bvj-rRU
|
.youtube.com TRUE / TRUE 1784298680 VISITOR_INFO1_LIVE ShB1Bvj-rRU
|
||||||
.youtube.com TRUE / TRUE 1784375997 VISITOR_PRIVACY_METADATA CgJWThIEGgAgWA%3D%3D
|
.youtube.com TRUE / TRUE 1784298680 VISITOR_PRIVACY_METADATA CgJWThIEGgAgWA%3D%3D
|
||||||
|
|
|
||||||
BIN
data/BpwWnK6n9IQ.m4a
Normal file
BIN
data/BpwWnK6n9IQ.m4a
Normal file
Binary file not shown.
BIN
data/U2oEJKsPdHo.m4a
Normal file
BIN
data/U2oEJKsPdHo.m4a
Normal file
Binary file not shown.
BIN
data/UtGG6u1RBXI.m4a
Normal file
BIN
data/UtGG6u1RBXI.m4a
Normal file
Binary file not shown.
BIN
data/kvtube.db
Normal file
BIN
data/kvtube.db
Normal file
Binary file not shown.
BIN
data/m4xEF92ZPuk.m4a
Normal file
BIN
data/m4xEF92ZPuk.m4a
Normal file
Binary file not shown.
3
data/settings.json
Normal file
3
data/settings.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"youtube_engine": "local"
|
||||||
|
}
|
||||||
0
deploy.py
Executable file → Normal file
0
deploy.py
Executable file → Normal file
0
dev.sh
Executable file → Normal file
0
dev.sh
Executable file → Normal file
0
doc/Product Requirements Document (PRD) - KV-Tube
Executable file → Normal file
0
doc/Product Requirements Document (PRD) - KV-Tube
Executable file → Normal file
0
docker-compose.yml
Executable file → Normal file
0
docker-compose.yml
Executable file → Normal file
0
entrypoint.sh
Executable file → Normal file
0
entrypoint.sh
Executable file → Normal file
131
hydration_debug.txt
Executable file → Normal file
131
hydration_debug.txt
Executable file → Normal file
|
|
@ -1011,134 +1011,3 @@ 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
Executable file → Normal file
0
kv_server.py
Executable file → Normal file
0
kv_tube.db
Normal file
0
kv_tube.db
Normal file
BIN
kvtube.db
Normal file
BIN
kvtube.db
Normal file
Binary file not shown.
2
requirements.txt
Executable file → Normal file
2
requirements.txt
Executable file → Normal file
|
|
@ -4,6 +4,4 @@ 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
Executable file → Normal file
0
start.sh
Executable file → Normal file
0
static/css/modules/base.css
Executable file → Normal file
0
static/css/modules/base.css
Executable file → Normal file
0
static/css/modules/cards.css
Executable file → Normal file
0
static/css/modules/cards.css
Executable file → Normal file
0
static/css/modules/chat.css
Executable file → Normal file
0
static/css/modules/chat.css
Executable file → Normal file
0
static/css/modules/components.css
Executable file → Normal file
0
static/css/modules/components.css
Executable file → Normal file
0
static/css/modules/downloads.css
Executable file → Normal file
0
static/css/modules/downloads.css
Executable file → Normal file
0
static/css/modules/grid.css
Executable file → Normal file
0
static/css/modules/grid.css
Executable file → Normal file
0
static/css/modules/layout.css
Executable file → Normal file
0
static/css/modules/layout.css
Executable file → Normal file
0
static/css/modules/pages.css
Executable file → Normal file
0
static/css/modules/pages.css
Executable file → Normal file
0
static/css/modules/utils.css
Executable file → Normal file
0
static/css/modules/utils.css
Executable file → Normal file
0
static/css/modules/variables.css
Executable file → Normal file
0
static/css/modules/variables.css
Executable file → Normal file
34
static/css/modules/watch.css
Executable file → Normal file
34
static/css/modules/watch.css
Executable file → Normal file
|
|
@ -21,7 +21,39 @@ body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mini player removed per user request */
|
/* ========== 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ========== Skeleton Loading ========== */
|
/* ========== Skeleton Loading ========== */
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
|
|
|
||||||
|
|
@ -1,277 +0,0 @@
|
||||||
/**
|
|
||||||
* 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
Executable file → Normal file
0
static/css/style.css
Executable file → Normal file
0
static/favicon.ico
Executable file → Normal file
0
static/favicon.ico
Executable file → Normal file
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
0
static/icons/icon-192x192.png
Executable file → Normal file
0
static/icons/icon-192x192.png
Executable file → Normal file
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
0
static/icons/icon-512x512.png
Executable file → Normal file
0
static/icons/icon-512x512.png
Executable file → Normal file
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
0
static/js/artplayer.js
Executable file → Normal file
0
static/js/artplayer.js
Executable file → Normal file
0
static/js/download-manager.js
Executable file → Normal file
0
static/js/download-manager.js
Executable file → Normal file
0
static/js/hls.min.js
vendored
Executable file → Normal file
0
static/js/hls.min.js
vendored
Executable file → Normal file
6
static/js/main.js
Executable file → Normal file
6
static/js/main.js
Executable file → Normal file
|
|
@ -169,12 +169,6 @@ 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
Executable file → Normal file
0
static/js/navigation-manager.js
Executable file → Normal file
|
|
@ -1,340 +0,0 @@
|
||||||
/**
|
|
||||||
* 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
Executable file → Normal file
0
static/manifest.json
Executable file → Normal file
0
static/sw.js
Executable file → Normal file
0
static/sw.js
Executable file → Normal file
0
templates/channel.html
Executable file → Normal file
0
templates/channel.html
Executable file → Normal file
0
templates/downloads.html
Executable file → Normal file
0
templates/downloads.html
Executable file → Normal file
0
templates/index.html
Executable file → Normal file
0
templates/index.html
Executable file → Normal file
0
templates/layout.html
Executable file → Normal file
0
templates/layout.html
Executable file → Normal file
0
templates/login.html
Executable file → Normal file
0
templates/login.html
Executable file → Normal file
0
templates/my_videos.html
Executable file → Normal file
0
templates/my_videos.html
Executable file → Normal file
0
templates/register.html
Executable file → Normal file
0
templates/register.html
Executable file → Normal file
0
templates/settings.html
Executable file → Normal file
0
templates/settings.html
Executable file → Normal file
552
templates/watch.html
Executable file → Normal file
552
templates/watch.html
Executable file → Normal file
|
|
@ -27,7 +27,8 @@
|
||||||
<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;">
|
||||||
|
|
@ -135,11 +136,6 @@
|
||||||
</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>
|
||||||
|
|
@ -288,32 +284,6 @@
|
||||||
|
|
||||||
<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>
|
||||||
|
|
@ -505,6 +475,9 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup Mini Player logic
|
||||||
|
setupMiniPlayer();
|
||||||
|
|
||||||
return art;
|
return art;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -581,6 +554,9 @@
|
||||||
saveToLibrary('history', currentVideoData);
|
saveToLibrary('history', currentVideoData);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
|
// Setup Mini Player (still works with IFrame container)
|
||||||
|
setupMiniPlayer();
|
||||||
|
|
||||||
return window.player;
|
return window.player;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -611,7 +587,135 @@
|
||||||
return initYouTubePlayer(videoId);
|
return initYouTubePlayer(videoId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mini player removed per user request
|
// --- 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
|
||||||
|
|
||||||
// --- Loop Logic ---
|
// --- Loop Logic ---
|
||||||
function toggleLoop(btn) {
|
function toggleLoop(btn) {
|
||||||
|
|
@ -1702,63 +1806,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Track current language state and WebLLM status
|
// Track current language state
|
||||||
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)
|
||||||
async function loadSummaryInline(lang = 'en') {
|
function loadSummaryInline(lang = 'en') {
|
||||||
if (!currentVideoData.id) return;
|
if (!currentVideoData.id) return;
|
||||||
|
|
||||||
const summaryBox = document.getElementById('summaryBox');
|
const summaryBox = document.getElementById('summaryBox');
|
||||||
|
|
@ -1775,279 +1827,82 @@
|
||||||
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';
|
||||||
|
|
||||||
// Check if WebLLM is ready and try to use it
|
// Build URL with optional translation
|
||||||
let useLocalAI = false;
|
let url = `/api/summarize?v=${currentVideoData.id}`;
|
||||||
if (window.webLLMService && window.webLLMService.isReady()) {
|
if (lang === 'vi') url += '&lang=vi';
|
||||||
useLocalAI = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useLocalAI) {
|
fetch(url)
|
||||||
// Use Local WebLLM AI
|
.then(res => res.json())
|
||||||
try {
|
.then(data => {
|
||||||
const transcript = await fetchTranscript(currentVideoData.id);
|
if (data.success && data.summary) {
|
||||||
if (!transcript) {
|
// Update language indicator
|
||||||
throw new Error('No transcript available');
|
currentSummaryLang = data.lang || 'en';
|
||||||
}
|
const hasTranslation = data.translated_summary;
|
||||||
|
|
||||||
// Generate summary with WebLLM
|
if (summaryLang) {
|
||||||
const summary = await window.webLLMService.summarize(transcript, lang);
|
summaryLang.innerHTML = hasTranslation ? '🇬🇧 + 🇻🇳' : '🇬🇧 English';
|
||||||
currentSummaryData[lang] = summary;
|
}
|
||||||
currentSummaryLang = lang;
|
if (translateBtn) {
|
||||||
|
translateBtn.innerHTML = hasTranslation
|
||||||
|
? '🇬🇧 <span>English Only</span>'
|
||||||
|
: '🇻🇳 <span>+ Tiếng Việt</span>';
|
||||||
|
}
|
||||||
|
|
||||||
// Update UI
|
// Display summary - show both if translation exists
|
||||||
displaySummary(summary, null, lang, true);
|
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>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Extract key points
|
// Display key points - HIDE when translation is active (to avoid redundancy)
|
||||||
if (keyPointsSection && keyPointsList) {
|
if (data.key_points && data.key_points.length > 0 && keyPointsSection && keyPointsList) {
|
||||||
try {
|
if (hasTranslation) {
|
||||||
const keyPoints = await window.webLLMService.extractKeyPoints(transcript, lang);
|
// Hide key points when showing dual-language to avoid redundancy
|
||||||
if (keyPoints && keyPoints.length > 0) {
|
keyPointsSection.style.display = 'none';
|
||||||
keyPointsList.innerHTML = keyPoints.map(point =>
|
} else {
|
||||||
|
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';
|
||||||
}
|
}
|
||||||
|
})
|
||||||
return;
|
.catch(err => {
|
||||||
} catch (e) {
|
console.error('Summary error:', err);
|
||||||
console.error('WebLLM summarization failed, falling back to server:', e);
|
summaryBox.style.display = 'none'; // Hide on error
|
||||||
// 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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display summary with proper formatting
|
// Keep the button click function for manual trigger
|
||||||
// summary = English content, translatedSummary = Vietnamese content (if available)
|
function showSummaryModal() {
|
||||||
function displaySummary(summary, translatedSummary, lang, isLocal) {
|
loadSummaryInline();
|
||||||
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
|
||||||
async function toggleSummaryTranslation() {
|
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() {
|
||||||
|
|
@ -2055,22 +1910,13 @@
|
||||||
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);
|
}, 2000); // Wait 2 seconds for video info to load
|
||||||
});
|
});
|
||||||
|
|
||||||
function toggleCaptions() {
|
function toggleCaptions() {
|
||||||
|
|
|
||||||
0
tests/test_loader_integration.py
Executable file → Normal file
0
tests/test_loader_integration.py
Executable file → Normal file
0
tests/test_summarizer_logic.py
Executable file → Normal file
0
tests/test_summarizer_logic.py
Executable file → Normal file
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit 4b16bebf7d81925131001006231795f38538a928
|
|
||||||
47
tmp_media_roller_research/Dockerfile
Normal file
47
tmp_media_roller_research/Dockerfile
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
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"]
|
||||||
59
tmp_media_roller_research/README.md
Normal file
59
tmp_media_roller_research/README.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# 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.
|
||||||
2
tmp_media_roller_research/build.sh
Normal file
2
tmp_media_roller_research/build.sh
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
go build -x -o media-roller ./src
|
||||||
2
tmp_media_roller_research/docker-build.sh
Normal file
2
tmp_media_roller_research/docker-build.sh
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
docker build -f Dockerfile -t media-roller .
|
||||||
2
tmp_media_roller_research/docker-run.sh
Normal file
2
tmp_media_roller_research/docker-run.sh
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
docker run -p 3000:3000 -v $(pwd)/download:/download media-roller
|
||||||
17
tmp_media_roller_research/go.mod
Normal file
17
tmp_media_roller_research/go.mod
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
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
|
||||||
|
)
|
||||||
26
tmp_media_roller_research/go.sum
Normal file
26
tmp_media_roller_research/go.sum
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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=
|
||||||
2
tmp_media_roller_research/run.sh
Normal file
2
tmp_media_roller_research/run.sh
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
#!/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