koan-core 0.66.4

Core library for koan — bit-perfect music player. Audio engine, player, database, format strings.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
mod albums;
pub mod api_keys;
pub mod app_passwords;
mod artists;
pub mod auth;
pub mod batch;
mod bookmarks;
pub mod dsp;
mod favourites;
pub mod history;
pub mod lyrics;
mod play_queues;
pub mod playback_state;
pub mod playlists;
mod ratings;
mod scan_cache;
pub mod scrobbling;
mod search;
pub mod shares;
pub mod smart;
pub(crate) mod sources;
mod stats;
pub mod tracks;
pub mod uids;

use std::path::PathBuf;

// Re-exported so callers can `use queries::*`.
pub use albums::*;
pub use artists::*;
pub use auth::LOCAL_USER;
pub use batch::*;
pub use bookmarks::*;
pub use favourites::*;
pub use history::*;
pub use lyrics::*;
pub use play_queues::*;
pub use playback_state::*;
pub use playlists::*;
pub use ratings::*;
pub use scan_cache::*;
pub use search::*;
pub use sources::sources_of_track;
pub use stats::*;
pub use tracks::*;
pub use uids::*;

/// A write transaction that holds the write lock from its first statement.
///
/// What `Connection::unchecked_transaction` gives is deferred: it takes the
/// lock at its first write, and if another connection wrote since its first
/// read, SQLite refuses that upgrade outright rather than waiting, since
/// waiting could deadlock. A scan chunk or a sync page that met another writer
/// therefore failed every statement in it. Immediate waits for the lock the way
/// any other statement does.
pub fn write_transaction(
    conn: &rusqlite::Connection,
) -> rusqlite::Result<rusqlite::Transaction<'_>> {
    rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)
}

/// Run `f` as one unit: its writes land together or not at all, readers never
/// see them half done, and the write lock is taken once rather than per
/// statement.
///
/// Inside a caller's transaction it is a savepoint, so it nests. Outside one it
/// begins `IMMEDIATE`: a deferred transaction that reads before it writes
/// fails outright, without waiting, when another connection wrote in between.
pub fn atomically<T, E: From<rusqlite::Error>>(
    conn: &rusqlite::Connection,
    f: impl FnOnce() -> Result<T, E>,
) -> Result<T, E> {
    let (begin, commit, rollback) = if conn.is_autocommit() {
        ("BEGIN IMMEDIATE", "COMMIT", "ROLLBACK")
    } else {
        (
            "SAVEPOINT atomically",
            "RELEASE atomically",
            "ROLLBACK TO atomically; RELEASE atomically",
        )
    };
    conn.execute_batch(begin)?;
    let result = f();
    match &result {
        Ok(_) => conn.execute_batch(commit)?,
        // The work's own error is what the caller needs. A rollback that
        // fails, as one does after something inside ended the transaction
        // already, is logged rather than put in its place.
        Err(_) => {
            if let Err(e) = conn.execute_batch(rollback) {
                log::warn!("rollback after a failed transaction: {e}");
            }
        }
    }
    result
}

/// A list as one JSON array, for `IN (SELECT value FROM json_each(?))`.
///
/// One placeholder per item stops at SQLite's limit of 32,766 parameters, and
/// a queue or a playlist may be longer than that. One parameter is not limited.
pub fn json_list<T: serde::Serialize>(items: &[T]) -> String {
    serde_json::to_string(items).unwrap_or_else(|_| "[]".into())
}

