NullPlayer CLI Mode
SkillWeb & browsingHeadless CLI mode for NullPlayer. Use when working on CLI features, arguments, playback control, query commands, source resolution, or terminal display. Covers architecture, all flags, keyboard controls, and source APIs.
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 NullPlayer CLI Mode skill
What this skill tells your AI
The instructions your AI receives, as published by ad-repo/nullplayer in skills/cli/SKILL.md and read by ahel’s review.
NullPlayer supports a --cli flag that launches a headless mode for browsing, searching, playing, and routing media from all configured sources via the terminal. Treat this as a first-class command surface, not a debug-only mode. The process stays alive during playback with interactive keyboard controls. Query commands (--list-*, --search) print results and exit immediately.
Positioning
The right mental model is:
nullplayeris a scriptable media control command- it connects multiple media sources to multiple playback targets
- it works well inside shell scripts, launchers, shortcuts, and automation pipelines
Be explicit about both sides of the pipeline:
- Sources in: local files/library, Plex, Subsonic/Navidrome, Jellyfin, Emby, internet radio
- Targets out: local audio outputs, Sonos, Chromecast, UPnP/DLNA. Video casting is limited to Chromecast and DLNA TV targets.
Do not describe it as a daemon, background control server, or remote-control protocol unless that is separately implemented. It is a command you invoke to query, resolve, start playback, and optionally cast.
Launching
nullplayer --cli [OPTIONS]
--cli and --ui-testing are mutually exclusive. In CLI mode the app runs with .accessory activation policy — no Dock icon, no menu bar.
If the launcher is not installed, the underlying executable is still:
NullPlayer.app/Contents/MacOS/NullPlayer --cli [OPTIONS]
Automation Use Cases
This CLI is strong when you need one command to:
- query available sources, libraries, outputs, and cast devices
- resolve media from different backends with one consistent UX
- start playback on the Mac itself or send it to a network playback target
- emit machine-friendly query output via
--json
Typical examples:
# Query sources and devices for downstream scripting
nullplayer --cli --list-sources --json
nullplayer --cli --list-devices --json
nullplayer --cli --list-outputs --json
# Start playback from different backends with one command shape
nullplayer --cli --source local --artist "Aphex Twin"
nullplayer --cli --source plex --playlist "Dinner"
nullplayer --cli --source jellyfin --album "Moon Safari"
# Use NullPlayer as a media-routing command
nullplayer --cli --source radio --station "KEXP" --cast "Living Room" --cast-type sonos
nullplayer --cli --source local --album "Kid A" --cast "Office TV" --cast-type dlna
nullplayer --cli --source subsonic --artist "Massive Attack" --cast "Kitchen Speaker" --cast-type chromecast
nullplayer --cli --file ~/Movies/sample.mkv --cast "Living Room TV" --cast-type chromecast
nullplayer --cli --source jellyfin --show "The Office" --episode "Dinner Party" --cast "Bedroom TV" --cast-type dlna
When documenting or selling the feature, good phrasing is:
- "scriptable media control command"
- "headless playback and casting command"
- "automation-friendly CLI for multi-source media routing"
Avoid vague phrasing like "CLI browser" when the real value is orchestration across sources and outputs.
Architecture
New Files (Sources/NullPlayer/CLI/)
| File | Purpose |
|---|---|
CLIMode.swift | NSApplicationDelegate for CLI mode; CLIOptions struct with arg parsing; signal handling |
CLIPlayer.swift | Headless playback controller; owns AudioEngine; implements AudioEngineDelegate |
CLIKeyboard.swift | Raw terminal input via tcsetattr; ANSI escape sequence handling on background queue |
CLIDisplay.swift | Terminal output: progress bar, status lines, album art, --json formatting; terminal color detection |
CLIArtwork.swift | Artwork loading from local/Plex/Subsonic/Jellyfin/Emby |
CLISourceResolver.swift | Resolves all flags to audio tracks, .radioStation, or .video(CLIVideoItem); CLISourceError enum |
CLIQueryHandler.swift | Handles --list-* and --search queries; prints results then calls exit() |
CLIStderr.swift | cliStderr handle + suppressFrameworkLoggingForCLI(verbose:) — silences NSLog noise while keeping CLI messages visible |
Modified Files
| File | Change |
|---|---|
App/main.swift | Branch on --cli flag; --cli+--ui-testing mutual exclusion; calls suppressFrameworkLoggingForCLI(verbose:) before app.run() |
Audio/AudioEngine.swift | static var isHeadless = false; guards on all 10 WindowManager.shared video references |
Radio/RadioManager.swift | static weak var cliAudioEngine; resolvedAudioEngine; currentMetadataTitle; 4 play(station:) replacements |
Casting/CastManager.swift | static weak var cliAudioEngine; resolvedAudioEngine; replacements in castCurrentTrack, castNewTrack, pauseLocalPlayback, Chromecast status handler |
Subsonic/SubsonicManager.swift | Added fetchPlaylistSongs(id:) |
Jellyfin/JellyfinManager.swift | Added fetchPlaylistSongs(id:) |
Emby/EmbyManager.swift | Added fetchPlaylistSongs(id:) |
Arguments
Boolean Flags
| Flag | Description |
|---|---|
--cli | Enable headless CLI mode |
--json | JSON output for all queries/status |
--help | Show help text and exit 0 |
--version | Show version string and exit 0 |
--shuffle | Enable shuffle mode |
--repeat-all | Repeat entire playlist (CLIPlayer-managed; restarts on .stopped) |
--repeat-one | Repeat current track (AudioEngine.repeatEnabled = true) |
--no-art | Disable album art entirely (art is shown by default) |
--color-art | Force color half-block art (truecolor/256) |
--ascii-art | Force monochrome character-ramp art |
--verbose | Keep framework NSLog output (suppressed by default) |
--repeat-all and --repeat-one are mutually exclusive; validated at startup.
Art rendering and log suppression each have their own section below.
Query Commands (print and exit)
| Flag | Source required? |
|---|---|
--list-sources | No |
--list-libraries | Yes (plex, subsonic, jellyfin, emby) |
--list-artists | Yes |
--list-albums | Yes (optional --artist filter) |
--list-tracks | Yes (optional --artist/--album filter) |
--list-genres | No (local library only) |
--list-playlists | Yes |
--list-stations | No (optional --folder filter) |
--list-devices | No (5s discovery wait) |
--list-outputs | No |
--list-eq | No |
--search without playback flags (--artist, --album, --playlist, --radio, --station, --file, --movie, --episode) is also a query command.
String/Int Parameters
| Flag | Type | Notes |
|---|---|---|
--source <name> | string | local, plex, subsonic, jellyfin, emby, radio |
--library <name> | string | Select sub-library/folder within source (plex, subsonic, jellyfin, emby); case-insensitive |
--artist <name> | string | Filter/select by artist (case-insensitive) |
--album <name> | string | Filter/select by album (case-insensitive) |
--track <name> | string | Post-filter by track title (substring) |
--genre <name> | string | Filter by genre |
--decade <year> | int | Decade start year (e.g. 1970); passed as start:end+9 |
--playlist <name> | string | Select playlist by exact name (case-insensitive) |
--file <path> | string | Play a local audio file, or cast a local video file when the extension is video (.mp4, .mkv, .mov, etc.). |
--movie <title> | string | Cast a movie by title from --source plex|jellyfin|emby; list-then-filter matching, exact match preferred. Requires --cast. |
--show <name> | string | TV show scope for --episode; required for episode casting. |
--episode <title> | string | Cast an episode by title from --source plex|jellyfin|emby; requires --show and --cast. |
--season <n> | int | Optional season filter for --episode. |
--number <n> | int | Optional episode-number filter for --episode. |
--search <query> | string | Search within source |
--radio <mode> | string | See radio modes below |
--station <name> | string | Internet radio station name (--source radio required) |
--folder <name> | string | Radio folder (see RadioFolderKind mapping) |
--channel <name> | string | Radio channel (with --folder channel) |
--region <name> | string | Radio region (with --folder region) |
--volume <0-100> | int | Initial volume (divided by 100 for AudioEngine.volume) |
--cast <device[,rooms…]> | string | Cast to named device (case-insensitive match). Comma-separated: the first entry is the cast target / Sonos group coordinator; remaining entries are Sonos rooms grouped onto it (merged with --sonos-rooms, deduped, coordinator dropped). Grouping is Sonos-only — a non-Sonos target with extra rooms warns and ignores them. |
--cast-type <type> | string | sonos, chromecast, dlna (UPnP/DLNA target filter). Video rejects sonos. |
--sonos-rooms <rooms> | string | Extra comma-separated Sonos room names to group, merged with any rooms in --cast |
--eq <preset> | string | EQ preset name (case-insensitive; from EQPreset.allPresets) |
--output <device> | string | Audio output device name (case-insensitive) |
--tuning <off|Hz> | string | Reference Tuning: off, or target reference frequency in Hz (e.g. 432). Enables pitch shift for local output only (local files and HTTP streams) — not casting. Session-only override. |
--tuning-source <Hz> | string | Source reference frequency in Hz (default 440). Used with --tuning <Hz>. |
--tuning-offset-cents <n> | float | Direct cents offset (±2400). Wins over --tuning/--tuning-source. Session-only. |
Multi-Source / Multi-Output Framing
When explaining this subsystem, always make the two-sided model clear:
- Source selection
- Playback routing
Source selection is handled by --source, library filters, search filters, playlists, radio modes, and station selection.
Playback routing is handled by:
- default local playback if no routing flag is supplied
--output <device>for local audio device selection--cast <device>with optional--cast-typefor network playback targets--sonos-rooms <rooms>when targeting grouped Sonos playback- video files/movies/episodes require
--castand route only to Chromecast or DLNA TV targets
That is why "media control command" is more accurate than "terminal player". The command is not limited to local playback; it also chooses where playback goes.
Query vs Control Behavior
There are two main command shapes:
- Query commands: print results and exit
- Playback commands: resolve media, start playback, then remain attached for interactive control
This distinction matters for automation guidance:
- Use query commands plus
--jsonwhen NullPlayer is feeding another step in the pipeline - Use playback commands when NullPlayer is the execution step that actually starts playback or casting
Good examples:
# Query step
nullplayer --cli --list-playlists --source plex --json
# Execution step
nullplayer --cli --source plex --playlist "Focus" --cast "Bedroom" --cast-type sonos
--library Flag
Selects a source-specific sub-library before any query or playback. All subsequent operations (artists, albums, radio, etc.) are scoped to that library.
| Source | Concept | Manager API |
|---|---|---|
plex | Plex library section | selectLibrary(_:) on PlexManager; from availableLibraries |
subsonic | Music folder | selectMusicFolder(_:) on SubsonicManager; from musicFolders |
jellyfin | Music library | selectMusicLibrary(_:) on JellyfinManager; from musicLibraries |
emby | Music library | selectMusicLibrary(_:) on EmbyManager; from musicLibraries |
Use --list-libraries --source <name> to see available libraries. The current selection (marked *) is from the GUI's saved preference.
Implicit music-library selection (Plex, Jellyfin, Emby)
The CLI's music paths are audio-only, but Plex, Jellyfin, and Emby all expose non-music sections and carry over the GUI's last-selected library, which may be one of them (a Plex Movies/TV section, or a Jellyfin/Emby Playlists/Video/Movies/TV shows view). A music query against a non-music library returns [], surfacing as "artist not found" / "0 artist(s)". Video flags (--movie, --episode) use the corresponding movie/TV library selection instead.
CLISourceResolver.ensureMusicLibrarySelected(source:) runs before music-only operations (--list-artists/albums/tracks, and artist/album/search playback — not playlists, which are server-level, and not Subsonic, see below):
- If the current library is already a music library, it is kept.
- If there is exactly one music library, it is auto-selected (and persists, since selection is written to UserDefaults).
- If there are several and the current one isn't music, it throws a clear "specify one with
--library <name>" error listing the available music libraries.
How "is this a music library?" is decided per source:
- Plex:
PlexLibrary.isMusicLibrary(fromavailableLibraries). - Jellyfin / Emby:
collectionType == "music". Gotcha:JellyfinManager.musicLibraries/EmbyManager.musicLibrariesare misnamed —fetchMusicLibraries()maps every view (/Users/{id}/Views) with no filtering, so those arrays includePlaylists/Video/Movies/TV shows. Always filter bycollectionTypebefore treating an entry as music.connectInBackgroundonly auto-selects a music library when there is exactly one view or a saved ID, so with multiple views the restoredcurrentMusicLibraryis often a non-music view.
ensureMusicLibrarySelected is applied before every music-only path: query-mode --list-artists/albums/tracks, --search (both the query-mode searchAndPrint branch and playback), and server --radio modes. Playlists skip it. It runs after applyLibrary so an explicit --library always wins.
Selecting the library is necessary but not sufficient for search on Jellyfin/Emby: client.search(query:) used to hit /Items with Recursive=true and no parentId, so it searched the whole server regardless of the selection. JellyfinManager.search / EmbyManager.search accept an optional parentId; CLI search paths pass currentMusicLibrary?.id after applyLibrary / ensureMusicLibrarySelected, while GUI Library Browser search omits parentId to keep mixed global results (music, movies, TV). Plex search is already library-scoped via currentLibrary; Subsonic is music-only.
This pairs with the connectivity fix: checkConnectivity now awaits the background connect/refresh task for all server sources (serverRefreshTask for Plex, serverConnectTask for Subsonic/Jellyfin/Emby) so serverClient/currentLibrary are populated before any query. listSources() awaits the same tasks so configured servers report Connected instead of racing to "Not configured".
Preload is detached from the awaited task. The connect/refresh tasks used to await preloadLibraryContent() (which fetches artists + up to 10k albums, and on Emby also movies/shows) inline, so awaiting them blocked one-shot CLI queries and --list-sources on a full library scan. Preload now runs in a Task.detached(priority: .utility) after connection (Jellyfin already did this), so the awaited task resolves once the connection and library selection are ready. Nothing outside the CLI awaits these tasks, so this doesn't change GUI behavior; browsing still gets a warm cache from the detached preload.
Subsonic/Navidrome is exempt (ensureMusicLibrarySelected no-ops for it): it is a music-only server with no music/video split. fetchArtists() passes musicFolderId: currentMusicFolder?.id, and nil (the default, "all folders") returns every artist — there is no non-music library to land on.
Radio Modes (--radio <mode>)
| Mode | Required flags | Source availability |
|---|---|---|
library | — | Plex, Subsonic, Jellyfin, Emby |
genre | --genre <name> | All |
decade | --decade <year> | All |
hits | — | Plex only |
deep-cuts | — | Plex only |
rating | — | Plex (minRating: 4.0), Subsonic (no params) |
favorites | — | Jellyfin, Emby only |
artist | --artist <name> | All |
album | --artist <name> --album <name> | All |
track | --track <name> or --search <name> | All |
Plex param differences: createDecadeRadio(startYear:endYear:) — not start:end:.
Subsonic/Jellyfin/Emby: createDecadeRadio(start:end:).
Subsonic artist/album radio: takes ID string, not model object.
Plex artist/album radio: takes model object (must resolve via fetchArtists/fetchAlbums first).
RadioFolderKind Mapping
--folder value | Enum case |
|---|---|
all | .allStations |
favorites | .favorites |
top-rated | .topRated |
unrated | .unrated |
recent | .recentlyPlayed |
channels | .byChannel |
genres | .byGenre |
regions | .byRegion |
genre | .genre(name) — requires --genre |
channel | .channel(name) — requires --channel |
region | .region(name) — requires --region |
Keyboard Controls (during playback)
| Key | Action |
|---|---|
Space | Pause/Resume |
q / Q | Quit (restores terminal) |
> | Next track |
< | Previous track |
→ (right arrow) | Seek forward 10s |
← (left arrow) | Seek backward 10s |
↑ (up arrow) | Volume up 5% |
↓ (down arrow) | Volume down 5% |
s / S | Toggle shuffle |
r / R | Cycle repeat (off → all → one → off) |
m / M | Toggle mute |
i / I | Show current track info |
CLIKeyboard reads stdin on a background DispatchQueue and dispatches all player calls to DispatchQueue.main.async.
In video-cast mode, Space maps to cast pause/resume, left/right arrows seek on the cast session, and q stops casting before exiting. Track navigation, shuffle, repeat, mute, and volume are no-ops for video. Chromecast video exits automatically after active playback ends and the cast session is torn down. DLNA video has no reliable end-of-stream signal in the current stack, so it prints approximate progress and requires q to stop the CLI.
Local video files are served through LocalMediaServer on port 8765 before being handed to Chromecast or DLNA targets. If the main NullPlayer UI is already open, that process may own the port; CLI local-video casts should fail with a descriptive "port 8765 is unavailable" error. Tell users to quit the app UI or stop the other NullPlayer process, then retry. A port conflict can otherwise look like a DLNA 716 Resource not found because the TV receives a /media/<token> URL that belongs to the CLI process, while another process is actually serving the port.
Thread Safety
CLIKeyboardreads stdin on background queue — allAudioEnginecalls dispatched toDispatchQueue.main.asyncAudioEngineDelegatecallbacks arrive on main thread — safe to updateCLIDisplay- Signal handlers use
DispatchSourceSignalon.main— safe for terminal restore CLIMode.applicationDidFinishLaunchingspawnsTask { @MainActor in ... }for all async resolution
Album Art Rendering
CLIDisplay.printAsciiArt(_:forceColor:forceAscii:) renders album art three ways. The
mode is auto-detected by default and can be forced by flags.
CLIDisplay.detectColorMode() → .truecolor / .ansi256 / .mono, purely from the
environment (declarative — it reports what the terminal claims):
- Not a TTY (
isatty(fileno(stdout)) == 0, i.e. piped/redirected) →.mono COLORTERMcontainstruecolor/24bit→.truecolorTERMcontainstruecolor/direct→.truecolorTERMcontains256color→.ansi256- otherwise (no positive color signal) →
.mono
Rendering per mode:
- Color (
.truecolor/.ansi256): every cell is a▀half-block; the image is carried entirely by per-cell color codes (\e[38;2;…truecolor or\e[38;5;…256-color viaansi256Index(r:g:b:), which picks the nearest of the 6×6×6 cube or the grayscale ramp). Samples a 60×60 pixel grid → 30 char rows (2 pixels per row). - Mono (
.mono): a luminance→character ramp (.:-=+*#%@,asciiRamp) using no color codes at all — the only thing that renders on terminals that ignore ANSI color (where the color path collapses to a flat wall of▀). Samples 60×30 (one char per pixel). Assumes a dark background (denser glyph = brighter pixel); on a light background it reads inverted.
Mode precedence (in printAsciiArt): forceAscii (--ascii-art) → mono; else forceColor
(--color-art) → color (truecolor if detected, else 256); else the NULLPLAYER_ART env var
(ascii/mono → mono, color → color, unset/auto/unrecognized → auto-detect). --no-art
(in CLIOptions.art) short-circuits before any of this and skips art entirely.
Why the overrides exist: detection is env-based and a terminal can misreport — most
commonly export COLORTERM=truecolor in a shell profile makes every terminal claim
truecolor even when it renders monochrome, so auto-detect picks color and the art collapses to
blocks. --ascii-art is the per-invocation escape hatch; NULLPLAYER_ART=ascii is the
per-terminal one (pin it in that terminal's shell profile so the default renders ascii with no
flag). There is no runtime way to know a terminal actually renders monochrome short of an
escape-sequence round-trip (OSC 4 / DA query with a timed stdin read), which is fragile across
tmux/ssh and not done.
Log Suppression
The app calls NSLog in ~80 files; that output goes to stderr and floods a headless
session endlessly. CLIStderr.swift handles this:
suppressFrameworkLoggingForCLI(verbose:)— called frommain.swiftin the--clibranch beforeapp.run()(so it's active before anyNSLogfires). Itdups the real stderr, redirects fd 2 to/dev/null(swallowing allNSLog), and exposes the saved terminal stderr as the globalcliStderr.- All CLI diagnostics write to
cliStderr, notstderr— everyfputs(…, cliStderr)inCLIMode/CLIPlayer/CLIQueryHandler/CLISourceResolver. This is a hard rule: a barefputs(…, stderr)in CLI code would be silently swallowed by the redirect. (The one exception ismain.swift's pre-redirect--cli+--ui-testingerror, which runs before the redirect and correctly usesstderr.) --verbosesetsCLIOptions.verboseand makessuppressFrameworkLoggingForCLIa no-op, so framework logs return for debugging.main.swiftreads it directly viaargs.contains("--verbose")(before options are parsed).
Casting Keep-Alive
When --cast hands off to a device, CastManager calls AudioEngine.stopLocalForCasting(),
which sets local state = .stopped. Without guarding, CLIPlayer.audioEngineDidChangeState
treats that as end-of-playlist (hasStartedPlaying && !repeatAll) and calls exit(0) — the
CLI would quit the instant the cast starts.
CLIPlayer guards this with castSessionActive, set true right before
CastManager.shared.castCurrentTrack(to:). While set, a local .stopped is ignored (no exit,
no repeat-all restart) — audio is on the cast device, and the engine re-enters .playing from
updateCastPosition once the device reports status. The flag can't rely on
CastManager.isCasting at the handoff instant: stopLocalForCasting runs before
upnpManager.connect, so activeSession is still nil and isCasting is false when the
.stopped fires.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 120
- Forks
- 9
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
cli- Source
- github.com/ad-repo/nullplayer