yt downloader works (expect for user avater + banner), instagram doesnt work, replace the local route (192.168.0.107:8000) with your backend route ig

This commit is contained in:
katR 2026-04-10 14:37:39 +05:30
parent d11962052a
commit 749c25b620
8 changed files with 1505 additions and 0 deletions

69
backend/main.py Normal file
View file

@ -0,0 +1,69 @@
"""
just download stuff lol — FastAPI backend
Run: uvicorn main:app --reload --host 0.0.0.0 --port 8000
"""
import asyncio
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from routers import youtube, instagram, info
DOWNLOAD_DIR = Path("downloads")
DOWNLOAD_DIR.mkdir(exist_ok=True)
@asynccontextmanager
async def lifespan(app: FastAPI):
asyncio.create_task(_cleanup_loop())
yield
app = FastAPI(
title="just download stuff lol",
description="no cap, it just works™",
version="2.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(info.router, prefix="/api/info", tags=["info"])
app.include_router(youtube.router, prefix="/api/youtube", tags=["youtube"])
app.include_router(instagram.router, prefix="/api/instagram", tags=["instagram"])
app.mount("/files", StaticFiles(directory="downloads"), name="files")
@app.get("/")
async def root():
return {"status": "ok", "message": "just download stuff lol"}
@app.get("/health")
async def health():
return {"status": "healthy"}
async def _cleanup_loop():
"""Delete files older than 1 hour, runs every 10 minutes."""
while True:
await asyncio.sleep(600)
now = asyncio.get_event_loop().time()
for f in DOWNLOAD_DIR.iterdir():
try:
if f.is_file() and (now - os.path.getmtime(f)) > 3600:
f.unlink()
except Exception:
pass

8
backend/requirements.txt Normal file
View file

@ -0,0 +1,8 @@
pydantic==1.10.21
fastapi==0.100.1
uvicorn>=0.29.0
yt-dlp>=2024.4.9
instaloader>=4.10.3
httpx>=0.27.0
python-multipart>=0.0.9
aiofiles>=23.2.1

52
backend/routers/info.py Normal file
View file

@ -0,0 +1,52 @@
"""
/api/info — platform detection and metadata preview
"""
import re
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services.youtube_service import fetch_yt_info
from services.instagram_service import fetch_ig_info
router = APIRouter()
YT_RE = re.compile(r"(youtube\.com|youtu\.be)")
IG_RE = re.compile(r"(instagram\.com|instagr\.am)")
class InfoRequest(BaseModel):
url: str
def detect_platform(url: str) -> str:
if YT_RE.search(url):
return "youtube"
if IG_RE.search(url):
return "instagram"
return "unknown"
@router.post("/detect")
async def detect(req: InfoRequest):
platform = detect_platform(req.url)
if platform == "unknown":
raise HTTPException(400, detail="could not detect platform from that url")
return {"platform": platform, "url": req.url}
@router.post("/preview")
async def preview(req: InfoRequest):
platform = detect_platform(req.url)
try:
if platform == "youtube":
data = await fetch_yt_info(req.url)
elif platform == "instagram":
data = await fetch_ig_info(req.url)
else:
raise HTTPException(400, detail="unsupported platform — only YouTube and Instagram")
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, detail=f"fetch failed: {str(e)}")
return {"platform": platform, **data}

View file

@ -0,0 +1,94 @@
"""
/api/instagram — post, reel, story, profile, thumbnail
"""
import asyncio
import os
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import Optional
from services.instagram_service import (
download_post,
download_reel,
download_profile,
download_ig_thumbnail,
fetch_ig_info,
)
router = APIRouter()
class PostRequest(BaseModel):
url: str
format: str = "mp4"
start_time: Optional[str] = None
end_time: Optional[str] = None
class ProfileRequest(BaseModel):
username: str
class SimpleRequest(BaseModel):
url: str
async def _delete_later(path: str, delay: int = 3600):
await asyncio.sleep(delay)
try:
os.unlink(path)
except Exception:
pass
@router.post("/post")
async def post(req: PostRequest, bg: BackgroundTasks):
try:
path, filename = await download_post(
url=req.url,
fmt=req.format,
start_time=req.start_time,
end_time=req.end_time,
)
except Exception as e:
raise HTTPException(500, detail=str(e))
bg.add_task(_delete_later, path)
media_type = "video/mp4" if filename.endswith(".mp4") else "image/jpeg"
return FileResponse(path, filename=filename, media_type=media_type)
@router.post("/reel")
async def reel(req: PostRequest, bg: BackgroundTasks):
# reels use yt-dlp directly — more reliable than instaloader for reels
try:
path, filename = await download_reel(
url=req.url,
start_time=req.start_time,
end_time=req.end_time,
)
except Exception as e:
raise HTTPException(500, detail=str(e))
bg.add_task(_delete_later, path)
return FileResponse(path, filename=filename, media_type="video/mp4")
@router.post("/profile")
async def profile(req: ProfileRequest, bg: BackgroundTasks):
try:
path, filename = await download_profile(req.username)
except Exception as e:
raise HTTPException(500, detail=str(e))
bg.add_task(_delete_later, path)
return FileResponse(path, filename=filename, media_type="image/jpeg")
@router.post("/thumbnail")
async def thumbnail(req: SimpleRequest, bg: BackgroundTasks):
try:
path, filename = await download_ig_thumbnail(req.url)
except Exception as e:
raise HTTPException(500, detail=str(e))
bg.add_task(_delete_later, path)
return FileResponse(path, filename=filename, media_type="image/jpeg")

114
backend/routers/youtube.py Normal file
View file