/// The half-open range of paths under a folder, for `path >= .0 AND path < .1`.
///
/// A prefix match on an indexed column, rather than `LIKE 'folder/%'` — which
/// SQLite answers by reading every row, because a pattern is opaque to an
/// index until it has been evaluated. It also takes the pattern out of the
/// path: `LIKE` reads `_` as "any character" and folds ASCII case, so
/// `/Volumes/My_Music` would match `/Volumes/My Music` and `/volumes/my_music`
/// alike.
///
/// The trailing separator is what keeps `/Volumes/Music` out of
/// `/Volumes/Music Backup`; the upper bound is the highest code point, so
/// every path under the folder sorts below it.
pub fn folder_prefix_range(folder: &std::path::Path) -> (String, String) {
    let prefix = format!(
        "{}{}",
        folder
            .to_string_lossy()
            .trim_end_matches(std::path::MAIN_SEPARATOR),
        std::path::MAIN_SEPARATOR
    );
    let upper = format!("{prefix}\u{10FFFF}");
    (prefix, upper)
}

// --- Row types ---

#[derive(Debug, Clone)]
pub struct ArtistRow {
    pub id: i64,
    pub name: String,
    pub sort_name: Option<String>,
    pub remote_id: Option<String>,
    /// Albums credited to this artist, and tracks across them. Aggregated in
    /// the same query as the row itself — a count per artist would be one
    /// query per row in a list thousands long.
    pub album_count: i64,
    pub track_count: i64,
}

#[derive(Debug, Clone)]
pub struct AlbumRow {
    pub id: i64,
    pub title: String,
    pub artist_id: i64,
    pub artist_name: String,
    pub date: Option<String>,
    pub total_discs: Option<i32>,
    pub total_tracks: Option<i32>,
    pub codec: Option<String>,
    pub label: Option<String>,
    pub remote_id: Option<String>,
    /// When the album entered the library — the server's `created` for remote
    /// albums, otherwise the time it was first indexed.
    pub added_at: Option<String>,
    /// How much of it can play here, when the listing was narrowed to this
    /// device or ordered by it.
    pub on_device: Option<OnDevice>,
}

#[derive(Debug, Clone)]
pub struct TrackRow {
    pub id: i64,
    pub album_id: Option<i64>,
    pub artist_id: Option<i64>,
    /// The artist its record is filed under.
    pub album_artist_id: Option<i64>,
    pub artist_name: String,
    pub album_artist_name: String,
    pub album_title: String,
    pub disc: Option<i32>,
    pub track_number: Option<i32>,
    pub title: String,
    pub duration_ms: Option<i64>,
    pub path: Option<String>,
    pub codec: Option<String>,
    pub sample_rate: Option<i32>,
    pub bit_depth: Option<i32>,
    pub channels: Option<i32>,
    pub bitrate: Option<i32>,
    pub genre: Option<String>,
    pub source: String,
    pub remote_id: Option<String>,
    pub cached_path: Option<String>,
}

impl TrackRow {
    /// Where the track is filed: its record and both artists.
    pub fn place(&self) -> Place {
        Place {
            album_id: self.album_id,
            artist_id: self.artist_id,
            album_artist_id: self.album_artist_id,
        }
    }
}

/// Where to get audio data for playback. Local always wins.
#[derive(Debug, Clone)]
pub enum PlaybackSource {
    Local(PathBuf),
    Cached(PathBuf),
    Remote(String),
}

#[derive(Debug, Clone, Default)]
pub struct LibraryStats {
    pub total_tracks: i64,
    pub local_tracks: i64,
    pub remote_tracks: i64,
    pub cached_tracks: i64,
    pub total_albums: i64,
    pub total_artists: i64,
}

