downloading avater should return a .jpg file
This commit is contained in:
parent
76732934f7
commit
82716eb755
2 changed files with 192 additions and 194 deletions
|
|
@ -1,21 +1,10 @@
|
|||
"""
|
||||
Instagram service.
|
||||
|
||||
Strategy:
|
||||
- Post/reel info: yt-dlp first, instaloader fallback
|
||||
- Post download: yt-dlp first, instaloader fallback
|
||||
- Reel download: yt-dlp only (instaloader 403s on reels)
|
||||
- Profile picture: yt-dlp (avoids instaloader rate limits on Profile.from_username)
|
||||
- Thumbnail: yt-dlp
|
||||
|
||||
Session setup (run once on tablet, needed for instaloader fallback):
|
||||
python3 -c "
|
||||
import instaloader
|
||||
L = instaloader.Instaloader()
|
||||
L.interactive_login('your_username')
|
||||
L.save_session_to_file('ig_session')
|
||||
print('saved')
|
||||
"
|
||||
Profile picture: yt-dlp only (instaloader Profile.from_username is broken/rate-limited)
|
||||
Posts/images: yt-dlp first, instaloader fallback
|
||||
Reels: yt-dlp only
|
||||
Thumbnails: yt-dlp
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
|
@ -45,92 +34,71 @@ _BASE_YDL = {
|
|||
}
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _run(func, *args, **kwargs):
|
||||
loop = asyncio.get_event_loop()
|
||||
return loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
||||
|
||||
|
||||
def _uid() -> str:
|
||||
def _uid():
|
||||
return uuid.uuid4().hex[:10]
|
||||
|
||||
|
||||
def _safe(name: str) -> str:
|
||||
def _safe(name):
|
||||
return re.sub(r'[\\/*?:"<>|]', "_", name or "file")[:80]
|
||||
|
||||
|
||||
def _fmt_s(s) -> str:
|
||||
if not s:
|
||||
return "0:00"
|
||||
def _fmt_s(s):
|
||||
if not s: return "0:00"
|
||||
m, sec = divmod(int(s), 60)
|
||||
return f"{m}:{sec:02d}"
|
||||
|
||||
|
||||
def _download_image_url(url: str, dest: Path) -> Path:
|
||||
def _download_image_url(url, dest):
|
||||
import httpx
|
||||
resp = httpx.get(url, follow_redirects=True, timeout=20)
|
||||
resp.raise_for_status()
|
||||
dest.write_bytes(resp.content)
|
||||
return dest
|
||||
|
||||
|
||||
def _trim_video(src: Path, dest: Path, start: Optional[str], end: Optional[str]) -> Path:
|
||||
def _trim_video(src, dest, start, end):
|
||||
import subprocess
|
||||
cmd = ["ffmpeg", "-y"]
|
||||
if start:
|
||||
cmd += ["-ss", start]
|
||||
if start: cmd += ["-ss", start]
|
||||
cmd += ["-i", str(src)]
|
||||
if end:
|
||||
cmd += ["-to", end]
|
||||
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]}")
|
||||
r = subprocess.run(cmd, capture_output=True, timeout=120)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg failed: {r.stderr.decode()[:200]}")
|
||||
return dest
|
||||
|
||||
|
||||
def _first_media(directory: Path) -> Optional[Path]:
|
||||
def _first_media(directory):
|
||||
return next(
|
||||
(f for f in sorted(directory.iterdir())
|
||||
if f.suffix in (".mp4", ".jpg", ".jpeg", ".png")),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _get_loader() -> instaloader.Instaloader:
|
||||
def _get_loader():
|
||||
global _loader
|
||||
if _loader is not None:
|
||||
return _loader
|
||||
|
||||
L = instaloader.Instaloader(
|
||||
download_pictures=True,
|
||||
download_videos=True,
|
||||
download_video_thumbnails=False,
|
||||
save_metadata=False,
|
||||
post_metadata_txt_pattern="",
|
||||
quiet=True,
|
||||
download_pictures=True, download_videos=True,
|
||||
download_video_thumbnails=False, save_metadata=False,
|
||||
post_metadata_txt_pattern="", quiet=True,
|
||||
)
|
||||
|
||||
if SESSION_FILE.exists():
|
||||
try:
|
||||
L.load_session_from_file(IG_USERNAME or "user", str(SESSION_FILE))
|
||||
print("[ig] session loaded from file")
|
||||
print("[ig] session loaded")
|
||||
_loader = L
|
||||
return _loader
|
||||
except Exception as e:
|
||||
print(f"[ig] session file failed: {e}")
|
||||
|
||||
print(f"[ig] session failed: {e}")
|
||||
if IG_USERNAME and IG_PASSWORD:
|
||||
try:
|
||||
L.login(IG_USERNAME, IG_PASSWORD)
|
||||
print("[ig] logged in with password")
|
||||
_loader = L
|
||||
return _loader
|
||||
except Exception as e:
|
||||
print(f"[ig] password login failed: {e}")
|
||||
|
||||
print("[ig] WARNING: unauthenticated — most IG content will fail")
|
||||
print(f"[ig] login failed: {e}")
|
||||
_loader = L
|
||||
return _loader
|
||||
|
||||
|
|
@ -151,11 +119,10 @@ async def fetch_ig_post_info(url: str) -> dict:
|
|||
"owner": info.get("uploader"),
|
||||
"likes": info.get("like_count"),
|
||||
}
|
||||
|
||||
def _via_instaloader():
|
||||
from utils import extract_ig_shortcode
|
||||
shortcode = extract_ig_shortcode(url)
|
||||
L = _get_loader()
|
||||
L = _get_loader()
|
||||
post = instaloader.Post.from_shortcode(L.context, shortcode)
|
||||
return {
|
||||
"type": "video" if post.is_video else "image",
|
||||
|
|
@ -166,7 +133,6 @@ async def fetch_ig_post_info(url: str) -> dict:
|
|||
"owner": post.owner_username,
|
||||
"likes": post.likes,
|
||||
}
|
||||
|
||||
try:
|
||||
return await _run(_via_ytdlp)
|
||||
except Exception:
|
||||
|
|
@ -174,31 +140,33 @@ async def fetch_ig_post_info(url: str) -> dict:
|
|||
return await _run(_via_instaloader)
|
||||
|
||||
|
||||
# ── profile info ──────────────────────────────────────────────────────────────
|
||||
# ── profile info — yt-dlp ONLY, no instaloader ───────────────────────────────
|
||||
|
||||
async def fetch_ig_profile_info(username: str) -> dict:
|
||||
"""
|
||||
Fetch profile info using yt-dlp's Instagram user extractor.
|
||||
This avoids instaloader's Profile.from_username() which gets
|
||||
rate-limited and returns "does not exist" even for real accounts.
|
||||
Uses yt-dlp's Instagram user extractor.
|
||||
instaloader.Profile.from_username() is NOT used here — it fails
|
||||
with 'does not exist' even for real accounts due to IG API changes.
|
||||
"""
|
||||
def _via_ytdlp():
|
||||
# yt-dlp can extract Instagram user pages
|
||||
def _fetch():
|
||||
profile_url = f"https://www.instagram.com/{username}/"
|
||||
# yt-dlp's Instagram extractor handles profile pages
|
||||
# and returns the profile pic as the thumbnail
|
||||
opts = {
|
||||
**_BASE_YDL,
|
||||
"skip_download": True,
|
||||
"extract_flat": True,
|
||||
"skip_download": True,
|
||||
"extract_flat": True,
|
||||
"playlist_items": "0",
|
||||
}
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
info = ydl.extract_info(profile_url, download=False)
|
||||
|
||||
# yt-dlp returns uploader_url, thumbnail from profile pages
|
||||
thumb = info.get("thumbnail") or (
|
||||
info.get("thumbnails") or [{}]
|
||||
)[-1].get("url")
|
||||
|
||||
# thumbnail is the profile pic URL
|
||||
thumbnails = info.get("thumbnails") or []
|
||||
thumb = (
|
||||
info.get("thumbnail")
|
||||
or next((t["url"] for t in reversed(thumbnails) if t.get("url")), None)
|
||||
)
|
||||
return {
|
||||
"type": "profile",
|
||||
"title": f"@{username}",
|
||||
|
|
@ -206,28 +174,45 @@ async def fetch_ig_profile_info(username: str) -> dict:
|
|||
"owner": username,
|
||||
"likes": info.get("channel_follower_count") or 0,
|
||||
}
|
||||
return await _run(_fetch)
|
||||
|
||||
def _via_instaloader():
|
||||
L = _get_loader()
|
||||
profile = instaloader.Profile.from_username(L.context, username)
|
||||
try:
|
||||
pic_url = profile.profile_pic_url_no_iphone
|
||||
except Exception:
|
||||
pic_url = profile.profile_pic_url
|
||||
return {
|
||||
"type": "profile",
|
||||
"title": f"@{profile.username}",
|
||||
"thumbnail": pic_url,
|
||||
"owner": profile.username,
|
||||
"likes": profile.followers,
|
||||
|
||||
# ── profile download — yt-dlp ONLY ───────────────────────────────────────────
|
||||
|
||||
async def download_profile(username: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Downloads profile picture via yt-dlp. No instaloader involved.
|
||||
"""
|
||||
uid = _uid()
|
||||
|
||||
def _dl():
|
||||
profile_url = f"https://www.instagram.com/{username}/"
|
||||
opts = {
|
||||
**_BASE_YDL,
|
||||
"skip_download": True,
|
||||
"extract_flat": True,
|
||||
"playlist_items": "0",
|
||||
}
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
info = ydl.extract_info(profile_url, download=False)
|
||||
|
||||
try:
|
||||
return await _run(_via_ytdlp)
|
||||
except Exception:
|
||||
pass
|
||||
# instaloader as last resort
|
||||
return await _run(_via_instaloader)
|
||||
thumbnails = info.get("thumbnails") or []
|
||||
thumb = (
|
||||
info.get("thumbnail")
|
||||
or next((t["url"] for t in reversed(thumbnails) if t.get("url")), None)
|
||||
)
|
||||
if not thumb:
|
||||
raise ValueError(
|
||||
f"could not find profile picture for @{username} — "
|
||||
"yt-dlp returned no thumbnail. the account may be private or "
|
||||
"instagram may be blocking this request."
|
||||
)
|
||||
|
||||
path = DOWNLOAD_DIR / f"ig_pfp_{_safe(username)}_{uid}.jpg"
|
||||
_download_image_url(thumb, path)
|
||||
return str(path), f"{_safe(username)}_profile.jpg"
|
||||
|
||||
return await _run(_dl)
|
||||
|
||||
|
||||
# ── post download ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -274,7 +259,7 @@ async def download_post(
|
|||
L.download_post(post, target=str(tmp_dir))
|
||||
media = _first_media(tmp_dir)
|
||||
if not media:
|
||||
raise FileNotFoundError("instaloader produced no media file")
|
||||
raise FileNotFoundError("instaloader produced no media")
|
||||
owner = _safe(post.owner_username)
|
||||
dest = DOWNLOAD_DIR / f"ig_post_{uid}{media.suffix}"
|
||||
if post.is_video and (start_time or end_time):
|
||||
|
|
@ -301,14 +286,12 @@ async def download_reel(
|
|||
) -> 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)
|
||||
|
|
@ -323,79 +306,22 @@ async def download_reel(
|
|||
final.unlink(missing_ok=True)
|
||||
final = trimmed
|
||||
return str(final), f"{owner}_reel_{uid}.mp4"
|
||||
|
||||
return await _run(_dl)
|
||||
|
||||
|
||||
# ── profile picture download ──────────────────────────────────────────────────
|
||||
|
||||
async def download_profile(username: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Downloads profile picture using yt-dlp's Instagram extractor,
|
||||
falling back to instaloader if yt-dlp can't find a usable image URL.
|
||||
"""
|
||||
uid = _uid()
|
||||
|
||||
def _via_ytdlp():
|
||||
profile_url = f"https://www.instagram.com/{username}/"
|
||||
opts = {
|
||||
**_BASE_YDL,
|
||||
"skip_download": True,
|
||||
"extract_flat": True,
|
||||
"playlist_items": "0",
|
||||
}
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
info = ydl.extract_info(profile_url, download=False)
|
||||
|
||||
# try thumbnail fields in order of preference
|
||||
thumb = (
|
||||
info.get("thumbnail")
|
||||
or next(
|
||||
(t.get("url") for t in reversed(info.get("thumbnails") or [])
|
||||
if t.get("url")),
|
||||
None,
|
||||
)
|
||||
)
|
||||
if not thumb:
|
||||
raise ValueError("yt-dlp found no thumbnail for this profile")
|
||||
|
||||
path = DOWNLOAD_DIR / f"ig_pfp_{_safe(username)}_{uid}.jpg"
|
||||
_download_image_url(thumb, path)
|
||||
return str(path), f"{_safe(username)}_profile.jpg"
|
||||
|
||||
def _via_instaloader():
|
||||
L = _get_loader()
|
||||
profile = instaloader.Profile.from_username(L.context, username)
|
||||
try:
|
||||
pic_url = profile.profile_pic_url_no_iphone
|
||||
except Exception:
|
||||
pic_url = profile.profile_pic_url
|
||||
path = DOWNLOAD_DIR / f"ig_pfp_{_safe(username)}_{uid}.jpg"
|
||||
_download_image_url(pic_url, path)
|
||||
return str(path), f"{_safe(username)}_profile.jpg"
|
||||
|
||||
try:
|
||||
return await _run(_via_ytdlp)
|
||||
except Exception:
|
||||
pass
|
||||
return await _run(_via_instaloader)
|
||||
|
||||
|
||||
# ── thumbnail ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def download_ig_thumbnail(url: str) -> Tuple[str, str]:
|
||||
uid = _uid()
|
||||
|
||||
def _dl():
|
||||
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")
|
||||
thumb = info.get("thumbnail")
|
||||
if not thumb:
|
||||
raise ValueError("no thumbnail found")
|
||||
owner = _safe(info.get("uploader") or "ig")
|
||||
path = DOWNLOAD_DIR / f"ig_thumb_{uid}.jpg"
|
||||
_download_image_url(thumb_url, path)
|
||||
_download_image_url(thumb, path)
|
||||
return str(path), f"{owner}_thumbnail_{uid}.jpg"
|
||||
|
||||
return await _run(_dl)
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;min
|
|||
</div>
|
||||
|
||||
<div id="panel-yt" class="panel">
|
||||
<div class="section-label">what do u want gng</div>
|
||||
<div class="section-label">choose</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>
|
||||
|
|
@ -176,7 +176,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;min
|
|||
<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 (not working rn nub)</div><div class="opt-desc">Avatar + banner</div>
|
||||
<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>
|
||||
|
|
@ -222,7 +222,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;min
|
|||
</div>
|
||||
|
||||
<div id="panel-ig" class="panel">
|
||||
<div class="section-label">what do you want nub</div>
|
||||
<div class="section-label">choose</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>
|
||||
|
|
@ -233,7 +233,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;min
|
|||
<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 (not working bruh)</div><div class="opt-desc">Avatar</div>
|
||||
<span class="opt-icon">👤</span><div class="opt-title">Profile pic</div><div class="opt-desc">Avatar JPG</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')">
|
||||
|
|
@ -282,12 +282,34 @@ let activeOpts = { yt: 'yt-video', ig: 'ig-post' };
|
|||
let downloading = false;
|
||||
let downloadHistory = [];
|
||||
|
||||
// ── URL helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function cleanYtUrl(url) {
|
||||
// strip playlist/radio params — keep only the video id
|
||||
const m = url.match(/[?&]v=([A-Za-z0-9_-]{11})/);
|
||||
if (m) return `https://www.youtube.com/watch?v=${m[1]}`;
|
||||
const s = url.match(/youtu\.be\/([A-Za-z0-9_-]{11})/);
|
||||
if (s) return `https://youtu.be/${s[1]}`;
|
||||
return url; // channel URLs etc pass through unchanged
|
||||
}
|
||||
|
||||
function extractIgUsername(raw) {
|
||||
// accepts: full URL, @username, or bare username
|
||||
if (raw.includes('instagram.com')) {
|
||||
const path = raw.replace(/https?:\/\/(www\.)?instagram\.com\/?/, '').replace(/^@/, '');
|
||||
return path.split('/')[0].split('?')[0].trim();
|
||||
}
|
||||
return raw.replace(/^@/, '').trim();
|
||||
}
|
||||
|
||||
function detectPlatform(url) {
|
||||
if (/youtube\.com|youtu\.be/.test(url)) return 'yt';
|
||||
if (/instagram\.com|instagr\.am/.test(url)) return 'ig';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── preview ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleUrl() {
|
||||
const url = document.getElementById('urlInput').value.trim();
|
||||
if (!url) { showToast('paste something first lol', '#e24b4a'); return; }
|
||||
|
|
@ -332,7 +354,7 @@ function renderPreview(data, platform) {
|
|||
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.likes) pills.push(`<span class="meta-pill">${fmtNum(data.likes)} ${data.type==='profile'?'followers':'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('');
|
||||
|
|
@ -346,6 +368,8 @@ function fmtNum(n) {
|
|||
return String(n);
|
||||
}
|
||||
|
||||
// ── platform / options ────────────────────────────────────────────────────────
|
||||
|
||||
function switchPlatform(p) {
|
||||
activePlatform = p;
|
||||
['yt','ig'].forEach(id => {
|
||||
|
|
@ -372,6 +396,8 @@ function setTrim(mode) {
|
|||
document.getElementById('trim-inputs').style.display = mode==='custom' ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
// ── download ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function startDownload() {
|
||||
if (downloading) return;
|
||||
if (!activePlatform) { showToast('pick a platform first', '#e24b4a'); return; }
|
||||
|
|
@ -385,8 +411,8 @@ function quickDownload() {
|
|||
}
|
||||
|
||||
async function runDownload(quick) {
|
||||
const url = document.getElementById('urlInput').value.trim();
|
||||
if (!url) { showToast('paste a url first lol', '#e24b4a'); return; }
|
||||
const raw = document.getElementById('urlInput').value.trim();
|
||||
if (!raw) { showToast('paste a url first lol', '#e24b4a'); return; }
|
||||
downloading = true;
|
||||
setButtons(false);
|
||||
|
||||
|
|
@ -410,8 +436,57 @@ async function runDownload(quick) {
|
|||
const statInterval = setInterval(() => { if (si < statuses.length) stat.textContent = statuses[si++]; }, 3000);
|
||||
|
||||
try {
|
||||
const opt = activeOpts[activePlatform];
|
||||
|
||||
// ── channel art: returns JSON with multiple image URLs, not a blob ────────
|
||||
if (activePlatform === 'yt' && opt === 'yt-channel') {
|
||||
lbl.textContent = 'downloading channel art...';
|
||||
const res = await fetch(`${API}/api/youtube/channel-art`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channel_url: raw })
|
||||
});
|
||||
clearInterval(fakeInterval); clearInterval(statInterval);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
bar.style.width = '100%'; pct.textContent = '100%'; stat.textContent = 'opening files...';
|
||||
// trigger a download for each image separately
|
||||
for (const asset of data.assets) {
|
||||
const a = document.createElement('a');
|
||||
a.href = `${API}${asset.url}`;
|
||||
a.download = asset.filename;
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
await new Promise(r => setTimeout(r, 600)); // stagger so browser doesn't block second download
|
||||
}
|
||||
showToast(`got ${data.assets.length} file(s) 👌`, '#1db954');
|
||||
addHistory(`channel art (${data.assets.length} files)`);
|
||||
setTimeout(() => { wrap.className='progress-wrap'; bar.style.width='0%'; }, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── instagram profile: extract username, send as {username} not {url} ────
|
||||
if (activePlatform === 'ig' && opt === 'ig-profile') {
|
||||
lbl.textContent = 'downloading profile pic...';
|
||||
const username = extractIgUsername(raw);
|
||||
const res = await fetch(`${API}/api/instagram/profile`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
clearInterval(fakeInterval); clearInterval(statInterval);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
bar.style.width = '95%'; pct.textContent = '95%'; stat.textContent = 'saving file...';
|
||||
await triggerBlobDownload(res, `${username}_profile.jpg`);
|
||||
bar.style.width = '100%'; pct.textContent = '100%'; stat.textContent = 'done lol';
|
||||
showToast('got it 👌', '#1db954');
|
||||
addHistory(`${username}_profile.jpg`);
|
||||
setTimeout(() => { wrap.className='progress-wrap'; bar.style.width='0%'; }, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── standard single-file download ─────────────────────────────────────────
|
||||
const endpoint = getEndpoint(quick);
|
||||
const body = buildBody(url, quick);
|
||||
const body = buildBody(raw, quick);
|
||||
lbl.textContent = 'downloading — ' + (activePlatform==='yt'?'YouTube':'Instagram');
|
||||
|
||||
const res = await fetch(`${API}${endpoint}`, {
|
||||
|
|
@ -419,25 +494,16 @@ async function runDownload(quick) {
|
|||
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);
|
||||
|
||||
const filename = await triggerBlobDownload(res, guessFilename());
|
||||
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%';
|
||||
|
|
@ -447,28 +513,39 @@ async function runDownload(quick) {
|
|||
}
|
||||
}
|
||||
|
||||
async function triggerBlobDownload(res, fallbackName) {
|
||||
const disposition = res.headers.get('content-disposition') || '';
|
||||
const filename = disposition.split('filename=')[1]?.replace(/"/g,'') || fallbackName;
|
||||
const blob = await res.blob();
|
||||
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);
|
||||
return filename;
|
||||
}
|
||||
|
||||
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 (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 (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';
|
||||
if (opt==='ig-post') return '/api/instagram/post';
|
||||
if (opt==='ig-reel') return '/api/instagram/reel';
|
||||
if (opt==='ig-thumb') return '/api/instagram/thumbnail';
|
||||
}
|
||||
return '/api/youtube/video';
|
||||
}
|
||||
|
||||
function buildBody(url, quick) {
|
||||
function buildBody(raw, quick) {
|
||||
if (activePlatform === 'yt') {
|
||||
const url = cleanYtUrl(raw);
|
||||
if (quick) return { url, resolution: 'best', format: 'mp4' };
|
||||
const opt = activeOpts.yt;
|
||||
if (opt==='yt-video') {
|
||||
|
|
@ -478,22 +555,17 @@ function buildBody(url, quick) {
|
|||
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 };
|
||||
if (opt==='yt-audio') return { url, quality: '192', format: 'mp3' };
|
||||
if (opt==='yt-subs') return { url, lang: 'en' };
|
||||
return { url };
|
||||
}
|
||||
if (activePlatform === 'ig') {
|
||||
const opt = activeOpts.ig;
|
||||
const url = raw.split('?')[0].replace(/\/$/, '');
|
||||
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 };
|
||||
}
|
||||
if (activeOpts.ig==='ig-post' || activeOpts.ig==='ig-reel') return { url, format: fmt };
|
||||
return { url };
|
||||
}
|
||||
return { url };
|
||||
return { url: raw };
|
||||
}
|
||||
|
||||
function guessFilename() {
|
||||
|
|
@ -514,7 +586,7 @@ function addHistory(filename) {
|
|||
}
|
||||
|
||||
function renderHistory() {
|
||||
const sec = document.getElementById('historySection');
|
||||
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=>`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue