Skip to main content

musefs_db/
structural.rs

1use crate::error::DbError;
2use crate::models::StructuralBlock;
3use crate::{Db, ReadWrite, Result};
4use rusqlite::params;
5
6impl<M> Db<M> {
7    /// Track ids that have at least one structural block row. Used by `revalidate`
8    /// to detect legacy FLAC tracks (scanned under V1) that still need a backfill.
9    pub fn track_ids_with_structural_blocks(&self) -> Result<std::collections::HashSet<i64>> {
10        let mut stmt = self
11            .conn
12            .prepare("SELECT DISTINCT track_id FROM structural_blocks")?;
13        let rows = stmt.query_map([], |r| r.get::<_, i64>(0))?;
14        Ok(rows.collect::<rusqlite::Result<std::collections::HashSet<i64>>>()?)
15    }
16
17    /// Structural blocks for a track, ordered by (kind, ordinal). Empty when a
18    /// FLAC track has not been (re)scanned under V2 — callers fall back to a
19    /// front read in that case.
20    pub fn get_structural_blocks(&self, track_id: i64) -> Result<Vec<StructuralBlock>> {
21        let mut stmt = self.conn.prepare_cached(
22            "SELECT kind, ordinal, length(body), body FROM structural_blocks \
23             WHERE track_id = ?1 ORDER BY kind, ordinal",
24        )?;
25        let mut rows = stmt.query(params![track_id])?;
26        let mut out = Vec::new();
27        while let Some(r) = rows.next()? {
28            let kind: String = r.get(0)?;
29            let ordinal: i64 = r.get(1)?;
30            let body_len: i64 = r.get(2)?;
31            if !crate::limits::STRUCTURAL_KINDS.contains(&kind.as_str()) {
32                return Err(DbError::InvalidStructuralBlock {
33                    track_id,
34                    detail: format!("unknown kind {kind:?}"),
35                });
36            }
37            if ordinal < 0 {
38                return Err(DbError::InvalidStructuralBlock {
39                    track_id,
40                    detail: format!("negative ordinal {ordinal}"),
41                });
42            }
43            crate::error::check_field_len(
44                "structural_blocks",
45                "body",
46                body_len,
47                crate::limits::MAX_STRUCTURAL_BODY_LEN,
48            )?;
49            out.push(StructuralBlock {
50                kind,
51                ordinal: u64::try_from(ordinal).expect("ordinal guarded >= 0 above"),
52                body: r.get(3)?,
53            });
54        }
55        Ok(out)
56    }
57}
58
59/// Replace a track's structural blocks. Runs on `conn` so `Db<ReadWrite>` (own
60/// transaction) and `BulkWriter` (caller-held transaction) share one body.
61pub(crate) fn set_structural_blocks_in(
62    conn: &rusqlite::Connection,
63    track_id: i64,
64    blocks: &[StructuralBlock],
65) -> Result<()> {
66    conn.execute(
67        "DELETE FROM structural_blocks WHERE track_id = ?1",
68        params![track_id],
69    )?;
70    let mut stmt = conn.prepare_cached(
71        "INSERT INTO structural_blocks (track_id, kind, ordinal, body) \
72         VALUES (?1, ?2, ?3, ?4)",
73    )?;
74    for b in blocks {
75        stmt.execute(params![track_id, b.kind, b.ordinal, b.body])?;
76    }
77    Ok(())
78}
79
80impl Db<ReadWrite> {
81    /// Replace the track's structural blocks (FLAC STREAMINFO/SEEKTABLE).
82    pub fn set_structural_blocks(&self, track_id: i64, blocks: &[StructuralBlock]) -> Result<()> {
83        let tx = self.conn.unchecked_transaction()?;
84        set_structural_blocks_in(&tx, track_id, blocks)?;
85        tx.commit()?;
86        Ok(())
87    }
88}
89
90#[cfg(test)]
91mod guard_tests {
92    use crate::error::DbError;
93    use crate::{Db, Format, NewTrack};
94
95    fn db_with_track() -> (Db, i64) {
96        let db = Db::open_in_memory().unwrap();
97        let id = db
98            .upsert_track(&NewTrack {
99                backing_path: "/a.flac".into(),
100                format: Format::Flac,
101                audio_offset: 0,
102                audio_length: 1,
103                backing_size: 1,
104                backing_mtime_ns: 0,
105                backing_ctime_ns: 0,
106            })
107            .unwrap();
108        (db, id)
109    }
110
111    #[test]
112    fn rejects_oversize_body() {
113        let (db, id) = db_with_track();
114        db.conn
115            .execute_batch("PRAGMA ignore_check_constraints=ON")
116            .unwrap();
117        db.conn
118            .execute(
119                "INSERT INTO structural_blocks (track_id, kind, ordinal, body) \
120                 VALUES (?1, 'STREAMINFO', 0, zeroblob(16777216))",
121                rusqlite::params![id],
122            )
123            .unwrap();
124        let err = db.get_structural_blocks(id).unwrap_err();
125        assert!(
126            matches!(err, DbError::FieldTooLarge { field: "body", .. }),
127            "{err:?}"
128        );
129    }
130
131    #[test]
132    fn accepts_body_at_cap() {
133        let (db, id) = db_with_track();
134        db.conn
135            .execute(
136                "INSERT INTO structural_blocks (track_id, kind, ordinal, body) \
137                 VALUES (?1, 'STREAMINFO', 0, zeroblob(16777215))",
138                rusqlite::params![id],
139            )
140            .unwrap();
141        let rows = db.get_structural_blocks(id).unwrap();
142        assert_eq!(rows.len(), 1);
143        assert_eq!(rows[0].body.len(), 16_777_215);
144    }
145
146    #[test]
147    fn rejects_unknown_kind() {
148        let (db, id) = db_with_track();
149        db.conn
150            .execute_batch("PRAGMA ignore_check_constraints=ON")
151            .unwrap();
152        db.conn
153            .execute(
154                "INSERT INTO structural_blocks (track_id, kind, ordinal, body) \
155                 VALUES (?1, 'APPLICATION', 0, X'00')",
156                rusqlite::params![id],
157            )
158            .unwrap();
159        let err = db.get_structural_blocks(id).unwrap_err();
160        assert!(
161            matches!(err, DbError::InvalidStructuralBlock { .. }),
162            "{err:?}"
163        );
164    }
165
166    #[test]
167    fn rejects_negative_ordinal() {
168        let (db, id) = db_with_track();
169        db.conn
170            .execute_batch("PRAGMA ignore_check_constraints=ON")
171            .unwrap();
172        db.conn
173            .execute(
174                "INSERT INTO structural_blocks (track_id, kind, ordinal, body) \
175                 VALUES (?1, 'STREAMINFO', -1, X'00')",
176                rusqlite::params![id],
177            )
178            .unwrap();
179        let err = db.get_structural_blocks(id).unwrap_err();
180        assert!(
181            matches!(err, DbError::InvalidStructuralBlock { .. }),
182            "{err:?}"
183        );
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use crate::{Db, Format, NewTrack, StructuralBlock};
190
191    #[test]
192    fn structural_blocks_round_trip_and_replace() {
193        let db = Db::open_in_memory().unwrap();
194        let id = db
195            .upsert_track(&NewTrack {
196                backing_path: "/a.flac".into(),
197                format: Format::Flac,
198                audio_offset: 0,
199                audio_length: 1,
200                backing_size: 1,
201                backing_mtime_ns: 0,
202                backing_ctime_ns: 0,
203            })
204            .unwrap();
205        db.set_structural_blocks(
206            id,
207            &[
208                StructuralBlock {
209                    kind: "STREAMINFO".into(),
210                    ordinal: 0,
211                    body: vec![1, 2],
212                },
213                StructuralBlock {
214                    kind: "SEEKTABLE".into(),
215                    ordinal: 0,
216                    body: vec![3],
217                },
218            ],
219        )
220        .unwrap();
221        let got = db.get_structural_blocks(id).unwrap();
222        assert_eq!(got.len(), 2);
223        // ordered by kind: SEEKTABLE before STREAMINFO
224        assert_eq!(got[0].kind, "SEEKTABLE");
225        assert_eq!(got[1].body, vec![1, 2]);
226
227        db.set_structural_blocks(id, &[]).unwrap();
228        assert!(db.get_structural_blocks(id).unwrap().is_empty());
229    }
230}