149 lines
6.4 KiB
Rust
149 lines
6.4 KiB
Rust
//! Music domain root — entity types decided in `13-MUSIC-MODEL.md` (D-13,
|
|
//! one-way). See that document for the full rationale and the rejected
|
|
//! alternatives; this module implements the decision, it does not re-derive
|
|
//! it.
|
|
//!
|
|
//! Track identity is hybrid: `(source, canonical path)` is the row key
|
|
//! (cheap, stat-only, incremental via mtime), with a lazily-backfilled
|
|
//! content-hash dedupe column (`Track::content_hash`) for the move/dedupe
|
|
//! case that identity alone can't handle. Albums and artists are derived at
|
|
//! read time by grouping tracks on their tags, not stored as first-class
|
|
//! rows. The on-disk index is a single JSON file at
|
|
//! `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
|
|
/// the newer-version-on-older-binary handling contract: an older binary
|
|
/// encountering a newer-versioned index treats it as absent rather than
|
|
/// reinterpreting or overwriting it.
|
|
pub const MUSIC_SCHEMA_VERSION: u32 = 1;
|
|
|
|
/// Where a track was discovered. Both sources are indexed
|
|
/// (`13-MUSIC-MODEL.md`'s "Sources indexed: both").
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum MusicSource {
|
|
/// The node's own FileBrowser `Music` folder.
|
|
OwnLibrary,
|
|
/// A peer's shared audio, reachable via the content/peer-proxy
|
|
/// subsystem.
|
|
Peer { onion: String },
|
|
}
|
|
|
|
/// Stable identity for a track (hybrid-identity, `13-MUSIC-MODEL.md`):
|
|
/// `(source, canonical path)` is the row key — cheap, stat-only, survives a
|
|
/// rescan via mtime. A file move or rename orphans this identity;
|
|
/// `Track::content_hash` is the lazily-backfilled dedupe column that exists
|
|
/// for exactly that case, and for cross-peer dedupe once it's populated.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub struct TrackId {
|
|
pub source: MusicSource,
|
|
pub path: PathBuf,
|
|
}
|
|
|
|
/// Derived album grouping key (derived-albums, `13-MUSIC-MODEL.md`). Albums
|
|
/// are not stored rows — this is the key produced by grouping `Track`s at
|
|
/// read time on `(album_artist, album)`, not a persisted identity. A retag
|
|
/// simply changes what the grouping produces on the next read.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub struct AlbumId {
|
|
pub album_artist: Option<String>,
|
|
pub album: String,
|
|
}
|
|
|
|
/// Derived artist grouping key. Same read-time-only status as `AlbumId`.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub struct ArtistId(pub String);
|
|
|
|
/// A single indexed track — a row in `data_dir/music/index.json`. This is
|
|
/// the migration surface `13-MUSIC-MODEL.md`'s one-way decision is about:
|
|
/// changing this shape after nodes have indexed libraries needs a reindex
|
|
/// path (`MUSIC_SCHEMA_VERSION`), not just a code change.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct Track {
|
|
pub id: TrackId,
|
|
pub title: String,
|
|
pub artist: Option<String>,
|
|
pub album: Option<String>,
|
|
pub album_artist: Option<String>,
|
|
pub track_number: Option<u32>,
|
|
pub disc_number: Option<u32>,
|
|
pub year: Option<u32>,
|
|
pub duration_secs: u64,
|
|
/// `false` when `title` was derived from the filename stem because the
|
|
/// file carried no readable tags — the track still appears in the
|
|
/// library rather than being dropped (see `tags::extract_tags`).
|
|
pub has_tags: bool,
|
|
/// Lazily-backfilled dedupe column (hybrid-identity,
|
|
/// `13-MUSIC-MODEL.md`). `None` until a background backfill pass
|
|
/// computes it; absence is not an error state.
|
|
#[serde(default)]
|
|
pub content_hash: Option<String>,
|
|
}
|
|
|
|
/// An album, computed at read time by grouping `Track`s on
|
|
/// `(album_artist, album)` — never persisted directly (derived-albums,
|
|
/// `13-MUSIC-MODEL.md`).
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct Album {
|
|
pub id: AlbumId,
|
|
pub track_ids: Vec<TrackId>,
|
|
}
|
|
|
|
/// An artist, computed at read time by grouping `Track`s on `artist`. Same
|
|
/// read-time-only status as `Album`.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
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>,
|
|
}
|