diff --git a/cli/__pycache__/sjnmusiccpython-314.pyc b/cli/__pycache__/sjnmusiccpython-314.pyc new file mode 100644 index 0000000..6944f9b Binary files /dev/null and b/cli/__pycache__/sjnmusiccpython-314.pyc differ diff --git a/cli/sjnmusic b/cli/sjnmusic index 50df4a9..6f7a31c 100755 --- a/cli/sjnmusic +++ b/cli/sjnmusic @@ -139,7 +139,7 @@ def join_query(args) -> str: return " ".join(parts).strip() -def call(host, port, method, route, body=None, query=None): +def call(host, port, method, route, body=None, query=None, timeout=15): qs = "" if query: qs = "?" + urllib.parse.urlencode(query) @@ -153,7 +153,7 @@ def call(host, port, method, route, body=None, query=None): req = urllib.request.Request(url=url, data=data, headers=headers, method=method) try: - with urllib.request.urlopen(req, timeout=15) as resp: + with urllib.request.urlopen(req, timeout=timeout) as resp: payload = resp.read().decode("utf-8") return resp.status, payload except urllib.error.HTTPError as e: @@ -333,8 +333,13 @@ def main(argv=None): query = {"q": q} if limit is not None and limit > 0: query["limit"] = str(limit) + # yt-dlp searches can easily take 30+ seconds on a slow network + # (the daemon calls `ytsearchN:QUERY -j` which hits YouTube). + # The default 15 s `urllib` timeout short-circuits the picker + # before the daemon even returns, so give this one endpoint a + # generous budget. All other endpoints keep the 15 s default. status, payload = call( - host, port, "GET", "/search/yt", query=query + host, port, "GET", "/search/yt", query=query, timeout=120 ) obj = try_parse(payload) results = (obj or {}).get("results") or [] diff --git a/daemon/src/server.rs b/daemon/src/server.rs index fc7a9b2..3a6c5c3 100644 --- a/daemon/src/server.rs +++ b/daemon/src/server.rs @@ -180,7 +180,15 @@ fn handle( .get("limit") .and_then(|s| s.parse::().ok()) .unwrap_or(state.search_count); - match state.search_yt_sync(&q, limit) { + // Release the synchronous state guard BEFORE calling + // search_yt_sync — yt-dlp can take 30+ seconds on a slow + // network and holding the daemon mutex here would freeze + // every other endpoint (including the GUI's `/init`, + // `/downloads`, `/now-playing` polls) for the entire + // duration. Mirrors the drop-then-call pattern that + // `init_sync` already uses. + drop(state); + match DaemonState::search_yt_sync(&q, limit) { Ok(results) => json_response(200, &serde_json::json!({ "query": q, "limit": limit, diff --git a/daemon/src/state.rs b/daemon/src/state.rs index 2a3c540..74f02e7 100644 --- a/daemon/src/state.rs +++ b/daemon/src/state.rs @@ -556,7 +556,16 @@ impl DaemonState { /// non-zero exit are tolerated as much as possible: an empty list is /// returned when nothing parsed, while a yt-dlp startup failure surfaces /// a descriptive error string so the CLI can show it to the user. - pub fn search_yt_sync(&self, query: &str, limit: usize) -> Result, String> { + /// + /// Lives as an associated function (no `&self`) so the HTTP handler can + /// release the daemon state mutex before spawning yt-dlp. Earlier + /// revisions invoked this as `state.search_yt_sync(...)` *while* + /// holding the single-threaded `tiny_http` request handler's state + /// guard, which froze the entire daemon (including `/init`, `/queue`, + /// `/now-playing`) for the full duration of a yt-dlp search — typically + /// 30+ seconds on a slow network. Mirrors the lock-free pattern already + /// used for `init_sync` below. + pub fn search_yt_sync(query: &str, limit: usize) -> Result, String> { let limit = limit.max(1); let src = format!("ytsearch{limit}:{query}"); debug!("yt-dlp search: {src}"); diff --git a/gui-rs/src/daemon.rs b/gui-rs/src/daemon.rs index 5a1e7c0..b5977e3 100644 --- a/gui-rs/src/daemon.rs +++ b/gui-rs/src/daemon.rs @@ -330,12 +330,23 @@ impl DaemonClient { } pub fn search_yt(&self, q: &str, limit: usize) -> Result, DaemonError> { + // `/search/yt` shells out to `yt-dlp ytsearchN:...` on the daemon + // side, which can easily take 30+ seconds on a slow network — well + // past the agent's 8 s default. Override per-request so the picker + // doesn't ghost-fail with a transport timeout. All other + // endpoints keep the default; the daemon's state lock is no + // longer held during the search (see daemon state.rs), so other + // GETs (now-playing, downloads, queue) keep flowing while a + // search is in flight. Ok(self - .get::(&format!( - "/search/yt?q={}&limit={}", - urlencoding::encode(q), - limit - ))? + .get_with_timeout::( + &format!( + "/search/yt?q={}&limit={}", + urlencoding::encode(q), + limit + ), + Duration::from_secs(120), + )? .results) } @@ -484,7 +495,19 @@ impl DaemonClient { // ---------------------------------------------------------------- fn get Deserialize<'de>>(&self, path: &str) -> Result { - self._request_json("GET", path, None) + self._request_json("GET", path, None, None) + } + + /// Like `get` but lets callers override the per-request timeout. Used + /// only for endpoints that back onto slow subprocess calls (currently + /// just `/search/yt`, since yt-dlp's first network round-trip can blow + /// past the 8 s agent default). + fn get_with_timeout Deserialize<'de>>( + &self, + path: &str, + timeout: Duration, + ) -> Result { + self._request_json("GET", path, None, Some(timeout)) } fn post_json Deserialize<'de>>( @@ -492,7 +515,7 @@ impl DaemonClient { path: &str, body: &serde_json::Value, ) -> Result { - self._request_json("POST", path, Some(body)) + self._request_json("POST", path, Some(body), None) } fn patch Deserialize<'de>>( @@ -500,7 +523,7 @@ impl DaemonClient { path: &str, body: &serde_json::Value, ) -> Result { - self._request_json("PATCH", path, Some(body)) + self._request_json("PATCH", path, Some(body), None) } fn del_json Deserialize<'de>>( @@ -508,11 +531,11 @@ impl DaemonClient { path: &str, body: &serde_json::Value, ) -> Result { - self._request_json("DELETE", path, Some(body)) + self._request_json("DELETE", path, Some(body), None) } fn del Deserialize<'de>>(&self, path: &str) -> Result { - self._request_json("DELETE", path, None) + self._request_json("DELETE", path, None, None) } fn _request_json Deserialize<'de>>( @@ -520,6 +543,7 @@ impl DaemonClient { method: &str, path: &str, body: Option<&serde_json::Value>, + timeout: Option, ) -> Result { let url = format!("{}{}", self.base, path); let req = match method { @@ -535,6 +559,10 @@ impl DaemonClient { ))) } }; + let req = match timeout { + Some(t) => req.timeout(t), + None => req, + }; let req = match body { Some(b) => req.send_json(ureq::json!(b.clone())), None => req.call(),