@ -0,0 +1,114 @@
"""
/api/youtube — video, audio, thumbnail, subtitles, channel art
"""
import asyncio
import os
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import Optional
from services.youtube_service import (
download_video,
download_audio,
download_thumbnail,
download_subtitles,
download_channel_art,
)
router = APIRouter()
class VideoRequest(BaseModel):
url: str
resolution: str = "best"
format: str = "mp4"
start_time: Optional[str] = None
end_time: Optional[str] = None
class AudioRequest(BaseModel):
url: str
quality: str = "192"
format: str = "mp3"
class SimpleRequest(BaseModel):
url: str
class SubtitleRequest(BaseModel):
url: str
lang: str = "en"
class ChannelRequest(BaseModel):
channel_url: str
async def _delete_later(path: str, delay: int = 3600):
await asyncio.sleep(delay)
try:
os.unlink(path)
except Exception:
pass
@router.post("/video")
async def video(req: VideoRequest, bg: BackgroundTasks):
try:
path, filename = await download_video(
url=req.url,
resolution=req.resolution,
fmt=req.format,
start_time=req.start_time,
end_time=req.end_time,
)
except Exception as e:
raise HTTPException(500, detail=str(e))
bg.add_task(_delete_later, path)
return FileResponse(path, filename=filename, media_type="video/mp4")
@router.post("/audio")
async def audio(req: AudioRequest, bg: BackgroundTasks):
try:
path, filename = await download_audio(
url=req.url,
quality=req.quality,
fmt=req.format,
)
except Exception as e:
raise HTTPException(500, detail=str(e))
bg.add_task(_delete_later, path)
return FileResponse(path, filename=filename, media_type="audio/mpeg")
@router.post("/thumbnail")
async def thumbnail(req: SimpleRequest, bg: BackgroundTasks):
try:
path, filename = await download_thumbnail(req.url)
except Exception as e:
raise HTTPException(500, detail=str(e))
bg.add_task(_delete_later, path)
return FileResponse(path, filename=filename, media_type="image/jpeg")
@router.post("/subtitles")
async def subtitles(req: SubtitleRequest, bg: BackgroundTasks):
try:
path, filename = await download_subtitles(req.url, req.lang)
except Exception as e:
raise HTTPException(500, detail=str(e))
bg.add_task(_delete_later, path)
return FileResponse(path, filename=filename, media_type="text/plain")
@router.post("/channel-art")
async def channel_art(req: ChannelRequest):
try:
assets = await download_channel_art(req.channel_url)
except Exception as e:
raise HTTPException(500, detail=str(e))
return {"assets": assets}

View file

@ -0,0 +1,335 @@
"""
Instagram service.
Strategy:
- Posts (images): instaloader (best quality originals)
- Posts (videos): yt-dlp first, instaloader fallback
- Reels: yt-dlp (instaloader 403s on reels consistently)
- Profile pictures: instaloader (direct profile_pic_url)
- Thumbnails: yt-dlp (fast, no auth needed)
Instagram blocks unauthenticated API calls aggressively.
For private content set IG_USERNAME / IG_PASSWORD env vars.
"""
import asyncio
import os
import re
import shutil
import uuid
from pathlib import Path
from typing import Optional, Tuple
import yt_dlp
import instaloader
DOWNLOAD_DIR = Path("downloads")
DOWNLOAD_DIR.mkdir(exist_ok=True)
IG_USERNAME = os.getenv("IG_USERNAME", "")
IG_PASSWORD = os.getenv("IG_PASSWORD", "")
_loader: Optional[instaloader.Instaloader] = None
_BASE_YDL = {
"quiet": True,
"no_warnings": True,
"noprogress": True,
}
# ── helpers ───────────────────────────────────────────────────────────────────
def _run(func, *args, **kwargs):
loop = asyncio.get_event_loop()
return loop.run_in_executor(None, lambda: func(*args, **kwargs))
def _uid() -> str:
return uuid.uuid4().hex[:10]
def _safe(name: str) -> str:
return re.sub(r'[\\/*?:"<>|]', "_", name or "file")[:80]
def _fmt_s(s) -> str:
if not s:
return "0:00"
m, sec = divmod(int(s), 60)
return f"{m}:{sec:02d}"
def _get_loader() -> instaloader.Instaloader:
global _loader
if _loader is None:
_loader = instaloader.Instaloader(
download_pictures=True,
download_videos=True,
download_video_thumbnails=False,
save_metadata=False,
post_metadata_txt_pattern="",
quiet=True,
)
if IG_USERNAME and IG_PASSWORD:
try:
_loader.login(IG_USERNAME, IG_PASSWORD)
except Exception:
pass
return _loader
def _shortcode_from_url(url: str) -> str:
"""
Extract shortcode from any Instagram post/reel/tv URL.
Handles:
instagram.com/p/ABC123/
instagram.com/reel/ABC123/
instagram.com/tv/ABC123/
instagram.com/reels/ABC123/
"""
m = re.search(r"/(?:p|reel|reels|tv)/([A-Za-z0-9_-]+)", url)
if m:
return m.group(1)
raise ValueError(
f"could not extract shortcode from URL: {url}\n"
"make sure it's a post, reel, or tv URL — not a profile or explore page"
)
def _username_from_url(url: str) -> str:
"""
Extract username from instagram.com/@username or instagram.com/username
Strips trailing slashes, query params, etc.
"""
# strip protocol and domain
path = re.sub(r"https?://(www\.)?instagram\.com/?", "", url)
# strip @ if present
path = path.lstrip("@")
# take first path segment
username = path.split("/")[0].split("?")[0].strip()
if not username:
raise ValueError("could not extract username from URL")
# reject if it looks like a post URL
if username in ("p", "reel", "reels", "tv", "explore", "stories"):
raise ValueError(f"that looks like a post URL, not a profile — got segment: {username}")
return username
def _trim_video(src: Path, dest: Path, start: Optional[str], end: Optional[str]) -> Path:
import subprocess
cmd = ["ffmpeg", "-y"]
if start:
cmd += ["-ss", start]
cmd += ["-i", str(src)]
if end:
cmd += ["-to", end]
cmd += ["-c", "copy", str(dest)]
result = subprocess.run(cmd, capture_output=True, timeout=120)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg trim failed: {result.stderr.decode()[:200]}")
return dest
# ── info ──────────────────────────────────────────────────────────────────────
async def fetch_ig_info(url: str) -> dict:
"""
Try yt-dlp first (works for reels/public posts without auth),
fall back to instaloader for images and private content.
"""
def _fetch_ytdlp():
opts = {**_BASE_YDL, "skip_download": True}
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url, download=False)
return {
"type": "video" if info.get("ext") != "jpg" else "image",
"title": info.get("title") or info.get("uploader") or "Instagram post",
"thumbnail": info.get("thumbnail"),
"duration": info.get("duration"),
"duration_str": _fmt_s(info.get("duration")),
"owner": info.get("uploader"),
"likes": info.get("like_count"),
}
def _fetch_instaloader():
shortcode = _shortcode_from_url(url)
L = _get_loader()
post = instaloader.Post.from_shortcode(L.context, shortcode)
return {
"type": "video" if post.is_video else "image",
"title": f"@{post.owner_username}",
"thumbnail": post.url,
"duration": post.video_duration if post.is_video else None,
"duration_str": _fmt_s(post.video_duration) if post.is_video else None,
"owner": post.owner_username,
"likes": post.likes,
}
try:
return await _run(_fetch_ytdlp)
except Exception:
pass
return await _run(_fetch_instaloader)
# ── post (image or video) ─────────────────────────────────────────────────────
async def download_post(
url: str,
fmt: str = "mp4",
start_time: Optional[str] = None,
end_time: Optional[str] = None,
) -> Tuple[str, str]:
uid = _uid()
def _try_ytdlp():
out_tmpl = str(DOWNLOAD_DIR / f"ig_post_{uid}.%(ext)s")
opts = {
**_BASE_YDL,
"format": "bestvideo+bestaudio/best",
"outtmpl": out_tmpl,
"merge_output_format": "mp4",
}
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url)
owner = _safe(info.get("uploader") or "ig")
ext = "mp4"
final = DOWNLOAD_DIR / f"ig_post_{uid}.{ext}"
if not final.exists():
candidates = list(DOWNLOAD_DIR.glob(f"ig_post_{uid}.*"))
if not candidates:
raise FileNotFoundError("yt-dlp produced no file")
final = candidates[0]
if (start_time or end_time) and final.suffix == ".mp4":
trimmed = DOWNLOAD_DIR / f"ig_post_{uid}_trim.mp4"
_trim_video(final, trimmed, start_time, end_time)
final.unlink(missing_ok=True)
final = trimmed
return str(final), f"{owner}_{uid}{final.suffix}"
def _try_instaloader():
tmp_dir = DOWNLOAD_DIR / f"ig_il_{uid}"
tmp_dir.mkdir(exist_ok=True)
try:
shortcode = _shortcode_from_url(url)
L = _get_loader()
post = instaloader.Post.from_shortcode(L.context, shortcode)
L.dirname_pattern = str(tmp_dir)
L.download_post(post, target=str(tmp_dir))
media = next(
(f for f in tmp_dir.iterdir() if f.suffix in (".mp4", ".jpg", ".jpeg", ".png")),
None,
)
if not media:
raise FileNotFoundError("instaloader produced no media file")
owner = _safe(post.owner_username)
dest = DOWNLOAD_DIR / f"ig_post_{uid}{media.suffix}"
if post.is_video and (start_time or end_time):
_trim_video(media, dest, start_time, end_time)
else:
shutil.copy2(media, dest)
return str(dest), f"{owner}_{uid}{media.suffix}"
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
# try yt-dlp first, fall back to instaloader
try:
return await _run(_try_ytdlp)
except Exception:
pass
return await _run(_try_instaloader)
# ── reel (yt-dlp only — instaloader 403s on reels) ───────────────────────────
async def download_reel(
url: str,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
) -> Tuple[str, str]:
uid = _uid()
out_tmpl = str(DOWNLOAD_DIR / f"ig_reel_{uid}.%(ext)s")
opts = {
**_BASE_YDL,
"format": "bestvideo+bestaudio/best",
"outtmpl": out_tmpl,
"merge_output_format": "mp4",
}
def _dl():
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url)
owner = _safe(info.get("uploader") or "ig")
final = DOWNLOAD_DIR / f"ig_reel_{uid}.mp4"
if not final.exists():
candidates = list(DOWNLOAD_DIR.glob(f"ig_reel_{uid}.*"))
if not candidates:
raise FileNotFoundError("yt-dlp produced no reel file")
final = candidates[0]
if start_time or end_time:
trimmed = DOWNLOAD_DIR / f"ig_reel_{uid}_trim.mp4"
_trim_video(final, trimmed, start_time, end_time)
final.unlink(missing_ok=True)
final = trimmed
return str(final), f"{owner}_reel_{uid}.mp4"
return await _run(_dl)
# ── profile picture ───────────────────────────────────────────────────────────
async def download_profile(username_or_url: str) -> Tuple[str, str]:
# accept either a username string or a full instagram URL
if "instagram.com" in username_or_url:
username = _username_from_url(username_or_url)
else:
username = username_or_url.lstrip("@").strip()
if not username:
raise ValueError("no username provided")
uid = _uid()
def _dl():
import httpx
L = _get_loader()
profile = instaloader.Profile.from_username(L.context, username)
pic_url = profile.profile_pic_url
path = DOWNLOAD_DIR / f"ig_pfp_{_safe(username)}_{uid}.jpg"
data = httpx.get(pic_url, follow_redirects=True, timeout=15).content
path.write_bytes(data)
return str(path), f"{_safe(username)}_profile.jpg"
return await _run(_dl)
# ── thumbnail ─────────────────────────────────────────────────────────────────
async def download_ig_thumbnail(url: str) -> Tuple[str, str]:
uid = _uid()
def _dl():
import httpx
# try yt-dlp for thumbnail URL
opts = {**_BASE_YDL, "skip_download": True}
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url, download=False)
thumb_url = info.get("thumbnail")
if not thumb_url:
raise ValueError("no thumbnail found for this post")
owner = _safe(info.get("uploader") or "ig")
path = DOWNLOAD_DIR / f"ig_thumb_{uid}.jpg"
data = httpx.get(thumb_url, follow_redirects=True, timeout=15).content
path.write_bytes(data)
return str(path), f"{owner}_thumbnail_{uid}.jpg"
return await _run(_dl)

