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:
parent
d11962052a
commit
749c25b620
8 changed files with 1505 additions and 0 deletions
69
backend/main.py
Normal file
69
backend/main.py
Normal 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
8
backend/requirements.txt
Normal 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
52
backend/routers/info.py
Normal 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}
|
||||
94
backend/routers/instagram.py
Normal file
94
backend/routers/instagram.py
Normal 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
114
backend/routers/youtube.py
Normal 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}
|
||||
335
backend/services/instagram_service.py
Normal file
335
backend/services/instagram_service.py
Normal 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)
|
||||
280
backend/services/youtube_service.py
Normal file
280
backend/services/youtube_service.py
Normal 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
553
frontend/index.html
Normal 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>
|
||||
Loading…
Add table
Reference in a new issue