Skip to main content

musefs_db/
art.rs

1use crate::error::{check_art_count, check_field_len};
2use crate::limits::{MAX_ART_DESCRIPTION_LEN, MAX_ART_MIME_LEN};
3use crate::models::{Art, ArtMeta, NewArt, TrackArt};
4use crate::{Db, ReadWrite, Result};
5use rusqlite::params;
6use sha2::{Digest, Sha256};
7
8// Hand-encoded: sha2 0.11's digest output (hybrid_array::Array) has no
9// LowerHex impl, so `format!("{:x}", ..)` does not compile. Revisit on the
10// next sha2 bump (RustCrypto/hybrid-array#201).
11pub(crate) fn sha256_hex(data: &[u8]) -> String {
12    let mut s = String::with_capacity(64);
13    for b in Sha256::digest(data) {
14        use std::fmt::Write;
15        let _ = write!(s, "{b:02x}");
16    }
17    s
18}
19
20impl<M> Db<M> {
21    pub fn get_art(&self, id: i64) -> Result<Option<Art>> {
22        let mut stmt = self.conn.prepare(
23            "SELECT id, sha256, mime, width, height, byte_len, data FROM art WHERE id = ?1",
24        )?;
25        let mut rows = stmt.query(params![id])?;
26        match rows.next()? {
27            Some(r) => Ok(Some(Art {
28                id: r.get(0)?,
29                sha256: r.get(1)?,
30                mime: r.get(2)?,
31                width: r.get(3)?,
32                height: r.get(4)?,
33                byte_len: r.get(5)?,
34                data: r.get(6)?,
35            })),
36            None => Ok(None),
37        }
38    }
39
40    /// Art row metadata without loading the image blob — used to build synthesis
41    /// inputs at resolve time without materializing art in memory.
42    pub fn get_art_meta(&self, id: i64) -> Result<Option<ArtMeta>> {
43        let mut stmt = self
44            .conn
45            .prepare("SELECT length(mime), mime, width, height, byte_len FROM art WHERE id = ?1")?;
46        let mut rows = stmt.query(params![id])?;
47        match rows.next()? {
48            Some(r) => {
49                check_field_len("art", "mime", r.get(0)?, MAX_ART_MIME_LEN)?;
50                Ok(Some(ArtMeta {
51                    mime: r.get(1)?,
52                    width: r.get(2)?,
53                    height: r.get(3)?,
54                    byte_len: r.get(4)?,
55                }))
56            }
57            None => Ok(None),
58        }
59    }
60
61    /// Stream art-blob bytes at `offset` directly into `buf` via SQLite incremental
62    /// blob I/O — no intermediate allocation (#70). A short read means the row no
63    /// longer matches the layout; `read_at_exact` surfaces that as an error rather
64    /// than silently zero-filling.
65    pub fn read_art_chunk_into(&self, art_id: i64, offset: u64, buf: &mut [u8]) -> Result<()> {
66        let blob = self.conn.blob_open("main", "art", "data", art_id, true)?;
67        blob.read_at_exact(buf, crate::convert::usize_from(offset))?;
68        Ok(())
69    }
70
71    /// Allocating convenience form of `read_art_chunk_into` (non-hot-path callers).
72    pub fn read_art_chunk(&self, art_id: i64, offset: u64, len: usize) -> Result<Vec<u8>> {
73        let mut buf = vec![0u8; len];
74        self.read_art_chunk_into(art_id, offset, &mut buf)?;
75        Ok(buf)
76    }
77
78    pub fn get_track_art(&self, track_id: i64) -> Result<Vec<TrackArt>> {
79        let mut stmt = self.conn.prepare(
80            "SELECT length(description), art_id, picture_type, description, ordinal
81             FROM track_art WHERE track_id = ?1 ORDER BY ordinal",
82        )?;
83        let mut rows = stmt.query(params![track_id])?;
84        let mut out = Vec::new();
85        while let Some(r) = rows.next()? {
86            check_field_len(
87                "track_art",
88                "description",
89                r.get(0)?,
90                MAX_ART_DESCRIPTION_LEN,
91            )?;
92            out.push(TrackArt {
93                art_id: r.get(1)?,
94                picture_type: r.get(2)?,
95                description: r.get(3)?,
96                ordinal: r.get(4)?,
97            });
98            check_art_count(track_id, out.len())?;
99        }
100        Ok(out)
101    }
102}
103
104impl Db<ReadWrite> {
105    pub fn upsert_art(&self, a: &NewArt) -> Result<i64> {
106        let sha = sha256_hex(&a.data);
107        self.conn.execute(
108            "INSERT INTO art (sha256, mime, width, height, byte_len, data)
109             VALUES (?1, ?2, ?3, ?4, ?5, ?6)
110             ON CONFLICT(sha256) DO NOTHING",
111            params![sha, a.mime, a.width, a.height, a.data.len() as u64, a.data],
112        )?;
113        let id =
114            self.conn
115                .query_row("SELECT id FROM art WHERE sha256 = ?1", params![sha], |r| {
116                    r.get(0)
117                })?;
118        Ok(id)
119    }
120
121    pub fn set_track_art(&self, track_id: i64, items: &[TrackArt]) -> Result<()> {
122        let tx = self.conn.unchecked_transaction()?;
123        tx.execute(
124            "DELETE FROM track_art WHERE track_id = ?1",
125            params![track_id],
126        )?;
127        {
128            let mut stmt = tx.prepare(
129                "INSERT INTO track_art (track_id, art_id, picture_type, description, ordinal)
130                 VALUES (?1, ?2, ?3, ?4, ?5)",
131            )?;
132            for it in items {
133                stmt.execute(params![
134                    track_id,
135                    it.art_id,
136                    it.picture_type,
137                    it.description,
138                    it.ordinal
139                ])?;
140            }
141        }
142        tx.commit()?;
143        Ok(())
144    }
145
146    /// Delete `art` rows no longer referenced by any `track_art`. Returns the
147    /// number of rows removed.
148    pub fn gc_orphan_art(&self) -> Result<usize> {
149        let removed = self.conn.execute(
150            "DELETE FROM art WHERE id NOT IN (SELECT art_id FROM track_art)",
151            [],
152        )?;
153        Ok(removed)
154    }
155}
156
157#[cfg(test)]
158mod guard_tests {
159    use crate::error::DbError;
160    use crate::models::{NewArt, TrackArt};
161    use crate::{Db, Format, NewTrack};
162
163    fn db_track_art() -> (Db, i64, i64) {
164        let db = Db::open_in_memory().unwrap();
165        let track = db
166            .upsert_track(&NewTrack {
167                backing_path: "/a.flac".into(),
168                format: Format::Flac,
169                audio_offset: 0,
170                audio_length: 1,
171                backing_size: 1,
172                backing_mtime_ns: 0,
173                backing_ctime_ns: 0,
174            })
175            .unwrap();
176        let art = db
177            .upsert_art(&NewArt {
178                mime: "image/png".into(),
179                width: None,
180                height: None,
181                data: vec![0u8],
182            })
183            .unwrap();
184        (db, track, art)
185    }
186
187    #[test]
188    fn get_art_meta_rejects_oversize_mime() {
189        let (db, _t, _art) = db_track_art();
190        db.conn
191            .execute_batch("PRAGMA ignore_check_constraints=ON")
192            .unwrap();
193        // art rows are immutable under the V5 `art_reject_content_update`
194        // trigger (which `ignore_check_constraints` does not disable), so plant
195        // the oversize-mime row with a fresh INSERT — the trigger guards only
196        // UPDATE — rather than mutating an existing row in place.
197        let mime = "x".repeat(256);
198        db.conn
199            .execute(
200                "INSERT INTO art (sha256, mime, width, height, byte_len, data) \
201                 VALUES (?1, ?2, NULL, NULL, 1, X'00')",
202                rusqlite::params!["b".repeat(64), mime],
203            )
204            .unwrap();
205        let bad = db.conn.last_insert_rowid();
206        let err = db.get_art_meta(bad).unwrap_err();
207        assert!(
208            matches!(
209                err,
210                DbError::FieldTooLarge {
211                    table: "art",
212                    field: "mime",
213                    ..
214                }
215            ),
216            "{err:?}"
217        );
218    }
219
220    #[test]
221    fn get_track_art_rejects_oversize_description() {
222        let (db, track, art) = db_track_art();
223        db.conn
224            .execute_batch("PRAGMA ignore_check_constraints=ON")
225            .unwrap();
226        let desc = "d".repeat(1025);
227        db.set_track_art(
228            track,
229            &[TrackArt {
230                art_id: art,
231                picture_type: 3,
232                description: desc,
233                ordinal: 0,
234            }],
235        )
236        .unwrap();
237        let err = db.get_track_art(track).unwrap_err();
238        assert!(
239            matches!(
240                err,
241                DbError::FieldTooLarge {
242                    table: "track_art",
243                    field: "description",
244                    ..
245                }
246            ),
247            "{err:?}"
248        );
249    }
250
251    #[test]
252    fn get_track_art_accepts_description_at_cap() {
253        let (db, track, art) = db_track_art();
254        let desc = "d".repeat(1024);
255        db.set_track_art(
256            track,
257            &[TrackArt {
258                art_id: art,
259                picture_type: 3,
260                description: desc,
261                ordinal: 0,
262            }],
263        )
264        .unwrap();
265        assert_eq!(db.get_track_art(track).unwrap()[0].description.len(), 1024);
266    }
267
268    #[test]
269    fn get_track_art_rejects_excess_rows() {
270        let (db, track, art) = db_track_art();
271        // 4097 track_art rows sharing one art_id -> TooManyArtRows. Raw INSERT
272        // (not set_track_art) keeps the fixture to a single planted blob; the
273        // PRIMARY KEY (track_id, ordinal) is satisfied by the distinct ordinals.
274        let tx = db.conn.unchecked_transaction().unwrap();
275        let mut stmt = tx
276            .prepare(
277                "INSERT INTO track_art (track_id, art_id, picture_type, description, ordinal) \
278                 VALUES (?1, ?2, 3, '', ?3)",
279            )
280            .unwrap();
281        for i in 0..4097 {
282            stmt.execute(rusqlite::params![track, art, i]).unwrap();
283        }
284        drop(stmt);
285        tx.commit().unwrap();
286        let err = db.get_track_art(track).unwrap_err();
287        assert!(matches!(err, DbError::TooManyArtRows { .. }), "{err:?}");
288    }
289
290    #[test]
291    fn get_track_art_accepts_rows_at_cap() {
292        let (db, track, art) = db_track_art();
293        let tx = db.conn.unchecked_transaction().unwrap();
294        let mut stmt = tx
295            .prepare(
296                "INSERT INTO track_art (track_id, art_id, picture_type, description, ordinal) \
297                 VALUES (?1, ?2, 3, '', ?3)",
298            )
299            .unwrap();
300        for i in 0..4096 {
301            stmt.execute(rusqlite::params![track, art, i]).unwrap();
302        }
303        drop(stmt);
304        tx.commit().unwrap();
305        assert_eq!(db.get_track_art(track).unwrap().len(), 4096);
306    }
307}