Indexing WIP

This commit is contained in:
Antoine Gersant 2024-07-29 02:07:28 -07:00
parent 2965cbdf7e
commit 2012258a72
13 changed files with 270 additions and 102 deletions

View file

@ -108,22 +108,6 @@ impl Browser {
Ok(songs)
}
pub async fn get_random_albums(
&self,
count: i64,
) -> Result<Vec<collection::Directory>, collection::Error> {
// TODO move to Index
Ok(vec![])
}
pub async fn get_recent_albums(
&self,
count: i64,
) -> Result<Vec<collection::Directory>, collection::Error> {
// TODO move to Index
Ok(vec![])
}
pub async fn search(&self, query: &str) -> Result<Vec<collection::File>, collection::Error> {
let mut connection = self.db.connect().await?;
let like_test = format!("%{}%", query);
@ -285,28 +269,6 @@ mod test {
assert_eq!(songs.len(), 7);
}
#[tokio::test]
async fn can_get_random_albums() {
let mut ctx = test::ContextBuilder::new(test_name!())
.mount(TEST_MOUNT_NAME, "test-data/small-collection")
.build()
.await;
ctx.updater.update().await.unwrap();
let albums = ctx.browser.get_random_albums(1).await.unwrap();
assert_eq!(albums.len(), 1);
}
#[tokio::test]
async fn can_get_recent_albums() {
let mut ctx = test::ContextBuilder::new(test_name!())
.mount(TEST_MOUNT_NAME, "test-data/small-collection")
.build()
.await;
ctx.updater.update().await.unwrap();
let albums = ctx.browser.get_recent_albums(2).await.unwrap();
assert_eq!(albums.len(), 2);
}
#[tokio::test]
async fn can_get_a_song() {
let mut ctx = test::ContextBuilder::new(test_name!())

View file

@ -1,32 +1,142 @@
use std::{collections::HashMap, sync::Arc};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use rand::{rngs::ThreadRng, seq::IteratorRandom};
use tokio::sync::RwLock;
use crate::app::collection;
#[derive(Clone, Default)]
#[derive(Clone)]
pub struct Index {
lookups: Arc<RwLock<Lookups>>,
}
impl Index {
pub fn new() -> Self {
Self::default()
Self {
lookups: Arc::default(),
}
}
pub async fn replace_lookup_tables(&mut self, new_lookups: Lookups) {
pub(super) async fn replace_lookup_tables(&mut self, new_lookups: Lookups) {
let mut lock = self.lookups.write().await;
*lock = new_lookups;
}
pub async fn get_random_albums(
&self,
count: usize,
) -> Result<Vec<collection::Album>, collection::Error> {
let lookups = self.lookups.read().await;
Ok(lookups
.songs_by_albums
.keys()
.choose_multiple(&mut ThreadRng::default(), count)
.iter()
.filter_map(|k| lookups.get_album(k))
.collect())
}
pub async fn get_recent_albums(
&self,
count: i64,
) -> Result<Vec<collection::Album>, collection::Error> {
// TODO implement
Ok(vec![])
}
}
// TODO how can clients refer to an album?
#[derive(Clone, PartialEq, Eq, Hash)]
struct AlbumKey {
pub artists: Vec<String>,
pub name: Option<String>,
}
#[derive(Default)]
pub struct Lookups {
data: HashMap<String, String>,
pub(super) struct Lookups {
all_songs: HashMap<String, collection::Song>,
songs_by_albums: HashMap<AlbumKey, HashSet<String>>, // TODO should this store collection::Album structs instead?
}
impl Lookups {
pub fn add_song(&mut self, _song: &collection::Song) {
// todo!()
pub fn add_song(&mut self, song: &collection::Song) {
self.all_songs
.insert(song.virtual_path.clone(), song.clone());
let album_artists = match song.album_artists.0.is_empty() {
true => &song.artists.0,
false => &song.album_artists.0,
};
let album_key = AlbumKey {
artists: album_artists.iter().cloned().collect(),
name: song.album.clone(),
};
let song_list = match self.songs_by_albums.get_mut(&album_key) {
Some(l) => l,
None => {
self.songs_by_albums
.insert(album_key.clone(), HashSet::new());
self.songs_by_albums.get_mut(&album_key).unwrap()
}
};
song_list.insert(song.virtual_path.clone());
}
pub fn get_album(&self, key: &AlbumKey) -> Option<collection::Album> {
let Some(songs) = self.songs_by_albums.get(key) else {
return None;
};
let songs: Vec<&collection::Song> =
songs.iter().filter_map(|s| self.all_songs.get(s)).collect();
Some(collection::Album {
name: key.name.clone(),
artwork: songs.iter().find_map(|s| s.artwork.clone()),
artists: key.artists.iter().cloned().collect(),
year: songs.iter().find_map(|s| s.year),
date_added: songs
.iter()
.min_by_key(|s| s.date_added)
.map(|s| s.date_added)
.unwrap_or_default(),
})
}
}
#[cfg(test)]
mod test {
use crate::app::test;
use crate::test_name;
const TEST_MOUNT_NAME: &str = "root";
#[tokio::test]
async fn can_get_random_albums() {
let mut ctx = test::ContextBuilder::new(test_name!())
.mount(TEST_MOUNT_NAME, "test-data/small-collection")
.build()
.await;
ctx.updater.update().await.unwrap();
let albums = ctx.index.get_random_albums(1).await.unwrap();
assert_eq!(albums.len(), 1);
}
#[tokio::test]
async fn can_get_recent_albums() {
let mut ctx = test::ContextBuilder::new(test_name!())
.mount(TEST_MOUNT_NAME, "test-data/small-collection")
.build()
.await;
ctx.updater.update().await.unwrap();
let albums = ctx.index.get_recent_albums(2).await.unwrap();
assert_eq!(albums.len(), 2);
}
}

View file

@ -23,6 +23,17 @@ impl<'q> sqlx::Encode<'q, Sqlite> for MultiString {
}
}
impl<'q> sqlx::Decode<'q, Sqlite> for MultiString {
fn decode(
value: <Sqlite as sqlx::database::HasValueRef<'q>>::ValueRef,
) -> Result<Self, sqlx::error::BoxDynError> {
let s: &str = sqlx::Decode::<Sqlite>::decode(value)?;
Ok(MultiString(
s.split(MultiString::SEPARATOR).map(str::to_owned).collect(),
))
}
}
impl sqlx::Type<Sqlite> for MultiString {
fn type_info() -> SqliteTypeInfo {
<&str as sqlx::Type<Sqlite>>::type_info()

View file

@ -1,11 +1,13 @@
use std::path::PathBuf;
use sqlx::prelude::FromRow;
use crate::{
app::vfs::{self},
db,
};
#[derive(Debug, PartialEq, Eq)]
#[derive(Clone, Debug, FromRow, PartialEq, Eq)]
pub struct MultiString(pub Vec<String>);
impl MultiString {
@ -43,7 +45,7 @@ pub enum File {
Song(Song),
}
#[derive(Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Song {
pub id: i64,
pub path: String,
@ -72,3 +74,12 @@ pub struct Directory {
pub virtual_path: String,
pub virtual_parent: Option<String>,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Album {
pub name: Option<String>,
pub artwork: Option<String>,
pub artists: Vec<String>,
pub year: Option<i64>,
pub date_added: i64,
}

View file

@ -47,6 +47,8 @@ impl Updater {
}
});
// TODO populate index w/ whatever is already in DB
updater
}

View file

@ -7,6 +7,7 @@ use crate::test::*;
pub struct Context {
pub db: DB,
pub browser: collection::Browser,
pub index: collection::Index,
pub updater: collection::Updater,
pub config_manager: config::Manager,
pub ddns_manager: ddns::Manager,
@ -81,6 +82,7 @@ impl ContextBuilder {
Context {
db,
browser,
index,
updater,
config_manager,
ddns_manager,

View file

@ -33,6 +33,12 @@ impl FromRef<App> for app::collection::Browser {
}
}
impl FromRef<App> for app::collection::Index {
fn from_ref(app: &App) -> Self {
app.index.clone()
}
}
impl FromRef<App> for app::collection::Updater {
fn from_ref(app: &App) -> Self {
app.updater.clone()

View file

@ -269,7 +269,7 @@ fn collection_files_to_response(
files
.into_iter()
.map(|f| f.into())
.collect::<Vec<dto::CollectionFile>>(),
.collect::<Vec<dto::BrowserEntry>>(),
)
.into_response(),
}
@ -294,23 +294,20 @@ fn songs_to_response(files: Vec<collection::Song>, api_version: APIMajorVersion)
}
}
fn directories_to_response(
files: Vec<collection::Directory>,
api_version: APIMajorVersion,
) -> Response {
fn albums_to_response(albums: Vec<collection::Album>, api_version: APIMajorVersion) -> Response {
match api_version {
APIMajorVersion::V7 => Json(
files
albums
.into_iter()
.map(|f| f.into())
.collect::<Vec<dto::v7::Directory>>(),
)
.into_response(),
APIMajorVersion::V8 => Json(
files
albums
.into_iter()
.map(|f| f.into())
.collect::<Vec<dto::Directory>>(),
.collect::<Vec<dto::Album>>(),
)
.into_response(),
}
@ -371,25 +368,25 @@ async fn get_flatten(
async fn get_random(
_auth: Auth,
api_version: APIMajorVersion,
State(browser): State<collection::Browser>,
State(index): State<collection::Index>,
) -> Response {
let directories = match browser.get_random_albums(20).await {
let albums = match index.get_random_albums(20).await {
Ok(d) => d,
Err(e) => return APIError::from(e).into_response(),
};
directories_to_response(directories, api_version)
albums_to_response(albums, api_version)
}
async fn get_recent(
_auth: Auth,
api_version: APIMajorVersion,
State(browser): State<collection::Browser>,
State(index): State<collection::Index>,
) -> Response {
let directories = match browser.get_recent_albums(20).await {
let albums = match index.get_recent_albums(20).await {
Ok(d) => d,
Err(e) => return APIError::from(e).into_response(),
};
directories_to_response(directories, api_version)
albums_to_response(albums, api_version)
}
async fn get_search_root(

View file

@ -302,7 +302,6 @@ pub struct Directory {
pub year: Option<i64>,
pub album: Option<String>,
pub artwork: Option<String>,
pub date_added: i64,
}
impl From<collection::Directory> for Directory {
@ -313,7 +312,21 @@ impl From<collection::Directory> for Directory {
year: None,
album: None,
artwork: None,
date_added: 0,
}
}
}
impl From<collection::Album> for Directory {
fn from(a: collection::Album) -> Self {
Self {
path: todo!(), // TODO implement
artist: match a.artists.is_empty() {
true => None,
false => Some(a.artists.join("")),
},
year: a.year,
album: a.name,
artwork: a.artwork,
}
}
}

View file

@ -228,21 +228,6 @@ impl From<settings::Settings> for Settings {
}
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CollectionFile {
Directory(Directory),
Song(Song),
}
impl From<collection::File> for CollectionFile {
fn from(f: collection::File) -> Self {
match f {
collection::File::Directory(d) => Self::Directory(d.into()),
collection::File::Song(s) => Self::Song(s.into()),
}
}
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Song {
pub path: String,
@ -296,16 +281,47 @@ impl From<collection::Song> for Song {
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Directory {
pub struct BrowserEntry {
pub path: String,
pub is_directory: bool,
}
impl From<collection::Directory> for Directory {
fn from(d: collection::Directory) -> Self {
Self {
path: d.virtual_path,
impl From<collection::File> for BrowserEntry {
fn from(file: collection::File) -> Self {
match file {
collection::File::Directory(d) => Self {
is_directory: true,
path: d.virtual_path,
},
collection::File::Song(s) => Self {
is_directory: false,
path: s.virtual_path,
},
}
}
}
// TODO: Preferencesshould have dto types
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Album {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artwork: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub artists: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub year: Option<i64>,
}
impl From<collection::Album> for Album {
fn from(a: collection::Album) -> Self {
Self {
name: a.name,
artwork: a.artwork,
artists: a.artists,
year: a.year,
}
}
}
// TODO: Preferences should have dto types

View file

@ -119,7 +119,7 @@ pub trait TestService {
loop {
let browse_request = protocol::browse::<V8>(Path::new(""));
let response = self
.fetch_json::<(), Vec<dto::CollectionFile>>(&browse_request)
.fetch_json::<(), Vec<dto::BrowserEntry>>(&browse_request)
.await;
let entries = response.body();
if !entries.is_empty() {

View file

@ -50,13 +50,13 @@ async fn trigger_index_golden_path() {
let request = protocol::random::<V8>();
let response = service.fetch_json::<_, Vec<dto::Directory>>(&request).await;
let response = service.fetch_json::<_, Vec<dto::Album>>(&request).await;
let entries = response.body();
assert_eq!(entries.len(), 0);
service.index().await;
let response = service.fetch_json::<_, Vec<dto::Directory>>(&request).await;
let response = service.fetch_json::<_, Vec<dto::Album>>(&request).await;
let entries = response.body();
assert_eq!(entries.len(), 3);
}

View file

@ -24,7 +24,7 @@ async fn browse_root() {
let request = protocol::browse::<V8>(&PathBuf::new());
let response = service
.fetch_json::<_, Vec<dto::CollectionFile>>(&request)
.fetch_json::<_, Vec<dto::BrowserEntry>>(&request)
.await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
@ -42,7 +42,7 @@ async fn browse_directory() {
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted"].iter().collect();
let request = protocol::browse::<V8>(&path);
let response = service
.fetch_json::<_, Vec<dto::CollectionFile>>(&request)
.fetch_json::<_, Vec<dto::BrowserEntry>>(&request)
.await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
@ -168,7 +168,7 @@ async fn random_golden_path() {
service.login().await;
let request = protocol::random::<V8>();
let response = service.fetch_json::<_, Vec<dto::Directory>>(&request).await;
let response = service.fetch_json::<_, Vec<dto::Album>>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
assert_eq!(entries.len(), 3);
@ -184,7 +184,24 @@ async fn random_with_trailing_slash() {
let mut request = protocol::random::<V8>();
add_trailing_slash(&mut request);
let response = service.fetch_json::<_, Vec<dto::Directory>>(&request).await;
let response = service.fetch_json::<_, Vec<dto::Album>>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
assert_eq!(entries.len(), 3);
}
#[tokio::test]
async fn random_golden_path_api_v7() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let request = protocol::random::<V7>();
let response = service
.fetch_json::<_, Vec<dto::v7::Directory>>(&request)
.await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
assert_eq!(entries.len(), 3);
@ -207,7 +224,7 @@ async fn recent_golden_path() {
service.login().await;
let request = protocol::recent::<V8>();
let response = service.fetch_json::<_, Vec<dto::Directory>>(&request).await;
let response = service.fetch_json::<_, Vec<dto::Album>>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
assert_eq!(entries.len(), 3);
@ -223,7 +240,24 @@ async fn recent_with_trailing_slash() {
let mut request = protocol::recent::<V8>();
add_trailing_slash(&mut request);
let response = service.fetch_json::<_, Vec<dto::Directory>>(&request).await;
let response = service.fetch_json::<_, Vec<dto::Album>>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
assert_eq!(entries.len(), 3);
}
#[tokio::test]
async fn recent_golden_path_api_v7() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let request = protocol::recent::<V7>();
let response = service
.fetch_json::<_, Vec<dto::v7::Directory>>(&request)
.await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
assert_eq!(entries.len(), 3);
@ -245,7 +279,7 @@ async fn search_without_query() {
let request = protocol::search::<V8>("");
let response = service
.fetch_json::<_, Vec<dto::CollectionFile>>(&request)
.fetch_json::<_, Vec<dto::BrowserEntry>>(&request)
.await;
assert_eq!(response.status(), StatusCode::OK);
}
@ -260,14 +294,18 @@ async fn search_with_query() {
let request = protocol::search::<V8>("door");
let response = service
.fetch_json::<_, Vec<dto::CollectionFile>>(&request)
.fetch_json::<_, Vec<dto::BrowserEntry>>(&request)
.await;
let results = response.body();
assert_eq!(results.len(), 1);
match results[0] {
dto::CollectionFile::Song(ref s) => {
assert_eq!(s.title, Some("Beyond The Door".into()))
}
_ => panic!(),
}
let path: PathBuf = [
TEST_MOUNT_NAME,
"Khemmis",
"Hunted",
"04 - Beyond The Door.mp3",
]
.iter()
.collect();
assert_eq!(results[0].path, path.to_string_lossy());
}