ArtyomV2X 1 mesiac pred
commit
d09cbda12b
6 zmenil súbory, kde vykonal 2265 pridanie a 0 odobranie
  1. 200 0
      yaar/README.md
  2. 74 0
      yaar/proxmox-mount.md
  3. 233 0
      yaar/setup.sh
  4. 26 0
      yaar/systemd/yaar.service
  5. 809 0
      yaar/web/app.py
  6. 923 0
      yaar/web/templates/index.html

+ 200 - 0
yaar/README.md

@@ -0,0 +1,200 @@
+# YAAR — YouTube Auto-Archiver and Retagger
+
+Self-hosted YouTube archival system designed for Proxmox LXC (Alpine Linux).
+Archives YouTube videos as H.264/AAC MKVs with embedded metadata, written
+directly into a Jellyfin-compatible folder structure.
+
+---
+
+## What it does
+
+- Web UI for submitting YouTube URLs with one click
+- **Probes** the URL first to pre-fill title, author, and upload date
+- Archives as one of three Jellyfin-native types:
+  - **YouTube video** → `/media/yaar/YouTube/<Channel>/<Title>.mkv`
+  - **TV series episode** → `/media/yaar/Shows/<Series>/Season NN/SNNENN - <Title>.mkv`
+  - **Movie** → `/media/yaar/Movies/<Title> (Year)/<Title> (Year).mkv`
+- Downloads best quality with **H.264 video + AAC audio** (Jellyfin-safe, no transcoding needed)
+- Remuxes to **MKV** with:
+  - Embedded metadata: title, artist/channel, date, original URL, description
+  - Embedded thumbnail
+  - Chapter markers (if available)
+- Writes **NFO sidecar files** for Jellyfin scraping (series and movies)
+- Real-time job queue with progress bars and error reporting
+- Jobs persist across restarts (JSON files in `/opt/yaar/jobs/`)
+- Weekly **auto-update** of yt-dlp via cron
+
+---
+
+## Requirements
+
+- Proxmox VE 7+ (any version with LXC support)
+- Alpine Linux 3.20 LXC template
+- A storage volume or directory on the host for media (bind-mounted into the container)
+
+---
+
+## Quick start
+
+YAAR installs entirely from **inside your existing Alpine LXC container**.
+The only thing done on the Proxmox host is attaching the Jellyfin bind-mount.
+
+### Step 1 — Attach the bind-mount in Proxmox (one-time, host only)
+
+See `proxmox-mount.md` for the full guide. In short:
+
+**Web UI:** Container → Resources → Add → Mount Point
+- Host path: `/mnt/nas/jellyfin` (your NAS location on the host)
+- Container path: `/nas/jellyfin`
+- Read-only: No
+
+**Or via the Proxmox host shell:**
+```sh
+pct set <CTID> -mp0 /mnt/nas/jellyfin,mp=/nas/jellyfin
+pct restart <CTID>
+```
+
+### Step 2 — Get YAAR into the container
+
+```sh
+# From the Proxmox host:
+pct push <CTID> yaar.tar.gz /root/yaar.tar.gz
+
+# Or via scp from any machine:
+scp yaar.tar.gz root@<container-ip>:/root/
+```
+
+### Step 3 — Run setup inside the container
+
+```sh
+pct enter <CTID>          # enter the container, or ssh into it
+tar xzf /root/yaar.tar.gz -C /root/
+sh /root/yaar/setup.sh
+```
+
+`setup.sh` detects if it's accidentally run on the Proxmox host and exits
+immediately — it will not proceed unless it's running inside a container.
+
+The script installs:
+- Python 3, pip, venv
+- ffmpeg
+- yt-dlp (latest), Flask, Gunicorn
+- OpenRC init script (`/etc/init.d/yaar`)
+- Weekly yt-dlp auto-update cron
+
+---
+
+## Service management (Alpine / OpenRC)
+
+```sh
+rc-service yaar start
+rc-service yaar stop
+rc-service yaar restart
+rc-service yaar status
+```
+
+Logs:
+```sh
+tail -f /opt/yaar/logs/yaar.log       # Application log
+tail -f /opt/yaar/logs/gunicorn.log   # Web server log
+```
+
+---
+
+## Jellyfin integration
+
+Point Jellyfin at the three library roots:
+
+| Library type | Path |
+|---|---|
+| Movies | `/media/yaar/Movies` |
+| Shows | `/media/yaar/Shows` |
+| Other (YouTube) | `/media/yaar/YouTube` |
+
+For the **Shows** library, enable NFO metadata reading so episode details from
+yt-dlp populate correctly. For YouTube videos, the library type "Other Videos"
+or "Shows" both work — choose based on preference.
+
+---
+
+## Format choices
+
+| Setting | Value | Why |
+|---|---|---|
+| Video codec | H.264 (avc1) | Universal Jellyfin direct-play, no transcoding |
+| Audio codec | AAC (mp4a) | Universal, no transcoding on any client |
+| Container | MKV | Supports metadata, chapters, thumbnails |
+| Quality | Best available H.264 | yt-dlp format selector: `bestvideo[vcodec^=avc1]+bestaudio[acodec^=mp4a]` |
+
+If a video is only available as VP9/AV1 (e.g. high-res YouTube uploads), yt-dlp
+falls back to the best available VP9 + audio, then remuxes to MKV. The web UI
+shows whatever was actually used.
+
+---
+
+## File structure
+
+```
+/opt/yaar/
+  web/
+    app.py           ← Flask application
+    templates/
+      index.html     ← Web UI
+  venv/              ← Python virtualenv
+  logs/
+    yaar.log
+    gunicorn.log
+    access.log
+  jobs/              ← Persisted job JSON files
+
+/media/yaar/
+  Movies/
+    Fantastic Voyage (1966)/
+      Fantastic Voyage (1966).mkv
+      Fantastic Voyage (1966).nfo
+  Shows/
+    Kurzgesagt/
+      Season 01/
+        S01E01 - The Egg.mkv
+        S01E01 - The Egg.nfo
+  YouTube/
+    Veritasium/
+      Does Planet 9 Exist.mkv
+```
+
+---
+
+## Configuration
+
+Environment variables (set in `/etc/yaar.env`):
+
+| Variable | Default | Description |
+|---|---|---|
+| `YAAR_BASE` | `/opt/yaar` | App, logs, jobs directory |
+| `YAAR_MEDIA` | `/media/yaar` | Root media output directory |
+| `YAAR_PORT` | `7474` | Web UI port |
+
+---
+
+## Updating yt-dlp manually
+
+```sh
+/opt/yaar/venv/bin/pip install --upgrade yt-dlp
+rc-service yaar restart
+```
+
+---
+
+## Troubleshooting
+
+**Download fails with "Sign in to confirm your age"**
+YouTube sometimes requires cookies for age-restricted content. Export cookies from a logged-in browser and pass them to yt-dlp via `--cookies` in `build_ydl_opts()` in `app.py`.
+
+**H.264 format not available / falls back to VP9**
+Some YouTube videos are only available in VP9. YAAR will still download and remux, but Jellyfin may transcode on lower-powered clients. This is a YouTube limitation, not a YAAR bug.
+
+**Container can't write to media directory**
+Ensure the bind mount path exists on the host and the container has write permission. For unprivileged containers, the UID/GID mapping may need adjustment: `pct set <CT_ID> --mp0 /host/path,mp=/media/yaar,uid=0,gid=0`.
+
+**Port already in use**
+Edit `YAAR_PORT` in `/etc/yaar.env` and restart the service.

+ 74 - 0
yaar/proxmox-mount.md

@@ -0,0 +1,74 @@
+# Attaching the Jellyfin bind-mount in Proxmox
+
+This is the **only step that requires touching Proxmox**. Everything else
+(installing packages, configuring YAAR, starting the service) is done from
+inside your existing Alpine container using `setup.sh`.
+
+---
+
+## What this does
+
+Proxmox's `mp0` bind-mount makes a directory on the Proxmox host visible
+inside the container at a chosen path. YAAR expects to find your Jellyfin
+media at `/nas/jellyfin` inside the container.
+
+You only need to run one of the options below — once, before running `setup.sh`.
+
+---
+
+## Option A — Proxmox web UI (safest, no shell access needed)
+
+1. Log into the Proxmox web UI.
+2. Click your container in the left sidebar.
+3. Go to **Resources** → **Add** → **Mount Point**.
+4. Fill in:
+   | Field | Value |
+   |---|---|
+   | Storage | — (leave blank for directory bind-mount) |
+   | Host path | `/mnt/nas/jellyfin` *(the path on your Proxmox host)* |
+   | Container path | `/nas/jellyfin` |
+   | Read-only | No |
+5. Click **Add**.
+6. **Restart the container** from the web UI (or `pct restart <CTID>`).
+
+---
+
+## Option B — Proxmox host shell (one command)
+
+SSH into your Proxmox host (not the container), then:
+
+```sh
+pct set <CTID> -mp0 /mnt/nas/jellyfin,mp=/nas/jellyfin
+pct restart <CTID>
+```
+
+Replace `<CTID>` with your container ID and `/mnt/nas/jellyfin` with the
+actual path to your Jellyfin media directory on the Proxmox host.
+
+---
+
+## Verify the mount is visible inside the container
+
+```sh
+pct enter <CTID>
+ls /nas/jellyfin
+# Should show: Movies  Shows  (and whatever else is on your NAS)
+```
+
+---
+
+## Then run YAAR setup (inside the container)
+
+```sh
+# Get the YAAR archive into the container — choose one:
+#   From host:  pct push <CTID> /path/to/yaar.tar.gz /root/yaar.tar.gz
+#   Via scp:    scp yaar.tar.gz root@<container-ip>:/root/
+#   Via browser: Proxmox web UI → container → Upload
+
+# Then, inside the container:
+tar xzf /root/yaar.tar.gz -C /root/
+sh /root/yaar/setup.sh
+```
+
+`setup.sh` checks that it is not running on the Proxmox host and will refuse
+to proceed if it detects a PVE environment.

+ 233 - 0
yaar/setup.sh

