Compare commits
20 commits
bb4576915e
...
15b5b12a06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15b5b12a06 | ||
|
|
15cb0b6d81 | ||
|
|
d110556a4d | ||
|
|
109b29ee55 | ||
|
|
0f609aa4cc | ||
|
|
66d323b7f7 | ||
|
|
576aad3bfe | ||
|
|
d533301880 | ||
|
|
691fb39cbb | ||
|
|
e179b9bf90 | ||
|
|
2f0d8300ce | ||
|
|
d1909f3083 | ||
|
|
6e7be67f0b | ||
|
|
99604a7e55 | ||
|
|
ba05a3cb0c | ||
|
|
17d82b84c2 | ||
|
|
dcf9ee241c | ||
|
|
60d01657eb | ||
|
|
54818f5f05 | ||
|
|
9d8a59e3a8 |
107 changed files with 105621 additions and 563 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
graphify-out/graph.json merge=graphify
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -3,3 +3,6 @@ config.toml
|
|||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Local hygiene notes (not for commit)
|
||||
CLAUDE.md
|
||||
|
|
|
|||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -178,6 +178,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "bread-onnx"
|
||||
version = "0.3.0"
|
||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bread-utils",
|
||||
|
|
@ -192,6 +193,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "bread-utils"
|
||||
version = "0.3.0"
|
||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -9,5 +9,4 @@ anyhow.workspace = true
|
|||
toml.workspace = true
|
||||
reqwest.workspace = true
|
||||
chrono.workspace = true
|
||||
# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern
|
||||
bread-utils = { path = "../../bread-ecosystem/bread-utils" }
|
||||
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", }
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ pub struct Config {
|
|||
pub sources: SourcesConfig,
|
||||
#[serde(default)]
|
||||
pub notifications: NotificationsConfig,
|
||||
#[serde(default)]
|
||||
pub transcode: TranscodeConfig,
|
||||
}
|
||||
|
||||
/// Where the TUI's "add show" flow places new series by default. Sonarr/
|
||||
|
|
@ -55,6 +57,12 @@ pub struct SourcesConfig {
|
|||
pub nyaa_rss_url: String,
|
||||
#[serde(default = "default_grab_poll_interval_secs")]
|
||||
pub grab_poll_interval_secs: u64,
|
||||
/// Human kill switch for the passive RSS-feed grab loop (nyaa, anime
|
||||
/// only) — same reasoning as `search_enabled`/`upgrade_enabled`, kept
|
||||
/// as its own flag since this loop watches a different source and can
|
||||
/// need to be paused independently of the search-driven ones.
|
||||
#[serde(default = "default_grab_enabled")]
|
||||
pub grab_enabled: bool,
|
||||
#[serde(default = "default_import_poll_interval_secs")]
|
||||
pub import_poll_interval_secs: u64,
|
||||
#[serde(default = "default_search_poll_interval_secs")]
|
||||
|
|
@ -108,6 +116,7 @@ impl Default for SourcesConfig {
|
|||
torrent_1337x_mirrors: default_1337x_mirrors(),
|
||||
nyaa_rss_url: default_nyaa_rss_url(),
|
||||
grab_poll_interval_secs: default_grab_poll_interval_secs(),
|
||||
grab_enabled: default_grab_enabled(),
|
||||
import_poll_interval_secs: default_import_poll_interval_secs(),
|
||||
search_poll_interval_secs: default_search_poll_interval_secs(),
|
||||
search_budget_per_cycle: default_search_budget_per_cycle(),
|
||||
|
|
@ -161,6 +170,10 @@ fn default_grab_poll_interval_secs() -> u64 {
|
|||
300
|
||||
}
|
||||
|
||||
fn default_grab_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_import_poll_interval_secs() -> u64 {
|
||||
60
|
||||
}
|
||||
|
|
@ -266,6 +279,283 @@ pub struct JellyfinConfig {
|
|||
pub api_key: String,
|
||||
}
|
||||
|
||||
/// GPU-accelerated AV1 transcode: re-encodes freshly-grabbed and existing
|
||||
/// library files down to a space-reasonable size instead of keeping
|
||||
/// whatever the source release happened to be (REMUX, huge season packs,
|
||||
/// etc). `enabled` defaults false — this needs a manual calibration pass
|
||||
/// against real content on the target GPU before it's safe to run
|
||||
/// unattended against a whole library.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TranscodeConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Tight on purpose — the actual pace is bottlenecked by encode time
|
||||
/// (minutes per file), not this interval; a short poll just means a
|
||||
/// freshly-completed job's slot gets refilled promptly instead of
|
||||
/// sitting idle for the rest of a longer interval.
|
||||
#[serde(default = "default_transcode_poll_interval_secs")]
|
||||
pub poll_interval_secs: u64,
|
||||
#[serde(default = "default_vaapi_device")]
|
||||
pub vaapi_device: String,
|
||||
/// Applies to both pipelines identically when Jellyfin reports an
|
||||
/// active transcoding session — deliberately not split per-pipeline;
|
||||
/// when someone's actually watching something, both the GPU (which
|
||||
/// they're using) and CPU (contending for the same box) should back off.
|
||||
#[serde(default = "default_parallelism_min")]
|
||||
pub parallelism_min: usize,
|
||||
/// The total concurrent **live-action** (`av1_vaapi`, GPU-bound) stream
|
||||
/// budget — not just "the batch job's cap", but the real ceiling the
|
||||
/// GPU can sustain at all, batch work and live Jellyfin viewers
|
||||
/// combined. `transcode::live_action_parallelism_for` subtracts however
|
||||
/// many Jellyfin transcode sessions are actually active from this
|
||||
/// number to get the batch job's actual parallelism each cycle, so
|
||||
/// real viewers get exactly the headroom they need rather than the
|
||||
/// batch job dropping to a flat minimum regardless of how many people
|
||||
/// are watching. Deliberately separate from `parallelism_max_anime` —
|
||||
/// the two pipelines contend for genuinely different hardware (GPU
|
||||
/// encode engine vs CPU threads), so raising one shouldn't raise the
|
||||
/// other. Empirically calibrated on Hestia's Arc A380: per-stream
|
||||
/// throughput stays above 1.5x realtime through 7 concurrent streams,
|
||||
/// crosses below it at 8 (aggregate throughput itself plateaus around
|
||||
/// ~12x realtime from 5-6 streams on, i.e. the GPU's actual saturation
|
||||
/// point) — see [[breadarr-av1-transcode]] for the full scaling-test
|
||||
/// numbers.
|
||||
#[serde(default = "default_parallelism_max")]
|
||||
pub parallelism_max: usize,
|
||||
/// Ramp-up ceiling for concurrent **anime** (`libsvtav1`, CPU-bound)
|
||||
/// encode streams — capped separately from `parallelism_max` (see its
|
||||
/// doc comment) precisely because a single shared cap would let "raise
|
||||
/// GPU parallelism" accidentally also raise anime concurrency, and
|
||||
/// anime jobs are CPU-thread-hungry (`anime_svtav1_max_threads` each)
|
||||
/// in a way live-action jobs aren't. Kept at the original
|
||||
/// conservative shared-cap default (2) since concurrent-anime-job
|
||||
/// memory/CPU behavior at higher counts hasn't been load-tested the
|
||||
/// way the live-action GPU path has.
|
||||
#[serde(default = "default_parallelism_max_anime")]
|
||||
pub parallelism_max_anime: usize,
|
||||
/// The "looks fine, no complaints" calibration reference: a real
|
||||
/// bitrate (Mbps, in kbps here) from content already in the library at
|
||||
/// `reference_height` that the user is happy with. New AV1 encodes are
|
||||
/// targeted relative to this, scaled by resolution and AV1's encoding
|
||||
/// efficiency, rather than picking a bitrate out of thin air.
|
||||
#[serde(default = "default_reference_bitrate_kbps")]
|
||||
pub reference_bitrate_kbps: u32,
|
||||
#[serde(default = "default_reference_height")]
|
||||
pub reference_height: u32,
|
||||
/// AV1 reaches equivalent perceived quality to HEVC at a meaningfully
|
||||
/// lower bitrate — this factor is applied on top of the resolution
|
||||
/// scaling so the AV1 target isn't just a like-for-like copy of the
|
||||
/// HEVC/H264 reference bitrate. Conservative (not maximally aggressive)
|
||||
/// on purpose: erring toward "still clearly smaller" over "as small as
|
||||
/// AV1 could theoretically go" leaves margin against visible artifacts.
|
||||
#[serde(default = "default_av1_efficiency_factor")]
|
||||
pub av1_efficiency_factor: f32,
|
||||
/// HDR10/Dolby Vision metadata preservation through the GPU encoder
|
||||
/// hasn't been verified yet — excluded from both the backfill and the
|
||||
/// post-grab path until that's specifically checked on a few samples.
|
||||
#[serde(default = "default_exclude_hdr")]
|
||||
pub exclude_hdr: bool,
|
||||
/// Excludes the 2160p tier from the first pass for the same reason as
|
||||
/// `exclude_hdr` (most current 4K content in this library is HDR
|
||||
/// anyway) — revisit once HDR handling is confirmed safe.
|
||||
#[serde(default = "default_exclude_min_height")]
|
||||
pub exclude_min_height: u32,
|
||||
/// `global_quality` for the live-action `av1_vaapi` `QVBR` encode — the
|
||||
/// actual quality driver now that rate control is quality-based rather
|
||||
/// than a flat bitrate target (see `run_ffmpeg_encode_live_action`).
|
||||
/// `reference_bitrate_kbps`/`av1_efficiency_factor` still compute a
|
||||
/// `-b:v`/`-maxrate`/`-bufsize` ceiling alongside this, so a source that's
|
||||
/// already unusually efficient doesn't get inflated up toward the
|
||||
/// ceiling — QVBR only spends up to it on content that actually needs it.
|
||||
#[serde(default = "default_quality_live_action")]
|
||||
pub quality_live_action: u32,
|
||||
/// Root folder path prefixes (exact string prefix match against
|
||||
/// `episode_file.path`) routed to the anime encode pipeline
|
||||
/// (`run_ffmpeg_encode_anime`) instead of the live-action one, regardless
|
||||
/// of `anime_mapping`/`anime_tmdb_movie` metadata coverage — path is a
|
||||
/// more reliable signal than TVDB/TMDB anime-list membership, which has
|
||||
/// real gaps (e.g. Avatar: The Last Airbender and some Dragon Ball movies
|
||||
/// were missing from those tables and slipped through as "not anime").
|
||||
/// Empty by default (a no-op) — set per-deployment to match how the
|
||||
/// library is actually organized.
|
||||
#[serde(default)]
|
||||
pub anime_root_folders: Vec<String>,
|
||||
/// CRF for the anime pipeline's software `libsvtav1` encode (0-63, lower
|
||||
/// = higher quality/larger). No hardware AV1 10-bit encode entrypoint
|
||||
/// exists on Hestia's Arc A380 (`vainfo` only lists `AV1Profile0`,
|
||||
/// 8-bit) — anime needs true 10-bit output to avoid banding in the flat
|
||||
/// gradients the art style is full of, so this pipeline trades GPU
|
||||
/// offload for CPU-based `libsvtav1` specifically to get it.
|
||||
#[serde(default = "default_quality_anime")]
|
||||
pub quality_anime: u32,
|
||||
/// `libsvtav1` preset (0-13, lower = slower/better compression AND more
|
||||
/// memory-hungry — SVT-AV1's lookahead/reference buffering scales with
|
||||
/// preset, not just thread count). Raised from an initial guess of 6 to
|
||||
/// 10 after a real validation run hit a genuine kernel OOM: preset 6 on
|
||||
/// a single 1080p anime episode grew to 9.3GB resident memory on
|
||||
/// Hestia's 6-core/12-thread box. This runs as unattended background
|
||||
/// work, so trading some compression efficiency for a much smaller,
|
||||
/// safer memory footprint is the right call — see
|
||||
/// `anime_svtav1_max_threads` for the other half of that fix.
|
||||
#[serde(default = "default_anime_svtav1_preset")]
|
||||
pub anime_svtav1_preset: u32,
|
||||
/// Passed to `libsvtav1` as `-svtav1-params lp=N` — caps how many
|
||||
/// worker threads it uses, independent of preset. More parallel workers
|
||||
/// means more concurrently-buffered frames, so this is the other lever
|
||||
/// (alongside `anime_svtav1_preset`) for bounding the encoder's peak
|
||||
/// memory to something predictable regardless of how many cores the
|
||||
/// host actually has. Default is conservative (well under a typical
|
||||
/// modern host's core count) after the same OOM incident that raised
|
||||
/// the preset default.
|
||||
#[serde(default = "default_anime_svtav1_max_threads")]
|
||||
pub anime_svtav1_max_threads: u32,
|
||||
/// Hard floor on what counts as "worth keeping": a transcode whose
|
||||
/// output isn't at least this fraction smaller than the original is
|
||||
/// discarded (job marked `skipped`, original left untouched) rather than
|
||||
/// swapped in. Exists because quality-driven rate control can still
|
||||
/// occasionally produce an output that's the same size as or larger than
|
||||
/// an already-efficient source — this is the invariant that makes that
|
||||
/// safe regardless of how good the rate-control tuning is, after a real
|
||||
/// incident where flat-bitrate VBR targeting silently produced files
|
||||
/// *larger* than the original on the majority of a backfill.
|
||||
#[serde(default = "default_min_size_reduction_pct")]
|
||||
pub min_size_reduction_pct: f64,
|
||||
/// Skip attempting a transcode at all (no GPU/CPU time spent) when the
|
||||
/// source's current bitrate is already at or below this fraction of the
|
||||
/// resolution-scaled ceiling (`target_bitrate_kbps`) — a strong signal
|
||||
/// there's little room left to save, so it's not worth the encode time
|
||||
/// to find out (the `min_size_reduction_pct` check above would reject
|
||||
/// most of these anyway, this just avoids paying for that finding).
|
||||
#[serde(default = "default_skip_below_ceiling_ratio")]
|
||||
pub skip_below_ceiling_ratio: f64,
|
||||
/// Size (seconds) of each of the three start/middle/end windows
|
||||
/// `ffprobe::verify_decodable_sampled` actually decodes, instead of the
|
||||
/// whole file — a full decode verification was measured as the actual
|
||||
/// CPU bottleneck of a transcode cycle (400%+ CPU per job, dwarfing the
|
||||
/// GPU encode time), not the encode itself. Bounds verification cost to
|
||||
/// a small constant regardless of source length.
|
||||
#[serde(default = "default_verify_sample_secs")]
|
||||
pub verify_sample_secs: f64,
|
||||
}
|
||||
|
||||
impl Default for TranscodeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
poll_interval_secs: default_transcode_poll_interval_secs(),
|
||||
vaapi_device: default_vaapi_device(),
|
||||
parallelism_min: default_parallelism_min(),
|
||||
parallelism_max: default_parallelism_max(),
|
||||
parallelism_max_anime: default_parallelism_max_anime(),
|
||||
reference_bitrate_kbps: default_reference_bitrate_kbps(),
|
||||
reference_height: default_reference_height(),
|
||||
av1_efficiency_factor: default_av1_efficiency_factor(),
|
||||
exclude_hdr: default_exclude_hdr(),
|
||||
exclude_min_height: default_exclude_min_height(),
|
||||
quality_live_action: default_quality_live_action(),
|
||||
anime_root_folders: Vec::new(),
|
||||
quality_anime: default_quality_anime(),
|
||||
anime_svtav1_preset: default_anime_svtav1_preset(),
|
||||
anime_svtav1_max_threads: default_anime_svtav1_max_threads(),
|
||||
min_size_reduction_pct: default_min_size_reduction_pct(),
|
||||
skip_below_ceiling_ratio: default_skip_below_ceiling_ratio(),
|
||||
verify_sample_secs: default_verify_sample_secs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_transcode_poll_interval_secs() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_vaapi_device() -> String {
|
||||
"/dev/dri/renderD128".to_string()
|
||||
}
|
||||
|
||||
fn default_parallelism_min() -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_parallelism_max() -> usize {
|
||||
// The measured total GPU budget (not "batch cap plus a static
|
||||
// reservation") — `live_action_parallelism_for` dynamically subtracts
|
||||
// real Jellyfin transcode sessions from this each cycle. Raised from
|
||||
// an initial conservative guess of 2 after an actual concurrent-stream
|
||||
// scaling test on Hestia's Arc A380 (see `parallelism_max`'s doc
|
||||
// comment): per-stream throughput stays above 1.5x realtime through 7
|
||||
// total concurrent streams, crossing below at 8.
|
||||
7
|
||||
}
|
||||
|
||||
fn default_parallelism_max_anime() -> usize {
|
||||
// Kept at the original conservative shared-cap value — unlike
|
||||
// `parallelism_max`, this hasn't been load-tested at higher counts.
|
||||
// A real incident already showed a *single* uncapped anime job could
|
||||
// hit 9.3GB resident memory; multiple concurrent anime jobs (each its
|
||||
// own `anime_svtav1_max_threads`-sized thread pool) multiply both CPU
|
||||
// thread contention and memory pressure in a way the GPU path doesn't
|
||||
// have to worry about. Raise deliberately, per-deployment, only after
|
||||
// watching `free -h` and CPU load under real concurrent-anime load.
|
||||
2
|
||||
}
|
||||
|
||||
fn default_reference_bitrate_kbps() -> u32 {
|
||||
5320
|
||||
}
|
||||
|
||||
fn default_reference_height() -> u32 {
|
||||
1080
|
||||
}
|
||||
|
||||
fn default_av1_efficiency_factor() -> f32 {
|
||||
0.7
|
||||
}
|
||||
|
||||
fn default_exclude_hdr() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_exclude_min_height() -> u32 {
|
||||
2000
|
||||
}
|
||||
|
||||
// Starting point for `av1_vaapi`'s `-global_quality` under `QVBR`, needs the
|
||||
// same real-hardware calibration pass as the bitrate reference did — this is
|
||||
// a reasonable guess (roughly x264/x265 "visually near-lossless" territory
|
||||
// on the encoder's internal QP-like scale), not a measured value.
|
||||
fn default_quality_live_action() -> u32 {
|
||||
26
|
||||
}
|
||||
|
||||
// SVT-AV1 CRF starting point for the anime pipeline — slightly lower
|
||||
// (higher quality) than the live-action guess above since flat-color/
|
||||
// gradient-heavy anime content shows banding more readily than live-action
|
||||
// grain/texture does at the same nominal quality level. Also unvalidated
|
||||
// against real hardware/content yet.
|
||||
fn default_quality_anime() -> u32 {
|
||||
24
|
||||
}
|
||||
|
||||
fn default_anime_svtav1_preset() -> u32 {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_anime_svtav1_max_threads() -> u32 {
|
||||
4
|
||||
}
|
||||
|
||||
fn default_min_size_reduction_pct() -> f64 {
|
||||
0.10
|
||||
}
|
||||
|
||||
fn default_skip_below_ceiling_ratio() -> f64 {
|
||||
0.5
|
||||
}
|
||||
|
||||
fn default_verify_sample_secs() -> f64 {
|
||||
20.0
|
||||
}
|
||||
|
||||
/// TVDB v4 API key, exchanged for a short-lived JWT at request time.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct TvdbConfig {
|
||||
|
|
@ -289,9 +579,41 @@ impl Config {
|
|||
|
||||
let raw = fs::read_to_string(&path)?;
|
||||
let cfg: Config = toml::from_str(&raw)?;
|
||||
cfg.validate()?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Rejects a handful of `transcode` values that are individually
|
||||
/// syntactically valid TOML but make the transcode pipeline's math
|
||||
/// nonsensical — there's no other validation anywhere in this config,
|
||||
/// so a typo here would otherwise only surface much later, deep inside
|
||||
/// an encode.
|
||||
fn validate(&self) -> Result<()> {
|
||||
// `target_bitrate_kbps` divides by `reference_height` (via
|
||||
// `reference_pixels`); zero makes that ratio `f64::INFINITY`, which
|
||||
// saturates to `u32::MAX` on the cast back to `u32` — then
|
||||
// `run_ffmpeg_encode_live_action`'s `bitrate_ceiling_kbps * 3`
|
||||
// overflows that `u32::MAX` (panics in a debug build, silently
|
||||
// wraps to a nonsense small value in release).
|
||||
anyhow::ensure!(
|
||||
self.transcode.reference_height > 0,
|
||||
"transcode.reference_height must be greater than 0"
|
||||
);
|
||||
// `is_beneficial` computes `original_bytes * (1.0 -
|
||||
// min_size_reduction_pct)` as the max allowed output size — a
|
||||
// negative value here would raise that ceiling *above* the
|
||||
// original, letting a transcode that actually grew the file still
|
||||
// count as "beneficial." That's the exact failure mode
|
||||
// `min_size_reduction_pct` exists to prevent (see its own doc
|
||||
// comment: a real incident where flat-bitrate VBR silently produced
|
||||
// files larger than the original).
|
||||
anyhow::ensure!(
|
||||
(0.0..=1.0).contains(&self.transcode.min_size_reduction_pct),
|
||||
"transcode.min_size_reduction_pct must be between 0.0 and 1.0"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn db_path(&self) -> PathBuf {
|
||||
expand_home(&self.daemon.db_path)
|
||||
}
|
||||
|
|
@ -378,4 +700,37 @@ mod tests {
|
|||
assert_eq!(cfg.daemon.log_level, "debug");
|
||||
assert_eq!(cfg.daemon.listen_addr, "127.0.0.1:7879");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config_passes_validation() {
|
||||
Config::default().validate().unwrap();
|
||||
}
|
||||
|
||||
// Regression test for a real gap found in review: `reference_height =
|
||||
// 0` makes `target_bitrate_kbps`'s resolution-scaling ratio divide by
|
||||
// zero, which eventually overflows a `u32` multiplication deep inside
|
||||
// the live-action encoder's maxrate calculation — a config typo that
|
||||
// used to only surface as a panic/garbage value in the middle of an
|
||||
// encode, not at startup.
|
||||
#[test]
|
||||
fn rejects_a_zero_reference_height() {
|
||||
let cfg: Config = toml::from_str("[transcode]\nreference_height = 0\n").unwrap();
|
||||
assert!(cfg.validate().is_err());
|
||||
}
|
||||
|
||||
// Regression test for a real gap found in review: a negative
|
||||
// `min_size_reduction_pct` would let `is_beneficial` accept an encode
|
||||
// that actually *grew* the file — the exact invariant this field exists
|
||||
// to guarantee against.
|
||||
#[test]
|
||||
fn rejects_a_negative_min_size_reduction_pct() {
|
||||
let cfg: Config = toml::from_str("[transcode]\nmin_size_reduction_pct = -0.1\n").unwrap();
|
||||
assert!(cfg.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_min_size_reduction_pct_above_one() {
|
||||
let cfg: Config = toml::from_str("[transcode]\nmin_size_reduction_pct = 1.5\n").unwrap();
|
||||
assert!(cfg.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ pub struct ReviewQueueEntry {
|
|||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StalledGrab {
|
||||
pub release_id: i64,
|
||||
pub media_item_id: i64,
|
||||
pub media_title: String,
|
||||
pub raw_title: String,
|
||||
pub grabbed_at: String,
|
||||
|
|
@ -142,6 +143,7 @@ pub struct HealthDetail {
|
|||
pub last_import_cycle: Option<CycleInfo>,
|
||||
pub last_search_cycle: Option<CycleInfo>,
|
||||
pub last_upgrade_cycle: Option<CycleInfo>,
|
||||
pub last_transcode_cycle: Option<CycleInfo>,
|
||||
pub search_halted: bool,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,88 @@ impl AddKind {
|
|||
}
|
||||
}
|
||||
|
||||
/// Client-side filter over the already-fetched `media_items` — the server
|
||||
/// has no filter/sort query params, and the library is small enough that
|
||||
/// refiltering the in-memory `Vec` on every keypress is free.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LibraryFilter {
|
||||
All,
|
||||
Series,
|
||||
Movies,
|
||||
Missing,
|
||||
Unmonitored,
|
||||
}
|
||||
|
||||
impl LibraryFilter {
|
||||
pub const ALL: [LibraryFilter; 5] = [
|
||||
LibraryFilter::All,
|
||||
LibraryFilter::Series,
|
||||
LibraryFilter::Movies,
|
||||
LibraryFilter::Missing,
|
||||
LibraryFilter::Unmonitored,
|
||||
];
|
||||
|
||||
pub fn next(self) -> Self {
|
||||
let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
|
||||
Self::ALL[(idx + 1) % Self::ALL.len()]
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
LibraryFilter::All => "all",
|
||||
LibraryFilter::Series => "series",
|
||||
LibraryFilter::Movies => "movies",
|
||||
LibraryFilter::Missing => "missing",
|
||||
LibraryFilter::Unmonitored => "unmonitored",
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(&self, m: &MediaItemSummary) -> bool {
|
||||
match self {
|
||||
LibraryFilter::All => true,
|
||||
LibraryFilter::Series => m.kind == "series",
|
||||
LibraryFilter::Movies => m.kind == "movie",
|
||||
LibraryFilter::Missing => m.missing_count > 0,
|
||||
LibraryFilter::Unmonitored => !m.monitored,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LibrarySort {
|
||||
TitleAsc,
|
||||
MissingDesc,
|
||||
Kind,
|
||||
}
|
||||
|
||||
impl LibrarySort {
|
||||
pub const ALL: [LibrarySort; 3] = [
|
||||
LibrarySort::TitleAsc,
|
||||
LibrarySort::MissingDesc,
|
||||
LibrarySort::Kind,
|
||||
];
|
||||
|
||||
pub fn next(self) -> Self {
|
||||
let idx = Self::ALL.iter().position(|s| *s == self).unwrap_or(0);
|
||||
Self::ALL[(idx + 1) % Self::ALL.len()]
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
LibrarySort::TitleAsc => "title",
|
||||
LibrarySort::MissingDesc => "missing",
|
||||
LibrarySort::Kind => "kind",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which half of the Stuck tab has selection/arrow-key focus.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StuckSection {
|
||||
Stalled,
|
||||
Maxed,
|
||||
}
|
||||
|
||||
/// A weight axis's display name plus a getter/setter pair, so
|
||||
/// `WEIGHT_FIELDS` can enumerate `WeightsDto`'s fields by position instead
|
||||
/// of every caller matching on an index.
|
||||
|
|
@ -123,9 +205,24 @@ pub struct App {
|
|||
pub focus: Focus,
|
||||
pub status: String,
|
||||
pub should_quit: bool,
|
||||
/// Toggled by `?`; intercepted at the top of `main.rs`'s key handler
|
||||
/// like `Focus::AddSearchInput`/`Focus::WeightInput`, but kept as its
|
||||
/// own bool (not folded into `Focus`) since it needs to open from any
|
||||
/// tab rather than being scoped to one.
|
||||
pub help_visible: bool,
|
||||
/// Set by the `R` key; consumed by `main.rs`'s refresh loop to bypass
|
||||
/// the normal 3s throttle for one immediate refresh.
|
||||
pub force_refresh: bool,
|
||||
|
||||
pub media_items: Vec<MediaItemSummary>,
|
||||
pub media_state: ListState,
|
||||
/// Filtered+sorted indices into `media_items`, recomputed by
|
||||
/// `recompute_library_view` — the top-level Library list and
|
||||
/// `media_state` always operate over this, never over `media_items`
|
||||
/// directly, so filter/sort never has to touch the raw fetched data.
|
||||
pub library_view: Vec<usize>,
|
||||
pub library_filter: LibraryFilter,
|
||||
pub library_sort: LibrarySort,
|
||||
pub detail: Option<MediaItemDetail>,
|
||||
/// Selection within `detail.episodes` — separate from `media_state`
|
||||
/// since they're two different lists sharing the same tab.
|
||||
|
|
@ -142,6 +239,9 @@ pub struct App {
|
|||
pub add_results_state: ListState,
|
||||
|
||||
pub stuck: Option<StuckReport>,
|
||||
pub stuck_focus: StuckSection,
|
||||
pub stalled_state: ListState,
|
||||
pub maxed_state: ListState,
|
||||
pub calendar: Vec<CalendarEntry>,
|
||||
pub library_health: Option<LibraryHealthReport>,
|
||||
|
||||
|
|
@ -185,8 +285,13 @@ impl App {
|
|||
focus: Focus::List,
|
||||
status: String::new(),
|
||||
should_quit: false,
|
||||
help_visible: false,
|
||||
force_refresh: false,
|
||||
media_items: Vec::new(),
|
||||
media_state: ListState::default(),
|
||||
library_view: Vec::new(),
|
||||
library_filter: LibraryFilter::All,
|
||||
library_sort: LibrarySort::TitleAsc,
|
||||
detail: None,
|
||||
episode_state: ListState::default(),
|
||||
releases: Vec::new(),
|
||||
|
|
@ -197,6 +302,9 @@ impl App {
|
|||
add_results: Vec::new(),
|
||||
add_results_state: ListState::default(),
|
||||
stuck: None,
|
||||
stuck_focus: StuckSection::Stalled,
|
||||
stalled_state: ListState::default(),
|
||||
maxed_state: ListState::default(),
|
||||
calendar: Vec::new(),
|
||||
library_health: None,
|
||||
candidates: Vec::new(),
|
||||
|
|
@ -236,9 +344,7 @@ impl App {
|
|||
}
|
||||
} else {
|
||||
self.media_items = self.client.list_media().await?;
|
||||
if self.media_state.selected().is_none() && !self.media_items.is_empty() {
|
||||
self.media_state.select(Some(0));
|
||||
}
|
||||
self.recompute_library_view();
|
||||
}
|
||||
}
|
||||
Tab::History => {
|
||||
|
|
@ -255,7 +361,17 @@ impl App {
|
|||
}
|
||||
Tab::Add => {}
|
||||
Tab::Stuck => {
|
||||
self.stuck = Some(self.client.stuck().await?);
|
||||
let report = self.client.stuck().await?;
|
||||
if self.stalled_state.selected().is_none() && !report.stalled_grabs.is_empty()
|
||||
{
|
||||
self.stalled_state.select(Some(0));
|
||||
}
|
||||
if self.maxed_state.selected().is_none()
|
||||
&& !report.maxed_out_search_targets.is_empty()
|
||||
{
|
||||
self.maxed_state.select(Some(0));
|
||||
}
|
||||
self.stuck = Some(report);
|
||||
}
|
||||
Tab::Calendar => {
|
||||
self.calendar = self.client.calendar().await?;
|
||||
|
|
@ -287,13 +403,68 @@ impl App {
|
|||
self.detail.as_ref().map(|d| d.id)
|
||||
}
|
||||
|
||||
/// Rebuilds `library_view` from `media_items` under the current
|
||||
/// filter/sort, then re-selects whichever item was selected before (by
|
||||
/// id, not raw index) if it's still in view — falls back to the first
|
||||
/// item, or no selection if the view is now empty. Called after
|
||||
/// `media_items` changes, and after `library_filter`/`library_sort`
|
||||
/// change.
|
||||
pub fn recompute_library_view(&mut self) {
|
||||
let previously_selected_id = self.selected_media_item().map(|m| m.id);
|
||||
|
||||
let mut indices: Vec<usize> = self
|
||||
.media_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| self.library_filter.matches(m))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
match self.library_sort {
|
||||
LibrarySort::TitleAsc => indices.sort_by(|&a, &b| {
|
||||
self.media_items[a]
|
||||
.title
|
||||
.to_lowercase()
|
||||
.cmp(&self.media_items[b].title.to_lowercase())
|
||||
}),
|
||||
LibrarySort::MissingDesc => indices.sort_by(|&a, &b| {
|
||||
self.media_items[b]
|
||||
.missing_count
|
||||
.cmp(&self.media_items[a].missing_count)
|
||||
}),
|
||||
LibrarySort::Kind => indices.sort_by(|&a, &b| {
|
||||
self.media_items[a].kind.cmp(&self.media_items[b].kind).then_with(|| {
|
||||
self.media_items[a]
|
||||
.title
|
||||
.to_lowercase()
|
||||
.cmp(&self.media_items[b].title.to_lowercase())
|
||||
})
|
||||
}),
|
||||
}
|
||||
self.library_view = indices;
|
||||
|
||||
match previously_selected_id
|
||||
.and_then(|id| self.library_view.iter().position(|&i| self.media_items[i].id == id))
|
||||
{
|
||||
Some(pos) => self.media_state.select(Some(pos)),
|
||||
None if !self.library_view.is_empty() => self.media_state.select(Some(0)),
|
||||
None => self.media_state.select(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_media_item(&self) -> Option<&MediaItemSummary> {
|
||||
let idx = self.media_state.selected()?;
|
||||
let real_idx = *self.library_view.get(idx)?;
|
||||
self.media_items.get(real_idx)
|
||||
}
|
||||
|
||||
pub fn move_selection(&mut self, delta: i32) {
|
||||
let (state, len) = match self.tab {
|
||||
Tab::Library if matches!(self.focus, Focus::Candidates) => {
|
||||
(&mut self.candidates_state, self.candidates.len())
|
||||
}
|
||||
Tab::Library if self.detail.is_none() => {
|
||||
(&mut self.media_state, self.media_items.len())
|
||||
(&mut self.media_state, self.library_view.len())
|
||||
}
|
||||
Tab::Library => (
|
||||
&mut self.episode_state,
|
||||
|
|
@ -302,6 +473,15 @@ impl App {
|
|||
Tab::History => (&mut self.releases_state, self.releases.len()),
|
||||
Tab::Review => (&mut self.review_state, self.review_items.len()),
|
||||
Tab::Add => (&mut self.add_results_state, self.add_results.len()),
|
||||
Tab::Stuck => {
|
||||
let report_len = self.stuck.as_ref().map_or((0, 0), |r| {
|
||||
(r.stalled_grabs.len(), r.maxed_out_search_targets.len())
|
||||
});
|
||||
match self.stuck_focus {
|
||||
StuckSection::Stalled => (&mut self.stalled_state, report_len.0),
|
||||
StuckSection::Maxed => (&mut self.maxed_state, report_len.1),
|
||||
}
|
||||
}
|
||||
Tab::Profiles if self.profile_detail.is_some() => {
|
||||
(&mut self.profile_weight_state, WEIGHT_FIELDS.len())
|
||||
}
|
||||
|
|
@ -320,10 +500,7 @@ impl App {
|
|||
if !matches!(self.tab, Tab::Library) || self.detail.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(idx) = self.media_state.selected() else {
|
||||
return;
|
||||
};
|
||||
let Some(item) = self.media_items.get(idx) else {
|
||||
let Some(item) = self.selected_media_item() else {
|
||||
return;
|
||||
};
|
||||
match self.client.media_detail(item.id).await {
|
||||
|
|
@ -707,6 +884,28 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
/// Toggles monitored for whatever's selected in the top-level Library
|
||||
/// list, without needing to open its detail view first.
|
||||
pub async fn toggle_monitor_list_selected(&mut self) {
|
||||
let Some(item) = self.selected_media_item() else {
|
||||
return;
|
||||
};
|
||||
let (id, monitored) = (item.id, item.monitored);
|
||||
let result = if monitored {
|
||||
self.client.unmonitor(id).await
|
||||
} else {
|
||||
self.client.monitor(id).await
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.status = "monitor state updated".to_string();
|
||||
self.media_items = self.client.list_media().await.unwrap_or_default();
|
||||
self.recompute_library_view();
|
||||
}
|
||||
Err(e) => self.status = format!("monitor toggle failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn toggle_monitor_selected(&mut self) {
|
||||
let Some(detail) = &self.detail else {
|
||||
return;
|
||||
|
|
@ -745,6 +944,7 @@ impl App {
|
|||
self.status = "deleted".to_string();
|
||||
self.detail = None;
|
||||
self.media_items = self.client.list_media().await.unwrap_or_default();
|
||||
self.recompute_library_view();
|
||||
}
|
||||
Err(e) => self.status = format!("delete failed: {e}"),
|
||||
}
|
||||
|
|
@ -789,4 +989,40 @@ impl App {
|
|||
Err(e) => self.status = format!("file delete failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Jumps from whichever Stuck-tab row is selected straight to that
|
||||
/// show's Library detail view.
|
||||
pub async fn jump_to_stuck_target(&mut self) {
|
||||
let Some(report) = &self.stuck else {
|
||||
return;
|
||||
};
|
||||
let media_item_id = match self.stuck_focus {
|
||||
StuckSection::Maxed => self
|
||||
.maxed_state
|
||||
.selected()
|
||||
.and_then(|i| report.maxed_out_search_targets.get(i))
|
||||
.map(|t| t.media_item_id),
|
||||
StuckSection::Stalled => self
|
||||
.stalled_state
|
||||
.selected()
|
||||
.and_then(|i| report.stalled_grabs.get(i))
|
||||
.map(|g| g.media_item_id),
|
||||
};
|
||||
let Some(id) = media_item_id else {
|
||||
return;
|
||||
};
|
||||
match self.client.media_detail(id).await {
|
||||
Ok(detail) => {
|
||||
self.tab = Tab::Library;
|
||||
self.episode_state.select(if detail.episodes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(0)
|
||||
});
|
||||
self.detail = Some(detail);
|
||||
self.focus = Focus::List;
|
||||
}
|
||||
Err(e) => self.status = format!("error loading detail: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use crossterm::terminal::{
|
|||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
use app::{App, Focus, Tab};
|
||||
use app::{App, Focus, StuckSection, Tab};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
|
|
@ -47,8 +47,9 @@ async fn run(
|
|||
let mut last_refresh = tokio::time::Instant::now() - Duration::from_secs(10);
|
||||
|
||||
loop {
|
||||
if last_refresh.elapsed() >= Duration::from_secs(3) {
|
||||
if last_refresh.elapsed() >= Duration::from_secs(3) || app.force_refresh {
|
||||
app.refresh_active_tab().await;
|
||||
app.force_refresh = false;
|
||||
last_refresh = tokio::time::Instant::now();
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +102,16 @@ async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Help overlay intercepts everything while open, same
|
||||
// priority-over-global-keys idiom as the two input modes above.
|
||||
if app.help_visible {
|
||||
match code {
|
||||
KeyCode::Char('?') | KeyCode::Esc => app.help_visible = false,
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Any key other than a second `x`/`d` clears a pending delete
|
||||
// confirmation — the confirmation must be the very next keypress, not
|
||||
// just "any keypress before the user gets distracted."
|
||||
|
|
@ -136,8 +147,28 @@ async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) {
|
|||
app.start_editing_selected_weight();
|
||||
}
|
||||
Tab::Profiles => app.open_profile_detail(),
|
||||
Tab::Stuck => app.jump_to_stuck_target().await,
|
||||
_ => {}
|
||||
},
|
||||
KeyCode::Left | KeyCode::Right if matches!(app.tab, Tab::Stuck) => {
|
||||
app.stuck_focus = match app.stuck_focus {
|
||||
StuckSection::Stalled => StuckSection::Maxed,
|
||||
StuckSection::Maxed => StuckSection::Stalled,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('?') => app.help_visible = true,
|
||||
KeyCode::Char('R') => app.force_refresh = true,
|
||||
KeyCode::Char('f') if matches!(app.tab, Tab::Library) && app.detail.is_none() => {
|
||||
app.library_filter = app.library_filter.next();
|
||||
app.recompute_library_view();
|
||||
}
|
||||
KeyCode::Char('o') if matches!(app.tab, Tab::Library) && app.detail.is_none() => {
|
||||
app.library_sort = app.library_sort.next();
|
||||
app.recompute_library_view();
|
||||
}
|
||||
KeyCode::Char('m') if matches!(app.tab, Tab::Library) && app.detail.is_none() => {
|
||||
app.toggle_monitor_list_selected().await;
|
||||
}
|
||||
KeyCode::Char('a') if matches!(app.tab, Tab::Review) => {
|
||||
app.approve_selected_review().await;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Tabs};
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::{App, Focus, Tab};
|
||||
use crate::app::{App, Focus, StuckSection, Tab};
|
||||
|
||||
// Shared color palette — kept to these meanings so a color never has to be
|
||||
// second-guessed at a glance:
|
||||
// Green healthy / 0 missing / high confidence / not stuck
|
||||
// Yellow warning / low missing / mid confidence / at backoff ceiling
|
||||
// Red critical / high missing / low confidence / past ceiling
|
||||
// DarkGray unmonitored / muted / not currently relevant
|
||||
// Blue TV kind tag (categorical, not severity)
|
||||
// Magenta Movie kind tag (categorical, not severity)
|
||||
// Cyan focus/interactive accent (tab highlight, active input, focused section) — never severity
|
||||
|
||||
pub fn draw(frame: &mut Frame, app: &App) {
|
||||
let chunks = Layout::default()
|
||||
|
|
@ -30,6 +40,102 @@ pub fn draw(frame: &mut Frame, app: &App) {
|
|||
}
|
||||
|
||||
draw_status(frame, chunks[2], app);
|
||||
|
||||
if app.help_visible {
|
||||
draw_help_overlay(frame, frame.area(), app);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keybindings relevant to the current tab/focus/detail state, in the order
|
||||
/// they should be shown — the single source of truth shared by the status
|
||||
/// bar (which shows a short prefix) and the help overlay (which shows all of
|
||||
/// it plus `GLOBAL_KEYS`), so the two can't drift apart.
|
||||
fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> {
|
||||
match app.tab {
|
||||
Tab::Library if matches!(app.focus, Focus::Candidates) => {
|
||||
vec![("Enter", "grab"), ("Esc", "cancel")]
|
||||
}
|
||||
Tab::Library if app.detail.is_some() => vec![
|
||||
("Esc", "back"),
|
||||
("s", "search now"),
|
||||
("m", "monitor show"),
|
||||
("e", "monitor episode"),
|
||||
("S", "monitor season"),
|
||||
("x", "delete show"),
|
||||
("d", "delete file"),
|
||||
("c", "pick release"),
|
||||
],
|
||||
Tab::Library => vec![
|
||||
("Enter", "open detail"),
|
||||
("f", "cycle filter"),
|
||||
("o", "cycle sort"),
|
||||
("m", "monitor toggle"),
|
||||
],
|
||||
Tab::Review => vec![("a", "approve"), ("r", "reject")],
|
||||
Tab::Add => match app.focus {
|
||||
Focus::AddSearchInput => vec![("Enter", "search")],
|
||||
_ => vec![("Enter", "add"), ("Esc", "back to search")],
|
||||
},
|
||||
Tab::Profiles if app.profile_detail.is_some() => {
|
||||
vec![("Enter", "edit weight"), ("Esc", "back")]
|
||||
}
|
||||
Tab::Profiles => vec![("Enter", "open profile")],
|
||||
Tab::Stuck => vec![("Left/Right", "switch section"), ("Enter", "jump to show")],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
const GLOBAL_KEYS: &[(&str, &str)] = &[
|
||||
("Tab", "switch tab"),
|
||||
("j/k", "move"),
|
||||
("?", "help"),
|
||||
("R", "refresh now"),
|
||||
("q", "quit"),
|
||||
];
|
||||
|
||||
/// Centers a `width` x `height` rect inside `area` — standard ratatui idiom
|
||||
/// for a popup/overlay.
|
||||
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||
let width = width.min(area.width);
|
||||
let height = height.min(area.height);
|
||||
Rect {
|
||||
x: area.x + (area.width.saturating_sub(width)) / 2,
|
||||
y: area.y + (area.height.saturating_sub(height)) / 2,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_help_overlay(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let mut lines: Vec<Line> = context_keybindings(app)
|
||||
.into_iter()
|
||||
.map(|(key, desc)| {
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{key:12}"), Style::default().fg(Color::Cyan)),
|
||||
Span::raw(desc),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Global",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
lines.extend(GLOBAL_KEYS.iter().map(|(key, desc)| {
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{key:12}"), Style::default().fg(Color::Cyan)),
|
||||
Span::raw(*desc),
|
||||
])
|
||||
}));
|
||||
|
||||
let popup = centered_rect(50, lines.len() as u16 + 2, area);
|
||||
frame.render_widget(Clear, popup);
|
||||
let paragraph = Paragraph::new(lines).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Help — ? or Esc to close"),
|
||||
);
|
||||
frame.render_widget(paragraph, popup);
|
||||
}
|
||||
|
||||
fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) {
|
||||
|
|
@ -88,18 +194,21 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) {
|
|||
.episodes
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let status = if e.has_file {
|
||||
"✓"
|
||||
let (status, color) = if e.has_file {
|
||||
("✓", Color::Green)
|
||||
} else if e.monitored {
|
||||
"…"
|
||||
("…", Color::Yellow)
|
||||
} else {
|
||||
"-"
|
||||
("-", Color::DarkGray)
|
||||
};
|
||||
let title = e.title.as_deref().unwrap_or("");
|
||||
ListItem::new(format!(
|
||||
"{status} S{:02}E{:02} {title}",
|
||||
e.season_number, e.episode_number
|
||||
))
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(status, Style::default().fg(color)),
|
||||
Span::raw(format!(
|
||||
" S{:02}E{:02} {title}",
|
||||
e.season_number, e.episode_number
|
||||
)),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
let monitor_label = if detail.monitored {
|
||||
|
|
@ -116,9 +225,7 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) {
|
|||
};
|
||||
let list = List::new(items)
|
||||
.block(Block::default().borders(Borders::ALL).title(format!(
|
||||
"{} ({}) [{monitor_label}] — Esc: back s: search now m: monitor show \
|
||||
e: monitor episode S: monitor season x: delete show d: delete file \
|
||||
c: pick release{confirm}",
|
||||
"{} ({}) [{monitor_label}]{confirm}",
|
||||
detail.title,
|
||||
detail.year.map(|y| y.to_string()).unwrap_or_default()
|
||||
)))
|
||||
|
|
@ -129,28 +236,64 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) {
|
|||
}
|
||||
|
||||
let items: Vec<ListItem> = app
|
||||
.media_items
|
||||
.library_view
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let missing = if m.missing_count > 0 {
|
||||
.map(|&i| {
|
||||
let m = &app.media_items[i];
|
||||
let kind_tag = if m.kind == "movie" { "[Movie]" } else { "[TV]" };
|
||||
let kind_color = if m.kind == "movie" {
|
||||
Color::Magenta
|
||||
} else {
|
||||
Color::Blue
|
||||
};
|
||||
let ratio = if m.episode_count > 0 {
|
||||
m.missing_count as f64 / m.episode_count as f64
|
||||
} else if m.missing_count > 0 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Mute severity color for unmonitored items — a missing count
|
||||
// on something deliberately unmonitored isn't actionable.
|
||||
let missing_color = if !m.monitored || m.missing_count == 0 {
|
||||
Color::DarkGray
|
||||
} else if ratio <= 0.25 {
|
||||
Color::Yellow
|
||||
} else {
|
||||
Color::Red
|
||||
};
|
||||
let missing_text = if m.missing_count > 0 {
|
||||
format!(" — {} missing", m.missing_count)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
ListItem::new(format!(
|
||||
"{} ({}){}",
|
||||
m.title,
|
||||
m.year.map(|y| y.to_string()).unwrap_or_default(),
|
||||
missing
|
||||
))
|
||||
let title_style = if m.monitored {
|
||||
Style::default()
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(format!("{kind_tag} "), Style::default().fg(kind_color)),
|
||||
Span::styled(
|
||||
format!(
|
||||
"{} ({})",
|
||||
m.title,
|
||||
m.year.map(|y| y.to_string()).unwrap_or_default()
|
||||
),
|
||||
title_style,
|
||||
),
|
||||
Span::styled(missing_text, Style::default().fg(missing_color)),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Monitored Shows — Enter for detail"),
|
||||
)
|
||||
.block(Block::default().borders(Borders::ALL).title(format!(
|
||||
"Monitored Shows ({}/{}) — filter: {} sort: {}",
|
||||
app.library_view.len(),
|
||||
app.media_items.len(),
|
||||
app.library_filter.label(),
|
||||
app.library_sort.label()
|
||||
)))
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
let mut state = app.media_state.clone();
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
|
|
@ -233,12 +376,21 @@ fn draw_review(frame: &mut Frame, area: Rect, app: &App) {
|
|||
.review_items
|
||||
.iter()
|
||||
.map(|r| {
|
||||
ListItem::new(format!(
|
||||
"({:.0}%) {} -> {}",
|
||||
r.confidence * 100.0,
|
||||
r.raw_release_title,
|
||||
r.candidate_media_title.as_deref().unwrap_or("?")
|
||||
))
|
||||
let color = if r.confidence < 0.70 {
|
||||
Color::Red
|
||||
} else if r.confidence < 0.85 {
|
||||
Color::Yellow
|
||||
} else {
|
||||
Color::Green
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(format!("({:.0}%)", r.confidence * 100.0), Style::default().fg(color)),
|
||||
Span::raw(format!(
|
||||
" {} -> {}",
|
||||
r.raw_release_title,
|
||||
r.candidate_media_title.as_deref().unwrap_or("?")
|
||||
)),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
|
|
@ -256,6 +408,11 @@ fn draw_review(frame: &mut Frame, area: Rect, app: &App) {
|
|||
/// than expected, how deep the review queue has backed up, and search
|
||||
/// targets that have been failing every attempt long enough for their
|
||||
/// backoff to hit its ceiling. Read-only report, no selection/navigation.
|
||||
// Mirrors breadarrd/src/api/routes/stuck.rs::MAXED_SEARCH_COUNT. Duplicated
|
||||
// because the daemon doesn't expose it via the API; if it drifts this is
|
||||
// cosmetic only (wrong shade), not a behavior bug.
|
||||
const MAXED_SEARCH_COUNT: i64 = 6;
|
||||
|
||||
fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let Some(report) = &app.stuck else {
|
||||
let placeholder = Paragraph::new("loading...").block(
|
||||
|
|
@ -272,42 +429,71 @@ fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) {
|
|||
.constraints([Constraint::Min(3), Constraint::Min(3)])
|
||||
.split(area);
|
||||
|
||||
let stalled_border = if matches!(app.stuck_focus, StuckSection::Stalled) {
|
||||
Color::Cyan
|
||||
} else {
|
||||
Color::Reset
|
||||
};
|
||||
let stalled_items: Vec<ListItem> = report
|
||||
.stalled_grabs
|
||||
.iter()
|
||||
.map(|g| {
|
||||
ListItem::new(format!(
|
||||
"{} — {} (grabbed {})",
|
||||
g.media_title, g.raw_title, g.grabbed_at
|
||||
))
|
||||
ListItem::new(Line::from(Span::styled(
|
||||
format!("{} — {} (grabbed {})", g.media_title, g.raw_title, g.grabbed_at),
|
||||
Style::default().fg(Color::Yellow),
|
||||
)))
|
||||
})
|
||||
.collect();
|
||||
let stalled_list =
|
||||
List::new(stalled_items).block(Block::default().borders(Borders::ALL).title(format!(
|
||||
"Stalled grabs ({}) — review queue: {} pending",
|
||||
report.stalled_grabs.len(),
|
||||
report.review_queue_depth
|
||||
)));
|
||||
frame.render_widget(stalled_list, chunks[0]);
|
||||
let stalled_list = List::new(stalled_items)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(stalled_border))
|
||||
.title(format!(
|
||||
"Stalled grabs ({}) — review queue: {} pending",
|
||||
report.stalled_grabs.len(),
|
||||
report.review_queue_depth
|
||||
)),
|
||||
)
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
let mut stalled_state = app.stalled_state.clone();
|
||||
frame.render_stateful_widget(stalled_list, chunks[0], &mut stalled_state);
|
||||
|
||||
let maxed_border = if matches!(app.stuck_focus, StuckSection::Maxed) {
|
||||
Color::Cyan
|
||||
} else {
|
||||
Color::Reset
|
||||
};
|
||||
let maxed_items: Vec<ListItem> = report
|
||||
.maxed_out_search_targets
|
||||
.iter()
|
||||
.map(|t| {
|
||||
ListItem::new(format!(
|
||||
"{} — {} attempts, last searched {}",
|
||||
t.media_title,
|
||||
t.search_count,
|
||||
t.last_searched_at.as_deref().unwrap_or("never")
|
||||
))
|
||||
let color = if t.search_count > MAXED_SEARCH_COUNT {
|
||||
Color::Red
|
||||
} else {
|
||||
Color::Yellow
|
||||
};
|
||||
ListItem::new(Line::from(Span::styled(
|
||||
format!(
|
||||
"{} — {} attempts, last searched {}",
|
||||
t.media_title,
|
||||
t.search_count,
|
||||
t.last_searched_at.as_deref().unwrap_or("never")
|
||||
),
|
||||
Style::default().fg(color),
|
||||
)))
|
||||
})
|
||||
.collect();
|
||||
let maxed_list = List::new(maxed_items).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Search targets at max backoff"),
|
||||
);
|
||||
frame.render_widget(maxed_list, chunks[1]);
|
||||
let maxed_list = List::new(maxed_items)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(maxed_border))
|
||||
.title("Search targets at max backoff — Enter: jump to show"),
|
||||
)
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
let mut maxed_state = app.maxed_state.clone();
|
||||
frame.render_stateful_widget(maxed_list, chunks[1], &mut maxed_state);
|
||||
}
|
||||
|
||||
fn draw_add(frame: &mut Frame, area: Rect, app: &App) {
|
||||
|
|
@ -549,10 +735,15 @@ fn draw_profiles(frame: &mut Frame, area: Rect, app: &App) {
|
|||
}
|
||||
|
||||
fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let text = if app.status.is_empty() {
|
||||
"Tab: switch view | j/k: move | q: quit".to_string()
|
||||
} else {
|
||||
let text = if !app.status.is_empty() {
|
||||
app.status.clone()
|
||||
} else {
|
||||
let hints: Vec<String> = context_keybindings(app)
|
||||
.into_iter()
|
||||
.take(4)
|
||||
.map(|(key, desc)| format!("{key}: {desc}"))
|
||||
.collect();
|
||||
format!("{} | ?: help", hints.join(" | "))
|
||||
};
|
||||
let status = Paragraph::new(text).block(Block::default().borders(Borders::ALL));
|
||||
frame.render_widget(status, area);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ serde_json.workspace = true
|
|||
ort.workspace = true
|
||||
tokenizers.workspace = true
|
||||
# TODO(owner): switch to tag-pinned git dependency once bread-onnx is merged and tagged, matching the bread-theme pattern
|
||||
bread-onnx = { path = "../../bread-ecosystem/bread-onnx" }
|
||||
bread-onnx = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" }
|
||||
scraper.workspace = true
|
||||
chrono.workspace = true
|
||||
fastrand.workspace = true
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ pub struct CycleStatus {
|
|||
pub last_import: Option<CycleRecord>,
|
||||
pub last_search: Option<CycleRecord>,
|
||||
pub last_upgrade: Option<CycleRecord>,
|
||||
pub last_transcode: Option<CycleRecord>,
|
||||
/// Set once the search-driven loop's consecutive-failure backoff hits
|
||||
/// its ceiling — still ticking at max backoff underneath (self-healing
|
||||
/// if the source recovers), but worth a loud, easy-to-spot signal that
|
||||
|
|
@ -108,13 +109,27 @@ async fn require_api_token(State(state): State<AppState>, req: Request, next: Ne
|
|||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.is_some_and(|token| token == state.config.daemon.api_token);
|
||||
.is_some_and(|token| constant_time_eq(token, &state.config.daemon.api_token));
|
||||
if !authorized {
|
||||
return (StatusCode::UNAUTHORIZED, "missing or invalid API token").into_response();
|
||||
}
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// Byte-wise `==` short-circuits on the first mismatching byte, making
|
||||
/// comparison time a (weak, but real) signal of how many leading bytes of a
|
||||
/// guessed token were correct — a classic timing oracle. This always
|
||||
/// touches every byte of the shorter input regardless of where they first
|
||||
/// differ. Real-world exposure here is low (loopback-bound by default, a
|
||||
/// personal single-user daemon), but it costs nothing to close.
|
||||
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter().zip(b.iter()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(routes::health::health))
|
||||
|
|
@ -178,3 +193,28 @@ pub fn router(state: AppState) -> Router {
|
|||
))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn constant_time_eq_matches_identical_strings() {
|
||||
assert!(constant_time_eq("secret-token", "secret-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_time_eq_rejects_different_strings_of_the_same_length() {
|
||||
assert!(!constant_time_eq("secret-token", "secret-toke1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_time_eq_rejects_different_lengths() {
|
||||
assert!(!constant_time_eq("short", "a-much-longer-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_time_eq_treats_empty_strings_as_equal() {
|
||||
assert!(constant_time_eq("", ""));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ pub async fn health(State(state): State<AppState>) -> Json<HealthDetail> {
|
|||
last_import_cycle: status.last_import.as_ref().map(to_info),
|
||||
last_search_cycle: status.last_search.as_ref().map(to_info),
|
||||
last_upgrade_cycle: status.last_upgrade.as_ref().map(to_info),
|
||||
last_transcode_cycle: status.last_transcode.as_ref().map(to_info),
|
||||
search_halted: status.search_halted,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,15 +75,26 @@ pub async fn approve(
|
|||
}
|
||||
};
|
||||
|
||||
let torrent_hash = scheduler::grab_prepared_approval(qbit, &state.qbit_category, &prepared)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
let torrent_hash = match scheduler::grab_prepared_approval(qbit, &state.qbit_category, &prepared).await {
|
||||
Ok(hash) => hash,
|
||||
Err(e) => {
|
||||
// The grab errored outright (not just "added but no hash
|
||||
// captured" — `finalize_review_approval` below handles that
|
||||
// case and still runs to completion). `prepare_review_approval`
|
||||
// already claimed this row into `approved` before we got here;
|
||||
// without releasing it back to `pending`, a transient
|
||||
// qBittorrent error would strand the review permanently
|
||||
// unapprovable with nothing ever recorded for it.
|
||||
let conn = state.conn.lock().await;
|
||||
let _ = scheduler::release_review_claim(&conn, id);
|
||||
return Err(internal(e));
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let conn = state.conn.lock().await;
|
||||
scheduler::finalize_review_approval(
|
||||
&conn,
|
||||
id,
|
||||
&prepared,
|
||||
&state.qbit_category,
|
||||
torrent_hash.as_deref(),
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ pub async fn stuck(
|
|||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT r.id, m.title, r.raw_title, r.grabbed_at
|
||||
"SELECT r.id, r.media_item_id, m.title, r.raw_title, r.grabbed_at
|
||||
FROM release r JOIN media_item m ON m.id = r.media_item_id
|
||||
WHERE r.status = 'grabbed'
|
||||
AND (julianday('now') - julianday(r.grabbed_at)) * 24.0 > ?1
|
||||
|
|
@ -34,9 +34,10 @@ pub async fn stuck(
|
|||
.query_map([STALLED_GRAB_HOURS], |row| {
|
||||
Ok(StalledGrab {
|
||||
release_id: row.get(0)?,
|
||||
media_title: row.get(1)?,
|
||||
raw_title: row.get(2)?,
|
||||
grabbed_at: row.get(3)?,
|
||||
media_item_id: row.get(1)?,
|
||||
media_title: row.get(2)?,
|
||||
raw_title: row.get(3)?,
|
||||
grabbed_at: row.get(4)?,
|
||||
})
|
||||
})
|
||||
.map_err(internal)?
|
||||
|
|
|
|||
|
|
@ -353,7 +353,32 @@ pub fn init(conn: &Connection) -> anyhow::Result<()> {
|
|||
fetched_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_torrent_fetch_hash ON torrent_fetch(torrent_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_torrent_fetch_fetched_at ON torrent_fetch(fetched_at);",
|
||||
CREATE INDEX IF NOT EXISTS idx_torrent_fetch_fetched_at ON torrent_fetch(fetched_at);
|
||||
|
||||
-- One row per file queued for AV1 transcoding, whether from the
|
||||
-- post-import async hook or the `transcode-library` backfill sweep.
|
||||
-- Persisted (not an in-memory queue) so a `pending`/`running` row
|
||||
-- left over from a daemon crash mid-encode just gets picked up
|
||||
-- again on the next tick instead of silently vanishing.
|
||||
CREATE TABLE IF NOT EXISTS transcode_job (
|
||||
id INTEGER PRIMARY KEY,
|
||||
episode_file_id INTEGER NOT NULL REFERENCES episode_file(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','running','done','failed','skipped')),
|
||||
original_codec TEXT,
|
||||
original_bytes INTEGER,
|
||||
new_bytes INTEGER,
|
||||
error TEXT,
|
||||
queued_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_transcode_job_status ON transcode_job(status);
|
||||
-- Prevents two jobs for the same file ever being active at once —
|
||||
-- closes the door on the same file getting encoded twice
|
||||
-- concurrently regardless of how it happened (a stray duplicate
|
||||
-- enqueue, a daemon-restart reset racing a still-alive backfill).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_transcode_job_active_episode_file
|
||||
ON transcode_job(episode_file_id) WHERE status IN ('pending','running');",
|
||||
)?;
|
||||
|
||||
// Progress watermark for stalled-download detection (added after the
|
||||
|
|
@ -411,6 +436,47 @@ pub fn init(conn: &Connection) -> anyhow::Result<()> {
|
|||
// less-common stream metadata). See `ffprobe::MediaProbe::raw_json`.
|
||||
add_column_if_missing(conn, "media_file_probe", "raw_ffprobe_json", "TEXT")?;
|
||||
|
||||
// Set once a file has been through a successful local AV1 transcode —
|
||||
// stops the upgrade cycle from treating it as still needing a bigger
|
||||
// HEVC/H264 release, since `best_existing_score` otherwise only ever
|
||||
// sees the stored `release.score` from original-grab time, which a
|
||||
// local re-encode never touches.
|
||||
add_column_if_missing(
|
||||
conn,
|
||||
"episode_file",
|
||||
"upgrade_locked",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
// Decided at enqueue time (path prefix match against
|
||||
// `transcode.anime_root_folders`, OR'd with the `anime_mapping`/
|
||||
// `anime_tmdb_movie` metadata check) and carried on the job row so
|
||||
// `claim_pending_jobs` can dispatch straight to the right encode
|
||||
// pipeline (`run_ffmpeg_encode_anime` vs `_live_action`) without
|
||||
// re-deriving it — the eligibility metadata lookups aren't available
|
||||
// from the job row's own columns alone (no media_item_id here).
|
||||
add_column_if_missing(
|
||||
conn,
|
||||
"transcode_job",
|
||||
"is_anime",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
// Lets a job re-encode a file that's already AV1 — normally
|
||||
// `encode_and_verify` treats "already AV1" as nothing-to-do and skips
|
||||
// the encode entirely, which is right for a fresh library scan but
|
||||
// wrong for deliberately re-transcoding a file that got mis-encoded
|
||||
// (e.g. the 476 files a real rate-control bug left *larger* than their
|
||||
// original — already AV1, so the normal backfill query skips them, but
|
||||
// they're exactly what a remediation pass needs to revisit). See
|
||||
// `find_oversized_av1_candidates`.
|
||||
add_column_if_missing(
|
||||
conn,
|
||||
"transcode_job",
|
||||
"force_reencode",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -473,7 +539,7 @@ mod tests {
|
|||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(table_count, 17);
|
||||
assert_eq!(table_count, 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ pub struct AudioStream {
|
|||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SubtitleStream {
|
||||
pub language: Option<String>,
|
||||
/// mov_text (mp4's timed-text subtitle codec) isn't valid inside a
|
||||
/// Matroska container — a transcode pipeline that always outputs `.mkv`
|
||||
/// needs to know this per-stream to convert rather than blindly stream
|
||||
/// copy. See `transcode::subtitle_codec_args`.
|
||||
pub codec: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
|
|
@ -109,6 +114,16 @@ pub fn probe(path: &Path) -> Result<MediaProbe> {
|
|||
let json: serde_json::Value =
|
||||
serde_json::from_slice(&output.stdout).context("ffprobe output was not valid JSON")?;
|
||||
|
||||
Ok(build_media_probe(&json, raw_json))
|
||||
}
|
||||
|
||||
/// The actual JSON-to-`MediaProbe` mapping, split out from `probe` so it can
|
||||
/// be unit-tested directly against hand-built ffprobe-shaped JSON — real
|
||||
/// muxers are inconsistent enough about stream ordering/disposition (see
|
||||
/// `probe_finds_the_real_video_stream_even_when_attached_pic_comes_first`'s
|
||||
/// doc comment) that constructing every case as an actual file via `ffmpeg`
|
||||
/// isn't always practical.
|
||||
fn build_media_probe(json: &serde_json::Value, raw_json: String) -> MediaProbe {
|
||||
let format = &json["format"];
|
||||
let duration_secs = format["duration"]
|
||||
.as_str()
|
||||
|
|
@ -134,11 +149,18 @@ pub fn probe(path: &Path) -> Result<MediaProbe> {
|
|||
.as_str()
|
||||
.or_else(|| stream["tags"]["LANGUAGE"].as_str())
|
||||
.map(str::to_string);
|
||||
let is_attached_pic = stream["disposition"]["attached_pic"].as_i64() == Some(1);
|
||||
match codec_type {
|
||||
"video" if probe.video_codec.is_none() => {
|
||||
// First video stream only — a second "video" stream in a
|
||||
// real-world file is almost always an embedded cover-art
|
||||
// thumbnail, not a second picture track.
|
||||
// First non-attached-pic video stream — a second "video" stream
|
||||
// in a real-world file is almost always an embedded cover-art
|
||||
// thumbnail, not a second picture track, and ffmpeg does not
|
||||
// guarantee it comes *after* the real content stream (some mp4
|
||||
// remuxes and mkvmerge outputs put it first). Explicitly
|
||||
// checking `disposition.attached_pic` rather than relying on
|
||||
// stream order means a cover-art-first file no longer has its
|
||||
// actual video codec/height silently replaced by the
|
||||
// thumbnail's.
|
||||
"video" if probe.video_codec.is_none() && !is_attached_pic => {
|
||||
probe.video_codec = stream["codec_name"].as_str().map(str::to_string);
|
||||
probe.width = stream["width"].as_i64();
|
||||
probe.height = stream["height"].as_i64();
|
||||
|
|
@ -164,13 +186,14 @@ pub fn probe(path: &Path) -> Result<MediaProbe> {
|
|||
});
|
||||
}
|
||||
"subtitle" => {
|
||||
probe.subtitles.push(SubtitleStream { language });
|
||||
let codec = stream["codec_name"].as_str().map(str::to_string);
|
||||
probe.subtitles.push(SubtitleStream { language, codec });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(probe)
|
||||
probe
|
||||
}
|
||||
|
||||
/// ffprobe reports frame rate as a "num/den" fraction string (e.g.
|
||||
|
|
@ -216,6 +239,59 @@ pub fn verify_decodable(path: &Path) -> Result<DecodeCheck> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Bounded, sampled variant of `verify_decodable` for callers where a full
|
||||
/// decode's O(duration) cost is the actual bottleneck — the transcode
|
||||
/// pipeline measured this in practice: two concurrent full-file decode
|
||||
/// verifications pinned two CPU cores at 400%+ each for the whole
|
||||
/// verification pass, dwarfing the GPU encode time itself for long files.
|
||||
///
|
||||
/// Decodes only fixed-size windows (`sample_secs` each) near the start,
|
||||
/// middle, and end of the file, rather than every frame — a deliberate
|
||||
/// trade of "catches most real corruption cheaply" for "bounded cost
|
||||
/// regardless of file length", not equivalent thoroughness to a full
|
||||
/// decode. Truncation specifically doesn't need this: `encode_and_verify`'s
|
||||
/// separate duration-match check against the original already catches that
|
||||
/// regardless of what this function samples, since a truncated output's
|
||||
/// container-reported duration comes up short either way.
|
||||
///
|
||||
/// Falls back to a full `verify_decodable` when `duration_secs` is small
|
||||
/// enough that sampling wouldn't save meaningful time anyway.
|
||||
pub fn verify_decodable_sampled(
|
||||
path: &Path,
|
||||
duration_secs: f64,
|
||||
sample_secs: f64,
|
||||
) -> Result<DecodeCheck> {
|
||||
if duration_secs <= sample_secs * 3.0 {
|
||||
return verify_decodable(path);
|
||||
}
|
||||
|
||||
let windows = [
|
||||
0.0,
|
||||
(duration_secs / 2.0 - sample_secs / 2.0).max(0.0),
|
||||
(duration_secs - sample_secs).max(0.0),
|
||||
];
|
||||
|
||||
for start in windows {
|
||||
let mut cmd = Command::new("ffmpeg");
|
||||
cmd.args(["-v", "error", "-xerror"]);
|
||||
if start > 0.0 {
|
||||
cmd.args(["-ss", &format!("{start:.2}")]);
|
||||
}
|
||||
cmd.arg("-i").arg(path);
|
||||
cmd.args(["-t", &format!("{sample_secs:.2}"), "-f", "null", "-"]);
|
||||
let output = cmd
|
||||
.output()
|
||||
.context("failed to run ffmpeg for sampled decode verification")?;
|
||||
|
||||
if !(output.status.success() && output.stderr.is_empty()) {
|
||||
return Ok(DecodeCheck::Corrupt(
|
||||
String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(DecodeCheck::Ok)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -297,6 +373,78 @@ mod tests {
|
|||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
fn generate_clip(dir: &Path, name: &str, duration_secs: u32) -> std::path::PathBuf {
|
||||
let path = dir.join(name);
|
||||
let status = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
&format!("testsrc=size=320x240:duration={duration_secs}:rate=5"),
|
||||
])
|
||||
.args(["-c:v", "libx264", "-preset", "ultrafast"])
|
||||
.arg(&path)
|
||||
.output()
|
||||
.expect("failed to run ffmpeg to generate a test clip");
|
||||
assert!(
|
||||
status.status.success(),
|
||||
"ffmpeg failed to generate a test clip: {}",
|
||||
String::from_utf8_lossy(&status.stderr)
|
||||
);
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_decodable_sampled_passes_a_genuinely_intact_short_file() {
|
||||
// Short enough to hit the "falls back to a full check" path.
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"breadarr-ffprobe-sampled-short-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let clip = generate_clip(&dir, "short.mkv", 2);
|
||||
|
||||
let result = verify_decodable_sampled(&clip, 2.0, 20.0).unwrap();
|
||||
assert!(matches!(result, DecodeCheck::Ok));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_decodable_sampled_passes_a_genuinely_intact_long_file() {
|
||||
// Long enough (duration > sample_secs * 3) to actually exercise the
|
||||
// windowed start/middle/end sampling path, not the short-file
|
||||
// fallback.
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"breadarr-ffprobe-sampled-long-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let clip = generate_clip(&dir, "long.mkv", 10);
|
||||
|
||||
let result = verify_decodable_sampled(&clip, 10.0, 2.0).unwrap();
|
||||
assert!(matches!(result, DecodeCheck::Ok));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_decodable_sampled_flags_a_file_that_does_not_decode_at_all() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"breadarr-ffprobe-sampled-corrupt-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("corrupt.mkv");
|
||||
std::fs::write(&path, b"this is not a real video file").unwrap();
|
||||
|
||||
let result = verify_decodable_sampled(&path, 10.0, 2.0).unwrap();
|
||||
assert!(matches!(result, DecodeCheck::Corrupt(_)));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// Generates a tiny real video via ffmpeg's `lavfi` synthetic source —
|
||||
/// validates the actual JSON field extraction (width/height/codec/
|
||||
/// duration/audio language+default) against genuine ffprobe output,
|
||||
|
|
@ -376,4 +524,82 @@ mod tests {
|
|||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
// Regression test for a real gap found in review: `probe`'s "first video
|
||||
// stream wins" selection used to have no idea about
|
||||
// `disposition.attached_pic` and just trusted stream order — a
|
||||
// convention real muxers don't reliably follow (ffmpeg's own mp4 muxer
|
||||
// was observed reordering an attached-pic stream to the *end*
|
||||
// regardless of requested `-map` order, which makes constructing a
|
||||
// genuine cover-art-*first* file via `ffmpeg` impractical — so this
|
||||
// exercises `build_media_probe` directly against hand-built,
|
||||
// real-shaped ffprobe JSON instead of a generated file, deterministically
|
||||
// covering the ordering `ffmpeg`'s own tooling won't produce). Unlike
|
||||
// `generate_clip_with_attached_pic` in `transcode::mod::tests` (cover
|
||||
// art second, the already-handled case), this puts it *first* to prove
|
||||
// the fix is order-independent, not just "skip the second video
|
||||
// stream".
|
||||
#[test]
|
||||
fn probe_finds_the_real_video_stream_even_when_attached_pic_comes_first() {
|
||||
let json = serde_json::json!({
|
||||
"format": { "duration": "10.0", "bit_rate": "5000000", "format_long_name": "Matroska / WebM" },
|
||||
"streams": [
|
||||
{
|
||||
"index": 0,
|
||||
"codec_type": "video",
|
||||
"codec_name": "png",
|
||||
"width": 64,
|
||||
"height": 64,
|
||||
"disposition": { "attached_pic": 1 }
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"codec_type": "video",
|
||||
"codec_name": "h264",
|
||||
"width": 640,
|
||||
"height": 360,
|
||||
"disposition": { "attached_pic": 0 }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let probe = build_media_probe(&json, "{}".to_string());
|
||||
assert_eq!(probe.width, Some(640), "must pick the real content stream's width, not the 64x64 cover art's");
|
||||
assert_eq!(probe.height, Some(360), "must pick the real content stream's height, not the 64x64 cover art's");
|
||||
assert_eq!(probe.video_codec.as_deref(), Some("h264"), "must pick the real content stream's codec, not the cover art's png");
|
||||
}
|
||||
|
||||
// Companion case: attached-pic *second* (the ordering the code
|
||||
// previously assumed was the only one) must still work exactly as
|
||||
// before — this fix is additive, not a behavior change for the
|
||||
// already-handled ordering.
|
||||
#[test]
|
||||
fn probe_finds_the_real_video_stream_when_attached_pic_comes_second() {
|
||||
let json = serde_json::json!({
|
||||
"format": { "duration": "10.0", "bit_rate": "5000000", "format_long_name": "Matroska / WebM" },
|
||||
"streams": [
|
||||
{
|
||||
"index": 0,
|
||||
"codec_type": "video",
|
||||
"codec_name": "h264",
|
||||
"width": 640,
|
||||
"height": 360,
|
||||
"disposition": { "attached_pic": 0 }
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"codec_type": "video",
|
||||
"codec_name": "png",
|
||||
"width": 64,
|
||||
"height": 64,
|
||||
"disposition": { "attached_pic": 1 }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let probe = build_media_probe(&json, "{}".to_string());
|
||||
assert_eq!(probe.width, Some(640));
|
||||
assert_eq!(probe.height, Some(360));
|
||||
assert_eq!(probe.video_codec.as_deref(), Some("h264"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct JellyfinClient {
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
|
|
@ -38,4 +39,33 @@ impl JellyfinClient {
|
|||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Counts sessions Jellyfin is actively transcoding for right now (as
|
||||
/// opposed to direct-play/direct-stream, which cost the GPU nothing) —
|
||||
/// used to throttle the AV1 batch-transcode worker back so it doesn't
|
||||
/// contend with a real viewer for the same encode/decode engines.
|
||||
/// `TranscodingInfo` is only present on a session object while that
|
||||
/// session is actually transcoding.
|
||||
pub async fn active_transcode_sessions(&self) -> Result<usize> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/Sessions", self.base_url))
|
||||
.header("X-Emby-Token", &self.api_key)
|
||||
.send()
|
||||
.await
|
||||
.context("jellyfin sessions request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
bail!("jellyfin sessions request failed: status={status} body={body:?}");
|
||||
}
|
||||
|
||||
let sessions: Vec<serde_json::Value> =
|
||||
resp.json().await.context("failed to parse jellyfin sessions response")?;
|
||||
Ok(sessions
|
||||
.iter()
|
||||
.filter(|s| !s["TranscodingInfo"].is_null())
|
||||
.count())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ pub struct ScanReport {
|
|||
pub unmatched: Vec<String>,
|
||||
pub files_linked: usize,
|
||||
pub files_renamed: usize,
|
||||
pub files_reorganized: usize,
|
||||
}
|
||||
|
||||
fn get_episode_title(conn: &Connection, episode_id: i64) -> Result<Option<String>> {
|
||||
|
|
@ -525,6 +526,30 @@ pub async fn scan_tv_root(
|
|||
}
|
||||
};
|
||||
|
||||
// Files imported before the season-folder convention existed
|
||||
// (or moved around by hand) can still be sitting flat in the
|
||||
// show root — move them under `Season NN` now rather than just
|
||||
// recording wherever they happen to already be. Same
|
||||
// filesystem as `series_dir`, so this is a plain rename.
|
||||
let season_folder = importer::season_dir(&series_dir.to_string_lossy(), season);
|
||||
let final_path = if final_path.parent() != Some(season_folder.as_path()) {
|
||||
match std::fs::create_dir_all(&season_folder).and_then(|_| {
|
||||
let dest = season_folder.join(final_path.file_name().unwrap());
|
||||
std::fs::rename(&final_path, &dest).map(|_| dest)
|
||||
}) {
|
||||
Ok(dest) => {
|
||||
report.files_reorganized += 1;
|
||||
dest
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(file = %final_path.display(), error = %e, "season-folder move failed, keeping in place");
|
||||
final_path
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final_path
|
||||
};
|
||||
|
||||
let size = std::fs::metadata(&final_path)?.len();
|
||||
conn.execute(
|
||||
"INSERT INTO episode_file (episode_id, path, size_bytes, subtitle_status) VALUES (?1, ?2, ?3, 'none')",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ mod qbit;
|
|||
mod scheduler;
|
||||
mod scoring;
|
||||
mod sources;
|
||||
mod transcode;
|
||||
|
||||
use std::env;
|
||||
|
||||
|
|
@ -114,9 +115,18 @@ async fn main() -> Result<()> {
|
|||
Some("probe-library") => {
|
||||
return probe_library_cmd(&config).await;
|
||||
}
|
||||
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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
|
@ -138,6 +148,11 @@ async fn run_daemon(config: Config) -> Result<()> {
|
|||
let conn = Connection::open(config.db_path())?;
|
||||
db::init(&conn)?;
|
||||
info!(path = %config.db_path().display(), "database ready");
|
||||
match transcode::reset_orphaned_running_jobs(&conn) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => info!(n, "reset orphaned 'running' transcode jobs left over from a previous crash"),
|
||||
Err(e) => tracing::warn!(error = %e, "failed to reset orphaned transcode jobs"),
|
||||
}
|
||||
|
||||
// A second, independent connection for the HTTP API rather than sharing
|
||||
// `background_loop`'s. Both point at the same on-disk (WAL-mode)
|
||||
|
|
@ -349,6 +364,19 @@ async fn background_loop(
|
|||
let mut upgrade_ticker = tokio::time::interval(std::time::Duration::from_secs(
|
||||
config.sources.upgrade_poll_interval_secs,
|
||||
));
|
||||
// Guards against the transcode ticker itself blocking every other cycle
|
||||
// (import, search, upgrade, reconcile) for the full multi-minute
|
||||
// duration of an encode — the ticker below spawns each cycle detached
|
||||
// rather than awaiting it inline, and this is what stops two spawned
|
||||
// cycles from running at once if one is still going when the next tick
|
||||
// fires (a real possibility: files easily take longer to encode than
|
||||
// `poll_interval_secs`). `claim_pending_jobs`'s own concurrency cap
|
||||
// already makes overlap *safe*; this just keeps it from happening
|
||||
// pointlessly.
|
||||
let transcode_busy = std::sync::Arc::new(tokio::sync::Mutex::new(()));
|
||||
let mut transcode_ticker = tokio::time::interval(std::time::Duration::from_secs(
|
||||
config.transcode.poll_interval_secs,
|
||||
));
|
||||
// Disk state doesn't change on its own — hourly is plenty to catch a
|
||||
// file deleted/moved by hand without adding meaningful load (one query
|
||||
// per tracked episode file, all local). Deliberately does *not* fire at
|
||||
|
|
@ -366,6 +394,7 @@ async fn background_loop(
|
|||
search_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
upgrade_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
reconcile_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
transcode_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
// Cycle-level backoff on top of the search loop's own per-mirror
|
||||
// cooldowns: a whole cycle failing (source exhausted, or an outright
|
||||
|
|
@ -378,6 +407,9 @@ async fn background_loop(
|
|||
loop {
|
||||
tokio::select! {
|
||||
_ = grab_ticker.tick() => {
|
||||
if !config.sources.grab_enabled {
|
||||
continue;
|
||||
}
|
||||
let result = {
|
||||
let conn = conn.lock().await;
|
||||
scheduler::run_grab_cycle(&conn, &nyaa_source, 1, &mut title_matcher, &qbit, &config.qbit.category).await
|
||||
|
|
@ -404,7 +436,7 @@ async fn background_loop(
|
|||
_ = import_ticker.tick() => {
|
||||
let result = {
|
||||
let conn = conn.lock().await;
|
||||
importer::run_import_cycle(&conn, &qbit, jellyfin.as_ref(), &config.qbit.category, &config.qbit.container_downloads_path, &config.qbit.host_downloads_path).await
|
||||
importer::run_import_cycle(&conn, &qbit, jellyfin.as_ref(), &config.qbit.category, &config.qbit.container_downloads_path, &config.qbit.host_downloads_path, config.transcode.enabled.then_some(&config.transcode)).await
|
||||
};
|
||||
if let (Ok(stats), Some(n)) = (&result, ¬ifier) {
|
||||
if stats.failed > 0 {
|
||||
|
|
@ -533,6 +565,38 @@ async fn background_loop(
|
|||
}
|
||||
}
|
||||
}
|
||||
_ = transcode_ticker.tick() => {
|
||||
if !config.transcode.enabled {
|
||||
continue;
|
||||
}
|
||||
let Ok(busy_permit) = transcode_busy.clone().try_lock_owned() else {
|
||||
// A previous cycle is still running (this file's
|
||||
// encode took longer than one poll interval) — skip
|
||||
// this tick rather than spawning a second overlapping
|
||||
// one; the still-running cycle will pick up any newly
|
||||
// pending jobs on its own next iteration anyway.
|
||||
continue;
|
||||
};
|
||||
let conn = conn.clone();
|
||||
let cfg = config.transcode.clone();
|
||||
let jellyfin = jellyfin.clone();
|
||||
let cycle_status = cycle_status.clone();
|
||||
tokio::spawn(async move {
|
||||
let _permit = busy_permit;
|
||||
let result = transcode::run_cycle(conn, cfg, jellyfin.as_ref()).await;
|
||||
let record = match &result {
|
||||
Ok(stats) => {
|
||||
info!(?stats, "transcode cycle complete");
|
||||
api::CycleRecord { at: chrono::Utc::now(), ok: true, detail: format!("{stats:?}") }
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "transcode cycle failed");
|
||||
api::CycleRecord { at: chrono::Utc::now(), ok: false, detail: e.to_string() }
|
||||
}
|
||||
};
|
||||
cycle_status.lock().expect("cycle_status poisoned").last_transcode = Some(record);
|
||||
});
|
||||
}
|
||||
_ = reconcile_ticker.tick() => {
|
||||
let result = {
|
||||
let conn = conn.lock().await;
|
||||
|
|
@ -846,6 +910,7 @@ async fn debug_import_cycle(config: &Config) -> Result<()> {
|
|||
&config.qbit.category,
|
||||
&config.qbit.container_downloads_path,
|
||||
&config.qbit.host_downloads_path,
|
||||
config.transcode.enabled.then_some(&config.transcode),
|
||||
)
|
||||
.await?;
|
||||
println!("{stats:?}");
|
||||
|
|
@ -927,11 +992,12 @@ async fn debug_scan_tv(config: &Config, path: &str) -> Result<()> {
|
|||
.await?;
|
||||
|
||||
println!(
|
||||
"matched={} unmatched={} files_linked={} files_renamed={}",
|
||||
"matched={} unmatched={} files_linked={} files_renamed={} files_reorganized={}",
|
||||
report.matched.len(),
|
||||
report.unmatched.len(),
|
||||
report.files_linked,
|
||||
report.files_renamed
|
||||
report.files_renamed,
|
||||
report.files_reorganized
|
||||
);
|
||||
if !report.unmatched.is_empty() {
|
||||
println!("unmatched:");
|
||||
|
|
@ -974,11 +1040,12 @@ async fn debug_scan_movies(config: &Config, path: &str) -> Result<()> {
|
|||
.await?;
|
||||
|
||||
println!(
|
||||
"matched={} unmatched={} files_linked={} files_renamed={}",
|
||||
"matched={} unmatched={} files_linked={} files_renamed={} files_reorganized={}",
|
||||
report.matched.len(),
|
||||
report.unmatched.len(),
|
||||
report.files_linked,
|
||||
report.files_renamed
|
||||
report.files_renamed,
|
||||
report.files_reorganized
|
||||
);
|
||||
if !report.unmatched.is_empty() {
|
||||
println!("unmatched:");
|
||||
|
|
@ -1148,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)?;
|
||||
|
|
@ -1170,6 +1275,128 @@ async fn probe_library_cmd(config: &Config) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// One-time backfill: enqueues every existing-library file eligible for
|
||||
/// AV1 transcoding (see `transcode::find_backlog_candidates` for the exact
|
||||
/// eligibility rules — not already AV1, not anime, not HDR/2160p+ per the
|
||||
/// first pass's scope) as a `transcode_job` row, then drives the same
|
||||
/// worker loop the daemon's steady-state ticker uses
|
||||
/// (`transcode::run_cycle`) until nothing is left pending. Shares that one
|
||||
/// code path deliberately — there is exactly one place that actually runs
|
||||
/// an encode, whether triggered by a backlog sweep or a fresh grab.
|
||||
// Deliberately does NOT call `transcode::reset_orphaned_running_jobs` on
|
||||
// startup the way `run_daemon` does — from this CLI's vantage point a
|
||||
// `running` row could belong to the actual daemon's own ticker legitimately
|
||||
// working on it right now (see the safety writeup on `claim_pending_jobs`:
|
||||
// running this backfill alongside a live daemon with transcode enabled is
|
||||
// intentionally supported, the two now share one atomic, count-aware
|
||||
// concurrency cap), and there's no reliable way to tell that apart from a
|
||||
// genuinely orphaned row from a ago-crashed run of this same command.
|
||||
// If *this* command itself is killed mid-run, its claimed jobs stay
|
||||
// `running` until the daemon is next restarted (which does its own reset).
|
||||
async fn transcode_library_cmd(config: &Config) -> Result<()> {
|
||||
if !config.transcode.enabled {
|
||||
bail!("transcode.enabled is false in config — enable it before running a backfill");
|
||||
}
|
||||
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_backlog_candidates(&conn, &config.transcode)?;
|
||||
println!(
|
||||
"found {} backlog candidate(s), highest-bitrate 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,
|
||||
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 {
|
||||
Some(JellyfinClient::new(
|
||||
config.jellyfin.base_url.clone(),
|
||||
config.jellyfin.api_key.clone(),
|
||||
))
|
||||
};
|
||||
|
||||
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 {
|
||||
let stats =
|
||||
transcode::run_cycle(conn.clone(), config.transcode.clone(), jellyfin.as_ref()).await?;
|
||||
if stats.attempted == 0 {
|
||||
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_skipped} skipped (not beneficial), {total_failed} failed, {:.1} GB saved total",
|
||||
total_bytes_saved as f64 / 1_073_741_824.0
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs `importer::verify_library` — the expensive full-decode corruption
|
||||
/// check (`ffmpeg -xerror`, actually decoding every frame) against every
|
||||
/// header-probed-ok file that hasn't been decode-verified yet. Unlike
|
||||
|
|
|
|||
|
|
@ -96,6 +96,17 @@ impl TitleMatcher {
|
|||
})
|
||||
}
|
||||
|
||||
/// Caches by text — appropriate for candidate-side text only (library
|
||||
/// `media_item` titles/aliases, or a metadata provider's small,
|
||||
/// bounded result list), which the same daemon process legitimately
|
||||
/// re-embeds across many calls. Deliberately never used for the query
|
||||
/// side (see `embed_query`): `TitleMatcher` lives for the whole life of
|
||||
/// `background_loop`, which never returns, and the query is a
|
||||
/// freshly-parsed release title from every RSS item and search result
|
||||
/// the daemon ever sees — almost never repeated verbatim. Caching those
|
||||
/// too grew this `HashMap` without bound for the process's entire
|
||||
/// (months-long) lifetime, a slow but real leak on a box also running
|
||||
/// several GB of concurrent GPU/CPU transcode work.
|
||||
fn embed_cached(&mut self, text: &str) -> Result<Vec<f32>> {
|
||||
if let Some(v) = self.cache.get(text) {
|
||||
return Ok(v.clone());
|
||||
|
|
@ -105,6 +116,12 @@ impl TitleMatcher {
|
|||
Ok(v)
|
||||
}
|
||||
|
||||
/// The query-side counterpart to `embed_cached` — same embedding, never
|
||||
/// stored in `self.cache`. See `embed_cached`'s doc comment for why.
|
||||
fn embed_query(&mut self, text: &str) -> Result<Vec<f32>> {
|
||||
self.embedder.embed(text)
|
||||
}
|
||||
|
||||
/// Given a flat list of candidate texts (e.g. every search result's
|
||||
/// name plus its aliases, flattened with an index back to which result
|
||||
/// each one belongs to), returns the index of whichever candidate text
|
||||
|
|
@ -121,7 +138,7 @@ impl TitleMatcher {
|
|||
query: &str,
|
||||
candidates: &[(usize, String)],
|
||||
) -> Result<Option<(usize, f32)>> {
|
||||
let query_emb = self.embed_cached(query)?;
|
||||
let query_emb = self.embed_query(query)?;
|
||||
let mut best: Option<(usize, f32)> = None;
|
||||
for (owner_index, text) in candidates {
|
||||
let emb = self.embed_cached(text)?;
|
||||
|
|
@ -137,7 +154,7 @@ impl TitleMatcher {
|
|||
/// aliases, returning the single best match and whether it clears the
|
||||
/// auto-match bar or needs a human to confirm it in the review queue.
|
||||
pub fn match_title(&mut self, conn: &Connection, query: &str) -> Result<MatchOutcome> {
|
||||
let query_emb = self.embed_cached(query)?;
|
||||
let query_emb = self.embed_query(query)?;
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, title FROM media_item WHERE monitored = 1
|
||||
|
|
|
|||
|
|
@ -97,23 +97,34 @@ pub fn insert_series(
|
|||
|
||||
let mut seasons_seen = HashSet::new();
|
||||
for ep in episodes {
|
||||
// TVDB's "season 0" is a catch-all for specials — recaps, shorts,
|
||||
// and often a tie-in movie that's frequently already tracked as
|
||||
// its own separate `media_item` (verified live: Chainsaw Man's
|
||||
// season 0 included "Chainsaw Man – The Movie: Reze Arc", which
|
||||
// already exists as its own movie entry). Monitoring these by
|
||||
// default means the library never actually "completes" and the
|
||||
// missing-episode count is inflated with content the user was
|
||||
// never trying to acquire as episodes in the first place. Regular
|
||||
// seasons keep the previous default of monitored.
|
||||
let monitored = i64::from(ep.season_number != 0);
|
||||
if seasons_seen.insert(ep.season_number) {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO season (media_item_id, season_number, monitored) VALUES (?1, ?2, 1)",
|
||||
params![media_item_id, ep.season_number],
|
||||
"INSERT OR IGNORE INTO season (media_item_id, season_number, monitored) VALUES (?1, ?2, ?3)",
|
||||
params![media_item_id, ep.season_number, monitored],
|
||||
)?;
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO episode
|
||||
(media_item_id, season_number, episode_number, absolute_number, title, air_date, monitored, has_file)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 0)",
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0)",
|
||||
params![
|
||||
media_item_id,
|
||||
ep.season_number,
|
||||
ep.episode_number,
|
||||
ep.absolute_number,
|
||||
ep.title,
|
||||
ep.air_date
|
||||
ep.air_date,
|
||||
monitored,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,8 +110,6 @@ impl TvdbClient {
|
|||
}
|
||||
|
||||
pub async fn episodes(&self, series_id: &str) -> Result<Vec<EpisodeInfo>> {
|
||||
let token = self.token().await?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EpisodesResponse {
|
||||
data: EpisodesData,
|
||||
|
|
@ -131,20 +129,47 @@ impl TvdbClient {
|
|||
aired: Option<String>,
|
||||
}
|
||||
|
||||
let resp: EpisodesResponse = self
|
||||
.client
|
||||
.get(format!(
|
||||
"https://api4.thetvdb.com/v4/series/{series_id}/episodes/default"
|
||||
))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.context("tvdb episodes request failed")?
|
||||
.error_for_status()
|
||||
.context("tvdb episodes returned an error status")?
|
||||
.json()
|
||||
.await
|
||||
.context("tvdb episodes response was not valid JSON")?;
|
||||
// TVDB's plain `/episodes/default` returns names in the show's
|
||||
// original airing language — for a lot of anime that's Japanese,
|
||||
// with no English name at all (verified live: entire shows came
|
||||
// back Japanese-only). `/episodes/default/eng` is the same episode
|
||||
// list (same numbering/air-date fields) but with TVDB's own
|
||||
// crowd-sourced English translation substituted in for `name`
|
||||
// wherever one exists — a real translation, not a guess, so it's
|
||||
// tried first and only falls back to the original-language
|
||||
// endpoint if TVDB has no English data for this show at all.
|
||||
let token = self.token().await?;
|
||||
let fetch = |url: String| {
|
||||
let client = self.client.clone();
|
||||
let token = token.clone();
|
||||
async move {
|
||||
client
|
||||
.get(url)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.context("tvdb episodes request failed")?
|
||||
.error_for_status()
|
||||
.context("tvdb episodes returned an error status")?
|
||||
.json::<EpisodesResponse>()
|
||||
.await
|
||||
.context("tvdb episodes response was not valid JSON")
|
||||
}
|
||||
};
|
||||
|
||||
let resp = match fetch(format!(
|
||||
"https://api4.thetvdb.com/v4/series/{series_id}/episodes/default/eng"
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(_) => {
|
||||
fetch(format!(
|
||||
"https://api4.thetvdb.com/v4/series/{series_id}/episodes/default"
|
||||
))
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(resp
|
||||
.data
|
||||
|
|
|
|||
|
|
@ -131,6 +131,16 @@ mod tests {
|
|||
assert_eq!(p.title_normalized, "Ascendance of a Bookworm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_sxxexx_with_a_trailing_fansub_revision_tag() {
|
||||
// "v2" glued directly onto the episode number ("a fixed re-release
|
||||
// of this episode") previously broke the word-boundary check
|
||||
// entirely, leaving season/episode both None.
|
||||
let p = parse("[Judas] Chainsaw Man - S01E01v2 1080p WEB-DL");
|
||||
assert_eq!(p.season, Some(1));
|
||||
assert_eq!(p.episode, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_subsplease_dash_episode_with_group_and_hash() {
|
||||
let p = parse("[SubsPlease] Honzuki no Gekokujou S4 - 13 (1080p) [A4FE0990].mkv");
|
||||
|
|
@ -171,6 +181,78 @@ mod tests {
|
|||
assert_eq!(p.resolution, Some(1080));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_season_pack_shapes_without_literal_parens() {
|
||||
// Real review-queue entries that all failed to resolve at all
|
||||
// before this fix — season came back None, not just unmatched
|
||||
// episode — because SEASON_PACK_RE required a literal
|
||||
// "(S?N Complete)" shape.
|
||||
assert_eq!(
|
||||
parse("Modern.Family.S10.COMPLETE.720p.AMZN.WEBRip.x264-GalaxyTV").season,
|
||||
Some(10)
|
||||
);
|
||||
assert_eq!(
|
||||
parse("Modern Family 2009 Season 8 Complete 720p AMZN WEBRip x264 [i_c]").season,
|
||||
Some(8)
|
||||
);
|
||||
assert_eq!(
|
||||
parse("Red.Dwarf.S04.1080p.BluRay.x264-LATENCY [Season 4 Four Complete]").season,
|
||||
Some(4)
|
||||
);
|
||||
assert_eq!(
|
||||
parse("Red.Dwarf.S11.1080p.BluRay.x264-SHORTBREHD [Season 11 Eleven]").season,
|
||||
Some(11)
|
||||
);
|
||||
assert_eq!(
|
||||
parse("Game of Thrones - Season 8 S08 - 2019 1080p Bluray AAC5.1 x264-R").season,
|
||||
Some(8)
|
||||
);
|
||||
}
|
||||
|
||||
// Regression test for a real gap found in review: "Season 2 - 25" was
|
||||
// first swallowed whole by `looks_like_episode_range` (its own
|
||||
// `BARE_EPISODE_RANGE_RE` skips over the un-matchable "Season" word and
|
||||
// finds its first real match at "2 - 25", mistaking the season marker's
|
||||
// own number for a range start), and even with that fixed,
|
||||
// `extract_episode_info`'s `SEASON_PACK_RE` branch used to return
|
||||
// season-only and never look for a trailing episode number at all.
|
||||
// Either bug alone drops episode 25 silently.
|
||||
#[test]
|
||||
fn parses_a_season_marker_followed_by_a_dash_episode() {
|
||||
let p = parse("[Erai-raws] Some Show Season 2 - 25 [1080p]");
|
||||
assert_eq!(p.season, Some(2));
|
||||
assert_eq!(p.episode, Some(25));
|
||||
}
|
||||
|
||||
// Companion case: a genuine season-only pack (no trailing dash-episode
|
||||
// anywhere) must still resolve to season-only, not spuriously pick up
|
||||
// an unrelated number as an episode.
|
||||
#[test]
|
||||
fn a_genuine_season_only_pack_with_no_dash_episode_still_has_no_episode() {
|
||||
let p = parse("Some Show Season 2 Complete [1080p]");
|
||||
assert_eq!(p.season, Some(2));
|
||||
assert_eq!(p.episode, None);
|
||||
}
|
||||
|
||||
// Regression test for a real gap found in review: a date-named release
|
||||
// ("2024-01-15") got misread by `BARE_EPISODE_RANGE_RE` as an episode
|
||||
// range — the 4-digit year is too many digits for `\d{1,3}` to match
|
||||
// whole, so its first real match starts at the month/day pair
|
||||
// ("01-15") instead, and `looks_like_episode_range` treats that as a
|
||||
// real range. Asserted directly against the tokenizer rather than
|
||||
// `parse()`, since a false-positive range and a genuine "no episode
|
||||
// marker at all" both surface identically as `None`/`None` on
|
||||
// `ParsedRelease` — `looks_like_episode_range` returning `false` is the
|
||||
// actual fix being tested here.
|
||||
#[test]
|
||||
fn does_not_mistake_a_yyyy_mm_dd_date_for_an_episode_range() {
|
||||
assert!(!tokens::looks_like_episode_range("Some Daily Show 2024-01-15 1080p WEB-DL"));
|
||||
|
||||
let p = parse("Some Daily Show 2024-01-15 1080p WEB-DL");
|
||||
assert_eq!(p.season, None);
|
||||
assert_eq!(p.episode, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_yameii_dash_sxxexx_with_english_dub_tag() {
|
||||
let p = parse("[Yameii] Ascendance of a Bookworm - S04E11 [English Dub] [CR WEB-DL 1080p H264 AAC] [8ACE7B72] (Honzuki no Gekokujou)");
|
||||
|
|
|
|||
|
|
@ -38,12 +38,33 @@ static YEAR_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[(\[](19|20)\d{2
|
|||
static BARE_YEAR_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\b((?:19|20)\d{2})\b").unwrap());
|
||||
|
||||
// The trailing `v\d+` is a fansub revision tag ("v2" = "second release of
|
||||
// this episode, fixed encode/subs") stuck directly onto the episode number
|
||||
// with no separator — "S01E01v2". Without consuming it before the `\b`,
|
||||
// the boundary check fails outright (digit→letter isn't a word boundary),
|
||||
// so the whole pattern silently doesn't match and the file falls through
|
||||
// to unparsed (verified live: every v2 release of several shows, e.g. an
|
||||
// entire show that only had v2 releases, ended up with zero linked
|
||||
// episode files during a library scan).
|
||||
static SXXEXX_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)\bS(\d{1,2})E(\d{1,3})\b").unwrap());
|
||||
LazyLock::new(|| Regex::new(r"(?i)\bS(\d{1,2})E(\d{1,3})(?:v\d+)?\b").unwrap());
|
||||
static SXX_DASH_EP_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)\bS(\d{1,2})\s*-\s*(\d{1,3})\b").unwrap());
|
||||
// A bare season marker with no episode number attached — "S01", "Season 8",
|
||||
// "Season.1", optionally with a trailing "Complete"/spelled-out season word
|
||||
// (e.g. "[Season 4 Four Complete]") that's irrelevant to the number itself.
|
||||
// Previously required literal parens around an explicit "S?N Complete)"
|
||||
// shape, matching only one specific release-group convention; real
|
||||
// releases routinely drop the parens, drop "Complete" entirely (e.g.
|
||||
// "Game of Thrones - Season 8 S08 - 2019"), or spell "Season" out with a
|
||||
// dot instead of a space (verified live: several real review-queue entries
|
||||
// failed to resolve at all — season came back `None` — because none of
|
||||
// these shapes matched the old pattern). Only reached after
|
||||
// `SXXEXX_RE`/`SXX_DASH_EP_RE` have already failed to find an actual
|
||||
// episode number, so treating a bare season marker as a pack signal here is
|
||||
// safe.
|
||||
static SEASON_PACK_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)\(S?(\d{1,2})\s*Complete\)").unwrap());
|
||||
LazyLock::new(|| Regex::new(r"(?i)\bS(?:eason)?\.?\s*(\d{1,2})\b").unwrap());
|
||||
static DASH_EPISODE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"-\s*(\d{1,3})\b").unwrap());
|
||||
|
||||
// A batch/season-pack release covering many episodes in one torrent.
|
||||
|
|
@ -69,15 +90,46 @@ static SXX_EPISODE_RANGE_RE: LazyLock<Regex> =
|
|||
// elsewhere in the title with a smaller trailing number.
|
||||
static BARE_EPISODE_RANGE_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\b(\d{1,3})\s*[-~]\s*(\d{1,3})\b").unwrap());
|
||||
// A `YYYY-MM-DD` date ("2024-01-15"): the year's 4 digits are too many for
|
||||
// `BARE_EPISODE_RANGE_RE`'s/`DASH_EPISODE_RE`'s `\d{1,3}` to match as a
|
||||
// whole, so those regexes' *first real match* on a date-named release ends
|
||||
// up starting at the month ("01-15", or "01" alone) instead — a bare
|
||||
// month/day pair, not a real episode range or episode number. Matched as a
|
||||
// whole date span (not just a "year-" prefix check) so both the month *and*
|
||||
// the day segment are covered — checking only the text immediately before a
|
||||
// candidate match would still let "15" in "2024-01-15" slip through as a
|
||||
// false "episode 15" once "01" alone was correctly rejected.
|
||||
static DATE_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\b(?:19|20)\d{2}-\d{1,2}-\d{1,2}\b").unwrap());
|
||||
|
||||
fn overlaps_a_date(s: &str, start: usize, end: usize) -> bool {
|
||||
DATE_RE.find_iter(s).any(|d| d.start() <= start && end <= d.end())
|
||||
}
|
||||
|
||||
pub(super) fn looks_like_episode_range(s: &str) -> bool {
|
||||
if BATCH_WORD_RE.is_match(s) || SXX_EPISODE_RANGE_RE.is_match(s) {
|
||||
return true;
|
||||
}
|
||||
BARE_EPISODE_RANGE_RE.captures(s).is_some_and(|c| {
|
||||
let a: u32 = c[1].parse().unwrap_or(0);
|
||||
let b: u32 = c[2].parse().unwrap_or(0);
|
||||
b > a
|
||||
BARE_EPISODE_RANGE_RE.captures_iter(s).any(|c| {
|
||||
let first = c.get(1).unwrap();
|
||||
let second = c.get(2).unwrap();
|
||||
let a: u32 = first.as_str().parse().unwrap_or(0);
|
||||
let b: u32 = second.as_str().parse().unwrap_or(0);
|
||||
if b <= a {
|
||||
return false;
|
||||
}
|
||||
if overlaps_a_date(s, first.start(), second.end()) {
|
||||
return false;
|
||||
}
|
||||
// "Season 2 - 25": the range's first number is really a season
|
||||
// marker's own number (checked by comparing spans, not just text,
|
||||
// so this only fires when the two genuinely overlap), not a range
|
||||
// start — "2 - 25" isn't a real episode range, it's "season 2,
|
||||
// episode 25", resolved separately in `extract_episode_info`.
|
||||
let is_season_marker_number = SEASON_PACK_RE.captures(s).is_some_and(|sc| {
|
||||
sc.get(1).unwrap().range() == first.range()
|
||||
});
|
||||
!is_season_marker_number
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +196,23 @@ pub(super) fn extract_year(s: &str) -> Option<u32> {
|
|||
s[m.start()..m.end()].parse().ok()
|
||||
}
|
||||
|
||||
/// `DASH_EPISODE_RE`'s first match that isn't actually the month of a
|
||||
/// `YYYY-MM-DD` date. A bare `-\s*\d{1,3}\b` alone can't tell "Show - 25
|
||||
/// [1080p]" (a real episode number) apart from "...2024-01-15..." (the "01"
|
||||
/// is just a month, matched for the same reason `BARE_EPISODE_RANGE_RE`
|
||||
/// does in `looks_like_episode_range` — the 4-digit year is too many digits
|
||||
/// to match as a whole, so the regex's first real match starts one segment
|
||||
/// later). Reused by every `extract_episode_info` branch that falls back to
|
||||
/// `DASH_EPISODE_RE`, not just the range-detection path, since the date
|
||||
/// misread happens independently of whether `looks_like_episode_range`
|
||||
/// fires.
|
||||
fn find_real_dash_episode(s: &str) -> Option<regex::Captures<'_>> {
|
||||
DASH_EPISODE_RE.captures_iter(s).find(|c| {
|
||||
let m = c.get(0).unwrap();
|
||||
!overlaps_a_date(s, m.start(), m.end())
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns (season, episode, absolute_episode, title_span_end) — the last
|
||||
/// element is the byte offset in `s` where the episode/season token (or,
|
||||
/// failing that, the first quality marker) begins, used to slice out the
|
||||
|
|
@ -173,9 +242,19 @@ pub(super) fn extract_episode_info(s: &str) -> (Option<u32>, Option<u32>, Option
|
|||
}
|
||||
if let Some(c) = SEASON_PACK_RE.captures(s) {
|
||||
let season = c[1].parse().ok();
|
||||
// A season marker immediately followed elsewhere by a dash-number
|
||||
// ("Season 2 - 25") names one episode within that season, not a
|
||||
// season-only pack — checked here rather than reordering the checks
|
||||
// above `SXX_DASH_EP_RE`/`SXXEXX_RE` still get first crack at more
|
||||
// specific shapes, and a genuine season-only pack (no trailing
|
||||
// dash-number anywhere) is unaffected.
|
||||
if let Some(ep) = find_real_dash_episode(s) {
|
||||
let episode: Option<u32> = ep[1].parse().ok();
|
||||
return (season, episode, None, c.get(0).unwrap().start());
|
||||
}
|
||||
return (season, None, None, c.get(0).unwrap().start());
|
||||
}
|
||||
if let Some(c) = DASH_EPISODE_RE.captures(s) {
|
||||
if let Some(c) = find_real_dash_episode(s) {
|
||||
let episode: Option<u32> = c[1].parse().ok();
|
||||
return (None, episode, episode, c.get(0).unwrap().start());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,15 @@ impl std::fmt::Display for MagnetRejected {
|
|||
|
||||
impl std::error::Error for MagnetRejected {}
|
||||
|
||||
/// Newer qBittorrent WebUI API versions' `torrents/add` JSON response shape.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct AddTorrentResponse {
|
||||
#[serde(default)]
|
||||
success_count: u32,
|
||||
#[serde(default)]
|
||||
failure_count: u32,
|
||||
}
|
||||
|
||||
pub struct QbitClient {
|
||||
base_url: String,
|
||||
client: reqwest::Client,
|
||||
|
|
@ -101,7 +110,12 @@ impl QbitClient {
|
|||
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() || body.trim() != "Ok." {
|
||||
// Older qBittorrent WebUI API versions return 200 with body "Ok.";
|
||||
// newer ones return 204 No Content with an empty body instead. Bad
|
||||
// credentials return a real error status (401), which `is_success`
|
||||
// already catches — the response body's exact text isn't part of
|
||||
// the actual success contract, just an artifact of the old version.
|
||||
if !status.is_success() {
|
||||
bail!("qbit login failed: status={status} body={body:?}");
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -142,13 +156,21 @@ impl QbitClient {
|
|||
}
|
||||
// qBittorrent's add-torrent endpoint returns HTTP 200 even when
|
||||
// it rejects the magnet outright (a dead/malformed hash, one it
|
||||
// already knows is unreachable) — the *only* signal is the
|
||||
// response body text ("Ok." vs "Fails."). Without this check a
|
||||
// rejected magnet looks identical to a real success: the caller
|
||||
// records a `release` row as grabbed and nothing ever
|
||||
// downloads, silently and permanently (verified live — this
|
||||
// happened for a real release).
|
||||
if body.trim() != "Ok." {
|
||||
// already knows is unreachable) — the response body is the only
|
||||
// signal. Older qBittorrent versions returned plain text ("Ok."
|
||||
// vs "Fails."); newer ones return a JSON summary with
|
||||
// success_count/failure_count instead (verified live against
|
||||
// the currently deployed version). Without checking whichever
|
||||
// shape is actually in play, a rejected magnet looks identical
|
||||
// to a real success: the caller records a `release` row as
|
||||
// grabbed and nothing ever downloads, silently and permanently
|
||||
// (verified live — this happened for a real release under the
|
||||
// old text-only check).
|
||||
let rejected = match serde_json::from_str::<AddTorrentResponse>(&body) {
|
||||
Ok(r) => r.failure_count > 0 || r.success_count == 0,
|
||||
Err(_) => body.trim() != "Ok.",
|
||||
};
|
||||
if rejected {
|
||||
return Err(anyhow::Error::new(MagnetRejected { body }));
|
||||
}
|
||||
return Ok(());
|
||||
|
|
@ -204,10 +226,6 @@ impl QbitClient {
|
|||
unreachable!("loop always returns or bails on its second iteration")
|
||||
}
|
||||
|
||||
/// Moves a torrent's save location — qBittorrent physically relocates
|
||||
/// the underlying file(s) itself and continues seeding from the new
|
||||
/// path, rather than breadarr keeping a second permanent copy purely to
|
||||
/// satisfy its own import step.
|
||||
/// Held for the duration of an add-then-correlate-hash sequence — see
|
||||
/// `grab_lock`'s doc comment on why this needs to be process-wide, not
|
||||
/// just per-call.
|
||||
|
|
@ -215,10 +233,19 @@ impl QbitClient {
|
|||
self.grab_lock.lock().await
|
||||
}
|
||||
|
||||
pub async fn set_location(&self, hash: &str, location: &str) -> Result<()> {
|
||||
/// Removes a torrent from qBittorrent's own tracking after breadarr has
|
||||
/// already moved its data straight into the library — `delete_files:
|
||||
/// false` because by the time this is called there's nothing left at
|
||||
/// the torrent's original save path for qBittorrent to delete; leaving
|
||||
/// the torrent registered would just leave it sitting in an "files
|
||||
/// missing" error state indefinitely. Best-effort from the caller's
|
||||
/// side: a failure here never undoes or blocks the import that already
|
||||
/// succeeded, it just leaves one stale entry in the qBittorrent UI to
|
||||
/// clean up by hand.
|
||||
pub async fn delete_torrent(&self, hash: &str) -> Result<()> {
|
||||
self.post_form(
|
||||
"/api/v2/torrents/setLocation",
|
||||
&[("hashes", hash), ("location", location)],
|
||||
"/api/v2/torrents/delete",
|
||||
&[("hashes", hash), ("deleteFiles", "false")],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -123,6 +123,20 @@ fn looks_like_season_pack(parsed: &ParsedRelease) -> bool {
|
|||
parsed.season.is_some() && parsed.episode.is_none() && parsed.absolute_episode.is_none()
|
||||
}
|
||||
|
||||
/// Sort key for a batch of raw search results: season packs first (a pack
|
||||
/// clears every missing episode in one grab instead of one at a time, and
|
||||
/// each still goes through the normal seeder/quality gate in `process_item`
|
||||
/// — a pack with too few seeders is rejected there and iteration just falls
|
||||
/// through to the next candidate, so this never trades a viable single
|
||||
/// episode for an unviable pack), highest-seeded first within each group.
|
||||
fn release_sort_key(item: &RawReleaseItem) -> (std::cmp::Reverse<bool>, std::cmp::Reverse<u32>) {
|
||||
let is_pack = looks_like_season_pack(&parser::parse(&item.title));
|
||||
(
|
||||
std::cmp::Reverse(is_pack),
|
||||
std::cmp::Reverse(item.seeders.unwrap_or(0)),
|
||||
)
|
||||
}
|
||||
|
||||
/// True when a release's raw title carries an explicit non-video format
|
||||
/// marker — an ebook, audiobook, or comic that happens to share a
|
||||
/// monitored show/movie's title text, not an actual episode or film. A
|
||||
|
|
@ -208,7 +222,22 @@ fn movie_eligible_for_upgrade(conn: &Connection, media_item_id: i64) -> Result<b
|
|||
params![media_item_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(in_flight == 0)
|
||||
if in_flight > 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
// Same reasoning as the `upgrade_locked` check in
|
||||
// `enumerate_upgrade_targets`: a locally AV1-transcoded file is a
|
||||
// deliberate shrink, not something the upgrade loop should try to
|
||||
// replace with the next bigger HEVC/x264 release it finds.
|
||||
let upgrade_locked: i64 = conn
|
||||
.query_row(
|
||||
"SELECT upgrade_locked FROM episode_file
|
||||
WHERE media_item_id = ?1 AND episode_id IS NULL",
|
||||
params![media_item_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
Ok(upgrade_locked == 0)
|
||||
}
|
||||
|
||||
fn is_anime(conn: &Connection, tvdb_id: i64) -> Result<bool> {
|
||||
|
|
@ -460,12 +489,9 @@ async fn process_item(
|
|||
|
||||
let parsed = parser::parse(&item.title);
|
||||
|
||||
let candidate = match matcher.match_title(conn, &parsed.title_normalized)? {
|
||||
MatchOutcome::Auto(c) => c,
|
||||
MatchOutcome::NeedsReview(c) => {
|
||||
matcher::queue_for_review(conn, &item.title, &c, Some(&item.link), Some(source_id))?;
|
||||
return Ok(ProcessOutcome::QueuedForReview);
|
||||
}
|
||||
let (candidate, needs_review) = match matcher.match_title(conn, &parsed.title_normalized)? {
|
||||
MatchOutcome::Auto(c) => (c, false),
|
||||
MatchOutcome::NeedsReview(c) => (c, true),
|
||||
MatchOutcome::NoMatch => return Ok(ProcessOutcome::NoMatch),
|
||||
};
|
||||
|
||||
|
|
@ -474,6 +500,16 @@ async fn process_item(
|
|||
let mut episode_id: Option<i64> = None;
|
||||
let mut season_pack_number: Option<u32> = None;
|
||||
|
||||
// Whether this match needs a human's confirmation (queued for review)
|
||||
// or was confident enough to act on automatically, the show/season/
|
||||
// episode/movie still has to actually need *something* first — a
|
||||
// low-confidence title match against an already-complete show gains
|
||||
// nothing from a human's yes/no, it's just noise that reappears every
|
||||
// cycle the source keeps re-listing the same old release (verified
|
||||
// live: fully-complete shows' season-pack re-releases piling up in the
|
||||
// review queue indefinitely because this check only ever ran on the
|
||||
// auto-match path). So this eligibility check runs before the
|
||||
// queue-for-review decision below, not only on the auto-grab path.
|
||||
if media_item.kind == "movie" {
|
||||
// A release carrying a season/episode/absolute-episode marker
|
||||
// title-matched a movie by name alone — it's actually an episode
|
||||
|
|
@ -534,10 +570,30 @@ async fn process_item(
|
|||
is_season_pack: season_pack_number.is_some(),
|
||||
};
|
||||
|
||||
// Quality gates — including "reject sub-1080p when a 1080p+ alternative
|
||||
// exists in this same batch" — apply before a candidate ever reaches a
|
||||
// human, not just on the confident auto-grab path. A human's review
|
||||
// decision is about whether this is genuinely the right show/season;
|
||||
// it was never meant to also be the place quality standards get
|
||||
// relaxed just because the title match happened to be ambiguous
|
||||
// (verified live: several 720p season-pack releases sat in the review
|
||||
// queue for shows that also had 1080p+ releases available in the same
|
||||
// search, when they should have been silently gate-rejected instead).
|
||||
if let GateResult::Reject(reason) = scoring::evaluate_gates(&parsed, &gate_ctx, &profile) {
|
||||
return Ok(ProcessOutcome::GateRejected(reason));
|
||||
}
|
||||
|
||||
if needs_review {
|
||||
matcher::queue_for_review(
|
||||
conn,
|
||||
&item.title,
|
||||
&candidate,
|
||||
Some(&item.link),
|
||||
Some(source_id),
|
||||
)?;
|
||||
return Ok(ProcessOutcome::QueuedForReview);
|
||||
}
|
||||
|
||||
let release_score = scoring::score(&parsed, item.seeders.unwrap_or(0), false, &profile);
|
||||
let existing_best = match (episode_id, season_pack_number) {
|
||||
(Some(eid), _) => best_existing_score(conn, eid)?,
|
||||
|
|
@ -1129,6 +1185,7 @@ fn enumerate_upgrade_targets(
|
|||
(SELECT tvdb_id FROM anime_mapping WHERE tvdb_id IS NOT NULL))
|
||||
AND NOT EXISTS (SELECT 1 FROM release r WHERE r.episode_id = e.id
|
||||
AND r.status IN ('grabbed','downloading'))
|
||||
AND (ef.upgrade_locked IS NULL OR ef.upgrade_locked = 0)
|
||||
AND (us.last_checked_at IS NULL OR {})",
|
||||
UPGRADE_DUE_CLAUSE
|
||||
.replace("last_checked_at", "us.last_checked_at")
|
||||
|
|
@ -1638,7 +1695,7 @@ pub async fn execute_search_targets(
|
|||
};
|
||||
|
||||
let mut sorted = items;
|
||||
sorted.sort_by_key(|i| std::cmp::Reverse(i.seeders.unwrap_or(0)));
|
||||
sorted.sort_by_key(release_sort_key);
|
||||
sorted.truncate(MAX_RESULTS_PER_SEARCH);
|
||||
|
||||
// Computed once per target, over candidates that at least pass the
|
||||
|
|
@ -2055,6 +2112,30 @@ pub fn prepare_review_approval(conn: &Connection, review_id: i64) -> Result<Appr
|
|||
let profile = load_quality_profile(conn, media_item.quality_profile_id, profile_kind)?;
|
||||
let score = scoring::score(&parsed, 0, false, &profile);
|
||||
|
||||
// Claimed atomically here, still under the caller's DB lock — a real
|
||||
// TOCTOU otherwise: the caller only checked `status == "pending"` above
|
||||
// (a plain read, not a claim), then drops the lock and does the actual
|
||||
// grab (~30s of network/qBittorrent I/O) before `finalize_review_approval`
|
||||
// ever writes anything. A double-click, or an HTTP client retrying after
|
||||
// an apparent timeout, lands two concurrent `approve()` calls that both
|
||||
// pass the read above, both grab the same release, and both write their
|
||||
// own `release`/`torrent_fetch` rows for it. Marking `approved` here
|
||||
// rather than waiting for `finalize_review_approval` closes that window;
|
||||
// `approved` at this point means "no longer available for a second
|
||||
// approval attempt," not "successfully grabbed" — same distinction
|
||||
// `release.status` already draws between `grabbed` and `failed`, and if
|
||||
// the grab itself then errors outright, `release_review_claim` reverts
|
||||
// this back to `pending` (see its own doc comment).
|
||||
let claimed = conn.execute(
|
||||
"UPDATE review_queue SET status = 'approved' WHERE id = ?1 AND status = 'pending'",
|
||||
params![review_id],
|
||||
)?;
|
||||
if claimed == 0 {
|
||||
// Lost the race to a concurrent approve() between the read above
|
||||
// and this claim.
|
||||
return Ok(ApprovalPrep::NotPending);
|
||||
}
|
||||
|
||||
Ok(ApprovalPrep::Ready(PreparedApproval {
|
||||
media_item_id: media_item.id,
|
||||
episode_id,
|
||||
|
|
@ -2066,6 +2147,30 @@ pub fn prepare_review_approval(conn: &Connection, review_id: i64) -> Result<Appr
|
|||
}))
|
||||
}
|
||||
|
||||
/// Releases a claim `prepare_review_approval` took when the grab itself
|
||||
/// then fails outright (a network/qBittorrent error — `Err` from
|
||||
/// `grab_prepared_approval`, not just a missing hash on an otherwise-ok add;
|
||||
/// see `finalize_review_approval`'s own handling for that case, which still
|
||||
/// runs to completion and records a `failed` release). Without this, an
|
||||
/// approval claimed just before a transient qBittorrent outage would be
|
||||
/// stuck `approved` forever with no `release`/`torrent_fetch` row to show
|
||||
/// for it — worse than the un-atomic version this replaced, which at least
|
||||
/// left the row `pending` and retryable. Guarded on `WHERE status =
|
||||
/// 'approved'` mainly to no-op if the row somehow moved on already (e.g. a
|
||||
/// racing `reject`), not because `approved` distinguishes "merely claimed"
|
||||
/// from "successfully finalized" — it doesn't, `review_queue.status` has no
|
||||
/// separate state for that. Safe in practice because a single request's
|
||||
/// grab either errors (this runs, nothing was ever finalized) or succeeds
|
||||
/// (`finalize_review_approval` runs instead, this never gets called) — the
|
||||
/// two are mutually exclusive within one `approve()` call.
|
||||
pub fn release_review_claim(conn: &Connection, review_id: i64) -> Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE review_queue SET status = 'pending' WHERE id = ?1 AND status = 'approved'",
|
||||
params![review_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The actual grab — network/qBittorrent I/O only, no `Connection` involved,
|
||||
/// safe to `.await` from anywhere.
|
||||
pub async fn grab_prepared_approval(
|
||||
|
|
@ -2076,11 +2181,12 @@ pub async fn grab_prepared_approval(
|
|||
grab_and_capture_hash(qbit, &prepared.link, qbit_category).await
|
||||
}
|
||||
|
||||
/// Sync-only: records the grab and marks the review approved. Call after
|
||||
/// [`grab_prepared_approval`] completes.
|
||||
/// Sync-only: records the grab. Call after [`grab_prepared_approval`]
|
||||
/// completes. Doesn't touch `review_queue.status` — `prepare_review_approval`
|
||||
/// already claimed it into `approved` before the grab ran (see its doc
|
||||
/// comment).
|
||||
pub fn finalize_review_approval(
|
||||
conn: &Connection,
|
||||
review_id: i64,
|
||||
prepared: &PreparedApproval,
|
||||
qbit_category: &str,
|
||||
torrent_hash: Option<&str>,
|
||||
|
|
@ -2111,10 +2217,6 @@ pub fn finalize_review_approval(
|
|||
torrent_hash,
|
||||
status,
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE review_queue SET status = 'approved' WHERE id = ?1",
|
||||
params![review_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -2276,6 +2378,22 @@ mod tests {
|
|||
assert_eq!(targets[1].episode_id, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enumerate_upgrade_targets_excludes_an_upgrade_locked_episode() {
|
||||
let conn = seeded_upgrade_conn_with_one_flagged_episode();
|
||||
// Episode 2 is the probe-flagged one that would otherwise sort
|
||||
// first — lock it (as a completed local AV1 transcode would) and
|
||||
// confirm it drops out entirely rather than just losing priority.
|
||||
conn.execute(
|
||||
"UPDATE episode_file SET upgrade_locked = 1 WHERE episode_id = 2",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let targets = enumerate_upgrade_targets(&conn, 10, 5.0).unwrap();
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0].episode_id, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_grab_writes_both_a_release_row_and_a_torrent_fetch_audit_row() {
|
||||
let conn = seeded_conn();
|
||||
|
|
@ -2472,6 +2590,47 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
// Regression test for a real gap found in review: `prepare_review_approval`
|
||||
// used to only *read* `status == "pending"`, never claim it — two
|
||||
// concurrent `approve()` calls (a double-click, or an HTTP client retry)
|
||||
// could both pass that check, both grab the same release, and both
|
||||
// write their own `release`/`torrent_fetch` rows. A second call for the
|
||||
// same review must now see it's already claimed.
|
||||
#[test]
|
||||
fn a_second_prepare_call_on_an_already_claimed_review_sees_not_pending() {
|
||||
let conn = seeded_movie_conn();
|
||||
let review_id = insert_pending_review(&conn, "Some Movie 2016 1080p BluRay x264");
|
||||
|
||||
assert!(matches!(
|
||||
prepare_review_approval(&conn, review_id).unwrap(),
|
||||
ApprovalPrep::Ready(_)
|
||||
));
|
||||
// Same review_id, called again before any grab or finalize ran —
|
||||
// simulates the double-click/retry race.
|
||||
assert!(matches!(
|
||||
prepare_review_approval(&conn, review_id).unwrap(),
|
||||
ApprovalPrep::NotPending
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_review_claim_reverts_a_claimed_row_back_to_pending() {
|
||||
let conn = seeded_movie_conn();
|
||||
let review_id = insert_pending_review(&conn, "Some Movie 2016 1080p BluRay x264");
|
||||
|
||||
assert!(matches!(
|
||||
prepare_review_approval(&conn, review_id).unwrap(),
|
||||
ApprovalPrep::Ready(_)
|
||||
));
|
||||
release_review_claim(&conn, review_id).unwrap();
|
||||
// The claim was released (e.g. because the grab itself then errored
|
||||
// outright) — a fresh approval attempt must be possible again.
|
||||
assert!(matches!(
|
||||
prepare_review_approval(&conn, review_id).unwrap(),
|
||||
ApprovalPrep::Ready(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_movie_review_whose_title_looks_like_an_episode() {
|
||||
let conn = seeded_movie_conn();
|
||||
|
|
@ -2545,6 +2704,20 @@ mod tests {
|
|||
assert!(movie_eligible_for_upgrade(&conn, 1).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movie_eligible_for_upgrade_is_false_once_upgrade_locked() {
|
||||
let conn = seeded_movie_conn();
|
||||
conn.execute(
|
||||
"INSERT INTO episode_file (media_item_id, episode_id, path, size_bytes, upgrade_locked)
|
||||
VALUES (1, NULL, '/tmp/movie.mkv', 100, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// A locally AV1-transcoded file is a deliberate shrink, not
|
||||
// something the upgrade loop should try to replace.
|
||||
assert!(!movie_eligible_for_upgrade(&conn, 1).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movie_eligible_for_upgrade_is_false_when_unmonitored() {
|
||||
let conn = seeded_movie_conn();
|
||||
|
|
@ -2630,6 +2803,54 @@ mod tests {
|
|||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_sort_key_prefers_a_season_pack_over_a_higher_seeded_episode() {
|
||||
let pack = RawReleaseItem {
|
||||
title: "Some Show (S02 Complete) 1080p WEB-DL".into(),
|
||||
link: String::new(),
|
||||
guid: "pack".into(),
|
||||
size_bytes: None,
|
||||
seeders: Some(10),
|
||||
leechers: None,
|
||||
};
|
||||
let episode = RawReleaseItem {
|
||||
title: "Some Show S02E05 1080p WEB-DL".into(),
|
||||
link: String::new(),
|
||||
guid: "episode".into(),
|
||||
size_bytes: None,
|
||||
seeders: Some(500),
|
||||
leechers: None,
|
||||
};
|
||||
let mut items = vec![episode.clone(), pack.clone()];
|
||||
items.sort_by_key(release_sort_key);
|
||||
assert_eq!(items[0].guid, "pack");
|
||||
assert_eq!(items[1].guid, "episode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_sort_key_falls_back_to_seeders_within_the_same_group() {
|
||||
let low = RawReleaseItem {
|
||||
title: "Some Show S02E05 1080p WEB-DL".into(),
|
||||
link: String::new(),
|
||||
guid: "low".into(),
|
||||
size_bytes: None,
|
||||
seeders: Some(5),
|
||||
leechers: None,
|
||||
};
|
||||
let high = RawReleaseItem {
|
||||
title: "Some Show S02E05 720p WEB-DL".into(),
|
||||
link: String::new(),
|
||||
guid: "high".into(),
|
||||
size_bytes: None,
|
||||
seeders: Some(50),
|
||||
leechers: None,
|
||||
};
|
||||
let mut items = vec![low.clone(), high.clone()];
|
||||
items.sort_by_key(release_sort_key);
|
||||
assert_eq!(items[0].guid, "high");
|
||||
assert_eq!(items[1].guid, "low");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_monitored_missing_episodes_in_season_counts_correctly() {
|
||||
let conn = seeded_conn();
|
||||
|
|
|
|||
|
|
@ -45,7 +45,17 @@ pub(crate) fn urlencode(s: &str) -> String {
|
|||
|
||||
pub(crate) fn parse_human_size(s: &str) -> Option<u64> {
|
||||
let s = s.trim();
|
||||
let (num_part, unit) = s.split_once(' ')?;
|
||||
// Split on the first character that isn't part of the number, rather
|
||||
// than requiring a literal space — some sources render this without one
|
||||
// ("38.1GiB"). Requiring a space made `split_once(' ')` return `None`
|
||||
// for those, silently leaving `size_bytes` unset rather than failing
|
||||
// outright: the gate's size sanity check (`gate.rs`) treats a missing
|
||||
// size as "nothing to check" and skips it entirely instead of rejecting
|
||||
// the release, so a value this parser simply couldn't read bypassed
|
||||
// size validation altogether rather than being caught by it.
|
||||
let split_at = s.find(|c: char| !(c.is_ascii_digit() || c == '.'))?;
|
||||
let (num_part, unit) = s.split_at(split_at);
|
||||
let unit = unit.trim();
|
||||
let num: f64 = num_part.parse().ok()?;
|
||||
let mult = match unit {
|
||||
"B" => 1.0,
|
||||
|
|
@ -80,4 +90,14 @@ mod tests {
|
|||
fn rejects_unknown_unit() {
|
||||
assert_eq!(parse_human_size("5 XiB"), None);
|
||||
}
|
||||
|
||||
// Regression test for a real gap found in review: some sources render
|
||||
// this with no space between the number and the unit — the old
|
||||
// `split_once(' ')` returned `None` for those, silently leaving
|
||||
// `size_bytes` unset (which skips the gate's size sanity check entirely)
|
||||
// rather than rejecting a value this parser genuinely couldn't read.
|
||||
#[test]
|
||||
fn parses_a_size_with_no_space_before_the_unit() {
|
||||
assert_eq!(parse_human_size("38.1GiB"), Some(40909563494));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,39 @@ fn build_search_url(feed_url: &str, query: Option<&str>) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// Every field accumulated across one `<item>`'s `Text`/`CData` events,
|
||||
/// bundled into one struct (rather than six separate `&mut Option<_>`
|
||||
/// parameters) purely to keep `accumulate_field` under clippy's
|
||||
/// too-many-arguments threshold.
|
||||
#[derive(Default)]
|
||||
struct ItemFields {
|
||||
title: Option<String>,
|
||||
link: Option<String>,
|
||||
guid: Option<String>,
|
||||
seeders: Option<u32>,
|
||||
leechers: Option<u32>,
|
||||
size_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
/// Appends rather than overwrites title/link/guid: a real feed can split a
|
||||
/// single logical value across more than one `Text`/`CData` event for the
|
||||
/// same tag (mixed content, or just a parser buffer boundary) — a plain
|
||||
/// assignment would silently keep only the *last* fragment, truncating the
|
||||
/// value. `nyaa:seeders`/`nyaa:leechers`/`nyaa:size` stay parse-and-overwrite
|
||||
/// since they're short numeric/size tokens, not free text expected to span
|
||||
/// multiple events.
|
||||
fn accumulate_field(tag: &str, text: &str, fields: &mut ItemFields) {
|
||||
match tag {
|
||||
"title" => fields.title.get_or_insert_with(String::new).push_str(text),
|
||||
"link" => fields.link.get_or_insert_with(String::new).push_str(text),
|
||||
"guid" => fields.guid.get_or_insert_with(String::new).push_str(text),
|
||||
"nyaa:seeders" => fields.seeders = text.parse().ok(),
|
||||
"nyaa:leechers" => fields.leechers = text.parse().ok(),
|
||||
"nyaa:size" => fields.size_bytes = parse_human_size(text),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_nyaa_rss(bytes: &[u8]) -> Result<Vec<RawReleaseItem>> {
|
||||
let mut reader = Reader::from_reader(bytes);
|
||||
reader.config_mut().trim_text(true);
|
||||
|
|
@ -61,12 +94,7 @@ fn parse_nyaa_rss(bytes: &[u8]) -> Result<Vec<RawReleaseItem>> {
|
|||
|
||||
let mut in_item = false;
|
||||
let mut cur_tag = String::new();
|
||||
let mut title = None;
|
||||
let mut link = None;
|
||||
let mut guid = None;
|
||||
let mut seeders = None;
|
||||
let mut leechers = None;
|
||||
let mut size_bytes = None;
|
||||
let mut fields = ItemFields::default();
|
||||
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf)? {
|
||||
|
|
@ -75,41 +103,42 @@ fn parse_nyaa_rss(bytes: &[u8]) -> Result<Vec<RawReleaseItem>> {
|
|||
let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
|
||||
if name == "item" {
|
||||
in_item = true;
|
||||
title = None;
|
||||
link = None;
|
||||
guid = None;
|
||||
seeders = None;
|
||||
leechers = None;
|
||||
size_bytes = None;
|
||||
fields = ItemFields::default();
|
||||
}
|
||||
cur_tag = name;
|
||||
}
|
||||
Event::Text(t) if in_item => {
|
||||
let raw = t.decode()?;
|
||||
let text = unescape(&raw)?.into_owned();
|
||||
match cur_tag.as_str() {
|
||||
"title" => title = Some(text),
|
||||
"link" => link = Some(text),
|
||||
"guid" => guid = Some(text),
|
||||
"nyaa:seeders" => seeders = text.parse().ok(),
|
||||
"nyaa:leechers" => leechers = text.parse().ok(),
|
||||
"nyaa:size" => size_bytes = parse_human_size(&text),
|
||||
_ => {}
|
||||
}
|
||||
accumulate_field(&cur_tag, &text, &mut fields);
|
||||
}
|
||||
// CDATA content is raw text by definition — XML entity escaping
|
||||
// doesn't apply inside a CDATA section (running `unescape()` on
|
||||
// it would misinterpret a literal "&" as an escaped
|
||||
// ampersand), so this decodes without it. Many real-world feeds
|
||||
// wrap `<title>`/`<link>` in CDATA; previously only
|
||||
// `Event::Text` was handled at all, so those items silently
|
||||
// came back with `title = None` and were dropped at the
|
||||
// `item`-close check below with zero error — pointing
|
||||
// `nyaa_rss_url` at a CDATA-heavy feed yielded zero items, not
|
||||
// a visible failure.
|
||||
Event::CData(t) if in_item => {
|
||||
let text = t.decode()?.into_owned();
|
||||
accumulate_field(&cur_tag, &text, &mut fields);
|
||||
}
|
||||
Event::End(e) => {
|
||||
let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
|
||||
if name == "item" {
|
||||
if let (Some(title), Some(link), Some(guid)) =
|
||||
(title.take(), link.take(), guid.take())
|
||||
(fields.title.take(), fields.link.take(), fields.guid.take())
|
||||
{
|
||||
items.push(RawReleaseItem {
|
||||
title,
|
||||
link,
|
||||
guid,
|
||||
size_bytes,
|
||||
seeders,
|
||||
leechers,
|
||||
size_bytes: fields.size_bytes,
|
||||
seeders: fields.seeders,
|
||||
leechers: fields.leechers,
|
||||
});
|
||||
}
|
||||
in_item = false;
|
||||
|
|
@ -156,6 +185,40 @@ mod tests {
|
|||
assert_eq!(item.size_bytes, Some(373817344));
|
||||
}
|
||||
|
||||
// Regression test for a real gap found in review: many real-world feeds
|
||||
// wrap `<title>`/`<link>`/`<guid>` in CDATA rather than plain text
|
||||
// content (often to avoid having to XML-escape ampersands/brackets
|
||||
// common in release titles). Previously only `Event::Text` was
|
||||
// handled — `Event::CData` was silently ignored — so every field
|
||||
// wrapped this way came back `None` and the whole item was dropped at
|
||||
// the `item`-close check with no error surfaced at all.
|
||||
#[test]
|
||||
fn parses_cdata_wrapped_fields() {
|
||||
const CDATA_SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss xmlns:nyaa="https://nyaa.si/xmlns/nyaa" version="2.0">
|
||||
<channel>
|
||||
<item>
|
||||
<title><![CDATA[[Group] Some Show & Friends - 05 [1080p]]]></title>
|
||||
<link><![CDATA[https://nyaa.si/download/2130903.torrent]]></link>
|
||||
<guid isPermaLink="true"><![CDATA[https://nyaa.si/view/2130903]]></guid>
|
||||
<nyaa:seeders>12</nyaa:seeders>
|
||||
<nyaa:leechers>3</nyaa:leechers>
|
||||
<nyaa:size>356.5 MiB</nyaa:size>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>"#;
|
||||
|
||||
let items = parse_nyaa_rss(CDATA_SAMPLE.as_bytes()).unwrap();
|
||||
assert_eq!(items.len(), 1, "a CDATA-wrapped item must not be silently dropped");
|
||||
let item = &items[0];
|
||||
// A literal "&" survives verbatim — CDATA content isn't
|
||||
// XML-entity-escaped, so this must NOT come back as "&".
|
||||
assert_eq!(item.title, "[Group] Some Show & Friends - 05 [1080p]");
|
||||
assert_eq!(item.link, "https://nyaa.si/download/2130903.torrent");
|
||||
assert_eq!(item.guid, "https://nyaa.si/view/2130903");
|
||||
assert_eq!(item.seeders, Some(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_search_url_appends_query_param() {
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,17 @@ use serde::Deserialize;
|
|||
|
||||
use super::{urlencode, RawReleaseItem, ReleaseSource};
|
||||
|
||||
/// A valid BitTorrent v1 info_hash: 40 hex chars or 32 base32 chars — same
|
||||
/// shape `qbit::extract_btih` accepts out of a magnet URI. Checked before
|
||||
/// building a magnet from `info_hash` at all: apibay is normally reliable,
|
||||
/// but a malformed value would otherwise silently produce a magnet
|
||||
/// `extract_btih` can't parse back out, downgrading that grab to the slow
|
||||
/// ~30s `torrents/info` polling path with no visible error anywhere.
|
||||
fn is_valid_info_hash(hash: &str) -> bool {
|
||||
(hash.len() == 40 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||
|| (hash.len() == 32 && hash.bytes().all(|b| matches!(b, b'2'..=b'7' | b'a'..=b'z' | b'A'..=b'Z')))
|
||||
}
|
||||
|
||||
/// A community-run JSON API mirror of The Pirate Bay's search — unlike
|
||||
/// 1337x, this is a genuine machine-readable API (not HTML scraping), and
|
||||
/// unlike 1337x's HTML results table, `info_hash` is enough to build a
|
||||
|
|
@ -85,8 +96,15 @@ impl ReleaseSource for TpbSource {
|
|||
// A query with no matches returns a single sentinel row
|
||||
// (id="0", an all-zero info_hash) rather than an empty array —
|
||||
// has to be filtered out explicitly or it'd be treated as one
|
||||
// real (and completely bogus) result.
|
||||
.filter(|r| r.id != "0" && !r.info_hash.chars().all(|c| c == '0'))
|
||||
// real (and completely bogus) result. The all-zero hash is
|
||||
// itself 40 valid hex characters, so `is_valid_info_hash` alone
|
||||
// wouldn't catch it — both checks are needed, not one replacing
|
||||
// the other.
|
||||
.filter(|r| {
|
||||
r.id != "0"
|
||||
&& !r.info_hash.chars().all(|c| c == '0')
|
||||
&& is_valid_info_hash(&r.info_hash)
|
||||
})
|
||||
.map(|r| RawReleaseItem {
|
||||
title: r.name.clone(),
|
||||
link: build_magnet(&r.info_hash, &r.name),
|
||||
|
|
@ -125,8 +143,31 @@ mod tests {
|
|||
let results: Vec<TpbResult> = serde_json::from_str(body).unwrap();
|
||||
let filtered: Vec<_> = results
|
||||
.into_iter()
|
||||
.filter(|r| r.id != "0" && !r.info_hash.chars().all(|c| c == '0'))
|
||||
.filter(|r| {
|
||||
r.id != "0"
|
||||
&& !r.info_hash.chars().all(|c| c == '0')
|
||||
&& is_valid_info_hash(&r.info_hash)
|
||||
})
|
||||
.collect();
|
||||
assert!(filtered.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_info_hash_accepts_both_real_shapes() {
|
||||
assert!(is_valid_info_hash("8F87C7C186172F17E35F4512BB1A3E93B614ADED")); // 40 hex
|
||||
assert!(is_valid_info_hash("abcdefghijklmnopqrstuvwxyz234567")); // 32 base32
|
||||
}
|
||||
|
||||
// Regression test for a real gap found in review: a malformed
|
||||
// `info_hash` from apibay used to flow straight into `build_magnet`
|
||||
// with no validation, silently producing a magnet `qbit::extract_btih`
|
||||
// can't parse back out — downgrading that grab to the slow polling path
|
||||
// with no error surfaced anywhere.
|
||||
#[test]
|
||||
fn is_valid_info_hash_rejects_malformed_values() {
|
||||
assert!(!is_valid_info_hash(""));
|
||||
assert!(!is_valid_info_hash("too-short"));
|
||||
assert!(!is_valid_info_hash("not-a-hex-string-at-all-nope!!!!!!!!!!!!")); // 40 chars, non-hex
|
||||
assert!(!is_valid_info_hash("8F87C7C186172F17E35F4512BB1A3E93B614ADE")); // 39 hex chars
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
breadarrd/src/src.tar.xz
Normal file
BIN
breadarrd/src/src.tar.xz
Normal file
Binary file not shown.
1893
breadarrd/src/transcode/mod.rs
Normal file
1893
breadarrd/src/transcode/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
45745
graphify-out/.graphify_ast.json
Normal file
45745
graphify-out/.graphify_ast.json
Normal file
File diff suppressed because it is too large
Load diff
134
graphify-out/.graphify_chunk_01.json
Normal file
134
graphify-out/.graphify_chunk_01.json
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
{
|
||||
"nodes": [
|
||||
{"id": "readme", "label": "breadarr README", "file_type": "document", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_breadarrd", "label": "breadarrd (daemon)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_breadarr_tui", "label": "breadarr-tui (terminal client)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_nyaa_si_rss", "label": "nyaa.si (RSS source, anime TV)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_apibay_org", "label": "apibay.org (TPB mirror JSON API source)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_1337x", "label": "1337x (scraped HTML source, mirrored)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_nyaa_si_search_mode", "label": "nyaa.si search mode (anime movies)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_request_budget", "label": "Shared per-cycle search request budget", "file_type": "rationale", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_wrong_default_audio_track", "label": "Wrong default audio track fix", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_anime_numbering", "label": "Anime absolute-episode numbering resolution", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_fuzzy_title_matching", "label": "Fuzzy title matching (ONNX embedding)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_library_normalization", "label": "Library normalization (folder rename to Title (Year))", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_season_packs", "label": "Season pack per-episode splitting", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_media_intelligence", "label": "Media intelligence (ffprobe ground truth)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_quality_upgrades", "label": "Post-import quality upgrade background cycle", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_manual_release_picker", "label": "Manual release picker (TUI 'c' key)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_mkvmerge", "label": "mkvmerge (mkvtoolnix-cli)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_ffprobe", "label": "ffprobe", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_ffmpeg", "label": "ffmpeg (incl. -xerror decode verify)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_jellyfin", "label": "Jellyfin", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_qbittorrent", "label": "qBittorrent (WebUI)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_tvdb", "label": "TVDB API", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_tmdb", "label": "TMDB API (v4 Read Access Token)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_anidb", "label": "AniDB (mapping source)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_all_minilm_l6_v2", "label": "all-MiniLM-L6-v2 (ONNX embedding model, CPU-only)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_whisper", "label": "Whisper (external tool being reimplemented)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_token_overlap_gate", "label": "Token-overlap sanity gate", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_review_queue", "label": "Review queue (low-confidence title matches)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_quality_profile_weights", "label": "Hardcoded quality-scoring weights (known limitation)", "file_type": "rationale", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "breadarrd_src_scoring_profile_default_tv", "label": "QualityProfile::default_tv", "file_type": "code", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "breadarrd_src_scoring_profile_default_movie", "label": "QualityProfile::default_movie", "file_type": "code", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "breadarrd_src_matcher_mod_min_token_overlap", "label": "MIN_TOKEN_OVERLAP constant", "file_type": "code", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "breadarrd_src_matcher_mod_auto_match_confidence", "label": "AUTO_MATCH_CONFIDENCE constant", "file_type": "code", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "breadarrd_src_scoring_score_module", "label": "scoring/score.rs module", "file_type": "code", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_remux_backlog", "label": "breadarrd remux-backlog command", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_probe_library", "label": "breadarrd probe-library command", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_verify_library", "label": "breadarrd verify-library command", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_disk_reconciliation", "label": "Hourly disk-reconciliation pass", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_database_backup", "label": "Startup database backup (WAL/SHM sidecars)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_health_endpoint", "label": "/health endpoint", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_library_health_endpoint", "label": "/library/health endpoint", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_media_file_probe_table", "label": "media_file_probe table", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_episode_file_table", "label": "episode_file table", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_torrent_fetch_table", "label": "torrent_fetch table", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_quality_profile_table", "label": "quality_profile table (weights JSON column)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_release_table", "label": "release table (status field)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_tdarr_hand_off", "label": "Roadmap: Tdarr hand-off", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_smart_library_auto_upgrade_daemon", "label": "Roadmap: Smart-library auto-upgrade daemon", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_tracker_release_group_reliability_scoring", "label": "Roadmap: Tracker/release-group reliability scoring", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_mining_raw_ffprobe_json", "label": "Roadmap: Mining the raw ffprobe JSON", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_learning_from_review_queue_decisions", "label": "Roadmap: Learning from review-queue decisions", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_seerr_overseerr_compat_shim", "label": "Roadmap: Seerr/Overseerr compatibility shim + request fulfillment", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_whisper_subtitle_generation", "label": "Roadmap: Whisper subtitle generation", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_compat_module", "label": "compat/ module (reserved, Sonarr/Radarr v3 API shim)", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_no_indexer_plugin_system_no_web_ui", "label": "Design rejection: no indexer-plugin system, no web UI", "file_type": "rationale", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_gotify_webhook", "label": "Gotify-shaped notifications.webhook_url", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null},
|
||||
{"id": "readme_systemd_user_service", "label": "systemd --user service deployment", "file_type": "concept", "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}
|
||||
],
|
||||
"edges": [
|
||||
{"source": "readme_breadarrd", "target": "readme_wrong_default_audio_track", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarrd", "target": "readme_anime_numbering", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarrd", "target": "readme_fuzzy_title_matching", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarrd", "target": "readme_library_normalization", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarrd", "target": "readme_season_packs", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarrd", "target": "readme_media_intelligence", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarrd", "target": "readme_quality_upgrades", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarrd", "target": "readme_manual_release_picker", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarrd", "target": "readme_disk_reconciliation", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarrd", "target": "readme_database_backup", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarrd", "target": "readme_health_endpoint", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarrd", "target": "readme_library_health_endpoint", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarrd", "target": "readme_jellyfin", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarrd", "target": "readme_qbittorrent", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarrd", "target": "readme_tvdb", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarrd", "target": "readme_tmdb", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarrd", "target": "readme_systemd_user_service", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarrd", "target": "readme_gotify_webhook", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarr_tui", "target": "readme_breadarrd", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarr_tui", "target": "readme_manual_release_picker", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarr_tui", "target": "readme_review_queue", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_breadarr_tui", "target": "readme_library_health_endpoint", "relation": "implements", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_wrong_default_audio_track", "target": "readme_mkvmerge", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_remux_backlog", "target": "readme_wrong_default_audio_track", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_anime_numbering", "target": "readme_anidb", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_anime_numbering", "target": "readme_tvdb", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_anime_numbering", "target": "readme_tmdb", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_fuzzy_title_matching", "target": "readme_all_minilm_l6_v2", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_fuzzy_title_matching", "target": "readme_token_overlap_gate", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_token_overlap_gate", "target": "breadarrd_src_matcher_mod_min_token_overlap", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_fuzzy_title_matching", "target": "readme_review_queue", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_library_normalization", "target": "readme_tvdb", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_library_normalization", "target": "readme_tmdb", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_media_intelligence", "target": "readme_ffprobe", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_quality_upgrades", "target": "readme_media_file_probe_table", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_quality_upgrades", "target": "readme_request_budget", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_apibay_org", "target": "readme_request_budget", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_1337x", "target": "readme_request_budget", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_nyaa_si_search_mode", "target": "readme_request_budget", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_quality_profile_weights", "target": "breadarrd_src_scoring_profile_default_tv", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_quality_profile_weights", "target": "breadarrd_src_scoring_profile_default_movie", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_quality_profile_weights", "target": "readme_quality_profile_table", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_learning_from_review_queue_decisions", "target": "breadarrd_src_matcher_mod_auto_match_confidence", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_learning_from_review_queue_decisions", "target": "breadarrd_src_matcher_mod_min_token_overlap", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_learning_from_review_queue_decisions", "target": "readme_review_queue", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_tdarr_hand_off", "target": "readme_media_file_probe_table", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_tracker_release_group_reliability_scoring", "target": "readme_torrent_fetch_table", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_tracker_release_group_reliability_scoring", "target": "readme_release_table", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_tracker_release_group_reliability_scoring", "target": "breadarrd_src_scoring_score_module", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_mining_raw_ffprobe_json", "target": "readme_media_file_probe_table", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_seerr_overseerr_compat_shim", "target": "readme_compat_module", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_whisper_subtitle_generation", "target": "readme_episode_file_table", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_whisper_subtitle_generation", "target": "readme_mkvmerge", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_whisper_subtitle_generation", "target": "readme_whisper", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_no_indexer_plugin_system_no_web_ui", "target": "readme_breadarrd", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_no_indexer_plugin_system_no_web_ui", "target": "readme_breadarr_tui", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_smart_library_auto_upgrade_daemon", "target": "readme_quality_upgrades", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_smart_library_auto_upgrade_daemon", "target": "readme_library_health_endpoint", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_smart_library_auto_upgrade_daemon", "target": "readme_gotify_webhook", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_verify_library", "target": "readme_media_intelligence", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_probe_library", "target": "readme_media_intelligence", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_disk_reconciliation", "target": "readme_episode_file_table", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_disk_reconciliation", "target": "readme_database_backup", "relation": "semantically_similar_to", "confidence": "INFERRED", "confidence_score": 0.65, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0},
|
||||
{"source": "readme_quality_upgrades", "target": "readme_remux_backlog", "relation": "semantically_similar_to", "confidence": "INFERRED", "confidence_score": 0.75, "source_file": "/home/breadway/Projects/breadarr/README.md", "source_location": null, "weight": 1.0}
|
||||
],
|
||||
"hyperedges": [
|
||||
{"id": "search_budget_sources", "label": "Search-driven sources sharing per-cycle request budget", "nodes": ["readme_apibay_org", "readme_1337x", "readme_nyaa_si_search_mode", "readme_request_budget"], "relation": "participate_in", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md"},
|
||||
{"id": "medium_term_roadmap_group", "label": "Medium-term roadmap items closing the loop on already-collected data", "nodes": ["readme_tdarr_hand_off", "readme_smart_library_auto_upgrade_daemon", "readme_tracker_release_group_reliability_scoring", "readme_mining_raw_ffprobe_json"], "relation": "participate_in", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md"},
|
||||
{"id": "core_problems_solved_directly", "label": "Problems breadarr solves directly instead of configuring around", "nodes": ["readme_wrong_default_audio_track", "readme_anime_numbering", "readme_fuzzy_title_matching", "readme_library_normalization", "readme_season_packs", "readme_media_intelligence", "readme_quality_upgrades", "readme_manual_release_picker"], "relation": "implement", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "/home/breadway/Projects/breadarr/README.md"}
|
||||
],
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
}
|
||||
1
graphify-out/.graphify_detect.json
Normal file
1
graphify-out/.graphify_detect.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"files": {"code": ["/home/breadway/Projects/breadarr/breadarr-shared/src/client.rs", "/home/breadway/Projects/breadarr/breadarr-shared/src/config.rs", "/home/breadway/Projects/breadarr/breadarr-shared/src/dto.rs", "/home/breadway/Projects/breadarr/breadarr-shared/src/lib.rs", "/home/breadway/Projects/breadarr/breadarr-tui/src/app.rs", "/home/breadway/Projects/breadarr/breadarr-tui/src/main.rs", "/home/breadway/Projects/breadarr/breadarr-tui/src/ui.rs", "/home/breadway/Projects/breadarr/breadarrd/src/api/mod.rs", "/home/breadway/Projects/breadarr/breadarrd/src/api/routes/calendar.rs", "/home/breadway/Projects/breadarr/breadarrd/src/api/routes/health.rs", "/home/breadway/Projects/breadarr/breadarrd/src/api/routes/library_health.rs", "/home/breadway/Projects/breadarr/breadarrd/src/api/routes/media.rs", "/home/breadway/Projects/breadarr/breadarrd/src/api/routes/mod.rs", "/home/breadway/Projects/breadarr/breadarrd/src/api/routes/quality_profiles.rs", "/home/breadway/Projects/breadarr/breadarrd/src/api/routes/releases.rs", "/home/breadway/Projects/breadarr/breadarrd/src/api/routes/review.rs", "/home/breadway/Projects/breadarr/breadarrd/src/api/routes/search.rs", "/home/breadway/Projects/breadarr/breadarrd/src/api/routes/stuck.rs", "/home/breadway/Projects/breadarr/breadarrd/src/db.rs", "/home/breadway/Projects/breadarr/breadarrd/src/importer/ffprobe.rs", "/home/breadway/Projects/breadarr/breadarrd/src/importer/mkv.rs", "/home/breadway/Projects/breadarr/breadarrd/src/importer/mod.rs", "/home/breadway/Projects/breadarr/breadarrd/src/jellyfin.rs", "/home/breadway/Projects/breadarr/breadarrd/src/library_scan.rs", "/home/breadway/Projects/breadarr/breadarrd/src/main.rs", "/home/breadway/Projects/breadarr/breadarrd/src/matcher/embed.rs", "/home/breadway/Projects/breadarr/breadarrd/src/matcher/mod.rs", "/home/breadway/Projects/breadarr/breadarrd/src/metadata/anime_map.rs", "/home/breadway/Projects/breadarr/breadarrd/src/metadata/mod.rs", "/home/breadway/Projects/breadarr/breadarrd/src/metadata/tmdb.rs", "/home/breadway/Projects/breadarr/breadarrd/src/metadata/tvdb.rs", "/home/breadway/Projects/breadarr/breadarrd/src/notify.rs", "/home/breadway/Projects/breadarr/breadarrd/src/parser/mod.rs", "/home/breadway/Projects/breadarr/breadarrd/src/parser/tokens.rs", "/home/breadway/Projects/breadarr/breadarrd/src/qbit/mod.rs", "/home/breadway/Projects/breadarr/breadarrd/src/scheduler.rs", "/home/breadway/Projects/breadarr/breadarrd/src/scoring/gate.rs", "/home/breadway/Projects/breadarr/breadarrd/src/scoring/mod.rs", "/home/breadway/Projects/breadarr/breadarrd/src/scoring/profile.rs", "/home/breadway/Projects/breadarr/breadarrd/src/scoring/score.rs", "/home/breadway/Projects/breadarr/breadarrd/src/sources/mod.rs", "/home/breadway/Projects/breadarr/breadarrd/src/sources/rss.rs", "/home/breadway/Projects/breadarr/breadarrd/src/sources/scrape.rs", "/home/breadway/Projects/breadarr/breadarrd/src/sources/tpb.rs", "/home/breadway/Projects/breadarr/breadarrd/src/transcode/mod.rs"], "document": ["/home/breadway/Projects/breadarr/README.md"], "paper": [], "image": [], "video": []}, "total_files": 46, "total_words": 84649, "needs_graph": true, "warning": null, "skipped_sensitive": [], "unclassified": ["/home/breadway/Projects/breadarr/.gitignore", "/home/breadway/Projects/breadarr/Cargo.toml", "/home/breadway/Projects/breadarr/LICENSE", "/home/breadway/Projects/breadarr/breadarr-shared/Cargo.toml", "/home/breadway/Projects/breadarr/breadarr-tui/Cargo.toml", "/home/breadway/Projects/breadarr/breadarrd/Cargo.toml", "/home/breadway/Projects/breadarr/breadarrd/src/src.tar.xz", "/home/breadway/Projects/breadarr/config.example.toml", "/home/breadway/Projects/breadarr/packaging/systemd/breadarrd.service"], "walk_errors": [], "ignored": ["/home/breadway/Projects/breadarr/.claude/scheduled_tasks.lock", "/home/breadway/Projects/breadarr/CLAUDE.md"], "pruned_noise_dirs": ["/home/breadway/Projects/breadarr/.git/", "/home/breadway/Projects/breadarr/.idea/", "/home/breadway/Projects/breadarr/graphify-out/", "/home/breadway/Projects/breadarr/target/"], "graphifyignore_patterns": 16, "scan_root": "/home/breadway/Projects/breadarr"}
|
||||
26
graphify-out/.graphify_labels.json
Normal file
26
graphify-out/.graphify_labels.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"0": "scheduler.rs",
|
||||
"1": "api/mod.rs",
|
||||
"2": "Connection",
|
||||
"3": "fetch_candidates",
|
||||
"4": "enumerate_upgrade_targets",
|
||||
"5": "rss.rs",
|
||||
"6": "TitleMatcher",
|
||||
"7": "parser/mod.rs",
|
||||
"8": "config.rs",
|
||||
"9": "transcode/mod.rs",
|
||||
"10": "ffprobe.rs",
|
||||
"11": "importer/mod.rs",
|
||||
"12": "Result",
|
||||
"13": "process_pending_grabs",
|
||||
"14": "Connection",
|
||||
"15": "import_one",
|
||||
"16": "find_relinkable_episode_files",
|
||||
"17": "main.rs",
|
||||
"18": "import_season_pack",
|
||||
"19": "QbitClient",
|
||||
"20": "db.rs",
|
||||
"21": "String",
|
||||
"22": "enumerate_search_targets",
|
||||
"23": "seeded_conn"
|
||||
}
|
||||
1
graphify-out/.graphify_labels.json.sig
Normal file
1
graphify-out/.graphify_labels.json.sig
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"0": "a20711b8a258bdba", "1": "00eec605035731e0", "2": "fe27e60c0c69f644", "3": "aab173d1c84b4fa5", "4": "fcbc321c7cc48bd5", "5": "f2c1c699c8bbb3f1", "6": "edcd439ab7a6e602", "7": "045ecbe6e4c68626", "8": "1bc76ecd7d47170b", "9": "41ae8ae078021091", "10": "b5579e7d3cb56163", "11": "6e96670b2e6365c8", "12": "86a8b42c2c2db791", "13": "afab72e500fc3a5e", "14": "d44144490006b82e", "15": "03a7fd4f5097c30c", "16": "742257b51006ffce", "17": "c4a1f2a2c670d77d", "18": "6e74d48dd61b8d86", "19": "4fe857e1f7ed4db3", "20": "416c05e55e9af1a8", "21": "61f849337b416151", "22": "fe71e07eb838ebc3", "23": "ef859e5fff310b02"}
|
||||
1
graphify-out/.graphify_python
Normal file
1
graphify-out/.graphify_python
Normal file
|
|
@ -0,0 +1 @@
|
|||
/home/breadway/.cache/uv/archive-v0/4yQjntA8tzDKxQRL/bin/python
|
||||
1
graphify-out/.graphify_root
Normal file
1
graphify-out/.graphify_root
Normal file
|
|
@ -0,0 +1 @@
|
|||
/home/breadway/Projects/breadarr
|
||||
1
graphify-out/.graphify_uncached.txt
Normal file
1
graphify-out/.graphify_uncached.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
/home/breadway/Projects/breadarr/README.md
|
||||
21
graphify-out/2026-08-03/.graphify_labels.json
Normal file
21
graphify-out/2026-08-03/.graphify_labels.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"0": "scheduler.rs",
|
||||
"1": "api/mod.rs",
|
||||
"2": "Connection",
|
||||
"3": "execute_search_targets",
|
||||
"4": "enumerate_search_targets",
|
||||
"5": "rss.rs",
|
||||
"6": "matcher/mod.rs",
|
||||
"7": "parser/mod.rs",
|
||||
"8": "config.rs",
|
||||
"9": "transcode/mod.rs",
|
||||
"10": "ffprobe.rs",
|
||||
"11": "importer/mod.rs",
|
||||
"12": "Result",
|
||||
"13": "process_pending_grabs",
|
||||
"14": "Connection",
|
||||
"15": "import_one",
|
||||
"16": "find_relinkable_episode_files",
|
||||
"17": "fetch_candidates",
|
||||
"18": "import_season_pack"
|
||||
}
|
||||
159
graphify-out/2026-08-03/GRAPH_REPORT.md
Normal file
159
graphify-out/2026-08-03/GRAPH_REPORT.md
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# Graph Report - breadarr (2026-08-03)
|
||||
|
||||
## Corpus Check
|
||||
- 46 files · ~90,608 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 605 nodes · 1557 edges · 19 communities
|
||||
- Extraction: 100% EXTRACTED · 0% INFERRED · 0% AMBIGUOUS · INFERRED: 2 edges (avg confidence: 0.8)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Graph Freshness
|
||||
- Built from commit: `109b29ee`
|
||||
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||
- Run `graphify update .` after code changes (no API cost).
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- scheduler.rs
|
||||
- api/mod.rs
|
||||
- Connection
|
||||
- execute_search_targets
|
||||
- enumerate_search_targets
|
||||
- rss.rs
|
||||
- matcher/mod.rs
|
||||
- parser/mod.rs
|
||||
- config.rs
|
||||
- transcode/mod.rs
|
||||
- ffprobe.rs
|
||||
- importer/mod.rs
|
||||
- Result
|
||||
- process_pending_grabs
|
||||
- Connection
|
||||
- import_one
|
||||
- find_relinkable_episode_files
|
||||
- fetch_candidates
|
||||
- import_season_pack
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `import_one()` - 30 edges
|
||||
2. `process_item()` - 30 edges
|
||||
3. `TranscodeConfig` - 24 edges
|
||||
4. `process_pending_grabs()` - 24 edges
|
||||
5. `parse()` - 22 edges
|
||||
6. `AppState` - 19 edges
|
||||
7. `encode_and_verify()` - 18 edges
|
||||
8. `fetch_candidates()` - 18 edges
|
||||
9. `import_season_pack_file()` - 17 edges
|
||||
10. `execute_search_targets()` - 17 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `import_one()` --references--> `TranscodeConfig` [EXTRACTED]
|
||||
breadarrd/src/importer/mod.rs → breadarr-shared/src/config.rs
|
||||
- `import_season_pack()` --references--> `TranscodeConfig` [EXTRACTED]
|
||||
breadarrd/src/importer/mod.rs → breadarr-shared/src/config.rs
|
||||
- `import_season_pack_file()` --references--> `TranscodeConfig` [EXTRACTED]
|
||||
breadarrd/src/importer/mod.rs → breadarr-shared/src/config.rs
|
||||
- `maybe_enqueue_transcode()` --references--> `TranscodeConfig` [EXTRACTED]
|
||||
breadarrd/src/importer/mod.rs → breadarr-shared/src/config.rs
|
||||
- `process_pending_grabs()` --references--> `TranscodeConfig` [EXTRACTED]
|
||||
breadarrd/src/importer/mod.rs → breadarr-shared/src/config.rs
|
||||
|
||||
## Import Cycles
|
||||
- None detected.
|
||||
|
||||
## Communities (19 total, 0 thin omitted)
|
||||
|
||||
### Community 0 - "scheduler.rs"
|
||||
Cohesion: 0.07
|
||||
Nodes (24): a_second_prepare_call_on_an_already_claimed_review_sees_not_pending(), count_monitored_missing_episodes_in_season_counts_correctly(), does_not_find_an_unmonitored_or_already_have_episode(), find_episode_id_ignores_monitored_and_has_file_state(), finds_a_monitored_missing_episode(), insert_pending_review(), movie_does_not_need_grab_once_it_has_a_file(), movie_does_not_need_grab_when_unmonitored() (+16 more)
|
||||
|
||||
### Community 1 - "api/mod.rs"
|
||||
Cohesion: 0.09
|
||||
Nodes (37): AppState, BackgroundRequest, constant_time_eq(), CycleRecord, CycleStatus, require_api_token(), router(), Connection (+29 more)
|
||||
|
||||
### Community 2 - "Connection"
|
||||
Cohesion: 0.16
|
||||
Nodes (37): ApprovalPrep, best_existing_movie_score(), best_existing_score(), best_existing_season_pack_score(), count_monitored_missing_episodes_in_season(), finalize_review_approval(), find_episode_id(), find_media_item_id_by_title() (+29 more)
|
||||
|
||||
### Community 3 - "execute_search_targets"
|
||||
Cohesion: 0.24
|
||||
Nodes (17): TitleMatcher, execute_search_targets(), GrabCycleStats, is_seen(), mark_seen(), QbitClient, run_grab_cycle(), run_search_cycle() (+9 more)
|
||||
|
||||
### Community 4 - "enumerate_search_targets"
|
||||
Cohesion: 0.13
|
||||
Nodes (30): build_movie_query(), build_tv_query(), cadence_stays_backed_off_at_a_high_search_count_instead_of_overflowing(), enumerate_search_targets(), enumerate_search_targets_excludes_owned_movies_includes_missing(), enumerate_search_targets_excludes_unaired_inflight_and_anime(), enumerate_search_targets_for_media_item(), enumerate_search_targets_for_media_item_routes_anime_movie_via_tmdb_table() (+22 more)
|
||||
|
||||
### Community 5 - "rss.rs"
|
||||
Cohesion: 0.07
|
||||
Nodes (29): parse_human_size(), RawReleaseItem, Option, String, urlencode(), accumulate_field(), build_search_url(), ItemFields (+21 more)
|
||||
|
||||
### Community 6 - "matcher/mod.rs"
|
||||
Cohesion: 0.14
|
||||
Nodes (17): download(), ensure_model(), MatchCandidate, MatchOutcome, queue_for_review(), Connection, Option, Path (+9 more)
|
||||
|
||||
### Community 7 - "parser/mod.rs"
|
||||
Cohesion: 0.08
|
||||
Nodes (42): a_genuine_season_only_pack_with_no_dash_episode_still_has_no_episode(), brackets_still_take_priority_over_a_coincidental_bare_year(), Codec, does_not_mistake_a_year_titled_movie_for_a_bare_year(), does_not_mistake_a_yyyy_mm_dd_date_for_an_episode_range(), does_not_panic_on_real_corpus(), does_not_panic_on_unparsable_manga_release(), extracts_a_bare_year_from_a_scene_style_movie_name() (+34 more)
|
||||
|
||||
### Community 8 - "config.rs"
|
||||
Cohesion: 0.06
|
||||
Nodes (56): Config, config_path(), DaemonConfig, default_1337x_mirrors(), default_anime_svtav1_max_threads(), default_anime_svtav1_preset(), default_av1_efficiency_factor(), default_config_has_expected_values() (+48 more)
|
||||
|
||||
### Community 9 - "transcode/mod.rs"
|
||||
Cohesion: 0.09
|
||||
Nodes (63): Arc, TranscodeConfig, BacklogCandidate, cfg(), claim_pending_jobs(), claim_pending_jobs_claims_nothing_when_already_at_the_cap(), claim_pending_jobs_enforces_live_action_and_anime_caps_independently(), claim_pending_jobs_respects_already_running_jobs_as_a_global_cap() (+55 more)
|
||||
|
||||
### Community 10 - "ffprobe.rs"
|
||||
Cohesion: 0.12
|
||||
Nodes (27): AudioStream, build_media_probe(), DecodeCheck, generate_clip(), generate_test_clip(), is_english(), MediaProbe, parse_frame_rate_fraction() (+19 more)
|
||||
|
||||
### Community 11 - "importer/mod.rs"
|
||||
Cohesion: 0.10
|
||||
Nodes (25): a_fresh_grab_is_not_stalled(), a_grab_untouched_past_the_threshold_is_stalled(), a_torrent_missing_past_the_grace_period_is_failed(), a_torrent_missing_within_the_grace_period_is_not_yet_failed(), fail_grab_reopens_the_release_for_search(), find_relinkable_episode_files_falls_back_to_the_unpadded_season_folder(), find_relinkable_episode_files_skips_an_ambiguous_match_rather_than_guessing(), media_item_with_root() (+17 more)
|
||||
|
||||
### Community 12 - "Result"
|
||||
Cohesion: 0.15
|
||||
Nodes (24): copy_via_temp_file(), copy_via_temp_file_writes_through_a_part_file_and_renames_into_place(), find_by_basename(), find_by_stem(), import_season_pack_file(), insufficient_space(), largest_video_file(), locate_video_file() (+16 more)
|
||||
|
||||
### Community 13 - "process_pending_grabs"
|
||||
Cohesion: 0.20
|
||||
Nodes (13): a_persistently_failing_import_escalates_to_failed_instead_of_retrying_forever(), does_not_import_a_torrent_while_it_is_still_being_physically_moved(), fail_grab(), fetch_pending_grabs(), ImportStats, PendingGrab, process_pending_grabs(), process_pending_grabs_routes_each_grab_by_torrent_state() (+5 more)
|
||||
|
||||
### Community 14 - "Connection"
|
||||
Cohesion: 0.17
|
||||
Nodes (16): ensure_probed(), ensure_probed_does_not_flag_a_wide_aspect_ratio_file_as_under_quality(), generate_dual_audio_clip(), grab_is_stalled(), grab_missing_past_grace(), probe_library(), probe_library_reports_probed_vs_skipped_up_to_date(), ProbeSweepReport (+8 more)
|
||||
|
||||
### Community 15 - "import_one"
|
||||
Cohesion: 0.16
|
||||
Nodes (14): a_drifted_root_folder_does_not_defeat_the_dest_collision_check(), a_strictly_better_release_replaces_the_existing_file_via_a_real_move(), an_upgrade_swap_sweeps_every_duplicate_episode_file_row_not_just_one(), ensure_probed_flags_a_low_resolution_file_as_under_quality(), ensure_probed_skips_reprobing_an_unchanged_file(), generate_test_clip(), import_one(), import_one_enqueues_a_transcode_job_when_transcode_is_enabled() (+6 more)
|
||||
|
||||
### Community 16 - "find_relinkable_episode_files"
|
||||
Cohesion: 0.20
|
||||
Nodes (13): collect_video_files(), deterministic_filename(), deterministic_movie_filename(), find_relinkable_episode_files(), find_relinkable_episode_files_finds_a_file_with_no_tracked_row(), has_cjk(), ProbeFields, relink_episode_files() (+5 more)
|
||||
|
||||
### Community 17 - "fetch_candidates"
|
||||
Cohesion: 0.17
|
||||
Nodes (12): fetch_candidates(), infer_has_english_audio(), is_anime(), load_quality_profile(), load_quality_profile_applies_a_stored_override(), load_quality_profile_uses_defaults_for_the_seeded_empty_weights_row(), passes_relevance_filter(), release_sort_key() (+4 more)
|
||||
|
||||
### Community 18 - "import_season_pack"
|
||||
Cohesion: 0.43
|
||||
Nodes (7): import_season_pack(), import_season_pack_fails_when_nothing_in_it_matches_a_tracked_episode(), import_season_pack_imports_every_file_and_marks_episodes_owned(), import_season_pack_skips_an_upgrade_locked_episode_even_with_a_lower_scoring_existing_release(), import_season_pack_skips_episodes_that_already_have_a_better_file(), SeasonPackImportOutcome, seeded_season_pack_conn()
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `TranscodeConfig` connect `transcode/mod.rs` to `config.rs`, `Result`, `process_pending_grabs`, `import_one`, `import_season_pack`?**
|
||||
_High betweenness centrality (0.462) - this node is a cross-community bridge._
|
||||
- **Why does `SearchCycleStats` connect `execute_search_targets` to `scheduler.rs`, `api/mod.rs`?**
|
||||
_High betweenness centrality (0.316) - this node is a cross-community bridge._
|
||||
- **Why does `AppState` connect `api/mod.rs` to `transcode/mod.rs`?**
|
||||
_High betweenness centrality (0.195) - this node is a cross-community bridge._
|
||||
- **Should `scheduler.rs` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.0666049953746531 - nodes in this community are weakly interconnected._
|
||||
- **Should `api/mod.rs` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.08527131782945736 - nodes in this community are weakly interconnected._
|
||||
- **Should `enumerate_search_targets` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.12643678160919541 - nodes in this community are weakly interconnected._
|
||||
- **Should `rss.rs` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.06570048309178744 - nodes in this community are weakly interconnected._
|
||||
24858
graphify-out/2026-08-03/graph.json
Normal file
24858
graphify-out/2026-08-03/graph.json
Normal file
File diff suppressed because it is too large
Load diff
232
graphify-out/2026-08-03/manifest.json
Normal file
232
graphify-out/2026-08-03/manifest.json
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
{
|
||||
"breadarr-shared/src/client.rs": {
|
||||
"mtime": 1784176136.6198714,
|
||||
"ast_hash": "383a3e626e611317f06b28984d72ab9e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarr-shared/src/config.rs": {
|
||||
"mtime": 1785696395.8005114,
|
||||
"ast_hash": "f6b6fb2eb97829e12f88821408ad1f02",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarr-shared/src/dto.rs": {
|
||||
"mtime": 1784908572.450929,
|
||||
"ast_hash": "aa32217f2eb0f9f0dd3cb5384063e22f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarr-shared/src/lib.rs": {
|
||||
"mtime": 1783781950.6175613,
|
||||
"ast_hash": "a2ab57e66492dd8a3dd59c60f9821b27",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarr-tui/src/app.rs": {
|
||||
"mtime": 1784638215.5754883,
|
||||
"ast_hash": "ca1205c8be64ec95230c75db46ceac51",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarr-tui/src/main.rs": {
|
||||
"mtime": 1784638316.713315,
|
||||
"ast_hash": "5fd64e003fb112a15f45e26b70a0e286",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarr-tui/src/ui.rs": {
|
||||
"mtime": 1784638447.1445198,
|
||||
"ast_hash": "44d8a181957b8c1e644a45dff91e9a76",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/api/mod.rs": {
|
||||
"mtime": 1785696056.3421187,
|
||||
"ast_hash": "3868111877263996f985e7297f566569",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/api/routes/calendar.rs": {
|
||||
"mtime": 1784033809.0910208,
|
||||
"ast_hash": "4d04ad23ceba40405f5a015525753b5c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/api/routes/health.rs": {
|
||||
"mtime": 1784908576.1114342,
|
||||
"ast_hash": "55d7e27388131a844315e941095f2127",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/api/routes/library_health.rs": {
|
||||
"mtime": 1784113370.1363761,
|
||||
"ast_hash": "1e2fc6fff36cbf091a539000ede57611",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/api/routes/media.rs": {
|
||||
"mtime": 1784171609.152028,
|
||||
"ast_hash": "c1e84c6268e624f1eae46fd70033fa04",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/api/routes/mod.rs": {
|
||||
"mtime": 1784175849.5137725,
|
||||
"ast_hash": "46b952bdd0b9cbb8d0418a350e814459",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/api/routes/quality_profiles.rs": {
|
||||
"mtime": 1784176136.6858702,
|
||||
"ast_hash": "2dfbf6122c323b7521df3087e739f62f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/api/routes/releases.rs": {
|
||||
"mtime": 1783782116.7739515,
|
||||
"ast_hash": "0eb53bc15913f1445687f8de7e4040ad",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/api/routes/review.rs": {
|
||||
"mtime": 1785695923.5693555,
|
||||
"ast_hash": "907f8d308d2942eb3abb92482ffca187",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/api/routes/search.rs": {
|
||||
"mtime": 1783841453.3332663,
|
||||
"ast_hash": "7d82ca20febd26e9020642aecbab313c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/api/routes/stuck.rs": {
|
||||
"mtime": 1784638231.949136,
|
||||
"ast_hash": "46ff0112d0302f18ed8ad0d2c47d9f3b",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/db.rs": {
|
||||
"mtime": 1785332784.4654708,
|
||||
"ast_hash": "54d92eeae67311c25b0dec690302f6c1",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/importer/ffprobe.rs": {
|
||||
"mtime": 1785695262.529791,
|
||||
"ast_hash": "c9749d92cd5bffb5e7633facb369f051",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/importer/mkv.rs": {
|
||||
"mtime": 1784175099.8600957,
|
||||
"ast_hash": "99c3e8684a7081eda3730e6ecee4d862",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/importer/mod.rs": {
|
||||
"mtime": 1785695989.9557424,
|
||||
"ast_hash": "3f763d44c853bcd5d0cdfba4a60783c6",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/jellyfin.rs": {
|
||||
"mtime": 1784911004.2736921,
|
||||
"ast_hash": "245f75ff1d6f3c86e15635537f9234a5",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/library_scan.rs": {
|
||||
"mtime": 1784632454.081846,
|
||||
"ast_hash": "b35f50965f0e39bacf99059c84d18006",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/main.rs": {
|
||||
"mtime": 1785677152.6518264,
|
||||
"ast_hash": "3e5baa3fdebcfe6172675afe575b4505",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/matcher/embed.rs": {
|
||||
"mtime": 1784269828.2931612,
|
||||
"ast_hash": "e0a425d2e351589b6e56af02fe8ad396",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/matcher/mod.rs": {
|
||||
"mtime": 1785695690.847343,
|
||||
"ast_hash": "e71d4677d9f421b7ef8b84354ac9fc4d",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/metadata/anime_map.rs": {
|
||||
"mtime": 1783855515.7563374,
|
||||
"ast_hash": "46b90382ecc49a1452d580f61d7806a4",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/metadata/mod.rs": {
|
||||
"mtime": 1784636520.9888098,
|
||||
"ast_hash": "bc4ebde5b9e89515ee87ea6a5ffd3c10",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/metadata/tmdb.rs": {
|
||||
"mtime": 1783802179.6291993,
|
||||
"ast_hash": "02ff58e5db664e947f486bb0e685f5b4",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/metadata/tvdb.rs": {
|
||||
"mtime": 1784635726.8710334,
|
||||
"ast_hash": "cd21d4943f6a05269a46d95bae368758",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/notify.rs": {
|
||||
"mtime": 1784033287.1188982,
|
||||
"ast_hash": "35b8279fb3d6f05892bda5401541c838",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/parser/mod.rs": {
|
||||
"mtime": 1785695514.945018,
|
||||
"ast_hash": "5d42da21fd9eae954c0b1d0633d7ba45",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/parser/tokens.rs": {
|
||||
"mtime": 1785695636.4009888,
|
||||
"ast_hash": "88b08a119a5285c9e640d2dfe59dbd85",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/qbit/mod.rs": {
|
||||
"mtime": 1785662339.058403,
|
||||
"ast_hash": "cd3e6c110e9cf757815d4bfb62faca8e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/scheduler.rs": {
|
||||
"mtime": 1785695949.7192466,
|
||||
"ast_hash": "99233bde5f082ea7ed9b9065ff4943a0",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/scoring/gate.rs": {
|
||||
"mtime": 1784117113.4110034,
|
||||
"ast_hash": "f6090fd0a592380dc3ce045decf6932d",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/scoring/mod.rs": {
|
||||
"mtime": 1784175656.340803,
|
||||
"ast_hash": "f00b3e1527789cc6ab5ac6e384c451c0",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/scoring/profile.rs": {
|
||||
"mtime": 1784175744.9204473,
|
||||
"ast_hash": "5f637981034d7c4edb9e7f70aebc2e36",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/scoring/score.rs": {
|
||||
"mtime": 1784015678.9287148,
|
||||
"ast_hash": "6ca1852f1e611ac7046127ab2b60524e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/sources/mod.rs": {
|
||||
"mtime": 1785696200.501458,
|
||||
"ast_hash": "d4efc09eab30de5fee6d25d000ca2b0c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/sources/rss.rs": {
|
||||
"mtime": 1785696625.7660108,
|
||||
"ast_hash": "4f3fd2400a3cc2cf3e24900f1335e884",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/sources/scrape.rs": {
|
||||
"mtime": 1784177018.756294,
|
||||
"ast_hash": "df885de8d787d6c2af3d814f0831ee6e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/sources/tpb.rs": {
|
||||
"mtime": 1785696280.7010753,
|
||||
"ast_hash": "8b6896a57f82268a0e1c6924054ce968",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadarrd/src/transcode/mod.rs": {
|
||||
"mtime": 1785696564.622992,
|
||||
"ast_hash": "fd89d1494a14380c2d758a7d90312a1b",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"README.md": {
|
||||
"mtime": 1784175535.4875388,
|
||||
"ast_hash": "62f6240c65824f55179178205fb74b90",
|
||||
"semantic_hash": ""
|
||||
}
|
||||
}
|
||||
188
graphify-out/GRAPH_REPORT.md
Normal file
188
graphify-out/GRAPH_REPORT.md
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
# Graph Report - breadarr (2026-08-03)
|
||||
|
||||
## Corpus Check
|
||||
- 46 files · ~90,608 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 693 nodes · 1770 edges · 24 communities
|
||||
- Extraction: 100% EXTRACTED · 0% INFERRED · 0% AMBIGUOUS · INFERRED: 2 edges (avg confidence: 0.8)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Graph Freshness
|
||||
- Built from commit: `d110556a`
|
||||
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||
- Run `graphify update .` after code changes (no API cost).
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- scheduler.rs
|
||||
- api/mod.rs
|
||||
- Connection
|
||||
- fetch_candidates
|
||||
- enumerate_upgrade_targets
|
||||
- rss.rs
|
||||
- TitleMatcher
|
||||
- parser/mod.rs
|
||||
- config.rs
|
||||
- transcode/mod.rs
|
||||
- ffprobe.rs
|
||||
- importer/mod.rs
|
||||
- Result
|
||||
- process_pending_grabs
|
||||
- Connection
|
||||
- import_one
|
||||
- find_relinkable_episode_files
|
||||
- main.rs
|
||||
- import_season_pack
|
||||
- QbitClient
|
||||
- db.rs
|
||||
- String
|
||||
- enumerate_search_targets
|
||||
- seeded_conn
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `import_one()` - 30 edges
|
||||
2. `process_item()` - 30 edges
|
||||
3. `TranscodeConfig` - 24 edges
|
||||
4. `process_pending_grabs()` - 24 edges
|
||||
5. `main()` - 23 edges
|
||||
6. `parse()` - 22 edges
|
||||
7. `AppState` - 19 edges
|
||||
8. `encode_and_verify()` - 18 edges
|
||||
9. `fetch_candidates()` - 18 edges
|
||||
10. `QbitClient` - 17 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `import_one()` --references--> `TranscodeConfig` [EXTRACTED]
|
||||
breadarrd/src/importer/mod.rs → breadarr-shared/src/config.rs
|
||||
- `import_season_pack()` --references--> `TranscodeConfig` [EXTRACTED]
|
||||
breadarrd/src/importer/mod.rs → breadarr-shared/src/config.rs
|
||||
- `import_season_pack_file()` --references--> `TranscodeConfig` [EXTRACTED]
|
||||
breadarrd/src/importer/mod.rs → breadarr-shared/src/config.rs
|
||||
- `maybe_enqueue_transcode()` --references--> `TranscodeConfig` [EXTRACTED]
|
||||
breadarrd/src/importer/mod.rs → breadarr-shared/src/config.rs
|
||||
- `process_pending_grabs()` --references--> `TranscodeConfig` [EXTRACTED]
|
||||
breadarrd/src/importer/mod.rs → breadarr-shared/src/config.rs
|
||||
|
||||
## Import Cycles
|
||||
- None detected.
|
||||
|
||||
## Communities (24 total, 0 thin omitted)
|
||||
|
||||
### Community 0 - "scheduler.rs"
|
||||
Cohesion: 0.07
|
||||
Nodes (21): a_second_prepare_call_on_an_already_claimed_review_sees_not_pending(), insert_pending_review(), load_quality_profile(), load_quality_profile_applies_a_stored_override(), load_quality_profile_uses_defaults_for_the_seeded_empty_weights_row(), movie_does_not_need_grab_once_it_has_a_file(), movie_does_not_need_grab_when_unmonitored(), movie_does_not_need_grab_while_a_release_is_in_flight() (+13 more)
|
||||
|
||||
### Community 1 - "api/mod.rs"
|
||||
Cohesion: 0.09
|
||||
Nodes (36): AppState, BackgroundRequest, constant_time_eq(), CycleRecord, CycleStatus, require_api_token(), router(), Connection (+28 more)
|
||||
|
||||
### Community 2 - "Connection"
|
||||
Cohesion: 0.18
|
||||
Nodes (30): best_existing_movie_score(), best_existing_score(), best_existing_season_pack_score(), count_monitored_missing_episodes_in_season(), find_episode_id(), find_media_item_id_by_title(), find_monitored_episode(), find_monitored_missing_episode() (+22 more)
|
||||
|
||||
### Community 3 - "fetch_candidates"
|
||||
Cohesion: 0.20
|
||||
Nodes (18): execute_search_targets(), fetch_candidates(), infer_has_english_audio(), is_anime(), looks_like_season_pack(), passes_relevance_filter(), release_sort_key(), ReleaseCandidate (+10 more)
|
||||
|
||||
### Community 4 - "enumerate_upgrade_targets"
|
||||
Cohesion: 0.21
|
||||
Nodes (15): build_tv_query(), enumerate_search_targets_for_media_item(), enumerate_search_targets_for_media_item_routes_anime_movie_via_tmdb_table(), enumerate_upgrade_targets(), enumerate_upgrade_targets_excludes_an_upgrade_locked_episode(), enumerate_upgrade_targets_prioritizes_a_probe_flagged_episode_over_a_clean_one(), enumerate_upgrade_targets_returns_both_when_budget_allows(), relevance_filter_accepts_a_genuine_match() (+7 more)
|
||||
|
||||
### Community 5 - "rss.rs"
|
||||
Cohesion: 0.07
|
||||
Nodes (29): parse_human_size(), RawReleaseItem, Option, String, urlencode(), accumulate_field(), build_search_url(), ItemFields (+21 more)
|
||||
|
||||
### Community 6 - "TitleMatcher"
|
||||
Cohesion: 0.15
|
||||
Nodes (19): download(), ensure_model(), MatchCandidate, MatchOutcome, queue_for_review(), Connection, Option, Path (+11 more)
|
||||
|
||||
### Community 7 - "parser/mod.rs"
|
||||
Cohesion: 0.08
|
||||
Nodes (42): a_genuine_season_only_pack_with_no_dash_episode_still_has_no_episode(), brackets_still_take_priority_over_a_coincidental_bare_year(), Codec, does_not_mistake_a_year_titled_movie_for_a_bare_year(), does_not_mistake_a_yyyy_mm_dd_date_for_an_episode_range(), does_not_panic_on_real_corpus(), does_not_panic_on_unparsable_manga_release(), extracts_a_bare_year_from_a_scene_style_movie_name() (+34 more)
|
||||
|
||||
### Community 8 - "config.rs"
|
||||
Cohesion: 0.06
|
||||
Nodes (56): Config, config_path(), DaemonConfig, default_1337x_mirrors(), default_anime_svtav1_max_threads(), default_anime_svtav1_preset(), default_av1_efficiency_factor(), default_config_has_expected_values() (+48 more)
|
||||
|
||||
### Community 9 - "transcode/mod.rs"
|
||||
Cohesion: 0.09
|
||||
Nodes (63): Arc, TranscodeConfig, BacklogCandidate, cfg(), claim_pending_jobs(), claim_pending_jobs_claims_nothing_when_already_at_the_cap(), claim_pending_jobs_enforces_live_action_and_anime_caps_independently(), claim_pending_jobs_respects_already_running_jobs_as_a_global_cap() (+55 more)
|
||||
|
||||
### Community 10 - "ffprobe.rs"
|
||||
Cohesion: 0.12
|
||||
Nodes (27): AudioStream, build_media_probe(), DecodeCheck, generate_clip(), generate_test_clip(), is_english(), MediaProbe, parse_frame_rate_fraction() (+19 more)
|
||||
|
||||
### Community 11 - "importer/mod.rs"
|
||||
Cohesion: 0.10
|
||||
Nodes (25): a_fresh_grab_is_not_stalled(), a_grab_untouched_past_the_threshold_is_stalled(), a_torrent_missing_past_the_grace_period_is_failed(), a_torrent_missing_within_the_grace_period_is_not_yet_failed(), fail_grab_reopens_the_release_for_search(), find_relinkable_episode_files_falls_back_to_the_unpadded_season_folder(), find_relinkable_episode_files_skips_an_ambiguous_match_rather_than_guessing(), media_item_with_root() (+17 more)
|
||||
|
||||
### Community 12 - "Result"
|
||||
Cohesion: 0.15
|
||||
Nodes (24): copy_via_temp_file(), copy_via_temp_file_writes_through_a_part_file_and_renames_into_place(), find_by_basename(), find_by_stem(), import_season_pack_file(), insufficient_space(), largest_video_file(), locate_video_file() (+16 more)
|
||||
|
||||
### Community 13 - "process_pending_grabs"
|
||||
Cohesion: 0.20
|
||||
Nodes (13): a_persistently_failing_import_escalates_to_failed_instead_of_retrying_forever(), does_not_import_a_torrent_while_it_is_still_being_physically_moved(), fail_grab(), fetch_pending_grabs(), ImportStats, PendingGrab, process_pending_grabs(), process_pending_grabs_routes_each_grab_by_torrent_state() (+5 more)
|
||||
|
||||
### Community 14 - "Connection"
|
||||
Cohesion: 0.17
|
||||
Nodes (16): ensure_probed(), ensure_probed_does_not_flag_a_wide_aspect_ratio_file_as_under_quality(), generate_dual_audio_clip(), grab_is_stalled(), grab_missing_past_grace(), probe_library(), probe_library_reports_probed_vs_skipped_up_to_date(), ProbeSweepReport (+8 more)
|
||||
|
||||
### Community 15 - "import_one"
|
||||
Cohesion: 0.16
|
||||
Nodes (14): a_drifted_root_folder_does_not_defeat_the_dest_collision_check(), a_strictly_better_release_replaces_the_existing_file_via_a_real_move(), an_upgrade_swap_sweeps_every_duplicate_episode_file_row_not_just_one(), ensure_probed_flags_a_low_resolution_file_as_under_quality(), ensure_probed_skips_reprobing_an_unchanged_file(), generate_test_clip(), import_one(), import_one_enqueues_a_transcode_job_when_transcode_is_enabled() (+6 more)
|
||||
|
||||
### Community 16 - "find_relinkable_episode_files"
|
||||
Cohesion: 0.20
|
||||
Nodes (13): collect_video_files(), deterministic_filename(), deterministic_movie_filename(), find_relinkable_episode_files(), find_relinkable_episode_files_finds_a_file_with_no_tracked_row(), has_cjk(), ProbeFields, relink_episode_files() (+5 more)
|
||||
|
||||
### Community 17 - "main.rs"
|
||||
Cohesion: 0.17
|
||||
Nodes (35): BackgroundRequest, background_loop(), debug_1337x_search(), debug_anime_map_refresh(), debug_grab_cycle(), debug_import_cycle(), debug_jellyfin_refresh(), debug_match_title() (+27 more)
|
||||
|
||||
### Community 18 - "import_season_pack"
|
||||
Cohesion: 0.43
|
||||
Nodes (7): import_season_pack(), import_season_pack_fails_when_nothing_in_it_matches_a_tracked_episode(), import_season_pack_imports_every_file_and_marks_episodes_owned(), import_season_pack_skips_an_upgrade_locked_episode_even_with_a_lower_scoring_existing_release(), import_season_pack_skips_episodes_that_already_have_a_better_file(), SeasonPackImportOutcome, seeded_season_pack_conn()
|
||||
|
||||
### Community 19 - "QbitClient"
|
||||
Cohesion: 0.10
|
||||
Nodes (18): AddTorrentResponse, extract_btih(), MagnetRejected, QbitClient, Mutex, Option, Result, TorrentInfo (+10 more)
|
||||
|
||||
### Community 20 - "db.rs"
|
||||
Cohesion: 0.25
|
||||
Nodes (18): add_column_if_missing(), backup_before_open(), backup_before_open_copies_an_existing_database(), backup_before_open_is_a_noop_when_no_database_exists_yet(), backup_before_open_prunes_beyond_max_backups(), init(), init_is_idempotent(), prune_old_backups() (+10 more)
|
||||
|
||||
### Community 21 - "String"
|
||||
Cohesion: 0.23
|
||||
Nodes (13): ApprovalPrep, build_movie_query(), finalize_review_approval(), get_media_item(), grab_and_capture_hash(), grab_candidate(), grab_prepared_approval(), MediaItemRow (+5 more)
|
||||
|
||||
### Community 22 - "enumerate_search_targets"
|
||||
Cohesion: 0.33
|
||||
Nodes (11): cadence_stays_backed_off_at_a_high_search_count_instead_of_overflowing(), enumerate_search_targets(), enumerate_search_targets_excludes_owned_movies_includes_missing(), enumerate_search_targets_excludes_unaired_inflight_and_anime(), enumerate_search_targets_prioritizes_never_searched_first(), enumerate_search_targets_respects_budget(), enumerate_search_targets_routes_anime_movies_to_nyaa_search(), record_search_attempt() (+3 more)
|
||||
|
||||
### Community 23 - "seeded_conn"
|
||||
Cohesion: 0.22
|
||||
Nodes (10): count_monitored_missing_episodes_in_season_counts_correctly(), does_not_find_an_unmonitored_or_already_have_episode(), find_episode_id_ignores_monitored_and_has_file_state(), finds_a_monitored_missing_episode(), record_grab(), record_grab_still_logs_to_torrent_fetch_when_hash_capture_failed(), record_grab_writes_both_a_release_row_and_a_torrent_fetch_audit_row(), resolves_absolute_episode_via_anime_map() (+2 more)
|
||||
|
||||
## Knowledge Gaps
|
||||
- **1 isolated node(s):** `AddTorrentResponse`
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `TranscodeConfig` connect `transcode/mod.rs` to `config.rs`, `Result`, `process_pending_grabs`, `import_one`, `import_season_pack`?**
|
||||
_High betweenness centrality (0.409) - this node is a cross-community bridge._
|
||||
- **Why does `SearchCycleStats` connect `fetch_candidates` to `scheduler.rs`, `api/mod.rs`?**
|
||||
_High betweenness centrality (0.271) - this node is a cross-community bridge._
|
||||
- **Why does `AppState` connect `api/mod.rs` to `transcode/mod.rs`, `main.rs`?**
|
||||
_High betweenness centrality (0.217) - this node is a cross-community bridge._
|
||||
- **What connects `AddTorrentResponse` to the rest of the system?**
|
||||
_1 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
- **Should `scheduler.rs` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.06976744186046512 - nodes in this community are weakly interconnected._
|
||||
- **Should `api/mod.rs` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.08826945412311266 - nodes in this community are weakly interconnected._
|
||||
- **Should `rss.rs` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.06570048309178744 - nodes in this community are weakly interconnected._
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [{"id": "$graphify-root$_breadarrd_src_scoring_mod_rs", "label": "mod.rs", "file_type": "code", "source_file": "breadarrd/src/scoring/mod.rs", "source_location": "L1"}], "edges": [{"source": "$graphify-root$_breadarrd_src_scoring_mod_rs", "target": "gate", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "breadarrd/src/scoring/mod.rs", "source_location": "L5", "weight": 1.0, "context": "import"}, {"source": "$graphify-root$_breadarrd_src_scoring_mod_rs", "target": "profile", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "breadarrd/src/scoring/mod.rs", "source_location": "L6", "weight": 1.0, "context": "import"}, {"source": "$graphify-root$_breadarrd_src_scoring_mod_rs", "target": "score", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "breadarrd/src/scoring/mod.rs", "source_location": "L7", "weight": 1.0, "context": "import"}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [{"id": "$graphify-root$_breadarr_shared_src_lib_rs", "label": "lib.rs", "file_type": "code", "source_file": "breadarr-shared/src/lib.rs", "source_location": "L1"}], "edges": [{"source": "$graphify-root$_breadarr_shared_src_lib_rs", "target": "daemonclient", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "breadarr-shared/src/lib.rs", "source_location": "L5", "weight": 1.0, "context": "import"}, {"source": "$graphify-root$_breadarr_shared_src_lib_rs", "target": "config", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "breadarr-shared/src/lib.rs", "source_location": "L6", "weight": 1.0, "context": "import"}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [{"id": "$graphify-root$_breadarrd_src_api_routes_mod_rs", "label": "mod.rs", "file_type": "code", "source_file": "breadarrd/src/api/routes/mod.rs", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue