"Needs the app" is gone. It named what the cold service could not do — the resolver and the Soulseek session live in the page, and a car that starts the app cold has no page — and Kyle's answer to the label was the right one: a tap should find a copy and play it, in the background, and a playlist should keep going through rows that are not here exactly as the page keeps going. headless_slsk.rs is a Soulseek session for a process with no page: the stored account, one search (artist + simplified title, the server's excluded phrases stripped), copies ranked by rules close to the page's songCandidates — the title in the filename, streamable formats, a duration within eight per cent, lossless over 320 over the rest, the artist in the path, a free upload slot before anything else — and a download into the same stream cache the page uses. Its own tokio runtime: a car-started process has no Tauri one. headless.rs is the page's queue in Rust. resolve_playable queues EVERY row of a playlist in order, a row not here with its remembered peer file and no path. The engine starts a row from its file when it is whole, from the fetch already filling its cache file when one is, and otherwise finds it on the network right then (the remembered peer first, then the search), playing the bytes as they land; a peer that sends nothing for fifteen seconds is dropped for the next copy, twice, and when a row has no copy anywhere the queue moves on — only the last row's failure reaches the card. The next three rows are fetched ahead of time, four seconds after a start so nothing competes with the track that is starting, and when the immediate next is whole before the current one ends it is appended to the sink and the boundary is gapless — the page's preloadLocal and preloadCachedStream in one. Fetches are keyed by cache file, not index, so a shuffle cannot start the same file from the same peer twice. The card shows the title at once with a spinner (STATE_BUFFERING through headlessState), then the track; every source is remembered in the mirror, so the row is "here" next time. One session per process: when the app opens over it, login_inner ADOPTS the client and reads its events through subscribe() instead of logging in again — two logins from one address in quick succession is what the server throttles — and the fetches in flight become the page's own preloads, so the row the car was fetching ahead lands gaplessly under the page too. Covers, always, like the desktop. Thirty of GhettoTechie's forty rows have no catalog cover, and the car drew the app icon over them. In the cold process the cover embedded in the file is extracted — the desktop's own tagger::extract_art, keyed by the audio path under <library>/.soulstream/stream-art/by-file/, a miss remembered as an empty file — for browse rows, the card and the Continue row, and a row fetched cold gets the same once 200 kB of it have landed. On the phone, coverart.svelte.js is the desktop's playlist backfill as a background walk over every playlist: the file before the catalog (the row's own cached copy, persisted as art_path), then the ONE strict ladder the import review uses (findCatalogTrack, persisted as art_url + track_id) — never a loose name lookup. After boot, on every playlist change, every six hours, the open page's rows first, a miss remembered for a day and only when the catalog answered. The player does the same for the track that is playing (ensureStreamArt: embedded, then catalog), makes the URL the entry's art key and hands it to the walk to persist. The page's own hole is closed too: a streamed row with no cover got no art key at all, and where it had one the extractor's FILE PATH was stored where a data URL belongs; such rows get a "stream:" key and the file is read as a thumbnail, and a stream row's art_path now draws on the phone. A car that starts the app cold has no page to walk for it, so the cold process asks the catalog itself. cover_hunt.rs is catalogMatch.js in Rust — the same Deezer field queries through the metadata crate, a match only when the artist agrees, the base title agrees, the version qualifiers are compatible and a known duration does not contradict; the two are meant to stay in step — fed by every browse and every queue built from a playlist. A blank stream row is answered from what was asked before (stream-art/catalog.json, a miss for a day and only when the catalog answered), else queued for one thread that looks rows up one at a time behind the client's own Deezer pacing, writes the answer on the row (art_url and the track id, only ever filling a NULL; the library is WAL and the page's writer is another connection), and re-lists the playlist on the car. The track that is playing gets the same, through an entry that moves only the card's art and never its position. The cold Soulseek search is the page's two-rung ladder as well: artist plus the simplified title, then the title alone with the artist required in the peer's path; the hunt's last rung is the page's last rung too, the artist's whole shelf flattened to tracks, for the records the search index buries — spent only on the track that is playing, bounded to forty seconds, and the cover memory carries the matcher's version so a "no" from an older matcher is no answer once a rung is added. Two things the head unit found in the seam. media3's default onSubscribe answers "not supported", so nobody was ever subscribed and the re-list notice reached nobody; LibraryTree accepts subscriptions now, and covers land on the car's list within seconds of being found. And the Soulseek client spawns a download's transfer task with tokio::spawn, which needs a runtime on the calling thread — the engine's resolver thread is not a Tauri command, the spawn panicked, the thread died without a word, and the card sat on BUFFERING; every client call from an engine thread runs under the cold runtime's enter() now. The phone's artist page never ran the MusicBrainz pass. It built its discography from Deezer's list alone, so the releases Deezer's artist listing omits — EPs, and whole albums — never appeared, nor the authoritative release types or the guest-appearance and co-tenant arbitration. The desktop's identity merge is a plain module now (lib/artistIdentity.js, moved out of state.svelte.js verbatim, the desktop importing it back), and the phone runs it after the page is on screen — one implementation for both surfaces. What the restarted head unit found, all in the seam between a car session and the page — and one bug older than this branch. The pump's calls into Kotlin never worked. "auto advanced to" has not appeared in a single log while cold queues advanced through whole albums under a card stuck on track one, and the new end-of-queue notice did not fire either. FindClass on the self-attached pump thread resolves through the system class loader, which cannot see com.soulstream.mediasession .AutoNative; the lookup failed and the exception was cleared, silently, every time. The class is taken from the object Kotlin hands every JNI entry — on a Java thread, through the app's own loader — and kept as a global reference; every pump call goes through call_auto_native. Autoplay on connection worked unprompted and resumed Starboy at 3:50 of 3:50: the page writes the position one last time when a track runs out, so a resume point can sit at the end. resume_queue treats a position within two seconds of the duration as "the next item from the top", keeps every entry (one not here is found on reach, like any other), and the headless engine reports end-of-queue to Kotlin, which turns the card to paused. A seek on the page reached the resume point only at the pump's next five-second flush; seekSecs writes the session at once. Opening the app over a cold car session handed the page the right queue and the page called it a paused session that needed re-sourcing: the transport snapshot it reads is built only from events after the page subscribes, and an engine already playing sends no Playing. The bar said paused, the card said PAUSED, the audio kept going. playback::init seeds the snapshot from the engine's own progress when it adopts a running engine, so the page follows what is sounding. preview::init opened two more output streams at every launch for the desktop's hover previews, which the phone never calls — three live AAudio tracks on our uid with one sounding. It is gated off on Android.
437 lines
18 KiB
Rust
437 lines
18 KiB
Rust
//! A Soulseek session for a process with no page — so a row the phone does
|
|
//! not hold yet can be found and streamed from the car.
|
|
//!
|
|
//! "Needs the app" was the honest label for what the cold service could not
|
|
//! do: the resolver and the Soulseek session live in the page, and a car
|
|
//! that starts the app cold has no page. Kyle's answer to that label was the
|
|
//! right one — a tap should find a copy and play it, in the background —
|
|
//! and this is the smallest thing that does it: the stored account signs
|
|
//! in from here, one search, the best copy by rules close to the page's
|
|
//! (`src/lib/sources.js` songCandidates), a download into the same stream
|
|
//! cache the page uses, and the engine plays the bytes as they land, the
|
|
//! way `play_stream` does for the page.
|
|
//!
|
|
//! **One session per process, and the page adopts it.** When the app opens
|
|
//! over a car session, `commands::login_inner` takes this client instead of
|
|
//! logging in again (two logins from one address in quick succession is
|
|
//! what the server throttles), and every event from here on is forwarded to
|
|
//! the page's own pump through [`subscribe`]. The download in flight keeps
|
|
//! going: dropping a `Client` handle does not tear the connection down.
|
|
//!
|
|
//! Its own tokio runtime, because there is no Tauri one in a process the
|
|
//! car started; the two coexist when the app comes up.
|
|
|
|
use player::StreamProgress;
|
|
use soulseek_client::{Client, Config, DownloadState, Event};
|
|
use soulseek_proto::types::SearchResponse;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
use std::time::{Duration, Instant};
|
|
use tokio::sync::mpsc;
|
|
|
|
use crate::auto_browse::warn;
|
|
|
|
static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
|
|
/// The handle, until the page adopts it.
|
|
static CLIENT: Mutex<Option<Client>> = Mutex::new(None);
|
|
/// Signed in, from this process's point of view.
|
|
static CONNECTED: Mutex<Option<String>> = Mutex::new(None);
|
|
/// token → the responses that came back for it.
|
|
static SEARCHES: Mutex<Vec<(u32, Vec<SearchResponse>)>> = Mutex::new(Vec::new());
|
|
/// download id → the progress the engine is reading.
|
|
static DOWNLOADS: Mutex<Vec<(u64, Arc<StreamProgress>)>> = Mutex::new(Vec::new());
|
|
/// The server's excluded search phrases, lowercased — a query containing
|
|
/// one returns nothing network-wide.
|
|
static EXCLUDED: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
|
/// Where the page's pump listens once it exists.
|
|
static SUBSCRIBER: Mutex<Option<mpsc::UnboundedSender<Event>>> = Mutex::new(None);
|
|
|
|
pub(crate) fn rt() -> &'static tokio::runtime::Runtime {
|
|
RT.get_or_init(|| {
|
|
tokio::runtime::Builder::new_multi_thread()
|
|
.worker_threads(2)
|
|
.enable_all()
|
|
.thread_name("slsk-cold")
|
|
.build()
|
|
.expect("a tokio runtime for the cold Soulseek session")
|
|
})
|
|
}
|
|
|
|
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
|
m.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
/// True once this process has signed in (and the page has not yet adopted
|
|
/// the session — after adoption the page's own state is the truth).
|
|
pub fn connected() -> bool {
|
|
lock(&CONNECTED).is_some() && lock(&CLIENT).is_some()
|
|
}
|
|
|
|
/// The library root, the way auto_browse derives it: `download_dir` from
|
|
/// config.json, else `<data_dir>/Library`.
|
|
fn library_dir(data_dir: &Path) -> PathBuf {
|
|
std::fs::read_to_string(data_dir.join("config.json"))
|
|
.ok()
|
|
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
|
.and_then(|v| v.get("download_dir").and_then(|d| d.as_str()).map(str::to_owned))
|
|
.filter(|s| !s.is_empty())
|
|
.map_or_else(|| data_dir.join("Library"), PathBuf::from)
|
|
}
|
|
|
|
/// Sign in with the stored account, once. Blocks the caller — a car tap
|
|
/// arrives on a worker thread that may wait — for up to ~20 s.
|
|
pub fn ensure_session(data_dir: &Path) -> Result<(), String> {
|
|
if connected() {
|
|
return Ok(());
|
|
}
|
|
crate::config::remember_secrets_dir_path(data_dir);
|
|
let cfg_json = std::fs::read_to_string(data_dir.join("config.json"))
|
|
.ok()
|
|
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
|
.unwrap_or_default();
|
|
let username = cfg_json
|
|
.get("username")
|
|
.and_then(|u| u.as_str())
|
|
.map(str::to_owned)
|
|
.filter(|u| !u.is_empty())
|
|
.ok_or("no Soulseek account on this phone")?;
|
|
let password = crate::config::load_password(&username)
|
|
.ok_or("the Soulseek account is not signed in on this phone")?;
|
|
|
|
let mut cfg = Config::default();
|
|
if let Some(p) = cfg_json.get("listen_port").and_then(|p| p.as_u64()) {
|
|
cfg.listen_port = p as u16;
|
|
}
|
|
let downloads = library_dir(data_dir);
|
|
cfg.downloads_dir = downloads.clone();
|
|
cfg.shared_folders = vec![downloads];
|
|
cfg.description = "Shared with SoulStream".into();
|
|
|
|
let (client, events) = rt()
|
|
.block_on(async {
|
|
let (client, events) =
|
|
tokio::time::timeout(Duration::from_secs(15), Client::connect(cfg))
|
|
.await
|
|
.map_err(|_| "could not reach the Soulseek server in time".to_string())?
|
|
.map_err(|e| format!("could not reach the Soulseek server: {e}"))?;
|
|
tokio::time::timeout(Duration::from_secs(15), client.login(&username, &password))
|
|
.await
|
|
.map_err(|_| "Soulseek did not answer the sign-in in time".to_string())?
|
|
.map_err(|e| format!("Soulseek refused the sign-in: {e}"))?;
|
|
Ok::<_, String>((client, events))
|
|
})?;
|
|
warn(&format!("cold soulseek: signed in as {username}"));
|
|
*lock(&CLIENT) = Some(client);
|
|
*lock(&CONNECTED) = Some(username);
|
|
rt().spawn(consume(events));
|
|
Ok(())
|
|
}
|
|
|
|
/// The one consumer of the session's events: search replies and download
|
|
/// progress land in the tables above, and everything is forwarded to the
|
|
/// page's pump once one has subscribed.
|
|
async fn consume(mut events: mpsc::UnboundedReceiver<Event>) {
|
|
while let Some(ev) = events.recv().await {
|
|
match &ev {
|
|
Event::SearchResult { token, response } => {
|
|
let mut s = lock(&SEARCHES);
|
|
if let Some((_, list)) = s.iter_mut().find(|(t, _)| t == token) {
|
|
list.push(response.clone());
|
|
}
|
|
}
|
|
Event::Download(u) => {
|
|
let progress = lock(&DOWNLOADS)
|
|
.iter()
|
|
.find(|(id, _)| *id == u.id)
|
|
.map(|(_, p)| p.clone());
|
|
if let Some(p) = progress {
|
|
let (done, failed) = match &u.state {
|
|
DownloadState::Complete { .. } => (true, None),
|
|
DownloadState::Failed { reason } => (false, Some(reason.clone())),
|
|
DownloadState::Cancelled => (false, Some("stream cancelled".to_string())),
|
|
_ => (false, None),
|
|
};
|
|
p.update(u.received, done, failed);
|
|
if done {
|
|
lock(&DOWNLOADS).retain(|(id, _)| *id != u.id);
|
|
}
|
|
}
|
|
}
|
|
Event::ExcludedSearchPhrases(p) => {
|
|
*lock(&EXCLUDED) = p.iter().map(|s| s.to_lowercase()).collect();
|
|
}
|
|
Event::Disconnected { reason } => {
|
|
warn(&format!("cold soulseek: disconnected ({reason})"));
|
|
*lock(&CONNECTED) = None;
|
|
}
|
|
_ => {}
|
|
}
|
|
if let Some(tx) = lock(&SUBSCRIBER).as_ref() {
|
|
let _ = tx.send(ev);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The page taking the session over: the client handle, and the account.
|
|
/// `None` when there is nothing to adopt (the page then signs in itself).
|
|
pub fn adopt() -> Option<(Client, String)> {
|
|
let client = lock(&CLIENT).take()?;
|
|
let user = lock(&CONNECTED).clone()?;
|
|
Some((client, user))
|
|
}
|
|
|
|
/// Where the page's pump reads this session's events from after adoption.
|
|
pub fn subscribe() -> mpsc::UnboundedReceiver<Event> {
|
|
let (tx, rx) = mpsc::unbounded_channel();
|
|
*lock(&SUBSCRIBER) = Some(tx);
|
|
rx
|
|
}
|
|
|
|
/* ── finding a copy ───────────────────────────────────────────────────── */
|
|
|
|
/// Streamable, the page's list (RESOLVER_STREAMABLE).
|
|
const STREAMABLE: [&str; 8] = ["flac", "mp3", "m4a", "ogg", "opus", "wav", "aiff", "aif"];
|
|
const LOSSLESS: [&str; 4] = ["flac", "wav", "aiff", "aif"];
|
|
|
|
/// Lowercased, punctuation folded to spaces, whitespace collapsed — the
|
|
/// page's normText, near enough for a contains() test.
|
|
fn norm(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
let mut space = true;
|
|
for c in s.chars().flat_map(|c| c.to_lowercase()) {
|
|
if c.is_alphanumeric() {
|
|
out.push(c);
|
|
space = false;
|
|
} else if !space {
|
|
out.push(' ');
|
|
space = true;
|
|
}
|
|
}
|
|
out.trim().to_owned()
|
|
}
|
|
|
|
/// The page's `simplifyTitle`, for the query: the title up to the first
|
|
/// `; : ( [`, and its first six words. The full title still has to be in
|
|
/// the filename.
|
|
fn simplify(title: &str) -> String {
|
|
let first = title
|
|
.split(|c| c == ';' || c == ':' || c == '(' || c == '[')
|
|
.next()
|
|
.unwrap_or("")
|
|
.trim();
|
|
let base = if first.is_empty() { title } else { first };
|
|
let words: Vec<&str> = base.split_whitespace().take(6).collect();
|
|
let out = words.join(" ");
|
|
if out.is_empty() {
|
|
title.to_owned()
|
|
} else {
|
|
out
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Pick {
|
|
pub username: String,
|
|
pub path: String,
|
|
pub size: u64,
|
|
pub duration: Option<f64>,
|
|
pub score: i64,
|
|
pub slot: bool,
|
|
}
|
|
|
|
/// The best copies of a track on the network, best first. With
|
|
/// `require_identity` — the page's `requireIdentityEvidence`, for a rung
|
|
/// that searched by title alone — the artist has to appear somewhere in
|
|
/// the peer's path, or the copy is another artist's song of that name.
|
|
fn candidates(
|
|
responses: &[SearchResponse],
|
|
title: &str,
|
|
artist: &str,
|
|
expect_secs: Option<f64>,
|
|
require_identity: bool,
|
|
) -> Vec<Pick> {
|
|
let want = norm(title);
|
|
let want_short = norm(&simplify(title));
|
|
let artist_key = norm(artist);
|
|
let mut out: Vec<Pick> = Vec::new();
|
|
for r in responses {
|
|
for f in &r.files {
|
|
let name = f.filename.rsplit('\\').next().unwrap_or(&f.filename);
|
|
let stem = name.rsplit_once('.').map(|(s, _)| s).unwrap_or(name);
|
|
let nname = norm(stem);
|
|
if !(nname.contains(&want) || (!want_short.is_empty() && nname.contains(&want_short))) {
|
|
continue;
|
|
}
|
|
let ext = f.extension.to_lowercase();
|
|
if !STREAMABLE.contains(&ext.as_str()) {
|
|
continue;
|
|
}
|
|
if f.size == 0 || f.size > 400 * 1024 * 1024 {
|
|
continue;
|
|
}
|
|
let bitrate = f.attributes.iter().find(|(k, _)| *k == 0).map(|(_, v)| *v);
|
|
let duration = f.attributes.iter().find(|(k, _)| *k == 1).map(|(_, v)| *v as f64);
|
|
if let (Some(want), Some(got)) = (expect_secs, duration) {
|
|
// the page's durationMismatch: a copy that is not the same
|
|
// recording — a radio edit, a live take — is not this song
|
|
if (want - got).abs() > (want * 0.08).max(8.0) {
|
|
continue;
|
|
}
|
|
}
|
|
let lossless = LOSSLESS.contains(&ext.as_str());
|
|
let mut score: i64 = if lossless { 3000 } else { 1000 };
|
|
if let Some(b) = bitrate {
|
|
score += (b.min(400) as i64) * 2;
|
|
}
|
|
if f.size < 800 * 1024 {
|
|
score -= 2000; // a snippet, not the song
|
|
}
|
|
let path_n = norm(&f.filename);
|
|
let artist_evidence = !artist_key.is_empty() && path_n.contains(&artist_key);
|
|
if require_identity && !artist_key.is_empty() && !artist_evidence {
|
|
continue;
|
|
}
|
|
if artist_evidence {
|
|
score += 400;
|
|
}
|
|
if nname.ends_with(&want) {
|
|
score += 250;
|
|
}
|
|
if r.queue_length > 0 {
|
|
score -= (r.queue_length.min(50) as i64) * 20;
|
|
}
|
|
out.push(Pick {
|
|
username: r.username.clone(),
|
|
path: f.filename.clone(),
|
|
size: f.size,
|
|
duration,
|
|
score,
|
|
slot: r.has_free_upload_slot,
|
|
});
|
|
}
|
|
}
|
|
out.sort_by(|a, b| b.score.cmp(&a.score));
|
|
// Streaming needs bytes NOW: peers with a free slot first, best within
|
|
// each half (the page's rule, learned on "Da Funk" 2026-08-03).
|
|
let (free, busy): (Vec<Pick>, Vec<Pick>) = out.into_iter().partition(|p| p.slot);
|
|
free.into_iter().chain(busy).collect()
|
|
}
|
|
|
|
fn strip_excluded(query: &str) -> String {
|
|
let excluded = lock(&EXCLUDED).clone();
|
|
let mut words: Vec<&str> = query.split_whitespace().collect();
|
|
for phrase in &excluded {
|
|
let lower = query.to_lowercase();
|
|
if lower.contains(phrase.as_str()) {
|
|
let bad: Vec<&str> = phrase.split_whitespace().collect();
|
|
words.retain(|w| !bad.iter().any(|b| w.eq_ignore_ascii_case(b)));
|
|
}
|
|
}
|
|
words.join(" ")
|
|
}
|
|
|
|
/// Every word of the artist still in the query, after the server's
|
|
/// excluded phrases came out of it.
|
|
fn keeps_artist(query: &str, artist: &str) -> bool {
|
|
let q = norm(query);
|
|
let words: Vec<&str> = q.split(' ').collect();
|
|
norm(artist)
|
|
.split(' ')
|
|
.filter(|w| !w.is_empty())
|
|
.all(|w| words.contains(&w))
|
|
}
|
|
|
|
/// The page's ladder for a song (`streamSong`): artist + simplified title,
|
|
/// then the simplified title alone. Each rung is one search, a few seconds
|
|
/// of replies, the best copies; the first rung that produces any wins. A
|
|
/// rung without the artist in it — the second, or the first once the
|
|
/// server's excluded phrases took the artist out — matches by title
|
|
/// alone, so the artist has to appear in the peer's path instead.
|
|
pub fn find(title: &str, artist: &str, expect_secs: Option<f64>) -> Result<Vec<Pick>, String> {
|
|
let simple = simplify(title);
|
|
let rungs = [format!("{artist} {simple}"), simple.clone()];
|
|
for raw in rungs {
|
|
let stripped = strip_excluded(&raw);
|
|
let query = if stripped.trim().is_empty() { raw.clone() } else { stripped };
|
|
if query.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let require_identity = !keeps_artist(&query, artist);
|
|
// the client may spawn onto its runtime from any call; this thread
|
|
// is not on it (see `fetch`)
|
|
let _rt = rt().enter();
|
|
let token = {
|
|
let guard = lock(&CLIENT);
|
|
let client = guard.as_ref().ok_or("not signed in to Soulseek")?;
|
|
client.search(&query).map_err(|e| e.to_string())?
|
|
};
|
|
lock(&SEARCHES).push((token, Vec::new()));
|
|
let started = Instant::now();
|
|
let mut best: Vec<Pick> = Vec::new();
|
|
let mut files = 0usize;
|
|
// Up to eight seconds; done early once a dozen usable copies are in.
|
|
while started.elapsed() < Duration::from_secs(8) {
|
|
std::thread::sleep(Duration::from_millis(400));
|
|
let responses = lock(&SEARCHES)
|
|
.iter()
|
|
.find(|(t, _)| *t == token)
|
|
.map(|(_, r)| r.clone())
|
|
.unwrap_or_default();
|
|
files = responses.iter().map(|r| r.files.len()).sum();
|
|
best = candidates(&responses, title, artist, expect_secs, require_identity);
|
|
if best.len() >= 12 && started.elapsed() > Duration::from_millis(2500) {
|
|
break;
|
|
}
|
|
}
|
|
if let Some(client) = lock(&CLIENT).as_ref() {
|
|
client.stop_search(token);
|
|
}
|
|
lock(&SEARCHES).retain(|(t, _)| *t != token);
|
|
warn(&format!("cold soulseek: '{query}' → {files} files, {} copies", best.len()));
|
|
if !best.is_empty() {
|
|
return Ok(best);
|
|
}
|
|
}
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
/// Start fetching a peer's copy into the stream cache. Returns the cache
|
|
/// path, the partial path the bytes land in, the progress the engine reads,
|
|
/// and the download id.
|
|
pub fn fetch(
|
|
data_dir: &Path,
|
|
username: &str,
|
|
path: &str,
|
|
size: u64,
|
|
) -> Result<(PathBuf, PathBuf, Arc<StreamProgress>, u64), String> {
|
|
let dir = crate::playback::cache_dir_in(data_dir);
|
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
|
let cache = crate::playback::cache_file_in(&dir, username, path);
|
|
let partial = crate::playback::partial_path(&cache);
|
|
let _ = std::fs::remove_file(&partial);
|
|
let progress = StreamProgress::new();
|
|
let id = {
|
|
// `download_to` spawns the transfer task with `tokio::spawn`, which
|
|
// needs a runtime on the CALLING thread — and the caller here is
|
|
// the engine's resolver thread, not a Tauri command. Without this
|
|
// the spawn panics, the thread dies without a word (a cold process
|
|
// has no stderr in logcat), and the card sits on BUFFERING for as
|
|
// long as anyone looks (2026-09-18, the first cold fetch).
|
|
let _rt = rt().enter();
|
|
let guard = lock(&CLIENT);
|
|
let client = guard.as_ref().ok_or("not signed in to Soulseek")?;
|
|
client.download_to(username, path, size, cache.clone())
|
|
};
|
|
lock(&DOWNLOADS).push((id, progress.clone()));
|
|
Ok((cache, partial, progress, id))
|
|
}
|
|
|
|
/// Give up on a fetch that never delivered.
|
|
pub fn cancel(id: u64) {
|
|
let _rt = rt().enter();
|
|
if let Some(client) = lock(&CLIENT).as_ref() {
|
|
client.cancel_download(id);
|
|
}
|
|
lock(&DOWNLOADS).retain(|(i, _)| *i != id);
|
|
}
|