/// Metadata for inserting/updating a track.
#[derive(Debug, Clone, PartialEq)]
pub struct TrackMeta {
    pub title: String,
    pub artist: String,
    pub album_artist: Option<String>,
    pub album: String,
    pub date: Option<String>,
    pub disc: Option<i32>,
    /// How many discs the release has, where the file's tags say.
    pub disc_total: Option<i32>,
    pub track_number: Option<i32>,
    pub genre: Option<String>,
    pub label: Option<String>,
    pub duration_ms: Option<i64>,
    pub codec: Option<String>,
    pub sample_rate: Option<i32>,
    pub bit_depth: Option<i32>,
    pub channels: Option<i32>,
    pub bitrate: Option<i32>,
    pub size_bytes: Option<i64>,
    pub mtime: Option<i64>,
    pub path: Option<String>,
    /// Where the server keeps the file, relative to its library folder, as
    /// Subsonic's `path` reports it. A remote entry's only; `path` is always
    /// a file on this device.
    pub server_path: Option<String>,
    pub source: String,
    pub remote_id: Option<String>,
    pub remote_url: Option<String>,
    /// The server's ids for the album and its artist.
    ///
    /// Carried alongside the track's own, because the server keys stars,
    /// shares and cover art off them — a library synced without these has
    /// albums and artists it can name but cannot refer to.
    pub album_remote_id: Option<String>,
    pub artist_remote_id: Option<String>,
    /// MusicBrainz recording and release ids — `MUSICBRAINZ_TRACKID` and
    /// `MUSICBRAINZ_ALBUMID` in a file's tags, `musicBrainzId` on a server's
    /// song and album. Together they name one track whatever each source calls
    /// the album; the recording alone recurs on every compilation it is on.
    pub mbid: Option<String>,
    pub album_mbid: Option<String>,
    /// When the album this track belongs to entered the library. Remote sync
    /// supplies the server's `created`; anything else leaves it and the album
    /// is stamped with the time it was first seen.
    pub album_added_at: Option<String>,
    /// Each artist the credits name, where the source names them one by one.
    pub credits: Credits,
}

/// The artists a track's and its album's credits name, one by one: `ARTISTS`
/// and `ALBUMARTISTS` with their MusicBrainz ids in a file, OpenSubsonic's
/// `artists` and `albumArtists` from a server. `artist` and `album_artist`
/// stay the credit as it reads ("A × B"); these are what lists the record
/// under each artist. Empty when the source names only the credit.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Credits {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub artists: Vec<Credit>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub album_artists: Vec<Credit>,
}

#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Credit {
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mbid: Option<String>,
    /// The server's id for the artist.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub remote_id: Option<String>,
}

impl Credits {
    pub fn is_empty(&self) -> bool {
        self.artists.is_empty() && self.album_artists.is_empty()
    }

    /// Names paired with ids by position, as taggers write them; ids that do
    /// not line up with the names are dropped rather than guessed at.
    pub fn pair(names: Vec<String>, mbids: Vec<String>) -> Vec<Credit> {
        let aligned = names.len() == mbids.len();
        names
            .into_iter()
            .enumerate()
            .map(|(i, name)| Credit {
                name,
                mbid: aligned.then(|| mbids[i].clone()),
                remote_id: None,
            })
            .collect()
    }
}

impl TrackMeta {
    /// What this source says, labelled for an info view, empty values left
    /// out. The stream URL carries the account's credentials, so it is left out.
    pub fn info_fields(self) -> Vec<(&'static str, String)> {
        let text = |v: Option<String>| v.filter(|v| !v.is_empty());
        let number = |v: Option<i32>| v.map(|v| v.to_string());
        let length = self.duration_ms.map(|ms| {
            let s = ms / 1000;
            format!("{}:{:02}", s / 60, s % 60)
        });
        let modified = self
            .mtime
            .and_then(|t| chrono::DateTime::from_timestamp(t, 0))
            .map(|t| t.to_rfc3339());
        [
            ("Title", Some(self.title)),
            ("Artist", Some(self.artist)),
            ("Album artist", text(self.album_artist)),
            ("Album", Some(self.album)),
            ("Date", text(self.date)),
            ("Disc", number(self.disc)),
            ("Track", number(self.track_number)),
            ("Genre", text(self.genre)),
            ("Label", text(self.label)),
            ("Length", length),
            ("Codec", text(self.codec)),
            ("Sample rate", self.sample_rate.map(|r| format!("{r} Hz"))),
            ("Bit depth", self.bit_depth.map(|b| format!("{b} bit"))),
            ("Channels", number(self.channels)),
            ("Bitrate", self.bitrate.map(|b| format!("{b} kbps"))),
            ("Size", self.size_bytes.map(|b| format!("{b} bytes"))),
            ("Modified", modified),
            ("Path", text(self.path)),
            ("Path on server", text(self.server_path)),
            ("Server id", text(self.remote_id)),
            ("Server album id", text(self.album_remote_id)),
            ("Server artist id", text(self.artist_remote_id)),
            ("MusicBrainz recording", text(self.mbid)),
            ("MusicBrainz release", text(self.album_mbid)),
            ("Album added", text(self.album_added_at)),
        ]
        .into_iter()
        .filter_map(|(name, value)| Some((name, value?)))
        .collect()
    }
}

