From 2ac5199c274016b6d01c5a42c7b508b2cbe0c740 Mon Sep 17 00:00:00 2001 From: Ryder Casazza Date: Tue, 21 Jul 2026 14:25:45 -0700 Subject: [PATCH 1/2] perf: stream the mapping file straight into the index build_index used to deserialise the whole ~9 MB document into an intermediate HashMap of all ~77k source entries (each with its own inner map) before extracting the ~12k targets it actually needs -- about 33 MiB of transient allocations per refresh, which glibc then retains as idle RSS. A streaming serde visitor now folds keys directly into the four index maps and discards everything else as it goes. --- src/mapping.rs | 238 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 176 insertions(+), 62 deletions(-) diff --git a/src/mapping.rs b/src/mapping.rs index 8bc2705..238d613 100644 --- a/src/mapping.rs +++ b/src/mapping.rs @@ -384,69 +384,16 @@ impl PlexAniBridgeMappings { } fn build_index(bytes: &[u8]) -> Result { - // Deserialise with borrowed keys and discard the season-range values via `IgnoredAny`. - let raw: HashMap, HashMap, IgnoredAny>> = - serde_json::from_slice(bytes) + // Stream the cache file straight into the index + // Saves alot of memory over deserializing the whole document into a `serde_json::Value` + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let index = + serde::Deserializer::deserialize_map(&mut deserializer, IndexBuilder::default()) .context("failed to deserialise plexanibridge mapping file")?; - - let mut tvdb_index: HashMap> = HashMap::new(); - let mut anilist_index: HashMap> = HashMap::new(); - let mut tmdb_index: HashMap = HashMap::new(); - let mut anilist_tmdb: HashMap = HashMap::new(); - - for (source_key, targets) in &raw { - if source_key.starts_with('$') { - continue; - } - - let Some(("anilist", id_str, _scope)) = parse_descriptor(source_key) else { - continue; - }; - - let Ok(anilist_id) = id_str.parse::() else { - debug!(source_key = %source_key, "skipping mapping with non-numeric anilist id"); - continue; - }; - - for target_key in targets.keys() { - let Some((target_provider, target_id_str, target_scope)) = - parse_descriptor(target_key) - else { - continue; - }; - - if target_provider == "tvdb_show" { - let Ok(tvdb_id) = target_id_str.parse::() else { - continue; - }; - let season = target_scope.unwrap_or("s1").to_owned(); - tvdb_index.entry(tvdb_id).or_default().push(MappingEntry { - anilist_id, - seasons: vec![season.clone()], - }); - anilist_index - .entry(anilist_id) - .or_default() - .push(ReverseMappingEntry { - tvdb_id, - seasons: vec![season], - }); - } else if target_provider == "tmdb_movie" { - let Ok(tmdb_id) = target_id_str.parse::() else { - continue; - }; - tmdb_index.insert(tmdb_id, anilist_id); - anilist_tmdb.insert(anilist_id, tmdb_id); - } - } - } - - Ok(MappingIndex { - tvdb_to_entries: tvdb_index, - anilist_to_entries: anilist_index, - tmdb_to_anilist: tmdb_index, - anilist_to_tmdb: anilist_tmdb, - }) + deserializer + .end() + .context("failed to deserialise plexanibridge mapping file")?; + Ok(index) } pub async fn resolve_anilist_id(&self, tvdb_id: i64, season: u32) -> Result> { @@ -521,6 +468,173 @@ impl PlexAniBridgeMappings { } } +/// Streaming visitor over the top-level mappings object. Accumulates the four +/// index maps while discarding everything that the index doesn't need. +#[derive(Default)] +struct IndexBuilder { + tvdb_to_entries: HashMap>, + anilist_to_entries: HashMap>, + tmdb_to_anilist: HashMap, + anilist_to_tmdb: HashMap, +} + +impl IndexBuilder { + fn record_target(&mut self, anilist_id: i64, target_key: &str) { + let Some((provider, id_str, scope)) = parse_descriptor(target_key) else { + return; + }; + + if provider == "tvdb_show" { + let Ok(tvdb_id) = id_str.parse::() else { + return; + }; + let season = scope.unwrap_or("s1").to_owned(); + self.tvdb_to_entries + .entry(tvdb_id) + .or_default() + .push(MappingEntry { + anilist_id, + seasons: vec![season.clone()], + }); + self.anilist_to_entries + .entry(anilist_id) + .or_default() + .push(ReverseMappingEntry { + tvdb_id, + seasons: vec![season], + }); + } else if provider == "tmdb_movie" { + let Ok(tmdb_id) = id_str.parse::() else { + return; + }; + self.tmdb_to_anilist.insert(tmdb_id, anilist_id); + self.anilist_to_tmdb.insert(anilist_id, tmdb_id); + } + } +} + +impl<'de> serde::de::Visitor<'de> for IndexBuilder { + type Value = MappingIndex; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a map of provider descriptors to mapping objects") + } + + fn visit_map(mut self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + while let Some(CowStr(source_key)) = map.next_key::>()? { + let anilist_id = if source_key.starts_with('$') { + None + } else { + match parse_descriptor(&source_key) { + Some(("anilist", id_str, _scope)) => match id_str.parse::() { + Ok(id) => Some(id), + Err(_) => { + debug!(source_key = %source_key, "skipping mapping with non-numeric anilist id"); + None + } + }, + _ => None, + } + }; + + match anilist_id { + Some(anilist_id) => map.next_value_seed(TargetsSeed { + anilist_id, + builder: &mut self, + })?, + None => { + map.next_value::()?; + } + } + } + + Ok(MappingIndex { + tvdb_to_entries: self.tvdb_to_entries, + anilist_to_entries: self.anilist_to_entries, + tmdb_to_anilist: self.tmdb_to_anilist, + anilist_to_tmdb: self.anilist_to_tmdb, + }) + } +} + +/// Streams one source entry's target map, recording each target key and +/// discarding its value. +struct TargetsSeed<'a> { + anilist_id: i64, + builder: &'a mut IndexBuilder, +} + +impl<'de> serde::de::DeserializeSeed<'de> for TargetsSeed<'_> { + type Value = (); + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_map(self) + } +} + +impl<'de> serde::de::Visitor<'de> for TargetsSeed<'_> { + type Value = (); + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a map of target descriptors") + } + + fn visit_map(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + while let Some(CowStr(target_key)) = map.next_key::>()? { + map.next_value::()?; + self.builder.record_target(self.anilist_id, &target_key); + } + Ok(()) + } +} + +/// `Cow` that borrows from the input when the deserializer allows it. +/// serde's own `Cow` impl always allocates an owned copy. +struct CowStr<'de>(Cow<'de, str>); + +impl<'de> serde::Deserialize<'de> for CowStr<'de> { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct CowStrVisitor; + + impl<'de> serde::de::Visitor<'de> for CowStrVisitor { + type Value = CowStr<'de>; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string") + } + + fn visit_borrowed_str( + self, + v: &'de str, + ) -> Result { + Ok(CowStr(Cow::Borrowed(v))) + } + + fn visit_str(self, v: &str) -> Result { + Ok(CowStr(Cow::Owned(v.to_owned()))) + } + + fn visit_string(self, v: String) -> Result { + Ok(CowStr(Cow::Owned(v))) + } + } + + deserializer.deserialize_str(CowStrVisitor) + } +} + fn parse_descriptor(key: &str) -> Option<(&str, &str, Option<&str>)> { let mut parts = key.splitn(3, ':'); let provider = parts.next()?; From 3f4800f6e941b70a02af4b574e418d5d301c67d8 Mon Sep 17 00:00:00 2001 From: Ryder Casazza Date: Tue, 21 Jul 2026 14:26:43 -0700 Subject: [PATCH 2/2] refactor: store parsed season numbers in the mapping index Every index entry carried a Vec that always held exactly one "sN" key, which lookups then re-formatted or re-parsed. Seasons are now parsed to u32 once at build time, dropping two heap allocations per entry in each direction and simplifying both lookup sites. Behavior note: a hypothetical scope like "s1e5" previously never matched a season search (exact string compare) but now parses as season 1; a survey of the live mappings file shows all 9,653 scopes are plain "sN", so no practical change. --- src/mapping.rs | 29 ++++++++++++----------------- src/service.rs | 19 +++---------------- 2 files changed, 15 insertions(+), 33 deletions(-) diff --git a/src/mapping.rs b/src/mapping.rs index 238d613..faeb42e 100644 --- a/src/mapping.rs +++ b/src/mapping.rs @@ -32,13 +32,13 @@ struct CachedMappings { #[derive(Debug, Clone)] struct MappingEntry { anilist_id: i64, - seasons: Vec, + season: u32, } #[derive(Debug, Clone)] struct ReverseMappingEntry { tvdb_id: i64, - seasons: Vec, + season: u32, } #[derive(Debug)] @@ -52,7 +52,7 @@ struct MappingIndex { #[derive(Debug, Clone)] pub struct TvdbMapping { pub tvdb_id: i64, - pub seasons: Vec, + pub season: u32, } #[derive(Debug, Clone)] @@ -398,7 +398,6 @@ impl PlexAniBridgeMappings { pub async fn resolve_anilist_id(&self, tvdb_id: i64, season: u32) -> Result> { let mappings = self.index().await?; - let season_key = format!("s{season}"); if let Some(entries) = mappings.tvdb_to_entries.get(&tvdb_id) { trace!( @@ -409,7 +408,7 @@ impl PlexAniBridgeMappings { ); for entry in entries { - if entry.seasons.iter().any(|key| key == &season_key) { + if entry.season == season { trace!( tvdb_id, season, @@ -458,7 +457,7 @@ impl PlexAniBridgeMappings { .iter() .map(|entry| TvdbMapping { tvdb_id: entry.tvdb_id, - seasons: entry.seasons.clone(), + season: entry.season, }) .collect() }) @@ -488,21 +487,17 @@ impl IndexBuilder { let Ok(tvdb_id) = id_str.parse::() else { return; }; - let season = scope.unwrap_or("s1").to_owned(); + let Some(season) = parse_season_key(scope.unwrap_or("s1")) else { + return; + }; self.tvdb_to_entries .entry(tvdb_id) .or_default() - .push(MappingEntry { - anilist_id, - seasons: vec![season.clone()], - }); + .push(MappingEntry { anilist_id, season }); self.anilist_to_entries .entry(anilist_id) .or_default() - .push(ReverseMappingEntry { - tvdb_id, - seasons: vec![season], - }); + .push(ReverseMappingEntry { tvdb_id, season }); } else if provider == "tmdb_movie" { let Ok(tmdb_id) = id_str.parse::() else { return; @@ -643,7 +638,7 @@ fn parse_descriptor(key: &str) -> Option<(&str, &str, Option<&str>)> { Some((provider, id, scope)) } -pub(crate) fn parse_season_key(key: &str) -> Option { +fn parse_season_key(key: &str) -> Option { if !key.starts_with('s') { return None; } @@ -688,7 +683,7 @@ mod tests { assert_eq!(index.tvdb_to_entries.len(), 2); let entries = &index.tvdb_to_entries[&72025]; assert_eq!(entries[0].anilist_id, 290); - assert_eq!(entries[0].seasons, vec!["s1".to_string()]); + assert_eq!(entries[0].season, 1); assert_eq!(index.anilist_to_entries[&290][0].tvdb_id, 72025); // tmdb_movie targets populate the movie maps both directions. diff --git a/src/service.rs b/src/service.rs index d352734..c65bd97 100644 --- a/src/service.rs +++ b/src/service.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use crate::anilist::{AniListClient, AniListError, MediaFormat}; use crate::config::AppConfig; -use crate::mapping::{PlexAniBridgeMappings, TvdbMapping, parse_season_key}; +use crate::mapping::{PlexAniBridgeMappings, TvdbMapping}; use crate::radarr::{RadarrClient, RadarrError}; use crate::releases::{ReleasesClient, ReleasesError, Torrent, Tracker}; use crate::sonarr::{SonarrClient, SonarrError}; @@ -704,22 +704,9 @@ impl SearchService { let mut best: Option<(i64, u32)> = None; for mapping in mappings { - let mut seasons: Vec = mapping - .seasons - .iter() - .filter_map(|key| parse_season_key(key)) - .collect(); - - if seasons.is_empty() { - continue; - } - - seasons.sort_unstable(); - let season = seasons[0]; - match best { - Some((_, current)) if season >= current => {} - _ => best = Some((mapping.tvdb_id, season)), + Some((_, current)) if mapping.season >= current => {} + _ => best = Some((mapping.tvdb_id, mapping.season)), } }