YouTube Source

SkillMedia

YouTube channel uploads in the Radio tab — browse channels, download audio (FLAC / MP3) or video (720p / 1080p) ad-free, store in a user folder, and play/cast locally. Use when working on YouTube source UI, channel/video listing, downloads, manifest tracking, or format settings.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the YouTube Source skill

What this skill tells your AI

The instructions your AI receives, as published by ad-repo/nullplayer in skills/youtube-source/SKILL.md and read by ahel’s review.

Subscribe to YouTube channels in the Radio tab and browse their uploads. Double-click a video to download its audio or video (ad-free, via yt-dlp) and play immediately. Downloads are stored in a user-chosen folder (reachability checked before downloading), organized per channel as <Channel Name>/<Title> [<videoId>].<ext> and tracked in a manifest; a format setting (FLAC / MP3 High / MP3 Low / Video 720p / Video 1080p) is in the Library menu. Downloaded audio files are local file:// tracks that play locally and cast to Sonos, Chromecast, DLNA; downloaded videos open in the video player window.

Quick Start (user)

  1. Radio tab+ Add YouTube Channel
  2. Paste a YouTube channel URL (e.g., https://www.youtube.com/@channel_name)
  3. Channel appears as a folder; expand to see uploads
  4. Double-click a video to download its audio or video and play
  5. Library → YouTube → Set Download Folder… to choose where downloads live
  6. Library → YouTube → Format to pick FLAC, MP3 High, MP3 Low, Video 720p, or Video 1080p
  7. Library → YouTube → Videos per Channel to pick how many recent uploads to list (50 / 100 / 200 / 500)

Architecture

Sources/NullPlayer/
├── YouTube/
│   ├── YouTubeModels.swift          # Channel, Video, Download, Quality data models
│   └── YouTubeManager.swift         # Singleton: channels, video listing, downloads, manifest (youtube_downloads.json), all in one file
├── Windows/ModernLibraryBrowser/
│   └── ModernLibraryBrowserView.swift # YouTube folder tree integration
└── Windows/PlexBrowser/
    └── PlexBrowserView.swift        # YouTube folder tree integration (classic UI)

YouTubeManager

Singleton (YouTubeManager.shared) that manages:

  • Channel list (persisted in UserDefaults under YouTubeChannels)
  • Video listing per channel (via yt-dlp)
  • Download root folder (user-selected via Library menu, persisted under YouTubeDownloadRoot)
  • Download manifest (youtube_downloads.json in the download root)

Key Properties:

private(set) var channels: [YouTubeChannel]  // All subscribed channels
var downloadRoot: URL                        // User-chosen folder (reachability checked before download)
var quality: YouTubeQuality                  // .flac / .mp3High / .mp3Low / .video720 / .video1080 (persisted under "YouTubeQuality")

Notifications:

  • YouTubeManager.youtubeChannelsDidChangeNotification — Channel list modified (the only notification)

Data Models

struct YouTubeChannel: Codable, Identifiable, Hashable {
    let id: String       // Normalized channel key (handle or channel ID)
    let title: String
    let url: URL         // Base channel URL (e.g. https://www.youtube.com/@handle)
    let dateAdded: Date
}

struct YouTubeVideo: Codable, Identifiable, Hashable {
    let videoId: String
    let title: String
    let channelId: String
    let duration: TimeInterval?
    let publishedAt: Date?          // approximate upload date (see "Approximate dates" below)
    var id: String { videoId }      // Identifiable
    var watchURL: URL { ... }       // https://www.youtube.com/watch?v=<videoId>
    var formattedDate: String?      // "MMM d, yyyy", nil when publishedAt is nil
}

struct YouTubeDownload: Codable {
    let videoId: String
    let title: String
    let channelId: String
    let fileName: String            // Path relative to downloadRoot (channel/file)
}

Manifest (youtube_downloads.json)

Stored inside downloadRoot, tracks downloaded files (the in-memory form is a [videoId: YouTubeDownload] dictionary; serialized JSON shape below):

A JSON dictionary keyed by videoId, each value a YouTubeDownload. fileName is a path relative to downloadRoot (<Channel Name>/<Title> [<videoId>].<ext>):

{
  "dQw4w9WgXcQ": {
    "videoId": "dQw4w9WgXcQ",
    "title": "Video Title",
    "channelId": "channel_handle",
    "fileName": "Channel Name/Video Title [dQw4w9WgXcQ].flac"
  }
}

Channel / Video Listing

Both ModernLibraryBrowserView and PlexBrowserView (classic UI) integrate YouTube as a source branch alongside internet radio stations. Channels appear as expandable folders; expanding a channel calls YouTubeManager.videos(forChannel:limit:) which shells out to yt-dlp --flat-playlist with no API key:

yt-dlp --flat-playlist -J --playlist-end 200 \
  --extractor-args "youtubetab:approximate_date" \
  "https://www.youtube.com/@channel_name/videos"

-J dumps a single JSON object; parseFlatPlaylist decodes its entries (each id/title/duration/timestamp) into YouTubeVideos. The limit defaults to YouTubeManager.videoLimit (a user setting, default 200, persisted under YouTubeVideoLimit, chosen via Library → YouTube → Videos per Channel: 50/100/200/500). Changing it posts youtubeVideoLimitDidChangeNotification; both browser views drop their cached youtubeChannelVideos and re-fetch expanded channels (reloadExpandedYouTubeChannels) so it applies without a restart. Channel title on add comes from a separate --playlist-end 1 fetch (fetchChannelTitle, no extractor arg).

Approximate dates: plain --flat-playlist returns no upload_date/timestamp — the channel grid only exposes relative dates ("3 weeks ago"). The youtubetab:approximate_date extractor arg (passed via fetchYtDlpJSON(…, approximateDate: true), videos call only) makes yt-dlp populate each entry's timestamp with an estimated epoch, decoded into YouTubeVideo.publishedAt. Accurate to the day for recent uploads, coarsening for older ones (older videos can share a timestamp). Unsupported/old yt-dlp just omits it → publishedAt nil → empty Date column, natural newest-first order preserved.

Videos appear as indented child rows. Double-clicking a video triggers YouTubeManager.download(video:) (audio or video MP4 depending on the current Quality setting; then the browser loads the returned local file and plays it).

Column rendering (Channels tab)

Video (leaf) rows render through the established resizable-column path (drawColumnRow), not the simple list path, so a long title truncates inside its column instead of printing over the time. The column set is:

static let youtubeColumns: [ModernBrowserColumn] = [.title, .youtubeDate, .duration]  // .youtubeDate titled "Date", .duration titled "Time"
  • A dedicated .youtube case in LibraryColumnVisibilityGroup namespaces the persisted widths (youtube:title, youtube:youtubeDate, youtube:duration) so they survive migrateColumnWidths. This is why the internet-radio column path can't be reused: internetRadioColumns are deliberately non-resizable (hitTestColumnResize early-returns when hasInternetRadioColumns), and the requirement here is a movable Time column like the library tabs.
  • The youtubeDate column shows video.formattedDate; sorting it goes through the raw-Date branch of columnSortAreInOrder (via columnDateValue, alongside dateAdded/lastPlayed), not string compare, so it orders by actual date.
  • Channel (parent) rows stay on the simple-list path so they keep their ▶/▼ expand arrows; only video rows use columns — mirroring radio folders vs. stations.
  • Column headers appear only once a channel is expanded (video rows exist), gated by hasYouTubeColumns (radioSlotShowingChannels + any .youtubeVideo in displayItems).
  • Clicking a header sorts via applyYouTubeColumnSort — an in-place sort of each contiguous run of video rows (leaving channel leaders put), mirroring applyInternetRadioColumnSort.
  • Plumbing touched: columnGroup(for:), currentColumnGroup(), columnsForItem, currentVisibleColumns, headerColumnsForCurrentContent, columnValue, plus the four allColumns/defaultColumnIds/visibleColumnIds/setVisibleColumnIds group switches.
  • Both UIs implement this independently: the classic PlexBrowserView mirrors the whole column set (its own BrowserColumn.youtubeColumns, columnValue, columnDateValue, applyYouTubeColumnSort, session sort, etc.). Any change to YouTube columns/sorting must be made in both ModernLibraryBrowserView and PlexBrowserView — they share no code.

Download Flow

  1. Reachability Check: Verify downloadRoot is accessible (mounted NAS, etc.)
  2. Download: YouTubeManager.download(video:) builds the format/output args and, for audio qualities, delegates to StreamRipper.downloadAudio(from:formatArgs:outputTemplate:) to download the video's best audio. For video qualities (quality.isVideo) it instead delegates to StreamRipper.downloadVideo(from:maxHeight:outputTemplate:) to download an MP4 capped at quality.videoMaxHeight (720/1080)
  3. Channel folder + readable name: Save under a per-channel subfolder as <Channel Name>/<Title> [<videoId>].<ext> (yt-dlp sanitizes the title and picks the extension; the bracketed video ID keeps names unique). The manifest stores this as a fileName path relative to downloadRoot.
  4. Manifest Update: Append entry to youtube_downloads.json
  5. Track Creation: Construct a local Track(url:) from the manifest entry
  6. Playback: Load the track into the audio engine and play

Library Menu Integration

All items live under a single Library → YouTube submenu, built in ContextMenuBuilder.buildYouTubeMenuItem() (actions setYouTubeDownloadFolder, setYouTubeQuality(_:), setYouTubeVideoLimit(_:)). The submenu reads current values at build time, so checkmarks update whenever the menu is rebuilt on open.

Library → YouTube → Set Download Folder…

  • Opens NSOpenPanel for directory selection
  • Validates reachability before storing
  • youtube_downloads.json is written lazily on the first recorded download, not on folder selection
  • Persists in UserDefaults under YouTubeDownloadRoot (the folder path)

Library → YouTube → Quality

  • Five-way FLAC / MP3 High / MP3 Low / Video (720p) / Video (1080p) setting (YouTubeQuality), persisted in UserDefaults under YouTubeQuality
  • Consulted before each download in YouTubeManager.download(video:): audio qualities pass quality.ytdlpArgs to StreamRipper.downloadAudio; video qualities (quality.isVideo) route to StreamRipper.downloadVideo with quality.videoMaxHeight

Library → YouTube → Videos per Channel

  • How many recent uploads to list per channel (--playlist-end); presets YouTubeManager.videoLimitChoices = 50/100/200/500, default 200, persisted under YouTubeVideoLimit
  • videos(forChannel:limit:) defaults limit to videoLimit
  • Changing it posts youtubeVideoLimitDidChangeNotification; both browser views clear youtubeChannelVideos and re-fetch expanded channels (reloadExpandedYouTubeChannels) so it applies live (no restart)

UI Mode Support

YouTube channels appear identically in:

  • Modern LibraryBrowserView (right-side tree, expandable channel folders + indented video rows)
  • Classic PlexBrowserView (left sidebar + table view, same folder/row structure as radio stations)

Both reuse existing BrowserSource / ModernBrowserSource enum routing.

Casting

Downloaded files are local file:// tracks. After download completes, the Track object is passed to the audio engine's normal playback path:

  • Local playback: Direct file read
  • Sonos: Track wrapped as a playable item (local file URL → proxy URL if needed)
  • Chromecast / DLNA: URL is reachable from the remote renderer via local HTTP server (FlyingFox)

Gotchas

  • No API key / YouTube account required: Uses yt-dlp --flat-playlist which scrapes the public channel uploads page (no authentication)
  • Video list is flat: --flat-playlist does not recurse; it only lists the channel's uploads. Playlists, live streams, and videos from other channels are not included unless explicitly in the uploads view
  • Download folder reachability: isDownloadFolderReachable() checks FileManager.fileExists + URL.checkResourceIsReachable() before downloading; a disconnected mount throws downloadFolderNotReachable instead of writing into a stale path
  • Manifest is line-of-business: Direct JSON file writes; no SQLite. Corruption or missing entries are rare but unrecoverable (keep off user-visible data)
  • Quality setting is global: One quality setting applies to all future downloads; past downloads retain their own quality field in the manifest
  • Streaming playback not offered: YouTube streams (live, members-only, age-restricted) may fail silently if yt-dlp can't extract them; only downloadable videos are listed
  • Video titles from yt-dlp: Source of truth is yt-dlp's title extraction; titles are not synced with YouTube's API and may differ from what the web UI shows
  • YouTube has its own session sort (default date order): The channels tab must NOT inherit the persisted library column sort (columnSortId), or every rebuild — including after a download — re-sorts videos to A–Z. Both views keep session-only youtubeColumnSortId/youtubeColumnSortAscending (default nil = yt-dlp's newest-first order), read through activeColumnSortId/activeColumnSortAscending by every sort/header-draw path. A header click in the YouTube tab sets the session sort only; it never writes the library sort. This state resets to date order on relaunch (intended).
  • Downloaded marker is rebuild-driven: A downloading video draws a per-row spinner gated on downloadingVideoIds; the prefix for a finished download is added in buildYouTubeChannelItems from isDownloaded. The download handler calls rebuildCurrentModeItems() on success (adds the marker) and a defer clears downloadingVideoIds (drops the spinner) — so the spinner→icon transition only works because the row stays put, which is why the session-sort fix above matters (an A–Z re-sort would relocate the row mid-transition).
  • Channels tab uses the .youtube column group, not the radio column path: Don't route YouTube videos through internetRadioColumns — those columns are fixed-width by design. Video rows use youtubeColumns ([.title, .youtubeDate, .duration]) via the resizable LibraryColumnVisibilityGroup.youtube group; adding/changing that enum requires updating every exhaustive switch group in both ModernLibraryBrowserView and PlexBrowserView

Signals

GitHub stars
118
Forks
9
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
youtube-source
Source
github.com/ad-repo/nullplayer