Nest all platforms under channels_root (was: siblings)

Previously platform_root resolved YouTube to channels_root verbatim and
non-YouTube to siblings under channels_root.parent(). That assumed the
user's library was split across two levels:

  /library/yt-offline/   ← config.backup.directory (YouTube)
  /library/bandcamp/     ← sibling
  /library/tiktok/       ← sibling

In practice users (myself included) keep everything under one umbrella:

  /library/yt-offline/
    channels/    ← YouTube
    bandcamp/
    tiktok/
    music/
    …

Make that the canonical layout. platform_root now returns
channels_root.join(dir_name) for every platform including YouTube
(which keeps its `channels` dir_name for back-compat with existing
on-disk trees that already have the channels/ subdir populated).

library_root is now == channels_root in both app.rs and web.rs — the
two-level concept is gone. The field stays as a distinct name so
file_url() and the /files/ static-file mount keep working without
churn at every call site. music_root and music dir also moved from
channels_root.with_file_name("music") to channels_root.join("music").

Tests in platform.rs updated to the new layout. 74 pass.

Note: this is a breaking change for installs that had the sibling
layout. The fix is to mv the platform dirs into the channels_root.
The next commit walks the user through that for my install.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-27 10:38:01 -07:00
parent 4cead6cf93
commit 4f15ab873f
4 changed files with 37 additions and 30 deletions

View file

@ -240,11 +240,13 @@ impl App {
let channels_root = config.backup.directory.clone(); let channels_root = config.backup.directory.clone();
let settings_dir = channels_root.display().to_string(); let settings_dir = channels_root.display().to_string();
let _ = std::fs::create_dir_all(&channels_root); let _ = std::fs::create_dir_all(&channels_root);
let library_root = channels_root // library_root is the parent of every platform's per-creator
.parent() // folder. With the post-2026-05 nested layout every platform
.map(|p| p.to_path_buf()) // (including YouTube's `channels/`) lives under channels_root,
.unwrap_or_else(|| channels_root.clone()); // so the two paths are the same. `library_root` is kept as a
let _ = std::fs::create_dir_all(&library_root); // separate field for places that previously expected the
// sibling-platform layout (file_url, /files/ mount, maintenance).
let library_root = channels_root.clone();
// Pre-create every platform's folder so scans see them. // Pre-create every platform's folder so scans see them.
for &p in Platform::all() { for &p in Platform::all() {
let dir = platform::platform_root(&channels_root, p); let dir = platform::platform_root(&channels_root, p);
@ -275,7 +277,8 @@ impl App {
let flags = db.get_video_flags().unwrap_or_default(); let flags = db.get_video_flags().unwrap_or_default();
let resume_positions = db.get_positions().unwrap_or_default(); let resume_positions = db.get_positions().unwrap_or_default();
let music_root = channels_root.with_file_name("music"); // Music dir nests under channels_root like the platform dirs.
let music_root = channels_root.join("music");
let music_library = library::scan_music(&music_root); let music_library = library::scan_music(&music_root);
let max_concurrent = config.backup.max_concurrent; let max_concurrent = config.backup.max_concurrent;

View file

@ -494,9 +494,10 @@ impl Downloader {
self.enqueue(cmd, url, label); self.enqueue(cmd, url, label);
} }
/// Path to the music download directory (sibling of `channels_root`). /// Path to the music download directory, nested under `channels_root`
/// alongside the platform folders.
pub fn music_root(&self) -> PathBuf { pub fn music_root(&self) -> PathBuf {
self.channels_root.with_file_name("music") self.channels_root.join("music")
} }
/// Download `url` as audio-only, storing tracks in `music/<artist>/`. /// Download `url` as audio-only, storing tracks in `music/<artist>/`.

View file

@ -351,15 +351,20 @@ fn extract_after<'a>(url: &'a str, marker: &str) -> Option<&'a str> {
// ── Filesystem layout ───────────────────────────────────────────────────────── // ── Filesystem layout ─────────────────────────────────────────────────────────
/// Absolute path to a given platform's video folder, derived from the /// Absolute path to a given platform's video folder.
/// configured YouTube `channels_root` (its parent is treated as the ///
/// implicit library root, with each platform as a sibling folder). /// All platforms (including YouTube) are nested as subdirectories of the
/// configured `channels_root`. So a config with
/// `directory = "/mnt/library/yt-offline"` puts YouTube channels at
/// `/mnt/library/yt-offline/channels/<creator>/` and Bandcamp artists at
/// `/mnt/library/yt-offline/bandcamp/<artist>/`. This keeps everything a
/// user might want to archive under one tidy umbrella directory.
///
/// The function's name predates the layout change — `channels_root` is
/// really "library root" now, but renaming would touch ~40 call sites
/// for no functional benefit.
pub fn platform_root(channels_root: &Path, platform: Platform) -> PathBuf { pub fn platform_root(channels_root: &Path, platform: Platform) -> PathBuf {
if platform == Platform::YouTube { channels_root.join(platform.dir_name())
// YouTube keeps the legacy path verbatim.
return channels_root.to_path_buf();
}
channels_root.with_file_name(platform.dir_name())
} }
/// Where the `.source-url` sidecar lives for a creator folder. /// Where the `.source-url` sidecar lives for a creator folder.
@ -486,16 +491,16 @@ mod tests {
} }
#[test] #[test]
fn platform_root_youtube_is_channels_root() { fn platform_root_youtube_nests_as_channels_subdir() {
let cr = Path::new("/foo/channels"); let cr = Path::new("/foo/library");
assert_eq!(platform_root(cr, Platform::YouTube), cr); assert_eq!(platform_root(cr, Platform::YouTube), Path::new("/foo/library/channels"));
} }
#[test] #[test]
fn platform_root_others_are_siblings() { fn platform_root_others_nest_too() {
let cr = Path::new("/foo/channels"); let cr = Path::new("/foo/library");
assert_eq!(platform_root(cr, Platform::TikTok), Path::new("/foo/tiktok")); assert_eq!(platform_root(cr, Platform::TikTok), Path::new("/foo/library/tiktok"));
assert_eq!(platform_root(cr, Platform::Twitch), Path::new("/foo/twitch")); assert_eq!(platform_root(cr, Platform::Twitch), Path::new("/foo/library/twitch"));
} }
#[test] #[test]

View file

@ -2182,13 +2182,11 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
let channels_root = config.backup.directory.clone(); let channels_root = config.backup.directory.clone();
let _ = std::fs::create_dir_all(&channels_root); let _ = std::fs::create_dir_all(&channels_root);
// library_root holds every platform folder side-by-side (channels/, // library_root holds every platform folder side-by-side (channels/,
// tiktok/, twitch/, …). The implicit anchor is the parent of the // tiktok/, twitch/, …). Post-2026-05 layout nests them all under
// user-configured channels dir. // channels_root, so the two paths are identical. The field is kept
let library_root = channels_root // distinct because file_url() and the /files/ static-file mount
.parent() // still resolve URLs against it as a separate concept.
.map(|p| p.to_path_buf()) let library_root = channels_root.clone();
.unwrap_or_else(|| channels_root.clone());
let _ = std::fs::create_dir_all(&library_root);
// Pre-create every platform's folder so the static-file mount can serve // Pre-create every platform's folder so the static-file mount can serve
// them without 404ing on first access. // them without 404ing on first access.
for &p in crate::platform::Platform::all() { for &p in crate::platform::Platform::all() {