diff --git a/completions/_psysonic b/completions/_psysonic index ff680d96..e8391b37 100644 --- a/completions/_psysonic +++ b/completions/_psysonic @@ -39,6 +39,25 @@ _psysonic_library_folder_ids() { (( $#ids )) && compadd -a ids } +_psysonic_snapshot_json() { + local f + if [[ -n $XDG_RUNTIME_DIR ]]; then + f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json" + [[ -r $f ]] && { print -r -- "$f"; return } + fi + f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json" + [[ -r $f ]] && print -r -- "$f" +} + +_psysonic_server_ids() { + command -v jq &>/dev/null || return + local jf + jf="$(_psysonic_snapshot_json)" || return + local -a ids + ids=( ${(@f)"$(jq -r '.servers[]? | 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 @@ -68,7 +87,8 @@ fi integer n=${#sub[@]} if (( n == 0 )); then compadd -J verbs -X 'player command' -- \ - next prev play pause seek volume audio-device library mix + next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload \ + audio-device library server search mix return fi @@ -92,10 +112,33 @@ case ${sub[1]} in mix) (( n == 1 )) && compadd append new ;; + server) + if (( n == 1 )); then + compadd list set + elif [[ ${sub[2]} == set ]] && (( n == 2 )); then + _psysonic_server_ids + fi + ;; + search) + if (( n == 1 )); then + compadd track album artist + elif (( n >= 2 )); then + _message -e descriptions 'search query' + fi + ;; + repeat) + (( n == 1 )) && compadd off all one + ;; + rating) + (( n == 1 )) && compadd 0 1 2 3 4 5 + ;; seek) (( n == 1 )) && _message -e descriptions 'integer delta (seconds, e.g. -5)' ;; volume) (( n == 1 )) && _message -e descriptions 'percent 0–100' ;; + play) + (( n == 1 )) && _message -e descriptions 'Subsonic id (song, album, or artist)' + ;; esac diff --git a/completions/psysonic.bash b/completions/psysonic.bash index 44f4e507..67fc0296 100644 --- a/completions/psysonic.bash +++ b/completions/psysonic.bash @@ -3,6 +3,14 @@ # Optional: jq + prior `psysonic --player audio-device list` for device name completion. # # Uses no `mapfile` so bash 3.2 (macOS default) works. +# +# compopt is bash-only (programmable completion). Guard so sourcing this file +# under zsh or plain sh does not print "command not found: compopt". + +_psysonic_compopt() { + command -v compopt &>/dev/null || return 0 + compopt "$@" 2>/dev/null || return 0 +} _psysonic_compreply_from_compgen() { # $1 = compgen -W word list, $2 = current word @@ -33,6 +41,16 @@ _psysonic_library_json() { [[ -r $f ]] && printf '%s' "$f" } +_psysonic_snapshot_json() { + local f + if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then + f="$XDG_RUNTIME_DIR/psysonic-cli-snapshot.json" + [[ -r $f ]] && { printf '%s' "$f"; return; } + fi + f="${TMPDIR:-/tmp}/psysonic-cli-snapshot.json" + [[ -r $f ]] && printf '%s' "$f" +} + _psysonic_complete() { local cur cur="${COMP_WORDS[COMP_CWORD]}" @@ -58,7 +76,7 @@ _psysonic_complete() { local n=${#sub[@]} if (( n == 0 )); then - _psysonic_compreply_from_compgen 'next prev play pause seek volume audio-device library mix' "$cur" + _psysonic_compreply_from_compgen 'next prev play pause stop seek volume shuffle repeat mute unmute star unstar rating reload audio-device library server search mix' "$cur" return fi @@ -78,7 +96,7 @@ _psysonic_complete() { while IFS= read -r line; do [[ -n $line ]] && COMPREPLY+=("$line") done < <(compgen -W 'default' -- "$cur") - ((${#COMPREPLY[@]})) && compopt -o filenames 2>/dev/null + ((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames fi ;; library) @@ -96,14 +114,52 @@ _psysonic_complete() { while IFS= read -r line; do [[ -n $line ]] && COMPREPLY+=("$line") done < <(compgen -W 'all' -- "$cur") - ((${#COMPREPLY[@]})) && compopt -o filenames 2>/dev/null + ((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames fi ;; mix) (( n == 1 )) && _psysonic_compreply_from_compgen 'append new' "$cur" ;; + server) + if (( n == 1 )); then + _psysonic_compreply_from_compgen 'list set' "$cur" + elif [[ ${sub[1]} == set ]] && (( n == 2 )); then + COMPREPLY=() + local jf sid + jf="$(_psysonic_snapshot_json)" + if [[ -n $jf ]] && command -v jq &>/dev/null; then + while IFS= read -r sid; do + [[ -n $sid && $sid == "$cur"* ]] && COMPREPLY+=("$sid") + done < <(jq -r '.servers[]? | select(.id != null) | .id | tostring' "$jf" 2>/dev/null) + fi + ((${#COMPREPLY[@]})) && _psysonic_compopt -o filenames + fi + ;; + search) + if (( n == 1 )); then + _psysonic_compreply_from_compgen 'track album artist' "$cur" + elif (( n >= 2 )); then + _psysonic_compopt -o default + COMPREPLY=() + fi + ;; + repeat) + (( n == 1 )) && _psysonic_compreply_from_compgen 'off all one' "$cur" + ;; + rating) + (( n == 1 )) && _psysonic_compreply_from_compgen '0 1 2 3 4 5' "$cur" + ;; seek|volume) - (( n == 1 )) && compopt -o default && COMPREPLY=() + if (( n == 1 )); then + _psysonic_compopt -o default + COMPREPLY=() + fi + ;; + play) + if (( n == 1 )); then + _psysonic_compopt -o default + COMPREPLY=() + fi ;; esac } diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 794e86fe..9c9d0877 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1473,7 +1473,7 @@ pub struct AudioEngine { pub eq_gains: Arc<[AtomicU32; 10]>, pub eq_enabled: Arc, pub eq_pre_gain: Arc, - pub preloaded: Arc>>, + pub(crate) preloaded: Arc>>, pub crossfade_enabled: Arc, pub crossfade_secs: Arc, pub fading_out_sink: Arc>>, @@ -1482,7 +1482,7 @@ pub struct AudioEngine { pub gapless_enabled: Arc, /// Info about the next-up chained track (gapless mode). /// The progress task reads this when `current_source_done` fires. - pub chained_info: Arc>>, + pub(crate) chained_info: Arc>>, /// Atomic sample counter — incremented by CountingSource in the audio thread. /// Progress task reads this for drift-free position tracking. pub samples_played: Arc, @@ -1495,7 +1495,7 @@ pub struct AudioEngine { pub gapless_switch_at: Arc, /// Active radio session state. None for regular (non-radio) tracks. /// Dropping the value aborts the HTTP download task via RadioLiveState::Drop. - pub radio_state: Mutex>, + pub(crate) radio_state: Mutex>, } pub struct AudioCurrent { diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index 7b8d9543..84d226db 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -12,13 +12,37 @@ use serde_json::Value; use tauri::{AppHandle, Emitter, Runtime}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RepeatCliMode { + Off, + All, + One, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SearchCliScope { + Track, + Album, + Artist, +} + +#[derive(Clone, Debug, PartialEq, Eq)] pub enum PlayerCliCmd { Next, Prev, Play, + PlayOpaqueId(String), Pause, + Stop, Seek { delta_secs: i32 }, Volume { percent: u8 }, + ShuffleQueue, + Repeat(RepeatCliMode), + Mute, + Unmute, + StarCurrent, + UnstarCurrent, + Rating { stars: u8 }, + ReloadPlayer, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -37,6 +61,12 @@ pub enum CliCommand { /// `"all"` or a music-folder id from `library list`. LibrarySet(String), Mix(MixCliMode), + ServerList, + ServerSet(String), + Search { + scope: SearchCliScope, + query: String, + }, } pub fn wants_version(args: &[String]) -> bool { @@ -63,7 +93,7 @@ pub fn wants_quiet(args: &[String]) -> bool { .any(|a| a == "--quiet" || a == "-q") } -/// Machine-readable output for `--json` with `audio-device list` or `library list`. +/// Machine-readable output for `--json` with list/search commands (`audio-device`, `library`, `server`, `search`). pub fn wants_cli_json_output(args: &[String]) -> bool { args.iter().skip(1).any(|a| a == "--json") } @@ -100,6 +130,24 @@ pub fn cli_library_response_path() -> PathBuf { std::env::temp_dir().join("psysonic-cli-library.json") } +pub fn cli_server_list_path() -> PathBuf { + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + if !dir.is_empty() { + return PathBuf::from(dir).join("psysonic-cli-servers.json"); + } + } + std::env::temp_dir().join("psysonic-cli-servers.json") +} + +pub fn cli_search_response_path() -> PathBuf { + if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { + if !dir.is_empty() { + return PathBuf::from(dir).join("psysonic-cli-search.json"); + } + } + std::env::temp_dir().join("psysonic-cli-search.json") +} + /// `psysonic completions …` — returns exit code when this argv should not start the GUI. pub fn try_completions_dispatch(args: &[String]) -> Option { if args.get(1).map(|s| s.as_str()) != Some("completions") { @@ -186,18 +234,32 @@ pub fn print_help(program: &str) { eprintln!(" Windows / macOS: handled via single-instance (a helper process may run briefly).\n"); eprintln!(" Global flags (place before --player when needed):"); eprintln!(" --quiet | -q Suppress \"OK: …\" lines (stderr errors are always shown)."); - eprintln!(" --json With `audio-device list` or `library list`: JSON on stdout."); + eprintln!(" --json With `audio-device list`, `library list`, `server list`, or `search`: JSON on stdout."); eprintln!(" Use {program} -q --player seek -5 so the seek delta is not parsed as a flag.\n"); eprintln!(" Playback"); - eprintln!(" {program} [--quiet|-q] --player next | prev | play | pause"); + eprintln!(" {program} [--quiet|-q] --player next | prev | play | pause | stop"); + eprintln!(" {program} [--quiet|-q] --player play Track, album, or artist id (artist → shuffled library)."); eprintln!(" {program} [--quiet|-q] --player seek Integer delta, e.g. 15 or -10"); - eprintln!(" {program} [--quiet|-q] --player volume <0-100> Absolute volume percent.\n"); + eprintln!(" {program} [--quiet|-q] --player volume <0-100> Absolute volume percent."); + eprintln!(" {program} [--quiet|-q] --player shuffle Shuffle the current queue."); + eprintln!(" {program} [--quiet|-q] --player repeat off|all|one"); + eprintln!(" {program} [--quiet|-q] --player mute | unmute"); + eprintln!(" {program} [--quiet|-q] --player star | unstar Current track (Subsonic star)."); + eprintln!(" {program} [--quiet|-q] --player rating <0-5> Set song rating (0 clears)."); + eprintln!(" {program} [--quiet|-q] --player reload Restart audio for the current track or reload server queue.\n"); eprintln!(" Audio output"); eprintln!(" {program} [--json] --player audio-device list"); eprintln!(" {program} --player audio-device set \n"); eprintln!(" Music library (Subsonic music folders for the active server)"); eprintln!(" {program} [--json] --player library list"); eprintln!(" {program} --player library set all | \n"); + eprintln!(" Servers (saved profiles — same as the in-app server switcher)"); + eprintln!(" {program} [--json] --player server list"); + eprintln!(" {program} --player server set \n"); + eprintln!(" Search (active server; respects library folder filter)"); + eprintln!(" {program} [--json] --player search track "); + eprintln!(" {program} [--json] --player search album "); + eprintln!(" {program} [--json] --player search artist \n"); eprintln!(" Instant mix (from the track that is currently loaded)"); eprintln!(" {program} --player mix append"); eprintln!(" {program} --player mix new\n"); @@ -275,6 +337,186 @@ pub fn write_library_cli_response(payload: &Value) -> Result<(), String> { Ok(()) } +pub fn write_server_list_cli_response(payload: &Value) -> Result<(), String> { + let path = cli_server_list_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let data = serde_json::to_string_pretty(payload).map_err(|e| e.to_string())?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, &data).map_err(|e| e.to_string())?; + std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?; + Ok(()) +} + +pub fn write_search_cli_response(payload: &Value) -> Result<(), String> { + let path = cli_search_response_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let data = serde_json::to_string_pretty(payload).map_err(|e| e.to_string())?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, &data).map_err(|e| e.to_string())?; + std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?; + Ok(()) +} + +/// Wait for `psysonic-cli-servers.json` after `cli:server-list`. +fn read_server_list_cli_response_blocking(max_wait: Duration) -> String { + let path = cli_server_list_path(); + let deadline = Instant::now() + max_wait; + loop { + if let Ok(text) = std::fs::read_to_string(&path) { + let trimmed = text.trim(); + if let Ok(v) = serde_json::from_str::(trimmed) { + if v.get("servers").and_then(|x| x.as_array()).is_some() { + return text; + } + } + } + if Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(40)); + } + std::fs::read_to_string(&path).unwrap_or_else(|_| "{}".into()) +} + +pub fn print_server_list_cli_stdout(text: &str, json_out: bool) { + if json_out { + println!("{}", text.trim()); + return; + } + if let Ok(v) = serde_json::from_str::(text) { + print_server_list_human(&v); + } else { + println!("{}", text.trim()); + } +} + +fn print_server_list_human(v: &Value) { + if let Some(sid) = v.get("active_server_id").and_then(|x| x.as_str()) { + println!("active_server_id: {sid}"); + } else { + println!("active_server_id: (none)"); + } + println!("servers:"); + if let Some(Value::Array(rows)) = v.get("servers") { + if rows.is_empty() { + println!(" (none)"); + return; + } + for row in rows { + let id = row.get("id").and_then(|x| x.as_str()).unwrap_or("?"); + let name = row.get("name").and_then(|x| x.as_str()).unwrap_or("?"); + println!(" - {id}\t{name}"); + } + } else { + println!(" (invalid JSON: missing servers array)"); + } +} + +/// Wait for `psysonic-cli-search.json` after `cli:search`. +fn read_search_cli_response_blocking(max_wait: Duration) -> String { + let path = cli_search_response_path(); + let deadline = Instant::now() + max_wait; + loop { + if let Ok(text) = std::fs::read_to_string(&path) { + let trimmed = text.trim(); + if let Ok(v) = serde_json::from_str::(trimmed) { + let ready = v.get("ready").and_then(|x| x.as_bool()) == Some(true); + let has_err = v.get("error").and_then(|x| x.as_str()).is_some_and(|s| !s.is_empty()); + if ready || has_err { + return text; + } + } + } + if Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(40)); + } + std::fs::read_to_string(&path).unwrap_or_else(|_| "{}".into()) +} + +pub fn print_search_cli_stdout(text: &str, json_out: bool) { + if json_out { + println!("{}", text.trim()); + return; + } + if let Ok(v) = serde_json::from_str::(text) { + print_search_human(&v); + } else { + println!("{}", text.trim()); + } +} + +fn print_search_human(v: &Value) { + if let Some(err) = v.get("error").and_then(|x| x.as_str()) { + if !err.is_empty() { + println!("error: {err}"); + return; + } + } + let scope = v.get("scope").and_then(|x| x.as_str()).unwrap_or("?"); + let query = v.get("query").and_then(|x| x.as_str()).unwrap_or(""); + println!("scope: {scope}"); + println!("query: {query}"); + match scope { + "track" => { + println!("songs:"); + if let Some(Value::Array(rows)) = v.get("songs") { + if rows.is_empty() { + println!(" (none)"); + return; + } + for row in rows { + let id = row.get("id").and_then(|x| x.as_str()).unwrap_or("?"); + let title = row.get("title").and_then(|x| x.as_str()).unwrap_or("?"); + let artist = row.get("artist").and_then(|x| x.as_str()).unwrap_or("?"); + println!(" - {id}\t{artist} — {title}"); + } + } else { + println!(" (missing songs array)"); + } + } + "album" => { + println!("albums:"); + if let Some(Value::Array(rows)) = v.get("albums") { + if rows.is_empty() { + println!(" (none)"); + return; + } + for row in rows { + let id = row.get("id").and_then(|x| x.as_str()).unwrap_or("?"); + let name = row.get("name").and_then(|x| x.as_str()).unwrap_or("?"); + let artist = row.get("artist").and_then(|x| x.as_str()).unwrap_or("?"); + println!(" - {id}\t{artist} — {name}"); + } + } else { + println!(" (missing albums array)"); + } + } + "artist" => { + println!("artists:"); + if let Some(Value::Array(rows)) = v.get("artists") { + if rows.is_empty() { + println!(" (none)"); + return; + } + for row in rows { + let id = row.get("id").and_then(|x| x.as_str()).unwrap_or("?"); + let name = row.get("name").and_then(|x| x.as_str()).unwrap_or("?"); + println!(" - {id}\t{name}"); + } + } else { + println!(" (missing artists array)"); + } + } + _ => println!("(unknown scope)"), + } +} + fn tauri_identifier() -> &'static str { static ID: OnceLock = OnceLock::new(); ID.get_or_init(|| { @@ -439,12 +681,28 @@ fn print_info_human(v: &Value) { "volume", "queue_index", "queue_length", + "repeat_mode", + "current_track_user_rating", + "current_track_starred", ] { if let Some(val) = o.get(key) { println!(" {key}: {}", value_inline(val)); } } + println!("=== servers (saved) ==="); + match o.get("servers").and_then(|x| x.as_array()) { + None => println!("(none)"), + Some(rows) if rows.is_empty() => println!("(none)"), + Some(rows) => { + for row in rows { + let id = row.get("id").and_then(|x| x.as_str()).unwrap_or("?"); + let name = row.get("name").and_then(|x| x.as_str()).unwrap_or("?"); + println!(" - {id}\t{name}"); + } + } + } + println!("=== queue ({} items) ===", o.get("queue_length").and_then(|x| x.as_u64()).unwrap_or(0)); if let Some(Value::Array(items)) = o.get("queue") { for (i, item) in items.iter().enumerate() { @@ -481,13 +739,50 @@ fn value_inline(v: &Value) -> String { } } +fn parse_repeat_mode(arg: &str) -> Option { + match arg { + "off" => Some(RepeatCliMode::Off), + "all" => Some(RepeatCliMode::All), + "one" => Some(RepeatCliMode::One), + _ => None, + } +} + fn parse_player_cli_at(args: &[String], pos: usize) -> Option { let verb = args.get(pos + 1)?.as_str(); match verb { "next" => Some(PlayerCliCmd::Next), "prev" => Some(PlayerCliCmd::Prev), - "play" => Some(PlayerCliCmd::Play), + "play" => match args.get(pos + 2).map(|s| s.as_str()) { + None => Some(PlayerCliCmd::Play), + Some(flag) if flag.starts_with('-') => None, + Some(extra) => { + if extra.is_empty() { + return None; + } + Some(PlayerCliCmd::PlayOpaqueId(extra.to_string())) + } + }, "pause" => Some(PlayerCliCmd::Pause), + "stop" => Some(PlayerCliCmd::Stop), + "shuffle" => Some(PlayerCliCmd::ShuffleQueue), + "repeat" => { + let m = parse_repeat_mode(args.get(pos + 2)?.as_str())?; + Some(PlayerCliCmd::Repeat(m)) + } + "mute" => Some(PlayerCliCmd::Mute), + "unmute" => Some(PlayerCliCmd::Unmute), + "star" => Some(PlayerCliCmd::StarCurrent), + "unstar" => Some(PlayerCliCmd::UnstarCurrent), + "rating" => { + let raw = args.get(pos + 2)?; + let n: u8 = raw.parse().ok()?; + if n > 5 { + return None; + } + Some(PlayerCliCmd::Rating { stars: n }) + } + "reload" => Some(PlayerCliCmd::ReloadPlayer), "seek" => { let raw = args.get(pos + 2)?; let delta_secs: i32 = raw.parse().ok()?; @@ -547,6 +842,35 @@ pub fn parse_cli_command(args: &[String]) -> Option { _ => None, } } + "server" => { + let sub = args.get(pos + 2)?.as_str(); + match sub { + "list" => Some(CliCommand::ServerList), + "set" => { + let id = args.get(pos + 3)?; + if id.is_empty() { + return None; + } + Some(CliCommand::ServerSet(id.clone())) + } + _ => None, + } + } + "search" => { + let scope_raw = args.get(pos + 2)?.as_str(); + let scope = match scope_raw { + "track" => SearchCliScope::Track, + "album" => SearchCliScope::Album, + "artist" => SearchCliScope::Artist, + _ => return None, + }; + let tail = args.get(pos + 3..)?; + let query = tail.join(" ").trim().to_string(); + if query.is_empty() { + return None; + } + Some(CliCommand::Search { scope, query }) + } _ => parse_player_cli_at(args, pos).map(CliCommand::Player), } } @@ -630,6 +954,26 @@ pub fn handle_cli_on_primary_instance(app: &AppHandle, argv: &[St let _ = app.emit("cli:library-set", folder.clone()); true } + Some(CliCommand::ServerList) => { + let _ = app.emit("cli:server-list", ()); + true + } + Some(CliCommand::ServerSet(id)) => { + let _ = app.emit("cli:server-set", id.clone()); + true + } + Some(CliCommand::Search { scope, query }) => { + let scope_s = match scope { + SearchCliScope::Track => "track", + SearchCliScope::Album => "album", + SearchCliScope::Artist => "artist", + }; + let _ = app.emit( + "cli:search", + serde_json::json!({ "scope": scope_s, "query": query }), + ); + true + } None => false, } } @@ -678,6 +1022,7 @@ pub fn spawn_deferred_cli_argv_handler(app: &AppHandle) { let _ = handle.emit("cli:instant-mix", s); } CliCommand::LibraryList => { + let _ = std::fs::remove_file(cli_library_response_path()); let _ = handle.emit("cli:library-list", ()); let text = read_library_cli_response_blocking(Duration::from_secs(3)); print_library_cli_stdout(&text, json_out); @@ -685,6 +1030,29 @@ pub fn spawn_deferred_cli_argv_handler(app: &AppHandle) { CliCommand::LibrarySet(folder) => { let _ = handle.emit("cli:library-set", folder.clone()); } + CliCommand::ServerList => { + let _ = std::fs::remove_file(cli_server_list_path()); + let _ = handle.emit("cli:server-list", ()); + let text = read_server_list_cli_response_blocking(Duration::from_secs(3)); + print_server_list_cli_stdout(&text, json_out); + } + CliCommand::ServerSet(id) => { + let _ = handle.emit("cli:server-set", id.clone()); + } + CliCommand::Search { scope, query } => { + let _ = std::fs::remove_file(cli_search_response_path()); + let scope_s = match scope { + SearchCliScope::Track => "track", + SearchCliScope::Album => "album", + SearchCliScope::Artist => "artist", + }; + let _ = handle.emit( + "cli:search", + serde_json::json!({ "scope": scope_s, "query": query }), + ); + let text = read_search_cli_response_blocking(Duration::from_secs(12)); + print_search_cli_stdout(&text, json_out); + } } if !quiet { println!("OK: {ok_line} (applied after startup)"); @@ -694,7 +1062,7 @@ pub fn spawn_deferred_cli_argv_handler(app: &AppHandle) { pub fn describe_cli_command(cmd: &CliCommand) -> String { match cmd { - CliCommand::Player(c) => describe_player_cli_cmd(*c), + CliCommand::Player(c) => describe_player_cli_cmd(c), CliCommand::AudioDeviceList => "audio-device list".into(), CliCommand::AudioDeviceSet(None) => "audio-device set default".into(), CliCommand::AudioDeviceSet(Some(s)) => format!("audio-device set {s}"), @@ -703,17 +1071,41 @@ pub fn describe_cli_command(cmd: &CliCommand) -> String { CliCommand::LibraryList => "library list".into(), CliCommand::LibrarySet(s) if s == "all" => "library set all".into(), CliCommand::LibrarySet(s) => format!("library set {s}"), + CliCommand::ServerList => "server list".into(), + CliCommand::ServerSet(s) => format!("server set {s}"), + CliCommand::Search { scope, query } => { + let sc = match scope { + SearchCliScope::Track => "track", + SearchCliScope::Album => "album", + SearchCliScope::Artist => "artist", + }; + format!("search {sc} {query}") + } } } -pub fn describe_player_cli_cmd(cmd: PlayerCliCmd) -> String { +pub fn describe_player_cli_cmd(cmd: &PlayerCliCmd) -> String { match cmd { PlayerCliCmd::Next => "next track".into(), PlayerCliCmd::Prev => "previous track".into(), PlayerCliCmd::Play => "play".into(), + PlayerCliCmd::PlayOpaqueId(id) => format!("play {id}"), PlayerCliCmd::Pause => "pause".into(), + PlayerCliCmd::Stop => "stop".into(), PlayerCliCmd::Seek { delta_secs } => format!("seek {delta_secs:+} s"), PlayerCliCmd::Volume { percent } => format!("volume {percent}%"), + PlayerCliCmd::ShuffleQueue => "shuffle".into(), + PlayerCliCmd::Repeat(m) => match m { + RepeatCliMode::Off => "repeat off".into(), + RepeatCliMode::All => "repeat all".into(), + RepeatCliMode::One => "repeat one".into(), + }, + PlayerCliCmd::Mute => "mute".into(), + PlayerCliCmd::Unmute => "unmute".into(), + PlayerCliCmd::StarCurrent => "star".into(), + PlayerCliCmd::UnstarCurrent => "unstar".into(), + PlayerCliCmd::Rating { stars } => format!("rating {stars}"), + PlayerCliCmd::ReloadPlayer => "reload".into(), } } @@ -728,15 +1120,50 @@ pub fn emit_player_cli_cmd(app: &AppHandle, cmd: PlayerCliCmd) { PlayerCliCmd::Play => { let _ = app.emit("media:play", ()); } + PlayerCliCmd::PlayOpaqueId(id) => { + let _ = app.emit("cli:play-id", id); + } PlayerCliCmd::Pause => { let _ = app.emit("media:pause", ()); } + PlayerCliCmd::Stop => { + let _ = app.emit("media:stop", ()); + } PlayerCliCmd::Seek { delta_secs } => { let _ = app.emit("media:seek-relative", delta_secs); } PlayerCliCmd::Volume { percent } => { let _ = app.emit("media:set-volume", percent); } + PlayerCliCmd::ShuffleQueue => { + let _ = app.emit("cli:shuffle-queue", ()); + } + PlayerCliCmd::Repeat(mode) => { + let s = match mode { + RepeatCliMode::Off => "off", + RepeatCliMode::All => "all", + RepeatCliMode::One => "one", + }; + let _ = app.emit("cli:set-repeat", s); + } + PlayerCliCmd::Mute => { + let _ = app.emit("cli:mute", ()); + } + PlayerCliCmd::Unmute => { + let _ = app.emit("cli:unmute", ()); + } + PlayerCliCmd::StarCurrent => { + let _ = app.emit("cli:star-current", true); + } + PlayerCliCmd::UnstarCurrent => { + let _ = app.emit("cli:star-current", false); + } + PlayerCliCmd::Rating { stars } => { + let _ = app.emit("cli:set-rating-current", stars); + } + PlayerCliCmd::ReloadPlayer => { + let _ = app.emit("cli:reload-player", ()); + } } } @@ -771,6 +1198,12 @@ pub fn linux_try_forward_player_cli_secondary(args: &[String]) -> Result { let _ = std::fs::remove_file(cli_library_response_path()); } + Some(CliCommand::ServerList) => { + let _ = std::fs::remove_file(cli_server_list_path()); + } + Some(CliCommand::Search { .. }) => { + let _ = std::fs::remove_file(cli_search_response_path()); + } _ => {} } @@ -803,6 +1236,22 @@ pub fn linux_try_forward_player_cli_secondary(args: &[String]) -> Result Result<(), String> { crate::cli::write_library_cli_response(&payload) } +/// Writes `psysonic-cli-servers.json` for `psysonic --player server list`. +#[tauri::command] +fn cli_publish_server_list(payload: serde_json::Value) -> Result<(), String> { + crate::cli::write_server_list_cli_response(&payload) +} + +/// Writes `psysonic-cli-search.json` for `psysonic --player search …`. +#[tauri::command] +fn cli_publish_search_results(payload: serde_json::Value) -> Result<(), String> { + crate::cli::write_search_cli_response(&payload) +} + /// Toggle native window decorations at runtime (Linux custom title bar opt-out). #[tauri::command] fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) { @@ -2109,6 +2121,30 @@ fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result { .build(app) } +/// Creates the tray icon, or `None` if the OS cannot host one. +/// +/// On Linux, `libayatana-appindicator3` / `libappindicator3` may be absent (minimal +/// installs, wrong `LD_LIBRARY_PATH`). The `tray-icon` stack can **panic** on `dlopen` +/// failure instead of returning `Err`, so we catch unwind and keep the app running +/// (e.g. cold start with `--player` still works without tray libraries). +fn try_build_tray_icon(app: &tauri::AppHandle) -> Option { + let app = app.clone(); + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build_tray_icon(&app))) { + Ok(Ok(tray)) => Some(tray), + Ok(Err(e)) => { + eprintln!("[Psysonic] System tray unavailable: {e}"); + None + } + Err(_) => { + eprintln!( + "[Psysonic] System tray unavailable — missing libayatana-appindicator3 or libappindicator3 \ + (install the distro package or set LD_LIBRARY_PATH)" + ); + None + } + } +} + /// Show (`true`) or fully remove (`false`) the system-tray icon. /// /// The command is strictly idempotent: @@ -2132,7 +2168,12 @@ fn toggle_tray_icon( if guard.is_some() { return Ok(()); } - *guard = Some(build_tray_icon(&app).map_err(|e| e.to_string())?); + let Some(tray) = try_build_tray_icon(&app) else { + return Err( + "Tray icon could not be created (missing system libraries on Linux).".into(), + ); + }; + *guard = Some(tray); } else if let Some(tray) = guard.take() { // Hide synchronously before dropping so the OS processes the removal // before any subsequent show=true call can create a new icon. @@ -2267,11 +2308,13 @@ pub fn run() { } // ── System tray ─────────────────────────────────────────────── - // Always build on startup; the frontend calls toggle_tray_icon(false) + // Always build on startup when possible; the frontend calls toggle_tray_icon(false) // immediately after load if the user has disabled the tray icon. + // May be skipped if Ayatana/AppIndicator libraries are missing (no panic). { - let tray = build_tray_icon(app.handle())?; - *app.state::().lock().unwrap() = Some(tray); + if let Some(tray) = try_build_tray_icon(app.handle()) { + *app.state::().lock().unwrap() = Some(tray); + } } // ── MPRIS2 / OS media controls via souvlaki ────────────────── @@ -2415,6 +2458,8 @@ pub fn run() { exit_app, cli_publish_player_snapshot, cli_publish_library_list, + cli_publish_server_list, + cli_publish_search_results, set_window_decorations, no_compositing_mode, is_tiling_wm_cmd, diff --git a/src/App.tsx b/src/App.tsx index 35efc81d..367cd9ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,10 +58,21 @@ import { IS_LINUX } from './utils/platform'; import { version } from '../package.json'; import { useConnectionStatus } from './hooks/useConnectionStatus'; import { useAuthStore } from './store/authStore'; -import { getMusicFolders, getSimilarSongs, probeEntityRatingSupport } from './api/subsonic'; +import { + getMusicFolders, + getSimilarSongs, + getSong, + probeEntityRatingSupport, + search as subsonicSearch, + setRating, + star, + unstar, +} from './api/subsonic'; import { useOfflineStore } from './store/offlineStore'; import { initHotCachePrefetch } from './hotCachePrefetch'; import i18n from './i18n'; +import { playByOpaqueId } from './utils/playByOpaqueId'; +import { switchActiveServer } from './utils/switchActiveServer'; import { usePlayerStore, initAudioListeners, songToTrack, shuffleArray } from './store/playerStore'; import { useThemeStore } from './store/themeStore'; import { useThemeScheduler } from './hooks/useThemeScheduler'; @@ -72,6 +83,9 @@ import { useGlobalShortcutsStore } from './store/globalShortcutsStore'; import { useZipDownloadStore } from './store/zipDownloadStore'; import ZipDownloadOverlay from './components/ZipDownloadOverlay'; +/** Volume before last `psysonic --player mute` (CLI only; in-memory). */ +let cliPremuteVolume: number | null = null; + function RequireAuth({ children }: { children: React.ReactNode }) { const { isLoggedIn, servers, activeServerId } = useAuthStore(); if (!isLoggedIn || !activeServerId || servers.length === 0) return ; @@ -544,6 +558,171 @@ function TauriEventBridge() { }; }, []); + // CLI: servers, search, transport extras, mute, star, rating, play-by-id, reload. + useEffect(() => { + const unsubs: Array<() => void> = []; + listen('cli:server-list', async () => { + const auth = useAuthStore.getState(); + await invoke('cli_publish_server_list', { + payload: { + active_server_id: auth.activeServerId, + servers: auth.servers.map(s => ({ id: s.id, name: s.name })), + }, + }); + }).then(u => unsubs.push(u)); + listen('cli:server-set', async e => { + const raw = typeof e.payload === 'string' ? e.payload : ''; + const id = raw.trim(); + if (!id) return; + const server = useAuthStore.getState().servers.find(s => s.id === id); + if (!server) { + showToast(i18n.t('contextMenu.cliServerNotFound', { defaultValue: 'Server id not found.' }), 4000, 'error'); + return; + } + const ok = await switchActiveServer(server); + if (!ok) { + showToast(i18n.t('contextMenu.cliServerSwitchFailed', { defaultValue: 'Could not switch server (ping failed).' }), 5000, 'error'); + } + }).then(u => unsubs.push(u)); + listen<{ scope: string; query: string }>('cli:search', async e => { + const { scope, query } = e.payload; + const base = { scope, query, ready: false }; + try { + const r = await subsonicSearch(query, { songCount: 50, albumCount: 30, artistCount: 30 }); + const payload = + scope === 'track' + ? { + ...base, + songs: r.songs.map(s => ({ id: s.id, title: s.title, artist: s.artist })), + albums: [] as { id: string; name: string; artist: string }[], + artists: [] as { id: string; name: string }[], + ready: true, + } + : scope === 'album' + ? { + ...base, + songs: [] as { id: string; title: string; artist: string }[], + albums: r.albums.map(a => ({ id: a.id, name: a.name, artist: a.artist })), + artists: [] as { id: string; name: string }[], + ready: true, + } + : { + ...base, + songs: [] as { id: string; title: string; artist: string }[], + albums: [] as { id: string; name: string; artist: string }[], + artists: r.artists.map(a => ({ id: a.id, name: a.name })), + ready: true, + }; + await invoke('cli_publish_search_results', { payload }); + } catch (err) { + console.error('CLI search failed', err); + await invoke('cli_publish_search_results', { + payload: { + ...base, + songs: [], + albums: [], + artists: [], + ready: true, + error: err instanceof Error ? err.message : 'search failed', + }, + }).catch(() => {}); + } + }).then(u => unsubs.push(u)); + listen('cli:play-id', async e => { + const id = typeof e.payload === 'string' ? e.payload.trim() : ''; + if (!id) return; + try { + await playByOpaqueId(id); + } catch (err) { + console.error('CLI play failed', err); + const notFound = err instanceof Error && err.message === 'play_by_id_not_found'; + showToast( + i18n.t('contextMenu.cliPlayIdNotFound', { + defaultValue: notFound + ? 'No song, album, or artist matches this id.' + : 'Could not start playback.', + }), + 5000, + 'error', + ); + } + }).then(u => unsubs.push(u)); + listen('cli:shuffle-queue', () => { + usePlayerStore.getState().shuffleQueue(); + }).then(u => unsubs.push(u)); + listen('cli:set-repeat', e => { + const m = typeof e.payload === 'string' ? e.payload : ''; + const mode = m === 'all' ? 'all' : m === 'one' ? 'one' : 'off'; + usePlayerStore.setState({ repeatMode: mode }); + }).then(u => unsubs.push(u)); + listen('cli:mute', () => { + const { volume, setVolume } = usePlayerStore.getState(); + if (volume > 0) cliPremuteVolume = volume; + setVolume(0); + }).then(u => unsubs.push(u)); + listen('cli:unmute', () => { + const restore = cliPremuteVolume ?? 0.8; + cliPremuteVolume = null; + usePlayerStore.getState().setVolume(restore); + }).then(u => unsubs.push(u)); + listen('cli:star-current', async e => { + const want = e.payload === true; + const track = usePlayerStore.getState().currentTrack; + if (!track) { + showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error'); + return; + } + try { + if (want) { + await star(track.id, 'song'); + usePlayerStore.getState().setStarredOverride(track.id, true); + } else { + await unstar(track.id, 'song'); + usePlayerStore.getState().setStarredOverride(track.id, false); + } + } catch (err) { + console.error('CLI star failed', err); + showToast(i18n.t('contextMenu.cliStarFailed', { defaultValue: 'Star/unstar failed.' }), 5000, 'error'); + } + }).then(u => unsubs.push(u)); + listen('cli:set-rating-current', async e => { + const stars = e.payload; + if (typeof stars !== 'number' || Number.isNaN(stars) || stars < 0 || stars > 5) return; + const track = usePlayerStore.getState().currentTrack; + if (!track) { + showToast(i18n.t('contextMenu.cliMixNeedsTrack'), 5000, 'error'); + return; + } + try { + await setRating(track.id, stars); + usePlayerStore.getState().setUserRatingOverride(track.id, stars); + } catch (err) { + console.error('CLI set rating failed', err); + } + }).then(u => unsubs.push(u)); + listen('cli:reload-player', async () => { + const store = usePlayerStore.getState(); + const { currentTrack, queue, stop, resetAudioPause, playTrack, initializeFromServerQueue } = store; + stop(); + resetAudioPause(); + await invoke('audio_stop').catch(() => {}); + if (currentTrack) { + try { + const fresh = await getSong(currentTrack.id); + const t = fresh ? songToTrack(fresh) : currentTrack; + playTrack(t, queue, true); + } catch { + playTrack(currentTrack, queue, true); + } + } else { + await initializeFromServerQueue(); + } + }).then(u => unsubs.push(u)); + return () => { + unsubs.forEach(u => u()); + }; + }, []); + // Sync tray-icon visibility with the user's stored setting. // Runs once on mount (initial sync) and again whenever the setting changes. const showTrayIcon = useAuthStore(s => s.showTrayIcon); @@ -622,6 +801,7 @@ function TauriEventBridge() { ['tray:play-pause', () => togglePlay()], ['tray:next', () => next()], ['tray:previous', () => previous()], + ['media:stop', () => usePlayerStore.getState().stop()], ['media:volume-up', () => { const s = usePlayerStore.getState(); s.setVolume(Math.min(1, s.volume + 0.05)); }], ['media:volume-down', () => { const s = usePlayerStore.getState(); s.setVolume(Math.max(0, s.volume - 0.05)); }], ]; @@ -687,6 +867,13 @@ function TauriEventBridge() { const auth = useAuthStore.getState(); const sid = auth.activeServerId; const selected = sid ? (auth.musicLibraryFilterByServer[sid] ?? 'all') : 'all'; + const ct = s.currentTrack; + const currentTrackUserRating = + ct != null ? (s.userRatingOverrides[ct.id] ?? ct.userRating ?? null) : null; + const currentTrackStarred = + ct != null + ? (ct.id in s.starredOverrides ? s.starredOverrides[ct.id] : Boolean(ct.starred)) + : null; const snapshot = { current_track: s.currentTrack, current_radio: s.currentRadio, @@ -696,6 +883,10 @@ function TauriEventBridge() { is_playing: s.isPlaying, current_time: s.currentTime, volume: s.volume, + repeat_mode: s.repeatMode, + current_track_user_rating: currentTrackUserRating, + current_track_starred: currentTrackStarred, + servers: auth.servers.map(({ id, name }) => ({ id, name })), music_library: { active_server_id: sid, selected, diff --git a/src/utils/playArtistShuffled.ts b/src/utils/playArtistShuffled.ts new file mode 100644 index 00000000..8989160a --- /dev/null +++ b/src/utils/playArtistShuffled.ts @@ -0,0 +1,25 @@ +import { getAlbum, getArtist } from '../api/subsonic'; +import { shuffleArray, songToTrack, usePlayerStore } from '../store/playerStore'; + +/** + * All tracks from the artist’s albums, shuffled — same idea as Artist page “shuffle play”. + */ +export async function playArtistShuffled(artistId: string): Promise { + const { albums } = await getArtist(artistId); + if (albums.length === 0) { + throw new Error('play_artist_no_tracks'); + } + + const results = await Promise.all(albums.map(a => getAlbum(a.id))); + const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0)); + const tracks = sorted.flatMap(r => + [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0)).map(songToTrack), + ); + + if (tracks.length === 0) { + throw new Error('play_artist_no_tracks'); + } + + const shuffled = shuffleArray(tracks); + usePlayerStore.getState().playTrack(shuffled[0], shuffled); +} diff --git a/src/utils/playByOpaqueId.ts b/src/utils/playByOpaqueId.ts new file mode 100644 index 00000000..ff065e81 --- /dev/null +++ b/src/utils/playByOpaqueId.ts @@ -0,0 +1,35 @@ +import { getAlbum, getSong } from '../api/subsonic'; +import { playAlbum } from './playAlbum'; +import { playArtistShuffled } from './playArtistShuffled'; +import { songToTrack, usePlayerStore } from '../store/playerStore'; + +/** + * `getSong` → `getAlbum` → `getArtist`: one opaque Subsonic id may refer to a track, + * album, or artist depending on the server. + */ +export async function playByOpaqueId(id: string): Promise { + const trimmed = id.trim(); + if (!trimmed) return; + + const song = await getSong(trimmed); + if (song) { + usePlayerStore.getState().playTrack(songToTrack(song)); + return; + } + + try { + const { songs } = await getAlbum(trimmed); + if (songs.length > 0) { + await playAlbum(trimmed); + return; + } + } catch { + /* not an album */ + } + + try { + await playArtistShuffled(trimmed); + } catch { + throw new Error('play_by_id_not_found'); + } +}