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,154 @@ async def upload_video(
|
|||||||
# ────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────
|
||||||
# YouTube submit
|
# 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")
|
@app.post("/api/youtube")
|
||||||
async def submit_youtube(
|
async def submit_youtube(
|
||||||
payload: YouTubeJobIn,
|
payload: YouTubeJobIn,
|
||||||
background: BackgroundTasks,
|
background: BackgroundTasks,
|
||||||
user: str = Depends(check_auth),
|
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 = uuid.uuid4().hex[:12]
|
||||||
job = {
|
job = {
|
||||||
"id": job_id,
|
"id": job_id,
|
||||||
"source_type": "youtube",
|
"source_type": "youtube",
|
||||||
"youtube_url": payload.url,
|
"youtube_url": url,
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"current_step": "V vrsti za YouTube prenos",
|
"current_step": "V vrsti za YouTube prenos",
|
||||||
"created_at": time.time(),
|
"created_at": time.time(),
|
||||||
@ -1369,9 +1506,9 @@ async def submit_youtube(
|
|||||||
"subtitle_style": payload.subtitle_style,
|
"subtitle_style": payload.subtitle_style,
|
||||||
"whisper_model": payload.whisper_model,
|
"whisper_model": payload.whisper_model,
|
||||||
"quality": payload.quality,
|
"quality": payload.quality,
|
||||||
|
"tv_station": payload.tv_station,
|
||||||
}
|
}
|
||||||
save_job(job)
|
save_job(job)
|
||||||
# Queue worker bo pograbil
|
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -74,6 +74,69 @@ def get_info(url, cookies_file=None):
|
|||||||
return json.loads(result.stdout.strip().split("\n")[0])
|
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():
|
def main():
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("url")
|
ap.add_argument("url")
|
||||||
|
|||||||
@ -340,8 +340,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tab-youtube" class="hidden">
|
<div id="tab-youtube" class="hidden">
|
||||||
<label>YouTube URL</label>
|
<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=...">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- TV postaja: določa Nextcloud target mapo -->
|
<!-- TV postaja: določa Nextcloud target mapo -->
|
||||||
@ -820,7 +821,42 @@
|
|||||||
if (isYT) {
|
if (isYT) {
|
||||||
const url = $("#yt-url").value.trim();
|
const url = $("#yt-url").value.trim();
|
||||||
if (!url) { alert("Vpiši YouTube URL"); $("#submit-btn").disabled = false; return; }
|
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", {
|
const r = await fetch("/api/youtube", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@ -831,8 +867,17 @@
|
|||||||
liveFail("YouTube submit napaka: " + err);
|
liveFail("YouTube submit napaka: " + err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const job = await r.json();
|
const data = await r.json();
|
||||||
watchJob(job.id);
|
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();
|
refreshJobs();
|
||||||
} else {
|
} else {
|
||||||
if (pendingFiles.length === 0) {
|
if (pendingFiles.length === 0) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user