Fix qBittorrent WebUI API compat and season-folder placement in library scan
qBittorrent's newer WebUI API returns 204 (not 200 "Ok.") on login success, and a JSON success/failure summary (not plain "Ok."/"Fails." text) from torrents/add — both broke against the currently deployed version. Also had scan_tv_root move already-tracked episode files into their Season NN subfolder instead of just recording wherever they already sat on disk.
This commit is contained in:
parent
9d8a59e3a8
commit
54818f5f05
3 changed files with 61 additions and 12 deletions
|
|
@ -18,6 +18,7 @@ pub struct ScanReport {
|
||||||
pub unmatched: Vec<String>,
|
pub unmatched: Vec<String>,
|
||||||
pub files_linked: usize,
|
pub files_linked: usize,
|
||||||
pub files_renamed: usize,
|
pub files_renamed: usize,
|
||||||
|
pub files_reorganized: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_episode_title(conn: &Connection, episode_id: i64) -> Result<Option<String>> {
|
fn get_episode_title(conn: &Connection, episode_id: i64) -> Result<Option<String>> {
|
||||||
|
|
@ -525,6 +526,30 @@ pub async fn scan_tv_root(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Files imported before the season-folder convention existed
|
||||||
|
// (or moved around by hand) can still be sitting flat in the
|
||||||
|
// show root — move them under `Season NN` now rather than just
|
||||||
|
// recording wherever they happen to already be. Same
|
||||||
|
// filesystem as `series_dir`, so this is a plain rename.
|
||||||
|
let season_folder = importer::season_dir(&series_dir.to_string_lossy(), season);
|
||||||
|
let final_path = if final_path.parent() != Some(season_folder.as_path()) {
|
||||||
|
match std::fs::create_dir_all(&season_folder).and_then(|_| {
|
||||||
|
let dest = season_folder.join(final_path.file_name().unwrap());
|
||||||
|
std::fs::rename(&final_path, &dest).map(|_| dest)
|
||||||
|
}) {
|
||||||
|
Ok(dest) => {
|
||||||
|
report.files_reorganized += 1;
|
||||||
|
dest
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(file = %final_path.display(), error = %e, "season-folder move failed, keeping in place");
|
||||||
|
final_path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final_path
|
||||||
|
};
|
||||||
|
|
||||||
let size = std::fs::metadata(&final_path)?.len();
|
let size = std::fs::metadata(&final_path)?.len();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO episode_file (episode_id, path, size_bytes, subtitle_status) VALUES (?1, ?2, ?3, 'none')",
|
"INSERT INTO episode_file (episode_id, path, size_bytes, subtitle_status) VALUES (?1, ?2, ?3, 'none')",
|
||||||
|
|
|
||||||
|
|
@ -927,11 +927,12 @@ async fn debug_scan_tv(config: &Config, path: &str) -> Result<()> {
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"matched={} unmatched={} files_linked={} files_renamed={}",
|
"matched={} unmatched={} files_linked={} files_renamed={} files_reorganized={}",
|
||||||
report.matched.len(),
|
report.matched.len(),
|
||||||
report.unmatched.len(),
|
report.unmatched.len(),
|
||||||
report.files_linked,
|
report.files_linked,
|
||||||
report.files_renamed
|
report.files_renamed,
|
||||||
|
report.files_reorganized
|
||||||
);
|
);
|
||||||
if !report.unmatched.is_empty() {
|
if !report.unmatched.is_empty() {
|
||||||
println!("unmatched:");
|
println!("unmatched:");
|
||||||
|
|
@ -974,11 +975,12 @@ async fn debug_scan_movies(config: &Config, path: &str) -> Result<()> {
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"matched={} unmatched={} files_linked={} files_renamed={}",
|
"matched={} unmatched={} files_linked={} files_renamed={} files_reorganized={}",
|
||||||
report.matched.len(),
|
report.matched.len(),
|
||||||
report.unmatched.len(),
|
report.unmatched.len(),
|
||||||
report.files_linked,
|
report.files_linked,
|
||||||
report.files_renamed
|
report.files_renamed,
|
||||||
|
report.files_reorganized
|
||||||
);
|
);
|
||||||
if !report.unmatched.is_empty() {
|
if !report.unmatched.is_empty() {
|
||||||
println!("unmatched:");
|
println!("unmatched:");
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,15 @@ impl std::fmt::Display for MagnetRejected {
|
||||||
|
|
||||||
impl std::error::Error for MagnetRejected {}
|
impl std::error::Error for MagnetRejected {}
|
||||||
|
|
||||||
|
/// Newer qBittorrent WebUI API versions' `torrents/add` JSON response shape.
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
struct AddTorrentResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
success_count: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
failure_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct QbitClient {
|
pub struct QbitClient {
|
||||||
base_url: String,
|
base_url: String,
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
|
|
@ -101,7 +110,12 @@ impl QbitClient {
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
if !status.is_success() || body.trim() != "Ok." {
|
// Older qBittorrent WebUI API versions return 200 with body "Ok.";
|
||||||
|
// newer ones return 204 No Content with an empty body instead. Bad
|
||||||
|
// credentials return a real error status (401), which `is_success`
|
||||||
|
// already catches — the response body's exact text isn't part of
|
||||||
|
// the actual success contract, just an artifact of the old version.
|
||||||
|
if !status.is_success() {
|
||||||
bail!("qbit login failed: status={status} body={body:?}");
|
bail!("qbit login failed: status={status} body={body:?}");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -142,13 +156,21 @@ impl QbitClient {
|
||||||
}
|
}
|
||||||
// qBittorrent's add-torrent endpoint returns HTTP 200 even when
|
// qBittorrent's add-torrent endpoint returns HTTP 200 even when
|
||||||
// it rejects the magnet outright (a dead/malformed hash, one it
|
// it rejects the magnet outright (a dead/malformed hash, one it
|
||||||
// already knows is unreachable) — the *only* signal is the
|
// already knows is unreachable) — the response body is the only
|
||||||
// response body text ("Ok." vs "Fails."). Without this check a
|
// signal. Older qBittorrent versions returned plain text ("Ok."
|
||||||
// rejected magnet looks identical to a real success: the caller
|
// vs "Fails."); newer ones return a JSON summary with
|
||||||
// records a `release` row as grabbed and nothing ever
|
// success_count/failure_count instead (verified live against
|
||||||
// downloads, silently and permanently (verified live — this
|
// the currently deployed version). Without checking whichever
|
||||||
// happened for a real release).
|
// shape is actually in play, a rejected magnet looks identical
|
||||||
if body.trim() != "Ok." {
|
// to a real success: the caller records a `release` row as
|
||||||
|
// grabbed and nothing ever downloads, silently and permanently
|
||||||
|
// (verified live — this happened for a real release under the
|
||||||
|
// old text-only check).
|
||||||
|
let rejected = match serde_json::from_str::<AddTorrentResponse>(&body) {
|
||||||
|
Ok(r) => r.failure_count > 0 || r.success_count == 0,
|
||||||
|
Err(_) => body.trim() != "Ok.",
|
||||||
|
};
|
||||||
|
if rejected {
|
||||||
return Err(anyhow::Error::new(MagnetRejected { body }));
|
return Err(anyhow::Error::new(MagnetRejected { body }));
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue