feat(cli): completions, library/audio commands, server switcher

Embed bash and zsh completion scripts and add a `completions` subcommand.
Extend the player CLI (library, audio device, instant mix) and surface
`music_library` in snapshots. Add a shared active-server switcher in the
header and locales; instant mix can reseed the queue from CLI and UI.
This commit is contained in:
Maxim Isaev
2026-04-15 00:56:40 +03:00
parent 19b7c8ec06
commit c75297fcf6
24 changed files with 1533 additions and 53 deletions
+36
View File
@@ -0,0 +1,36 @@
# Shell completion for `psysonic`
Covers global flags (`--help`, `--info`, …), `completions …`, and `--player` commands (`next`, `audio-device …`, `library …`, `mix …`, …). Run `psysonic --help` for the full list.
The same scripts are **embedded in the release binary**: run **`psysonic completions`** for install instructions, or **`psysonic completions bash` / `zsh`** to print the scripts (no repo checkout needed).
## zsh
Copy or symlink `_psysonic` into a directory on your `$fpath`, then reload completion:
```sh
mkdir -p ~/.zsh/completions
ln -sf /path/to/psysonic/completions/_psysonic ~/.zsh/completions/_psysonic
fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit && compinit
```
If you use a plugin manager, point its `fpath` at this repos `completions/` directory instead.
## bash
Source the script once (e.g. in `~/.bashrc`):
```sh
source /path/to/psysonic/completions/psysonic.bash
```
Use this only under **bash** (including macOSs `/bin/bash` 3.2). **zsh** users should install `_psysonic` instead — do not `source` the `.bash` file in zsh.
## Device names after `audio-device set`
Completion can suggest IDs from `psysonic-cli-audio-devices.json` (same paths the app uses: `$XDG_RUNTIME_DIR` or `$TMPDIR`/`/tmp`). That file appears after you run **`psysonic --player audio-device list`** while the app is running. Optional: install **`jq`** for parsing that JSON in the completion scripts.
## Folder ids after `library set`
Same idea with **`psysonic-cli-library.json`**, produced by **`psysonic --player library list`**. Optional **`jq`** for completion of folder ids plus the literal **`all`**.
+101
View File
@@ -0,0 +1,101 @@
#compdef psysonic
# Zsh completion for Psysonic CLI (see `psysonic --help`).
_psysonic_audio_device_json() {
local f
if [[ -n $XDG_RUNTIME_DIR ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json"
[[ -r $f ]] && { print -r -- "$f"; return }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json"
[[ -r $f ]] && print -r -- "$f"
}
_psysonic_audio_device_ids() {
command -v jq &>/dev/null || return
local jf
jf="$(_psysonic_audio_device_json)" || return
local -a devs
devs=( ${(@f)"$(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null)"} )
(( $#devs )) && compadd -a devs
}
_psysonic_library_json() {
local f
if [[ -n $XDG_RUNTIME_DIR ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-library.json"
[[ -r $f ]] && { print -r -- "$f"; return }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-library.json"
[[ -r $f ]] && print -r -- "$f"
}
_psysonic_library_folder_ids() {
command -v jq &>/dev/null || return
local jf
jf="$(_psysonic_library_json)" || return
local -a ids
ids=( ${(@f)"$(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)"} )
(( $#ids )) && compadd -a ids
}
_psysonic_globals() {
compadd -J options -X 'option' -- \
--help --version --info --json --quiet --player completions
}
integer i pidx=0
for (( i = 2; i < CURRENT; i++ )); do
[[ ${words[i]} == --player ]] && pidx=i
done
if (( pidx == 0 )); then
if (( CURRENT == 3 )) && [[ ${words[2]} == completions ]]; then
compadd help bash zsh
return
fi
_psysonic_globals
return
fi
local -a sub
if (( pidx + 1 <= CURRENT - 1 )); then
sub=( ${words[pidx + 1,CURRENT - 1]} )
else
sub=()
fi
integer n=${#sub[@]}
if (( n == 0 )); then
compadd -J verbs -X 'player command' -- \
next prev play pause seek volume audio-device library mix
return
fi
case ${sub[1]} in
audio-device)
if (( n == 1 )); then
compadd list set
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
compadd default
_psysonic_audio_device_ids
fi
;;
library)
if (( n == 1 )); then
compadd list set
elif [[ ${sub[2]} == set ]] && (( n == 2 )); then
compadd all
_psysonic_library_folder_ids
fi
;;
mix)
(( n == 1 )) && compadd append new
;;
seek)
(( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)'
;;
volume)
(( n == 1 )) && _message -e descriptions 'percent 0100'
;;
esac
+111
View File
@@ -0,0 +1,111 @@
# bash completion for Psysonic (see `psysonic --help`).
# Install: source /path/to/completions/psysonic.bash
# Optional: jq + prior `psysonic --player audio-device list` for device name completion.
#
# Uses no `mapfile` so bash 3.2 (macOS default) works.
_psysonic_compreply_from_compgen() {
# $1 = compgen -W word list, $2 = current word
COMPREPLY=()
local line
while IFS= read -r line; do
[[ -n $line ]] && COMPREPLY+=("$line")
done < <(compgen -W "$1" -- "$2")
}
_psysonic_audio_device_json() {
local f
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-audio-devices.json"
[[ -r $f ]] && { printf '%s' "$f"; return; }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-audio-devices.json"
[[ -r $f ]] && printf '%s' "$f"
}
_psysonic_library_json() {
local f
if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then
f="$XDG_RUNTIME_DIR/psysonic-cli-library.json"
[[ -r $f ]] && { printf '%s' "$f"; return; }
fi
f="${TMPDIR:-/tmp}/psysonic-cli-library.json"
[[ -r $f ]] && printf '%s' "$f"
}
_psysonic_complete() {
local cur
cur="${COMP_WORDS[COMP_CWORD]}"
local i pidx=0
for (( i = 1; i < COMP_CWORD; i++ )); do
[[ ${COMP_WORDS[i]} == --player ]] && pidx=$i
done
if (( pidx == 0 )); then
if [[ ${COMP_WORDS[1]} == completions && COMP_CWORD -eq 2 ]]; then
_psysonic_compreply_from_compgen 'help bash zsh' "$cur"
return
fi
_psysonic_compreply_from_compgen '--help --version --info --json --quiet --player completions' "$cur"
return
fi
local -a sub=()
for (( i = pidx + 1; i < COMP_CWORD; i++ )); do
sub+=("${COMP_WORDS[i]}")
done
local n=${#sub[@]}
if (( n == 0 )); then
_psysonic_compreply_from_compgen 'next prev play pause seek volume audio-device library mix' "$cur"
return
fi
case ${sub[0]} in
audio-device)
if (( n == 1 )); then
_psysonic_compreply_from_compgen 'list set' "$cur"
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
COMPREPLY=()
local jf d
jf="$(_psysonic_audio_device_json)"
if [[ -n $jf ]] && command -v jq &>/dev/null; then
while IFS= read -r d; do
[[ -n $d && $d == "$cur"* ]] && COMPREPLY+=("$d")
done < <(jq -r '.devices[]? | select(type == "string")' "$jf" 2>/dev/null)
fi
while IFS= read -r line; do
[[ -n $line ]] && COMPREPLY+=("$line")
done < <(compgen -W 'default' -- "$cur")
((${#COMPREPLY[@]})) && compopt -o filenames 2>/dev/null
fi
;;
library)
if (( n == 1 )); then
_psysonic_compreply_from_compgen 'list set' "$cur"
elif [[ ${sub[1]} == set ]] && (( n == 2 )); then
COMPREPLY=()
local jf id line
jf="$(_psysonic_library_json)"
if [[ -n $jf ]] && command -v jq &>/dev/null; then
while IFS= read -r id; do
[[ -n $id && $id == "$cur"* ]] && COMPREPLY+=("$id")
done < <(jq -r '.folders[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null)
fi
while IFS= read -r line; do
[[ -n $line ]] && COMPREPLY+=("$line")
done < <(compgen -W 'all' -- "$cur")
((${#COMPREPLY[@]})) && compopt -o filenames 2>/dev/null
fi
;;
mix)
(( n == 1 )) && _psysonic_compreply_from_compgen 'append new' "$cur"
;;
seek|volume)
(( n == 1 )) && compopt -o default && COMPREPLY=()
;;
esac
}
complete -F _psysonic_complete psysonic