fixed instagram avater download + youtube avater download and yt radio mix issues most likely
This commit is contained in:
parent
49c8bac7a7
commit
384e6a903c
6 changed files with 277 additions and 194 deletions
|
|
@ -6,8 +6,9 @@ import re
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from utils import clean_yt_url, clean_ig_url, is_ig_profile_url, extract_ig_username
|
||||||
from services.youtube_service import fetch_yt_info
|
from services.youtube_service import fetch_yt_info
|
||||||
from services.instagram_service import fetch_ig_info
|
from services.instagram_service import fetch_ig_post_info, fetch_ig_profile_info
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -37,16 +38,29 @@ async def detect(req: InfoRequest):
|
||||||
|
|
||||||
@router.post("/preview")
|
@router.post("/preview")
|
||||||
async def preview(req: InfoRequest):
|
async def preview(req: InfoRequest):
|
||||||
platform = detect_platform(req.url)
|
url = req.url.strip()
|
||||||
|
platform = detect_platform(url)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if platform == "youtube":
|
if platform == "youtube":
|
||||||
data = await fetch_yt_info(req.url)
|
url = clean_yt_url(url)
|
||||||
|
data = await fetch_yt_info(url)
|
||||||
|
|
||||||
elif platform == "instagram":
|
elif platform == "instagram":
|
||||||
data = await fetch_ig_info(req.url)
|
url = clean_ig_url(url)
|
||||||
|
if is_ig_profile_url(url):
|
||||||
|
# profile page — show username + avatar
|
||||||
|
username = extract_ig_username(url)
|
||||||
|
data = await fetch_ig_profile_info(username)
|
||||||
|
else:
|
||||||
|
# post / reel / tv
|
||||||
|
data = await fetch_ig_post_info(url)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(400, detail="unsupported platform — only YouTube and Instagram")
|
raise HTTPException(400, detail="unsupported platform — only YouTube and Instagram")
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, detail=f"fetch failed: {str(e)}")
|
raise HTTPException(500, detail=f"fetch failed: {str(e)}")
|
||||||
|
|
||||||
return {"platform": platform, **data}
|
return {"platform": platform, **data}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
"""
|
"""
|
||||||
/api/instagram — post, reel, story, profile, thumbnail
|
/api/instagram — post, reel, profile, thumbnail
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
@ -9,12 +9,12 @@ from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from utils import clean_ig_url, extract_ig_username
|
||||||
from services.instagram_service import (
|
from services.instagram_service import (
|
||||||
download_post,
|
download_post,
|
||||||
download_reel,
|
download_reel,
|
||||||
download_profile,
|
download_profile,
|
||||||
download_ig_thumbnail,
|
download_ig_thumbnail,
|
||||||
fetch_ig_info,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -28,6 +28,7 @@ class PostRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class ProfileRequest(BaseModel):
|
class ProfileRequest(BaseModel):
|
||||||
|
# accepts either a username ("oiupoyt") or full URL ("instagram.com/oiupoyt/")
|
||||||
username: str
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -45,12 +46,11 @@ async def _delete_later(path: str, delay: int = 3600):
|
||||||
|
|
||||||
@router.post("/post")
|
@router.post("/post")
|
||||||
async def post(req: PostRequest, bg: BackgroundTasks):
|
async def post(req: PostRequest, bg: BackgroundTasks):
|
||||||
|
url = clean_ig_url(req.url.strip())
|
||||||
try:
|
try:
|
||||||
path, filename = await download_post(
|
path, filename = await download_post(
|
||||||
url=req.url,
|
url=url, fmt=req.format,
|
||||||
fmt=req.format,
|
start_time=req.start_time, end_time=req.end_time,
|
||||||
start_time=req.start_time,
|
|
||||||
end_time=req.end_time,
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, detail=str(e))
|
raise HTTPException(500, detail=str(e))
|
||||||
|
|
@ -61,12 +61,10 @@ async def post(req: PostRequest, bg: BackgroundTasks):
|
||||||
|
|
||||||
@router.post("/reel")
|
@router.post("/reel")
|
||||||
async def reel(req: PostRequest, bg: BackgroundTasks):
|
async def reel(req: PostRequest, bg: BackgroundTasks):
|
||||||
# reels use yt-dlp directly — more reliable than instaloader for reels
|
url = clean_ig_url(req.url.strip())
|
||||||
try:
|
try:
|
||||||
path, filename = await download_reel(
|
path, filename = await download_reel(
|
||||||
url=req.url,
|
url=url, start_time=req.start_time, end_time=req.end_time,
|
||||||
start_time=req.start_time,
|
|
||||||
end_time=req.end_time,
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, detail=str(e))
|
raise HTTPException(500, detail=str(e))
|
||||||
|
|
@ -76,8 +74,25 @@ async def reel(req: PostRequest, bg: BackgroundTasks):
|
||||||
|
|
||||||
@router.post("/profile")
|
@router.post("/profile")
|
||||||
async def profile(req: ProfileRequest, bg: BackgroundTasks):
|
async def profile(req: ProfileRequest, bg: BackgroundTasks):
|
||||||
|
"""
|
||||||
|
Accepts a username ("oiupoyt") or full URL ("https://www.instagram.com/oiupoyt/").
|
||||||
|
Downloads the profile picture as a JPEG.
|
||||||
|
"""
|
||||||
|
raw = req.username.strip()
|
||||||
|
# if it looks like a URL, extract username from it
|
||||||
|
if "instagram.com" in raw:
|
||||||
try:
|
try:
|
||||||
path, filename = await download_profile(req.username)
|
username = extract_ig_username(raw)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, detail=str(e))
|
||||||
|
else:
|
||||||
|
username = raw.lstrip("@")
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(400, detail="no username provided")
|
||||||
|
|
||||||
|
try:
|
||||||
|
path, filename = await download_profile(username)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, detail=str(e))
|
raise HTTPException(500, detail=str(e))
|
||||||
bg.add_task(_delete_later, path)
|
bg.add_task(_delete_later, path)
|
||||||
|
|
@ -86,8 +101,9 @@ async def profile(req: ProfileRequest, bg: BackgroundTasks):
|
||||||
|
|
||||||
@router.post("/thumbnail")
|
@router.post("/thumbnail")
|
||||||
async def thumbnail(req: SimpleRequest, bg: BackgroundTasks):
|
async def thumbnail(req: SimpleRequest, bg: BackgroundTasks):
|
||||||
|
url = clean_ig_url(req.url.strip())
|
||||||
try:
|
try:
|
||||||
path, filename = await download_ig_thumbnail(req.url)
|
path, filename = await download_ig_thumbnail(url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, detail=str(e))
|
raise HTTPException(500, detail=str(e))
|
||||||
bg.add_task(_delete_later, path)
|
bg.add_task(_delete_later, path)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from utils import clean_yt_url
|
||||||
from services.youtube_service import (
|
from services.youtube_service import (
|
||||||
download_video,
|
download_video,
|
||||||
download_audio,
|
download_audio,
|
||||||
|
|
@ -44,7 +45,7 @@ class SubtitleRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class ChannelRequest(BaseModel):
|
class ChannelRequest(BaseModel):
|
||||||
channel_url: str
|
channel_url: str # youtube.com/@handle or youtube.com/channel/UCxxxxxx
|
||||||
|
|
||||||
|
|
||||||
async def _delete_later(path: str, delay: int = 3600):
|
async def _delete_later(path: str, delay: int = 3600):
|
||||||
|
|
@ -57,9 +58,10 @@ async def _delete_later(path: str, delay: int = 3600):
|
||||||
|
|
||||||
@router.post("/video")
|
@router.post("/video")
|
||||||
async def video(req: VideoRequest, bg: BackgroundTasks):
|
async def video(req: VideoRequest, bg: BackgroundTasks):
|
||||||
|
url = clean_yt_url(req.url.strip())
|
||||||
try:
|
try:
|
||||||
path, filename = await download_video(
|
path, filename = await download_video(
|
||||||
url=req.url,
|
url=url,
|
||||||
resolution=req.resolution,
|
resolution=req.resolution,
|
||||||
fmt=req.format,
|
fmt=req.format,
|
||||||
start_time=req.start_time,
|
start_time=req.start_time,
|
||||||
|
|
@ -73,12 +75,9 @@ async def video(req: VideoRequest, bg: BackgroundTasks):
|
||||||
|
|
||||||
@router.post("/audio")
|
@router.post("/audio")
|
||||||
async def audio(req: AudioRequest, bg: BackgroundTasks):
|
async def audio(req: AudioRequest, bg: BackgroundTasks):
|
||||||
|
url = clean_yt_url(req.url.strip())
|
||||||
try:
|
try:
|
||||||
path, filename = await download_audio(
|
path, filename = await download_audio(url=url, quality=req.quality, fmt=req.format)
|
||||||
url=req.url,
|
|
||||||
quality=req.quality,
|
|
||||||
fmt=req.format,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, detail=str(e))
|
raise HTTPException(500, detail=str(e))
|
||||||
bg.add_task(_delete_later, path)
|
bg.add_task(_delete_later, path)
|
||||||
|
|
@ -87,8 +86,9 @@ async def audio(req: AudioRequest, bg: BackgroundTasks):
|
||||||
|
|
||||||
@router.post("/thumbnail")
|
@router.post("/thumbnail")
|
||||||
async def thumbnail(req: SimpleRequest, bg: BackgroundTasks):
|
async def thumbnail(req: SimpleRequest, bg: BackgroundTasks):
|
||||||
|
url = clean_yt_url(req.url.strip())
|
||||||
try:
|
try:
|
||||||
path, filename = await download_thumbnail(req.url)
|
path, filename = await download_thumbnail(url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, detail=str(e))
|
raise HTTPException(500, detail=str(e))
|
||||||
bg.add_task(_delete_later, path)
|
bg.add_task(_delete_later, path)
|
||||||
|
|
@ -97,8 +97,9 @@ async def thumbnail(req: SimpleRequest, bg: BackgroundTasks):
|
||||||
|
|
||||||
@router.post("/subtitles")
|
@router.post("/subtitles")
|
||||||
async def subtitles(req: SubtitleRequest, bg: BackgroundTasks):
|
async def subtitles(req: SubtitleRequest, bg: BackgroundTasks):
|
||||||
|
url = clean_yt_url(req.url.strip())
|
||||||
try:
|
try:
|
||||||
path, filename = await download_subtitles(req.url, req.lang)
|
path, filename = await download_subtitles(url, req.lang)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, detail=str(e))
|
raise HTTPException(500, detail=str(e))
|
||||||
bg.add_task(_delete_later, path)
|
bg.add_task(_delete_later, path)
|
||||||
|
|
@ -108,18 +109,22 @@ async def subtitles(req: SubtitleRequest, bg: BackgroundTasks):
|
||||||
@router.post("/channel-art")
|
@router.post("/channel-art")
|
||||||
async def channel_art(req: ChannelRequest, bg: BackgroundTasks):
|
async def channel_art(req: ChannelRequest, bg: BackgroundTasks):
|
||||||
"""
|
"""
|
||||||
Returns JSON with download URLs for avatar and banner images.
|
Downloads avatar (JPG) and banner (JPG) for a YouTube channel.
|
||||||
Frontend fetches each URL separately to trigger downloads.
|
Returns JSON — frontend opens each asset URL to trigger download.
|
||||||
Response: {"assets": [{"type": "avatar", "url": "/files/..."}, ...]}
|
|
||||||
|
{
|
||||||
|
"assets": [
|
||||||
|
{"type": "avatar", "filename": "...", "url": "/files/..."},
|
||||||
|
{"type": "banner", "filename": "...", "url": "/files/..."}
|
||||||
|
]
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
assets = await download_channel_art(req.channel_url)
|
assets = await download_channel_art(req.channel_url.strip())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, detail=str(e))
|
raise HTTPException(500, detail=str(e))
|
||||||
|
|
||||||
# schedule cleanup for each file
|
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
path = f"downloads/{asset['filename']}"
|
bg.add_task(_delete_later, f"downloads/{asset['filename']}")
|
||||||
bg.add_task(_delete_later, path)
|
|
||||||
|
|
||||||
return {"assets": assets}
|
return {"assets": assets}
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,16 @@
|
||||||
Instagram service.
|
Instagram service.
|
||||||
|
|
||||||
Strategy:
|
Strategy:
|
||||||
- Posts (images): instaloader with saved session
|
- Posts/images: instaloader (with session) → yt-dlp fallback
|
||||||
- Posts (videos): yt-dlp first, instaloader fallback
|
- Reels/videos: yt-dlp (instaloader 403s on reels)
|
||||||
- Reels: yt-dlp (instaloader 403s on reels)
|
- Profile pics: instaloader (uses username directly, no shortcode)
|
||||||
- Profile pics: instaloader with saved session
|
|
||||||
- Thumbnails: yt-dlp
|
- Thumbnails: yt-dlp
|
||||||
|
|
||||||
Auth: run this once on the tablet to create ig_session:
|
Session setup (run once on the tablet):
|
||||||
python3 -c "
|
python3 -c "
|
||||||
import instaloader, os
|
import instaloader
|
||||||
L = instaloader.Instaloader()
|
L = instaloader.Instaloader()
|
||||||
L.interactive_login('your_username')
|
L.interactive_login('your_ig_username')
|
||||||
L.save_session_to_file('ig_session')
|
L.save_session_to_file('ig_session')
|
||||||
print('saved')
|
print('saved')
|
||||||
"
|
"
|
||||||
|
|
@ -81,17 +80,15 @@ def _get_loader() -> instaloader.Instaloader:
|
||||||
quiet=True,
|
quiet=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# prefer saved session file — avoids triggering suspicious login alerts
|
|
||||||
if SESSION_FILE.exists():
|
if SESSION_FILE.exists():
|
||||||
try:
|
try:
|
||||||
L.load_session_from_file(IG_USERNAME or "user", str(SESSION_FILE))
|
L.load_session_from_file(IG_USERNAME or "user", str(SESSION_FILE))
|
||||||
print("[ig] loaded session from file")
|
print("[ig] session loaded from file")
|
||||||
_loader = L
|
_loader = L
|
||||||
return _loader
|
return _loader
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ig] session file load failed: {e}")
|
print(f"[ig] session file failed: {e}")
|
||||||
|
|
||||||
# fall back to env var credentials
|
|
||||||
if IG_USERNAME and IG_PASSWORD:
|
if IG_USERNAME and IG_PASSWORD:
|
||||||
try:
|
try:
|
||||||
L.login(IG_USERNAME, IG_PASSWORD)
|
L.login(IG_USERNAME, IG_PASSWORD)
|
||||||
|
|
@ -101,32 +98,17 @@ def _get_loader() -> instaloader.Instaloader:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ig] password login failed: {e}")
|
print(f"[ig] password login failed: {e}")
|
||||||
|
|
||||||
print("[ig] WARNING: no session — most IG content requires login now")
|
print("[ig] WARNING: unauthenticated — most IG content will fail")
|
||||||
_loader = L
|
_loader = L
|
||||||
return _loader
|
return _loader
|
||||||
|
|
||||||
|
|
||||||
def _shortcode_from_url(url: str) -> str:
|
def _download_image_url(url: str, dest: Path) -> Path:
|
||||||
m = re.search(r"/(?:p|reel|reels|tv)/([A-Za-z0-9_-]+)", url)
|
import httpx
|
||||||
if m:
|
resp = httpx.get(url, follow_redirects=True, timeout=20)
|
||||||
return m.group(1)
|
resp.raise_for_status()
|
||||||
raise ValueError(
|
dest.write_bytes(resp.content)
|
||||||
f"could not extract shortcode from: {url}\n"
|
return dest
|
||||||
"must be a post, reel, or tv URL — not a profile or explore page"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _username_from_url(url: str) -> str:
|
|
||||||
path = re.sub(r"https?://(www\.)?instagram\.com/?", "", url)
|
|
||||||
path = path.lstrip("@")
|
|
||||||
username = path.split("/")[0].split("?")[0].strip()
|
|
||||||
if not username:
|
|
||||||
raise ValueError("could not extract username from URL")
|
|
||||||
if username in ("p", "reel", "reels", "tv", "explore", "stories", "accounts"):
|
|
||||||
raise ValueError(
|
|
||||||
f"that looks like a post/page URL, not a profile — got: {username}"
|
|
||||||
)
|
|
||||||
return username
|
|
||||||
|
|
||||||
|
|
||||||
def _trim_video(src: Path, dest: Path, start: Optional[str], end: Optional[str]) -> Path:
|
def _trim_video(src: Path, dest: Path, start: Optional[str], end: Optional[str]) -> Path:
|
||||||
|
|
@ -144,18 +126,20 @@ def _trim_video(src: Path, dest: Path, start: Optional[str], end: Optional[str])
|
||||||
return dest
|
return dest
|
||||||
|
|
||||||
|
|
||||||
def _download_image(url: str, dest: Path) -> Path:
|
def _first_media(directory: Path) -> Optional[Path]:
|
||||||
import httpx
|
return next(
|
||||||
resp = httpx.get(url, follow_redirects=True, timeout=20)
|
(f for f in sorted(directory.iterdir())
|
||||||
resp.raise_for_status()
|
if f.suffix in (".mp4", ".jpg", ".jpeg", ".png")),
|
||||||
dest.write_bytes(resp.content)
|
None,
|
||||||
return dest
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── info ──────────────────────────────────────────────────────────────────────
|
# ── post info (shortcode-based) ───────────────────────────────────────────────
|
||||||
|
|
||||||
async def fetch_ig_info(url: str) -> dict:
|
async def fetch_ig_post_info(url: str) -> dict:
|
||||||
def _fetch_ytdlp():
|
"""Info for post / reel / tv URLs."""
|
||||||
|
|
||||||
|
def _via_ytdlp():
|
||||||
opts = {**_BASE_YDL, "skip_download": True}
|
opts = {**_BASE_YDL, "skip_download": True}
|
||||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||||
info = ydl.extract_info(url, download=False)
|
info = ydl.extract_info(url, download=False)
|
||||||
|
|
@ -169,8 +153,9 @@ async def fetch_ig_info(url: str) -> dict:
|
||||||
"likes": info.get("like_count"),
|
"likes": info.get("like_count"),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _fetch_instaloader():
|
def _via_instaloader():
|
||||||
shortcode = _shortcode_from_url(url)
|
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)
|
post = instaloader.Post.from_shortcode(L.context, shortcode)
|
||||||
return {
|
return {
|
||||||
|
|
@ -184,13 +169,36 @@ async def fetch_ig_info(url: str) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await _run(_fetch_ytdlp)
|
return await _run(_via_ytdlp)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return await _run(_fetch_instaloader)
|
return await _run(_via_instaloader)
|
||||||
|
|
||||||
|
|
||||||
# ── post ──────────────────────────────────────────────────────────────────────
|
# ── profile info (username-based) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def fetch_ig_profile_info(username: str) -> dict:
|
||||||
|
"""Info for profile pages — uses username directly, no shortcode."""
|
||||||
|
|
||||||
|
def _fetch():
|
||||||
|
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, # repurpose likes field for follower count
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _run(_fetch)
|
||||||
|
|
||||||
|
|
||||||
|
# ── post download ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def download_post(
|
async def download_post(
|
||||||
url: str,
|
url: str,
|
||||||
|
|
@ -200,7 +208,7 @@ async def download_post(
|
||||||
) -> Tuple[str, str]:
|
) -> Tuple[str, str]:
|
||||||
uid = _uid()
|
uid = _uid()
|
||||||
|
|
||||||
def _try_ytdlp():
|
def _via_ytdlp():
|
||||||
out_tmpl = str(DOWNLOAD_DIR / f"ig_post_{uid}.%(ext)s")
|
out_tmpl = str(DOWNLOAD_DIR / f"ig_post_{uid}.%(ext)s")
|
||||||
opts = {
|
opts = {
|
||||||
**_BASE_YDL,
|
**_BASE_YDL,
|
||||||
|
|
@ -211,58 +219,48 @@ async def download_post(
|
||||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||||
info = ydl.extract_info(url)
|
info = ydl.extract_info(url)
|
||||||
owner = _safe(info.get("uploader") or "ig")
|
owner = _safe(info.get("uploader") or "ig")
|
||||||
|
|
||||||
candidates = list(DOWNLOAD_DIR.glob(f"ig_post_{uid}.*"))
|
candidates = list(DOWNLOAD_DIR.glob(f"ig_post_{uid}.*"))
|
||||||
if not candidates:
|
if not candidates:
|
||||||
raise FileNotFoundError("yt-dlp produced no file")
|
raise FileNotFoundError("yt-dlp produced no file")
|
||||||
final = candidates[0]
|
final = candidates[0]
|
||||||
|
|
||||||
if (start_time or end_time) and final.suffix == ".mp4":
|
if (start_time or end_time) and final.suffix == ".mp4":
|
||||||
trimmed = DOWNLOAD_DIR / f"ig_post_{uid}_trim.mp4"
|
trimmed = DOWNLOAD_DIR / f"ig_post_{uid}_trim.mp4"
|
||||||
_trim_video(final, trimmed, start_time, end_time)
|
_trim_video(final, trimmed, start_time, end_time)
|
||||||
final.unlink(missing_ok=True)
|
final.unlink(missing_ok=True)
|
||||||
final = trimmed
|
final = trimmed
|
||||||
|
|
||||||
return str(final), f"{owner}_{uid}{final.suffix}"
|
return str(final), f"{owner}_{uid}{final.suffix}"
|
||||||
|
|
||||||
def _try_instaloader():
|
def _via_instaloader():
|
||||||
|
from utils import extract_ig_shortcode
|
||||||
tmp_dir = DOWNLOAD_DIR / f"ig_il_{uid}"
|
tmp_dir = DOWNLOAD_DIR / f"ig_il_{uid}"
|
||||||
tmp_dir.mkdir(exist_ok=True)
|
tmp_dir.mkdir(exist_ok=True)
|
||||||
try:
|
try:
|
||||||
shortcode = _shortcode_from_url(url)
|
shortcode = extract_ig_shortcode(url)
|
||||||
L = _get_loader()
|
L = _get_loader()
|
||||||
post = instaloader.Post.from_shortcode(L.context, shortcode)
|
post = instaloader.Post.from_shortcode(L.context, shortcode)
|
||||||
L.dirname_pattern = str(tmp_dir)
|
L.dirname_pattern = str(tmp_dir)
|
||||||
L.download_post(post, target=str(tmp_dir))
|
L.download_post(post, target=str(tmp_dir))
|
||||||
|
media = _first_media(tmp_dir)
|
||||||
media = next(
|
|
||||||
(f for f in sorted(tmp_dir.iterdir())
|
|
||||||
if f.suffix in (".mp4", ".jpg", ".jpeg", ".png")),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not media:
|
if not media:
|
||||||
raise FileNotFoundError("instaloader produced no media file")
|
raise FileNotFoundError("instaloader produced no media file")
|
||||||
|
|
||||||
owner = _safe(post.owner_username)
|
owner = _safe(post.owner_username)
|
||||||
dest = DOWNLOAD_DIR / f"ig_post_{uid}{media.suffix}"
|
dest = DOWNLOAD_DIR / f"ig_post_{uid}{media.suffix}"
|
||||||
|
|
||||||
if post.is_video and (start_time or end_time):
|
if post.is_video and (start_time or end_time):
|
||||||
_trim_video(media, dest, start_time, end_time)
|
_trim_video(media, dest, start_time, end_time)
|
||||||
else:
|
else:
|
||||||
shutil.copy2(media, dest)
|
shutil.copy2(media, dest)
|
||||||
|
|
||||||
return str(dest), f"{owner}_{uid}{media.suffix}"
|
return str(dest), f"{owner}_{uid}{media.suffix}"
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await _run(_try_ytdlp)
|
return await _run(_via_ytdlp)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return await _run(_try_instaloader)
|
return await _run(_via_instaloader)
|
||||||
|
|
||||||
|
|
||||||
# ── reel ──────────────────────────────────────────────────────────────────────
|
# ── reel download (yt-dlp only) ───────────────────────────────────────────────
|
||||||
|
|
||||||
async def download_reel(
|
async def download_reel(
|
||||||
url: str,
|
url: str,
|
||||||
|
|
@ -283,46 +281,38 @@ async def download_reel(
|
||||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||||
info = ydl.extract_info(url)
|
info = ydl.extract_info(url)
|
||||||
owner = _safe(info.get("uploader") or "ig")
|
owner = _safe(info.get("uploader") or "ig")
|
||||||
|
|
||||||
candidates = list(DOWNLOAD_DIR.glob(f"ig_reel_{uid}.*"))
|
candidates = list(DOWNLOAD_DIR.glob(f"ig_reel_{uid}.*"))
|
||||||
if not candidates:
|
if not candidates:
|
||||||
raise FileNotFoundError("yt-dlp produced no reel file")
|
raise FileNotFoundError("yt-dlp produced no reel file")
|
||||||
final = candidates[0]
|
final = candidates[0]
|
||||||
|
|
||||||
if start_time or end_time:
|
if start_time or end_time:
|
||||||
trimmed = DOWNLOAD_DIR / f"ig_reel_{uid}_trim.mp4"
|
trimmed = DOWNLOAD_DIR / f"ig_reel_{uid}_trim.mp4"
|
||||||
_trim_video(final, trimmed, start_time, end_time)
|
_trim_video(final, trimmed, start_time, end_time)
|
||||||
final.unlink(missing_ok=True)
|
final.unlink(missing_ok=True)
|
||||||
final = trimmed
|
final = trimmed
|
||||||
|
|
||||||
return str(final), f"{owner}_reel_{uid}.mp4"
|
return str(final), f"{owner}_reel_{uid}.mp4"
|
||||||
|
|
||||||
return await _run(_dl)
|
return await _run(_dl)
|
||||||
|
|
||||||
|
|
||||||
# ── profile picture ───────────────────────────────────────────────────────────
|
# ── profile picture download ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async def download_profile(username_or_url: str) -> Tuple[str, str]:
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
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).
|
||||||
|
"""
|
||||||
uid = _uid()
|
uid = _uid()
|
||||||
|
|
||||||
def _dl():
|
def _dl():
|
||||||
L = _get_loader()
|
L = _get_loader()
|
||||||
profile = instaloader.Profile.from_username(L.context, username)
|
profile = instaloader.Profile.from_username(L.context, username)
|
||||||
# profile_pic_url_no_iphone gives higher resolution
|
|
||||||
try:
|
try:
|
||||||
pic_url = profile.profile_pic_url_no_iphone
|
pic_url = profile.profile_pic_url_no_iphone
|
||||||
except Exception:
|
except Exception:
|
||||||
pic_url = profile.profile_pic_url
|
pic_url = profile.profile_pic_url
|
||||||
path = DOWNLOAD_DIR / f"ig_pfp_{_safe(username)}_{uid}.jpg"
|
path = DOWNLOAD_DIR / f"ig_pfp_{_safe(username)}_{uid}.jpg"
|
||||||
_download_image(pic_url, path)
|
_download_image_url(pic_url, path)
|
||||||
return str(path), f"{_safe(username)}_profile.jpg"
|
return str(path), f"{_safe(username)}_profile.jpg"
|
||||||
|
|
||||||
return await _run(_dl)
|
return await _run(_dl)
|
||||||
|
|
@ -342,7 +332,7 @@ async def download_ig_thumbnail(url: str) -> Tuple[str, str]:
|
||||||
raise ValueError("no thumbnail found for this post")
|
raise ValueError("no thumbnail found for this post")
|
||||||
owner = _safe(info.get("uploader") or "ig")
|
owner = _safe(info.get("uploader") or "ig")
|
||||||
path = DOWNLOAD_DIR / f"ig_thumb_{uid}.jpg"
|
path = DOWNLOAD_DIR / f"ig_thumb_{uid}.jpg"
|
||||||
_download_image(thumb_url, path)
|
_download_image_url(thumb_url, path)
|
||||||
return str(path), f"{owner}_thumbnail_{uid}.jpg"
|
return str(path), f"{owner}_thumbnail_{uid}.jpg"
|
||||||
|
|
||||||
return await _run(_dl)
|
return await _run(_dl)
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ def _parse_time(t: str) -> float:
|
||||||
return parts[0]
|
return parts[0]
|
||||||
|
|
||||||
|
|
||||||
def _download_image(url: str, dest: Path) -> Path:
|
def _download_image_url(url: str, dest: Path) -> Path:
|
||||||
import httpx
|
import httpx
|
||||||
resp = httpx.get(url, follow_redirects=True, timeout=20)
|
resp = httpx.get(url, follow_redirects=True, timeout=20)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
@ -58,6 +58,11 @@ def _download_image(url: str, dest: Path) -> Path:
|
||||||
return dest
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def _first_file(pattern: str) -> Optional[Path]:
|
||||||
|
candidates = list(DOWNLOAD_DIR.glob(pattern))
|
||||||
|
return candidates[0] if candidates else None
|
||||||
|
|
||||||
|
|
||||||
# ── info ──────────────────────────────────────────────────────────────────────
|
# ── info ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def fetch_yt_info(url: str) -> dict:
|
async def fetch_yt_info(url: str) -> dict:
|
||||||
|
|
@ -132,10 +137,9 @@ async def download_video(
|
||||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||||
info = ydl.extract_info(url)
|
info = ydl.extract_info(url)
|
||||||
title = _safe(info.get("title", "video"))
|
title = _safe(info.get("title", "video"))
|
||||||
candidates = list(DOWNLOAD_DIR.glob(f"yt_video_{uid}.*"))
|
final = _first_file(f"yt_video_{uid}.*")
|
||||||
if not candidates:
|
if not final:
|
||||||
raise FileNotFoundError("output file not found after download")
|
raise FileNotFoundError("output file not found after download")
|
||||||
final = candidates[0]
|
|
||||||
return str(final), f"{title}{final.suffix}"
|
return str(final), f"{title}{final.suffix}"
|
||||||
|
|
||||||
return await _run(_dl)
|
return await _run(_dl)
|
||||||
|
|
@ -150,20 +154,14 @@ async def download_audio(
|
||||||
) -> Tuple[str, str]:
|
) -> Tuple[str, str]:
|
||||||
uid = _uid()
|
uid = _uid()
|
||||||
out_tmpl = str(DOWNLOAD_DIR / f"yt_audio_{uid}.%(ext)s")
|
out_tmpl = str(DOWNLOAD_DIR / f"yt_audio_{uid}.%(ext)s")
|
||||||
|
codec = {"mp3": "mp3", "opus": "opus", "flac": "flac", "m4a": "m4a"}.get(fmt, "mp3")
|
||||||
codec_map = {"mp3": "mp3", "opus": "opus", "flac": "flac", "m4a": "m4a"}
|
|
||||||
codec = codec_map.get(fmt, "mp3")
|
|
||||||
|
|
||||||
opts = {
|
opts = {
|
||||||
**_BASE_OPTS,
|
**_BASE_OPTS,
|
||||||
"format": "bestaudio/best",
|
"format": "bestaudio/best",
|
||||||
"outtmpl": out_tmpl,
|
"outtmpl": out_tmpl,
|
||||||
"postprocessors": [
|
"postprocessors": [
|
||||||
{
|
{"key": "FFmpegExtractAudio", "preferredcodec": codec, "preferredquality": quality},
|
||||||
"key": "FFmpegExtractAudio",
|
|
||||||
"preferredcodec": codec,
|
|
||||||
"preferredquality": quality,
|
|
||||||
},
|
|
||||||
{"key": "FFmpegMetadata", "add_metadata": True},
|
{"key": "FFmpegMetadata", "add_metadata": True},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
@ -172,10 +170,9 @@ async def download_audio(
|
||||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||||
info = ydl.extract_info(url)
|
info = ydl.extract_info(url)
|
||||||
title = _safe(info.get("title", "audio"))
|
title = _safe(info.get("title", "audio"))
|
||||||
candidates = list(DOWNLOAD_DIR.glob(f"yt_audio_{uid}.*"))
|
final = _first_file(f"yt_audio_{uid}.*")
|
||||||
if not candidates:
|
if not final:
|
||||||
raise FileNotFoundError("audio file not found after download")
|
raise FileNotFoundError("audio file not found after download")
|
||||||
final = candidates[0]
|
|
||||||
return str(final), f"{title}{final.suffix}"
|
return str(final), f"{title}{final.suffix}"
|
||||||
|
|
||||||
return await _run(_dl)
|
return await _run(_dl)
|
||||||
|
|
@ -231,12 +228,10 @@ async def download_subtitles(url: str, lang: str = "en") -> Tuple[str, str]:
|
||||||
candidate = DOWNLOAD_DIR / f"yt_subs_{uid}.{lang}.{ext}"
|
candidate = DOWNLOAD_DIR / f"yt_subs_{uid}.{lang}.{ext}"
|
||||||
if candidate.exists():
|
if candidate.exists():
|
||||||
return str(candidate), f"{title}.{lang}.{ext}"
|
return str(candidate), f"{title}.{lang}.{ext}"
|
||||||
candidates = list(DOWNLOAD_DIR.glob(f"yt_subs_{uid}.*"))
|
final = _first_file(f"yt_subs_{uid}.*")
|
||||||
if candidates:
|
if final:
|
||||||
return str(candidates[0]), candidates[0].name
|
return str(final), final.name
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(f"no subtitles found for lang={lang}")
|
||||||
f"no subtitles found for lang={lang} — video may not have them"
|
|
||||||
)
|
|
||||||
|
|
||||||
return await _run(_dl)
|
return await _run(_dl)
|
||||||
|
|
||||||
|
|
@ -245,15 +240,22 @@ async def download_subtitles(url: str, lang: str = "en") -> Tuple[str, str]:
|
||||||
|
|
||||||
async def download_channel_art(channel_url: str) -> List[dict]:
|
async def download_channel_art(channel_url: str) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
Downloads channel avatar and banner as separate JPEG files.
|
Given a channel URL (youtube.com/@handle or youtube.com/channel/UCxxxxx),
|
||||||
Returns list of {"type", "filename", "url"} dicts.
|
downloads the avatar and banner as separate JPEG files.
|
||||||
Avatar and banner are identified by aspect ratio heuristics.
|
|
||||||
|
Returns:
|
||||||
|
[
|
||||||
|
{"type": "avatar", "filename": "channelname_avatar.jpg", "url": "/files/..."},
|
||||||
|
{"type": "banner", "filename": "channelname_banner.jpg", "url": "/files/..."},
|
||||||
|
]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _dl():
|
def _dl():
|
||||||
uid = _uid()
|
import httpx
|
||||||
results = []
|
|
||||||
|
|
||||||
|
uid = _uid()
|
||||||
|
|
||||||
|
# fetch channel metadata — extract_flat avoids fetching all videos
|
||||||
opts = {
|
opts = {
|
||||||
**_BASE_OPTS,
|
**_BASE_OPTS,
|
||||||
"skip_download": True,
|
"skip_download": True,
|
||||||
|
|
@ -262,71 +264,67 @@ async def download_channel_art(channel_url: str) -> List[dict]:
|
||||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||||
info = ydl.extract_info(channel_url, download=False)
|
info = ydl.extract_info(channel_url, download=False)
|
||||||
|
|
||||||
|
channel_name = _safe(info.get("channel") or info.get("uploader") or "channel")
|
||||||
thumbnails = info.get("thumbnails") or []
|
thumbnails = info.get("thumbnails") or []
|
||||||
|
results = []
|
||||||
|
|
||||||
# ── avatar ────────────────────────────────────────────────────────────
|
# ── avatar ────────────────────────────────────────────────────────────
|
||||||
# avatars are square-ish; YouTube avatar thumbnails often come from
|
# YouTube channel avatars come from ggpht.com and are square
|
||||||
# ggpht.com or have "photo" in the URL
|
|
||||||
avatar_url = None
|
avatar_url = None
|
||||||
avatar_candidates = [
|
|
||||||
t for t in thumbnails
|
# first try thumbnails tagged as square / avatar-like
|
||||||
if t.get("url") and _is_avatar(t)
|
square = [t for t in thumbnails if _is_avatar(t)]
|
||||||
]
|
if square:
|
||||||
if avatar_candidates:
|
# highest resolution square thumbnail
|
||||||
# pick highest resolution among avatar candidates
|
avatar_url = max(square, key=lambda t: (t.get("width") or 0) * (t.get("height") or 0))["url"]
|
||||||
avatar_url = max(
|
|
||||||
avatar_candidates,
|
# fallback: any thumbnail from ggpht (Google's image CDN for avatars)
|
||||||
key=lambda t: (t.get("width") or 0) * (t.get("height") or 0)
|
if not avatar_url:
|
||||||
)["url"]
|
ggpht = [t for t in thumbnails if "ggpht" in (t.get("url") or "")]
|
||||||
elif thumbnails:
|
if ggpht:
|
||||||
# last resort: use the last thumbnail
|
avatar_url = ggpht[-1]["url"]
|
||||||
avatar_url = thumbnails[-1]["url"]
|
|
||||||
|
# last resort: channel thumbnail field
|
||||||
|
if not avatar_url:
|
||||||
|
avatar_url = info.get("thumbnail")
|
||||||
|
|
||||||
if avatar_url:
|
if avatar_url:
|
||||||
path = DOWNLOAD_DIR / f"yt_ch_{uid}_avatar.jpg"
|
dest = DOWNLOAD_DIR / f"{channel_name}_avatar_{uid}.jpg"
|
||||||
try:
|
try:
|
||||||
_download_image(avatar_url, path)
|
_download_image_url(avatar_url, dest)
|
||||||
results.append({
|
results.append({
|
||||||
"type": "avatar",
|
"type": "avatar",
|
||||||
"filename": path.name,
|
"filename": dest.name,
|
||||||
"url": f"/files/{path.name}",
|
"url": f"/files/{dest.name}",
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[yt] avatar download failed: {e}")
|
print(f"[yt channel] avatar download failed: {e}")
|
||||||
|
|
||||||
# ── banner ────────────────────────────────────────────────────────────
|
# ── banner ────────────────────────────────────────────────────────────
|
||||||
# banners are very wide (ratio > 3:1), or keyed explicitly
|
banner_url = info.get("header_image") or info.get("banner")
|
||||||
banner_url = (
|
|
||||||
info.get("header_image")
|
|
||||||
or info.get("banner")
|
|
||||||
)
|
|
||||||
if not banner_url:
|
if not banner_url:
|
||||||
banner_candidates = [
|
# look for very wide thumbnails (banner aspect ratio)
|
||||||
t for t in thumbnails
|
wide = [t for t in thumbnails if _is_banner(t)]
|
||||||
if t.get("url") and _is_banner(t)
|
if wide:
|
||||||
]
|
banner_url = max(wide, key=lambda t: t.get("width") or 0)["url"]
|
||||||
if banner_candidates:
|
|
||||||
banner_url = max(
|
|
||||||
banner_candidates,
|
|
||||||
key=lambda t: (t.get("width") or 0)
|
|
||||||
)["url"]
|
|
||||||
|
|
||||||
if banner_url:
|
if banner_url:
|
||||||
path = DOWNLOAD_DIR / f"yt_ch_{uid}_banner.jpg"
|
dest = DOWNLOAD_DIR / f"{channel_name}_banner_{uid}.jpg"
|
||||||
try:
|
try:
|
||||||
_download_image(banner_url, path)
|
_download_image_url(banner_url, dest)
|
||||||
results.append({
|
results.append({
|
||||||
"type": "banner",
|
"type": "banner",
|
||||||
"filename": path.name,
|
"filename": dest.name,
|
||||||
"url": f"/files/{path.name}",
|
"url": f"/files/{dest.name}",
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[yt] banner download failed: {e}")
|
print(f"[yt channel] banner download failed: {e}")
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"no channel art found — pass a full channel URL like "
|
"no channel art found — make sure you're using a channel URL "
|
||||||
"youtube.com/@channelname or youtube.com/channel/UCxxxxxx"
|
"like youtube.com/@channelname or youtube.com/channel/UCxxxxxx"
|
||||||
)
|
)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
@ -337,10 +335,8 @@ async def download_channel_art(channel_url: str) -> List[dict]:
|
||||||
def _is_avatar(t: dict) -> bool:
|
def _is_avatar(t: dict) -> bool:
|
||||||
w = t.get("width") or 0
|
w = t.get("width") or 0
|
||||||
h = t.get("height") or 1
|
h = t.get("height") or 1
|
||||||
url = t.get("url", "")
|
url = t.get("url") or ""
|
||||||
if w and h:
|
if w and h and 0.8 < (w / h) < 1.3:
|
||||||
ratio = w / h
|
|
||||||
if 0.8 < ratio < 1.3:
|
|
||||||
return True
|
return True
|
||||||
return any(kw in url for kw in ("ggpht", "photo", "avatar"))
|
return any(kw in url for kw in ("ggpht", "photo", "avatar"))
|
||||||
|
|
||||||
|
|
@ -348,7 +344,7 @@ def _is_avatar(t: dict) -> bool:
|
||||||
def _is_banner(t: dict) -> bool:
|
def _is_banner(t: dict) -> bool:
|
||||||
w = t.get("width") or 0
|
w = t.get("width") or 0
|
||||||
h = t.get("height") or 1
|
h = t.get("height") or 1
|
||||||
url = t.get("url", "")
|
url = t.get("url") or ""
|
||||||
if w and h and (w / h) > 2.5:
|
if w and h and (w / h) > 2.5:
|
||||||
return True
|
return True
|
||||||
return "banner" in url.lower()
|
return "banner" in url.lower()
|
||||||
|
|
|
||||||
62
backend/utils.py
Normal file
62
backend/utils.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""
|
||||||
|
Shared URL utilities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def clean_yt_url(url: str) -> str:
|
||||||
|
"""
|
||||||
|
Strip playlist/radio params from YouTube URLs so yt-dlp
|
||||||
|
treats them as single videos.
|
||||||
|
|
||||||
|
youtube.com/watch?v=ABC&list=RD...&start_radio=1
|
||||||
|
→ youtube.com/watch?v=ABC
|
||||||
|
"""
|
||||||
|
# if it has a video id, keep only that
|
||||||
|
m = re.search(r"[?&]v=([A-Za-z0-9_-]{11})", url)
|
||||||
|
if m:
|
||||||
|
return f"https://www.youtube.com/watch?v={m.group(1)}"
|
||||||
|
# youtu.be short links — keep as-is (no query needed)
|
||||||
|
m2 = re.match(r"https?://youtu\.be/([A-Za-z0-9_-]{11})", url)
|
||||||
|
if m2:
|
||||||
|
return f"https://youtu.be/{m2.group(1)}"
|
||||||
|
# channel or playlist URL — return as-is
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def clean_ig_url(url: str) -> str:
|
||||||
|
"""Strip query params and trailing slashes from Instagram URLs."""
|
||||||
|
url = url.split("?")[0].rstrip("/")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def is_ig_profile_url(url: str) -> bool:
|
||||||
|
"""
|
||||||
|
Returns True if the URL points to a profile page rather than a post/reel.
|
||||||
|
Profile: instagram.com/username
|
||||||
|
Post: instagram.com/p/ABC instagram.com/reel/ABC
|
||||||
|
"""
|
||||||
|
path = re.sub(r"https?://(www\.)?instagram\.com/?", "", url).lstrip("/")
|
||||||
|
first_segment = path.split("/")[0].split("?")[0].lstrip("@")
|
||||||
|
return first_segment not in ("p", "reel", "reels", "tv", "explore", "stories", "accounts", "")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_ig_username(url: str) -> str:
|
||||||
|
"""Pull username from instagram.com/username or instagram.com/@username."""
|
||||||
|
path = re.sub(r"https?://(www\.)?instagram\.com/?", "", url).lstrip("/")
|
||||||
|
username = path.split("/")[0].split("?")[0].lstrip("@").strip()
|
||||||
|
if not username:
|
||||||
|
raise ValueError("could not extract username from URL")
|
||||||
|
return username
|
||||||
|
|
||||||
|
|
||||||
|
def extract_ig_shortcode(url: str) -> str:
|
||||||
|
"""Pull shortcode from post/reel/tv URLs."""
|
||||||
|
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} — "
|
||||||
|
"expected a post, reel, or tv URL"
|
||||||
|
)
|
||||||
Loading…
Add table
Reference in a new issue