···3434 let port = std::env::var("RB_TYPESENSE_PORT").unwrap_or_else(|_| "8109".to_string());
3535 let url = format!("http://localhost:{}/health", port);
3636 let client = reqwest::Client::new();
3737+ info!(
3838+ "Waiting for Typesense to accept connections on port {}...",
3939+ port
4040+ );
3741 for attempt in 1..=30 {
3842 match client.get(&url).send().await {
3943 Ok(r) if r.status().is_success() => {
4040- info!("Typesense ready after {} attempt(s)", attempt);
4444+ info!("Typesense is ready (took {} attempt(s))", attempt);
4145 return;
4246 }
4343- _ => {
4747+ Ok(r) => {
4848+ tracing::debug!(
4949+ "Typesense health check attempt {}: HTTP {}",
5050+ attempt,
5151+ r.status()
5252+ );
5353+ tokio::time::sleep(Duration::from_secs(1)).await;
5454+ }
5555+ Err(e) => {
5656+ tracing::debug!("Typesense health check attempt {}: {}", attempt, e);
4457 tokio::time::sleep(Duration::from_secs(1)).await;
4558 }
4659 }
4760 }
4848- warn!("Typesense did not become ready in time; proceeding anyway");
6161+ warn!("Typesense did not become ready within 30s; proceeding anyway");
4962}
50635164/// SIGTERM/SIGINT handler: kill the typesense child then _exit immediately.
···148161 let path = rockbox_settings::get_music_dir().unwrap_or(format!("{}/Music", home));
149162 let rt = tokio::runtime::Runtime::new().unwrap();
150163 rt.block_on(async {
164164+ info!("Setting up Typesense search engine...");
151165 rockbox_typesense::setup()?;
152166153167 // Wait for Typesense to accept connections before any HTTP calls.
···155169 // may not be listening yet when we reach the first collection call.
156170 wait_for_typesense().await;
157171172172+ info!("Connecting to library database...");
158173 let pool = create_connection_pool().await?;
159174 let tracks = repo::track::all(pool.clone()).await?;
160175 if tracks.is_empty() || update_library {
176176+ if tracks.is_empty() {
177177+ info!(
178178+ "Library is empty — starting first-time audio scan of: {}",
179179+ path
180180+ );
181181+ } else {
182182+ info!(
183183+ "ROCKBOX_UPDATE_LIBRARY set — rescanning audio library at: {}",
184184+ path
185185+ );
186186+ }
161187 match scan_audio_files(pool.clone(), path.into()).await {
162162- Ok(_) => info!("Finished scanning audio files"),
188188+ Ok(_) => info!("Audio scan complete"),
163189 Err(e) => error!("Failed to scan audio files: {}", e),
164190 }
165191 let tracks = repo::track::all(pool.clone()).await?;
166192 let albums = repo::album::all(pool.clone()).await?;
167193 let artists = repo::artist::all(pool.clone()).await?;
168194195195+ info!(
196196+ "Indexing {} tracks, {} albums, {} artists into Typesense...",
197197+ tracks.len(),
198198+ albums.len(),
199199+ artists.len()
200200+ );
201201+202202+ info!("Creating Typesense collections...");
169203 create_tracks_collection().await?;
170204 create_albums_collection().await?;
171205 create_artists_collection().await?;
172206207207+ info!("Inserting {} tracks...", tracks.len());
173208 insert_tracks(tracks.into_iter().map(Track::from).collect()).await?;
209209+ info!("Inserting {} artists...", artists.len());
174210 insert_artists(artists.into_iter().map(Artist::from).collect()).await?;
211211+ info!("Inserting {} albums...", albums.len());
175212 insert_albums(albums.into_iter().map(Album::from).collect()).await?;
213213+214214+ info!("Search index build complete.");
215215+ } else {
216216+ info!(
217217+ "Library already indexed ({} tracks); skipping scan.",
218218+ tracks.len()
219219+ );
176220 }
177221222222+ info!("Setting up playlists collection...");
178223 create_playlists_collection().await?;
179224 let playlist_store = PlaylistStore::new(pool.clone());
180225 let saved = playlist_store.list().await.unwrap_or_default();
···202247 }))
203248 .collect();
204249 if !ts_playlists.is_empty() {
250250+ info!(
251251+ "Indexing {} playlist(s) into Typesense...",
252252+ ts_playlists.len()
253253+ );
205254 insert_playlists(ts_playlists).await?;
255255+ info!("Playlist index complete.");
256256+ } else {
257257+ info!("No playlists to index.");
206258 }
207259 Ok::<(), Error>(())
208260 })
···294346 });
295347 }
296348349349+ info!(
350350+ "Starting typesense-server (binary: {}, port: {}, data: {})",
351351+ ts_bin.display(),
352352+ port,
353353+ data_dir.display()
354354+ );
297355 let mut child = cmd.spawn()?;
298298- TYPESENSE_PID.store(child.id() as i32, Ordering::SeqCst);
356356+ let pid = child.id();
357357+ TYPESENSE_PID.store(pid as i32, Ordering::SeqCst);
358358+ info!("typesense-server started with PID {}", pid);
299359300360 if let Some(stdout) = child.stdout.take() {
301361 thread::spawn(move || {
+2-2
crates/discovery/src/lib.rs
···4343 let mpd_service = format!("mpd-{}", device_id);
44444545 thread::spawn(move || {
4646- let http_port = env::var("ROCKBOX_HTTP_PORT").unwrap_or("6061".to_string());
4646+ let grpc_port = env::var("ROCKBOX_PORT").unwrap_or("6061".to_string());
4747 let graphql_port = env::var("ROCKBOX_GRAPHQL_PORT").unwrap_or("6062".to_string());
4848- let grpc_port = env::var("ROCKBOX_PORT").unwrap_or("6063".to_string());
4848+ let http_port = env::var("ROCKBOX_TCP_PORT").unwrap_or("6063".to_string());
4949 let mpd_port = env::var("ROCKBOX_MPD_PORT").unwrap_or("6600".to_string());
5050 let mut responder = MdnsResponder::new();
5151 responder.register_service(&http_service, http_port.parse::<u16>().unwrap());
+2
crates/server/src/lib.rs
···298298 // Wait for the rpc server to start
299299 thread::sleep(std::time::Duration::from_millis(500));
300300301301+ rockbox_discovery::register_services();
302302+301303 #[cfg(target_os = "linux")]
302304 {
303305 use rockbox_mpris::MprisServer;
+41-7
crates/typesense/src/lib.rs
···22 fs,
33 process::{Command, Stdio},
44};
55-use tracing::info;
55+use tracing::{info, warn};
6677pub mod client;
88pub mod types;
···1717 homedir.display(),
1818 ".rockbox/bin"
1919 );
2020+2121+ info!("Checking for typesense-server binary (PATH includes ~/.rockbox/bin)...");
2022 let mut cmd = Command::new("sh")
2123 .arg("-c")
2224 .arg("command -v typesense-server")
···2628 .spawn()?;
27292830 let data_dir = homedir.join(".config/rockbox.org/typesense");
3131+ info!("Ensuring Typesense data directory: {}", data_dir.display());
2932 fs::create_dir_all(&data_dir)?;
30333134 if !data_dir.join("api-key").exists() {
3235 let api_key = uuid::Uuid::new_v4().to_string();
3336 fs::write(data_dir.join("api-key"), &api_key)?;
3434- info!("Generated new Typesense API key: {}", api_key);
3737+ info!(
3838+ "Generated new Typesense API key (saved to {})",
3939+ data_dir.join("api-key").display()
4040+ );
3541 if std::env::var("RB_TYPESENSE_API_KEY").is_err() {
3642 std::env::set_var("RB_TYPESENSE_API_KEY", &api_key);
3743 }
3844 } else {
3945 let api_key = fs::read_to_string(data_dir.join("api-key"))?;
4040- info!("Using existing Typesense API key: {}", api_key);
4646+ info!("Loaded existing Typesense API key from disk");
4147 if std::env::var("RB_TYPESENSE_API_KEY").is_err() {
4248 std::env::set_var("RB_TYPESENSE_API_KEY", &api_key);
4349 }
4450 }
45514652 if cmd.wait()?.success() {
4747- info!("Typesense server is already installed and available in PATH.");
5353+ info!("typesense-server already installed; skipping download.");
4854 return Ok(());
4955 }
5056···7076 );
7177 let filename = format!("typesense-server-{version}-{os}-{arch}.tar.gz");
72787373- Command::new("curl")
7979+ info!(
8080+ "typesense-server not found. Downloading v{} for {}/{}...",
8181+ version, os, arch
8282+ );
8383+ info!("Download URL: {}", url);
8484+8585+ let status = Command::new("curl")
7486 .arg("-L")
8787+ .arg("--progress-bar")
7588 .arg(&url)
7689 .arg("-o")
7790 .arg(&filename)
···7992 .stderr(Stdio::inherit())
8093 .status()?;
81948282- Command::new("tar")
9595+ if !status.success() {
9696+ return Err(anyhow::anyhow!("curl exited with {}", status));
9797+ }
9898+ info!("Download complete: {}", filename);
9999+100100+ info!("Extracting {}...", filename);
101101+ let status = Command::new("tar")
83102 .arg("xzf")
84103 .arg(&filename)
85104 .stdout(Stdio::inherit())
86105 .stderr(Stdio::inherit())
87106 .status()?;
881078989- Command::new("sh")
108108+ if !status.success() {
109109+ return Err(anyhow::anyhow!("tar exited with {}", status));
110110+ }
111111+ info!("Extraction complete.");
112112+113113+ info!("Installing typesense-server to ~/.rockbox/bin/...");
114114+ let status = Command::new("sh")
90115 .arg("-c")
91116 .arg("mkdir -p ~/.rockbox/bin && cp typesense-server ~/.rockbox/bin && chmod +x ~/.rockbox/bin/typesense-server && rm -f typesense-server typesense-server-*.tar.gz typesense-server.md5.txt")
92117 .stdout(Stdio::inherit())
93118 .stderr(Stdio::inherit())
94119 .status()?;
120120+121121+ if !status.success() {
122122+ warn!(
123123+ "Install script exited with {}; binary may not be in place",
124124+ status
125125+ );
126126+ } else {
127127+ info!("typesense-server installed to ~/.rockbox/bin/typesense-server");
128128+ }
9512996130 Ok(())
97131}
···2323 .with_assets(assets.clone())
2424 .run(move |cx| {
2525 cx.set_global(crate::state::TokioHandle(tokio_handle));
2626+ cx.set_global(crate::ui::components::ServerPickerOpen(false));
2727+ cx.set_global(crate::ui::components::DiscoveredServers::default());
2628 let bounds = Bounds::centered(None, size(px(1280.0), px(760.0)), cx);
2729 assets.load_fonts(cx).expect("failed to load fonts");
2830 // Theme is set as a global inside StartupGate / Rockbox::new.
+90-70
gpui/src/client.rs
···2424use anyhow::Result;
2525use tokio::sync::mpsc::Sender;
26262727-const URL: &str = "http://127.0.0.1:6061";
2828-const HTTP_URL: &str = "http://127.0.0.1:6063";
2727+fn url() -> String {
2828+ crate::server::get_grpc_url()
2929+}
3030+fn http_url() -> String {
3131+ crate::server::get_http_url()
3232+}
29333034// ── Library ───────────────────────────────────────────────────────────────────
31353236pub async fn fetch_tracks() -> Result<Vec<Track>> {
3333- let mut c = LibraryServiceClient::connect(URL).await?;
3737+ let mut c = LibraryServiceClient::connect(url()).await?;
3438 let resp = c.get_tracks(GetTracksRequest {}).await?;
3539 Ok(resp
3640 .into_inner()
···4347pub async fn get_album(
4448 id: &str,
4549) -> Result<(String, Option<String>)> {
4646- let mut c = LibraryServiceClient::connect(URL).await?;
5050+ let mut c = LibraryServiceClient::connect(url()).await?;
4751 let resp = c.get_album(GetAlbumRequest { id: id.to_string() }).await?;
4852 let album = resp.into_inner().album;
4953 Ok(album
···7478// ── Playback control ──────────────────────────────────────────────────────────
75797680pub async fn resume() -> Result<()> {
7777- let mut c = PlaybackServiceClient::connect(URL).await?;
8181+ let mut c = PlaybackServiceClient::connect(url()).await?;
7882 c.resume(ResumeRequest {}).await?;
7983 Ok(())
8084}
81858286// Resume from saved state after a daemon restart (playlist_resume + resume_track).
8387pub async fn resume_track() -> Result<()> {
8484- let mut c = PlaylistServiceClient::connect(URL).await?;
8888+ let mut c = PlaylistServiceClient::connect(url()).await?;
8589 c.resume_track(ResumeTrackRequest {
8690 start_index: 0,
8791 crc: 0,
···9397}
94989599pub async fn pause() -> Result<()> {
9696- let mut c = PlaybackServiceClient::connect(URL).await?;
100100+ let mut c = PlaybackServiceClient::connect(url()).await?;
97101 c.pause(PauseRequest {}).await?;
98102 Ok(())
99103}
100104101105/// Seek to `new_time_ms` milliseconds from the start of the current track.
102106pub async fn seek(new_time_ms: i32) -> Result<()> {
103103- let mut c = PlaybackServiceClient::connect(URL).await?;
107107+ let mut c = PlaybackServiceClient::connect(url()).await?;
104108 c.fast_forward_rewind(FastForwardRewindRequest {
105109 new_time: new_time_ms,
106110 })
···109113}
110114111115pub async fn next() -> Result<()> {
112112- let mut c = PlaybackServiceClient::connect(URL).await?;
116116+ let mut c = PlaybackServiceClient::connect(url()).await?;
113117 c.next(NextRequest {}).await?;
114118 Ok(())
115119}
116120117121pub async fn prev() -> Result<()> {
118118- let mut c = PlaybackServiceClient::connect(URL).await?;
122122+ let mut c = PlaybackServiceClient::connect(url()).await?;
119123 c.previous(PreviousRequest {}).await?;
120124 Ok(())
121125}
122126123127pub async fn play_track(path: String) -> Result<()> {
124124- let mut c = PlaybackServiceClient::connect(URL).await?;
128128+ let mut c = PlaybackServiceClient::connect(url()).await?;
125129 c.play_track(PlayTrackRequest { path }).await?;
126130 Ok(())
127131}
128132129133pub async fn play_album(album_id: String, shuffle: bool) -> Result<()> {
130130- let mut c = PlaybackServiceClient::connect(URL).await?;
134134+ let mut c = PlaybackServiceClient::connect(url()).await?;
131135 c.play_album(PlayAlbumRequest {
132136 album_id,
133137 shuffle: Some(shuffle),
···138142}
139143140144pub async fn play_artist_tracks(artist_id: String, shuffle: bool) -> Result<()> {
141141- let mut c = PlaybackServiceClient::connect(URL).await?;
145145+ let mut c = PlaybackServiceClient::connect(url()).await?;
142146 c.play_artist_tracks(PlayArtistTracksRequest {
143147 artist_id,
144148 shuffle: Some(shuffle),
···149153}
150154151155pub async fn play_all_tracks() -> Result<()> {
152152- let mut c = PlaybackServiceClient::connect(URL).await?;
156156+ let mut c = PlaybackServiceClient::connect(url()).await?;
153157 c.play_all_tracks(PlayAllTracksRequest {
154158 shuffle: Some(false),
155159 position: Some(0),
···161165// ── Queue / Playlist ──────────────────────────────────────────────────────────
162166163167pub async fn jump_to_queue_position(pos: i32) -> Result<()> {
164164- let mut c = PlaylistServiceClient::connect(URL).await?;
168168+ let mut c = PlaylistServiceClient::connect(url()).await?;
165169 c.start(StartRequest {
166170 start_index: Some(pos),
167171 elapsed: Some(0),
···172176}
173177174178pub async fn insert_track_next(path: String) -> Result<()> {
175175- let mut c = PlaylistServiceClient::connect(URL).await?;
179179+ let mut c = PlaylistServiceClient::connect(url()).await?;
176180 c.insert_tracks(InsertTracksRequest {
177181 playlist_id: None,
178182 position: INSERT_FIRST,
···184188}
185189186190pub async fn insert_track_last(path: String) -> Result<()> {
187187- let mut c = PlaylistServiceClient::connect(URL).await?;
191191+ let mut c = PlaylistServiceClient::connect(url()).await?;
188192 c.insert_tracks(InsertTracksRequest {
189193 playlist_id: None,
190194 position: INSERT_LAST,
···196200}
197201198202pub async fn insert_tracks(paths: Vec<String>, position: i32, shuffle: bool) -> Result<()> {
199199- let mut c = PlaylistServiceClient::connect(URL).await?;
203203+ let mut c = PlaylistServiceClient::connect(url()).await?;
200204 c.insert_tracks(InsertTracksRequest {
201205 playlist_id: None,
202206 position,
···208212}
209213210214pub async fn search(term: String) -> Result<SearchResults> {
211211- let mut c = LibraryServiceClient::connect(URL).await?;
215215+ let mut c = LibraryServiceClient::connect(url()).await?;
212216 let resp = c.search(SearchRequest { term }).await?;
213217 let resp = resp.into_inner();
214218 let tracks = resp.tracks.into_iter().map(track_from_proto).collect();
···254258}
255259256260pub async fn fetch_queue(tx: Sender<StateUpdate>) {
257257- match PlaylistServiceClient::connect(URL).await {
261261+ match PlaylistServiceClient::connect(url()).await {
258262 Ok(mut c) => match c.get_current(GetCurrentRequest {}).await {
259263 Ok(resp) => {
260264 let resp = resp.into_inner();
···293297}
294298295299pub async fn play_liked_tracks(paths: Vec<String>, shuffle: bool) -> Result<()> {
296296- let mut c = PlaylistServiceClient::connect(URL).await?;
300300+ let mut c = PlaylistServiceClient::connect(url()).await?;
297301 c.insert_tracks(InsertTracksRequest {
298302 playlist_id: None,
299303 position: 0,
···317321// ── Queue mutation ────────────────────────────────────────────────────────────
318322319323pub async fn remove_from_queue(position: i32) -> Result<()> {
320320- let mut c = PlaylistServiceClient::connect(URL).await?;
324324+ let mut c = PlaylistServiceClient::connect(url()).await?;
321325 c.remove_tracks(RemoveTracksRequest {
322326 positions: vec![position],
323327 })
···328332// ── Sound / Volume ────────────────────────────────────────────────────────────
329333330334pub async fn adjust_volume(steps: i32) -> Result<()> {
331331- let mut c = SoundServiceClient::connect(URL).await?;
335335+ let mut c = SoundServiceClient::connect(url()).await?;
332336 c.adjust_volume(AdjustVolumeRequest { steps }).await?;
333337 Ok(())
334338}
335339336340pub async fn get_current_volume() -> Result<i32> {
337341 const SOUND_VOLUME: i32 = 0;
338338- let mut c = SoundServiceClient::connect(URL).await?;
342342+ let mut c = SoundServiceClient::connect(url()).await?;
339343 let resp = c
340344 .sound_current(SoundCurrentRequest {
341345 setting: SOUND_VOLUME,
···347351// ── Settings (shuffle, repeat) ────────────────────────────────────────────────
348352349353pub async fn save_shuffle(enabled: bool) -> Result<()> {
350350- let mut c = SettingsServiceClient::connect(URL).await?;
354354+ let mut c = SettingsServiceClient::connect(url()).await?;
351355 c.save_settings(SaveSettingsRequest {
352356 playlist_shuffle: Some(enabled),
353357 ..Default::default()
···357361}
358362359363pub async fn save_repeat(repeat_mode: i32) -> Result<()> {
360360- let mut c = SettingsServiceClient::connect(URL).await?;
364364+ let mut c = SettingsServiceClient::connect(url()).await?;
361365 c.save_settings(SaveSettingsRequest {
362366 repeat_mode: Some(repeat_mode),
363367 ..Default::default()
···369373// Fetch the saved resume position on startup so the progress bar shows the
370374// right value before the user presses play.
371375pub async fn run_resume_info_sync(tx: Sender<StateUpdate>) {
372372- match SystemServiceClient::connect(URL).await {
376376+ match SystemServiceClient::connect(url()).await {
373377 Ok(mut c) => match c.get_global_status(GetGlobalStatusRequest {}).await {
374378 Ok(resp) => {
375379 let s = resp.into_inner();
···386390387391 // The status stream only fires on changes; fetch the initial status once so
388392 // the Now Playing widget shows correctly when the app opens with a paused track.
389389- match PlaybackServiceClient::connect(URL).await {
393393+ match PlaybackServiceClient::connect(url()).await {
390394 Ok(mut c) => match c.status(StatusRequest {}).await {
391395 Ok(resp) => {
392396 let s = resp.into_inner();
···408412pub async fn run_settings_sync(tx: Sender<StateUpdate>) {
409413 let live_volume = get_current_volume().await.ok();
410414411411- match SettingsServiceClient::connect(URL).await {
415415+ match SettingsServiceClient::connect(url()).await {
412416 Ok(mut c) => match c.get_global_settings(GetGlobalSettingsRequest {}).await {
413417 Ok(resp) => {
414418 let s = resp.into_inner();
···430434// ── Likes ─────────────────────────────────────────────────────────────────────
431435432436pub async fn fetch_liked_tracks() -> Result<Vec<String>> {
433433- let mut c = LibraryServiceClient::connect(URL).await?;
437437+ let mut c = LibraryServiceClient::connect(url()).await?;
434438 let resp = c.get_liked_tracks(GetLikedTracksRequest {}).await?;
435439 Ok(resp.into_inner().tracks.into_iter().map(|t| t.id).collect())
436440}
437441438442pub async fn like_track(id: String) -> Result<()> {
439439- let mut c = LibraryServiceClient::connect(URL).await?;
443443+ let mut c = LibraryServiceClient::connect(url()).await?;
440444 c.like_track(LikeTrackRequest { id }).await?;
441445 Ok(())
442446}
443447444448pub async fn unlike_track(id: String) -> Result<()> {
445445- let mut c = LibraryServiceClient::connect(URL).await?;
449449+ let mut c = LibraryServiceClient::connect(url()).await?;
446450 c.unlike_track(UnlikeTrackRequest { id }).await?;
447451 Ok(())
448452}
···460464}
461465462466pub async fn run_library_stream(tx: Sender<StateUpdate>) {
467467+ let notify = crate::server::server_notify();
463468 loop {
464464- if let Err(e) = library_stream_inner(&tx).await {
465465- log::warn!("library stream: {e}");
469469+ tokio::select! {
470470+ result = library_stream_inner(&tx) => {
471471+ if let Err(e) = result { log::warn!("library stream: {e}"); }
472472+ tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
473473+ }
474474+ _ = notify.notified() => {} // server changed — drop connection and reconnect
466475 }
467467- tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
468476 }
469477}
470478471479async fn library_stream_inner(tx: &Sender<StateUpdate>) -> Result<()> {
472472- let mut c = LibraryServiceClient::connect(URL).await?;
480480+ let mut c = LibraryServiceClient::connect(url()).await?;
473481 let resp = c.stream_library(StreamLibraryRequest {}).await?;
474482 let mut stream = resp.into_inner();
475483 loop {
···503511}
504512505513pub async fn run_artist_images_sync(tx: Sender<StateUpdate>) {
506506- match LibraryServiceClient::connect(URL).await {
514514+ match LibraryServiceClient::connect(url()).await {
507515 Ok(mut c) => match c.get_artists(GetArtistsRequest {}).await {
508516 Ok(resp) => {
509517 let images: ArtistImages = resp
···521529}
522530523531pub async fn run_status_stream(tx: Sender<StateUpdate>) {
532532+ let notify = crate::server::server_notify();
524533 loop {
525525- if let Err(e) = status_stream_inner(&tx).await {
526526- log::warn!("status stream: {e}");
534534+ tokio::select! {
535535+ result = status_stream_inner(&tx) => {
536536+ if let Err(e) = result { log::warn!("status stream: {e}"); }
537537+ tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
538538+ }
539539+ _ = notify.notified() => {}
527540 }
528528- tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
529541 }
530542}
531543532544async fn status_stream_inner(tx: &Sender<StateUpdate>) -> Result<()> {
533533- let mut c = PlaybackServiceClient::connect(URL).await?;
545545+ let mut c = PlaybackServiceClient::connect(url()).await?;
534546 let resp = c.stream_status(StreamStatusRequest {}).await?;
535547 let mut stream = resp.into_inner();
536548 loop {
···558570}
559571560572pub async fn run_current_track_stream(tx: Sender<StateUpdate>) {
573573+ let notify = crate::server::server_notify();
561574 loop {
562562- if let Err(e) = current_track_stream_inner(&tx).await {
563563- log::warn!("current track stream: {e}");
575575+ tokio::select! {
576576+ result = current_track_stream_inner(&tx) => {
577577+ if let Err(e) = result { log::warn!("current track stream: {e}"); }
578578+ tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
579579+ }
580580+ _ = notify.notified() => {}
564581 }
565565- tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
566582 }
567583}
568584569585async fn current_track_stream_inner(tx: &Sender<StateUpdate>) -> Result<()> {
570570- let mut c = PlaybackServiceClient::connect(URL).await?;
586586+ let mut c = PlaybackServiceClient::connect(url()).await?;
571587 let resp = c.stream_current_track(StreamCurrentTrackRequest {}).await?;
572588 let mut stream = resp.into_inner();
573589 loop {
···591607}
592608593609pub async fn run_playlist_stream(tx: Sender<StateUpdate>) {
610610+ let notify = crate::server::server_notify();
594611 loop {
595595- if let Err(e) = playlist_stream_inner(&tx).await {
596596- log::warn!("playlist stream: {e}");
612612+ tokio::select! {
613613+ result = playlist_stream_inner(&tx) => {
614614+ if let Err(e) = result { log::warn!("playlist stream: {e}"); }
615615+ tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
616616+ }
617617+ _ = notify.notified() => {}
597618 }
598598- tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
599619 }
600620}
601621···606626 // publishes — without this fetch the queue would stay empty if we connect
607627 // after the broker's initial publish.
608628 {
609609- if let Ok(mut c) = PlaylistServiceClient::connect(URL).await {
629629+ if let Ok(mut c) = PlaylistServiceClient::connect(url()).await {
610630 let _ = c.playlist_resume(PlaylistResumeRequest {}).await;
611631 }
612632 }
613633 fetch_queue(tx.clone()).await;
614634615615- let mut c = PlaybackServiceClient::connect(URL).await?;
635635+ let mut c = PlaybackServiceClient::connect(url()).await?;
616636 let resp = c.stream_playlist(StreamPlaylistRequest {}).await?;
617637 let mut stream = resp.into_inner();
618638 loop {
···677697}
678698679699pub async fn tree_get_entries(path: Option<String>) -> Result<Vec<FileEntry>> {
680680- let mut c = BrowseServiceClient::connect(URL).await?;
700700+ let mut c = BrowseServiceClient::connect(url()).await?;
681701 let resp = c.tree_get_entries(TreeGetEntriesRequest { path }).await?;
682702 let mut entries: Vec<FileEntry> = resp
683703 .into_inner()
···702722}
703723704724pub async fn play_directory(path: String, shuffle: bool) -> Result<()> {
705705- let mut c = PlaybackServiceClient::connect(URL).await?;
725725+ let mut c = PlaybackServiceClient::connect(url()).await?;
706726 c.play_directory(PlayDirectoryRequest {
707727 path,
708728 shuffle: Some(shuffle),
···714734}
715735716736pub async fn play_directory_at(path: String, position: i32) -> Result<()> {
717717- let mut c = PlaybackServiceClient::connect(URL).await?;
737737+ let mut c = PlaybackServiceClient::connect(url()).await?;
718738 c.play_directory(PlayDirectoryRequest {
719739 path,
720740 shuffle: Some(false),
···726746}
727747728748pub async fn insert_directory(path: String, position: i32) -> Result<()> {
729729- let mut c = PlaylistServiceClient::connect(URL).await?;
749749+ let mut c = PlaylistServiceClient::connect(url()).await?;
730750 c.insert_directory(InsertDirectoryRequest {
731751 directory: path,
732752 position,
···744764 use crate::api::v1alpha1::{
745765 saved_playlist_service_client::SavedPlaylistServiceClient, GetSavedPlaylistsRequest,
746766 };
747747- let mut c = SavedPlaylistServiceClient::connect(URL).await?;
767767+ let mut c = SavedPlaylistServiceClient::connect(url()).await?;
748768 let resp = c
749769 .get_saved_playlists(GetSavedPlaylistsRequest { folder_id: None })
750770 .await?;
···769789 use crate::api::v1alpha1::{
770790 smart_playlist_service_client::SmartPlaylistServiceClient, GetSmartPlaylistsRequest,
771791 };
772772- let mut c = SmartPlaylistServiceClient::connect(URL).await?;
792792+ let mut c = SmartPlaylistServiceClient::connect(url()).await?;
773793 let resp = c
774794 .get_smart_playlists(GetSmartPlaylistsRequest {})
775795 .await?;
···800820 use crate::api::v1alpha1::{
801821 saved_playlist_service_client::SavedPlaylistServiceClient, CreateSavedPlaylistRequest,
802822 };
803803- let mut c = SavedPlaylistServiceClient::connect(URL).await?;
823823+ let mut c = SavedPlaylistServiceClient::connect(url()).await?;
804824 c.create_saved_playlist(CreateSavedPlaylistRequest {
805825 name,
806826 description,
···817837 saved_playlist_service_client::SavedPlaylistServiceClient,
818838 AddTracksToSavedPlaylistRequest,
819839 };
820820- let mut c = SavedPlaylistServiceClient::connect(URL).await?;
840840+ let mut c = SavedPlaylistServiceClient::connect(url()).await?;
821841 c.add_tracks_to_saved_playlist(AddTracksToSavedPlaylistRequest {
822842 playlist_id,
823843 track_ids: vec![track_id],
···832852 saved_playlist_service_client::SavedPlaylistServiceClient,
833853 GetSavedPlaylistTracksRequest,
834854 };
835835- let mut c = SavedPlaylistServiceClient::connect(URL).await?;
855855+ let mut c = SavedPlaylistServiceClient::connect(url()).await?;
836856 let resp = c
837857 .get_saved_playlist_tracks(GetSavedPlaylistTracksRequest { playlist_id })
838858 .await?;
···845865 smart_playlist_service_client::SmartPlaylistServiceClient,
846866 GetSmartPlaylistTracksRequest,
847867 };
848848- let mut c = SmartPlaylistServiceClient::connect(URL).await?;
868868+ let mut c = SmartPlaylistServiceClient::connect(url()).await?;
849869 let resp = c
850870 .get_smart_playlist_tracks(GetSmartPlaylistTracksRequest { id: playlist_id })
851871 .await?;
···856876 use crate::api::v1alpha1::{
857877 saved_playlist_service_client::SavedPlaylistServiceClient, PlaySavedPlaylistRequest,
858878 };
859859- let mut c = SavedPlaylistServiceClient::connect(URL).await?;
879879+ let mut c = SavedPlaylistServiceClient::connect(url()).await?;
860880 c.play_saved_playlist(PlaySavedPlaylistRequest { playlist_id })
861881 .await?;
862882 Ok(())
···866886 use crate::api::v1alpha1::{
867887 smart_playlist_service_client::SmartPlaylistServiceClient, PlaySmartPlaylistRequest,
868888 };
869869- let mut c = SmartPlaylistServiceClient::connect(URL).await?;
889889+ let mut c = SmartPlaylistServiceClient::connect(url()).await?;
870890 c.play_smart_playlist(PlaySmartPlaylistRequest { id: playlist_id })
871891 .await?;
872892 Ok(())
···876896 use crate::api::v1alpha1::{
877897 saved_playlist_service_client::SavedPlaylistServiceClient, DeleteSavedPlaylistRequest,
878898 };
879879- let mut c = SavedPlaylistServiceClient::connect(URL).await?;
899899+ let mut c = SavedPlaylistServiceClient::connect(url()).await?;
880900 c.delete_saved_playlist(DeleteSavedPlaylistRequest { id: playlist_id })
881901 .await?;
882902 Ok(())
···890910 use crate::api::v1alpha1::{
891911 saved_playlist_service_client::SavedPlaylistServiceClient, UpdateSavedPlaylistRequest,
892912 };
893893- let mut c = SavedPlaylistServiceClient::connect(URL).await?;
913913+ let mut c = SavedPlaylistServiceClient::connect(url()).await?;
894914 c.update_saved_playlist(UpdateSavedPlaylistRequest {
895915 id,
896916 name,
···910930 saved_playlist_service_client::SavedPlaylistServiceClient,
911931 RemoveTrackFromSavedPlaylistRequest,
912932 };
913913- let mut c = SavedPlaylistServiceClient::connect(URL).await?;
933933+ let mut c = SavedPlaylistServiceClient::connect(url()).await?;
914934 c.remove_track_from_saved_playlist(RemoveTrackFromSavedPlaylistRequest {
915935 playlist_id,
916936 track_id,
···923943 use crate::api::v1alpha1::{
924944 saved_playlist_service_client::SavedPlaylistServiceClient, PlaySavedPlaylistRequest,
925945 };
926926- let mut c = SavedPlaylistServiceClient::connect(URL).await?;
946946+ let mut c = SavedPlaylistServiceClient::connect(url()).await?;
927947 c.play_saved_playlist(PlaySavedPlaylistRequest { playlist_id })
928948 .await?;
929949 // After loading, shuffle
930950 use crate::api::v1alpha1::{
931951 playlist_service_client::PlaylistServiceClient, ShufflePlaylistRequest,
932952 };
933933- let mut pc = PlaylistServiceClient::connect(URL).await?;
953953+ let mut pc = PlaylistServiceClient::connect(url()).await?;
934954 pc.shuffle_playlist(ShufflePlaylistRequest { start_index: 0 })
935955 .await?;
936956 Ok(())
···939959// ── Device output API ─────────────────────────────────────────────────────────
940960941961pub async fn fetch_devices() -> Result<Vec<DeviceItem>> {
942942- let body = reqwest::get(format!("{HTTP_URL}/devices"))
962962+ let body = reqwest::get(format!("{}/devices", http_url()))
943963 .await?
944964 .text()
945965 .await?;
···950970pub async fn connect_device(id: String) -> Result<()> {
951971 let client = reqwest::Client::new();
952972 client
953953- .put(format!("{HTTP_URL}/devices/{id}/connect"))
973973+ .put(format!("{}/devices/{id}/connect", http_url()))
954974 .send()
955975 .await?;
956976 Ok(())
···959979pub async fn disconnect_device(id: String) -> Result<()> {
960980 let client = reqwest::Client::new();
961981 client
962962- .put(format!("{HTTP_URL}/devices/{id}/disconnect"))
982982+ .put(format!("{}/devices/{id}/disconnect", http_url()))
963983 .send()
964984 .await?;
965985 Ok(())
+16
gpui/src/controller.rs
···3838 rt.spawn(crate::client::run_current_track_stream(tx.clone()));
3939 rt.spawn(crate::client::run_playlist_stream(tx.clone()));
40404141+ // Re-run one-shot syncs whenever the user switches the active server.
4242+ let tx_for_switch = tx.clone();
4343+ let notify_for_switch = crate::server::server_notify();
4444+ rt.spawn(async move {
4545+ loop {
4646+ notify_for_switch.notified().await;
4747+ // Small delay to let the new server's gRPC port come up.
4848+ tokio::time::sleep(std::time::Duration::from_millis(500)).await;
4949+ crate::client::run_library_sync(tx_for_switch.clone()).await;
5050+ crate::client::run_liked_tracks_sync(tx_for_switch.clone()).await;
5151+ crate::client::run_artist_images_sync(tx_for_switch.clone()).await;
5252+ crate::client::run_settings_sync(tx_for_switch.clone()).await;
5353+ crate::client::run_resume_info_sync(tx_for_switch.clone()).await;
5454+ }
5555+ });
5656+4157 // Initialise OS media controls on the main thread (required by macOS).
4258 let now_playing = NowPlayingManager::new().map(|m| Arc::new(Mutex::new(m)));
4359
+1
gpui/src/main.rs
···44pub mod controller;
55pub mod http_client;
66pub mod now_playing;
77+pub mod server;
78pub mod startup;
89pub mod state;
910pub mod ui;
+152
gpui/src/server.rs
···11+use std::sync::{Arc, LazyLock, RwLock};
22+33+#[derive(Clone, Debug)]
44+pub struct ServerInfo {
55+ pub name: String,
66+ pub host: String,
77+ pub grpc_port: u16,
88+ pub graphql_port: u16,
99+ pub http_port: u16,
1010+}
1111+1212+impl ServerInfo {
1313+ pub fn localhost() -> Self {
1414+ Self {
1515+ name: "localhost".to_string(),
1616+ host: "127.0.0.1".to_string(),
1717+ grpc_port: 6061,
1818+ graphql_port: 6062,
1919+ http_port: 6063,
2020+ }
2121+ }
2222+2323+ pub fn grpc_url(&self) -> String {
2424+ format!("http://{}:{}", self.host, self.grpc_port)
2525+ }
2626+2727+ pub fn graphql_url(&self) -> String {
2828+ format!("http://{}:{}", self.host, self.graphql_port)
2929+ }
3030+3131+ pub fn http_url(&self) -> String {
3232+ format!("http://{}:{}", self.host, self.http_port)
3333+ }
3434+3535+ pub fn display_name(&self) -> String {
3636+ if self.host == "127.0.0.1" || self.host == "localhost" {
3737+ "localhost".to_string()
3838+ } else if !self.name.is_empty() && self.name != self.host {
3939+ format!("{} ({})", self.name, self.host)
4040+ } else {
4141+ self.host.clone()
4242+ }
4343+ }
4444+4545+ pub fn is_localhost(&self) -> bool {
4646+ self.host == "127.0.0.1" || self.host == "localhost"
4747+ }
4848+}
4949+5050+static CURRENT_SERVER: LazyLock<RwLock<ServerInfo>> =
5151+ LazyLock::new(|| RwLock::new(ServerInfo::localhost()));
5252+5353+static SERVER_NOTIFY: LazyLock<Arc<tokio::sync::Notify>> =
5454+ LazyLock::new(|| Arc::new(tokio::sync::Notify::new()));
5555+5656+pub fn get_grpc_url() -> String {
5757+ CURRENT_SERVER.read().unwrap().grpc_url()
5858+}
5959+6060+pub fn get_http_url() -> String {
6161+ CURRENT_SERVER.read().unwrap().http_url()
6262+}
6363+6464+pub fn get_covers_base() -> String {
6565+ let s = CURRENT_SERVER.read().unwrap();
6666+ format!("http://{}:{}/covers/", s.host, s.graphql_port)
6767+}
6868+6969+pub fn set_server(info: ServerInfo) {
7070+ *CURRENT_SERVER.write().unwrap() = info;
7171+ SERVER_NOTIFY.notify_waiters();
7272+}
7373+7474+pub fn current_server() -> ServerInfo {
7575+ CURRENT_SERVER.read().unwrap().clone()
7676+}
7777+7878+/// Returns a handle to the server-switch notification.
7979+/// Callers can `.await` `.notified()` to be woken immediately when the active server changes.
8080+pub fn server_notify() -> Arc<tokio::sync::Notify> {
8181+ SERVER_NOTIFY.clone()
8282+}
8383+8484+/// Blocking mDNS scan — returns all discovered rockboxd instances within `timeout`.
8585+/// Looks for `_rockbox._tcp.local.` services; service names prefixed with `grpc-`,
8686+/// `graphql-`, or `http-` update the corresponding port for that host.
8787+pub fn scan_mdns(timeout: std::time::Duration) -> Vec<ServerInfo> {
8888+ use mdns_sd::{ServiceDaemon, ServiceEvent};
8989+ use std::collections::HashMap;
9090+9191+ let mdns = match ServiceDaemon::new() {
9292+ Ok(m) => m,
9393+ Err(_) => return vec![],
9494+ };
9595+ let receiver = match mdns.browse("_rockbox._tcp.local.") {
9696+ Ok(r) => r,
9797+ Err(_) => return vec![],
9898+ };
9999+100100+ let mut by_host: HashMap<String, ServerInfo> = HashMap::new();
101101+ let deadline = std::time::Instant::now() + timeout;
102102+103103+ loop {
104104+ let now = std::time::Instant::now();
105105+ if now >= deadline {
106106+ break;
107107+ }
108108+ let remaining = deadline - now;
109109+ let poll = remaining.min(std::time::Duration::from_millis(100));
110110+111111+ match receiver.recv_timeout(poll) {
112112+ Ok(ServiceEvent::ServiceResolved(info)) => {
113113+ // Prefer an IPv4 address; only fall back to the hostname when none is present.
114114+ // Hostnames like `foo.local` may resolve to an IPv6 link-local address, which
115115+ // tonic's http2 transport rejects or connects to the wrong interface.
116116+ // get_addresses() returns &HashSet<Ipv4Addr> — all entries are IPv4.
117117+ // Prefer the raw IP over the .local hostname to avoid IPv6 resolution.
118118+ let host = info
119119+ .get_addresses()
120120+ .iter()
121121+ .next()
122122+ .map(|a| a.to_string())
123123+ .unwrap_or_else(|| info.get_hostname().trim_end_matches('.').to_string());
124124+ let port = info.get_port();
125125+ let fullname = info.get_fullname().to_string();
126126+127127+ let entry = by_host.entry(host.clone()).or_insert_with(|| ServerInfo {
128128+ name: host.clone(),
129129+ host: host.clone(),
130130+ grpc_port: 6061,
131131+ graphql_port: 6062,
132132+ http_port: 6063,
133133+ });
134134+135135+ if fullname.starts_with("grpc-") {
136136+ entry.grpc_port = port;
137137+ } else if fullname.starts_with("graphql-") {
138138+ entry.graphql_port = port;
139139+ } else if fullname.starts_with("http-") {
140140+ entry.http_port = port;
141141+ }
142142+ }
143143+ Ok(_) | Err(_) => {}
144144+ }
145145+ }
146146+147147+ // Exclude localhost — handled separately as the priority default.
148148+ by_host
149149+ .into_values()
150150+ .filter(|s| s.host != "127.0.0.1" && s.host != "localhost")
151151+ .collect()
152152+}
+18-9
gpui/src/startup.rs
···11use std::net::TcpStream;
22use std::time::Duration;
3344-const GRPC_ADDR: &str = "127.0.0.1:6061";
44+const LOCALHOST_GRPC: &str = "127.0.0.1:6061";
55const CONNECT_TIMEOUT: Duration = Duration::from_millis(500);
66+const MDNS_SCAN_TIMEOUT: Duration = Duration::from_secs(3);
6778#[derive(Clone, Copy, Debug)]
89pub enum StartupError {
910 /// `rockboxd` binary not found anywhere in PATH or common locations.
1011 NotInstalled,
1111- /// Binary found but daemon is not listening on the gRPC port.
1212+ /// Binary found but no daemon is reachable (localhost or network).
1213 NotRunning,
1314}
14151515-/// Run all pre-flight checks. Returns `None` when everything is ready.
1616+/// Run all pre-flight checks. Returns `None` when everything is ready.
1717+///
1818+/// Priority:
1919+/// 1. localhost:6061 — fastest, no mDNS overhead.
2020+/// 2. mDNS scan — discovers remote instances on the local network (blocks up
2121+/// to MDNS_SCAN_TIMEOUT); sets the active server via `crate::server::set_server`.
1622pub fn check() -> Option<StartupError> {
1723 if !is_installed() {
1824 return Some(StartupError::NotInstalled);
1925 }
2020- if !is_running() {
2121- return Some(StartupError::NotRunning);
2626+ if is_running() {
2727+ return None;
2828+ }
2929+ let discovered = crate::server::scan_mdns(MDNS_SCAN_TIMEOUT);
3030+ if let Some(server) = discovered.into_iter().next() {
3131+ crate::server::set_server(server);
3232+ return None;
2233 }
2323- None
3434+ Some(StartupError::NotRunning)
2435}
25362637pub fn is_installed() -> bool {
2727- // Walk PATH explicitly — app bundles have a stripped environment.
2838 if let Ok(path_var) = std::env::var("PATH") {
2939 for dir in path_var.split(':') {
3040 if std::path::Path::new(dir).join("rockboxd").exists() {
···3242 }
3343 }
3444 }
3535- // Fallback: common macOS install locations regardless of PATH.
3645 [
3746 "/usr/local/bin/rockboxd",
3847 "/opt/homebrew/bin/rockboxd",
···4453}
45544655pub fn is_running() -> bool {
4747- TcpStream::connect_timeout(&GRPC_ADDR.parse().unwrap(), CONNECT_TIMEOUT).is_ok()
5656+ TcpStream::connect_timeout(&LOCALHOST_GRPC.parse().unwrap(), CONNECT_TIMEOUT).is_ok()
4857}