#!/usr/bin/env python3
"""
LINUX Unplugged (Jupiter Broadcasting) — YouTube playlist -> MP3 podcast feed.
Syncs the newest N playlist entries, downloads missing ones as MP3,
enriches episodes with official show notes from the JB RSS feed,
maintains state.json and regenerates lu_feed.xml.

Usage:
  python3 lu_podcast.py [backlog_count]   # default 25
"""

import json
import os
import re
import subprocess
import sys
import time
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import quote
import xml.etree.ElementTree as ET
from email.utils import formatdate

# --- Configuration ---
BASE_DIR = Path("/mnt/storage/nzbdav/lu-podcast")
EPISODES_DIR = BASE_DIR / "episodes"
FEED_FILE = BASE_DIR / "lu_feed.xml"
STATE_FILE = BASE_DIR / "state.json"
PLAYLIST_URL = "https://youtube.com/playlist?list=PLUW3LUwQvegxVckkyK5dyyARdfjwGY-cw"

# Official feed: show notes, titles, air dates (our own MP3s stay the enclosure)
OFFICIAL_FEED_URL = "https://linuxunplugged.com/rss"
JB_CACHE_FILE = BASE_DIR / "jb_rss_cache.xml"
JB_CACHE_TTL = 6 * 3600  # seconds

# Newest N playlist entries to track/download (flat-playlist returns newest first)
BACKLOG = int(sys.argv[1]) if len(sys.argv) > 1 else 25

BASE_URL = "https://pka.3e.tel/lu"
PODCAST_TITLE = "LINUX Unplugged"
PODCAST_DESC = "LINUX Unplugged from Jupiter Broadcasting, auto-synced from YouTube as MP3."
PODCAST_AUTHOR = "Jupiter Broadcasting"
PODCAST_IMAGE = ""  # set from playlist thumbnails on first sync


def ensure_dirs():
    EPISODES_DIR.mkdir(parents=True, exist_ok=True)


def load_state():
    if STATE_FILE.exists():
        with open(STATE_FILE) as f:
            return json.load(f)
    return {"episodes": [], "image": ""}


def save_state(state):
    with open(STATE_FILE, "w") as f:
        json.dump(state, f, indent=2)


def get_playlist_entries(limit):
    """Newest-first entries from the playlist via yt-dlp flat-playlist."""
    env = {**os.environ, "PATH": os.environ.get("PATH", "") + ":/home/pkmx/.deno/bin"}
    cmd = [
        "yt-dlp",
        "--extractor-args", "youtube:player_client=web",
        "--remote-components", "ejs:github",
        "--flat-playlist",
        "--playlist-items", f"1:{limit}",
        "--dump-single-json", "--no-warnings",
        PLAYLIST_URL,
    ]
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=env)
    if result.returncode != 0:
        # fallback clients
        for client in ["android_vr", "mweb"]:
            cmd = [
                "yt-dlp",
                "--extractor-args", f"youtube:player_client={client}",
                "--flat-playlist",
                "--playlist-items", f"1:{limit}",
                "--dump-single-json", "--no-warnings",
                PLAYLIST_URL,
            ]
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=env)
            if result.returncode == 0:
                break
        if result.returncode != 0:
            print(f"ERROR fetching playlist: {result.stderr[-300:]}", file=sys.stderr)
            return None, ""
    data = json.loads(result.stdout)
    image = ""
    thumbs = data.get("thumbnails") or []
    if thumbs:
        image = thumbs[0].get("url", "")
    return data.get("entries") or [], image


def safe_filename(title, video_id):
    safe = re.sub(r"[^\w\s-]", "", title or video_id).strip()[:80]
    safe = re.sub(r"\s+", "_", safe)
    return f"{safe}.mp3"


def download_mp3(video_id, title):
    """Download one video as MP3. Returns (filepath, info_dict) or (None, None)."""
    filename = safe_filename(title, video_id)
    filepath = EPISODES_DIR / filename
    if filepath.exists() and filepath.stat().st_size > 1024:
        return filepath, None  # already on disk (state may have been lost)

    url = f"https://www.youtube.com/watch?v={video_id}"
    env = {**os.environ, "PATH": os.environ.get("PATH", "") + ":/home/pkmx/.deno/bin"}
    # mweb first: proven working client on this IP (web/android_vr bot-blocked for downloads)
    clients = [("mweb", False), ("web", True), ("android_vr", False)]
    for client, need_remote in clients:
        cmd = [
            "yt-dlp",
            "--extractor-args", f"youtube:player_client={client}",
        ]
        if need_remote:
            cmd += ["--remote-components", "ejs:github"]
        cmd += [
            "-x", "--audio-format", "mp3", "--audio-quality", "128K",
            "--embed-thumbnail", "--add-metadata",
            "--print-json",
            "-o", str(filepath),
            "--no-playlist", "--no-warnings",
            url,
        ]
        print(f"Downloading '{title}' via {client}...")
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=1800, env=env)
        if result.returncode == 0 and filepath.exists() and filepath.stat().st_size > 1024:
            info = None
            try:
                info = json.loads(result.stdout)
            except json.JSONDecodeError:
                pass
            return filepath, info
        print(f"{client} failed: {result.stderr[:200]}", file=sys.stderr)
    return None, None


