From 7e1b7450cfb292487652221160531569f41d014c Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 5 Aug 2026 19:02:27 +0800 Subject: [PATCH] Fix TUI Add flow landing new movies/shows flat in the library root add_selected_search_result passed the raw configured default_root_folder straight through as root_folder for both movies and series, with no per-item subfolder computed. import_one/season_dir both expect root_folder to already be the item's own folder, so new grabs landed directly in the shared library root instead of their own folder, invisible to Jellyfin's per-category libraries. Split default_root_folder (series) from a new movies_root_folder, and have the TUI build the "{Title} (Year)" subfolder itself before sending the add request. --- breadarr-shared/src/config.rs | 22 +++++++++++++++++--- breadarr-tui/src/app.rs | 39 ++++++++++++++++++++++++++++++++--- breadarr-tui/src/main.rs | 17 ++++++++------- config.example.toml | 7 ++++++- 4 files changed, 71 insertions(+), 14 deletions(-) diff --git a/breadarr-shared/src/config.rs b/breadarr-shared/src/config.rs index 5945368..b8c1f51 100644 --- a/breadarr-shared/src/config.rs +++ b/breadarr-shared/src/config.rs @@ -27,19 +27,31 @@ pub struct Config { pub transcode: TranscodeConfig, } -/// Where the TUI's "add show" flow places new series by default. Sonarr/ -/// Radarr let you pick a root folder per add; a single configured default -/// is a reasonable v1 simplification — per-add picking can follow later. +/// Where the TUI's "add" flow places new series/movies by default. Sonarr/ +/// Radarr let you pick a root folder per add; a single configured default per +/// kind is a reasonable v1 simplification — per-add picking can follow later. +/// Series and movies need *separate* defaults (not one shared value) because +/// they live under different category roots on disk (e.g. `TV Shows/` vs +/// `Movies/`) — a real production bug had both kinds falling back to one +/// bare library root with no per-item subfolder, landing new grabs directly +/// in the library root instead of inside their own show/movie folder, +/// invisible to Jellyfin's per-category libraries. #[derive(Debug, Clone, Default, Deserialize)] pub struct LibraryConfig { #[serde(default = "default_root_folder")] pub default_root_folder: String, + #[serde(default = "default_movies_root_folder")] + pub movies_root_folder: String, } fn default_root_folder() -> String { "~/breadarr-library".to_string() } +fn default_movies_root_folder() -> String { + "~/breadarr-library/Movies".to_string() +} + #[derive(Debug, Clone, Deserialize)] pub struct SourcesConfig { /// 1337x's main domain bans IPs at the Cloudflare WAF level after @@ -625,6 +637,10 @@ impl Config { pub fn default_root_folder(&self) -> PathBuf { expand_home(&self.library.default_root_folder) } + + pub fn movies_root_folder(&self) -> PathBuf { + expand_home(&self.library.movies_root_folder) + } } fn config_path() -> PathBuf { diff --git a/breadarr-tui/src/app.rs b/breadarr-tui/src/app.rs index e97c265..046bf33 100644 --- a/breadarr-tui/src/app.rs +++ b/breadarr-tui/src/app.rs @@ -6,6 +6,39 @@ use breadarr_shared::dto::{ }; use breadarr_shared::DaemonClient; use ratatui::widgets::ListState; +use std::path::Path; + +/// Category roots the TUI's "Add" flow can place new items under — kept as +/// two separate paths (not one shared default) since a series and a movie +/// added with the same title must not collide on disk, and Jellyfin's +/// per-category libraries only see files under their own category root. +pub struct LibraryRoots { + pub series: String, + pub movies: String, +} + +/// Builds the per-item folder a freshly added series/movie lands in — +/// `{root}/{Title} ({Year})`, matching the convention the importer already +/// assumes (`import_one` places a movie's file directly inside its +/// `media_item.root_folder`, with no further subfolder of its own, and +/// `season_dir` does the same for a show's `Season NN` folders). Passing the +/// bare category root straight through as `root_folder` — the bug this +/// replaces — landed every new grab directly in that shared root instead of +/// its own show/movie folder. +fn item_root_folder(root: &str, title: &str, year: Option) -> String { + let sanitized: String = title + .chars() + .map(|c| if "/\\:*?\"<>|".contains(c) { '_' } else { c }) + .collect(); + let folder_name = match year { + Some(y) => format!("{sanitized} ({y})"), + None => sanitized, + }; + Path::new(root) + .join(folder_name) + .to_string_lossy() + .to_string() +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tab { @@ -659,7 +692,7 @@ impl App { self.focus = Focus::AddResults; } - pub async fn add_selected_search_result(&mut self, root_folder: &str) { + pub async fn add_selected_search_result(&mut self, roots: &LibraryRoots) { let Some(idx) = self.add_results_state.selected() else { return; }; @@ -673,7 +706,7 @@ impl App { title: result.title.clone(), year: result.year, aliases: Vec::new(), - root_folder: root_folder.to_string(), + root_folder: item_root_folder(&roots.series, &result.title, result.year), }; self.client.add_series(&req).await.map(|r| r.media_item_id) } @@ -682,7 +715,7 @@ impl App { tmdb_id: result.external_id.clone(), title: result.title.clone(), year: result.year, - root_folder: root_folder.to_string(), + root_folder: item_root_folder(&roots.movies, &result.title, result.year), }; self.client.add_movie(&req).await.map(|r| r.media_item_id) } diff --git a/breadarr-tui/src/main.rs b/breadarr-tui/src/main.rs index 627b771..b41c78b 100644 --- a/breadarr-tui/src/main.rs +++ b/breadarr-tui/src/main.rs @@ -14,14 +14,17 @@ use crossterm::terminal::{ use ratatui::backend::CrosstermBackend; use ratatui::Terminal; -use app::{App, Focus, StuckSection, Tab}; +use app::{App, Focus, LibraryRoots, StuckSection, Tab}; #[tokio::main] async fn main() -> Result<()> { let config = Config::load()?; let base_url = format!("http://{}", config.daemon.listen_addr); let client = DaemonClient::new(base_url, &config.daemon.api_token); - let root_folder = config.default_root_folder().to_string_lossy().to_string(); + let roots = LibraryRoots { + series: config.default_root_folder().to_string_lossy().to_string(), + movies: config.movies_root_folder().to_string_lossy().to_string(), + }; enable_raw_mode()?; let mut stdout = io::stdout(); @@ -30,7 +33,7 @@ async fn main() -> Result<()> { let mut terminal = Terminal::new(backend)?; let mut app = App::new(client); - let result = run(&mut terminal, &mut app, &root_folder).await; + let result = run(&mut terminal, &mut app, &roots).await; disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; @@ -42,7 +45,7 @@ async fn main() -> Result<()> { async fn run( terminal: &mut Terminal>, app: &mut App, - root_folder: &str, + roots: &LibraryRoots, ) -> Result<()> { let mut last_refresh = tokio::time::Instant::now() - Duration::from_secs(10); @@ -60,7 +63,7 @@ async fn run( if key.kind != KeyEventKind::Press { continue; } - handle_key(app, key.code, root_folder).await; + handle_key(app, key.code, roots).await; if app.should_quit { return Ok(()); } @@ -69,7 +72,7 @@ async fn run( } } -async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) { +async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { // Typing into the add-show search box takes priority over global keys. if matches!(app.tab, Tab::Add) && matches!(app.focus, Focus::AddSearchInput) { match code { @@ -140,7 +143,7 @@ async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) { } Tab::Library => app.open_detail().await, Tab::Add => match app.focus { - Focus::AddResults => app.add_selected_search_result(root_folder).await, + Focus::AddResults => app.add_selected_search_result(roots).await, _ => app.focus = Focus::AddSearchInput, }, Tab::Profiles if app.profile_detail.is_some() => { diff --git a/config.example.toml b/config.example.toml index 383929c..5370a7b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -50,8 +50,13 @@ api_key = "" bearer_token = "" [library] -# Default root folder for shows added via the TUI's "Add Show" flow. +# Default root folder for shows added via the TUI's "Add" flow. Each show +# lands in its own "{root}/{Title} ({Year})" subfolder. default_root_folder = "~/breadarr-library" +# Same, but for movies added via the TUI's "Add" flow — kept separate from +# default_root_folder since movies and shows live under different category +# roots on disk. +movies_root_folder = "~/breadarr-library/Movies" [sources] # nyaa's English-translated anime category — the daemon polls this