# YouTube RSS

Downloads audio from YouTube channels you're subscribed to and serves it as a
private podcast feed you can subscribe to in Overcast.

Lives at:
`~/Documents/PARA/2 Areas of Responsibility/Sound Computer/Personal Apps/YouTube RSS`

## One-time setup

### 1. Install yt-dlp

```
brew install yt-dlp
```

Check it worked:

```
yt-dlp --version
```

### 2. Put this folder in place

Make sure the whole `YouTube RSS` folder (with `watcher.py`, `channels.txt`,
`downloads/`, `com.dan.ytrss.*.plist`) lives exactly at:

```
/Users/dan/Documents/PARA/2 Areas of Responsibility/Sound Computer/Personal Apps/YouTube RSS
```

The launchd plists have that path hardcoded, so if you move the folder later
you'll need to edit both `.plist` files and reload them (see below).

### 3. Add channels to watch

Edit `channels.txt` and add channel IDs, one per line. Find a channel ID by
viewing the channel's page source and searching for `"channelId"`, or use a
site like commentpicker.com/youtube-channel-id.php.

### 4. Check Tailscale is connected

```
/Applications/Tailscale.app/Contents/MacOS/Tailscale status
```

Confirm `macbook-air-2023` shows up. `BASE_URL` is set to this Mac's
Tailscale Funnel hostname, not its Tailscale IP, so it doesn't need
updating if the IP changes - only if the machine or tailnet is ever
renamed.

### 4b. (Optional) Avoid downloading a channel's whole back catalog

The first time a channel is checked, the watcher will try to download
everything currently in its feed (YouTube's feed returns roughly the last 15
uploads). If you don't want that flood, set `ONLY_DOWNLOAD_AFTER` near the
top of `watcher.py` to a date like `"2026-07-01"` before your first run -
anything published before that date is logged and skipped instead of
downloaded. It's safe to leave the setting in place afterwards; it has no
effect on videos published after the date.

### 5. Test it manually first

```
cd "/Users/dan/Documents/PARA/2 Areas of Responsibility/Sound Computer/Personal Apps/YouTube RSS"
python3 watcher.py
```

This should download anything currently in your channels' feeds and create
`feed.xml`. Check `downloads/` for mp3s and `watcher.log` for what happened.

### 6. Install the launchd jobs

The two `.plist` files live in this project's root folder (not a `launchd/`
subfolder):

```
cd "/Users/dan/Documents/PARA/2 Areas of Responsibility/Sound Computer/Personal Apps/YouTube RSS"
cp com.dan.ytrss.server.plist ~/Library/LaunchAgents/
cp com.dan.ytrss.watcher.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.dan.ytrss.server.plist
launchctl load ~/Library/LaunchAgents/com.dan.ytrss.watcher.plist
```

Both jobs run `/usr/bin/python3`, which needs **Full Disk Access** granted
before it can read/write anything in this folder (it lives under
`~/Documents`, which macOS locks down for background processes by
default). If `launchctl list | grep ytrss` shows the server job exiting
immediately instead of staying running, or `server.error.log` shows
`PermissionError: [Errno 1] Operation not permitted`, this is why. Fix:
System Settings → Privacy & Security → Full Disk Access → add
`/usr/bin/python3` (Cmd+Shift+G in the file picker to type the path) →
then `launchctl unload`/`launchctl load` both jobs again.

The server job starts immediately and stays running (serves files on port
8000). The watcher job runs at 4am and 4pm daily.

### 7. Enable and start Tailscale Funnel

Overcast fetches feeds and episode audio from Overcast's own servers, not
directly from your phone - so a plain Tailscale address doesn't work, since
Overcast's servers aren't part of your tailnet and can't reach a private
`100.x.x.x` address. Tailscale Funnel exposes the server publicly instead:

```
/Applications/Tailscale.app/Contents/MacOS/Tailscale funnel 8000
```

The first time you run this it'll print a URL - open that to enable Funnel
for this tailnet (one-time, needs your Tailscale login), then run this to
make it persistent in the background:

```
/Applications/Tailscale.app/Contents/MacOS/Tailscale funnel --bg 8000
```

Confirm it's active:

```
/Applications/Tailscale.app/Contents/MacOS/Tailscale funnel status
```

**Privacy note**: this makes the feed and downloaded audio reachable by
anyone on the public internet who has the URL, not just your own devices.
Privacy currently relies on the hostname being obscure and unguessable
(`macbook-air-2023.tail67203.ts.net`), not on any real access control. A
tighter option (a secret path segment in the URL) is possible but not
currently set up - ask Claude to add it if this becomes a concern.

