Files
soulseek-rs-modded/soulseek-rs/src/api.rs
T
Michel de Graaf 26b551d997 feat: browse your own shares as a peer receives them
The window could show any peer's share tree but never this account's, so
"am I actually sharing that?" had no answer short of asking a friend to
browse you. The tree is already there; what was missing was feeding it
from our own index.

B opens it — the same collapsible tree, filter, and navigation the peer
browse offers, built from `Shares::directories()`, which is the listing
GetShareFileList answers with. So what it shows is what the network sees,
not what the config meant to share. r re-scans the disk rather than
re-asking a peer, o steps across to the folders that feed it, and nothing
here queues a download from ourselves.

Attached to a daemon the index lives on the daemon's host, so it comes
back over a new `shares.files` method rather than being scanned locally;
`shares files [--filter]` prints the same listing headless, through the
`emit_listing` that `browse` now shares with it — the two are only
comparable while they are built the same way.

Two allocation fixes came out of the review, both on paths this reaches:
the daemon hands its listing to the DTO instead of deep-copying one it
already owns, and `Shares::directories()` allocates its map key once per
folder rather than once per file, which the peer-facing serve path has
been paying for all along.
2026-09-10 11:44:57 +02:00

439 lines
16 KiB
Rust

//! The seam between "what this program does" and "where the session lives".
//!
//! Commands and the TUI hold an `Arc<dyn SessionApi>` rather than an
//! `Arc<Client>`. Locally that *is* the client — the trait is implemented on
//! `Client` itself, so an `Arc<Client>` coerces straight to the trait object
//! and the local path keeps calling the same methods it always did. In daemon
//! mode the same handle is a [`crate::remote::RemoteSession`] that answers over
//! a socket.
//!
//! The signatures deliberately mirror `Client`'s, because every one of them is
//! already load-bearing somewhere in the UI or the command layer. The one
//! change is [`SessionApi::username`], which returns an owned `String`: a
//! remote session has no borrowed client to hand a `&str` out of.
use crate::daemon::proto::ChatMessageDto;
use soulseek_rs::types::{Download, DownloadMetadata, RoomUserStats};
use soulseek_rs::{
Client, DownloadStatus, Result, RoomEvent, SearchResult, SessionLoss,
SharedDirectory, UploadInfo, UserInfo, UserMessage,
};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::mpsc::Receiver;
use std::time::Duration;
/// What a session knows about one search it has run.
///
/// `started_secs_ago` is what lets a second window tell a search that is still
/// collecting from one that has finished: the collecting window is the
/// client's, so without the daemon saying when a search began, every window
/// but the one that started it would show the result as final immediately.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionSearch {
pub query: String,
/// Files found so far, across every peer that has answered.
pub files: usize,
/// How long ago the search went out, or `None` when the session cannot
/// say — which is the local case, where the window that ran it already
/// knows.
pub started_secs_ago: Option<u64>,
}
/// Everything the command layer and the TUI ask of a logged-in session.
///
/// Two rules keep the remote implementation honest:
///
/// * Non-destructive getters (`uploads`, `room_members`, `user_info`, …) are
/// plain request/response — a caller may poll them as often as it likes.
/// * The `take_*` drains are destructive, so with more than one client
/// attached they cannot be served by asking the daemon to drain on demand:
/// whoever asked first would steal the events. The daemon drains once and
/// fans the events out, and a remote session's `take_*` empties its own
/// local mirror.
pub trait SessionApi: Send + Sync {
fn username(&self) -> String;
/// The daemon this session is borrowed from, for the UI to name. `None`
/// when the session belongs to this process.
fn daemon_endpoint(&self) -> Option<String> {
None
}
fn listen_port(&self) -> Option<u16>;
fn session_loss(&self) -> Option<SessionLoss>;
fn relogin(&self) -> Result<bool> {
Err(soulseek_rs::SoulseekRs::NotConnected)
}
/// Search and wait out the window. Every implementation is
/// `search_with_cancel` with nothing to cancel on.
fn search(
&self,
query: &str,
timeout: Duration,
) -> Result<Vec<SearchResult>> {
self.search_with_cancel(query, timeout, None)
}
fn search_with_cancel(
&self,
query: &str,
timeout: Duration,
cancel: Option<Arc<AtomicBool>>,
) -> Result<Vec<SearchResult>>;
fn get_search_results(&self, key: &str) -> Vec<SearchResult>;
/// Every search the session knows about.
///
/// Daemon-side this is shared, so a second client sees what the first has
/// been searching for rather than starting from a blank list.
fn all_searches(&self) -> Vec<SessionSearch>;
/// Drop a search from the session, so every window loses it rather than
/// only the one that dismissed it.
fn forget_search(&self, query: &str) -> bool;
/// How many files a search has collected so far — a count cheap enough
/// to poll, never a copy of the results.
fn get_search_results_count(&self, key: &str) -> usize;
fn try_get_search_results(&self, key: &str) -> Option<Vec<SearchResult>>;
fn start_wishlist_search(&self, query: &str) -> Result<()>;
fn wishlist_interval(&self) -> Duration;
fn download(
&self,
filename: String,
username: String,
size: u64,
download_directory: String,
) -> Result<(Download, Receiver<DownloadStatus>)>;
fn download_with_metadata(
&self,
filename: String,
username: String,
size: u64,
download_directory: String,
metadata: DownloadMetadata,
) -> Result<(Download, Receiver<DownloadStatus>)>;
fn get_all_downloads(&self) -> Vec<Download>;
fn pause_download(&self, username: &str, filename: &str) -> bool;
fn resume_download(&self, username: &str, filename: &str) -> bool;
fn remove_queued_download(&self, username: &str, filename: &str) -> bool;
fn remove_download(&self, username: &str, filename: &str) -> bool;
fn cancel_download(&self, username: &str, filename: &str) -> bool;
fn uploads(&self) -> Vec<UploadInfo>;
fn take_upload_events(&self) -> Vec<UploadInfo>;
fn cancel_upload(&self, username: &str, filename: &str) -> bool;
fn set_upload_slots(&self, slots: usize);
fn check_privileges(&self) -> Result<()>;
fn own_privilege_seconds(&self) -> Option<u32>;
fn request_room_list(&self) -> Result<()>;
fn join_room(&self, room: &str) -> Result<()>;
fn leave_room(&self, room: &str) -> Result<()>;
fn say_in_room(&self, room: &str, message: &str) -> Result<()>;
fn room_members(&self, room: &str) -> Vec<String>;
/// What the server said about each member when the room was joined:
/// status, share statistics, free upload slots and country.
fn room_member_stats(&self, room: &str) -> Vec<RoomUserStats>;
fn take_room_events(&self) -> Vec<RoomEvent>;
fn send_private_message(&self, username: &str, message: &str)
-> Result<()>;
fn take_private_messages(&self) -> Vec<UserMessage>;
fn browse_user(&self, username: &str) -> Result<()>;
fn take_browse_result(
&self,
username: &str,
) -> Option<Vec<SharedDirectory>>;
fn request_user_info(&self, username: &str) -> Result<()>;
fn user_info(&self, username: &str) -> Option<UserInfo>;
/// Ask the server to keep pushing `username`'s status until unwatched.
fn watch_user(&self, username: &str) -> Result<()>;
fn unwatch_user(&self, username: &str) -> Result<()>;
/// Everyone this session is watching, sorted by name.
fn watched_users(&self) -> Vec<String>;
/// The conversation this session already knows about.
///
/// Empty locally, where the TUI's own snapshot on disk is the record. A
/// daemon has been collecting private messages the whole time it has been
/// running, including while nothing was attached, so an attaching client
/// asks for the backlog rather than starting a conversation half-way
/// through.
fn message_history(&self) -> Vec<ChatMessageDto> {
Vec::new()
}
// aislop-ignore-next-line complexity/function-too-long -- scanner bug: a bodyless trait signature followed only by more signatures has no closing brace, so the counter runs to EOF. One line.
fn shared_counts(&self) -> (u32, u32);
// aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line.
fn shared_directories(&self) -> Vec<String>;
/// The share index in the shape a peer receives it, for showing this
/// session's own files exactly as the network sees them.
// aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line.
fn shared_listing(&self) -> Vec<SharedDirectory>;
// aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line.
fn set_shared_directories(&self, directories: Vec<String>) -> Result<()>;
/// Point future transfers at `directory`.
///
/// Only a daemon holds a download folder of its own — locally every
/// download call names its directory, so the local session has nothing to
/// store and accepts silently.
// aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line.
fn set_download_directory(&self, directory: String) -> Result<()>;
/// Make `password` this account's password on the server.
///
/// `Ok(())` means the request reached the server, not that it took it:
/// the protocol's own reply to a password change is not read back.
// aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line.
fn change_password(&self, password: &str) -> Result<()>;
}
/// The local session: the library client itself, so `Arc<Client>` is already an
/// `Arc<dyn SessionApi>` and the in-process path costs nothing but a vtable.
impl SessionApi for Client {
fn username(&self) -> String {
Self::username(self).to_string()
}
fn listen_port(&self) -> Option<u16> {
Self::listen_port(self)
}
fn session_loss(&self) -> Option<SessionLoss> {
Self::session_loss(self)
}
fn relogin(&self) -> Result<bool> {
Self::login(self)
}
fn search_with_cancel(
&self,
query: &str,
timeout: Duration,
cancel: Option<Arc<AtomicBool>>,
) -> Result<Vec<SearchResult>> {
Self::search_with_cancel(self, query, timeout, cancel)
}
fn get_search_results(&self, key: &str) -> Vec<SearchResult> {
Self::get_search_results(self, key)
}
fn forget_search(&self, query: &str) -> bool {
Self::forget_search(self, query)
}
fn all_searches(&self) -> Vec<SessionSearch> {
// Counts, not copies: the daemon answers this for every attached
// client many times a second, and cloning every result set to sum
// its lengths held the context lock against the thread storing new
// results.
Self::search_file_counts(self)
.into_iter()
.map(|(query, files)| SessionSearch {
query,
files,
// A local window started every search it can see, so it
// already knows which are still running.
started_secs_ago: None,
})
.collect()
}
fn get_search_results_count(&self, key: &str) -> usize {
// Files, agreeing with the remote session — the library's own
// accessor of this name counts per-peer responses instead.
Self::search_file_counts(self)
.into_iter()
.find(|(query, _)| query == key)
.map_or(0, |(_, files)| files)
}
fn try_get_search_results(&self, key: &str) -> Option<Vec<SearchResult>> {
Self::try_get_search_results(self, key)
}
fn start_wishlist_search(&self, query: &str) -> Result<()> {
Self::start_wishlist_search(self, query)
}
fn wishlist_interval(&self) -> Duration {
Self::wishlist_interval(self)
}
fn download(
&self,
filename: String,
username: String,
size: u64,
download_directory: String,
) -> Result<(Download, Receiver<DownloadStatus>)> {
Self::download(self, filename, username, size, download_directory)
}
fn download_with_metadata(
&self,
filename: String,
username: String,
size: u64,
download_directory: String,
metadata: DownloadMetadata,
) -> Result<(Download, Receiver<DownloadStatus>)> {
Self::download_with_metadata(
self,
filename,
username,
size,
download_directory,
metadata,
)
}
fn get_all_downloads(&self) -> Vec<Download> {
Self::get_all_downloads(self)
}
fn pause_download(&self, username: &str, filename: &str) -> bool {
Self::pause_download(self, username, filename)
}
fn resume_download(&self, username: &str, filename: &str) -> bool {
Self::resume_download(self, username, filename)
}
fn remove_queued_download(&self, username: &str, filename: &str) -> bool {
Self::remove_queued_download(self, username, filename)
}
fn remove_download(&self, username: &str, filename: &str) -> bool {
Self::remove_download(self, username, filename)
}
fn cancel_download(&self, username: &str, filename: &str) -> bool {
Self::cancel_download(self, username, filename)
}
fn uploads(&self) -> Vec<UploadInfo> {
Self::uploads(self)
}
fn take_upload_events(&self) -> Vec<UploadInfo> {
Self::take_upload_events(self)
}
fn cancel_upload(&self, username: &str, filename: &str) -> bool {
Self::cancel_upload(self, username, filename)
}
fn set_upload_slots(&self, slots: usize) {
Self::set_upload_slots(self, slots);
}
fn check_privileges(&self) -> Result<()> {
Self::check_privileges(self)
}
fn own_privilege_seconds(&self) -> Option<u32> {
Self::own_privilege_seconds(self)
}
fn request_room_list(&self) -> Result<()> {
Self::request_room_list(self)
}
fn join_room(&self, room: &str) -> Result<()> {
Self::join_room(self, room)
}
fn leave_room(&self, room: &str) -> Result<()> {
Self::leave_room(self, room)
}
fn say_in_room(&self, room: &str, message: &str) -> Result<()> {
Self::say_in_room(self, room, message)
}
fn room_members(&self, room: &str) -> Vec<String> {
Self::room_members(self, room)
}
fn room_member_stats(&self, room: &str) -> Vec<RoomUserStats> {
Self::room_member_stats(self, room)
}
fn take_room_events(&self) -> Vec<RoomEvent> {
Self::take_room_events(self)
}
fn send_private_message(
&self,
username: &str,
message: &str,
) -> Result<()> {
Self::send_private_message(self, username, message)
}
fn take_private_messages(&self) -> Vec<UserMessage> {
Self::take_private_messages(self)
}
fn browse_user(&self, username: &str) -> Result<()> {
Self::browse_user(self, username)
}
fn take_browse_result(
&self,
username: &str,
) -> Option<Vec<SharedDirectory>> {
Self::take_browse_result(self, username)
}
fn request_user_info(&self, username: &str) -> Result<()> {
Self::request_user_info(self, username)
}
fn user_info(&self, username: &str) -> Option<UserInfo> {
Self::user_info(self, username)
}
fn watch_user(&self, username: &str) -> Result<()> {
Self::watch_user(self, username)
}
fn unwatch_user(&self, username: &str) -> Result<()> {
Self::unwatch_user(self, username)
}
fn watched_users(&self) -> Vec<String> {
Self::watched_users(self)
}
fn shared_counts(&self) -> (u32, u32) {
Self::shared_counts(self)
}
fn shared_directories(&self) -> Vec<String> {
Self::shared_directories(self)
}
fn shared_listing(&self) -> Vec<SharedDirectory> {
Self::shared_listing(self)
}
fn set_shared_directories(&self, directories: Vec<String>) -> Result<()> {
Self::set_shared_directories(self, directories)
}
fn set_download_directory(&self, _directory: String) -> Result<()> {
Ok(())
}
fn change_password(&self, password: &str) -> Result<()> {
Self::change_password(self, password)
}
}