#!/usr/bin/env bash set -euo pipefail API_BASE="${MINEMUZAK_API_BASE:-https://mymuzak.minecrunch.net/api/v1}" CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/minemuzak-cli" CONFIG_FILE="$CONFIG_DIR/config.json" normalize_api_base() { local raw="$1" local base="${raw%/}" if [[ "$base" =~ ^https?://[^/]+$ ]]; then printf '%s/api/v1' "$base" return fi if [[ "$base" =~ /api/v1/docs$ ]]; then printf '%s' "${base%/docs}" return fi if [[ "$base" =~ /api$ ]]; then printf '%s/v1' "$base" return fi printf '%s' "$base" } usage() { cat <<'EOF' MineMuzak CLI Usage: minemuzak [--url ] [args] minemuzak login [--url ] minemuzak register minemuzak change-password minemuzak auth minemuzak health [--url ] minemuzak me minemuzak users [args] minemuzak list minemuzak music [args] minemuzak video [args] minemuzak play --filename minemuzak play --index minemuzak play queue [--next] minemuzak play state [--update-playing] [--source-client-id ] minemuzak queue [args] minemuzak playlists [args] minemuzak shuffle [json] minemuzak podcasts [args] minemuzak interactive minemuzak api [--data ''] [--query key=value] [--no-auth] minemuzak example minemuzak help Environment: MINEMUZAK_API_BASE Override API base URL (default: https://mymuzak.minecrunch.net/api/v1) Login Options: --url Set and save the API base URL for future commands Global Options: --url Override API base URL for this command invocation Notes: - Requires: curl, jq (and ffmpeg for play) - If ffplay is available, it is used for audio playback after ffmpeg decoding. EOF } die() { echo "Error: $*" >&2 exit 1 } require_cmd() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" } ensure_base_deps() { require_cmd curl require_cmd jq } ensure_playback_deps() { ensure_base_deps require_cmd ffmpeg } urlencode() { jq -nr --arg v "$1" '$v|@uri' } jwt_claim() { local token="$1" local claim="$2" local payload payload="$(printf '%s' "$token" | awk -F '.' '{print $2}')" [[ -n "$payload" ]] || return 0 payload="${payload//-/+}" payload="${payload//_//}" case $(( ${#payload} % 4 )) in 2) payload+="==" ;; 3) payload+="=" ;; esac printf '%s' "$payload" | base64 -d 2>/dev/null | jq -r --arg c "$claim" '.[$c] // empty' 2>/dev/null || true } pretty_print_json_or_raw() { local body="$1" if printf '%s' "$body" | jq -e . >/dev/null 2>&1; then printf '%s' "$body" | jq . else printf '%s\n' "$body" fi } api_origin() { printf '%s' "$API_BASE" | sed -E 's#^(https?://[^/]+).*$#\1#' } build_url() { local path="$1" local base="${API_BASE%/}" local origin if [[ "$path" =~ ^https?:// ]]; then printf '%s' "$path" return fi origin="$(api_origin)" if [[ "$path" == /api/v1/* ]]; then printf '%s%s' "$origin" "$path" elif [[ "$path" == api/v1/* ]]; then printf '%s/%s' "$origin" "$path" elif [[ "$path" == /* ]]; then printf '%s%s' "$base" "$path" else printf '%s/%s' "$base" "$path" fi } encode_query_pair() { local pair="$1" local key local value [[ "$pair" == *"="* ]] || die "Invalid --query value '$pair'. Expected key=value" key="${pair%%=*}" value="${pair#*=}" printf '%s=%s' "$(urlencode "$key")" "$(urlencode "$value")" } prompt_text() { local label="$1" local value read -r -p "$label" value printf '%s' "$value" } csv_to_int_json_array() { local csv="$1" printf '%s' "$csv" \ | tr ',' '\n' \ | sed -E 's/^\s+|\s+$//g' \ | sed '/^$/d' \ | jq -R -s 'split("\n") | map(select(length>0) | tonumber)' } positions_csv_to_ids_json_array() { local csv="$1" shift local -a ids=("$@") local -a parts=() local -a out=() local p local idx local count local cleaned declare -A seen=() count=${#ids[@]} [[ $count -gt 0 ]] || die "No items available to reorder" IFS=',' read -r -a parts <<< "$csv" for p in "${parts[@]}"; do cleaned="$(printf '%s' "$p" | sed -E 's/^\s+|\s+$//g')" [[ "$cleaned" =~ ^[0-9]+$ ]] || die "Order must be comma-separated position numbers (example: 2,1,3)" (( cleaned >= 1 && cleaned <= count )) || die "Position $cleaned is out of range 1..$count" [[ -z "${seen[$cleaned]:-}" ]] || die "Position $cleaned appears more than once" seen[$cleaned]=1 idx=$((cleaned - 1)) out+=("${ids[$idx]}") done [[ ${#out[@]} -eq $count ]] || die "You must specify all positions exactly once (1..$count)" jq -n --argjson ids "$(printf '%s\n' "${out[@]}" | jq -R -s 'split("\n") | map(select(length>0) | tonumber)')" '$ids' } show_numbered_items() { local title="$1" shift local -a names=("$@") local i echo "$title" for (( i=0; i<${#names[@]}; i++ )); do printf '%d) %s\n' "$((i + 1))" "${names[$i]}" done } save_token() { local token="$1" local token_type="$2" local username="${3:-}" local api_base="${4:-}" mkdir -p "$CONFIG_DIR" jq -n \ --arg token "$token" \ --arg token_type "$token_type" \ --arg username "$username" \ --arg api_base "$api_base" \ '{access_token:$token, token_type:$token_type, username:$username, api_base:$api_base}' >"$CONFIG_FILE" } load_token() { [[ -f "$CONFIG_FILE" ]] || die "Not logged in. Run: minemuzak login " TOKEN="$(jq -r '.access_token // empty' "$CONFIG_FILE")" TOKEN_TYPE="$(jq -r '.token_type // "bearer"' "$CONFIG_FILE")" local saved_api_base saved_api_base="$(jq -r '.api_base // empty' "$CONFIG_FILE")" if [[ -n "$saved_api_base" ]]; then API_BASE="$(normalize_api_base "$saved_api_base")" fi [[ -n "$TOKEN" ]] || die "Stored token is invalid. Re-run login." } load_saved_api_base_if_present() { local saved_api_base [[ -f "$CONFIG_FILE" ]] || return 0 saved_api_base="$(jq -r '.api_base // empty' "$CONFIG_FILE")" if [[ -n "$saved_api_base" ]]; then API_BASE="$(normalize_api_base "$saved_api_base")" fi } api_call_url() { local method="$1" local url="$2" local data="${3:-}" local auth_required="${4:-1}" local body_file local status local body local -a curl_args curl_args=(-sS -L -X "$method" -H "Accept: application/json") if [[ -n "$data" ]]; then curl_args+=(-H "Content-Type: application/json" --data "$data") fi if [[ "$auth_required" -eq 1 ]]; then load_token curl_args+=(-H "Authorization: ${TOKEN_TYPE^} $TOKEN") fi body_file="$(mktemp)" status="$(curl "${curl_args[@]}" -o "$body_file" -w "%{http_code}" "$url")" body="$(cat "$body_file")" rm -f "$body_file" if [[ "$status" -ge 400 ]]; then if [[ -n "$body" ]]; then echo "$body" >&2 fi die "Request failed: $method $url (HTTP $status)" fi printf '%s' "$body" } api_call() { local method="$1" local path="$2" local data="${3:-}" local auth_required="${4:-1}" api_call_url "$method" "$(build_url "$path")" "$data" "$auth_required" } api_call_form() { local method="$1" local path="$2" local auth_required="$3" shift 3 api_call_form_url "$method" "$(build_url "$path")" "$auth_required" "$@" } api_call_form_url() { local method="$1" local url="$2" local auth_required="$3" shift 3 local body_file local status local body local -a curl_args curl_args=(-sS -L -X "$method" -H "Accept: application/json") if [[ "$auth_required" -eq 1 ]]; then load_token curl_args+=(-H "Authorization: ${TOKEN_TYPE^} $TOKEN") fi if [[ $# -gt 0 ]]; then curl_args+=("$@") fi body_file="$(mktemp)" status="$(curl "${curl_args[@]}" -o "$body_file" -w "%{http_code}" "$url")" body="$(cat "$body_file")" rm -f "$body_file" if [[ "$status" -ge 400 ]]; then if [[ -n "$body" ]]; then echo "$body" >&2 fi die "Request failed: $method $url (HTTP $status)" fi printf '%s' "$body" } extract_tracks_tsv() { jq -r ' def item_list: if type == "array" then . elif type == "object" and (.files|type? == "array") then .files elif type == "object" and (.items|type? == "array") then .items else [] end; item_list | to_entries[] | . as $entry | if ($entry.value|type) == "string" then {idx: ($entry.key + 1), filename: $entry.value} elif ($entry.value|type) == "object" then { idx: ($entry.key + 1), filename: ( $entry.value.filename // $entry.value.name // $entry.value.title // $entry.value.file // $entry.value.path // empty ) } else empty end | select(.filename != null and .filename != "") | "\(.idx)\t\(.filename)" ' } cmd_login() { local url="" local username="" local password="" local payload local response local token local token_type local resolved_username while [[ $# -gt 0 ]]; do case "$1" in --url) url="${2:-}" [[ -n "$url" ]] || die "--url requires a value" shift 2 ;; *) if [[ -z "$username" ]]; then username="$1" elif [[ -z "$password" ]]; then password="$1" else die "Unexpected argument: $1" fi shift ;; esac done [[ -n "$username" && -n "$password" ]] || die "Usage: minemuzak login [--url ] " if [[ -n "$url" ]]; then API_BASE="$(normalize_api_base "$url")" fi payload="$(jq -n --arg username "$username" --arg password "$password" '{username:$username,password:$password}')" response="$(api_call POST /auth/token "$payload" 0)" token="$(printf '%s' "$response" | jq -r '.access_token // empty')" token_type="$(printf '%s' "$response" | jq -r '.token_type // "bearer"')" resolved_username="$(printf '%s' "$response" | jq -r '.user.username // empty')" if [[ -z "$resolved_username" ]]; then resolved_username="$username" fi [[ -n "$token" ]] || die "Login succeeded but no token was returned." save_token "$token" "$token_type" "$resolved_username" "$API_BASE" echo "Login successful. Token stored at $CONFIG_FILE" } cmd_register() { local payload="${1:-}" [[ -n "$payload" ]] || die "Usage: minemuzak register ''" pretty_print_json_or_raw "$(api_call POST /auth/register "$payload" 0)" } cmd_change_password() { local payload="${1:-}" [[ -n "$payload" ]] || die "Usage: minemuzak change-password ''" pretty_print_json_or_raw "$(api_call POST /auth/change-password "$payload")" } cmd_auth() { local action="${1:-}" local payload="${2:-}" case "$action" in verify-code) [[ -n "$payload" ]] || die "Usage: minemuzak auth verify-code ''" pretty_print_json_or_raw "$(api_call POST /auth/verify-registration-code "$payload" 0)" ;; resend-code) [[ -n "$payload" ]] || die "Usage: minemuzak auth resend-code ''" pretty_print_json_or_raw "$(api_call POST /auth/resend-registration-code "$payload" 0)" ;; forgot-password) [[ -n "$payload" ]] || die "Usage: minemuzak auth forgot-password ''" pretty_print_json_or_raw "$(api_call POST /auth/forgot-password "$payload" 0)" ;; reset-password) [[ -n "$payload" ]] || die "Usage: minemuzak auth reset-password ''" pretty_print_json_or_raw "$(api_call POST /auth/reset-password-with-code "$payload" 0)" ;; login-alt) local response local token local token_type local resolved_username [[ -n "$payload" ]] || die "Usage: minemuzak auth login-alt ''" response="$(api_call POST /login "$payload" 0)" token="$(printf '%s' "$response" | jq -r '.access_token // empty')" token_type="$(printf '%s' "$response" | jq -r '.token_type // "bearer"')" resolved_username="$(printf '%s' "$response" | jq -r '.user.username // empty')" if [[ -z "$resolved_username" ]]; then resolved_username="$(printf '%s' "$payload" | jq -r '.username // empty' 2>/dev/null || true)" fi [[ -n "$token" ]] || die "Login succeeded but no token was returned." save_token "$token" "$token_type" "$resolved_username" "$API_BASE" pretty_print_json_or_raw "$response" ;; *) die "Usage: minemuzak auth " ;; esac } cmd_health() { local url="" while [[ $# -gt 0 ]]; do case "$1" in --url) url="${2:-}" [[ -n "$url" ]] || die "--url requires a value" shift 2 ;; *) die "Usage: minemuzak health [--url ]" ;; esac done if [[ -n "$url" ]]; then API_BASE="$(normalize_api_base "$url")" fi pretty_print_json_or_raw "$(api_call GET /health "" 0)" } cmd_me() { local username local token username="" token="" if [[ -f "$CONFIG_FILE" ]]; then username="$(jq -r '.username // empty' "$CONFIG_FILE")" token="$(jq -r '.access_token // empty' "$CONFIG_FILE")" fi if [[ -z "$username" && -n "$token" ]]; then username="$(jwt_claim "$token" username)" fi if [[ -z "$username" && -n "$token" ]]; then username="$(jwt_claim "$token" preferred_username)" fi if [[ -n "$username" && "$username" =~ ^[0-9]+$ ]]; then username="" fi if [[ -n "$username" ]]; then pretty_print_json_or_raw "$(api_call GET "/users/me?username=$(urlencode "$username")")" else die "Unable to infer username for /users/me. Re-run 'minemuzak login ' once, then try again." fi } infer_current_username() { local username="" local token="" if [[ -f "$CONFIG_FILE" ]]; then username="$(jq -r '.username // empty' "$CONFIG_FILE")" token="$(jq -r '.access_token // empty' "$CONFIG_FILE")" fi if [[ -z "$username" && -n "$token" ]]; then username="$(jwt_claim "$token" username)" fi if [[ -z "$username" && -n "$token" ]]; then username="$(jwt_claim "$token" preferred_username)" fi if [[ -n "$username" && "$username" =~ ^[0-9]+$ ]]; then username="" fi printf '%s' "$username" } require_admin_command() { local username local me local is_superuser local admin_role username="$(infer_current_username)" [[ -n "$username" ]] || die "Unable to verify admin access. Re-run 'minemuzak login ' and try again." me="$(api_call GET "/users/me?username=$(urlencode "$username")")" is_superuser="$(printf '%s' "$me" | jq -r '.is_superuser // false')" admin_role="$(printf '%s' "$me" | jq -r '[.roles[]?.name // empty | ascii_downcase] | index("admin") | if . == null then "false" else "true" end')" if [[ "$is_superuser" != "true" && "$admin_role" != "true" ]]; then die "Admin privileges are required for this command." fi } path_requires_admin() { local path="$1" [[ "$path" == *"/admin/"* ]] } cmd_users() { local action="${1:-}" shift || true if [[ "$action" == admin-* ]]; then require_admin_command fi case "$action" in me) local username="${1:-}" if [[ -n "$username" ]]; then pretty_print_json_or_raw "$(api_call GET "/users/me?username=$(urlencode "$username")")" else cmd_me fi ;; update-profile) local payload="${1:-}" [[ -n "$payload" ]] || die "Usage: minemuzak users update-profile ''" pretty_print_json_or_raw "$(api_call PUT /users/me/profile "$payload")" ;; admin-users) pretty_print_json_or_raw "$(api_call GET /users/admin/users)" ;; admin-roles) pretty_print_json_or_raw "$(api_call GET /users/admin/roles)" ;; admin-permissions) pretty_print_json_or_raw "$(api_call GET /users/admin/permissions)" ;; admin-app-settings-get) pretty_print_json_or_raw "$(api_call GET /users/admin/app-settings)" ;; admin-app-settings-update) local payload="${1:-}" [[ -n "$payload" ]] || die "Usage: minemuzak users admin-app-settings-update ''" pretty_print_json_or_raw "$(api_call PUT /users/admin/app-settings "$payload")" ;; admin-app-settings-test-email) local payload="${1:-}" [[ -n "$payload" ]] || die "Usage: minemuzak users admin-app-settings-test-email ''" pretty_print_json_or_raw "$(api_call POST /users/admin/app-settings/test-email "$payload")" ;; admin-user-status) local user_id="${1:-}" local payload="${2:-}" [[ "$user_id" =~ ^[0-9]+$ && -n "$payload" ]] || die "Usage: minemuzak users admin-user-status ''" pretty_print_json_or_raw "$(api_call PUT "/users/admin/users/$user_id/status" "$payload")" ;; admin-user-roles) local user_id="${1:-}" local payload="${2:-}" [[ "$user_id" =~ ^[0-9]+$ && -n "$payload" ]] || die "Usage: minemuzak users admin-user-roles ''" pretty_print_json_or_raw "$(api_call PUT "/users/admin/users/$user_id/roles" "$payload")" ;; admin-user-quota) local user_id="${1:-}" local payload="${2:-}" [[ "$user_id" =~ ^[0-9]+$ && -n "$payload" ]] || die "Usage: minemuzak users admin-user-quota ''" pretty_print_json_or_raw "$(api_call PUT "/users/admin/users/$user_id/quota" "$payload")" ;; admin-user-profile) local user_id="${1:-}" local payload="${2:-}" [[ "$user_id" =~ ^[0-9]+$ && -n "$payload" ]] || die "Usage: minemuzak users admin-user-profile ''" pretty_print_json_or_raw "$(api_call PUT "/users/admin/users/$user_id/profile" "$payload")" ;; admin-role-permissions) local role_id="${1:-}" local payload="${2:-}" [[ "$role_id" =~ ^[0-9]+$ && -n "$payload" ]] || die "Usage: minemuzak users admin-role-permissions ''" pretty_print_json_or_raw "$(api_call PUT "/users/admin/roles/$role_id/permissions" "$payload")" ;; admin-rebuild-track-index) pretty_print_json_or_raw "$(api_call POST /users/admin/rebuild-track-index "{}")" ;; admin-user-rebuild-track-index) local user_id="${1:-}" [[ "$user_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak users admin-user-rebuild-track-index " pretty_print_json_or_raw "$(api_call POST "/users/admin/users/$user_id/rebuild-track-index" "{}")" ;; admin-fetch-missing-artwork) local payload="${1:-}" pretty_print_json_or_raw "$(api_call POST /users/admin/fetch-missing-artwork "$payload")" ;; admin-user-fetch-missing-artwork) local user_id="${1:-}" local payload="${2:-}" [[ "$user_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak users admin-user-fetch-missing-artwork [json]" pretty_print_json_or_raw "$(api_call POST "/users/admin/users/$user_id/fetch-missing-artwork" "$payload")" ;; admin-delete-user) local user_id="${1:-}" local delete_storage="${2:-true}" [[ "$user_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak users admin-delete-user [delete_storage=true|false]" if [[ "$delete_storage" != "true" && "$delete_storage" != "false" ]]; then die "delete_storage must be true or false" fi pretty_print_json_or_raw "$(api_call DELETE "/users/admin/users/$user_id?delete_storage=$delete_storage")" ;; *) die "Usage: minemuzak users [args]" ;; esac } cmd_list() { local response response="$(api_call GET /music/list)" printf '%s' "$response" | extract_tracks_tsv || true } cmd_upload_dir() { local media_kind="$1" local endpoint="$2" local root_dir="$3" [[ -n "$root_dir" && -d "$root_dir" ]] || die "Usage: minemuzak $media_kind upload-dir " local count_total=0 local count_ok=0 local count_fail=0 local file while IFS= read -r -d '' file; do count_total=$((count_total + 1)) echo "[$count_total] Uploading: $file" if api_call_form POST "$endpoint" 1 -F "file=@$file" >/dev/null; then count_ok=$((count_ok + 1)) else count_fail=$((count_fail + 1)) echo "Failed: $file" >&2 fi done < <(find "$root_dir" -type f -print0) echo "Upload summary ($media_kind): total=$count_total success=$count_ok failed=$count_fail" if [[ "$count_fail" -gt 0 ]]; then return 2 fi } cmd_music() { local action="${1:-}" shift || true case "$action" in upload) local file_path="${1:-}" [[ -n "$file_path" && -f "$file_path" ]] || die "Usage: minemuzak music upload " pretty_print_json_or_raw "$(api_call_form POST /music/upload 1 -F "file=@$file_path")" ;; upload-batch) [[ $# -gt 0 ]] || die "Usage: minemuzak music upload-batch " local -a form_args=() local f for f in "$@"; do [[ -f "$f" ]] || die "File not found: $f" form_args+=(-F "files=@$f") done pretty_print_json_or_raw "$(api_call_form POST /music/upload-batch 1 "${form_args[@]}")" ;; upload-dir) local root_dir="${1:-}" [[ $# -eq 1 ]] || die "Usage: minemuzak music upload-dir " cmd_upload_dir music /music/upload "$root_dir" ;; delete) local filename="${1:-}" [[ -n "$filename" ]] || die "Usage: minemuzak music delete " pretty_print_json_or_raw "$(api_call DELETE "/music/delete?filename=$(urlencode "$filename")")" ;; storage-info) pretty_print_json_or_raw "$(api_call GET /music/storage-info)" ;; favorite) local filename="${1:-}" local is_favorite="${2:-}" local payload [[ -n "$filename" && -n "$is_favorite" ]] || die "Usage: minemuzak music favorite " if [[ "$is_favorite" != "true" && "$is_favorite" != "false" ]]; then die "is_favorite must be true or false" fi payload="$(jq -n --arg filename "$filename" --argjson is_favorite "$is_favorite" '{filename:$filename,is_favorite:$is_favorite}')" pretty_print_json_or_raw "$(api_call PATCH /music/favorites "$payload")" ;; download-meta) local filename="${1:-}" [[ -n "$filename" ]] || die "Usage: minemuzak music download-meta " pretty_print_json_or_raw "$(api_call GET "/music/download?filename=$(urlencode "$filename")")" ;; download-stream) local filename="${1:-}" local output="${2:-}" local stream_url [[ -n "$filename" ]] || die "Usage: minemuzak music download-stream [output_file]" if [[ -z "$output" ]]; then output="${filename##*/}" fi load_token stream_url="$(build_url "/music/download-stream?filename=$(urlencode "$filename")")" curl -sS -L -H "Authorization: ${TOKEN_TYPE^} $TOKEN" -o "$output" "$stream_url" echo "Saved stream to $output" ;; recently-played) local sub="${1:-}" case "$sub" in get) pretty_print_json_or_raw "$(api_call GET /music/recently-played)" ;; add) local payload="${2:-}" [[ -n "$payload" ]] || die "Usage: minemuzak music recently-played add ''" pretty_print_json_or_raw "$(api_call POST /music/recently-played "$payload")" ;; *) die "Usage: minemuzak music recently-played [json]" ;; esac ;; get-artwork-sources) local filename="${1:-}" local source="${2:-auto}" [[ -n "$filename" ]] || die "Usage: minemuzak music get-artwork-sources [source=auto]" pretty_print_json_or_raw "$(api_call GET "/music/artwork/sources?filename=$(urlencode "$filename")&source=$(urlencode "$source")")" ;; download-artwork) local payload="${1:-}" [[ -n "$payload" ]] || die "Usage: minemuzak music download-artwork ''" pretty_print_json_or_raw "$(api_call POST /music/artwork/download "$payload")" ;; *) die "Usage: minemuzak music [args]" ;; esac } cmd_video() { local action="${1:-}" shift || true if [[ "$action" == admin-* ]]; then require_admin_command fi case "$action" in list) pretty_print_json_or_raw "$(api_call GET /video/list)" ;; upload) local file_path="${1:-}" [[ -n "$file_path" && -f "$file_path" ]] || die "Usage: minemuzak video upload " pretty_print_json_or_raw "$(api_call_form POST /video/upload 1 -F "file=@$file_path")" ;; upload-batch) [[ $# -gt 0 ]] || die "Usage: minemuzak video upload-batch " local -a form_args=() local f for f in "$@"; do [[ -f "$f" ]] || die "File not found: $f" form_args+=(-F "files=@$f") done pretty_print_json_or_raw "$(api_call_form POST /video/upload-batch 1 "${form_args[@]}")" ;; upload-dir) local root_dir="${1:-}" [[ $# -eq 1 ]] || die "Usage: minemuzak video upload-dir " cmd_upload_dir video /video/upload "$root_dir" ;; delete) local filename="${1:-}" [[ -n "$filename" ]] || die "Usage: minemuzak video delete " pretty_print_json_or_raw "$(api_call DELETE "/video/delete?filename=$(urlencode "$filename")")" ;; storage-info) pretty_print_json_or_raw "$(api_call GET /video/storage-info)" ;; stream) local filename="${1:-}" local output="${2:-}" local stream_url [[ -n "$filename" ]] || die "Usage: minemuzak video stream [output_file]" if [[ -z "$output" ]]; then output="${filename##*/}" fi load_token stream_url="$(build_url "/video/stream?filename=$(urlencode "$filename")")" curl -sS -L -H "Authorization: ${TOKEN_TYPE^} $TOKEN" -o "$output" "$stream_url" echo "Saved video stream to $output" ;; play) local filename="${1:-}" local player="" local stream_url shift || true while [[ $# -gt 0 ]]; do case "$1" in --player) player="${2:-}" [[ -n "$player" ]] || die "--player requires a value" shift 2 ;; *) die "Usage: minemuzak video play [--player ]" ;; esac done [[ -n "$filename" ]] || die "Usage: minemuzak video play [--player ]" load_token stream_url="$(build_url "/video/stream?filename=$(urlencode "$filename")&access_token=$(urlencode "$TOKEN")")" if [[ -z "$player" ]]; then if command -v mpv >/dev/null 2>&1; then player="mpv" elif command -v vlc >/dev/null 2>&1; then player="vlc" elif command -v ffplay >/dev/null 2>&1; then player="ffplay" elif command -v xdg-open >/dev/null 2>&1; then player="xdg-open" else die "No supported video player found. Install one of: mpv, vlc, ffplay (or xdg-open)." fi fi case "$player" in mpv) mpv "$stream_url" ;; vlc) vlc "$stream_url" ;; ffplay) ffplay -autoexit "$stream_url" ;; xdg-open) xdg-open "$stream_url" ;; *) die "Unsupported player '$player'. Supported: mpv, vlc, ffplay, xdg-open" ;; esac ;; poster) local filename="${1:-}" local output="${2:-}" local poster_url [[ -n "$filename" ]] || die "Usage: minemuzak video poster [output_file]" if [[ -z "$output" ]]; then output="${filename##*/}" output="${output%.*}.jpg" fi load_token poster_url="$(build_url "/video/poster?filename=$(urlencode "$filename")")" curl -sS -L -H "Authorization: ${TOKEN_TYPE^} $TOKEN" -o "$output" "$poster_url" echo "Saved poster to $output" ;; favorite) local filename="${1:-}" local is_favorite="${2:-}" local payload [[ -n "$filename" && -n "$is_favorite" ]] || die "Usage: minemuzak video favorite " if [[ "$is_favorite" != "true" && "$is_favorite" != "false" ]]; then die "is_favorite must be true or false" fi payload="$(jq -n --arg filename "$filename" --argjson is_favorite "$is_favorite" '{filename:$filename,is_favorite:$is_favorite}')" pretty_print_json_or_raw "$(api_call PATCH /video/favorites "$payload")" ;; title) local filename="${1:-}" local title="${2:-}" local payload [[ -n "$filename" && -n "$title" ]] || die "Usage: minemuzak video title " payload="$(jq -n --arg title "$title" '{title:$title}')" pretty_print_json_or_raw "$(api_call PATCH "/video/title?filename=$(urlencode "$filename")" "$payload")" ;; queue-get) pretty_print_json_or_raw "$(api_call GET /video/queue)" ;; queue-clear) pretty_print_json_or_raw "$(api_call DELETE /video/queue)" ;; queue-add) local filename="${1:-}" [[ -n "$filename" ]] || die "Usage: minemuzak video queue-add " pretty_print_json_or_raw "$(api_call POST /video/queue/items "$(jq -n --arg filename "$filename" '{filename:$filename}')")" ;; queue-remove) local item_id="${1:-}" [[ "$item_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak video queue-remove " pretty_print_json_or_raw "$(api_call DELETE "/video/queue/items/$item_id")" ;; queue-reorder) local payload="${1:-}" [[ -n "$payload" ]] || die "Usage: minemuzak video queue-reorder ''" pretty_print_json_or_raw "$(api_call PUT /video/queue/reorder "$payload")" ;; queue-save-playlist) local payload="${1:-}" [[ -n "$payload" ]] || die "Usage: minemuzak video queue-save-playlist ''" pretty_print_json_or_raw "$(api_call POST /video/queue/save-to-playlist "$payload")" ;; queue-state-get) pretty_print_json_or_raw "$(api_call GET /video/queue/state)" ;; queue-state-set) local payload="${1:-}" [[ -n "$payload" ]] || die "Usage: minemuzak video queue-state-set ''" pretty_print_json_or_raw "$(api_call PUT /video/queue/state "$payload")" ;; playlists-list) pretty_print_json_or_raw "$(api_call GET /video/playlists)" ;; playlists-create) local payload="${1:-}" [[ -n "$payload" ]] || die "Usage: minemuzak video playlists-create ''" pretty_print_json_or_raw "$(api_call POST /video/playlists "$payload")" ;; playlists-get) local playlist_id="${1:-}" [[ "$playlist_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak video playlists-get " pretty_print_json_or_raw "$(api_call GET "/video/playlists/$playlist_id")" ;; playlists-update) local playlist_id="${1:-}" local payload="${2:-}" [[ "$playlist_id" =~ ^[0-9]+$ && -n "$payload" ]] || die "Usage: minemuzak video playlists-update ''" pretty_print_json_or_raw "$(api_call PUT "/video/playlists/$playlist_id" "$payload")" ;; playlists-delete) local playlist_id="${1:-}" [[ "$playlist_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak video playlists-delete " pretty_print_json_or_raw "$(api_call DELETE "/video/playlists/$playlist_id")" ;; playlists-add-item) local playlist_id="${1:-}" local payload="${2:-}" [[ "$playlist_id" =~ ^[0-9]+$ && -n "$payload" ]] || die "Usage: minemuzak video playlists-add-item ''" pretty_print_json_or_raw "$(api_call POST "/video/playlists/$playlist_id/items" "$payload")" ;; playlists-remove-item) local playlist_id="${1:-}" local playlist_item_id="${2:-}" [[ "$playlist_id" =~ ^[0-9]+$ && "$playlist_item_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak video playlists-remove-item " pretty_print_json_or_raw "$(api_call DELETE "/video/playlists/$playlist_id/items/$playlist_item_id")" ;; playlists-reorder) local playlist_id="${1:-}" local payload="${2:-}" [[ "$playlist_id" =~ ^[0-9]+$ && -n "$payload" ]] || die "Usage: minemuzak video playlists-reorder ''" pretty_print_json_or_raw "$(api_call PUT "/video/playlists/$playlist_id/items/reorder" "$payload")" ;; playlists-load-queue) local playlist_id="${1:-}" [[ "$playlist_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak video playlists-load-queue " pretty_print_json_or_raw "$(api_call POST "/video/playlists/$playlist_id/load-to-queue" "{}")" ;; playlists-export) local playlist_id="${1:-}" local format="${2:-json}" [[ "$playlist_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak video playlists-export [json|m3u]" pretty_print_json_or_raw "$(api_call GET "/video/playlists/$playlist_id/export?format=$(urlencode "$format")")" ;; playlists-import) local file_path="${1:-}" [[ -n "$file_path" && -f "$file_path" ]] || die "Usage: minemuzak video playlists-import " pretty_print_json_or_raw "$(api_call_form POST /video/playlists/import 1 -F "file=@$file_path")" ;; admin-transcode-queue) local limit="${1:-}" if [[ -n "$limit" ]]; then [[ "$limit" =~ ^[0-9]+$ ]] || die "Usage: minemuzak video admin-transcode-queue [limit]" pretty_print_json_or_raw "$(api_call GET "/users/admin/video-transcode-queue?limit=$limit")" else pretty_print_json_or_raw "$(api_call GET /users/admin/video-transcode-queue)" fi ;; admin-hls-jobs) pretty_print_json_or_raw "$(api_call GET /video/admin/hls-jobs)" ;; admin-hls-storage) pretty_print_json_or_raw "$(api_call GET /video/admin/hls-storage)" ;; admin-hls-requeue) local payload="${1:-}" pretty_print_json_or_raw "$(api_call POST /video/admin/hls-requeue "$payload")" ;; admin-hls-purge) local payload="${1:-}" pretty_print_json_or_raw "$(api_call POST /video/admin/hls-purge "$payload")" ;; *) die "Usage: minemuzak video [args]" ;; esac } cmd_api() { local method="${1:-}" local path="${2:-}" local data="" local auth_required=1 local -a query_pairs=() local query local separator local url [[ -n "$method" && -n "$path" ]] || die "Usage: minemuzak api [--data ''] [--query key=value] [--no-auth]" shift 2 while [[ $# -gt 0 ]]; do case "$1" in --data) data="${2:-}" [[ -n "$data" ]] || die "--data requires a JSON value" shift 2 ;; --query) [[ -n "${2:-}" ]] || die "--query requires key=value" query_pairs+=("$2") shift 2 ;; --no-auth) auth_required=0 shift ;; *) die "Unknown argument for api: $1" ;; esac done if path_requires_admin "$path"; then [[ "$auth_required" -eq 1 ]] || die "Admin API routes require authentication. Remove --no-auth." require_admin_command fi url="$(build_url "$path")" if [[ ${#query_pairs[@]} -gt 0 ]]; then query="" separator="?" for pair in "${query_pairs[@]}"; do query+="$separator$(encode_query_pair "$pair")" separator="&" done url+="$query" fi pretty_print_json_or_raw "$(api_call_url "${method^^}" "$url" "$data" "$auth_required")" } choose_filename_from_index() { local index="$1" local response local selected [[ "$index" =~ ^[0-9]+$ ]] || die "--index must be a positive integer" response="$(api_call GET /music/list)" selected="$(printf '%s' "$response" | extract_tracks_tsv | awk -F '\t' -v i="$index" '$1==i{print $2; exit}')" [[ -n "$selected" ]] || die "No track found at index $index" printf '%s' "$selected" } queue_items_tsv() { jq -r ' .items // [] | to_entries[] | . as $e | "\($e.key + 1)\t\($e.value.id // "")\t\($e.value.name // $e.value.filename // "")" ' } stream_song() { local filename="$1" local encoded local metadata local download_url local stream_url local origin local status local sample load_token encoded="$(urlencode "$filename")" metadata="$(api_call GET "/music/download?filename=$encoded")" download_url="$(printf '%s' "$metadata" | jq -r '.download_url // empty')" [[ -n "$download_url" ]] || die "API did not return download_url for '$filename'" if [[ "$download_url" == *"filename="* ]]; then local prefix local suffix prefix="${download_url%%filename=*}" suffix="${download_url#*filename=}" if [[ "$suffix" == *"&"* ]]; then suffix="&${suffix#*&}" else suffix="" fi download_url="${prefix}filename=${encoded}${suffix}" fi if [[ "$download_url" =~ ^https?:// ]]; then stream_url="$download_url" elif [[ "$download_url" == /* ]]; then origin="$(api_origin)" stream_url="$origin$download_url" else stream_url="${API_BASE%/}/$download_url" fi sample="$(mktemp)" status="$(curl -sS -L -o "$sample" -w "%{http_code}" -H "Authorization: ${TOKEN_TYPE^} $TOKEN" "$stream_url")" if [[ "$status" -ge 400 ]]; then rm -f "$sample" if [[ "$status" -eq 404 ]]; then die "Stream endpoint not found (HTTP 404). API returned download_url '$download_url' for '$filename', but that URL is not available on this server." fi die "Download request failed for '$filename' (HTTP $status)" fi rm -f "$sample" echo "Streaming: $filename" if command -v ffplay >/dev/null 2>&1; then # Use ffmpeg as the decoder in the stream pipeline, then feed PCM to ffplay. curl -sS -L -H "Authorization: ${TOKEN_TYPE^} $TOKEN" "$stream_url" \ | ffmpeg -hide_banner -loglevel warning -i pipe:0 -f s16le -acodec pcm_s16le -ac 2 -ar 44100 pipe:1 \ | ffplay -nodisp -autoexit -loglevel warning -f s16le -ac 2 -ar 44100 pipe:0 else # Fallback for systems without ffplay. ffmpeg sends audio directly to ALSA. curl -sS -L -H "Authorization: ${TOKEN_TYPE^} $TOKEN" "$stream_url" \ | ffmpeg -hide_banner -loglevel warning -i pipe:0 -f alsa default fi } cmd_play() { if [[ "${1:-}" == "state" ]]; then local update_playing=0 local source_client_id="" local state_json local state_id local queue_json local filename local start_payload local stop_payload shift while [[ $# -gt 0 ]]; do case "$1" in --update-playing) update_playing=1 shift ;; --source-client-id) source_client_id="${2:-}" [[ -n "$source_client_id" ]] || die "--source-client-id requires a value" shift 2 ;; *) die "Usage: minemuzak play state [--update-playing] [--source-client-id ]" ;; esac done state_json="$(api_call GET /music/queue/state)" state_id="$(printf '%s' "$state_json" | jq -r '.state.current_queue_item_id // empty')" [[ -n "$state_id" ]] || die "No current_queue_item_id set in queue state." queue_json="$(api_call GET /music/queue)" filename="$(printf '%s' "$queue_json" | jq -r --argjson id "$state_id" '.items[] | select(.id == $id) | (.name // .filename) // empty' | head -n1)" [[ -n "$filename" ]] || die "Queue item ID $state_id from queue state was not found in current queue." if [[ "$update_playing" -eq 1 ]]; then start_payload="$(jq -n --argjson id "$state_id" --arg source "$source_client_id" '{current_queue_item_id:$id,is_playing:true,source_client_id:(if $source=="" then null else $source end)}')" pretty_print_json_or_raw "$(api_call PUT /music/queue/state "$start_payload")" >/dev/null fi stream_song "$filename" if [[ "$update_playing" -eq 1 ]]; then stop_payload="$(jq -n --argjson id "$state_id" --arg source "$source_client_id" '{current_queue_item_id:$id,is_playing:false,source_client_id:(if $source=="" then null else $source end)}')" pretty_print_json_or_raw "$(api_call PUT /music/queue/state "$stop_payload")" >/dev/null fi return fi if [[ "${1:-}" == "queue" ]]; then local queue_mode="all" shift if [[ "${1:-}" == "--next" ]]; then queue_mode="next" shift fi [[ $# -eq 0 ]] || die "Usage: minemuzak play queue [--next]" cmd_play_queue "$queue_mode" return fi local mode="" local value="" local -a filename_parts=() while [[ $# -gt 0 ]]; do case "$1" in --filename) [[ -z "$mode" ]] || die "Use either --filename or --index, not both" mode="filename" shift while [[ $# -gt 0 && "$1" != --* ]]; do filename_parts+=("$1") shift done [[ ${#filename_parts[@]} -gt 0 ]] || die "--filename requires a value" value="${filename_parts[*]}" ;; --index) [[ -z "$mode" ]] || die "Use either --filename or --index, not both" mode="index" value="${2:-}" [[ -n "$value" ]] || die "--index requires a value" shift 2 ;; *) if [[ "$mode" == "filename" ]]; then filename_parts+=("$1") value="${filename_parts[*]}" shift else die "Unknown argument for play: $1" fi ;; esac done [[ -n "$mode" && -n "$value" ]] || die "Usage: minemuzak play --filename | --index | queue [--next] | state [--update-playing] [--source-client-id ]" if [[ "$mode" == "filename" ]]; then stream_song "$value" else stream_song "$(choose_filename_from_index "$value")" fi } cmd_play_queue() { local mode="${1:-all}" local queue_json local -a names=() local name queue_json="$(api_call GET /music/queue)" mapfile -t names < <(printf '%s' "$queue_json" | jq -r '.items // [] | map(.name // .filename // empty) | .[]') [[ ${#names[@]} -gt 0 ]] || die "Queue is empty." if [[ "$mode" == "next" ]]; then stream_song "${names[0]}" return fi for name in "${names[@]}"; do stream_song "$name" done } cmd_interactive() { local action="${1:-}" local payload local csv local playlist_id local episode_id local feed_id local queue_json local -a queue_ids=() local -a queue_names=() local -a playlist_ids=() local -a playlist_names=() case "$action" in queue-reorder) csv="$(prompt_text "Queue item IDs in order (comma-separated): ")" payload="$(jq -n --argjson ids "$(csv_to_int_json_array "$csv")" '{item_ids:$ids}')" pretty_print_json_or_raw "$(api_call PUT /music/queue/reorder "$payload")" ;; queue-reorder-guided) queue_json="$(api_call GET /music/queue)" mapfile -t queue_ids < <(printf '%s' "$queue_json" | jq -r '.items // [] | .[] | .id') mapfile -t queue_names < <(printf '%s' "$queue_json" | jq -r '.items // [] | .[] | (.name // .filename // "")') [[ ${#queue_ids[@]} -gt 0 ]] || die "Queue is empty." show_numbered_items "Current queue order (choose new order by position numbers):" "${queue_names[@]}" csv="$(prompt_text "New order positions (example: 2,1,3): ")" payload="$(jq -n --argjson ids "$(positions_csv_to_ids_json_array "$csv" "${queue_ids[@]}")" '{item_ids:$ids}')" pretty_print_json_or_raw "$(api_call PUT /music/queue/reorder "$payload")" ;; queue-save-playlist) playlist_id="$(prompt_text "Existing playlist_id (leave blank to create by name): ")" if [[ -n "$playlist_id" ]]; then payload="$(jq -n --argjson pid "$playlist_id" '{playlist_id:$pid}')" else local playlist_name playlist_name="$(prompt_text "New playlist name: ")" [[ -n "$playlist_name" ]] || die "Playlist name is required when playlist_id is blank" payload="$(jq -n --arg name "$playlist_name" '{playlist_name:$name}')" fi pretty_print_json_or_raw "$(api_call POST /music/queue/save-to-playlist "$payload")" ;; queue-state) local shuffle_enabled local repeat_mode local current_queue_item_id local is_playing local position_seconds local source_client_id shuffle_enabled="$(prompt_text "Shuffle enabled (true/false): ")" repeat_mode="$(prompt_text "Repeat mode (off/one/all): ")" current_queue_item_id="$(prompt_text "Current queue item ID (blank to skip): ")" is_playing="$(prompt_text "Is playing (true/false, blank to skip): ")" position_seconds="$(prompt_text "Position seconds (number, blank to skip): ")" source_client_id="$(prompt_text "Source client ID (blank to skip): ")" payload="$(jq -n \ --argjson shuffle "$shuffle_enabled" \ --arg mode "$repeat_mode" \ --arg current "$current_queue_item_id" \ --arg playing "$is_playing" \ --arg pos "$position_seconds" \ --arg source "$source_client_id" ' { shuffle_enabled:$shuffle, repeat_mode:$mode, current_queue_item_id:(if $current == "" then null else ($current|tonumber) end), is_playing:(if $playing == "" then null else ($playing == "true") end), position_seconds:(if $pos == "" then null else ($pos|tonumber) end), source_client_id:(if $source == "" then null else $source end) } ')" pretty_print_json_or_raw "$(api_call PUT /music/queue/state "$payload")" ;; playlist-create) local playlist_name playlist_name="$(prompt_text "Playlist name: ")" [[ -n "$playlist_name" ]] || die "Playlist name is required" payload="$(jq -n --arg name "$playlist_name" '{name:$name}')" pretty_print_json_or_raw "$(api_call POST /music/playlists "$payload")" ;; playlist-add-item) playlist_id="$(prompt_text "Playlist ID: ")" [[ "$playlist_id" =~ ^[0-9]+$ ]] || die "Playlist ID must be an integer" local filename filename="$(prompt_text "Filename to add: ")" [[ -n "$filename" ]] || die "Filename is required" payload="$(jq -n --arg filename "$filename" '{filename:$filename}')" pretty_print_json_or_raw "$(api_call POST "/music/playlists/$playlist_id/items" "$payload")" ;; playlist-reorder) playlist_id="$(prompt_text "Playlist ID: ")" [[ "$playlist_id" =~ ^[0-9]+$ ]] || die "Playlist ID must be an integer" csv="$(prompt_text "Playlist item IDs in order (comma-separated): ")" payload="$(jq -n --argjson ids "$(csv_to_int_json_array "$csv")" '{item_ids:$ids}')" pretty_print_json_or_raw "$(api_call PUT "/music/playlists/$playlist_id/items/reorder" "$payload")" ;; playlist-reorder-guided) playlist_id="$(prompt_text "Playlist ID: ")" [[ "$playlist_id" =~ ^[0-9]+$ ]] || die "Playlist ID must be an integer" queue_json="$(api_call GET "/music/playlists/$playlist_id")" mapfile -t playlist_ids < <(printf '%s' "$queue_json" | jq -r '.items // [] | .[] | .id') mapfile -t playlist_names < <(printf '%s' "$queue_json" | jq -r '.items // [] | .[] | (.name // .title // .filename // "")') [[ ${#playlist_ids[@]} -gt 0 ]] || die "Playlist has no items." show_numbered_items "Current playlist order (choose new order by position numbers):" "${playlist_names[@]}" csv="$(prompt_text "New order positions (example: 2,1,3): ")" payload="$(jq -n --argjson ids "$(positions_csv_to_ids_json_array "$csv" "${playlist_ids[@]}")" '{item_ids:$ids}')" pretty_print_json_or_raw "$(api_call PUT "/music/playlists/$playlist_id/items/reorder" "$payload")" ;; shuffle-next) local current_id current_id="$(prompt_text "Current queue item ID: ")" [[ "$current_id" =~ ^[0-9]+$ ]] || die "Current queue item ID must be an integer" csv="$(prompt_text "All queue item IDs in current order (comma-separated): ")" payload="$(jq -n --argjson current "$current_id" --argjson ids "$(csv_to_int_json_array "$csv")" '{current_queue_item_id:$current, queue_item_ids:$ids}')" pretty_print_json_or_raw "$(api_call POST /shuffle/next "$payload")" ;; podcast-add-feed) local rss_url rss_url="$(prompt_text "Podcast RSS URL: ")" [[ -n "$rss_url" ]] || die "RSS URL is required" payload="$(jq -n --arg url "$rss_url" '{rss_url:$url}')" pretty_print_json_or_raw "$(api_call POST /podcasts/feeds "$payload")" ;; *) die "Usage: minemuzak interactive " ;; esac } cmd_queue() { local action="${1:-}" shift || true case "$action" in get) pretty_print_json_or_raw "$(api_call GET /music/queue)" ;; clear) pretty_print_json_or_raw "$(api_call DELETE /music/queue)" ;; add) local filename="${1:-}" [[ -n "$filename" ]] || die "Usage: minemuzak queue add " pretty_print_json_or_raw "$(api_call POST /music/queue/items "$(jq -n --arg filename "$filename" '{filename:$filename}')")" ;; add-all) pretty_print_json_or_raw "$(api_call POST /music/queue/items/add-all "{}")" ;; remove) local item_id="${1:-}" [[ "$item_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak queue remove " pretty_print_json_or_raw "$(api_call DELETE "/music/queue/items/$item_id")" ;; reorder) local payload="${1:-}" [[ -n "$payload" ]] || die "Usage: minemuzak queue reorder ''" pretty_print_json_or_raw "$(api_call PUT /music/queue/reorder "$payload")" ;; save-playlist) local payload="${1:-}" [[ -n "$payload" ]] || die "Usage: minemuzak queue save-playlist ''" pretty_print_json_or_raw "$(api_call POST /music/queue/save-to-playlist "$payload")" ;; state) if [[ $# -eq 0 || "${1:-}" == "get" ]]; then pretty_print_json_or_raw "$(api_call GET /music/queue/state)" elif [[ "${1:-}" == "set" ]]; then local payload="${2:-}" [[ -n "$payload" ]] || die "Usage: minemuzak queue state set ''" pretty_print_json_or_raw "$(api_call PUT /music/queue/state "$payload")" else local payload="${1:-}" [[ -n "$payload" ]] || die "Usage: minemuzak queue state [get|set ''|'']" pretty_print_json_or_raw "$(api_call PUT /music/queue/state "$payload")" fi ;; *) die "Usage: minemuzak queue [args]" ;; esac } cmd_playlists() { local action="${1:-}" shift || true case "$action" in list) pretty_print_json_or_raw "$(api_call GET /music/playlists)" ;; create) local payload="${1:-}" [[ -n "$payload" ]] || die "Usage: minemuzak playlists create ''" pretty_print_json_or_raw "$(api_call POST /music/playlists "$payload")" ;; get) local playlist_id="${1:-}" [[ "$playlist_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak playlists get " pretty_print_json_or_raw "$(api_call GET "/music/playlists/$playlist_id")" ;; update) local playlist_id="${1:-}" local payload="${2:-}" [[ "$playlist_id" =~ ^[0-9]+$ && -n "$payload" ]] || die "Usage: minemuzak playlists update ''" pretty_print_json_or_raw "$(api_call PUT "/music/playlists/$playlist_id" "$payload")" ;; delete) local playlist_id="${1:-}" [[ "$playlist_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak playlists delete " pretty_print_json_or_raw "$(api_call DELETE "/music/playlists/$playlist_id")" ;; add-item) local playlist_id="${1:-}" local payload="${2:-}" [[ "$playlist_id" =~ ^[0-9]+$ && -n "$payload" ]] || die "Usage: minemuzak playlists add-item ''" pretty_print_json_or_raw "$(api_call POST "/music/playlists/$playlist_id/items" "$payload")" ;; remove-item) local playlist_id="${1:-}" local playlist_item_id="${2:-}" [[ "$playlist_id" =~ ^[0-9]+$ && "$playlist_item_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak playlists remove-item " pretty_print_json_or_raw "$(api_call DELETE "/music/playlists/$playlist_id/items/$playlist_item_id")" ;; reorder) local playlist_id="${1:-}" local payload="${2:-}" [[ "$playlist_id" =~ ^[0-9]+$ && -n "$payload" ]] || die "Usage: minemuzak playlists reorder ''" pretty_print_json_or_raw "$(api_call PUT "/music/playlists/$playlist_id/items/reorder" "$payload")" ;; load-queue) local playlist_id="${1:-}" [[ "$playlist_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak playlists load-queue " pretty_print_json_or_raw "$(api_call POST "/music/playlists/$playlist_id/queue/load" "{}")" ;; export) local playlist_id="${1:-}" [[ "$playlist_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak playlists export " pretty_print_json_or_raw "$(api_call GET "/music/playlists/$playlist_id/export")" ;; import) local file_path="${1:-}" [[ -n "$file_path" && -f "$file_path" ]] || die "Usage: minemuzak playlists import " pretty_print_json_or_raw "$(api_call_form POST /music/playlists/import 1 -F "file=@$file_path")" ;; *) die "Usage: minemuzak playlists [args]" ;; esac } cmd_shuffle() { local action="${1:-}" shift || true case "$action" in next) local payload="${1:-}" [[ -n "$payload" ]] || die "Usage: minemuzak shuffle next ''" pretty_print_json_or_raw "$(api_call POST /shuffle/next "$payload")" ;; previous) pretty_print_json_or_raw "$(api_call POST /shuffle/previous "{}")" ;; clear) pretty_print_json_or_raw "$(api_call POST /shuffle/clear "{}")" ;; *) die "Usage: minemuzak shuffle [json]" ;; esac } cmd_podcasts() { local action="${1:-}" shift || true case "$action" in catalog) local query="${1:-}" local limit="${2:-}" local source="${3:-}" local path="/podcasts/catalog" local sep="?" if [[ -n "$query" ]]; then path+="${sep}query=$(urlencode "$query")" sep="&" fi if [[ -n "$limit" ]]; then [[ "$limit" =~ ^[0-9]+$ ]] || die "Usage: minemuzak podcasts catalog [query] [limit] [source]" path+="${sep}limit=$limit" sep="&" fi if [[ -n "$source" ]]; then path+="${sep}source=$(urlencode "$source")" fi pretty_print_json_or_raw "$(api_call GET "$path" "" 0)" ;; feeds) pretty_print_json_or_raw "$(api_call GET /podcasts/feeds)" ;; add-feed) local payload="${1:-}" [[ -n "$payload" ]] || die "Usage: minemuzak podcasts add-feed ''" pretty_print_json_or_raw "$(api_call POST /podcasts/feeds "$payload")" ;; refresh-feed) local feed_id="${1:-}" [[ "$feed_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak podcasts refresh-feed " pretty_print_json_or_raw "$(api_call POST "/podcasts/feeds/$feed_id/refresh" "{}")" ;; delete-feed) local feed_id="${1:-}" [[ "$feed_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak podcasts delete-feed " pretty_print_json_or_raw "$(api_call DELETE "/podcasts/feeds/$feed_id")" ;; episodes) local feed_id="${1:-}" local limit="${2:-}" [[ "$feed_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak podcasts episodes [limit]" if [[ -n "$limit" ]]; then [[ "$limit" =~ ^[0-9]+$ ]] || die "Usage: minemuzak podcasts episodes [limit]" pretty_print_json_or_raw "$(api_call GET "/podcasts/feeds/$feed_id/episodes?limit=$limit")" else pretty_print_json_or_raw "$(api_call GET "/podcasts/feeds/$feed_id/episodes")" fi ;; downloaded) pretty_print_json_or_raw "$(api_call GET /podcasts/downloaded)" ;; download) local episode_id="${1:-}" [[ "$episode_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak podcasts download " pretty_print_json_or_raw "$(api_call POST "/podcasts/episodes/$episode_id/download" "{}")" ;; remove-download) local episode_id="${1:-}" [[ "$episode_id" =~ ^[0-9]+$ ]] || die "Usage: minemuzak podcasts remove-download " pretty_print_json_or_raw "$(api_call DELETE "/podcasts/episodes/$episode_id/download")" ;; *) die "Usage: minemuzak podcasts [args]" ;; esac } cmd_example() { cat <<'EOF' MineMuzak CLI - Complete Command Cheat Sheet # Help and global options ./minemuzak help ./minemuzak --url https://mymuzak.minecrunch.net health # Auth and account ./minemuzak login tstreit 'your-password' ./minemuzak login --url https://mymuzak.minecrunch.net tstreit 'your-password' ./minemuzak register '{"username":"demo","email":"demo@example.com","password":"secret123"}' ./minemuzak change-password '{"old_password":"old","new_password":"newsecret123","confirm_password":"newsecret123"}' ./minemuzak auth verify-code '{"username_or_email":"demo","code":"123456"}' ./minemuzak auth resend-code '{"username_or_email":"demo"}' ./minemuzak auth forgot-password '{"username_or_email":"demo"}' ./minemuzak auth reset-password '{"username_or_email":"demo","code":"123456","new_password":"secret123","confirm_password":"secret123"}' ./minemuzak auth login-alt '{"username":"demo","password":"secret123"}' ./minemuzak me ./minemuzak health ./minemuzak health --url https://mymuzak.minecrunch.net # Generic API runner ./minemuzak api GET /health --no-auth ./minemuzak api GET /music/list ./minemuzak api GET /music/download --query 'filename=A-Ha/01 - Take on Me.mp3' ./minemuzak api POST /music/queue/items --data '{"filename":"A-Ha/01 - Take on Me.mp3"}' # Music library and files ./minemuzak list ./minemuzak music upload ./song.mp3 ./minemuzak music upload-batch ./A.mp3 ./B.mp3 ./minemuzak music upload-dir ./Music ./minemuzak music delete 'A-Ha/01 - Take on Me.mp3' ./minemuzak music storage-info ./minemuzak music favorite 'A-Ha/01 - Take on Me.mp3' true ./minemuzak music download-meta 'A-Ha/01 - Take on Me.mp3' ./minemuzak music download-stream 'A-Ha/01 - Take on Me.mp3' ./take-on-me.mp3 ./minemuzak music recently-played get ./minemuzak music recently-played add '{"filename":"A-Ha/01 - Take on Me.mp3"}' ./minemuzak music get-artwork-sources 'A-Ha/01 - Take on Me.mp3' auto ./minemuzak music download-artwork '{"filename":"A-Ha/01 - Take on Me.mp3","source":"auto"}' # Video library and files ./minemuzak video list ./minemuzak video upload ./movie.mp4 ./minemuzak video upload-batch ./movie1.mp4 ./movie2.mkv ./minemuzak video upload-dir ./Videos ./minemuzak video delete 'Movies/Inception (2010).mp4' ./minemuzak video storage-info ./minemuzak video stream 'Movies/Inception (2010).mp4' ./inception.mp4 ./minemuzak video play 'Movies/Inception (2010).mp4' ./minemuzak video play 'Movies/Inception (2010).mp4' --player mpv ./minemuzak video poster 'Movies/Inception (2010).mp4' ./inception.jpg ./minemuzak video favorite 'Movies/Inception (2010).mp4' true ./minemuzak video title 'Movies/Inception (2010).mp4' 'Inception (2010)' ./minemuzak video queue-get ./minemuzak video queue-add 'Movies/Inception (2010).mp4' ./minemuzak video queue-state-get ./minemuzak video queue-state-set '{"current_queue_item_id":10,"is_playing":true,"position_seconds":123.4,"source_client_id":"desktop-cli"}' ./minemuzak video playlists-list ./minemuzak video playlists-create '{"name":"Movie Night"}' ./minemuzak video playlists-get 1 ./minemuzak video playlists-add-item 1 '{"filename":"Movies/Inception (2010).mp4"}' ./minemuzak video playlists-load-queue 1 ./minemuzak video playlists-export 1 json ./minemuzak video playlists-import ./video-playlist.json ./minemuzak video admin-transcode-queue 50 ./minemuzak video admin-hls-jobs ./minemuzak video admin-hls-storage ./minemuzak video admin-hls-requeue ./minemuzak video admin-hls-purge # Playback ./minemuzak play --filename 'A-Ha/01 - Take on Me.mp3' ./minemuzak play --index 1 ./minemuzak play queue ./minemuzak play queue --next ./minemuzak play state ./minemuzak play state --update-playing --source-client-id desktop-cli # Queue ./minemuzak queue get ./minemuzak queue add 'A-Ha/01 - Take on Me.mp3' ./minemuzak queue add-all ./minemuzak queue remove 63 ./minemuzak queue clear ./minemuzak queue reorder '{"item_ids":[63,42,10]}' ./minemuzak queue save-playlist '{"playlist_name":"Favorites"}' ./minemuzak queue state get ./minemuzak queue state set '{"shuffle_enabled":true,"repeat_mode":"off","current_queue_item_id":63,"is_playing":true,"position_seconds":42.5,"source_client_id":"desktop-cli"}' # Playlists ./minemuzak playlists list ./minemuzak playlists create '{"name":"Road Trip"}' ./minemuzak playlists get 1 ./minemuzak playlists update 1 '{"name":"Road Trip Updated"}' ./minemuzak playlists delete 1 ./minemuzak playlists add-item 1 '{"filename":"A-Ha/01 - Take on Me.mp3"}' ./minemuzak playlists remove-item 1 22 ./minemuzak playlists reorder 1 '{"item_ids":[22,21,20]}' ./minemuzak playlists load-queue 1 ./minemuzak playlists export 1 ./minemuzak playlists import ./playlist-export.json # Shuffle ./minemuzak shuffle next '{"current_queue_item_id":63,"queue_item_ids":[63,64,65]}' ./minemuzak shuffle previous ./minemuzak shuffle clear # Podcasts ./minemuzak podcasts catalog ./minemuzak podcasts catalog 'lofi' 25 db ./minemuzak podcasts feeds ./minemuzak podcasts add-feed '{"rss_url":"https://example.com/feed.xml"}' ./minemuzak podcasts refresh-feed 1 ./minemuzak podcasts delete-feed 1 ./minemuzak podcasts episodes 1 ./minemuzak podcasts episodes 1 100 ./minemuzak podcasts downloaded ./minemuzak podcasts download 100 ./minemuzak podcasts remove-download 100 # Users ./minemuzak users me ./minemuzak users me tstreit ./minemuzak users update-profile '{"nickname":"DJ Demo","theme_preference":"dark"}' ./minemuzak users admin-users ./minemuzak users admin-roles ./minemuzak users admin-permissions ./minemuzak users admin-app-settings-get ./minemuzak users admin-app-settings-update '{"app_title":"MineMuzak"}' ./minemuzak users admin-app-settings-test-email '{"recipient_email":"admin@example.com"}' ./minemuzak users admin-user-status 42 '{"is_active":true,"is_superuser":false}' ./minemuzak users admin-user-roles 42 '{"role_ids":[1,2]}' ./minemuzak users admin-user-quota 42 '{"storage_quota_gb":25}' ./minemuzak users admin-user-profile 42 '{"nickname":"PowerUser"}' ./minemuzak users admin-role-permissions 1 '{"permission_ids":[1,2,3]}' ./minemuzak users admin-rebuild-track-index ./minemuzak users admin-user-rebuild-track-index 42 ./minemuzak users admin-fetch-missing-artwork '{"source":"auto","include_existing":false}' ./minemuzak users admin-user-fetch-missing-artwork 42 '{"source":"lastfm"}' ./minemuzak users admin-delete-user 42 false # Interactive helpers ./minemuzak interactive queue-reorder ./minemuzak interactive queue-reorder-guided ./minemuzak interactive queue-save-playlist ./minemuzak interactive queue-state ./minemuzak interactive playlist-create ./minemuzak interactive playlist-add-item ./minemuzak interactive playlist-reorder ./minemuzak interactive playlist-reorder-guided ./minemuzak interactive shuffle-next ./minemuzak interactive podcast-add-feed EOF } main() { local explicit_api_base="" local cmd API_BASE="$(normalize_api_base "$API_BASE")" while [[ $# -gt 0 ]]; do case "$1" in --url) explicit_api_base="${2:-}" [[ -n "$explicit_api_base" ]] || die "--url requires a value" explicit_api_base="$(normalize_api_base "$explicit_api_base")" shift 2 ;; --) shift break ;; -h|--help|help) usage return ;; *) break ;; esac done if [[ -n "$explicit_api_base" ]]; then API_BASE="$explicit_api_base" elif [[ -z "${MINEMUZAK_API_BASE:-}" ]]; then load_saved_api_base_if_present fi cmd="${1:-help}" shift || true case "$cmd" in login) ensure_base_deps cmd_login "$@" ;; register) ensure_base_deps cmd_register "$@" ;; change-password) ensure_base_deps cmd_change_password "$@" ;; auth) ensure_base_deps cmd_auth "$@" ;; health) ensure_base_deps cmd_health "$@" ;; me) ensure_base_deps cmd_me ;; users) ensure_base_deps cmd_users "$@" ;; list) ensure_base_deps cmd_list ;; music) ensure_base_deps cmd_music "$@" ;; video) ensure_base_deps cmd_video "$@" ;; play) ensure_playback_deps cmd_play "$@" ;; interactive) ensure_base_deps cmd_interactive "$@" ;; queue) ensure_base_deps cmd_queue "$@" ;; playlists) ensure_base_deps cmd_playlists "$@" ;; shuffle) ensure_base_deps cmd_shuffle "$@" ;; podcasts) ensure_base_deps cmd_podcasts "$@" ;; api) ensure_base_deps cmd_api "$@" ;; example) cmd_example ;; help|-h|--help) usage ;; *) die "Unknown command: $cmd" ;; esac } main "$@"