#!/usr/bin/env python3
"""
YouTube -> Podcast RSS watcher.

Checks a list of YouTube channels for new uploads, downloads the audio
with yt-dlp, and regenerates a local podcast RSS feed pointing at the
downloaded files.

Run manually with:  python3 watcher.py
Normally run twice a day via launchd (see launchd/ folder).
"""

import json
import os
import subprocess
import sys
import time
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from email.utils import format_datetime
from xml.sax.saxutils import escape

# ---------------------------------------------------------------------------
# CONFIG - edit these to match your setup
# ---------------------------------------------------------------------------

# Public URL this Mac is reachable at, via Tailscale Funnel (check with:
# tailscale funnel status). Needs to be a Funnel HTTPS URL, not the plain
# Tailscale IP - Overcast fetches feeds/audio from its own servers, not
# from your phone, so the address has to be reachable from the public
# internet, not just from devices on your tailnet.
BASE_URL = "https://macbook-air-2023.tail67203.ts.net"

APP_DIR = os.path.dirname(os.path.abspath(__file__))
CHANNELS_FILE = os.path.join(APP_DIR, "channels.txt")
DOWNLOADS_DIR = os.path.join(APP_DIR, "downloads")
EPISODES_FILE = os.path.join(APP_DIR, "episodes.json")
FEED_FILE = os.path.join(APP_DIR, "feed.xml")
LOG_FILE = os.path.join(APP_DIR, "watcher.log")

PODCAST_TITLE = "Dan's YouTube Subscriptions"
PODCAST_DESCRIPTION = "Audio pulled from YouTube channels I'm subscribed to."
PODCAST_AUTHOR = "Dan"

YTDLP_BIN = "/usr/local/bin/yt-dlp"  # full path - launchd's PATH doesn't include /usr/local/bin

# Only download videos published on or after this date ("YYYY-MM-DD"), or
# None to download everything a channel's feed returns. Mainly useful when
# adding a channel for the first time, to avoid pulling in its whole back
# catalog. Safe to leave set permanently - it has no effect on videos
# published after the date.
ONLY_DOWNLOAD_AFTER = None  # e.g. "2026-07-01"

# ---------------------------------------------------------------------------


def log(msg):
    ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    line = f"[{ts}] {msg}"
    print(line)
    with open(LOG_FILE, "a") as f:
        f.write(line + "\n")


def load_channels():
    if not os.path.exists(CHANNELS_FILE):
        log(f"No channels.txt found at {CHANNELS_FILE}")
        return []
    channels = []
    with open(CHANNELS_FILE) as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            channels.append(line)
    return channels


def load_episodes():
    if not os.path.exists(EPISODES_FILE):
        return {}
    with open(EPISODES_FILE) as f:
        return json.load(f)


def save_episodes(episodes):
    with open(EPISODES_FILE, "w") as f:
        json.dump(episodes, f, indent=2)


def fetch_channel_feed(channel_id):
    url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
    req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
    with urllib.request.urlopen(req, timeout=20) as resp:
        return resp.read()


def parse_channel_feed(xml_bytes):
    """Return list of dicts: video_id, title, published, url"""
    ns = {
        "atom": "http://www.w3.org/2005/Atom",
        "yt": "http://www.youtube.com/xml/schemas/2015",
    }
    root = ET.fromstring(xml_bytes)
    entries = []
    for entry in root.findall("atom:entry", ns):
        video_id = entry.find("yt:videoId", ns).text
        title = entry.find("atom:title", ns).text
        published = entry.find("atom:published", ns).text
        entries.append(
            {
                "video_id": video_id,
                "title": title,
                "published": published,
                "url": f"https://www.youtube.com/watch?v={video_id}",
            }
        )
    return entries


def download_audio(video):
    """Download audio via yt-dlp. Returns filename (relative to downloads/) or None."""
    # Date + title so files are identifiable in Finder; video ID kept in
    # brackets so it stays unique even if two titles happen to collide.
    out_template = os.path.join(
        DOWNLOADS_DIR, "%(upload_date>%Y-%m-%d)s - %(title)s [%(id)s].%(ext)s"
    )
    cmd = [
        YTDLP_BIN,
        "-x",
        "--audio-format",
        "mp3",
        "--audio-quality",
        "0",
        "-o",
        out_template,
        "--print",
        "after_move:%(filepath)s",
        video["url"],
    ]
    log(f"Downloading: {video['title']}")
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        log(f"  yt-dlp failed for {video['video_id']}: {result.stderr[-500:]}")
        return None

    # --print after_move reports the actual final path, since yt-dlp
    # sanitizes titles for the filesystem and we don't want to reimplement
    # that logic here to predict the filename ourselves.
    lines = [line for line in result.stdout.strip().splitlines() if line.strip()]
    full_path = lines[-1] if lines else None
    if full_path and os.path.exists(full_path):
        return os.path.basename(full_path)

    log(f"  Warning: could not determine downloaded filename for {video['video_id']}")
    return None


