Move torrents to completed after finish
This commit is contained in:
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,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},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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(¤t)
|
||||||
|
.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(¤t, &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
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user