Skill v1.0.1
currentAutomated scan100/100+1 new
version: "1.0.1" name: stream-ripper description: Rip a media URL to FLAC/MP3/MP4 via yt-dlp — quality-first format selection, metadata tagging, cover art, metadata-based filenames, .cue from chapters, and the main-window activity band. Use when working on the Stream Ripper, yt-dlp/ffmpeg integration, or the Output → Streaming menu.
Stream Ripper
Paste a URL and download it to a local file: lossless FLAC, MP3, or a playback-safe H.264/AAC MP4 video at a chosen resolution/bitrate profile (720p/2.5 Mbps, 1080p/4 Mbps recommended, 1080p/8 Mbps high quality, 1440p/16 Mbps, 4K/35 Mbps, Full/50 Mbps max). Implemented entirely in Sources/NullPlayer/StreamRipper/StreamRipper.swift, wired into the menu via App/ContextMenuBuilder.swift, with progress shown via App/MainWindowProviding.swift.
Quick Start (user)
- Output → Streaming → Rip URL… (right-click anywhere, or the menu bar Output menu)
- Paste a URL (the field pre-fills from the clipboard when it holds an
http(s)link) - Pick an output type: Audio — FLAC (lossless), Audio — MP3, or video at 1080p / 4 Mbps / 720p / 2.5 Mbps / 1080p / 8 Mbps / 1440p / 16 Mbps / 4K / 35 Mbps / Full / 50 Mbps
- Choose a destination folder (defaults to Downloads)
- A spinner + message appears at the top of the main window during the rip
- When done: Play Now, Reveal in Finder, or Done
Requirements
Ripping shells out to a system-installed yt-dlp (+ ffmpeg) — nothing is bundled (deliberately, to avoid the helper-binary machinery). StreamRipper.resolveYtDlp() searches /opt/homebrew/bin, /usr/local/bin, /opt/local/bin, /usr/bin. If yt-dlp isn't found the dialog short-circuits with an install hint (brew install yt-dlp ffmpeg). The child process's PATH is set to those dirs so yt-dlp can find ffmpeg even when launched without a login shell.
Architecture
- `StreamRipper` (
@MainActor final class,.sharedsingleton) owns the whole flow:
promptForInput() (URL + format popup) → promptForDestinationFolder() (NSOpenPanel) → rip(...).
- Menu:
ContextMenuBuilder.buildMenuBarStreamingSubmenuadds the single Rip URL… item. Action:MenuActions.ripURL()→MainActor.assumeIsolated { StreamRipper.shared.promptAndRip() }(menu clicks are already on the main thread). - The rip runs off the main thread (
DispatchQueue.global); UI (alerts, activity band) is dispatched back to main.
yt-dlp invocation (quality-first)
Common: --no-playlist --embed-metadata, output template "<local staging>/%(artist|)s%(artist& - |)s%(title)s.%(ext)s" (→ Artist - Title.ext, or just Title.ext when no artist), and --print-to-file after_move:filepath <tmp> so the actual final path (extension depends on the native codec) is read back. The completed local media file is copied directly to a collision-free final filename on the destination as one sequential write, with no destination-side temporary file or rename. On any failure, remaining staging contents are moved to the user's visible ~/Downloads folder and the /var/folders staging directory is removed after successful recovery.
- Audio (
-f bestaudio/best -x --audio-format {flac|mp3} --audio-quality 0 --embed-thumbnail --convert-thumbnails jpg):
grabs the best audio-only source, transcodes to the chosen format (FLAC = lossless encode of the decoded source; MP3 = top-VBR). Thumbnail is converted to JPEG before embedding (YouTube serves WebP, which doesn't embed cleanly into FLAC/MP3). Also adds --print-to-file after_move:%(chapters)j <tmp> for the cue sheet.
- Video —
Mode.video(VideoProfile)carries the user-chosen resolution/bitrate profile. yt-dlp first downloads the best source video+audio within the selected height cap (720p,1080p,1440p,2160p; uncapped for Full) into a temporaryArtist - Title [source].extfile, using--merge-output-format mkvso WebM/MP4 source combinations are accepted. Then ffmpeg creates the finalArtist - Title.mp4as H.264/AAC withyuv420p,+faststart, and profile bitrates: - 720p: 2.5 Mbps video / 128 kbps audio
- 1080p recommended: 4 Mbps video / 160 kbps audio
- 1080p high quality: 8 Mbps video / 192 kbps audio
- 1440p: 16 Mbps video / 192 kbps audio
- 4K: 35 Mbps video / 192 kbps audio
- Full/max: 50 Mbps video / 256 kbps audio, with no height cap
- Why this transcode exists: YouTube can serve MP4 files using codec/pixel-format combinations VLC accepts but NullPlayer's video/cast paths may not. The compatibility pass trades extra encode time for predictable playback.
- Why 1080p/4 Mbps is recommended: 1080p/8 Mbps produced a ~671 MB file for a 10-minute source, which is expected at that bitrate but too large for a default. The 4 Mbps profile is roughly half that size while keeping a playback-safe MP4.
- Why the cap matters: unconstrained "best" can pull oversized 4K+/high-bitrate streams — a 2.5h video ballooned to ~28GB. The user picks 720p / 1080p (default-recommended) / 1440p / 4K per rip; the height filter is a hard cap, not just a sort preference. Pick Full / 50 Mbps only when you explicitly want the maximum source resolution.
Quality caveat
Web sources (YouTube etc.) only serve lossy audio. FLAC losslessly wraps the decoded lossy audio — no quality is recovered (large files), but no further generational loss either. MP3 adds a second lossy pass (tandem coding) so it degrades further. FLAC is the no-further-degradation option of the two; the only truly zero-loss + small choice would be keeping native Opus (not currently offered).
CUE sheets from chapters
Audio rips capture the source chapter list via %(chapters)j (JSON array of {start_time, end_time, title}). When there are ≥ 2 chapters, writeCueFile emits a .cue next to the audio:
FILE "<name>" WAVE(orMP3for mp3)- one
TRACK NN AUDIOper chapter, each withTITLEandINDEX 01 MM:SS:FF cueTimestampconverts seconds →MM:SS:FFat 75 frames/second (CUE standard)- album/performer derived from the
Artist - Titlefilename; quotes in titles are sanitized to'
readChapters / writeCueFile / cueTimestamp are nonisolated static so they run safely in the background completion block. writeCueFile builds the cue in local staging, transfers it only after the media succeeds, and reports transfer errors instead of claiming a cue was written.
The cue this writes is consumed by the cue-sheets feature (direct-play virtual split / library split-on-import); its parser's parseCueTimestamp is the exact inverse of cueTimestamp and must round-trip — see the cue-sheets skill.
Progress band (main window)
MainWindowProviding gained showActivity(_:) / hideActivity() with default implementations (so both classic and modern UIs get it free, without either window referencing the other). It overlays a 22px bar at the top of window.contentView (y = height - barHeight, autoresizing [.width, .minYMargin]) with a spinning NSProgressIndicator + white label on translucent black, found/removed by NSUserInterfaceItemIdentifier. StreamRipper calls showActivity before launching and hideActivity on every completion path.
Play Now
presentSuccess(outputPath:mode:cueTrackCount:) branches by mode:
- audio →
audioEngine.loadFiles([url]); audioEngine.play()(same path as opening a file from Finder) - video →
WindowManager.shared.showVideoPlayer(url:title:allowCasting: false)using the final compatibility-transcoded.mp4(opens the local video player window; it deliberately bypasses active/preferred cast routing because the user just clicked Play Now for a local rip)
Gotchas
--print-to-file after_move:…only fires after a real download/move — it won't write in--skip-downloadtest runs. Use plain--print "%(chapters)j" --skip-downloadto inspect chapter output manually.start_timein the chapter JSON can be an integer (0);JSONSerialization→NSNumberbridges toDoubleregardless, soentry["start_time"] as? Doubleis fine.- The output extension is not fixed for audio when keeping native containers would apply — always trust the
after_move:filepathvalue, not an assumed.flac/.mp3/.mp4. - A failed download, transcode, destination copy, or CUE transfer moves every remaining staging item into
~/Downloadswith collision-free names. The error dialog reports the recovered path;/var/foldersis retained only if Downloads itself cannot be written. - Do not call
WindowManager.playVideoTrackfrom Stream Ripper's Play Now path unless you intend to cast.playVideoTrackis playlist-oriented and routes totargetVideoCastDevicewhen a video-capable cast session or preferred video cast device exists. UseshowVideoPlayer(..., allowCasting: false)for local Play Now. - No Spotify/Apple/Amazon sources (project policy) — this is a generic URL ripper backed by yt-dlp.