diff --git a/.sqlx/query-8fce2d85078e69b81b084aabdc16e99bd7a609452c13502ae6287c489b512797.json b/.sqlx/query-7caab9e1f6676b11d8053d48df86cf3b60362a5c2007793fe1c2ff5a57fa0c40.json
similarity index 62%
rename from .sqlx/query-8fce2d85078e69b81b084aabdc16e99bd7a609452c13502ae6287c489b512797.json
rename to .sqlx/query-7caab9e1f6676b11d8053d48df86cf3b60362a5c2007793fe1c2ff5a57fa0c40.json
index 9b02faf..3af084b 100644
--- a/.sqlx/query-8fce2d85078e69b81b084aabdc16e99bd7a609452c13502ae6287c489b512797.json
+++ b/.sqlx/query-7caab9e1f6676b11d8053d48df86cf3b60362a5c2007793fe1c2ff5a57fa0c40.json
@@ -1,6 +1,6 @@
{
"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 ",
+ "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 (\n m.wanted = 1\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 ) AS \"is_gap!: i64\"\n FROM movies m\n WHERE m.id = ?\n AND m.blocked = 0\n ",
"describe": {
"columns": [
{
@@ -47,6 +47,11 @@
"name": "metadata_refreshed_at",
"ordinal": 8,
"type_info": "Text"
+ },
+ {
+ "name": "is_gap!: i64",
+ "ordinal": 9,
+ "type_info": "Integer"
}
],
"parameters": {
@@ -61,8 +66,9 @@
false,
true,
true,
- true
+ true,
+ false
]
},
- "hash": "8fce2d85078e69b81b084aabdc16e99bd7a609452c13502ae6287c489b512797"
+ "hash": "7caab9e1f6676b11d8053d48df86cf3b60362a5c2007793fe1c2ff5a57fa0c40"
}
diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs
index cc71267..feb736e 100644
--- a/crates/arr-daemon/src/grab.rs
+++ b/crates/arr-daemon/src/grab.rs
@@ -129,8 +129,9 @@ impl GrabAction {
for movie in gaps {
let (movie_id, title) = (movie.id, movie.title.clone());
let movie = match self.refresh_metadata(database, movie).await {
- Ok(Some(movie)) => movie,
- Ok(None) => continue,
+ Ok((movie, true)) => movie,
+ // Not released yet: no targeted search (§6.2).
+ Ok((_, false)) => continue,
// A title's metadata is not the rest of the tick's problem,
// same as a grab failure below.
Err(error) => {
@@ -162,17 +163,22 @@ impl GrabAction {
Ok(outcomes)
}
+ /// Refresh one title from TMDB, returning it alongside whether it is
+ /// digitally released (§6.2). Targeted search is gated on that flag; the
+ /// manual deck refresh of an already-available movie is not, since the
+ /// file on disk is proof enough and TMDB does not know a digital date for
+ /// every title (issue #115).
async fn refresh_metadata(
&self,
database: &Db,
movie: PendingMovie,
- ) -> Result, GrabError> {
+ ) -> Result<(PendingMovie, bool), GrabError> {
let Some(tmdb) = &self.tmdb else {
- return Ok(Some(movie));
+ return Ok((movie, true));
};
if !metadata_refresh_due(movie.metadata_refreshed_at.as_deref()) {
let released = is_digitally_released(movie.digital_release.as_deref());
- return Ok(released.then_some(movie));
+ return Ok((movie, released));
}
let tmdb_id =
u32::try_from(movie.tmdb_id).map_err(|_| GrabError::InvalidTmdbId(movie.id))?;
@@ -232,28 +238,31 @@ impl GrabAction {
.execute(database.pool())
.await?;
}
- if !metadata.is_digitally_released(chrono::Utc::now().date_naive()) {
+ let released = metadata.is_digitally_released(chrono::Utc::now().date_naive());
+ if !released {
tracing::debug!(
movie_id = movie.id,
"digital release has not happened; skipping targeted search"
);
- return Ok(None);
}
- Ok(Some(PendingMovie {
- id: movie.id,
- tmdb_id: movie.tmdb_id,
- title,
- year,
- original_language,
- search_attempts: if changed { 0 } else { movie.search_attempts },
- last_searched_at: if changed {
- None
- } else {
- movie.last_searched_at
+ Ok((
+ PendingMovie {
+ id: movie.id,
+ tmdb_id: movie.tmdb_id,
+ title,
+ year,
+ original_language,
+ search_attempts: if changed { 0 } else { movie.search_attempts },
+ last_searched_at: if changed {
+ None
+ } else {
+ movie.last_searched_at
+ },
+ digital_release,
+ metadata_refreshed_at: None,
},
- digital_release,
- metadata_refreshed_at: None,
- }))
+ released,
+ ))
}
/// Search every indexer for one title, cache each candidate with its
@@ -374,28 +383,91 @@ 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
+ /// Sweep every searchable indexer for one title and upsert the results
+ /// into its release deck (§9.3) without choosing a winner.
+ ///
+ /// Issue #115: a manual search on a movie that is already satisfied is an
+ /// upgrade view being refreshed, not a gap being closed. It must re-score
+ /// and re-cache candidates — the same writes as the grab lane — and stop
+ /// there. Only a wanted movie with nothing on disk auto-grabs.
+ async fn refresh_deck(
+ &self,
+ database: &Db,
+ movie: &PendingMovie,
+ indexers: &[i64],
+ ) -> Result<(), GrabError> {
+ let Some(loaded) = database.movie_policy(movie.id).await? else {
+ return Ok(());
+ };
+ // §5.2: no original language, nothing to evaluate the language rule
+ // against — same refusal as the grab lane.
+ let Some(original_language) = movie.original_language.as_deref() else {
+ tracing::warn!(
+ movie_id = movie.id,
+ title = movie.title,
+ "no original language yet; not searching"
+ );
+ return Ok(());
+ };
+ let original_language = arr_db::policy::language(original_language);
+ let blacklist = Blacklist::load(database.pool()).await?;
+ let eligible = self
+ .search(
+ database,
+ movie,
+ indexers,
+ &loaded,
+ &original_language,
+ &blacklist,
+ )
+ .await?;
+ record_search(database, movie.id).await?;
+ tracing::info!(
+ movie_id = movie.id,
+ title = movie.title,
+ eligible = eligible.len(),
+ "release deck refreshed; not grabbing an already-satisfied movie"
+ );
+ Ok(())
+ }
+
+ /// The manual trigger (§6.2, §9.3, issues #107 and #115): reset the
+ /// backoff so `search_due` cannot skip the title, then sweep the
+ /// indexers, scoped to this one movie instead of the tick's
/// `MOVIES_PER_TICK` batch.
+ ///
+ /// What happens to the results depends on the movie, not on the command:
+ /// a gap — wanted, unblocked, nothing on disk, no grab in flight — gets
+ /// search and grab; anything else already satisfied gets its deck
+ /// refreshed and no grab; a blocked movie is refused outright (§6.3).
pub(crate) async fn search_now(
&self,
database: &Db,
movie_id: i64,
) -> Result , GrabError> {
+ let Some((movie, lane)) = manual_movie(database, movie_id).await? else {
+ tracing::info!(movie_id, "manual search refused: unknown or blocked movie");
+ return Ok(None);
+ };
reset_search_backoff(database, movie_id).await?;
- let Some(movie) = pending_movie(database, movie_id).await? else {
+ let (movie, released) = self.refresh_metadata(database, movie).await?;
+ // The release-date gate belongs to targeted search. A movie that is
+ // already on disk is released whatever TMDB says.
+ if !released && lane == ManualSearch::SearchAndGrab {
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
+ match lane {
+ ManualSearch::SearchAndGrab => self.grab_one(database, &movie, &searchable).await,
+ ManualSearch::RefreshDeck => {
+ self.refresh_deck(database, &movie, &searchable).await?;
+ Ok(None)
+ }
+ }
}
/// The manual one-click grab (§9.3, issue #107): the release is already
@@ -901,9 +973,24 @@ 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> {
+/// What a manual search on one movie is allowed to do (issue #115).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ManualSearch {
+ /// The movie is a gap: sweep the indexers and grab the winner.
+ SearchAndGrab,
+ /// The movie is already satisfied: sweep the indexers, refresh the deck
+ /// (§9.3), grab nothing.
+ RefreshDeck,
+}
+
+/// Same columns as [`pending_movies`], scoped to one id, without the tick's
+/// batch limit — the manual trigger already named which title to search — and
+/// without the gap filter, which decides the lane here instead of hiding the
+/// movie. `blocked` still refuses (§6.3).
+async fn manual_movie(
+ database: &Db,
+ movie_id: i64,
+) -> Result , GrabError> {
let row = sqlx::query!(
r#"
SELECT m.id AS "id!: i64",
@@ -914,36 +1001,48 @@ async fn pending_movie(database: &Db, movie_id: i64) -> Result (tempfile::TempDir, Db) {
+ let (dir, database) = wanted_movie().await;
+ sqlx::query(
+ "INSERT INTO media_files (owner_kind, owner_id, path, size)
+ VALUES ('movie', 1, '/mnt/media/movies/Dune Part Two (2024).mkv', 1)",
+ )
+ .execute(database.pool())
+ .await
+ .unwrap();
+ sqlx::query("UPDATE movies SET state = 'available' WHERE id = 1")
+ .execute(database.pool())
+ .await
+ .unwrap();
+ (dir, database)
+ }
+
+ async fn deck(database: &Db) -> Vec<(String, String)> {
+ sqlx::query_as::<_, (String, String)>(
+ "SELECT r.name, r.verdict
+ FROM releases r JOIN movie_releases mr ON mr.release_id = r.id
+ WHERE mr.movie_id = 1 ORDER BY r.guid",
+ )
+ .fetch_all(database.pool())
+ .await
+ .unwrap()
+ }
+
+ /// Issue #115: a manual search on an available movie sweeps the indexers
+ /// and refreshes the deck §9.3 reads, and grabs nothing. The old code
+ /// loaded the movie through the gap filter, so this was a silent no-op.
+ #[tokio::test]
+ async fn a_manual_search_on_an_available_movie_refreshes_the_deck() {
+ let (_dir, database) = available_movie().await;
+ let indexer = prowlarr().await;
+ let (downloader, fake) = transmission().await;
+
+ let outcome = action(&indexer, &downloader)
+ .search_now(&database, 1)
+ .await
+ .unwrap();
+
+ assert!(outcome.is_none(), "an available movie must not auto-grab");
+ assert_eq!(targeted_searches(&indexer).await, 1);
+ assert_eq!(deck(&database).await.len(), 3, "every candidate is cached");
+ assert!(fake.torrents().is_empty());
+ assert!(grabs(&database).await.is_empty());
+ }
+
+ /// The digital-release gate belongs to targeted search (§6.2). A movie
+ /// with a file on disk is released whatever TMDB knows, so it must not
+ /// silence the manual deck refresh.
+ #[tokio::test]
+ async fn an_available_movie_refreshes_its_deck_before_the_digital_release() {
+ let (_dir, database) = available_movie().await;
+ let indexer = prowlarr().await;
+ let metadata = tmdb(UNRELEASED_METADATA).await;
+ let (downloader, _fake) = transmission().await;
+
+ action_with_tmdb(&indexer, &downloader, &metadata)
+ .search_now(&database, 1)
+ .await
+ .unwrap();
+
+ assert_eq!(deck(&database).await.len(), 3);
+ }
+
+ /// A grab already in flight is not a gap either: refresh the deck, do not
+ /// send a second torrent.
+ #[tokio::test]
+ async fn a_manual_search_with_a_grab_in_flight_does_not_grab_again() {
+ let (_dir, database) = wanted_movie().await;
+ let indexer = prowlarr().await;
+ let (downloader, fake) = transmission().await;
+ let action = action(&indexer, &downloader);
+ action.tick(&database).await.unwrap();
+ assert_eq!(grabs(&database).await.len(), 1);
+
+ action.search_now(&database, 1).await.unwrap();
+
+ assert_eq!(fake.torrents().len(), 1);
+ assert_eq!(grabs(&database).await.len(), 1);
+ }
+
+ /// The gap lane is unchanged: a wanted movie with nothing on disk still
+ /// searches and grabs from the manual trigger.
+ #[tokio::test]
+ async fn a_manual_search_on_a_wanted_movie_still_grabs() {
+ let (_dir, database) = wanted_movie().await;
+ let indexer = prowlarr().await;
+ let (downloader, fake) = transmission().await;
+
+ let outcome = action(&indexer, &downloader)
+ .search_now(&database, 1)
+ .await
+ .unwrap();
+
+ assert!(outcome.is_some());
+ assert_eq!(fake.torrents().len(), 1);
+ assert_eq!(grabs(&database).await.len(), 1);
+ }
+
+ /// §6.3: blocked refuses the manual trigger too, deck refresh included.
+ #[tokio::test]
+ async fn a_manual_search_on_a_blocked_movie_calls_no_indexer() {
+ let (_dir, database) = available_movie().await;
+ sqlx::query("UPDATE movies SET blocked = 1")
+ .execute(database.pool())
+ .await
+ .unwrap();
+ let indexer = prowlarr().await;
+ let (downloader, fake) = transmission().await;
+
+ let outcome = action(&indexer, &downloader)
+ .search_now(&database, 1)
+ .await
+ .unwrap();
+
+ assert!(outcome.is_none());
+ assert_eq!(targeted_searches(&indexer).await, 0);
+ assert!(deck(&database).await.is_empty());
+ assert!(fake.torrents().is_empty());
+ }
+
/// §6.3: `blocked` stops targeted search for a title.
#[tokio::test]
async fn a_blocked_title_is_not_searched() {