diff --git a/src/app.rs b/src/app.rs
index bde215f..cd76400 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -4,12 +4,12 @@ use std::path::PathBuf;
use crate::db::{self, DB};
use crate::paths::Paths;
+pub mod collection;
pub mod config;
pub mod ddns;
-pub mod index;
+pub mod formats;
pub mod lastfm;
pub mod playlist;
-pub mod scanner;
pub mod settings;
pub mod thumbnail;
pub mod user;
@@ -35,8 +35,9 @@ pub struct App {
pub port: u16,
pub web_dir_path: PathBuf,
pub swagger_dir_path: PathBuf,
- pub scanner: scanner::Scanner,
- pub index: index::Index,
+ pub updater: collection::Updater,
+ pub browser: collection::Browser,
+ pub index: collection::Index,
pub config_manager: config::Manager,
pub ddns_manager: ddns::Manager,
pub lastfm_manager: lastfm::Manager,
@@ -64,9 +65,14 @@ impl App {
let auth_secret = settings_manager.get_auth_secret().await?;
let ddns_manager = ddns::Manager::new(db.clone());
let user_manager = user::Manager::new(db.clone(), auth_secret);
- let scanner =
- scanner::Scanner::new(db.clone(), vfs_manager.clone(), settings_manager.clone());
- let index = index::Index::new(db.clone(), vfs_manager.clone());
+ let index = collection::Index::new();
+ let browser = collection::Browser::new(db.clone(), vfs_manager.clone());
+ let updater = collection::Updater::new(
+ db.clone(),
+ index.clone(),
+ settings_manager.clone(),
+ vfs_manager.clone(),
+ );
let config_manager = config::Manager::new(
settings_manager.clone(),
user_manager.clone(),
@@ -75,7 +81,7 @@ impl App {
);
let playlist_manager = playlist::Manager::new(db.clone(), vfs_manager.clone());
let thumbnail_manager = thumbnail::Manager::new(thumbnails_dir_path);
- let lastfm_manager = lastfm::Manager::new(index.clone(), user_manager.clone());
+ let lastfm_manager = lastfm::Manager::new(browser.clone(), user_manager.clone());
if let Some(config_path) = paths.config_file_path {
let config = config::Config::from_path(&config_path)?;
@@ -86,7 +92,8 @@ impl App {
port,
web_dir_path: paths.web_dir_path,
swagger_dir_path: paths.swagger_dir_path,
- scanner,
+ updater,
+ browser,
index,
config_manager,
ddns_manager,
diff --git a/src/app/collection.rs b/src/app/collection.rs
new file mode 100644
index 0000000..09ed312
--- /dev/null
+++ b/src/app/collection.rs
@@ -0,0 +1,15 @@
+mod browser;
+mod cleaner;
+mod index;
+mod inserter;
+mod scanner;
+mod types;
+mod updater;
+
+pub use browser::*;
+pub use cleaner::*;
+pub use index::*;
+pub use inserter::*;
+pub use scanner::*;
+pub use types::*;
+pub use updater::*;
diff --git a/src/app/collection/browser.rs b/src/app/collection/browser.rs
new file mode 100644
index 0000000..51184a9
--- /dev/null
+++ b/src/app/collection/browser.rs
@@ -0,0 +1,343 @@
+use std::path::Path;
+
+use crate::app::{collection, vfs};
+use crate::db::DB;
+
+#[derive(Clone)]
+pub struct Browser {
+ db: DB,
+ vfs_manager: vfs::Manager,
+}
+
+impl Browser {
+ pub fn new(db: DB, vfs_manager: vfs::Manager) -> Self {
+ Self { db, vfs_manager }
+ }
+
+ pub async fn browse
(&self, path: P) -> Result, collection::Error>
+ where
+ P: AsRef,
+ {
+ let mut output = Vec::new();
+ 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(&self, path: P) -> Result, collection::Error>
+ where
+ P: AsRef,
+ {
+ let mut connection = self.db.connect().await?;
+
+ 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 get_random_albums(
+ &self,
+ count: i64,
+ ) -> Result, collection::Error> {
+ // TODO move to Index
+ Ok(vec![])
+ }
+
+ pub async fn get_recent_albums(
+ &self,
+ count: i64,
+ ) -> Result, collection::Error> {
+ // TODO move to Index
+ Ok(vec![])
+ }
+
+ pub async fn search(&self, query: &str) -> Result, collection::Error> {
+ let mut connection = self.db.connect().await?;
+ 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 {
+ let mut connection = self.db.connect().await?;
+
+ 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)
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use std::path::{Path, PathBuf};
+
+ use super::*;
+ use crate::app::test;
+ use crate::test_name;
+
+ const TEST_MOUNT_NAME: &str = "root";
+
+ #[tokio::test]
+ async fn can_browse_top_level() {
+ let mut ctx = test::ContextBuilder::new(test_name!())
+ .mount(TEST_MOUNT_NAME, "test-data/small-collection")
+ .build()
+ .await;
+ ctx.updater.update().await.unwrap();
+
+ let root_path = Path::new(TEST_MOUNT_NAME);
+ let files = ctx.browser.browse(Path::new("")).await.unwrap();
+ assert_eq!(files.len(), 1);
+ match files[0] {
+ collection::File::Directory(ref d) => {
+ assert_eq!(d.virtual_path, root_path.to_str().unwrap())
+ }
+ _ => panic!("Expected directory"),
+ }
+ }
+
+ #[tokio::test]
+ async fn can_browse_directory() {
+ let khemmis_path: PathBuf = [TEST_MOUNT_NAME, "Khemmis"].iter().collect();
+ let tobokegao_path: PathBuf = [TEST_MOUNT_NAME, "Tobokegao"].iter().collect();
+
+ let mut ctx = test::ContextBuilder::new(test_name!())
+ .mount(TEST_MOUNT_NAME, "test-data/small-collection")
+ .build()
+ .await;
+ ctx.updater.update().await.unwrap();
+
+ let files = ctx
+ .browser
+ .browse(Path::new(TEST_MOUNT_NAME))
+ .await
+ .unwrap();
+
+ assert_eq!(files.len(), 2);
+ match files[0] {
+ collection::File::Directory(ref d) => {
+ assert_eq!(d.virtual_path, khemmis_path.to_str().unwrap())
+ }
+ _ => panic!("Expected directory"),
+ }
+
+ match files[1] {
+ collection::File::Directory(ref d) => {
+ assert_eq!(d.virtual_path, tobokegao_path.to_str().unwrap())
+ }
+ _ => panic!("Expected directory"),
+ }
+ }
+
+ #[tokio::test]
+ async fn can_flatten_root() {
+ let mut ctx = test::ContextBuilder::new(test_name!())
+ .mount(TEST_MOUNT_NAME, "test-data/small-collection")
+ .build()
+ .await;
+ ctx.updater.update().await.unwrap();
+ let songs = ctx
+ .browser
+ .flatten(Path::new(TEST_MOUNT_NAME))
+ .await
+ .unwrap();
+ assert_eq!(songs.len(), 13);
+ assert_eq!(songs[0].title, Some("Above The Water".to_owned()));
+ }
+
+ #[tokio::test]
+ async fn can_flatten_directory() {
+ let mut ctx = test::ContextBuilder::new(test_name!())
+ .mount(TEST_MOUNT_NAME, "test-data/small-collection")
+ .build()
+ .await;
+ ctx.updater.update().await.unwrap();
+ let path: PathBuf = [TEST_MOUNT_NAME, "Tobokegao"].iter().collect();
+ let songs = ctx.browser.flatten(path).await.unwrap();
+ assert_eq!(songs.len(), 8);
+ }
+
+ #[tokio::test]
+ async fn can_flatten_directory_with_shared_prefix() {
+ let mut ctx = test::ContextBuilder::new(test_name!())
+ .mount(TEST_MOUNT_NAME, "test-data/small-collection")
+ .build()
+ .await;
+ ctx.updater.update().await.unwrap();
+ let path: PathBuf = [TEST_MOUNT_NAME, "Tobokegao", "Picnic"].iter().collect(); // Prefix of '(Picnic Remixes)'
+ let songs = ctx.browser.flatten(path).await.unwrap();
+ 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!())
+ .mount(TEST_MOUNT_NAME, "test-data/small-collection")
+ .build()
+ .await;
+
+ ctx.updater.update().await.unwrap();
+
+ let picnic_virtual_dir: PathBuf = [TEST_MOUNT_NAME, "Tobokegao", "Picnic"].iter().collect();
+ let song_virtual_path = picnic_virtual_dir.join("05 - シャーベット (Sherbet).mp3");
+ let artwork_virtual_path = picnic_virtual_dir.join("Folder.png");
+
+ let song = ctx.browser.get_song(&song_virtual_path).await.unwrap();
+ assert_eq!(
+ song.virtual_path,
+ song_virtual_path.to_string_lossy().as_ref()
+ );
+ assert_eq!(song.track_number, Some(5));
+ assert_eq!(song.disc_number, None);
+ assert_eq!(song.title, Some("シャーベット (Sherbet)".to_owned()));
+ assert_eq!(
+ song.artists,
+ collection::MultiString(vec!["Tobokegao".to_owned()])
+ );
+ assert_eq!(song.album_artists, collection::MultiString(vec![]));
+ assert_eq!(song.album, Some("Picnic".to_owned()));
+ assert_eq!(song.year, Some(2016));
+ assert_eq!(
+ song.artwork,
+ Some(artwork_virtual_path.to_string_lossy().into_owned())
+ );
+ }
+}
diff --git a/src/app/collection/cleaner.rs b/src/app/collection/cleaner.rs
new file mode 100644
index 0000000..c149737
--- /dev/null
+++ b/src/app/collection/cleaner.rs
@@ -0,0 +1,89 @@
+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::>()
+ })
+ .await?;
+
+ let mut connection = self.db.connect().await?;
+ for chunk in missing_directories[..].chunks(Self::BUFFER_SIZE) {
+ QueryBuilder::::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::>()
+ })
+ .await?;
+
+ for chunk in deleted_songs[..].chunks(Cleaner::BUFFER_SIZE) {
+ let mut connection = self.db.connect().await?;
+ QueryBuilder::::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(())
+ }
+}
diff --git a/src/app/collection/index.rs b/src/app/collection/index.rs
new file mode 100644
index 0000000..123ee44
--- /dev/null
+++ b/src/app/collection/index.rs
@@ -0,0 +1,32 @@
+use std::{collections::HashMap, sync::Arc};
+
+use tokio::sync::RwLock;
+
+use crate::app::collection;
+
+#[derive(Clone, Default)]
+pub struct Index {
+ lookups: Arc>,
+}
+
+impl Index {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub async fn replace_lookup_tables(&mut self, new_lookups: Lookups) {
+ let mut lock = self.lookups.write().await;
+ *lock = new_lookups;
+ }
+}
+
+#[derive(Default)]
+pub struct Lookups {
+ data: HashMap,
+}
+
+impl Lookups {
+ pub fn add_song(&mut self, _song: &collection::Song) {
+ // todo!()
+ }
+}
diff --git a/src/app/collection/inserter.rs b/src/app/collection/inserter.rs
new file mode 100644
index 0000000..5d86139
--- /dev/null
+++ b/src/app/collection/inserter.rs
@@ -0,0 +1,123 @@
+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>) -> 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 sqlx::Type for MultiString {
+ fn type_info() -> SqliteTypeInfo {
+ <&str as sqlx::Type>::type_info()
+ }
+}
+
+pub struct Inserter {
+ new_entries: Vec,
+ db: DB,
+}
+
+impl Inserter
+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) {
+ 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,
+ connection: PoolConnection,
+ ) -> Result<(), sqlx::Error>;
+}
+
+impl Insertable for collection::Directory {
+ async fn bulk_insert(
+ entries: &Vec,
+ mut connection: PoolConnection,
+ ) -> Result<(), sqlx::Error> {
+ QueryBuilder::::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,
+ mut connection: PoolConnection,
+ ) -> Result<(), sqlx::Error> {
+ QueryBuilder::::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(|_| ())
+ }
+}
diff --git a/src/app/collection/scanner.rs b/src/app/collection/scanner.rs
new file mode 100644
index 0000000..cbde420
--- /dev/null
+++ b/src/app/collection/scanner.rs
@@ -0,0 +1,196 @@
+use log::{error, info};
+use rayon::{Scope, ThreadPoolBuilder};
+use regex::Regex;
+use std::cmp::min;
+use std::fs;
+use std::path::Path;
+use std::str::FromStr;
+use tokio::sync::mpsc::UnboundedSender;
+
+use crate::app::vfs;
+use crate::app::{
+ collection::{self, MultiString},
+ formats,
+};
+
+pub struct Scanner {
+ directories_output: UnboundedSender,
+ songs_output: UnboundedSender,
+ vfs_manager: vfs::Manager,
+ artwork_regex: Option,
+}
+
+impl Scanner {
+ pub fn new(
+ directories_output: UnboundedSender,
+ songs_output: UnboundedSender,
+ vfs_manager: vfs::Manager,
+ artwork_regex: Option,
+ ) -> Self {
+ Self {
+ directories_output,
+ songs_output,
+ vfs_manager,
+ artwork_regex,
+ }
+ }
+
+ pub async fn scan(self) -> Result<(), collection::Error> {
+ let vfs = self.vfs_manager.get_vfs().await?;
+ let roots = vfs.mounts().clone();
+
+ let key = "POLARIS_NUM_TRAVERSER_THREADS";
+ let num_threads = std::env::var_os(key)
+ .map(|v| v.to_string_lossy().to_string())
+ .and_then(|v| usize::from_str(&v).ok())
+ .unwrap_or_else(|| min(num_cpus::get(), 4));
+ info!("Browsing collection using {} threads", num_threads);
+
+ let directories_output = self.directories_output.clone();
+ let songs_output = self.songs_output.clone();
+ let artwork_regex = self.artwork_regex.clone();
+
+ let thread_pool = ThreadPoolBuilder::new().num_threads(num_threads).build()?;
+ thread_pool.scope({
+ |scope| {
+ for root in roots {
+ scope.spawn(|scope| {
+ process_directory(
+ scope,
+ root.source,
+ root.name,
+ directories_output.clone(),
+ songs_output.clone(),
+ artwork_regex.clone(),
+ );
+ });
+ }
+ }
+ });
+
+ Ok(())
+ }
+}
+
+fn process_directory, Q: AsRef>(
+ scope: &Scope,
+ real_path: P,
+ virtual_path: Q,
+ directories_output: UnboundedSender,
+ songs_output: UnboundedSender,
+ artwork_regex: Option,
+) {
+ let read_dir = match fs::read_dir(&real_path) {
+ Ok(read_dir) => read_dir,
+ Err(e) => {
+ error!(
+ "Directory read error for `{}`: {}",
+ real_path.as_ref().display(),
+ e
+ );
+ return;
+ }
+ };
+
+ let mut songs = vec![];
+ let mut artwork_file = None;
+
+ for entry in read_dir {
+ let name = match entry {
+ Ok(ref f) => f.file_name(),
+ Err(e) => {
+ error!(
+ "File read error within `{}`: {}",
+ real_path.as_ref().display(),
+ e
+ );
+ break;
+ }
+ };
+
+ 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_string = entry_virtual_path.to_string_lossy().to_string();
+
+ if entry_real_path.is_dir() {
+ scope.spawn({
+ let directories_output = directories_output.clone();
+ let songs_output = songs_output.clone();
+ let artwork_regex = artwork_regex.clone();
+ |scope| {
+ process_directory(
+ scope,
+ entry_real_path,
+ entry_virtual_path,
+ directories_output,
+ songs_output,
+ artwork_regex,
+ );
+ }
+ });
+ } else if let Some(metadata) = formats::read_metadata(&entry_real_path) {
+ songs.push(collection::Song {
+ id: 0,
+ path: entry_real_path_string.clone(),
+ virtual_path: entry_virtual_path.to_string_lossy().to_string(),
+ virtual_parent: entry_virtual_path
+ .parent()
+ .unwrap()
+ .to_string_lossy()
+ .to_string(),
+ track_number: metadata.track_number.map(|n| n as i64),
+ disc_number: metadata.disc_number.map(|n| n as i64),
+ title: metadata.title,
+ artists: MultiString(metadata.artists),
+ album_artists: MultiString(metadata.album_artists),
+ year: metadata.year.map(|n| n as i64),
+ album: metadata.album,
+ artwork: metadata
+ .has_artwork
+ .then(|| entry_virtual_path_string.clone()),
+ duration: metadata.duration.map(|n| n as i64),
+ lyricists: MultiString(metadata.lyricists),
+ composers: MultiString(metadata.composers),
+ genres: MultiString(metadata.genres),
+ labels: MultiString(metadata.labels),
+ date_added: get_date_created(&entry_real_path).unwrap_or_default(),
+ });
+ } else if artwork_file.is_none()
+ && artwork_regex
+ .as_ref()
+ .is_some_and(|r| r.is_match(name.to_str().unwrap_or_default()))
+ {
+ artwork_file = Some(entry_virtual_path_string);
+ }
+ }
+
+ for mut song in songs {
+ song.artwork = song.artwork.or_else(|| artwork_file.clone());
+ songs_output.send(song).ok();
+ }
+
+ directories_output
+ .send(collection::Directory {
+ id: 0,
+ path: real_path.as_ref().to_string_lossy().to_string(),
+ 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();
+}
+
+fn get_date_created>(path: P) -> Option {
+ if let Ok(t) = fs::metadata(path).and_then(|m| m.created().or_else(|_| m.modified())) {
+ t.duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs() as i64)
+ .ok()
+ } else {
+ None
+ }
+}
diff --git a/src/app/collection/types.rs b/src/app/collection/types.rs
new file mode 100644
index 0000000..1286ae3
--- /dev/null
+++ b/src/app/collection/types.rs
@@ -0,0 +1,74 @@
+use std::path::PathBuf;
+
+use crate::{
+ app::vfs::{self},
+ db,
+};
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct MultiString(pub Vec);
+
+impl MultiString {
+ pub const SEPARATOR: &'static str = "\u{000C}";
+}
+
+impl From