@@ -0,0 +1,233 @@
+#!/bin/sh
+# YAAR — YouTube Auto-Archiver and Retagger
+# ─────────────────────────────────────────
+# Run this script INSIDE your existing Alpine Linux LXC container as root.
+# It does not touch the Proxmox host in any way.
+#
+# BEFORE running this, do one thing in Proxmox to attach your Jellyfin media:
+#   Proxmox web UI → your container → Resources → Add → Mount Point
+#     Host path:       /mnt/nas/jellyfin   (wherever your NAS is mounted on the host)
+#     Container path:  /nas/jellyfin
+#     Read-only:       No
+#   Then restart the container.
+#
+#   Or via the Proxmox shell (not this container — the host shell):
+#     pct set <CTID> -mp0 /mnt/nas/jellyfin,mp=/nas/jellyfin
+#     pct restart <CTID>
+#
+# After that, get this script into the container any way you like:
+#   - Copy from the Proxmox web UI "Upload" button
+#   - scp yaar.tar.gz root@<container-ip>:/root/
+#   - pct push <CTID> yaar.tar.gz /root/yaar.tar.gz   (from Proxmox host only)
+#
+# Then inside the container:
+#   tar xzf /root/yaar.tar.gz -C /root/
+#   sh /root/yaar/setup.sh
+# ─────────────────────────────────────────────────────────────────────────────
+
+set -e
+
+YAAR_DIR="/opt/yaar"
+MEDIA_DIR="/nas/jellyfin"       # Must match the mp0 mount point set in Proxmox
+WEB_DIR="${YAAR_DIR}/web"
+VENV_DIR="${YAAR_DIR}/venv"
+LOG_DIR="${YAAR_DIR}/logs"
+JOB_DIR="${YAAR_DIR}/jobs"
+PORT=7474
+
+RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; YELLOW='\033[0;33m'; NC='\033[0m'
+info()    { printf "${CYAN}[YAAR]${NC} %s\n" "$*"; }
+success() { printf "${GREEN}[OK]${NC} %s\n" "$*"; }
+warn()    { printf "${YELLOW}[WARN]${NC} %s\n" "$*"; }
+die()     { printf "${RED}[ERR]${NC} %s\n" "$*"; exit 1; }
+
+# ── 0. Preflight ──────────────────────────────────────────────────────────────
+
+[ "$(id -u)" -eq 0 ] || die "Run as root:  sh setup.sh"
+
+# Hard stop if somehow run on the Proxmox host itself.
+# pveversion is a binary that only exists on PVE hosts — not in any container.
+if command -v pveversion >/dev/null 2>&1; then
+  printf "\n"
+  die "This script must be run INSIDE the Alpine container, not on the Proxmox host.
+       On the host, enter the container first:
+         pct enter <CTID>
+       Then re-run setup.sh from inside it."
+fi
+
+# Make sure we're on Alpine (or at least warn loudly if not).
+if [ ! -f /etc/alpine-release ]; then
+  warn "This does not look like an Alpine Linux system."
+  warn "The apk commands below will fail on non-Alpine distros."
+  printf "${YELLOW}Continue anyway? [y/N] ${NC}"
+  read -r yn
+  case "$yn" in
+    [Yy]*) ;;
+    *) die "Aborted." ;;
+  esac
+fi
+
+# Confirm the source files are next to this script.
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+[ -f "${SCRIPT_DIR}/web/app.py" ] || \
+  die "Cannot find web/app.py relative to setup.sh (expected at ${SCRIPT_DIR}/web/app.py).
+       Make sure you extracted the full YAAR archive before running this."
+[ -f "${SCRIPT_DIR}/web/templates/index.html" ] || \
+  die "Cannot find web/templates/index.html relative to setup.sh."
+
+info "Running inside container as root — Proxmox host will not be touched."
+
+# ── 1. Bind-mount check ───────────────────────────────────────────────────────
+info "Checking Jellyfin media mount at ${MEDIA_DIR}..."
+MOUNT_READY=1
+
+if [ ! -d "${MEDIA_DIR}" ]; then
+  warn ""
+  warn "${MEDIA_DIR} does not exist inside this container."
+  warn "YAAR will install fine, but no downloads will succeed until the mount is added."
+  warn ""
+  warn "To fix this (on the Proxmox host — not here):"
+  warn "  pct set <CTID> -mp0 /mnt/nas/jellyfin,mp=${MEDIA_DIR}"
+  warn "  pct restart <CTID>"
+  warn ""
+  warn "Or use the Proxmox web UI:"
+  warn "  Container → Resources → Add → Mount Point"
+  warn "  Host path: /mnt/nas/jellyfin   Container path: ${MEDIA_DIR}"
+  warn ""
+  MOUNT_READY=0
+elif [ ! -w "${MEDIA_DIR}" ]; then
+  warn "${MEDIA_DIR} exists but this container cannot write to it."
+  warn "Check the mp0 entry in Proxmox — 'ro=1' makes it read-only."
+  MOUNT_READY=0
+else
+  success "${MEDIA_DIR} is mounted and writable"
+fi
+
+# ── 2. System packages ────────────────────────────────────────────────────────
+info "Updating apk and installing system packages..."
+apk update --quiet
+apk add --quiet --no-progress \
+  python3 \
+  py3-pip \
+  py3-virtualenv \
+  ffmpeg \
+  curl \
+  ca-certificates \
+  openrc
+success "System packages installed"
+
+# ── 3. Application directories ────────────────────────────────────────────────
+info "Creating application directories under ${YAAR_DIR}..."
+mkdir -p "${WEB_DIR}/templates" "${LOG_DIR}" "${JOB_DIR}"
+# MEDIA_DIR is a host bind-mount — never mkdir it from inside the container.
+# YAAR will create Movies/, Shows/, YouTube/ inside it on first archive.
+success "Directories ready"
+
+# ── 4. Copy application files ─────────────────────────────────────────────────
+info "Installing application files to ${WEB_DIR}..."
+cp "${SCRIPT_DIR}/web/app.py"               "${WEB_DIR}/app.py"
+cp "${SCRIPT_DIR}/web/templates/index.html" "${WEB_DIR}/templates/index.html"
+success "Files installed"
+
+# ── 5. Python virtualenv + pip packages ──────────────────────────────────────
+info "Creating Python virtualenv at ${VENV_DIR}..."
+python3 -m venv "${VENV_DIR}"
+"${VENV_DIR}/bin/pip" install --quiet --upgrade pip
+"${VENV_DIR}/bin/pip" install --quiet \
+  "yt-dlp>=2024.1.0" \
+  "flask>=3.0.0" \
+  "gunicorn>=21.0.0"
+success "Python dependencies installed (yt-dlp, Flask, Gunicorn)"
+
+# ── 6. Environment config ─────────────────────────────────────────────────────
+info "Writing /etc/yaar.env..."
+cat > /etc/yaar.env <<ENVEOF
+YAAR_BASE=${YAAR_DIR}
+YAAR_MEDIA=${MEDIA_DIR}
+YAAR_PORT=${PORT}
+ENVEOF
+success "/etc/yaar.env written"
+
+# ── 7. OpenRC init script ─────────────────────────────────────────────────────
+info "Installing OpenRC service (/etc/init.d/yaar)..."
+cat > /etc/init.d/yaar <<'INITEOF'
+#!/sbin/openrc-run
+
+name="yaar"
+description="YAAR - YouTube Auto-Archiver and Retagger"
+
+YAAR_DIR="/opt/yaar"
+VENV_DIR="${YAAR_DIR}/venv"
+WEB_DIR="${YAAR_DIR}/web"
+LOG_DIR="${YAAR_DIR}/logs"
+
+command="${VENV_DIR}/bin/gunicorn"
+command_args="--workers 2 --bind 0.0.0.0:7474 --timeout 0 --log-file ${LOG_DIR}/gunicorn.log --access-logfile ${LOG_DIR}/access.log app:app"
+command_background="yes"
+pidfile="/run/yaar.pid"
+directory="${WEB_DIR}"
+
+depend() {
+    need net
+    after firewall
+}
+
+start_pre() {
+    [ -f /etc/yaar.env ] && export $(grep -v '^#' /etc/yaar.env | xargs) || true
+    checkpath --directory --owner root:root --mode 0755 "${LOG_DIR}"
+}
+INITEOF
+chmod +x /etc/init.d/yaar
+rc-update add yaar default
+success "OpenRC service installed and enabled at boot"
+
+# ── 8. Weekly yt-dlp auto-update via Alpine cron ─────────────────────────────
+info "Installing weekly yt-dlp auto-update cron..."
+cat > /etc/periodic/weekly/yt-dlp-update <<CRONEOF
+#!/bin/sh
+${VENV_DIR}/bin/pip install --quiet --upgrade yt-dlp && \
+  printf "[YAAR] yt-dlp updated to \$(${VENV_DIR}/bin/yt-dlp --version)\\n" >> ${LOG_DIR}/yaar.log
+CRONEOF
+chmod +x /etc/periodic/weekly/yt-dlp-update
+success "Auto-update cron installed (/etc/periodic/weekly/yt-dlp-update)"
+
+# ── 9. Tool verification ──────────────────────────────────────────────────────
+info "Verifying ffmpeg..."
+ffmpeg -version 2>&1 | head -1
+success "ffmpeg OK"
+
+info "Verifying yt-dlp..."
+"${VENV_DIR}/bin/yt-dlp" --version
+success "yt-dlp OK"
+
+# ── 10. Start service ─────────────────────────────────────────────────────────
+info "Starting YAAR service..."
+if rc-service yaar start 2>/dev/null; then
+  success "YAAR service started"
+else
+  warn "Service start returned an error — check: ${LOG_DIR}/gunicorn.log"
+  warn "You can start it manually with: rc-service yaar start"
+fi
+
+# ── Done ──────────────────────────────────────────────────────────────────────
+HOST_IP=$(ip route get 1.1.1.1 2>/dev/null | awk '/src/{print $7}' | head -1 || echo "<container-ip>")
+
+printf "\n"
+printf "${GREEN}╔══════════════════════════════════════════════════════╗${NC}\n"
+printf "${GREEN}║             YAAR installed successfully              ║${NC}\n"
+printf "${GREEN}╠══════════════════════════════════════════════════════╣${NC}\n"
+printf "${GREEN}║  Web UI:    http://%-33s║${NC}\n" "${HOST_IP}:${PORT}"
+printf "${GREEN}║  Logs:      ${LOG_DIR}/yaar.log${NC}\n"
+printf "${GREEN}║  Service:   rc-service yaar {start|stop|restart}     ║${NC}\n"
+printf "${GREEN}╠══════════════════════════════════════════════════════╣${NC}\n"
+
+if [ "${MOUNT_READY}" = "0" ]; then
+  printf "${YELLOW}║  ⚠  ${MEDIA_DIR} not ready.                   ║${NC}\n"
+  printf "${YELLOW}║     Add mp0 in Proxmox and restart container.        ║${NC}\n"
+  printf "${YELLOW}║     YAAR will work automatically once mounted.        ║${NC}\n"
+  printf "${YELLOW}╚══════════════════════════════════════════════════════╝${NC}\n"
+else
+  printf "${GREEN}║  Media:     ${MEDIA_DIR}${NC}\n"
+  printf "${GREEN}╚══════════════════════════════════════════════════════╝${NC}\n"
+fi
+printf "\n"

+ 26 - 0
yaar/systemd/yaar.service

@@ -0,0 +1,26 @@
+[Unit]
+Description=YAAR - YouTube Auto-Archiver and Retagger
+After=network.target
+
+[Service]
+Type=simple
+WorkingDirectory=/opt/yaar/web
+EnvironmentFile=/etc/yaar.env
+ExecStart=/opt/yaar/venv/bin/gunicorn \
+    --workers 2 \
+    --bind 0.0.0.0:7474 \
+    --timeout 0 \
+    --log-file /opt/yaar/logs/gunicorn.log \
+    --access-logfile /opt/yaar/logs/access.log \
+    app:app
+Restart=on-failure
+RestartSec=5s
+
+# Security hardening
+NoNewPrivileges=yes
+PrivateTmp=yes
+ProtectSystem=full
+ReadWritePaths=/opt/yaar /media/yaar
+
+[Install]
+WantedBy=multi-user.target

+ 809 - 0
yaar/web/app.py