/// Test helper: build a sample TrackMeta for use in tests across sub-modules.
#[cfg(test)]
pub fn sample_meta(title: &str, artist: &str, album: &str) -> TrackMeta {
    TrackMeta {
        title: title.into(),
        artist: artist.into(),
        album_artist: Some(artist.into()),
        album: album.into(),
        date: Some("2024".into()),
        disc: Some(1),
        disc_total: None,
        track_number: Some(1),
        genre: Some("Electronic".into()),
        label: None,
        duration_ms: Some(240_000),
        codec: Some("FLAC".into()),
        sample_rate: Some(44100),
        bit_depth: Some(16),
        channels: Some(2),
        bitrate: Some(1000),
        size_bytes: Some(30_000_000),
        mtime: Some(1700000000),
        path: Some(format!("/music/{}/{}.flac", album, title)),
        server_path: None,
        source: "local".into(),
        remote_id: None,
        album_remote_id: None,
        artist_remote_id: None,
        mbid: None,
        album_mbid: None,
        remote_url: None,
        album_added_at: None,
        credits: Default::default(),
    }
}

#[cfg(test)]
mod write_transaction_tests {
    use std::time::Duration;

    fn open(path: &std::path::Path) -> rusqlite::Connection {
        let conn = rusqlite::Connection::open(path).unwrap();
        conn.pragma_update(None, "journal_mode", "wal").unwrap();
        conn.busy_timeout(Duration::from_secs(5)).unwrap();
        conn
    }

    /// A deferred transaction that read before another connection committed
    /// cannot write at all, however long the busy timeout; `write_transaction`
    /// waits its turn instead. This is what a sync page meeting another writer
    /// ran into.
    #[test]
    fn a_write_transaction_waits_where_a_deferred_one_fails() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("t.db");
        open(&path)
            .execute_batch("CREATE TABLE t (x INTEGER)")
            .unwrap();

        let deferred = open(&path);
        let tx = deferred.unchecked_transaction().unwrap();
        let _: i64 = tx
            .query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
            .unwrap();
        open(&path).execute("INSERT INTO t VALUES (1)", []).unwrap();
        let err = tx.execute("INSERT INTO t VALUES (2)", []).unwrap_err();
        assert!(
            err.to_string().contains("locked") || err.to_string().contains("busy"),
            "{err}"
        );
        drop(tx);

        let path_for_writer = path.clone();
        let writer = std::thread::spawn(move || {
            let conn = open(&path_for_writer);
            conn.execute_batch("BEGIN IMMEDIATE; INSERT INTO t VALUES (3)")
                .unwrap();
            std::thread::sleep(Duration::from_millis(200));
            conn.execute_batch("COMMIT").unwrap();
        });
        std::thread::sleep(Duration::from_millis(50));
        let conn = open(&path);
        let tx = super::write_transaction(&conn).unwrap();
        let _: i64 = tx
            .query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
            .unwrap();
        tx.execute("INSERT INTO t VALUES (4)", []).unwrap();
        tx.commit().unwrap();
        writer.join().unwrap();

        let n: i64 = conn
            .query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
            .unwrap();
        assert_eq!(n, 3);
    }
}