diff --git a/.sqlx/query-8fce2d85078e69b81b084aabdc16e99bd7a609452c13502ae6287c489b512797.json b/.sqlx/query-8fce2d85078e69b81b084aabdc16e99bd7a609452c13502ae6287c489b512797.json new file mode 100644 index 0000000..9b02faf --- /dev/null +++ b/.sqlx/query-8fce2d85078e69b81b084aabdc16e99bd7a609452c13502ae6287c489b512797.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT m.id AS \"id!: i64\",\n m.tmdb_id AS \"tmdb_id!: i64\",\n m.title AS \"title!: String\",\n m.year,\n m.original_language,\n m.search_attempts AS \"search_attempts!: i64\",\n m.last_searched_at,\n m.digital_release,\n m.metadata_refreshed_at\n FROM movies m\n WHERE m.id = ?\n AND m.wanted = 1\n AND m.blocked = 0\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'movie' AND f.owner_id = m.id\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = m.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n ", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "tmdb_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "title!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "original_language", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "search_attempts!: i64", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "last_searched_at", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "digital_release", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "metadata_refreshed_at", + "ordinal": 8, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + true, + true, + true + ] + }, + "hash": "8fce2d85078e69b81b084aabdc16e99bd7a609452c13502ae6287c489b512797" +} diff --git a/.sqlx/query-a0d7296eced171dd9ce02faadfaea5bc67e37123d7e30b18b575f0dd566952ae.json b/.sqlx/query-a0d7296eced171dd9ce02faadfaea5bc67e37123d7e30b18b575f0dd566952ae.json new file mode 100644 index 0000000..1f2a2bf --- /dev/null +++ b/.sqlx/query-a0d7296eced171dd9ce02faadfaea5bc67e37123d7e30b18b575f0dd566952ae.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT r.id AS \"id!: i64\",\n r.indexer_id AS \"indexer_id!: i64\",\n r.guid AS \"guid!: String\",\n r.name AS \"name!: String\",\n r.download_url AS \"download_url!: String\",\n CAST(COALESCE(r.score, 0) AS INTEGER) AS \"score!: i64\"\n FROM releases r\n JOIN movie_releases mr ON mr.release_id = r.id\n WHERE mr.movie_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived')\n ", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "indexer_id!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "guid!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "name!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "download_url!: String", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "score!: i64", + "ordinal": 5, + "type_info": "Null" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true, + false, + false, + false, + false, + null + ] + }, + "hash": "a0d7296eced171dd9ce02faadfaea5bc67e37123d7e30b18b575f0dd566952ae" +} diff --git a/.sqlx/query-ce34d0f329c6e3f8ec2df1b9c85502186ff6fddddd98cc15423fcd41ca308a04.json b/.sqlx/query-ce34d0f329c6e3f8ec2df1b9c85502186ff6fddddd98cc15423fcd41ca308a04.json new file mode 100644 index 0000000..75262e1 --- /dev/null +++ b/.sqlx/query-ce34d0f329c6e3f8ec2df1b9c85502186ff6fddddd98cc15423fcd41ca308a04.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT title FROM movies WHERE id = ?", + "describe": { + "columns": [ + { + "name": "title", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "ce34d0f329c6e3f8ec2df1b9c85502186ff6fddddd98cc15423fcd41ca308a04" +} diff --git a/.sqlx/query-e6274f081ce0751331e78f7fec573e088d08e3683fc60e84ff325e95fe42d803.json b/.sqlx/query-e6274f081ce0751331e78f7fec573e088d08e3683fc60e84ff325e95fe42d803.json new file mode 100644 index 0000000..91b5055 --- /dev/null +++ b/.sqlx/query-e6274f081ce0751331e78f7fec573e088d08e3683fc60e84ff325e95fe42d803.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE movies SET search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "e6274f081ce0751331e78f7fec573e088d08e3683fc60e84ff325e95fe42d803" +} diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index 598426a..eecc8f8 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -373,6 +373,64 @@ impl GrabAction { ) .await } + + /// The manual trigger (§6.2, §9.3, issue #107): reset the backoff so + /// `search_due` cannot skip the title, then run the same search-and-grab + /// path as a tick, scoped to this one movie instead of the tick's + /// `MOVIES_PER_TICK` batch. + pub(crate) async fn search_now( + &self, + database: &Db, + movie_id: i64, + ) -> Result, GrabError> { + reset_search_backoff(database, movie_id).await?; + let Some(movie) = pending_movie(database, movie_id).await? else { + return Ok(None); + }; + let Some(movie) = self.refresh_metadata(database, movie).await? else { + return Ok(None); + }; + let searchable = self.indexers.searchable().await?; + if searchable.is_empty() { + tracing::warn!("no indexer advertises a text search; nothing can be grabbed"); + return Ok(None); + } + self.grab_one(database, &movie, &searchable).await + } + + /// The manual one-click grab (§9.3, issue #107): the release is already + /// chosen, so this skips search and scoring and sends it straight to + /// Transmission. + pub(crate) async fn grab_release_now( + &self, + database: &Db, + movie_id: i64, + release_id: i64, + ) -> Result, GrabError> { + let Some(loaded) = database.movie_policy(movie_id).await? else { + return Ok(None); + }; + let Some(title) = movie_title(database, movie_id).await? else { + return Ok(None); + }; + let Some(release) = load_release(database, movie_id, release_id).await? else { + return Ok(None); + }; + let blacklist = Blacklist::load(database.pool()).await?; + self.grabber + .send_winner( + database, + &GrabTarget { + scope: GrabScope::Movie { movie_id }, + title: &title, + counts_as_attempt: false, + }, + &loaded, + &blacklist, + release, + ) + .await + } } /// Sending a chosen release to Transmission and recording the grab. @@ -842,6 +900,109 @@ fn search_due(movie: &PendingMovie) -> bool { backoff_elapsed(movie.search_attempts, movie.last_searched_at.as_deref()) } +/// Same shape as [`pending_movies`], scoped to one id and without the tick's +/// batch limit — the manual trigger already named which title to search. +async fn pending_movie(database: &Db, movie_id: i64) -> Result, GrabError> { + let row = sqlx::query!( + r#" + SELECT m.id AS "id!: i64", + m.tmdb_id AS "tmdb_id!: i64", + m.title AS "title!: String", + m.year, + m.original_language, + m.search_attempts AS "search_attempts!: i64", + m.last_searched_at, + m.digital_release, + m.metadata_refreshed_at + FROM movies m + WHERE m.id = ? + AND m.wanted = 1 + AND m.blocked = 0 + AND NOT EXISTS ( + SELECT 1 FROM media_files f + WHERE f.owner_kind = 'movie' AND f.owner_id = m.id + ) + AND NOT EXISTS ( + SELECT 1 FROM grabs g + WHERE g.target_kind = 'movie' AND g.target_id = m.id + AND g.state IN ('sent', 'downloaded', 'imported') + ) + "#, + movie_id + ) + .fetch_optional(database.pool()) + .await?; + + Ok(row.map(|row| PendingMovie { + id: row.id, + tmdb_id: row.tmdb_id, + title: row.title, + year: row.year, + original_language: row.original_language, + search_attempts: row.search_attempts, + last_searched_at: row.last_searched_at, + digital_release: row.digital_release, + metadata_refreshed_at: row.metadata_refreshed_at, + })) +} + +/// Unconditional, unlike [`record_search`]: the manual trigger's whole point +/// is to ignore the exponential backoff (§6.2). +async fn reset_search_backoff(database: &Db, movie_id: i64) -> Result<(), GrabError> { + sqlx::query!( + "UPDATE movies SET search_attempts = 0, last_searched_at = NULL, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ?", + movie_id + ) + .execute(database.pool()) + .await?; + Ok(()) +} + +async fn movie_title(database: &Db, movie_id: i64) -> Result, GrabError> { + Ok( + sqlx::query_scalar!("SELECT title FROM movies WHERE id = ?", movie_id) + .fetch_optional(database.pool()) + .await?, + ) +} + +/// The chosen release, still associated with the movie and not hard-failed — +/// a manual grab may take a `waived` release (§9.3), never a `rejected` one. +async fn load_release( + database: &Db, + movie_id: i64, + release_id: i64, +) -> Result, GrabError> { + let row = sqlx::query!( + r#" + SELECT r.id AS "id!: i64", + r.indexer_id AS "indexer_id!: i64", + r.guid AS "guid!: String", + r.name AS "name!: String", + r.download_url AS "download_url!: String", + CAST(COALESCE(r.score, 0) AS INTEGER) AS "score!: i64" + FROM releases r + JOIN movie_releases mr ON mr.release_id = r.id + WHERE mr.movie_id = ? AND r.id = ? AND r.verdict IN ('eligible', 'waived') + "#, + movie_id, + release_id + ) + .fetch_optional(database.pool()) + .await?; + + Ok(row.map(|row| Eligible { + id: row.id, + indexer_id: row.indexer_id, + guid: row.guid, + name: row.name, + download_url: row.download_url, + score: row.score, + })) +} + /// The §6.2 targeted-search backoff, shared by movie and episode search. pub(crate) fn backoff_elapsed(search_attempts: i64, last_searched_at: Option<&str>) -> bool { let Some(last_searched_at) = last_searched_at else { diff --git a/crates/arr-daemon/src/main.rs b/crates/arr-daemon/src/main.rs index 24e9147..f66892a 100644 --- a/crates/arr-daemon/src/main.rs +++ b/crates/arr-daemon/src/main.rs @@ -7,6 +7,7 @@ mod grab; mod import; mod indexers; mod jellyfin; +mod manual; mod notify; mod reaper; pub mod reconcile; @@ -100,8 +101,8 @@ enum Error { }, #[error("serve: {0}")] Serve(std::io::Error), - #[error("reconcile task: {0}")] - ReconcileTask(#[from] tokio::task::JoinError), + #[error("background task: {0}")] + BackgroundTask(#[from] tokio::task::JoinError), } async fn run() -> Result<(), Error> { @@ -120,7 +121,8 @@ async fn run() -> Result<(), Error> { None }; let notifier = Notifier::new(config.ntfy_url.clone())?; - let reconcile = reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), ¬ifier)?; + let (reconcile, manual_grab) = + reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), ¬ifier)?; // Jellyseerr's Radarr shim (DESIGN.md §9.4) reads the same database and // needs its own TMDB client for `movie/lookup`. @@ -135,9 +137,9 @@ async fn run() -> Result<(), Error> { if let Some(tmdb_url) = config.tmdb_url { upstreams = upstreams.with_tmdb_url(tmdb_url); } - let state = AppState::new(upstreams)?.with_database(database); + let state = AppState::new(upstreams)?.with_database(database.clone()); - let app = arr_api::router(state) + let app = arr_api::router(state.clone()) .merge(arr_compat::router(compat)) .fallback(web::serve) .layer(TraceLayer::new_for_http()); @@ -152,7 +154,11 @@ async fn run() -> Result<(), Error> { tracing::info!(addr = %config.bind_addr, docs = arr_api::DOCS_PATH, "listening"); let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); - let mut reconcile_task = tokio::spawn(reconcile.run(shutdown_rx)); + let mut reconcile_task = tokio::spawn(reconcile.run(shutdown_rx.clone())); + // Issue #107: nothing else drains `AppState`'s `MovieCommand` channel, so + // the manual-search and manual-grab endpoints were a no-op — the command + // sat in the 64-slot buffer forever. + let mut manual_task = tokio::spawn(manual::run(state, database, manual_grab, shutdown_rx)); let signal_tx = shutdown_tx.clone(); let server = async move { axum::serve(listener, app) @@ -168,10 +174,19 @@ async fn run() -> Result<(), Error> { result = &mut server => { let _ = shutdown_tx.send(true); reconcile_task.await?; + manual_task.await?; result.map_err(Error::Serve) } result = &mut reconcile_task => { result?; + let _ = shutdown_tx.send(true); + manual_task.await?; + server.await.map_err(Error::Serve) + } + result = &mut manual_task => { + result?; + let _ = shutdown_tx.send(true); + reconcile_task.await?; server.await.map_err(Error::Serve) } } @@ -180,14 +195,19 @@ async fn run() -> Result<(), Error> { /// Wire the reconcile lanes (DESIGN.md §8). Grab and RSS both need a /// Prowlarr key and grab needs TMDB as well; a lane whose upstream is not /// configured stays unregistered rather than failing every tick. +/// +/// Also returns a second, independent `GrabAction` for `manual::run` (issue +/// #107) — the manual trigger needs the same search-and-grab path on demand +/// rather than on the reconcile tick's schedule, and `ReconcileLoop::register` +/// takes ownership of the one it ticks. fn reconcile_loop( database: &Db, config: &Config, transmission: &arr_dl::TransmissionClient, tmdb: Option<&Arc>, notifier: &Notifier, -) -> Result { - let mut reconcile = ReconcileLoop::new(database.clone()); +) -> Result<(ReconcileLoop, Option), Error> { + let reconcile = ReconcileLoop::new(database.clone()); let seeding = SeedingRules::new( SeedingLimits { ratio: config.seed_ratio_limit, @@ -213,20 +233,14 @@ fn reconcile_loop( .map(|key| arr_indexer::ProwlarrClient::new(config.prowlarr_url.clone(), key)) .transpose()?; - if let (Some(prowlarr), Some(tmdb)) = (prowlarr.as_ref(), tmdb) { - reconcile = reconcile.register( - Tick::Reconcile, - GrabAction::new( - prowlarr.clone(), - transmission.clone(), - config.download_dir.clone(), - seeding.clone(), - ) - .with_tmdb(Arc::clone(tmdb)), - ); - } else { - tracing::warn!("Prowlarr or TMDB is not configured: nothing will be grabbed"); - } + let (mut reconcile, manual_grab) = register_movie_grab( + reconcile, + prowlarr.as_ref(), + transmission, + config, + &seeding, + tmdb, + ); // TV grabbing needs no TMDB at grab time: air dates are already on the // episode rows, which is the same gate the digital release date is for // movies (§6.2). @@ -298,7 +312,57 @@ fn reconcile_loop( ); } - Ok(reconcile.register(Tick::Reaper, ReaperAction::new(transmission.clone()))) + let reconcile = reconcile.register(Tick::Reaper, ReaperAction::new(transmission.clone())); + Ok((reconcile, manual_grab)) +} + +/// Register the movie grab lane on `reconcile` and hand back a second, +/// independent instance for `manual::run` (issue #107) — the manual trigger +/// needs the same search-and-grab path on demand rather than on the tick's +/// schedule, and `ReconcileLoop::register` takes ownership of the one it +/// ticks. `None` when Prowlarr or TMDB is not configured; nothing can be +/// grabbed either way. +fn register_movie_grab( + mut reconcile: ReconcileLoop, + prowlarr: Option<&arr_indexer::ProwlarrClient>, + transmission: &arr_dl::TransmissionClient, + config: &Config, + seeding: &SeedingRules, + tmdb: Option<&Arc>, +) -> (ReconcileLoop, Option) { + let Some((prowlarr, tmdb)) = prowlarr.zip(tmdb) else { + tracing::warn!("Prowlarr or TMDB is not configured: nothing will be grabbed"); + return (reconcile, None); + }; + reconcile = reconcile.register( + Tick::Reconcile, + movie_grab_action(prowlarr, transmission, config, seeding, tmdb), + ); + let manual_grab = Some(movie_grab_action( + prowlarr, + transmission, + config, + seeding, + tmdb, + )); + (reconcile, manual_grab) +} + +/// The movie grab lane, built fresh for each caller. +fn movie_grab_action( + prowlarr: &arr_indexer::ProwlarrClient, + transmission: &arr_dl::TransmissionClient, + config: &Config, + seeding: &SeedingRules, + tmdb: &Arc, +) -> GrabAction { + GrabAction::new( + prowlarr.clone(), + transmission.clone(), + config.download_dir.clone(), + seeding.clone(), + ) + .with_tmdb(Arc::clone(tmdb)) } /// Stop accepting on Ctrl-C, or on the SIGTERM a service manager sends. diff --git a/crates/arr-daemon/src/manual.rs b/crates/arr-daemon/src/manual.rs new file mode 100644 index 0000000..a9bd2b1 --- /dev/null +++ b/crates/arr-daemon/src/manual.rs @@ -0,0 +1,250 @@ +//! Drains `AppState`'s `MovieCommand` channel — the daemon-side consumer +//! DESIGN.md §6.2 and §9.3 assume exists (issue #107). `Search` resets the +//! movie's backoff and re-runs the targeted-search + grab lane immediately; +//! `Grab` sends an already-chosen release straight to Transmission, skipping +//! search. + +use arr_api::{AppState, MovieCommand}; +use arr_db::Db; +use tokio::sync::watch; + +use crate::grab::{GrabAction, GrabError}; + +/// Run until every sender is dropped or `shutdown` fires. `grab` is `None` +/// when Prowlarr or TMDB is not configured (`main.rs` warns about this +/// already for the reconcile lane) — commands are still drained so the +/// channel never fills, they just cannot be acted on. +pub async fn run( + state: AppState, + database: Db, + grab: Option, + mut shutdown: watch::Receiver, +) { + loop { + let command = tokio::select! { + biased; + changed = shutdown.changed() => { + if changed.is_err() || *shutdown.borrow() { + return; + } + continue; + } + command = state.next_movie_command() => command, + }; + let Some(command) = command else { + return; + }; + let Some(grab) = &grab else { + tracing::warn!("manual movie command received but Prowlarr or TMDB is not configured"); + continue; + }; + if let Err(error) = handle(grab, &database, command).await { + tracing::error!(%error, "manual movie command failed"); + } + } +} + +async fn handle(grab: &GrabAction, database: &Db, command: MovieCommand) -> Result<(), GrabError> { + match command { + MovieCommand::Search { movie_id } => { + grab.search_now(database, movie_id).await?; + } + MovieCommand::Grab { + movie_id, + release_id, + } => { + grab.grab_release_now(database, movie_id, release_id) + .await?; + } + } + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use std::collections::HashMap; + use std::path::PathBuf; + + use arr_db::Db; + use arr_dl::TransmissionClient; + use arr_indexer::ProwlarrClient; + use wiremock::matchers::{method, path, path_regex, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + use super::*; + use crate::grab::{SeedingLimits, SeedingRules}; + + async fn database_with_wanted_movie() -> (tempfile::TempDir, Db) { + let dir = tempfile::tempdir().unwrap(); + let database = Db::connect(dir.path().join("arr.db")).await.unwrap(); + database.migrate().await.unwrap(); + sqlx::query( + "INSERT INTO movies (tmdb_id, title, year, original_language, root_id) + SELECT 693134, 'Dune Part Two', 2024, 'en', id + FROM roots WHERE kind = 'movie' AND audience = 'main'", + ) + .execute(database.pool()) + .await + .unwrap(); + (dir, database) + } + + async fn transmission() -> MockServer { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "result": "success", + "arguments": {"torrent-added": {"id": 1, "name": "x", "hashString": "aaaa"}} + }))) + .mount(&server) + .await; + server + } + + fn grab_action(indexer: &MockServer, transmission: &MockServer) -> GrabAction { + GrabAction::new( + ProwlarrClient::new(indexer.uri(), "key").unwrap(), + TransmissionClient::new(&transmission.uri()).unwrap(), + PathBuf::from("/mnt/media/transmission/complete"), + SeedingRules::new( + SeedingLimits { + ratio: 1.5, + idle_minutes: 60, + }, + HashMap::new(), + ), + ) + } + + /// Issue #107: a `Search` command must reset the backoff, not merely be + /// accepted onto the channel. `search_attempts` starts at 5 (a 7-day + /// backoff, nowhere near elapsed) so a normal tick would skip this movie; + /// ending at 1 rather than 6 proves the reset happened, not just a + /// one-off bypass of the gate. + #[tokio::test] + async fn a_search_command_resets_backoff_and_searches_now() { + let (_dir, database) = database_with_wanted_movie().await; + sqlx::query( + "UPDATE movies SET search_attempts = 5, + last_searched_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-6 days')", + ) + .execute(database.pool()) + .await + .unwrap(); + + let indexer = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/indexer")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + {"id": 7, "name": "tracker", "enable": true} + ]))) + .mount(&indexer) + .await; + Mock::given(method("GET")) + .and(path("/7/api")) + .and(query_param("t", "caps")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#""#, + )) + .mount(&indexer) + .await; + Mock::given(method("GET")) + .and(path("/7/api")) + .and(query_param("t", "search")) + .respond_with( + ResponseTemplate::new(200).set_body_string(""), + ) + .mount(&indexer) + .await; + let transmission = transmission().await; + let grab = grab_action(&indexer, &transmission); + + handle(&grab, &database, MovieCommand::Search { movie_id: 1 }) + .await + .unwrap(); + + let searched = indexer + .received_requests() + .await + .unwrap() + .into_iter() + .any(|request| { + request.url.path() == "/7/api" + && request + .url + .query_pairs() + .any(|(name, value)| name == "t" && value == "search") + }); + assert!(searched, "the reset backoff must let the search run now"); + let (attempts, last_searched_at): (i64, Option) = + sqlx::query_as("SELECT search_attempts, last_searched_at FROM movies WHERE id = 1") + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(attempts, 1, "reset to 0, then one real attempt — not 6"); + assert!(last_searched_at.is_some()); + } + + /// Issue #107: a `Grab` command sends the already-chosen release straight + /// to Transmission — no indexer search at all. + #[tokio::test] + async fn a_grab_command_sends_the_chosen_release_without_searching() { + let (_dir, database) = database_with_wanted_movie().await; + let indexer = MockServer::start().await; + Mock::given(method("GET")) + .and(path_regex("^/dl/")) + .respond_with(ResponseTemplate::new(302).insert_header( + "location", + "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + )) + .mount(&indexer) + .await; + let transmission = transmission().await; + let grab = grab_action(&indexer, &transmission); + + let release_id = sqlx::query( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict) + VALUES (7, 'chosen', 'Dune.Part.Two.2024.2160p.WEB-DL', 1, ?, '{}', 900, 'eligible')", + ) + .bind(format!("{}/dl/chosen", indexer.uri())) + .execute(database.pool()) + .await + .unwrap() + .last_insert_rowid(); + sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (1, ?)") + .bind(release_id) + .execute(database.pool()) + .await + .unwrap(); + + handle( + &grab, + &database, + MovieCommand::Grab { + movie_id: 1, + release_id, + }, + ) + .await + .unwrap(); + + let grabbed: i64 = sqlx::query_scalar("SELECT count(*) FROM grabs WHERE target_id = 1") + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(grabbed, 1); + let searched = indexer + .received_requests() + .await + .unwrap() + .into_iter() + .any(|request| { + request + .url + .query_pairs() + .any(|(name, value)| name == "t" && value == "search") + }); + assert!(!searched, "a chosen release must not trigger a search"); + } +} diff --git a/crates/arr-e2e/tests/e2e.rs b/crates/arr-e2e/tests/e2e.rs index 47376d6..f7ae564 100644 --- a/crates/arr-e2e/tests/e2e.rs +++ b/crates/arr-e2e/tests/e2e.rs @@ -52,8 +52,9 @@ async fn daemon_runs_against_fakes_and_a_real_transmission() { let movie_id = movie["id"].as_i64().expect("movie id"); assert_eq!(movie["wanted"], true); - // The manual-search trigger is accepted; its consumer is the reconcile - // loop (#21), so no releases materialise yet. + // The manual-search trigger is accepted and consumed by the daemon + // (issue #107), so the fixture's releases materialise across the + // process boundary. let search = reqwest::Client::new() .post(format!("{base}/api/movies/{movie_id}/search")) .send() @@ -61,14 +62,30 @@ async fn daemon_runs_against_fakes_and_a_real_transmission() { .expect("trigger search"); assert_eq!(search.status(), reqwest::StatusCode::ACCEPTED); - let releases: Vec = - reqwest::get(format!("{base}/api/movies/{movie_id}/releases")) - .await - .expect("list releases") - .json() - .await - .expect("releases json"); - assert!(releases.is_empty()); + let releases: Vec = wait_for_releases(base, movie_id).await; + assert_eq!(releases.len(), 2); + assert!(releases.iter().any(|release| release["name"] + .as_str() + .is_some_and(|name| name.contains("Dune.Part.Two.2024.2160p.WEB-DL")))); +} + +/// The manual search runs on a background task, not inline with the HTTP +/// response, so give it a moment to land instead of racing it. +async fn wait_for_releases(base: &str, movie_id: i64) -> Vec { + for _ in 0..50 { + let releases: Vec = + reqwest::get(format!("{base}/api/movies/{movie_id}/releases")) + .await + .expect("list releases") + .json() + .await + .expect("releases json"); + if !releases.is_empty() { + return releases; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + panic!("no releases appeared after the manual search"); } /// The recorded fixtures satisfy the real Prowlarr and TMDB clients, so a