Sorting for accented characters
This commit is contained in:
parent
e8845c7ef9
commit
68b8041f97
9 changed files with 583 additions and 208 deletions
279
Cargo.lock
generated
279
Cargo.lock
generated
|
@ -528,6 +528,17 @@ dependencies = [
|
|||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.72",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.13.0"
|
||||
|
@ -977,6 +988,149 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collator"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d370371887d31d56f361c3eaa15743e54f13bc677059c9191c77e099ed6966b2"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collator_data",
|
||||
"icu_collections",
|
||||
"icu_locid_transform",
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"utf16_iter",
|
||||
"utf8_iter",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collator_data"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee3f88741364b7d6269cce6827a3e6a8a2cf408a78f766c9224ab479d5e4ae5"
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locid"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locid_transform"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locid",
|
||||
"icu_locid_transform_data",
|
||||
"icu_provider",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locid_transform_data"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"utf16_iter",
|
||||
"utf8_iter",
|
||||
"write16",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locid_transform",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locid",
|
||||
"icu_provider_macros",
|
||||
"stable_deref_trait",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_macros"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.72",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id3"
|
||||
version = "1.14.0"
|
||||
|
@ -1120,6 +1274,12 @@ version = "0.4.14"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.12"
|
||||
|
@ -1567,6 +1727,7 @@ dependencies = [
|
|||
"getopts",
|
||||
"headers",
|
||||
"http 1.1.0",
|
||||
"icu_collator",
|
||||
"id3",
|
||||
"image",
|
||||
"lasso2",
|
||||
|
@ -2101,6 +2262,12 @@ version = "0.9.8"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
|
||||
|
||||
[[package]]
|
||||
name = "stacker"
|
||||
version = "0.1.15"
|
||||
|
@ -2356,6 +2523,17 @@ version = "1.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394"
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.72",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.11.0"
|
||||
|
@ -2431,6 +2609,16 @@ dependencies = [
|
|||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.8.0"
|
||||
|
@ -2720,6 +2908,18 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf16_iter"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
|
@ -2999,12 +3199,48 @@ dependencies = [
|
|||
"toml 0.5.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "write16"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
|
||||
|
||||
[[package]]
|
||||
name = "yansi"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.72",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.7.35"
|
||||
|
@ -3026,12 +3262,55 @@ dependencies = [
|
|||
"syn 2.0.72",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.72",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.72",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.4.12"
|
||||
|
|
|
@ -22,6 +22,7 @@ enum-map = { version = "2.7.3", features = ["serde"] }
|
|||
getopts = "0.2.21"
|
||||
headers = "0.4"
|
||||
http = "1.1.0"
|
||||
icu_collator = "1.5.0"
|
||||
id3 = "1.14.0"
|
||||
lasso2 = { version = "0.8.2", features = ["serialize"] }
|
||||
lewton = "0.10.2"
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use lasso2::{Rodeo, RodeoReader, Spur};
|
||||
use log::{error, info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::task::spawn_blocking;
|
||||
|
@ -13,6 +11,7 @@ use crate::app::{scanner, Error};
|
|||
|
||||
mod browser;
|
||||
mod collection;
|
||||
mod dictionary;
|
||||
mod query;
|
||||
mod search;
|
||||
mod storage;
|
||||
|
@ -108,7 +107,7 @@ impl Manager {
|
|||
let index_manager = self.clone();
|
||||
move || {
|
||||
let index = index_manager.index.read().unwrap();
|
||||
index.browser.browse(&index.strings, virtual_path)
|
||||
index.browser.browse(&index.dictionary, virtual_path)
|
||||
}
|
||||
})
|
||||
.await
|
||||
|
@ -120,7 +119,7 @@ impl Manager {
|
|||
let index_manager = self.clone();
|
||||
move || {
|
||||
let index = index_manager.index.read().unwrap();
|
||||
index.browser.flatten(&index.strings, virtual_path)
|
||||
index.browser.flatten(&index.dictionary, virtual_path)
|
||||
}
|
||||
})
|
||||
.await
|
||||
|
@ -132,7 +131,7 @@ impl Manager {
|
|||
let index_manager = self.clone();
|
||||
move || {
|
||||
let index = index_manager.index.read().unwrap();
|
||||
index.collection.get_genres(&index.strings)
|
||||
index.collection.get_genres(&index.dictionary)
|
||||
}
|
||||
})
|
||||
.await
|
||||
|
@ -145,13 +144,13 @@ impl Manager {
|
|||
move || {
|
||||
let index = index_manager.index.read().unwrap();
|
||||
let name = index
|
||||
.strings
|
||||
.dictionary
|
||||
.get(&name)
|
||||
.ok_or_else(|| Error::GenreNotFound)?;
|
||||
let genre_key = GenreKey(name);
|
||||
index
|
||||
.collection
|
||||
.get_genre(&index.strings, genre_key)
|
||||
.get_genre(&index.dictionary, genre_key)
|
||||
.ok_or_else(|| Error::GenreNotFound)
|
||||
}
|
||||
})
|
||||
|
@ -164,7 +163,7 @@ impl Manager {
|
|||
let index_manager = self.clone();
|
||||
move || {
|
||||
let index = index_manager.index.read().unwrap();
|
||||
index.collection.get_albums(&index.strings)
|
||||
index.collection.get_albums(&index.dictionary)
|
||||
}
|
||||
})
|
||||
.await
|
||||
|
@ -176,7 +175,7 @@ impl Manager {
|
|||
let index_manager = self.clone();
|
||||
move || {
|
||||
let index = index_manager.index.read().unwrap();
|
||||
index.collection.get_artists(&index.strings)
|
||||
index.collection.get_artists(&index.dictionary)
|
||||
}
|
||||
})
|
||||
.await
|
||||
|
@ -189,13 +188,13 @@ impl Manager {
|
|||
move || {
|
||||
let index = index_manager.index.read().unwrap();
|
||||
let name = index
|
||||
.strings
|
||||
.dictionary
|
||||
.get(name)
|
||||
.ok_or_else(|| Error::ArtistNotFound)?;
|
||||
let artist_key = ArtistKey(name);
|
||||
index
|
||||
.collection
|
||||
.get_artist(&index.strings, artist_key)
|
||||
.get_artist(&index.dictionary, artist_key)
|
||||
.ok_or_else(|| Error::ArtistNotFound)
|
||||
}
|
||||
})
|
||||
|
@ -209,20 +208,20 @@ impl Manager {
|
|||
move || {
|
||||
let index = index_manager.index.read().unwrap();
|
||||
let name = index
|
||||
.strings
|
||||
.dictionary
|
||||
.get(&name)
|
||||
.ok_or_else(|| Error::AlbumNotFound)?;
|
||||
let album_key = AlbumKey {
|
||||
artists: artists
|
||||
.into_iter()
|
||||
.filter_map(|a| index.strings.get(a))
|
||||
.filter_map(|a| index.dictionary.get(a))
|
||||
.map(|k| ArtistKey(k))
|
||||
.collect(),
|
||||
name,
|
||||
};
|
||||
index
|
||||
.collection
|
||||
.get_album(&index.strings, album_key)
|
||||
.get_album(&index.dictionary, album_key)
|
||||
.ok_or_else(|| Error::AlbumNotFound)
|
||||
}
|
||||
})
|
||||
|
@ -242,7 +241,7 @@ impl Manager {
|
|||
let index = index_manager.index.read().unwrap();
|
||||
Ok(index
|
||||
.collection
|
||||
.get_random_albums(&index.strings, seed, offset, count))
|
||||
.get_random_albums(&index.dictionary, seed, offset, count))
|
||||
}
|
||||
})
|
||||
.await
|
||||
|
@ -260,7 +259,7 @@ impl Manager {
|
|||
let index = index_manager.index.read().unwrap();
|
||||
Ok(index
|
||||
.collection
|
||||
.get_recent_albums(&index.strings, offset, count))
|
||||
.get_recent_albums(&index.dictionary, offset, count))
|
||||
}
|
||||
})
|
||||
.await
|
||||
|
@ -275,10 +274,10 @@ impl Manager {
|
|||
virtual_paths
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
p.get(&index.strings)
|
||||
p.get(&index.dictionary)
|
||||
.and_then(|virtual_path| {
|
||||
let key = SongKey { virtual_path };
|
||||
index.collection.get_song(&index.strings, key)
|
||||
index.collection.get_song(&index.dictionary, key)
|
||||
})
|
||||
.ok_or_else(|| Error::SongNotFound)
|
||||
})
|
||||
|
@ -296,7 +295,7 @@ impl Manager {
|
|||
let index = index_manager.index.read().unwrap();
|
||||
index
|
||||
.search
|
||||
.find_songs(&index.collection, &index.strings, &index.canon, &query)
|
||||
.find_songs(&index.collection, &index.dictionary, &query)
|
||||
}
|
||||
})
|
||||
.await
|
||||
|
@ -306,8 +305,7 @@ impl Manager {
|
|||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Index {
|
||||
pub strings: RodeoReader,
|
||||
pub canon: HashMap<String, Spur>,
|
||||
pub dictionary: dictionary::Dictionary,
|
||||
pub browser: browser::Browser,
|
||||
pub collection: collection::Collection,
|
||||
pub search: search::Search,
|
||||
|
@ -316,8 +314,7 @@ pub struct Index {
|
|||
impl Default for Index {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
strings: Rodeo::new().into_reader(),
|
||||
canon: Default::default(),
|
||||
dictionary: Default::default(),
|
||||
browser: Default::default(),
|
||||
collection: Default::default(),
|
||||
search: Default::default(),
|
||||
|
@ -327,8 +324,7 @@ impl Default for Index {
|
|||
|
||||
#[derive(Clone)]
|
||||
pub struct Builder {
|
||||
strings: Rodeo,
|
||||
canon: HashMap<String, Spur>,
|
||||
dictionary_builder: dictionary::Builder,
|
||||
browser_builder: browser::Builder,
|
||||
collection_builder: collection::Builder,
|
||||
search_builder: search::Builder,
|
||||
|
@ -337,8 +333,7 @@ pub struct Builder {
|
|||
impl Builder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
strings: Rodeo::new(),
|
||||
canon: HashMap::default(),
|
||||
dictionary_builder: dictionary::Builder::default(),
|
||||
browser_builder: browser::Builder::default(),
|
||||
collection_builder: collection::Builder::default(),
|
||||
search_builder: search::Builder::default(),
|
||||
|
@ -347,13 +342,13 @@ impl Builder {
|
|||
|
||||
pub fn add_directory(&mut self, directory: scanner::Directory) {
|
||||
self.browser_builder
|
||||
.add_directory(&mut self.strings, directory);
|
||||
.add_directory(&mut self.dictionary_builder, directory);
|
||||
}
|
||||
|
||||
pub fn add_song(&mut self, scanner_song: scanner::Song) {
|
||||
if let Some(storage_song) = store_song(&mut self.strings, &mut self.canon, &scanner_song) {
|
||||
if let Some(storage_song) = store_song(&mut self.dictionary_builder, &scanner_song) {
|
||||
self.browser_builder
|
||||
.add_song(&mut self.strings, &scanner_song);
|
||||
.add_song(&mut self.dictionary_builder, &scanner_song);
|
||||
self.collection_builder.add_song(&storage_song);
|
||||
self.search_builder.add_song(&scanner_song, &storage_song);
|
||||
}
|
||||
|
@ -361,11 +356,10 @@ impl Builder {
|
|||
|
||||
pub fn build(self) -> Index {
|
||||
Index {
|
||||
dictionary: self.dictionary_builder.build(),
|
||||
browser: self.browser_builder.build(),
|
||||
collection: self.collection_builder.build(),
|
||||
search: self.search_builder.build(),
|
||||
strings: self.strings.into_reader(),
|
||||
canon: self.canon,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,14 +6,13 @@ use std::{
|
|||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use lasso2::{Rodeo, RodeoReader};
|
||||
use rayon::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tinyvec::TinyVec;
|
||||
use trie_rs::{Trie, TrieBuilder};
|
||||
use unicase::UniCase;
|
||||
|
||||
use crate::app::index::{
|
||||
dictionary::{self, Dictionary},
|
||||
storage::{self, PathKey},
|
||||
InternPath,
|
||||
};
|
||||
|
@ -43,12 +42,12 @@ impl Default for Browser {
|
|||
impl Browser {
|
||||
pub fn browse<P: AsRef<Path>>(
|
||||
&self,
|
||||
strings: &RodeoReader,
|
||||
dictionary: &Dictionary,
|
||||
virtual_path: P,
|
||||
) -> Result<Vec<File>, Error> {
|
||||
let path = virtual_path
|
||||
.as_ref()
|
||||
.get(strings)
|
||||
.get(dictionary)
|
||||
.ok_or_else(|| Error::DirectoryNotFound(virtual_path.as_ref().to_owned()))?;
|
||||
|
||||
let Some(files) = self.directories.get(&path) else {
|
||||
|
@ -62,7 +61,7 @@ impl Browser {
|
|||
storage::File::Directory(p) => p,
|
||||
storage::File::Song(p) => p,
|
||||
};
|
||||
let path = Path::new(OsStr::new(strings.resolve(&path.0))).to_owned();
|
||||
let path = Path::new(OsStr::new(dictionary.resolve(&path.0))).to_owned();
|
||||
match f {
|
||||
storage::File::Directory(_) => File::Directory(path),
|
||||
storage::File::Song(_) => File::Song(path),
|
||||
|
@ -72,10 +71,11 @@ impl Browser {
|
|||
|
||||
if virtual_path.as_ref().parent().is_none() {
|
||||
if let [File::Directory(ref p)] = files[..] {
|
||||
return self.browse(strings, p);
|
||||
return self.browse(dictionary, p);
|
||||
}
|
||||
}
|
||||
|
||||
let collator = dictionary::make_collator();
|
||||
files.sort_by(|a, b| {
|
||||
let (a, b) = match (a, b) {
|
||||
(File::Directory(_), File::Song(_)) => return Ordering::Less,
|
||||
|
@ -83,10 +83,10 @@ impl Browser {
|
|||
(File::Directory(a), File::Directory(b)) => (a, b),
|
||||
(File::Song(a), File::Song(b)) => (a, b),
|
||||
};
|
||||
// TODO replace unicase with icu_collator to handle accented characters too
|
||||
let a = UniCase::new(a.as_os_str().to_string_lossy());
|
||||
let b = UniCase::new(b.as_os_str().to_string_lossy());
|
||||
a.cmp(&b)
|
||||
collator.compare(
|
||||
a.as_os_str().to_string_lossy().as_ref(),
|
||||
b.as_os_str().to_string_lossy().as_ref(),
|
||||
)
|
||||
});
|
||||
|
||||
Ok(files)
|
||||
|
@ -94,38 +94,46 @@ impl Browser {
|
|||
|
||||
pub fn flatten<P: AsRef<Path>>(
|
||||
&self,
|
||||
strings: &RodeoReader,
|
||||
dictionary: &Dictionary,
|
||||
virtual_path: P,
|
||||
) -> Result<Vec<PathBuf>, Error> {
|
||||
let path_components = virtual_path
|
||||
.as_ref()
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_str().unwrap_or_default())
|
||||
.filter_map(|c| strings.get(c))
|
||||
.filter_map(|c| dictionary.get(c))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !self.flattened.is_prefix(&path_components) {
|
||||
return Err(Error::DirectoryNotFound(virtual_path.as_ref().to_owned()));
|
||||
}
|
||||
|
||||
let mut files = self
|
||||
let mut results: Vec<TinyVec<[_; 8]>> = self
|
||||
.flattened
|
||||
.predictive_search(path_components)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
results.par_sort_unstable_by(|a, b| {
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
match dictionary.cmp(x, y) {
|
||||
Ordering::Equal => continue,
|
||||
ordering @ _ => return ordering,
|
||||
}
|
||||
}
|
||||
a.len().cmp(&b.len())
|
||||
});
|
||||
|
||||
let files = results
|
||||
.into_iter()
|
||||
.map(|c: TinyVec<[_; 8]>| -> PathBuf {
|
||||
c.into_iter()
|
||||
.map(|s| strings.resolve(&s))
|
||||
.map(|s| dictionary.resolve(&s))
|
||||
.collect::<TinyVec<[&str; 8]>>()
|
||||
.join(std::path::MAIN_SEPARATOR_STR)
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
files.par_sort_by(|a, b| {
|
||||
let a = UniCase::new(a.as_os_str().to_string_lossy());
|
||||
let b = UniCase::new(b.as_os_str().to_string_lossy());
|
||||
a.cmp(&b)
|
||||
});
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
}
|
||||
|
@ -137,15 +145,19 @@ pub struct Builder {
|
|||
}
|
||||
|
||||
impl Builder {
|
||||
pub fn add_directory(&mut self, strings: &mut Rodeo, directory: scanner::Directory) {
|
||||
let Some(virtual_path) = (&directory.virtual_path).get_or_intern(strings) else {
|
||||
pub fn add_directory(
|
||||
&mut self,
|
||||
dictionary_builder: &mut dictionary::Builder,
|
||||
directory: scanner::Directory,
|
||||
) {
|
||||
let Some(virtual_path) = (&directory.virtual_path).get_or_intern(dictionary_builder) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(virtual_parent) = directory
|
||||
.virtual_path
|
||||
.parent()
|
||||
.and_then(|p| p.get_or_intern(strings))
|
||||
.and_then(|p| p.get_or_intern(dictionary_builder))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
@ -158,15 +170,15 @@ impl Builder {
|
|||
.insert(storage::File::Directory(virtual_path));
|
||||
}
|
||||
|
||||
pub fn add_song(&mut self, strings: &mut Rodeo, song: &scanner::Song) {
|
||||
let Some(virtual_path) = (&song.virtual_path).get_or_intern(strings) else {
|
||||
pub fn add_song(&mut self, dictionary_builder: &mut dictionary::Builder, song: &scanner::Song) {
|
||||
let Some(virtual_path) = (&song.virtual_path).get_or_intern(dictionary_builder) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(virtual_parent) = song
|
||||
.virtual_path
|
||||
.parent()
|
||||
.and_then(|p| p.get_or_intern(strings))
|
||||
.and_then(|p| p.get_or_intern(dictionary_builder))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
@ -179,7 +191,7 @@ impl Builder {
|
|||
self.flattened.push(
|
||||
song.virtual_path
|
||||
.components()
|
||||
.map(|c| strings.get_or_intern(c.as_os_str().to_str().unwrap()))
|
||||
.map(|c| dictionary_builder.get_or_intern(c.as_os_str().to_str().unwrap()))
|
||||
.collect::<TinyVec<[lasso2::Spur; 8]>>(),
|
||||
);
|
||||
}
|
||||
|
@ -199,8 +211,8 @@ mod test {
|
|||
|
||||
use super::*;
|
||||
|
||||
fn setup_test(songs: HashSet<PathBuf>) -> (Browser, RodeoReader) {
|
||||
let mut strings = Rodeo::new();
|
||||
fn setup_test(songs: HashSet<PathBuf>) -> (Browser, Dictionary) {
|
||||
let mut dictionary_builder = dictionary::Builder::default();
|
||||
let mut builder = Builder::default();
|
||||
|
||||
let directories = songs
|
||||
|
@ -210,7 +222,7 @@ mod test {
|
|||
|
||||
for directory in directories {
|
||||
builder.add_directory(
|
||||
&mut strings,
|
||||
&mut dictionary_builder,
|
||||
scanner::Directory {
|
||||
virtual_path: directory.to_owned(),
|
||||
},
|
||||
|
@ -220,13 +232,13 @@ mod test {
|
|||
for path in songs {
|
||||
let mut song = scanner::Song::default();
|
||||
song.virtual_path = path.clone();
|
||||
builder.add_song(&mut strings, &song);
|
||||
builder.add_song(&mut dictionary_builder, &song);
|
||||
}
|
||||
|
||||
let browser = builder.build();
|
||||
let strings = strings.into_reader();
|
||||
let dictionary = dictionary_builder.build();
|
||||
|
||||
(browser, strings)
|
||||
(browser, dictionary)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -288,6 +300,7 @@ mod test {
|
|||
PathBuf::from_iter(["Ott", "Mir.mp3"]),
|
||||
PathBuf::from("Helios.mp3"),
|
||||
PathBuf::from("asura.mp3"),
|
||||
PathBuf::from("à la maison.mp3"),
|
||||
]));
|
||||
|
||||
let files = browser.browse(&strings, PathBuf::new()).unwrap();
|
||||
|
@ -296,6 +309,7 @@ mod test {
|
|||
files,
|
||||
[
|
||||
File::Directory(PathBuf::from("Ott")),
|
||||
File::Song(PathBuf::from("à la maison.mp3")),
|
||||
File::Song(PathBuf::from("asura.mp3")),
|
||||
File::Song(PathBuf::from("Helios.mp3")),
|
||||
]
|
||||
|
@ -342,6 +356,7 @@ mod test {
|
|||
let (browser, strings) = setup_test(HashSet::from([
|
||||
PathBuf::from_iter(["Ott", "Mir.mp3"]),
|
||||
PathBuf::from("Helios.mp3"),
|
||||
PathBuf::from("à la maison.mp3.mp3"),
|
||||
PathBuf::from("asura.mp3"),
|
||||
]));
|
||||
|
||||
|
@ -350,6 +365,7 @@ mod test {
|
|||
assert_eq!(
|
||||
files,
|
||||
[
|
||||
PathBuf::from("à la maison.mp3.mp3"),
|
||||
PathBuf::from("asura.mp3"),
|
||||
PathBuf::from("Helios.mp3"),
|
||||
PathBuf::from_iter(["Ott", "Mir.mp3"]),
|
||||
|
|
|
@ -4,12 +4,12 @@ use std::{
|
|||
path::PathBuf,
|
||||
};
|
||||
|
||||
use lasso2::RodeoReader;
|
||||
use rand::{rngs::StdRng, seq::SliceRandom, SeedableRng};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tinyvec::TinyVec;
|
||||
use unicase::UniCase;
|
||||
|
||||
use crate::app::index::dictionary::Dictionary;
|
||||
use crate::app::index::storage::{self, AlbumKey, ArtistKey, GenreKey, SongKey};
|
||||
|
||||
use super::storage::fetch_song;
|
||||
|
@ -90,37 +90,39 @@ pub struct Collection {
|
|||
}
|
||||
|
||||
impl Collection {
|
||||
pub fn get_albums(&self, strings: &RodeoReader) -> Vec<AlbumHeader> {
|
||||
pub fn get_albums(&self, dictionary: &Dictionary) -> Vec<AlbumHeader> {
|
||||
let mut albums = self
|
||||
.albums
|
||||
.values()
|
||||
.map(|a| make_album_header(a, strings))
|
||||
.map(|a| make_album_header(a, dictionary))
|
||||
.collect::<Vec<_>>();
|
||||
albums.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
albums
|
||||
}
|
||||
|
||||
pub fn get_artists(&self, strings: &RodeoReader) -> Vec<ArtistHeader> {
|
||||
let exceptions = vec![strings.get("Various Artists"), strings.get("VA")];
|
||||
pub fn get_artists(&self, dictionary: &Dictionary) -> Vec<ArtistHeader> {
|
||||
let exceptions = vec![dictionary.get("Various Artists"), dictionary.get("VA")];
|
||||
let mut artists = self
|
||||
.artists
|
||||
.values()
|
||||
.filter(|a| !exceptions.contains(&Some(a.name)))
|
||||
.map(|a| make_artist_header(a, strings))
|
||||
.map(|a| make_artist_header(a, dictionary))
|
||||
.collect::<Vec<_>>();
|
||||
// TODO collator sort
|
||||
artists.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
artists
|
||||
}
|
||||
|
||||
pub fn get_artist(&self, strings: &RodeoReader, artist_key: ArtistKey) -> Option<Artist> {
|
||||
pub fn get_artist(&self, dictionary: &Dictionary, artist_key: ArtistKey) -> Option<Artist> {
|
||||
self.artists.get(&artist_key).map(|artist| {
|
||||
let header = make_artist_header(artist, strings);
|
||||
let header = make_artist_header(artist, dictionary);
|
||||
let albums = {
|
||||
let mut albums = artist
|
||||
.all_albums
|
||||
.iter()
|
||||
.filter_map(|key| self.get_album(strings, key.clone()))
|
||||
.filter_map(|key| self.get_album(dictionary, key.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
// TODO collator sort
|
||||
albums.sort_by(|a, b| {
|
||||
(&a.header.year, &a.header.name).cmp(&(&b.header.year, &b.header.name))
|
||||
});
|
||||
|
@ -130,14 +132,14 @@ impl Collection {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn get_album(&self, strings: &RodeoReader, album_key: AlbumKey) -> Option<Album> {
|
||||
pub fn get_album(&self, dictionary: &Dictionary, album_key: AlbumKey) -> Option<Album> {
|
||||
self.albums.get(&album_key).map(|a| {
|
||||
let mut songs = a
|
||||
.songs
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
self.get_song(
|
||||
strings,
|
||||
dictionary,
|
||||
SongKey {
|
||||
virtual_path: s.virtual_path,
|
||||
},
|
||||
|
@ -148,7 +150,7 @@ impl Collection {
|
|||
songs.sort_by_key(|s| (s.disc_number.unwrap_or(-1), s.track_number.unwrap_or(-1)));
|
||||
|
||||
Album {
|
||||
header: make_album_header(a, strings),
|
||||
header: make_album_header(a, dictionary),
|
||||
songs,
|
||||
}
|
||||
})
|
||||
|
@ -156,7 +158,7 @@ impl Collection {
|
|||
|
||||
pub fn get_random_albums(
|
||||
&self,
|
||||
strings: &RodeoReader,
|
||||
dictionary: &Dictionary,
|
||||
seed: Option<u64>,
|
||||
offset: usize,
|
||||
count: usize,
|
||||
|
@ -175,13 +177,13 @@ impl Collection {
|
|||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(count)
|
||||
.filter_map(|k| self.get_album(strings, k.clone()))
|
||||
.filter_map(|k| self.get_album(dictionary, k.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_recent_albums(
|
||||
&self,
|
||||
strings: &RodeoReader,
|
||||
dictionary: &Dictionary,
|
||||
offset: usize,
|
||||
count: usize,
|
||||
) -> Vec<Album> {
|
||||
|
@ -189,21 +191,21 @@ impl Collection {
|
|||
.iter()
|
||||
.skip(offset)
|
||||
.take(count)
|
||||
.filter_map(|k| self.get_album(strings, k.clone()))
|
||||
.filter_map(|k| self.get_album(dictionary, k.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_genres(&self, strings: &RodeoReader) -> Vec<GenreHeader> {
|
||||
pub fn get_genres(&self, dictionary: &Dictionary) -> Vec<GenreHeader> {
|
||||
let mut genres = self
|
||||
.genres
|
||||
.values()
|
||||
.map(|a| make_genre_header(a, strings))
|
||||
.map(|a| make_genre_header(a, dictionary))
|
||||
.collect::<Vec<_>>();
|
||||
genres.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
genres
|
||||
}
|
||||
|
||||
pub fn get_genre(&self, strings: &RodeoReader, genre_key: GenreKey) -> Option<Genre> {
|
||||
pub fn get_genre(&self, dictionary: &Dictionary, genre_key: GenreKey) -> Option<Genre> {
|
||||
self.genres.get(&genre_key).map(|genre| {
|
||||
let mut albums = genre
|
||||
.albums
|
||||
|
@ -211,7 +213,7 @@ impl Collection {
|
|||
.filter_map(|album_key| {
|
||||
self.albums
|
||||
.get(album_key)
|
||||
.map(|a| make_album_header(a, strings))
|
||||
.map(|a| make_album_header(a, dictionary))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
albums.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
@ -222,7 +224,7 @@ impl Collection {
|
|||
.filter_map(|artist_key| {
|
||||
self.artists
|
||||
.get(artist_key)
|
||||
.map(|a| make_artist_header(a, strings))
|
||||
.map(|a| make_artist_header(a, dictionary))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
artists.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
@ -230,7 +232,7 @@ impl Collection {
|
|||
let songs = genre
|
||||
.songs
|
||||
.iter()
|
||||
.filter_map(|k| self.get_song(strings, *k))
|
||||
.filter_map(|k| self.get_song(dictionary, *k))
|
||||
.collect::<Vec<_>>();
|
||||
// TODO sort songs
|
||||
|
||||
|
@ -238,12 +240,12 @@ impl Collection {
|
|||
.related_genres
|
||||
.iter()
|
||||
.map(|(genre_key, song_count)| {
|
||||
(strings.resolve(&genre_key.0).to_owned(), *song_count)
|
||||
(dictionary.resolve(&genre_key.0).to_owned(), *song_count)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Genre {
|
||||
header: make_genre_header(genre, strings),
|
||||
header: make_genre_header(genre, dictionary),
|
||||
albums,
|
||||
artists,
|
||||
related_genres,
|
||||
|
@ -256,32 +258,33 @@ impl Collection {
|
|||
self.songs.len()
|
||||
}
|
||||
|
||||
pub fn get_song(&self, strings: &RodeoReader, song_key: SongKey) -> Option<Song> {
|
||||
self.songs.get(&song_key).map(|s| fetch_song(strings, s))
|
||||
pub fn get_song(&self, dictionary: &Dictionary, song_key: SongKey) -> Option<Song> {
|
||||
self.songs.get(&song_key).map(|s| fetch_song(dictionary, s))
|
||||
}
|
||||
}
|
||||
|
||||
fn make_album_header(album: &storage::Album, strings: &RodeoReader) -> AlbumHeader {
|
||||
fn make_album_header(album: &storage::Album, dictionary: &Dictionary) -> AlbumHeader {
|
||||
AlbumHeader {
|
||||
name: strings.resolve(&album.name).to_string(),
|
||||
name: dictionary.resolve(&album.name).to_string(),
|
||||
artwork: album
|
||||
.artwork
|
||||
.as_ref()
|
||||
.map(|a| strings.resolve(&a.0))
|
||||
.map(|a| dictionary.resolve(&a.0))
|
||||
.map(PathBuf::from),
|
||||
artists: album
|
||||
.artists
|
||||
.iter()
|
||||
.map(|a| strings.resolve(&a.0).to_string())
|
||||
.map(|a| dictionary.resolve(&a.0).to_string())
|
||||
.collect(),
|
||||
year: album.year,
|
||||
date_added: album.date_added,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_artist_header(artist: &storage::Artist, strings: &RodeoReader) -> ArtistHeader {
|
||||
fn make_artist_header(artist: &storage::Artist, dictionary: &Dictionary) -> ArtistHeader {
|
||||
// TODO drop unicase
|
||||
ArtistHeader {
|
||||
name: UniCase::new(strings.resolve(&artist.name).to_owned()),
|
||||
name: UniCase::new(dictionary.resolve(&artist.name).to_owned()),
|
||||
num_albums_as_performer: artist.albums_as_performer.len() as u32,
|
||||
num_albums_as_additional_performer: artist.albums_as_additional_performer.len() as u32,
|
||||
num_albums_as_composer: artist.albums_as_composer.len() as u32,
|
||||
|
@ -289,15 +292,15 @@ fn make_artist_header(artist: &storage::Artist, strings: &RodeoReader) -> Artist
|
|||
num_songs_by_genre: artist
|
||||
.num_songs_by_genre
|
||||
.iter()
|
||||
.map(|(genre, num)| (strings.resolve(genre).to_string(), *num))
|
||||
.map(|(genre, num)| (dictionary.resolve(genre).to_string(), *num))
|
||||
.collect(),
|
||||
num_songs: artist.num_songs,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_genre_header(genre: &storage::Genre, strings: &RodeoReader) -> GenreHeader {
|
||||
fn make_genre_header(genre: &storage::Genre, dictionary: &Dictionary) -> GenreHeader {
|
||||
GenreHeader {
|
||||
name: strings.resolve(&genre.name).to_string(),
|
||||
name: dictionary.resolve(&genre.name).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -504,28 +507,26 @@ impl Builder {
|
|||
#[cfg(test)]
|
||||
mod test {
|
||||
|
||||
use lasso2::Rodeo;
|
||||
use tinyvec::tiny_vec;
|
||||
|
||||
use crate::app::scanner;
|
||||
use crate::app::{index::dictionary, scanner};
|
||||
use storage::{store_song, InternPath};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn setup_test(songs: Vec<scanner::Song>) -> (Collection, RodeoReader) {
|
||||
let mut strings = Rodeo::new();
|
||||
let mut canon = HashMap::new();
|
||||
fn setup_test(songs: Vec<scanner::Song>) -> (Collection, Dictionary) {
|
||||
let mut dictionary_builder = dictionary::Builder::default();
|
||||
let mut builder = Builder::default();
|
||||
|
||||
for song in songs {
|
||||
let song = store_song(&mut strings, &mut canon, &song).unwrap();
|
||||
let song = store_song(&mut dictionary_builder, &song).unwrap();
|
||||
builder.add_song(&song);
|
||||
}
|
||||
|
||||
let browser = builder.build();
|
||||
let strings = strings.into_reader();
|
||||
let dictionary = dictionary_builder.build();
|
||||
|
||||
(browser, strings)
|
||||
(browser, dictionary)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
110
src/app/index/dictionary.rs
Normal file
110
src/app/index/dictionary.rs
Normal file
|
@ -0,0 +1,110 @@
|
|||
use std::{cmp::Ordering, collections::HashMap};
|
||||
|
||||
use icu_collator::{Collator, CollatorOptions, Strength};
|
||||
use lasso2::{Rodeo, RodeoReader, Spur};
|
||||
use rayon::slice::ParallelSliceMut;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub fn sanitize(s: &str) -> String {
|
||||
// TODO merge inconsistent diacritic usage
|
||||
let mut cleaned = s.to_owned();
|
||||
cleaned.retain(|c| match c {
|
||||
' ' | '_' | '-' | '\'' => false,
|
||||
_ => true,
|
||||
});
|
||||
cleaned.to_lowercase()
|
||||
}
|
||||
|
||||
pub fn make_collator() -> Collator {
|
||||
let options = {
|
||||
let mut o = CollatorOptions::new();
|
||||
o.strength = Some(Strength::Secondary);
|
||||
o
|
||||
};
|
||||
Collator::try_new(&Default::default(), options).unwrap()
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Dictionary {
|
||||
strings: RodeoReader, // Interned strings
|
||||
canon: HashMap<String, Spur>, // Canonical representation of similar strings
|
||||
sort_keys: HashMap<Spur, u32>, // All spurs sorted against each other
|
||||
}
|
||||
|
||||
impl Dictionary {
|
||||
pub fn get<S: AsRef<str>>(&self, string: S) -> Option<Spur> {
|
||||
self.strings.get(string)
|
||||
}
|
||||
|
||||
pub fn get_canon<S: AsRef<str>>(&self, string: S) -> Option<Spur> {
|
||||
self.canon.get(&sanitize(string.as_ref())).copied()
|
||||
}
|
||||
|
||||
pub fn resolve(&self, spur: &Spur) -> &str {
|
||||
self.strings.resolve(spur)
|
||||
}
|
||||
|
||||
pub fn cmp(&self, a: &Spur, b: &Spur) -> Ordering {
|
||||
self.sort_keys
|
||||
.get(a)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.cmp(&self.sort_keys.get(b).copied().unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Dictionary {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
strings: Rodeo::default().into_reader(),
|
||||
canon: Default::default(),
|
||||
sort_keys: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Builder {
|
||||
strings: Rodeo,
|
||||
canon: HashMap<String, Spur>,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
pub fn build(self) -> Dictionary {
|
||||
let mut sorted_spurs = self.strings.iter().collect::<Vec<_>>();
|
||||
// TODO this is too slow!
|
||||
sorted_spurs.par_sort_unstable_by(|(_, a), (_, b)| {
|
||||
let collator = make_collator();
|
||||
collator.compare(a, b)
|
||||
});
|
||||
|
||||
let sort_keys = sorted_spurs
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (spur, _))| (spur, i as u32))
|
||||
.collect();
|
||||
|
||||
Dictionary {
|
||||
strings: self.strings.into_reader(),
|
||||
canon: self.canon,
|
||||
sort_keys,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_or_intern<S: AsRef<str>>(&mut self, string: S) -> Spur {
|
||||
self.strings.get_or_intern(string)
|
||||
}
|
||||
|
||||
pub fn get_or_intern_canon<S: AsRef<str>>(&mut self, string: S) -> Option<Spur> {
|
||||
let cleaned = sanitize(string.as_ref());
|
||||
match cleaned.is_empty() {
|
||||
true => None,
|
||||
false => Some(
|
||||
self.canon
|
||||
.entry(cleaned)
|
||||
.or_insert_with(|| self.strings.get_or_intern(string.as_ref()))
|
||||
.to_owned(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
use chumsky::Parser;
|
||||
use enum_map::EnumMap;
|
||||
use lasso2::{RodeoReader, Spur};
|
||||
use lasso2::Spur;
|
||||
use nohash_hasher::{IntMap, IntSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
|
@ -11,17 +11,14 @@ use tinyvec::TinyVec;
|
|||
|
||||
use crate::app::{
|
||||
index::{
|
||||
dictionary::Dictionary,
|
||||
query::{BoolOp, Expr, Literal, NumberField, NumberOp, TextField, TextOp},
|
||||
storage::SongKey,
|
||||
},
|
||||
scanner, Error,
|
||||
};
|
||||
|
||||
use super::{
|
||||
collection,
|
||||
query::make_parser,
|
||||
storage::{self, sanitize},
|
||||
};
|
||||
use super::{collection, dictionary::sanitize, query::make_parser, storage};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Search {
|
||||
|
@ -64,8 +61,7 @@ impl Search {
|
|||
pub fn find_songs(
|
||||
&self,
|
||||
collection: &collection::Collection,
|
||||
strings: &RodeoReader,
|
||||
canon: &HashMap<String, Spur>,
|
||||
dictionary: &Dictionary,
|
||||
query: &str,
|
||||
) -> Result<Vec<collection::Song>, Error> {
|
||||
let parser = make_parser();
|
||||
|
@ -74,9 +70,9 @@ impl Search {
|
|||
.map_err(|_| Error::SearchQueryParseError)?;
|
||||
|
||||
let mut songs = self
|
||||
.eval(strings, canon, &parsed_query)
|
||||
.eval(dictionary, &parsed_query)
|
||||
.into_iter()
|
||||
.filter_map(|song_key| collection.get_song(strings, song_key))
|
||||
.filter_map(|song_key| collection.get_song(dictionary, song_key))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
songs.sort_by(compare_songs);
|
||||
|
@ -84,24 +80,18 @@ impl Search {
|
|||
Ok(songs)
|
||||
}
|
||||
|
||||
fn eval(
|
||||
&self,
|
||||
strings: &RodeoReader,
|
||||
canon: &HashMap<String, Spur>,
|
||||
expr: &Expr,
|
||||
) -> IntSet<SongKey> {
|
||||
fn eval(&self, dictionary: &Dictionary, expr: &Expr) -> IntSet<SongKey> {
|
||||
match expr {
|
||||
Expr::Fuzzy(s) => self.eval_fuzzy(strings, s),
|
||||
Expr::TextCmp(field, op, s) => self.eval_text_operator(strings, canon, *field, *op, &s),
|
||||
Expr::Fuzzy(s) => self.eval_fuzzy(dictionary, s),
|
||||
Expr::TextCmp(field, op, s) => self.eval_text_operator(dictionary, *field, *op, &s),
|
||||
Expr::NumberCmp(field, op, n) => self.eval_number_operator(*field, *op, *n),
|
||||
Expr::Combined(e, op, f) => self.combine(strings, canon, e, *op, f),
|
||||
Expr::Combined(e, op, f) => self.combine(dictionary, e, *op, f),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine(
|
||||
&self,
|
||||
strings: &RodeoReader,
|
||||
canon: &HashMap<String, Spur>,
|
||||
dictionary: &Dictionary,
|
||||
e: &Box<Expr>,
|
||||
op: BoolOp,
|
||||
f: &Box<Expr>,
|
||||
|
@ -113,8 +103,8 @@ impl Search {
|
|||
_ => true,
|
||||
};
|
||||
|
||||
let left = is_operable(e).then(|| self.eval(strings, canon, e));
|
||||
let right = is_operable(f).then(|| self.eval(strings, canon, f));
|
||||
let left = is_operable(e).then(|| self.eval(dictionary, e));
|
||||
let right = is_operable(f).then(|| self.eval(dictionary, f));
|
||||
|
||||
match (left, op, right) {
|
||||
(Some(l), BoolOp::And, Some(r)) => l.intersection(&r).cloned().collect(),
|
||||
|
@ -127,12 +117,12 @@ impl Search {
|
|||
}
|
||||
}
|
||||
|
||||
fn eval_fuzzy(&self, strings: &RodeoReader, value: &Literal) -> IntSet<SongKey> {
|
||||
fn eval_fuzzy(&self, dictionary: &Dictionary, value: &Literal) -> IntSet<SongKey> {
|
||||
match value {
|
||||
Literal::Text(s) => {
|
||||
let mut songs = IntSet::default();
|
||||
for field in self.text_fields.values() {
|
||||
songs.extend(field.find_like(strings, s));
|
||||
songs.extend(field.find_like(dictionary, s));
|
||||
}
|
||||
songs
|
||||
}
|
||||
|
@ -142,7 +132,7 @@ impl Search {
|
|||
songs.extend(field.find(*n as i64, NumberOp::Eq));
|
||||
}
|
||||
songs
|
||||
.union(&self.eval_fuzzy(strings, &Literal::Text(n.to_string())))
|
||||
.union(&self.eval_fuzzy(dictionary, &Literal::Text(n.to_string())))
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
|
@ -151,15 +141,14 @@ impl Search {
|
|||
|
||||
fn eval_text_operator(
|
||||
&self,
|
||||
strings: &RodeoReader,
|
||||
canon: &HashMap<String, Spur>,
|
||||
dictionary: &Dictionary,
|
||||
field: TextField,
|
||||
operator: TextOp,
|
||||
value: &str,
|
||||
) -> IntSet<SongKey> {
|
||||
match operator {
|
||||
TextOp::Eq => self.text_fields[field].find_exact(canon, value),
|
||||
TextOp::Like => self.text_fields[field].find_like(strings, value),
|
||||
TextOp::Eq => self.text_fields[field].find_exact(dictionary, value),
|
||||
TextOp::Like => self.text_fields[field].find_like(dictionary, value),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -194,7 +183,7 @@ impl TextFieldIndex {
|
|||
self.exact.entry(value).or_default().insert(key);
|
||||
}
|
||||
|
||||
pub fn find_like(&self, strings: &RodeoReader, value: &str) -> IntSet<SongKey> {
|
||||
pub fn find_like(&self, dictionary: &Dictionary, value: &str) -> IntSet<SongKey> {
|
||||
let sanitized = sanitize(value);
|
||||
let characters = sanitized.chars().collect::<Vec<_>>();
|
||||
let empty = IntMap::default();
|
||||
|
@ -222,7 +211,7 @@ impl TextFieldIndex {
|
|||
})
|
||||
// [narrow phase] Only keep songs that actually contain the search term in full
|
||||
.filter(|(_song_key, indexed_value)| {
|
||||
let resolved = strings.resolve(indexed_value);
|
||||
let resolved = dictionary.resolve(indexed_value);
|
||||
sanitize(resolved).contains(&sanitized)
|
||||
})
|
||||
.map(|(k, _v)| k)
|
||||
|
@ -230,9 +219,9 @@ impl TextFieldIndex {
|
|||
.collect()
|
||||
}
|
||||
|
||||
pub fn find_exact(&self, canon: &HashMap<String, Spur>, value: &str) -> IntSet<SongKey> {
|
||||
canon
|
||||
.get(&sanitize(value))
|
||||
pub fn find_exact(&self, dictionary: &Dictionary, value: &str) -> IntSet<SongKey> {
|
||||
dictionary
|
||||
.get_canon(value)
|
||||
.and_then(|s| self.exact.get(&s))
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
|
@ -353,23 +342,21 @@ impl Builder {
|
|||
mod test {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use lasso2::Rodeo;
|
||||
use super::*;
|
||||
use crate::app::index::dictionary;
|
||||
use collection::Collection;
|
||||
use storage::store_song;
|
||||
|
||||
use super::*;
|
||||
use collection::Collection;
|
||||
|
||||
struct Context {
|
||||
canon: HashMap<String, Spur>,
|
||||
dictionary: Dictionary,
|
||||
collection: Collection,
|
||||
search: Search,
|
||||
strings: RodeoReader,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub fn search(&self, query: &str) -> Vec<PathBuf> {
|
||||
self.search
|
||||
.find_songs(&self.collection, &self.strings, &self.canon, query)
|
||||
.find_songs(&self.collection, &self.dictionary, query)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|s| s.virtual_path)
|
||||
|
@ -378,22 +365,19 @@ mod test {
|
|||
}
|
||||
|
||||
fn setup_test(songs: Vec<scanner::Song>) -> Context {
|
||||
let mut strings = Rodeo::new();
|
||||
let mut canon = HashMap::new();
|
||||
|
||||
let mut dictionary_builder = dictionary::Builder::default();
|
||||
let mut collection_builder = collection::Builder::default();
|
||||
let mut search_builder = Builder::default();
|
||||
for song in songs {
|
||||
let storage_song = store_song(&mut strings, &mut canon, &song).unwrap();
|
||||
let storage_song = store_song(&mut dictionary_builder, &song).unwrap();
|
||||
collection_builder.add_song(&storage_song);
|
||||
search_builder.add_song(&song, &storage_song);
|
||||
}
|
||||
|
||||
Context {
|
||||
canon,
|
||||
collection: collection_builder.build(),
|
||||
search: search_builder.build(),
|
||||
strings: strings.into_reader(),
|
||||
dictionary: dictionary_builder.build(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,13 +3,15 @@ use std::{
|
|||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use lasso2::{Rodeo, RodeoReader, Spur};
|
||||
use lasso2::Spur;
|
||||
use log::error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tinyvec::TinyVec;
|
||||
|
||||
use crate::app::scanner;
|
||||
|
||||
use crate::app::index::dictionary::{self, Dictionary};
|
||||
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum File {
|
||||
Directory(PathKey),
|
||||
|
@ -111,49 +113,27 @@ impl Song {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn sanitize(s: &str) -> String {
|
||||
// TODO merge inconsistent diacritic usage
|
||||
let mut cleaned = s.to_owned();
|
||||
cleaned.retain(|c| match c {
|
||||
' ' | '_' | '-' | '\'' => false,
|
||||
_ => true,
|
||||
});
|
||||
cleaned.to_lowercase()
|
||||
}
|
||||
|
||||
pub fn store_song(
|
||||
strings: &mut Rodeo,
|
||||
canon: &mut HashMap<String, Spur>,
|
||||
dictionary_builder: &mut dictionary::Builder,
|
||||
song: &scanner::Song,
|
||||
) -> Option<Song> {
|
||||
let Some(real_path) = (&song.real_path).get_or_intern(strings) else {
|
||||
let Some(real_path) = (&song.real_path).get_or_intern(dictionary_builder) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let Some(virtual_path) = (&song.virtual_path).get_or_intern(strings) else {
|
||||
let Some(virtual_path) = (&song.virtual_path).get_or_intern(dictionary_builder) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let artwork = match &song.artwork {
|
||||
Some(a) => match a.get_or_intern(strings) {
|
||||
Some(a) => match a.get_or_intern(dictionary_builder) {
|
||||
Some(a) => Some(a),
|
||||
None => return None,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut canonicalize = |s: &String| {
|
||||
let cleaned = sanitize(s);
|
||||
match cleaned.is_empty() {
|
||||
true => None,
|
||||
false => Some(
|
||||
canon
|
||||
.entry(cleaned)
|
||||
.or_insert_with(|| strings.get_or_intern(s))
|
||||
.to_owned(),
|
||||
),
|
||||
}
|
||||
};
|
||||
let mut canonicalize = |s: &String| dictionary_builder.get_or_intern_canon(s);
|
||||
|
||||
Some(Song {
|
||||
real_path,
|
||||
|
@ -195,63 +175,65 @@ pub fn store_song(
|
|||
})
|
||||
}
|
||||
|
||||
pub fn fetch_song(strings: &RodeoReader, song: &Song) -> super::Song {
|
||||
pub fn fetch_song(dictionary: &Dictionary, song: &Song) -> super::Song {
|
||||
super::Song {
|
||||
real_path: PathBuf::from(strings.resolve(&song.real_path.0)),
|
||||
virtual_path: PathBuf::from(strings.resolve(&song.virtual_path.0)),
|
||||
real_path: PathBuf::from(dictionary.resolve(&song.real_path.0)),
|
||||
virtual_path: PathBuf::from(dictionary.resolve(&song.virtual_path.0)),
|
||||
track_number: song.track_number,
|
||||
disc_number: song.disc_number,
|
||||
title: song.title.map(|s| strings.resolve(&s).to_string()),
|
||||
title: song.title.map(|s| dictionary.resolve(&s).to_string()),
|
||||
artists: song
|
||||
.artists
|
||||
.iter()
|
||||
.map(|k| strings.resolve(&k.0).to_string())
|
||||
.map(|k| dictionary.resolve(&k.0).to_string())
|
||||
.collect(),
|
||||
album_artists: song
|
||||
.album_artists
|
||||
.iter()
|
||||
.map(|k| strings.resolve(&k.0).to_string())
|
||||
.map(|k| dictionary.resolve(&k.0).to_string())
|
||||
.collect(),
|
||||
year: song.year,
|
||||
album: song.album.map(|s| strings.resolve(&s).to_string()),
|
||||
artwork: song.artwork.map(|a| PathBuf::from(strings.resolve(&a.0))),
|
||||
album: song.album.map(|s| dictionary.resolve(&s).to_string()),
|
||||
artwork: song
|
||||
.artwork
|
||||
.map(|a| PathBuf::from(dictionary.resolve(&a.0))),
|
||||
duration: song.duration,
|
||||
lyricists: song
|
||||
.lyricists
|
||||
.iter()
|
||||
.map(|k| strings.resolve(&k.0).to_string())
|
||||
.map(|k| dictionary.resolve(&k.0).to_string())
|
||||
.collect(),
|
||||
composers: song
|
||||
.composers
|
||||
.iter()
|
||||
.map(|k| strings.resolve(&k.0).to_string())
|
||||
.map(|k| dictionary.resolve(&k.0).to_string())
|
||||
.collect(),
|
||||
genres: song
|
||||
.genres
|
||||
.iter()
|
||||
.map(|s| strings.resolve(&s).to_string())
|
||||
.map(|s| dictionary.resolve(&s).to_string())
|
||||
.collect(),
|
||||
labels: song
|
||||
.labels
|
||||
.iter()
|
||||
.map(|s| strings.resolve(&s).to_string())
|
||||
.map(|s| dictionary.resolve(&s).to_string())
|
||||
.collect(),
|
||||
date_added: song.date_added,
|
||||
}
|
||||
}
|
||||
|
||||
pub trait InternPath {
|
||||
fn get_or_intern(self, strings: &mut Rodeo) -> Option<PathKey>;
|
||||
fn get(self, strings: &RodeoReader) -> Option<PathKey>;
|
||||
fn get_or_intern(self, dictionary: &mut dictionary::Builder) -> Option<PathKey>;
|
||||
fn get(self, dictionary: &Dictionary) -> Option<PathKey>;
|
||||
}
|
||||
|
||||
impl<P: AsRef<Path>> InternPath for P {
|
||||
fn get_or_intern(self, strings: &mut Rodeo) -> Option<PathKey> {
|
||||
fn get_or_intern(self, dictionary: &mut dictionary::Builder) -> Option<PathKey> {
|
||||
let id = self
|
||||
.as_ref()
|
||||
.as_os_str()
|
||||
.to_str()
|
||||
.map(|s| strings.get_or_intern(s))
|
||||
.map(|s| dictionary.get_or_intern(s))
|
||||
.map(PathKey);
|
||||
if id.is_none() {
|
||||
error!("Unsupported path: `{}`", self.as_ref().to_string_lossy());
|
||||
|
@ -259,12 +241,12 @@ impl<P: AsRef<Path>> InternPath for P {
|
|||
id
|
||||
}
|
||||
|
||||
fn get(self, strings: &RodeoReader) -> Option<PathKey> {
|
||||
fn get(self, dictionary: &Dictionary) -> Option<PathKey> {
|
||||
let id = self
|
||||
.as_ref()
|
||||
.as_os_str()
|
||||
.to_str()
|
||||
.and_then(|s| strings.get(s))
|
||||
.and_then(|s| dictionary.get(s))
|
||||
.map(PathKey);
|
||||
if id.is_none() {
|
||||
error!("Unsupported path: `{}`", self.as_ref().to_string_lossy());
|
||||
|
|
|
@ -3,11 +3,11 @@ use std::collections::HashMap;
|
|||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use icu_collator::{Collator, CollatorOptions, Strength};
|
||||
use native_db::*;
|
||||
use native_model::{native_model, Model};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::task::spawn_blocking;
|
||||
use unicase::UniCase;
|
||||
|
||||
use crate::app::{index, ndb, Error};
|
||||
|
||||
|
@ -18,7 +18,7 @@ pub struct Manager {
|
|||
|
||||
#[derive(Debug)]
|
||||
pub struct PlaylistHeader {
|
||||
pub name: UniCase<String>,
|
||||
pub name: String,
|
||||
pub duration: Duration,
|
||||
pub num_songs_by_genre: HashMap<String, u32>,
|
||||
}
|
||||
|
@ -58,7 +58,7 @@ pub mod v1 {
|
|||
impl From<PlaylistModel> for PlaylistHeader {
|
||||
fn from(p: PlaylistModel) -> Self {
|
||||
Self {
|
||||
name: UniCase::new(p.name),
|
||||
name: p.name,
|
||||
duration: p.duration,
|
||||
num_songs_by_genre: p.num_songs_by_genre,
|
||||
}
|
||||
|
@ -93,7 +93,15 @@ impl Manager {
|
|||
.filter_map(|p| p.ok())
|
||||
.map(PlaylistHeader::from)
|
||||
.collect::<Vec<_>>();
|
||||
playlists.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
let collator_options = {
|
||||
let mut o = CollatorOptions::new();
|
||||
o.strength = Some(Strength::Secondary);
|
||||
o
|
||||
};
|
||||
let collator = Collator::try_new(&Default::default(), collator_options).unwrap();
|
||||
|
||||
playlists.sort_by(|a, b| collator.compare(&a.name, &b.name));
|
||||
Ok(playlists)
|
||||
}
|
||||
})
|
||||
|
@ -335,7 +343,7 @@ mod test {
|
|||
.build()
|
||||
.await;
|
||||
|
||||
for name in ["a", "b", "A", "B"] {
|
||||
for name in ["ax", "b", "Ay", "B", "àz"] {
|
||||
ctx.playlist_manager
|
||||
.save_playlist(name, TEST_USER, Vec::new())
|
||||
.await
|
||||
|
@ -353,6 +361,6 @@ mod test {
|
|||
.map(|p| p.name.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(names, vec!["A", "a", "B", "b"]);
|
||||
assert_eq!(names, vec!["ax", "Ay", "àz", "B", "b"]);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue