diff --git a/nghe-api/src/id3/album/mod.rs b/nghe-api/src/id3/album/mod.rs index 282243f85..a8ce937cc 100644 --- a/nghe-api/src/id3/album/mod.rs +++ b/nghe-api/src/id3/album/mod.rs @@ -3,6 +3,7 @@ mod full; use bon::Builder; pub use full::Full; use nghe_proc_macro::api_derive; +use time::OffsetDateTime; use uuid::Uuid; use super::{artist, date, genre}; @@ -17,6 +18,7 @@ pub struct Album { pub cover_art: Option, pub song_count: u16, pub duration: u32, + pub created: OffsetDateTime, pub year: Option, pub music_brainz_id: Option, #[builder(default)] diff --git a/nghe-api/src/id3/song/full.rs b/nghe-api/src/id3/song/full.rs index 05a42e6ae..2711e7afd 100644 --- a/nghe-api/src/id3/song/full.rs +++ b/nghe-api/src/id3/song/full.rs @@ -1,14 +1,11 @@ use nghe_proc_macro::api_derive; -use uuid::Uuid; -use super::Song; +use super::Short; use crate::id3::genre; #[api_derive] pub struct Full { #[serde(flatten)] - pub song: Song, - pub album: String, - pub album_id: Uuid, + pub short: Short, pub genres: genre::Genres, } diff --git a/nghe-api/src/id3/song/mod.rs b/nghe-api/src/id3/song/mod.rs index e6e423fd6..44a936b72 100644 --- a/nghe-api/src/id3/song/mod.rs +++ b/nghe-api/src/id3/song/mod.rs @@ -1,10 +1,13 @@ mod full; +mod short; use std::borrow::Cow; use bon::Builder; pub use full::Full; use nghe_proc_macro::api_derive; +pub use short::Short; +use time::OffsetDateTime; use uuid::Uuid; use super::artist; @@ -28,6 +31,7 @@ pub struct Song { pub sampling_rate: u32, pub channel_count: u8, pub disc_number: Option, + pub created: OffsetDateTime, pub artists: Vec, pub music_brainz_id: Option, } diff --git a/nghe-api/src/id3/song/short.rs b/nghe-api/src/id3/song/short.rs new file mode 100644 index 000000000..29e2853ee --- /dev/null +++ b/nghe-api/src/id3/song/short.rs @@ -0,0 +1,12 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +use super::Song; + +#[api_derive] +pub struct Short { + #[serde(flatten)] + pub song: Song, + pub album: String, + pub album_id: Uuid, +} diff --git a/nghe-api/src/lib.rs b/nghe-api/src/lib.rs index 6effd1888..8367e0ef7 100644 --- a/nghe-api/src/lib.rs +++ b/nghe-api/src/lib.rs @@ -11,6 +11,7 @@ pub mod media_annotation; pub mod media_retrieval; pub mod music_folder; pub mod permission; +pub mod playlists; pub mod scan; pub mod search; pub mod system; diff --git a/nghe-api/src/lists/get_album_list2.rs b/nghe-api/src/lists/get_album_list2.rs index 9409d41d4..3b96dbcd2 100644 --- a/nghe-api/src/lists/get_album_list2.rs +++ b/nghe-api/src/lists/get_album_list2.rs @@ -6,16 +6,7 @@ use crate::id3; // TODO: Optimize this after https://github.com/serde-rs/serde/issues/1183 #[serde_as] -#[api_derive(request = true, test_only = false)] -#[derive(Clone, Copy)] -pub struct ByYear { - #[serde_as(as = "serde_with::DisplayFromStr")] - pub from_year: u16, - #[serde_as(as = "serde_with::DisplayFromStr")] - pub to_year: u16, -} - -#[api_derive(request = true, copy = false)] +#[api_derive(copy = false)] #[serde(tag = "type")] #[cfg_attr(test, derive(Default))] pub enum Type { @@ -25,7 +16,12 @@ pub enum Type { Frequent, Recent, AlphabeticalByName, - ByYear(ByYear), + ByYear { + #[serde_as(as = "serde_with::DisplayFromStr")] + from_year: u16, + #[serde_as(as = "serde_with::DisplayFromStr")] + to_year: u16, + }, ByGenre { genre: String, }, @@ -75,17 +71,17 @@ mod tests { #[case( "type=byYear&fromYear=1000&toYear=2000", Some(Request { - ty: Type::ByYear ( - ByYear{ from_year: 1000, to_year: 2000 } - ), size: None, ..Default::default() + ty: Type::ByYear { + from_year: 1000, to_year: 2000 + }, size: None, ..Default::default() }) )] #[case( "type=byYear&fromYear=1000&toYear=2000&size=10", Some(Request { - ty: Type::ByYear ( - ByYear{ from_year: 1000, to_year: 2000 } - ), size: Some(10), ..Default::default() + ty: Type::ByYear { + from_year: 1000, to_year: 2000 + }, size: Some(10), ..Default::default() }) )] #[case( diff --git a/nghe-api/src/playlists/create_playlist.rs b/nghe-api/src/playlists/create_playlist.rs new file mode 100644 index 000000000..f91c75704 --- /dev/null +++ b/nghe-api/src/playlists/create_playlist.rs @@ -0,0 +1,94 @@ +use nghe_proc_macro::api_derive; +use serde_with::serde_as; +use uuid::Uuid; + +use super::playlist; + +// TODO: Optimize this after https://github.com/serde-rs/serde/issues/1183 +#[serde_as] +#[api_derive(request = true, copy = false)] +#[serde(untagged)] +pub enum CreateOrUpdate { + Create { + name: String, + }, + Update { + #[serde_as(as = "serde_with::DisplayFromStr")] + playlist_id: Uuid, + }, +} + +#[api_derive] +#[endpoint(path = "createPlaylist")] +pub struct Request { + #[serde(flatten)] + pub create_or_update: CreateOrUpdate, + #[serde(rename = "songId")] + pub song_ids: Option>, +} + +#[api_derive] +pub struct Response { + pub playlist: playlist::Full, +} + +#[cfg(any(test, feature = "test"))] +mod test { + use super::*; + + impl From for CreateOrUpdate { + fn from(value: String) -> Self { + Self::Create { name: value } + } + } + + impl From for CreateOrUpdate { + fn from(value: Uuid) -> Self { + Self::Update { playlist_id: value } + } + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use uuid::uuid; + + use super::*; + + #[rstest] + #[case( + "name=ef14c42b-6efa-45f3-961c-74856fd431d5", + Some(Request { + create_or_update: "ef14c42b-6efa-45f3-961c-74856fd431d5".to_owned().into(), + song_ids: None, + }) + )] + #[case( + "name=ef14c42b-6efa-45f3-961c-74856fd431d5&\ + songId=2b839103-04ab-4b39-9b05-8c664590eda4", + Some(Request { + create_or_update: "ef14c42b-6efa-45f3-961c-74856fd431d5".to_owned().into(), + song_ids: Some(vec![uuid!("2b839103-04ab-4b39-9b05-8c664590eda4")]), + }) + )] + #[case( + "playlistId=ef14c42b-6efa-45f3-961c-74856fd431d5", + Some(Request { + create_or_update: uuid!("ef14c42b-6efa-45f3-961c-74856fd431d5").into(), + song_ids: None, + }) + )] + #[case( + "playlistId=ef14c42b-6efa-45f3-961c-74856fd431d5&\ + songId=2b839103-04ab-4b39-9b05-8c664590eda4", + Some(Request { + create_or_update: uuid!("ef14c42b-6efa-45f3-961c-74856fd431d5").into(), + song_ids: Some(vec![uuid!("2b839103-04ab-4b39-9b05-8c664590eda4")]), + }) + )] + #[case("playlistId=none", None)] + fn test_deserialize(#[case] url: &str, #[case] request: Option) { + assert_eq!(serde_html_form::from_str::(url).ok(), request); + } +} diff --git a/nghe-api/src/playlists/delete_playlist.rs b/nghe-api/src/playlists/delete_playlist.rs new file mode 100644 index 000000000..8fc89a218 --- /dev/null +++ b/nghe-api/src/playlists/delete_playlist.rs @@ -0,0 +1,11 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +#[api_derive] +#[endpoint(path = "deletePlaylist")] +pub struct Request { + pub id: Uuid, +} + +#[api_derive] +pub struct Response; diff --git a/nghe-api/src/playlists/get_playlist.rs b/nghe-api/src/playlists/get_playlist.rs new file mode 100644 index 000000000..f45332a03 --- /dev/null +++ b/nghe-api/src/playlists/get_playlist.rs @@ -0,0 +1,15 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +use super::playlist; + +#[api_derive] +#[endpoint(path = "getPlaylist")] +pub struct Request { + pub id: Uuid, +} + +#[api_derive] +pub struct Response { + pub playlist: playlist::Full, +} diff --git a/nghe-api/src/playlists/get_playlists.rs b/nghe-api/src/playlists/get_playlists.rs new file mode 100644 index 000000000..c6ce3be81 --- /dev/null +++ b/nghe-api/src/playlists/get_playlists.rs @@ -0,0 +1,17 @@ +use nghe_proc_macro::api_derive; + +use super::playlist; + +#[api_derive] +#[endpoint(path = "getPlaylists")] +pub struct Request {} + +#[api_derive] +pub struct Playlists { + pub playlist: Vec, +} + +#[api_derive] +pub struct Response { + pub playlists: Playlists, +} diff --git a/nghe-api/src/playlists/mod.rs b/nghe-api/src/playlists/mod.rs new file mode 100644 index 000000000..544da1987 --- /dev/null +++ b/nghe-api/src/playlists/mod.rs @@ -0,0 +1,6 @@ +pub mod create_playlist; +pub mod delete_playlist; +pub mod get_playlist; +pub mod get_playlists; +pub mod playlist; +pub mod update_playlist; diff --git a/nghe-api/src/playlists/playlist.rs b/nghe-api/src/playlists/playlist.rs new file mode 100644 index 000000000..4abe2e956 --- /dev/null +++ b/nghe-api/src/playlists/playlist.rs @@ -0,0 +1,33 @@ +use bon::Builder; +use nghe_proc_macro::api_derive; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::id3; + +#[api_derive] +#[derive(Builder)] +#[builder(on(_, required))] +#[builder(state_mod(vis = "pub"))] +pub struct Playlist { + pub id: Uuid, + pub name: String, + pub comment: Option, + pub public: bool, + pub song_count: u16, + pub duration: u32, + pub created: OffsetDateTime, + pub changed: OffsetDateTime, +} + +#[api_derive] +pub struct Full { + #[serde(flatten)] + pub playlist: Playlist, + pub entry: Vec, +} + +pub mod builder { + pub use super::playlist_builder::*; + pub use super::PlaylistBuilder as Builder; +} diff --git a/nghe-api/src/playlists/update_playlist.rs b/nghe-api/src/playlists/update_playlist.rs new file mode 100644 index 000000000..8c8d0b429 --- /dev/null +++ b/nghe-api/src/playlists/update_playlist.rs @@ -0,0 +1,19 @@ +use nghe_proc_macro::api_derive; +use uuid::Uuid; + +#[api_derive] +#[endpoint(path = "updatePlaylist")] +#[cfg_attr(feature = "test", derive(Default))] +pub struct Request { + pub playlist_id: Uuid, + pub name: Option, + pub comment: Option, + pub public: Option, + #[serde(rename = "songIdToAdd")] + pub add_ids: Option>, + #[serde(rename = "songIndexToRemove")] + pub remove_indexes: Option>, +} + +#[api_derive] +pub struct Response; diff --git a/nghe-api/src/search/search3.rs b/nghe-api/src/search/search3.rs index f680e0ea2..e5b38e238 100644 --- a/nghe-api/src/search/search3.rs +++ b/nghe-api/src/search/search3.rs @@ -21,7 +21,7 @@ pub struct Request { pub struct SearchResult3 { pub artist: Vec, pub album: Vec, - pub song: Vec, + pub song: Vec, } #[api_derive] diff --git a/nghe-backend/migrations/2024-04-19-095649_create_playlists/up.sql b/nghe-backend/migrations/2024-04-19-095649_create_playlists/up.sql index e303904e3..8fc125b72 100644 --- a/nghe-backend/migrations/2024-04-19-095649_create_playlists/up.sql +++ b/nghe-backend/migrations/2024-04-19-095649_create_playlists/up.sql @@ -10,16 +10,11 @@ create table playlists ( select add_updated_at('playlists'); --- access level: --- 1 - read songs --- 2 - add/remove songs --- 3 - admin (add/remove users, edit, delete) create table playlists_users ( playlist_id uuid not null, user_id uuid not null, - access_level smallint not null constraint playlists_users_access_level check ( - access_level between 1 and 3 - ), + write boolean not null, + owner boolean not null default false, constraint playlists_users_pkey primary key (playlist_id, user_id), constraint playlists_users_playlist_id_fkey foreign key ( playlist_id @@ -29,6 +24,9 @@ create table playlists_users ( ) references users (id) on delete cascade ); +create unique index playlists_users_owner on playlists_users (playlist_id) +where owner; + create table playlists_songs ( playlist_id uuid not null, song_id uuid not null, diff --git a/nghe-backend/migrations/2024-11-22-065114_add_cover_arts_source_constraint_updated_at_scanned_at/up.sql b/nghe-backend/migrations/2024-11-22-065114_add_cover_arts_source_constraint_updated_at_scanned_at/up.sql index 32ab8b742..7daa39fd1 100644 --- a/nghe-backend/migrations/2024-11-22-065114_add_cover_arts_source_constraint_updated_at_scanned_at/up.sql +++ b/nghe-backend/migrations/2024-11-22-065114_add_cover_arts_source_constraint_updated_at_scanned_at/up.sql @@ -9,3 +9,5 @@ unique nulls not distinct ( source, file_hash, file_size ), drop constraint cover_arts_format_file_hash_file_size_key; + +select add_updated_at_leave_scanned_at('cover_arts'); diff --git a/nghe-backend/src/lib.rs b/nghe-backend/src/lib.rs index 04991882f..00a2662dc 100644 --- a/nghe-backend/src/lib.rs +++ b/nghe-backend/src/lib.rs @@ -62,6 +62,7 @@ pub async fn build(config: config::Config) -> Router { .merge(route::browsing::router()) .merge(route::lists::router()) .merge(route::media_annotation::router()) + .merge(route::playlists::router()) .merge(route::search::router()) .merge(route::system::router()) .with_state(database::Database::new(&config.database)) diff --git a/nghe-backend/src/orm/id3/album/full.rs b/nghe-backend/src/orm/id3/album/full.rs index 541bb6fc7..e5144cd80 100644 --- a/nghe-backend/src/orm/id3/album/full.rs +++ b/nghe-backend/src/orm/id3/album/full.rs @@ -22,7 +22,7 @@ pub struct Full { #[diesel(select_expression = sql("bool_or(songs_album_artists.compilation) is_compilation"))] #[diesel(select_expression_type = SqlLiteral::)] pub is_compilation: bool, - #[diesel(select_expression = sql("array_agg(distinct(songs.id)) album_artists"))] + #[diesel(select_expression = sql("array_agg(distinct(songs.id)) song_ids"))] #[diesel(select_expression_type = SqlLiteral::>)] pub songs: Vec, } diff --git a/nghe-backend/src/orm/id3/album/mod.rs b/nghe-backend/src/orm/id3/album/mod.rs index 3b0c3ca99..41f64d401 100644 --- a/nghe-backend/src/orm/id3/album/mod.rs +++ b/nghe-backend/src/orm/id3/album/mod.rs @@ -7,6 +7,7 @@ use diesel::prelude::*; use diesel::sql_types; use nghe_api::id3; use nghe_api::id3::builder::album as builder; +use time::OffsetDateTime; use uuid::Uuid; use super::genre::Genres; @@ -25,6 +26,8 @@ pub struct Album { ))] #[diesel(select_expression_type = SqlLiteral>)] pub cover_art: Option, + #[diesel(column_name = created_at)] + pub created: OffsetDateTime, #[diesel(embed)] pub date: albums::date::Date, #[diesel(column_name = mbz_id)] @@ -41,7 +44,9 @@ pub type BuilderSet = builder::SetReleaseDate< builder::SetOriginalReleaseDate< builder::SetGenres< builder::SetMusicBrainzId< - builder::SetYear>>, + builder::SetYear< + builder::SetCreated>>, + >, >, >, >, @@ -53,6 +58,7 @@ impl Album { .id(self.id) .name(self.name) .cover_art(self.cover_art) + .created(self.created) .year(self.date.year.map(u16::try_from).transpose()?) .music_brainz_id(self.music_brainz_id) .genres(self.genres.into()) diff --git a/nghe-backend/src/orm/id3/duration.rs b/nghe-backend/src/orm/id3/duration.rs index 31a10edcd..1c5820071 100644 --- a/nghe-backend/src/orm/id3/duration.rs +++ b/nghe-backend/src/orm/id3/duration.rs @@ -18,12 +18,18 @@ impl Trait for f32 { impl Trait for song::durations::Durations { fn duration(&self) -> Result { self.value - .iter() - .copied() - .reduce(song::durations::Duration::add) - .ok_or_else(|| Error::DatabaseSongDurationIsEmpty)? - .value - .duration() + .as_ref() + .map(|value| { + value + .iter() + .copied() + .reduce(song::durations::Duration::add) + .ok_or_else(|| Error::DatabaseSongDurationIsEmpty)? + .value + .duration() + }) + .transpose() + .map(Option::unwrap_or_default) } } @@ -38,3 +44,9 @@ impl Trait for Vec { self.iter().map(|song| song.property.duration).sum::().duration() } } + +impl Trait for Vec { + fn duration(&self) -> Result { + self.iter().map(|song| song.song.property.duration).sum::().duration() + } +} diff --git a/nghe-backend/src/orm/id3/song/durations.rs b/nghe-backend/src/orm/id3/song/durations.rs index 8433149a8..17d9844e7 100644 --- a/nghe-backend/src/orm/id3/song/durations.rs +++ b/nghe-backend/src/orm/id3/song/durations.rs @@ -18,10 +18,11 @@ pub type SqlType = sql_types::Record<(sql_types::Uuid, sql_types::Float)>; #[derive(Debug, Queryable, Selectable)] pub struct Durations { #[diesel(select_expression = sql( - "array_agg(distinct(songs.id, songs.duration)) song_id_durations" + "array_agg(distinct(songs.id, songs.duration)) \ + filter (where songs.id is not null) song_id_durations" ))] - #[diesel(select_expression_type = SqlLiteral::>)] - pub value: Vec, + #[diesel(select_expression_type = SqlLiteral::>>)] + pub value: Option>, } impl Add for Duration { @@ -34,7 +35,7 @@ impl Add for Duration { impl Durations { pub fn count(&self) -> usize { - self.value.len() + self.value.as_ref().map(Vec::len).unwrap_or_default() } } diff --git a/nghe-backend/src/orm/id3/song/full.rs b/nghe-backend/src/orm/id3/song/full.rs index 53956c4b2..bdadfdd98 100644 --- a/nghe-backend/src/orm/id3/song/full.rs +++ b/nghe-backend/src/orm/id3/song/full.rs @@ -1,12 +1,9 @@ -use diesel::dsl::sql; -use diesel::expression::SqlLiteral; use diesel::prelude::*; -use diesel::sql_types; use nghe_api::id3; use o2o::o2o; use uuid::Uuid; -use super::Song; +use super::short::Short; use crate::orm::id3::genre; use crate::Error; @@ -15,13 +12,7 @@ use crate::Error; pub struct Full { #[into(~.try_into()?)] #[diesel(embed)] - pub song: Song, - #[diesel(select_expression = sql("any_value(albums.name) album_name"))] - #[diesel(select_expression_type = SqlLiteral)] - pub album: String, - #[diesel(select_expression = sql("any_value(albums.id) album_id"))] - #[diesel(select_expression_type = SqlLiteral)] - pub album_id: Uuid, + pub short: Short, #[into(~.into())] #[diesel(embed)] pub genres: genre::Genres, @@ -103,9 +94,9 @@ mod test { if allow { let database_song = database_song.unwrap(); - let database_artists: Vec = database_song.song.artists.into(); - assert_eq!(database_song.album, album.name); - assert_eq!(database_song.album_id, album_id); + let database_artists: Vec = database_song.short.song.artists.into(); + assert_eq!(database_song.short.album, album.name); + assert_eq!(database_song.short.album_id, album_id); assert_eq!(database_artists, artists); assert_eq!(database_song.genres.value.len(), n_genre); } else { diff --git a/nghe-backend/src/orm/id3/song/mod.rs b/nghe-backend/src/orm/id3/song/mod.rs index 197cd0f76..9415e0979 100644 --- a/nghe-backend/src/orm/id3/song/mod.rs +++ b/nghe-backend/src/orm/id3/song/mod.rs @@ -1,5 +1,6 @@ pub mod durations; pub mod full; +pub mod short; use diesel::dsl::sql; use diesel::expression::SqlLiteral; @@ -8,6 +9,7 @@ use diesel::sql_types; use nghe_api::common::format::Trait as _; use nghe_api::id3; use nghe_api::id3::builder::song as builder; +use time::OffsetDateTime; use uuid::Uuid; use super::artist; @@ -35,6 +37,8 @@ pub struct Song { pub property: songs::property::Property, #[diesel(embed)] pub disc: songs::position::Disc, + #[diesel(column_name = created_at)] + pub created: OffsetDateTime, #[diesel(embed)] pub artists: artist::required::Artists, #[diesel(column_name = mbz_id)] @@ -43,19 +47,21 @@ pub struct Song { pub type BuilderSet = builder::SetMusicBrainzId< builder::SetArtists< - builder::SetDiscNumber< - builder::SetChannelCount< - builder::SetSamplingRate< - builder::SetBitDepth< - builder::SetBitRate< - builder::SetDuration< - builder::SetSuffix< - builder::SetContentType< - builder::SetSize< - builder::SetCoverArt< - builder::SetYear< - builder::SetTrack< - builder::SetTitle, + builder::SetCreated< + builder::SetDiscNumber< + builder::SetChannelCount< + builder::SetSamplingRate< + builder::SetBitDepth< + builder::SetBitRate< + builder::SetDuration< + builder::SetSuffix< + builder::SetContentType< + builder::SetSize< + builder::SetCoverArt< + builder::SetYear< + builder::SetTrack< + builder::SetTitle, + >, >, >, >, @@ -89,6 +95,7 @@ impl Song { .sampling_rate(self.property.sample_rate.try_into()?) .channel_count(self.property.channel_count.try_into()?) .disc_number(self.disc.number.map(u16::try_from).transpose()?) + .created(self.created) .artists(self.artists.into()) .music_brainz_id(self.music_brainz_id)) } @@ -106,7 +113,7 @@ pub mod query { use diesel::dsl::{auto_type, AsSelect}; use super::*; - use crate::orm::{albums, permission, songs_artists}; + use crate::orm::{albums, songs_artists}; #[auto_type] pub fn unchecked_no_group_by() -> _ { @@ -126,18 +133,6 @@ pub mod query { let song: AsSelect = Song::as_select(); unchecked_no_group_by().group_by(songs::id).select(song) } - - #[auto_type] - pub fn with_user_id(user_id: Uuid) -> _ { - let permission: permission::with_album = permission::with_album(user_id); - unchecked().filter(permission) - } - - #[auto_type] - pub fn with_music_folder<'ids>(user_id: Uuid, music_folder_ids: &'ids [Uuid]) -> _ { - let with_user_id: with_user_id = with_user_id(user_id); - with_user_id.filter(albums::music_folder_id.eq_any(music_folder_ids)) - } } #[cfg(test)] diff --git a/nghe-backend/src/orm/id3/song/short.rs b/nghe-backend/src/orm/id3/song/short.rs new file mode 100644 index 000000000..330981294 --- /dev/null +++ b/nghe-backend/src/orm/id3/song/short.rs @@ -0,0 +1,50 @@ +use diesel::dsl::sql; +use diesel::expression::SqlLiteral; +use diesel::prelude::*; +use diesel::sql_types; +use nghe_api::id3; +use o2o::o2o; +use uuid::Uuid; + +use super::Song; +use crate::Error; + +#[derive(Debug, Queryable, Selectable, o2o)] +#[owned_try_into(id3::song::Short, Error)] +pub struct Short { + #[into(~.try_into()?)] + #[diesel(embed)] + pub song: Song, + #[diesel(select_expression = sql("any_value(albums.name) album_name"))] + #[diesel(select_expression_type = SqlLiteral)] + pub album: String, + #[diesel(select_expression = sql("any_value(albums.id) album_id"))] + #[diesel(select_expression_type = SqlLiteral)] + pub album_id: Uuid, +} + +pub mod query { + use diesel::dsl::{auto_type, AsSelect}; + + use super::*; + use crate::orm::id3::song; + use crate::orm::{albums, permission, songs}; + + #[auto_type] + pub fn unchecked() -> _ { + let full: AsSelect = Short::as_select(); + song::query::unchecked_no_group_by().group_by(songs::id).select(full) + } + + #[auto_type] + pub fn with_user_id(user_id: Uuid) -> _ { + let permission: permission::with_album = permission::with_album(user_id); + unchecked().filter(permission) + } + + #[auto_type] + pub fn with_music_folder<'ids>(user_id: Uuid, music_folder_ids: &'ids [Uuid]) -> _ { + let with_user_id: with_user_id = with_user_id(user_id); + with_user_id.filter(albums::music_folder_id.eq_any(music_folder_ids)) + } +} diff --git a/nghe-backend/src/orm/mod.rs b/nghe-backend/src/orm/mod.rs index 8b4082bad..b3d961c64 100644 --- a/nghe-backend/src/orm/mod.rs +++ b/nghe-backend/src/orm/mod.rs @@ -9,6 +9,10 @@ pub mod id3; pub mod music_folders; pub mod permission; pub mod playbacks; +pub mod playlist; +pub mod playlists; +pub mod playlists_songs; +pub mod playlists_users; pub mod songs; pub mod songs_album_artists; pub mod songs_artists; diff --git a/nghe-backend/src/orm/playlist/full.rs b/nghe-backend/src/orm/playlist/full.rs new file mode 100644 index 000000000..090680541 --- /dev/null +++ b/nghe-backend/src/orm/playlist/full.rs @@ -0,0 +1,100 @@ +use diesel::dsl::sql; +use diesel::expression::SqlLiteral; +use diesel::prelude::*; +use diesel::sql_types; +use diesel_async::RunQueryDsl; +use nghe_api::playlists::playlist; +use uuid::Uuid; + +use super::Playlist; +use crate::database::Database; +use crate::orm::id3::duration::Trait; +use crate::orm::id3::song; +use crate::orm::{playlists_songs, songs}; +use crate::Error; + +#[derive(Debug, Queryable, Selectable)] +pub struct Full { + #[diesel(embed)] + pub playlist: Playlist, + #[diesel(select_expression = sql( + "array_remove(array_agg(distinct(songs.id)), null) entry_ids" + ))] + #[diesel(select_expression_type = SqlLiteral::>)] + pub entries: Vec, +} + +impl Full { + pub async fn try_into(self, database: &Database) -> Result { + let entry = song::short::query::unchecked() + .inner_join(playlists_songs::table) + .filter(songs::id.eq_any(self.entries)) + .filter(playlists_songs::playlist_id.eq(self.playlist.id)) + .order_by(sql::("any_value(playlists_songs.created_at)")) + .get_results(&mut database.get().await?) + .await?; + let duration = entry.duration()?; + let entry: Vec<_> = entry.into_iter().map(song::short::Short::try_into).try_collect()?; + + let playlist = self + .playlist + .into_builder() + .song_count(entry.len().try_into()?) + .duration(duration) + .build(); + + Ok(playlist::Full { playlist, entry }) + } +} + +pub mod query { + use diesel::dsl::{auto_type, AsSelect}; + + use super::*; + use crate::orm::playlist; + + #[auto_type] + pub fn with_user_id(user_id: Uuid) -> _ { + let with_user_id: playlist::query::with_user_id = playlist::query::with_user_id(user_id); + let full: AsSelect = Full::as_select(); + with_user_id.select(full) + } +} + +#[cfg(test)] +mod tests { + use diesel_async::RunQueryDsl; + use fake::{Fake, Faker}; + use indexmap::IndexSet; + use rstest::rstest; + + use super::*; + use crate::route::playlists::create_playlist; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_query(#[future(awt)] mock: Mock, #[values(0, 5)] n_song: usize) { + let mut music_folder = mock.music_folder(0).await; + music_folder.add_audio().n_song(n_song).call().await; + + let user_id = mock.user_id(0).await; + create_playlist::handler( + mock.database(), + user_id, + create_playlist::Request { + create_or_update: Faker.fake::().into(), + song_ids: Some(music_folder.database.keys().copied().collect()), + }, + ) + .await + .unwrap(); + + let database_playlist = + query::with_user_id(user_id).get_result(&mut mock.get().await).await.unwrap(); + assert_eq!( + database_playlist.entries.iter().collect::>(), + music_folder.database.keys().collect::>() + ); + } +} diff --git a/nghe-backend/src/orm/playlist/mod.rs b/nghe-backend/src/orm/playlist/mod.rs new file mode 100644 index 000000000..f885c2b29 --- /dev/null +++ b/nghe-backend/src/orm/playlist/mod.rs @@ -0,0 +1,112 @@ +pub mod full; +pub mod permission; +pub mod short; + +use diesel::dsl::sql; +use diesel::expression::SqlLiteral; +use diesel::prelude::*; +use diesel::sql_types; +use nghe_api::playlists::playlist::{self, builder}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::orm::playlists; + +#[derive(Debug, Queryable, Selectable)] +#[diesel(table_name = playlists, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = false)] +pub struct Playlist { + pub id: Uuid, + pub name: String, + pub comment: Option, + pub public: bool, + #[diesel(column_name = created_at)] + pub created: OffsetDateTime, + #[diesel(select_expression = sql( + "greatest(max(playlists_songs.created_at), playlists.updated_at)" + ))] + #[diesel(select_expression_type = SqlLiteral)] + pub changed: OffsetDateTime, +} + +pub type BuilderSet = builder::SetChanged< + builder::SetCreated>>>, +>; + +impl Playlist { + pub fn into_builder(self) -> builder::Builder { + playlist::Playlist::builder() + .id(self.id) + .name(self.name) + .comment(self.comment) + .public(self.public) + .created(self.created) + .changed(self.changed) + } +} + +pub mod query { + use diesel::dsl::{auto_type, AsSelect}; + + use super::*; + use crate::orm::{albums, permission, playlists, playlists_songs, playlists_users, songs}; + + #[auto_type] + pub fn with_album(user_id: Uuid) -> _ { + let permission: permission::with_album = permission::with_album(user_id); + albums::id.is_null().or(permission) + } + + #[auto_type] + pub fn with_user_id(user_id: Uuid) -> _ { + let with_album: with_album = with_album(user_id); + let playlist: AsSelect = Playlist::as_select(); + playlists::table + .inner_join(playlists_users::table) + .left_join(playlists_songs::table) + .left_join(songs::table.on(songs::id.eq(playlists_songs::song_id))) + .left_join(albums::table.on(albums::id.eq(songs::album_id))) + .filter(playlists_users::user_id.eq(user_id)) + .filter(with_album) + .group_by(playlists::id) + .select(playlist) + } +} + +#[cfg(test)] +mod tests { + use diesel_async::RunQueryDsl; + use fake::{Fake, Faker}; + use rstest::rstest; + + use super::*; + use crate::route::playlists::create_playlist; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_query(#[future(awt)] mock: Mock, #[values(0, 5)] n_song: usize) { + let mut music_folder = mock.music_folder(0).await; + music_folder.add_audio().n_song(n_song).call().await; + + let user_id = mock.user_id(0).await; + create_playlist::handler( + mock.database(), + user_id, + create_playlist::Request { + create_or_update: Faker.fake::().into(), + song_ids: Some(music_folder.database.keys().copied().collect()), + }, + ) + .await + .unwrap(); + + let database_playlist = + query::with_user_id(user_id).get_result(&mut mock.get().await).await.unwrap(); + if n_song == 0 { + assert_eq!(database_playlist.created, database_playlist.changed); + } else { + assert!(database_playlist.changed > database_playlist.created); + } + } +} diff --git a/nghe-backend/src/orm/playlist/permission.rs b/nghe-backend/src/orm/playlist/permission.rs new file mode 100644 index 000000000..215b462de --- /dev/null +++ b/nghe-backend/src/orm/playlist/permission.rs @@ -0,0 +1,51 @@ +use diesel::dsl::{exists, select}; +use diesel_async::RunQueryDsl; +use uuid::Uuid; + +use crate::database::Database; +use crate::Error; + +pub async fn check_write( + database: &Database, + playlist_id: Uuid, + user_id: Uuid, + owner: bool, +) -> Result<(), Error> { + let exist = if owner { + select(exists(query::owner(playlist_id, user_id))) + .get_result(&mut database.get().await?) + .await? + } else { + select(exists(query::write(playlist_id, user_id))) + .get_result(&mut database.get().await?) + .await? + }; + if exist { Ok(()) } else { Err(Error::NotFound) } +} + +pub mod query { + use diesel::dsl::auto_type; + use diesel::{ExpressionMethods, QueryDsl}; + + use super::*; + use crate::orm::playlists_users; + + #[auto_type] + pub fn read(playlist_id: Uuid, user_id: Uuid) -> _ { + playlists_users::table + .filter(playlists_users::playlist_id.eq(playlist_id)) + .filter(playlists_users::user_id.eq(user_id)) + } + + #[auto_type] + pub fn write(playlist_id: Uuid, user_id: Uuid) -> _ { + let read: read = read(playlist_id, user_id); + read.filter(playlists_users::write) + } + + #[auto_type] + pub fn owner(playlist_id: Uuid, user_id: Uuid) -> _ { + let read: read = read(playlist_id, user_id); + read.filter(playlists_users::owner) + } +} diff --git a/nghe-backend/src/orm/playlist/short.rs b/nghe-backend/src/orm/playlist/short.rs new file mode 100644 index 000000000..1cceda07d --- /dev/null +++ b/nghe-backend/src/orm/playlist/short.rs @@ -0,0 +1,88 @@ +use diesel::prelude::*; +use nghe_api::playlists::playlist::{self, builder}; + +use super::Playlist; +use crate::orm::id3::duration::Trait as _; +use crate::orm::id3::song; +use crate::Error; + +#[derive(Debug, Queryable, Selectable)] +pub struct Short { + #[diesel(embed)] + pub playlist: Playlist, + #[diesel(embed)] + pub durations: song::durations::Durations, +} + +pub type BuilderSet = builder::SetDuration>; + +impl Short { + pub fn try_into_builder(self) -> Result, Error> { + Ok(self + .playlist + .into_builder() + .song_count(self.durations.count().try_into()?) + .duration(self.durations.duration()?)) + } +} + +impl TryFrom for playlist::Playlist { + type Error = Error; + + fn try_from(value: Short) -> Result { + Ok(value.try_into_builder()?.build()) + } +} + +pub mod query { + use diesel::dsl::{auto_type, AsSelect}; + use uuid::Uuid; + + use super::*; + use crate::orm::{playlist, playlists}; + + #[auto_type] + pub fn with_user_id(user_id: Uuid) -> _ { + let with_user_id: playlist::query::with_user_id = playlist::query::with_user_id(user_id); + let full: AsSelect = Short::as_select(); + with_user_id.order_by(playlists::created_at.desc()).select(full) + } +} + +#[cfg(test)] +mod tests { + use diesel_async::RunQueryDsl; + use fake::{Fake, Faker}; + use rstest::rstest; + + use super::*; + use crate::route::playlists::create_playlist; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_query(#[future(awt)] mock: Mock, #[values(0, 5)] n_song: usize) { + let mut music_folder = mock.music_folder(0).await; + music_folder.add_audio().n_song(n_song).call().await; + + let user_id = mock.user_id(0).await; + create_playlist::handler( + mock.database(), + user_id, + create_playlist::Request { + create_or_update: Faker.fake::().into(), + song_ids: Some(music_folder.database.keys().copied().collect()), + }, + ) + .await + .unwrap(); + + let database_playlist = + query::with_user_id(user_id).get_result(&mut mock.get().await).await.unwrap(); + assert_eq!(database_playlist.durations.count(), n_song); + assert_eq!( + database_playlist.durations.duration().unwrap(), + music_folder.database.duration().unwrap() + ); + } +} diff --git a/nghe-backend/src/orm/playlists.rs b/nghe-backend/src/orm/playlists.rs new file mode 100644 index 000000000..29f701be4 --- /dev/null +++ b/nghe-backend/src/orm/playlists.rs @@ -0,0 +1,55 @@ +#![allow(clippy::option_option)] + +use std::borrow::Cow; + +use diesel::prelude::*; +use diesel_derives::AsChangeset; +use o2o::o2o; + +pub use crate::schema::playlists::{self, *}; + +#[derive(Insertable, AsChangeset, Default, o2o)] +#[diesel(table_name = playlists, check_for_backend(crate::orm::Type))] +#[diesel(treat_none_as_null = false)] +#[from_ref(nghe_api::playlists::update_playlist::Request)] +pub struct Upsert<'a> { + #[from(~.as_ref().map(|value| value.as_str().into()))] + pub name: Option>, + #[from(~.as_ref().map( + |value| if value.is_empty() { None } else { Some(value.as_str().into()) } + ))] + pub comment: Option>>, + pub public: Option, +} + +mod upsert { + use diesel::ExpressionMethods; + use diesel_async::RunQueryDsl; + use uuid::Uuid; + + use super::{playlists, Upsert}; + use crate::database::Database; + use crate::Error; + + impl crate::orm::upsert::Insert for Upsert<'_> { + async fn insert(&self, database: &Database) -> Result { + diesel::insert_into(playlists::table) + .values(self) + .returning(playlists::id) + .get_result(&mut database.get().await?) + .await + .map_err(Error::from) + } + } + + impl crate::orm::upsert::Update for Upsert<'_> { + async fn update(&self, database: &Database, id: Uuid) -> Result<(), Error> { + diesel::update(playlists::table) + .filter(playlists::id.eq(id)) + .set(self) + .execute(&mut database.get().await?) + .await?; + Ok(()) + } + } +} diff --git a/nghe-backend/src/orm/playlists_songs.rs b/nghe-backend/src/orm/playlists_songs.rs new file mode 100644 index 000000000..ac82cb28d --- /dev/null +++ b/nghe-backend/src/orm/playlists_songs.rs @@ -0,0 +1,45 @@ +use diesel::prelude::*; +use diesel_derives::AsChangeset; +use uuid::Uuid; + +pub use crate::schema::playlists_songs::{self, *}; + +#[derive(Insertable, AsChangeset)] +#[diesel(table_name = playlists_songs, check_for_backend(crate::orm::Type))] +pub struct Upsert { + pub playlist_id: Uuid, + pub song_id: Uuid, +} + +mod upsert { + use diesel::ExpressionMethods; + use diesel_async::RunQueryDsl; + use uuid::Uuid; + + use super::{playlists_songs, Upsert}; + use crate::database::Database; + use crate::Error; + + impl Upsert { + pub async fn upsert(&self, database: &Database) -> Result<(), Error> { + diesel::insert_into(playlists_songs::table) + .values((self, playlists_songs::created_at.eq(crate::time::now().await))) + .on_conflict_do_nothing() + .execute(&mut database.get().await?) + .await?; + Ok(()) + } + + pub async fn upserts( + database: &Database, + playlist_id: Uuid, + song_ids: &[Uuid], + ) -> Result<(), Error> { + // Upserting one by one to avoid two songs having too close `created_at`. + for song_id in song_ids.iter().copied() { + Self { playlist_id, song_id }.upsert(database).await?; + } + Ok(()) + } + } +} diff --git a/nghe-backend/src/orm/playlists_users.rs b/nghe-backend/src/orm/playlists_users.rs new file mode 100644 index 000000000..da4ad081f --- /dev/null +++ b/nghe-backend/src/orm/playlists_users.rs @@ -0,0 +1,40 @@ +use diesel::prelude::*; +use diesel_derives::AsChangeset; +use uuid::Uuid; + +pub use crate::schema::playlists_users::{self, *}; + +#[derive(Insertable, AsChangeset)] +#[diesel(table_name = playlists_users, check_for_backend(crate::orm::Type))] +pub struct Upsert { + pub playlist_id: Uuid, + pub user_id: Uuid, + pub write: bool, +} + +mod upsert { + use diesel::ExpressionMethods; + use diesel_async::RunQueryDsl; + use uuid::Uuid; + + use super::{playlists_users, Upsert}; + use crate::database::Database; + use crate::Error; + + impl Upsert { + pub async fn insert_owner( + database: &Database, + playlist_id: Uuid, + user_id: Uuid, + ) -> Result<(), Error> { + diesel::insert_into(playlists_users::table) + .values(( + Upsert { playlist_id, user_id, write: true }, + playlists_users::owner.eq(true), + )) + .execute(&mut database.get().await?) + .await?; + Ok(()) + } + } +} diff --git a/nghe-backend/src/route/browsing/get_song.rs b/nghe-backend/src/route/browsing/get_song.rs index 095290909..859d980dc 100644 --- a/nghe-backend/src/route/browsing/get_song.rs +++ b/nghe-backend/src/route/browsing/get_song.rs @@ -60,11 +60,11 @@ mod test { .unwrap() .song; - assert_eq!(database_song.album, album.name); - assert_eq!(database_song.album_id, album_id); + assert_eq!(database_song.short.album, album.name); + assert_eq!(database_song.short.album_id, album_id); let database_artists: Vec<_> = - database_song.song.artists.into_iter().map(|artist| artist.name).collect(); + database_song.short.song.artists.into_iter().map(|artist| artist.name).collect(); assert_eq!(database_artists, artists); let genres = database_song.genres.value; diff --git a/nghe-backend/src/route/lists/get_album_list2.rs b/nghe-backend/src/route/lists/get_album_list2.rs index 71e3354ec..f8de9a6de 100644 --- a/nghe-backend/src/route/lists/get_album_list2.rs +++ b/nghe-backend/src/route/lists/get_album_list2.rs @@ -1,7 +1,7 @@ use diesel::dsl::{max, sum}; use diesel::{ExpressionMethods, JoinOnDsl, PgSortExpressionMethods as _, QueryDsl}; use diesel_async::RunQueryDsl; -use nghe_api::lists::get_album_list2::{AlbumList2, ByYear, Type}; +use nghe_api::lists::get_album_list2::{AlbumList2, Type}; pub use nghe_api::lists::get_album_list2::{Request, Response}; use nghe_proc_macro::{check_music_folder, handler}; use uuid::Uuid; @@ -51,7 +51,7 @@ pub async fn handler( .await? } Type::AlphabeticalByName => query.get_results(&mut database.get().await?).await?, - Type::ByYear(ByYear { from_year, to_year }) => { + Type::ByYear { from_year, to_year } => { let from_year: i16 = from_year.try_into()?; let to_year: i16 = to_year.try_into()?; if from_year < to_year { diff --git a/nghe-backend/src/route/mod.rs b/nghe-backend/src/route/mod.rs index 84e4dcd06..81fa69d65 100644 --- a/nghe-backend/src/route/mod.rs +++ b/nghe-backend/src/route/mod.rs @@ -4,6 +4,7 @@ pub mod media_annotation; pub mod media_retrieval; pub mod music_folder; pub mod permission; +pub mod playlists; pub mod scan; pub mod search; pub mod system; diff --git a/nghe-backend/src/route/playlists/create_playlist.rs b/nghe-backend/src/route/playlists/create_playlist.rs new file mode 100644 index 000000000..996f09306 --- /dev/null +++ b/nghe-backend/src/route/playlists/create_playlist.rs @@ -0,0 +1,50 @@ +use diesel::ExpressionMethods; +use diesel_async::RunQueryDsl; +use nghe_api::playlists::create_playlist::CreateOrUpdate; +pub use nghe_api::playlists::create_playlist::{Request, Response}; +use nghe_proc_macro::handler; +use uuid::Uuid; + +use super::get_playlist; +use crate::database::Database; +use crate::orm::upsert::Insert; +use crate::orm::{playlist, playlists, playlists_songs, playlists_users}; +use crate::Error; + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + let playlist_id = match request.create_or_update { + CreateOrUpdate::Create { name } => { + let playlist_id = playlists::Upsert { name: Some(name.into()), ..Default::default() } + .insert(database) + .await?; + playlists_users::Upsert::insert_owner(database, playlist_id, user_id).await?; + playlist_id + } + CreateOrUpdate::Update { playlist_id } => { + playlist::permission::check_write(database, playlist_id, user_id, false).await?; + diesel::delete(playlists_songs::table) + .filter(playlists_songs::playlist_id.eq(playlist_id)) + .execute(&mut database.get().await?) + .await?; + playlist_id + } + }; + if let Some(ref song_ids) = request.song_ids { + playlists_songs::Upsert::upserts(database, playlist_id, song_ids).await?; + } + + Ok(Response { + playlist: get_playlist::handler( + database, + user_id, + get_playlist::Request { id: playlist_id }, + ) + .await? + .playlist, + }) +} diff --git a/nghe-backend/src/route/playlists/delete_playlist.rs b/nghe-backend/src/route/playlists/delete_playlist.rs new file mode 100644 index 000000000..cf7ff7ed4 --- /dev/null +++ b/nghe-backend/src/route/playlists/delete_playlist.rs @@ -0,0 +1,24 @@ +use diesel::ExpressionMethods; +use diesel_async::RunQueryDsl; +pub use nghe_api::playlists::delete_playlist::{Request, Response}; +use nghe_proc_macro::handler; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::{playlist, playlists}; +use crate::Error; + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + let playlist_id = request.id; + playlist::permission::check_write(database, playlist_id, user_id, true).await?; + diesel::delete(playlists::table) + .filter(playlists::id.eq(playlist_id)) + .execute(&mut database.get().await?) + .await?; + Ok(Response) +} diff --git a/nghe-backend/src/route/playlists/get_playlist.rs b/nghe-backend/src/route/playlists/get_playlist.rs new file mode 100644 index 000000000..8cfad046e --- /dev/null +++ b/nghe-backend/src/route/playlists/get_playlist.rs @@ -0,0 +1,121 @@ +use diesel::{ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; +pub use nghe_api::playlists::get_playlist::{Request, Response}; +use nghe_proc_macro::handler; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::{playlist, playlists}; +use crate::Error; + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + let playlist_id = request.id; + Ok(Response { + playlist: playlist::full::query::with_user_id(user_id) + .filter(playlists::id.eq(playlist_id)) + .get_result(&mut database.get().await?) + .await? + .try_into(database) + .await?, + }) +} + +#[cfg(test)] +mod tests { + use fake::{Fake, Faker}; + use futures_lite::{stream, StreamExt as _}; + use rstest::rstest; + + use super::*; + use crate::route::playlists::create_playlist; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_handler_music_folder( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + #[values(true, false)] allow: bool, + ) { + mock.add_music_folder().allow(allow).call().await; + mock.add_music_folder().call().await; + + let mut music_folder_permission = mock.music_folder(0).await; + let mut music_folder = mock.music_folder(1).await; + + let n_song_permission = (2..4).fake(); + let n_song = (2..4).fake(); + music_folder_permission.add_audio().n_song(n_song_permission).call().await; + music_folder.add_audio().n_song(n_song).call().await; + + let song_ids: Vec<_> = music_folder_permission + .database + .keys() + .copied() + .chain(music_folder.database.keys().copied()) + .collect(); + + let playlist = create_playlist::handler( + mock.database(), + mock.user_id(0).await, + create_playlist::Request { + create_or_update: Faker.fake::().into(), + song_ids: Some(song_ids.clone()), + }, + ) + .await + .unwrap() + .playlist; + + let database_song_ids: Vec<_> = playlist.entry.iter().map(|entry| entry.song.id).collect(); + let index = if allow { 0 } else { n_song_permission }; + assert_eq!(database_song_ids, song_ids[index..]); + } + + #[rstest] + #[tokio::test] + async fn test_handler_user( + #[future(awt)] + #[with(2, 1)] + mock: Mock, + ) { + let mut music_folder = mock.music_folder(0).await; + music_folder.add_audio().n_song((2..4).fake()).call().await; + let song_ids: Vec<_> = music_folder.database.keys().copied().collect(); + + let (user_ids, playlist_ids): (Vec<_>, Vec<_>) = stream::iter(0..2) + .then(async |i| { + let user_id = mock.user_id(i).await; + ( + user_id, + create_playlist::handler( + mock.database(), + user_id, + create_playlist::Request { + create_or_update: Faker.fake::().into(), + song_ids: Some(song_ids.clone()), + }, + ) + .await + .unwrap() + .playlist + .playlist + .id, + ) + }) + .collect() + .await; + + for i in 0..2 { + let user_id = user_ids[i]; + let playlist_id = playlist_ids[1 - i]; + assert!(handler(mock.database(), user_id, Request { id: playlist_id }).await.is_err()); + } + } +} diff --git a/nghe-backend/src/route/playlists/get_playlists.rs b/nghe-backend/src/route/playlists/get_playlists.rs new file mode 100644 index 000000000..0a4bda67c --- /dev/null +++ b/nghe-backend/src/route/playlists/get_playlists.rs @@ -0,0 +1,92 @@ +use diesel_async::RunQueryDsl; +use nghe_api::playlists::get_playlists::Playlists; +pub use nghe_api::playlists::get_playlists::{Request, Response}; +use nghe_proc_macro::handler; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::playlist; +use crate::Error; + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + Ok(Response { + playlists: Playlists { + playlist: playlist::short::query::with_user_id(user_id) + .get_results(&mut database.get().await?) + .await? + .into_iter() + .map(playlist::short::Short::try_into) + .try_collect()?, + }, + }) +} + +#[cfg(test)] +mod tests { + use fake::{Fake, Faker}; + use futures_lite::{stream, StreamExt as _}; + use rstest::rstest; + + use super::*; + use crate::route::playlists::create_playlist; + use crate::test::{mock, Mock}; + + #[rstest] + #[tokio::test] + async fn test_handler( + #[future(awt)] + #[with(2, 1)] + mock: Mock, + ) { + let mut music_folder = mock.music_folder(0).await; + music_folder.add_audio().n_song((2..4).fake()).call().await; + let song_ids: Vec<_> = music_folder.database.keys().copied().collect(); + + let (user_ids, playlist_ids): (Vec<_>, Vec>) = stream::iter(0..2) + .then(async |i| { + let user_id = mock.user_id(i).await; + ( + user_id, + stream::iter(0..(2..4).fake()) + .then(async |_| { + create_playlist::handler( + mock.database(), + user_id, + create_playlist::Request { + create_or_update: Faker.fake::().into(), + song_ids: Some(song_ids.clone()), + }, + ) + .await + .unwrap() + .playlist + .playlist + .id + }) + .collect() + .await, + ) + }) + .collect() + .await; + + for i in 0..2 { + let user_id = user_ids[i]; + let database_playlist_ids: Vec<_> = handler(mock.database(), user_id, Request {}) + .await + .unwrap() + .playlists + .playlist + .into_iter() + .map(|playlist| playlist.id) + .rev() + .collect(); + assert_eq!(database_playlist_ids, playlist_ids[i]); + } + } +} diff --git a/nghe-backend/src/route/playlists/mod.rs b/nghe-backend/src/route/playlists/mod.rs new file mode 100644 index 000000000..5127f31a2 --- /dev/null +++ b/nghe-backend/src/route/playlists/mod.rs @@ -0,0 +1,9 @@ +pub mod create_playlist; +mod delete_playlist; +pub mod get_playlist; +mod get_playlists; +mod update_playlist; + +nghe_proc_macro::build_router! { + modules = [create_playlist, delete_playlist, get_playlist, get_playlists, update_playlist] +} diff --git a/nghe-backend/src/route/playlists/update_playlist.rs b/nghe-backend/src/route/playlists/update_playlist.rs new file mode 100644 index 000000000..571091aee --- /dev/null +++ b/nghe-backend/src/route/playlists/update_playlist.rs @@ -0,0 +1,148 @@ +use diesel::{ExpressionMethods, JoinOnDsl, QueryDsl}; +use diesel_async::RunQueryDsl; +pub use nghe_api::playlists::update_playlist::{Request, Response}; +use nghe_proc_macro::handler; +use uuid::Uuid; + +use crate::database::Database; +use crate::orm::upsert::Update; +use crate::orm::{albums, playlist, playlists, playlists_songs, songs}; +use crate::Error; + +#[handler] +pub async fn handler( + database: &Database, + user_id: Uuid, + request: Request, +) -> Result { + let playlist_id = request.playlist_id; + playlist::permission::check_write(database, playlist_id, user_id, false).await?; + + if request.name.is_some() || request.comment.is_some() || request.public.is_some() { + playlists::Upsert::from(&request).update(database, playlist_id).await?; + } + + if let Some(song_indexes) = request.remove_indexes { + // TODO: Do it in one query. + let song_ids = playlists_songs::table + .left_join(songs::table) + .left_join(albums::table.on(albums::id.eq(songs::album_id))) + .filter(playlists_songs::playlist_id.eq(playlist_id)) + .filter(playlist::query::with_album(user_id)) + .select(playlists_songs::song_id) + .order_by(playlists_songs::created_at) + .get_results::(&mut database.get().await?) + .await?; + + let song_ids: Vec<_> = song_indexes + .into_iter() + .filter_map(|index| song_ids.get::(index.into())) + .collect(); + + diesel::delete(playlists_songs::table) + .filter(playlists_songs::playlist_id.eq(playlist_id)) + .filter(playlists_songs::song_id.eq_any(song_ids)) + .execute(&mut database.get().await?) + .await?; + } + + if let Some(song_ids) = request.add_ids { + playlists_songs::Upsert::upserts(database, playlist_id, &song_ids).await?; + } + + Ok(Response) +} + +#[cfg(test)] +mod tests { + use fake::{Fake, Faker}; + use rstest::rstest; + + use super::*; + use crate::route::playlists::{create_playlist, get_playlist}; + use crate::test::{mock, Mock}; + + #[rstest] + #[case(0, false, &[], &[])] + #[case(0, true, &[], &[])] + #[case(5, false, &[], &[5, 6, 7, 8, 9])] + #[case(5, true, &[], &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9])] + #[case(5, false, &[0, 1, 5, 9], &[7, 8, 9])] + #[case(5, true, &[0, 1, 5, 9], &[2, 3, 4, 6, 7, 8])] + #[case(5, false, &[5, 1, 0, 9], &[7, 8, 9])] + #[case(5, true, &[5, 1, 0, 9], &[2, 3, 4, 6, 7, 8])] + #[case(5, false, &[0, 1, 5, 9, 19], &[7, 8, 9])] + #[case(5, true, &[0, 1, 5, 9, 19], &[2, 3, 4, 6, 7, 8])] + #[tokio::test] + async fn test_delete( + #[future(awt)] + #[with(1, 0)] + mock: Mock, + #[case] n_song: usize, + #[case] allow: bool, + #[case] remove_indexes: &[u16], + #[case] retain_indexes: &[u16], + ) { + mock.add_music_folder().allow(allow).call().await; + mock.add_music_folder().call().await; + + let mut music_folder_permission = mock.music_folder(0).await; + let mut music_folder = mock.music_folder(1).await; + + music_folder_permission.add_audio().n_song(n_song).call().await; + music_folder.add_audio().n_song(n_song).call().await; + + let song_ids: Vec<_> = music_folder_permission + .database + .keys() + .copied() + .chain(music_folder.database.keys().copied()) + .collect(); + + let user_id = mock.user_id(0).await; + let playlist_id = create_playlist::handler( + mock.database(), + user_id, + create_playlist::Request { + create_or_update: Faker.fake::().into(), + song_ids: Some(song_ids.clone()), + }, + ) + .await + .unwrap() + .playlist + .playlist + .id; + + handler( + mock.database(), + user_id, + Request { + playlist_id, + remove_indexes: Some(remove_indexes.into()), + ..Default::default() + }, + ) + .await + .unwrap(); + + let song_ids: Vec<_> = retain_indexes + .iter() + .filter_map(|index| song_ids.get::((*index).into())) + .copied() + .collect(); + let database_song_ids: Vec<_> = get_playlist::handler( + mock.database(), + user_id, + get_playlist::Request { id: playlist_id }, + ) + .await + .unwrap() + .playlist + .entry + .into_iter() + .map(|entry| entry.song.id) + .collect(); + assert_eq!(database_song_ids, song_ids); + } +} diff --git a/nghe-backend/src/route/search/search3.rs b/nghe-backend/src/route/search/search3.rs index 7438d8058..427ed49de 100644 --- a/nghe-backend/src/route/search/search3.rs +++ b/nghe-backend/src/route/search/search3.rs @@ -93,7 +93,7 @@ pub async fn handler( let count = request.song_count.unwrap_or(20).into(); let song = if count > 0 { let offset = request.song_offset.unwrap_or(0).into(); - let query = id3::song::query::with_user_id(user_id).limit(count).offset(offset); + let query = id3::song::short::query::with_user_id(user_id).limit(count).offset(offset); if sync { query .order_by((songs::title, songs::mbz_id)) @@ -126,7 +126,7 @@ pub async fn handler( search_result3: SearchResult3 { artist: artist.into_iter().map(id3::artist::Artist::try_into).try_collect()?, album: album.into_iter().map(id3::album::short::Short::try_into).try_collect()?, - song: song.into_iter().map(id3::song::Song::try_into).try_collect()?, + song: song.into_iter().map(id3::song::short::Short::try_into).try_collect()?, }, }) } diff --git a/nghe-backend/src/schema.rs b/nghe-backend/src/schema.rs index 3b4c71af4..6039bf2fd 100644 --- a/nghe-backend/src/schema.rs +++ b/nghe-backend/src/schema.rs @@ -162,7 +162,8 @@ diesel::table! { playlists_users (playlist_id, user_id) { playlist_id -> Uuid, user_id -> Uuid, - access_level -> Int2, + write -> Bool, + owner -> Bool, } }