fixed instagram avater probs and yt avater too ig
This commit is contained in:
parent
3f398f7f71
commit
f862e1d670
2 changed files with 207 additions and 93 deletions
|
|
@ -2,16 +2,17 @@
|
|||
Instagram service.
|
||||
|
||||
Strategy:
|
||||
- Posts/images: instaloader (with session) → yt-dlp fallback
|
||||
- Reels/videos: yt-dlp (instaloader 403s on reels)
|
||||
- Profile pics: instaloader (uses username directly, no shortcode)
|
||||
- Thumbnails: yt-dlp
|
||||
- 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 the tablet):
|
||||
Session setup (run once on tablet, needed for instaloader fallback):
|
||||
python3 -c "
|
||||
import instaloader
|
||||
L = instaloader.Instaloader()
|
||||
L.interactive_login('your_ig_username')
|
||||
L.interactive_login('your_username')
|
||||
L.save_session_to_file('ig_session')
|
||||
print('saved')
|
||||
"
|
||||
|
|
@ -66,6 +67,37 @@ def _fmt_s(s) -> str:
|
|||
return f"{m}:{sec:02d}"
|
||||
|
||||
|
||||
def _download_image_url(url: str, dest: Path) -> Path:
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
def _first_media(directory: Path) -> Optional[Path]:
|
||||
return next(
|
||||
(f for f in sorted(directory.iterdir())
|
||||
if f.suffix in (".mp4", ".jpg", ".jpeg", ".png")),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _get_loader() -> instaloader.Instaloader:
|
||||
global _loader
|
||||
if _loader is not None:
|
||||
|
|
@ -103,42 +135,9 @@ def _get_loader() -> instaloader.Instaloader:
|
|||
return _loader
|
||||
|
||||
|
||||
def _download_image_url(url: str, dest: Path) -> Path:
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
def _first_media(directory: Path) -> Optional[Path]:
|
||||
return next(
|
||||
(f for f in sorted(directory.iterdir())
|
||||
if f.suffix in (".mp4", ".jpg", ".jpeg", ".png")),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
# ── post info (shortcode-based) ───────────────────────────────────────────────
|
||||
# ── post info ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def fetch_ig_post_info(url: str) -> dict:
|
||||
"""Info for post / reel / tv URLs."""
|
||||
|
||||
def _via_ytdlp():
|
||||
opts = {**_BASE_YDL, "skip_download": True}
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
|
|
@ -175,12 +174,40 @@ async def fetch_ig_post_info(url: str) -> dict:
|
|||
return await _run(_via_instaloader)
|
||||
|
||||
|
||||
# ── profile info (username-based) ─────────────────────────────────────────────
|
||||
# ── profile info ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def fetch_ig_profile_info(username: str) -> dict:
|
||||
"""Info for profile pages — uses username directly, no shortcode."""
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
def _via_ytdlp():
|
||||
# yt-dlp can extract Instagram user pages
|
||||
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)
|
||||
|
||||
def _fetch():
|
||||
# yt-dlp returns uploader_url, thumbnail from profile pages
|
||||
thumb = info.get("thumbnail") or (
|
||||
info.get("thumbnails") or [{}]
|
||||
)[-1].get("url")
|
||||
|
||||
return {
|
||||
"type": "profile",
|
||||
"title": f"@{username}",
|
||||
"thumbnail": thumb,
|
||||
"owner": username,
|
||||
"likes": info.get("channel_follower_count") or 0,
|
||||
}
|
||||
|
||||
def _via_instaloader():
|
||||
L = _get_loader()
|
||||
profile = instaloader.Profile.from_username(L.context, username)
|
||||
try:
|
||||
|
|
@ -192,10 +219,15 @@ async def fetch_ig_profile_info(username: str) -> dict:
|
|||
"title": f"@{profile.username}",
|
||||
"thumbnail": pic_url,
|
||||
"owner": profile.username,
|
||||
"likes": profile.followers, # repurpose likes field for follower count
|
||||
"likes": profile.followers,
|
||||
}
|
||||
|
||||
return await _run(_fetch)
|
||||
try:
|
||||
return await _run(_via_ytdlp)
|
||||
except Exception:
|
||||
pass
|
||||
# instaloader as last resort
|
||||
return await _run(_via_instaloader)
|
||||
|
||||
|
||||
# ── post download ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -260,7 +292,7 @@ async def download_post(
|
|||
return await _run(_via_instaloader)
|
||||
|
||||
|
||||
# ── reel download (yt-dlp only) ───────────────────────────────────────────────
|
||||
# ── reel download ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def download_reel(
|
||||
url: str,
|
||||
|
|
@ -299,12 +331,39 @@ async def download_reel(
|
|||
|
||||
async def download_profile(username: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Downloads the profile picture for a given username.
|
||||
username should already be clean (no @ prefix, no URL).
|
||||
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 _dl():
|
||||
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:
|
||||
|
|
@ -315,7 +374,11 @@ async def download_profile(username: str) -> Tuple[str, str]:
|
|||
_download_image_url(pic_url, path)
|
||||
return str(path), f"{_safe(username)}_profile.jpg"
|
||||
|
||||
return await _run(_dl)
|
||||
try:
|
||||
return await _run(_via_ytdlp)
|
||||
except Exception:
|
||||
pass
|
||||
return await _run(_via_instaloader)
|
||||
|
||||
|
||||
# ── thumbnail ─────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -240,14 +240,15 @@ async def download_subtitles(url: str, lang: str = "en") -> Tuple[str, str]:
|
|||
|
||||
async def download_channel_art(channel_url: str) -> List[dict]:
|
||||
"""
|
||||
Given a channel URL (youtube.com/@handle or youtube.com/channel/UCxxxxx),
|
||||
downloads the avatar and banner as separate JPEG files.
|
||||
Downloads avatar + banner for a YouTube channel as separate JPEGs.
|
||||
|
||||
Returns:
|
||||
[
|
||||
{"type": "avatar", "filename": "channelname_avatar.jpg", "url": "/files/..."},
|
||||
{"type": "banner", "filename": "channelname_banner.jpg", "url": "/files/..."},
|
||||
]
|
||||
Approach:
|
||||
1. Use yt-dlp to get channel metadata (uploader, thumbnails list)
|
||||
2. Parse thumbnails list — YouTube returns avatars (square, ggpht CDN)
|
||||
and banners (wide ratio) as separate entries
|
||||
3. Download each directly with httpx
|
||||
|
||||
Returns list of {"type", "filename", "url"} dicts.
|
||||
"""
|
||||
|
||||
def _dl():
|
||||
|
|
@ -255,38 +256,53 @@ async def download_channel_art(channel_url: str) -> List[dict]:
|
|||
|
||||
uid = _uid()
|
||||
|
||||
# fetch channel metadata — extract_flat avoids fetching all videos
|
||||
# fetch channel page metadata only — no video list needed
|
||||
# DO NOT use extract_flat here, it causes yt-dlp to return
|
||||
# video entries instead of channel metadata
|
||||
opts = {
|
||||
**_BASE_OPTS,
|
||||
"skip_download": True,
|
||||
"extract_flat": True,
|
||||
"extract_flat": False,
|
||||
"playlist_items": "0", # fetch 0 videos — just channel info
|
||||
}
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
info = ydl.extract_info(channel_url, download=False)
|
||||
|
||||
channel_name = _safe(info.get("channel") or info.get("uploader") or "channel")
|
||||
channel_name = _safe(
|
||||
info.get("channel")
|
||||
or info.get("uploader")
|
||||
or info.get("title")
|
||||
or "channel"
|
||||
)
|
||||
thumbnails = info.get("thumbnails") or []
|
||||
results = []
|
||||
|
||||
print(f"[yt channel] found {len(thumbnails)} thumbnails for {channel_name}")
|
||||
for t in thumbnails:
|
||||
print(f" w={t.get('width')} h={t.get('height')} url={str(t.get('url',''))[:80]}")
|
||||
|
||||
# ── avatar ────────────────────────────────────────────────────────────
|
||||
# YouTube channel avatars come from ggpht.com and are square
|
||||
# YouTube avatar thumbnails:
|
||||
# - hosted on ggpht.com or lh3.googleusercontent.com
|
||||
# - square aspect ratio (width == height)
|
||||
avatar_url = None
|
||||
avatar_candidates = [
|
||||
t for t in thumbnails
|
||||
if t.get("url") and _is_avatar_thumb(t)
|
||||
]
|
||||
if avatar_candidates:
|
||||
# pick highest resolution
|
||||
best = max(
|
||||
avatar_candidates,
|
||||
key=lambda t: (t.get("width") or 0) * (t.get("height") or 0),
|
||||
)
|
||||
avatar_url = best["url"]
|
||||
# for ggpht URLs, request a larger size by tweaking the size param
|
||||
avatar_url = _upscale_ggpht(avatar_url, 800)
|
||||
|
||||
# first try thumbnails tagged as square / avatar-like
|
||||
square = [t for t in thumbnails if _is_avatar(t)]
|
||||
if square:
|
||||
# highest resolution square thumbnail
|
||||
avatar_url = max(square, key=lambda t: (t.get("width") or 0) * (t.get("height") or 0))["url"]
|
||||
|
||||
# fallback: any thumbnail from ggpht (Google's image CDN for avatars)
|
||||
if not avatar_url:
|
||||
ggpht = [t for t in thumbnails if "ggpht" in (t.get("url") or "")]
|
||||
if ggpht:
|
||||
avatar_url = ggpht[-1]["url"]
|
||||
|
||||
# last resort: channel thumbnail field
|
||||
if not avatar_url:
|
||||
avatar_url = info.get("thumbnail")
|
||||
# fallback: channel's own thumbnail field
|
||||
if not avatar_url and info.get("thumbnail"):
|
||||
avatar_url = info["thumbnail"]
|
||||
|
||||
if avatar_url:
|
||||
dest = DOWNLOAD_DIR / f"{channel_name}_avatar_{uid}.jpg"
|
||||
|
|
@ -297,17 +313,22 @@ async def download_channel_art(channel_url: str) -> List[dict]:
|
|||
"filename": dest.name,
|
||||
"url": f"/files/{dest.name}",
|
||||
})
|
||||
print(f"[yt channel] avatar saved: {dest.name}")
|
||||
except Exception as e:
|
||||
print(f"[yt channel] avatar download failed: {e}")
|
||||
|
||||
# ── banner ────────────────────────────────────────────────────────────
|
||||
# Banners are wide (ratio > 3:1) or explicitly keyed
|
||||
banner_url = info.get("header_image") or info.get("banner")
|
||||
|
||||
if not banner_url:
|
||||
# look for very wide thumbnails (banner aspect ratio)
|
||||
wide = [t for t in thumbnails if _is_banner(t)]
|
||||
if wide:
|
||||
banner_url = max(wide, key=lambda t: t.get("width") or 0)["url"]
|
||||
banner_candidates = [
|
||||
t for t in thumbnails
|
||||
if t.get("url") and _is_banner_thumb(t)
|
||||
]
|
||||
if banner_candidates:
|
||||
best = max(banner_candidates, key=lambda t: t.get("width") or 0)
|
||||
banner_url = best["url"]
|
||||
|
||||
if banner_url:
|
||||
dest = DOWNLOAD_DIR / f"{channel_name}_banner_{uid}.jpg"
|
||||
|
|
@ -318,13 +339,14 @@ async def download_channel_art(channel_url: str) -> List[dict]:
|
|||
"filename": dest.name,
|
||||
"url": f"/files/{dest.name}",
|
||||
})
|
||||
print(f"[yt channel] banner saved: {dest.name}")
|
||||
except Exception as e:
|
||||
print(f"[yt channel] banner download failed: {e}")
|
||||
|
||||
if not results:
|
||||
raise ValueError(
|
||||
"no channel art found — make sure you're using a channel URL "
|
||||
"like youtube.com/@channelname or youtube.com/channel/UCxxxxxx"
|
||||
"no channel art found — use a channel URL like "
|
||||
"youtube.com/@channelname or youtube.com/channel/UCxxxxxx"
|
||||
)
|
||||
|
||||
return results
|
||||
|
|
@ -332,19 +354,48 @@ async def download_channel_art(channel_url: str) -> List[dict]:
|
|||
return await _run(_dl)
|
||||
|
||||
|
||||
def _is_avatar(t: dict) -> bool:
|
||||
def _is_avatar_thumb(t: dict) -> bool:
|
||||
"""True if this thumbnail looks like a channel avatar (square, ggpht CDN)."""
|
||||
url = t.get("url") or ""
|
||||
w = t.get("width") or 0
|
||||
h = t.get("height") or 1
|
||||
url = t.get("url") or ""
|
||||
if w and h and 0.8 < (w / h) < 1.3:
|
||||
|
||||
# ggpht / lh3.googleusercontent = Google's avatar CDN
|
||||
if any(cdn in url for cdn in ("ggpht.com", "lh3.googleusercontent.com")):
|
||||
return True
|
||||
return any(kw in url for kw in ("ggpht", "photo", "avatar"))
|
||||
|
||||
# square-ish and small-to-medium (avatars are usually ≤ 900px)
|
||||
if w and h and 0.85 < (w / h) < 1.15 and w <= 900:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _is_banner(t: dict) -> bool:
|
||||
def _is_banner_thumb(t: dict) -> bool:
|
||||
"""True if this thumbnail looks like a channel banner (very wide)."""
|
||||
url = t.get("url") or ""
|
||||
w = t.get("width") or 0
|
||||
h = t.get("height") or 1
|
||||
url = t.get("url") or ""
|
||||
if w and h and (w / h) > 2.5:
|
||||
|
||||
if "banner" in url.lower():
|
||||
return True
|
||||
return "banner" in url.lower()
|
||||
|
||||
# banners are typically 2560×1440 or similar ultra-wide
|
||||
if w and h and (w / h) > 3.0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _upscale_ggpht(url: str, size: int) -> str:
|
||||
"""
|
||||
ggpht URLs end with =s{size} or =s{size}-c.
|
||||
Replace with a larger size for higher resolution avatars.
|
||||
e.g. =s88-c-k → =s800-c-k
|
||||
"""
|
||||
# match =sNNN optionally followed by other params
|
||||
upscaled = re.sub(r"=s\d+", f"=s{size}", url)
|
||||
if upscaled != url:
|
||||
return upscaled
|
||||
# if no size param found, just return original
|
||||
return url
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue