feat(yt): YouTube offline — hent til Emby, slet igen når videoen er set
Nyt admin-modul /admin/yt: indsæt en YouTube-URL, og videoen hentes med yt-dlp til Emby-biblioteket "YouTube" (/media/yt-offline) — klar til offline-brug. Oprydningen er bevidst modsat mediaclean.py: dér tæller selv delvis afspilning som "set" for at frede alt der er rørt, her slettes kun videoer der er eksplicit markeret som set (Emby UserData.Played). Halvt sete videoer bliver liggende. Favoritter slettes aldrig, og en favoritering i karensperioden freder igen. Sikkerhedsnet: oprydningen rører kun filer portalen selv har hentet (registreret med fuld sti i yt_state.json). Det eksisterende YT-bibliotek er urørligt, og download-træet er lagt uden for det, så Emby ikke indekserer dobbelt. Standardvalg: - H.264/AAC foretrækkes frem for AV1. Pascal-GPU'en i CT100 kan ikke afkode AV1, og mange afspillere kan det slet ikke offline. Koster ~1,8x diskplads. - SponsorBlock klipper sponsorater ud og beholder resten som kapitler. - Undertekster hentes hvis de findes, men er aldrig et krav: YouTube rate-limiter undertekst-endpointet uafhængigt af videoen, så en 429 dér ville ellers vælte hele hentningen. Fald-tilbage henter videoen uden undertekster. - Metadata, miniature og kapitler indlejres, så Emby har noget at vise. Alt kan redigeres i UI'et. Natlig timer (yt-offline.timer) tømmer køen og rydder op. Claude-Session: https://claude.ai/code/session_011WxG8pbLj61jYU1MMaD7VC
This commit is contained in:
@@ -56,6 +56,27 @@ MEDIA_DEFAULTS = {
|
||||
"do_series": True,
|
||||
}
|
||||
|
||||
|
||||
# ---- YouTube offline (yt-dlp + Emby) ----
|
||||
YT_DOWNLOAD_DIR = os.environ.get("YT_DOWNLOAD_DIR", "/media/yt-offline")
|
||||
YT_DLP_BIN = os.environ.get("YT_DLP_BIN", "/usr/local/bin/yt-dlp")
|
||||
YT_STATE_PATH = DATA / "yt_state.json"
|
||||
YT_SETTINGS_PATH = DATA / "yt_settings.json"
|
||||
# Standardindstillinger - kan redigeres i /admin/yt:
|
||||
YT_DEFAULTS = {
|
||||
"enabled": True, # slet sete videoer automatisk
|
||||
"grace_days": 7, # dage fra markeret-som-set til sletning
|
||||
# H.264 foretrukket: Pascal-GPU'en i CT100 kan ikke afkode AV1, og AV1 kan
|
||||
# mange afspillere slet ikke offline. Falder tilbage til alt-andet hvis
|
||||
# YouTube ikke har H.264 i den oenskede oploesning.
|
||||
"quality": "bestvideo[vcodec^=avc1][height<=1080]+bestaudio[acodec^=mp4a]/bestvideo[vcodec^=avc1][height<=1080]+bestaudio/best[vcodec^=avc1][height<=1080]/bestvideo[height<=1080]+bestaudio/best",
|
||||
"sponsorblock": True, # klip sponsorater ud, behold resten som kapitler
|
||||
"subtitles": True, # rigtige undertekster, ellers YouTubes automatiske
|
||||
"sub_langs": "da,en,-live_chat",
|
||||
"metadata": True, # titel/kanal/dato + miniature + kapitler i filen
|
||||
"library_name": "YouTube",
|
||||
}
|
||||
|
||||
GROUP_ORDER = {n: i for i, n in enumerate(
|
||||
["Home", "Familie", "Medie", "Social", "AI", "Office", "Finans", "dev", "game", "Admin"])}
|
||||
|
||||
@@ -78,4 +99,6 @@ ADMIN_MODULES = [
|
||||
"desc": "Hvilke brugere og grupper kan tilgå hver enkelt service."},
|
||||
{"key": "media", "title": "Medie-oprydning", "icon": "🎬",
|
||||
"desc": "Slet aldrig-set film & serier automatisk — med karens, behold-liste og ntfy."},
|
||||
{"key": "yt", "title": "YouTube offline", "icon": "📺",
|
||||
"desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."},
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import authentik as ak
|
||||
@@ -16,6 +17,7 @@ import mediaclean as mc
|
||||
import services as svc
|
||||
import store
|
||||
import versions
|
||||
import ytdl as yt
|
||||
|
||||
app = FastAPI(title="Weircon Portal")
|
||||
app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, https_only=True,
|
||||
@@ -508,7 +510,8 @@ async def search_index(request: Request):
|
||||
{"type": "page", "title": "App-beskrivelser", "url": "/admin/apps", "extra": "apps"},
|
||||
{"type": "page", "title": "IP → label & land", "url": "/admin/ip-labels", "extra": "ip geo"},
|
||||
{"type": "page", "title": "Statistik", "url": "/admin/stats", "extra": "matomo"},
|
||||
{"type": "page", "title": "Medie-oprydning", "url": "/admin/media", "extra": "film serier emby sonarr radarr slet disk plads"}]
|
||||
{"type": "page", "title": "Medie-oprydning", "url": "/admin/media", "extra": "film serier emby sonarr radarr slet disk plads"},
|
||||
{"type": "page", "title": "YouTube offline", "url": "/admin/yt", "extra": "youtube yt-dlp download offline fly emby"}]
|
||||
try:
|
||||
for a in await ak.fetch_user_apps(user["username"]):
|
||||
items.append({"type": "app", "title": a["name"], "url": a["url"],
|
||||
@@ -521,3 +524,84 @@ async def search_index(request: Request):
|
||||
@app.get("/healthz")
|
||||
async def healthz():
|
||||
return {"ok": True, "configured": bool(config.CLIENT_ID and config.API_TOKEN)}
|
||||
|
||||
|
||||
# ---------------- YouTube offline ----------------
|
||||
@app.get("/admin/yt", response_class=HTMLResponse)
|
||||
async def admin_yt(request: Request, saved: str = ""):
|
||||
user = require_admin(request)
|
||||
st = yt.get_state()
|
||||
jobs = sorted(st["jobs"].values(), key=lambda j: j["added"], reverse=True)
|
||||
ctx = {"request": request, "user": user, "is_admin": True, "saved": saved,
|
||||
"settings": yt.get_settings(), "human": yt._human, "now": time.time(),
|
||||
"download_dir": config.YT_DOWNLOAD_DIR, "unconfigured": False,
|
||||
"ours": [], "ours_size": 0, "leaving": st["leaving"], "log": st["log"][:15],
|
||||
"jobs": jobs[:25], "inv_error": "",
|
||||
"queued": sum(1 for j in jobs if j["status"] == "queued"),
|
||||
"running": any(j["status"] == "running" for j in jobs)}
|
||||
if not yt.configured():
|
||||
ctx["unconfigured"] = True
|
||||
return templates.TemplateResponse(request, "admin_yt.html", ctx)
|
||||
inv = cache.get("yt:inv")
|
||||
if inv is None:
|
||||
inv = await yt.emby_yt_items()
|
||||
cache.put("yt:inv", inv, 60)
|
||||
ctx["inv_error"] = inv.get("error") or ""
|
||||
owned = yt.owned_paths()
|
||||
ours = [{**it, "size": yt.file_size(it["path"]),
|
||||
"delete_after": (st["leaving"].get(it["path"]) or {}).get("delete_after")}
|
||||
for it in inv.get("items", []) if it["path"] in owned]
|
||||
ours.sort(key=lambda v: v["name"].lower())
|
||||
ctx["ours"] = ours
|
||||
ctx["ours_size"] = sum(v["size"] for v in ours)
|
||||
return templates.TemplateResponse(request, "admin_yt.html", ctx)
|
||||
|
||||
|
||||
async def _yt_drain():
|
||||
"""Tøm download-køen i baggrunden, så web-svaret ikke venter på yt-dlp.
|
||||
Timeren tager den igen hvis portalen genstarter midt i en hentning."""
|
||||
try:
|
||||
await yt.process_queue()
|
||||
cache.clear("yt:")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.post("/admin/yt/add")
|
||||
async def admin_yt_add(request: Request, url: str = Form(""), playlist: str = Form("")):
|
||||
require_admin(request)
|
||||
url = (url or "").strip()
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return RedirectResponse("/admin/yt?saved=Afvist: det ligner ikke en URL", status_code=303)
|
||||
yt.add_job(url, playlist=bool(playlist))
|
||||
asyncio.create_task(_yt_drain())
|
||||
return RedirectResponse("/admin/yt?saved=Lagt i kø — hentningen er i gang", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/yt/settings")
|
||||
async def admin_yt_settings(request: Request):
|
||||
require_admin(request)
|
||||
f = await request.form()
|
||||
patch = {k: bool(f.get(k)) for k in ("enabled", "sponsorblock", "subtitles", "metadata")}
|
||||
try:
|
||||
patch["grace_days"] = max(0, min(365, int(f.get("grace_days") or 7)))
|
||||
except ValueError:
|
||||
pass
|
||||
for k in ("sub_langs", "library_name", "quality"):
|
||||
if f.get(k):
|
||||
patch[k] = str(f.get(k)).strip()
|
||||
yt.save_settings(patch)
|
||||
cache.clear("yt:")
|
||||
return RedirectResponse("/admin/yt?saved=Indstillinger gemt", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/yt/run")
|
||||
async def admin_yt_run(request: Request):
|
||||
require_admin(request)
|
||||
res = await yt.run_cycle(dry_run=False)
|
||||
cache.clear("yt:")
|
||||
if res.get("error"):
|
||||
return RedirectResponse(f"/admin/yt?saved=Fejl: {res['error']}", status_code=303)
|
||||
msg = (f"Kørt — {res['seen']} af vores videoer · {len(res['new'])} nye på vej ud · "
|
||||
f"{len(res['spared'])} fredet · {len(res['deleted'])} slettet")
|
||||
return RedirectResponse(f"/admin/yt?saved={msg}", status_code=303)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin — YouTube offline{% endblock %}
|
||||
{% block body %}
|
||||
<h1>YouTube offline</h1>
|
||||
<p class="lead">Indsæt en YouTube-URL, så hentes videoen til Emby-biblioteket
|
||||
<strong>{{ settings.library_name }}</strong> — klar til at se uden net, fx i et fly.
|
||||
Når du har <strong>markeret den som set</strong> i Emby, slettes den efter
|
||||
{{ settings.grace_days }} dage. <strong>Favoritter slettes aldrig.</strong>
|
||||
Halvt sete videoer rører oprydningen ikke.</p>
|
||||
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
|
||||
|
||||
{% if unconfigured %}
|
||||
<div class="err">Ikke konfigureret endnu. Kræver <code>EMBY_URL/EMBY_API_KEY</code> i
|
||||
<code>/etc/portal.env</code>, <code>yt-dlp</code> installeret, og at download-mappen
|
||||
<code>{{ download_dir }}</code> findes i containeren.</div>
|
||||
{% else %}
|
||||
|
||||
{% if inv_error %}<div class="err">⚠ Emby: {{ inv_error }} — oprydningen står stille indtil den kan læse se-data (sikkerhed).</div>{% endif %}
|
||||
|
||||
{# ---- hent ny ---- #}
|
||||
<form method="post" action="/admin/yt/add" style="margin-top:1.4rem;display:flex;gap:.6rem;flex-wrap:wrap;align-items:center">
|
||||
<input name="url" placeholder="https://www.youtube.com/watch?v=…" required
|
||||
style="flex:1 1 26rem;min-width:16rem">
|
||||
<label class="sw" style="flex:0 0 auto"><input type="checkbox" name="playlist"> <span>Hele playlisten</span></label>
|
||||
<button class="btn" type="submit">Hent</button>
|
||||
</form>
|
||||
|
||||
{# ---- topkort ---- #}
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fill,minmax(14rem,1fr));margin-top:1.4rem">
|
||||
<div class="stat"><div class="muted" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.08em">I biblioteket</div>
|
||||
<div class="n">{{ ours|length }}</div>
|
||||
<div class="muted" style="font-size:.85rem;margin-top:.4rem">hentet af portalen · {{ human(ours_size) }}</div></div>
|
||||
<div class="stat"><div class="muted" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.08em">På vej ud</div>
|
||||
<div class="n">{{ leaving|length }}</div>
|
||||
<div class="muted" style="font-size:.85rem;margin-top:.4rem">set · i karens</div></div>
|
||||
<div class="stat"><div class="muted" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.08em">I kø</div>
|
||||
<div class="n">{{ queued }}</div>
|
||||
<div class="muted" style="font-size:.85rem;margin-top:.4rem">{% if running %}1 i gang{% else %}ingen i gang{% endif %}</div></div>
|
||||
<div class="stat"><div class="muted" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.08em">Sletning</div>
|
||||
<div class="n" style="font-size:1.4rem;margin-top:.4rem">{% if settings.enabled %}<span style="color:#3ecf8e">AKTIV</span>{% else %}<span style="color:#f5a623">fra</span>{% endif %}</div>
|
||||
<div class="muted" style="font-size:.85rem;margin-top:.4rem">{{ settings.grace_days }} dages karens</div></div>
|
||||
</div>
|
||||
|
||||
{# ---- indstillinger ---- #}
|
||||
<details style="margin-top:1.6rem">
|
||||
<summary style="cursor:pointer;font-weight:600">⚙️ Indstillinger</summary>
|
||||
<form method="post" action="/admin/yt/settings" style="margin-top:1rem">
|
||||
<div class="setgrid">
|
||||
<label class="sw"><input type="checkbox" name="enabled" {% if settings.enabled %}checked{% endif %}> <span>Slet sete videoer automatisk</span></label>
|
||||
<label class="sw"><input type="checkbox" name="sponsorblock" {% if settings.sponsorblock %}checked{% endif %}> <span>SponsorBlock — klip sponsorater ud</span></label>
|
||||
<label class="sw"><input type="checkbox" name="subtitles" {% if settings.subtitles %}checked{% endif %}> <span>Undertekster hvis de findes</span></label>
|
||||
<label class="sw"><input type="checkbox" name="metadata" {% if settings.metadata %}checked{% endif %}> <span>Metadata, miniature & kapitler</span></label>
|
||||
</div>
|
||||
<div class="setgrid" style="margin-top:.8rem">
|
||||
<label class="fld"><span>Karens efter set (dage)</span><input type="number" name="grace_days" min="0" max="365" value="{{ settings.grace_days }}"></label>
|
||||
<label class="fld"><span>Undertekst-sprog</span><input name="sub_langs" value="{{ settings.sub_langs }}"></label>
|
||||
<label class="fld"><span>Emby-bibliotek</span><input name="library_name" value="{{ settings.library_name }}"></label>
|
||||
<label class="fld"><span>Kvalitet (yt-dlp -f)</span><input name="quality" value="{{ settings.quality }}"></label>
|
||||
</div>
|
||||
<p style="margin-top:1rem;display:flex;gap:.6rem;flex-wrap:wrap">
|
||||
<button class="btn" type="submit">Gem indstillinger</button>
|
||||
<button class="btn secondary" type="submit" formaction="/admin/yt/run" formmethod="post">Kør oprydning nu</button>
|
||||
</p>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{# ---- vores videoer ---- #}
|
||||
<h2 style="margin-top:2rem">Hentede videoer</h2>
|
||||
{% if not ours %}<p class="muted">Ingen endnu — indsæt en URL ovenfor.</p>{% else %}
|
||||
<table class="wt-table" style="margin-top:.8rem">
|
||||
<thead><tr><th>Titel</th><th style="width:7rem">Størrelse</th><th style="width:9rem">Status</th><th style="width:11rem">Slettes</th></tr></thead>
|
||||
<tbody>
|
||||
{% for v in ours %}
|
||||
<tr>
|
||||
<td>{{ v.name }}</td>
|
||||
<td class="muted">{{ human(v.size) }}</td>
|
||||
<td>
|
||||
{% if v.favorite %}<span style="color:#3ecf8e">★ favorit — fredet</span>
|
||||
{% elif v.played %}<span style="color:#f5a623">set</span>
|
||||
{% else %}<span class="muted">ikke set</span>{% endif %}
|
||||
</td>
|
||||
<td class="muted">
|
||||
{% if v.favorite %}aldrig
|
||||
{% elif v.delete_after %}om {{ ((v.delete_after - now) / 86400) | round(0, 'ceil') | int }} dage
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{# ---- kø & historik ---- #}
|
||||
{% if jobs %}
|
||||
<h2 style="margin-top:2rem">Kø & historik</h2>
|
||||
<table class="wt-table" style="margin-top:.8rem">
|
||||
<thead><tr><th>URL / titel</th><th style="width:8rem">Status</th><th>Besked</th></tr></thead>
|
||||
<tbody>
|
||||
{% for j in jobs %}
|
||||
<tr>
|
||||
<td>{{ j.title or j.url }}</td>
|
||||
<td>{% if j.status == 'done' %}<span style="color:#3ecf8e">hentet</span>
|
||||
{% elif j.status == 'error' %}<span style="color:#f87171">fejl</span>
|
||||
{% elif j.status == 'running' %}i gang…
|
||||
{% elif j.status == 'deleted' %}<span class="muted">slettet</span>
|
||||
{% else %}i kø{% endif %}</td>
|
||||
<td class="muted">{{ j.error }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if log %}
|
||||
<h2 style="margin-top:2rem">Log</h2>
|
||||
<ul class="muted" style="margin-top:.6rem;line-height:1.7">
|
||||
{% for l in log %}<li>{{ l.text }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Natlig YouTube-kørsel (systemd-timer): tøm download-køen og ryd op efter sete videoer.
|
||||
|
||||
Køen tømmes også her — ikke kun fra web-ruten — så et job overlever en genstart af
|
||||
portalen midt i en hentning.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import ytdl
|
||||
|
||||
|
||||
async def main():
|
||||
if not ytdl.configured():
|
||||
print("yt_job: ikke konfigureret (mangler EMBY-env, yt-dlp eller download-mappe)")
|
||||
return
|
||||
q = await ytdl.process_queue()
|
||||
if q["done"] or q["failed"]:
|
||||
print(f"yt_job: hentet={q['done']} fejlet={q['failed']}")
|
||||
res = await ytdl.run_cycle(dry_run=False)
|
||||
if res.get("error"):
|
||||
print("yt_job FEJL:", res["error"])
|
||||
return
|
||||
print(f"yt_job: vores videoer={res['seen']} nye_paa_vej_ud={len(res['new'])} "
|
||||
f"fredet={len(res['spared'])} slettet={len(res['deleted'])} "
|
||||
f"frigjort={ytdl._human(res['freed'])} "
|
||||
f"sletning={'til' if res['enabled'] else 'fra'}")
|
||||
for e in res.get("errors", []):
|
||||
print(" fejl:", e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,428 @@
|
||||
"""YouTube-offline: hent videoer med yt-dlp, servér dem via Emby, og ryd op når de er SET.
|
||||
|
||||
Bevidst forskel fra mediaclean.py: dér tæller selv delvis afspilning som "set", fordi
|
||||
formålet dér er at frede alt der er rørt. Her er det modsat — en video slettes KUN når
|
||||
den er eksplicit **markeret som set** (Emby `UserData.Played`). Halvt sete videoer og
|
||||
videoer der er lagt fra sig midtvejs bliver liggende. Favoritter slettes aldrig.
|
||||
|
||||
Sikkerhedsnettet er lige så vigtigt som reglen: oprydningen rører **kun filer portalen
|
||||
selv har hentet**, registreret med fuld sti i yt_state.json. Alt andet i YouTube-
|
||||
biblioteket — fx det der lå i /slow/shared/yt i forvejen — er urørligt.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
import config
|
||||
|
||||
TICKS = 10_000_000 # Emby: 1 sek = 10^7 ticks
|
||||
|
||||
|
||||
# ---------------- state + settings ----------------
|
||||
def _load(path, default):
|
||||
try:
|
||||
return json.loads(path.read_text("utf-8"))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _save(path, data):
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), "utf-8")
|
||||
|
||||
|
||||
def get_settings() -> dict:
|
||||
s = dict(config.YT_DEFAULTS)
|
||||
s.update(_load(config.YT_SETTINGS_PATH, {}) or {})
|
||||
return s
|
||||
|
||||
|
||||
def save_settings(patch: dict) -> dict:
|
||||
s = get_settings()
|
||||
s.update(patch)
|
||||
_save(config.YT_SETTINGS_PATH, s)
|
||||
return s
|
||||
|
||||
|
||||
def get_state() -> dict:
|
||||
st = _load(config.YT_STATE_PATH, {}) or {}
|
||||
st.setdefault("jobs", {}) # id -> {url,status,title,path,size,added,finished,error}
|
||||
st.setdefault("leaving", {}) # sti -> {title,size,first_seen,delete_after}
|
||||
st.setdefault("log", []) # seneste hændelser
|
||||
return st
|
||||
|
||||
|
||||
def save_state(st: dict):
|
||||
_save(config.YT_STATE_PATH, st)
|
||||
|
||||
|
||||
def _log(st: dict, text: str):
|
||||
st["log"].insert(0, {"t": time.time(), "text": text})
|
||||
del st["log"][60:]
|
||||
|
||||
|
||||
def configured() -> bool:
|
||||
return bool(config.EMBY_URL and config.EMBY_API_KEY
|
||||
and shutil.which(config.YT_DLP_BIN)
|
||||
and os.path.isdir(config.YT_DOWNLOAD_DIR))
|
||||
|
||||
|
||||
def _human(n: int) -> str:
|
||||
n = float(n or 0)
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if n < 1024 or unit == "TB":
|
||||
return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B"
|
||||
n /= 1024
|
||||
|
||||
|
||||
# ---------------- download ----------------
|
||||
def add_job(url: str, playlist: bool = False) -> dict:
|
||||
"""Læg en URL i køen. Selve hentningen sker i process_queue()."""
|
||||
st = get_state()
|
||||
jid = uuid.uuid4().hex[:12]
|
||||
job = {"id": jid, "url": url.strip(), "playlist": bool(playlist), "status": "queued",
|
||||
"title": "", "path": "", "size": 0, "added": time.time(),
|
||||
"finished": 0, "error": ""}
|
||||
st["jobs"][jid] = job
|
||||
_log(st, f"Lagt i kø: {url.strip()[:80]}")
|
||||
save_state(st)
|
||||
return job
|
||||
|
||||
|
||||
def _set_job(jid: str, **kw):
|
||||
st = get_state()
|
||||
job = st["jobs"].get(jid)
|
||||
if not job:
|
||||
return
|
||||
job.update(kw)
|
||||
save_state(st)
|
||||
|
||||
|
||||
def _build_cmd(job: dict, settings: dict, with_subs: bool) -> list:
|
||||
dest = config.YT_DOWNLOAD_DIR
|
||||
# %(title)s beholder æøå; [%(id)s] gør navnet unikt og gør det muligt at
|
||||
# genkende videoen igen uden at gemme en sidevogn af metadata.
|
||||
out = os.path.join(dest, "%(title)s [%(id)s].%(ext)s")
|
||||
cmd = [config.YT_DLP_BIN,
|
||||
"-f", settings.get("quality") or config.YT_DEFAULTS["quality"],
|
||||
"--merge-output-format", "mp4",
|
||||
"--no-progress", "--newline", "--no-warnings",
|
||||
"--concurrent-fragments", "4",
|
||||
"--retries", "5", "--sleep-requests", "1",
|
||||
"--print", "after_move:%(filepath)s",
|
||||
"-o", out]
|
||||
|
||||
if settings.get("metadata", True):
|
||||
# Titel/kanal/dato ind i filen + miniature som cover, så Emby har noget at
|
||||
# vise uden at skulle gætte ud fra filnavnet.
|
||||
cmd += ["--embed-metadata", "--embed-thumbnail", "--embed-chapters"]
|
||||
|
||||
if with_subs:
|
||||
# "hvis de er der": rigtige undertekster først, ellers YouTubes automatiske.
|
||||
# Både indlejret (rejser med filen når Emby-appen henter den ned) og som
|
||||
# .srt-sidecar. Sidecars ryddes med i slettetrinnet.
|
||||
cmd += ["--sub-langs", settings.get("sub_langs") or config.YT_DEFAULTS["sub_langs"],
|
||||
"--write-subs", "--write-auto-subs", "--convert-subs", "srt", "--embed-subs"]
|
||||
|
||||
if settings.get("sponsorblock", True):
|
||||
# Klip sponsorater/selvpromovering ud, og behold resten som kapitler man
|
||||
# selv kan springe i. Kræver ffmpeg. Videoer uden indsendte segmenter
|
||||
# påvirkes ikke (yt-dlp henter dem fra SponsorBlock-API'et pr. video).
|
||||
cmd += ["--sponsorblock-remove", "default", "--sponsorblock-mark", "all"]
|
||||
|
||||
if not job.get("playlist"):
|
||||
cmd.append("--no-playlist")
|
||||
cmd.append(job["url"])
|
||||
return cmd
|
||||
|
||||
|
||||
async def _attempt(job: dict, settings: dict, with_subs: bool) -> dict:
|
||||
dest = config.YT_DOWNLOAD_DIR
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*_build_cmd(job, settings, with_subs),
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
err = (stderr.decode("utf-8", "replace").strip().splitlines() or ["ukendt fejl"])[-1]
|
||||
return {"ok": False, "error": err[:300]}
|
||||
|
||||
# --print after_move giver én linje pr. hentet fil (flere ved playlister).
|
||||
paths = [ln for ln in stdout.decode("utf-8", "replace").splitlines()
|
||||
if ln.startswith(dest)]
|
||||
if not paths:
|
||||
return {"ok": False, "error": "yt-dlp meldte ingen fil — allerede hentet?"}
|
||||
size = sum(os.path.getsize(p) for p in paths if os.path.exists(p))
|
||||
title = os.path.basename(paths[0]).rsplit(".", 1)[0]
|
||||
return {"ok": True, "title": title, "path": paths[0], "paths": paths, "size": size,
|
||||
"note": "" if with_subs else "uden undertekster"}
|
||||
|
||||
|
||||
async def _run_ytdlp(job: dict, settings: dict) -> dict:
|
||||
"""Kør yt-dlp for ét job. Returnerer {ok, title, path, size, error}.
|
||||
|
||||
Undertekster er et gode, ikke et krav: YouTube rate-limiter dem (HTTP 429)
|
||||
uafhængigt af selve videoen, og uden dette fald-tilbage ville en 429 på en
|
||||
undertekstfil vælte hele hentningen. Vi prøver derfor én gang mere uden dem.
|
||||
"""
|
||||
want_subs = bool(settings.get("subtitles", True))
|
||||
res = await _attempt(job, settings, with_subs=want_subs)
|
||||
if not res["ok"] and want_subs and "subtitle" in res["error"].lower():
|
||||
res = await _attempt(job, settings, with_subs=False)
|
||||
if res["ok"]:
|
||||
res["note"] = "undertekster sprunget over (YouTube afviste dem lige nu)"
|
||||
return res
|
||||
|
||||
|
||||
def _sidecars(path: str) -> list:
|
||||
"""Filer der hører til videoen (undertekster, miniature) og skal slettes med den.
|
||||
|
||||
yt-dlp navngiver dem efter samme stamme: "Titel [id].da.srt", "... .en.srt",
|
||||
"... .jpg". Uden dette trin ville en slettet video efterlade sine .srt-filer,
|
||||
og Emby ville blive ved med at vise et tomt punkt i biblioteket.
|
||||
"""
|
||||
stem = path.rsplit(".", 1)[0]
|
||||
folder = os.path.dirname(path) or "."
|
||||
base = os.path.basename(stem)
|
||||
out = []
|
||||
try:
|
||||
for f in os.listdir(folder):
|
||||
full = os.path.join(folder, f)
|
||||
if full != path and f.startswith(base + ".") and os.path.isfile(full):
|
||||
out.append(full)
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
async def process_queue() -> dict:
|
||||
"""Hent alt der står i kø. Kaldes både fra web-ruten og fra timeren."""
|
||||
settings = get_settings()
|
||||
done, failed = 0, 0
|
||||
while True:
|
||||
st = get_state()
|
||||
queued = [j for j in st["jobs"].values() if j["status"] == "queued"]
|
||||
if not queued:
|
||||
break
|
||||
job = sorted(queued, key=lambda j: j["added"])[0]
|
||||
_set_job(job["id"], status="running")
|
||||
try:
|
||||
res = await _run_ytdlp(job, settings)
|
||||
except Exception as e: # noqa: BLE001
|
||||
res = {"ok": False, "error": str(e)[:300]}
|
||||
st = get_state()
|
||||
j = st["jobs"].get(job["id"], {})
|
||||
if res.get("ok"):
|
||||
j.update(status="done", title=res["title"], path=res["path"],
|
||||
size=res["size"], finished=time.time(), error="")
|
||||
# Playlister giver flere filer; registrér dem alle så oprydningen
|
||||
# kender dem (ellers ville kun den første være "vores").
|
||||
j["paths"] = res.get("paths") or [res["path"]]
|
||||
note = f" — {res['note']}" if res.get("note") else ""
|
||||
_log(st, f"Hentet: {res['title']} ({_human(res['size'])}){note}")
|
||||
j["note"] = res.get("note") or ""
|
||||
done += 1
|
||||
else:
|
||||
j.update(status="error", error=res.get("error") or "", finished=time.time())
|
||||
_log(st, f"FEJL: {job['url'][:60]} — {j['error'][:120]}")
|
||||
failed += 1
|
||||
save_state(st)
|
||||
if done:
|
||||
await refresh_library()
|
||||
return {"done": done, "failed": failed}
|
||||
|
||||
|
||||
def file_size(path: str) -> int:
|
||||
"""Størrelse af videoen inkl. dens undertekst-/miniature-sidecars."""
|
||||
try:
|
||||
return os.path.getsize(path) + sum(os.path.getsize(x) for x in _sidecars(path))
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def owned_paths() -> dict:
|
||||
"""Sti -> jobtitel for alt portalen selv har hentet (og som stadig ligger der)."""
|
||||
out = {}
|
||||
for j in get_state()["jobs"].values():
|
||||
if j["status"] != "done":
|
||||
continue
|
||||
for p in (j.get("paths") or ([j["path"]] if j.get("path") else [])):
|
||||
if p:
|
||||
out[os.path.realpath(p)] = j.get("title") or os.path.basename(p)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------- Emby ----------------
|
||||
async def _emby(client, path, **params):
|
||||
params["api_key"] = config.EMBY_API_KEY
|
||||
r = await client.get(f"{config.EMBY_URL}/emby{path}", params=params)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _library_id(client, name: str):
|
||||
try:
|
||||
folders = await _emby(client, "/Library/VirtualFolders")
|
||||
except Exception:
|
||||
return None
|
||||
for f in folders:
|
||||
if (f.get("Name") or "").lower() == name.lower():
|
||||
return f.get("ItemId")
|
||||
return None
|
||||
|
||||
|
||||
def _marked_played(ud: dict) -> bool:
|
||||
"""SET = eksplicit markeret som set. Ikke 50%, ikke 'påbegyndt'.
|
||||
|
||||
Derfor kun UserData.Played — bevidst uden PlaybackPositionTicks/PlayedPercentage,
|
||||
som mediaclean.py netop tæller med i sin modsatte hensigt.
|
||||
"""
|
||||
return bool((ud or {}).get("Played"))
|
||||
|
||||
|
||||
async def emby_yt_items() -> dict:
|
||||
"""Alle videoer i YouTube-biblioteket med set/favorit aggregeret over ALLE brugere."""
|
||||
settings = get_settings()
|
||||
out = {"ok": False, "items": [], "error": ""}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=45) as c:
|
||||
lib = await _library_id(c, settings.get("library_name") or "YouTube")
|
||||
if not lib:
|
||||
out["error"] = (f"Fandt ikke Emby-biblioteket "
|
||||
f"'{settings.get('library_name')}'")
|
||||
return out
|
||||
users = [u["Id"] for u in await _emby(c, "/Users")]
|
||||
merged = {}
|
||||
for uid in users:
|
||||
data = await _emby(c, f"/Users/{uid}/Items", ParentId=lib,
|
||||
Recursive="true", Fields="Path,UserData")
|
||||
for it in data.get("Items", []):
|
||||
path = it.get("Path") or ""
|
||||
if not path:
|
||||
continue
|
||||
key = os.path.realpath(path)
|
||||
m = merged.setdefault(key, {
|
||||
"id": it["Id"], "name": it.get("Name") or os.path.basename(path),
|
||||
"path": key, "played": False, "favorite": False})
|
||||
ud = it.get("UserData") or {}
|
||||
if _marked_played(ud):
|
||||
m["played"] = True
|
||||
if ud.get("IsFavorite"):
|
||||
m["favorite"] = True
|
||||
out["items"] = list(merged.values())
|
||||
out["ok"] = True
|
||||
except Exception as e: # noqa: BLE001
|
||||
out["error"] = str(e)[:200]
|
||||
return out
|
||||
|
||||
|
||||
async def refresh_library():
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
await c.post(f"{config.EMBY_URL}/emby/Library/Refresh",
|
||||
params={"api_key": config.EMBY_API_KEY})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def ntfy(title, message, click=None, tags=None):
|
||||
if not config.NTFY_URL:
|
||||
return
|
||||
headers = {"Title": title}
|
||||
if tags:
|
||||
headers["Tags"] = tags
|
||||
if click:
|
||||
headers["Click"] = click
|
||||
headers["Actions"] = f"view, Åbn admin, {click}"
|
||||
if config.NTFY_TOKEN:
|
||||
headers["Authorization"] = f"Bearer {config.NTFY_TOKEN}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
await c.post(f"{config.NTFY_URL}/{config.NTFY_MEDIA_TOPIC}",
|
||||
content=message.encode("utf-8"), headers=headers)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------- oprydning ----------------
|
||||
async def run_cycle(dry_run: bool = False) -> dict:
|
||||
"""Markér sete videoer til sletning efter karens, og slet de udløbne.
|
||||
|
||||
Rører kun stier portalen selv har hentet. Favorit = fredet, og en video der
|
||||
favoritteres i karensperioden fjernes igen fra listen.
|
||||
"""
|
||||
settings = get_settings()
|
||||
res = {"seen": 0, "new": [], "spared": [], "deleted": [], "freed": 0,
|
||||
"enabled": bool(settings.get("enabled")) and not dry_run, "errors": [], "error": ""}
|
||||
inv = await emby_yt_items()
|
||||
if not inv["ok"]:
|
||||
res["error"] = inv["error"]
|
||||
return res
|
||||
|
||||
owned = owned_paths()
|
||||
st = get_state()
|
||||
now = time.time()
|
||||
grace = float(settings.get("grace_days") or 7) * 86400
|
||||
|
||||
for it in inv["items"]:
|
||||
path = it["path"]
|
||||
if path not in owned:
|
||||
continue # ikke vores fil — rør den aldrig
|
||||
res["seen"] += 1
|
||||
entry = st["leaving"].get(path)
|
||||
if it["favorite"] or not it["played"]:
|
||||
# Favorit eller ikke markeret som set → skal ikke slettes.
|
||||
if entry:
|
||||
st["leaving"].pop(path, None)
|
||||
res["spared"].append(it["name"])
|
||||
_log(st, f"Fredet igen: {it['name']}"
|
||||
+ (" (favorit)" if it["favorite"] else " (ikke længere markeret set)"))
|
||||
continue
|
||||
if not entry:
|
||||
size = os.path.getsize(path) if os.path.exists(path) else 0
|
||||
st["leaving"][path] = {"title": it["name"], "size": size,
|
||||
"first_seen": now, "delete_after": now + grace}
|
||||
res["new"].append({"title": it["name"], "size": size})
|
||||
|
||||
# slet udløbne
|
||||
for path, e in list(st["leaving"].items()):
|
||||
if now < e["delete_after"]:
|
||||
continue
|
||||
if not res["enabled"]:
|
||||
continue
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
for side in _sidecars(path):
|
||||
res["freed"] += os.path.getsize(side)
|
||||
os.remove(side)
|
||||
os.remove(path)
|
||||
res["freed"] += e.get("size") or 0
|
||||
st["leaving"].pop(path, None)
|
||||
# marker jobbet som slettet så owned_paths ikke tæller den igen
|
||||
for j in st["jobs"].values():
|
||||
paths = j.get("paths") or ([j.get("path")] if j.get("path") else [])
|
||||
if os.path.realpath(path) in [os.path.realpath(p) for p in paths if p]:
|
||||
j["status"] = "deleted"
|
||||
res["deleted"].append(e["title"])
|
||||
_log(st, f"Slettet efter karens: {e['title']} ({_human(e.get('size') or 0)})")
|
||||
except Exception as ex: # noqa: BLE001
|
||||
res["errors"].append(f"{e['title']}: {ex}")
|
||||
|
||||
save_state(st)
|
||||
|
||||
if res["deleted"]:
|
||||
await refresh_library()
|
||||
await ntfy(f"Slettede {len(res['deleted'])} sete videoer",
|
||||
"Frigjorde " + _human(res["freed"]) + ":\n"
|
||||
+ "\n".join("· " + t for t in res["deleted"][:15]),
|
||||
click="https://weircon.dk/admin/yt", tags="wastebasket")
|
||||
elif res["new"]:
|
||||
await ntfy(f"{len(res['new'])} sete videoer på vej ud",
|
||||
f"Slettes om {int(settings.get('grace_days') or 7)} dage, "
|
||||
"medmindre du favoritter dem:\n"
|
||||
+ "\n".join(f"· {i['title']}" for i in res["new"][:15]),
|
||||
click="https://weircon.dk/admin/yt", tags="hourglass")
|
||||
return res
|
||||
Reference in New Issue
Block a user