@@ -0,0 +1,809 @@
+#!/usr/bin/env python3
+"""YAAR - YouTube Auto-Archiver and Retagger
+
+Flask web UI that archives YouTube content into a Jellyfin-compatible layout.
+
+Media root defaults to /nas/jellyfin (a Proxmox mp0 bind-mount). Expected
+top-level subdirectories: Movies/  Shows/  Music/
+
+A job is archived as one of three media types:
+  series → Shows/<Series>/Season NN/SNNENN - <Episode>.mkv
+  movie  → Movies/<Title> (Year).mkv            (flat, no per-movie folder)
+  music  → Music/<Artist>/<Album>/<Track>.<ext>  (album optional)
+
+Logging is split into discrete files under <LOG_DIR>:
+  yaar.log              unified tail of everything
+  log_boot/<ts>.log     one file per service start
+  log_mount/<ts>.log    one file per mount check (UI + pre-download)
+  log_archive/<id>.log  one file per archive job, named by video id
+"""
+
+import os
+import re
+import json
+import uuid
+import logging
+from datetime import datetime
+from pathlib import Path
+from threading import Thread
+from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
+
+import yt_dlp
+from flask import Flask, render_template, request, jsonify
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Configuration
+# ══════════════════════════════════════════════════════════════════════════════
+
+BASE_DIR  = Path(os.environ.get("YAAR_BASE",  "/opt/yaar"))
+MEDIA_DIR = Path(os.environ.get("YAAR_MEDIA", "/nas/jellyfin"))
+PORT      = int(os.environ.get("YAAR_PORT", 7474))
+
+LOG_DIR = BASE_DIR / "logs"
+JOB_DIR = BASE_DIR / "jobs"
+
+BOOT_LOG_DIR    = LOG_DIR / "log_boot"
+MOUNT_LOG_DIR   = LOG_DIR / "log_mount"
+ARCHIVE_LOG_DIR = LOG_DIR / "log_archive"
+
+for _d in (BASE_DIR, LOG_DIR, JOB_DIR, BOOT_LOG_DIR, MOUNT_LOG_DIR, ARCHIVE_LOG_DIR):
+    _d.mkdir(parents=True, exist_ok=True)
+
+# Media sections we manage. Single source of truth used by browse + mount check.
+MEDIA_SECTIONS = ("Movies", "Shows", "Music")
+
+# Audio formats accepted by /api/archive for music jobs. Each maps directly to
+# yt-dlp's FFmpegExtractAudio `preferredcodec`.
+ALLOWED_AUDIO_FORMATS = ("m4a", "opus", "mp3", "flac", "aac", "wav")
+
+# Audio formats whose output extension differs from / needs normalising.
+# (All current formats use their own name as the extension.)
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Logging
+# ══════════════════════════════════════════════════════════════════════════════
+
+_LOG_FMT  = "%(asctime)s [%(levelname)s] %(message)s"
+_LOG_DATE = "%Y-%m-%d %H:%M:%S"
+_FORMATTER = logging.Formatter(_LOG_FMT, datefmt=_LOG_DATE)
+
+
+def _file_handler(path: Path) -> logging.FileHandler:
+    h = logging.FileHandler(path, encoding="utf-8")
+    h.setFormatter(_FORMATTER)
+    return h
+
+
+# Root logger. Everything propagates up to here, so yaar.log + console always
+# carry the full picture regardless of which discrete logger emitted the line.
+log = logging.getLogger("yaar")
+log.setLevel(logging.DEBUG)
+log.addHandler(_file_handler(LOG_DIR / "yaar.log"))
+_console = logging.StreamHandler()
+_console.setFormatter(_FORMATTER)
+log.addHandler(_console)
+
+
+def _ts() -> str:
+    """Filesystem-safe UTC timestamp, e.g. 2025-01-15_09-32-11."""
+    return datetime.utcnow().strftime("%Y-%m-%d_%H-%M-%S")
+
+
+def _fresh_logger(name: str, path: Path) -> logging.Logger:
+    """Return a uniquely-named child logger with exactly one file handler.
+
+    Child loggers propagate to the root 'yaar' logger, so their records also
+    land in yaar.log and on the console. The name is always unique (caller
+    includes a uuid/job id), which guarantees we never stack a second handler
+    onto a previously-created logger and emit duplicate lines.
+    """
+    lg = logging.getLogger(f"yaar.{name}")
+    if not lg.handlers:                 # idempotent: never double-attach
+        lg.addHandler(_file_handler(path))
+    lg.setLevel(logging.DEBUG)
+    lg.propagate = True
+    return lg
+
+
+def boot_logger() -> logging.Logger:
+    return _fresh_logger(f"boot.{_ts()}", BOOT_LOG_DIR / f"{_ts()}.log")
+
+
+def mount_logger(context: str = "") -> logging.Logger:
+    tag = f"_{context}" if context else ""
+    uid = uuid.uuid4().hex[:6]
+    path = MOUNT_LOG_DIR / f"{_ts()}{tag}.log"
+    return _fresh_logger(f"mount.{_ts()}.{uid}", path)
+
+
+def archive_logger(job_id: str, url: str) -> logging.Logger:
+    """One file per job, named '<video_id>__<job_id>.log'.
+
+    Keyed on job_id so retries reuse the same logger object (and append to the
+    same file) without stacking duplicate handlers.
+    """
+    path = ARCHIVE_LOG_DIR / f"{video_id(url)}__{job_id}.log"
+    return _fresh_logger(f"archive.{job_id}", path)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# URL + filename helpers
+# ══════════════════════════════════════════════════════════════════════════════
+
+def sanitize(name: str) -> str:
+    """Strip characters invalid in Linux/Windows filenames."""
+    return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", name or "").strip()
+
+
+def clean_url(url: str) -> str:
+    """Strip tracking/playlist params, keeping only what yt-dlp needs.
+
+    youtube.com/watch  → keep only ?v=<id>
+    youtu.be / shorts  → drop the entire query string (id is in the path)
+    """
+    parsed = urlparse((url or "").strip())
+    host   = parsed.netloc.lower().removeprefix("www.")
+
+    if host == "youtube.com" and parsed.path.startswith("/watch"):
+        v = parse_qs(parsed.query).get("v", [""])[0]
+        query = urlencode({"v": v}) if v else ""
+        return urlunparse(parsed._replace(query=query, fragment=""))
+
+    return urlunparse(parsed._replace(query="", fragment=""))
+
+
+def video_id(url: str) -> str:
+    """Extract a YouTube video id for use as a log filename.
+
+    Falls back to a sanitised, truncated form of the URL when no id is found.
+    """
+    try:
+        p    = urlparse(url or "")
+        host = p.netloc.lower().removeprefix("www.")
+        if host == "youtu.be":
+            vid = p.path.lstrip("/").split("/")[0]
+            if vid:
+                return vid
+        if host == "youtube.com":
+            v = parse_qs(p.query).get("v", [""])[0]
+            if v:
+                return v
+            parts = [x for x in p.path.split("/") if x]
+            if len(parts) >= 2 and parts[0] in ("shorts", "live", "embed"):
+                return parts[1]
+    except Exception:
+        pass
+    return re.sub(r"[^A-Za-z0-9_-]", "_", url or "unknown")[:60] or "unknown"
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Boot
+# ══════════════════════════════════════════════════════════════════════════════
+
+app = Flask(__name__)
+
+# In-memory job registry, persisted to JOB_DIR as one JSON file per job.
+jobs: dict[str, dict] = {}
+
+# Polling support. `action_version` increments on every state mutation so the
+# frontend can drop stale, out-of-order poll responses. `server_instance` lets
+# the frontend detect a restart (version resets to 0) and re-sync instead of
+# freezing because the counter went backwards.
+action_version  = 0
+server_instance = uuid.uuid4().hex[:12]
+
+
+def bump_version() -> int:
+    global action_version
+    action_version += 1
+    return action_version
+
+
+def jobs_snapshot() -> dict:
+    """Standard response body for every endpoint that lists or mutates jobs."""
+    ordered = sorted(jobs.values(), key=lambda j: j.get("created_at", ""), reverse=True)
+    return {
+        "server_instance": server_instance,
+        "action_version":  action_version,
+        "jobs":            ordered[:50],
+    }
+
+
+def save_job(job: dict) -> None:
+    """Persist a job to disk, but only if it still exists in the registry.
+
+    Guards against a deleted job being resurrected by a still-running worker
+    thread that holds a closure reference to the orphaned dict. Bumps the
+    action version so polling clients observe the change.
+    """
+    job_id = job.get("id")
+    if not job_id or job_id not in jobs:
+        return
+    bump_version()
+    (JOB_DIR / f"{job_id}.json").write_text(json.dumps(job, default=str, indent=2))
+
+
+def load_jobs() -> None:
+    for p in sorted(JOB_DIR.glob("*.json"), key=lambda f: f.stat().st_mtime, reverse=True):
+        try:
+            j = json.loads(p.read_text())
+            jobs[j["id"]] = j
+        except Exception as e:
+            log.warning(f"Could not load job {p}: {e}")
+
+
+load_jobs()
+
+_boot = boot_logger()
+_boot.info("=" * 60)
+_boot.info("YAAR service starting")
+_boot.info(f"  BASE_DIR  : {BASE_DIR}")
+_boot.info(f"  MEDIA_DIR : {MEDIA_DIR}")
+_boot.info(f"  LOG_DIR   : {LOG_DIR}")
+_boot.info(f"  JOB_DIR   : {JOB_DIR}")
+_boot.info(f"  Port      : {PORT}")
+_boot.info(f"  PID       : {os.getpid()}")
+_boot.info(f"Restored {len(jobs)} job(s) from disk:")
+for _jid, _j in jobs.items():
+    _boot.info(f"  [{_jid}] {_j.get('status', '?'):8s} {_j.get('url', '')}")
+_boot.info("=" * 60)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Mount + path resolution
+# ══════════════════════════════════════════════════════════════════════════════
+
+def get_mount_info() -> dict:
+    """Report mount status and which known subdirectories exist.
+
+    Writes a discrete entry to log_mount/ on every call.
+    """
+    mlog = mount_logger("api")
+    mlog.info(f"Mount check (API) — target: {MEDIA_DIR}")
+
+    mounted  = MEDIA_DIR.exists()
+    writable = False
+    subdirs: dict[str, dict] = {}
+
+    if mounted:
+        mlog.info(f"  {MEDIA_DIR} exists")
+        try:
+            probe = MEDIA_DIR / ".yaar_write_test"
+            probe.touch()
+            probe.unlink()
+            writable = True
+            mlog.info(f"  {MEDIA_DIR} is writable")
+        except OSError as e:
+            mlog.warning(f"  {MEDIA_DIR} is NOT writable: {e}")
+
+        for name in MEDIA_SECTIONS:
+            p = MEDIA_DIR / name
+            subdirs[name] = {"exists": p.exists(), "path": str(p)}
+            mlog.info(f"  subdir {name}/: {'present' if p.exists() else 'absent'}")
+    else:
+        mlog.warning(f"  {MEDIA_DIR} does NOT exist — bind-mount (mp0) may be missing")
+
+    mlog.info(f"Result: mounted={mounted} writable={writable}")
+    return {"root": str(MEDIA_DIR), "mounted": mounted, "writable": writable, "subdirs": subdirs}
+
+
+def resolve_output_dir(job: dict) -> Path:
+    """Return (and create) the directory a job's files should land in.
+
+    Always rooted at MEDIA_DIR; raises ValueError on an unknown media type or
+    if the resolved path would escape the media root.
+    """
+    mtype = job["media_type"]
+    log.debug(f"resolve_output_dir: media_type={mtype!r} job_id={job.get('id')}")
+
+    if mtype == "movie":
+        out_dir = MEDIA_DIR / "Movies"
+
+    elif mtype == "series":
+        folder = sanitize(job.get("folder") or job.get("series") or "Unknown Series")
+        season = int(job.get("season") or 1)
+        out_dir = MEDIA_DIR / "Shows" / folder / f"Season {season:02d}"
+
+    elif mtype == "music":
+        artist = sanitize(job.get("artist") or job.get("author") or "Unknown Artist")
+        album  = sanitize(job.get("album") or "")
+        out_dir = MEDIA_DIR / "Music" / artist
+        if album:
+            out_dir = out_dir / album
+
+    else:
+        raise ValueError(f"Unknown media_type: {mtype!r}")
+
+    try:
+        out_dir.resolve().relative_to(MEDIA_DIR.resolve())
+    except ValueError:
+        raise ValueError(f"Resolved output path escapes media root: {out_dir}")
+
+    out_dir.mkdir(parents=True, exist_ok=True)
+    log.debug(f"resolve_output_dir: resolved → {out_dir}")
+    return out_dir
+
+
+def output_extension(job: dict) -> str:
+    """Final container/file extension for a job."""
+    if job["media_type"] == "music":
+        fmt = (job.get("format") or "m4a").lower()
+        return fmt if fmt in ALLOWED_AUDIO_FORMATS else "m4a"
+    return "mkv"
+
+
+def build_output_template(job: dict) -> Path:
+    """Full yt-dlp outtmpl path, including the %(ext)s placeholder."""
+    out_dir = resolve_output_dir(job)
+    mtype   = job["media_type"]
+    title   = sanitize(job.get("title") or "untitled")
+
+    if mtype == "movie":
+        year = job.get("year", "")
+        base = f"{title} ({year})" if year else title
+        filename = f"{base}.%(ext)s"
+
+    elif mtype == "series":
+        season  = int(job.get("season") or 1)
+        episode = int(job.get("episode") or 1)
+        ep      = sanitize(job.get("episode_title") or title)
+        filename = f"S{season:02d}E{episode:02d} - {ep}.%(ext)s"
+
+    elif mtype == "music":
+        track = job.get("track")
+        try:
+            prefix = f"{int(track):02d} - " if track else ""
+        except (ValueError, TypeError):
+            prefix = ""
+        filename = f"{prefix}{title}.%(ext)s"
+
+    else:
+        raise ValueError(f"Unknown media_type: {mtype!r}")
+
+    return out_dir / filename
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# yt-dlp option building
+# ══════════════════════════════════════════════════════════════════════════════
+
+def build_ffmpeg_metadata_args(job: dict) -> list[str]:
+    """ffmpeg -metadata flags embedded into the output container."""
+    meta = {
+        "title":       job.get("title", ""),
+        "artist":      job.get("artist") or job.get("author", ""),
+        "date":        job.get("upload_date", ""),
+        "comment":     job.get("url", ""),
+        "description": (job.get("description") or "")[:500],
+    }
+
+    if job["media_type"] == "series":
+        meta["show"]          = job.get("series", "")
+        meta["season_number"] = str(job.get("season") or 1)
+        meta["episode_id"]    = str(job.get("episode") or 1)
+        meta["episode_sort"]  = str(job.get("episode") or 1)
+
+    elif job["media_type"] == "music":
+        if job.get("album"):
+            meta["album"] = job["album"]
+        if job.get("track"):
+            meta["track"] = str(job["track"])
+        if job.get("genre"):
+            meta["genre"] = job["genre"]
+        if job.get("year"):
+            meta["date"] = str(job["year"])
+        meta["album_artist"] = job.get("artist") or job.get("author", "")
+
+    args: list[str] = []
+    for k, v in meta.items():
+        if v:
+            args += ["-metadata", f"{k}={v}"]
+    return args
+
+
+def build_ydl_opts(job: dict, output_template: Path, progress_hook) -> dict:
+    """yt-dlp options. Music = audio-only; series/movie = H.264/AAC → MKV."""
+    common = {
+        "outtmpl": str(output_template),
+        "writeinfojson": False,
+        "postprocessor_args": {"ffmpeg": build_ffmpeg_metadata_args(job)},
+        "progress_hooks": [progress_hook],
+        "quiet": True,
+        "no_warnings": True,
+        "ignoreerrors": False,
+    }
+
+    if job["media_type"] == "music":
+        fmt = (job.get("format") or "m4a").lower()
+        if fmt not in ALLOWED_AUDIO_FORMATS:
+            fmt = "m4a"
+
+        # Opus lives in webm on YouTube; everything else starts from m4a/AAC.
+        src = "bestaudio[ext=webm]/bestaudio/best" if fmt == "opus" \
+              else "bestaudio[ext=m4a]/bestaudio/best"
+
+        # Lossless/uncompressed containers don't take embedded cover art well.
+        embed_thumb = fmt not in ("flac", "wav")
+
+        post = [
+            {"key": "FFmpegExtractAudio", "preferredcodec": fmt, "preferredquality": "0"},
+            {"key": "FFmpegMetadata", "add_metadata": True},
+        ]
+        if embed_thumb:
+            post.append({"key": "EmbedThumbnail", "already_have_thumbnail": False})
+
+        return {
+            **common,
+            "format": src,
+            "writethumbnail": embed_thumb,
+            "embedthumbnail": embed_thumb,
+            "postprocessors": post,
+        }
+
+    # Video (series / movie): prefer H.264 + AAC for Jellyfin direct-play,
+    # falling back to best available if only VP9/AV1 is offered.
+    return {
+        **common,
+        "format": (
+            "bestvideo[vcodec^=avc1]+bestaudio[acodec^=mp4a]/"
+            "bestvideo[vcodec^=avc1]+bestaudio/"
+            "bestvideo+bestaudio/best"
+        ),
+        "merge_output_format": "mkv",
+        "writethumbnail": True,
+        "embedthumbnail": True,
+        "postprocessors": [
+            {"key": "FFmpegVideoConvertor", "preferedformat": "mkv"},
+            {"key": "FFmpegMetadata", "add_metadata": True, "add_chapters": True},
+            {"key": "EmbedThumbnail", "already_have_thumbnail": False},
+        ],
+    }
+
+
+def probe_url(url: str) -> dict:
+    """Fetch metadata without downloading."""
+    with yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True, "skip_download": True}) as ydl:
+        return ydl.extract_info(url, download=False)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# NFO sidecar
+# ══════════════════════════════════════════════════════════════════════════════
+
+def write_nfo(job: dict, output_template: Path) -> None:
+    """Write a Jellyfin NFO sidecar next to the media file.
+
+    Series and movies get NFOs; music relies on embedded tags and is skipped.
+    """
+    nfo_path = Path(str(output_template).replace(".%(ext)s", ".nfo"))
+    mtype = job["media_type"]
+
+    if mtype == "series":
+        nfo = f"""<?xml version="1.0" encoding="UTF-8"?>
+<episodedetails>
+  <title>{job.get('episode_title') or job.get('title', '')}</title>
+  <showtitle>{job.get('series', '')}</showtitle>
+  <season>{job.get('season', 1)}</season>
+  <episode>{job.get('episode', 1)}</episode>
+  <plot>{job.get('description', '')}</plot>
+  <aired>{job.get('upload_date', '')}</aired>
+  <director>{job.get('author', '')}</director>
+  <uniqueid type="youtube">{job.get('url', '')}</uniqueid>
+</episodedetails>"""
+
+    elif mtype == "movie":
+        nfo = f"""<?xml version="1.0" encoding="UTF-8"?>
+<movie>
+  <title>{job.get('title', '')}</title>
+  <year>{job.get('year', '')}</year>
+  <plot>{job.get('description', '')}</plot>
+  <director>{job.get('author', '')}</director>
+  <uniqueid type="youtube">{job.get('url', '')}</uniqueid>
+</movie>"""
+
+    else:
+        return
+
+    nfo_path.write_text(nfo, encoding="utf-8")
+    log.info(f"NFO written: {nfo_path}")
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Download worker
+# ══════════════════════════════════════════════════════════════════════════════
+
+class _Cancelled(Exception):
+    """Raised inside the yt-dlp hook when a job is deleted mid-download."""
+
+
+def run_download(job_id: str) -> None:
+    job  = jobs[job_id]
+    alog = archive_logger(job_id, job.get("url", ""))
+
+    job.update(status="running", started_at=datetime.utcnow().isoformat(), progress=0, log=[])
+    save_job(job)
+
+    alog.info("=" * 60)
+    alog.info("Archive job started")
+    alog.info(f"  Job ID     : {job_id}")
+    alog.info(f"  URL        : {job.get('url')}")
+    alog.info(f"  Media type : {job.get('media_type')}")
+    alog.info(f"  Title      : {job.get('title') or '(pending probe)'}")
+    if job.get("media_type") == "series":
+        alog.info(f"  Series     : {job.get('series') or job.get('folder')}")
+        alog.info(f"  Episode    : S{int(job.get('season') or 1):02d}E{int(job.get('episode') or 1):02d}")
+    elif job.get("media_type") == "movie":
+        alog.info(f"  Year       : {job.get('year') or '(unknown)'}")
+    elif job.get("media_type") == "music":
+        alog.info(f"  Artist     : {job.get('artist') or job.get('author')}")
+        alog.info(f"  Album      : {job.get('album') or '(none)'}")
+        alog.info(f"  Format     : {job.get('format') or 'm4a'}")
+    alog.info("=" * 60)
+    log.info(f"Job {job_id} started → {job['url']}")
+
+    def hook(d):
+        if job_id not in jobs:
+            raise _Cancelled(f"Job {job_id} removed by user")
+        if d["status"] == "downloading":
+            pct = d.get("_percent_str", "0%").strip().replace("%", "")
+            try:
+                job["progress"] = float(pct)
+            except ValueError:
+                pass
+            job["speed"] = d.get("_speed_str", "")
+            job["eta"]   = d.get("_eta_str", "")
+            save_job(job)
+        elif d["status"] == "finished":
+            job["progress"] = 99
+            fname = d.get("filename", "")
+            job["log"].append(f"Downloaded: {fname}")
+            alog.info(f"yt-dlp finished downloading: {fname}")
+            save_job(job)
+
+    try:
+        # ── Pre-download mount check ──────────────────────────────────────────
+        mlog = mount_logger(f"pre_archive_{job_id}")
+        mlog.info(f"Pre-download mount check for job {job_id} — {MEDIA_DIR}")
+
+        if not MEDIA_DIR.exists():
+            mlog.error(f"  FAIL — {MEDIA_DIR} does not exist")
+            alog.error(f"Mount check failed: {MEDIA_DIR} does not exist")
+            raise RuntimeError(
+                f"Media root {MEDIA_DIR} does not exist. Check that the Proxmox "
+                "bind-mount (mp0) is attached and the container is running."
+            )
+        if not os.access(MEDIA_DIR, os.W_OK):
+            mlog.error(f"  FAIL — {MEDIA_DIR} is not writable")
+            alog.error(f"Mount check failed: {MEDIA_DIR} is not writable")
+            raise RuntimeError(f"Media root {MEDIA_DIR} is not writable. Check container mount permissions.")
+        mlog.info(f"  OK — {MEDIA_DIR} is mounted and writable")
+
+        # ── Resolve output path ───────────────────────────────────────────────
+        alog.info("Resolving output directory…")
+        output_template = build_output_template(job)
+        job["output_path"] = str(output_template).replace(".%(ext)s", f".{output_extension(job)}")
+        alog.info(f"  Output : {job['output_path']}")
+        save_job(job)
+
+        # ── Download ──────────────────────────────────────────────────────────
+        alog.info("Starting yt-dlp download…")
+        with yt_dlp.YoutubeDL(build_ydl_opts(job, output_template, hook)) as ydl:
+            info = ydl.extract_info(job["url"])
+            if not job.get("author"):
+                job["author"] = info.get("uploader") or info.get("channel") or "Unknown"
+            if not job.get("upload_date"):
+                raw = info.get("upload_date", "")
+                if raw:
+                    job["upload_date"] = f"{raw[:4]}-{raw[4:6]}-{raw[6:]}"
+            job["description"] = (info.get("description") or "")[:500]
+            alog.info(f"  Channel    : {job.get('author')}")
+            alog.info(f"  Upload date: {job.get('upload_date')}")
+
+        write_nfo(job, output_template)
+
+        job.update(status="done", progress=100, finished_at=datetime.utcnow().isoformat())
+        alog.info("-" * 60)
+        alog.info("Archive COMPLETE")
+        alog.info(f"  Output      : {job['output_path']}")
+        alog.info(f"  Finished at : {job['finished_at']}")
+        alog.info("-" * 60)
+        log.info(f"Job {job_id} complete → {job['output_path']}")
+
+    except _Cancelled:
+        alog.warning(f"Job {job_id} cancelled by user — download aborted")
+        log.info(f"Job {job_id} cancelled by user")
+        return
+
+    except Exception as e:
+        # A job that vanished mid-download is a cancellation, not an error.
+        if job_id not in jobs:
+            alog.warning(f"Job {job_id} removed during download — treating as cancellation")
+            log.info(f"Job {job_id} removed during download")
+            return
+        job.update(status="error", error=str(e))
+        alog.error(f"Archive FAILED: {e}")
+        log.error(f"Job {job_id} failed: {e}")
+
+    save_job(job)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Routes
+# ══════════════════════════════════════════════════════════════════════════════
+
+@app.route("/")
+def index():
+    return render_template("index.html")
+
+
+@app.route("/api/mounts")
+def mounts():
+    """Mount status for the UI banner."""
+    return jsonify(get_mount_info())
+
+
+@app.route("/api/browse")
+def browse():
+    """First-level subdirectories inside each managed media section."""
+    result = {}
+    for section in MEDIA_SECTIONS:
+        p = MEDIA_DIR / section
+        result[section] = sorted(
+            e.name for e in p.iterdir() if e.is_dir() and not e.name.startswith(".")
+        ) if p.is_dir() else []
+    return jsonify(result)
+
+
+@app.route("/api/browse/<section>/<path:folder>")
+def browse_folder(section: str, folder: str):
+    """Subdirectories one level deep inside MEDIA_DIR/<section>/<folder>."""
+    if section not in MEDIA_SECTIONS:
+        return jsonify({"error": "Unknown section"}), 400
+
+    target = MEDIA_DIR / section / folder
+    try:
+        target.resolve().relative_to(MEDIA_DIR.resolve())
+    except ValueError:
+        return jsonify({"error": "Invalid path"}), 400
+
+    if not target.is_dir():
+        return jsonify([])
+    return jsonify(sorted(
+        e.name for e in target.iterdir() if e.is_dir() and not e.name.startswith(".")
+    ))
+
+
+@app.route("/api/probe", methods=["POST"])
+def probe():
+    """Fetch video metadata without downloading."""
+    url = clean_url((request.json or {}).get("url", ""))
+    if not url:
+        return jsonify({"error": "No URL provided"}), 400
+    try:
+        info = probe_url(url)
+        upload_date = info.get("upload_date", "")
+        if upload_date:
+            upload_date = f"{upload_date[:4]}-{upload_date[4:6]}-{upload_date[6:]}"
+        return jsonify({
+            "title":       info.get("title", ""),
+            "author":      info.get("uploader") or info.get("channel") or "",
+            "upload_date": upload_date,
+            "duration":    info.get("duration_string") or str(info.get("duration", "")),
+            "thumbnail":   info.get("thumbnail", ""),
+            "description": (info.get("description") or "")[:300],
+            "year":        upload_date[:4] if upload_date else "",
+        })
+    except Exception as e:
+        return jsonify({"error": str(e)}), 500
+
+
+@app.route("/api/archive", methods=["POST"])
+def archive():
+    """Validate inputs and enqueue a download job."""
+    data = request.json or {}
+    url  = clean_url(data.get("url", ""))
+    if not url:
+        return jsonify({"error": "URL is required"}), 400
+
+    # Fail fast if the mount is missing, before creating a doomed job.
+    if not MEDIA_DIR.exists():
+        return jsonify({
+            "error": f"Media root '{MEDIA_DIR}' is not accessible. Ensure the "
+                     "Proxmox bind-mount (mp0) is configured and the container is started."
+        }), 503
+
+    mtype = data.get("media_type", "series")
+    if mtype not in ("series", "movie", "music"):
+        return jsonify({"error": f"media_type must be 'series', 'movie', or 'music' (got: {mtype!r})"}), 400
+
+    if mtype == "music":
+        fmt = (data.get("format") or "m4a").lower()
+        if fmt not in ALLOWED_AUDIO_FORMATS:
+            return jsonify({"error": f"format must be one of {'/'.join(ALLOWED_AUDIO_FORMATS)} (got: {fmt!r})"}), 400
+
+    job_id = str(uuid.uuid4())[:8]
+    jobs[job_id] = {
+        "id":            job_id,
+        "url":           url,
+        "media_type":    mtype,
+        "title":         data.get("title", ""),
+        "author":        data.get("author", ""),
+        "upload_date":   data.get("upload_date", ""),
+        "description":   data.get("description", ""),
+        "year":          data.get("year", ""),
+        # series
+        "series":        data.get("series", ""),
+        "season":        int(data.get("season") or 1),
+        "episode":       int(data.get("episode") or 1),
+        "episode_title": data.get("episode_title", ""),
+        "folder":        data.get("folder", ""),
+        # music
+        "artist":        data.get("artist", ""),
+        "album":         data.get("album", ""),
+        "track":         data.get("track", ""),
+        "genre":         data.get("genre", ""),
+        "format":        (data.get("format") or "m4a").lower(),
+        # status
+        "output_path":   "",
+        "status":        "queued",
+        "progress":      0,
+        "created_at":    datetime.utcnow().isoformat(),
+    }
+    save_job(jobs[job_id])
+
+    Thread(target=run_download, args=(job_id,), daemon=True).start()
+    log.info(f"Job {job_id} queued: {url}")
+    return jsonify({"job_id": job_id, **jobs_snapshot()})
+
+
+@app.route("/api/jobs")
+def list_jobs():
+    return jsonify(jobs_snapshot())
+
+
+@app.route("/api/jobs/<job_id>")
+def get_job(job_id):
+    job = jobs.get(job_id)
+    if not job:
+        return jsonify({"error": "Job not found"}), 404
+    return jsonify(job)
+
+
+@app.route("/api/jobs/<job_id>/retry", methods=["POST"])
+def retry_job(job_id):
+    job = jobs.get(job_id)
+    if not job:
+        return jsonify({"error": "Job not found"}), 404
+    job.update(status="queued", progress=0, error="")
+    save_job(job)
+    Thread(target=run_download, args=(job_id,), daemon=True).start()
+    return jsonify({"ok": True, **jobs_snapshot()})
+
+
+@app.route("/api/jobs/<job_id>", methods=["DELETE"])
+def delete_job(job_id):
+    if jobs.pop(job_id, None) is not None:
+        bump_version()
+    p = JOB_DIR / f"{job_id}.json"
+    if p.exists():
+        p.unlink()
+    return jsonify({"ok": True, **jobs_snapshot()})
+
+
+@app.route("/api/jobs", methods=["DELETE"])
+def clear_finished_jobs():
+    """Remove all done/error jobs. Active jobs are left untouched."""
+    removed = []
+    for job_id in [jid for jid, j in jobs.items() if j.get("status") in ("done", "error")]:
+        jobs.pop(job_id, None)
+        p = JOB_DIR / f"{job_id}.json"
+        if p.exists():
+            p.unlink()
+        removed.append(job_id)
+    if removed:
+        bump_version()
+    return jsonify({"removed": removed, "count": len(removed), **jobs_snapshot()})
+
+
+if __name__ == "__main__":
+    app.run(host="0.0.0.0", port=PORT, debug=False)

