fix(daemon): consume the MovieCommand channel
ci / web (push) Successful in 36s
ci / rust (push) Successful in 1m23s
e2e / e2e (push) Successful in 1m17s

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:
Miguel Palhas
2026-08-23 09:02:42 +01:00
parent ca8a891347
commit 83ca921501
8 changed files with 675 additions and 33 deletions
+161
View File
@@ -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 {