use rusqlite::{Connection, OptionalExtension, params};
use crate::db::connection::DbError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ShareKind {
#[default]
Tracks,
Album,
Artist,
}
impl ShareKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Tracks => "tracks",
Self::Album => "album",
Self::Artist => "artist",
}
}
fn parse(s: &str) -> Self {
match s {
"album" => Self::Album,
"artist" => Self::Artist,
_ => Self::Tracks,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Slice {
pub kind: ShareKind,
pub subject_id: Option<i64>,
pub start_track_id: Option<i64>,
}
impl Slice {
pub const TRACKS: Self = Self {
kind: ShareKind::Tracks,
subject_id: None,
start_track_id: None,
};
}
#[derive(Debug, Clone, PartialEq)]
pub struct ShareRow {
pub id: String,
pub description: Option<String>,
pub created_at: i64,
pub expires_at: Option<i64>,
pub visits: i64,
pub last_visited: Option<i64>,
pub slice: Slice,
pub track_ids: Vec<i64>,
}
impl ShareRow {
pub fn is_live(&self, now: i64) -> bool {
self.expires_at.is_none_or(|e| e > now)
}
}
fn new_id() -> Result<String, DbError> {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).map_err(|e| {
DbError::Io(std::io::Error::other(format!(
"no randomness for a share id: {e}"
)))
})?;
Ok(bytes.iter().map(|b| format!("{b:02x}")).collect())
}
pub fn create_share(
conn: &Connection,
slice: Slice,
track_ids: &[i64],
description: Option<&str>,
created_at: i64,
expires_at: Option<i64>,
) -> Result<ShareRow, DbError> {
let id = new_id()?;
let tx = conn.unchecked_transaction()?;
tx.execute(
"INSERT INTO shares (id, description, created_at, expires_at, kind, subject_id, start_track_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
id,
description,
created_at,
expires_at,
slice.kind.as_str(),
slice.subject_id,
slice.start_track_id
],
)?;
{
let mut insert = tx.prepare(
"INSERT INTO share_tracks (share_id, position, track_id) VALUES (?1, ?2, ?3)",
)?;
for (position, track_id) in track_ids.iter().enumerate() {
insert.execute(params![id, position as i64, track_id])?;
}
}
tx.commit()?;
Ok(ShareRow {
id,
description: description.map(str::to_string),
created_at,
expires_at,
visits: 0,
last_visited: None,
slice,
track_ids: track_ids.to_vec(),
})
}
fn tracks_of(conn: &Connection, id: &str) -> Result<Vec<i64>, DbError> {
let mut stmt =
conn.prepare("SELECT track_id FROM share_tracks WHERE share_id = ?1 ORDER BY position")?;
let rows = stmt.query_map([id], |r| r.get(0))?;
Ok(rows.collect::<Result<_, _>>()?)
}
fn row(r: &rusqlite::Row) -> rusqlite::Result<ShareRow> {
Ok(ShareRow {
id: r.get(0)?,
description: r.get(1)?,
created_at: r.get(2)?,
expires_at: r.get(3)?,
visits: r.get(4)?,
last_visited: r.get(5)?,
slice: Slice {
kind: ShareKind::parse(&r.get::<_, String>(6)?),
subject_id: r.get(7)?,
start_track_id: r.get(8)?,
},
track_ids: Vec::new(),
})
}
const COLUMNS: &str = "id, description, created_at, expires_at, visits, last_visited, kind, subject_id, start_track_id";
pub fn get_share(conn: &Connection, id: &str) -> Result<Option<ShareRow>, DbError> {
let Some(mut share) = conn
.query_row(
&format!("SELECT {COLUMNS} FROM shares WHERE id = ?1"),
[id],
row,
)
.optional()?
else {
return Ok(None);
};
share.track_ids = tracks_of(conn, id)?;
Ok(Some(share))
}
pub fn list_shares(conn: &Connection) -> Result<Vec<ShareRow>, DbError> {
let mut stmt = conn.prepare(&format!(
"SELECT {COLUMNS} FROM shares ORDER BY created_at DESC, id"
))?;
let mut shares: Vec<ShareRow> = stmt.query_map([], row)?.collect::<Result<_, _>>()?;
for share in &mut shares {
share.track_ids = tracks_of(conn, &share.id)?;
}
Ok(shares)
}
pub fn delete_share(conn: &Connection, id: &str) -> Result<bool, DbError> {
Ok(conn.execute("DELETE FROM shares WHERE id = ?1", [id])? > 0)
}
pub fn update_share(
conn: &Connection,
id: &str,
description: Option<&str>,
expires_at: Option<i64>,
) -> Result<bool, DbError> {
Ok(conn.execute(
"UPDATE shares SET description = ?2, expires_at = ?3 WHERE id = ?1",
params![id, description, expires_at],
)? > 0)
}
pub fn record_visit(conn: &Connection, id: &str, now: i64) -> Result<(), DbError> {
conn.execute(
"UPDATE shares SET visits = visits + 1, last_visited = ?2 WHERE id = ?1",
params![id, now],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries::{sample_meta, upsert_track};
fn test_conn() -> (Connection, [i64; 3]) {
let conn = Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "on").unwrap();
crate::db::schema::create_tables(&conn).unwrap();
let t = ["One", "Two", "Three"]
.map(|title| upsert_track(&conn, &sample_meta(title, "Artist", "Album")).unwrap());
(conn, t)
}
#[test]
fn a_share_keeps_its_tracks_in_order_and_goes_when_deleted() {
let (conn, [t1, t2, t3]) = test_conn();
let a = create_share(&conn, Slice::TRACKS, &[t3, t1, t2], Some("mix"), 100, None).unwrap();
let b = create_share(&conn, Slice::TRACKS, &[t1], None, 200, Some(300)).unwrap();
assert_eq!(a.id.len(), 32);
assert_ne!(a.id, b.id);
assert_eq!(
get_share(&conn, &a.id).unwrap().unwrap().track_ids,
[t3, t1, t2]
);
assert_eq!(
list_shares(&conn)
.unwrap()
.iter()
.map(|s| s.id.clone())
.collect::<Vec<_>>(),
[b.id.clone(), a.id.clone()]
);
assert!(delete_share(&conn, &a.id).unwrap());
assert!(get_share(&conn, &a.id).unwrap().is_none());
assert!(!delete_share(&conn, &a.id).unwrap());
let n: i64 = conn
.query_row(
"SELECT COUNT(*) FROM share_tracks WHERE share_id = ?1",
[&a.id],
|r| r.get(0),
)
.unwrap();
assert_eq!(n, 0, "tracks go with the share");
}
#[test]
fn the_slice_is_kept_with_the_share() {
let (conn, [t1, t2, _]) = test_conn();
let slice = Slice {
kind: ShareKind::Album,
subject_id: Some(7),
start_track_id: Some(t2),
};
let s = create_share(&conn, slice, &[t1, t2], None, 100, None).unwrap();
let back = get_share(&conn, &s.id).unwrap().unwrap();
assert_eq!(back.slice, slice);
assert_eq!(back.track_ids, [t1, t2]);
}
#[test]
fn expiry_and_visits() {
let (conn, [t1, ..]) = test_conn();
let s = create_share(&conn, Slice::TRACKS, &[t1], None, 100, Some(200)).unwrap();
assert!(s.is_live(199) && !s.is_live(200));
record_visit(&conn, &s.id, 150).unwrap();
let s = get_share(&conn, &s.id).unwrap().unwrap();
assert_eq!((s.visits, s.last_visited), (1, Some(150)));
assert!(update_share(&conn, &s.id, Some("x"), None).unwrap());
assert!(get_share(&conn, &s.id).unwrap().unwrap().is_live(i64::MAX));
}
}