The watch subscription was reachable only from the library. It is most useful against a daemon -- that is the session that stays connected long enough to receive the server's status pushes -- so it needs the wire. - proto: user.watch / user.unwatch / user.watched, with a Watched response - SessionApi gains watch_user / unwatch_user / watched_users, so a local and a remote session are still interchangeable above the seam - CLI: `watch add`, `watch remove`, `watch list`, documented in SKILL.md including the caveat that a one-shot run's watch ends with the run - docs/openrpc.json regenerated from the wire types
332 lines
12 KiB
Rust
332 lines
12 KiB
Rust
//! Being a peer on the network: `serve`, `whoami`, and `user`.
|
|
|
|
use super::{Ctx, Session};
|
|
use crate::cli::{ServeArgs, UserArgs, WatchCommand};
|
|
use crate::output::{
|
|
CliError, CliResult, UploadRecord, UserRecord, WatchedUserRecord,
|
|
WhoamiRecord,
|
|
};
|
|
use soulseek_rs::types::{UploadInfo, UploadStatus, UserInfo};
|
|
use std::collections::HashMap;
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// How often the serve loop samples the upload table.
|
|
const POLL: Duration = Duration::from_millis(500);
|
|
|
|
/// How long `whoami` waits for the server's privilege answer. It is one small
|
|
/// message on an already-open connection, so a server that has not replied by
|
|
/// now is not going to.
|
|
const PRIVILEGE_TIMEOUT: Duration = Duration::from_secs(5);
|
|
|
|
pub fn whoami(ctx: &Ctx) -> CliResult {
|
|
let session = Session::open(ctx)?;
|
|
let (folders, files) = session.client.shared_counts();
|
|
// The bound port, not the configured one: a caller checking whether it is
|
|
// reachable needs what is true, not what was asked for.
|
|
let bound = session.client.listen_port();
|
|
ctx.out.emit(&WhoamiRecord {
|
|
// The session's own name, not this run's configuration: routed to a
|
|
// daemon they are different, and the session is the one that is
|
|
// actually on the network.
|
|
user: session.client.username(),
|
|
server: ctx.settings.server_address.to_string(),
|
|
listening: bound.is_some(),
|
|
listen_port: bound.unwrap_or(0),
|
|
shared_folders: folders,
|
|
shared_files: files,
|
|
download_dir: ctx.download_dir.clone(),
|
|
privilege_seconds: own_privileges(session.client.as_ref()),
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
/// Ask the server how much privilege time this account has left.
|
|
///
|
|
/// A server that does not answer leaves the field null rather than reporting a
|
|
/// zero, because "no privileges" and "did not say" are different facts.
|
|
fn own_privileges(client: &dyn crate::api::SessionApi) -> Option<u32> {
|
|
if client.check_privileges().is_err() {
|
|
return None;
|
|
}
|
|
super::poll_until(PRIVILEGE_TIMEOUT, Duration::from_millis(50), || {
|
|
client.own_privilege_seconds()
|
|
})
|
|
}
|
|
|
|
pub fn user(ctx: &Ctx, args: &UserArgs) -> CliResult {
|
|
let session = Session::open(ctx)?;
|
|
session.client.request_user_info(&args.user).map_err(|e| {
|
|
CliError::connection(format!("cannot ask about {}: {e}", args.user))
|
|
})?;
|
|
ctx.out
|
|
.status(&format!("asking the server about {}…", args.user));
|
|
|
|
// Presence and statistics are two replies; wait for both, but report
|
|
// whichever arrived rather than defaulting the other to something that
|
|
// reads like an answer.
|
|
let deadline = Instant::now() + Duration::from_secs(args.timeout);
|
|
let mut latest = None;
|
|
while Instant::now() < deadline {
|
|
latest = session.client.user_info(&args.user);
|
|
if latest.as_ref().is_some_and(UserInfo::is_complete) {
|
|
break;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
|
|
let Some(info) = latest else {
|
|
return Err(CliError::timeout(format!(
|
|
"the server said nothing about {} within {}s",
|
|
args.user, args.timeout
|
|
)));
|
|
};
|
|
if !info.is_complete() {
|
|
ctx.out
|
|
.warn("the server answered only partly; unknown fields are null");
|
|
}
|
|
ctx.out.emit(&UserRecord {
|
|
user: info.username,
|
|
status: info.presence.map(|p| p.status.to_string()),
|
|
privileged: info.presence.map(|p| p.privileged),
|
|
average_speed: info.stats.map(|s| s.average_speed),
|
|
shared_files: info.stats.map(|s| s.shared_files),
|
|
shared_folders: info.stats.map(|s| s.shared_folders),
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
/// Start or stop watching a user, or list who is being watched.
|
|
///
|
|
/// A watch lives in the session that made it, so `add` against a one-shot CLI
|
|
/// run ends with that run. Pointed at a daemon it persists for that daemon's
|
|
/// lifetime, which is the point of the command.
|
|
pub fn watch(ctx: &Ctx, command: &WatchCommand) -> CliResult {
|
|
let session = Session::open(ctx)?;
|
|
match command {
|
|
WatchCommand::Add { user } => {
|
|
session.client.watch_user(user).map_err(|e| {
|
|
CliError::connection(format!("cannot watch {user}: {e}"))
|
|
})?;
|
|
ctx.out.status(&format!("watching {user}"));
|
|
}
|
|
WatchCommand::Remove { user } => {
|
|
session.client.unwatch_user(user).map_err(|e| {
|
|
CliError::connection(format!("cannot unwatch {user}: {e}"))
|
|
})?;
|
|
ctx.out.status(&format!("no longer watching {user}"));
|
|
}
|
|
WatchCommand::List => {
|
|
for user in session.client.watched_users() {
|
|
let status = session
|
|
.client
|
|
.user_info(&user)
|
|
.and_then(|info| info.presence)
|
|
.map(|p| p.status.to_string());
|
|
ctx.out.emit(&WatchedUserRecord { user, status });
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Stay online sharing files, and — with `--wishlist` — re-run the standing
|
|
/// searches on the interval the server sets while doing so.
|
|
pub fn serve(
|
|
ctx: &Ctx,
|
|
args: &ServeArgs,
|
|
mut sweeper: Option<crate::commands::wish::Sweeper>,
|
|
) -> CliResult {
|
|
// Cheap check first, so a misconfigured local run is rejected before it
|
|
// bothers the server with a login.
|
|
if ctx.daemon.is_none() {
|
|
if ctx.settings.shared_directories.is_empty() {
|
|
return Err(nothing_to_share());
|
|
}
|
|
if !ctx.settings.enable_listen {
|
|
return Err(needs_a_listener());
|
|
}
|
|
}
|
|
|
|
let session = Session::open(ctx)?;
|
|
// What the session actually offers is what decides. Routed to a daemon
|
|
// that is what the daemon shares, which is the only thing peers can see —
|
|
// this run's own configuration has no bearing on it.
|
|
if session.client.shared_directories().is_empty() {
|
|
return Err(nothing_to_share());
|
|
}
|
|
if session.client.listen_port().is_none() {
|
|
return Err(needs_a_listener());
|
|
}
|
|
if let Some(slots) = args.upload_slots {
|
|
session.client.set_upload_slots(usize::from(slots));
|
|
ctx.out.status(&format!("{slots} upload slots"));
|
|
}
|
|
let (folders, files) = session.client.shared_counts();
|
|
ctx.out.status(&format!(
|
|
"serving {files} files in {folders} folders on port {}",
|
|
session.client.listen_port().unwrap_or_default()
|
|
));
|
|
if files == 0 {
|
|
ctx.out
|
|
.warn("the share index is empty — peers will find nothing");
|
|
}
|
|
|
|
let span = super::listen_span(args.duration, args.follow);
|
|
ctx.out.status(&match span {
|
|
Some(span) => format!("online for {}s…", span.as_secs()),
|
|
None => "online until interrupted…".to_string(),
|
|
});
|
|
|
|
let deadline = span.map(|span| Instant::now() + span);
|
|
let mut reported: HashMap<(String, String), UploadRecord> = HashMap::new();
|
|
while deadline.is_none_or(|deadline| Instant::now() < deadline) {
|
|
// Queued states first, and from the client's own record rather than
|
|
// this snapshot: with few slots and small files a peer can queue and be
|
|
// served well inside one poll, and "it had to wait" is precisely what a
|
|
// transfer log exists to report.
|
|
for queued in session.client.take_upload_events() {
|
|
emit_changed(&mut reported, upload_record(&queued), &ctx.out);
|
|
}
|
|
for upload in session.client.uploads() {
|
|
emit_changed(&mut reported, upload_record(&upload), &ctx.out);
|
|
}
|
|
if let Some(sweeper) = sweeper.as_mut() {
|
|
sweeper.tick(ctx, session.client.as_ref());
|
|
}
|
|
std::thread::sleep(POLL);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn nothing_to_share() -> CliError {
|
|
CliError::usage(
|
|
"nothing to share: pass --shared-dir, or add one with `shares add`",
|
|
)
|
|
}
|
|
|
|
fn needs_a_listener() -> CliError {
|
|
CliError::usage("serving needs the listener: drop --no-listener")
|
|
}
|
|
|
|
/// Flatten a live upload into the record `serve` reports.
|
|
fn upload_record(upload: &UploadInfo) -> UploadRecord {
|
|
let (status, reason) = match &upload.status {
|
|
UploadStatus::Queued(place) => {
|
|
("queued", Some(format!("place {place}")))
|
|
}
|
|
UploadStatus::InProgress => ("uploading", None),
|
|
UploadStatus::Completed => ("completed", None),
|
|
UploadStatus::Cancelled => ("cancelled", None),
|
|
UploadStatus::Failed(why) => ("failed", Some(why.clone())),
|
|
};
|
|
UploadRecord {
|
|
user: upload.username.clone(),
|
|
path: upload.filename.clone(),
|
|
size: upload.size,
|
|
bytes_sent: upload.bytes_sent,
|
|
speed: upload.speed_bytes_per_sec,
|
|
status: status.to_string(),
|
|
reason,
|
|
}
|
|
}
|
|
|
|
/// Emit `record` only when it differs from the last one reported for that
|
|
/// (user, file).
|
|
fn emit_changed(
|
|
reported: &mut HashMap<(String, String), UploadRecord>,
|
|
record: UploadRecord,
|
|
out: &crate::output::Out,
|
|
) -> bool {
|
|
let key = (record.user.clone(), record.path.clone());
|
|
if reported.get(&key) == Some(&record) {
|
|
return false;
|
|
}
|
|
out.emit(&record);
|
|
reported.insert(key, record);
|
|
true
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::output::{Out, Record};
|
|
|
|
fn upload(status: UploadStatus, bytes_sent: u64) -> UploadInfo {
|
|
UploadInfo {
|
|
username: "bob".to_string(),
|
|
filename: "@@x\\track.mp3".to_string(),
|
|
size: 100,
|
|
bytes_sent,
|
|
status,
|
|
speed_bytes_per_sec: 1.0,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_upload_maps_to_its_wire_status() {
|
|
assert_eq!(
|
|
upload_record(&upload(UploadStatus::InProgress, 5)).status,
|
|
"uploading"
|
|
);
|
|
assert_eq!(
|
|
upload_record(&upload(UploadStatus::Completed, 100)).status,
|
|
"completed"
|
|
);
|
|
assert_eq!(
|
|
upload_record(&upload(UploadStatus::Cancelled, 5)).status,
|
|
"cancelled"
|
|
);
|
|
|
|
let failed =
|
|
upload_record(&upload(UploadStatus::Failed("gone".into()), 5));
|
|
assert_eq!(failed.status, "failed");
|
|
assert_eq!(failed.reason.as_deref(), Some("gone"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_upload_line_leads_with_status_and_progress() {
|
|
let record = upload_record(&upload(UploadStatus::InProgress, 42));
|
|
assert_eq!(record.text(), "uploading\tbob\t42/100\t@@x\\track.mp3");
|
|
}
|
|
|
|
#[test]
|
|
fn an_unchanged_upload_is_reported_once() {
|
|
let out = Out::new(false, true);
|
|
let mut reported = HashMap::new();
|
|
let record = upload_record(&upload(UploadStatus::InProgress, 42));
|
|
|
|
assert!(emit_changed(&mut reported, record.clone(), &out));
|
|
assert!(
|
|
!emit_changed(&mut reported, record, &out),
|
|
"polling must not re-report an upload that has not moved"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn progress_and_status_changes_are_both_reported() {
|
|
let out = Out::new(false, true);
|
|
let mut reported = HashMap::new();
|
|
emit_changed(
|
|
&mut reported,
|
|
upload_record(&upload(UploadStatus::InProgress, 10)),
|
|
&out,
|
|
);
|
|
assert!(
|
|
emit_changed(
|
|
&mut reported,
|
|
upload_record(&upload(UploadStatus::InProgress, 60)),
|
|
&out
|
|
),
|
|
"more bytes sent is news"
|
|
);
|
|
assert!(
|
|
emit_changed(
|
|
&mut reported,
|
|
upload_record(&upload(UploadStatus::Completed, 100)),
|
|
&out
|
|
),
|
|
"finishing is news"
|
|
);
|
|
}
|
|
}
|