feat(13-07): persisted music index — scan, group, atomic save, incremental refresh

- music/index.rs: reindex + refresh_incremental over the media roots,
  (path, mtime, size) diffing so unchanged files never re-extract, rows
  removed when files disappear (derived albums vanish with their last
  track), per-file extraction errors counted in ScanStats.skipped
- save_atomic: temp sibling + fsync + rename — a concurrent read sees a
  complete index or the previous one, never a partial file (T-13-42)
- load refuses schema_version > MUSIC_SCHEMA_VERSION with a distinct
  NewerSchema error and never overwrites the newer file (T-13-43)
- symlinks whose canonical target escapes the media roots are skipped,
  not followed (T-13-39); confinement enforced here and in tags.rs
- reindex guard: AtomicBool + RAII release; a second concurrent scan
  reports already-running instead of duplicating the walk (T-13-41)
- music/mod.rs: media_roots(Config) (filebrowser/Music +
  purchased-content) and LibrarySnapshot (tracks + derived albums/artists)
- 9 tests, one per 13-07 Task 1 behavior bullet, programmatic FLAC
  fixtures into tempdirs (no committed binaries)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-04 08:18:06 -04:00
co-authored by Claude Fable 5
parent 589cbb030f
commit 49687f7ebd
2 changed files with 1135 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+43
View File
@@ -12,11 +12,35 @@
//! `data_dir/music/index.json`, matching `content_server.rs::load_catalog`'s
//! precedent.
pub mod index;
pub mod tags;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// The filesystem roots the music indexer is confined to — one per source
/// `13-MUSIC-MODEL.md` decided to index ("Sources indexed: both"):
///
/// - `data_dir/filebrowser/Music` — the node's own FileBrowser `Music`
/// folder (`MusicSource::OwnLibrary`). Peer purchases of audio are also
/// auto-filed here by `content.*`'s paid-download path, so they enter the
/// library through this root with their real filenames.
/// - `data_dir/purchased-content` — the local byte cache of peer-purchased
/// content (`MusicSource::Peer { onion }`), laid out as
/// `<onion>/<content_id>`.
///
/// Confinement is a parameter everywhere downstream (`tags::extract_tags`
/// and `index::reindex` both take these roots and refuse paths outside
/// them) — an indexer that can be aimed at `data_dir/secrets` is a
/// secret-exfiltration primitive (T-13-39), so the roots are computed in
/// exactly one place and passed through.
pub fn media_roots(config: &crate::config::Config) -> Vec<PathBuf> {
vec![
config.data_dir.join("filebrowser").join("Music"),
config.data_dir.join("purchased-content"),
]
}
/// Schema version of the on-disk music index (`data_dir/music/index.json`).
/// Bump when `Track`'s shape changes in a way that needs a reindex. See
/// `13-MUSIC-MODEL.md`'s "Schema version and the reindex path" section for
@@ -103,3 +127,22 @@ pub struct Artist {
pub id: ArtistId,
pub track_ids: Vec<TrackId>,
}
/// A complete, consistent read of the library at one point in time: the
/// persisted track rows plus the albums/artists derived from them
/// (derived-albums, `13-MUSIC-MODEL.md`). Produced by
/// `index::MusicIndex::snapshot`; because the on-disk index is written
/// atomically (`index::save_atomic`), a snapshot is always taken from a
/// complete index — never a partially-written one.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LibrarySnapshot {
pub schema_version: u32,
/// RFC3339 timestamp of the scan this snapshot was read from. Populated
/// even for an empty, never-scanned library (with the time the empty
/// snapshot was produced) — an empty library is empty arrays plus a
/// timestamp, never a null and never an error.
pub scanned_at: String,
pub tracks: Vec<Track>,
pub albums: Vec<Album>,
pub artists: Vec<Artist>,
}