### 8. Subscribe in Overcast

In Overcast, add a podcast by URL:

```
https://macbook-air-2023.tail67203.ts.net/feed.xml
```

Tailscale does not need to be connected on your iPhone for this, since the
feed is now publicly reachable via Funnel.

## Day-to-day maintenance

- **Add a new channel**: edit `channels.txt`, save. It'll pick up on the next
  scheduled run, or run `python3 watcher.py` manually to trigger it now. This
  only gets you the ~15 most recent uploads (YouTube's feed limit) - see
  "Backfilling older episodes" below to go further back.
- **Check what happened on the last run**: `watcher.log` in this folder.
- **Something's stuck / not downloading**: run `python3 watcher.py` manually
  in Terminal to see errors directly.
- **Stop everything**:
  ```
  launchctl unload ~/Library/LaunchAgents/com.dan.ytrss.server.plist
  launchctl unload ~/Library/LaunchAgents/com.dan.ytrss.watcher.plist
  ```
- **Restart after editing a .plist**:
  ```
  launchctl unload ~/Library/LaunchAgents/com.dan.ytrss.watcher.plist
  launchctl load ~/Library/LaunchAgents/com.dan.ytrss.watcher.plist
  ```
- **Free up space**: mp3s accumulate in `downloads/` forever right now -
  nothing deletes old ones automatically. Worth a periodic manual clear-out,
  or ask Claude to add auto-expiry (e.g. delete anything older than 30 days)
  if it becomes a problem.
- **Mac restarts**: launchd jobs marked `RunAtLoad` (the server) start
  automatically on login. The watcher just waits for its next scheduled time.

## Backfilling older episodes

`watcher.py` only ever sees a channel's ~15 most recent uploads, since
that's all YouTube's RSS feed exposes. `backfill.py` is a separate, manual
script for going further back - it asks yt-dlp to list a channel's entire
"Videos" tab instead, and downloads whatever isn't already in
`episodes.json`. It's not run automatically via launchd; run it by hand
whenever you want more of a channel's history (e.g. right after adding a
new channel).

Newest-first, 10 at a time is a reasonable way to work through a channel's
backlog without downloading everything at once:

```
cd "/Users/dan/Documents/PARA/2 Areas of Responsibility/Sound Computer/Personal Apps/YouTube RSS"
python3 backfill.py <channel_id> --limit 10
```

Run the same command again to get the next 10 older ones, and repeat until
you've got as far back as you want - it always picks up where the last run
left off, since it skips anything already downloaded. Other options:

```
python3 backfill.py <channel_id>                     # everything not yet downloaded
python3 backfill.py <channel_id> --since 2025-01-01   # only from that date onward
python3 backfill.py <channel_id> --dry-run            # preview what would download, without downloading
```

Downloaded files use the same readable `YYYY-MM-DD - Title [videoID].mp3`
naming as the automatic watcher, and `feed.xml` is regenerated after each
run.

One thing to avoid: don't run `backfill.py` and any one-off cleanup/rename
script at the same time - both read and rewrite `episodes.json` with no
locking, so overlapping runs can silently clobber each other's changes.
Let one finish before starting another.

## Known limitations

- No episode duration in the feed (skipped to avoid extra dependencies) -
  Overcast will still play files fine, it just won't show a running time
  until playback starts.
- If yt-dlp fails on a video (deleted, private, geo-blocked, or just a
  transient error), it's marked as attempted in `episodes.json` with
  `filename: null`, and the next run will automatically retry it. Videos
  skipped for being older than `ONLY_DOWNLOAD_AFTER` are also recorded with
  `filename: null` but are never retried, since that skip is intentional.
- Dedup is by YouTube video ID recorded in `episodes.json`, not by whether
  the file still exists in `downloads/`. So once you've listened to an
  episode, deleting its mp3 is safe - it won't be re-downloaded.
- `BASE_URL` is the Mac's Tailscale Funnel hostname, so it's unaffected by
  Tailscale IP changes. It would need updating (and `watcher.py` re-run) only
  if the machine or tailnet is ever renamed.
- Tailscale Funnel needs to stay enabled and running
  (`tailscale funnel --bg 8000`) for the feed to be reachable at all - if
  Overcast stops being able to refresh, check `tailscale funnel status`.
- The feed and downloaded audio are technically reachable by anyone on the
  public internet with the URL (Funnel doesn't restrict to your tailnet).
  Privacy currently relies on the hostname being obscure, not on real access
  control.
