fix(daemon): consume the MovieCommand channel
Nothing drained AppState's movie_commands mpsc, so manual search and one-click grab were accepted with 202 and then did nothing until the 64-slot buffer filled and the endpoint started 503ing. A new daemon task drains it: Search resets the movie's backoff and runs the targeted-search + grab lane immediately; Grab sends the already-chosen release straight to Transmission. Closes #107
This commit is contained in:
+68
@@ -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"
|
||||
}
|
||||
+50
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
@@ -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<Option<Outcome>, 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<Option<Outcome>, 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<Option<PendingMovie>, 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<Option<String>, 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<Option<Eligible>, 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 {
|
||||
|
||||
@@ -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<TmdbClient>>,
|
||||
notifier: &Notifier,
|
||||
) -> Result<ReconcileLoop, Error> {
|
||||
let mut reconcile = ReconcileLoop::new(database.clone());
|
||||
) -> Result<(ReconcileLoop, Option<GrabAction>), 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<TmdbClient>>,
|
||||
) -> (ReconcileLoop, Option<GrabAction>) {
|
||||
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<TmdbClient>,
|
||||
) -> 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.
|
||||
|
||||
@@ -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<GrabAction>,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) {
|
||||
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#"<caps><searching><search available="yes" supportedParams="q"/></searching></caps>"#,
|
||||
))
|
||||
.mount(&indexer)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/7/api"))
|
||||
.and(query_param("t", "search"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string("<rss><channel></channel></rss>"),
|
||||
)
|
||||
.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<String>) =
|
||||
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");
|
||||
}
|
||||
}
|
||||
+27
-10
@@ -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<serde_json::Value> =
|
||||
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<serde_json::Value> = 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<serde_json::Value> {
|
||||
for _ in 0..50 {
|
||||
let releases: Vec<serde_json::Value> =
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user