635 lines
22 KiB
Rust
635 lines
22 KiB
Rust
//! §9.5 *needs a decision* → the operator alone: a movie or series entered
|
|
//! the no-PT-source queue, or hard-failed twice on different releases (the
|
|
//! same queues `GET /api/queues/attention` reports, §9.3).
|
|
//!
|
|
//! Edge-triggered per title: it notifies once when the title enters either
|
|
//! queue, and is forgotten once it leaves both, so a future re-entry notifies
|
|
//! again. A series notifies as its series, never per episode — a broken
|
|
//! twenty-episode season is one message, which is the restraint §9.5 exists
|
|
//! for. The notified set is in-memory and transient — a restart re-notifies
|
|
//! whatever is currently queued, same tradeoff `ImportAction`'s probe cache
|
|
//! makes.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::Arc;
|
|
|
|
use arr_db::Db;
|
|
use tokio::sync::Mutex;
|
|
|
|
use crate::notify::Notifier;
|
|
use crate::reconcile::{Action, ActionFuture, Outcome};
|
|
|
|
/// What is queued. Movies and series have independent id sequences, so the
|
|
/// kind travels with the id.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
enum Subject {
|
|
Movie(i64),
|
|
Series(i64),
|
|
}
|
|
|
|
/// One series' standing in the TV queues, rolled up from its episodes.
|
|
#[derive(Debug, Default)]
|
|
struct TvEntry {
|
|
/// Wanted episodes whose every candidate was rejected for language.
|
|
no_pt_source: Vec<i64>,
|
|
/// Episodes two different releases hard-failed post-probe (§5.7).
|
|
hard_failed_episodes: Vec<i64>,
|
|
/// Seasons whose pack grab hard-failed, sending the season back to
|
|
/// per-episode grabbing.
|
|
failed_season_packs: Vec<i64>,
|
|
}
|
|
|
|
impl TvEntry {
|
|
fn reason(&self) -> String {
|
|
let mut parts = Vec::new();
|
|
if !self.no_pt_source.is_empty() {
|
|
parts.push(plural(
|
|
self.no_pt_source.len(),
|
|
"episode found no Portuguese-audio source",
|
|
"episodes found no Portuguese-audio source",
|
|
));
|
|
}
|
|
if !self.hard_failed_episodes.is_empty() {
|
|
parts.push(plural(
|
|
self.hard_failed_episodes.len(),
|
|
"episode hard-failed twice on different releases",
|
|
"episodes hard-failed twice on different releases",
|
|
));
|
|
}
|
|
if !self.failed_season_packs.is_empty() {
|
|
parts.push(plural(
|
|
self.failed_season_packs.len(),
|
|
"season pack hard-failed",
|
|
"season packs hard-failed",
|
|
));
|
|
}
|
|
parts.join("; ")
|
|
}
|
|
}
|
|
|
|
fn plural(count: usize, one: &str, many: &str) -> String {
|
|
if count == 1 {
|
|
format!("1 {one}")
|
|
} else {
|
|
format!("{count} {many}")
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct AttentionAction {
|
|
notifier: Notifier,
|
|
operator_topic: String,
|
|
notified: Arc<Mutex<HashSet<Subject>>>,
|
|
}
|
|
|
|
impl AttentionAction {
|
|
#[must_use]
|
|
pub fn new(notifier: Notifier, operator_topic: String) -> Self {
|
|
Self {
|
|
notifier,
|
|
operator_topic,
|
|
notified: Arc::new(Mutex::new(HashSet::new())),
|
|
}
|
|
}
|
|
|
|
async fn tick(&self, database: &Db) -> Result<Vec<Outcome>, sqlx::Error> {
|
|
let mut queued: HashMap<Subject, (String, String)> = HashMap::new();
|
|
|
|
let no_pt_source = sqlx::query!(
|
|
r#"
|
|
SELECT id AS "id!: i64", title AS "title!: String", year
|
|
FROM movies
|
|
WHERE id IN (
|
|
SELECT m.id
|
|
FROM movies m
|
|
JOIN roots root ON root.id = m.root_id
|
|
WHERE root.audience = 'kids'
|
|
AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing'
|
|
AND m.search_attempts > 0
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM movie_releases mr
|
|
JOIN releases r ON r.id = mr.release_id
|
|
WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived')
|
|
)
|
|
)
|
|
"#
|
|
)
|
|
.fetch_all(database.pool())
|
|
.await?;
|
|
for row in no_pt_source {
|
|
queued.insert(
|
|
Subject::Movie(row.id),
|
|
(
|
|
title_with_year(&row.title, row.year),
|
|
"no Portuguese-audio source found".to_string(),
|
|
),
|
|
);
|
|
}
|
|
|
|
let needs_decision = sqlx::query!(
|
|
r#"
|
|
SELECT id AS "id!: i64", title AS "title!: String", year
|
|
FROM movies
|
|
WHERE movies.wanted = 1 AND movies.state != 'available'
|
|
AND (SELECT count(DISTINCT g.release_id)
|
|
FROM grabs g
|
|
WHERE g.target_kind = 'movie' AND g.target_id = movies.id
|
|
AND g.state = 'failed') >= 2
|
|
"#
|
|
)
|
|
.fetch_all(database.pool())
|
|
.await?;
|
|
for row in needs_decision {
|
|
queued.entry(Subject::Movie(row.id)).or_insert_with(|| {
|
|
(
|
|
title_with_year(&row.title, row.year),
|
|
"hard-failed twice on different releases".to_string(),
|
|
)
|
|
});
|
|
}
|
|
|
|
for (series_id, title, year, entry) in queue_tv(database).await? {
|
|
queued.insert(
|
|
Subject::Series(series_id),
|
|
(title_with_year(&title, year), entry.reason()),
|
|
);
|
|
}
|
|
|
|
let mut notified = self.notified.lock().await;
|
|
let mut outcomes = Vec::new();
|
|
for (subject, (title, reason)) in &queued {
|
|
if !notified.insert(subject.clone()) {
|
|
continue;
|
|
}
|
|
match self
|
|
.notifier
|
|
.send(&self.operator_topic, title, reason)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
let (kind, id) = match subject {
|
|
Subject::Movie(id) => ("movie", *id),
|
|
Subject::Series(id) => ("series", *id),
|
|
};
|
|
outcomes.push(Outcome::new(
|
|
format!("{kind} {id} needs a decision"),
|
|
format!("notified operator: {reason}"),
|
|
));
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(%error, ?subject, "needs-decision notification failed");
|
|
}
|
|
}
|
|
}
|
|
notified.retain(|subject| queued.contains_key(subject));
|
|
|
|
Ok(outcomes)
|
|
}
|
|
}
|
|
|
|
fn title_with_year(title: &str, year: Option<i64>) -> String {
|
|
match year {
|
|
Some(year) => format!("{title} ({year})"),
|
|
None => title.to_string(),
|
|
}
|
|
}
|
|
|
|
/// The series' roll-up entry, created empty on first sight.
|
|
fn tv_entry(
|
|
tv: &mut HashMap<i64, (String, Option<i64>, TvEntry)>,
|
|
series_id: i64,
|
|
title: String,
|
|
year: Option<i64>,
|
|
) -> &mut TvEntry {
|
|
&mut tv
|
|
.entry(series_id)
|
|
.or_insert_with(|| (title, year, TvEntry::default()))
|
|
.2
|
|
}
|
|
|
|
/// TV roll-up (§9.5): every queued series with what put it there — wanted
|
|
/// episodes whose every candidate was rejected for language, episodes two
|
|
/// different releases hard-failed post-probe, and seasons whose pack grab
|
|
/// hard-failed. One entry per series, so the notification can be one per
|
|
/// series however long the broken season is.
|
|
async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntry)>, sqlx::Error> {
|
|
let mut tv = HashMap::new();
|
|
|
|
let tv_no_pt_source = sqlx::query!(
|
|
r#"
|
|
SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year,
|
|
e.id AS "episode_id!: i64"
|
|
FROM episodes e
|
|
JOIN seasons se ON se.id = e.season_id
|
|
JOIN series s ON s.id = se.series_id
|
|
JOIN roots root ON root.id = s.root_id
|
|
WHERE root.audience = 'kids'
|
|
AND s.blocked = 0
|
|
AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM episode_releases er
|
|
JOIN releases r ON r.id = er.release_id
|
|
WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')
|
|
)
|
|
"#
|
|
)
|
|
.fetch_all(database.pool())
|
|
.await?;
|
|
for row in tv_no_pt_source {
|
|
tv_entry(&mut tv, row.series_id, row.title, row.year)
|
|
.no_pt_source
|
|
.push(row.episode_id);
|
|
}
|
|
|
|
let episode_hard_fails = sqlx::query!(
|
|
r#"
|
|
SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year,
|
|
g.target_id AS "episode_id!: i64"
|
|
FROM grabs g
|
|
JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id
|
|
JOIN seasons se ON se.id = e.season_id
|
|
JOIN series s ON s.id = se.series_id
|
|
WHERE g.state = 'failed'
|
|
AND e.wanted = 1 AND e.state != 'available'
|
|
GROUP BY s.id, s.title, s.year, e.id
|
|
HAVING count(DISTINCT g.release_id) >= 2
|
|
"#
|
|
)
|
|
.fetch_all(database.pool())
|
|
.await?;
|
|
for row in episode_hard_fails {
|
|
tv_entry(&mut tv, row.series_id, row.title, row.year)
|
|
.hard_failed_episodes
|
|
.push(row.episode_id);
|
|
}
|
|
|
|
let season_pack_fails = sqlx::query!(
|
|
r#"
|
|
SELECT s.id AS "series_id!: i64", s.title AS "title!: String", s.year,
|
|
g.target_id AS "season_id!: i64"
|
|
FROM grabs g
|
|
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
|
|
JOIN series s ON s.id = se.series_id
|
|
WHERE g.state = 'failed'
|
|
GROUP BY s.id, s.title, s.year, se.id
|
|
"#
|
|
)
|
|
.fetch_all(database.pool())
|
|
.await?;
|
|
for row in season_pack_fails {
|
|
tv_entry(&mut tv, row.series_id, row.title, row.year)
|
|
.failed_season_packs
|
|
.push(row.season_id);
|
|
}
|
|
|
|
Ok(tv
|
|
.into_iter()
|
|
.map(|(id, (t, y, e))| (id, t, y, e))
|
|
.collect())
|
|
}
|
|
|
|
impl Action for AttentionAction {
|
|
fn name(&self) -> &'static str {
|
|
"attention"
|
|
}
|
|
|
|
fn run<'a>(&'a self, database: &'a Db) -> ActionFuture<'a> {
|
|
Box::pin(async move { self.tick(database).await.map_err(Into::into) })
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used)]
|
|
mod tests {
|
|
use wiremock::matchers::method;
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
use super::*;
|
|
|
|
async fn seeded_database() -> (tempfile::TempDir, Db) {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let database = Db::connect(dir.path().join("arr.db")).await.unwrap();
|
|
database.migrate().await.unwrap();
|
|
(dir, database)
|
|
}
|
|
|
|
async fn insert_no_pt_source_movie(database: &Db) -> i64 {
|
|
let root_id: i64 =
|
|
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'movie' AND audience = 'kids'")
|
|
.fetch_one(database.pool())
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"INSERT INTO movies
|
|
(tmdb_id, title, year, root_id, wanted, state, search_attempts)
|
|
VALUES (1, 'Encanto', 2021, ?, 1, 'missing', 1)
|
|
RETURNING id",
|
|
)
|
|
.bind(root_id)
|
|
.fetch_one(database.pool())
|
|
.await
|
|
.unwrap();
|
|
sqlx::query_scalar("SELECT id FROM movies WHERE tmdb_id = 1")
|
|
.fetch_one(database.pool())
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
/// A series on the kids TV root with one season, and `count` wanted,
|
|
/// missing episodes that have been searched. No stored releases: every
|
|
/// candidate was rejected for language (§5.2's no-PT-source case).
|
|
async fn insert_no_pt_source_series(database: &Db, tmdb_id: i64, episode_count: usize) -> i64 {
|
|
let root_id: i64 =
|
|
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
|
|
.fetch_one(database.pool())
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"INSERT INTO series (tmdb_id, title, year, root_id) VALUES (?, 'Bluey', 2018, ?)",
|
|
)
|
|
.bind(tmdb_id)
|
|
.bind(root_id)
|
|
.execute(database.pool())
|
|
.await
|
|
.unwrap();
|
|
let series_id: i64 = sqlx::query_scalar("SELECT id FROM series WHERE tmdb_id = ?")
|
|
.bind(tmdb_id)
|
|
.fetch_one(database.pool())
|
|
.await
|
|
.unwrap();
|
|
sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1)")
|
|
.bind(series_id)
|
|
.execute(database.pool())
|
|
.await
|
|
.unwrap();
|
|
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE series_id = ?")
|
|
.bind(series_id)
|
|
.fetch_one(database.pool())
|
|
.await
|
|
.unwrap();
|
|
for number in 0..episode_count {
|
|
sqlx::query(
|
|
"INSERT INTO episodes (season_id, number, title, wanted, state, search_attempts)
|
|
VALUES (?, ?, ?, 1, 'missing', 1)",
|
|
)
|
|
.bind(season_id)
|
|
.bind(i64::try_from(number).unwrap_or(0) + 1)
|
|
.bind(format!("Episode {number}"))
|
|
.execute(database.pool())
|
|
.await
|
|
.unwrap();
|
|
}
|
|
series_id
|
|
}
|
|
|
|
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
|
|
/// standing in for what the import tick leaves behind post-probe.
|
|
async fn insert_failed_grab(
|
|
database: &Db,
|
|
target_kind: &str,
|
|
target_id: i64,
|
|
release_guid: &str,
|
|
) {
|
|
let release_id: i64 = sqlx::query_scalar(
|
|
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
|
VALUES (7, ?, 'release', 10737418240, 'https://tracker/x.torrent', '{}', 'eligible')
|
|
RETURNING id",
|
|
)
|
|
.bind(release_guid)
|
|
.fetch_one(database.pool())
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
|
|
VALUES (?, ?, ?, ?, 'failed')",
|
|
)
|
|
.bind(release_id)
|
|
.bind(target_kind)
|
|
.bind(target_id)
|
|
.bind(format!("hash-{release_guid}"))
|
|
.execute(database.pool())
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
async fn action(server: &MockServer) -> AttentionAction {
|
|
Mock::given(method("POST"))
|
|
.respond_with(ResponseTemplate::new(200))
|
|
.mount(server)
|
|
.await;
|
|
AttentionAction::new(
|
|
Notifier::new(server.uri()).unwrap(),
|
|
"operator-topic".to_string(),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_movie_entering_the_queue_notifies_the_operator_once() {
|
|
let (_dir, database) = seeded_database().await;
|
|
insert_no_pt_source_movie(&database).await;
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.respond_with(ResponseTemplate::new(200))
|
|
.mount(&server)
|
|
.await;
|
|
let action = AttentionAction::new(
|
|
Notifier::new(server.uri()).unwrap(),
|
|
"operator-topic".to_string(),
|
|
);
|
|
|
|
let first = action.tick(&database).await.unwrap();
|
|
let second = action.tick(&database).await.unwrap();
|
|
|
|
assert_eq!(first.len(), 1, "notifies on the tick it enters the queue");
|
|
assert_eq!(second.len(), 0, "does not repeat while still queued");
|
|
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn leaving_and_re_entering_the_queue_notifies_again() {
|
|
let (_dir, database) = seeded_database().await;
|
|
let movie_id = insert_no_pt_source_movie(&database).await;
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.respond_with(ResponseTemplate::new(200))
|
|
.mount(&server)
|
|
.await;
|
|
let action = AttentionAction::new(
|
|
Notifier::new(server.uri()).unwrap(),
|
|
"operator-topic".to_string(),
|
|
);
|
|
action.tick(&database).await.unwrap();
|
|
|
|
sqlx::query("UPDATE movies SET blocked = 1 WHERE id = ?")
|
|
.bind(movie_id)
|
|
.execute(database.pool())
|
|
.await
|
|
.unwrap();
|
|
action.tick(&database).await.unwrap();
|
|
|
|
sqlx::query("UPDATE movies SET blocked = 0 WHERE id = ?")
|
|
.bind(movie_id)
|
|
.execute(database.pool())
|
|
.await
|
|
.unwrap();
|
|
let third = action.tick(&database).await.unwrap();
|
|
|
|
assert_eq!(third.len(), 1, "re-enters and notifies again");
|
|
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_series_entering_the_queue_notifies_once_per_series() {
|
|
let (_dir, database) = seeded_database().await;
|
|
// Twenty broken episodes are one notification, not twenty (§9.5).
|
|
insert_no_pt_source_series(&database, 1, 20).await;
|
|
|
|
let server = MockServer::start().await;
|
|
let action = action(&server).await;
|
|
|
|
let first = action.tick(&database).await.unwrap();
|
|
let second = action.tick(&database).await.unwrap();
|
|
|
|
assert_eq!(first.len(), 1, "one notification for the whole series");
|
|
assert_eq!(second.len(), 0, "does not repeat while still queued");
|
|
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_series_leaving_and_re_entering_the_queue_notifies_again() {
|
|
let (_dir, database) = seeded_database().await;
|
|
let series_id = insert_no_pt_source_series(&database, 1, 1).await;
|
|
|
|
let server = MockServer::start().await;
|
|
let action = action(&server).await;
|
|
action.tick(&database).await.unwrap();
|
|
|
|
sqlx::query("UPDATE series SET blocked = 1 WHERE id = ?")
|
|
.bind(series_id)
|
|
.execute(database.pool())
|
|
.await
|
|
.unwrap();
|
|
action.tick(&database).await.unwrap();
|
|
|
|
sqlx::query("UPDATE series SET blocked = 0 WHERE id = ?")
|
|
.bind(series_id)
|
|
.execute(database.pool())
|
|
.await
|
|
.unwrap();
|
|
let third = action.tick(&database).await.unwrap();
|
|
|
|
assert_eq!(third.len(), 1, "re-enters and notifies again");
|
|
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_episode_hard_failed_twice_notifies_its_series() {
|
|
let (_dir, database) = seeded_database().await;
|
|
insert_no_pt_source_series(&database, 1, 0).await;
|
|
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
|
|
.fetch_one(database.pool())
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"INSERT INTO episodes (season_id, number, title, wanted, state)
|
|
VALUES (?, 1, 'Episode 0', 1, 'missing')",
|
|
)
|
|
.bind(season_id)
|
|
.execute(database.pool())
|
|
.await
|
|
.unwrap();
|
|
let episode_id: i64 =
|
|
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
|
|
.bind(season_id)
|
|
.fetch_one(database.pool())
|
|
.await
|
|
.unwrap();
|
|
insert_failed_grab(&database, "episode", episode_id, "first").await;
|
|
insert_failed_grab(&database, "episode", episode_id, "second").await;
|
|
// The pack's failure sent this season back to per-episode grabbing;
|
|
// it queues the same series, so it must not double the message.
|
|
insert_failed_grab(&database, "season", season_id, "pack").await;
|
|
|
|
let server = MockServer::start().await;
|
|
let action = action(&server).await;
|
|
|
|
let outcomes = action.tick(&database).await.unwrap();
|
|
|
|
assert_eq!(
|
|
outcomes.len(),
|
|
1,
|
|
"both hard-fail conditions roll up to one series notification"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_movie_imported_after_two_hard_fails_leaves_the_queue() {
|
|
let (_dir, database) = seeded_database().await;
|
|
let movie_id = insert_no_pt_source_movie(&database).await;
|
|
insert_failed_grab(&database, "movie", movie_id, "first").await;
|
|
insert_failed_grab(&database, "movie", movie_id, "second").await;
|
|
|
|
let server = MockServer::start().await;
|
|
let action = action(&server).await;
|
|
|
|
let first = action.tick(&database).await.unwrap();
|
|
assert_eq!(first.len(), 1, "queued while unsatisfied");
|
|
|
|
// Imported from a third release: satisfied, no decision needed.
|
|
sqlx::query("UPDATE movies SET state = 'available' WHERE id = ?")
|
|
.bind(movie_id)
|
|
.execute(database.pool())
|
|
.await
|
|
.unwrap();
|
|
let second = action.tick(&database).await.unwrap();
|
|
|
|
assert_eq!(second.len(), 0, "leaves the queue once imported");
|
|
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_episode_imported_after_two_hard_fails_leaves_the_queue() {
|
|
let (_dir, database) = seeded_database().await;
|
|
insert_no_pt_source_series(&database, 1, 0).await;
|
|
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
|
|
.fetch_one(database.pool())
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"INSERT INTO episodes (season_id, number, title, wanted, state)
|
|
VALUES (?, 1, 'Episode 0', 1, 'missing')",
|
|
)
|
|
.bind(season_id)
|
|
.execute(database.pool())
|
|
.await
|
|
.unwrap();
|
|
let episode_id: i64 =
|
|
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
|
|
.bind(season_id)
|
|
.fetch_one(database.pool())
|
|
.await
|
|
.unwrap();
|
|
insert_failed_grab(&database, "episode", episode_id, "first").await;
|
|
insert_failed_grab(&database, "episode", episode_id, "second").await;
|
|
|
|
let server = MockServer::start().await;
|
|
let action = action(&server).await;
|
|
|
|
let first = action.tick(&database).await.unwrap();
|
|
assert_eq!(first.len(), 1, "queued while unsatisfied");
|
|
|
|
// Imported from a third release: satisfied, no decision needed.
|
|
sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?")
|
|
.bind(episode_id)
|
|
.execute(database.pool())
|
|
.await
|
|
.unwrap();
|
|
let second = action.tick(&database).await.unwrap();
|
|
|
|
assert_eq!(second.len(), 0, "leaves the queue once imported");
|
|
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
|
}
|
|
}
|