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:
parent
7a7d7ea20d
commit
77075795ce
141
app/main.py
141
app/main.py
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user