From 77075795cedce6580d6e9a10c832569bbe84995f Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Sat, 2 May 2026 12:34:27 +0000 Subject: [PATCH] YouTube playlist support: /api/youtube/playlist-preview za pred-confirm, /api/youtube zaznava 'list=' URL in kreira batch (1 job/video). Qnet auto-match na YT naslovu, Confirm dialog v UI z prvih 5 naslovov. --- app/main.py | 141 ++++++++++++++++++++++++++++++++++++++++- scripts/yt_download.py | 63 ++++++++++++++++++ templates/index.html | 55 ++++++++++++++-- 3 files changed, 252 insertions(+), 7 deletions(-) diff --git a/app/main.py b/app/main.py index 74ebed9..cae3d18 100644 --- a/app/main.py +++ b/app/main.py @@ -1345,17 +1345,154 @@ async def upload_video( # ──────────────────────────────────────────────────────────────── # YouTube submit # ──────────────────────────────────────────────────────────────── +class YouTubePlaylistPreviewIn(BaseModel): + url: str + + +@app.post("/api/youtube/playlist-preview") +async def youtube_playlist_preview(payload: YouTubePlaylistPreviewIn, user: str = Depends(check_auth)): + """Resolve YouTube URL — vrne listo videov če je playlist, ali en item. + + Frontend uporabi to za preview pred submit-om: če je playlist, user vidi + koliko komadov je in lahko potrdi. + """ + import sys as _sys + _sys.path.insert(0, str(SCRIPTS_DIR)) + try: + from yt_download import get_playlist + except ImportError: + raise HTTPException(500, "yt_download modul ne dostopen") + + try: + info = get_playlist(payload.url) + except subprocess.TimeoutExpired: + raise HTTPException(504, "yt-dlp timeout (>120s) — preveri URL") + except Exception as e: + raise HTTPException(500, f"yt-dlp napaka: {e}") + + if info.get("error"): + raise HTTPException(400, f"yt-dlp: {info['error']}") + if not info.get("items"): + raise HTTPException(400, "Ni najdenih videov na tem URL-u") + + return info + + @app.post("/api/youtube") async def submit_youtube( payload: YouTubeJobIn, background: BackgroundTasks, user: str = Depends(check_auth), ): + """Submit YouTube URL — single video ali cela playlist. + + Če URL vsebuje 'list=' parameter, resolve-amo playlist in kreiramo + batch jobs (en job za vsak video). Single video kreira en job. + """ + url = payload.url.strip() + is_playlist_url = "list=" in url and "watch?" not in url.split("list=")[0].split("&")[0] + # Bolj robustno: če ima list= ampak tudi v= potem je morda single video iz playliste + # → tretiraj kot single (default) + has_list = "list=" in url + has_v = "v=" in url or "/watch?" in url or "youtu.be/" in url + treat_as_playlist = has_list and not has_v # samo "playlist?list=..." URLs + # Pa tudi če je explicit "/playlist?" v URL-u + if "/playlist?" in url: + treat_as_playlist = True + + if treat_as_playlist: + # Resolve playlist + import sys as _sys + _sys.path.insert(0, str(SCRIPTS_DIR)) + try: + from yt_download import get_playlist + except ImportError: + raise HTTPException(500, "yt_download modul ne dostopen") + + try: + info = get_playlist(url) + except Exception as e: + raise HTTPException(500, f"Playlist resolve napaka: {e}") + + items = info.get("items", []) + if not items: + raise HTTPException(400, "Playlist je prazen ali ni dostopen") + + # Batch ID za grupiranje + batch_id = "yt-batch-" + uuid.uuid4().hex[:8] + playlist_title = info.get("playlist_title", "YouTube Playlist") + created_jobs = [] + + for item in items: + job_id = uuid.uuid4().hex[:12] + # Probaj match proti Qnet bazi (parsa Artist - Title iz YouTube naslova) + yt_title = item.get("title", "") + qm = qnet_match.match_filename(yt_title) + + job = { + "id": job_id, + "source_type": "youtube", + "youtube_url": item["url"], + "youtube_title": yt_title, + "youtube_id": item.get("id"), + "youtube_uploader": item.get("uploader", ""), + "playlist_url": url, + "playlist_title": playlist_title, + "batch_id": batch_id, + "status": "queued", + "current_step": "V vrsti za YouTube prenos", + "created_at": time.time(), + "updated_at": time.time(), + "mode": payload.mode, + "lang": payload.lang, + "auto_chorus": payload.auto_chorus, + "start": payload.start, + "duration": payload.duration, + "no_subs": payload.no_subs, + "subtitle_style": payload.subtitle_style, + "whisper_model": payload.whisper_model, + "quality": payload.quality, + "tv_station": payload.tv_station, + } + + # Qnet match — auto-fill clean Artist/Title in tv_station če je matched + if qm["matched"] and qm["confidence"] >= 0.85: + job["parsed_artist"] = qm["artist"] + job["parsed_title"] = qm["title"] + job["has_clean_name"] = True + job["qnet_match"] = { + "method": qm["method"], + "confidence": qm["confidence"], + "matched_file": qm["file"], + "matched_station": qm["station"], + } + # Auto-set tv_station iz Qnet match-a (override default) + job["tv_station"] = qm["station"] + else: + # Fallback: regex parser na YT naslovu + a, t = parse_artist_title(yt_title) + if a: job["parsed_artist"] = a + if t: job["parsed_title"] = t + job["has_clean_name"] = bool(a and t) + + save_job(job) + created_jobs.append(job) + + print(f"📋 YouTube playlist '{playlist_title}': {len(created_jobs)} jobov v batch {batch_id}", flush=True) + return { + "is_playlist": True, + "batch_id": batch_id, + "playlist_title": playlist_title, + "count": len(created_jobs), + "jobs": created_jobs, + } + + # ─── Single video ─── job_id = uuid.uuid4().hex[:12] job = { "id": job_id, "source_type": "youtube", - "youtube_url": payload.url, + "youtube_url": url, "status": "queued", "current_step": "V vrsti za YouTube prenos", "created_at": time.time(), @@ -1369,9 +1506,9 @@ async def submit_youtube( "subtitle_style": payload.subtitle_style, "whisper_model": payload.whisper_model, "quality": payload.quality, + "tv_station": payload.tv_station, } save_job(job) - # Queue worker bo pograbil return job diff --git a/scripts/yt_download.py b/scripts/yt_download.py index 17f77e6..62dcde0 100644 --- a/scripts/yt_download.py +++ b/scripts/yt_download.py @@ -74,6 +74,69 @@ def get_info(url, cookies_file=None): return json.loads(result.stdout.strip().split("\n")[0]) +def get_playlist(url, cookies_file=None): + """Vrni listo videov v playlistu (samo metadata, ne pobere). + + Returns: + { + "is_playlist": bool, + "playlist_title": str, + "items": [ + {"id": "VIDEO_ID", "title": "...", "url": "https://...", "duration": 234}, + ... + ] + } + + Če URL ni playlist, vrne is_playlist=False in samo en item. + """ + cmd = ["yt-dlp", "--flat-playlist", "--dump-json"] + if cookies_file is None: + for candidate in ["/data/cookies/youtube.txt", os.environ.get("YT_COOKIES_FILE", "")]: + if candidate and Path(candidate).exists(): + cookies_file = candidate + break + if cookies_file and Path(cookies_file).exists(): + cmd += ["--cookies", str(cookies_file)] + cmd.append(url) + print(f"📋 Resolving playlist: {url}", file=sys.stderr) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + if result.returncode != 0: + print(f"❌ yt-dlp playlist napaka:\n{result.stderr[-1500:]}", file=sys.stderr) + return {"is_playlist": False, "playlist_title": "", "items": [], "error": result.stderr[-500:]} + + items = [] + playlist_title = "" + for line in result.stdout.strip().split("\n"): + if not line.strip(): + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + # Playlist header (typ=playlist) + if entry.get("_type") == "playlist": + playlist_title = entry.get("title", "") + continue + # Video entry + vid = entry.get("id") + if not vid: + continue + items.append({ + "id": vid, + "title": entry.get("title", "") or "", + "url": entry.get("url") or f"https://www.youtube.com/watch?v={vid}", + "duration": entry.get("duration"), + "uploader": entry.get("uploader") or entry.get("channel") or "", + }) + + is_playlist = len(items) > 1 or ("list=" in url) + return { + "is_playlist": is_playlist, + "playlist_title": playlist_title or (items[0]["title"] if items else ""), + "items": items, + } + + def main(): ap = argparse.ArgumentParser() ap.add_argument("url") diff --git a/templates/index.html b/templates/index.html index ec2041a..607de5a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -340,8 +340,9 @@ @@ -820,7 +821,42 @@ if (isYT) { const url = $("#yt-url").value.trim(); if (!url) { alert("Vpiši YouTube URL"); $("#submit-btn").disabled = false; return; } - showLive("Pošiljam YouTube job...", url, null); + + // Detect playlist URL + const isPlaylist = /\/playlist\?/.test(url) || (url.includes("list=") && !url.match(/[?&]v=/)); + + if (isPlaylist) { + showLive("Berem playlist...", url, null); + try { + const previewR = await fetch("/api/youtube/playlist-preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + }); + if (!previewR.ok) { + const err = await previewR.text(); + liveFail("Ne morem prebrati playlist: " + err); + $("#submit-btn").disabled = false; + return; + } + const preview = await previewR.json(); + const count = preview.items ? preview.items.length : 0; + const titlePreview = preview.items.slice(0, 5).map(it => ` • ${it.title}`).join("\n"); + const moreText = count > 5 ? `\n ... in še ${count - 5} ostalih` : ""; + const confirm_msg = `Najdenih ${count} komadov v playlistu "${preview.playlist_title}":\n\n${titlePreview}${moreText}\n\nNaloži vse in obdelaj?`; + if (!confirm(confirm_msg)) { + liveReset(); + $("#submit-btn").disabled = false; + return; + } + } catch (e) { + liveFail("Playlist preview napaka: " + e.message); + $("#submit-btn").disabled = false; + return; + } + } + + showLive(isPlaylist ? "Pošiljam playlist v queue..." : "Pošiljam YouTube job...", url, null); const r = await fetch("/api/youtube", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -831,8 +867,17 @@ liveFail("YouTube submit napaka: " + err); return; } - const job = await r.json(); - watchJob(job.id); + const data = await r.json(); + if (data.is_playlist) { + // Batch playlist response + showLive(`✅ ${data.count} komadov v queueu`, + `Playlist "${data.playlist_title}" — obdelujejo se zaporedno`, 100); + // Watch all jobs + (data.jobs || []).forEach(j => watchJob(j.id)); + } else { + // Single video + watchJob(data.id); + } refreshJobs(); } else { if (pendingFiles.length === 0) {