| name | cli |
| description | 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. |
NullPlayer CLI Mode
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:
nullplayer --cli --list-sources --json
nullplayer --cli --list-devices --json
nullplayer --cli --list-outputs --json
nullplayer --cli --source local --artist "Aphex Twin"
nullplayer --cli --source plex --playlist "Dinner"
nullplayer --cli --source jellyfin --album "Moon Safari"
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-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:
nullplayer --cli --list-playlists --source plex --json
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 (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>)
| 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
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.
It's a session flag, not a one-shot: it stays set for the life of a successful cast so the
CLI doesn't auto-exit on the .stopped events cast auto-advance can emit between tracks (a
one-shot cleared after the first .stopped would exit mid-playlist). The user quits with q.
Failure must clear it. If castCurrentTrack throws (e.g. an all-Sonos-incompatible
playlist), the catch sets castSessionActive = false — otherwise the guard would swallow
every future .stopped and hang the CLI at natural end. It then exits (code 1) if local
playback is no longer running (it was stopped for the failed handoff), or lets local playback
continue to its natural end if the throw happened before local playback was touched.
Exit Codes
| Code | Meaning |
|---|
| 0 | Success / clean quit |
| 1 | Error (auth failure, no matches, invalid args) |
| 130 | Interrupted (SIGINT / Ctrl+C) |
AudioEngine.isHeadless Pattern
Set to true by CLIMode.applicationDidFinishLaunching before anything else runs. Guards all video-related WindowManager.shared accesses inside AudioEngine:
if !AudioEngine.isHeadless { WindowManager.shared.setVideoVolume(volume) }
guard !AudioEngine.isHeadless else { return }
WindowManager.shared.toggleVideoCastPlayPause()
resolvedAudioEngine Pattern
Both RadioManager and CastManager have:
static weak var cliAudioEngine: AudioEngine?
private var resolvedAudioEngine: AudioEngine {
if AudioEngine.isHeadless, let cliEngine = Self.cliAudioEngine {
return cliEngine
}
return WindowManager.shared.audioEngine
}
CLIPlayer.init wires this:
RadioManager.cliAudioEngine = audioEngine
CastManager.cliAudioEngine = audioEngine
All WindowManager.shared.audioEngine references in the cast/radio path use resolvedAudioEngine instead — including the cast-control, status-poll, and teardown paths, not just the initial cast handoff. This covers stopCastPlayback (natural cast end), resetCastTime, pauseCastPlayback/resumeCastPlayback, the isAudioCastRoutingActive poll guards, the Sonos poll-result engine, the Sonos advance-on-unsupported-format branch in castNewTrack, and the post-sleep-wake Sonos check. If any of these hit WindowManager.shared.audioEngine directly, cast end/pause/resume/status updates never reach the CLI-owned engine and the attached --cli session sits with stale state (e.g. a Chromecast cast ends but the CLI still thinks it's casting). The only intended WindowManager.shared.audioEngine reference is the fallback inside resolvedAudioEngine itself.
currentMetadataTitle
RadioManager.currentMetadataTitle is a computed property:
var currentMetadataTitle: String? { currentStreamTitle ?? currentSomaLastPlaying }
CLIPlayer polls this every 5s via a Timer to display stream metadata updates.
Connection State Checks
SubsonicManager, JellyfinManager, and EmbyManager have nested ConnectionState enums without Equatable conformance. Use pattern matching:
if case .connected = SubsonicManager.shared.connectionState { ... }
SubsonicManager.shared.connectionState == .connected
LibraryFilter Usage
LibraryFilter has var properties (Set<String>), not a custom initializer. Use property assignment:
var filter = LibraryFilter()
filter.artists = ["Pink Floyd"]
filter.albums = ["The Wall"]
LibraryFilter(artists: ["Pink Floyd"])
Available LibrarySortOption cases: .title, .artist, .album, .dateAdded, .duration, .playCount. There is no .trackNumber or .genre.
Testing
Test Files
| File | Target | Tests | Covers |
|---|
Tests/NullPlayerAppTests/CLIOptionsTests.swift | NullPlayerAppTests | 12 unit | CLIOptions.parse, isQueryMode, isSearchQuery |
Run them:
swift test --filter CLIOptionsTests
swift test needs the projectM dylibs on the runner's rpath — if it fails to launch,
symlink them first (see the swift-test-dylib-rpath note):
for d in Frameworks/libprojectM-4*.dylib; do ln -sf "$PWD/$d" ".build/debug/$(basename "$d")"; done
What is and isn't covered
CLIOptionsTests uses @testable import NullPlayer and calls CLIOptions.parse([String])
directly (index 0 is the executable path, skipped by the parser). It covers the pure,
deterministic surface: default values, every boolean/--list-* flag, --cli being a
no-op, all string/int/double value flags, and the query-vs-playback classification
(isQueryMode, and isSearchQuery flipping to playback when --search is combined with
--artist/--album/--playlist/--radio/--station). The original Plex bug command
shape (--source plex --list-albums --artist …) is pinned as a regression guard.
Not unit-tested (needs live servers or a spawned binary — do via manual QA):
CLISourceResolver source resolution and ensureMusicLibrarySelected — depend on
PlexManager/Subsonic/Jellyfin/Emby singletons connected to real servers. Verify
by hand against a configured server (e.g. --source jellyfin --list-artists returns rows,
not 0 artist(s)).
CLIDisplay table/JSON formatting and CLIQueryHandler output — no tests yet.
Testability limitations (known quality gaps)
CLIOptions.parse calls exit(1) on invalid input (bad --decade/--volume/
--tuning-offset-cents, unknown flags, or a flag missing its value). That terminates the
process, so error paths can't be unit-tested without spawning a subprocess. Parsing and
validation should be separated (return an error instead of exiting) before those cases can
be covered in-process — keep test inputs valid until then.
- A string flag whose value begins with
-- is treated as "missing value" (the parser
guards !args[i+1].hasPrefix("--")), so values like --search "--foo" are rejected.
fetchPlaylistSongs on Subsonic/Jellyfin/Emby
Added to each manager to avoid exposing serverClient to CLI code:
func fetchPlaylistSongs(id: String) async throws -> [SubsonicSong] {
guard let client = serverClient else { throw SubsonicClientError.unauthorized }
let result = try await client.fetchPlaylist(id: id)
return result.songs
}
Error type is SubsonicClientError.unauthorized / JellyfinClientError.unauthorized / EmbyClientError.unauthorized (no .notConnected case exists in these clients).