Merge main into blitz/subtitles

Feedback pass 2 and the size-band work landed on main while this branch
was finishing. Brings them in ahead of the merge back.

# Conflicts:
#	crates/arr-api/src/movies.rs
#	crates/arr-api/src/state.rs
#	crates/arr-daemon/src/main.rs
#	web/src/main.ts
This commit is contained in:
Miguel Palhas
2026-08-25 17:53:18 +01:00
76 changed files with 7184 additions and 416 deletions
+40 -10
View File
@@ -308,6 +308,7 @@ impl ImportAction {
Candidate::PostDownload(&feature.media),
Some(feature.size),
1,
0,
);
let waiver: Option<Rule> = match evaluation.verdict {
Verdict::Rejected(rule) => {
@@ -477,6 +478,10 @@ impl ImportAction {
// §5.6 second phase of truth, over every file that would be
// imported, before anything is placed: one hard failure condemns
// the whole release (§5.7), not the episodes.
let runtime_minutes = pending
.runtime_minutes
.and_then(|minutes| u32::try_from(minutes).ok())
.unwrap_or(0);
let mut imports = Vec::new();
for assignment in assignments {
if assignment.episode.has_file {
@@ -496,6 +501,7 @@ impl ImportAction {
Candidate::PostDownload(&assignment.file.media),
Some(assignment.file.size),
1,
runtime_minutes,
);
let waiver = match evaluation.verdict {
Verdict::Rejected(rule) => {
@@ -615,7 +621,10 @@ impl ImportAction {
)
.await?;
sqlx::query!(
"UPDATE grabs SET state = 'failed' WHERE id = ?",
"UPDATE grabs
SET state = 'failed',
failed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
pending.grab_id
)
.execute(database.pool())
@@ -753,7 +762,10 @@ impl ImportAction {
)
.await?;
sqlx::query!(
"UPDATE grabs SET state = 'failed' WHERE id = ?",
"UPDATE grabs
SET state = 'failed',
failed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
pending.grab_id
)
.execute(database.pool())
@@ -938,6 +950,10 @@ struct PendingTvImport {
series_title: String,
series_year: Option<i64>,
original_language: Option<String>,
/// §5.5: the series' minutes per episode, scaling the size bands the
/// same way the pre-grab verdict scaled them. `None` applies them
/// unscaled.
runtime_minutes: Option<i64>,
release_name: String,
}
@@ -974,6 +990,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
s.title AS "series_title!: String",
s.year AS "series_year",
s.original_language,
s.runtime_minutes,
r.name AS "release_name!: String"
FROM grabs g
JOIN episodes e ON e.id = g.target_id
@@ -997,6 +1014,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
series_title: row.series_title,
series_year: row.series_year,
original_language: row.original_language,
runtime_minutes: row.runtime_minutes,
release_name: row.release_name,
}));
@@ -1011,6 +1029,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
s.title AS "series_title!: String",
s.year AS "series_year",
s.original_language,
s.runtime_minutes,
r.name AS "release_name!: String"
FROM grabs g
JOIN seasons se ON se.id = g.target_id
@@ -1033,6 +1052,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
series_title: row.series_title,
series_year: row.series_year,
original_language: row.original_language,
runtime_minutes: row.runtime_minutes,
release_name: row.release_name,
}));
@@ -1557,11 +1577,16 @@ mod tests {
assert_eq!(normalised, arr_parse::normalise(RELEASE_NAME));
assert_eq!(reason, "dolby_vision_profile");
let grab_state: String = sqlx::query_scalar("SELECT state FROM grabs")
.fetch_one(h.database.pool())
.await
.unwrap();
let (grab_state, failed_at): (String, Option<String>) =
sqlx::query_as("SELECT state, failed_at FROM grabs")
.fetch_one(h.database.pool())
.await
.unwrap();
assert_eq!(grab_state, "failed");
assert!(
failed_at.is_some(),
"§5.7's window runs from the failure, so the failure is stamped"
);
let movie_state: String = sqlx::query_scalar("SELECT state FROM movies WHERE id = 1")
.fetch_one(h.database.pool())
.await
@@ -2195,11 +2220,16 @@ mod tests {
)],
"only the release is blacklisted, never the season"
);
let grab_state: String = sqlx::query_scalar("SELECT state FROM grabs")
.fetch_one(h.database.pool())
.await
.unwrap();
let (grab_state, failed_at): (String, Option<String>) =
sqlx::query_as("SELECT state, failed_at FROM grabs")
.fetch_one(h.database.pool())
.await
.unwrap();
assert_eq!(grab_state, "failed");
assert!(
failed_at.is_some(),
"§5.7's window runs from the failure, so the failure is stamped"
);
let states: Vec<(String, bool)> =
sqlx::query_as("SELECT state, wanted FROM episodes ORDER BY number")
.fetch_all(h.database.pool())