+ 923 - 0
yaar/web/templates/index.html

@@ -0,0 +1,923 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>YAAR — YouTube Auto-Archiver and Retagger</title>
+<style>
+  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+  :root {
+    --bg:        #0d0f12;
+    --bg2:       #13161b;
+    --bg3:       #1a1e26;
+    --border:    #252932;
+    --border2:   #313846;
+    --text:      #d4d8e2;
+    --muted:     #636c7e;
+    --accent:    #4f8ef7;
+    --accent-bg: #1a2540;
+    --accent-dim:#2c3f6a;
+    --green:     #3dd68c;
+    --green-bg:  #0e2419;
+    --amber:     #f5a623;
+    --amber-bg:  #2a1f0a;
+    --red:       #f05252;
+    --red-bg:    #2a0e0e;
+    --mono:      'Fira Code', 'Cascadia Code', 'Consolas', monospace;
+    --sans:      'Inter', system-ui, sans-serif;
+    --radius:    6px;
+  }
+
+  body { background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.6; min-height:100vh; }
+
+  header {
+    border-bottom:1px solid var(--border); padding:0 2rem; height:52px;
+    display:flex; align-items:center; gap:1rem;
+    position:sticky; top:0; background:var(--bg); z-index:10;
+  }
+  .logo { font-family:var(--mono); font-size:15px; font-weight:600; color:var(--accent); letter-spacing:.05em; }
+  .logo span { color:var(--muted); font-weight:400; font-size:12px; margin-left:.5rem; }
+  .header-status { margin-left:auto; font-size:12px; color:var(--muted); font-family:var(--mono); }
+  .dot { display:inline-block; width:6px; height:6px; border-radius:50%; background:var(--green); margin-right:6px; animation:pulse 2s infinite; }
+  @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
+
+  .layout { display:grid; grid-template-columns:440px 1fr; height:calc(100vh - 52px); }
+
+  .panel-left { border-right:1px solid var(--border); overflow-y:auto; padding:1.5rem; display:flex; flex-direction:column; gap:1.25rem; }
+  .panel-right { overflow-y:auto; padding:1.5rem; }
+
+  .section-label { font-size:10px; font-family:var(--mono); letter-spacing:.12em; color:var(--muted); text-transform:uppercase; margin-bottom:.6rem; }
+
+  .url-row { display:flex; gap:.5rem; }
+
+  input[type="text"], input[type="number"], select, textarea {
+    background:var(--bg2); border:1px solid var(--border2); color:var(--text);
+    border-radius:var(--radius); padding:.45rem .7rem; font-size:13px;
+    font-family:var(--sans); width:100%; outline:none; transition:border-color .15s;
+  }
+  input:focus, select:focus, textarea:focus { border-color:var(--accent-dim); }
+  input::placeholder, textarea::placeholder { color:var(--muted); }
+  select option { background:var(--bg2); }
+
+  .btn {
+    padding:.45rem 1rem; border-radius:var(--radius); font-size:13px; font-weight:500;
+    cursor:pointer; border:none; white-space:nowrap; transition:opacity .15s,transform .1s; font-family:var(--sans);
+  }
+  .btn:active { transform:scale(.97); }
+  .btn:disabled { opacity:.4; cursor:not-allowed; }
+  .btn-probe { background:var(--bg3); color:var(--text); border:1px solid var(--border2); min-width:32px; }
+  .btn-probe:hover:not(:disabled) { border-color:var(--accent-dim); }
+  .btn-primary { background:var(--accent); color:#fff; width:100%; }
+  .btn-primary:hover:not(:disabled) { opacity:.88; }
+
+  /* Preview card */
+  #preview-card { background:var(--bg2); border:1px solid var(--border); border-radius:var(--radius); padding:.85rem; display:none; gap:.75rem; }
+  #preview-card.visible { display:flex; }
+  #preview-thumb { width:100px; height:56px; object-fit:cover; border-radius:4px; flex-shrink:0; background:var(--bg3); }
+  .preview-meta { flex:1; min-width:0; }
+  .preview-title { font-size:13px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:3px; }
+  .preview-sub { font-size:11px; color:var(--muted); font-family:var(--mono); }
+
+  /* Type tabs */
+  .type-tabs { display:flex; background:var(--bg2); border:1px solid var(--border); border-radius:var(--radius); padding:3px; }
+  .type-tab { flex:1; text-align:center; padding:.35rem 0; font-size:12px; cursor:pointer; border-radius:4px; color:var(--muted); transition:all .15s; user-select:none; }
+  .type-tab.active { background:var(--bg3); color:var(--text); border:1px solid var(--border2); }
+
+  /* Fields */
+  .field { display:flex; flex-direction:column; gap:.3rem; }
+  .field label { font-size:11px; color:var(--muted); }
+  .row-2 { display:grid; grid-template-columns:1fr 1fr; gap:.6rem; }
+  .row-3 { display:grid; grid-template-columns:2fr 1fr 1fr; gap:.6rem; }
+
+  .type-section { display:none; flex-direction:column; gap:.75rem; }
+  .type-section.active { display:flex; }
+
+  hr { border:none; border-top:1px solid var(--border); }
+
+  /* Folder picker */
+  .folder-picker { display:flex; flex-direction:column; gap:.5rem; }
+  .folder-row { display:flex; gap:.5rem; align-items:flex-end; }
+  .folder-row select { flex:1; }
+  .folder-row input  { flex:1; }
+  .folder-hint { font-size:10px; color:var(--muted); font-family:var(--mono); padding:.3rem .4rem; background:var(--bg2); border:1px solid var(--border); border-radius:4px; word-break:break-all; display:none; }
+  .folder-hint.visible { display:block; }
+
+  /* Mount banner */
+  #mount-banner { display:none; font-size:11px; font-family:var(--mono); padding:.45rem .7rem; border-radius:var(--radius); border:1px solid; line-height:1.5; }
+  #mount-banner.ok    { background:var(--green-bg); color:var(--green); border-color:#1a4030; }
+  #mount-banner.warn  { background:var(--amber-bg); color:var(--amber); border-color:#4a350a; }
+  #mount-banner.error { background:var(--red-bg);   color:var(--red);   border-color:#4a1414; }
+  #mount-banner.visible { display:block; }
+
+  /* Path preview */
+  #path-preview { font-size:11px; font-family:var(--mono); color:var(--muted); background:var(--bg2); border:1px solid var(--border); border-radius:4px; padding:.4rem .6rem; word-break:break-all; display:none; }
+  #path-preview.visible { display:block; }
+
+  /* Probe status indicator inside URL row */
+  #probe-status { font-size:11px; font-family:var(--mono); color:var(--muted); white-space:nowrap; align-self:center; min-width:60px; text-align:right; }
+
+  /* Jobs panel */
+  .jobs-header { display:flex; align-items:center; gap:.75rem; margin-bottom:1rem; }
+  .jobs-title { font-size:13px; font-weight:500; font-family:var(--mono); color:var(--muted); }
+  .badge { font-size:10px; font-family:var(--mono); padding:1px 7px; border-radius:999px; background:var(--bg3); border:1px solid var(--border2); color:var(--muted); }
+  .jobs-list { display:flex; flex-direction:column; gap:.5rem; }
+
+  .job-card { background:var(--bg2); border:1px solid var(--border); border-radius:var(--radius); padding:.8rem 1rem; transition:border-color .2s; }
+  .job-card:hover { border-color:var(--border2); }
+  .job-top { display:flex; align-items:flex-start; gap:.75rem; margin-bottom:.5rem; }
+  .job-icon { width:28px; height:28px; border-radius:4px; display:flex; align-items:center; justify-content:center; font-size:12px; flex-shrink:0; font-family:var(--mono); font-weight:600; }
+  .icon-tv    { background:#0b1a2a; color:#4f8ef7; }
+  .icon-film  { background:#0c1f14; color:#3dd68c; }
+  .icon-music { background:#1a0c1f; color:#c084fc; }
+  .job-info { flex:1; min-width:0; }
+  .job-title { font-size:13px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:2px; }
+  .job-sub { font-size:11px; color:var(--muted); font-family:var(--mono); }
+  .job-status { font-size:10px; font-family:var(--mono); padding:2px 8px; border-radius:999px; font-weight:500; flex-shrink:0; }
+  .status-queued  { background:var(--bg3);      color:var(--muted);  border:1px solid var(--border2); }
+  .status-running { background:var(--accent-bg); color:var(--accent); border:1px solid var(--accent-dim); }
+  .status-done    { background:var(--green-bg);  color:var(--green);  border:1px solid #1a4030; }
+  .status-error   { background:var(--red-bg);    color:var(--red);    border:1px solid #4a1414; }
+  .progress-bar { height:2px; background:var(--border); border-radius:999px; overflow:hidden; margin-top:.5rem; }
+  .progress-fill { height:100%; background:var(--accent); border-radius:999px; transition:width .5s ease; }
+  .progress-fill.done  { background:var(--green); }
+  .progress-fill.error { background:var(--red); }
+  .job-speed { font-size:10px; color:var(--muted); font-family:var(--mono); margin-top:.35rem; }
+  .job-error { font-size:11px; color:var(--red); font-family:var(--mono); margin-top:.4rem; background:var(--red-bg); padding:.4rem .6rem; border-radius:4px; word-break:break-all; }
+  .job-actions { display:flex; gap:.4rem; margin-top:.5rem; justify-content:flex-end; }
+  .btn-sm { font-size:11px; font-family:var(--mono); padding:2px 10px; border-radius:4px; cursor:pointer; border:1px solid var(--border2); background:var(--bg3); color:var(--muted); transition:color .15s,border-color .15s; }
+  .btn-sm:hover { color:var(--text); border-color:var(--accent-dim); }
+  .btn-sm.danger:hover { color:var(--red); border-color:var(--red); }
+
+  .empty-state { text-align:center; color:var(--muted); padding:3rem 1rem; font-family:var(--mono); font-size:13px; }
+
+  #toast { position:fixed; bottom:1.5rem; right:1.5rem; background:var(--bg3); border:1px solid var(--border2); color:var(--text); padding:.6rem 1rem; border-radius:var(--radius); font-size:13px; font-family:var(--mono); opacity:0; transform:translateY(8px); transition:all .2s; pointer-events:none; z-index:100; }
+  #toast.show { opacity:1; transform:translateY(0); }
+  #toast.success { border-color:var(--green); color:var(--green); }
+  #toast.error   { border-color:var(--red);   color:var(--red);   }
+
+  .spinner { display:inline-block; width:12px; height:12px; border:1.5px solid var(--border2); border-top-color:var(--accent); border-radius:50%; animation:spin .7s linear infinite; vertical-align:middle; }
+  @keyframes spin { to{transform:rotate(360deg)} }
+
+  ::-webkit-scrollbar { width:5px; }
+  ::-webkit-scrollbar-track { background:transparent; }
+  ::-webkit-scrollbar-thumb { background:var(--border2); border-radius:999px; }
+</style>
+</head>
+<body>
+
+<header>
+  <div class="logo">YAAR<span>YouTube Auto-Archiver and Retagger</span></div>
+  <div class="header-status" id="header-status"><span class="dot"></span>checking…</div>
+</header>
+
+<div class="layout">
+
+  <div class="panel-left">
+
+    <div id="mount-banner"></div>
+
+    <!-- URL input -->
+    <div>
+      <div class="section-label">YouTube URL</div>
+      <div class="url-row">
+        <input type="text" id="url-input" placeholder="Paste a YouTube URL…" autocomplete="off" spellcheck="false">
+        <button class="btn btn-probe" id="probe-btn" onclick="probeUrl()" title="Re-fetch metadata">↻</button>
+      </div>
+      <div id="probe-status" style="margin-top:.35rem;font-size:11px;font-family:var(--mono);color:var(--muted);min-height:16px;"></div>
+    </div>
+
+    <!-- Preview card -->
+    <div id="preview-card">
+      <img id="preview-thumb" src="" alt="">
+      <div class="preview-meta">
+        <div class="preview-title" id="preview-title"></div>
+        <div class="preview-sub"  id="preview-sub"></div>
+      </div>
+    </div>
+
+    <hr>
+
+    <!-- Archive type -->
+    <div>
+      <div class="section-label">Archive as</div>
+      <div class="type-tabs">
+        <div class="type-tab active" data-type="series" onclick="setType('series')">TV series episode</div>
+        <div class="type-tab"        data-type="movie"  onclick="setType('movie')">Movie</div>
+        <div class="type-tab"        data-type="music"  onclick="setType('music')">Music track</div>
+      </div>
+    </div>
+
+    <!-- Common fields -->
+    <div class="field">
+      <label><span id="title-label">Video title</span></label>
+      <input type="text" id="title" placeholder="Auto-filled from URL">
+    </div>
+    <div class="row-2">
+      <div class="field">
+        <label><span id="author-label">Author / Channel</span></label>
+        <input type="text" id="author" placeholder="Auto-filled">
+      </div>
+      <div class="field">
+        <label>Upload date</label>
+        <input type="text" id="upload_date" placeholder="YYYY-MM-DD">
+      </div>
+    </div>
+
+    <!-- ── Series type fields ── -->
+    <div class="type-section active" id="series-fields">
+      <hr>
+      <div class="field">
+        <label>Series name  <span style="color:var(--muted);font-weight:400">(Jellyfin show folder)</span></label>
+        <div class="folder-row">
+          <select id="series-folder-select" onchange="onSeriesFolderSelect()">
+            <option value="">— choose existing —</option>
+          </select>
+          <input type="text" id="series" placeholder="or type a new series name" oninput="updatePathPreview()">
+        </div>
+      </div>
+      <div class="row-3">
+        <div class="field">
+          <label>Episode title</label>
+          <input type="text" id="episode_title" placeholder="Defaults to video title">
+        </div>
+        <div class="field">
+          <label>Season</label>
+          <input type="number" id="season" value="1" min="1" oninput="updatePathPreview()">
+        </div>
+        <div class="field">
+          <label>Episode</label>
+          <input type="number" id="episode" value="1" min="1">
+        </div>
+      </div>
+    </div>
+
+    <!-- ── Movie type fields ── -->
+    <div class="type-section" id="movie-fields">
+      <hr>
+      <div class="field">
+        <label>Year  <span style="color:var(--muted);font-weight:400">(appended to filename for Jellyfin matching)</span></label>
+        <input type="text" id="year" placeholder="e.g. 2024" oninput="updatePathPreview()">
+      </div>
+    </div>
+
+    <!-- ── Music type fields ── -->
+    <div class="type-section" id="music-fields">
+      <hr>
+      <div class="field">
+        <label>Artist  <span style="color:var(--muted);font-weight:400">(Jellyfin artist folder)</span></label>
+        <div class="folder-row">
+          <select id="artist-folder-select" onchange="onArtistFolderSelect()">
+            <option value="">— choose existing —</option>
+          </select>
+          <input type="text" id="artist" placeholder="or type a new artist name" oninput="updatePathPreview()">
+        </div>
+      </div>
+      <div class="row-2">
+        <div class="field">
+          <label>Album  <span style="color:var(--muted);font-weight:400">(optional)</span></label>
+          <input type="text" id="album" placeholder="Leave blank for flat artist folder" oninput="updatePathPreview()">
+        </div>
+        <div class="field">
+          <label>Track number  <span style="color:var(--muted);font-weight:400">(optional)</span></label>
+          <input type="number" id="track" min="1" placeholder="e.g. 3" oninput="updatePathPreview()">
+        </div>
+      </div>
+      <div class="row-2">
+        <div class="field">
+          <label>Year</label>
+          <input type="text" id="music_year" placeholder="e.g. 2024" oninput="updatePathPreview()">
+        </div>
+        <div class="field">
+          <label>Genre  <span style="color:var(--muted);font-weight:400">(optional)</span></label>
+          <input type="text" id="genre" placeholder="e.g. Electronic">
+        </div>
+      </div>
+      <div class="field">
+        <label>
+          Audio format
+          <span style="color:var(--muted);font-weight:400" id="format-hint">
+            (M4A/AAC — universal Jellyfin direct-play)
+          </span>
+        </label>
+        <select id="format" onchange="onFormatChange()">
+          <option value="m4a" selected>M4A (AAC in MP4 container, lossy — best Jellyfin compatibility)</option>
+          <option value="aac">AAC (raw ADTS, lossy — same codec as M4A, leaner container)</option>
+          <option value="opus">Opus (lossy, smaller files at same quality)</option>
+          <option value="mp3">MP3 (lossy, universal compatibility incl. old hardware)</option>
+          <option value="wav">WAV (uncompressed PCM — massive files, archival use only)</option>
+          <option value="flac">FLAC (lossless container — see note)</option>
+        </select>
+      </div>
+    </div>
+
+    <!-- Path preview -->
+    <div id="path-preview"></div>
+
+    <hr>
+    <button class="btn btn-primary" id="archive-btn" onclick="submitJob()">Archive video</button>
+
+  </div>
+
+  <!-- Right panel: queue -->
+  <div class="panel-right">
+    <div class="jobs-header">
+      <div class="jobs-title">archive queue</div>
+      <div class="badge" id="job-count">0 jobs</div>
+      <div style="margin-left:auto; display:flex; gap:.4rem">
+        <button class="btn-sm" id="clear-finished-btn" onclick="clearFinished()">✕ clear finished</button>
+        <button class="btn-sm" onclick="refreshJobs()">↻ refresh</button>
+      </div>
+    </div>
+    <div class="jobs-list" id="jobs-list">
+      <div class="empty-state">no jobs yet — paste a URL to get started</div>
+    </div>
+  </div>
+
+</div>
+
+<div id="toast"></div>
+
+<script>
+// ── State ─────────────────────────────────────────────────────────────────────
+let currentType = 'series';
+let probeData   = {};
+let pollTimer   = null;
+let mountInfo   = { root: '/nas/jellyfin', mounted: null, writable: null, subdirs: {} };
+let browseData  = { Movies: [], Shows: [], Music: [] };
+
+// ── Init ──────────────────────────────────────────────────────────────────────
+(async function init() {
+  await Promise.all([checkMount(), loadBrowseData()]);
+  loadJobs();
+})();
+
+// ── Mount check ───────────────────────────────────────────────────────────────
+async function checkMount() {
+  try {
+    const data = await fetch('/api/mounts').then(r => r.json());
+    mountInfo  = data;
+    const banner = document.getElementById('mount-banner');
+    const hdr    = document.getElementById('header-status');
+
+    if (!data.mounted) {
+      banner.className   = 'visible error';
+      banner.textContent = `✕  ${data.root} not found — attach Proxmox bind-mount (mp0) and restart.`;
+      hdr.innerHTML      = '<span class="dot" style="background:var(--red)"></span>mount missing';
+      document.getElementById('archive-btn').disabled = true;
+    } else if (!data.writable) {
+      banner.className   = 'visible warn';
+      banner.textContent = `⚠  ${data.root} is read-only — check LXC mount permissions.`;
+      hdr.innerHTML      = '<span class="dot" style="background:var(--amber)"></span>read-only';
+      document.getElementById('archive-btn').disabled = true;
+    } else {
+      const missing = ['Movies','Shows','Music'].filter(d => !data.subdirs[d]?.exists);
+      if (missing.length) {
+        banner.className   = 'visible warn';
+        banner.textContent = `⚠  ${data.root} writable. Missing subdirs (will be created): ${missing.join(', ')}`;
+        hdr.innerHTML      = '<span class="dot" style="background:var(--amber)"></span>' + data.root;
+      } else {
+        banner.className   = 'visible ok';
+        banner.textContent = `✓  ${data.root}  —  Movies/  Shows/  Music/  detected`;
+        hdr.innerHTML      = '<span class="dot"></span>' + data.root;
+      }
+      document.getElementById('archive-btn').disabled = false;
+    }
+    updatePathPreview();
+  } catch(e) { console.warn('Mount check failed:', e); }
+}
+
+// ── Browse (folder population) ────────────────────────────────────────────────
+async function loadBrowseData() {
+  try {
+    browseData = await fetch('/api/browse').then(r => r.json());
+    populateFolderSelects();
+  } catch(e) { console.warn('Browse failed:', e); }
+}
+
+function populateFolderSelects() {
+  populateSelect('series-folder-select', browseData.Shows || [], '— choose existing —');
+  populateSelect('artist-folder-select', browseData.Music || [], '— choose existing —');
+}
+
+function populateSelect(id, items, placeholder) {
+  const sel = document.getElementById(id);
+  if (!sel) return;
+  const first = sel.options[0]?.text || placeholder;
+  sel.innerHTML = `<option value="">${first}</option>` +
+    items.map(name => `<option value="${escAttr(name)}">${escHtml(name)}</option>`).join('');
+}
+
+function onSeriesFolderSelect() {
+  const val = document.getElementById('series-folder-select').value;
+  document.getElementById('series').value = val;
+  updatePathPreview();
+}
+
+function onArtistFolderSelect() {
+  const val = document.getElementById('artist-folder-select').value;
+  document.getElementById('artist').value = val;
+  updatePathPreview();
+}
+
+// ── Audio format selector ─────────────────────────────────────────────────────
+const FORMAT_HINTS = {
+  m4a:  '(M4A/AAC — universal Jellyfin direct-play)',
+  aac:  '(AAC in raw ADTS container — same codec as M4A, smaller container overhead)',
+  opus: '(Opus — smaller files, well supported)',
+  mp3:  '(MP3 — universal compatibility)',
+  wav:  '⚠ Note: WAV is uncompressed PCM and produces very large files. YouTube source is already lossy, so WAV provides no quality gain over m4a.',
+  flac: '⚠ Note: YouTube source is already lossy. FLAC just makes the file bigger; it cannot restore audio quality.',
+};
+
+function onFormatChange() {
+  const fmt  = document.getElementById('format').value;
+  const hint = document.getElementById('format-hint');
+  if (hint) {
+    hint.textContent = FORMAT_HINTS[fmt] || '';
+    hint.style.color = (fmt === 'flac' || fmt === 'wav')
+      ? 'var(--amber, #f5a623)' : 'var(--muted)';
+  }
+  updatePathPreview();
+}
+
+// ── Type selector ─────────────────────────────────────────────────────────────
+function setType(type) {
+  currentType = type;
+  document.querySelectorAll('.type-tab').forEach(t =>
+    t.classList.toggle('active', t.dataset.type === type));
+  ['series-fields','movie-fields','music-fields'].forEach(id =>
+    document.getElementById(id).classList.toggle('active', id.startsWith(type)));
+
+  // Repurpose the shared title/author labels for the music context
+  const titleLabel  = document.getElementById('title-label');
+  const authorLabel = document.getElementById('author-label');
+  const archiveBtn  = document.getElementById('archive-btn');
+  if (type === 'music') {
+    titleLabel.textContent  = 'Track title';
+    authorLabel.textContent = 'Channel  (not the artist)';
+    archiveBtn.textContent  = 'Archive track';
+  } else {
+    titleLabel.textContent  = 'Video title';
+    authorLabel.textContent = 'Author / Channel';
+    archiveBtn.textContent  = 'Archive video';
+  }
+
+  updatePathPreview();
+}
+
+// ── URL probe — auto-fetch on paste only ─────────────────────────────────────
+
+/**
+ * Clean tracking params from a YouTube URL.
+ * Only rewrites the field when we have a COMPLETE, parseable URL —
+ * never on partial input to avoid the re-trigger loop.
+ */
+function cleanYouTubeUrl(raw) {
+  try {
+    const u    = new URL(raw.trim());
+    const host = u.hostname.replace(/^www\./, '');
+    if (host === 'youtube.com' && u.pathname === '/watch') {
+      const v = u.searchParams.get('v') || '';
+      return `https://www.youtube.com/watch?v=${v}`;
+    }
+    if (host === 'youtu.be' || host === 'youtube.com') {
+      return `${u.origin}${u.pathname}`;
+    }
+    return raw.trim();
+  } catch { return raw.trim(); }
+}
+
+function looksLikeYouTube(url) {
+  try {
+    const u    = new URL(url);
+    const host = u.hostname.replace(/^www\./, '');
+    if (host === 'youtu.be' && u.pathname.length > 1)                    return true;
+    if (host === 'youtube.com' && u.searchParams.get('v'))               return true;
+    if (host === 'youtube.com' && /^\/(shorts|live)\/\w/.test(u.pathname)) return true;
+    return false;
+  } catch { return false; }
+}
+
+function setProbeStatus(msg, cls='') {
+  const el = document.getElementById('probe-status');
+  el.textContent  = msg;
+  el.style.color  = cls === 'ok'  ? 'var(--green)' :
+                    cls === 'err' ? 'var(--red)'   : 'var(--muted)';
+}
+
+async function probeUrl(urlOverride) {
+  const inputEl = document.getElementById('url-input');
+  const url     = urlOverride !== undefined ? urlOverride : inputEl.value.trim();
+  if (!url) { toast('Paste a YouTube URL first', 'error'); return; }
+
+  const btn = document.getElementById('probe-btn');
+  btn.disabled  = true;
+  btn.innerHTML = '<span class="spinner"></span>';
+  setProbeStatus('fetching…');
+
+  try {
+    const res  = await fetch('/api/probe', {
+      method:  'POST',
+      headers: {'Content-Type': 'application/json'},
+      body:    JSON.stringify({ url })
+    });
+    const data = await res.json();
+    if (!res.ok) throw new Error(data.error || 'Probe failed');
+
+    probeData = data;
+    document.getElementById('title').value       = data.title       || '';
+    document.getElementById('author').value      = data.author      || '';
+    document.getElementById('upload_date').value = data.upload_date || '';
+    document.getElementById('year').value        = data.year        || '';
+
+    document.getElementById('preview-thumb').src  = data.thumbnail || '';
+    document.getElementById('preview-title').textContent = data.title || '(untitled)';
+    document.getElementById('preview-sub').textContent   =
+      `${data.author || ''}  ·  ${data.upload_date || ''}  ·  ${data.duration || ''}`;
+    document.getElementById('preview-card').classList.add('visible');
+
+    setProbeStatus('✓ fetched', 'ok');
+    updatePathPreview();
+  } catch(e) {
+    setProbeStatus('fetch failed', 'err');
+    toast(e.message, 'error');
+  } finally {
+    btn.disabled  = false;
+    btn.textContent = '↻';
+  }
+}
+
+// Paste handler — robust cross-browser approach:
+// We let the browser complete the paste first (no preventDefault), then read
+// the field value in a microtask. This works on desktop, mobile, and all
+// browsers regardless of clipboardData availability.
+document.getElementById('url-input').addEventListener('paste', function() {
+  const el = this;
+  setTimeout(function() {
+    const raw     = el.value;
+    const cleaned = cleanYouTubeUrl(raw);
+
+    // Update field with cleaned URL only if it changed
+    if (cleaned !== raw) el.value = cleaned;
+
+    if (looksLikeYouTube(cleaned)) {
+      setProbeStatus('fetching…');
+      probeUrl(cleaned);
+    }
+  }, 0);
+});
+
+// Enter key as manual fallback
+document.getElementById('url-input').addEventListener('keydown', function(e) {
+  if (e.key === 'Enter') probeUrl();
+});
+
+// ── Path preview ──────────────────────────────────────────────────────────────
+function getSeriesName() {
+  return document.getElementById('series-folder-select').value ||
+         document.getElementById('series').value.trim() ||
+         'Series Name';
+}
+
+function getArtistName() {
+  return document.getElementById('artist-folder-select').value ||
+         document.getElementById('artist').value.trim() ||
+         'Artist Name';
+}
+
+function updatePathPreview() {
+  const root = mountInfo.root || '/nas/jellyfin';
+  let path   = '';
+
+  if (currentType === 'movie') {
+    // Flat layout: Movies/<Title> (Year).mkv  — no enclosing folder
+    const title = sanitize(document.getElementById('title').value || 'Title');
+    const year  = document.getElementById('year').value || 'YYYY';
+    const base  = year ? `${title} (${year})` : title;
+    path = `${root}/Movies/${base}.mkv`;
+
+  } else if (currentType === 'music') {
+    // Music/<Artist>/<Album>/[NN - ]<Track>.<ext>    (album & number optional)
+    const artist = sanitize(getArtistName());
+    const album  = sanitize(document.getElementById('album').value || '');
+    const track  = document.getElementById('track').value;
+    const title  = sanitize(document.getElementById('title').value || 'Track');
+    const fmtEl  = document.getElementById('format');
+    const ext    = (fmtEl && fmtEl.value) || 'm4a';
+    const trackPrefix = track ? `${String(track).padStart(2,'0')} - ` : '';
+    const dir = album ? `${root}/Music/${artist}/${album}` : `${root}/Music/${artist}`;
+    path = `${dir}/${trackPrefix}${title}.${ext}`;
+
+  } else {  // series
+    const series  = sanitize(getSeriesName());
+    const season  = String(document.getElementById('season').value || '1').padStart(2,'0');
+    const episode = String(document.getElementById('episode').value || '1').padStart(2,'0');
+    const epTitle = sanitize(document.getElementById('episode_title').value ||
+                             document.getElementById('title').value || 'Episode Title');
+    path = `${root}/Shows/${series}/Season ${season}/S${season}E${episode} - ${epTitle}.mkv`;
+  }
+
+  const el = document.getElementById('path-preview');
+  el.textContent = `→ ${path}`;
+  el.classList.add('visible');
+}
+
+function sanitize(s) {
+  return String(s || '').replace(/[<>:"/\\|?*]/g, '').trim() || '…';
+}
+
+// Live preview on common fields
+['title','author','upload_date','series','episode_title','season','episode','year',
+ 'artist','album','track','music_year','genre'].forEach(id => {
+  const el = document.getElementById(id);
+  if (el) el.addEventListener('input', updatePathPreview);
+});
+
+// ── Submit ─────────────────────────────────────────────────────────────────────
+async function submitJob() {
+  const url = document.getElementById('url-input').value.trim();
+  if (!url) { toast('URL is required', 'error'); return; }
+
+  // Year input is shared between movie ('year' field) and music ('music_year' field)
+  const year = currentType === 'music'
+    ? document.getElementById('music_year').value.trim()
+    : document.getElementById('year').value.trim();
+
+  const payload = {
+    url,
+    media_type:    currentType,
+    title:         document.getElementById('title').value.trim(),
+    author:        document.getElementById('author').value.trim(),
+    upload_date:   document.getElementById('upload_date').value.trim(),
+    year:          year,
+    description:   probeData.description || '',
+    // Series fields
+    folder:        currentType === 'series' ? getSeriesName() : '',
+    series:        document.getElementById('series').value.trim(),
+    season:        parseInt(document.getElementById('season').value) || 1,
+    episode:       parseInt(document.getElementById('episode').value) || 1,
+    episode_title: document.getElementById('episode_title').value.trim(),
+    // Music fields
+    artist:        currentType === 'music' ? getArtistName() : '',
+    album:         document.getElementById('album').value.trim(),
+    track:         document.getElementById('track').value.trim(),
+    genre:         document.getElementById('genre').value.trim(),
+    format:        currentType === 'music' ? document.getElementById('format').value : '',
+  };
+
+  const btn = document.getElementById('archive-btn');
+  const originalText = btn.textContent;
+  btn.disabled  = true;
+  btn.innerHTML = '<span class="spinner"></span> Queuing…';
+
+  try {
+    const res  = await fetch('/api/archive', {
+      method:  'POST',
+      headers: {'Content-Type': 'application/json'},
+      body:    JSON.stringify(payload)
+    });
+    const data = await res.json();
+    if (!res.ok) throw new Error(data.error || 'Failed to queue');
+
+    toast(`Job ${data.job_id} queued`, 'success');
+    clearForm();
+    await loadBrowseData();   // refresh folder lists after potential new folder
+    applySnapshot(data);      // the new job is in data.jobs with a fresh version
+    startPolling();
+  } catch(e) {
+    toast(e.message, 'error');
+  } finally {
+    btn.disabled  = false;
+    btn.textContent = originalText;
+  }
+}
+
+function clearForm() {
+  ['url-input','title','author','upload_date','series','episode_title','year',
+   'artist','album','track','music_year','genre'].forEach(id => {
+    const el = document.getElementById(id);
+    if (el) el.value = '';
+  });
+  document.getElementById('season').value  = '1';
+  document.getElementById('episode').value = '1';
+  const seriesSelect = document.getElementById('series-folder-select');
+  if (seriesSelect) seriesSelect.value = '';
+  const artistSelect = document.getElementById('artist-folder-select');
+  if (artistSelect) artistSelect.value = '';
+  const formatSel = document.getElementById('format');
+  if (formatSel) {
+    formatSel.value = 'm4a';
+    onFormatChange();  // reset hint text
+  }
+  document.getElementById('preview-card').classList.remove('visible');
+  document.getElementById('path-preview').classList.remove('visible');
+  setProbeStatus('');
+  probeData = {};
+}
+
+// ── Job list ──────────────────────────────────────────────────────────────────
+//
+// Race-free rendering — backed by a server-side action_version counter.
+//
+// Every response from /api/jobs and from mutating endpoints carries an
+// `action_version`. We keep the highest version we've applied locally and
+// refuse to render anything older. This makes the order of fetch responses
+// irrelevant: a stale poll cannot overwrite the result of a click, and a
+// click's response (which always carries the freshest version, since
+// mutations bump the counter before responding) always wins.
+//
+// Mutating endpoints also include the new job list in their response,
+// so we never have to issue a follow-up GET that could race with polling.
+
+let lastAppliedVersion = -1;
+let lastServerInstance = null;
+let pollTimer          = null;
+
+/**
+ * Apply a server snapshot to the DOM, but only if it's strictly newer than
+ * what's currently rendered. Stale snapshots are silently dropped.
+ *
+ * Accepts the shape: { server_instance: string, action_version: number, jobs: [...] }
+ *
+ * `server_instance` is a per-process UUID issued by the backend. Whenever it
+ * changes (server restart, code reload, container redeployment), we reset
+ * `lastAppliedVersion` so the fresh state is accepted even though the
+ * counter started over at 0. Without this, the queue would silently freeze
+ * after any backend restart until enough new actions occurred to push the
+ * counter past the client's stale watermark.
+ */
+function applySnapshot(snapshot) {
+  if (!snapshot || typeof snapshot.action_version !== 'number') return;
+
+  // Detect server restart and reset the version watermark.
+  if (snapshot.server_instance && snapshot.server_instance !== lastServerInstance) {
+    lastServerInstance = snapshot.server_instance;
+    lastAppliedVersion = -1;
+  }
+
+  if (snapshot.action_version < lastAppliedVersion) return;
+  lastAppliedVersion = snapshot.action_version;
+  const list = Array.isArray(snapshot.jobs) ? snapshot.jobs : [];
+  renderJobsList(list);
+}
+
+async function loadJobs() {
+  try {
+    const res  = await fetch('/api/jobs');
+    const data = await res.json();
+    applySnapshot(data);
+  } catch(e) {
+    // Network blip — leave the current view alone; next call retries.
+  }
+}
+
+function refreshJobs() {
+  loadJobs().then(() => toast('refreshed', ''));
+}
+
+function renderJobsList(jobs) {
+  const list = document.getElementById('jobs-list');
+
+  // Update count badge
+  document.getElementById('job-count').textContent =
+    `${jobs.length} job${jobs.length !== 1 ? 's' : ''}`;
+
+  // Build new content in a fragment so the swap is atomic (no flash)
+  if (!jobs.length) {
+    const placeholder = document.createElement('div');
+    placeholder.className   = 'empty-state';
+    placeholder.textContent = 'no jobs yet — paste a URL to get started';
+    list.replaceChildren(placeholder);
+    stopPolling();
+    return;
+  }
+
+  const frag = document.createDocumentFragment();
+  for (const job of jobs) {
+    const wrapper = document.createElement('div');
+    wrapper.innerHTML = renderJob(job).trim();
+    const card = wrapper.firstChild;
+    if (card) frag.appendChild(card);
+  }
+  list.replaceChildren(frag);
+
+  // Poll only when work is in progress
+  const hasActive = jobs.some(j => j.status === 'running' || j.status === 'queued');
+  if (hasActive) startPolling(); else stopPolling();
+}
+
+function renderJob(j) {
+  const iconClass = j.media_type === 'movie' ? 'icon-film'
+                  : j.media_type === 'music' ? 'icon-music'
+                  : 'icon-tv';
+  const iconLabel = j.media_type === 'movie' ? '🎬'
+                  : j.media_type === 'music' ? '♪'
+                  : 'TV';
+  const pct     = Math.round(j.progress || 0);
+  const fillCls = j.status === 'done' ? 'done' : j.status === 'error' ? 'error' : '';
+
+  let sub;
+  if (j.media_type === 'movie') {
+    sub = `movie  ${j.year || ''}`;
+  } else if (j.media_type === 'music') {
+    sub = `${j.artist || j.author || '—'}${j.album ? '  ·  ' + j.album : ''}`;
+  } else {
+    sub = `${j.series || j.folder || '—'}  S${String(j.season||1).padStart(2,'0')}E${String(j.episode||1).padStart(2,'0')}`;
+  }
+
+  const speed = j.status === 'running' && j.speed
+    ? `<div class="job-speed">${escHtml(j.speed)}${j.eta ? '  · ETA ' + escHtml(j.eta) : ''}</div>` : '';
+  const outPath = j.output_path
+    ? `<div class="job-speed" style="word-break:break-all;margin-top:3px">${escHtml(j.output_path)}</div>` : '';
+  const error = j.status === 'error' && j.error
+    ? `<div class="job-error">${escHtml(String(j.error).slice(0, 400))}</div>` : '';
+
+  const retryBtn = j.status === 'error'
+    ? `<button class="btn-sm" onclick="retryJob('${escAttr(j.id)}')">↺ retry</button>` : '';
+
+  return `<div class="job-card" data-job-id="${escAttr(j.id)}">
+  <div class="job-top">
+    <div class="job-icon ${iconClass}">${iconLabel}</div>
+    <div class="job-info">
+      <div class="job-title">${escHtml(j.title || j.url)}</div>
+      <div class="job-sub">${escHtml(sub)} · <span style="font-size:10px;font-family:monospace">${escHtml(j.id)}</span></div>
+    </div>
+    <div class="job-status status-${j.status}">${j.status}</div>
+  </div>
+  <div class="progress-bar"><div class="progress-fill ${fillCls}" style="width:${pct}%"></div></div>
+  ${speed}${outPath}${error}
+  <div class="job-actions">${retryBtn}<button class="btn-sm danger" onclick="deleteJob('${escAttr(j.id)}')">✕ remove</button></div>
+</div>`;
+}
+
+async function retryJob(id) {
+  try {
+    const res  = await fetch(`/api/jobs/${id}/retry`, { method: 'POST' });
+    const data = await res.json();
+    toast(`Job ${id} retrying`, 'success');
+    applySnapshot(data);
+  } catch(e) {
+    toast('Retry failed', 'error');
+    return;
+  }
+  startPolling();
+}
+
+async function deleteJob(id) {
+  // Optimistic: remove the card immediately so the click feels responsive.
+  // The server's response then becomes the authoritative state via applySnapshot.
+  const card = document.querySelector(`.job-card[data-job-id="${cssEsc(id)}"]`);
+  if (card) card.remove();
+  try {
+    const res  = await fetch(`/api/jobs/${id}`, { method: 'DELETE' });
+    const data = await res.json();
+    applySnapshot(data);
+  } catch(e) {
+    // Network blip — fall back to fetching fresh state
+    await loadJobs();
+  }
+}
+
+async function clearFinished() {
+  const btn = document.getElementById('clear-finished-btn');
+  btn.disabled = true;
+  try {
+    const res  = await fetch('/api/jobs', { method: 'DELETE' });
+    const data = await res.json();
+    toast(`Cleared ${data.count || 0} finished job${data.count === 1 ? '' : 's'}`, 'success');
+    applySnapshot(data);
+  } catch(e) {
+    toast('Clear failed', 'error');
+  } finally {
+    btn.disabled = false;
+  }
+}
+
+// ── Polling ───────────────────────────────────────────────────────────────────
+function startPolling() { if (!pollTimer) pollTimer = setInterval(loadJobs, 2500); }
+function stopPolling()  { if (pollTimer)  { clearInterval(pollTimer); pollTimer = null; } }
+
+// ── Toast ─────────────────────────────────────────────────────────────────────
+function toast(msg, type='') {
+  const el = document.getElementById('toast');
+  el.textContent = msg;
+  el.className   = 'show ' + type;
+  setTimeout(() => el.classList.remove('show'), 3500);
+}
+
+// ── Utils ─────────────────────────────────────────────────────────────────────
+function escHtml(s) {
+  return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;')
+                        .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+}
+function escAttr(s) { return escHtml(s); }
+
+/** Escape a string for safe use in a CSS attribute selector value. */
+function cssEsc(s) {
+  if (window.CSS && CSS.escape) return CSS.escape(String(s ?? ''));
+  return String(s ?? '').replace(/[^a-zA-Z0-9_-]/g, '\\$&');
+}
+</script>
+</body>
+</html>