NullPlayer CLI Mode

SkillWeb & browsing

Headless 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.

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:

  • nullplayer is 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/)

FilePurpose
CLIMode.swiftNSApplicationDelegate for CLI mode; CLIOptions struct with arg parsing; signal handling
CLIPlayer.swiftHeadless playback controller; owns AudioEngine; implements AudioEngineDelegate
CLIKeyboard.swiftRaw terminal input via tcsetattr; ANSI escape sequence handling on background queue
CLIDisplay.swiftTerminal output: progress bar, status lines, album art, --json formatting; terminal color detection
CLIArtwork.swiftArtwork loading from local/Plex/Subsonic/Jellyfin/Emby
CLISourceResolver.swiftResolves all flags to audio tracks, .radioStation, or .video(CLIVideoItem); CLISourceError enum
CLIQueryHandler.swiftHandles --list-* and --search queries; prints results then calls exit()
CLIStderr.swiftcliStderr handle + suppressFrameworkLoggingForCLI(verbose:) — silences NSLog noise while keeping CLI messages visible

Modified Files

FileChange
App/main.swiftBranch on --cli flag; --cli+--ui-testing mutual exclusion; calls suppressFrameworkLoggingForCLI(verbose:) before app.run()
Audio/AudioEngine.swiftstatic var isHeadless = false; guards on all 10 WindowManager.shared video references
Radio/RadioManager.swiftstatic weak var cliAudioEngine; resolvedAudioEngine; currentMetadataTitle; 4 play(station:) replacements
Casting/CastManager.swiftstatic weak var cliAudioEngine; resolvedAudioEngine; replacements in castCurrentTrack, castNewTrack, pauseLocalPlayback, Chromecast status handler
Subsonic/SubsonicManager.swiftAdded fetchPlaylistSongs(id:)
Jellyfin/JellyfinManager.swiftAdded fetchPlaylistSongs(id:)
Emby/EmbyManager.swiftAdded fetchPlaylistSongs(id:)

Arguments

Boolean Flags

FlagDescription
--cliEnable headless CLI mode
--jsonJSON output for all queries/status
--helpShow help text and exit 0
--versionShow version string and exit 0
--shuffleEnable shuffle mode
--repeat-allRepeat entire playlist (CLIPlayer-managed; restarts on .stopped)
--repeat-oneRepeat current track (AudioEngine.repeatEnabled = true)
--no-artDisable album art entirely (art is shown by default)
--color-artForce color half-block art (truecolor/256)
--ascii-artForce monochrome character-ramp art
--verboseKeep 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)

FlagSource required?
--list-sourcesNo
--list-librariesYes (plex, subsonic, jellyfin, emby)
--list-artistsYes
--list-albumsYes (optional --artist filter)
--list-tracksYes (optional --artist/--album filter)
--list-genresNo (local library only)
--list-playlistsYes
--list-stationsNo (optional --folder filter)
--list-devicesNo (5s discovery wait)
--list-outputsNo
--list-eqNo

--search without playback flags (--artist, --album, --playlist, --radio, --station, --file, --movie, --episode) is also a query command.

String/Int Parameters

