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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user