YouTube Source
SkillMediaYouTube 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.
No other account needed.
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)
- Radio tab → + Add YouTube Channel
- Paste a YouTube channel URL (e.g.,
https://www.youtube.com/@channel_name) - Channel appears as a folder; expand to see uploads
- Double-click a video to download its audio or video and play
- Library → YouTube → Set Download Folder… to choose where downloads live
- Library → YouTube → Format to pick FLAC, MP3 High, MP3 Low, Video 720p, or Video 1080p
- 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.jsonin 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
.youtubecase inLibraryColumnVisibilityGroupnamespaces the persisted widths (youtube:title,youtube:youtubeDate,youtube:duration) so they survivemigrateColumnWidths. This is why the internet-radio column path can't be reused:internetRadioColumnsare deliberately non-resizable (hitTestColumnResizeearly-returns whenhasInternetRadioColumns), and the requirement here is a movable Time column like the library tabs. - The
youtubeDatecolumn showsvideo.formattedDate; sorting it goes through the raw-Datebranch ofcolumnSortAreInOrder(viacolumnDateValue, alongsidedateAdded/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.youtubeVideoindisplayItems). - Clicking a header sorts via
applyYouTubeColumnSort— an in-place sort of each contiguous run of video rows (leaving channel leaders put), mirroringapplyInternetRadioColumnSort. - Plumbing touched:
columnGroup(for:),currentColumnGroup(),columnsForItem,currentVisibleColumns,headerColumnsForCurrentContent,columnValue, plus the fourallColumns/defaultColumnIds/visibleColumnIds/setVisibleColumnIdsgroup switches. - Both UIs implement this independently: the classic
PlexBrowserViewmirrors the whole column set (its ownBrowserColumn.youtubeColumns,columnValue,columnDateValue,applyYouTubeColumnSort, session sort, etc.). Any change to YouTube columns/sorting must be made in bothModernLibraryBrowserViewandPlexBrowserView— they share no code.
Download Flow
- Reachability Check: Verify
downloadRootis accessible (mounted NAS, etc.) - Download:
YouTubeManager.download(video:)builds the format/output args and, for audio qualities, delegates toStreamRipper.downloadAudio(from:formatArgs:outputTemplate:)to download the video's best audio. For video qualities (quality.isVideo) it instead delegates toStreamRipper.downloadVideo(from:maxHeight:outputTemplate:)to download an MP4 capped atquality.videoMaxHeight(720/1080) - 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 afileNamepath relative todownloadRoot. - Manifest Update: Append entry to
youtube_downloads.json - Track Creation: Construct a local
Track(url:)from the manifest entry - 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
NSOpenPanelfor directory selection - Validates reachability before storing
youtube_downloads.jsonis 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 underYouTubeQuality - Consulted before each download in
YouTubeManager.download(video:): audio qualities passquality.ytdlpArgstoStreamRipper.downloadAudio; video qualities (quality.isVideo) route toStreamRipper.downloadVideowithquality.videoMaxHeight
Library → YouTube → Videos per Channel
- How many recent uploads to list per channel (
--playlist-end); presetsYouTubeManager.videoLimitChoices= 50/100/200/500, default 200, persisted underYouTubeVideoLimit videos(forChannel:limit:)defaultslimittovideoLimit- Changing it posts
youtubeVideoLimitDidChangeNotification; both browser views clearyoutubeChannelVideosand 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-playlistwhich scrapes the public channel uploads page (no authentication) - Video list is flat:
--flat-playlistdoes 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()checksFileManager.fileExists+URL.checkResourceIsReachable()before downloading; a disconnected mount throwsdownloadFolderNotReachableinstead 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
qualitysetting applies to all future downloads; past downloads retain their ownqualityfield 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-onlyyoutubeColumnSortId/youtubeColumnSortAscending(default nil = yt-dlp's newest-first order), read throughactiveColumnSortId/activeColumnSortAscendingby 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 inbuildYouTubeChannelItemsfromisDownloaded. The download handler callsrebuildCurrentModeItems()on success (adds the marker) and adeferclearsdownloadingVideoIds(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
.youtubecolumn group, not the radio column path: Don't route YouTube videos throughinternetRadioColumns— those columns are fixed-width by design. Video rows useyoutubeColumns([.title, .youtubeDate, .duration]) via the resizableLibraryColumnVisibilityGroup.youtubegroup; adding/changing that enum requires updating every exhaustiveswitch groupin bothModernLibraryBrowserViewandPlexBrowserView
Signals
- GitHub stars
- 120
- Forks
- 9
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
youtube-source- Source
- github.com/ad-repo/nullplayer