Move torrents to completed after finish

This commit is contained in:
Alexander
2026-07-24 16:47:42 +02:00
parent 9c2499a4a3
commit 3f3a6d354a
10 changed files with 1588 additions and 549 deletions
File diff suppressed because it is too large Load Diff
@@ -10,15 +10,24 @@ impl serde::Serialize for AddRequest {
if !self.magnet.is_empty() { if !self.magnet.is_empty() {
len += 1; len += 1;
} }
if !self.output_dir.is_empty() { if !self.download_folder.is_empty() {
len += 1;
}
if !self.move_after_completion_folder.is_empty() {
len += 1; len += 1;
} }
let mut struct_ser = serializer.serialize_struct("torrent.AddRequest", len)?; let mut struct_ser = serializer.serialize_struct("torrent.AddRequest", len)?;
if !self.magnet.is_empty() { if !self.magnet.is_empty() {
struct_ser.serialize_field("magnet", &self.magnet)?; struct_ser.serialize_field("magnet", &self.magnet)?;
} }
if !self.output_dir.is_empty() { if !self.download_folder.is_empty() {
struct_ser.serialize_field("outputDir", &self.output_dir)?; struct_ser.serialize_field("downloadFolder", &self.download_folder)?;
}
if !self.move_after_completion_folder.is_empty() {
struct_ser.serialize_field(
"moveAfterCompletionFolder",
&self.move_after_completion_folder,
)?;
} }
struct_ser.end() struct_ser.end()
} }
@@ -29,12 +38,19 @@ impl<'de> serde::Deserialize<'de> for AddRequest {
where where
D: serde::Deserializer<'de>, D: serde::Deserializer<'de>,
{ {
const FIELDS: &[&str] = &["magnet", "output_dir", "outputDir"]; const FIELDS: &[&str] = &[
"magnet",
"download_folder",
"downloadFolder",
"move_after_completion_folder",
"moveAfterCompletionFolder",
];
#[allow(clippy::enum_variant_names)] #[allow(clippy::enum_variant_names)]
enum GeneratedField { enum GeneratedField {
Magnet, Magnet,
OutputDir, DownloadFolder,
MoveAfterCompletionFolder,
} }
impl<'de> serde::Deserialize<'de> for GeneratedField { impl<'de> serde::Deserialize<'de> for GeneratedField {
fn deserialize<D>(deserializer: D) -> std::result::Result<GeneratedField, D::Error> fn deserialize<D>(deserializer: D) -> std::result::Result<GeneratedField, D::Error>
@@ -60,7 +76,12 @@ impl<'de> serde::Deserialize<'de> for AddRequest {
{ {
match value { match value {
"magnet" => Ok(GeneratedField::Magnet), "magnet" => Ok(GeneratedField::Magnet),
"outputDir" | "output_dir" => Ok(GeneratedField::OutputDir), "downloadFolder" | "download_folder" => {
Ok(GeneratedField::DownloadFolder)
}
"moveAfterCompletionFolder" | "move_after_completion_folder" => {
Ok(GeneratedField::MoveAfterCompletionFolder)
}
_ => Err(serde::de::Error::unknown_field(value, FIELDS)), _ => Err(serde::de::Error::unknown_field(value, FIELDS)),
} }
} }
@@ -81,7 +102,8 @@ impl<'de> serde::Deserialize<'de> for AddRequest {
V: serde::de::MapAccess<'de>, V: serde::de::MapAccess<'de>,
{ {
let mut magnet__ = None; let mut magnet__ = None;
let mut output_dir__ = None; let mut download_folder__ = None;
let mut move_after_completion_folder__ = None;
while let Some(k) = map_.next_key()? { while let Some(k) = map_.next_key()? {
match k { match k {
GeneratedField::Magnet => { GeneratedField::Magnet => {
@@ -90,17 +112,27 @@ impl<'de> serde::Deserialize<'de> for AddRequest {
} }
magnet__ = Some(map_.next_value()?); magnet__ = Some(map_.next_value()?);
} }
GeneratedField::OutputDir => { GeneratedField::DownloadFolder => {
if output_dir__.is_some() { if download_folder__.is_some() {
return Err(serde::de::Error::duplicate_field("outputDir")); return Err(serde::de::Error::duplicate_field("downloadFolder"));
} }
output_dir__ = Some(map_.next_value()?); download_folder__ = Some(map_.next_value()?);
}
GeneratedField::MoveAfterCompletionFolder => {
if move_after_completion_folder__.is_some() {
return Err(serde::de::Error::duplicate_field(
"moveAfterCompletionFolder",
));
}
move_after_completion_folder__ = Some(map_.next_value()?);
} }
} }
} }
Ok(AddRequest { Ok(AddRequest {
magnet: magnet__.unwrap_or_default(), magnet: magnet__.unwrap_or_default(),
output_dir: output_dir__.unwrap_or_default(), download_folder: download_folder__.unwrap_or_default(),
move_after_completion_folder: move_after_completion_folder__
.unwrap_or_default(),
}) })
} }
} }
@@ -291,6 +323,260 @@ impl<'de> serde::Deserialize<'de> for AddedInfo {
deserializer.deserialize_struct("torrent.AddedInfo", FIELDS, GeneratedVisitor) deserializer.deserialize_struct("torrent.AddedInfo", FIELDS, GeneratedVisitor)
} }
} }
impl serde::Serialize for FileState {
#[allow(deprecated)]
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let variant = match self {
Self::Unspecified => "FILE_STATE_UNSPECIFIED",
Self::InProgress => "IN_PROGRESS",
Self::Ready => "READY",
};
serializer.serialize_str(variant)
}
}
impl<'de> serde::Deserialize<'de> for FileState {
#[allow(deprecated)]
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
const FIELDS: &[&str] = &["FILE_STATE_UNSPECIFIED", "IN_PROGRESS", "READY"];
struct GeneratedVisitor;
impl<'de> serde::de::Visitor<'de> for GeneratedVisitor {
type Value = FileState;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "expected one of: {:?}", &FIELDS)
}
fn visit_i64<E>(self, v: i64) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
i32::try_from(v)
.ok()
.and_then(|x| x.try_into().ok())
.ok_or_else(|| {
serde::de::Error::invalid_value(serde::de::Unexpected::Signed(v), &self)
})
}
fn visit_u64<E>(self, v: u64) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
i32::try_from(v)
.ok()
.and_then(|x| x.try_into().ok())
.ok_or_else(|| {
serde::de::Error::invalid_value(serde::de::Unexpected::Unsigned(v), &self)
})
}
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
match value {
"FILE_STATE_UNSPECIFIED" => Ok(FileState::Unspecified),
"IN_PROGRESS" => Ok(FileState::InProgress),
"READY" => Ok(FileState::Ready),
_ => Err(serde::de::Error::unknown_variant(value, FIELDS)),
}
}
}
deserializer.deserialize_any(GeneratedVisitor)
}
}
impl serde::Serialize for FilesRequest {
#[allow(deprecated)]
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut len = 0;
if !self.id.is_empty() {
len += 1;
}
let mut struct_ser = serializer.serialize_struct("torrent.FilesRequest", len)?;
if !self.id.is_empty() {
struct_ser.serialize_field("id", &self.id)?;
}
struct_ser.end()
}
}
impl<'de> serde::Deserialize<'de> for FilesRequest {
#[allow(deprecated)]
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
const FIELDS: &[&str] = &["id"];
#[allow(clippy::enum_variant_names)]
enum GeneratedField {
Id,
}
impl<'de> serde::Deserialize<'de> for GeneratedField {
fn deserialize<D>(deserializer: D) -> std::result::Result<GeneratedField, D::Error>
where
D: serde::Deserializer<'de>,
{
struct GeneratedVisitor;
impl<'de> serde::de::Visitor<'de> for GeneratedVisitor {
type Value = GeneratedField;
fn expecting(
&self,
formatter: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
write!(formatter, "expected one of: {:?}", &FIELDS)
}
#[allow(unused_variables)]
fn visit_str<E>(self, value: &str) -> std::result::Result<GeneratedField, E>
where
E: serde::de::Error,
{
match value {
"id" => Ok(GeneratedField::Id),
_ => Err(serde::de::Error::unknown_field(value, FIELDS)),
}
}
}
deserializer.deserialize_identifier(GeneratedVisitor)
}
}
struct GeneratedVisitor;
impl<'de> serde::de::Visitor<'de> for GeneratedVisitor {
type Value = FilesRequest;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("struct torrent.FilesRequest")
}
fn visit_map<V>(self, mut map_: V) -> std::result::Result<FilesRequest, V::Error>
where
V: serde::de::MapAccess<'de>,
{
let mut id__ = None;
while let Some(k) = map_.next_key()? {
match k {
GeneratedField::Id => {
if id__.is_some() {
return Err(serde::de::Error::duplicate_field("id"));
}
id__ = Some(map_.next_value()?);
}
}
}
Ok(FilesRequest {
id: id__.unwrap_or_default(),
})
}
}
deserializer.deserialize_struct("torrent.FilesRequest", FIELDS, GeneratedVisitor)
}
}
impl serde::Serialize for FilesResponse {
#[allow(deprecated)]
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut len = 0;
if !self.files.is_empty() {
len += 1;
}
let mut struct_ser = serializer.serialize_struct("torrent.FilesResponse", len)?;
if !self.files.is_empty() {
struct_ser.serialize_field("files", &self.files)?;
}
struct_ser.end()
}
}
impl<'de> serde::Deserialize<'de> for FilesResponse {
#[allow(deprecated)]
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
const FIELDS: &[&str] = &["files"];
#[allow(clippy::enum_variant_names)]
enum GeneratedField {
Files,
}
impl<'de> serde::Deserialize<'de> for GeneratedField {
fn deserialize<D>(deserializer: D) -> std::result::Result<GeneratedField, D::Error>
where
D: serde::Deserializer<'de>,
{
struct GeneratedVisitor;
impl<'de> serde::de::Visitor<'de> for GeneratedVisitor {
type Value = GeneratedField;
fn expecting(
&self,
formatter: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
write!(formatter, "expected one of: {:?}", &FIELDS)
}
#[allow(unused_variables)]
fn visit_str<E>(self, value: &str) -> std::result::Result<GeneratedField, E>
where
E: serde::de::Error,
{
match value {
"files" => Ok(GeneratedField::Files),
_ => Err(serde::de::Error::unknown_field(value, FIELDS)),
}
}
}
deserializer.deserialize_identifier(GeneratedVisitor)
}
}
struct GeneratedVisitor;
impl<'de> serde::de::Visitor<'de> for GeneratedVisitor {
type Value = FilesResponse;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("struct torrent.FilesResponse")
}
fn visit_map<V>(self, mut map_: V) -> std::result::Result<FilesResponse, V::Error>
where
V: serde::de::MapAccess<'de>,
{
let mut files__ = None;
while let Some(k) = map_.next_key()? {
match k {
GeneratedField::Files => {
if files__.is_some() {
return Err(serde::de::Error::duplicate_field("files"));
}
files__ = Some(map_.next_value()?);
}
}
}
Ok(FilesResponse {
files: files__.unwrap_or_default(),
})
}
}
deserializer.deserialize_struct("torrent.FilesResponse", FIELDS, GeneratedVisitor)
}
}
impl serde::Serialize for ListRequest { impl serde::Serialize for ListRequest {
#[allow(deprecated)] #[allow(deprecated)]
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
@@ -1876,6 +2162,170 @@ impl<'de> serde::Deserialize<'de> for StatusRequest {
deserializer.deserialize_struct("torrent.StatusRequest", FIELDS, GeneratedVisitor) deserializer.deserialize_struct("torrent.StatusRequest", FIELDS, GeneratedVisitor)
} }
} }
impl serde::Serialize for TorrentFile {
#[allow(deprecated)]
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut len = 0;
if !self.name.is_empty() {
len += 1;
}
if self.length != 0 {
len += 1;
}
if self.downloaded_bytes != 0 {
len += 1;
}
if self.state != 0 {
len += 1;
}
let mut struct_ser = serializer.serialize_struct("torrent.TorrentFile", len)?;
if !self.name.is_empty() {
struct_ser.serialize_field("name", &self.name)?;
}
if self.length != 0 {
#[allow(clippy::needless_borrow)]
#[allow(clippy::needless_borrows_for_generic_args)]
struct_ser.serialize_field("length", ToString::to_string(&self.length).as_str())?;
}
if self.downloaded_bytes != 0 {
#[allow(clippy::needless_borrow)]
#[allow(clippy::needless_borrows_for_generic_args)]
struct_ser.serialize_field(
"downloadedBytes",
ToString::to_string(&self.downloaded_bytes).as_str(),
)?;
}
if self.state != 0 {
let v = FileState::try_from(self.state).map_err(|_| {
serde::ser::Error::custom(format!("Invalid variant {}", self.state))
})?;
struct_ser.serialize_field("state", &v)?;
}
struct_ser.end()
}
}
impl<'de> serde::Deserialize<'de> for TorrentFile {
#[allow(deprecated)]
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
const FIELDS: &[&str] = &[
"name",
"length",
"downloaded_bytes",
"downloadedBytes",
"state",
];
#[allow(clippy::enum_variant_names)]
enum GeneratedField {
Name,
Length,
DownloadedBytes,
State,
}
impl<'de> serde::Deserialize<'de> for GeneratedField {
fn deserialize<D>(deserializer: D) -> std::result::Result<GeneratedField, D::Error>
where
D: serde::Deserializer<'de>,
{
struct GeneratedVisitor;
impl<'de> serde::de::Visitor<'de> for GeneratedVisitor {
type Value = GeneratedField;
fn expecting(
&self,
formatter: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
write!(formatter, "expected one of: {:?}", &FIELDS)
}
#[allow(unused_variables)]
fn visit_str<E>(self, value: &str) -> std::result::Result<GeneratedField, E>
where
E: serde::de::Error,
{
match value {
"name" => Ok(GeneratedField::Name),
"length" => Ok(GeneratedField::Length),
"downloadedBytes" | "downloaded_bytes" => {
Ok(GeneratedField::DownloadedBytes)
}
"state" => Ok(GeneratedField::State),
_ => Err(serde::de::Error::unknown_field(value, FIELDS)),
}
}
}
deserializer.deserialize_identifier(GeneratedVisitor)
}
}
struct GeneratedVisitor;
impl<'de> serde::de::Visitor<'de> for GeneratedVisitor {
type Value = TorrentFile;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("struct torrent.TorrentFile")
}
fn visit_map<V>(self, mut map_: V) -> std::result::Result<TorrentFile, V::Error>
where
V: serde::de::MapAccess<'de>,
{
let mut name__ = None;
let mut length__ = None;
let mut downloaded_bytes__ = None;
let mut state__ = None;
while let Some(k) = map_.next_key()? {
match k {
GeneratedField::Name => {
if name__.is_some() {
return Err(serde::de::Error::duplicate_field("name"));
}
name__ = Some(map_.next_value()?);
}
GeneratedField::Length => {
if length__.is_some() {
return Err(serde::de::Error::duplicate_field("length"));
}
length__ = Some(
map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?
.0,
);
}
GeneratedField::DownloadedBytes => {
if downloaded_bytes__.is_some() {
return Err(serde::de::Error::duplicate_field("downloadedBytes"));
}
downloaded_bytes__ = Some(
map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?
.0,
);
}
GeneratedField::State => {
if state__.is_some() {
return Err(serde::de::Error::duplicate_field("state"));
}
state__ = Some(map_.next_value::<FileState>()? as i32);
}
}
}
Ok(TorrentFile {
name: name__.unwrap_or_default(),
length: length__.unwrap_or_default(),
downloaded_bytes: downloaded_bytes__.unwrap_or_default(),
state: state__.unwrap_or_default(),
})
}
}
deserializer.deserialize_struct("torrent.TorrentFile", FIELDS, GeneratedVisitor)
}
}
impl serde::Serialize for TorrentStatus { impl serde::Serialize for TorrentStatus {
#[allow(deprecated)] #[allow(deprecated)]
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
@@ -190,6 +190,20 @@ pub mod torrents_client {
.insert(GrpcMethod::new("torrent.Torrents", "Notifications")); .insert(GrpcMethod::new("torrent.Torrents", "Notifications"));
self.inner.server_streaming(req, path, codec).await self.inner.server_streaming(req, path, codec).await
} }
pub async fn files(
&mut self,
request: impl tonic::IntoRequest<super::FilesRequest>,
) -> std::result::Result<tonic::Response<super::FilesResponse>, tonic::Status> {
self.inner.ready().await.map_err(|e| {
tonic::Status::unknown(format!("Service was not ready: {}", e.into()))
})?;
let codec = tonic_prost::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static("/torrent.Torrents/Files");
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("torrent.Torrents", "Files"));
self.inner.unary(req, path, codec).await
}
} }
} }
/// Generated server implementations. /// Generated server implementations.
@@ -238,6 +252,10 @@ pub mod torrents_server {
&self, &self,
request: tonic::Request<super::NotificationsRequest>, request: tonic::Request<super::NotificationsRequest>,
) -> std::result::Result<tonic::Response<Self::NotificationsStream>, tonic::Status>; ) -> std::result::Result<tonic::Response<Self::NotificationsStream>, tonic::Status>;
async fn files(
&self,
request: tonic::Request<super::FilesRequest>,
) -> std::result::Result<tonic::Response<super::FilesResponse>, tonic::Status>;
} }
#[derive(Debug)] #[derive(Debug)]
pub struct TorrentsServer<T> { pub struct TorrentsServer<T> {
@@ -578,6 +596,43 @@ pub mod torrents_server {
}; };
Box::pin(fut) Box::pin(fut)
} }
"/torrent.Torrents/Files" => {
#[allow(non_camel_case_types)]
struct FilesSvc<T: Torrents>(pub Arc<T>);
impl<T: Torrents> tonic::server::UnaryService<super::FilesRequest> for FilesSvc<T> {
type Response = super::FilesResponse;
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
fn call(
&mut self,
request: tonic::Request<super::FilesRequest>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move { <T as Torrents>::files(&inner, request).await };
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = FilesSvc(inner);
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(
accept_compression_encodings,
send_compression_encodings,
)
.apply_max_message_size_config(
max_decoding_message_size,
max_encoding_message_size,
);
let res = grpc.unary(method, req).await;
Ok(res)
};
Box::pin(fut)
}
_ => Box::pin(async move { _ => Box::pin(async move {
let mut response = http::Response::new(tonic::body::Body::default()); let mut response = http::Response::new(tonic::body::Body::default());
let headers = response.headers_mut(); let headers = response.headers_mut();
+4 -3
View File
@@ -4,9 +4,10 @@ pub mod torrent {
pub use torrent::notification as notification_detail; pub use torrent::notification as notification_detail;
pub use torrent::{ pub use torrent::{
AddRequest, AddResponse, AddedInfo, ListRequest, ListResponse, Notification, NotificationKind, AddRequest, AddResponse, AddedInfo, FileState, FilesRequest, FilesResponse, ListRequest,
NotificationsRequest, PauseRequest, PauseResponse, ProgressMark, RemoveRequest, RemoveResponse, ListResponse, Notification, NotificationKind, NotificationsRequest, PauseRequest,
RemovedInfo, ResumeRequest, ResumeResponse, State, StateChanged, StatusRequest, TorrentStatus, PauseResponse, ProgressMark, RemoveRequest, RemoveResponse, RemovedInfo, ResumeRequest,
ResumeResponse, State, StateChanged, StatusRequest, TorrentFile, TorrentStatus,
torrents_client::TorrentsClient, torrents_client::TorrentsClient,
torrents_server::{Torrents, TorrentsServer}, torrents_server::{Torrents, TorrentsServer},
}; };
+6 -2
View File
@@ -77,9 +77,13 @@ pub enum Command {
Download { Download {
/// Magnet link, HTTP(S) URL, or path to a .torrent file. /// Magnet link, HTTP(S) URL, or path to a .torrent file.
source: String, source: String,
/// Directory to save the torrent into. Defaults to torad's configured download directory. /// Folder to download into. Defaults to torad's `<download_dir>/<torrent_name>/`.
#[arg(long)] #[arg(long)]
output_dir: Option<String>, download_folder: Option<String>,
/// Folder to move the torrent into once it finishes downloading.
/// Defaults to torad's `<download_dir>/completed/<torrent_name>/`.
#[arg(long)]
move_after_completion_folder: Option<String>,
}, },
/// Show the status of a single torrent. /// Show the status of a single torrent.
Status { Status {
+18 -3
View File
@@ -41,7 +41,20 @@ async fn main() -> Result<()> {
match cli.command { match cli.command {
Command::Health => health(&socket, f).await, Command::Health => health(&socket, f).await,
Command::Download { source, output_dir } => download(&socket, source, output_dir, f).await, Command::Download {
source,
download_folder,
move_after_completion_folder,
} => {
download(
&socket,
source,
download_folder,
move_after_completion_folder,
f,
)
.await
}
Command::Status { id } => status(&socket, id, f).await, Command::Status { id } => status(&socket, id, f).await,
Command::List { sort } => list(&socket, sort, f).await, Command::List { sort } => list(&socket, sort, f).await,
Command::Remove { id, delete_files } => remove(&socket, id, delete_files, f).await, Command::Remove { id, delete_files } => remove(&socket, id, delete_files, f).await,
@@ -101,7 +114,8 @@ async fn health(socket: &Path, f: &dyn Formatter) -> Result<()> {
async fn download( async fn download(
socket: &Path, socket: &Path,
source: String, source: String,
output_dir: Option<String>, download_folder: Option<String>,
move_after_completion_folder: Option<String>,
f: &dyn Formatter, f: &dyn Formatter,
) -> Result<()> { ) -> Result<()> {
let channel = connect(socket).await?; let channel = connect(socket).await?;
@@ -111,7 +125,8 @@ async fn download(
RPC_TIMEOUT, RPC_TIMEOUT,
client.add(AddRequest { client.add(AddRequest {
magnet: source, magnet: source,
output_dir: output_dir.unwrap_or_default(), download_folder: download_folder.unwrap_or_default(),
move_after_completion_folder: move_after_completion_folder.unwrap_or_default(),
}), }),
) )
.await .await
+33 -11
View File
@@ -32,7 +32,9 @@ pub struct TorrentRow {
pub info_hash: String, pub info_hash: String,
pub name: Option<String>, pub name: Option<String>,
pub source: String, pub source: String,
pub output_path: String, pub output_path: Option<String>,
pub download_folder: Option<String>,
pub move_after_completion_folder: Option<String>,
pub total_bytes: Option<i64>, pub total_bytes: Option<i64>,
pub downloaded_bytes: i64, pub downloaded_bytes: i64,
pub state: TorrentState, pub state: TorrentState,
@@ -43,17 +45,20 @@ pub async fn insert_pending(
pool: &PgPool, pool: &PgPool,
info_hash: &str, info_hash: &str,
source: &str, source: &str,
output_path: &str, download_folder: Option<&str>,
move_after_completion_folder: Option<&str>,
) -> Result<TorrentRow> { ) -> Result<TorrentRow> {
let row = sqlx::query_as::<_, TorrentRow>( let row = sqlx::query_as::<_, TorrentRow>(
"INSERT INTO torrents (info_hash, source, output_path) "INSERT INTO torrents (info_hash, source, download_folder, move_after_completion_folder)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4)
ON CONFLICT (info_hash) DO UPDATE SET updated_at = now() ON CONFLICT (info_hash) DO UPDATE SET updated_at = now()
RETURNING id, info_hash, name, source, output_path, total_bytes, downloaded_bytes, state, error_message", RETURNING id, info_hash, name, source, output_path, download_folder, \
move_after_completion_folder, total_bytes, downloaded_bytes, state, error_message",
) )
.bind(info_hash) .bind(info_hash)
.bind(source) .bind(source)
.bind(output_path) .bind(download_folder)
.bind(move_after_completion_folder)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
Ok(row) Ok(row)
@@ -61,7 +66,8 @@ pub async fn insert_pending(
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Option<TorrentRow>> { pub async fn get(pool: &PgPool, id: Uuid) -> Result<Option<TorrentRow>> {
let row = sqlx::query_as::<_, TorrentRow>( let row = sqlx::query_as::<_, TorrentRow>(
"SELECT id, info_hash, name, source, output_path, total_bytes, downloaded_bytes, state, error_message "SELECT id, info_hash, name, source, output_path, download_folder, \
move_after_completion_folder, total_bytes, downloaded_bytes, state, error_message
FROM torrents WHERE id = $1", FROM torrents WHERE id = $1",
) )
.bind(id) .bind(id)
@@ -72,7 +78,8 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<Option<TorrentRow>> {
pub async fn list(pool: &PgPool) -> Result<Vec<TorrentRow>> { pub async fn list(pool: &PgPool) -> Result<Vec<TorrentRow>> {
let rows = sqlx::query_as::<_, TorrentRow>( let rows = sqlx::query_as::<_, TorrentRow>(
"SELECT id, info_hash, name, source, output_path, total_bytes, downloaded_bytes, state, error_message "SELECT id, info_hash, name, source, output_path, download_folder, \
move_after_completion_folder, total_bytes, downloaded_bytes, state, error_message
FROM torrents ORDER BY added_at DESC", FROM torrents ORDER BY added_at DESC",
) )
.fetch_all(pool) .fetch_all(pool)
@@ -83,7 +90,8 @@ pub async fn list(pool: &PgPool) -> Result<Vec<TorrentRow>> {
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<Option<TorrentRow>> { pub async fn delete(pool: &PgPool, id: Uuid) -> Result<Option<TorrentRow>> {
let row = sqlx::query_as::<_, TorrentRow>( let row = sqlx::query_as::<_, TorrentRow>(
"DELETE FROM torrents WHERE id = $1 "DELETE FROM torrents WHERE id = $1
RETURNING id, info_hash, name, source, output_path, total_bytes, downloaded_bytes, state, error_message", RETURNING id, info_hash, name, source, output_path, download_folder, \
move_after_completion_folder, total_bytes, downloaded_bytes, state, error_message",
) )
.bind(id) .bind(id)
.fetch_optional(pool) .fetch_optional(pool)
@@ -93,7 +101,8 @@ pub async fn delete(pool: &PgPool, id: Uuid) -> Result<Option<TorrentRow>> {
pub async fn list_pending(pool: &PgPool) -> Result<Vec<TorrentRow>> { pub async fn list_pending(pool: &PgPool) -> Result<Vec<TorrentRow>> {
let rows = sqlx::query_as::<_, TorrentRow>( let rows = sqlx::query_as::<_, TorrentRow>(
"SELECT id, info_hash, name, source, output_path, total_bytes, downloaded_bytes, state, error_message "SELECT id, info_hash, name, source, output_path, download_folder, \
move_after_completion_folder, total_bytes, downloaded_bytes, state, error_message
FROM torrents WHERE state = 'pending' ORDER BY added_at", FROM torrents WHERE state = 'pending' ORDER BY added_at",
) )
.fetch_all(pool) .fetch_all(pool)
@@ -119,6 +128,18 @@ pub async fn set_state(pool: &PgPool, id: Uuid, state: TorrentState) -> Result<(
Ok(()) Ok(())
} }
/// Used by the poller to record the actual output path once librqbit
/// resolves metadata and creates the directory (or after a post-completion
/// move). Leaves all other columns untouched.
pub async fn update_output_path(pool: &PgPool, id: Uuid, output_path: &str) -> Result<()> {
sqlx::query("UPDATE torrents SET output_path = $2, updated_at = now() WHERE id = $1")
.bind(id)
.bind(output_path)
.execute(pool)
.await?;
Ok(())
}
pub struct Progress<'a> { pub struct Progress<'a> {
pub name: Option<&'a str>, pub name: Option<&'a str>,
pub total_bytes: i64, pub total_bytes: i64,
@@ -142,7 +163,8 @@ pub async fn update_progress(
updated_at = now(), updated_at = now(),
completed_at = CASE WHEN $5 = 'finished' THEN now() ELSE completed_at END completed_at = CASE WHEN $5 = 'finished' THEN now() ELSE completed_at END
WHERE id = $1 WHERE id = $1
RETURNING id, info_hash, name, source, output_path, total_bytes, downloaded_bytes, state, error_message", RETURNING id, info_hash, name, source, output_path, download_folder, \
move_after_completion_folder, total_bytes, downloaded_bytes, state, error_message",
) )
.bind(id) .bind(id)
.bind(progress.name) .bind(progress.name)
+270 -17
View File
@@ -1,5 +1,5 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -11,10 +11,10 @@ use sqlx::PgPool;
use tokio::sync::{Mutex, broadcast}; use tokio::sync::{Mutex, broadcast};
use tonic::{Request, Response, Status}; use tonic::{Request, Response, Status};
use tora_proto::{ use tora_proto::{
AddRequest, AddResponse, AddedInfo, ListRequest, ListResponse, Notification, NotificationKind, AddRequest, AddResponse, AddedInfo, FileState, FilesRequest, FilesResponse, ListRequest,
NotificationsRequest, PauseRequest, PauseResponse, ProgressMark, RemoveRequest, RemoveResponse, ListResponse, Notification, NotificationKind, NotificationsRequest, PauseRequest,
ResumeRequest, ResumeResponse, State as ProtoState, StateChanged, StatusRequest, TorrentStatus, PauseResponse, ProgressMark, RemoveRequest, RemoveResponse, ResumeRequest, ResumeResponse,
Torrents, State as ProtoState, StateChanged, StatusRequest, TorrentFile, TorrentStatus, Torrents,
}; };
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use uuid::Uuid; use uuid::Uuid;
@@ -45,7 +45,7 @@ pub struct TorrentManager {
session: Arc<Session>, session: Arc<Session>,
default_output_dir: PathBuf, default_output_dir: PathBuf,
source: SourceResolver, source: SourceResolver,
tracked: Mutex<HashMap<Uuid, Arc<ManagedTorrent>>>, pub(crate) tracked: Mutex<HashMap<Uuid, Arc<ManagedTorrent>>>,
adding: Mutex<HashSet<Uuid>>, adding: Mutex<HashSet<Uuid>>,
events: broadcast::Sender<Notification>, events: broadcast::Sender<Notification>,
poll_cache: Mutex<HashMap<Uuid, PollCache>>, poll_cache: Mutex<HashMap<Uuid, PollCache>>,
@@ -87,14 +87,28 @@ impl TorrentManager {
let _ = self.events.send(notification); let _ = self.events.send(notification);
} }
pub async fn add(&self, source: &str, output_dir: Option<&str>) -> Result<TorrentRow> { pub async fn add(
&self,
source: &str,
download_folder: Option<&str>,
move_after_completion_folder: Option<&str>,
) -> Result<TorrentRow> {
let resolved = self.source.resolve(source).await?; let resolved = self.source.resolve(source).await?;
let stored_source = resolved.rewritten_source.as_deref().unwrap_or(source); let stored_source = resolved.rewritten_source.as_deref().unwrap_or(source);
let output_path = output_dir let download_folder = download_folder
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.map(str::to_string) .map(str::to_string);
.unwrap_or_else(|| self.default_output_dir.display().to_string()); let move_after_completion_folder = move_after_completion_folder
db::insert_pending(&self.pool, &resolved.info_hash, stored_source, &output_path).await .filter(|s| !s.is_empty())
.map(str::to_string);
db::insert_pending(
&self.pool,
&resolved.info_hash,
stored_source,
download_folder.as_deref(),
move_after_completion_folder.as_deref(),
)
.await
} }
pub async fn get_status(&self, id: Uuid) -> Result<Option<TorrentStatus>> { pub async fn get_status(&self, id: Uuid) -> Result<Option<TorrentStatus>> {
@@ -149,7 +163,9 @@ impl TorrentManager {
} }
if delete_files { if delete_files {
let _ = std::fs::remove_dir_all(&row.output_path); if let Some(path) = &row.output_path {
let _ = std::fs::remove_dir_all(path);
}
} }
} }
db::delete(&self.pool, id).await db::delete(&self.pool, id).await
@@ -237,8 +253,13 @@ impl TorrentManager {
for row in to_start { for row in to_start {
let manager = Arc::clone(&self); let manager = Arc::clone(&self);
tokio::spawn(async move { tokio::spawn(async move {
// If the caller provided an explicit download_folder, pass it
// straight to librqbit (which uses it verbatim — flat layout
// for multi-file torrents). Otherwise leave output_folder
// unset so librqbit creates <download_dir>/<torrent_name>/
// itself for multi-file torrents.
let options = AddTorrentOptions { let options = AddTorrentOptions {
output_folder: Some(row.output_path.clone()), output_folder: row.download_folder.clone(),
overwrite: true, overwrite: true,
..Default::default() ..Default::default()
}; };
@@ -318,7 +339,7 @@ impl TorrentManager {
state: final_state, state: final_state,
error_message: stats.error.as_deref(), error_message: stats.error.as_deref(),
}; };
let row = match db::update_progress(&self.pool, id, progress).await { let mut row = match db::update_progress(&self.pool, id, progress).await {
Ok(row) => row, Ok(row) => row,
Err(err) => { Err(err) => {
error!(id = %id, error = %err, "failed to persist torrent progress"); error!(id = %id, error = %err, "failed to persist torrent progress");
@@ -326,6 +347,61 @@ impl TorrentManager {
} }
}; };
// Backfill output_path once metadata resolves (handle.name()
// becomes Some). Skipped if the user set download_folder
// explicitly — in that case the path IS the download_folder
// and we set it as soon as we know anything.
if row.output_path.is_none() {
let computed = row.download_folder.clone().or_else(|| {
name.as_ref().map(|n| {
self.default_output_dir
.join(n)
.to_string_lossy()
.into_owned()
})
});
if let Some(p) = computed {
if let Err(err) = db::update_output_path(&self.pool, id, &p).await {
warn!(id = %id, error = %err, "failed to backfill output_path");
} else {
row.output_path = Some(p);
}
}
}
// On transition into Finished, fire-and-forget the move into the
// completed directory. Spawned so a slow cross-filesystem copy
// doesn't stall subsequent per-torrent work in this tick.
if state_changed && final_state == TorrentState::Finished {
if let Some(current) = row.output_path.clone() {
let target = row.move_after_completion_folder.clone().unwrap_or_else(|| {
let basename = Path::new(&current)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| id.to_string());
self.default_output_dir
.join("completed")
.join(&basename)
.to_string_lossy()
.into_owned()
});
let pool = self.pool.clone();
tokio::spawn(async move {
match move_path(&current, &target).await {
Ok(()) => {
if let Err(err) = db::update_output_path(&pool, id, &target).await {
error!(id = %id, error = %err, "post-move output_path update failed");
}
info!(id = %id, from = %current, to = %target, "torrent moved after completion");
}
Err(err) => {
warn!(id = %id, from = %current, to = %target, error = %err, "post-completion move failed; leaving at original path");
}
}
});
}
}
let status = row_to_status(row, live); let status = row_to_status(row, live);
if state_changed { if state_changed {
@@ -482,6 +558,48 @@ fn now_millis() -> u64 {
.unwrap_or(0) .unwrap_or(0)
} }
/// Moves a file or directory from `from` to `to`. Tries an atomic rename
/// first; on EXDEV (cross-device) falls back to a recursive copy + delete.
/// Parent of `to` is created if missing.
async fn move_path(from: &str, to: &str) -> std::io::Result<()> {
let from = PathBuf::from(from);
let to = PathBuf::from(to);
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent)?;
}
match std::fs::rename(&from, &to) {
Ok(()) => Ok(()),
Err(e) if e.raw_os_error() == Some(18) => {
// EXDEV: cross-device rename — fall back.
copy_tree(&from, &to)?;
if from.is_dir() {
std::fs::remove_dir_all(&from)?;
} else {
std::fs::remove_file(&from)?;
}
Ok(())
}
Err(e) => Err(e),
}
})
.await
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?
}
fn copy_tree(from: &Path, to: &Path) -> std::io::Result<()> {
if from.is_dir() {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
copy_tree(&entry.path(), &to.join(entry.file_name()))?;
}
} else {
std::fs::copy(from, to)?;
}
Ok(())
}
async fn persist_error(pool: &PgPool, id: Uuid, message: &str) { async fn persist_error(pool: &PgPool, id: Uuid, message: &str) {
let progress = db::Progress { let progress = db::Progress {
name: None, name: None,
@@ -558,7 +676,7 @@ fn row_to_status(row: TorrentRow, live: LiveTorrentStats) -> TorrentStatus {
info_hash: row.info_hash, info_hash: row.info_hash,
name: row.name.unwrap_or_default(), name: row.name.unwrap_or_default(),
source: row.source, source: row.source,
output_path: row.output_path, output_path: row.output_path.unwrap_or_default(),
total_bytes: row.total_bytes.unwrap_or(0) as u64, total_bytes: row.total_bytes.unwrap_or(0) as u64,
downloaded_bytes: row.downloaded_bytes as u64, downloaded_bytes: row.downloaded_bytes as u64,
state: proto_state(row.state) as i32, state: proto_state(row.state) as i32,
@@ -598,10 +716,13 @@ fn resolve_err(e: ResolveError) -> Status {
impl Torrents for GrpcTorrents { impl Torrents for GrpcTorrents {
async fn add(&self, request: Request<AddRequest>) -> Result<Response<AddResponse>, Status> { async fn add(&self, request: Request<AddRequest>) -> Result<Response<AddResponse>, Status> {
let req = request.into_inner(); let req = request.into_inner();
let output_dir = (!req.output_dir.is_empty()).then_some(req.output_dir.as_str()); let download_folder =
(!req.download_folder.is_empty()).then_some(req.download_folder.as_str());
let move_after_completion_folder = (!req.move_after_completion_folder.is_empty())
.then_some(req.move_after_completion_folder.as_str());
let row = self let row = self
.manager .manager
.add(&req.magnet, output_dir) .add(&req.magnet, download_folder, move_after_completion_folder)
.await .await
.map_err(|err| Status::internal(err.to_string()))?; .map_err(|err| Status::internal(err.to_string()))?;
let id_str = row.id.to_string(); let id_str = row.id.to_string();
@@ -782,6 +903,66 @@ impl Torrents for GrpcTorrents {
Ok(Response::new(ReceiverStream::new(rx_stream))) Ok(Response::new(ReceiverStream::new(rx_stream)))
} }
async fn files(
&self,
request: Request<FilesRequest>,
) -> Result<Response<FilesResponse>, Status> {
let id = self
.manager
.resolve_id(&request.into_inner().id)
.await
.map_err(resolve_err)?;
let (file_infos, file_progress) = {
let tracked = self.manager.tracked.lock().await;
let Some(handle) = tracked.get(&id) else {
return Err(Status::not_found(
"torrent not found or metadata not yet resolved",
));
};
let infos = handle
.with_metadata(|m| m.file_infos.clone())
.map_err(|e| Status::failed_precondition(format!("metadata not resolved: {e}")))?;
let progress = handle.stats().file_progress;
(infos, progress)
};
Ok(Response::new(FilesResponse {
files: build_file_list(&file_infos, &file_progress),
}))
}
}
/// Pure mapping from librqbit's per-file metadata + per-file verified-byte
/// counters into the wire `TorrentFile` shape. Extracted from the `files`
/// handler so it can be unit-tested without a live librqbit session.
fn build_file_list(
file_infos: &[librqbit::file_info::FileInfo],
file_progress: &[u64],
) -> Vec<TorrentFile> {
file_infos
.iter()
.enumerate()
.map(|(i, fi)| {
let length = fi.len;
let downloaded = file_progress.get(i).copied().unwrap_or(0);
TorrentFile {
name: fi.relative_filename.to_string_lossy().into_owned(),
length,
downloaded_bytes: downloaded,
state: derive_file_state(downloaded, length) as i32,
}
})
.collect()
}
fn derive_file_state(downloaded: u64, length: u64) -> FileState {
if length > 0 && downloaded >= length {
FileState::Ready
} else {
FileState::InProgress
}
} }
#[cfg(test)] #[cfg(test)]
@@ -972,4 +1153,76 @@ mod tests {
let out = compute_diff(&mut cache, TorrentState::Downloading, 1000, 1, 1000); let out = compute_diff(&mut cache, TorrentState::Downloading, 1000, 1, 1000);
assert_eq!(out.2, 100); assert_eq!(out.2, 100);
} }
// ---- build_file_list / derive_file_state ----
fn fi(name: &str, len: u64) -> librqbit::file_info::FileInfo {
use librqbit_core::torrent_metainfo::FileDetailsAttrs;
librqbit::file_info::FileInfo {
relative_filename: std::path::PathBuf::from(name),
offset_in_torrent: 0,
piece_range: 0..0,
attrs: FileDetailsAttrs::default(),
len,
}
}
#[test]
fn derive_file_state_ready_when_downloaded_at_least_length() {
assert_eq!(derive_file_state(100, 100), FileState::Ready);
assert_eq!(derive_file_state(150, 100), FileState::Ready);
}
#[test]
fn derive_file_state_in_progress_below_length() {
assert_eq!(derive_file_state(0, 100), FileState::InProgress);
assert_eq!(derive_file_state(99, 100), FileState::InProgress);
}
#[test]
fn derive_file_state_in_progress_when_length_zero() {
// Defensive: length 0 shouldn't happen in practice but if it does we
// shouldn't claim READY — there's nothing to download.
assert_eq!(derive_file_state(0, 0), FileState::InProgress);
}
#[test]
fn build_file_list_zips_infos_with_progress_by_index() {
let infos = vec![fi("a.flac", 100), fi("b.flac", 200), fi("c.flac", 300)];
let progress = vec![100, 50, 0];
let out = build_file_list(&infos, &progress);
assert_eq!(out.len(), 3);
assert_eq!(out[0].name, "a.flac");
assert_eq!(out[0].length, 100);
assert_eq!(out[0].downloaded_bytes, 100);
assert_eq!(out[0].state, FileState::Ready as i32);
assert_eq!(out[1].downloaded_bytes, 50);
assert_eq!(out[1].state, FileState::InProgress as i32);
assert_eq!(out[2].downloaded_bytes, 0);
assert_eq!(out[2].state, FileState::InProgress as i32);
}
#[test]
fn build_file_list_handles_short_progress_vector() {
// librqbit shouldn't return a short file_progress, but be defensive.
let infos = vec![fi("a.flac", 100), fi("b.flac", 200)];
let progress = vec![100];
let out = build_file_list(&infos, &progress);
assert_eq!(out[1].downloaded_bytes, 0);
assert_eq!(out[1].state, FileState::InProgress as i32);
}
#[test]
fn build_file_list_preserves_subdir_in_name() {
let mut info = fi("subdir/track.flac", 100);
info.relative_filename = std::path::PathBuf::from("Artist - Album/01-track.flac");
let out = build_file_list(&[info], &[100]);
assert_eq!(out[0].name, "Artist - Album/01-track.flac");
}
#[test]
fn build_file_list_empty_inputs_returns_empty() {
let out = build_file_list(&[], &[]);
assert!(out.is_empty());
}
} }
+9 -1
View File
@@ -12,7 +12,15 @@ CREATE TABLE torrents (
info_hash TEXT NOT NULL UNIQUE, info_hash TEXT NOT NULL UNIQUE,
name TEXT, name TEXT,
source TEXT NOT NULL, source TEXT NOT NULL,
output_path TEXT NOT NULL, -- Set by the poller once librqbit resolves metadata and creates the
-- output directory. NULL until then (magnet links take time to resolve).
output_path TEXT,
-- Per-torrent override of the download location. NULL = use the daemon
-- default (<download_dir>/<torrent_name>/ for multi-file torrents).
download_folder TEXT,
-- Per-torrent override of the post-completion move target. NULL = use
-- the daemon default (<download_dir>/completed/<torrent_name>/).
move_after_completion_folder TEXT,
total_bytes BIGINT, total_bytes BIGINT,
downloaded_bytes BIGINT NOT NULL DEFAULT 0, downloaded_bytes BIGINT NOT NULL DEFAULT 0,
state torrent_state NOT NULL DEFAULT 'pending', state torrent_state NOT NULL DEFAULT 'pending',
+36 -2
View File
@@ -19,12 +19,21 @@ service Torrents {
// from the subscribe point onward — no history is replayed. New subscribers // from the subscribe point onward — no history is replayed. New subscribers
// only see events that fire after they connect. // only see events that fire after they connect.
rpc Notifications(NotificationsRequest) returns (stream Notification); rpc Notifications(NotificationsRequest) returns (stream Notification);
// List the files inside a torrent with their download progress. Returns
// NOT_FOUND if the torrent is unknown or its metadata has not resolved yet
// (e.g. a magnet link still fetching peers).
rpc Files(FilesRequest) returns (FilesResponse);
} }
message AddRequest { message AddRequest {
string magnet = 1; string magnet = 1;
// Optional output directory override. Empty means use torad's default. // Folder to download into. Empty = daemon default
string output_dir = 2; // (<download_dir>/<torrent_name>/ for multi-file torrents).
string download_folder = 2;
// Folder to move the torrent into after completion. Empty = daemon default
// (<download_dir>/completed/<torrent_name>/).
string move_after_completion_folder = 3;
} }
message AddResponse { message AddResponse {
@@ -69,6 +78,31 @@ message NotificationsRequest {
repeated NotificationKind kinds = 2; repeated NotificationKind kinds = 2;
} }
message FilesRequest {
string id = 1;
}
enum FileState {
FILE_STATE_UNSPECIFIED = 0;
// File is partially downloaded.
IN_PROGRESS = 1;
// File's full byte range has been verified.
READY = 2;
}
message TorrentFile {
// Path relative to the torrent root, as declared in the .torrent metadata.
// May contain forward slashes for multi-file torrents.
string name = 1;
uint64 length = 2;
uint64 downloaded_bytes = 3;
FileState state = 4;
}
message FilesResponse {
repeated TorrentFile files = 1;
}
message Notification { message Notification {
// Wall-clock time the daemon observed the change, in milliseconds since the // Wall-clock time the daemon observed the change, in milliseconds since the
// Unix epoch. // Unix epoch.