FlagTypeNotes
--source <name>stringlocal, plex, subsonic, jellyfin, emby, radio
--library <name>stringSelect sub-library/folder within source (plex, subsonic, jellyfin, emby); case-insensitive
--artist <name>stringFilter/select by artist (case-insensitive)
--album <name>stringFilter/select by album (case-insensitive)
--track <name>stringPost-filter by track title (substring)
--genre <name>stringFilter by genre
--decade <year>intDecade start year (e.g. 1970); passed as start:end+9
--playlist <name>stringSelect playlist by exact name (case-insensitive)
--file <path>stringPlay a local audio file, or cast a local video file when the extension is video (.mp4, .mkv, .mov, etc.).
--movie <title>stringCast a movie by title from --source plex|jellyfin|emby; list-then-filter matching, exact match preferred. Requires --cast.
--show <name>stringTV show scope for --episode; required for episode casting.
--episode <title>stringCast an episode by title from --source plex|jellyfin|emby; requires --show and --cast.
--season <n>intOptional season filter for --episode.
--number <n>intOptional episode-number filter for --episode.
--search <query>stringSearch within source
--radio <mode>stringSee radio modes below
--station <name>stringInternet radio station name (--source radio required)
--folder <name>stringRadio folder (see RadioFolderKind mapping)
--channel <name>stringRadio channel (with --folder channel)
--region <name>stringRadio region (with --folder region)
--volume <0-100>intInitial volume (divided by 100 for AudioEngine.volume)
--cast <device[,rooms…]>stringCast 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>stringsonos, chromecast, dlna (UPnP/DLNA target filter). Video rejects sonos.
--sonos-rooms <rooms>stringExtra comma-separated Sonos room names to group, merged with any rooms in --cast
--eq <preset>stringEQ preset name (case-insensitive; from EQPreset.allPresets)
--output <device>stringAudio output device name (case-insensitive)
--tuning <off|Hz>stringReference 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>stringSource reference frequency in Hz (default 440). Used with --tuning <Hz>.
--tuning-offset-cents <n>floatDirect 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:

  1. Source selection
  2. 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-type for network playback targets
  • --sonos-rooms <rooms> when targeting grouped Sonos playback
  • video files/movies/episodes require --cast and 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 --json when 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.

SourceConceptManager API
plexPlex library sectionselectLibrary(_:) on PlexManager; from availableLibraries
subsonicMusic folderselectMusicFolder(_:) on SubsonicManager; from musicFolders
jellyfinMusic libraryselectMusicLibrary(_:) on JellyfinManager; from musicLibraries
embyMusic libraryselectMusicLibrary(_:) 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 (from availableLibraries).
  • Jellyfin / Emby: collectionType == "music". Gotcha: JellyfinManager.musicLibraries / EmbyManager.musicLibraries are misnamed — fetchMusicLibraries() maps every view (/Users/{id}/Views) with no filtering, so those arrays include Playlists/Video/Movies/TV shows. Always filter by collectionType before treating an entry as music. connectInBackground only auto-selects a music library when there is exactly one view or a saved ID, so with multiple views the restored currentMusicLibrary is 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>)

ModeRequired flagsSource availability
libraryPlex, Subsonic, Jellyfin, Emby
genre--genre <name>All
decade--decade <year>All
hitsPlex only
deep-cutsPlex only
ratingPlex (minRating: 4.0), Subsonic (no params)
favoritesJellyfin, 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 valueEnum 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)

KeyAction
SpacePause/Resume
q / QQuit (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 / SToggle shuffle
r / RCycle repeat (off → all → one → off)
m / MToggle mute
i / IShow 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

  • CLIKeyboard reads stdin on background queue — all AudioEngine calls dispatched to DispatchQueue.main.async
  • AudioEngineDelegate callbacks arrive on main thread — safe to update CLIDisplay
  • Signal handlers use DispatchSourceSignal on .main — safe for terminal restore
  • CLIMode.applicationDidFinishLaunching spawns Task { @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
  • COLORTERM contains truecolor/24bit.truecolor
  • TERM contains truecolor/direct.truecolor
  • TERM contains 256color.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 via ansi256Index(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 from main.swift in the --cli branch before app.run() (so it's active before any NSLog fires). It dups the real stderr, redirects fd 2 to /dev/null (swallowing all NSLog), and exposes the saved terminal stderr as the global cliStderr.
  • All CLI diagnostics write to cliStderr, not stderr — every fputs(…, cliStderr) in CLIMode/CLIPlayer/CLIQueryHandler/CLISourceResolver. This is a hard rule: a bare fputs(…, stderr) in CLI code would be silently swallowed by the redirect. (The one exception is main.swift's pre-redirect --cli+--ui-testing error, which runs before the redirect and correctly uses stderr.)
  • --verbose sets CLIOptions.verbose and makes suppressFrameworkLoggingForCLI a no-op, so framework logs return for debugging. main.swift reads it directly via args.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