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.

This commit is contained in:
OpenClaw Agent 2026-05-02 12:34:27 +00:00
parent 7a7d7ea20d
commit 77075795ce
3 changed files with 252 additions and 7 deletions

View File

@ -1345,17 +1345,100 @@ 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": payload.url,
"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(),
@ -1369,9 +1452,63 @@ async def submit_youtube(
"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": url,
"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,
}
save_job(job)
# Queue worker bo pograbil
return job

View File

@ -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")

View File

@ -340,8 +340,9 @@
</div>
<div id="tab-youtube" class="hidden">
<label>YouTube URL</label>
<input type="url" id="yt-url" placeholder="https://www.youtube.com/watch?v=...">
<label>YouTube URL <span style="font-size:11px; color:var(--muted); font-weight:normal;">— en video ali cela playlist</span></label>
<input type="url" id="yt-url" placeholder="https://www.youtube.com/watch?v=... ali https://www.youtube.com/playlist?list=...">
<div style="font-size:11px; color:var(--muted); margin-top:4px;">💡 Playlist: vsak komad postane svoj reel — Qnet auto-match po naslovu</div>
</div>
<!-- TV postaja: določa Nextcloud target mapo -->
@ -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) {