Browsing via index (WIP)

This commit is contained in:
Antoine Gersant 2024-07-31 03:41:32 -07:00
parent b4b0e1181f
commit e8af339cde
18 changed files with 170 additions and 646 deletions

View file

@ -70,7 +70,6 @@ impl App {
let index_manager = collection::IndexManager::new(db.clone()).await; let index_manager = collection::IndexManager::new(db.clone()).await;
let browser = collection::Browser::new(db.clone(), vfs_manager.clone()); let browser = collection::Browser::new(db.clone(), vfs_manager.clone());
let updater = collection::Updater::new( let updater = collection::Updater::new(
db.clone(),
index_manager.clone(), index_manager.clone(),
settings_manager.clone(), settings_manager.clone(),
vfs_manager.clone(), vfs_manager.clone(),

View file

@ -1,15 +1,11 @@
mod browser; mod browser;
mod cleaner;
mod index; mod index;
mod inserter;
mod scanner; mod scanner;
mod types; mod types;
mod updater; mod updater;
pub use browser::*; pub use browser::*;
pub use cleaner::*;
pub use index::*; pub use index::*;
pub use inserter::*;
pub use scanner::*; pub use scanner::*;
pub use types::*; pub use types::*;
pub use updater::*; pub use updater::*;

View file

@ -18,152 +18,22 @@ impl Browser {
where where
P: AsRef<Path>, P: AsRef<Path>,
{ {
let mut output = Vec::new(); todo!();
let mut connection = self.db.connect().await?;
if path.as_ref().components().count() == 0 {
// Browse top-level
let directories = sqlx::query_as!(
collection::Directory,
"SELECT * FROM directories WHERE virtual_parent IS NULL"
)
.fetch_all(connection.as_mut())
.await?;
output.extend(directories.into_iter().map(collection::File::Directory));
} else {
let vfs = self.vfs_manager.get_vfs().await?;
match vfs.virtual_to_real(&path) {
Ok(p) if p.exists() => {}
_ => {
return Err(collection::Error::DirectoryNotFound(
path.as_ref().to_owned(),
))
}
}
let path = path.as_ref().to_string_lossy();
// Browse sub-directory
let directories = sqlx::query_as!(
collection::Directory,
"SELECT * FROM directories WHERE virtual_parent = $1 ORDER BY virtual_path COLLATE NOCASE ASC",
path
)
.fetch_all(connection.as_mut())
.await?;
output.extend(directories.into_iter().map(collection::File::Directory));
let songs = sqlx::query_as!(
collection::Song,
"SELECT * FROM songs WHERE virtual_parent = $1 ORDER BY virtual_path COLLATE NOCASE ASC",
path
)
.fetch_all(connection.as_mut())
.await?;
output.extend(songs.into_iter().map(collection::File::Song));
}
Ok(output)
} }
pub async fn flatten<P>(&self, path: P) -> Result<Vec<collection::Song>, collection::Error> pub async fn flatten<P>(&self, path: P) -> Result<Vec<collection::Song>, collection::Error>
where where
P: AsRef<Path>, P: AsRef<Path>,
{ {
let mut connection = self.db.connect().await?; todo!();
let songs = if path.as_ref().parent().is_some() {
let vfs = self.vfs_manager.get_vfs().await?;
match vfs.virtual_to_real(&path) {
Ok(p) if p.exists() => {}
_ => {
return Err(collection::Error::DirectoryNotFound(
path.as_ref().to_owned(),
))
}
}
let song_path_filter = {
let mut path_buf = path.as_ref().to_owned();
path_buf.push("%");
path_buf.as_path().to_string_lossy().into_owned()
};
sqlx::query_as!(
collection::Song,
"SELECT * FROM songs WHERE virtual_path LIKE $1 ORDER BY virtual_path COLLATE NOCASE ASC",
song_path_filter
)
.fetch_all(connection.as_mut())
.await?
} else {
sqlx::query_as!(
collection::Song,
"SELECT * FROM songs ORDER BY virtual_path COLLATE NOCASE ASC"
)
.fetch_all(connection.as_mut())
.await?
};
Ok(songs)
} }
pub async fn search(&self, query: &str) -> Result<Vec<collection::File>, collection::Error> { pub async fn search(&self, query: &str) -> Result<Vec<collection::File>, collection::Error> {
let mut connection = self.db.connect().await?; todo!();
let like_test = format!("%{}%", query);
let mut output = Vec::new();
// Find dirs with matching path and parent not matching
{
let directories = sqlx::query_as!(
collection::Directory,
"SELECT * FROM directories WHERE virtual_path LIKE $1 AND virtual_parent NOT LIKE $1",
like_test
)
.fetch_all(connection.as_mut())
.await?;
output.extend(directories.into_iter().map(collection::File::Directory));
}
// Find songs with matching title/album/artist and non-matching parent
{
let songs = sqlx::query_as!(
collection::Song,
r#"
SELECT * FROM songs
WHERE ( virtual_path LIKE $1
OR title LIKE $1
OR album LIKE $1
OR artists LIKE $1
OR album_artists LIKE $1
)
AND virtual_parent NOT LIKE $1
"#,
like_test
)
.fetch_all(connection.as_mut())
.await?;
output.extend(songs.into_iter().map(collection::File::Song));
}
Ok(output)
} }
pub async fn get_song(&self, path: &Path) -> Result<collection::Song, collection::Error> { pub async fn get_song(&self, path: &Path) -> Result<collection::Song, collection::Error> {
let mut connection = self.db.connect().await?; todo!();
let path = path.to_string_lossy();
let song = sqlx::query_as!(
collection::Song,
"SELECT * FROM songs WHERE virtual_path = $1",
path
)
.fetch_one(connection.as_mut())
.await?;
Ok(song)
} }
} }
@ -190,7 +60,7 @@ mod test {
assert_eq!(files.len(), 1); assert_eq!(files.len(), 1);
match files[0] { match files[0] {
collection::File::Directory(ref d) => { collection::File::Directory(ref d) => {
assert_eq!(d.virtual_path, root_path.to_str().unwrap()) assert_eq!(d, &root_path)
} }
_ => panic!("Expected directory"), _ => panic!("Expected directory"),
} }
@ -216,14 +86,14 @@ mod test {
assert_eq!(files.len(), 2); assert_eq!(files.len(), 2);
match files[0] { match files[0] {
collection::File::Directory(ref d) => { collection::File::Directory(ref d) => {
assert_eq!(d.virtual_path, khemmis_path.to_str().unwrap()) assert_eq!(d, &khemmis_path)
} }
_ => panic!("Expected directory"), _ => panic!("Expected directory"),
} }
match files[1] { match files[1] {
collection::File::Directory(ref d) => { collection::File::Directory(ref d) => {
assert_eq!(d.virtual_path, tobokegao_path.to_str().unwrap()) assert_eq!(d, &tobokegao_path)
} }
_ => panic!("Expected directory"), _ => panic!("Expected directory"),
} }
@ -283,10 +153,7 @@ mod test {
let artwork_virtual_path = picnic_virtual_dir.join("Folder.png"); let artwork_virtual_path = picnic_virtual_dir.join("Folder.png");
let song = ctx.browser.get_song(&song_virtual_path).await.unwrap(); let song = ctx.browser.get_song(&song_virtual_path).await.unwrap();
assert_eq!( assert_eq!(song.virtual_path, song_virtual_path);
song.virtual_path,
song_virtual_path.to_string_lossy().as_ref()
);
assert_eq!(song.track_number, Some(5)); assert_eq!(song.track_number, Some(5));
assert_eq!(song.disc_number, None); assert_eq!(song.disc_number, None);
assert_eq!(song.title, Some("シャーベット (Sherbet)".to_owned())); assert_eq!(song.title, Some("シャーベット (Sherbet)".to_owned()));
@ -297,9 +164,6 @@ mod test {
assert_eq!(song.album_artists, collection::MultiString(vec![])); assert_eq!(song.album_artists, collection::MultiString(vec![]));
assert_eq!(song.album, Some("Picnic".to_owned())); assert_eq!(song.album, Some("Picnic".to_owned()));
assert_eq!(song.year, Some(2016)); assert_eq!(song.year, Some(2016));
assert_eq!( assert_eq!(song.artwork, Some(artwork_virtual_path));
song.artwork,
Some(artwork_virtual_path.to_string_lossy().into_owned())
);
} }
} }

View file

@ -1,89 +0,0 @@
use rayon::prelude::*;
use sqlx::{QueryBuilder, Sqlite};
use std::path::Path;
use crate::app::{collection, vfs};
use crate::db::DB;
#[derive(Clone)]
pub struct Cleaner {
db: DB,
vfs_manager: vfs::Manager,
}
impl Cleaner {
const BUFFER_SIZE: usize = 500; // Deletions in each transaction
pub fn new(db: DB, vfs_manager: vfs::Manager) -> Self {
Self { db, vfs_manager }
}
pub async fn clean(&self) -> Result<(), collection::Error> {
tokio::try_join!(self.clean_songs(), self.clean_directories())?;
Ok(())
}
pub async fn clean_directories(&self) -> Result<(), collection::Error> {
let directories = {
let mut connection = self.db.connect().await?;
sqlx::query!("SELECT path, virtual_path FROM directories")
.fetch_all(connection.as_mut())
.await?
};
let vfs = self.vfs_manager.get_vfs().await?;
let missing_directories = tokio::task::spawn_blocking(move || {
directories
.into_par_iter()
.filter(|d| !vfs.exists(&d.virtual_path) || !Path::new(&d.path).exists())
.map(|d| d.virtual_path)
.collect::<Vec<_>>()
})
.await?;
let mut connection = self.db.connect().await?;
for chunk in missing_directories[..].chunks(Self::BUFFER_SIZE) {
QueryBuilder::<Sqlite>::new("DELETE FROM directories WHERE virtual_path IN ")
.push_tuples(chunk, |mut b, virtual_path| {
b.push_bind(virtual_path);
})
.build()
.execute(connection.as_mut())
.await?;
}
Ok(())
}
pub async fn clean_songs(&self) -> Result<(), collection::Error> {
let songs = {
let mut connection = self.db.connect().await?;
sqlx::query!("SELECT path, virtual_path FROM songs")
.fetch_all(connection.as_mut())
.await?
};
let vfs = self.vfs_manager.get_vfs().await?;
let deleted_songs = tokio::task::spawn_blocking(move || {
songs
.into_par_iter()
.filter(|s| !vfs.exists(&s.virtual_path) || !Path::new(&s.path).exists())
.map(|s| s.virtual_path)
.collect::<Vec<_>>()
})
.await?;
for chunk in deleted_songs[..].chunks(Cleaner::BUFFER_SIZE) {
let mut connection = self.db.connect().await?;
QueryBuilder::<Sqlite>::new("DELETE FROM songs WHERE virtual_path IN ")
.push_tuples(chunk, |mut b, virtual_path| {
b.push_bind(virtual_path);
})
.build()
.execute(connection.as_mut())
.await?;
}
Ok(())
}
}

View file

@ -2,6 +2,7 @@ use std::{
borrow::BorrowMut, borrow::BorrowMut,
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
hash::{DefaultHasher, Hash, Hasher}, hash::{DefaultHasher, Hash, Hasher},
path::{Path, PathBuf},
sync::{Arc, RwLock}, sync::{Arc, RwLock},
}; };
@ -73,18 +74,20 @@ impl IndexManager {
Ok(true) Ok(true)
} }
pub(super) async fn get_songs(&self) -> Vec<collection::Song> { pub async fn browse(
&self,
virtual_path: PathBuf,
) -> Result<Vec<collection::File>, collection::Error> {
spawn_blocking({ spawn_blocking({
let index_manager = self.clone(); let index_manager = self.clone();
move || { move || {
let index = index_manager.index.read().unwrap(); let index = index_manager.index.read().unwrap();
index.songs.values().cloned().collect::<Vec<_>>() index.browse(virtual_path)
} }
}) })
.await .await
.unwrap() .unwrap()
} }
pub async fn get_artist( pub async fn get_artist(
&self, &self,
artist_key: &ArtistKey, artist_key: &ArtistKey,
@ -165,14 +168,32 @@ impl IndexManager {
#[derive(Default)] #[derive(Default)]
pub(super) struct IndexBuilder { pub(super) struct IndexBuilder {
directories: HashMap<PathBuf, HashSet<collection::File>>,
// filesystem: Trie<>,
songs: HashMap<SongID, collection::Song>, songs: HashMap<SongID, collection::Song>,
artists: HashMap<ArtistID, Artist>, artists: HashMap<ArtistID, Artist>,
albums: HashMap<AlbumID, Album>, albums: HashMap<AlbumID, Album>,
} }
impl IndexBuilder { impl IndexBuilder {
pub fn add_directory(&mut self, directory: collection::Directory) {
self.directories
.entry(directory.virtual_path.clone())
.or_default();
if let Some(parent) = directory.virtual_parent {
self.directories
.entry(parent.clone())
.or_default()
.insert(collection::File::Directory(directory.virtual_path));
}
}
pub fn add_song(&mut self, song: collection::Song) { pub fn add_song(&mut self, song: collection::Song) {
let song_id: SongID = song.song_id(); let song_id: SongID = song.song_id();
self.directories
.entry(song.virtual_parent.clone())
.or_default()
.insert(collection::File::Song(song.virtual_path.clone()));
self.add_song_to_album(&song); self.add_song_to_album(&song);
self.add_album_to_artists(&song); self.add_album_to_artists(&song);
self.songs.insert(song_id, song); self.songs.insert(song_id, song);
@ -250,6 +271,7 @@ impl IndexBuilder {
}); });
Index { Index {
directories: self.directories,
songs: self.songs, songs: self.songs,
artists: self.artists, artists: self.artists,
albums: self.albums, albums: self.albums,
@ -260,6 +282,7 @@ impl IndexBuilder {
#[derive(Default, Serialize, Deserialize)] #[derive(Default, Serialize, Deserialize)]
pub(super) struct Index { pub(super) struct Index {
directories: HashMap<PathBuf, HashSet<collection::File>>,
songs: HashMap<SongID, collection::Song>, songs: HashMap<SongID, collection::Song>,
artists: HashMap<ArtistID, Artist>, artists: HashMap<ArtistID, Artist>,
albums: HashMap<AlbumID, Album>, albums: HashMap<AlbumID, Album>,
@ -267,6 +290,18 @@ pub(super) struct Index {
} }
impl Index { impl Index {
pub(self) fn browse<P: AsRef<Path>>(
&self,
virtual_path: P,
) -> Result<Vec<collection::File>, collection::Error> {
let Some(files) = self.directories.get(virtual_path.as_ref()) else {
return Err(collection::Error::DirectoryNotFound(
virtual_path.as_ref().to_owned(),
));
};
Ok(files.iter().cloned().collect())
}
pub(self) fn get_artist(&self, artist_id: ArtistID) -> Option<collection::Artist> { pub(self) fn get_artist(&self, artist_id: ArtistID) -> Option<collection::Artist> {
self.artists.get(&artist_id).map(|a| { self.artists.get(&artist_id).map(|a| {
let mut albums = a let mut albums = a
@ -312,7 +347,7 @@ struct SongID(u64);
#[derive(Clone, Eq, Hash, PartialEq, Serialize, Deserialize)] #[derive(Clone, Eq, Hash, PartialEq, Serialize, Deserialize)]
pub struct SongKey { pub struct SongKey {
pub virtual_path: String, pub virtual_path: PathBuf,
} }
impl From<&collection::Song> for SongKey { impl From<&collection::Song> for SongKey {
@ -360,7 +395,7 @@ impl From<&ArtistKey> for ArtistID {
#[derive(Clone, Default, Serialize, Deserialize)] #[derive(Clone, Default, Serialize, Deserialize)]
struct Album { struct Album {
pub name: Option<String>, pub name: Option<String>,
pub artwork: Option<String>, pub artwork: Option<PathBuf>,
pub artists: Vec<String>, pub artists: Vec<String>,
pub year: Option<i64>, pub year: Option<i64>,
pub date_added: i64, pub date_added: i64,

View file

@ -1,137 +0,0 @@
use std::borrow::Cow;
use log::error;
use sqlx::{
encode::IsNull,
pool::PoolConnection,
sqlite::{SqliteArgumentValue, SqliteTypeInfo},
QueryBuilder, Sqlite,
};
use crate::app::collection::{self, MultiString};
use crate::db::DB;
impl<'q> sqlx::Encode<'q, Sqlite> for MultiString {
fn encode_by_ref(&self, args: &mut Vec<SqliteArgumentValue<'q>>) -> IsNull {
if self.0.is_empty() {
IsNull::Yes
} else {
let joined = self.0.join(MultiString::SEPARATOR);
args.push(SqliteArgumentValue::Text(Cow::Owned(joined)));
IsNull::No
}
}
}
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()
}
}
pub struct Inserter<T> {
new_entries: Vec<T>,
db: DB,
}
impl<T> Inserter<T>
where
T: Insertable,
{
const BUFFER_SIZE: usize = 1000;
pub fn new(db: DB) -> Self {
let new_entries = Vec::with_capacity(Self::BUFFER_SIZE);
Self { new_entries, db }
}
pub async fn insert(&mut self, entry: T) {
self.new_entries.push(entry);
if self.new_entries.len() >= Self::BUFFER_SIZE {
self.flush().await;
}
}
pub async fn flush(&mut self) {
if self.new_entries.is_empty() {
return;
}
let Ok(connection) = self.db.connect().await else {
error!("Could not acquire connection to insert new entries in database");
return;
};
match Insertable::bulk_insert(&self.new_entries, connection).await {
Ok(_) => self.new_entries.clear(),
Err(e) => error!("Could not insert new entries in database: {}", e),
};
}
}
pub trait Insertable
where
Self: Sized,
{
async fn bulk_insert(
entries: &Vec<Self>,
connection: PoolConnection<Sqlite>,
) -> Result<(), sqlx::Error>;
}
impl Insertable for collection::Directory {
async fn bulk_insert(
entries: &Vec<Self>,
mut connection: PoolConnection<Sqlite>,
) -> Result<(), sqlx::Error> {
QueryBuilder::<Sqlite>::new("INSERT INTO directories(path, virtual_path, virtual_parent) ")
.push_values(entries.iter(), |mut b, directory| {
b.push_bind(&directory.path)
.push_bind(&directory.virtual_path)
.push_bind(&directory.virtual_parent);
})
.build()
.execute(connection.as_mut())
.await
.map(|_| ())
}
}
impl Insertable for collection::Song {
async fn bulk_insert(
entries: &Vec<Self>,
mut connection: PoolConnection<Sqlite>,
) -> Result<(), sqlx::Error> {
QueryBuilder::<Sqlite>::new("INSERT INTO songs(path, virtual_path, virtual_parent, track_number, disc_number, title, artists, album_artists, year, album, artwork, duration, lyricists, composers, genres, labels) ")
.push_values(entries.iter(), |mut b, song| {
b.push_bind(&song.path)
.push_bind(&song.virtual_path)
.push_bind(&song.virtual_parent)
.push_bind(song.track_number)
.push_bind(song.disc_number)
.push_bind(&song.title)
.push_bind(&song.artists)
.push_bind(&song.album_artists)
.push_bind(song.year)
.push_bind(&song.album)
.push_bind(&song.artwork)
.push_bind(song.duration)
.push_bind(&song.lyricists)
.push_bind(&song.composers)
.push_bind(&song.genres)
.push_bind(&song.labels);
})
.build()
.execute(connection.as_mut())
.await.map(|_| ())
}
}

View file

@ -109,10 +109,7 @@ fn process_directory<P: AsRef<Path>, Q: AsRef<Path>>(
}; };
let entry_real_path = real_path.as_ref().join(&name); let entry_real_path = real_path.as_ref().join(&name);
let entry_real_path_string = entry_real_path.to_string_lossy().to_string();
let entry_virtual_path = virtual_path.as_ref().join(&name); let entry_virtual_path = virtual_path.as_ref().join(&name);
let entry_virtual_path_string = entry_virtual_path.to_string_lossy().to_string();
if entry_real_path.is_dir() { if entry_real_path.is_dir() {
scope.spawn({ scope.spawn({
@ -132,14 +129,9 @@ fn process_directory<P: AsRef<Path>, Q: AsRef<Path>>(
}); });
} else if let Some(metadata) = formats::read_metadata(&entry_real_path) { } else if let Some(metadata) = formats::read_metadata(&entry_real_path) {
songs.push(collection::Song { songs.push(collection::Song {
id: 0, path: entry_real_path.clone(),
path: entry_real_path_string.clone(), virtual_path: entry_virtual_path.clone(),
virtual_path: entry_virtual_path.to_string_lossy().to_string(), virtual_parent: entry_virtual_path.parent().unwrap().to_owned(),
virtual_parent: entry_virtual_path
.parent()
.unwrap()
.to_string_lossy()
.to_string(),
track_number: metadata.track_number.map(|n| n as i64), track_number: metadata.track_number.map(|n| n as i64),
disc_number: metadata.disc_number.map(|n| n as i64), disc_number: metadata.disc_number.map(|n| n as i64),
title: metadata.title, title: metadata.title,
@ -147,9 +139,7 @@ fn process_directory<P: AsRef<Path>, Q: AsRef<Path>>(
album_artists: MultiString(metadata.album_artists), album_artists: MultiString(metadata.album_artists),
year: metadata.year.map(|n| n as i64), year: metadata.year.map(|n| n as i64),
album: metadata.album, album: metadata.album,
artwork: metadata artwork: metadata.has_artwork.then(|| entry_virtual_path.clone()),
.has_artwork
.then(|| entry_virtual_path_string.clone()),
duration: metadata.duration.map(|n| n as i64), duration: metadata.duration.map(|n| n as i64),
lyricists: MultiString(metadata.lyricists), lyricists: MultiString(metadata.lyricists),
composers: MultiString(metadata.composers), composers: MultiString(metadata.composers),
@ -162,7 +152,7 @@ fn process_directory<P: AsRef<Path>, Q: AsRef<Path>>(
.as_ref() .as_ref()
.is_some_and(|r| r.is_match(name.to_str().unwrap_or_default())) .is_some_and(|r| r.is_match(name.to_str().unwrap_or_default()))
{ {
artwork_file = Some(entry_virtual_path_string); artwork_file = Some(entry_virtual_path);
} }
} }
@ -173,14 +163,8 @@ fn process_directory<P: AsRef<Path>, Q: AsRef<Path>>(
directories_output directories_output
.send(collection::Directory { .send(collection::Directory {
id: 0, virtual_path: virtual_path.as_ref().to_owned(),
path: real_path.as_ref().to_string_lossy().to_string(), virtual_parent: virtual_path.as_ref().parent().map(Path::to_owned),
virtual_path: virtual_path.as_ref().to_string_lossy().to_string(),
virtual_parent: virtual_path
.as_ref()
.parent()
.map(|p| p.to_string_lossy().to_string())
.filter(|p| !p.is_empty()),
}) })
.ok(); .ok();
} }

View file

@ -8,6 +8,7 @@ use crate::{
db, db,
}; };
// TODO no longer needed!!
#[derive(Clone, Debug, FromRow, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, FromRow, PartialEq, Eq, Serialize, Deserialize)]
pub struct MultiString(pub Vec<String>); pub struct MultiString(pub Vec<String>);
@ -48,18 +49,17 @@ pub enum Error {
ThreadJoining(#[from] tokio::task::JoinError), ThreadJoining(#[from] tokio::task::JoinError),
} }
#[derive(Debug, PartialEq, Eq)] #[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub enum File { pub enum File {
Directory(Directory), Directory(PathBuf),
Song(Song), Song(PathBuf),
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Song { pub struct Song {
pub id: i64, pub path: PathBuf,
pub path: String, pub virtual_path: PathBuf,
pub virtual_path: String, pub virtual_parent: PathBuf,
pub virtual_parent: String,
pub track_number: Option<i64>, pub track_number: Option<i64>,
pub disc_number: Option<i64>, pub disc_number: Option<i64>,
pub title: Option<String>, pub title: Option<String>,
@ -67,7 +67,7 @@ pub struct Song {
pub album_artists: MultiString, pub album_artists: MultiString,
pub year: Option<i64>, pub year: Option<i64>,
pub album: Option<String>, pub album: Option<String>,
pub artwork: Option<String>, pub artwork: Option<PathBuf>,
pub duration: Option<i64>, pub duration: Option<i64>,
pub lyricists: MultiString, pub lyricists: MultiString,
pub composers: MultiString, pub composers: MultiString,
@ -78,10 +78,8 @@ pub struct Song {
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub struct Directory { pub struct Directory {
pub id: i64, pub virtual_path: PathBuf,
pub path: String, pub virtual_parent: Option<PathBuf>,
pub virtual_path: String,
pub virtual_parent: Option<String>,
} }
#[derive(Debug, Default, PartialEq, Eq)] #[derive(Debug, Default, PartialEq, Eq)]
@ -93,7 +91,7 @@ pub struct Artist {
#[derive(Debug, Default, PartialEq, Eq)] #[derive(Debug, Default, PartialEq, Eq)]
pub struct Album { pub struct Album {
pub name: Option<String>, pub name: Option<String>,
pub artwork: Option<String>, pub artwork: Option<PathBuf>,
pub artists: Vec<String>, pub artists: Vec<String>,
pub year: Option<i64>, pub year: Option<i64>,
pub date_added: i64, pub date_added: i64,

View file

@ -6,14 +6,10 @@ use tokio::{
time::Instant, time::Instant,
}; };
use crate::{ use crate::app::{collection::*, settings, vfs};
app::{collection::*, settings, vfs},
db::DB,
};
#[derive(Clone)] #[derive(Clone)]
pub struct Updater { pub struct Updater {
db: DB,
index_manager: IndexManager, index_manager: IndexManager,
settings_manager: settings::Manager, settings_manager: settings::Manager,
vfs_manager: vfs::Manager, vfs_manager: vfs::Manager,
@ -22,13 +18,11 @@ pub struct Updater {
impl Updater { impl Updater {
pub async fn new( pub async fn new(
db: DB,
index_manager: IndexManager, index_manager: IndexManager,
settings_manager: settings::Manager, settings_manager: settings::Manager,
vfs_manager: vfs::Manager, vfs_manager: vfs::Manager,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
let updater = Self { let updater = Self {
db,
index_manager, index_manager,
vfs_manager, vfs_manager,
settings_manager, settings_manager,
@ -94,49 +88,48 @@ impl Updater {
album_art_pattern, album_art_pattern,
); );
let mut directory_inserter = Inserter::<Directory>::new(self.db.clone()); let index_task = tokio::spawn(async move {
let directory_task = tokio::spawn(async move {
let capacity = 500;
let mut buffer: Vec<Directory> = Vec::with_capacity(capacity);
loop {
match collection_directories_input
.recv_many(&mut buffer, capacity)
.await
{
0 => break,
_ => {
for directory in buffer.drain(0..) {
directory_inserter.insert(directory).await;
}
}
}
}
directory_inserter.flush().await;
});
let song_task = tokio::spawn(async move {
let capacity = 500; let capacity = 500;
let mut index_builder = IndexBuilder::default(); let mut index_builder = IndexBuilder::default();
let mut buffer: Vec<Song> = Vec::with_capacity(capacity); let mut song_buffer: Vec<Song> = Vec::with_capacity(capacity);
let mut directory_buffer: Vec<Directory> = Vec::with_capacity(capacity);
loop { loop {
match collection_songs_input let exhausted_songs = match collection_songs_input
.recv_many(&mut buffer, capacity) .recv_many(&mut song_buffer, capacity)
.await .await
{ {
0 => break, 0 => true,
_ => { _ => {
for song in buffer.drain(0..) { for song in song_buffer.drain(0..) {
index_builder.add_song(song); index_builder.add_song(song);
} }
false
} }
};
let exhausted_directories = match collection_directories_input
.recv_many(&mut directory_buffer, capacity)
.await
{
0 => true,
_ => {
for directory in directory_buffer.drain(0..) {
index_builder.add_directory(directory);
}
false
}
};
if exhausted_directories && exhausted_songs {
break;
} }
} }
index_builder.build() index_builder.build()
}); });
let index = tokio::join!(scanner.scan(), directory_task, song_task).2?; let index = tokio::join!(scanner.scan(), index_task).1?;
self.index_manager.persist_index(&index).await?; self.index_manager.persist_index(&index).await?;
self.index_manager.replace_index(index).await; self.index_manager.replace_index(index).await;
@ -145,33 +138,6 @@ impl Updater {
start.elapsed().as_millis() as f32 / 1000.0 start.elapsed().as_millis() as f32 / 1000.0
); );
let start = Instant::now();
info!("Beginning collection DB update");
tokio::task::spawn({
let db = self.db.clone();
let vfs_manager = self.vfs_manager.clone();
let index_manager = self.index_manager.clone();
async move {
let cleaner = Cleaner::new(db.clone(), vfs_manager);
if let Err(e) = cleaner.clean().await {
error!("Error while cleaning up database: {}", e);
}
let mut song_inserter = Inserter::<Song>::new(db.clone());
for song in index_manager.get_songs().await {
song_inserter.insert(song).await;
}
song_inserter.flush().await;
}
})
.await?;
info!(
"Collection DB update took {} seconds",
start.elapsed().as_millis() as f32 / 1000.0
);
Ok(()) Ok(())
} }
} }
@ -181,7 +147,7 @@ mod test {
use std::path::PathBuf; use std::path::PathBuf;
use crate::{ use crate::{
app::{collection::*, settings, test}, app::{settings, test},
test_name, test_name,
}; };
@ -197,71 +163,10 @@ mod test {
ctx.updater.update().await.unwrap(); ctx.updater.update().await.unwrap();
ctx.updater.update().await.unwrap(); // Validates that subsequent updates don't run into conflicts ctx.updater.update().await.unwrap(); // Validates that subsequent updates don't run into conflicts
let mut connection = ctx.db.connect().await.unwrap(); todo!();
let all_directories = sqlx::query_as!(Directory, "SELECT * FROM directories")
.fetch_all(connection.as_mut())
.await
.unwrap();
let all_songs = sqlx::query_as!(Song, "SELECT * FROM songs")
.fetch_all(connection.as_mut())
.await
.unwrap();
assert_eq!(all_directories.len(), 6);
assert_eq!(all_songs.len(), 13);
}
#[tokio::test] // assert_eq!(all_directories.len(), 6);
async fn scan_removes_missing_content() { // assert_eq!(all_songs.len(), 13);
let builder = test::ContextBuilder::new(test_name!());
let original_collection_dir: PathBuf = ["test-data", "small-collection"].iter().collect();
let test_collection_dir: PathBuf = builder.test_directory.join("small-collection");
let copy_options = fs_extra::dir::CopyOptions::new();
fs_extra::dir::copy(
original_collection_dir,
&builder.test_directory,
&copy_options,
)
.unwrap();
let mut ctx = builder
.mount(TEST_MOUNT_NAME, test_collection_dir.to_str().unwrap())
.build()
.await;
ctx.updater.update().await.unwrap();
{
let mut connection = ctx.db.connect().await.unwrap();
let all_directories = sqlx::query_as!(Directory, "SELECT * FROM directories")
.fetch_all(connection.as_mut())
.await
.unwrap();
let all_songs = sqlx::query_as!(Song, "SELECT * FROM songs")
.fetch_all(connection.as_mut())
.await
.unwrap();
assert_eq!(all_directories.len(), 6);
assert_eq!(all_songs.len(), 13);
}
let khemmis_directory = test_collection_dir.join("Khemmis");
std::fs::remove_dir_all(khemmis_directory).unwrap();
ctx.updater.update().await.unwrap();
{
let mut connection = ctx.db.connect().await.unwrap();
let all_directories = sqlx::query_as!(Directory, "SELECT * FROM directories")
.fetch_all(connection.as_mut())
.await
.unwrap();
let all_songs = sqlx::query_as!(Song, "SELECT * FROM songs")
.fetch_all(connection.as_mut())
.await
.unwrap();
assert_eq!(all_directories.len(), 4);
assert_eq!(all_songs.len(), 8);
}
} }
#[tokio::test] #[tokio::test]
@ -277,10 +182,7 @@ mod test {
let song_virtual_path = picnic_virtual_dir.join("07 - なぜ (Why).mp3"); let song_virtual_path = picnic_virtual_dir.join("07 - なぜ (Why).mp3");
let song = ctx.browser.get_song(&song_virtual_path).await.unwrap(); let song = ctx.browser.get_song(&song_virtual_path).await.unwrap();
assert_eq!( assert_eq!(song.artwork, Some(song_virtual_path));
song.artwork,
Some(song_virtual_path.to_string_lossy().into_owned())
);
} }
#[tokio::test] #[tokio::test]
@ -306,10 +208,7 @@ mod test {
[TEST_MOUNT_NAME, "Khemmis", "Hunted"].iter().collect(); [TEST_MOUNT_NAME, "Khemmis", "Hunted"].iter().collect();
let artwork_virtual_path = hunted_virtual_dir.join("Folder.jpg"); let artwork_virtual_path = hunted_virtual_dir.join("Folder.jpg");
let song = &ctx.browser.flatten(&hunted_virtual_dir).await.unwrap()[0]; let song = &ctx.browser.flatten(&hunted_virtual_dir).await.unwrap()[0];
assert_eq!( assert_eq!(song.artwork, Some(artwork_virtual_path));
song.artwork,
Some(artwork_virtual_path.to_string_lossy().into_owned())
);
} }
} }
} }

View file

@ -1,5 +1,6 @@
use core::clone::Clone; use core::clone::Clone;
use sqlx::{Acquire, QueryBuilder, Sqlite}; use sqlx::{Acquire, QueryBuilder, Sqlite};
use std::path::PathBuf;
use crate::app::{collection::Song, vfs}; use crate::app::{collection::Song, vfs};
use crate::db::{self, DB}; use crate::db::{self, DB};
@ -48,7 +49,7 @@ impl Manager {
&self, &self,
playlist_name: &str, playlist_name: &str,
owner: &str, owner: &str,
content: &[String], content: &[PathBuf],
) -> Result<(), Error> { ) -> Result<(), Error> {
let vfs = self.vfs_manager.get_vfs().await?; let vfs = self.vfs_manager.get_vfs().await?;
@ -145,19 +146,20 @@ impl Manager {
.ok_or(Error::PlaylistNotFound)?; .ok_or(Error::PlaylistNotFound)?;
// List songs // List songs
sqlx::query_as!( todo!();
Song, // sqlx::query_as!(
r#" // Song,
SELECT s.* // r#"
FROM playlist_songs ps // SELECT s.*
INNER JOIN songs s ON ps.path = s.path // FROM playlist_songs ps
WHERE ps.playlist = $1 // INNER JOIN songs s ON ps.virtual_path = s.virtual_path
ORDER BY ps.ordering // WHERE ps.playlist = $1
"#, // ORDER BY ps.ordering
playlist_id // "#,
) // playlist_id
.fetch_all(connection.as_mut()) // )
.await? // .fetch_all(connection.as_mut())
// .await?
}; };
Ok(songs) Ok(songs)
@ -230,14 +232,14 @@ mod test {
ctx.updater.update().await.unwrap(); ctx.updater.update().await.unwrap();
let playlist_content: Vec<String> = ctx let playlist_content = ctx
.browser .browser
.flatten(Path::new(TEST_MOUNT_NAME)) .flatten(Path::new(TEST_MOUNT_NAME))
.await .await
.unwrap() .unwrap()
.into_iter() .into_iter()
.map(|s| s.virtual_path) .map(|s| s.virtual_path)
.collect(); .collect::<Vec<_>>();
assert_eq!(playlist_content.len(), 13); assert_eq!(playlist_content.len(), 13);
ctx.playlist_manager ctx.playlist_manager
@ -295,14 +297,14 @@ mod test {
ctx.updater.update().await.unwrap(); ctx.updater.update().await.unwrap();
let playlist_content: Vec<String> = ctx let playlist_content = ctx
.browser .browser
.flatten(Path::new(TEST_MOUNT_NAME)) .flatten(Path::new(TEST_MOUNT_NAME))
.await .await
.unwrap() .unwrap()
.into_iter() .into_iter()
.map(|s| s.virtual_path) .map(|s| s.virtual_path)
.collect(); .collect::<Vec<_>>();
assert_eq!(playlist_content.len(), 13); assert_eq!(playlist_content.len(), 13);
ctx.playlist_manager ctx.playlist_manager
@ -327,6 +329,6 @@ mod test {
] ]
.iter() .iter()
.collect(); .collect();
assert_eq!(songs[0].virtual_path, first_song_path.to_str().unwrap()); assert_eq!(songs[0].virtual_path, first_song_path);
} }
} }

View file

@ -5,7 +5,6 @@ use crate::db::DB;
use crate::test::*; use crate::test::*;
pub struct Context { pub struct Context {
pub db: DB,
pub browser: collection::Browser, pub browser: collection::Browser,
pub index_manager: collection::IndexManager, pub index_manager: collection::IndexManager,
pub updater: collection::Updater, pub updater: collection::Updater,
@ -70,7 +69,6 @@ impl ContextBuilder {
let browser = collection::Browser::new(db.clone(), vfs_manager.clone()); let browser = collection::Browser::new(db.clone(), vfs_manager.clone());
let index_manager = collection::IndexManager::new(db.clone()).await; let index_manager = collection::IndexManager::new(db.clone()).await;
let updater = collection::Updater::new( let updater = collection::Updater::new(
db.clone(),
index_manager.clone(), index_manager.clone(),
settings_manager.clone(), settings_manager.clone(),
vfs_manager.clone(), vfs_manager.clone(),
@ -82,7 +80,6 @@ impl ContextBuilder {
config_manager.apply(&self.config).await.unwrap(); config_manager.apply(&self.config).await.unwrap();
Context { Context {
db,
browser, browser,
index_manager, index_manager,
updater, updater,

View file

@ -52,12 +52,6 @@ impl VFS {
VFS { mounts } VFS { mounts }
} }
pub fn exists<P: AsRef<Path>>(&self, virtual_path: P) -> bool {
self.mounts
.iter()
.any(|m| virtual_path.as_ref().starts_with(&m.name))
}
pub fn virtual_to_real<P: AsRef<Path>>(&self, virtual_path: P) -> Result<PathBuf, Error> { pub fn virtual_to_real<P: AsRef<Path>>(&self, virtual_path: P) -> Result<PathBuf, Error> {
for mount in &self.mounts { for mount in &self.mounts {
let mount_path = Path::new(&mount.name); let mount_path = Path::new(&mount.name);

View file

@ -45,36 +45,6 @@ CREATE TABLE users (
UNIQUE(name) UNIQUE(name)
); );
CREATE TABLE directories (
id INTEGER PRIMARY KEY NOT NULL,
path TEXT NOT NULL,
virtual_path TEXT NOT NULL,
virtual_parent TEXT,
UNIQUE(path) ON CONFLICT REPLACE
);
CREATE TABLE songs (
id INTEGER PRIMARY KEY NOT NULL,
path TEXT NOT NULL,
virtual_path TEXT NOT NULL,
virtual_parent TEXT NOT NULL,
track_number INTEGER,
disc_number INTEGER,
title TEXT,
artists TEXT,
album_artists TEXT,
year INTEGER,
album TEXT,
artwork TEXT,
duration INTEGER,
lyricists TEXT,
composers TEXT,
genres TEXT,
labels TEXT,
date_added INTEGER DEFAULT 0 NOT NULL,
UNIQUE(path) ON CONFLICT REPLACE
);
CREATE TABLE collection_index ( CREATE TABLE collection_index (
id INTEGER PRIMARY KEY NOT NULL CHECK(id = 0), id INTEGER PRIMARY KEY NOT NULL CHECK(id = 0),
content BLOB content BLOB
@ -93,7 +63,7 @@ CREATE TABLE playlists (
CREATE TABLE playlist_songs ( CREATE TABLE playlist_songs (
id INTEGER PRIMARY KEY NOT NULL, id INTEGER PRIMARY KEY NOT NULL,
playlist INTEGER NOT NULL, playlist INTEGER NOT NULL,
path TEXT NOT NULL, virtual_path TEXT NOT NULL,
ordering INTEGER NOT NULL, ordering INTEGER NOT NULL,
FOREIGN KEY(playlist) REFERENCES playlists(id) ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY(playlist) REFERENCES playlists(id) ON DELETE CASCADE ON UPDATE CASCADE,
UNIQUE(playlist, ordering) ON CONFLICT REPLACE UNIQUE(playlist, ordering) ON CONFLICT REPLACE

View file

@ -1,3 +1,5 @@
use std::path::PathBuf;
use axum::{ use axum::{
extract::{DefaultBodyLimit, Path, Query, State}, extract::{DefaultBodyLimit, Path, Query, State},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
@ -321,9 +323,9 @@ fn albums_to_response(albums: Vec<collection::Album>, api_version: APIMajorVersi
async fn get_browse_root( async fn get_browse_root(
_auth: Auth, _auth: Auth,
api_version: APIMajorVersion, api_version: APIMajorVersion,
State(browser): State<collection::Browser>, State(index_manager): State<collection::IndexManager>,
) -> Response { ) -> Response {
let result = match browser.browse(std::path::Path::new("")).await { let result = match index_manager.browse(PathBuf::new()).await {
Ok(r) => r, Ok(r) => r,
Err(e) => return APIError::from(e).into_response(), Err(e) => return APIError::from(e).into_response(),
}; };
@ -333,11 +335,10 @@ async fn get_browse_root(
async fn get_browse( async fn get_browse(
_auth: Auth, _auth: Auth,
api_version: APIMajorVersion, api_version: APIMajorVersion,
State(browser): State<collection::Browser>, State(index_manager): State<collection::IndexManager>,
Path(path): Path<String>, Path(path): Path<PathBuf>,
) -> Response { ) -> Response {
let path = percent_decode_str(&path).decode_utf8_lossy(); let result = match index_manager.browse(path).await {
let result = match browser.browse(std::path::Path::new(path.as_ref())).await {
Ok(r) => r, Ok(r) => r,
Err(e) => return APIError::from(e).into_response(), Err(e) => return APIError::from(e).into_response(),
}; };

View file

@ -4,7 +4,7 @@ use crate::app::{
collection::{self, MultiString}, collection::{self, MultiString},
config, ddns, settings, thumbnail, user, vfs, config, ddns, settings, thumbnail, user, vfs,
}; };
use std::convert::From; use std::{convert::From, path::PathBuf};
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] #[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct Version { pub struct Version {
@ -240,8 +240,29 @@ pub enum CollectionFile {
impl From<collection::File> for CollectionFile { impl From<collection::File> for CollectionFile {
fn from(f: collection::File) -> Self { fn from(f: collection::File) -> Self {
match f { match f {
collection::File::Directory(d) => Self::Directory(d.into()), collection::File::Directory(d) => Self::Directory(Directory {
collection::File::Song(s) => Self::Song(s.into()), path: d,
artist: None,
year: None,
album: None,
artwork: None,
}),
collection::File::Song(s) => Self::Song(Song {
path: s,
track_number: None,
disc_number: None,
title: None,
artist: None,
album_artist: None,
year: None,
album: None,
artwork: None,
duration: None,
lyricist: None,
composer: None,
genre: None,
label: None,
}),
} }
} }
} }
@ -258,7 +279,7 @@ impl MultiString {
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Song { pub struct Song {
pub path: String, pub path: PathBuf,
pub track_number: Option<i64>, pub track_number: Option<i64>,
pub disc_number: Option<i64>, pub disc_number: Option<i64>,
pub title: Option<String>, pub title: Option<String>,
@ -266,7 +287,7 @@ pub struct Song {
pub album_artist: Option<String>, pub album_artist: Option<String>,
pub year: Option<i64>, pub year: Option<i64>,
pub album: Option<String>, pub album: Option<String>,
pub artwork: Option<String>, pub artwork: Option<PathBuf>,
pub duration: Option<i64>, pub duration: Option<i64>,
pub lyricist: Option<String>, pub lyricist: Option<String>,
pub composer: Option<String>, pub composer: Option<String>,
@ -297,23 +318,11 @@ impl From<collection::Song> for Song {
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Directory { pub struct Directory {
pub path: String, pub path: PathBuf,
pub artist: Option<String>, pub artist: Option<String>,
pub year: Option<i64>, pub year: Option<i64>,
pub album: Option<String>, pub album: Option<String>,
pub artwork: Option<String>, pub artwork: Option<PathBuf>,
}
impl From<collection::Directory> for Directory {
fn from(d: collection::Directory) -> Self {
Self {
path: d.virtual_path,
artist: None,
year: None,
album: None,
artwork: None,
}
}
} }
impl From<collection::Album> for Directory { impl From<collection::Album> for Directory {

View file

@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::app::{collection, config, ddns, settings, thumbnail, user, vfs}; use crate::app::{collection, config, ddns, settings, thumbnail, user, vfs};
use std::convert::From; use std::{convert::From, path::PathBuf};
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] #[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct Version { pub struct Version {
@ -73,7 +73,7 @@ pub struct ListPlaylistsEntry {
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct SavePlaylistInput { pub struct SavePlaylistInput {
pub tracks: Vec<String>, pub tracks: Vec<std::path::PathBuf>,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -230,7 +230,7 @@ impl From<settings::Settings> for Settings {
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Song { pub struct Song {
pub path: String, pub path: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub track_number: Option<i64>, pub track_number: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@ -246,7 +246,7 @@ pub struct Song {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub album: Option<String>, pub album: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub artwork: Option<String>, pub artwork: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub duration: Option<i64>, pub duration: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
@ -282,7 +282,7 @@ impl From<collection::Song> for Song {
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BrowserEntry { pub struct BrowserEntry {
pub path: String, pub path: PathBuf,
pub is_directory: bool, pub is_directory: bool,
} }
@ -291,11 +291,11 @@ impl From<collection::File> for BrowserEntry {
match file { match file {
collection::File::Directory(d) => Self { collection::File::Directory(d) => Self {
is_directory: true, is_directory: true,
path: d.virtual_path, path: d,
}, },
collection::File::Song(s) => Self { collection::File::Song(s) => Self {
is_directory: false, is_directory: false,
path: s.virtual_path, path: s,
}, },
} }
} }
@ -332,7 +332,7 @@ impl From<collection::Album> for AlbumHeader {
fn from(a: collection::Album) -> Self { fn from(a: collection::Album) -> Self {
Self { Self {
name: a.name, name: a.name,
artwork: a.artwork, artwork: a.artwork.map(|a| a.to_string_lossy().to_string()),
artists: a.artists, artists: a.artists,
year: a.year, year: a.year,
} }

View file

@ -317,5 +317,5 @@ async fn search_with_query() {
] ]
.iter() .iter()
.collect(); .collect();
assert_eq!(results[0].path, path.to_string_lossy()); assert_eq!(results[0].path, path);
} }

View file

@ -1,3 +1,5 @@
use std::path::Path;
use http::StatusCode; use http::StatusCode;
use crate::server::dto; use crate::server::dto;
@ -53,7 +55,7 @@ async fn save_playlist_large() {
service.login().await; service.login().await;
let tracks = (0..100_000) let tracks = (0..100_000)
.map(|_| "My Super Cool Song".to_string()) .map(|_| Path::new("My Super Cool Song").to_owned())
.collect(); .collect();
let my_playlist = dto::SavePlaylistInput { tracks }; let my_playlist = dto::SavePlaylistInput { tracks };
let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist); let request = protocol::save_playlist(TEST_PLAYLIST_NAME, my_playlist);