def get_file_duration(filepath):
    cmd = [
        "ffprobe", "-v", "quiet",
        "-show_entries", "format=duration",
        "-of", "default=noprint_wrappers=1:nokey=1",
        str(filepath),
    ]
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
    if result.returncode == 0:
        try:
            return int(float(result.stdout.strip()))
        except ValueError:
            pass
    return 0


# --- Official feed (show notes) ---

JB_NS = {"content": "http://purl.org/rss/1.0/modules/content/",
         "itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd"}


def fetch_jb_items():
    """Fetch (cached) official feed and return parsed item dicts, or [] on failure."""
    fresh = JB_CACHE_FILE.exists() and (time.time() - JB_CACHE_FILE.stat().st_mtime) < JB_CACHE_TTL
    if not fresh:
        try:
            req = urllib.request.Request(OFFICIAL_FEED_URL, headers={"User-Agent": "Mozilla/5.0"})
            with urllib.request.urlopen(req, timeout=60) as r:
                JB_CACHE_FILE.write_bytes(r.read())
        except Exception as e:
            print(f"JB feed fetch failed: {e}", file=sys.stderr)
            if not JB_CACHE_FILE.exists():
                return []
    try:
        root = ET.parse(JB_CACHE_FILE).getroot()
    except ET.ParseError as e:
        print(f"JB cache parse error: {e}", file=sys.stderr)
        return []
    ch = root.find("channel")
    if ch is None:
        return []
    items = []
    for it in ch.findall("item"):
        title = (it.findtext("title") or "").strip()
        body = it.findtext("content:encoded", namespaces=JB_NS) or it.findtext("description") or ""
        num = it.findtext("itunes:episode", namespaces=JB_NS)
        try:
            num = str(int(num)) if num else None
        except ValueError:
            num = None
        items.append({
            "title": title,
            "ep_num": num,
            "pub_date_rfc": it.findtext("pubDate") or "",
            "description": body.strip(),
            "link": it.findtext("link") or "",
        })
    return items


def norm_title(t):
    """Normalize a title for matching: strip episode numbers and show tags."""
    t = re.sub(r"^\s*\d+\s*[:.]\s*", "", t or "")                      # leading 'N:'
    t = re.sub(r"\s*\|\s*(?:linux\s*)?unplugged\s*\d+\s*$", "", t, flags=re.I)  # trailing '| LINUX Unplugged N'
    t = re.sub(r"[^\w\s]", "", t.lower())
    return re.sub(r"\s+", " ", t).strip()


def ep_num_from_yt_title(t):
    """Episode number from a YouTube title, either leading 'N:' or trailing '| ... Unplugged N'."""
    m = re.match(r"^\s*(\d+)\s*[:.]", t or "")
    if m:
        return m.group(1)
    m = re.search(r"unplugged\s+(\d+)\s*$", t or "", re.I)
    return m.group(1) if m else None


def enrich_from_jb(state, jb_items):
    """Fill official title/shownotes/air date for episodes missing them.
    Returns count enriched."""
    if not jb_items:
        return 0
    by_num = {}
    by_title = {}
    for it in jb_items:
        if it["ep_num"]:
            by_num.setdefault(it["ep_num"], it)
        by_title.setdefault(norm_title(it["title"]), it)

    changed = 0
    for ep in state["episodes"]:
        if ep.get("shownotes"):
            continue
        num = ep_num_from_yt_title(ep["title"])
        cand = by_num.get(num) if num else None
        if cand is None:
            cand = by_title.get(norm_title(ep["title"]))
        if cand is None:
            continue
        ep["official_title"] = cand["title"]
        ep["shownotes"] = cand["description"]
        ep["official_pub_date_rfc"] = cand["pub_date_rfc"]
        ep["official_link"] = cand["link"]
        if cand["ep_num"]:
            ep["episode_num"] = cand["ep_num"]
        changed += 1
    if changed:
        save_state(state)
    return changed


