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.
This commit is contained in:
parent
15b5b12a06
commit
7e1b7450cf
4 changed files with 71 additions and 14 deletions
|
|
@ -27,19 +27,31 @@ pub struct Config {
|
||||||
pub transcode: TranscodeConfig,
|
pub transcode: TranscodeConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the TUI's "add show" flow places new series by default. Sonarr/
|
/// 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
|
/// Radarr let you pick a root folder per add; a single configured default per
|
||||||
/// is a reasonable v1 simplification — per-add picking can follow later.
|
/// 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)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
pub struct LibraryConfig {
|
pub struct LibraryConfig {
|
||||||
#[serde(default = "default_root_folder")]
|
#[serde(default = "default_root_folder")]
|
||||||
pub default_root_folder: String,
|
pub default_root_folder: String,
|
||||||
|
#[serde(default = "default_movies_root_folder")]
|
||||||
|
pub movies_root_folder: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_root_folder() -> String {
|
fn default_root_folder() -> String {
|
||||||
"~/breadarr-library".to_string()
|
"~/breadarr-library".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_movies_root_folder() -> String {
|
||||||
|
"~/breadarr-library/Movies".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct SourcesConfig {
|
pub struct SourcesConfig {
|
||||||
/// 1337x's main domain bans IPs at the Cloudflare WAF level after
|
/// 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 {
|
pub fn default_root_folder(&self) -> PathBuf {
|
||||||
expand_home(&self.library.default_root_folder)
|
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 {
|
fn config_path() -> PathBuf {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,39 @@ use breadarr_shared::dto::{
|
||||||
};
|
};
|
||||||
use breadarr_shared::DaemonClient;
|
use breadarr_shared::DaemonClient;
|
||||||
use ratatui::widgets::ListState;
|
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<i64>) -> 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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Tab {
|
pub enum Tab {
|
||||||
|
|
@ -659,7 +692,7 @@ impl App {
|
||||||
self.focus = Focus::AddResults;
|
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 {
|
let Some(idx) = self.add_results_state.selected() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -673,7 +706,7 @@ impl App {
|
||||||
title: result.title.clone(),
|
title: result.title.clone(),
|
||||||
year: result.year,
|
year: result.year,
|
||||||
aliases: Vec::new(),
|
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)
|
self.client.add_series(&req).await.map(|r| r.media_item_id)
|
||||||
}
|
}
|
||||||
|
|
@ -682,7 +715,7 @@ impl App {
|
||||||
tmdb_id: result.external_id.clone(),
|
tmdb_id: result.external_id.clone(),
|
||||||
title: result.title.clone(),
|
title: result.title.clone(),
|
||||||
year: result.year,
|
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)
|
self.client.add_movie(&req).await.map(|r| r.media_item_id)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,17 @@ use crossterm::terminal::{
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
|
|
||||||
use app::{App, Focus, StuckSection, Tab};
|
use app::{App, Focus, LibraryRoots, StuckSection, Tab};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let config = Config::load()?;
|
let config = Config::load()?;
|
||||||
let base_url = format!("http://{}", config.daemon.listen_addr);
|
let base_url = format!("http://{}", config.daemon.listen_addr);
|
||||||
let client = DaemonClient::new(base_url, &config.daemon.api_token);
|
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()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
|
|
@ -30,7 +33,7 @@ async fn main() -> Result<()> {
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
let mut app = App::new(client);
|
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()?;
|
disable_raw_mode()?;
|
||||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
|
|
@ -42,7 +45,7 @@ async fn main() -> Result<()> {
|
||||||
async fn run(
|
async fn run(
|
||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
root_folder: &str,
|
roots: &LibraryRoots,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut last_refresh = tokio::time::Instant::now() - Duration::from_secs(10);
|
let mut last_refresh = tokio::time::Instant::now() - Duration::from_secs(10);
|
||||||
|
|
||||||
|
|
@ -60,7 +63,7 @@ async fn run(
|
||||||
if key.kind != KeyEventKind::Press {
|
if key.kind != KeyEventKind::Press {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
handle_key(app, key.code, root_folder).await;
|
handle_key(app, key.code, roots).await;
|
||||||
if app.should_quit {
|
if app.should_quit {
|
||||||
return Ok(());
|
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.
|
// Typing into the add-show search box takes priority over global keys.
|
||||||
if matches!(app.tab, Tab::Add) && matches!(app.focus, Focus::AddSearchInput) {
|
if matches!(app.tab, Tab::Add) && matches!(app.focus, Focus::AddSearchInput) {
|
||||||
match code {
|
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::Library => app.open_detail().await,
|
||||||
Tab::Add => match app.focus {
|
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,
|
_ => app.focus = Focus::AddSearchInput,
|
||||||
},
|
},
|
||||||
Tab::Profiles if app.profile_detail.is_some() => {
|
Tab::Profiles if app.profile_detail.is_some() => {
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,13 @@ api_key = ""
|
||||||
bearer_token = ""
|
bearer_token = ""
|
||||||
|
|
||||||
[library]
|
[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"
|
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]
|
[sources]
|
||||||
# nyaa's English-translated anime category — the daemon polls this
|
# nyaa's English-translated anime category — the daemon polls this
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue