Add oversized-AV1 remediation and orphaned-file relink commands

- transcode_job gains is_anime/force_reencode so a job can be
  re-encoded even though it's already AV1 - needed for the rate-control
  bug that left ~476 files larger than their originals; the normal
  backfill query skips already-AV1 files, so this is find_oversized_
  av1_candidates plus a dedicated retranscode-oversized CLI command
- relink-orphaned-files: read-only reconciliation for episode_file rows
  that lost their association (DB-recovery incident) despite the real
  file still sitting where the importer would have put it
- qbit: delete finished torrents from qBittorrent after import instead
  of relocating them with set_location, since nothing is left seeding
  from the old save path after the move
This commit is contained in:
Breadway 2026-08-03 08:44:07 +08:00
parent 109b29ee55
commit d110556a4d
3 changed files with 135 additions and 8 deletions

View file

@ -118,9 +118,15 @@ async fn main() -> Result<()> {
Some("transcode-library") => {
return transcode_library_cmd(&config).await;
}
Some("retranscode-oversized") => {
return retranscode_oversized_cmd(&config).await;
}
Some("verify-library") => {
return verify_library_cmd(&config).await;
}
Some("relink-orphaned-files") => {
return relink_orphaned_files_cmd(&config).await;
}
_ => {}
}
@ -1209,6 +1215,44 @@ async fn remux_backlog_cmd(config: &Config) -> Result<()> {
/// doesn't stall the grab/import/search cycles; this command is for
/// immediately backfilling that same backlog by hand instead of waiting for
/// it to trickle in over several hours.
/// One-time reconciliation for episodes whose `episode_file` association
/// went missing (almost certainly the earlier DB-recovery incident) despite
/// their real file still sitting exactly where breadarr's own importer
/// would have put it — see `importer::find_relinkable_episode_files`'s doc
/// comment for the full story. Purely additive: reports what it found
/// before touching anything, never moves/deletes/overwrites a single file,
/// and flags ambiguous matches for a human to look at rather than guessing.
async fn relink_orphaned_files_cmd(config: &Config) -> Result<()> {
if let Some(parent) = config.db_path().parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open(config.db_path())?;
db::init(&conn)?;
let (candidates, ambiguous) = importer::find_relinkable_episode_files(&conn)?;
println!(
"found {} orphaned episode file(s) to relink, {} ambiguous case(s) left for manual review",
candidates.len(),
ambiguous.len()
);
for c in &candidates {
println!(
" relink: {} S{:02}E{:02} -> {}",
c.series_title,
c.season_number,
c.episode_number,
c.path.display()
);
}
for a in &ambiguous {
println!(" ambiguous, skipped: {a}");
}
let linked = importer::relink_episode_files(&conn, &candidates)?;
println!("done: {linked} episode(s) relinked");
Ok(())
}
async fn probe_library_cmd(config: &Config) -> Result<()> {
if let Some(parent) = config.db_path().parent() {
std::fs::create_dir_all(parent)?;
@ -1270,9 +1314,56 @@ async fn transcode_library_cmd(config: &Config) -> Result<()> {
candidate.episode_file_id,
candidate.video_codec.as_deref(),
candidate.size_bytes,
candidate.is_anime,
false,
)?;
}
drive_transcode_queue_to_completion(conn, config).await
}
/// Re-transcodes files a real rate-control bug left larger than their
/// original (already AV1, so `transcode-library`'s own backfill query
/// excludes them) — see `transcode::find_oversized_av1_candidates`'s doc
/// comment for the full story. Enqueues with `force_reencode: true`, the
/// only thing that lets `encode_and_verify` attempt a real re-encode of a
/// file that's already AV1 rather than treating it as nothing-to-do.
async fn retranscode_oversized_cmd(config: &Config) -> Result<()> {
if !config.transcode.enabled {
bail!("transcode.enabled is false in config — enable it before running this");
}
if let Some(parent) = config.db_path().parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open(config.db_path())?;
db::init(&conn)?;
let candidates = transcode::find_oversized_av1_candidates(&conn, &config.transcode)?;
println!(
"found {} oversized AV1 file(s) to re-transcode, worst offenders first",
candidates.len()
);
for candidate in &candidates {
transcode::enqueue(
&conn,
candidate.episode_file_id,
candidate.video_codec.as_deref(),
candidate.size_bytes,
candidate.is_anime,
true,
)?;
}
drive_transcode_queue_to_completion(conn, config).await
}
/// Shared by `transcode_library_cmd` and `retranscode_oversized_cmd`: drains
/// whatever's now `pending` in `transcode_job` via repeated `run_cycle`
/// calls until nothing is left, printing a running total. The only
/// difference between the two commands is which candidates got enqueued
/// (and with what `force_reencode` value) before this runs — there's
/// exactly one place that actually drives the worker loop to completion.
async fn drive_transcode_queue_to_completion(conn: Connection, config: &Config) -> Result<()> {
let jellyfin = if config.jellyfin.base_url.is_empty() {
None
} else {
@ -1284,6 +1375,7 @@ async fn transcode_library_cmd(config: &Config) -> Result<()> {
let conn = std::sync::Arc::new(tokio::sync::Mutex::new(conn));
let mut total_succeeded = 0usize;
let mut total_skipped = 0usize;
let mut total_failed = 0usize;
let mut total_bytes_saved: i64 = 0;
loop {
@ -1293,12 +1385,13 @@ async fn transcode_library_cmd(config: &Config) -> Result<()> {
break;
}
total_succeeded += stats.succeeded;
total_skipped += stats.skipped;
total_failed += stats.failed;
total_bytes_saved += stats.bytes_saved;
println!("batch: {stats:?}");
}
println!(
"done: {total_succeeded} succeeded, {total_failed} failed, {:.1} GB saved total",
"done: {total_succeeded} succeeded, {total_skipped} skipped (not beneficial), {total_failed} failed, {:.1} GB saved total",
total_bytes_saved as f64 / 1_073_741_824.0
);
Ok(())