feat(arr): reconcile subtitle gaps in the daemon

Closes each unsatisfied wanted language per DESIGN.md §8/§15: embedded
tracks satisfy for free (recording them as #189 left to this issue),
then provider search + ranked fetch + alass sync + sidecar write, then
immediate machine translation — extracting a text-format embedded track
when that is the only source — and otherwise the reason lands on the
attempt row for the missing-subtitles queue.

Closes run as detached tasks because alass and translation outlive the
25 s reconcile action budget; every outcome is recorded in domain rows
first, so a crash converges on the next tick. Failures back off on the
same §6.2 curve as movie searches; a rate-limited provider is the
'capped' queue state; unreachable providers and translators fold into
the existing §9.5 broken notification, edge-triggered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-25 02:10:49 +01:00
parent f1a58c0810
commit c2ca25895f
5 changed files with 1738 additions and 0 deletions
+29
View File
@@ -13,6 +13,7 @@ mod reaper;
pub mod reconcile;
mod rss;
mod series_refresh;
mod subtitles;
mod tv_grab;
mod web;
@@ -33,6 +34,7 @@ use reaper::ReaperAction;
use reconcile::{ReconcileLoop, Tick};
use rss::RssAction;
use series_refresh::SeriesRefreshAction;
use subtitles::SubtitleAction;
use tower_http::trace::TraceLayer;
use tv_grab::TvGrabAction;
@@ -330,6 +332,9 @@ fn reconcile_loop(
),
);
// §15: subtitle gaps are reconciled from the same rows the API writes.
reconcile = reconcile.register(Tick::Reconcile, subtitle_action(config, notifier)?);
// §9.5 *needs a decision* and *broken* both go to the operator alone;
// without a topic configured there is nowhere to send them.
if let Some(operator_topic) = &config.ntfy_operator_topic {
@@ -473,6 +478,30 @@ fn jellyfin_client(config: &Config) -> Result<arr_api::jellyfin::JellyfinClient,
)?)
}
/// The §15 reconcile lane: closes subtitle gaps from the attempt rows.
///
/// Translation backends are passed empty: none is constructed at startup yet
/// — the compiled-in backends (#191#193) still need their bootstrap wiring
/// (model names, base URLs) — so the translate step records "not compiled"
/// and backs off rather than failing obscurely.
fn subtitle_action(config: &Config, notifier: &Notifier) -> Result<SubtitleAction, Error> {
let action = SubtitleAction::new(
subtitle_providers(
config.opensubtitles_api_key.clone(),
config.opensubtitles_username.clone(),
config.opensubtitles_password.clone(),
),
Vec::new(),
arr_subs::Syncer::new().with_binary(config.alass_path.clone()),
arr_probe::Extractor::new().with_binary(config.ffmpeg_path.clone()),
jellyfin_client(config)?,
);
Ok(match &config.ntfy_operator_topic {
Some(topic) => action.with_notifier(notifier.clone(), topic.clone()),
None => action,
})
}
/// The subtitle providers this deployment can reach (DESIGN.md §15).
///
/// Credentials are bootstrap config and never reach the database (§10), so
File diff suppressed because it is too large Load Diff