def build_feed(episodes):
    items_xml = []
    # newest first
    for vid, ep in sorted(
        episodes.items(), key=lambda kv: kv[1]["published"], reverse=True
    ):
        filename = ep.get("filename")
        if not filename:
            continue
        full_path = os.path.join(DOWNLOADS_DIR, filename)
        if not os.path.exists(full_path):
            continue

        size_bytes = os.path.getsize(full_path)
        file_url = f"{BASE_URL}/downloads/{filename}"

        try:
            pub_dt = datetime.fromisoformat(ep["published"].replace("Z", "+00:00"))
        except ValueError:
            pub_dt = datetime.now(timezone.utc)
        pub_rfc822 = format_datetime(pub_dt)

        items_xml.append(
            f"""
    <item>
      <title>{escape(ep['title'])}</title>
      <guid isPermaLink="false">{vid}</guid>
      <pubDate>{pub_rfc822}</pubDate>
      <enclosure url="{escape(file_url)}" length="{size_bytes}" type="audio/mpeg" />
      <link>{escape(ep.get('source_url', ''))}</link>
      <description>{escape(ep['title'])}</description>
    </item>"""
        )

    now_rfc822 = format_datetime(datetime.now(timezone.utc))

    feed = f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
  <channel>
    <title>{escape(PODCAST_TITLE)}</title>
    <link>{escape(BASE_URL)}</link>
    <description>{escape(PODCAST_DESCRIPTION)}</description>
    <language>en-au</language>
    <itunes:author>{escape(PODCAST_AUTHOR)}</itunes:author>
    <lastBuildDate>{now_rfc822}</lastBuildDate>
    {''.join(items_xml)}
  </channel>
</rss>
"""
    with open(FEED_FILE, "w") as f:
        f.write(feed)
    log(f"Feed written: {FEED_FILE} ({len(episodes)} episodes)")


def main():
    os.makedirs(DOWNLOADS_DIR, exist_ok=True)

    channels = load_channels()
    if not channels:
        log("No channels configured. Add channel IDs to channels.txt.")
        return

    episodes = load_episodes()
    new_count = 0

    cutoff = None
    if ONLY_DOWNLOAD_AFTER:
        cutoff = datetime.fromisoformat(ONLY_DOWNLOAD_AFTER).replace(tzinfo=timezone.utc)

    for channel_id in channels:
        log(f"Checking channel: {channel_id}")
        try:
            xml_bytes = fetch_channel_feed(channel_id)
        except Exception as e:
            log(f"  Failed to fetch channel feed: {e}")
            continue

        try:
            entries = parse_channel_feed(xml_bytes)
        except Exception as e:
            log(f"  Failed to parse channel feed: {e}")
            continue

        for video in entries:
            existing = episodes.get(video["video_id"])
            if existing:
                if existing.get("filename"):
                    continue  # already downloaded
                if cutoff:
                    try:
                        existing_pub_dt = datetime.fromisoformat(
                            existing["published"].replace("Z", "+00:00")
                        )
                    except ValueError:
                        existing_pub_dt = None
                    if existing_pub_dt and existing_pub_dt < cutoff:
                        continue  # correctly skipped for being before the cutoff
                log(f"  Retrying previously failed download: {video['title']}")

            if cutoff:
                pub_dt = datetime.fromisoformat(video["published"].replace("Z", "+00:00"))
                if pub_dt < cutoff:
                    log(f"  Skipping (before cutoff): {video['title']}")
                    episodes[video["video_id"]] = {
                        "title": video["title"],
                        "published": video["published"],
                        "source_url": video["url"],
                        "filename": None,
                        "channel_id": channel_id,
                    }
                    save_episodes(episodes)
                    continue

            filename = download_audio(video)
            episodes[video["video_id"]] = {
                "title": video["title"],
                "published": video["published"],
                "source_url": video["url"],
                "filename": filename,
                "channel_id": channel_id,
            }
            save_episodes(episodes)  # save after every download so a crash doesn't lose progress

            if filename:
                new_count += 1

            time.sleep(1)  # be polite

    build_feed(episodes)
    log(f"Done. {new_count} new episode(s) downloaded.")


if __name__ == "__main__":
    main()