def generate_rss(state):
    rss = ET.Element("rss", version="2.0")
    rss.set("xmlns:itunes", "http://www.itunes.com/dtds/podcast-1.0.dtd")
    rss.set("xmlns:atom", "http://www.w3.org/2005/Atom")
    rss.set("xmlns:media", "http://search.yahoo.com/mrss/")

    channel = ET.SubElement(rss, "channel")
    ET.SubElement(channel, "title").text = PODCAST_TITLE
    ET.SubElement(channel, "link").text = "https://www.jupiterbroadcasting.com/show/linux-unplugged/"
    ET.SubElement(channel, "description").text = PODCAST_DESC
    ET.SubElement(channel, "language").text = "en"
    ET.SubElement(channel, "itunes:author").text = PODCAST_AUTHOR
    ET.SubElement(channel, "itunes:explicit").text = "no"
    ET.SubElement(channel, "itunes:category", text="Technology")
    if state.get("image"):
        ET.SubElement(channel, "itunes:image", href=state["image"])

    episodes = sorted(state["episodes"], key=lambda e: e.get("pub_date", ""), reverse=True)
    for ep in episodes:
        item = ET.SubElement(channel, "item")
        # Prefer official title/show notes/air date; fall back to YouTube metadata
        ET.SubElement(item, "title").text = ep.get("official_title") or ep["title"]
        if ep.get("official_link"):
            ET.SubElement(item, "link").text = ep["official_link"]
        ET.SubElement(item, "description").text = ep.get("shownotes") or ep.get("description") or PODCAST_DESC
        ET.SubElement(item, "pubDate").text = ep.get("official_pub_date_rfc") or ep["pub_date_rfc"]
        ET.SubElement(item, "guid", isPermaLink="false").text = f"lu-{ep['video_id']}"

        enclosure_url = f"{BASE_URL}/episodes/{quote(ep['filename'])}"
        ET.SubElement(item, "enclosure",
                       url=enclosure_url,
                       length=str(ep["size"]),
                       type="audio/mpeg")
        ET.SubElement(item, "itunes:duration").text = str(ep["duration"])
        ET.SubElement(item, "media:content",
                       url=enclosure_url,
                       fileSize=str(ep["size"]),
                       type="audio/mpeg",
                       duration=str(ep["duration"]))

    ET.indent(rss, space="  ")
    xml_str = '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(rss, encoding="unicode")
    with open(FEED_FILE, "w") as f:
        f.write(xml_str)
    print(f"RSS feed updated: {FEED_FILE} ({len(episodes)} items)")


def sync():
    ensure_dirs()
    state = load_state()
    known = {ep["video_id"]: ep for ep in state["episodes"]}

    entries, image = get_playlist_entries(BACKLOG)
    if entries is None:
        entries = []
    if image and not state.get("image"):
        state["image"] = image

    added = []
    for e in entries:  # newest first
        vid = e.get("id")
        if not vid or vid in known:
            continue
        title = e.get("title") or vid

        filepath, info = download_mp3(vid, title)
        if not filepath:
            print(f"ERROR: could not download {vid} ({title})", file=sys.stderr)
            continue

        if info:
            upload_date = info.get("upload_date") or datetime.now(timezone.utc).strftime("%Y%m%d")
            description = (info.get("description") or "")[:500]
        else:
            upload_date = datetime.now(timezone.utc).strftime("%Y%m%d")
            description = PODCAST_DESC
        try:
            dt = datetime.strptime(upload_date, "%Y%m%d").replace(tzinfo=timezone.utc)
        except ValueError:
            dt = datetime.now(timezone.utc)

        episode = {
            "video_id": vid,
            "title": title,
            "filename": filepath.name,
            "duration": int(info.get("duration") or 0) if info else get_file_duration(filepath),
            "size": filepath.stat().st_size,
            "description": description,
            "pub_date": dt.isoformat(),
            "pub_date_rfc": formatdate(dt.timestamp(), usegmt=True),
            "added_at": datetime.now(timezone.utc).isoformat(),
        }
        state["episodes"].append(episode)
        known[vid] = episode
        save_state(state)
        generate_rss(state)
        added.append(title)
        print(f"Added: {title} ({episode['size'] // 1024 // 1024} MB)")

    # Enrich with official show notes (new + any previously unmatched), then
    # regenerate the feed so enrichments show up even without new downloads.
    enriched = enrich_from_jb(state, fetch_jb_items())
    if enriched:
        print(f"Enriched {enriched} episode(s) with official show notes")
        generate_rss(state)

    if not added and not enriched and not STATE_FILE.exists():
        generate_rss(state)  # empty feed on total failure to at least exist
    return added


if __name__ == "__main__":
    new = sync()
    if new:
        print(f"\nLU sync complete: {len(new)} added")
    else:
        print("LU sync: nothing new")
