Add a Bitmagnet import action

This commit is contained in:
Marc Plano-Lesay 2025-05-01 19:32:42 +10:00
parent 8eda807ead
commit d26931f4c6
Signed by: kernald
GPG key ID: 66A41B08CC62A6CF
15 changed files with 402 additions and 10 deletions

View file

@ -0,0 +1,70 @@
use crate::actions::action::{Action, ProcessedMagnets};
use crate::actions::bitmagnet::client::BitmagnetClient;
use crate::actions::bitmagnet::config::BitmagnetConfig;
use crate::db::{BitmagnetProcessedTable, Database};
use color_eyre::eyre::Result;
use log::{debug, warn};
/// Action for submitting magnet links to Bitmagnet
pub struct BitmagnetAction {
client: BitmagnetClient,
}
impl BitmagnetAction {
pub async fn new(config: &BitmagnetConfig) -> Result<Self> {
let client = BitmagnetClient::new(config)?;
Ok(BitmagnetAction { client })
}
}
#[async_trait::async_trait]
impl Action for BitmagnetAction {
/// Return the name of the action
fn name(&self) -> &str {
"Bitmagnet"
}
/// Process all unprocessed magnet links and return the list of processed magnets
async fn process_unprocessed_magnets(&mut self, db: &mut Database) -> Result<ProcessedMagnets> {
let unprocessed_magnets =
db.get_unprocessed_magnets_for_table::<BitmagnetProcessedTable>()?;
let mut processed_magnets = Vec::new();
let mut failed_magnets = Vec::new();
for magnet in unprocessed_magnets {
if let Some(id) = magnet.id {
match self
.client
.submit_magnet(
&magnet.link,
&magnet.published_at.and_utc(),
&magnet.imdb_id,
)
.await
{
Ok(_) => {
debug!(
"Successfully submitted magnet link to Bitmagnet: {}",
magnet.title
);
debug!("Magnet link: {}", magnet.link);
db.mark_magnet_processed_for_table::<BitmagnetProcessedTable>(id)?;
processed_magnets.push(magnet);
}
Err(e) => {
warn!("Failed to submit magnet link to Bitmagnet: {}", e);
failed_magnets.push(magnet);
}
}
} else {
warn!("Skipping magnet with null ID: {}", magnet.link);
}
}
Ok(ProcessedMagnets {
success: processed_magnets,
failed: failed_magnets,
})
}
}

View file

@ -0,0 +1,129 @@
use crate::actions::bitmagnet::config::BitmagnetConfig;
use chrono::{DateTime, Utc};
use color_eyre::eyre::{eyre, Result, WrapErr};
use log::{info, warn};
use magnet_url::Magnet;
use reqwest::Client;
use serde_json::json;
use url::Url;
use urlencoding::decode;
/// High-level Bitmagnet client
pub struct BitmagnetClient {
client: Client,
base_url: Url,
}
impl BitmagnetClient {
/// Create a new Bitmagnet client from configuration
pub fn new(config: &BitmagnetConfig) -> Result<Self> {
if !config.enable {
return Err(eyre!("Bitmagnet action is disabled"));
}
let url_str = &config.host;
let base_url = Url::parse(url_str).wrap_err_with(|| format!("Invalid URL: {}", url_str))?;
let client = Client::new();
Ok(BitmagnetClient { client, base_url })
}
/// Submit a magnet link to Bitmagnet
pub async fn submit_magnet(
&self,
magnet: &str,
published_at: &DateTime<Utc>,
imdb_id: &Option<String>,
) -> Result<()> {
let url = self
.base_url
.join("import")
.wrap_err("Failed to construct API URL")?;
let structured_magnet =
Magnet::new(magnet).map_err(|e| eyre!("Invalid magnet link: {:?}", e))?;
// Create a JSON object with the required fields
let mut json_body = json!({
"publishedAt": published_at.to_rfc3339(),
"source": "reddit-magnet",
});
match structured_magnet.xt {
Some(info_hash) => {
json_body["infoHash"] = json!(info_hash);
}
None => {
return Err(eyre!("Info hash not found in magnet link"));
}
}
if let Some(name) = structured_magnet.dn {
json_body["name"] = json!(decode(&*name)?);
}
if let Some(id) = imdb_id {
json_body["contentSource"] = json!("imdb");
json_body["contentId"] = json!(id);
}
if let Some(size) = structured_magnet.xl {
json_body["size"] = json!(size);
}
let response = self
.client
.post(url)
.json(&json_body)
.send()
.await
.wrap_err("Failed to send request to Bitmagnet API")?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(eyre!(
"Bitmagnet API returned error: {} - {}",
status,
error_text
));
}
Ok(())
}
/// Extract the info hash from a magnet link
fn extract_info_hash(magnet: &str) -> Result<String> {
// Magnet links typically have the format: magnet:?xt=urn:btih:<info_hash>&dn=<name>&...
let parts: Vec<&str> = magnet.split('&').collect();
for part in parts {
if part.starts_with("magnet:?xt=urn:btih:") {
return Ok(part[20..].to_string());
}
if part.starts_with("xt=urn:btih:") {
return Ok(part[12..].to_string());
}
}
Err(eyre!("Info hash not found in magnet link"))
}
/// Extract the name from a magnet link
fn extract_name(magnet: &str) -> Option<String> {
// Magnet links typically have the format: magnet:?xt=urn:btih:<info_hash>&dn=<name>&...
let parts: Vec<&str> = magnet.split('&').collect();
for part in parts {
if part.starts_with("dn=") {
return Some(part[3..].to_string());
}
}
None
}
}

View file

@ -0,0 +1,8 @@
use serde::{Deserialize, Serialize};
/// Configuration for the Bitmagnet action
#[derive(Debug, Serialize, Deserialize)]
pub struct BitmagnetConfig {
pub enable: bool,
pub host: String,
}

View file

@ -0,0 +1,7 @@
mod action;
mod client;
mod config;
pub use action::BitmagnetAction;
pub use client::BitmagnetClient;
pub use config::BitmagnetConfig;

View file

@ -1,2 +1,3 @@
pub mod action;
pub mod bitmagnet;
pub mod transmission;

View file

@ -51,7 +51,6 @@ impl Action for TransmissionAction {
}
} else {
warn!("Skipping magnet with null ID: {}", magnet.link);
// Consider adding to failed_magnets if we want to report these as well
}
}