Compare commits
4 commits
520949c07a
...
5e9ed1439e
Author | SHA1 | Date | |
---|---|---|---|
5e9ed1439e | |||
a468f8fc95 | |||
53ba3163cd | |||
1e17282d6b |
26 changed files with 1528 additions and 340 deletions
753
Cargo.lock
generated
753
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -19,6 +19,7 @@ dotenvy = "0.15.7"
|
|||
log = "0.4.17"
|
||||
env_logger = "0.10.0"
|
||||
path_macro = "1.0.0"
|
||||
hex-literal = "0.4.1"
|
||||
reqwest = { version = "0.11.11", default-features = false, features = [
|
||||
"rustls-tls-native-roots",
|
||||
"json",
|
||||
|
@ -47,6 +48,11 @@ uuid = { version = "1.4.0", features = ["v4", "serde"] }
|
|||
siphasher = "1.0.0"
|
||||
nonempty-collections = { version = "0.1.3", features = ["serde"] }
|
||||
otvec = { git = "https://code.thetadev.de/ThetaDev/otvec.git" }
|
||||
rustypipe = { git = "https://code.thetadev.de/ThetaDev/rustypipe.git" }
|
||||
|
||||
# Tiraya crates
|
||||
tiraya-db = { path = "crates/db" }
|
||||
tiraya-extractor = { path = "crates/extractor" }
|
||||
|
||||
# Dev dependencies
|
||||
sqlx-database-tester = { path = "crates/sqlx-database-tester" }
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
[package]
|
||||
name = "tiraya_db"
|
||||
name = "tiraya-db"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
@ -19,6 +19,7 @@ uuid.workspace = true
|
|||
siphasher.workspace = true
|
||||
nonempty-collections.workspace = true
|
||||
otvec.workspace = true
|
||||
hex-literal.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
|
|
|
@ -68,11 +68,12 @@ CREATE TABLE albums (
|
|||
ul_artists text[],
|
||||
by_va bool NOT NULL DEFAULT false,
|
||||
image_url text,
|
||||
image_hash varchar(64),
|
||||
image_hash bytea,
|
||||
created_at timestamp NOT NULL DEFAULT now(),
|
||||
updated_at timestamp NOT NULL DEFAULT now(),
|
||||
dirty bool NOT NULL DEFAULT true,
|
||||
hidden bool NOT NULL DEFAULT false,
|
||||
album_hash bytea,
|
||||
CONSTRAINT album_pk PRIMARY KEY (id)
|
||||
);
|
||||
COMMENT ON COLUMN albums.id IS E'Internal album ID';
|
||||
|
@ -99,9 +100,9 @@ CREATE TABLE artists (
|
|||
name text NOT NULL,
|
||||
description text,
|
||||
image_url text,
|
||||
image_hash varchar(64),
|
||||
image_hash bytea,
|
||||
header_image_url text,
|
||||
header_image_hash varchar(64),
|
||||
header_image_hash bytea,
|
||||
subscribers bigint,
|
||||
wikipedia_url text,
|
||||
created_at timestamp NOT NULL DEFAULT now(),
|
||||
|
@ -219,7 +220,7 @@ CREATE TABLE playlists (
|
|||
owner_url text,
|
||||
playlist_type playlist_type NOT NULL DEFAULT 'local',
|
||||
image_url text,
|
||||
image_hash varchar(64),
|
||||
image_hash bytea,
|
||||
image_type playlist_img_type,
|
||||
created_at timestamp NOT NULL DEFAULT now(),
|
||||
updated_at timestamp NOT NULL DEFAULT now(),
|
||||
|
|
33
crates/db/src/errors.rs
Normal file
33
crates/db/src/errors.rs
Normal file
|
@ -0,0 +1,33 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use crate::models::IdOwned;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum DatabaseError {
|
||||
#[error("Error while interacting with the database: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("Item {0} not found")]
|
||||
NotFound(IdOwned),
|
||||
#[error("Conflict while inserting {typ}: ID {id}")]
|
||||
Conflict { typ: &'static str, id: String },
|
||||
#[error("Playlist VCS error: {0}")]
|
||||
PlaylistVcs(Cow<'static, str>),
|
||||
#[error("DB error: {0}")]
|
||||
Other(Cow<'static, str>),
|
||||
}
|
||||
|
||||
pub trait OptionalRes<T> {
|
||||
fn to_optional(self) -> Result<Option<T>, DatabaseError>;
|
||||
}
|
||||
|
||||
impl<T> OptionalRes<T> for Result<T, DatabaseError> {
|
||||
fn to_optional(self) -> Result<Option<T>, DatabaseError> {
|
||||
match self {
|
||||
Ok(res) => Ok(Some(res)),
|
||||
Err(DatabaseError::NotFound(_)) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
#![warn(clippy::dbg_macro)]
|
||||
|
||||
pub mod errors;
|
||||
pub mod models;
|
||||
mod util;
|
||||
|
||||
|
|
|
@ -3,12 +3,14 @@ use serde::Serialize;
|
|||
use sqlx::{types::Json, FromRow, QueryBuilder};
|
||||
use time::{Date, PrimitiveDateTime};
|
||||
|
||||
use crate::util::DB_CONCURRENCY;
|
||||
|
||||
use super::{
|
||||
artist::{ArtistId, ArtistJsonb},
|
||||
map_artists, AlbumType, Artist, DatabaseError, DatePrecision, Id, MusicService, OptionalRes,
|
||||
SrcId, SrcIdOwned, TrackSlim, TrackSlimRow,
|
||||
map_artists, AlbumType, Artist, DatePrecision, Id, MusicService, SrcId, SrcIdOwned, TrackSlim,
|
||||
TrackSlimRow,
|
||||
};
|
||||
use crate::{
|
||||
errors::{DatabaseError, OptionalRes},
|
||||
util::DB_CONCURRENCY,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
|
@ -23,7 +25,7 @@ pub struct Album {
|
|||
pub album_type: Option<AlbumType>,
|
||||
pub by_va: bool,
|
||||
pub image_url: Option<String>,
|
||||
pub image_hash: Option<String>,
|
||||
pub image_hash: Option<Vec<u8>>,
|
||||
pub created_at: PrimitiveDateTime,
|
||||
pub updated_at: PrimitiveDateTime,
|
||||
pub hidden: bool,
|
||||
|
@ -43,7 +45,7 @@ struct AlbumRow {
|
|||
ul_artists: Option<Vec<String>>,
|
||||
by_va: bool,
|
||||
image_url: Option<String>,
|
||||
image_hash: Option<String>,
|
||||
image_hash: Option<Vec<u8>>,
|
||||
created_at: PrimitiveDateTime,
|
||||
updated_at: PrimitiveDateTime,
|
||||
hidden: bool,
|
||||
|
@ -51,33 +53,35 @@ struct AlbumRow {
|
|||
}
|
||||
|
||||
/// Data for creating an album
|
||||
pub struct AlbumNew {
|
||||
pub src_id: String,
|
||||
pub struct AlbumNew<'a> {
|
||||
pub src_id: &'a str,
|
||||
pub service: MusicService,
|
||||
pub name: String,
|
||||
pub name: &'a str,
|
||||
pub release_date: Option<Date>,
|
||||
pub release_date_precision: Option<DatePrecision>,
|
||||
pub album_type: AlbumType,
|
||||
pub ul_artists: Option<Vec<String>>,
|
||||
pub ul_artists: Option<&'a [String]>,
|
||||
pub by_va: bool,
|
||||
pub image_url: Option<String>,
|
||||
pub image_hash: Option<String>,
|
||||
pub image_url: Option<&'a str>,
|
||||
pub image_hash: Option<&'a [u8]>,
|
||||
pub hidden: bool,
|
||||
pub album_hash: Option<&'a [u8]>,
|
||||
}
|
||||
|
||||
/// Data for updating an album
|
||||
#[derive(Default)]
|
||||
pub struct AlbumUpdate {
|
||||
pub name: Option<String>,
|
||||
pub struct AlbumUpdate<'a> {
|
||||
pub name: Option<&'a str>,
|
||||
pub release_date: Option<Option<Date>>,
|
||||
pub release_date_precision: Option<Option<DatePrecision>>,
|
||||
pub album_type: Option<AlbumType>,
|
||||
pub ul_artists: Option<Vec<String>>,
|
||||
pub ul_artists: Option<&'a [String]>,
|
||||
pub by_va: Option<bool>,
|
||||
pub image_url: Option<Option<String>>,
|
||||
pub image_hash: Option<Option<String>>,
|
||||
pub image_url: Option<Option<&'a str>>,
|
||||
pub image_hash: Option<Option<&'a [u8]>>,
|
||||
pub hidden: Option<bool>,
|
||||
pub dirty: Option<bool>,
|
||||
pub album_hash: Option<Option<&'a [u8]>>,
|
||||
}
|
||||
|
||||
/// Album item (for display)
|
||||
|
@ -90,7 +94,7 @@ pub struct AlbumSlim {
|
|||
pub release_date: Option<Date>,
|
||||
pub album_type: AlbumType,
|
||||
pub image_url: Option<String>,
|
||||
pub image_hash: Option<String>,
|
||||
pub image_hash: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
|
@ -101,7 +105,7 @@ pub struct AlbumSlimRow {
|
|||
pub release_date: Option<Date>,
|
||||
pub album_type: AlbumType,
|
||||
pub image_url: Option<String>,
|
||||
pub image_hash: Option<String>,
|
||||
pub image_hash: Option<Vec<u8>>,
|
||||
pub artists: Option<Json<Vec<ArtistJsonb>>>,
|
||||
pub ul_artists: Option<Vec<String>>,
|
||||
}
|
||||
|
@ -115,7 +119,7 @@ pub struct AlbumId {
|
|||
pub release_date: Option<Date>,
|
||||
pub album_type: AlbumType,
|
||||
pub image_url: Option<String>,
|
||||
pub image_hash: Option<String>,
|
||||
pub image_hash: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Album {
|
||||
|
@ -245,6 +249,7 @@ group by b.id"#,
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the tracks of an album
|
||||
pub async fn tracks<'a, E>(id: i32, exec: E) -> Result<Vec<TrackSlim>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
|
@ -258,9 +263,9 @@ jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order
|
|||
filter (where a.src_id is not null) as "artists: _",
|
||||
t.ul_artists
|
||||
from tracks t
|
||||
left join artists_tracks art on art.track_id = t.id
|
||||
left join artists a on a.id = art.artist_id
|
||||
join albums b on b.id = t.album_id
|
||||
left join artists_tracks art on art.track_id=t.id
|
||||
left join artists a on a.id=art.artist_id
|
||||
join albums b on b.id=t.album_id
|
||||
where b.id=$1
|
||||
group by (t.id, b.id)
|
||||
order by t.album_pos"#,
|
||||
|
@ -272,17 +277,41 @@ order by t.album_pos"#,
|
|||
.await
|
||||
.map_err(DatabaseError::from)
|
||||
}
|
||||
|
||||
/// Get a list of album ids which have the same album hash
|
||||
pub async fn ids_from_hash<'a, E>(
|
||||
artist_id: i32,
|
||||
album_hash: &[u8],
|
||||
exec: E,
|
||||
) -> Result<Vec<i32>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
sqlx::query!(
|
||||
r#"select b.id from albums b
|
||||
join artists_albums aa on aa.album_id=b.id
|
||||
where aa.artist_id=$1 and b.album_hash=$2
|
||||
order by b.release_date"#,
|
||||
artist_id,
|
||||
album_hash
|
||||
)
|
||||
.fetch(exec)
|
||||
.map_ok(|row| row.id)
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.map_err(DatabaseError::from)
|
||||
}
|
||||
}
|
||||
|
||||
impl AlbumNew {
|
||||
impl AlbumNew<'_> {
|
||||
pub async fn insert<'a, E>(&self, exec: E) -> Result<i32, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let res = sqlx::query!(
|
||||
r#"insert into albums (src_id, service, name, release_date, release_date_precision,
|
||||
album_type, ul_artists, by_va, image_url, image_hash, hidden)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
album_type, ul_artists, by_va, image_url, image_hash, hidden, album_hash)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
returning id"#,
|
||||
self.src_id,
|
||||
self.service as MusicService,
|
||||
|
@ -295,6 +324,7 @@ returning id"#,
|
|||
self.image_url,
|
||||
self.image_hash,
|
||||
self.hidden,
|
||||
self.album_hash,
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
|
@ -307,8 +337,8 @@ returning id"#,
|
|||
{
|
||||
let res = sqlx::query!(
|
||||
r#"insert into albums (src_id, service, name, release_date, release_date_precision,
|
||||
album_type, ul_artists, by_va, image_url, image_hash, hidden)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
album_type, ul_artists, by_va, image_url, image_hash, hidden, album_hash)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
on conflict (src_id, service) do update set
|
||||
name = excluded.name,
|
||||
release_date = coalesce(excluded.release_date, albums.release_date),
|
||||
|
@ -318,7 +348,8 @@ on conflict (src_id, service) do update set
|
|||
by_va = excluded.by_va,
|
||||
image_url = coalesce(excluded.image_url, albums.image_url),
|
||||
image_hash = coalesce(excluded.image_hash, albums.image_hash),
|
||||
hidden = excluded.hidden
|
||||
hidden = excluded.hidden,
|
||||
album_hash = coalesce(excluded.album_hash, albums.album_hash)
|
||||
returning id"#,
|
||||
self.src_id,
|
||||
self.service as MusicService,
|
||||
|
@ -331,6 +362,7 @@ returning id"#,
|
|||
self.image_url,
|
||||
self.image_hash,
|
||||
self.hidden,
|
||||
self.album_hash,
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
|
@ -338,7 +370,7 @@ returning id"#,
|
|||
}
|
||||
}
|
||||
|
||||
impl AlbumUpdate {
|
||||
impl AlbumUpdate<'_> {
|
||||
pub async fn update<'a, E>(&self, id: Id<'_>, exec: E) -> Result<(), DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
|
@ -423,6 +455,14 @@ impl AlbumUpdate {
|
|||
query.push_bind(dirty);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(album_hash) = &self.album_hash {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("album_hash=");
|
||||
query.push_bind(album_hash);
|
||||
n += 1;
|
||||
}
|
||||
|
||||
if n > 0 {
|
||||
query.push(" where ");
|
||||
|
@ -544,28 +584,30 @@ impl From<AlbumSlimRow> for AlbumSlim {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use hex_literal::hex;
|
||||
use time::macros::date;
|
||||
|
||||
use crate::testutil::{self, ids};
|
||||
|
||||
use super::*;
|
||||
use crate::testutil::{self, ids};
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn crud() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let ul_artists = vec!["Other artist".to_owned()];
|
||||
let album = AlbumNew {
|
||||
src_id: "MPREb_nlBWQROfvjo".to_owned(),
|
||||
src_id: "MPREb_nlBWQROfvjo",
|
||||
service: MusicService::YouTube,
|
||||
name: "Märchen enden gut".to_owned(),
|
||||
name: "Märchen enden gut",
|
||||
release_date: Some(date!(2016 - 10 - 21)),
|
||||
release_date_precision: Some(DatePrecision::Day),
|
||||
album_type: AlbumType::Album,
|
||||
ul_artists: Some(vec!["Other artist".to_owned()]),
|
||||
ul_artists: Some(&ul_artists),
|
||||
by_va: false,
|
||||
image_url: Some("https://lh3.googleusercontent.com/Z5CF2JCRD5o7fBywh9Spg_Wvmrqkg0M01FWsSm_mdmUSfplv--9NgIiBRExudt7s0TTd3tgpJ7CLRFal=w544-h544-l90-rj".to_owned()),
|
||||
image_hash: Some("aeafabf677bb186378a539a197cc087e2a94c33bc5c3ee41c2e6513fd79442a3".to_owned()),
|
||||
image_url: Some("https://lh3.googleusercontent.com/Z5CF2JCRD5o7fBywh9Spg_Wvmrqkg0M01FWsSm_mdmUSfplv--9NgIiBRExudt7s0TTd3tgpJ7CLRFal=w544-h544-l90-rj"),
|
||||
image_hash: Some(&hex!("aeafabf677bb186378a539a197cc087e2a94c33bc5c3ee41c2e6513fd79442a3")),
|
||||
hidden: false,
|
||||
album_hash: Some(&hex!("badeaffe")),
|
||||
};
|
||||
let album_artists = [ids::ARTIST_LEA, ids::ARTIST_CYRIL];
|
||||
|
||||
|
@ -587,7 +629,7 @@ mod tests {
|
|||
".updated_at" => "[date]",
|
||||
});
|
||||
|
||||
let srcid = SrcId(&album.src_id, album.service);
|
||||
let srcid = SrcId(album.src_id, album.service);
|
||||
assert_eq!(
|
||||
Album::get_src_id(new_id, &pool)
|
||||
.await
|
||||
|
@ -601,16 +643,17 @@ mod tests {
|
|||
// Update
|
||||
let mut u_tx = pool.begin().await.unwrap();
|
||||
let clear = AlbumUpdate {
|
||||
name: Some("empty".to_owned()),
|
||||
name: Some("empty"),
|
||||
release_date: Some(None),
|
||||
release_date_precision: Some(None),
|
||||
album_type: None,
|
||||
ul_artists: Some(vec![]),
|
||||
ul_artists: Some(&[]),
|
||||
by_va: None,
|
||||
image_url: Some(None),
|
||||
image_hash: Some(None),
|
||||
hidden: Some(true),
|
||||
dirty: Some(true),
|
||||
album_hash: Some(None),
|
||||
};
|
||||
clear.update(id, &mut *u_tx).await.unwrap();
|
||||
Album::set_artists(new_id, &[], &mut u_tx).await.unwrap();
|
||||
|
@ -658,4 +701,31 @@ mod tests {
|
|||
let tracks_iwwus = Album::tracks(ids::ALBUM_ID_IWWUS, &pool).await.unwrap();
|
||||
insta::assert_ron_snapshot!("album_tracks_iwwus", tracks_iwwus);
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn ids_from_hash() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let hash = hex!("badeaffe");
|
||||
AlbumUpdate {
|
||||
album_hash: Some(Some(&hash)),
|
||||
..Default::default()
|
||||
}
|
||||
.update(ids::ALBUM_VAKUUM, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
AlbumUpdate {
|
||||
album_hash: Some(Some(&hash)),
|
||||
..Default::default()
|
||||
}
|
||||
.update(ids::ALBUM_IWWUS, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ids = Album::ids_from_hash(ids::ARTIST_ID_LEA, &hash, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ids, [ids::ALBUM_ID_VAKUUM, ids::ALBUM_ID_IWWUS]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,11 +3,13 @@ use serde::{Deserialize, Serialize};
|
|||
use sqlx::{types::Json, QueryBuilder};
|
||||
use time::PrimitiveDateTime;
|
||||
|
||||
use crate::util::DB_CONCURRENCY;
|
||||
|
||||
use super::{
|
||||
album::AlbumSlimRow, AlbumSlim, DatabaseError, Id, IdOwned, MusicService, OptionalRes,
|
||||
PlaylistSlim, SrcId, SrcIdOwned, SyncData, TrackSlim, TrackSlimRow,
|
||||
album::AlbumSlimRow, AlbumSlim, Id, IdOwned, MusicService, PlaylistSlim, SrcId, SrcIdOwned,
|
||||
SyncData, TrackSlim, TrackSlimRow, TrackTiny,
|
||||
};
|
||||
use crate::{
|
||||
errors::{DatabaseError, OptionalRes},
|
||||
util::DB_CONCURRENCY,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
|
@ -18,9 +20,9 @@ pub struct Artist {
|
|||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub image_url: Option<String>,
|
||||
pub image_hash: Option<String>,
|
||||
pub image_hash: Option<Vec<u8>>,
|
||||
pub header_image_url: Option<String>,
|
||||
pub header_image_hash: Option<String>,
|
||||
pub header_image_hash: Option<Vec<u8>>,
|
||||
pub subscribers: Option<i64>,
|
||||
pub wikipedia_url: Option<String>,
|
||||
pub created_at: PrimitiveDateTime,
|
||||
|
@ -34,38 +36,38 @@ pub struct Artist {
|
|||
}
|
||||
|
||||
/// Data for creating an artist
|
||||
pub struct ArtistNew {
|
||||
pub src_id: String,
|
||||
pub struct ArtistNew<'a> {
|
||||
pub src_id: &'a str,
|
||||
pub service: MusicService,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub image_url: Option<String>,
|
||||
pub image_hash: Option<String>,
|
||||
pub header_image_url: Option<String>,
|
||||
pub header_image_hash: Option<String>,
|
||||
pub name: &'a str,
|
||||
pub description: Option<&'a str>,
|
||||
pub image_url: Option<&'a str>,
|
||||
pub image_hash: Option<&'a [u8]>,
|
||||
pub header_image_url: Option<&'a str>,
|
||||
pub header_image_hash: Option<&'a [u8]>,
|
||||
pub subscribers: Option<i64>,
|
||||
pub wikipedia_url: Option<String>,
|
||||
pub wikipedia_url: Option<&'a str>,
|
||||
pub playlist_id: Option<i32>,
|
||||
pub related_artists: Option<Vec<i32>>,
|
||||
pub related_playlists: Option<Vec<i32>>,
|
||||
pub top_tracks: Option<Vec<i32>>,
|
||||
pub related_artists: Option<&'a [i32]>,
|
||||
pub related_playlists: Option<&'a [i32]>,
|
||||
pub top_tracks: Option<&'a [i32]>,
|
||||
}
|
||||
|
||||
/// Data for updating an artist
|
||||
#[derive(Default)]
|
||||
pub struct ArtistUpdate {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<Option<String>>,
|
||||
pub image_url: Option<Option<String>>,
|
||||
pub image_hash: Option<Option<String>>,
|
||||
pub header_image_url: Option<Option<String>>,
|
||||
pub header_image_hash: Option<Option<String>>,
|
||||
pub struct ArtistUpdate<'a> {
|
||||
pub name: Option<&'a str>,
|
||||
pub description: Option<Option<&'a str>>,
|
||||
pub image_url: Option<Option<&'a str>>,
|
||||
pub image_hash: Option<Option<&'a [u8]>>,
|
||||
pub header_image_url: Option<Option<&'a str>>,
|
||||
pub header_image_hash: Option<Option<&'a [u8]>>,
|
||||
pub subscribers: Option<Option<i64>>,
|
||||
pub wikipedia_url: Option<Option<String>>,
|
||||
pub wikipedia_url: Option<Option<&'a str>>,
|
||||
pub playlist_id: Option<Option<i32>>,
|
||||
pub related_artists: Option<Vec<i32>>,
|
||||
pub related_playlists: Option<Vec<i32>>,
|
||||
pub top_tracks: Option<Vec<i32>>,
|
||||
pub related_artists: Option<&'a [i32]>,
|
||||
pub related_playlists: Option<&'a [i32]>,
|
||||
pub top_tracks: Option<&'a [i32]>,
|
||||
}
|
||||
|
||||
/// Artist item (for display)
|
||||
|
@ -75,7 +77,7 @@ pub struct ArtistSlim {
|
|||
pub service: MusicService,
|
||||
pub name: String,
|
||||
pub image_url: Option<String>,
|
||||
pub image_hash: Option<String>,
|
||||
pub image_hash: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Artist id decoded from jsonb
|
||||
|
@ -379,31 +381,45 @@ where a2.artist_id=$1 and a1.artist_id=$2)"#,
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn albums<'a, E>(id: i32, exec: E) -> Result<Vec<AlbumSlim>, DatabaseError>
|
||||
/// Get all albums from an artist, ordered by release date
|
||||
///
|
||||
/// Albums are ordered by release date (oldest first).
|
||||
/// If `hidden` is set to true, it will also return hidden (redundant) albums.
|
||||
pub async fn albums<'a, E>(
|
||||
id: i32,
|
||||
hidden: bool,
|
||||
exec: E,
|
||||
) -> Result<Vec<AlbumSlim>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
AlbumSlimRow,
|
||||
r#"select b.src_id, b.service as "service: _", b.name, b.release_date,
|
||||
b.album_type as "album_type: _", b.image_url, b.image_hash, b.ul_artists,
|
||||
let mut query = QueryBuilder::new(
|
||||
r#"select b.src_id, b.service, b.name, b.release_date,
|
||||
b.album_type, b.image_url, b.image_hash, b.ul_artists,
|
||||
(
|
||||
select jsonb_agg(json_build_object('id', xa.src_id, 'sv', xa.service, 'n', xa.name) order by xarb.seq)
|
||||
select jsonb_agg(json_build_object('id', xa.src_id, 'sv', xa.service, 'n', xa.name) order by xarb.seq)
|
||||
filter (where xa.src_id is not null)
|
||||
from artists xa
|
||||
join artists_albums xarb on xarb.artist_id=xa.id
|
||||
where xarb.album_id=b.id
|
||||
) as "artists: _"
|
||||
from artists xa
|
||||
join artists_albums xarb on xarb.artist_id=xa.id
|
||||
where xarb.album_id=b.id
|
||||
) as "artists"
|
||||
from albums b
|
||||
join artists_albums arb on arb.album_id=b.id
|
||||
where arb.artist_id=$1
|
||||
order by b.release_date"#,
|
||||
id
|
||||
)
|
||||
.fetch(exec)
|
||||
.map_ok(AlbumSlim::from)
|
||||
.try_collect::<Vec<_>>().await
|
||||
.map_err(DatabaseError::from)
|
||||
where arb.artist_id="#,
|
||||
);
|
||||
query.push_bind(id);
|
||||
if !hidden {
|
||||
query.push(" and b.hidden=false");
|
||||
}
|
||||
query.push(" order by b.release_date");
|
||||
|
||||
query
|
||||
.build_query_as::<AlbumSlimRow>()
|
||||
.fetch(exec)
|
||||
.map_ok(AlbumSlim::from)
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.map_err(DatabaseError::from)
|
||||
}
|
||||
|
||||
pub async fn tracks<'a, E>(id: i32, exec: E) -> Result<Vec<TrackSlim>, DatabaseError>
|
||||
|
@ -482,9 +498,47 @@ order by b.release_date, t.album_pos"#,
|
|||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a list of an artist's primary tracks (the ones contained in an artist's playlist)
|
||||
///
|
||||
/// Tracks are ordered by release date and album position (oldest first)
|
||||
pub async fn primary_tracks<'a, E>(
|
||||
artist_id: i32,
|
||||
primary: Option<bool>,
|
||||
exec: E,
|
||||
) -> Result<Vec<TrackTiny>, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
let mut query = QueryBuilder::new(
|
||||
r#"select t.id, t.src_id, t.service, t.name, t.duration
|
||||
from tracks t
|
||||
join artists_tracks art on art.track_id = t.id
|
||||
join albums b on b.id = t.album_id
|
||||
where art.artist_id="#,
|
||||
);
|
||||
query.push_bind(artist_id);
|
||||
query.push(" and t.primary_track");
|
||||
match primary {
|
||||
Some(primary) => {
|
||||
query.push("=");
|
||||
query.push_bind(primary);
|
||||
}
|
||||
None => {
|
||||
query.push(" is null");
|
||||
}
|
||||
}
|
||||
query.push(" order by b.release_date, t.album_pos");
|
||||
|
||||
query
|
||||
.build_query_as::<TrackTiny>()
|
||||
.fetch_all(exec)
|
||||
.await
|
||||
.map_err(DatabaseError::from)
|
||||
}
|
||||
}
|
||||
|
||||
impl ArtistNew {
|
||||
impl ArtistNew<'_> {
|
||||
pub async fn insert<'a, E>(&self, exec: E) -> Result<i32, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
|
@ -559,7 +613,7 @@ returning id"#,
|
|||
}
|
||||
}
|
||||
|
||||
impl ArtistUpdate {
|
||||
impl ArtistUpdate<'_> {
|
||||
pub async fn update<'a, E>(&self, id: Id<'_>, exec: E) -> Result<(), DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
|
@ -722,27 +776,31 @@ where src_id=$1 and service=$2"#,
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::testutil::{self, ids};
|
||||
use hex_literal::hex;
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
models::TrackUpdate,
|
||||
testutil::{self, ids},
|
||||
};
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn crud() {
|
||||
let artist = ArtistNew {
|
||||
src_id: "UCRw0x9_EfawqmgDI2IgQLLg".to_owned(),
|
||||
src_id: "UCRw0x9_EfawqmgDI2IgQLLg",
|
||||
service: MusicService::YouTube,
|
||||
name: "Oonagh".to_owned(),
|
||||
description: Some("Senta-Sofia Delliponti is a German singer, songwriter and actress. Since January 2014, she used the stage name Oonagh, until she changed it to Senta in 2022. Her signature musical style is inspired by the mystical lore of J. R. R. Tolkien's universe and by ethnic sounds throughout the world.".to_owned()),
|
||||
image_url: Some("https://lh3.googleusercontent.com/eMMHFaIWg8G3LL3B-8EAew8vhAP2G2aUIDfn4I1JHpS8WxmnO0Yof-vOSEyUSp4y3lCl-q6MIbugbw=w500-h500-p-l90-rj".to_owned()),
|
||||
image_hash: Some("b41f4a1f7a23ba0b7304f1e660af32125de7c77e59d5fa2b2b5ba9f796f1d8a2".to_owned()),
|
||||
header_image_url: Some("https://lh3.googleusercontent.com/eMMHFaIWg8G3LL3B-8EAew8vhAP2G2aUIDfn4I1JHpS8WxmnO0Yof-vOSEyUSp4y3lCl-q6MIbugbw=w1920-h800-p-l90-rj".to_owned()),
|
||||
header_image_hash: Some("5b476147305423e1c8e18bc56ed07f1caf59bd6782b97ba4bbc3d9f96643c342".to_owned()),
|
||||
name: "Oonagh",
|
||||
description: Some("Senta-Sofia Delliponti is a German singer, songwriter and actress. Since January 2014, she used the stage name Oonagh, until she changed it to Senta in 2022. Her signature musical style is inspired by the mystical lore of J. R. R. Tolkien's universe and by ethnic sounds throughout the world."),
|
||||
image_url: Some("https://lh3.googleusercontent.com/eMMHFaIWg8G3LL3B-8EAew8vhAP2G2aUIDfn4I1JHpS8WxmnO0Yof-vOSEyUSp4y3lCl-q6MIbugbw=w500-h500-p-l90-rj"),
|
||||
image_hash: Some(&hex!("b41f4a1f7a23ba0b7304f1e660af32125de7c77e59d5fa2b2b5ba9f796f1d8a2")),
|
||||
header_image_url: Some("https://lh3.googleusercontent.com/eMMHFaIWg8G3LL3B-8EAew8vhAP2G2aUIDfn4I1JHpS8WxmnO0Yof-vOSEyUSp4y3lCl-q6MIbugbw=w1920-h800-p-l90-rj"),
|
||||
header_image_hash: Some(&hex!("5b476147305423e1c8e18bc56ed07f1caf59bd6782b97ba4bbc3d9f96643c342")),
|
||||
subscribers: Some(36400),
|
||||
wikipedia_url: Some("https://en.wikipedia.org/wiki/Oonagh_(singer)".to_owned()),
|
||||
wikipedia_url: Some("https://en.wikipedia.org/wiki/Oonagh_(singer)"),
|
||||
playlist_id: None,
|
||||
related_artists: Some(vec![1, 2]),
|
||||
related_playlists: Some(vec![3, 4]),
|
||||
top_tracks: Some(vec![5, 6]),
|
||||
related_artists: Some(&[1, 2]),
|
||||
related_playlists: Some(&[3, 4]),
|
||||
top_tracks: Some(&[5, 6]),
|
||||
};
|
||||
|
||||
// Create
|
||||
|
@ -758,7 +816,7 @@ mod tests {
|
|||
".updated_at" => "[date]",
|
||||
});
|
||||
|
||||
let srcid = SrcId(&artist.src_id, artist.service);
|
||||
let srcid = SrcId(artist.src_id, artist.service);
|
||||
assert_eq!(
|
||||
Artist::get_src_id(new_id, &pool)
|
||||
.await
|
||||
|
@ -774,7 +832,7 @@ mod tests {
|
|||
|
||||
// Update
|
||||
let clear = ArtistUpdate {
|
||||
name: Some("empty".to_owned()),
|
||||
name: Some("empty"),
|
||||
description: Some(None),
|
||||
image_url: Some(None),
|
||||
image_hash: Some(None),
|
||||
|
@ -783,9 +841,9 @@ mod tests {
|
|||
subscribers: Some(None),
|
||||
wikipedia_url: Some(None),
|
||||
playlist_id: Some(None),
|
||||
related_artists: Some(Vec::new()),
|
||||
related_playlists: Some(Vec::new()),
|
||||
top_tracks: Some(Vec::new()),
|
||||
related_artists: Some(&[]),
|
||||
related_playlists: Some(&[]),
|
||||
top_tracks: Some(&[]),
|
||||
};
|
||||
clear.update(id, &pool).await.unwrap();
|
||||
|
||||
|
@ -858,10 +916,14 @@ mod tests {
|
|||
async fn albums() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let albums_lea = Artist::albums(ids::ARTIST_ID_LEA, &pool).await.unwrap();
|
||||
let albums_lea = Artist::albums(ids::ARTIST_ID_LEA, false, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
insta::assert_ron_snapshot!("albums_lea", albums_lea);
|
||||
|
||||
let albums_cyril = Artist::albums(ids::ARTIST_ID_CYRIL, &pool).await.unwrap();
|
||||
let albums_cyril = Artist::albums(ids::ARTIST_ID_CYRIL, false, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
insta::assert_ron_snapshot!("albums_cyril", albums_cyril);
|
||||
}
|
||||
|
||||
|
@ -900,6 +962,46 @@ mod tests {
|
|||
insta::assert_ron_snapshot!(related);
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn primary_tracks() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
TrackUpdate {
|
||||
primary_track: Some(Some(true)),
|
||||
..Default::default()
|
||||
}
|
||||
.update(ids::TRACK_VAKUUM, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
TrackUpdate {
|
||||
primary_track: Some(Some(false)),
|
||||
..Default::default()
|
||||
}
|
||||
.update(ids::TRACK_IWWUS, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let primary_true = Artist::primary_tracks(ids::ARTIST_ID_LEA, Some(true), &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(primary_true.len(), 1);
|
||||
assert_eq!(primary_true[0].id, ids::TRACK_ID_VAKUUM);
|
||||
|
||||
let primary_false = Artist::primary_tracks(ids::ARTIST_ID_LEA, Some(false), &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(primary_false.len(), 1);
|
||||
assert_eq!(primary_false[0].id, ids::TRACK_ID_IWWUS);
|
||||
|
||||
let primary_none = Artist::primary_tracks(ids::ARTIST_ID_LEA, None, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(primary_none.len() > 10, "got {} tracks", primary_none.len());
|
||||
assert!(primary_none
|
||||
.iter()
|
||||
.all(|t| t.id != ids::TRACK_ID_VAKUUM && t.id != ids::TRACK_ID_IWWUS))
|
||||
}
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn merge() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
|
|
@ -2,7 +2,8 @@ use otvec::Operation;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use time::PrimitiveDateTime;
|
||||
|
||||
use super::{DatabaseError, PlaylistChange, PlaylistEntry, SrcIdOwned};
|
||||
use super::{PlaylistChange, PlaylistEntry, SrcIdOwned};
|
||||
use crate::errors::DatabaseError;
|
||||
|
||||
/// Change operation stored in the database
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
@ -117,8 +118,9 @@ impl ChangeOperation {
|
|||
let end = pos + n;
|
||||
assert_pos(end)?;
|
||||
assert_pos(to)?;
|
||||
let to_corr = if to > pos { to - n } else { to };
|
||||
let moved = items.splice(pos..end, None).collect::<Vec<_>>();
|
||||
items.splice(to..to, moved);
|
||||
items.splice(to_corr..to_corr, moved);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
@ -208,18 +210,30 @@ mod tests {
|
|||
|
||||
const TESTDATE: time::PrimitiveDateTime = datetime!(2023-09-06 8:00:00);
|
||||
|
||||
fn test_entries(ids: &[usize]) -> Vec<PlaylistEntry> {
|
||||
ids.iter()
|
||||
.map(|id| PlaylistEntry {
|
||||
id: SrcIdOwned(id.to_string(), MusicService::Tiraya),
|
||||
dt: TESTDATE,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_entries(entries: &[PlaylistEntry], len: usize) {
|
||||
let ids = entries
|
||||
.iter()
|
||||
.map(|e| e.id.0.parse::<usize>().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(entries.len(), len, "got: {ids:?}");
|
||||
assert!(
|
||||
ids.iter().zip(1..=len).all(|(id, expect)| *id == expect),
|
||||
"got: {ids:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_ins() {
|
||||
let mut entries = vec![
|
||||
PlaylistEntry {
|
||||
id: SrcIdOwned("1".to_owned(), MusicService::Tiraya),
|
||||
dt: TESTDATE,
|
||||
},
|
||||
PlaylistEntry {
|
||||
id: SrcIdOwned("4".to_owned(), MusicService::Tiraya),
|
||||
dt: TESTDATE,
|
||||
},
|
||||
];
|
||||
let mut entries = test_entries(&[1, 4]);
|
||||
let ins = ChangeOperation::Ins {
|
||||
pos: 1,
|
||||
val: vec![
|
||||
|
@ -228,122 +242,94 @@ mod tests {
|
|||
],
|
||||
};
|
||||
ins.apply(&mut entries, TESTDATE).unwrap();
|
||||
|
||||
assert_eq!(entries.len(), 4);
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
assert_eq!(e.id.0, (i + 1).to_string());
|
||||
}
|
||||
assert_entries(&entries, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_del() {
|
||||
let mut entries = vec![
|
||||
PlaylistEntry {
|
||||
id: SrcIdOwned("1".to_owned(), MusicService::Tiraya),
|
||||
dt: TESTDATE,
|
||||
},
|
||||
PlaylistEntry {
|
||||
id: SrcIdOwned("2".to_owned(), MusicService::Tiraya),
|
||||
dt: TESTDATE,
|
||||
},
|
||||
PlaylistEntry {
|
||||
id: SrcIdOwned("X".to_owned(), MusicService::Tiraya),
|
||||
dt: TESTDATE,
|
||||
},
|
||||
PlaylistEntry {
|
||||
id: SrcIdOwned("X".to_owned(), MusicService::Tiraya),
|
||||
dt: TESTDATE,
|
||||
},
|
||||
];
|
||||
let mut entries = test_entries(&[1, 2, 0, 0]);
|
||||
let del = ChangeOperation::Del { pos: 2, n: 2 };
|
||||
del.apply(&mut entries, TESTDATE).unwrap();
|
||||
|
||||
assert_eq!(entries.len(), 2);
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
assert_eq!(e.id.0, (i + 1).to_string());
|
||||
}
|
||||
assert_entries(&entries, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_mov_up() {
|
||||
let mut entries = vec![
|
||||
PlaylistEntry {
|
||||
id: SrcIdOwned("3".to_owned(), MusicService::Tiraya),
|
||||
dt: TESTDATE,
|
||||
},
|
||||
PlaylistEntry {
|
||||
id: SrcIdOwned("4".to_owned(), MusicService::Tiraya),
|
||||
dt: TESTDATE,
|
||||
},
|
||||
PlaylistEntry {
|
||||
id: SrcIdOwned("1".to_owned(), MusicService::Tiraya),
|
||||
dt: TESTDATE,
|
||||
},
|
||||
PlaylistEntry {
|
||||
id: SrcIdOwned("2".to_owned(), MusicService::Tiraya),
|
||||
dt: TESTDATE,
|
||||
},
|
||||
];
|
||||
let mut entries = test_entries(&[3, 4, 1, 2]);
|
||||
let mov = ChangeOperation::Mov {
|
||||
pos: 2,
|
||||
n: 2,
|
||||
to: 0,
|
||||
};
|
||||
mov.apply(&mut entries, TESTDATE).unwrap();
|
||||
|
||||
assert_eq!(entries.len(), 4);
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
assert_eq!(e.id.0, (i + 1).to_string());
|
||||
}
|
||||
assert_entries(&entries, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_mov_down() {
|
||||
let mut entries = vec![
|
||||
PlaylistEntry {
|
||||
id: SrcIdOwned("3".to_owned(), MusicService::Tiraya),
|
||||
dt: TESTDATE,
|
||||
},
|
||||
PlaylistEntry {
|
||||
id: SrcIdOwned("4".to_owned(), MusicService::Tiraya),
|
||||
dt: TESTDATE,
|
||||
},
|
||||
PlaylistEntry {
|
||||
id: SrcIdOwned("1".to_owned(), MusicService::Tiraya),
|
||||
dt: TESTDATE,
|
||||
},
|
||||
PlaylistEntry {
|
||||
id: SrcIdOwned("2".to_owned(), MusicService::Tiraya),
|
||||
dt: TESTDATE,
|
||||
},
|
||||
];
|
||||
let mut entries = test_entries(&[3, 4, 1, 2]);
|
||||
let mov = ChangeOperation::Mov {
|
||||
pos: 0,
|
||||
n: 2,
|
||||
to: 2,
|
||||
to: 4,
|
||||
};
|
||||
mov.apply(&mut entries, TESTDATE).unwrap();
|
||||
assert_entries(&entries, 4);
|
||||
}
|
||||
|
||||
assert_eq!(entries.len(), 4);
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
assert_eq!(e.id.0, (i + 1).to_string());
|
||||
}
|
||||
#[test]
|
||||
fn apply_mov_down2() {
|
||||
let mut entries = test_entries(&[1, 3, 2, 4]);
|
||||
let mov = ChangeOperation::Mov {
|
||||
pos: 1,
|
||||
n: 1,
|
||||
to: 3,
|
||||
};
|
||||
mov.apply(&mut entries, TESTDATE).unwrap();
|
||||
assert_entries(&entries, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_mov_down3() {
|
||||
let mut entries = test_entries(&[1, 3, 4, 2, 5]);
|
||||
let mov = ChangeOperation::Mov {
|
||||
pos: 1,
|
||||
n: 2,
|
||||
to: 4,
|
||||
};
|
||||
mov.apply(&mut entries, TESTDATE).unwrap();
|
||||
assert_entries(&entries, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_del_out_of_range() {
|
||||
let mut entries = vec![
|
||||
PlaylistEntry {
|
||||
id: SrcIdOwned("1".to_owned(), MusicService::Tiraya),
|
||||
dt: TESTDATE,
|
||||
},
|
||||
PlaylistEntry {
|
||||
id: SrcIdOwned("2".to_owned(), MusicService::Tiraya),
|
||||
dt: TESTDATE,
|
||||
},
|
||||
];
|
||||
let mut entries = test_entries(&[1, 2]);
|
||||
let del = ChangeOperation::Del { pos: 1, n: 2 };
|
||||
let err = del.apply(&mut entries, TESTDATE).unwrap_err();
|
||||
assert!(matches!(err, DatabaseError::PlaylistVcs(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_mov_out_of_range1() {
|
||||
let mut entries = test_entries(&[1, 2, 3, 4]);
|
||||
let mov = ChangeOperation::Mov {
|
||||
pos: 0,
|
||||
n: 2,
|
||||
to: 5,
|
||||
};
|
||||
let err = mov.apply(&mut entries, TESTDATE).unwrap_err();
|
||||
assert!(matches!(err, DatabaseError::PlaylistVcs(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_mov_out_of_range2() {
|
||||
let mut entries = test_entries(&[1, 2, 3, 4]);
|
||||
let mov = ChangeOperation::Mov {
|
||||
pos: 3,
|
||||
n: 2,
|
||||
to: 0,
|
||||
};
|
||||
let err = mov.apply(&mut entries, TESTDATE).unwrap_err();
|
||||
assert!(matches!(err, DatabaseError::PlaylistVcs(_)));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
use std::{borrow::Cow, fmt::Write};
|
||||
use std::fmt::Write;
|
||||
|
||||
use serde::{de::Visitor, Deserialize, Serialize};
|
||||
use sqlx::types::Json;
|
||||
use sqlx::{types::Json, Row};
|
||||
|
||||
mod album;
|
||||
mod artist;
|
||||
mod change_operation;
|
||||
mod enums;
|
||||
mod playlist;
|
||||
mod playlist_change;
|
||||
mod track;
|
||||
mod types;
|
||||
|
||||
pub use album::{Album, AlbumId, AlbumNew, AlbumSlim, AlbumUpdate};
|
||||
pub use artist::{Artist, ArtistId, ArtistNew, ArtistSlim, ArtistUpdate};
|
||||
pub use enums::{
|
||||
AlbumType, DatePrecision, MusicService, PlaylistImgType, PlaylistType, SyncData, SyncError,
|
||||
};
|
||||
pub use playlist::{
|
||||
Playlist, PlaylistEntry, PlaylistEntryTrack, PlaylistNew, PlaylistSlim, PlaylistUpdate,
|
||||
};
|
||||
pub use track::{Track, TrackNew, TrackSlim, TrackUpdate};
|
||||
pub use types::{
|
||||
AlbumType, DatePrecision, MusicService, PlaylistImgType, PlaylistType, SyncData, SyncError,
|
||||
};
|
||||
pub use track::{Track, TrackNew, TrackSlim, TrackTiny, TrackUpdate};
|
||||
|
||||
pub(crate) use change_operation::ChangeOperation;
|
||||
pub(crate) use playlist_change::{PlaylistChange, PlaylistChangeNew};
|
||||
|
@ -27,22 +27,6 @@ pub(crate) use track::TrackSlimRow;
|
|||
|
||||
use artist::ArtistJsonb;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum DatabaseError {
|
||||
#[error("Error while interacting with the database: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("Item {0} not found")]
|
||||
NotFound(IdOwned),
|
||||
#[error("Conflict while inserting {typ}: ID {id}")]
|
||||
Conflict { typ: &'static str, id: String },
|
||||
#[error("Playlist VCS error: {0}")]
|
||||
PlaylistVcs(Cow<'static, str>),
|
||||
#[error("DB error: {0}")]
|
||||
Other(Cow<'static, str>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
pub enum Id<'a> {
|
||||
Db(i32),
|
||||
|
@ -196,6 +180,12 @@ impl<'de> Deserialize<'de> for SrcIdOwned {
|
|||
}
|
||||
}
|
||||
|
||||
impl sqlx::FromRow<'_, sqlx::postgres::PgRow> for SrcIdOwned {
|
||||
fn from_row(row: &sqlx::postgres::PgRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self(row.try_get("src_id")?, row.try_get("service")?))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> PartialEq<IdOwned> for Id<'a> {
|
||||
fn eq(&self, other: &IdOwned) -> bool {
|
||||
match self {
|
||||
|
@ -229,20 +219,6 @@ impl<'a> PartialEq<SrcId<'a>> for SrcIdOwned {
|
|||
}
|
||||
}
|
||||
|
||||
pub trait OptionalRes<T> {
|
||||
fn to_optional(self) -> Result<Option<T>, DatabaseError>;
|
||||
}
|
||||
|
||||
impl<T> OptionalRes<T> for Result<T, DatabaseError> {
|
||||
fn to_optional(self) -> Result<Option<T>, DatabaseError> {
|
||||
match self {
|
||||
Ok(res) => Ok(Some(res)),
|
||||
Err(DatabaseError::NotFound(_)) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_artists(
|
||||
jsonb: Option<Json<Vec<ArtistJsonb>>>,
|
||||
ul: Option<Vec<String>>,
|
||||
|
|
|
@ -4,11 +4,13 @@ use sqlx::{types::Json, FromRow, QueryBuilder};
|
|||
use time::PrimitiveDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::util::DB_CONCURRENCY;
|
||||
|
||||
use super::{
|
||||
ChangeOperation, DatabaseError, Id, MusicService, OptionalRes, PlaylistChange,
|
||||
PlaylistChangeNew, PlaylistImgType, PlaylistType, SrcId, SrcIdOwned, SyncData, TrackSlim,
|
||||
ChangeOperation, Id, MusicService, PlaylistChange, PlaylistChangeNew, PlaylistImgType,
|
||||
PlaylistType, SrcId, SrcIdOwned, SyncData, TrackSlim,
|
||||
};
|
||||
use crate::{
|
||||
errors::{DatabaseError, OptionalRes},
|
||||
util::DB_CONCURRENCY,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
|
@ -22,7 +24,7 @@ pub struct Playlist {
|
|||
pub owner_url: Option<String>,
|
||||
pub playlist_type: PlaylistType,
|
||||
pub image_url: Option<String>,
|
||||
pub image_hash: Option<String>,
|
||||
pub image_hash: Option<Vec<u8>>,
|
||||
pub image_type: Option<PlaylistImgType>,
|
||||
pub created_at: PrimitiveDateTime,
|
||||
pub updated_at: PrimitiveDateTime,
|
||||
|
@ -32,29 +34,29 @@ pub struct Playlist {
|
|||
|
||||
/// Data for creating a playlist
|
||||
#[derive(Default)]
|
||||
pub struct PlaylistNew {
|
||||
pub src_id: Option<String>,
|
||||
pub struct PlaylistNew<'a> {
|
||||
pub src_id: Option<&'a str>,
|
||||
pub service: Option<MusicService>,
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub owner_name: Option<String>,
|
||||
pub owner_url: Option<String>,
|
||||
pub name: Option<&'a str>,
|
||||
pub description: Option<&'a str>,
|
||||
pub owner_name: Option<&'a str>,
|
||||
pub owner_url: Option<&'a str>,
|
||||
pub playlist_type: PlaylistType,
|
||||
pub image_url: Option<String>,
|
||||
pub image_hash: Option<String>,
|
||||
pub image_url: Option<&'a str>,
|
||||
pub image_hash: Option<&'a [u8]>,
|
||||
pub image_type: Option<PlaylistImgType>,
|
||||
}
|
||||
|
||||
/// Data for updating a playlist
|
||||
#[derive(Default)]
|
||||
pub struct PlaylistUpdate {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<Option<String>>,
|
||||
pub owner_name: Option<Option<String>>,
|
||||
pub owner_url: Option<Option<String>>,
|
||||
pub struct PlaylistUpdate<'a> {
|
||||
pub name: Option<&'a str>,
|
||||
pub description: Option<Option<&'a str>>,
|
||||
pub owner_name: Option<Option<&'a str>>,
|
||||
pub owner_url: Option<Option<&'a str>>,
|
||||
pub playlist_type: Option<PlaylistType>,
|
||||
pub image_url: Option<Option<String>>,
|
||||
pub image_hash: Option<Option<String>>,
|
||||
pub image_url: Option<Option<&'a str>>,
|
||||
pub image_hash: Option<Option<&'a [u8]>>,
|
||||
pub image_type: Option<Option<PlaylistImgType>>,
|
||||
}
|
||||
|
||||
|
@ -65,7 +67,7 @@ pub struct PlaylistSlim {
|
|||
pub service: MusicService,
|
||||
pub name: String,
|
||||
pub image_url: Option<String>,
|
||||
pub image_hash: Option<String>,
|
||||
pub image_hash: Option<Vec<u8>>,
|
||||
pub owner_name: Option<String>,
|
||||
pub owner_url: Option<String>,
|
||||
}
|
||||
|
@ -76,7 +78,7 @@ pub struct PlaylistSlimRow {
|
|||
pub service: Option<MusicService>,
|
||||
pub name: Option<String>,
|
||||
pub image_url: Option<String>,
|
||||
pub image_hash: Option<String>,
|
||||
pub image_hash: Option<Vec<u8>>,
|
||||
pub owner_name: Option<String>,
|
||||
pub owner_url: Option<String>,
|
||||
}
|
||||
|
@ -467,7 +469,7 @@ order by pc.created_at"#,
|
|||
}
|
||||
}
|
||||
|
||||
impl PlaylistNew {
|
||||
impl PlaylistNew<'_> {
|
||||
pub async fn insert<'a, E>(&self, exec: E) -> Result<i32, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
|
@ -528,7 +530,7 @@ returning id"#,
|
|||
}
|
||||
}
|
||||
|
||||
impl PlaylistUpdate {
|
||||
impl PlaylistUpdate<'_> {
|
||||
pub async fn update<'a, E>(&self, id: Id<'_>, exec: E) -> Result<(), DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
|
@ -687,21 +689,23 @@ impl TryFrom<PlaylistSlimRow> for PlaylistSlim {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use hex_literal::hex;
|
||||
|
||||
use super::*;
|
||||
use crate::testutil::{self, ids};
|
||||
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn crud() {
|
||||
let playlist = PlaylistNew {
|
||||
src_id: Some("RDCLAK5uy_m5BMYwuJbcooMFbKC821i2yIljq-MC-fk".to_owned()),
|
||||
src_id: Some("RDCLAK5uy_m5BMYwuJbcooMFbKC821i2yIljq-MC-fk"),
|
||||
service: Some(MusicService::YouTube),
|
||||
name: Some("Party Time".to_owned()),
|
||||
description: Some("Die besten Party-Songs um so richtig loszulassen.".to_owned()),
|
||||
owner_name: Some("YouTube Music".to_owned()),
|
||||
owner_url: Some("https://music.youtube.com".to_owned()),
|
||||
name: Some("Party Time"),
|
||||
description: Some("Die besten Party-Songs um so richtig loszulassen."),
|
||||
owner_name: Some("YouTube Music"),
|
||||
owner_url: Some("https://music.youtube.com"),
|
||||
playlist_type: PlaylistType::Remote,
|
||||
image_url: Some("https://lh3.googleusercontent.com/BKUEX9tt7IqIHPynzKFyq7-xz0C-9xh2L6SsbZbhUQ2hD8VHbR3QYIAg3cv333H8bkKLaLjeUcJAHw=w544-h544-l90-rj".to_owned()),
|
||||
image_hash: Some("37670c46aee28f6f282ea51eecec7e6399bb27ecd50a4f3e172b4433a7db8275".to_owned()),
|
||||
image_url: Some("https://lh3.googleusercontent.com/BKUEX9tt7IqIHPynzKFyq7-xz0C-9xh2L6SsbZbhUQ2hD8VHbR3QYIAg3cv333H8bkKLaLjeUcJAHw=w544-h544-l90-rj"),
|
||||
image_hash: Some(&hex!("37670c46aee28f6f282ea51eecec7e6399bb27ecd50a4f3e172b4433a7db8275")),
|
||||
image_type: Some(PlaylistImgType::Custom),
|
||||
};
|
||||
|
||||
|
@ -717,10 +721,7 @@ mod tests {
|
|||
".updated_at" => "[date]",
|
||||
});
|
||||
|
||||
let srcid = SrcId(
|
||||
playlist.src_id.as_deref().unwrap(),
|
||||
playlist.service.unwrap(),
|
||||
);
|
||||
let srcid = SrcId(playlist.src_id.unwrap(), playlist.service.unwrap());
|
||||
assert_eq!(
|
||||
Playlist::get_src_id(new_id, &pool)
|
||||
.await
|
||||
|
@ -739,7 +740,7 @@ mod tests {
|
|||
|
||||
// Update
|
||||
let clear = PlaylistUpdate {
|
||||
name: Some("empty".to_owned()),
|
||||
name: Some("empty"),
|
||||
description: Some(None),
|
||||
owner_name: Some(None),
|
||||
owner_url: Some(None),
|
||||
|
@ -825,6 +826,11 @@ mod tests {
|
|||
async fn add_change() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
// Populate the cache
|
||||
Playlist::get_entries(ids::PLAYLIST_ID_TEST1, &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut tx = pool.begin().await.unwrap();
|
||||
Playlist::add_change(
|
||||
Some(ids::PLAYLIST_CHANGE_HEAD),
|
||||
|
@ -902,7 +908,8 @@ mod tests {
|
|||
}
|
||||
|
||||
/// Add a change to the playlist and run the merge operation concurrently to make
|
||||
/// sure it does not create any ghost revisions.
|
||||
/// sure the system works if multiple users request a playlist at the same time.
|
||||
/// There should be no ghost revisions created.
|
||||
#[sqlx_database_tester::test(pool(variable = "pool"))]
|
||||
async fn merge_concurrent() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
|
|
@ -6,8 +6,11 @@ use sqlx::types::Json;
|
|||
use time::PrimitiveDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{playlist::PlaylistEntry, ChangeOperation, DatabaseError};
|
||||
use crate::util::{self, MergeIds};
|
||||
use super::{playlist::PlaylistEntry, ChangeOperation};
|
||||
use crate::{
|
||||
errors::DatabaseError,
|
||||
util::{self, MergeIds},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
|
||||
pub struct PlaylistChange {
|
||||
|
|
|
@ -26,7 +26,40 @@ Album(
|
|||
album_type: Some(album),
|
||||
by_va: false,
|
||||
image_url: Some("https://lh3.googleusercontent.com/Z5CF2JCRD5o7fBywh9Spg_Wvmrqkg0M01FWsSm_mdmUSfplv--9NgIiBRExudt7s0TTd3tgpJ7CLRFal=w544-h544-l90-rj"),
|
||||
image_hash: Some("aeafabf677bb186378a539a197cc087e2a94c33bc5c3ee41c2e6513fd79442a3"),
|
||||
image_hash: Some([
|
||||
174,
|
||||
175,
|
||||
171,
|
||||
246,
|
||||
119,
|
||||
187,
|
||||
24,
|
||||
99,
|
||||
120,
|
||||
165,
|
||||
57,
|
||||
161,
|
||||
151,
|
||||
204,
|
||||
8,
|
||||
126,
|
||||
42,
|
||||
148,
|
||||
195,
|
||||
59,
|
||||
197,
|
||||
195,
|
||||
238,
|
||||
65,
|
||||
194,
|
||||
230,
|
||||
81,
|
||||
63,
|
||||
215,
|
||||
148,
|
||||
66,
|
||||
163,
|
||||
]),
|
||||
created_at: "[date]",
|
||||
updated_at: "[date]",
|
||||
hidden: false,
|
||||
|
|
|
@ -9,9 +9,75 @@ Artist(
|
|||
name: "Oonagh",
|
||||
description: Some("Senta-Sofia Delliponti is a German singer, songwriter and actress. Since January 2014, she used the stage name Oonagh, until she changed it to Senta in 2022. Her signature musical style is inspired by the mystical lore of J. R. R. Tolkien\'s universe and by ethnic sounds throughout the world."),
|
||||
image_url: Some("https://lh3.googleusercontent.com/eMMHFaIWg8G3LL3B-8EAew8vhAP2G2aUIDfn4I1JHpS8WxmnO0Yof-vOSEyUSp4y3lCl-q6MIbugbw=w500-h500-p-l90-rj"),
|
||||
image_hash: Some("b41f4a1f7a23ba0b7304f1e660af32125de7c77e59d5fa2b2b5ba9f796f1d8a2"),
|
||||
image_hash: Some([
|
||||
180,
|
||||
31,
|
||||
74,
|
||||
31,
|
||||
122,
|
||||
35,
|
||||
186,
|
||||
11,
|
||||
115,
|
||||
4,
|
||||
241,
|
||||
230,
|
||||
96,
|
||||
175,
|
||||
50,
|
||||
18,
|
||||
93,
|
||||
231,
|
||||
199,
|
||||
126,
|
||||
89,
|
||||
213,
|
||||
250,
|
||||
43,
|
||||
43,
|
||||
91,
|
||||
169,
|
||||
247,
|
||||
150,
|
||||
241,
|
||||
216,
|
||||
162,
|
||||
]),
|
||||
header_image_url: Some("https://lh3.googleusercontent.com/eMMHFaIWg8G3LL3B-8EAew8vhAP2G2aUIDfn4I1JHpS8WxmnO0Yof-vOSEyUSp4y3lCl-q6MIbugbw=w1920-h800-p-l90-rj"),
|
||||
header_image_hash: Some("5b476147305423e1c8e18bc56ed07f1caf59bd6782b97ba4bbc3d9f96643c342"),
|
||||
header_image_hash: Some([
|
||||
91,
|
||||
71,
|
||||
97,
|
||||
71,
|
||||
48,
|
||||
84,
|
||||
35,
|
||||
225,
|
||||
200,
|
||||
225,
|
||||
139,
|
||||
197,
|
||||
110,
|
||||
208,
|
||||
127,
|
||||
28,
|
||||
175,
|
||||
89,
|
||||
189,
|
||||
103,
|
||||
130,
|
||||
185,
|
||||
123,
|
||||
164,
|
||||
187,
|
||||
195,
|
||||
217,
|
||||
249,
|
||||
102,
|
||||
67,
|
||||
195,
|
||||
66,
|
||||
]),
|
||||
subscribers: Some(36400),
|
||||
wikipedia_url: Some("https://en.wikipedia.org/wiki/Oonagh_(singer)"),
|
||||
created_at: "[date]",
|
||||
|
|
|
@ -7,5 +7,38 @@ ArtistSlim(
|
|||
service: yt,
|
||||
name: "Oonagh",
|
||||
image_url: Some("https://lh3.googleusercontent.com/eMMHFaIWg8G3LL3B-8EAew8vhAP2G2aUIDfn4I1JHpS8WxmnO0Yof-vOSEyUSp4y3lCl-q6MIbugbw=w500-h500-p-l90-rj"),
|
||||
image_hash: Some("b41f4a1f7a23ba0b7304f1e660af32125de7c77e59d5fa2b2b5ba9f796f1d8a2"),
|
||||
image_hash: Some([
|
||||
180,
|
||||
31,
|
||||
74,
|
||||
31,
|
||||
122,
|
||||
35,
|
||||
186,
|
||||
11,
|
||||
115,
|
||||
4,
|
||||
241,
|
||||
230,
|
||||
96,
|
||||
175,
|
||||
50,
|
||||
18,
|
||||
93,
|
||||
231,
|
||||
199,
|
||||
126,
|
||||
89,
|
||||
213,
|
||||
250,
|
||||
43,
|
||||
43,
|
||||
91,
|
||||
169,
|
||||
247,
|
||||
150,
|
||||
241,
|
||||
216,
|
||||
162,
|
||||
]),
|
||||
)
|
||||
|
|
|
@ -12,7 +12,40 @@ Playlist(
|
|||
owner_url: Some("https://music.youtube.com"),
|
||||
playlist_type: remote,
|
||||
image_url: Some("https://lh3.googleusercontent.com/BKUEX9tt7IqIHPynzKFyq7-xz0C-9xh2L6SsbZbhUQ2hD8VHbR3QYIAg3cv333H8bkKLaLjeUcJAHw=w544-h544-l90-rj"),
|
||||
image_hash: Some("37670c46aee28f6f282ea51eecec7e6399bb27ecd50a4f3e172b4433a7db8275"),
|
||||
image_hash: Some([
|
||||
55,
|
||||
103,
|
||||
12,
|
||||
70,
|
||||
174,
|
||||
226,
|
||||
143,
|
||||
111,
|
||||
40,
|
||||
46,
|
||||
165,
|
||||
30,
|
||||
236,
|
||||
236,
|
||||
126,
|
||||
99,
|
||||
153,
|
||||
187,
|
||||
39,
|
||||
236,
|
||||
213,
|
||||
10,
|
||||
79,
|
||||
62,
|
||||
23,
|
||||
43,
|
||||
68,
|
||||
51,
|
||||
167,
|
||||
219,
|
||||
130,
|
||||
117,
|
||||
]),
|
||||
image_type: Some(custom),
|
||||
created_at: "[date]",
|
||||
updated_at: "[date]",
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
---
|
||||
source: crates/db/src/models/playlist.rs
|
||||
assertion_line: 463
|
||||
expression: slim
|
||||
---
|
||||
PlaylistSlim(
|
||||
|
@ -8,7 +7,40 @@ PlaylistSlim(
|
|||
service: yt,
|
||||
name: "Party Time",
|
||||
image_url: Some("https://lh3.googleusercontent.com/BKUEX9tt7IqIHPynzKFyq7-xz0C-9xh2L6SsbZbhUQ2hD8VHbR3QYIAg3cv333H8bkKLaLjeUcJAHw=w544-h544-l90-rj"),
|
||||
image_hash: Some("37670c46aee28f6f282ea51eecec7e6399bb27ecd50a4f3e172b4433a7db8275"),
|
||||
image_hash: Some([
|
||||
55,
|
||||
103,
|
||||
12,
|
||||
70,
|
||||
174,
|
||||
226,
|
||||
143,
|
||||
111,
|
||||
40,
|
||||
46,
|
||||
165,
|
||||
30,
|
||||
236,
|
||||
236,
|
||||
126,
|
||||
99,
|
||||
153,
|
||||
187,
|
||||
39,
|
||||
236,
|
||||
213,
|
||||
10,
|
||||
79,
|
||||
62,
|
||||
23,
|
||||
43,
|
||||
68,
|
||||
51,
|
||||
167,
|
||||
219,
|
||||
130,
|
||||
117,
|
||||
]),
|
||||
owner_name: Some("YouTube Music"),
|
||||
owner_url: Some("https://music.youtube.com"),
|
||||
)
|
||||
|
|
|
@ -17,6 +17,7 @@ Track(
|
|||
isrc: None,
|
||||
created_at: "[date]",
|
||||
updated_at: "[date]",
|
||||
primary_track: None,
|
||||
downloaded_at: None,
|
||||
last_streamed_at: None,
|
||||
n_streams: 0,
|
||||
|
|
|
@ -30,6 +30,7 @@ Track(
|
|||
isrc: Some("DEUM71602459"),
|
||||
created_at: "[date]",
|
||||
updated_at: "[date]",
|
||||
primary_track: Some(true),
|
||||
downloaded_at: None,
|
||||
last_streamed_at: None,
|
||||
n_streams: 0,
|
||||
|
|
|
@ -3,13 +3,14 @@ use serde::Serialize;
|
|||
use sqlx::{types::Json, FromRow, QueryBuilder};
|
||||
use time::{Date, PrimitiveDateTime};
|
||||
|
||||
use crate::util::DB_CONCURRENCY;
|
||||
|
||||
use super::{
|
||||
album::AlbumId,
|
||||
artist::{ArtistId, ArtistJsonb},
|
||||
map_artists, AlbumType, Artist, DatabaseError, Id, MusicService, OptionalRes, SrcId,
|
||||
SrcIdOwned,
|
||||
map_artists, AlbumType, Artist, Id, MusicService, SrcId, SrcIdOwned,
|
||||
};
|
||||
use crate::{
|
||||
errors::{DatabaseError, OptionalRes},
|
||||
util::DB_CONCURRENCY,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
@ -28,6 +29,7 @@ pub struct Track {
|
|||
pub isrc: Option<String>,
|
||||
pub created_at: PrimitiveDateTime,
|
||||
pub updated_at: PrimitiveDateTime,
|
||||
pub primary_track: Option<bool>,
|
||||
pub downloaded_at: Option<PrimitiveDateTime>,
|
||||
pub last_streamed_at: Option<PrimitiveDateTime>,
|
||||
pub n_streams: i32,
|
||||
|
@ -50,38 +52,41 @@ struct TrackRow {
|
|||
isrc: Option<String>,
|
||||
created_at: PrimitiveDateTime,
|
||||
updated_at: PrimitiveDateTime,
|
||||
primary_track: Option<bool>,
|
||||
downloaded_at: Option<PrimitiveDateTime>,
|
||||
last_streamed_at: Option<PrimitiveDateTime>,
|
||||
n_streams: i32,
|
||||
}
|
||||
|
||||
/// Data for creating a track
|
||||
pub struct TrackNew {
|
||||
pub src_id: String,
|
||||
pub struct TrackNew<'a> {
|
||||
pub src_id: &'a str,
|
||||
pub service: MusicService,
|
||||
pub name: String,
|
||||
pub name: &'a str,
|
||||
pub duration: Option<i32>,
|
||||
pub duration_ms: bool,
|
||||
pub size: Option<i64>,
|
||||
pub loudness: Option<f32>,
|
||||
pub album_id: i32,
|
||||
pub album_pos: Option<i16>,
|
||||
pub ul_artists: Option<Vec<String>>,
|
||||
pub isrc: Option<String>,
|
||||
pub ul_artists: Option<&'a [String]>,
|
||||
pub isrc: Option<&'a str>,
|
||||
pub primary_track: Option<bool>,
|
||||
}
|
||||
|
||||
/// Data for updating a track
|
||||
#[derive(Default)]
|
||||
pub struct TrackUpdate {
|
||||
pub name: Option<String>,
|
||||
pub struct TrackUpdate<'a> {
|
||||
pub name: Option<&'a str>,
|
||||
pub duration: Option<Option<i32>>,
|
||||
pub duration_ms: Option<bool>,
|
||||
pub size: Option<Option<i64>>,
|
||||
pub loudness: Option<Option<f32>>,
|
||||
pub album_id: Option<i32>,
|
||||
pub album_pos: Option<Option<i16>>,
|
||||
pub ul_artists: Option<Vec<String>>,
|
||||
pub isrc: Option<Option<String>>,
|
||||
pub ul_artists: Option<&'a [String]>,
|
||||
pub isrc: Option<Option<&'a str>>,
|
||||
pub primary_track: Option<Option<bool>>,
|
||||
pub downloaded_at: Option<Option<PrimitiveDateTime>>,
|
||||
pub last_streamed_at: Option<Option<PrimitiveDateTime>>,
|
||||
pub n_streams: Option<i32>,
|
||||
|
@ -114,11 +119,21 @@ pub struct TrackSlimRow {
|
|||
pub album_service: MusicService,
|
||||
pub album_name: String,
|
||||
pub image_url: Option<String>,
|
||||
pub image_hash: Option<String>,
|
||||
pub image_hash: Option<Vec<u8>>,
|
||||
pub release_date: Option<Date>,
|
||||
pub album_type: AlbumType,
|
||||
}
|
||||
|
||||
/// Tiny track model used for internal queries
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct TrackTiny {
|
||||
pub id: i32,
|
||||
pub src_id: String,
|
||||
pub service: MusicService,
|
||||
pub name: String,
|
||||
pub duration: Option<i32>,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
pub fn id(&self) -> Id {
|
||||
Id::Db(self.id)
|
||||
|
@ -134,7 +149,7 @@ impl Track {
|
|||
TrackRow,
|
||||
r#"select t.id, t.src_id, t.service as "service: _", t.name, t.duration, t.duration_ms,
|
||||
t.size, t.loudness, t.album_id, t.album_pos, t.ul_artists, t.isrc, t.created_at, t.updated_at,
|
||||
t.downloaded_at, t.last_streamed_at, t.n_streams,
|
||||
t.primary_track, t.downloaded_at, t.last_streamed_at, t.n_streams,
|
||||
jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by art.seq)
|
||||
filter (where a.src_id is not null) as "artists: _"
|
||||
from tracks t
|
||||
|
@ -152,7 +167,7 @@ group by t.id"#,
|
|||
TrackRow,
|
||||
r#"select t.id, t.src_id, t.service as "service: _", t.name, t.duration, t.duration_ms,
|
||||
t.size, t.loudness, t.album_id, t.album_pos, t.ul_artists, t.isrc, t.created_at, t.updated_at,
|
||||
t.downloaded_at, t.last_streamed_at, t.n_streams,
|
||||
t.primary_track, t.downloaded_at, t.last_streamed_at, t.n_streams,
|
||||
jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by art.seq)
|
||||
filter (where a.src_id is not null) as "artists: _"
|
||||
from tracks t
|
||||
|
@ -174,7 +189,7 @@ group by t.id"#,
|
|||
TrackRow,
|
||||
r#"select t.id, t.src_id, t.service as "service: _", t.name, t.duration, t.duration_ms,
|
||||
t.size, t.loudness, t.album_id, t.album_pos, t.ul_artists, t.isrc, t.created_at, t.updated_at,
|
||||
t.downloaded_at, t.last_streamed_at, t.n_streams,
|
||||
t.primary_track, t.downloaded_at, t.last_streamed_at, t.n_streams,
|
||||
jsonb_agg(json_build_object('id', a.src_id, 'sv', a.service, 'n', a.name) order by art.seq)
|
||||
filter (where a.src_id is not null) as "artists: _"
|
||||
from tracks t
|
||||
|
@ -329,15 +344,15 @@ on conflict (src_id, service) do update set track_id=excluded.track_id"#,
|
|||
}
|
||||
}
|
||||
|
||||
impl TrackNew {
|
||||
impl TrackNew<'_> {
|
||||
pub async fn insert<'a, E>(&self, exec: E) -> Result<i32, DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let res = sqlx::query!(
|
||||
r#"insert into tracks (src_id, service, name, duration, duration_ms,
|
||||
size, loudness, album_id, album_pos, ul_artists, isrc)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
size, loudness, album_id, album_pos, ul_artists, isrc, primary_track)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
returning id"#,
|
||||
self.src_id,
|
||||
self.service as MusicService,
|
||||
|
@ -350,6 +365,7 @@ returning id"#,
|
|||
self.album_pos,
|
||||
self.ul_artists.as_deref(),
|
||||
self.isrc,
|
||||
self.primary_track,
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
|
@ -362,8 +378,8 @@ returning id"#,
|
|||
{
|
||||
let res = sqlx::query!(
|
||||
r#"insert into tracks (src_id, service, name, duration, duration_ms,
|
||||
size, loudness, album_id, album_pos, ul_artists, isrc)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
size, loudness, album_id, album_pos, ul_artists, isrc, primary_track)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
on conflict (src_id, service) do update set
|
||||
name = excluded.name,
|
||||
duration = coalesce(excluded.duration, tracks.duration),
|
||||
|
@ -373,7 +389,8 @@ on conflict (src_id, service) do update set
|
|||
album_id = excluded.album_id,
|
||||
album_pos = coalesce(excluded.album_pos, tracks.album_pos),
|
||||
ul_artists = coalesce(excluded.ul_artists, tracks.ul_artists),
|
||||
isrc = coalesce(excluded.isrc, tracks.isrc)
|
||||
isrc = coalesce(excluded.isrc, tracks.isrc),
|
||||
primary_track = coalesce(excluded.primary_track, tracks.primary_track)
|
||||
returning id"#,
|
||||
self.src_id,
|
||||
self.service as MusicService,
|
||||
|
@ -386,6 +403,7 @@ returning id"#,
|
|||
self.album_pos,
|
||||
self.ul_artists.as_deref(),
|
||||
self.isrc,
|
||||
self.primary_track,
|
||||
)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
|
@ -393,7 +411,7 @@ returning id"#,
|
|||
}
|
||||
}
|
||||
|
||||
impl TrackUpdate {
|
||||
impl TrackUpdate<'_> {
|
||||
pub async fn update<'a, E>(&self, id: Id<'_>, exec: E) -> Result<(), DatabaseError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
|
@ -470,6 +488,14 @@ impl TrackUpdate {
|
|||
query.push_bind(isrc);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(primary_track) = &self.primary_track {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("primary_track=");
|
||||
query.push_bind(primary_track);
|
||||
n += 1;
|
||||
}
|
||||
if let Some(downloaded_at) = &self.downloaded_at {
|
||||
if n != 0 {
|
||||
query.push(", ");
|
||||
|
@ -596,6 +622,7 @@ impl From<TrackRow> for Track {
|
|||
isrc: value.isrc,
|
||||
created_at: value.created_at,
|
||||
updated_at: value.updated_at,
|
||||
primary_track: value.primary_track,
|
||||
downloaded_at: value.downloaded_at,
|
||||
last_streamed_at: value.last_streamed_at,
|
||||
n_streams: value.n_streams,
|
||||
|
@ -635,18 +662,20 @@ mod tests {
|
|||
async fn crud() {
|
||||
testutil::run_sql("base.sql", &pool).await;
|
||||
|
||||
let ul_artists = ["Other artist".to_owned()];
|
||||
let track = TrackNew {
|
||||
src_id: "g0iRiJ_ck48".to_owned(),
|
||||
src_id: "g0iRiJ_ck48",
|
||||
service: MusicService::YouTube,
|
||||
name: "Aulë und Yavanna".to_owned(),
|
||||
name: "Aulë und Yavanna",
|
||||
duration: Some(216000),
|
||||
duration_ms: false,
|
||||
size: Some(3_439_414),
|
||||
loudness: Some(6.1513805),
|
||||
album_id: 1,
|
||||
album_pos: Some(1),
|
||||
ul_artists: Some(vec!["Other artist".to_owned()]),
|
||||
isrc: Some("DEUM71602459".to_owned()),
|
||||
ul_artists: Some(&ul_artists),
|
||||
isrc: Some("DEUM71602459"),
|
||||
primary_track: Some(true),
|
||||
};
|
||||
let track_artists = [ids::ARTIST_LEA, ids::ARTIST_CYRIL];
|
||||
|
||||
|
@ -668,7 +697,7 @@ mod tests {
|
|||
".updated_at" => "[date]",
|
||||
});
|
||||
|
||||
let srcid = SrcId(&track.src_id, track.service);
|
||||
let srcid = SrcId(track.src_id, track.service);
|
||||
assert_eq!(
|
||||
Track::get_src_id(new_id, &pool)
|
||||
.await
|
||||
|
@ -685,15 +714,16 @@ mod tests {
|
|||
// Update
|
||||
let mut u_tx = pool.begin().await.unwrap();
|
||||
let clear = TrackUpdate {
|
||||
name: Some("empty".to_owned()),
|
||||
name: Some("empty"),
|
||||
duration: Some(None),
|
||||
duration_ms: Some(false),
|
||||
size: Some(None),
|
||||
loudness: Some(None),
|
||||
album_id: None,
|
||||
album_pos: Some(None),
|
||||
ul_artists: Some(vec![]),
|
||||
ul_artists: Some(&[]),
|
||||
isrc: Some(None),
|
||||
primary_track: Some(None),
|
||||
downloaded_at: Some(None),
|
||||
last_streamed_at: Some(None),
|
||||
n_streams: Some(0),
|
||||
|
|
|
@ -34,6 +34,14 @@ pub mod ids {
|
|||
pub const TRACK_SRC_VAKUUM: SrcId = SrcId("LeEgBsYfjLU", MusicService::YouTube);
|
||||
pub const TRACK_VAKUUM: Id = Id::Db(TRACK_ID_VAKUUM);
|
||||
|
||||
pub const TRACK_ID_IWWUS: i32 = 13;
|
||||
pub const TRACK_SRC_IWWUS: SrcId = SrcId("hWFarQmaQAQ", MusicService::YouTube);
|
||||
pub const TRACK_IWWUS: Id = Id::Db(TRACK_ID_IWWUS);
|
||||
|
||||
pub const TRACK_ID_BMAMBA: i32 = 15;
|
||||
pub const TRACK_SRC_BMAMBA: SrcId = SrcId("ZeerrnuLi5E", MusicService::YouTube);
|
||||
pub const TRACK_BMAMBA: Id = Id::Db(TRACK_ID_BMAMBA);
|
||||
|
||||
pub const PLAYLIST_ID_TEST1: i32 = 3;
|
||||
pub const PLAYLIST_SRC_TEST1: SrcId = SrcId("test1", MusicService::Tiraya);
|
||||
pub const PLAYLIST_TEST1: Id = Id::Db(PLAYLIST_ID_TEST1);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
use std::hash::Hasher;
|
||||
|
||||
use hex_literal::hex;
|
||||
use siphasher::sip128::{Hasher128, SipHasher};
|
||||
use time::{OffsetDateTime, PrimitiveDateTime};
|
||||
use uuid::Uuid;
|
||||
|
@ -19,9 +20,7 @@ pub struct MergeIds {
|
|||
pub merge: Uuid,
|
||||
}
|
||||
|
||||
const KEY_MERGE_IDS: [u8; 16] = [
|
||||
0x01, 0x4a, 0x29, 0x0a, 0x04, 0x64, 0x35, 0xe9, 0x54, 0xef, 0x88, 0x98, 0xcd, 0x83, 0x64, 0x95,
|
||||
];
|
||||
const KEY_MERGE_IDS: [u8; 16] = hex!("014a290a046435e954ef8898cd836495");
|
||||
|
||||
impl MergeIds {
|
||||
pub fn new(id1: Uuid, id2: Uuid) -> Self {
|
||||
|
|
6
crates/db/testdata/base.sql
vendored
6
crates/db/testdata/base.sql
vendored
|
@ -11,9 +11,9 @@ INSERT INTO artists (src_id,service,"name",description,image_url,image_hash,head
|
|||
('UCkQRXVZuBMktEdVyptoUgGg','yt','Mark Forster','Mark Ćwiertnia, known professionally as Mark Forster, is a German-Polish singer, songwriter and television personality.','https://lh3.googleusercontent.com/RIRL6gRqXnOOAXcSh2pXkRPyuCVzL5PcQvvLrJLDpz3L0XhHo9nlj2ewnwKHwNZ82jjgh9JXPcOvO9Y=w544-h544-p-l90-rj',NULL,'https://lh3.googleusercontent.com/RIRL6gRqXnOOAXcSh2pXkRPyuCVzL5PcQvvLrJLDpz3L0XhHo9nlj2ewnwKHwNZ82jjgh9JXPcOvO9Y=w1920-h800-p-l90-rj',NULL,855000,'https://en.wikipedia.org/wiki/Mark_Forster_(singer)','2023-08-30 22:05:55.350782','2023-08-30 22:54:27.446367',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
('UCA-uIWGyE0n9YvJ-titY8zA','yt','LOTTE',NULL,'https://lh3.googleusercontent.com/l_pq6m1w2cJiBfmK7Vqzv5rYJOrG_4IAaZFmQg4AogonohFXbTVfDTvXcc0h8IBc351ccbZ-QtwDnSU=w544-h544-p-l90-rj',NULL,'https://lh3.googleusercontent.com/l_pq6m1w2cJiBfmK7Vqzv5rYJOrG_4IAaZFmQg4AogonohFXbTVfDTvXcc0h8IBc351ccbZ-QtwDnSU=w1920-h800-p-l90-rj',NULL,82700,NULL,'2023-08-30 22:06:53.912354','2023-08-30 22:54:27.448493',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
('UCZEPIcm6KGYyqWUMaP2axSA','yt','Cyril aka Aaron Hilmer',NULL,'https://lh3.googleusercontent.com/qDM-gcxG06QCiMIzvpYKilu7dq5b6gCwpkphP6ADzOzOx05Yt03G6XWfNyFWBspm3WjeePjyJZxF9nxIqw=w544-h544-p-l90-rj',NULL,'https://lh3.googleusercontent.com/qDM-gcxG06QCiMIzvpYKilu7dq5b6gCwpkphP6ADzOzOx05Yt03G6XWfNyFWBspm3WjeePjyJZxF9nxIqw=w1425-h593-p-l90-rj',NULL,4380,NULL,'2023-08-30 22:53:50.360159','2023-08-30 22:53:50.360159',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
('UCEdZAdnnKqbaHOlv8nM6OtA','yt','aespa',NULL,'TODO',NULL,'TODO',NULL,4780000,NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
('UCQ6yypykkyPLM5FVhOm4Eog','yt','Dabin',NULL,'TODO',NULL,'TODO',NULL,63200,NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
('UCIh4j8fXWf2U0ro0qnGU8Mg','yt','Namika',NULL,'TODO',NULL,'TODO',NULL,753000,NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
('UCEdZAdnnKqbaHOlv8nM6OtA','yt','aespa',NULL,'https://lh3.googleusercontent.com/dqyq2mPpfF3r4ImT2RwUpHhbfdAQw4soRxqP0gH2eA9JxcMVl1lOjHEl_OjuEBqsCZx2fablL9tAwNg=w1920-h800-p-l90-rj',NULL,'https://lh3.googleusercontent.com/dqyq2mPpfF3r4ImT2RwUpHhbfdAQw4soRxqP0gH2eA9JxcMVl1lOjHEl_OjuEBqsCZx2fablL9tAwNg=w544-h544-p-l90-rj',NULL,4780000,NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
('UCQ6yypykkyPLM5FVhOm4Eog','yt','Dabin',NULL,'https://lh3.googleusercontent.com/-kSzXKMATDXim7Ed-MriZtvPp_tqODpOdHdkcehCHDY1qaTW4YVijrJG94dkNeTGsRcAMz7Q2T5NuoM=w1920-h800-p-l90-rj',NULL,'https://lh3.googleusercontent.com/-kSzXKMATDXim7Ed-MriZtvPp_tqODpOdHdkcehCHDY1qaTW4YVijrJG94dkNeTGsRcAMz7Q2T5NuoM=w544-h544-p-l90-rj',NULL,63200,NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
('UCIh4j8fXWf2U0ro0qnGU8Mg','yt','Namika',NULL,'https://lh3.googleusercontent.com/6xUcQPdo6dmReBndEDSy6OxozGfWyG6LO7tnB2Rg7Dfd8II11pRdUqcqRKyWUPilPAOnh2Or62bWYXM=w1920-h800-p-l90-rj',NULL,'https://lh3.googleusercontent.com/6xUcQPdo6dmReBndEDSy6OxozGfWyG6LO7tnB2Rg7Dfd8II11pRdUqcqRKyWUPilPAOnh2Or62bWYXM=w544-h544-p-l90-rj',NULL,753000,NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',NULL,NULL,NULL,NULL,NULL,NULL),
|
||||
('UC-2mb3G26qV676d-iXbOTVQ','yt','LINA',NULL,'TODO',NULL,'TODO',NULL,407000,NULL,'2023-09-09 22:53:50.360159','2023-09-09 22:53:50.360159',NULL,NULL,NULL,NULL,NULL,NULL);
|
||||
|
||||
INSERT INTO albums (src_id,service,"name",release_date,release_date_precision,"album_type",ul_artists,by_va,image_url,image_hash,created_at,updated_at,dirty,hidden) VALUES
|
||||
|
|
19
crates/extractor/Cargo.toml
Normal file
19
crates/extractor/Cargo.toml
Normal file
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "tiraya-extractor"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
description.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
rustypipe.workspace = true
|
||||
thiserror.workspace = true
|
||||
futures.workspace = true
|
||||
|
||||
tiraya-db.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
sqlx-database-tester.workspace = true
|
14
crates/extractor/src/lib.rs
Normal file
14
crates/extractor/src/lib.rs
Normal file
|
@ -0,0 +1,14 @@
|
|||
pub fn add(left: usize, right: usize) -> usize {
|
||||
left + right
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
let result = add(2, 2);
|
||||
assert_eq!(result, 4);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue