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() {
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;
}
let mut struct_ser = serializer.serialize_struct("torrent.AddRequest", len)?;
if !self.magnet.is_empty() {
struct_ser.serialize_field("magnet", &self.magnet)?;
}
if !self.output_dir.is_empty() {
struct_ser.serialize_field("outputDir", &self.output_dir)?;
if !self.download_folder.is_empty() {
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()
}
@@ -29,12 +38,19 @@ impl<'de> serde::Deserialize<'de> for AddRequest {
where
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)]
enum GeneratedField {
Magnet,
OutputDir,
DownloadFolder,
MoveAfterCompletionFolder,
}
impl<'de> serde::Deserialize<'de> for GeneratedField {
fn deserialize<D>(deserializer: D) -> std::result::Result<GeneratedField, D::Error>
@@ -60,7 +76,12 @@ impl<'de> serde::Deserialize<'de> for AddRequest {
{
match value {
"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)),
}
}
@@ -81,7 +102,8 @@ impl<'de> serde::Deserialize<'de> for AddRequest {
V: serde::de::MapAccess<'de>,
{
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()? {
match k {
GeneratedField::Magnet => {
@@ -90,17 +112,27 @@ impl<'de> serde::Deserialize<'de> for AddRequest {
}
magnet__ = Some(map_.next_value()?);
}
GeneratedField::OutputDir => {
if output_dir__.is_some() {
return Err(serde::de::Error::duplicate_field("outputDir"));
GeneratedField::DownloadFolder => {
if download_folder__.is_some() {
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 {
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)
}
}
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 {
#[allow(deprecated)]
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)
}
}
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 {
#[allow(deprecated)]
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"));
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.
@@ -238,6 +252,10 @@ pub mod torrents_server {
&self,
request: tonic::Request<super::NotificationsRequest>,
) -> 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)]
pub struct TorrentsServer<T> {
@@ -578,6 +596,43 @@ pub mod torrents_server {
};
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 {
let mut response = http::Response::new(tonic::body::Body::default());
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::{
AddRequest, AddResponse, AddedInfo, ListRequest, ListResponse, Notification, NotificationKind,
NotificationsRequest, PauseRequest, PauseResponse, ProgressMark, RemoveRequest, RemoveResponse,
RemovedInfo, ResumeRequest, ResumeResponse, State, StateChanged, StatusRequest, TorrentStatus,
AddRequest, AddResponse, AddedInfo, FileState, FilesRequest, FilesResponse, ListRequest,
ListResponse, Notification, NotificationKind, NotificationsRequest, PauseRequest,
PauseResponse, ProgressMark, RemoveRequest, RemoveResponse, RemovedInfo, ResumeRequest,
ResumeResponse, State, StateChanged, StatusRequest, TorrentFile, TorrentStatus,
torrents_client::TorrentsClient,
torrents_server::{Torrents, TorrentsServer},
};
+6 -2
View File
@@ -77,9 +77,13 @@ pub enum Command {
Download {
/// Magnet link, HTTP(S) URL, or path to a .torrent file.
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)]
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.
Status {
+18 -3
View File
@@ -41,7 +41,20 @@ async fn main() -> Result<()> {
match cli.command {
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::List { sort } => list(&socket, sort, 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(
socket: &Path,
source: String,
output_dir: Option<String>,
download_folder: Option<String>,
move_after_completion_folder: Option<String>,
f: &dyn Formatter,
) -> Result<()> {
let channel = connect(socket).await?;
@@ -111,7 +125,8 @@ async fn download(
RPC_TIMEOUT,
client.add(AddRequest {
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
+33 -11
View File
@@ -32,7 +32,9 @@ pub struct TorrentRow {
pub info_hash: String,
pub name: Option<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 downloaded_bytes: i64,
pub state: TorrentState,
@@ -43,17 +45,20 @@ pub async fn insert_pending(
pool: &PgPool,
info_hash: &str,
source: &str,
output_path: &str,
download_folder: Option<&str>,
move_after_completion_folder: Option<&str>,
) -> Result<TorrentRow> {
let row = sqlx::query_as::<_, TorrentRow>(
"INSERT INTO torrents (info_hash, source, output_path)
VALUES ($1, $2, $3)
"INSERT INTO torrents (info_hash, source, download_folder, move_after_completion_folder)
VALUES ($1, $2, $3, $4)
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(source)
.bind(output_path)
.bind(download_folder)
.bind(move_after_completion_folder)
.fetch_one(pool)
.await?;
Ok(row)
@@ -61,7 +66,8 @@ pub async fn insert_pending(
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Option<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",
)
.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>> {
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",
)
.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>> {
let row = sqlx::query_as::<_, TorrentRow>(
"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)
.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>> {
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",
)
.fetch_all(pool)
@@ -119,6 +128,18 @@ pub async fn set_state(pool: &PgPool, id: Uuid, state: TorrentState) -> Result<(
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 name: Option<&'a str>,
pub total_bytes: i64,
@@ -142,7 +163,8 @@ pub async fn update_progress(
updated_at = now(),
completed_at = CASE WHEN $5 = 'finished' THEN now() ELSE completed_at END
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(progress.name)
+270 -17
View File
@@ -1,5 +1,5 @@
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -11,10 +11,10 @@ use sqlx::PgPool;
use tokio::sync::{Mutex, broadcast};
use tonic::{Request, Response, Status};
use tora_proto::{
AddRequest, AddResponse, AddedInfo, ListRequest, ListResponse, Notification, NotificationKind,
NotificationsRequest, PauseRequest, PauseResponse, ProgressMark, RemoveRequest, RemoveResponse,
ResumeRequest, ResumeResponse, State as ProtoState, StateChanged, StatusRequest, TorrentStatus,
Torrents,
AddRequest, AddResponse, AddedInfo, FileState, FilesRequest, FilesResponse, ListRequest,
ListResponse, Notification, NotificationKind, NotificationsRequest, PauseRequest,
PauseResponse, ProgressMark, RemoveRequest, RemoveResponse, ResumeRequest, ResumeResponse,
State as ProtoState, StateChanged, StatusRequest, TorrentFile, TorrentStatus, Torrents,
};
use tracing::{error, info, warn};
use uuid::Uuid;
@@ -45,7 +45,7 @@ pub struct TorrentManager {
session: Arc<Session>,
default_output_dir: PathBuf,
source: SourceResolver,
tracked: Mutex<HashMap<Uuid, Arc<ManagedTorrent>>>,
pub(crate) tracked: Mutex<HashMap<Uuid, Arc<ManagedTorrent>>>,
adding: Mutex<HashSet<Uuid>>,
events: broadcast::Sender<Notification>,
poll_cache: Mutex<HashMap<Uuid, PollCache>>,
@@ -87,14 +87,28 @@ impl TorrentManager {
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 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())
.map(str::to_string)
.unwrap_or_else(|| self.default_output_dir.display().to_string());
db::insert_pending(&self.pool, &resolved.info_hash, stored_source, &output_path).await
.map(str::to_string);
let move_after_completion_folder = move_after_completion_folder
.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>> {
@@ -149,7 +163,9 @@ impl TorrentManager {
}
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
@@ -237,8 +253,13 @@ impl TorrentManager {
for row in to_start {
let manager = Arc::clone(&self);
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 {
output_folder: Some(row.output_path.clone()),
output_folder: row.download_folder.clone(),
overwrite: true,
..Default::default()
};
@@ -318,7 +339,7 @@ impl TorrentManager {
state: final_state,
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,
Err(err) => {
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);
if state_changed {
@@ -482,6 +558,48 @@ fn now_millis() -> u64 {
.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) {
let progress = db::Progress {
name: None,
@@ -558,7 +676,7 @@ fn row_to_status(row: TorrentRow, live: LiveTorrentStats) -> TorrentStatus {
info_hash: row.info_hash,
name: row.name.unwrap_or_default(),
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,
downloaded_bytes: row.downloaded_bytes as u64,
state: proto_state(row.state) as i32,
@@ -598,10 +716,13 @@ fn resolve_err(e: ResolveError) -> Status {
impl Torrents for GrpcTorrents {
async fn add(&self, request: Request<AddRequest>) -> Result<Response<AddResponse>, Status> {
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
.manager
.add(&req.magnet, output_dir)
.add(&req.magnet, download_folder, move_after_completion_folder)
.await
.map_err(|err| Status::internal(err.to_string()))?;
let id_str = row.id.to_string();
@@ -782,6 +903,66 @@ impl Torrents for GrpcTorrents {
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)]
@@ -972,4 +1153,76 @@ mod tests {
let out = compute_diff(&mut cache, TorrentState::Downloading, 1000, 1, 1000);
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,
name TEXT,
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,
downloaded_bytes BIGINT NOT NULL DEFAULT 0,
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
// only see events that fire after they connect.
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 {
string magnet = 1;
// Optional output directory override. Empty means use torad's default.
string output_dir = 2;
// Folder to download into. Empty = daemon default
// (<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 {
@@ -69,6 +78,31 @@ message NotificationsRequest {
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 {
// Wall-clock time the daemon observed the change, in milliseconds since the
// Unix epoch.