View file

@ -0,0 +1,280 @@
"""
YouTube service — all yt-dlp operations.
"""
import asyncio
import uuid
import re
from pathlib import Path
from typing import Optional, Tuple
import yt_dlp
DOWNLOAD_DIR = Path("downloads")
DOWNLOAD_DIR.mkdir(exist_ok=True)
# shared yt-dlp options applied to every call
_BASE_OPTS = {
"quiet": True,
"no_warnings": True,
"noprogress": True,
}
def _run(func, *args, **kwargs):
loop = asyncio.get_event_loop()
return loop.run_in_executor(None, lambda: func(*args, **kwargs))
def _uid() -> str:
return uuid.uuid4().hex[:10]
def _safe(name: str) -> str:
return re.sub(r'[\\/*?:"<>|]', "_", name or "file")[:80]
def _fmt_duration(seconds: int) -> str:
if not seconds:
return "0:00"
h, rem = divmod(int(seconds), 3600)
m, s = divmod(rem, 60)
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
def _parse_time(t: str) -> float:
parts = [float(p) for p in t.strip().split(":")]
if len(parts) == 3:
return parts[0] * 3600 + parts[1] * 60 + parts[2]
if len(parts) == 2:
return parts[0] * 60 + parts[1]
return parts[0]
# ── info ──────────────────────────────────────────────────────────────────────
async def fetch_yt_info(url: str) -> dict:
def _fetch():
opts = {**_BASE_OPTS, "skip_download": True}
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url, download=False)
formats = info.get("formats") or []
resolutions = sorted(
{f.get("height") for f in formats if f.get("height")},
reverse=True,
)
fps_options = sorted(
{int(f.get("fps")) for f in formats if f.get("fps")},
reverse=True,
)
return {
"title": info.get("title"),
"duration": info.get("duration"),
"duration_str": _fmt_duration(info.get("duration", 0)),
"thumbnail": info.get("thumbnail"),
"uploader": info.get("uploader"),
"view_count": info.get("view_count"),
"upload_date": info.get("upload_date"),
"available_resolutions": resolutions,
"fps_options": fps_options,
"has_subtitles": bool(info.get("subtitles")),
"subtitle_langs": list((info.get("subtitles") or {}).keys()),
}
return await _run(_fetch)
# ── video ─────────────────────────────────────────────────────────────────────
async def download_video(
url: str,
resolution: str = "best",
fmt: str = "mp4",
start_time: Optional[str] = None,
end_time: Optional[str] = None,
) -> Tuple[str, str]:
uid = _uid()
out_tmpl = str(DOWNLOAD_DIR / f"yt_video_{uid}.%(ext)s")
# build format selector
if resolution == "best":
fmt_sel = f"bestvideo[ext={fmt}]+bestaudio/bestvideo+bestaudio/best"
else:
fmt_sel = (
f"bestvideo[height<={resolution}][ext={fmt}]+bestaudio/"
f"bestvideo[height<={resolution}]+bestaudio/best"
)
opts = {
**_BASE_OPTS,
"format": fmt_sel,
"outtmpl": out_tmpl,
"merge_output_format": fmt,
}
if start_time or end_time:
start_s = _parse_time(start_time) if start_time else 0
end_s = _parse_time(end_time) if end_time else None
def _range_fn(info_dict, ydl):
end = end_s if end_s is not None else info_dict.get("duration", 86400)
return [{"start_time": start_s, "end_time": end}]
opts["download_ranges"] = _range_fn
opts["force_keyframes_at_cuts"] = True
def _dl():
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url)
title = _safe(info.get("title", "video"))
ext = info.get("ext", fmt)
# find the actual output file
final = DOWNLOAD_DIR / f"yt_video_{uid}.{ext}"
if not final.exists():
# yt-dlp may have merged to a different ext
candidates = list(DOWNLOAD_DIR.glob(f"yt_video_{uid}.*"))
if not candidates:
raise FileNotFoundError("output file not found after download")
final = candidates[0]
return str(final), f"{title}.{final.suffix.lstrip('.')}"
return await _run(_dl)
# ── audio ─────────────────────────────────────────────────────────────────────
async def download_audio(
url: str,
quality: str = "192",
fmt: str = "mp3",
) -> Tuple[str, str]:
uid = _uid()
out_tmpl = str(DOWNLOAD_DIR / f"yt_audio_{uid}.%(ext)s")
codec_map = {"mp3": "mp3", "opus": "opus", "flac": "flac", "m4a": "m4a"}
codec = codec_map.get(fmt, "mp3")
opts = {
**_BASE_OPTS,
"format": "bestaudio/best",
"outtmpl": out_tmpl,
"postprocessors": [
{
"key": "FFmpegExtractAudio",
"preferredcodec": codec,
"preferredquality": quality,
},
{"key": "FFmpegMetadata", "add_metadata": True},
],
}
def _dl():
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url)
title = _safe(info.get("title", "audio"))
final = DOWNLOAD_DIR / f"yt_audio_{uid}.{fmt}"
if not final.exists():
candidates = list(DOWNLOAD_DIR.glob(f"yt_audio_{uid}.*"))
if not candidates:
raise FileNotFoundError("audio file not found after download")
final = candidates[0]
return str(final), f"{title}.{final.suffix.lstrip('.')}"
return await _run(_dl)
# ── thumbnail ─────────────────────────────────────────────────────────────────
async def download_thumbnail(url: str) -> Tuple[str, str]:
uid = _uid()
out_tmpl = str(DOWNLOAD_DIR / f"yt_thumb_{uid}.%(ext)s")
opts = {
**_BASE_OPTS,
"skip_download": True,
"writethumbnail": True,
"outtmpl": out_tmpl,
}
def _dl():
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url, download=True)
title = _safe(info.get("title", "thumbnail"))
for ext in ("jpg", "webp", "png", "jpeg"):
candidate = DOWNLOAD_DIR / f"yt_thumb_{uid}.{ext}"
if candidate.exists():
return str(candidate), f"{title}_thumbnail.{ext}"
raise FileNotFoundError("thumbnail not found after download")
return await _run(_dl)
# ── subtitles ─────────────────────────────────────────────────────────────────
async def download_subtitles(url: str, lang: str = "en") -> Tuple[str, str]:
uid = _uid()
out_tmpl = str(DOWNLOAD_DIR / f"yt_subs_{uid}.%(ext)s")
opts = {
**_BASE_OPTS,
"skip_download": True,
"writesubtitles": True,
"writeautomaticsub": True,
"subtitleslangs": [lang],
"subtitlesformat": "srt",
"outtmpl": out_tmpl,
}
def _dl():
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url, download=True)
title = _safe(info.get("title", "subtitles"))
for ext in ("srt", "vtt", "ass"):
# yt-dlp names it: yt_subs_{uid}.{lang}.{ext}
candidate = DOWNLOAD_DIR / f"yt_subs_{uid}.{lang}.{ext}"
if candidate.exists():
return str(candidate), f"{title}.{lang}.{ext}"
# fallback: any subtitle file matching uid
candidates = list(DOWNLOAD_DIR.glob(f"yt_subs_{uid}.*"))
if candidates:
return str(candidates[0]), candidates[0].name
raise FileNotFoundError(f"no subtitles found for lang={lang} — video may not have them")
return await _run(_dl)
# ── channel art ───────────────────────────────────────────────────────────────
async def download_channel_art(channel_url: str) -> list:
def _dl():
import httpx
opts = {**_BASE_OPTS, "skip_download": True, "extract_flat": True}
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(channel_url, download=False)
results = []
uid = _uid()
# avatar — last thumbnail is usually highest res
thumbnails = info.get("thumbnails") or []
avatar_url = thumbnails[-1].get("url") if thumbnails else info.get("thumbnail")
if avatar_url:
path = DOWNLOAD_DIR / f"yt_ch_{uid}_avatar.jpg"
data = httpx.get(avatar_url, follow_redirects=True, timeout=15).content
path.write_bytes(data)
results.append({"type": "avatar", "url": f"/files/{path.name}"})
# banner
banner_url = info.get("header_image") or info.get("banner")
if banner_url:
path = DOWNLOAD_DIR / f"yt_ch_{uid}_banner.jpg"
data = httpx.get(banner_url, follow_redirects=True, timeout=15).content
path.write_bytes(data)
results.append({"type": "banner", "url": f"/files/{path.name}"})
if not results:
raise ValueError("no channel art found — try passing the full channel URL")
return results
return await _run(_dl)

