Baseline: full tree snapshot after git repository loss

The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
KeelrLeek committed 2026-07-22 17:57:00 -05:00
commit a667b4b28e
161 files changed
+53170

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
[package]
name = "library"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[dependencies]
rusqlite = { version = "0.32", features = ["bundled"] }
serde.workspace = true
thiserror.workspace = true
log.workspace = true
+724
View File
@@ -0,0 +1,724 @@
//! Music library: SQLite-backed albums and tracks. All methods are
//! synchronous — call from a blocking context.
use rusqlite::Connection;
use serde::Serialize;
use std::path::Path;
use std::sync::Mutex;
#[derive(Debug, thiserror::Error)]
pub enum LibraryError {
#[error("sqlite: {0}")]
Sqlite(#[from] rusqlite::Error),
}
pub type Result<T> = std::result::Result<T, LibraryError>;
#[derive(Debug, Clone, Serialize)]
pub struct Album {
pub id: i64,
pub artist: String,
pub title: String,
pub year: Option<u32>,
pub art_path: Option<String>,
pub artist_art_path: Option<String>,
pub track_count: u32,
pub added_at: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct Track {
pub id: i64,
pub album_id: i64,
pub track_no: Option<u32>,
pub title: String,
pub artist: String,
pub duration_secs: Option<u32>,
pub bitrate_kbps: Option<u32>,
pub sample_rate: Option<u32>,
pub bit_depth: Option<u32>,
pub size: Option<u64>,
pub path: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Playlist {
pub id: i64,
pub name: String,
pub track_count: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct PlaylistTrack {
pub position: i64,
pub title: String,
pub artist: String,
pub duration_secs: Option<u32>,
pub path: String,
pub art_path: Option<String>,
}
/// Cumulative listening stats for one artist (drives the artist-page chips).
#[derive(Debug, Clone, Serialize)]
pub struct PlayStats {
pub seconds: u64,
pub last_played_at: Option<i64>,
/// Most played track, ranked by listened time (not session count, so ten
/// instant skips never beat one album's worth of real listening).
pub top_title: Option<String>,
/// Listening sessions of the top track — the "N×" the chip displays.
pub top_count: u32,
}
pub struct Library {
conn: Mutex<Connection>,
}
impl Library {
pub fn open(db_path: &Path) -> Result<Library> {
let conn = Connection::open(db_path)?;
conn.execute_batch(
"
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS albums (
id INTEGER PRIMARY KEY,
artist TEXT NOT NULL,
title TEXT NOT NULL,
year INTEGER,
art_path TEXT,
artist_art_path TEXT,
mbid TEXT,
added_at INTEGER NOT NULL,
UNIQUE(artist COLLATE NOCASE, title COLLATE NOCASE)
);
CREATE TABLE IF NOT EXISTS tracks (
id INTEGER PRIMARY KEY,
album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
track_no INTEGER,
title TEXT NOT NULL,
artist TEXT NOT NULL,
duration_secs INTEGER,
bitrate_kbps INTEGER,
sample_rate INTEGER,
bit_depth INTEGER,
size INTEGER,
path TEXT NOT NULL UNIQUE,
added_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tracks_album ON tracks(album_id);
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS playlist_tracks (
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
duration_secs INTEGER,
path TEXT NOT NULL,
art_path TEXT,
PRIMARY KEY (playlist_id, position)
);
CREATE TABLE IF NOT EXISTS plays (
id INTEGER PRIMARY KEY,
artist TEXT NOT NULL,
title TEXT NOT NULL,
started_at INTEGER NOT NULL,
seconds INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_plays_artist ON plays(artist COLLATE NOCASE);
",
)?;
// Migrate databases created before artist portraits were added.
let _ = conn.execute("ALTER TABLE albums ADD COLUMN artist_art_path TEXT", []);
// Migrate databases created before per-track audio props were stored.
let _ = conn.execute("ALTER TABLE tracks ADD COLUMN sample_rate INTEGER", []);
let _ = conn.execute("ALTER TABLE tracks ADD COLUMN bit_depth INTEGER", []);
let _ = conn.execute("ALTER TABLE tracks ADD COLUMN size INTEGER", []);
Ok(Library {
conn: Mutex::new(conn),
})
}
/// Insert or fetch the album, updating year/art/mbid when newly known.
pub fn upsert_album(
&self,
artist: &str,
title: &str,
year: Option<u32>,
art_path: Option<&str>,
artist_art_path: Option<&str>,
mbid: Option<&str>,
now: i64,
) -> Result<i64> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO albums (artist, title, year, art_path, artist_art_path, mbid, added_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(artist COLLATE NOCASE, title COLLATE NOCASE) DO UPDATE SET
year = COALESCE(albums.year, excluded.year),
art_path = COALESCE(albums.art_path, excluded.art_path),
artist_art_path = COALESCE(albums.artist_art_path, excluded.artist_art_path),
mbid = COALESCE(albums.mbid, excluded.mbid)",
rusqlite::params![artist, title, year, art_path, artist_art_path, mbid, now],
)?;
let id = conn.query_row(
"SELECT id FROM albums WHERE artist = ?1 COLLATE NOCASE AND title = ?2 COLLATE NOCASE",
rusqlite::params![artist, title],
|r| r.get(0),
)?;
Ok(id)
}
pub fn set_album_art(&self, album_id: i64, art_path: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE albums SET art_path = ?2 WHERE id = ?1",
rusqlite::params![album_id, art_path],
)?;
Ok(())
}
pub fn set_artist_art(&self, artist: &str, art_path: &str) -> Result<usize> {
let conn = self.conn.lock().unwrap();
Ok(conn.execute(
"UPDATE albums SET artist_art_path = ?2 WHERE artist = ?1 COLLATE NOCASE",
rusqlite::params![artist, art_path],
)?)
}
// ---- play history ----
// One row per listening session. The frontend opens a row when a track
// first accumulates listened time and tops up its seconds while it plays,
// so a skipped track never writes a row and per-play counts stay honest.
pub fn record_play(&self, artist: &str, title: &str, now: i64) -> Result<i64> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO plays (artist, title, started_at, seconds) VALUES (?1, ?2, ?3, 0)",
rusqlite::params![artist, title, now],
)?;
Ok(conn.last_insert_rowid())
}
pub fn add_play_seconds(&self, play_id: i64, secs: u32) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE plays SET seconds = seconds + ?2 WHERE id = ?1",
rusqlite::params![play_id, secs],
)?;
Ok(())
}
pub fn artist_play_stats(&self, artist: &str) -> Result<PlayStats> {
let conn = self.conn.lock().unwrap();
let (seconds, last_played_at) = conn.query_row(
"SELECT COALESCE(SUM(seconds), 0), MAX(started_at)
FROM plays WHERE artist = ?1 COLLATE NOCASE",
rusqlite::params![artist],
|r| Ok((r.get::<_, i64>(0)?.max(0) as u64, r.get(1)?)),
)?;
// Sessions shorter than 30s are noise (previews, near-instant skips);
// excluding them keeps the count aligned with the scrobbling norm.
let top = conn
.query_row(
"SELECT title, COUNT(*) FROM plays
WHERE artist = ?1 COLLATE NOCASE AND seconds >= 30
GROUP BY title COLLATE NOCASE
ORDER BY SUM(seconds) DESC LIMIT 1",
rusqlite::params![artist],
|r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u32)),
)
.map(Some)
.or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(None),
other => Err(other),
})?;
let (top_title, top_count) = top.map_or((None, 0), |(t, c)| (Some(t), c));
Ok(PlayStats {
seconds,
last_played_at,
top_title,
top_count,
})
}
#[allow(clippy::too_many_arguments)]
pub fn upsert_track(
&self,
album_id: i64,
track_no: Option<u32>,
title: &str,
artist: &str,
duration_secs: Option<u32>,
bitrate_kbps: Option<u32>,
sample_rate: Option<u32>,
bit_depth: Option<u32>,
size: Option<u64>,
path: &str,
now: i64,
) -> Result<i64> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO tracks (album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path, added_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(path) DO UPDATE SET
album_id = excluded.album_id,
track_no = excluded.track_no,
title = excluded.title,
artist = excluded.artist,
duration_secs = excluded.duration_secs,
bitrate_kbps = excluded.bitrate_kbps,
sample_rate = excluded.sample_rate,
bit_depth = excluded.bit_depth,
size = excluded.size",
rusqlite::params![album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path, now],
)?;
let id = conn.query_row(
"SELECT id FROM tracks WHERE path = ?1",
rusqlite::params![path],
|r| r.get(0),
)?;
Ok(id)
}
pub fn has_track_path(&self, path: &str) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM tracks WHERE path = ?1",
rusqlite::params![path],
|r| r.get(0),
)?;
Ok(n > 0)
}
/// Whether this song is already represented in the library, independent
/// of its on-disk path. Downloads are organized with a " (2)" suffix to
/// avoid overwriting files, so a path-only check cannot prevent a second
/// save of the same track from becoming a duplicate library row.
pub fn has_track(&self, artist: &str, album: &str, title: &str) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let n: i64 = conn.query_row(
"SELECT COUNT(*)
FROM tracks t
JOIN albums a ON a.id = t.album_id
WHERE a.artist = ?1 COLLATE NOCASE
AND a.title = ?2 COLLATE NOCASE
AND t.title = ?3 COLLATE NOCASE",
rusqlite::params![artist, album, title],
|r| r.get(0),
)?;
Ok(n > 0)
}
pub fn get_album(&self, album_id: i64) -> Result<Option<(Album, Option<String>)>> {
let conn = self.conn.lock().unwrap();
let row = conn
.query_row(
"SELECT a.id, a.artist, a.title, a.year, a.art_path, a.artist_art_path, a.added_at, a.mbid,
(SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id)
FROM albums a WHERE a.id = ?1",
[album_id],
|r| {
Ok((
Album {
id: r.get(0)?,
artist: r.get(1)?,
title: r.get(2)?,
year: r.get(3)?,
art_path: r.get(4)?,
artist_art_path: r.get(5)?,
added_at: r.get(6)?,
track_count: r.get::<_, i64>(8)? as u32,
},
r.get::<_, Option<String>>(7)?,
))
},
)
.map(Some)
.or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(None),
other => Err(other),
})?;
Ok(row)
}
pub fn list_albums(&self) -> Result<Vec<Album>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT a.id, a.artist, a.title, a.year, a.art_path, a.artist_art_path, a.added_at,
(SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id)
FROM albums a
ORDER BY a.added_at DESC, a.artist, a.title",
)?;
let rows = stmt.query_map([], |r| {
Ok(Album {
id: r.get(0)?,
artist: r.get(1)?,
title: r.get(2)?,
year: r.get(3)?,
art_path: r.get(4)?,
artist_art_path: r.get(5)?,
added_at: r.get(6)?,
track_count: r.get::<_, i64>(7)? as u32,
})
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
pub fn album_tracks(&self, album_id: i64) -> Result<Vec<Track>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path
FROM tracks WHERE album_id = ?1
ORDER BY track_no IS NULL, track_no, title",
)?;
let rows = stmt.query_map([album_id], |r| {
Ok(Track {
id: r.get(0)?,
album_id: r.get(1)?,
track_no: r.get(2)?,
title: r.get(3)?,
artist: r.get(4)?,
duration_secs: r.get(5)?,
bitrate_kbps: r.get(6)?,
sample_rate: r.get(7)?,
bit_depth: r.get(8)?,
size: r.get::<_, Option<i64>>(9)?.map(|n| n as u64),
path: r.get(10)?,
})
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
/// Paths of tracks missing the audio-props backfill (size is the sentinel:
/// always available from the filesystem, so a non-null size means scanned).
pub fn tracks_missing_props(&self) -> Result<Vec<(i64, String)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, path FROM tracks WHERE size IS NULL")?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
/// Backfill audio props for one track (found via tracks_missing_props).
pub fn set_track_props(
&self,
id: i64,
sample_rate: Option<u32>,
bit_depth: Option<u32>,
size: Option<u64>,
) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE tracks SET sample_rate = ?2, bit_depth = ?3, size = ?4 WHERE id = ?1",
rusqlite::params![id, sample_rate, bit_depth, size.map(|n| n as i64)],
)?;
Ok(())
}
// ---- playlists ----
pub fn create_playlist(&self, name: &str, now: i64) -> Result<i64> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO playlists (name, created_at) VALUES (?1, ?2)",
rusqlite::params![name, now],
)?;
Ok(conn.last_insert_rowid())
}
pub fn rename_playlist(&self, id: i64, name: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE playlists SET name = ?2 WHERE id = ?1",
rusqlite::params![id, name],
)?;
Ok(())
}
pub fn delete_playlist(&self, id: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM playlist_tracks WHERE playlist_id = ?1", [id])?;
conn.execute("DELETE FROM playlists WHERE id = ?1", [id])?;
Ok(())
}
/// (id, name, track_count), newest first.
pub fn list_playlists(&self) -> Result<Vec<Playlist>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT p.id, p.name,
(SELECT COUNT(*) FROM playlist_tracks t WHERE t.playlist_id = p.id)
FROM playlists p ORDER BY p.created_at DESC",
)?;
let rows = stmt.query_map([], |r| {
Ok(Playlist {
id: r.get(0)?,
name: r.get(1)?,
track_count: r.get::<_, i64>(2)? as u32,
})
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
pub fn add_to_playlist(&self, playlist_id: i64, t: &PlaylistTrack) -> Result<()> {
let conn = self.conn.lock().unwrap();
let next: i64 = conn.query_row(
"SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_tracks WHERE playlist_id = ?1",
[playlist_id],
|r| r.get(0),
)?;
conn.execute(
"INSERT INTO playlist_tracks
(playlist_id, position, title, artist, duration_secs, path, art_path)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
playlist_id,
next,
t.title,
t.artist,
t.duration_secs,
t.path,
t.art_path
],
)?;
Ok(())
}
pub fn playlist_tracks(&self, playlist_id: i64) -> Result<Vec<PlaylistTrack>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT position, title, artist, duration_secs, path, art_path
FROM playlist_tracks WHERE playlist_id = ?1 ORDER BY position",
)?;
let rows = stmt.query_map([playlist_id], |r| {
Ok(PlaylistTrack {
position: r.get(0)?,
title: r.get(1)?,
artist: r.get(2)?,
duration_secs: r.get(3)?,
path: r.get(4)?,
art_path: r.get(5)?,
})
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
pub fn remove_from_playlist(&self, playlist_id: i64, position: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"DELETE FROM playlist_tracks WHERE playlist_id = ?1 AND position = ?2",
rusqlite::params![playlist_id, position],
)?;
Ok(())
}
/// Drop tracks whose files vanished; returns how many were removed.
pub fn prune_missing(&self) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let paths: Vec<(i64, String)> = conn
.prepare("SELECT id, path FROM tracks")?
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.filter_map(|r| r.ok())
.collect();
let mut removed = 0;
for (id, path) in paths {
if !Path::new(&path).exists() {
conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
removed += 1;
}
}
conn.execute(
"DELETE FROM albums WHERE id NOT IN (SELECT DISTINCT album_id FROM tracks)",
[],
)?;
Ok(removed)
}
/// Repoint stale artwork references to an identically named file in the
/// current app's art cache. This covers app-identifier/data-directory
/// migrations without requiring artwork to be downloaded again.
pub fn repair_art_paths(&self, art_dir: &Path) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let rows: Vec<(i64, Option<String>, Option<String>)> = conn
.prepare("SELECT id, art_path, artist_art_path FROM albums")?
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
.filter_map(|r| r.ok())
.collect();
let mut repaired = 0;
for (id, art_path, artist_art_path) in rows {
if let Some(path) = art_path.and_then(|p| cached_art_path(&p, art_dir)) {
conn.execute(
"UPDATE albums SET art_path = ?2 WHERE id = ?1",
rusqlite::params![id, path],
)?;
repaired += 1;
}
if let Some(path) = artist_art_path.and_then(|p| cached_art_path(&p, art_dir)) {
conn.execute(
"UPDATE albums SET artist_art_path = ?2 WHERE id = ?1",
rusqlite::params![id, path],
)?;
repaired += 1;
}
}
Ok(repaired)
}
/// Remove rows accidentally created for non-audio download extras (cover
/// images, playlists, text files, etc.). The library should only ever
/// expose playable audio tracks.
pub fn prune_non_audio(&self) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let rows: Vec<(i64, String)> = conn
.prepare("SELECT id, path FROM tracks")?
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.filter_map(|r| r.ok())
.collect();
let mut removed = 0;
for (id, path) in rows {
let audio = Path::new(&path)
.extension()
.and_then(|e| e.to_str())
.map(|e| {
matches!(
e.to_ascii_lowercase().as_str(),
"flac" | "mp3" | "m4a" | "ogg" | "opus" | "wav" | "aac" | "wma"
)
})
.unwrap_or(false);
if !audio {
conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
removed += 1;
}
}
conn.execute(
"DELETE FROM albums WHERE id NOT IN (SELECT DISTINCT album_id FROM tracks)",
[],
)?;
Ok(removed)
}
}
fn cached_art_path(path: &str, art_dir: &Path) -> Option<String> {
if Path::new(path).is_file() {
return None;
}
let name = Path::new(path).file_name()?;
let candidate = art_dir.join(name);
candidate
.is_file()
.then(|| candidate.to_string_lossy().into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip() {
let dir = std::env::temp_dir().join("ssplus-lib-test");
std::fs::create_dir_all(&dir).unwrap();
let db = dir.join("t.db");
let _ = std::fs::remove_file(&db);
let lib = Library::open(&db).unwrap();
let album = lib
.upsert_album(
"Radiohead",
"OK Computer",
Some(1997),
None,
None,
None,
100,
)
.unwrap();
// Same album, case-insensitive → same id, art fills in.
let again = lib
.upsert_album(
"radiohead",
"ok computer",
None,
Some("art.jpg"),
None,
None,
200,
)
.unwrap();
assert_eq!(album, again);
lib.upsert_track(
album,
Some(1),
"Airbag",
"Radiohead",
Some(287),
Some(1024),
Some(44100),
Some(16),
Some(30_000_000),
"C:\\x\\01.flac",
100,
)
.unwrap();
lib.upsert_track(
album,
Some(2),
"Paranoid Android",
"Radiohead",
None,
None,
None,
None,
None,
"C:\\x\\02.flac",
100,
)
.unwrap();
let albums = lib.list_albums().unwrap();
assert_eq!(albums.len(), 1);
assert_eq!(albums[0].track_count, 2);
assert_eq!(albums[0].art_path.as_deref(), Some("art.jpg"));
let tracks = lib.album_tracks(album).unwrap();
assert_eq!(tracks[0].title, "Airbag");
assert!(lib.has_track_path("C:\\x\\01.flac").unwrap());
assert!(lib.has_track("radiohead", "ok computer", "airbag").unwrap());
assert!(!lib
.has_track("Radiohead", "OK Computer", "Karma Police")
.unwrap());
let art_dir = dir.join("art");
std::fs::create_dir_all(&art_dir).unwrap();
let cover = art_dir.join("cover.jpg");
let artist = art_dir.join("artist.jpg");
std::fs::write(&cover, []).unwrap();
std::fs::write(&artist, []).unwrap();
let stale = lib
.upsert_album(
"Stale Artist",
"Stale Album",
None,
Some("C:\\old-app-data\\art\\cover.jpg"),
Some("C:\\old-app-data\\art\\artist.jpg"),
None,
100,
)
.unwrap();
assert_eq!(lib.repair_art_paths(&art_dir).unwrap(), 2);
let repaired = lib.get_album(stale).unwrap().unwrap().0;
assert_eq!(
repaired.art_path.as_deref(),
Some(cover.to_string_lossy().as_ref())
);
assert_eq!(
repaired.artist_art_path.as_deref(),
Some(artist.to_string_lossy().as_ref())
);
}
}