Fix review-queue dead ends and harden grab/import/API paths
Some checks failed
check / check (push) Failing after 14m5s
dev release / build (push) Successful in 3m54s

Stop upgrade-search from queuing mid-confidence matches that approve
cannot honor (owned movies/episodes 409'd on the TUI). De-dupe pending
review rows, scope 1080p gates to the target episode, refuse unsafe
pack cleanup, and require a token for non-loopback binds.
This commit is contained in:
Breadway 2026-08-16 00:44:58 +08:00
parent 4a2adbc24d
commit 7ab28d30a7
31 changed files with 2536 additions and 328 deletions

View file

@ -71,7 +71,7 @@ pub enum BackgroundRequest {
/// Mutex` is fine here (unlike `conn`) since updates are a single field
/// write with no `.await` in between. Exists so a silently-stalled
/// background loop (e.g. every cycle erroring for hours) is visible from a
/// single `/health` call instead of only in the journal.
/// single `/health/detail` call instead of only in the journal.
#[derive(Clone, Default)]
pub struct CycleStatus {
pub last_grab: Option<CycleRecord>,
@ -96,10 +96,11 @@ pub struct CycleRecord {
/// Rejects any request lacking `Authorization: Bearer <config.daemon.api_token>`
/// once a token is actually configured — a no-op (every request passes)
/// when it's empty, so an unconfigured install behaves exactly as before.
/// `/health` is deliberately exempt even with a token configured: it's
/// commonly polled by external monitoring (e.g. an uptime dashboard) that
/// has no reason to hold the same credential as the TUI/API client, and it
/// exposes nothing more sensitive than "is the process alive."
/// Exact `/health` is deliberately exempt even with a token configured:
/// it's commonly polled by external monitoring (e.g. an uptime dashboard)
/// that has no reason to hold the same credential as the TUI/API client,
/// and it exposes nothing more sensitive than "is the process alive."
/// `/health/detail` is *not* exempt — it includes cycle status.
async fn require_api_token(State(state): State<AppState>, req: Request, next: Next) -> Response {
if state.config.daemon.api_token.is_empty() || req.uri().path() == "/health" {
return next.run(req).await;
@ -118,21 +119,27 @@ async fn require_api_token(State(state): State<AppState>, req: Request, next: Ne
/// Byte-wise `==` short-circuits on the first mismatching byte, making
/// comparison time a (weak, but real) signal of how many leading bytes of a
/// guessed token were correct — a classic timing oracle. This always
/// touches every byte of the shorter input regardless of where they first
/// differ. Real-world exposure here is low (loopback-bound by default, a
/// personal single-user daemon), but it costs nothing to close.
/// guessed token were correct — a classic timing oracle. This always walks
/// the longer input (padding the shorter against a dummy) and folds a
/// length mismatch into the accumulator so a length difference is not a
/// first-instruction return. Pair with `api_token.len() >= 16` in
/// `Config::validate` so a length-oracle of short guesses is useless.
fn constant_time_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
return false;
let max = a.len().max(b.len());
let mut acc = u8::from(a.len() != b.len());
for i in 0..max {
let x = *a.get(i).unwrap_or(&0);
let y = *b.get(i).unwrap_or(&0);
acc |= x ^ y;
}
a.iter().zip(b.iter()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
acc == 0
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/health", get(routes::health::health))
.route("/health/detail", get(routes::health::health_detail))
.route("/media", get(routes::media::list).post(routes::media::add))
.route(
"/media/:id",

View file

@ -1,6 +1,6 @@
use axum::extract::State;
use axum::Json;
use breadarr_shared::dto::{CycleInfo, HealthDetail};
use breadarr_shared::dto::{CycleInfo, HealthDetail, HealthStatus};
use crate::api::AppState;
@ -12,7 +12,15 @@ fn to_info(r: &crate::api::CycleRecord) -> CycleInfo {
}
}
pub async fn health(State(state): State<AppState>) -> Json<HealthDetail> {
/// Unauthenticated liveness — cheap, no cycle detail.
pub async fn health() -> Json<HealthStatus> {
Json(HealthStatus {
status: "ok".to_string(),
})
}
/// Authenticated cycle-status payload (same as the old `/health`).
pub async fn health_detail(State(state): State<AppState>) -> Json<HealthDetail> {
let status = state.cycle_status.lock().expect("cycle_status poisoned");
Json(HealthDetail {
status: "ok".to_string(),

View file

@ -1,3 +1,5 @@
use std::path::{Component, Path as FsPath, PathBuf};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
@ -5,6 +7,7 @@ use breadarr_shared::dto::{
AddMovieRequest, AddMovieResponse, AddSeriesRequest, AddSeriesResponse, EpisodeSummary,
MediaItemDetail, MediaItemSummary, SearchNowResult,
};
use breadarr_shared::Config;
use rusqlite::{params, OptionalExtension};
use crate::api::AppState;
@ -45,7 +48,7 @@ pub async fn detail(
Path(id): Path<i64>,
) -> Result<Json<MediaItemDetail>, (StatusCode, String)> {
let conn = state.conn.lock().await;
let (kind, title, year, monitored, root_folder) = conn
let row = conn
.query_row(
"SELECT kind, title, year, monitored, root_folder FROM media_item WHERE id = ?1",
params![id],
@ -59,7 +62,11 @@ pub async fn detail(
))
},
)
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
.optional()
.map_err(internal)?;
let Some((kind, title, year, monitored, root_folder)) = row else {
return Err((StatusCode::NOT_FOUND, format!("no media_item {id}")));
};
let mut stmt = conn
.prepare(
@ -105,6 +112,8 @@ pub async fn add(
));
};
let root_folder = constrain_root_folder(&req.root_folder, &state.config.default_root_folder())?;
// Fetch before taking the lock — a sync MutexGuard can't be held across
// an `.await` point.
let episodes = tvdb.episodes(&req.tvdb_id).await.map_err(internal)?;
@ -117,7 +126,7 @@ pub async fn add(
&req.title,
req.year.map(|y| y as u32),
&req.aliases,
&req.root_folder,
&root_folder,
1,
&episodes,
)
@ -131,13 +140,14 @@ pub async fn add_movie(
State(state): State<AppState>,
Json(req): Json<AddMovieRequest>,
) -> Result<Json<AddMovieResponse>, (StatusCode, String)> {
let root_folder = constrain_root_folder(&req.root_folder, &state.config.movies_root_folder())?;
let conn = state.conn.lock().await;
let media_item_id = metadata::insert_movie(
&conn,
&req.tmdb_id,
&req.title,
req.year.map(|y| y as u32),
&req.root_folder,
&root_folder,
2,
)
.map_err(internal)?;
@ -208,13 +218,9 @@ async fn set_episode_monitored(
Ok(StatusCode::NO_CONTENT)
}
/// Toggles every episode in one season at once — a season-level "row" isn't
/// separately tracked (the `season` table exists in the schema but was
/// never actually populated by any insert path, so resurrecting it just to
/// hold one redundant monitored flag would mean keeping two copies of the
/// same state in sync for no behavioral gain); bulk-updating the episodes
/// directly gets the identical practical effect — this season's episodes
/// stop appearing in search enumeration — with one source of truth.
/// Toggles every episode in one season and the `season.monitored` flag
/// when a season row exists (`insert_series` writes those). A missing
/// season row is not an error — episodes still update.
pub async fn monitor_season(
State(state): State<AppState>,
Path((media_item_id, season_number)): Path<(i64, i64)>,
@ -242,6 +248,11 @@ async fn set_season_monitored(
params![monitored as i64, media_item_id, season_number],
)
.map_err(internal)?;
// Season row is best-effort — older libraries (or movies) may have none.
let _ = conn.execute(
"UPDATE season SET monitored = ?1 WHERE media_item_id = ?2 AND season_number = ?3",
params![monitored as i64, media_item_id, season_number],
);
if rows == 0 {
return Err((
StatusCode::NOT_FOUND,
@ -283,21 +294,20 @@ pub async fn delete_episode_file(
Path(episode_id): Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
let conn = state.conn.lock().await;
let row: Option<(i64, String)> = conn
.query_row(
"SELECT id, path FROM episode_file WHERE episode_id = ?1",
params![episode_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.map_err(internal)?;
let Some((file_id, path)) = row else {
let files = tracked_files(
&conn,
"SELECT id, path FROM episode_file WHERE episode_id = ?1",
episode_id,
)?;
if files.is_empty() {
return Err((
StatusCode::NOT_FOUND,
format!("no file tracked for episode {episode_id}"),
));
};
delete_file_and_clear(&conn, &path, file_id)?;
}
for (file_id, path) in files {
delete_file_and_clear(&conn, &path, file_id)?;
}
conn.execute(
"UPDATE episode SET has_file = 0 WHERE id = ?1",
params![episode_id],
@ -315,21 +325,20 @@ pub async fn delete_movie_file(
Path(media_item_id): Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
let conn = state.conn.lock().await;
let row: Option<(i64, String)> = conn
.query_row(
"SELECT id, path FROM episode_file WHERE media_item_id = ?1 AND episode_id IS NULL",
params![media_item_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.map_err(internal)?;
let Some((file_id, path)) = row else {
let files = tracked_files(
&conn,
"SELECT id, path FROM episode_file WHERE media_item_id = ?1 AND episode_id IS NULL",
media_item_id,
)?;
if files.is_empty() {
return Err((
StatusCode::NOT_FOUND,
format!("no file tracked for media_item {media_item_id}"),
));
};
delete_file_and_clear(&conn, &path, file_id)?;
}
for (file_id, path) in files {
delete_file_and_clear(&conn, &path, file_id)?;
}
Ok(StatusCode::NO_CONTENT)
}
@ -339,6 +348,20 @@ pub async fn delete_movie_file(
/// alone, which a TV show shares across every one of its episode rows and
/// would otherwise risk wiping an entire show's tracked files instead of
/// the one the caller actually looked up.
fn tracked_files(
conn: &rusqlite::Connection,
sql: &str,
id: i64,
) -> Result<Vec<(i64, String)>, (StatusCode, String)> {
let mut stmt = conn.prepare(sql).map_err(internal)?;
let files = stmt
.query_map(params![id], |row| Ok((row.get(0)?, row.get(1)?)))
.map_err(internal)?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(internal)?;
Ok(files)
}
fn delete_file_and_clear(
conn: &rusqlite::Connection,
path: &str,
@ -502,6 +525,59 @@ async fn grab_candidate_via_background(
Ok(StatusCode::NO_CONTENT)
}
/// After `~/` expand and lexical normalize, the path must stay under
/// `allowed_root`. `..` components and absolute paths outside that root
/// are rejected with 400.
fn constrain_root_folder(
requested: &str,
allowed_root: &FsPath,
) -> Result<String, (StatusCode, String)> {
let requested = requested.trim();
if requested.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"root_folder must not be empty".into(),
));
}
let expanded = Config::expand_path(requested);
if expanded
.components()
.any(|c| matches!(c, Component::ParentDir))
{
return Err((
StatusCode::BAD_REQUEST,
"root_folder must not contain '..'".into(),
));
}
let candidate = if expanded.is_absolute() {
normalize_lexically(&expanded)
} else {
normalize_lexically(&allowed_root.join(expanded))
};
let root = normalize_lexically(allowed_root);
if !candidate.starts_with(&root) {
return Err((
StatusCode::BAD_REQUEST,
format!("root_folder must be under {}", root.display()),
));
}
Ok(candidate.to_string_lossy().into_owned())
}
fn normalize_lexically(path: &FsPath) -> PathBuf {
let mut out = PathBuf::new();
for c in path.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other),
}
}
out
}
fn internal<E: std::fmt::Display>(e: E) -> (StatusCode, String) {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
}

View file

@ -68,6 +68,12 @@ pub async fn update_weights(
Path(id): Path<i64>,
Json(req): Json<UpdateQualityProfileWeightsRequest>,
) -> Result<StatusCode, (StatusCode, String)> {
if !weights_are_valid(&req.weights) {
return Err((
StatusCode::BAD_REQUEST,
"quality-profile weights must be finite and >= 0".into(),
));
}
let weights_json = serde_json::to_string(&req.weights).map_err(internal)?;
let conn = state.conn.lock().await;
let updated = conn
@ -82,6 +88,59 @@ pub async fn update_weights(
Ok(StatusCode::NO_CONTENT)
}
fn weights_are_valid(w: &WeightsDto) -> bool {
[
w.seeder,
w.resolution_tier,
w.source_tier,
w.codec_tier,
w.bit_depth,
w.container,
w.group_allowlist,
w.repack,
w.hdr,
]
.into_iter()
.all(|v| v.is_finite() && v >= 0.0)
}
fn internal<E: std::fmt::Display>(e: E) -> (StatusCode, String) {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_weights() -> WeightsDto {
WeightsDto {
seeder: 1.0,
resolution_tier: 1.0,
source_tier: 1.0,
codec_tier: 1.0,
bit_depth: 1.0,
container: 1.0,
group_allowlist: 1.0,
repack: 1.0,
hdr: 1.0,
}
}
#[test]
fn weights_are_valid_accepts_finite_non_negative() {
assert!(weights_are_valid(&valid_weights()));
}
#[test]
fn weights_are_valid_rejects_nan_inf_and_negative() {
let mut w = valid_weights();
w.seeder = f32::NAN;
assert!(!weights_are_valid(&w));
w = valid_weights();
w.hdr = f32::INFINITY;
assert!(!weights_are_valid(&w));
w = valid_weights();
w.repack = -0.1;
assert!(!weights_are_valid(&w));
}
}

View file

@ -75,21 +75,22 @@ pub async fn approve(
}
};
let torrent_hash = match scheduler::grab_prepared_approval(qbit, &state.qbit_category, &prepared).await {
Ok(hash) => hash,
Err(e) => {
// The grab errored outright (not just "added but no hash
// captured" — `finalize_review_approval` below handles that
// case and still runs to completion). `prepare_review_approval`
// already claimed this row into `approved` before we got here;
// without releasing it back to `pending`, a transient
// qBittorrent error would strand the review permanently
// unapprovable with nothing ever recorded for it.
let conn = state.conn.lock().await;
let _ = scheduler::release_review_claim(&conn, id);
return Err(internal(e));
}
};
let torrent_hash =
match scheduler::grab_prepared_approval(qbit, &state.qbit_category, &prepared).await {
Ok(hash) => hash,
Err(e) => {
// The grab errored outright (not just "added but no hash
// captured" — `finalize_review_approval` below handles that
// case and still runs to completion). `prepare_review_approval`
// already claimed this row into `approved` before we got here;
// without releasing it back to `pending`, a transient
// qBittorrent error would strand the review permanently
// unapprovable with nothing ever recorded for it.
let conn = state.conn.lock().await;
let _ = scheduler::release_review_claim(&conn, id);
return Err(internal(e));
}
};
{
let conn = state.conn.lock().await;
@ -110,7 +111,12 @@ pub async fn reject(
Path(id): Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
let conn = state.conn.lock().await;
scheduler::reject_review(&conn, id).map_err(internal)?;
if !scheduler::reject_review(&conn, id).map_err(internal)? {
return Err((
StatusCode::NOT_FOUND,
format!("no pending review item {id}"),
));
}
Ok(StatusCode::NO_CONTENT)
}

View file

@ -21,43 +21,50 @@ pub async fn search(
State(state): State<AppState>,
Query(params): Query<SearchParams>,
) -> Result<Json<Vec<SearchResult>>, (StatusCode, String)> {
if params.kind == "movie" {
let Some(tmdb) = &state.tmdb else {
return Err((
StatusCode::PRECONDITION_FAILED,
"tmdb.bearer_token is not configured".into(),
));
};
let results = tmdb
.search_movie(&params.q)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?
.into_iter()
.map(|r| SearchResult {
external_id: r.external_id,
title: r.title,
year: r.year.map(|y| y as i64),
})
.collect();
return Ok(Json(results));
match params.kind.as_str() {
"movie" => {
let Some(tmdb) = &state.tmdb else {
return Err((
StatusCode::PRECONDITION_FAILED,
"tmdb.bearer_token is not configured".into(),
));
};
let results = tmdb
.search_movie(&params.q)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?
.into_iter()
.map(|r| SearchResult {
external_id: r.external_id,
title: r.title,
year: r.year.map(|y| y as i64),
})
.collect();
Ok(Json(results))
}
"series" => {
let Some(tvdb) = &state.tvdb else {
return Err((
StatusCode::PRECONDITION_FAILED,
"tvdb.api_key is not configured".into(),
));
};
let results = tvdb
.search_series(&params.q)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?
.into_iter()
.map(|r| SearchResult {
external_id: r.external_id,
title: r.name,
year: r.year.map(|y| y as i64),
})
.collect();
Ok(Json(results))
}
other => Err((
StatusCode::BAD_REQUEST,
format!("kind must be series or movie, got {other:?}"),
)),
}
let Some(tvdb) = &state.tvdb else {
return Err((
StatusCode::PRECONDITION_FAILED,
"tvdb.api_key is not configured".into(),
));
};
let results = tvdb
.search_series(&params.q)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?
.into_iter()
.map(|r| SearchResult {
external_id: r.external_id,
title: r.name,
year: r.year.map(|y| y as i64),
})
.collect();
Ok(Json(results))
}

View file

@ -5,14 +5,14 @@ use rusqlite::Connection;
/// doesn't slowly fill the disk with an ever-growing pile of copies.
const MAX_BACKUPS: usize = 5;
/// Copies the database (and its WAL/SHM sidecar files, if present — WAL
/// mode means the real state can be split across all three) to a timestamped
/// backup before the daemon opens it, then prunes old backups beyond
/// Writes a consistent snapshot of the existing database to a timestamped
/// file under `<db-dir>/backups/`, then prunes old backups beyond
/// `MAX_BACKUPS`. A no-op if there's no existing database yet (fresh
/// install — nothing to back up). Sonarr/Radarr back themselves up before
/// every upgrade; breadarr has no migration framework to trigger that same
/// moment, so this runs on every startup instead, which is a superset of
/// the same protection.
/// install — nothing to back up). Uses `VACUUM INTO` so WAL state is
/// folded into one standalone file; a raw `fs::copy` of a live WAL
/// database can be torn. Sonarr/Radarr back themselves up before every
/// upgrade; breadarr has no migration framework to trigger that same
/// moment, so this runs on every startup instead.
pub fn backup_before_open(db_path: &std::path::Path) -> anyhow::Result<()> {
if !db_path.exists() {
return Ok(());
@ -32,15 +32,13 @@ pub fn backup_before_open(db_path: &std::path::Path) -> anyhow::Result<()> {
// and dedupe correctly instead of the second one silently overwriting.
let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S%3fZ");
let dest = backup_dir.join(format!("{timestamp}-{stem}"));
std::fs::copy(db_path, &dest)?;
for sidecar_ext in ["-wal", "-shm"] {
let sidecar = std::path::PathBuf::from(format!("{}{sidecar_ext}", db_path.display()));
if sidecar.exists() {
let dest_sidecar = backup_dir.join(format!("{timestamp}-{stem}{sidecar_ext}"));
std::fs::copy(&sidecar, &dest_sidecar)?;
}
}
let src = Connection::open_with_flags(db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
// Path is interpolated (VACUUM INTO does not bind `?` parameters);
// single quotes in the path are doubled so the SQL string stays valid.
let dest_sql = dest.to_string_lossy().replace('\'', "''");
src.execute(&format!("VACUUM INTO '{dest_sql}'"), [])?;
drop(src);
prune_old_backups(&backup_dir, stem)?;
Ok(())
@ -280,9 +278,7 @@ pub fn init(conn: &Connection) -> anyhow::Result<()> {
CREATE INDEX IF NOT EXISTS idx_event_history_media_item
ON event_history(media_item_id, occurred_at);
-- Placeholder profiles until Phase 6 builds real quality-scoring
-- weights; media_item.quality_profile_id needs something to
-- reference in the meantime.
-- Default profiles. Scoring reads these rows' `weights` on every grab.
INSERT OR IGNORE INTO quality_profile (id, name, kind, weights)
VALUES (1, 'Default TV', 'tv', '{}');
INSERT OR IGNORE INTO quality_profile (id, name, kind, weights)
@ -378,7 +374,14 @@ pub fn init(conn: &Connection) -> anyhow::Result<()> {
-- concurrently regardless of how it happened (a stray duplicate
-- enqueue, a daemon-restart reset racing a still-alive backfill).
CREATE UNIQUE INDEX IF NOT EXISTS idx_transcode_job_active_episode_file
ON transcode_job(episode_file_id) WHERE status IN ('pending','running');",
ON transcode_job(episode_file_id) WHERE status IN ('pending','running');
CREATE INDEX IF NOT EXISTS idx_release_status ON release(status);
CREATE INDEX IF NOT EXISTS idx_release_torrent_hash ON release(torrent_hash);
CREATE INDEX IF NOT EXISTS idx_release_episode_id ON release(episode_id);
CREATE INDEX IF NOT EXISTS idx_episode_file_episode_id ON episode_file(episode_id);
CREATE INDEX IF NOT EXISTS idx_review_queue_status ON review_queue(status);
CREATE INDEX IF NOT EXISTS idx_episode_air_date ON episode(air_date);",
)?;
// Progress watermark for stalled-download detection (added after the
@ -477,6 +480,155 @@ pub fn init(conn: &Connection) -> anyhow::Result<()> {
"INTEGER NOT NULL DEFAULT 0",
)?;
ensure_indexes(conn)?;
Ok(())
}
fn index_exists(conn: &Connection, name: &str) -> anyhow::Result<bool> {
let n: i64 = conn.query_row(
"SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = ?1",
[name],
|row| row.get(0),
)?;
Ok(n > 0)
}
/// `CREATE UNIQUE INDEX IF NOT EXISTS` still errors when existing rows
/// violate uniqueness (IF NOT EXISTS only checks the index name). A dirty
/// production DB must still start, so uniqueness failures warn and skip.
fn create_unique_index_best_effort(conn: &Connection, name: &str, ddl: &str) -> bool {
match conn.execute(ddl, []) {
Ok(_) => true,
Err(e) => {
tracing::warn!(
index = name,
error = %e,
"skipping unique index; existing rows would violate it"
);
false
}
}
}
/// Delete extra `media_item` rows that share a non-NULL `tvdb_id`/`tmdb_id`,
/// keeping the lowest `id`. Extras with any `episode` or `episode_file`
/// rows are left in place — those are not safe to drop. Returns whether
/// every duplicate group was reduced to a single row.
fn dedupe_media_item_external_id(conn: &Connection, column: &str) -> anyhow::Result<bool> {
debug_assert!(column == "tvdb_id" || column == "tmdb_id");
let sql = format!(
"SELECT {column}, MIN(id) FROM media_item
WHERE {column} IS NOT NULL
GROUP BY {column}
HAVING COUNT(*) > 1"
);
let dupes: Vec<(i64, i64)> = {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
};
let mut safe = true;
let extra_sql = format!("SELECT id FROM media_item WHERE {column} = ?1 AND id != ?2");
for (ext_id, keep_id) in dupes {
let extras: Vec<i64> = {
let mut stmt = conn.prepare(&extra_sql)?;
let rows = stmt.query_map(rusqlite::params![ext_id, keep_id], |row| row.get(0))?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
};
for extra_id in extras {
let episode_count: i64 = conn.query_row(
"SELECT count(*) FROM episode WHERE media_item_id = ?1",
[extra_id],
|row| row.get(0),
)?;
let file_count: i64 = conn.query_row(
"SELECT count(*) FROM episode_file WHERE media_item_id = ?1",
[extra_id],
|row| row.get(0),
)?;
if episode_count == 0 && file_count == 0 {
conn.execute("DELETE FROM media_item WHERE id = ?1", [extra_id])?;
} else {
tracing::warn!(
column,
ext_id,
keep_id,
extra_id,
episode_count,
file_count,
"cannot safely dedupe media_item; extra row has episodes or files"
);
safe = false;
}
}
}
Ok(safe)
}
fn ensure_unique_external_id_index(
conn: &Connection,
column: &str,
unique_name: &str,
unique_ddl: &str,
lookup_ddl: &str,
) -> anyhow::Result<()> {
let unique_ok = if dedupe_media_item_external_id(conn, column)? {
create_unique_index_best_effort(conn, unique_name, unique_ddl)
} else {
tracing::warn!(
index = unique_name,
column,
"skipping unique index; media_item still has unsafely-duplicated rows"
);
false
};
if !unique_ok && !index_exists(conn, unique_name)? {
conn.execute(lookup_ddl, [])?;
}
Ok(())
}
fn ensure_indexes(conn: &Connection) -> anyhow::Result<()> {
ensure_unique_external_id_index(
conn,
"tvdb_id",
"idx_media_item_tvdb",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_media_item_tvdb
ON media_item(tvdb_id) WHERE tvdb_id IS NOT NULL",
"CREATE INDEX IF NOT EXISTS idx_media_item_tvdb_lookup ON media_item(tvdb_id)",
)?;
ensure_unique_external_id_index(
conn,
"tmdb_id",
"idx_media_item_tmdb",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_media_item_tmdb
ON media_item(tmdb_id) WHERE tmdb_id IS NOT NULL",
"CREATE INDEX IF NOT EXISTS idx_media_item_tmdb_lookup ON media_item(tmdb_id)",
)?;
create_unique_index_best_effort(
conn,
"idx_episode_file_path",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_episode_file_path ON episode_file(path)",
);
create_unique_index_best_effort(
conn,
"idx_alias_item_text",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_alias_item_text ON alias(media_item_id, text)",
);
create_unique_index_best_effort(
conn,
"idx_review_pending_title",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_review_pending_title
ON review_queue(candidate_media_item_id, raw_release_title)
WHERE status = 'pending'",
);
// No unique on release(source_id, guid): status can cycle and a
// re-grab of the same guid is legitimate. `seen_guid` already records
// first-seen.
Ok(())
}
@ -640,19 +792,145 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
fn write_real_sqlite(db_path: &std::path::Path) {
let conn = Connection::open(db_path).unwrap();
init(&conn).unwrap();
drop(conn);
}
fn seed_movie(conn: &Connection, title: &str, tmdb_id: i64) -> i64 {
conn.execute(
"INSERT INTO media_item (kind, title, year, tmdb_id, monitored, quality_profile_id, root_folder)
VALUES ('movie', ?1, NULL, ?2, 1, 2, '/tmp')",
rusqlite::params![title, tmdb_id],
)
.unwrap();
conn.last_insert_rowid()
}
#[test]
fn init_creates_unique_and_lookup_indexes_on_a_clean_database() {
let conn = Connection::open_in_memory().unwrap();
init(&conn).unwrap();
assert!(index_exists(&conn, "idx_media_item_tvdb").unwrap());
assert!(index_exists(&conn, "idx_media_item_tmdb").unwrap());
assert!(index_exists(&conn, "idx_episode_file_path").unwrap());
assert!(index_exists(&conn, "idx_alias_item_text").unwrap());
assert!(index_exists(&conn, "idx_release_status").unwrap());
assert!(index_exists(&conn, "idx_release_torrent_hash").unwrap());
assert!(index_exists(&conn, "idx_release_episode_id").unwrap());
assert!(index_exists(&conn, "idx_episode_file_episode_id").unwrap());
assert!(index_exists(&conn, "idx_review_queue_status").unwrap());
assert!(index_exists(&conn, "idx_review_pending_title").unwrap());
assert!(index_exists(&conn, "idx_episode_air_date").unwrap());
assert!(!index_exists(&conn, "idx_media_item_tvdb_lookup").unwrap());
assert!(!index_exists(&conn, "idx_media_item_tmdb_lookup").unwrap());
}
#[test]
fn init_dedupes_empty_duplicate_tmdb_rows_and_creates_unique_index() {
let conn = Connection::open_in_memory().unwrap();
init(&conn).unwrap();
conn.execute("DROP INDEX IF EXISTS idx_media_item_tmdb", [])
.unwrap();
let first = seed_movie(&conn, "A", 99);
seed_movie(&conn, "B", 99);
init(&conn).unwrap();
let count: i64 = conn
.query_row(
"SELECT count(*) FROM media_item WHERE tmdb_id = 99",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
let kept: i64 = conn
.query_row("SELECT id FROM media_item WHERE tmdb_id = 99", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(kept, first);
assert!(index_exists(&conn, "idx_media_item_tmdb").unwrap());
}
#[test]
fn init_skips_tmdb_unique_index_when_duplicate_has_files() {
let conn = Connection::open_in_memory().unwrap();
init(&conn).unwrap();
conn.execute("DROP INDEX IF EXISTS idx_media_item_tmdb", [])
.unwrap();
seed_movie(&conn, "A", 99);
let extra = seed_movie(&conn, "B", 99);
conn.execute(
"INSERT INTO episode_file (media_item_id, path, size_bytes, subtitle_status)
VALUES (?1, '/tmp/b.mkv', 1, 'none')",
[extra],
)
.unwrap();
init(&conn).unwrap();
let count: i64 = conn
.query_row(
"SELECT count(*) FROM media_item WHERE tmdb_id = 99",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 2);
assert!(!index_exists(&conn, "idx_media_item_tmdb").unwrap());
assert!(index_exists(&conn, "idx_media_item_tmdb_lookup").unwrap());
}
#[test]
fn init_skips_path_unique_index_when_duplicates_exist() {
let conn = Connection::open_in_memory().unwrap();
init(&conn).unwrap();
conn.execute("DROP INDEX IF EXISTS idx_episode_file_path", [])
.unwrap();
let id = seed_movie(&conn, "A", 1);
for _ in 0..2 {
conn.execute(
"INSERT INTO episode_file (media_item_id, path, size_bytes, subtitle_status)
VALUES (?1, '/tmp/x.mkv', 1, 'none')",
[id],
)
.unwrap();
}
init(&conn).unwrap();
assert!(!index_exists(&conn, "idx_episode_file_path").unwrap());
}
#[test]
fn backup_before_open_copies_an_existing_database() {
let dir = std::env::temp_dir().join(format!("breadarr-backup-copy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let db_path = dir.join("breadarr.db");
std::fs::write(&db_path, b"fake sqlite data").unwrap();
write_real_sqlite(&db_path);
backup_before_open(&db_path).unwrap();
let backup_dir = dir.join("backups");
let backups: Vec<_> = std::fs::read_dir(&backup_dir).unwrap().collect();
let backups: Vec<_> = std::fs::read_dir(&backup_dir)
.unwrap()
.map(|e| e.unwrap().path())
.collect();
assert_eq!(backups.len(), 1, "expected exactly one backup file");
let verify = Connection::open(&backups[0]).unwrap();
let n: i64 = verify
.query_row("SELECT count(*) FROM quality_profile", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 2);
std::fs::remove_dir_all(&dir).unwrap();
}
@ -660,9 +938,10 @@ mod tests {
fn backup_before_open_prunes_beyond_max_backups() {
let dir =
std::env::temp_dir().join(format!("breadarr-backup-prune-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let db_path = dir.join("breadarr.db");
std::fs::write(&db_path, b"fake sqlite data").unwrap();
write_real_sqlite(&db_path);
// One more than MAX_BACKUPS, sleeping a few ms between each so the
// millisecond-resolution timestamp in the filename is guaranteed to

View file

@ -201,9 +201,17 @@ pub(crate) fn walk_files(dir: &Path) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
// `path.is_dir()` follows symlinks, which would let a planted
// directory link walk the importer (and library-scan) out of the
// download/library root. Skip every symlink — file links to videos
// included — and only recurse into real directories.
let ft = std::fs::symlink_metadata(&path)?.file_type();
if ft.is_symlink() {
continue;
}
if ft.is_dir() {
out.extend(walk_files(&path)?);
} else {
} else if ft.is_file() {
out.push(path);
}
}
@ -263,9 +271,25 @@ pub(crate) fn deterministic_movie_filename(title: &str, year: Option<i64>, ext:
}
pub(crate) fn sanitize(s: &str) -> String {
s.chars()
.map(|c| if "/\\:*?\"<>|".contains(c) { '_' } else { c })
.collect()
let cleaned: String = s
.chars()
.map(|c| {
if c.is_ascii_control() || "/\\:*?\"<>|".contains(c) {
'_'
} else {
c
}
})
.collect();
// After the replacements above, a title of `.` or `..` is still a
// hostile path component (`root_folder/../file`). Slash-containing
// titles become `foo_.._bar`, which is fine — only a lone `.`/`..`
// can climb.
if cleaned == "." || cleaned == ".." {
"_".to_string()
} else {
cleaned
}
}
/// True when there isn't enough free space at `dir` to hold `needed_bytes`.
@ -335,6 +359,116 @@ fn move_or_copy_file(src: &Path, dest: &Path) -> Result<()> {
})
}
fn is_video_path(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| VIDEO_EXTS.contains(&e.to_lowercase().as_str()))
.unwrap_or(false)
}
/// Lexical `..`/`.` collapse without touching the filesystem — used when
/// `canonicalize` can't (path missing) and by `remap_path` before the
/// joined result is allowed to escape `host_prefix`.
fn normalize_lexically(path: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for comp in path.components() {
match comp {
Component::Prefix(p) => out.push(p.as_os_str()),
Component::RootDir => out.push(Component::RootDir.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
Component::Normal(c) => out.push(c),
}
}
out
}
fn canonicalize_or_normalize(path: &Path) -> PathBuf {
if let Ok(canon) = std::fs::canonicalize(path) {
return canon;
}
if let Some(parent) = path.parent() {
if let (Ok(canon_parent), Some(name)) = (std::fs::canonicalize(parent), path.file_name()) {
return canon_parent.join(name);
}
}
if path.is_absolute() {
return normalize_lexically(path);
}
std::env::current_dir()
.map(|cwd| normalize_lexically(&cwd.join(path)))
.unwrap_or_else(|_| normalize_lexically(path))
}
fn is_strictly_inside(path: &Path, root: &Path) -> bool {
let path = canonicalize_or_normalize(path);
let root = canonicalize_or_normalize(root);
path.starts_with(&root) && path != root
}
fn is_common_filesystem_root(path: &Path) -> bool {
let normalized = canonicalize_or_normalize(path);
if normalized == Path::new("/")
|| normalized == Path::new("/mnt")
|| normalized == Path::new("/media")
{
return true;
}
if let Some(home) = std::env::var_os("HOME") {
if normalized == Path::new(&home) {
return true;
}
}
false
}
/// `true` only for a per-torrent folder we are willing to `remove_dir_all`.
/// A configured `host_downloads_path` must strictly contain `content_path`;
/// an empty host falls back to "has a real parent that isn't `/` and isn't
/// a well-known root" so a default-empty config cannot wipe `/` or `$HOME`.
fn is_safe_cleanup_target(content_path: &Path, host_downloads_path: &str) -> bool {
let meta = match std::fs::symlink_metadata(content_path) {
Ok(m) => m,
Err(_) => return false,
};
if !meta.is_dir() || meta.file_type().is_symlink() {
return false;
}
if !host_downloads_path.is_empty() {
return is_strictly_inside(content_path, Path::new(host_downloads_path));
}
if is_common_filesystem_root(content_path) {
return false;
}
let Some(parent) = content_path.parent() else {
return false;
};
if parent == Path::new("/") || parent.as_os_str().is_empty() || !parent.exists() {
return false;
}
content_path != parent
}
fn dir_contains_video_files(dir: &Path) -> bool {
match walk_files(dir) {
Ok(files) => files.iter().any(|p| is_video_path(p)),
// A walk failure must not authorize a wipe.
Err(_) => true,
}
}
fn remove_non_video_files(dir: &Path) -> Result<()> {
for path in walk_files(dir)? {
if !is_video_path(&path) {
let _ = std::fs::remove_file(&path);
}
}
Ok(())
}
/// Removes whatever's left of a torrent's download folder once every file
/// breadarr cares about has already been moved out of it — split out from
/// `run_import_cycle` so it's directly testable without a real qBittorrent
@ -343,14 +477,21 @@ fn move_or_copy_file(src: &Path, dest: &Path) -> Result<()> {
/// `.jpg` sidecars) is otherwise left behind forever, since nothing else
/// ever points at it once the torrent itself is gone from qBittorrent. A
/// bare file (a release with no wrapping folder) needs no cleanup here —
/// `move_or_copy_file` already consumed it. The `!= host_downloads_path`
/// guard is defense in depth against a malformed `content_path` resolving
/// to the downloads root itself; every real torrent lands in its own
/// subdirectory or as a single file, never the root.
/// `move_or_copy_file` already consumed it.
///
/// Never wipes a directory that still contains a video we didn't import
/// (unmatched pack files), and never `remove_dir_all`s the downloads root
/// itself — `host_downloads_path` defaults to `""`, so a raw `!= host`
/// comparison is not enough.
fn cleanup_leftover_download_dir(content_path: &Path, host_downloads_path: &str) -> Result<()> {
if content_path.is_dir() && content_path != Path::new(host_downloads_path) {
std::fs::remove_dir_all(content_path)?;
if !is_safe_cleanup_target(content_path, host_downloads_path) {
return Ok(());
}
if dir_contains_video_files(content_path) {
remove_non_video_files(content_path)?;
return Ok(());
}
std::fs::remove_dir_all(content_path)?;
Ok(())
}
@ -1336,7 +1477,22 @@ fn remap_path(reported: &str, container_prefix: &str, host_prefix: &str) -> Path
return PathBuf::from(reported);
}
match reported.strip_prefix(container_prefix) {
Some(rest) => PathBuf::from(format!("{host_prefix}{rest}")),
Some(rest) => {
let rest_path = Path::new(rest);
if rest_path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return PathBuf::from(reported);
}
let joined = PathBuf::from(format!("{host_prefix}{rest}"));
let normalized = normalize_lexically(&joined);
let host = normalize_lexically(Path::new(host_prefix));
if !normalized.starts_with(&host) {
return PathBuf::from(reported);
}
normalized
}
None => PathBuf::from(reported),
}
}
@ -1423,17 +1579,13 @@ fn process_pending_grabs(
transcode_cfg: Option<&breadarr_shared::config::TranscodeConfig>,
) -> Result<(ImportStats, Vec<(String, PathBuf)>)> {
let mut stats = ImportStats::default();
// Torrents whose data has been fully dealt with this cycle — moved into
// the library, or deleted outright because a better file already existed
// (see `ImportOutcome::SkippedAlreadyHaveBetter`, whose only producer,
// `import_one`, deletes the losing download itself). Either way nothing
// *breadarr still needs* remains at the torrent's original location, so
// the caller (`run_import_cycle`, the async I/O boundary this function is
// deliberately kept free of) removes each from qBittorrent afterward and
// clears out whatever's left of `content_path` — a multi-file release
// only ever has its video moved out by `move_or_copy_file`, so without
// this the surrounding folder (sample clips, .nfo/.srt/.jpg sidecars)
// sits there forever with no torrent left to account for it.
// Torrents whose data has been fully dealt with this cycle — imported,
// skipped as already-better, or given up on (stall / missing past grace
// / MAX_IMPORT_ERRORS). The caller (`run_import_cycle`) removes each
// from qBittorrent afterward and runs leftover-dir cleanup, which now
// refuses to wipe leftover videos or the downloads root. `fail_grab`
// itself only writes SQLite; hashes collected here are how the torrent
// actually leaves qBit.
let mut imported_hashes: Vec<(String, PathBuf)> = Vec::new();
for grab in pending {
@ -1441,6 +1593,10 @@ fn process_pending_grabs(
if grab_missing_past_grace(conn, grab.release_id())? {
fail_grab(conn, grab, "torrent hash absent from qBittorrent")?;
stats.failed += 1;
// Torrent is already gone from qBit; still collect the hash
// so delete is attempted (best-effort) and any known path
// would be cleaned. No `content_path` is available here.
imported_hashes.push((grab.torrent_hash().to_string(), PathBuf::new()));
}
continue;
};
@ -1454,6 +1610,12 @@ fn process_pending_grabs(
"no progress for longer than the stall threshold",
)?;
stats.failed += 1;
let content_path = remap_path(
&torrent.content_path,
container_downloads_path,
host_downloads_path,
);
imported_hashes.push((grab.torrent_hash().to_string(), content_path));
}
continue;
}
@ -1497,7 +1659,11 @@ fn process_pending_grabs(
Ok(outcome) => {
stats.imported += outcome.episodes_imported;
stats.quality_flagged += outcome.quality_flagged;
if outcome.episodes_imported > 0 {
// Imported files *or* a no-op upgrade (`upgraded` with
// 0 imported): nothing breadarr still needs lives in
// qBit. Unmatched leftover videos are left on disk by
// `cleanup_leftover_download_dir`.
if outcome.episodes_imported > 0 || outcome.episodes_already_had_better > 0 {
imported_hashes.push((grab.torrent_hash().to_string(), content_path.clone()));
}
}
@ -1510,6 +1676,7 @@ fn process_pending_grabs(
&format!("season pack import failed {error_count} times in a row: {e}"),
)?;
stats.failed += 1;
imported_hashes.push((grab.torrent_hash().to_string(), content_path.clone()));
} else {
tracing::warn!(
error = %e,
@ -1553,6 +1720,7 @@ fn process_pending_grabs(
&format!("import failed {error_count} times in a row: {e}"),
)?;
stats.failed += 1;
imported_hashes.push((grab.torrent_hash().to_string(), content_path.clone()));
} else {
tracing::warn!(
error = %e,
@ -2068,6 +2236,12 @@ fn import_season_pack(
std::fs::create_dir_all(root_folder)?;
let tvdb_id: Option<i64> = conn.query_row(
"SELECT tvdb_id FROM media_item WHERE id = ?1",
params![media_item_id],
|row| row.get(0),
)?;
let mut outcome = SeasonPackImportOutcome::default();
for source_path in &video_files {
let filename_only = source_path
@ -2075,18 +2249,24 @@ fn import_season_pack(
.and_then(|f| f.to_str())
.unwrap_or_default();
let parsed = crate::parser::parse(filename_only);
let Some(episode_number) = parsed.episode.or(parsed.absolute_episode) else {
outcome.episodes_unmatched += 1;
tracing::warn!(
file = filename_only,
"season pack: could not determine an episode number for this file, skipping"
);
continue;
// Anime packs name files `[SubsPlease] Show - 15.mkv` (absolute
// only). Treating that as S01E15 skipped the AniDB map that
// `library_scan` already uses via `resolve_episode`.
let resolved = crate::scheduler::resolve_episode(conn, tvdb_id, &parsed)?;
let (file_season, episode_number) = match resolved {
Some(pair) => pair,
None => match (parsed.season, parsed.episode) {
(Some(season), Some(episode)) => (season, episode),
_ => {
outcome.episodes_unmatched += 1;
tracing::warn!(
file = filename_only,
"season pack: could not determine an episode number for this file, skipping"
);
continue;
}
},
};
// Prefer the individual file's own season marker when it has one
// (a pack can occasionally mix seasons); fall back to the pack's
// own season otherwise.
let file_season = parsed.season.unwrap_or(season_number);
let episode_id = match crate::scheduler::find_episode_id(
conn,
@ -3158,6 +3338,11 @@ mod tests {
#[test]
fn sanitizes_path_hostile_characters() {
assert_eq!(sanitize("Kill: Ao / Blue?"), "Kill_ Ao _ Blue_");
assert_eq!(sanitize(".."), "_");
assert_eq!(sanitize("."), "_");
// Slashes become `_` first, so this is not a lone `..` component.
assert_eq!(sanitize("foo/../bar"), "foo_.._bar");
assert_eq!(sanitize("title\nwith\x00ctrl"), "title_with_ctrl");
}
#[test]
@ -3307,6 +3492,70 @@ mod tests {
std::fs::remove_dir_all(&downloads).unwrap();
}
#[test]
fn cleanup_leftover_download_dir_leaves_unmatched_videos() {
let downloads = std::env::temp_dir().join(format!(
"breadarr-cleanup-leftover-video-{}",
std::process::id()
));
let release_dir = downloads.join("Some Show S01");
std::fs::create_dir_all(&release_dir).unwrap();
let unmatched = release_dir.join("Show - 15.mkv");
std::fs::write(&unmatched, b"unmatched video").unwrap();
std::fs::write(release_dir.join("release.nfo"), b"nfo").unwrap();
std::fs::write(release_dir.join("poster.jpg"), b"jpeg").unwrap();
cleanup_leftover_download_dir(&release_dir, &downloads.to_string_lossy()).unwrap();
assert!(
unmatched.exists(),
"an unmatched video must survive leftover-dir cleanup"
);
assert!(
!release_dir.join("release.nfo").exists(),
"sidecars next to leftover videos should still be dropped"
);
assert!(!release_dir.join("poster.jpg").exists());
assert!(release_dir.exists());
std::fs::remove_dir_all(&downloads).unwrap();
}
#[test]
fn cleanup_leftover_download_dir_empty_host_still_removes_a_per_torrent_folder() {
let downloads = std::env::temp_dir().join(format!(
"breadarr-cleanup-empty-host-{}",
std::process::id()
));
let release_dir = downloads.join("Some Movie 2016 1080p");
std::fs::create_dir_all(&release_dir).unwrap();
std::fs::write(release_dir.join("poster.jpg"), b"jpeg").unwrap();
cleanup_leftover_download_dir(&release_dir, "").unwrap();
assert!(!release_dir.exists());
assert!(downloads.exists());
std::fs::remove_dir_all(&downloads).unwrap();
}
#[test]
fn cleanup_leftover_download_dir_empty_host_refuses_common_roots() {
cleanup_leftover_download_dir(Path::new("/"), "").unwrap();
assert!(Path::new("/").exists());
cleanup_leftover_download_dir(Path::new("/mnt"), "").unwrap();
cleanup_leftover_download_dir(Path::new("/media"), "").unwrap();
if let Some(home) = std::env::var_os("HOME") {
let home_path = PathBuf::from(&home);
if home_path.is_dir() {
cleanup_leftover_download_dir(&home_path, "").unwrap();
assert!(home_path.exists(), "must never wipe $HOME");
}
}
}
#[test]
fn locates_a_single_file_torrent() {
let dir = std::env::temp_dir().join(format!("breadarr-test-{}", std::process::id()));
@ -3320,6 +3569,42 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn walk_files_does_not_follow_symlinks() {
let dir = std::env::temp_dir().join(format!(
"breadarr-walk-symlink-{}",
std::process::id()
));
let outside = std::env::temp_dir().join(format!(
"breadarr-walk-outside-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(dir.join("real.mkv"), b"inside").unwrap();
std::fs::write(outside.join("secret.mkv"), b"escaped").unwrap();
std::os::unix::fs::symlink(&outside, dir.join("escape")).unwrap();
std::os::unix::fs::symlink(outside.join("secret.mkv"), dir.join("link.mkv")).unwrap();
let walked = walk_files(&dir).unwrap();
let names: Vec<String> = walked
.iter()
.filter_map(|p| p.file_name().and_then(|n| n.to_str()).map(str::to_string))
.collect();
assert!(names.contains(&"real.mkv".to_string()));
assert!(
!names.contains(&"secret.mkv".to_string()),
"a directory symlink must not pull files from outside the scan root"
);
assert!(
!names.contains(&"link.mkv".to_string()),
"a file symlink to a video must be skipped"
);
std::fs::remove_dir_all(&dir).unwrap();
std::fs::remove_dir_all(&outside).unwrap();
}
#[test]
fn remap_path_translates_container_prefix_to_host_prefix() {
assert_eq!(
@ -3340,6 +3625,31 @@ mod tests {
);
}
#[test]
fn remap_path_rejects_parent_dir_escape() {
let remapped = remap_path(
"/downloads/../../etc",
"/downloads",
"/home/breadway/downloads",
);
assert_eq!(remapped, PathBuf::from("/downloads/../../etc"));
assert!(
!normalize_lexically(&remapped).starts_with("/home/breadway/downloads")
|| remapped.as_os_str() == "/downloads/../../etc",
"a `..` remainder must not be rewritten under the host prefix"
);
let remapped = remap_path(
"/downloads/show/../../../etc/passwd",
"/downloads",
"/home/breadway/downloads",
);
assert_eq!(
remapped,
PathBuf::from("/downloads/show/../../../etc/passwd")
);
}
#[test]
fn process_pending_grabs_routes_each_grab_by_torrent_state() {
use crate::qbit::TorrentInfo;
@ -3445,12 +3755,22 @@ mod tests {
// absent — simulating torrents qBit no longer knows about.
];
let (stats, _) = process_pending_grabs(&conn, &pending, &torrents, "", "", None).unwrap();
let (stats, hashes) = process_pending_grabs(&conn, &pending, &torrents, "", "", None).unwrap();
assert_eq!(stats.imported, 1);
assert_eq!(stats.skipped_incomplete, 1);
assert_eq!(stats.failed, 1);
assert_eq!(stats.errors, 0);
assert!(
hashes.iter().any(|(h, _)| h == "hash-complete"),
"a successful import must still be queued for qBit delete"
);
assert!(
hashes.iter().any(|(h, _)| h == "hash-missing-stale"),
"a grab failed for a missing torrent must be queued for qBit delete"
);
assert!(!hashes.iter().any(|(h, _)| h == "hash-downloading"));
assert!(!hashes.iter().any(|(h, _)| h == "hash-missing-fresh"));
let statuses: Vec<(i64, String)> = {
let mut stmt = conn
@ -3575,9 +3895,13 @@ mod tests {
// The Nth failure crosses the threshold and gives up.
let pending = fetch_pending_grabs(&conn).unwrap();
let (stats, _) = process_pending_grabs(&conn, &pending, &torrents, "", "", None).unwrap();
let (stats, hashes) = process_pending_grabs(&conn, &pending, &torrents, "", "", None).unwrap();
assert_eq!(stats.failed, 1);
assert_eq!(stats.errors, 0);
assert!(
hashes.iter().any(|(h, _)| h == "hash-broken"),
"a grab failed after MAX_IMPORT_ERRORS must be queued for qBit delete"
);
let status: String = conn
.query_row("SELECT status FROM release WHERE id = 1", [], |r| r.get(0))
.unwrap();
@ -3590,6 +3914,51 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_stalled_grab_is_queued_for_qbit_delete() {
use crate::qbit::TorrentInfo;
let (conn, release_id) = seeded_release_conn(STALL_THRESHOLD_HOURS + 1.0);
// Same progress already recorded — otherwise `update_grab_progress`
// would stamp `last_progress_at = now` and reset the stall clock.
conn.execute(
"UPDATE release SET last_seen_progress = 0.4, last_progress_at = datetime('now', ?1) WHERE id = ?2",
params![format!("-{} hours", STALL_THRESHOLD_HOURS + 1.0), release_id],
)
.unwrap();
let dir = std::env::temp_dir().join(format!(
"breadarr-stall-delete-{}",
std::process::id()
));
let content = dir.join("partial");
std::fs::create_dir_all(&content).unwrap();
std::fs::write(content.join("movie.mp4"), b"partial").unwrap();
let pending = fetch_pending_grabs(&conn).unwrap();
let torrents = vec![TorrentInfo {
hash: "deadbeef".to_string(),
name: "stalled".to_string(),
state: "downloading".to_string(),
progress: 0.4,
save_path: dir.to_string_lossy().to_string(),
content_path: content.to_string_lossy().to_string(),
}];
let (stats, hashes) = process_pending_grabs(&conn, &pending, &torrents, "", "", None).unwrap();
assert_eq!(stats.failed, 1);
assert!(
hashes.iter().any(|(h, p)| h == "deadbeef" && p == &content),
"a stalled grab must be queued for qBit delete with its content_path"
);
cleanup_leftover_download_dir(&content, "").unwrap();
assert!(
content.join("movie.mp4").exists(),
"cleanup of a failed grab must not wipe leftover videos"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn imports_a_movie_release_with_no_episode_id() {
let conn = Connection::open_in_memory().unwrap();
@ -4693,4 +5062,196 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn import_season_pack_uses_anime_absolute_mapping() {
let conn = Connection::open_in_memory().unwrap();
crate::db::init(&conn).unwrap();
conn.execute(
"INSERT INTO media_item (id, kind, title, year, tvdb_id, monitored, quality_profile_id, root_folder)
VALUES (1, 'series', 'Show', 2020, 366263, 1, 1, '/tmp')",
[],
)
.unwrap();
// Bookworm-style offsets: cours starting at absolute 1/15/27/37.
for (anidb_id, offset) in [(1, 0), (2, 14), (3, 26), (4, 36)] {
conn.execute(
"INSERT INTO anime_mapping (anidb_id, tvdb_id, season_offset, episode_offset)
VALUES (?1, 366263, 1, ?2)",
params![anidb_id, offset],
)
.unwrap();
}
// Mapped landing spot for absolute 15 (offset 14 → S01E01).
conn.execute(
"INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file)
VALUES (1, 1, 1, 1, 1, 0)",
[],
)
.unwrap();
// Naive S01E15 — must not be chosen over the mapped episode.
conn.execute(
"INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file)
VALUES (15, 1, 1, 15, 1, 0)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO release (id, media_item_id, episode_id, season_number, raw_title, source_id, guid, score, status, torrent_hash, grabbed_at)
VALUES (1, 1, NULL, 1, '[SubsPlease] Show (01-12)', 1, 'guid-1', 15.0, 'grabbed', 'aaaa', datetime('now'))",
[],
)
.unwrap();
let dir = std::env::temp_dir().join(format!(
"breadarr-season-pack-anime-{}",
std::process::id()
));
let pack_dir = dir.join("pack");
std::fs::create_dir_all(&pack_dir).unwrap();
std::fs::write(pack_dir.join("[SubsPlease] Show - 15.mkv"), b"abs 15").unwrap();
let dest_root = dir.join("library");
let outcome = import_season_pack(
&conn,
1,
1,
"Show",
1,
&dest_root.to_string_lossy(),
&pack_dir,
None,
)
.unwrap();
assert_eq!(outcome.episodes_imported, 1);
assert_eq!(outcome.episodes_unmatched, 0);
let (has_mapped, has_naive): (i64, i64) = conn
.query_row(
"SELECT
(SELECT has_file FROM episode WHERE id = 1),
(SELECT has_file FROM episode WHERE id = 15)",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(has_mapped, 1, "absolute 15 must land on mapped S01E01");
assert_eq!(has_naive, 0, "must not treat absolute 15 as S01E15");
assert!(dest_root.join("Season 01").join("Show - S01E01.mkv").exists());
assert!(!dest_root.join("Season 01").join("Show - S01E15.mkv").exists());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn import_season_pack_cleanup_preserves_unmatched_videos() {
let conn = seeded_season_pack_conn(1);
let dir = std::env::temp_dir().join(format!(
"breadarr-season-pack-leftover-{}",
std::process::id()
));
let pack_dir = dir.join("pack");
std::fs::create_dir_all(&pack_dir).unwrap();
std::fs::write(pack_dir.join("Show.S01E01.mkv"), b"matched").unwrap();
let unmatched = pack_dir.join("Show - 15.mkv");
std::fs::write(&unmatched, b"did not parse as SxxExx").unwrap();
std::fs::write(pack_dir.join("release.nfo"), b"nfo").unwrap();
let dest_root = dir.join("library");
let outcome = import_season_pack(
&conn,
1,
1,
"Some Show",
1,
&dest_root.to_string_lossy(),
&pack_dir,
None,
)
.unwrap();
assert_eq!(outcome.episodes_imported, 1);
assert_eq!(outcome.episodes_unmatched, 1);
cleanup_leftover_download_dir(&pack_dir, &dir.to_string_lossy()).unwrap();
assert!(
unmatched.exists(),
"unmatched pack video must survive leftover-dir cleanup"
);
assert!(
dest_root
.join("Season 01")
.join("Some Show - S01E01.mkv")
.exists()
);
assert!(!pack_dir.join("release.nfo").exists());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn process_pending_grabs_queues_a_noop_season_pack_upgrade_for_qbit_delete() {
use crate::qbit::TorrentInfo;
let conn = seeded_season_pack_conn(1);
let dir = std::env::temp_dir().join(format!(
"breadarr-season-pack-noop-upgrade-{}",
std::process::id()
));
let pack_dir = dir.join("pack");
std::fs::create_dir_all(&pack_dir).unwrap();
std::fs::write(pack_dir.join("Show.S01E01.mkv"), b"new e01").unwrap();
let dest_root = dir.join("library");
let season = dest_root.join("Season 01");
std::fs::create_dir_all(&season).unwrap();
let existing = season.join("Some Show - S01E01.mkv");
std::fs::write(&existing, b"already-better").unwrap();
conn.execute(
"INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, score, status, torrent_hash, grabbed_at)
VALUES (2, 1, 1, 'Some Show S01E01 REMUX', 1, 'guid-2', 50.0, 'imported', 'bbbb', datetime('now'))",
[],
)
.unwrap();
conn.execute(
"INSERT INTO episode_file (episode_id, media_item_id, path, size_bytes, subtitle_status)
VALUES (1, NULL, ?1, 14, 'none')",
params![existing.to_string_lossy()],
)
.unwrap();
conn.execute(
"UPDATE media_item SET root_folder = ?1 WHERE id = 1",
params![dest_root.to_string_lossy()],
)
.unwrap();
let pending = fetch_pending_grabs(&conn).unwrap();
let torrents = vec![TorrentInfo {
hash: "aaaa".to_string(),
name: "pack".to_string(),
state: "uploading".to_string(),
progress: 1.0,
save_path: dir.to_string_lossy().to_string(),
content_path: pack_dir.to_string_lossy().to_string(),
}];
let (stats, hashes) = process_pending_grabs(&conn, &pending, &torrents, "", "", None).unwrap();
assert_eq!(stats.imported, 0);
assert_eq!(stats.failed, 0);
let status: String = conn
.query_row("SELECT status FROM release WHERE id = 1", [], |r| r.get(0))
.unwrap();
assert_eq!(status, "upgraded");
assert!(
hashes.iter().any(|(h, _)| h == "aaaa"),
"a no-op season-pack upgrade must still be removed from qBit"
);
std::fs::remove_dir_all(&dir).unwrap();
}
}

View file

@ -435,7 +435,15 @@ pub async fn scan_tv_root(
report.unmatched.push(folder_name);
continue;
};
let tvdb_id: i64 = best.external_id.parse().unwrap_or_default();
let Some(tvdb_id) = metadata::parse_external_id(&best.external_id) else {
tracing::warn!(
folder = %folder_name,
external_id = %best.external_id,
"tvdb id was not a positive integer, skipping"
);
report.unmatched.push(folder_name);
continue;
};
let canonical_year = best.year.or(year);
// Normalize the folder itself down to "Title (Year)" — release
// tags/resolution/group cruft in the original folder name isn't
@ -729,7 +737,15 @@ pub async fn scan_movie_root(
report.unmatched.push(folder_name);
continue;
};
let tmdb_id: i64 = best.external_id.parse().unwrap_or_default();
let Some(tmdb_id) = metadata::parse_external_id(&best.external_id) else {
tracing::warn!(
folder = %folder_name,
external_id = %best.external_id,
"tmdb id was not a positive integer, skipping"
);
report.unmatched.push(folder_name);
continue;
};
let canonical_year = best.year.or(year);
// Normalizes the folder itself down to "Title (Year)" too — release
@ -883,4 +899,12 @@ mod tests {
)
);
}
#[test]
fn parse_external_id_never_yields_zero() {
assert_eq!(metadata::parse_external_id("0"), None);
assert_eq!(metadata::parse_external_id("000"), None);
assert_eq!(metadata::parse_external_id("not-a-number"), None);
assert_eq!(metadata::parse_external_id("550"), Some(550));
}
}

View file

@ -150,7 +150,10 @@ async fn run_daemon(config: Config) -> Result<()> {
info!(path = %config.db_path().display(), "database ready");
match transcode::reset_orphaned_running_jobs(&conn) {
Ok(0) => {}
Ok(n) => info!(n, "reset orphaned 'running' transcode jobs left over from a previous crash"),
Ok(n) => info!(
n,
"reset orphaned 'running' transcode jobs left over from a previous crash"
),
Err(e) => tracing::warn!(error = %e, "failed to reset orphaned transcode jobs"),
}
@ -168,6 +171,14 @@ async fn run_daemon(config: Config) -> Result<()> {
let listener = tokio::net::TcpListener::bind(&config.daemon.listen_addr).await?;
info!(addr = %config.daemon.listen_addr, "listening");
if config.daemon.api_token.is_empty() {
tracing::warn!(
"daemon.api_token is empty; any local process can add/delete/grab via {}",
config.daemon.listen_addr
);
} else if !config.listen_is_loopback() {
info!("API token auth is required (listen_addr is non-loopback)");
}
let tvdb = if config.tvdb.api_key.is_empty() {
None
@ -195,6 +206,10 @@ async fn run_daemon(config: Config) -> Result<()> {
)))
};
let (background_tx, background_rx) = tokio::sync::mpsc::channel(8);
// Lifted out of `background_loop` so shutdown can wait for an in-flight
// ffmpeg encode instead of dropping the process the instant SIGTERM
// arrives (systemd's TimeoutStopSec is longer than this wait).
let transcode_busy = std::sync::Arc::new(tokio::sync::Mutex::new(()));
let background_conn = std::sync::Arc::new(tokio::sync::Mutex::new(conn));
let state = api::AppState {
@ -224,6 +239,7 @@ async fn run_daemon(config: Config) -> Result<()> {
config.clone(),
state.cycle_status.clone(),
background_rx,
transcode_busy.clone(),
)
});
let mut state = state;
@ -268,6 +284,15 @@ async fn run_daemon(config: Config) -> Result<()> {
}
}
// Wait for an in-flight transcode (it holds this mutex for the whole
// cycle) so ffmpeg can finish, or at least so systemd's longer
// TimeoutStopSec applies instead of an instant drop.
info!("waiting up to 120s for in-flight transcode to finish");
match tokio::time::timeout(std::time::Duration::from_secs(120), transcode_busy.lock()).await {
Ok(_) => info!("no in-flight transcode (or it finished)"),
Err(_) => tracing::warn!("timed out waiting 120s for in-flight transcode"),
}
Ok(())
}
@ -290,6 +315,7 @@ async fn background_loop(
config: Config,
cycle_status: std::sync::Arc<std::sync::Mutex<api::CycleStatus>>,
mut background_rx: tokio::sync::mpsc::Receiver<api::BackgroundRequest>,
transcode_busy: std::sync::Arc<tokio::sync::Mutex<()>>,
) {
let notifier = notify::Notifier::new(&config.notifications.webhook_url);
{
@ -372,8 +398,7 @@ async fn background_loop(
// fires (a real possibility: files easily take longer to encode than
// `poll_interval_secs`). `claim_pending_jobs`'s own concurrency cap
// already makes overlap *safe*; this just keeps it from happening
// pointlessly.
let transcode_busy = std::sync::Arc::new(tokio::sync::Mutex::new(()));
// pointlessly. Created in `run_daemon` so shutdown can wait on it.
let mut transcode_ticker = tokio::time::interval(std::time::Duration::from_secs(
config.transcode.poll_interval_secs,
));

View file

@ -4,14 +4,20 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use rusqlite::{params, Connection};
use rusqlite::{params, Connection, OptionalExtension};
use embed::{cosine_similarity, OrtEmbedder};
// Pinned to Xenova/all-MiniLM-L6-v2 @ 751bff37182d3f1213fa05d7196b954e230abad9
// (current `main` as of this change) — not the floating `main` branch.
const MODEL_URL: &str =
"https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx";
"https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/751bff37182d3f1213fa05d7196b954e230abad9/onnx/model.onnx";
const TOKENIZER_URL: &str =
"https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main/tokenizer.json";
"https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/751bff37182d3f1213fa05d7196b954e230abad9/tokenizer.json";
// Official LFS sha256 of onnx/model.onnx at that commit.
const MODEL_SHA256: &str = "759c3cd2b7fe7e93933ad23c4c9181b7396442a2ed746ec7c1d46192c469c46e";
// sha256 of tokenizer.json at the same revision (not LFS; hashed from the published file).
const TOKENIZER_SHA256: &str = "da0e79933b9ed51798a3ae27893d3c5fa4a201126cef75586296df9b4d2c62a0";
/// Downloads the embedding model into `model_dir` if it isn't already
/// there — keeps setup to "run the daemon," no separate fetch step, in
@ -32,19 +38,21 @@ pub async fn ensure_model(model_dir: &Path) -> Result<(PathBuf, PathBuf)> {
if !model_path.exists() {
tracing::info!("downloading title-matching model (~90MB, one-time)");
download(MODEL_URL, model_path.clone()).await?;
download(MODEL_URL, model_path.clone(), MODEL_SHA256).await?;
}
if !tokenizer_path.exists() {
download(TOKENIZER_URL, tokenizer_path.clone()).await?;
download(TOKENIZER_URL, tokenizer_path.clone(), TOKENIZER_SHA256).await?;
}
Ok((model_path, tokenizer_path))
}
async fn download(url: &'static str, dest: PathBuf) -> Result<()> {
tokio::task::spawn_blocking(move || bread_onnx::download::ensure_file(url, &dest, None))
.await
.context("download task panicked")??;
async fn download(url: &'static str, dest: PathBuf, sha256: &'static str) -> Result<()> {
tokio::task::spawn_blocking(move || {
bread_onnx::download::ensure_file(url, &dest, Some(sha256))
})
.await
.context("download task panicked")??;
Ok(())
}
@ -250,6 +258,36 @@ pub fn queue_for_review(
link: Option<&str>,
source_id: Option<i64>,
) -> Result<i64> {
// Same release + same library row already sitting in pending: a
// no-op instead of another TUI row. Upgrade-search used to re-list
// the same movie 711 times (verified live on hestia).
if let Some(existing) = conn
.query_row(
"SELECT id FROM review_queue
WHERE status = 'pending'
AND candidate_media_item_id = ?1
AND raw_release_title = ?2",
params![candidate.media_item_id, raw_release_title],
|row| row.get(0),
)
.optional()?
{
return Ok(existing);
}
if let (Some(link), Some(source_id)) = (link, source_id) {
if let Some(existing) = conn
.query_row(
"SELECT id FROM review_queue
WHERE status = 'pending' AND source_id = ?1 AND link = ?2",
params![source_id, link],
|row| row.get(0),
)
.optional()?
{
return Ok(existing);
}
}
conn.execute(
"INSERT INTO review_queue (raw_release_title, candidate_media_item_id, confidence, link, source_id, status, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, 'pending', datetime('now'))",
@ -323,4 +361,81 @@ mod tests {
0.0
);
}
fn review_queue_conn() -> Connection {
let conn = Connection::open_in_memory().unwrap();
crate::db::init(&conn).unwrap();
conn.execute(
"INSERT INTO media_item (id, kind, title, monitored, quality_profile_id, root_folder)
VALUES (1, 'movie', 'Cars 3', 1, 2, '/tmp')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')",
[],
)
.unwrap();
conn
}
fn review_candidate() -> MatchCandidate {
MatchCandidate {
media_item_id: 1,
matched_text: "Cars 3".into(),
confidence: 0.7,
}
}
#[test]
fn queue_for_review_is_idempotent_for_the_same_pending_title() {
let conn = review_queue_conn();
let c = review_candidate();
let first = queue_for_review(&conn, "Cars.3.2017.1080p", &c, Some("magnet:a"), Some(1)).unwrap();
let second =
queue_for_review(&conn, "Cars.3.2017.1080p", &c, Some("magnet:a"), Some(1)).unwrap();
assert_eq!(first, second);
let n: i64 = conn
.query_row(
"SELECT count(*) FROM review_queue WHERE status = 'pending'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(n, 1);
}
#[test]
fn queue_for_review_is_idempotent_for_the_same_pending_link() {
let conn = review_queue_conn();
let c = review_candidate();
let first =
queue_for_review(&conn, "Cars.3.2017.1080p", &c, Some("magnet:same"), Some(1)).unwrap();
let second = queue_for_review(
&conn,
"Cars.3.2017.2160p.UHD",
&c,
Some("magnet:same"),
Some(1),
)
.unwrap();
assert_eq!(first, second);
let n: i64 = conn
.query_row(
"SELECT count(*) FROM review_queue WHERE status = 'pending'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(n, 1);
}
#[test]
fn queue_for_review_still_accepts_a_different_title_and_link() {
let conn = review_queue_conn();
let c = review_candidate();
let a = queue_for_review(&conn, "Cars.3.2017.1080p", &c, Some("magnet:a"), Some(1)).unwrap();
let b = queue_for_review(&conn, "Cars.3.2017.2160p", &c, Some("magnet:b"), Some(1)).unwrap();
assert_ne!(a, b);
}
}

View file

@ -5,7 +5,7 @@ pub mod tvdb;
use std::collections::HashSet;
use anyhow::Result;
use rusqlite::{params, Connection};
use rusqlite::{params, Connection, OptionalExtension};
#[derive(Debug, Clone, PartialEq)]
pub struct SeriesSearchResult {
@ -65,6 +65,21 @@ pub async fn add_series(
)
}
/// TVDB/TMDB ids are positive integers. `"0"` and non-numeric strings
/// must not be stored — `0` would collide under the unique external-id
/// indexes the same way a parse-failure `unwrap_or_default()` used to.
pub(crate) fn parse_external_id(raw: &str) -> Option<i64> {
raw.parse().ok().filter(|&id| id > 0)
}
fn existing_media_item_id(conn: &Connection, column: &str, value: i64) -> Result<Option<i64>> {
debug_assert!(column == "tvdb_id" || column == "tmdb_id");
let sql = format!("SELECT id FROM media_item WHERE {column} = ?1 ORDER BY id ASC LIMIT 1");
conn.query_row(&sql, params![value], |row| row.get(0))
.optional()
.map_err(Into::into)
}
#[allow(clippy::too_many_arguments)]
pub fn insert_series(
conn: &Connection,
@ -76,21 +91,23 @@ pub fn insert_series(
quality_profile_id: i64,
episodes: &[EpisodeInfo],
) -> Result<i64> {
conn.execute(
let tvdb_id = parse_external_id(tvdb_series_id);
if let Some(tvdb_id) = tvdb_id {
if let Some(existing) = existing_media_item_id(conn, "tvdb_id", tvdb_id)? {
return Ok(existing);
}
}
let tx = conn.unchecked_transaction()?;
tx.execute(
"INSERT INTO media_item (kind, title, year, tvdb_id, monitored, quality_profile_id, root_folder)
VALUES ('series', ?1, ?2, ?3, 1, ?4, ?5)",
params![
title,
year,
tvdb_series_id.parse::<i64>().ok(),
quality_profile_id,
root_folder
],
params![title, year, tvdb_id, quality_profile_id, root_folder],
)?;
let media_item_id = conn.last_insert_rowid();
let media_item_id = tx.last_insert_rowid();
for alias in aliases {
conn.execute(
tx.execute(
"INSERT INTO alias (media_item_id, text, source) VALUES (?1, ?2, 'tvdb')",
params![media_item_id, alias],
)?;
@ -109,12 +126,12 @@ pub fn insert_series(
// seasons keep the previous default of monitored.
let monitored = i64::from(ep.season_number != 0);
if seasons_seen.insert(ep.season_number) {
conn.execute(
tx.execute(
"INSERT OR IGNORE INTO season (media_item_id, season_number, monitored) VALUES (?1, ?2, ?3)",
params![media_item_id, ep.season_number, monitored],
)?;
}
conn.execute(
tx.execute(
"INSERT OR IGNORE INTO episode
(media_item_id, season_number, episode_number, absolute_number, title, air_date, monitored, has_file)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0)",
@ -130,6 +147,7 @@ pub fn insert_series(
)?;
}
tx.commit()?;
Ok(media_item_id)
}
@ -145,16 +163,177 @@ pub fn insert_movie(
root_folder: &str,
quality_profile_id: i64,
) -> Result<i64> {
let tmdb_id = parse_external_id(tmdb_movie_id);
if let Some(tmdb_id) = tmdb_id {
if let Some(existing) = existing_media_item_id(conn, "tmdb_id", tmdb_id)? {
return Ok(existing);
}
}
conn.execute(
"INSERT INTO media_item (kind, title, year, tmdb_id, monitored, quality_profile_id, root_folder)
VALUES ('movie', ?1, ?2, ?3, 1, ?4, ?5)",
params![
title,
year,
tmdb_movie_id.parse::<i64>().ok(),
quality_profile_id,
root_folder
],
params![title, year, tmdb_id, quality_profile_id, root_folder],
)?;
Ok(conn.last_insert_rowid())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
fn conn() -> Connection {
let conn = Connection::open_in_memory().unwrap();
db::init(&conn).unwrap();
conn
}
fn ep(season: u32, episode: u32) -> EpisodeInfo {
EpisodeInfo {
season_number: season,
episode_number: episode,
absolute_number: None,
title: Some(format!("E{episode}")),
air_date: Some("2020-01-01".into()),
}
}
#[test]
fn parse_external_id_rejects_zero_and_garbage() {
assert_eq!(parse_external_id("550"), Some(550));
assert_eq!(parse_external_id("0"), None);
assert_eq!(parse_external_id("-12"), None);
assert_eq!(parse_external_id("not-a-number"), None);
assert_eq!(parse_external_id(""), None);
}
#[test]
fn insert_series_writes_seasons_and_episodes_together() {
let conn = conn();
let id = insert_series(
&conn,
"12345",
"Show",
Some(2020),
&["Alias".into()],
"/tv/Show",
1,
&[ep(1, 1), ep(1, 2), ep(2, 1)],
)
.unwrap();
let seasons: i64 = conn
.query_row(
"SELECT count(*) FROM season WHERE media_item_id = ?1",
[id],
|r| r.get(0),
)
.unwrap();
let episodes: i64 = conn
.query_row(
"SELECT count(*) FROM episode WHERE media_item_id = ?1",
[id],
|r| r.get(0),
)
.unwrap();
let aliases: i64 = conn
.query_row(
"SELECT count(*) FROM alias WHERE media_item_id = ?1",
[id],
|r| r.get(0),
)
.unwrap();
assert_eq!(seasons, 2);
assert_eq!(episodes, 3);
assert_eq!(aliases, 1);
}
#[test]
fn insert_series_rolls_back_when_an_episode_insert_fails() {
let conn = conn();
conn.execute(
"CREATE TRIGGER fail_episode BEFORE INSERT ON episode
BEGIN SELECT RAISE(ABORT, 'boom'); END",
[],
)
.unwrap();
let err = insert_series(
&conn,
"99",
"Show",
None,
&["A".into()],
"/tv/Show",
1,
&[ep(1, 1)],
);
assert!(err.is_err());
let items: i64 = conn
.query_row("SELECT count(*) FROM media_item", [], |r| r.get(0))
.unwrap();
let seasons: i64 = conn
.query_row("SELECT count(*) FROM season", [], |r| r.get(0))
.unwrap();
let aliases: i64 = conn
.query_row("SELECT count(*) FROM alias", [], |r| r.get(0))
.unwrap();
assert_eq!(items, 0);
assert_eq!(seasons, 0);
assert_eq!(aliases, 0);
}
#[test]
fn insert_series_is_idempotent_on_tvdb_id() {
let conn = conn();
let first = insert_series(
&conn,
"12345",
"Show",
None,
&[],
"/tv/Show",
1,
&[ep(1, 1)],
)
.unwrap();
let second = insert_series(
&conn,
"12345",
"Other Title",
None,
&[],
"/tv/Other",
1,
&[],
)
.unwrap();
assert_eq!(first, second);
let count: i64 = conn
.query_row(
"SELECT count(*) FROM media_item WHERE tvdb_id = 12345",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn insert_movie_is_idempotent_on_tmdb_id() {
let conn = conn();
let first = insert_movie(&conn, "550", "Fight Club", Some(1999), "/movies", 2).unwrap();
let second = insert_movie(&conn, "550", "Fight Club 2", Some(2000), "/movies", 2).unwrap();
assert_eq!(first, second);
let count: i64 = conn
.query_row(
"SELECT count(*) FROM media_item WHERE tmdb_id = 550",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
}

View file

@ -34,6 +34,7 @@ pub struct ParsedRelease {
pub bit_depth: Option<u8>,
pub container: Option<String>,
pub is_repack: bool,
pub has_hdr: bool,
}
pub fn parse(raw_title: &str) -> ParsedRelease {
@ -53,6 +54,7 @@ pub fn parse(raw_title: &str) -> ParsedRelease {
let codec = tokens::extract_codec(&work);
let bit_depth = tokens::extract_bit_depth(&work);
let is_repack = tokens::REPACK_RE.is_match(&work);
let has_hdr = tokens::extract_hdr(&work);
let year = tokens::extract_year(&work);
let (season, episode, absolute_episode, title_span_end) = tokens::extract_episode_info(&work);
@ -72,6 +74,7 @@ pub fn parse(raw_title: &str) -> ParsedRelease {
bit_depth,
container,
is_repack,
has_hdr,
}
}
@ -360,6 +363,16 @@ mod tests {
assert_eq!(p.year, Some(2023));
}
#[test]
fn parses_hdr_hdr10_and_dolby_vision_tokens() {
assert!(parse("Movie.2024.2160p.HDR.mkv").has_hdr);
assert!(parse("Movie.2024.DV.mkv").has_hdr);
assert!(parse("Movie.2024.2160p.HDR10.BluRay").has_hdr);
assert!(parse("Movie.2024.2160p.DoVi.mkv").has_hdr);
assert!(parse("Movie.2024.Dolby.Vision.2160p").has_hdr);
assert!(!parse("Movie.2024.1080p.WEB-DL.H264").has_hdr);
}
#[test]
fn does_not_panic_on_unparsable_manga_release() {
// Not a video release at all — should degrade gracefully, not crash.

View file

@ -29,6 +29,9 @@ static BIT_DEPTH_RE: LazyLock<Regex> =
pub(super) static REPACK_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\b(REPACK|PROPER)\b").unwrap());
static HDR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\b(?:HDR10\+?|HDR|Dolby[.\s]?Vision|DoVi|DV)\b").unwrap());
static YEAR_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[(\[](19|20)\d{2}[)\]]").unwrap());
// Scene-style releases ("Dune.1984.1080p.BluRay.x264-GROUP") carry the year
// bare, with no surrounding brackets — `YEAR_RE` above never matches these
@ -181,6 +184,10 @@ pub(super) fn extract_bit_depth(s: &str) -> Option<u8> {
BIT_DEPTH_RE.captures(s)?[1].parse().ok()
}
pub(super) fn extract_hdr(s: &str) -> bool {
HDR_RE.is_match(s)
}
pub(super) fn extract_year(s: &str) -> Option<u32> {
if let Some(m) = YEAR_RE.find(s) {
return s[m.start() + 1..m.end() - 1].parse().ok();

View file

@ -194,13 +194,7 @@ fn movie_needs_grab(conn: &Connection, media_item_id: i64) -> Result<bool> {
if has_file > 0 {
return Ok(false);
}
let in_flight: i64 = conn.query_row(
"SELECT count(*) FROM release WHERE media_item_id = ?1 AND episode_id IS NULL
AND status IN ('grabbed','downloading')",
params![media_item_id],
|row| row.get(0),
)?;
Ok(in_flight == 0)
Ok(!movie_has_in_flight_release(conn, media_item_id)?)
}
/// Upgrade-search counterpart to `movie_needs_grab`: same monitored/
@ -216,13 +210,7 @@ fn movie_eligible_for_upgrade(conn: &Connection, media_item_id: i64) -> Result<b
if monitored == 0 {
return Ok(false);
}
let in_flight: i64 = conn.query_row(
"SELECT count(*) FROM release WHERE media_item_id = ?1 AND episode_id IS NULL
AND status IN ('grabbed','downloading')",
params![media_item_id],
|row| row.get(0),
)?;
if in_flight > 0 {
if movie_has_in_flight_release(conn, media_item_id)? {
return Ok(false);
}
// Same reasoning as the `upgrade_locked` check in
@ -236,10 +224,52 @@ fn movie_eligible_for_upgrade(conn: &Connection, media_item_id: i64) -> Result<b
params![media_item_id],
|row| row.get(0),
)
.optional()?
.unwrap_or(0);
Ok(upgrade_locked == 0)
}
fn movie_has_in_flight_release(conn: &Connection, media_item_id: i64) -> Result<bool> {
let n: i64 = conn.query_row(
"SELECT count(*) FROM release WHERE media_item_id = ?1 AND episode_id IS NULL
AND status IN ('grabbed','downloading')",
params![media_item_id],
|row| row.get(0),
)?;
Ok(n > 0)
}
fn episode_has_in_flight_release(conn: &Connection, episode_id: i64) -> Result<bool> {
let n: i64 = conn.query_row(
"SELECT count(*) FROM release WHERE episode_id = ?1 AND status IN ('grabbed','downloading')",
params![episode_id],
|row| row.get(0),
)?;
Ok(n > 0)
}
fn season_pack_in_flight(conn: &Connection, media_item_id: i64, season: u32) -> Result<bool> {
let n: i64 = conn.query_row(
"SELECT count(*) FROM release WHERE media_item_id = ?1 AND episode_id IS NULL
AND season_number = ?2 AND status IN ('grabbed','downloading')",
params![media_item_id, season],
|row| row.get(0),
)?;
Ok(n > 0)
}
/// True when this episode already has a grabbed/downloading release, or a
/// season pack covering this season is already in flight.
fn episode_is_in_flight(
conn: &Connection,
media_item_id: i64,
episode_id: i64,
season: u32,
) -> Result<bool> {
Ok(episode_has_in_flight_release(conn, episode_id)?
|| season_pack_in_flight(conn, media_item_id, season)?)
}
fn is_anime(conn: &Connection, tvdb_id: i64) -> Result<bool> {
let count: i64 = conn.query_row(
"SELECT count(*) FROM anime_mapping WHERE tvdb_id = ?1",
@ -295,14 +325,21 @@ fn find_monitored_missing_episode(
season: u32,
episode: u32,
) -> Result<Option<i64>> {
conn.query_row(
"SELECT id FROM episode WHERE media_item_id = ?1 AND season_number = ?2
AND episode_number = ?3 AND monitored = 1 AND has_file = 0",
params![media_item_id, season, episode],
|row| row.get(0),
)
.optional()
.map_err(Into::into)
let id: Option<i64> = conn
.query_row(
"SELECT id FROM episode WHERE media_item_id = ?1 AND season_number = ?2
AND episode_number = ?3 AND monitored = 1 AND has_file = 0",
params![media_item_id, season, episode],
|row| row.get(0),
)
.optional()?;
let Some(id) = id else {
return Ok(None);
};
if episode_is_in_flight(conn, media_item_id, id, season)? {
return Ok(None);
}
Ok(Some(id))
}
/// Upgrade-search counterpart to `find_monitored_missing_episode`: same
@ -336,8 +373,14 @@ fn count_monitored_missing_episodes_in_season(
season: u32,
) -> Result<i64> {
conn.query_row(
"SELECT count(*) FROM episode WHERE media_item_id = ?1 AND season_number = ?2
AND monitored = 1 AND has_file = 0",
"SELECT count(*) FROM episode e
WHERE e.media_item_id = ?1 AND e.season_number = ?2
AND e.monitored = 1 AND e.has_file = 0
AND NOT EXISTS (SELECT 1 FROM release r WHERE r.episode_id = e.id
AND r.status IN ('grabbed','downloading'))
AND NOT EXISTS (SELECT 1 FROM release r WHERE r.media_item_id = e.media_item_id
AND r.episode_id IS NULL AND r.season_number = e.season_number
AND r.status IN ('grabbed','downloading'))",
params![media_item_id, season],
|row| row.get(0),
)
@ -400,6 +443,14 @@ fn best_existing_season_pack_score(
/// an upgrade-search grab (see `SearchTarget::upgrade_min_gain`), so a file
/// already on disk isn't replaced over and over for score deltas too small
/// to matter.
/// Upgrade-search may auto-grab a better copy of something already owned.
/// The review-queue approve handler cannot — it only runs the first-copy
/// checks — so a NeedsReview match on an upgrade cycle must use those
/// same first-copy checks or the TUI's `a` key 409s every time.
fn use_upgrade_eligibility(upgrade_min_gain: Option<f32>, needs_review: bool) -> bool {
upgrade_min_gain.is_some() && !needs_review
}
fn should_grab(new_score: f32, is_repack: bool, existing_best: Option<f32>, min_gain: f32) -> bool {
match existing_best {
None => true,
@ -520,9 +571,15 @@ async fn process_item(
if movie_year_mismatch(parsed.year, media_item.year) {
return Ok(ProcessOutcome::YearMismatch);
}
let eligible = match upgrade_min_gain {
Some(_) => movie_eligible_for_upgrade(conn, media_item.id)?,
None => movie_needs_grab(conn, media_item.id)?,
// Review-queue approval only implements the first-copy path
// (`movie_needs_grab`). An upgrade-cycle match that still needs a
// human would otherwise be queued and then 409 on approve — the
// live hestia queue was 139 already-owned movies for exactly this
// reason. High-confidence auto-matches still use upgrade eligibility.
let eligible = if use_upgrade_eligibility(upgrade_min_gain, needs_review) {
movie_eligible_for_upgrade(conn, media_item.id)?
} else {
movie_needs_grab(conn, media_item.id)?
};
if !eligible {
return Ok(ProcessOutcome::NotMonitoredOrAlreadyHave);
@ -539,9 +596,10 @@ async fn process_item(
let Some((season, episode)) = resolve_episode(conn, media_item.tvdb_id, &parsed)? else {
return Ok(ProcessOutcome::CouldNotResolveEpisode);
};
let eid_opt = match upgrade_min_gain {
Some(_) => find_monitored_episode(conn, media_item.id, season, episode)?,
None => find_monitored_missing_episode(conn, media_item.id, season, episode)?,
let eid_opt = if use_upgrade_eligibility(upgrade_min_gain, needs_review) {
find_monitored_episode(conn, media_item.id, season, episode)?
} else {
find_monitored_missing_episode(conn, media_item.id, season, episode)?
};
let Some(eid) = eid_opt else {
return Ok(ProcessOutcome::NotMonitoredOrAlreadyHave);
@ -594,7 +652,7 @@ async fn process_item(
return Ok(ProcessOutcome::QueuedForReview);
}
let release_score = scoring::score(&parsed, item.seeders.unwrap_or(0), false, &profile);
let release_score = scoring::score(&parsed, item.seeders.unwrap_or(0), parsed.has_hdr, &profile);
let existing_best = match (episode_id, season_pack_number) {
(Some(eid), _) => best_existing_score(conn, eid)?,
(None, Some(season)) => best_existing_season_pack_score(conn, media_item.id, season)?,
@ -898,6 +956,11 @@ pub struct SearchTarget {
/// satisfied this target even though it has no single `episode_id`
/// of its own.
season_number: Option<u32>,
/// The target episode's number — `None` for a movie (or a season-pack
/// target). Used so `better_resolution_available` only counts 1080p+
/// results that are actually this episode (or a pack of this season),
/// not a sibling's higher-res release.
episode_number: Option<u32>,
query: String,
route: SearchRoute,
/// Significant (len >= 4, alphanumeric) lowercased words from the
@ -942,6 +1005,29 @@ fn passes_relevance_filter(target_words: &[String], candidate_title: &str) -> bo
target_words.iter().all(|w| lower.contains(w.as_str()))
}
/// True when a title-relevant item in this batch is 1080p+ *for this
/// target* — matching S/E, or a season pack of this season. A sibling
/// episode's 1080p must not reject this episode's only 720p option.
/// Movies: any title-relevant 1080p+ counts.
fn better_resolution_available(target: &SearchTarget, items: &[RawReleaseItem]) -> bool {
items.iter().any(|item| {
if !passes_relevance_filter(&target.title_words, &item.title) {
return false;
}
let parsed = parser::parse(&item.title);
if !parsed.resolution.is_some_and(|r| r >= 1080) {
return false;
}
if target.season_number.is_none() {
return true;
}
if looks_like_season_pack(&parsed) {
return parsed.season == target.season_number;
}
parsed.season == target.season_number && parsed.episode == target.episode_number
})
}
/// 1337x's search chokes on punctuation (colons, apostrophes) — replace
/// anything that isn't alphanumeric/whitespace with a space and collapse.
fn sanitize_query_text(s: &str) -> String {
@ -1035,6 +1121,9 @@ fn enumerate_search_targets(conn: &Connection, budget: usize) -> Result<Vec<Sear
(SELECT tvdb_id FROM anime_mapping WHERE tvdb_id IS NOT NULL))
AND NOT EXISTS (SELECT 1 FROM release r WHERE r.episode_id = e.id
AND r.status IN ('grabbed','downloading'))
AND NOT EXISTS (SELECT 1 FROM release r WHERE r.media_item_id = e.media_item_id
AND r.episode_id IS NULL AND r.season_number = e.season_number
AND r.status IN ('grabbed','downloading'))
AND (ss.last_searched_at IS NULL OR {})",
DUE_CLAUSE
.replace("last_searched_at", "ss.last_searched_at")
@ -1077,6 +1166,7 @@ fn enumerate_search_targets(conn: &Connection, budget: usize) -> Result<Vec<Sear
media_item_id,
episode_id: Some(episode_id),
season_number: Some(season as u32),
episode_number: Some(episode as u32),
query: build_tv_query(&title, season, episode, SearchRoute::Tpb),
title_words: significant_words(&title),
route: SearchRoute::Tpb,
@ -1141,6 +1231,7 @@ fn enumerate_search_targets(conn: &Connection, budget: usize) -> Result<Vec<Sear
media_item_id,
episode_id: None,
season_number: None,
episode_number: None,
query: build_movie_query(&title, year),
title_words: significant_words(&title),
route,
@ -1262,6 +1353,7 @@ fn enumerate_upgrade_targets(
media_item_id,
episode_id: Some(episode_id),
season_number: Some(season as u32),
episode_number: Some(episode as u32),
query: build_tv_query(&title, season, episode, SearchRoute::Tpb),
title_words: significant_words(&title),
route: SearchRoute::Tpb,
@ -1332,6 +1424,7 @@ fn enumerate_upgrade_targets(
media_item_id,
episode_id: None,
season_number: None,
episode_number: None,
query: build_movie_query(&title, year),
title_words: significant_words(&title),
route,
@ -1584,6 +1677,7 @@ pub fn enumerate_search_targets_for_media_item(
media_item_id,
episode_id: None,
season_number: None,
episode_number: None,
query: build_movie_query(&title, year),
title_words: significant_words(&title),
route,
@ -1599,6 +1693,9 @@ pub fn enumerate_search_targets_for_media_item(
AND e.air_date IS NOT NULL AND e.air_date <= date('now')
AND NOT EXISTS (SELECT 1 FROM release r WHERE r.episode_id = e.id
AND r.status IN ('grabbed','downloading'))
AND NOT EXISTS (SELECT 1 FROM release r WHERE r.media_item_id = e.media_item_id
AND r.episode_id IS NULL AND r.season_number = e.season_number
AND r.status IN ('grabbed','downloading'))
ORDER BY e.season_number, e.episode_number",
)?;
let rows: Vec<(i64, String, i64, i64)> = stmt
@ -1612,6 +1709,7 @@ pub fn enumerate_search_targets_for_media_item(
media_item_id,
episode_id: Some(episode_id),
season_number: Some(season as u32),
episode_number: Some(episode as u32),
query: build_tv_query(&title, season, episode, SearchRoute::Tpb),
title_words: significant_words(&title),
route: SearchRoute::Tpb,
@ -1738,12 +1836,7 @@ pub async fn execute_search_targets(
// matching `process_item` already does per-item below). Used to
// decide whether a sub-1080p candidate is a real downgrade or the
// only option actually available for this target.
let better_resolution_available = sorted.iter().any(|item| {
passes_relevance_filter(&target.title_words, &item.title)
&& parser::parse(&item.title)
.resolution
.is_some_and(|r| r >= 1080)
});
let better_resolution_available = better_resolution_available(target, &sorted);
// A query built for one target's title can surface a *different*
// monitored show/movie in its results (1337x's search isn't tightly
@ -1901,12 +1994,7 @@ pub async fn fetch_candidates(
sorted.sort_by_key(|i| std::cmp::Reverse(i.seeders.unwrap_or(0)));
sorted.truncate(MAX_RESULTS_PER_SEARCH);
let better_resolution_available = sorted.iter().any(|item| {
passes_relevance_filter(&target.title_words, &item.title)
&& parser::parse(&item.title)
.resolution
.is_some_and(|r| r >= 1080)
});
let better_resolution_available = better_resolution_available(&target, &sorted);
let mut candidates = Vec::new();
for item in &sorted {
@ -1929,7 +2017,7 @@ pub async fn fetch_candidates(
Some(scoring::score(
&parsed,
item.seeders.unwrap_or(0),
false,
parsed.has_hdr,
&profile,
)),
None,
@ -1960,6 +2048,68 @@ pub async fn fetch_candidates(
Ok(candidates)
}
/// Manual-path only: auto-grab of 1337x still needs detail-page
/// resolution. A picked candidate must already be a magnet or `.torrent`.
fn reject_unresolved_manual_grab_link(link: &str) -> Result<()> {
if sources::scrape::needs_resolution(link) {
anyhow::bail!("candidate must be a magnet or .torrent URL");
}
Ok(())
}
/// Binds a manual grab to the episode the title actually names, not the
/// TUI selection. A season pack has no episode id. Caller `episode_id` is
/// last-resort only (movies / unparsable titles).
fn resolve_grab_episode(
conn: &Connection,
media_item: &MediaItemRow,
parsed: &ParsedRelease,
caller_episode_id: Option<i64>,
) -> Result<Option<i64>> {
if looks_like_season_pack(parsed) {
return Ok(None);
}
if let Some((season, episode)) = resolve_episode(conn, media_item.tvdb_id, parsed)? {
if let Some(id) = find_episode_id(conn, media_item.id, season, episode)? {
return Ok(Some(id));
}
if let Some(id) = find_monitored_episode(conn, media_item.id, season, episode)? {
return Ok(Some(id));
}
}
Ok(caller_episode_id)
}
/// True when this resolved grab target already has a grabbed/downloading
/// release (episode, covering season pack, or movie).
fn grab_target_in_flight(
conn: &Connection,
media_item: &MediaItemRow,
episode_id: Option<i64>,
season_pack_number: Option<u32>,
) -> Result<bool> {
if media_item.kind == "movie" {
return movie_has_in_flight_release(conn, media_item.id);
}
if let Some(season) = season_pack_number {
return season_pack_in_flight(conn, media_item.id, season);
}
if let Some(eid) = episode_id {
let season: Option<u32> = conn
.query_row(
"SELECT season_number FROM episode WHERE id = ?1",
params![eid],
|row| row.get(0),
)
.optional()?;
if let Some(season) = season {
return episode_is_in_flight(conn, media_item.id, eid, season);
}
return episode_has_in_flight_release(conn, eid);
}
Ok(false)
}
/// Grabs a specific candidate a human picked from `fetch_candidates`'
/// output, bypassing the score-vs-existing-best `should_grab` comparison
/// entirely — a manual pick is an explicit override, not a competing
@ -1980,6 +2130,7 @@ pub async fn grab_candidate(
link: &str,
guid: &str,
) -> Result<()> {
reject_unresolved_manual_grab_link(link)?;
let parsed = parser::parse(raw_title);
let media_item = get_media_item(conn, media_item_id)?;
let profile_kind = if media_item.kind == "movie" {
@ -1993,17 +2144,19 @@ pub async fn grab_candidate(
} else {
None
};
let final_episode_id = if season_pack_number.is_some() {
None
} else {
episode_id
};
// Rebind to the episode the title actually names. Picking E06 while
// E05 is selected still grabs E06 — the TUI selection is last-resort
// only (movies / unparsable titles).
let final_episode_id = resolve_grab_episode(conn, &media_item, &parsed, episode_id)?;
if grab_target_in_flight(conn, &media_item, final_episode_id, season_pack_number)? {
anyhow::bail!("a grab is already in flight for this episode/movie");
}
// Real-time seeder data isn't available for a candidate picked from an
// earlier fetch — same `seeders=0` fallback `finalize_review_approval`
// already uses for the same reason, and for the same reason it's still
// real signal from resolution/source/codec/etc., not a meaningless
// hardcoded score.
let score = scoring::score(&parsed, 0, false, &profile);
let score = scoring::score(&parsed, 0, parsed.has_hdr, &profile);
let torrent_hash = match grab_and_capture_hash(qbit, link, qbit_category).await {
Ok(hash) => hash,
@ -2144,7 +2297,7 @@ pub fn prepare_review_approval(conn: &Connection, review_id: i64) -> Result<Appr
ProfileKind::Tv
};
let profile = load_quality_profile(conn, media_item.quality_profile_id, profile_kind)?;
let score = scoring::score(&parsed, 0, false, &profile);
let score = scoring::score(&parsed, 0, parsed.has_hdr, &profile);
// Claimed atomically here, still under the caller's DB lock — a real
// TOCTOU otherwise: the caller only checked `status == "pending"` above
@ -2254,18 +2407,26 @@ pub fn finalize_review_approval(
Ok(())
}
pub fn reject_review(conn: &Connection, review_id: i64) -> Result<()> {
conn.execute(
pub fn reject_review(conn: &Connection, review_id: i64) -> Result<bool> {
let rows = conn.execute(
"UPDATE review_queue SET status = 'rejected' WHERE id = ?1 AND status = 'pending'",
params![review_id],
)?;
Ok(())
Ok(rows > 0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn upgrade_cycle_does_not_use_upgrade_eligibility_for_a_review_match() {
assert!(!use_upgrade_eligibility(Some(5.0), true));
assert!(use_upgrade_eligibility(Some(5.0), false));
assert!(!use_upgrade_eligibility(None, false));
assert!(!use_upgrade_eligibility(None, true));
}
#[test]
fn should_grab_when_nothing_exists_yet() {
assert!(should_grab(10.0, false, None, 0.0));
@ -3317,4 +3478,246 @@ mod tests {
// e.g. a title that's entirely short/common words after filtering
assert!(passes_relevance_filter(&[], "anything at all"));
}
fn raw_item(title: &str) -> RawReleaseItem {
RawReleaseItem {
title: title.into(),
link: String::new(),
guid: title.into(),
size_bytes: None,
seeders: Some(10),
leechers: None,
}
}
fn tv_search_target(season: u32, episode: u32) -> SearchTarget {
SearchTarget {
media_item_id: 1,
episode_id: Some(i64::from(episode)),
season_number: Some(season),
episode_number: Some(episode),
query: "Some Show".into(),
route: SearchRoute::Tpb,
title_words: significant_words("Some Show"),
upgrade_min_gain: None,
}
}
#[test]
fn better_resolution_available_ignores_a_sibling_episodes_1080p() {
let target = tv_search_target(1, 1);
let items = [
raw_item("Some Show S01E01 720p WEB-DL H264"),
raw_item("Some Show S01E02 1080p WEB-DL H264"),
];
let flag = better_resolution_available(&target, &items);
assert!(
!flag,
"E02's 1080p must not count as a better option for E01"
);
let parsed = parser::parse("Some Show S01E01 720p WEB-DL H264");
let ctx = GateContext {
seeders: Some(50),
size_bytes: Some(400_000_000),
runtime_minutes: Some(24),
has_english_audio: true,
is_anime: false,
better_resolution_available: flag,
is_season_pack: false,
};
assert_eq!(
scoring::evaluate_gates(&parsed, &ctx, &QualityProfile::default_tv()),
scoring::GateResult::Accept
);
}
#[test]
fn better_resolution_available_is_true_for_this_episodes_own_1080p() {
let target = tv_search_target(1, 1);
let items = [
raw_item("Some Show S01E01 720p WEB-DL"),
raw_item("Some Show S01E01 1080p WEB-DL"),
];
assert!(better_resolution_available(&target, &items));
}
#[test]
fn better_resolution_available_counts_a_season_pack_of_this_season() {
let target = tv_search_target(1, 1);
let items = [
raw_item("Some Show S01E01 720p WEB-DL"),
raw_item("Some Show S01 Complete 1080p WEB-DL"),
];
assert!(better_resolution_available(&target, &items));
}
#[test]
fn better_resolution_available_for_a_movie_accepts_any_title_relevant_1080p() {
let target = SearchTarget {
media_item_id: 1,
episode_id: None,
season_number: None,
episode_number: None,
query: "Some Movie".into(),
route: SearchRoute::Tpb,
title_words: significant_words("Some Movie"),
upgrade_min_gain: None,
};
let items = [raw_item("Some Movie 2024 1080p BluRay")];
assert!(better_resolution_available(&target, &items));
}
fn seeded_e05_e06_conn() -> Connection {
let conn = Connection::open_in_memory().unwrap();
crate::db::init(&conn).unwrap();
conn.execute(
"INSERT INTO media_item (id, kind, title, tvdb_id, monitored, quality_profile_id, root_folder)
VALUES (1, 'series', 'Show', 12345, 1, 1, '/tmp')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file)
VALUES (5, 1, 1, 5, 1, 0), (6, 1, 1, 6, 1, 0)",
[],
)
.unwrap();
conn
}
#[test]
fn resolve_grab_episode_rebinds_to_the_parsed_episode_not_the_tui_selection() {
let conn = seeded_e05_e06_conn();
let media_item = get_media_item(&conn, 1).unwrap();
let parsed = parser::parse("Show.S01E06.1080p");
let bound = resolve_grab_episode(&conn, &media_item, &parsed, Some(5)).unwrap();
assert_eq!(bound, Some(6));
}
#[test]
fn resolve_grab_episode_clears_episode_id_for_a_season_pack() {
let conn = seeded_e05_e06_conn();
let media_item = get_media_item(&conn, 1).unwrap();
let parsed = parser::parse("Show.S01.COMPLETE.1080p");
let bound = resolve_grab_episode(&conn, &media_item, &parsed, Some(5)).unwrap();
assert_eq!(bound, None);
}
#[test]
fn resolve_grab_episode_keeps_caller_id_when_the_title_does_not_resolve() {
let conn = seeded_e05_e06_conn();
let media_item = get_media_item(&conn, 1).unwrap();
let parsed = parser::parse("Show.1080p.WEB-DL");
let bound = resolve_grab_episode(&conn, &media_item, &parsed, Some(5)).unwrap();
assert_eq!(bound, Some(5));
}
fn insert_test_source(conn: &Connection) {
conn.execute(
"INSERT INTO source (id, name, kind, base_url) VALUES (1, 'test', 'scrape', 'http://x')",
[],
)
.ok();
}
#[test]
fn find_monitored_missing_episode_excludes_an_in_flight_release() {
let conn = seeded_conn();
insert_test_source(&conn);
let episode_id = find_monitored_missing_episode(&conn, 1, 1, 1)
.unwrap()
.expect("seeded E01 should be missing");
conn.execute(
"INSERT INTO release (media_item_id, episode_id, raw_title, source_id, guid, status, grabbed_at)
VALUES (1, ?1, 'Show S01E01', 1, 'guid-ep', 'grabbed', datetime('now'))",
params![episode_id],
)
.unwrap();
assert!(find_monitored_missing_episode(&conn, 1, 1, 1)
.unwrap()
.is_none());
}
#[test]
fn find_monitored_missing_episode_excludes_an_in_flight_season_pack() {
let conn = seeded_conn();
insert_test_source(&conn);
conn.execute(
"INSERT INTO release (media_item_id, episode_id, season_number, raw_title, source_id, guid, status, grabbed_at)
VALUES (1, NULL, 1, 'Show S01 Complete', 1, 'guid-pack', 'downloading', datetime('now'))",
[],
)
.unwrap();
assert!(find_monitored_missing_episode(&conn, 1, 1, 1)
.unwrap()
.is_none());
}
#[test]
fn enumerate_search_targets_excludes_episodes_covered_by_an_in_flight_pack() {
let conn = search_enumeration_conn();
// E01 is the only aired missing episode of media_item 1 that isn't
// already in-flight. Cover the season with a pack and it must drop
// out of search enum along with any sibling.
conn.execute(
"INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file, air_date)
VALUES (10, 1, 1, 10, 1, 0, '2020-01-01')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO release (media_item_id, episode_id, season_number, raw_title, source_id, guid, status, grabbed_at)
VALUES (1, NULL, 1, 'Some Show S01 Complete', 1, 'guid-pack', 'grabbed', datetime('now'))",
[],
)
.unwrap();
let targets = enumerate_search_targets(&conn, 100).unwrap();
assert!(
!targets.iter().any(|t| t.media_item_id == 1),
"in-flight season pack should hide every missing episode of that season"
);
}
#[test]
fn grab_target_in_flight_is_true_for_an_episode_with_a_grabbed_release() {
let conn = seeded_e05_e06_conn();
insert_test_source(&conn);
conn.execute(
"INSERT INTO release (media_item_id, episode_id, raw_title, source_id, guid, status, grabbed_at)
VALUES (1, 6, 'Show S01E06', 1, 'guid-e06', 'grabbed', datetime('now'))",
[],
)
.unwrap();
let media_item = get_media_item(&conn, 1).unwrap();
assert!(grab_target_in_flight(&conn, &media_item, Some(6), None).unwrap());
assert!(!grab_target_in_flight(&conn, &media_item, Some(5), None).unwrap());
}
#[test]
fn grab_target_in_flight_is_true_when_a_season_pack_is_already_downloading() {
let conn = seeded_e05_e06_conn();
insert_test_source(&conn);
conn.execute(
"INSERT INTO release (media_item_id, episode_id, season_number, raw_title, source_id, guid, status, grabbed_at)
VALUES (1, NULL, 1, 'Show S01 Complete', 1, 'guid-pack', 'downloading', datetime('now'))",
[],
)
.unwrap();
let media_item = get_media_item(&conn, 1).unwrap();
assert!(grab_target_in_flight(&conn, &media_item, Some(5), None).unwrap());
assert!(grab_target_in_flight(&conn, &media_item, None, Some(1)).unwrap());
}
#[test]
fn reject_unresolved_manual_grab_link_allows_magnet_and_torrent_only() {
assert!(reject_unresolved_manual_grab_link("magnet:?xt=urn:btih:deadbeef").is_ok());
assert!(reject_unresolved_manual_grab_link("https://example.invalid/file.torrent").is_ok());
let err = reject_unresolved_manual_grab_link("https://1337x.to/torrent/123/")
.unwrap_err()
.to_string();
assert!(
err.contains("magnet or .torrent URL"),
"unexpected error: {err}"
);
}
}

View file

@ -1,4 +1,4 @@
use crate::parser::ParsedRelease;
use crate::parser::{Codec, ParsedRelease, Source};
use super::profile::{ProfileKind, QualityProfile};
@ -8,8 +8,8 @@ pub fn score(parsed: &ParsedRelease, seeders: u32, has_hdr: bool, profile: &Qual
total += w.seeder * seeder_score(seeders);
total += w.resolution_tier * resolution_tier(parsed.resolution);
total += w.source_tier * parsed.source.map(|s| s as u8 as f32).unwrap_or(0.0);
total += w.codec_tier * parsed.codec.map(|c| c as u8 as f32).unwrap_or(0.0);
total += w.source_tier * source_tier(parsed.source);
total += w.codec_tier * codec_tier(parsed.codec);
if parsed.bit_depth == Some(10) {
total += w.bit_depth;
@ -52,6 +52,29 @@ fn resolution_tier(resolution: Option<u32>) -> f32 {
}
}
/// Explicit tiers — `Source::Hdtv` is discriminant 0, so casting the enum
/// to `u8` scored HDTV identically to an unknown/missing source.
fn source_tier(source: Option<Source>) -> f32 {
match source {
Some(Source::Hdtv) => 1.0,
Some(Source::WebRip) => 2.0,
Some(Source::WebDl) => 3.0,
Some(Source::BluRay) => 4.0,
Some(Source::Remux) => 5.0,
None => 0.0,
}
}
/// Same reason as `source_tier`: `Codec::H264` is discriminant 0.
fn codec_tier(codec: Option<Codec>) -> f32 {
match codec {
Some(Codec::H264) => 1.0,
Some(Codec::Hevc) => 2.0,
Some(Codec::Av1) => 3.0,
None => 0.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -152,6 +175,36 @@ mod tests {
assert!(s720 > s480);
}
#[test]
fn hdtv_h264_scores_strictly_above_a_release_with_no_source_or_codec() {
let profile = QualityProfile::default_tv();
let known = parser::parse("Show S01E01 1080p HDTV H264");
let unknown = parser::parse("Show S01E01 1080p");
assert!(known.source.is_some());
assert!(known.codec.is_some());
assert!(unknown.source.is_none());
assert!(unknown.codec.is_none());
assert!(score(&known, 50, false, &profile) > score(&unknown, 50, false, &profile));
}
#[test]
fn remux_av1_scores_above_hdtv_h264() {
let profile = QualityProfile::default_movie();
let remux_av1 = parser::parse("Movie 2024 2160p Remux AV1");
let hdtv_h264 = parser::parse("Movie 2024 2160p HDTV H264");
assert!(score(&remux_av1, 50, false, &profile) > score(&hdtv_h264, 50, false, &profile));
}
#[test]
fn parsed_hdr_movie_scores_higher_than_the_same_release_without_hdr() {
let profile = QualityProfile::default_movie();
let hdr = parser::parse("Movie.2024.2160p.BluRay.HDR.H264");
let sdr = parser::parse("Movie.2024.2160p.BluRay.H264");
assert!(hdr.has_hdr);
assert!(!sdr.has_hdr);
assert!(score(&hdr, 50, hdr.has_hdr, &profile) > score(&sdr, 50, sdr.has_hdr, &profile));
}
#[test]
fn allowlisted_group_scores_higher_than_unlisted() {
let mut profile = QualityProfile::default_tv();

View file

@ -313,6 +313,9 @@ fn parse_search_results(html: &str, mirror: &str) -> Option<Vec<RawReleaseItem>>
continue;
}
let detail_url = if href.starts_with("http") {
if !same_origin(href, mirror) {
continue;
}
href.to_string()
} else {
format!("{mirror}{href}")
@ -360,6 +363,27 @@ fn parse_search_results(html: &str, mirror: &str) -> Option<Vec<RawReleaseItem>>
Some(items)
}
/// Scheme+host(+port) origin of an `http(s)://...` URL. `None` if the
/// string isn't an absolute http(s) URL with a host.
fn url_origin(url: &str) -> Option<(&str, &str)> {
let (scheme, rest) = if let Some(r) = url.strip_prefix("https://") {
("https", r)
} else {
let r = url.strip_prefix("http://")?;
("http", r)
};
let hostport = rest.split('/').next().filter(|s| !s.is_empty())?;
let hostport = hostport.rsplit('@').next().unwrap_or(hostport);
Some((scheme, hostport))
}
fn same_origin(href: &str, mirror: &str) -> bool {
match (url_origin(href), url_origin(mirror)) {
(Some((as_, ah)), Some((bs, bh))) => as_ == bs && ah.eq_ignore_ascii_case(bh),
_ => false,
}
}
fn extract_magnet(html: &str) -> Option<String> {
let doc = Html::parse_document(html);
let sel = Selector::parse(r#"a[href^="magnet:"]"#).unwrap();
@ -420,6 +444,32 @@ mod tests {
assert_eq!(a[0].guid, b[0].guid);
}
#[test]
fn drops_off_origin_absolute_hrefs_but_keeps_relative() {
let html = r#"
<table class="table-list table table-responsive table-striped">
<thead><tr><th class="coll-1 name">name</th><th class="coll-2">se</th><th class="coll-3">le</th><th class="coll-4">size</th></tr></thead>
<tbody>
<tr>
<td class="coll-1 name"><a href="http://127.0.0.1/evil">Evil</a></td>
<td class="coll-2">1</td>
<td class="coll-3">1</td>
<td class="coll-4">1 MB</td>
</tr>
<tr>
<td class="coll-1 name"><a href="/torrent/3250239/Ok/">Good</a></td>
<td class="coll-2">2</td>
<td class="coll-3">2</td>
<td class="coll-4">2 MB</td>
</tr>
</tbody>
</table>"#;
let items = parse_search_results(html, "https://13377x.info").unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].title, "Good");
assert_eq!(items[0].link, "https://13377x.info/torrent/3250239/Ok/");
}
#[test]
fn extract_torrent_id_handles_relative_and_absolute_hrefs() {
assert_eq!(