From 7e1b7450cfb292487652221160531569f41d014c Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 5 Aug 2026 19:02:27 +0800 Subject: [PATCH 1/2] 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 From 3cbac5ffe92e8352e536a3fdfff1d53bed850b6c Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 5 Aug 2026 19:04:22 +0800 Subject: [PATCH 2/2] CI: onboard breadarr onto the bakery distribution system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds bakery.toml (system_deps verified via ldd + Command::new grep against breadarrd/src, not guessed) and dev-release.yml/rc-release.yml/release.yml following bread-ecosystem's current single-trunk + RC-tag CI model. No GitHub Release step anywhere — this repo has no GitHub mirror. --- .forgejo/workflows/dev-release.yml | 99 ++++++++++++++++++++++++++++++ .forgejo/workflows/rc-release.yml | 79 ++++++++++++++++++++++++ .forgejo/workflows/release.yml | 79 ++++++++++++++++++++++++ bakery.toml | 44 +++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 .forgejo/workflows/dev-release.yml create mode 100644 .forgejo/workflows/rc-release.yml create mode 100644 .forgejo/workflows/release.yml create mode 100644 bakery.toml diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml new file mode 100644 index 0000000..41e4512 --- /dev/null +++ b/.forgejo/workflows/dev-release.yml @@ -0,0 +1,99 @@ +name: dev release + +# Publishes a dev-track build on every push to `main` (the trunk branch — +# there is no separate `dev` branch). See bread-ecosystem's +# docs/release-channels.md for the release-track policy this is part of. +on: + push: + branches: ['main'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch main --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked + + - name: compute dev version + run: | + set -euo pipefail + cd src + # Base the dev version off the latest published stable tag, not + # Cargo.toml — Cargo.toml can go stale relative to the last real + # release, which would make a dev build sort as OLDER than what's + # already installed and bakery would correctly refuse it. + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + # breadarr's own Cargo.toml is a virtual workspace manifest with + # no [workspace.package] version — breadarrd/Cargo.toml is the + # daemon crate's own version, used as the fallback instead. + CUR="$(grep -m1 '^version' breadarrd/Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi + IFS='.' read -r MA MI PA <<< "${CUR}" + SHA="$(git rev-parse --short HEAD)" + TS="$(date -u +%Y%m%d%H%M%S)" + echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV" + + - name: prepare artifacts + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/breadarr/${VERSION}" + mkdir -p "${PKG_DIR}" + for bin in breadarrd breadarr-tui; do + cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64" + strip "${PKG_DIR}/${bin}-x86_64" + sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/${bin}-x86_64.sha256" + done + cp src/packaging/systemd/breadarrd.service "${PKG_DIR}/" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadarr/latest" + + - name: sign dev binaries + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/breadarr/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + for bin in breadarrd breadarr-tui; do + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/${bin}-x86_64" \ + -x "${PKG_DIR}/${bin}-x86_64.minisig" /dev/null || true + # mktemp: a fixed clone path races when multiple repos' dev/rc + # workflows run close together on the same self-hosted runner. + ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" + git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" + rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/rc-release.yml b/.forgejo/workflows/rc-release.yml new file mode 100644 index 0000000..05386b2 --- /dev/null +++ b/.forgejo/workflows/rc-release.yml @@ -0,0 +1,79 @@ +name: beta (rc) release + +# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag pushed +# to `main` — there is no separate `beta` branch; "freezing" is just +# pausing pushes to main while an RC gets tested. See bread-ecosystem's +# docs/release-channels.md for the release-track policy. +on: + push: + tags: ['v*'] + +jobs: + build: + if: ${{ contains(github.ref_name, '-rc.') }} + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked + + - name: prepare artifacts + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="/srv/breadway-dl/beta/breadarr/${VERSION}" + mkdir -p "${PKG_DIR}" + for bin in breadarrd breadarr-tui; do + cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64" + strip "${PKG_DIR}/${bin}-x86_64" + sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/${bin}-x86_64.sha256" + done + cp src/packaging/systemd/breadarrd.service "${PKG_DIR}/" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadarr/latest" + + - name: sign beta binaries + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="/srv/breadway-dl/beta/breadarr/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + for bin in breadarrd breadarr-tui; do + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/${bin}-x86_64" \ + -x "${PKG_DIR}/${bin}-x86_64.minisig" /dev/null || true + # mktemp: a fixed clone path races when multiple repos' dev/rc + # workflows run close together on the same self-hosted runner. + ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" + git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" + rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..692002c --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,79 @@ +name: release + +on: + push: + tags: ['v*'] + +jobs: + build: + if: ${{ !contains(github.ref_name, '-rc.') }} + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked + + - name: prepare artifacts + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="/srv/breadway-dl/breadarr/${VERSION}" + mkdir -p "${PKG_DIR}" + for bin in breadarrd breadarr-tui; do + cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64" + strip "${PKG_DIR}/${bin}-x86_64" + sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/${bin}-x86_64.sha256" + done + cp src/packaging/systemd/breadarrd.service "${PKG_DIR}/" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/breadarr/latest" + + # Signs with the shared bakery ecosystem signing key (same key that + # signs index.json). BAKERY_MINISIGN_SEC_KEY_PATH is a *path on this + # runner's disk* (hestia has persistent storage), not the key + # contents. Dormant (binaries ship unsigned) until that secret is + # provisioned for this repo. + - name: sign release binaries + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="/srv/breadway-dl/breadarr/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + for bin in breadarrd breadarr-tui; do + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/${bin}-x86_64" \ + -x "${PKG_DIR}/${bin}-x86_64.minisig" /dev/null || true + # mktemp: a fixed clone path races when multiple repos' release + # workflows run close together on the same self-hosted runner. + ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" + git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" + bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" + rm -rf "${ECOSYSTEM_CI_DIR}" + + # No GitHub Release upload step — breadarr has no GitHub mirror, + # unlike most sibling bread-ecosystem repos. dl.breadway.dev is the + # only distribution point for this repo. diff --git a/bakery.toml b/bakery.toml new file mode 100644 index 0000000..e976440 --- /dev/null +++ b/bakery.toml @@ -0,0 +1,44 @@ +name = "breadarr" +description = "Single-daemon Sonarr+Radarr+Prowlarr replacement — release watching, matching, grabbing, importing, and a terminal UI, no web UI" +binaries = ["breadarrd", "breadarr-tui"] +# mkvtoolnix-cli / ffmpeg: `mkvmerge` and `ffprobe`/`ffmpeg` are shelled out +# to directly via Command::new() (breadarrd/src/importer/mkv.rs for the +# audio-track remux fix; breadarrd/src/importer/ffprobe.rs and +# breadarrd/src/transcode/mod.rs for media probing/corruption verification/ +# transcode-backlog work) rather than linked, so neither shows up in +# `ldd target/release/breadarrd` -- confirmed by grepping breadarrd/src for +# Command::new("mkvmerge"|"ffprobe"|"ffmpeg") and cross-checking against the +# README's own host-requirements instructions. The ONNX embedding model (the +# `ort` crate, used for fuzzy title matching) needs no system_deps entry: +# `ldd target/release/breadarrd` lists no libonnxruntime.so, and `strings` +# on the built binary is full of onnxruntime's own C++ symbol names -- +# confirming the `ort` crate's default strategy statically bundled its own +# onnxruntime build directly into breadarrd rather than dynamically linking +# the system onnxruntime-cpu package. openssl: libssl.so.3/libcrypto.so.3 +# (reqwest's TLS backend) show up directly in `ldd` for both breadarrd and +# breadarr-tui. libstdc++/libgcc/glibc/zlib/zstd/brotli also appear in +# `ldd` but are omitted here the same way breadcast's bakery.toml omits +# them: glibc/gcc-libs are unavoidable base-system dependencies, and +# zlib/zstd/brotli are themselves transitive deps of openssl/curl/pacman +# already guaranteed present on any real Arch install. +system_deps = [ + "mkvtoolnix-cli", + "ffmpeg", + "openssl", +] +optional_system_deps = [] +bread_deps = [] +license_file = "LICENSE" + +[[service]] +unit = "breadarrd.service" +enable = true + +[config] +dir = "~/.config/breadarr" +example = "config.example.toml" + +[install] +post_install = [ + "systemctl --user is-active --quiet breadarrd || systemctl --user start breadarrd", +]