553
frontend/index.html Normal file
View file

@ -0,0 +1,553 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>just download stuff lol</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0d0d0f;--bg2:#141416;--bg3:#1a1a1e;--bg4:#202026;
--border:#2a2a32;--border2:#35353f;
--text:#f0f0f5;--text2:#a0a0b0;--text3:#606070;
--accent:#7c6ff7;--accent2:#9d98f5;--accent-bg:rgba(124,111,247,0.12);
--yt:#ff4444;--sp:#1db954;--ig:#e1306c;
--radius:12px;--radius-sm:8px;
}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;min-height:100vh;padding:0}
.wrap{max-width:780px;margin:0 auto;padding:2rem 1.25rem 4rem}
.logo{display:flex;align-items:center;gap:10px;margin-bottom:2.5rem}
.logo-icon{width:36px;height:36px;background:var(--accent);border-radius:10px;display:flex;align-items:center;justify-content:center}
.logo-icon svg{width:18px;height:18px;fill:none;stroke:white;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.logo-name{font-size:17px;font-weight:500}
.logo-sub{font-size:11px;color:var(--text3);margin-top:1px}
.api-badge{display:inline-flex;align-items:center;gap:5px;background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:3px 10px;font-size:11px;color:var(--text3);margin-left:8px;vertical-align:middle}
.api-dot{width:6px;height:6px;border-radius:50%;background:#1db954}
.input-zone{background:var(--bg2);border:1.5px solid var(--border);border-radius:16px;padding:1.25rem;margin-bottom:1.5rem;transition:border-color 0.2s}
.input-zone.drag-over{border-color:var(--accent)}
.input-row{display:flex;gap:10px;align-items:center}
.url-input{flex:1;background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius-sm);padding:0.65rem 1rem;color:var(--text);font-size:14px;outline:none;transition:border-color 0.2s}
.url-input::placeholder{color:var(--text3)}
.url-input:focus{border-color:var(--accent)}
.btn-detect{background:var(--accent);color:white;border:none;border-radius:var(--radius-sm);padding:0.65rem 1.25rem;font-size:13px;font-weight:500;cursor:pointer;transition:opacity 0.15s,transform 0.1s}
.btn-detect:hover{opacity:0.88}
.btn-detect:active{transform:scale(0.97)}
.btn-detect:disabled{opacity:0.5;cursor:not-allowed}
.drag-hint{font-size:12px;color:var(--text3);margin-top:0.65rem;text-align:center}
.platform-tabs{display:flex;gap:8px;margin-bottom:1.25rem}
.tab{flex:1;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:0.6rem;font-size:13px;color:var(--text2);cursor:pointer;transition:all 0.15s;display:flex;align-items:center;justify-content:center;gap:6px;font-weight:500}
.tab:hover{border-color:var(--border2);color:var(--text)}
.tab.active-yt{background:rgba(255,68,68,0.1);border-color:var(--yt);color:var(--yt)}
.tab.active-ig{background:rgba(225,48,108,0.1);border-color:var(--ig);color:var(--ig)}
.tab-dot{width:8px;height:8px;border-radius:50%}
.tab-dot-yt{background:var(--yt)}.tab-dot-ig{background:var(--ig)}
.preview-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:1.25rem;margin-bottom:1.25rem;display:none}
.preview-card.show{display:flex;gap:1rem}
.preview-thumb{width:120px;height:68px;border-radius:var(--radius-sm);background:var(--bg4);overflow:hidden;flex-shrink:0;display:flex;align-items:center;justify-content:center}
.preview-thumb img{width:100%;height:100%;object-fit:cover}
.preview-info{flex:1;min-width:0}
.preview-title{font-size:14px;font-weight:500;line-height:1.4;margin-bottom:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.preview-meta{display:flex;gap:8px;flex-wrap:wrap}
.meta-pill{background:var(--bg4);border-radius:20px;padding:3px 10px;font-size:11px;color:var(--text2)}
.preview-quality{font-size:11px;color:var(--text3);margin-top:8px}
.skeleton{background:linear-gradient(90deg,var(--bg3) 25%,var(--bg4) 50%,var(--bg3) 75%);background-size:200%;animation:skel 1.4s infinite;border-radius:6px}
@keyframes skel{0%{background-position:200%}100%{background-position:-200%}}
.section-label{font-size:11px;color:var(--text3);text-transform:uppercase;letter-spacing:0.08em;margin-bottom:0.75rem;font-weight:500}
.options-grid{display:grid;gap:10px;margin-bottom:1.25rem}
.options-grid.cols3{grid-template-columns:1fr 1fr 1fr}
.opt-card{background:var(--bg2);border:1.5px solid var(--border);border-radius:var(--radius);padding:1rem;cursor:pointer;transition:all 0.15s;position:relative}
.opt-card:hover{border-color:var(--border2);background:var(--bg3)}
.opt-card.selected-yt{border-color:var(--yt);background:rgba(255,68,68,0.08)}
.opt-card.selected-ig{border-color:var(--ig);background:rgba(225,48,108,0.08)}
.opt-icon{font-size:20px;margin-bottom:8px;display:block}
.opt-title{font-size:13px;font-weight:500;margin-bottom:3px}
.opt-desc{font-size:11px;color:var(--text3)}
.opt-check{position:absolute;top:10px;right:10px;width:18px;height:18px;border-radius:50%;background:var(--accent);display:none;align-items:center;justify-content:center}
.opt-card.selected-yt .opt-check,.opt-card.selected-ig .opt-check{display:flex}
.opt-check svg{width:10px;height:10px;fill:none;stroke:white;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round}
.sub-panel{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:1.25rem;margin-bottom:1.25rem}
.form-row{display:flex;align-items:center;gap:12px;margin-bottom:0.75rem}
.form-row:last-child{margin-bottom:0}
.form-label{font-size:13px;color:var(--text2);min-width:90px;flex-shrink:0}
.form-select{background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius-sm);padding:0.45rem 0.75rem;color:var(--text);font-size:13px;outline:none;cursor:pointer;flex:1}
.form-toggle{display:flex}
.toggle-btn{background:var(--bg3);border:1px solid var(--border);padding:0.4rem 0.85rem;font-size:12px;color:var(--text2);cursor:pointer;transition:all 0.15s;font-weight:500}
.toggle-btn:first-child{border-radius:var(--radius-sm) 0 0 var(--radius-sm)}
.toggle-btn:last-child{border-radius:0 var(--radius-sm) var(--radius-sm) 0;border-left:none}
.toggle-btn:not(:first-child):not(:last-child){border-left:none}
.toggle-btn.on{background:var(--accent-bg);border-color:var(--accent);color:var(--accent2)}
.time-row{display:flex;gap:8px;align-items:center}
.time-input{background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius-sm);padding:0.45rem 0.6rem;color:var(--text);font-size:13px;width:90px;outline:none;text-align:center}
.time-input:focus{border-color:var(--accent)}
.time-sep{color:var(--text3);font-size:12px}
.progress-wrap{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:1.25rem;margin-bottom:1.25rem;display:none}
.progress-wrap.show{display:block}
.progress-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
.progress-label{font-size:13px;font-weight:500}
.progress-pct{font-size:13px;color:var(--accent2)}
.progress-bar-bg{background:var(--bg4);border-radius:20px;height:6px;overflow:hidden;margin-bottom:8px}
.progress-bar{height:6px;border-radius:20px;background:var(--accent);width:0%;transition:width 0.4s}
.progress-status{font-size:11px;color:var(--text3)}
.action-row{display:flex;gap:10px;margin-bottom:1.5rem}
.btn-main{flex:1;background:var(--accent);border:none;border-radius:var(--radius-sm);padding:0.8rem;font-size:14px;font-weight:500;color:white;cursor:pointer;transition:opacity 0.15s,transform 0.1s}
.btn-main:hover{opacity:0.88}
.btn-main:active{transform:scale(0.98)}
.btn-main:disabled{opacity:0.5;cursor:not-allowed}
.btn-secondary{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:0.8rem 1.25rem;font-size:13px;color:var(--text2);cursor:pointer;transition:all 0.15s}
.btn-secondary:hover{border-color:var(--border2);color:var(--text)}
.btn-secondary:disabled{opacity:0.5;cursor:not-allowed}
.history-section{margin-top:2rem}
.history-item{display:flex;align-items:center;gap:12px;padding:0.75rem 0;border-bottom:1px solid var(--border)}
.history-item:last-child{border-bottom:none}
.history-thumb{width:44px;height:28px;border-radius:5px;background:var(--bg4);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500}
.history-info{flex:1;min-width:0}
.history-title{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.history-meta{font-size:11px;color:var(--text3);margin-top:2px}
.history-status{font-size:11px;color:var(--sp)}
.toast{position:fixed;bottom:24px;right:24px;background:var(--bg3);border:1px solid var(--border2);border-radius:var(--radius-sm);padding:0.75rem 1.25rem;font-size:13px;display:flex;align-items:center;gap:8px;transform:translateY(80px);opacity:0;transition:all 0.25s;z-index:100;max-width:320px}
.toast.show{transform:translateY(0);opacity:1}
.toast-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.panel{display:none}.panel.active{display:block}
</style>
</head>
<body>
<div class="wrap">
<div class="logo">
<div class="logo-icon">
<svg viewBox="0 0 24 24"><path d="M12 3v13M5 13l7 7 7-7"/><path d="M3 20h18"/></svg>
</div>
<div>
<div class="logo-name">just download stuff lol <span class="api-badge"><span class="api-dot"></span>192.168.0.107:8000</span></div>
<div class="logo-sub">no cap, it just works™</div>
</div>
</div>
<div class="input-zone" id="inputZone">
<div class="input-row">
<input class="url-input" id="urlInput" type="text" placeholder="paste literally any yt / instagram link..." autocomplete="off"/>
<button class="btn-detect" id="fetchBtn" onclick="handleUrl()">go</button>
</div>
<div class="drag-hint">or drag & drop a url here</div>
</div>
<div class="platform-tabs">
<div class="tab" id="tab-yt" onclick="switchPlatform('yt')"><span class="tab-dot tab-dot-yt"></span>YouTube</div>
<div class="tab" id="tab-ig" onclick="switchPlatform('ig')"><span class="tab-dot tab-dot-ig"></span>Instagram</div>
</div>
<div id="previewCard" class="preview-card">
<div class="preview-thumb" id="previewThumb">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color:var(--text3)"><rect x="2" y="2" width="20" height="20" rx="4"/><path d="M10 8l6 4-6 4V8z"/></svg>
</div>
<div class="preview-info">
<div class="preview-title" id="previewTitle">—</div>
<div class="preview-meta" id="previewMeta"></div>
<div class="preview-quality" id="previewQuality"></div>
</div>
</div>
<div id="panel-yt" class="panel">
<div class="section-label">what do you want</div>
<div class="options-grid cols3" style="margin-bottom:1.25rem">
<div class="opt-card selected-yt" id="yt-video" onclick="selectOpt('yt','yt-video')">
<span class="opt-icon">🎬</span><div class="opt-title">Video</div><div class="opt-desc">MP4 with audio</div>
<div class="opt-check"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,4"/></svg></div>
</div>
<div class="opt-card" id="yt-audio" onclick="selectOpt('yt','yt-audio')">
<span class="opt-icon">🎵</span><div class="opt-title">Audio only</div><div class="opt-desc">MP3 extraction</div>
<div class="opt-check"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,4"/></svg></div>
</div>
<div class="opt-card" id="yt-thumb" onclick="selectOpt('yt','yt-thumb')">
<span class="opt-icon">🖼️</span><div class="opt-title">Thumbnail</div><div class="opt-desc">JPG/WebP</div>
<div class="opt-check"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,4"/></svg></div>
</div>
<div class="opt-card" id="yt-subs" onclick="selectOpt('yt','yt-subs')">
<span class="opt-icon">💬</span><div class="opt-title">Subtitles</div><div class="opt-desc">SRT / VTT</div>
<div class="opt-check"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,4"/></svg></div>
</div>
<div class="opt-card" id="yt-channel" onclick="selectOpt('yt','yt-channel')">
<span class="opt-icon">👤</span><div class="opt-title">Channel art</div><div class="opt-desc">Avatar + banner</div>
<div class="opt-check"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,4"/></svg></div>
</div>
</div>
<div class="sub-panel" id="yt-video-opts">
<div class="section-label">settings</div>
<div class="form-row">
<span class="form-label">Resolution</span>
<select class="form-select" id="yt-res">
<option value="best">Auto (best)</option>
<option value="2160">4K (2160p)</option>
<option value="1440">1440p</option>
<option value="1080">1080p</option>
<option value="720">720p</option>
<option value="480">480p</option>
<option value="360">360p</option>
<option value="240">240p</option>
<option value="144">144p</option>
</select>
</div>
<div class="form-row">
<span class="form-label">Format</span>
<div class="form-toggle" id="yt-fmt-toggle">
<div class="toggle-btn on" onclick="toggler(this,'yt-fmt-toggle')">MP4</div>
<div class="toggle-btn" onclick="toggler(this,'yt-fmt-toggle')">WebM</div>
<div class="toggle-btn" onclick="toggler(this,'yt-fmt-toggle')">MKV</div>
</div>
</div>
<div class="form-row" style="align-items:flex-start">
<span class="form-label" style="padding-top:4px">Trim</span>
<div>
<div class="form-toggle" style="margin-bottom:10px">
<div class="toggle-btn on" id="trim-full" onclick="setTrim('full')">Full</div>
<div class="toggle-btn" id="trim-custom" onclick="setTrim('custom')">Custom</div>
</div>
<div class="time-row" id="trim-inputs" style="display:none">
<input class="time-input" id="startTime" placeholder="00:00"/>
<span class="time-sep">→</span>
<input class="time-input" id="endTime" placeholder="10:24"/>
</div>
</div>
</div>
</div>
</div>
<div id="panel-ig" class="panel">
<div class="section-label">what do you want</div>
<div class="options-grid cols3" style="margin-bottom:1.25rem">
<div class="opt-card selected-ig" id="ig-post" onclick="selectOpt('ig','ig-post')">
<span class="opt-icon">📸</span><div class="opt-title">Post</div><div class="opt-desc">Image or video</div>
<div class="opt-check"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,4"/></svg></div>
</div>
<div class="opt-card" id="ig-reel" onclick="selectOpt('ig','ig-reel')">
<span class="opt-icon">🎞️</span><div class="opt-title">Reel</div><div class="opt-desc">Short video</div>
<div class="opt-check"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,4"/></svg></div>
</div>
<div class="opt-card" id="ig-profile" onclick="selectOpt('ig','ig-profile')">
<span class="opt-icon">👤</span><div class="opt-title">Profile</div><div class="opt-desc">Avatar</div>
<div class="opt-check"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,4"/></svg></div>
</div>
<div class="opt-card" id="ig-thumb" onclick="selectOpt('ig','ig-thumb')">
<span class="opt-icon">🖼️</span><div class="opt-title">Thumbnail</div><div class="opt-desc">Cover frame</div>
<div class="opt-check"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,4"/></svg></div>
</div>
</div>
<div class="sub-panel">
<div class="section-label">settings</div>
<div class="form-row">
<span class="form-label">Format</span>
<div class="form-toggle" id="ig-fmt-toggle">
<div class="toggle-btn on" onclick="toggler(this,'ig-fmt-toggle')">MP4</div>
<div class="toggle-btn" onclick="toggler(this,'ig-fmt-toggle')">JPG</div>
</div>
</div>
</div>
</div>
<div class="progress-wrap" id="progressWrap">
<div class="progress-header">
<span class="progress-label" id="progressLabel">downloading...</span>
<span class="progress-pct" id="progressPct">0%</span>
</div>
<div class="progress-bar-bg"><div class="progress-bar" id="progressBar"></div></div>
<div class="progress-status" id="progressStatus">connecting...</div>
</div>
<div class="action-row">
<button class="btn-main" id="dlBtn" onclick="startDownload()">⬇ download it</button>
<button class="btn-secondary" id="quickBtn" onclick="quickDownload()">quick (best quality)</button>
</div>
<div class="history-section" id="historySection" style="display:none">
<div class="section-label">stuff you grabbed</div>
<div id="historyList"></div>
</div>
</div>
<div class="toast" id="toast"><div class="toast-dot" id="toastDot"></div><span id="toastMsg"></span></div>
<script>
const API = "http://192.168.0.107:8000";
let activePlatform = null;
let activeOpts = { yt: 'yt-video', ig: 'ig-post' };
let downloading = false;
let downloadHistory = [];
function detectPlatform(url) {
if (/youtube\.com|youtu\.be/.test(url)) return 'yt';
if (/instagram\.com|instagr\.am/.test(url)) return 'ig';
return null;
}
async function handleUrl() {
const url = document.getElementById('urlInput').value.trim();
if (!url) { showToast('paste something first lol', '#e24b4a'); return; }
const platform = detectPlatform(url);
if (!platform) { showToast('only yt and instagram for now', '#e24b4a'); return; }
switchPlatform(platform);
showSkeleton();
const btn = document.getElementById('fetchBtn');
btn.disabled = true; btn.textContent = '...';
try {
const res = await fetch(`${API}/api/info/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
renderPreview(data, platform);
showToast('fetched ✓', '#1db954');
} catch (e) {
document.getElementById('previewCard').classList.remove('show');
showToast('fetch failed: ' + e.message.slice(0, 60), '#e24b4a');
} finally {
btn.disabled = false; btn.textContent = 'go';
}
}
function showSkeleton() {
document.getElementById('previewCard').classList.add('show');
document.getElementById('previewTitle').innerHTML = '<div class="skeleton" style="height:14px;width:80%"></div>';
document.getElementById('previewMeta').innerHTML = '<div class="skeleton" style="height:20px;width:70px;border-radius:20px"></div><div class="skeleton" style="height:20px;width:60px;border-radius:20px"></div>';
document.getElementById('previewQuality').innerHTML = '';
document.getElementById('previewThumb').innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color:var(--text3)"><rect x="2" y="2" width="20" height="20" rx="4"/><path d="M10 8l6 4-6 4V8z"/></svg>';
}
function renderPreview(data, platform) {
document.getElementById('previewCard').classList.add('show');
const thumbEl = document.getElementById('previewThumb');
if (data.thumbnail) thumbEl.innerHTML = `<img src="${data.thumbnail}" alt="" onerror="this.style.display='none'"/>`;
document.getElementById('previewTitle').textContent = data.title || data.owner || '—';
const color = platform === 'yt' ? 'var(--yt)' : 'var(--ig)';
const pills = [];
if (data.duration_str) pills.push(`<span class="meta-pill">${data.duration_str}</span>`);
if (data.view_count) pills.push(`<span class="meta-pill">${fmtNum(data.view_count)} views</span>`);
if (data.likes) pills.push(`<span class="meta-pill">${fmtNum(data.likes)} likes</span>`);
if (data.uploader) pills.push(`<span class="meta-pill" style="color:${color}">${data.uploader}</span>`);
if (data.owner) pills.push(`<span class="meta-pill" style="color:${color}">@${data.owner}</span>`);
document.getElementById('previewMeta').innerHTML = pills.join('');
const res = data.available_resolutions || [];
document.getElementById('previewQuality').textContent = res.length ? `up to ${res[0]}p available` : '';
}
function fmtNum(n) {
if (n >= 1e6) return (n/1e6).toFixed(1)+'M';
if (n >= 1e3) return (n/1e3).toFixed(1)+'K';
return String(n);
}
function switchPlatform(p) {
activePlatform = p;
['yt','ig'].forEach(id => {
document.getElementById('tab-'+id).className = 'tab'+(id===p?' active-'+p:'');
document.getElementById('panel-'+id).className = 'panel'+(id===p?' active':'');
});
}
function selectOpt(platform, id) {
document.querySelectorAll('#panel-'+platform+' .opt-card').forEach(c => c.className = 'opt-card');
document.getElementById(id).className = 'opt-card selected-'+platform;
activeOpts[platform] = id;
if (platform === 'yt') document.getElementById('yt-video-opts').style.display = id==='yt-video' ? 'block' : 'none';
}
function toggler(el, groupId) {
document.getElementById(groupId).querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('on'));
el.classList.add('on');
}
function setTrim(mode) {
document.getElementById('trim-full').classList.toggle('on', mode==='full');
document.getElementById('trim-custom').classList.toggle('on', mode==='custom');
document.getElementById('trim-inputs').style.display = mode==='custom' ? 'flex' : 'none';
}
function startDownload() {
if (downloading) return;
if (!activePlatform) { showToast('pick a platform first', '#e24b4a'); return; }
runDownload(false);
}
function quickDownload() {
if (downloading) return;
if (!activePlatform) { showToast('paste a url first', '#e24b4a'); return; }
runDownload(true);
}
async function runDownload(quick) {
const url = document.getElementById('urlInput').value.trim();
if (!url) { showToast('paste a url first lol', '#e24b4a'); return; }
downloading = true;
setButtons(false);
const wrap = document.getElementById('progressWrap');
const bar = document.getElementById('progressBar');
const pct = document.getElementById('progressPct');
const stat = document.getElementById('progressStatus');
const lbl = document.getElementById('progressLabel');
wrap.className = 'progress-wrap show';
bar.style.width = '0%'; pct.textContent = '0%';
lbl.textContent = 'sending request...';
stat.textContent = 'connecting to tablet...';
let fakeP = 0;
const fakeInterval = setInterval(() => {
if (fakeP < 85) { fakeP += Math.random()*4+1; bar.style.width=fakeP+'%'; pct.textContent=Math.round(fakeP)+'%'; }
}, 400);
const statuses = ['fetching from yt-dlp...','downloading stream...','muxing audio+video...','almost done...'];
let si = 0;
const statInterval = setInterval(() => { if (si < statuses.length) stat.textContent = statuses[si++]; }, 3000);
try {
const endpoint = getEndpoint(quick);
const body = buildBody(url, quick);
lbl.textContent = 'downloading — ' + (activePlatform==='yt'?'YouTube':'Instagram');
const res = await fetch(`${API}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
clearInterval(fakeInterval); clearInterval(statInterval);
if (!res.ok) throw new Error(await res.text());
bar.style.width = '95%'; pct.textContent = '95%'; stat.textContent = 'saving file...';
const blob = await res.blob();
const disposition = res.headers.get('content-disposition') || '';
const filename = disposition.split('filename=')[1]?.replace(/"/g,'') || guessFilename();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
bar.style.width = '100%'; pct.textContent = '100%'; stat.textContent = 'done lol';
showToast('got it 👌', '#1db954');
addHistory(filename);
setTimeout(() => { wrap.className='progress-wrap'; bar.style.width='0%'; }, 2000);
} catch(e) {
clearInterval(fakeInterval); clearInterval(statInterval);
wrap.className = 'progress-wrap'; bar.style.width = '0%';
showToast('error: ' + e.message.slice(0,80), '#e24b4a');
} finally {
downloading = false; setButtons(true);
}
}
function getEndpoint(quick) {
if (activePlatform === 'yt') {
if (quick) return '/api/youtube/video';
const opt = activeOpts.yt;
if (opt==='yt-video') return '/api/youtube/video';
if (opt==='yt-audio') return '/api/youtube/audio';
if (opt==='yt-thumb') return '/api/youtube/thumbnail';
if (opt==='yt-subs') return '/api/youtube/subtitles';
if (opt==='yt-channel') return '/api/youtube/channel-art';
}
if (activePlatform === 'ig') {
const opt = activeOpts.ig;
if (opt==='ig-post') return '/api/instagram/post';
if (opt==='ig-reel') return '/api/instagram/reel';
if (opt==='ig-profile') return '/api/instagram/profile';
if (opt==='ig-thumb') return '/api/instagram/thumbnail';
}
return '/api/youtube/video';
}
function buildBody(url, quick) {
if (activePlatform === 'yt') {
if (quick) return { url, resolution: 'best', format: 'mp4' };
const opt = activeOpts.yt;
if (opt==='yt-video') {
const res = document.getElementById('yt-res').value;
const fmt = document.querySelector('#yt-fmt-toggle .on')?.textContent.toLowerCase() || 'mp4';
const start = document.getElementById('startTime').value || null;
const end = document.getElementById('endTime').value || null;
return { url, resolution: res, format: fmt, start_time: start, end_time: end };
}
if (opt==='yt-audio') return { url, quality: '192', format: 'mp3' };
if (opt==='yt-subs') return { url, lang: 'en' };
if (opt==='yt-channel') return { channel_url: url };
return { url };
}
if (activePlatform === 'ig') {
const opt = activeOpts.ig;
const fmt = document.querySelector('#ig-fmt-toggle .on')?.textContent.toLowerCase() || 'mp4';
if (opt==='ig-post' || opt==='ig-reel') return { url, format: fmt };
if (opt==='ig-profile') {
const m = url.match(/instagram\.com\/([^/?]+)/);
return { username: m ? m[1] : url };
}
return { url };
}
return { url };
}
function guessFilename() {
const opt = activeOpts[activePlatform] || '';
if (opt.includes('audio')) return 'audio.mp3';
if (opt.includes('thumb')) return 'thumbnail.jpg';
if (opt.includes('subs')) return 'subtitles.srt';
return activePlatform==='ig' ? 'post.mp4' : 'video.mp4';
}
function setButtons(enabled) {
['dlBtn','quickBtn','fetchBtn'].forEach(id => document.getElementById(id).disabled = !enabled);
}
function addHistory(filename) {
downloadHistory.unshift({ title: filename, platform: activePlatform, time: new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}) });
renderHistory();
}
function renderHistory() {
const sec = document.getElementById('historySection');
const list = document.getElementById('historyList');
sec.style.display = downloadHistory.length ? 'block' : 'none';
list.innerHTML = downloadHistory.slice(0,6).map(item=>`
<div class="history-item">
<div class="history-thumb" style="color:var(--${item.platform==='yt'?'yt':'ig'})">${item.platform.toUpperCase()}</div>
<div class="history-info"><div class="history-title">${item.title}</div><div class="history-meta">${item.time}</div></div>
<div class="history-status">✓ got it</div>
</div>`).join('');
}
let toastTimeout;
function showToast(msg, color) {
clearTimeout(toastTimeout);
const t = document.getElementById('toast');
document.getElementById('toastMsg').textContent = msg;
document.getElementById('toastDot').style.background = color;
t.classList.add('show');
toastTimeout = setTimeout(() => t.classList.remove('show'), 3000);
}
document.getElementById('urlInput').addEventListener('paste', () => setTimeout(handleUrl, 60));
document.getElementById('urlInput').addEventListener('keydown', e => { if(e.key==='Enter') handleUrl(); });
const zone = document.getElementById('inputZone');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); });
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
zone.addEventListener('drop', e => {
e.preventDefault(); zone.classList.remove('drag-over');
const url = e.dataTransfer.getData('text');
if (url) { document.getElementById('urlInput').value = url; handleUrl(); }
});
switchPlatform('yt');
document.getElementById('yt-video-opts').style.display = 'block';
</script>
</body>
</html>