diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 71ce8a9..cd3b15a 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -30,14 +30,16 @@ jobs: run: cargo build --verbose - name: Run tests run: cargo test --verbose + - name: Run library tests + run: cargo test --verbose --features=library - name: Run example tests run: cargo test --verbose --examples - name: Build benches run: cargo +nightly-2022-02-16 bench --verbose --features=bench --no-run - name: Build examples - run: cargo build --examples --verbose --features=serde + run: cargo build --examples --verbose --features=serde,library - name: Lint - run: cargo clippy --examples --features=serde -- -D warnings + run: cargo clippy --examples --features=serde,library -- -D warnings build-test-lint-windows: name: Windows - build, test and lint diff --git a/Cargo.lock b/Cargo.lock index 426f18e..c8b2bf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + [[package]] name = "aho-corasick" version = "0.5.3" @@ -19,11 +30,11 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +checksum = "b4f55bd91a0978cbfd91c457a164bab8b4001c833b7f323132c0a4e1922dd44e" dependencies = [ - "memchr 2.4.1", + "memchr 2.5.0", ] [[package]] @@ -37,9 +48,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.56" +version = "1.0.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4361135be9122e0870de935d7c439aef945b9f9ddd4199a553b5270b49c82a27" +checksum = "98161a4e3e2184da77bb14f02184cdd111e83bbbcc9979dfee3c44b9a85f5602" [[package]] name = "atty" @@ -72,7 +83,7 @@ dependencies = [ "peeking_take_while", "proc-macro2", "quote", - "regex 1.5.5", + "regex 1.6.0", "rustc-hash", "shlex", ] @@ -91,9 +102,11 @@ dependencies = [ "bliss-audio-aubio-rs", "clap", "crossbeam", + "dirs", "env_logger", "ffmpeg-next", "glob", + "indicatif", "lazy_static 1.4.0", "log", "mime_guess", @@ -106,11 +119,14 @@ dependencies = [ "rayon", "rcue", "ripemd160", + "rusqlite", "rustfft", "serde", + "serde_ini", "serde_json", "strum", "strum_macros", + "tempdir", "thiserror", ] @@ -133,42 +149,24 @@ dependencies = [ "fftw-sys", ] -[[package]] -name = "block-buffer" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b" -dependencies = [ - "block-padding", - "byte-tools", - "byteorder", - "generic-array 0.12.4", -] - [[package]] name = "block-buffer" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" dependencies = [ - "generic-array 0.14.5", + "generic-array", ] [[package]] -name = "block-padding" -version = "0.1.5" +name = "block-buffer" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5" +checksum = "69cce20737498f97b993470a6e536b8523f0af7892a4f928cceb1ac5e52ebe7e" dependencies = [ - "byte-tools", + "generic-array", ] -[[package]] -name = "byte-tools" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" - [[package]] name = "byteorder" version = "1.4.3" @@ -232,9 +230,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cc00842eed744b858222c4c9faf7243aafc6d33f92f96935263ef4d8a41ce21" +checksum = "fa2e27ae6ab525c3d369ded447057bca5438d86dc3a68f6faafb8269ba82ebf3" dependencies = [ "glob", "libc", @@ -256,6 +254,29 @@ dependencies = [ "vec_map", ] +[[package]] +name = "console" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89eab4d20ce20cea182308bca13088fecea9c05f6776cf287205d41a0ed3c847" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "terminal_size", + "unicode-width", + "winapi 0.3.9", +] + +[[package]] +name = "cpufeatures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d997bd5e24a5928dd43e46dc529867e207907fe0b239c3477d924f7f2ca320" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.3.2" @@ -267,9 +288,9 @@ dependencies = [ [[package]] name = "crossbeam" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae5588f6b3c3cb05239e90bd110f257254aecd01e4635400391aeae07497845" +checksum = "2801af0d36612ae591caa9568261fddce32ce6e08a7275ea334a06a4ad021a2c" dependencies = [ "cfg-if", "crossbeam-channel", @@ -281,9 +302,9 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.4" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aaa7bd5fb665c6864b5f963dd9097905c54125909c7aa94c9e18507cdbe6c53" +checksum = "c2dd04ddaf88237dc3b8d8f9a3c1004b506b54b3313403944054d23c0870c521" dependencies = [ "cfg-if", "crossbeam-utils", @@ -291,9 +312,9 @@ dependencies = [ [[package]] name = "crossbeam-deque" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6455c0ca19f0d2fbf751b908d5c55c1f5cbc65e03c4225427254b46890bdde1e" +checksum = "715e8152b692bba2d374b53d4875445368fdf21a94751410af607a5ac677d1fc" dependencies = [ "cfg-if", "crossbeam-epoch", @@ -302,23 +323,23 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.8" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1145cf131a2c6ba0615079ab6a638f7e1973ac9c2634fcbeaaad6114246efe8c" +checksum = "045ebe27666471bb549370b4b0b3e51b07f56325befa4284db65fc89c02511b1" dependencies = [ "autocfg", "cfg-if", "crossbeam-utils", - "lazy_static 1.4.0", "memoffset", + "once_cell", "scopeguard", ] [[package]] name = "crossbeam-queue" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f25d8400f4a7a5778f0e4e52384a48cbd9b5c495d110786187fc750075277a2" +checksum = "1cd42583b04998a5363558e5f9291ee5a5ff6b49944332103f251e7479a82aa7" dependencies = [ "cfg-if", "crossbeam-utils", @@ -326,19 +347,29 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.8" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf124c720b7686e3c2663cf54062ab0f68a88af2fb6a030e87e30bf721fcb38" +checksum = "51887d4adc7b564537b15adcfb307936f8075dfcd5f00dde9a9f1d29383682bc" dependencies = [ "cfg-if", - "lazy_static 1.4.0", + "once_cell", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", ] [[package]] name = "ctor" -version = "0.1.22" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f877be4f7c9f246b183111634f75baa039715e3f46ce860677d3b19a69fb229c" +checksum = "cdffe87e1d521a10f9696f833fe502293ea446d7f256c06128293a4119bdf4cb" dependencies = [ "quote", "syn", @@ -346,18 +377,9 @@ dependencies = [ [[package]] name = "diff" -version = "0.1.12" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e25ea47919b1560c4e3b7fe0aaab9becf5b84a10325ddf7db0f0ba5e1026499" - -[[package]] -name = "digest" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" -dependencies = [ - "generic-array 0.12.4", -] +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" [[package]] name = "digest" @@ -365,14 +387,50 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" dependencies = [ - "generic-array 0.14.5", + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adfbc57365a37acbd2ebf2b64d7e69bb766e2fea813521ed536f5d0520dcf86c" +dependencies = [ + "block-buffer 0.10.3", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi 0.3.9", ] [[package]] name = "either" -version = "1.6.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" + +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" [[package]] name = "env_logger" @@ -383,15 +441,21 @@ dependencies = [ "atty", "humantime", "log", - "regex 1.5.5", + "regex 1.6.0", "termcolor", ] [[package]] -name = "fake-simd" -version = "0.1.2" +name = "fallible-iterator" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "ffmpeg-next" @@ -444,13 +508,11 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39522e96686d38f4bc984b9198e3a0613264abaebaff2c5c918bfa6b6da09af" +checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6" dependencies = [ - "cfg-if", "crc32fast", - "libc", "miniz_oxide", ] @@ -472,19 +534,16 @@ dependencies = [ ] [[package]] -name = "generic-array" -version = "0.12.4" +name = "fuchsia-cprng" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" -dependencies = [ - "typenum", -] +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" [[package]] name = "generic-array" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803" +checksum = "bff49e947297f3312447abdca79f45f4738097cc82b06e72054d2223f601f1b9" dependencies = [ "typenum", "version_check", @@ -492,13 +551,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad" +checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] @@ -512,6 +571,24 @@ name = "hashbrown" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashlink" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7249a3129cbc1ffccd74857f81464a323a152173cdb134e0fd81bc803b29facf" +dependencies = [ + "hashbrown 0.11.2", +] [[package]] name = "heck" @@ -539,28 +616,39 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "indexmap" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee" +checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.12.3", +] + +[[package]] +name = "indicatif" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfddc9561e8baf264e0e45e197fd7696320026eb10a8180340debc27b18f535b" +dependencies = [ + "console", + "number_prefix", + "unicode-width", ] [[package]] name = "itertools" -version = "0.10.3" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9a9d19fa1e79b6215ff29b9d6880b706147f16e9b1dbb1e4e5947b5b02bc5e3" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" +checksum = "6c8af84674fe1f223a982c933a0ee1086ac4d4052aa0fb8060c12c6ad838e754" [[package]] name = "jobserver" @@ -601,9 +689,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.123" +version = "0.2.133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb691a747a7ab48abc15c5b42066eaafde10dc427e3b6ee2a1cf43db04c763bd" +checksum = "c0f80d65747a3e43d1596c7c5492d95d5edddaabd45a7fcdb02b95f644164966" [[package]] name = "libloading" @@ -616,19 +704,23 @@ dependencies = [ ] [[package]] -name = "log" -version = "0.4.16" +name = "libsqlite3-sys" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6389c490849ff5bc16be905ae24bc913a9c8892e19b2341dbc175e14c341c2b8" +checksum = "898745e570c7d0453cc1fbc4a701eb6c662ed54e8fec8b7d14be137ebeeb9d14" dependencies = [ - "cfg-if", + "pkg-config", + "vcpkg", ] [[package]] -name = "maplit" -version = "1.0.2" +name = "log" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] [[package]] name = "matrixmultiply" @@ -650,9 +742,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" [[package]] name = "memoffset" @@ -687,21 +779,21 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.5.1" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2b29bd4bc3f33391105ebee3589c19197c4271e3e5a9ec9bfe8127eeff8f082" +checksum = "96590ba8f175222643a85693f33d26e9c8a015f599c216509b1a6894af675d34" dependencies = [ "adler", ] [[package]] name = "ndarray" -version = "0.15.4" +version = "0.15.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec23e6762830658d2b3d385a75aa212af2f67a4586d4442907144f3bb6a1ca8" +checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32" dependencies = [ "matrixmultiply", - "num-complex 0.4.0", + "num-complex 0.4.2", "num-integer", "num-traits", "rawpointer", @@ -716,7 +808,7 @@ checksum = "f85776816e34becd8bd9540818d7dc77bf28307f3b3dcc51cc82403c6931680c" dependencies = [ "byteorder", "ndarray", - "num-complex 0.4.0", + "num-complex 0.4.2", "num-traits", "py_literal", "zip", @@ -724,9 +816,9 @@ dependencies = [ [[package]] name = "ndarray-stats" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22877ad014bafa2f7dcfa5d556b0c7a52b0546cc98061a1ebef6d1834957b069" +checksum = "af5a8477ac96877b5bd1fd67e0c28736c12943aba24eda92b127e036b0c8f400" dependencies = [ "indexmap", "itertools", @@ -734,7 +826,7 @@ dependencies = [ "noisy_float", "num-integer", "num-traits", - "rand", + "rand 0.8.5", ] [[package]] @@ -752,7 +844,7 @@ version = "7.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36" dependencies = [ - "memchr 2.4.1", + "memchr 2.5.0", "minimal-lexical", ] @@ -789,18 +881,18 @@ dependencies = [ [[package]] name = "num-complex" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26873667bbbb7c5182d4a37c1add32cdf09f841af72da53318fdb81543c15085" +checksum = "7ae39348c8bc5fbd7f40c727a9925f03517afd2ab27d46702108b6a7e5414c19" dependencies = [ "num-traits", ] [[package]] name = "num-integer" -version = "0.1.44" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" dependencies = [ "autocfg", "num-traits", @@ -808,9 +900,9 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.42" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2021c8337a54d21aca0d59a92577a029af9431cb59b909b03252b9c164fad59" +checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" dependencies = [ "autocfg", "num-integer", @@ -819,9 +911,9 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" dependencies = [ "autocfg", ] @@ -837,10 +929,16 @@ dependencies = [ ] [[package]] -name = "opaque-debug" -version = "0.2.3" +name = "number_prefix" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "once_cell" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e82dad04139b71a90c080c8463fe0dc7902db5192d939bd0950f074d014339e1" [[package]] name = "opaque-debug" @@ -865,18 +963,19 @@ checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" [[package]] name = "pest" -version = "2.1.3" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" +checksum = "cb779fcf4bb850fbbb0edc96ff6cf34fd90c4b1a112ce042653280d9a7364048" dependencies = [ + "thiserror", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.1.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833d1ae558dc601e9a60366421196a8d94bc0ac980476d0b67e1d0988d72b2d0" +checksum = "502b62a6d0245378b04ffe0a7fb4f4419a4815fce813bd8a0ec89a56e07d67b1" dependencies = [ "pest", "pest_generator", @@ -884,9 +983,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.1.3" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99b8db626e31e5b81787b9783425769681b347011cc59471e33ea46d2ea0cf55" +checksum = "451e629bf49b750254da26132f1a5a9d11fd8a95a3df51d15c4abd1ba154cb6c" dependencies = [ "pest", "pest_meta", @@ -897,13 +996,13 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.1.3" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54be6e404f5317079812fc8f9f5279de376d8856929e21c184ecf6bbd692a11d" +checksum = "bcec162c71c45e269dfc3fc2916eaeb97feab22993a21bcce4721d08cd7801a6" dependencies = [ - "maplit", + "once_cell", "pest", - "sha-1", + "sha1", ] [[package]] @@ -920,32 +1019,32 @@ checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" [[package]] name = "pretty_assertions" -version = "1.2.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89f989ac94207d048d92db058e4f6ec7342b0971fc58d1271ca148b799b3563" +checksum = "a25e9bcb20aa780fd0bb16b72403a9064d6b3f22f026946029acb941a50af755" dependencies = [ - "ansi_term", "ctor", "diff", "output_vt100", + "yansi", ] [[package]] name = "primal-check" -version = "0.3.1" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01419cee72c1a1ca944554e23d83e483e1bccf378753344e881de28b5487511d" +checksum = "9df7f93fd637f083201473dab4fee2db4c429d32e55e3299980ab3957ab916a0" dependencies = [ "num-integer", ] [[package]] name = "proc-macro2" -version = "1.0.37" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec757218438d5fda206afc041538b2f6d889286160d649a86a24d37e1235afd1" +checksum = "0a2ca2c61bc9f3d74d2886294ab7b9853abd9c1ad903a3ac7815c58989bb7bab" dependencies = [ - "unicode-xid", + "unicode-ident", ] [[package]] @@ -955,7 +1054,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "102df7a3d46db9d3891f178dcc826dc270a6746277a9ae6436f8d29fd490a8e1" dependencies = [ "num-bigint", - "num-complex 0.4.0", + "num-complex 0.4.2", "num-traits", "pest", "pest_derive", @@ -963,13 +1062,26 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.18" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1" +checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" +dependencies = [ + "fuchsia-cprng", + "libc", + "rand_core 0.3.1", + "rdrand", + "winapi 0.3.9", +] + [[package]] name = "rand" version = "0.8.5" @@ -978,7 +1090,7 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -988,14 +1100,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] name = "rand_core" -version = "0.6.3" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", +] + +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ "getrandom", ] @@ -1008,9 +1135,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd249e82c21598a9a426a4e00dd7adc1d640b22445ec8545feef801d1a74c221" +checksum = "bd99e5772ead8baa5215278c9b15bf92087709e9c1b2d1f97cdb5a183c933a7d" dependencies = [ "autocfg", "crossbeam-deque", @@ -1020,9 +1147,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.9.2" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f51245e1e62e1f1629cbfec37b5793bbabcaeb90f30e94d2ba03564687353e4" +checksum = "258bcdb5ac6dad48491bb2992db6b7cf74878b0384908af124823d118c99683f" dependencies = [ "crossbeam-channel", "crossbeam-deque", @@ -1036,6 +1163,35 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fca1481d62f18158646de2ec552dd63f8bdc5be6448389b192ba95c939df997e" +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +dependencies = [ + "getrandom", + "redox_syscall", + "thiserror", +] + [[package]] name = "regex" version = "0.1.80" @@ -1051,13 +1207,13 @@ dependencies = [ [[package]] name = "regex" -version = "1.5.5" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a11647b6b25ff05a515cb92c365cec08801e83423a235b51e231e1808747286" +checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" dependencies = [ - "aho-corasick 0.7.18", - "memchr 2.4.1", - "regex-syntax 0.6.25", + "aho-corasick 0.7.19", + "memchr 2.5.0", + "regex-syntax 0.6.27", ] [[package]] @@ -1068,9 +1224,24 @@ checksum = "f9ec002c35e86791825ed294b50008eea9ddfc8def4420124fbc6b08db834957" [[package]] name = "regex-syntax" -version = "0.6.25" +version = "0.6.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" +checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" + +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi 0.3.9", +] + +[[package]] +name = "result" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194d8e591e405d1eecf28819740abed6d719d1a2db87fc0bcdedee9a26d55560" [[package]] name = "ripemd160" @@ -1080,7 +1251,22 @@ checksum = "2eca4ecc81b7f313189bf73ce724400a07da2a6dac19588b03c8bd76a2dcc251" dependencies = [ "block-buffer 0.9.0", "digest 0.9.0", - "opaque-debug 0.3.0", + "opaque-debug", +] + +[[package]] +name = "rusqlite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85127183a999f7db96d1a976a309eebbfb6ea3b0b400ddd8340190129de6eb7a" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "memchr 2.5.0", + "smallvec", ] [[package]] @@ -1105,9 +1291,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.9" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" +checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" [[package]] name = "scopeguard" @@ -1117,18 +1303,18 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "serde" -version = "1.0.136" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce31e24b01e1e524df96f1c2fdd054405f8d7376249a5110886fb4b658484789" +checksum = "728eb6351430bccb993660dfffc5a72f91ccc1295abaa8ce19b27ebe4f75568b" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.136" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08597e7152fcd306f41838ed3e37be9eaeed2b61c42e2117266a554fab4662f9" +checksum = "81fa1584d3d1bcacd84c277a0dfe21f5b0f6accf4a23d04d4c6d61f1af522b4c" dependencies = [ "proc-macro2", "quote", @@ -1136,10 +1322,21 @@ dependencies = [ ] [[package]] -name = "serde_json" -version = "1.0.79" +name = "serde_ini" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e8d9fa5c3b304765ce1fd9c4c8a3de2c8db365a5b91be52f186efc675681d95" +checksum = "eb236687e2bb073a7521c021949be944641e671b8505a94069ca37b656c81139" +dependencies = [ + "result", + "serde", + "void", +] + +[[package]] +name = "serde_json" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44" dependencies = [ "itoa", "ryu", @@ -1147,15 +1344,14 @@ dependencies = [ ] [[package]] -name = "sha-1" -version = "0.8.2" +name = "sha1" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7d94d0bede923b3cea61f3f1ff57ff8cdfd77b400fb8f9998949e0cf04163df" +checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" dependencies = [ - "block-buffer 0.7.3", - "digest 0.8.1", - "fake-simd", - "opaque-debug 0.2.3", + "cfg-if", + "cpufeatures", + "digest 0.10.5", ] [[package]] @@ -1164,6 +1360,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3" +[[package]] +name = "smallvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1" + [[package]] name = "strength_reduce" version = "0.2.3" @@ -1196,13 +1398,23 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.91" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b683b2b825c8eef438b77c36a06dc262294da3d5a5813fac20da149241dcd44d" +checksum = "52205623b1b0f064a4e71182c3b18ae902267282930c6d5462c91b859668426e" dependencies = [ "proc-macro2", "quote", - "unicode-xid", + "unicode-ident", +] + +[[package]] +name = "tempdir" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" +dependencies = [ + "rand 0.4.6", + "remove_dir_all", ] [[package]] @@ -1214,6 +1426,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal_size" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi 0.3.9", +] + [[package]] name = "textwrap" version = "0.11.0" @@ -1225,18 +1447,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.30" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417" +checksum = "c53f98874615aea268107765aa1ed8f6116782501d18e53d08b471733bea6c85" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.30" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b" +checksum = "f8b463991b4eab2d801e724172285ec4195c650e8ec79b149e6c2a8e6dd3f783" dependencies = [ "proc-macro2", "quote", @@ -1264,11 +1486,12 @@ dependencies = [ [[package]] name = "time" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" dependencies = [ "libc", + "wasi 0.10.0+wasi-snapshot-preview1", "winapi 0.3.9", ] @@ -1290,9 +1513,9 @@ checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" [[package]] name = "ucd-trie" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" +checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81" [[package]] name = "unicase" @@ -1304,22 +1527,22 @@ dependencies = [ ] [[package]] -name = "unicode-segmentation" -version = "1.9.0" +name = "unicode-ident" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" +checksum = "dcc811dc4066ac62f84f11307873c4850cb653bfa9b1719cee2bd2204a4bc5dd" + +[[package]] +name = "unicode-segmentation" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fdbf052a0783de01e944a6ce7a8cb939e295b1e7be835a1112c3b9a7f047a5a" [[package]] name = "unicode-width" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" - -[[package]] -name = "unicode-xid" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" [[package]] name = "utf8-ranges" @@ -1346,10 +1569,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[package]] -name = "wasi" -version = "0.10.2+wasi-snapshot-preview1" +name = "void" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "winapi" @@ -1394,6 +1629,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + [[package]] name = "zip" version = "0.5.13" diff --git a/Cargo.toml b/Cargo.toml index 501c514..7707af9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ keywords = ["audio", "analysis", "MIR", "playlist", "similarity"] readme = "README.md" [package.metadata.docs.rs] -features = ["bliss-audio-aubio-rs/rustdoc"] +features = ["bliss-audio-aubio-rs/rustdoc", "library"] no-default-features = true [features] @@ -23,6 +23,12 @@ ffmpeg-static = ["ffmpeg-next/static"] python-bindings = ["bliss-audio-aubio-rs/fftw3"] # Enable the benchmarks with `cargo +nightly bench --features=bench` bench = [] +library = [ + "serde", "dep:rusqlite", "dep:dirs", "dep:tempdir", + "dep:anyhow", "dep:serde_ini", "dep:serde_json", + "dep:indicatif", +] +serde = ["dep:serde"] [dependencies] ripemd160 = "0.9.0" @@ -44,13 +50,35 @@ thiserror = "1.0.24" bliss-audio-aubio-rs = "0.2.0" strum = "0.21" strum_macros = "0.21" -serde = { version = "1.0", optional = true, features = ["derive"] } rcue = "0.1.1" +# Deps for the library feature +serde = { version = "1.0", optional = true, features = ["derive"] } +serde_json = { version = "1.0.59", optional = true } +serde_ini = { version = "0.2.0", optional = true } +tempdir = { version = "0.3.7", optional = true } +rusqlite = { version = "0.27.0", optional = true } +dirs = { version = "4.0.0", optional = true } +anyhow = { version = "1.0.58", optional = true } +indicatif = { version = "0.17.0", optional = true } + [dev-dependencies] mime_guess = "2.0.3" glob = "0.3.0" anyhow = "1.0.45" -serde_json = "1.0.59" clap = "2.33.3" pretty_assertions = "1.2.1" +serde_json = "1.0.59" + +[[example]] +name = "library" +required-features = ["library"] + +[[example]] +name = "library_extra_info" +required-features = ["library"] + + +[[example]] +name = "playlist" +required-features = ["serde"] diff --git a/examples/library.rs b/examples/library.rs new file mode 100644 index 0000000..c7ea861 --- /dev/null +++ b/examples/library.rs @@ -0,0 +1,202 @@ +/// Basic example of how one would combine bliss with an "audio player", +/// through [Library]. +/// +/// For simplicity's sake, this example recursively gets songs from a folder +/// to emulate an audio player library, without handling CUE files. +use anyhow::Result; +use bliss_audio::library::{AppConfigTrait, BaseConfig, Library}; +use clap::{App, Arg, SubCommand}; +use glob::glob; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +// A config structure, that will be serialized as a +// JSON file upon Library creation. +pub struct Config { + #[serde(flatten)] + // The base configuration, containing both the config file + // path, as well as the database path. + pub base_config: BaseConfig, + // An extra field, to store the music library path. Any number + // of arbitrary fields (even Serializable structures) can + // of course be added. + pub music_library_path: PathBuf, +} + +impl Config { + pub fn new( + music_library_path: PathBuf, + config_path: Option, + database_path: Option, + ) -> Result { + let base_config = BaseConfig::new(config_path, database_path)?; + Ok(Self { + base_config, + music_library_path, + }) + } +} + +// The AppConfigTrait must know how to access the base config. +impl AppConfigTrait for Config { + fn base_config(&self) -> &BaseConfig { + &self.base_config + } + + fn base_config_mut(&mut self) -> &mut BaseConfig { + &mut self.base_config + } +} + +// A trait allowing to implement methods for the Library, +// useful if you don't need to store extra information in fields. +// Otherwise, doing +// ``` +// struct CustomLibrary { +// library: Library, +// extra_field: ..., +// } +// ``` +// and implementing functions for that struct would be the way to go. +// That's what the [reference](https://github.com/Polochon-street/blissify-rs) +// implementation does. +trait CustomLibrary { + fn song_paths(&self) -> Result>; +} + +impl CustomLibrary for Library { + /// Get all songs in the player library + fn song_paths(&self) -> Result> { + let music_path = &self.config.music_library_path; + let pattern = Path::new(&music_path).join("**").join("*"); + + Ok(glob(&pattern.to_string_lossy())? + .map(|e| fs::canonicalize(e.unwrap()).unwrap()) + .filter(|e| match mime_guess::from_path(e).first() { + Some(m) => m.type_() == "audio", + None => false, + }) + .map(|x| x.to_string_lossy().to_string()) + .collect::>()) + } +} + +// A simple example of what a CLI-app would look. +// +// Note that `Library::new` is used only on init, and subsequent +// commands use `Library::from_path`. +fn main() -> Result<()> { + let matches = App::new("library-example") + .version(env!("CARGO_PKG_VERSION")) + .author("Polochon_street") + .about("Example binary implementing bliss for an audio player.") + .subcommand( + SubCommand::with_name("init") + .about( + "Initialize a Library, both storing the config and analyzing folders + containing songs.", + ) + .arg( + Arg::with_name("FOLDER") + .help("A folder containing the music library to analyze.") + .required(true), + ) + .arg( + Arg::with_name("database-path") + .short("d") + .long("database-path") + .help( + "Optional path where to store the database file containing + the songs' analysis. Defaults to XDG_DATA_HOME/bliss-rs/bliss.db.", + ) + .takes_value(true), + ) + .arg( + Arg::with_name("config-path") + .short("c") + .long("config-path") + .help( + "Optional path where to store the config file containing + the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.", + ) + .takes_value(true), + ), + ) + .subcommand( + SubCommand::with_name("update") + .about( + "Update a Library's songs, trying to analyze failed songs, + as well as songs not in the library.", + ) + .arg( + Arg::with_name("config-path") + .short("c") + .long("config-path") + .help( + "Optional path where to load the config file containing + the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.", + ) + .takes_value(true), + ), + ) + .subcommand( + SubCommand::with_name("playlist") + .about( + "Make a playlist, starting with the song at SONG_PATH, returning + the songs' paths.", + ) + .arg(Arg::with_name("SONG_PATH").takes_value(true)) + .arg( + Arg::with_name("config-path") + .short("c") + .long("config-path") + .help( + "Optional path where to load the config file containing + the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.", + ) + .takes_value(true), + ) + .arg( + Arg::with_name("playlist-length") + .short("l") + .long("playlist-length") + .help("Optional playlist length. Defaults to 20.") + .takes_value(true), + ), + ) + .get_matches(); + if let Some(sub_m) = matches.subcommand_matches("init") { + let folder = PathBuf::from(sub_m.value_of("FOLDER").unwrap()); + let config_path = sub_m.value_of("config-path").map(PathBuf::from); + let database_path = sub_m.value_of("database-path").map(PathBuf::from); + + let config = Config::new(folder, config_path, database_path)?; + let mut library = Library::new(config)?; + + library.analyze_paths(library.song_paths()?, true)?; + } else if let Some(sub_m) = matches.subcommand_matches("update") { + let config_path = sub_m.value_of("config-path").map(PathBuf::from); + let mut library: Library = Library::from_config_path(config_path)?; + library.update_library(library.song_paths()?, true)?; + } else if let Some(sub_m) = matches.subcommand_matches("playlist") { + let song_path = sub_m.value_of("SONG_PATH").unwrap(); + let config_path = sub_m.value_of("config-path").map(PathBuf::from); + let playlist_length = sub_m + .value_of("playlist-length") + .unwrap_or("20") + .parse::()?; + let library: Library = Library::from_config_path(config_path)?; + let songs = library.playlist_from::<()>(song_path, playlist_length)?; + let song_paths = songs + .into_iter() + .map(|s| s.bliss_song.path.to_string_lossy().to_string()) + .collect::>(); + for song in song_paths { + println!("{:?}", song); + } + } + + Ok(()) +} diff --git a/examples/library_extra_info.rs b/examples/library_extra_info.rs new file mode 100644 index 0000000..239f942 --- /dev/null +++ b/examples/library_extra_info.rs @@ -0,0 +1,225 @@ +/// Basic example of how one would combine bliss with an "audio player", +/// through [Library], showing how to put extra info in the database for +/// each song. +/// +/// For simplicity's sake, this example recursively gets songs from a folder +/// to emulate an audio player library, without handling CUE files. +use anyhow::Result; +use bliss_audio::library::{AppConfigTrait, BaseConfig, Library}; +use clap::{App, Arg, SubCommand}; +use glob::glob; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +/// A config structure, that will be serialized as a +/// JSON file upon Library creation. +pub struct Config { + #[serde(flatten)] + /// The base configuration, containing both the config file + /// path, as well as the database path. + pub base_config: BaseConfig, + /// An extra field, to store the music library path. Any number + /// of arbitrary fields (even Serializable structures) can + /// of course be added. + pub music_library_path: PathBuf, +} + +impl Config { + pub fn new( + music_library_path: PathBuf, + config_path: Option, + database_path: Option, + ) -> Result { + let base_config = BaseConfig::new(config_path, database_path)?; + Ok(Self { + base_config, + music_library_path, + }) + } +} + +// The AppConfigTrait must know how to access the base config. +impl AppConfigTrait for Config { + fn base_config(&self) -> &BaseConfig { + &self.base_config + } + + fn base_config_mut(&mut self) -> &mut BaseConfig { + &mut self.base_config + } +} + +// A trait allowing to implement methods for the Library, +// useful if you don't need to store extra information in fields. +// Otherwise, doing +// ``` +// struct CustomLibrary { +// library: Library, +// extra_field: ..., +// } +// ``` +// and implementing functions for that struct would be the way to go. +// That's what the [reference](https://github.com/Polochon-street/blissify-rs) +// implementation does. +trait CustomLibrary { + fn song_paths_info(&self) -> Result>; +} + +impl CustomLibrary for Library { + /// Get all songs in the player library, along with the extra info + /// one would want to store along with each song. + fn song_paths_info(&self) -> Result> { + let music_path = &self.config.music_library_path; + let pattern = Path::new(&music_path).join("**").join("*"); + + Ok(glob(&pattern.to_string_lossy())? + .map(|e| fs::canonicalize(e.unwrap()).unwrap()) + .filter_map(|e| { + mime_guess::from_path(&e).first().map(|m| { + ( + e.to_string_lossy().to_string(), + ExtraInfo { + extension: e.extension().map(|e| e.to_string_lossy().to_string()), + file_name: e.file_name().map(|e| e.to_string_lossy().to_string()), + mime_type: format!("{}/{}", m.type_(), m.subtype()), + }, + ) + }) + }) + .collect::>()) + } +} + +#[derive(Deserialize, Serialize, Debug, PartialEq, Clone, Default)] +// An (somewhat simple) example of what extra metadata one would put, along +// with song analysis data. +struct ExtraInfo { + extension: Option, + file_name: Option, + mime_type: String, +} + +// A simple example of what a CLI-app would look. +// +// Note that `Library::new` is used only on init, and subsequent +// commands use `Library::from_path`. +fn main() -> Result<()> { + let matches = App::new("library-example") + .version(env!("CARGO_PKG_VERSION")) + .author("Polochon_street") + .about("Example binary implementing bliss for an audio player.") + .subcommand( + SubCommand::with_name("init") + .about( + "Initialize a Library, both storing the config and analyzing folders + containing songs.", + ) + .arg( + Arg::with_name("FOLDER") + .help("A folder containing the music library to analyze.") + .required(true), + ) + .arg( + Arg::with_name("database-path") + .short("d") + .long("database-path") + .help( + "Optional path where to store the database file containing + the songs' analysis. Defaults to XDG_DATA_HOME/bliss-rs/bliss.db.", + ) + .takes_value(true), + ) + .arg( + Arg::with_name("config-path") + .short("c") + .long("config-path") + .help( + "Optional path where to store the config file containing + the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.", + ) + .takes_value(true), + ), + ) + .subcommand( + SubCommand::with_name("update") + .about( + "Update a Library's songs, trying to analyze failed songs, + as well as songs not in the library.", + ) + .arg( + Arg::with_name("config-path") + .short("c") + .long("config-path") + .help( + "Optional path where to load the config file containing + the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.", + ) + .takes_value(true), + ), + ) + .subcommand( + SubCommand::with_name("playlist") + .about( + "Make a playlist, starting with the song at SONG_PATH, returning + the songs' paths.", + ) + .arg(Arg::with_name("SONG_PATH").takes_value(true)) + .arg( + Arg::with_name("config-path") + .short("c") + .long("config-path") + .help( + "Optional path where to load the config file containing + the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.", + ) + .takes_value(true), + ) + .arg( + Arg::with_name("playlist-length") + .short("l") + .long("playlist-length") + .help("Optional playlist length. Defaults to 20.") + .takes_value(true), + ), + ) + .get_matches(); + if let Some(sub_m) = matches.subcommand_matches("init") { + let folder = PathBuf::from(sub_m.value_of("FOLDER").unwrap()); + let config_path = sub_m.value_of("config-path").map(PathBuf::from); + let database_path = sub_m.value_of("database-path").map(PathBuf::from); + + let config = Config::new(folder, config_path, database_path)?; + let mut library = Library::new(config)?; + + library.analyze_paths_extra_info(library.song_paths_info()?, true)?; + } else if let Some(sub_m) = matches.subcommand_matches("update") { + let config_path = sub_m.value_of("config-path").map(PathBuf::from); + let mut library: Library = Library::from_config_path(config_path)?; + library.update_library_extra_info(library.song_paths_info()?, true)?; + } else if let Some(sub_m) = matches.subcommand_matches("playlist") { + let song_path = sub_m.value_of("SONG_PATH").unwrap(); + let config_path = sub_m.value_of("config-path").map(PathBuf::from); + let playlist_length = sub_m + .value_of("playlist-length") + .unwrap_or("20") + .parse::()?; + let library: Library = Library::from_config_path(config_path)?; + let songs = library.playlist_from::(song_path, playlist_length)?; + let playlist = songs + .into_iter() + .map(|s| { + ( + s.bliss_song.path.to_string_lossy().to_string(), + s.extra_info.mime_type, + ) + }) + .collect::>(); + for (path, mime_type) in playlist { + println!("{} <{}>", path, mime_type,); + } + } + + Ok(()) +} diff --git a/examples/playlist.rs b/examples/playlist.rs index 1851c4d..5e10840 100644 --- a/examples/playlist.rs +++ b/examples/playlist.rs @@ -1,26 +1,16 @@ -#[cfg(feature = "serde")] use anyhow::Result; -#[cfg(feature = "serde")] use bliss_audio::playlist::{closest_to_first_song, dedup_playlist, euclidean_distance}; -#[cfg(feature = "serde")] use bliss_audio::{analyze_paths, Song}; -#[cfg(feature = "serde")] use clap::{App, Arg}; -#[cfg(feature = "serde")] use glob::glob; -#[cfg(feature = "serde")] use std::env; -#[cfg(feature = "serde")] use std::fs; -#[cfg(feature = "serde")] use std::io::BufReader; -#[cfg(feature = "serde")] use std::path::{Path, PathBuf}; /* Analyzes a folder recursively, and make a playlist out of the file * provided by the user. */ // How to use: ./playlist [-o file.m3u] [-a analysis.json] -#[cfg(feature = "serde")] fn main() -> Result<()> { let matches = App::new("playlist") .version(env!("CARGO_PKG_VERSION")) @@ -103,8 +93,3 @@ fn main() -> Result<()> { } Ok(()) } - -#[cfg(not(feature = "serde"))] -fn main() { - println!("You need the serde feature enabled to run this file."); -} diff --git a/src/cue.rs b/src/cue.rs index acc57ee..616899b 100644 --- a/src/cue.rs +++ b/src/cue.rs @@ -131,7 +131,7 @@ impl BlissCueFile { let song = Song { path: PathBuf::from(format!( "{}/CUE_TRACK{:03}", - self.audio_file_path.to_string_lossy(), + self.cue_path.to_string_lossy(), index, )), album: self.album.to_owned(), @@ -210,7 +210,7 @@ mod tests { let songs = BlissCue::songs_from_path("data/testcue.cue").unwrap(); let expected = vec![ Ok(Song { - path: Path::new("data/testcue.flac/CUE_TRACK001").to_path_buf(), + path: Path::new("data/testcue.cue/CUE_TRACK001").to_path_buf(), analysis: Analysis { internal_analysis: [ 0.38463724, @@ -250,7 +250,7 @@ mod tests { ..Default::default() }), Ok(Song { - path: Path::new("data/testcue.flac/CUE_TRACK002").to_path_buf(), + path: Path::new("data/testcue.cue/CUE_TRACK002").to_path_buf(), analysis: Analysis { internal_analysis: [ 0.18622077, @@ -290,7 +290,7 @@ mod tests { ..Default::default() }), Ok(Song { - path: Path::new("data/testcue.flac/CUE_TRACK003").to_path_buf(), + path: Path::new("data/testcue.cue/CUE_TRACK003").to_path_buf(), analysis: Analysis { internal_analysis: [ 0.0024261475, diff --git a/src/lib.rs b/src/lib.rs index dcbf505..19a51d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,14 @@ //! is as easy as computing distances between that song and the rest, and ordering //! the songs by distance, ascending. //! +//! If you want to implement a bliss plugin for an already existing audio +//! player, the [Library] struct is a collection of goodies that should prove +//! useful (it contains utilities to store analyzed songs in a self-contained +//! database file, to make playlists directly from the database, etc). +//! [blissify](https://github.com/Polochon-street/blissify-rs/) for both +//! an example of how the [Library] struct works, and a real-life demo of bliss +//! implemented for [MPD](https://www.musicpd.org/). +//! //! # Examples //! //! ### Analyze & compute the distance between two songs @@ -59,6 +67,8 @@ #![warn(rustdoc::missing_doc_code_examples)] mod chroma; pub mod cue; +#[cfg(feature = "library")] +pub mod library; mod misc; pub mod playlist; mod song; @@ -262,11 +272,11 @@ mod tests { #[test] fn test_analyze_paths() { let paths = vec![ - PathBuf::from("./data/s16_mono_22_5kHz.flac"), - PathBuf::from("./data/testcue.cue"), - PathBuf::from("./data/white_noise.flac"), - PathBuf::from("definitely-not-existing.foo"), - PathBuf::from("not-existing.foo"), + "./data/s16_mono_22_5kHz.flac", + "./data/testcue.cue", + "./data/white_noise.flac", + "definitely-not-existing.foo", + "not-existing.foo", ]; let mut results = analyze_paths(&paths) .map(|x| match &x.1 { @@ -304,21 +314,9 @@ mod tests { )), ), (true, PathBuf::from("./data/s16_mono_22_5kHz.flac"), None), - ( - true, - PathBuf::from("./data/testcue.flac/CUE_TRACK001"), - None, - ), - ( - true, - PathBuf::from("./data/testcue.flac/CUE_TRACK002"), - None, - ), - ( - true, - PathBuf::from("./data/testcue.flac/CUE_TRACK003"), - None, - ), + (true, PathBuf::from("./data/testcue.cue/CUE_TRACK001"), None), + (true, PathBuf::from("./data/testcue.cue/CUE_TRACK002"), None), + (true, PathBuf::from("./data/testcue.cue/CUE_TRACK003"), None), (true, PathBuf::from("./data/white_noise.flac"), None), ]; diff --git a/src/library.rs b/src/library.rs new file mode 100644 index 0000000..8e07fc2 --- /dev/null +++ b/src/library.rs @@ -0,0 +1,2850 @@ +//! Module containing utilities to properly manage a library of [Song]s, +//! for people wanting to e.g. implement a bliss plugin for an existing +//! audio player. A good resource to look at for inspiration is +//! [blissify](https://github.com/Polochon-street/blissify-rs)'s source code. +//! +//! Useful to have direct and easy access to functions that analyze +//! and store analysis of songs in a SQLite database, as well as retrieve it, +//! and make playlists directly from analyzed songs. All functions are as +//! thoroughly tested as possible, so you don't have to do it yourself, +//! including for instance bliss features version handling, etc. +//! +//! It works in three parts: +//! * The first part is the configuration part, which allows you to +//! specify extra information that your plugin might need that will +//! be automatically stored / retrieved when you instanciate a +//! [Library] (the core of your plugin). +//! +//! To do so implies specifying a configuration struct, that will implement +//! [AppConfigTrait], i.e. implement `Serialize`, `Deserialize`, and a +//! function to retrieve the [BaseConfig] (which is just a structure +//! holding the path to the configuration file and the path to the database). +//! +//! The most straightforward way to do so is to have something like this ( +//! in this example, we assume that `path_to_extra_information` is something +//! you would want stored in your configuration file, path to a second music +//! folder for instance: +//! ``` +//! use anyhow::Result; +//! use serde::{Deserialize, Serialize}; +//! use std::path::PathBuf; +//! use bliss_audio::BlissError; +//! use bliss_audio::library::{AppConfigTrait, BaseConfig}; +//! +//! #[derive(Serialize, Deserialize, Clone, Debug)] +//! pub struct Config { +//! #[serde(flatten)] +//! pub base_config: BaseConfig, +//! pub music_library_path: PathBuf, +//! } +//! +//! impl AppConfigTrait for Config { +//! fn base_config(&self) -> &BaseConfig { +//! &self.base_config +//! } +//! +//! fn base_config_mut(&mut self) -> &mut BaseConfig { +//! &mut self.base_config +//! } +//! } +//! impl Config { +//! pub fn new( +//! music_library_path: PathBuf, +//! config_path: Option, +//! database_path: Option, +//! ) -> Result { +//! // Note that by passing `(None, None)` here, the paths will +//! // be inferred automatically using user data dirs. +//! let base_config = BaseConfig::new(config_path, database_path)?; +//! Ok(Self { +//! base_config, +//! music_library_path, +//! }) +//! } +//! } +//! ``` +//! * The second part is the actual [Library] structure, that makes the +//! bulk of the plug-in. To initialize a library once with a given config, +//! you can do (here with a base configuration): +//! ```no_run +//! use anyhow::{Error, Result}; +//! use bliss_audio::library::{BaseConfig, Library}; +//! use std::path::PathBuf; +//! +//! let config_path = Some(PathBuf::from("path/to/config/config.json")); +//! let database_path = Some(PathBuf::from("path/to/config/bliss.db")); +//! let config = BaseConfig::new(config_path, database_path)?; +//! let library: Library = Library::new(config)?; +//! # Ok::<(), Error>(()) +//! ``` +//! Once this is done, you can simply load the library by doing +//! `Library::from_config_path(config_path);` +//! * The third part is using the [Library] itself: it provides you with +//! utilies such as [Library::analyze_paths], which analyzes all songs +//! in given paths and stores it in the databases, as well as +//! [Library::playlist_from], which allows you to generate a playlist +//! from any given analyzed song. +//! +//! The [Library] structure also comes with a [LibrarySong] song struct, +//! which represents a song stored in the database. +//! +//! It is made of a `bliss_song` field, containing the analyzed bliss +//! song (with the normal metatada such as the artist, etc), and an +//! `extra_info` field, which can be any user-defined serialized struct. +//! For most use cases, it would just be the unit type `()` (which is no +//! extra info), that would be used like +//! `library.playlist_from<()>(song, path, playlist_length)`, +//! but functions such as [Library::analyze_paths_extra_info] and +//! [Library::analyze_paths_convert_extra_info] let you customize what +//! information you store for each song. +//! +//! The files in +//! [examples/library.rs](https://github.com/Polochon-street/bliss-rs/blob/master/examples/library.rs) +//! and +//! [examples/libray_extra_info.rs](https://github.com/Polochon-street/bliss-rs/blob/master/examples/library_extra_info.rs) +//! should provide the user with enough information to start with. For a more +//! "real-life" example, the +//! [blissify](https://github.com/Polochon-street/blissify-rs)'s code is using +//! [Library] to implement bliss for a MPD player. +use crate::analyze_paths; +use crate::cue::CueInfo; +use crate::playlist::closest_album_to_group_by_key; +use crate::playlist::closest_to_first_song_by_key; +use crate::playlist::dedup_playlist_by_key; +use crate::playlist::dedup_playlist_custom_distance_by_key; +use crate::playlist::euclidean_distance; +use crate::playlist::DistanceMetric; +use anyhow::{bail, Context, Result}; +#[cfg(not(test))] +use dirs::data_local_dir; +use indicatif::{ProgressBar, ProgressStyle}; +use log::warn; +use noisy_float::prelude::*; +use rusqlite::params; +use rusqlite::Connection; +use rusqlite::OptionalExtension; +use rusqlite::Params; +use rusqlite::Row; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::collections::HashMap; +use std::env; +use std::fs; +use std::fs::create_dir_all; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::Mutex; + +use crate::Song; +use crate::FEATURES_VERSION; +use crate::{Analysis, BlissError, NUMBER_FEATURES}; +use rusqlite::Error as RusqliteError; +use std::convert::TryInto; +use std::time::Duration; + +/// Configuration trait, used for instance to customize +/// the format in which the configuration file should be written. +pub trait AppConfigTrait: Serialize + Sized + DeserializeOwned { + // Implementers have to provide these. + /// This trait should return the [BaseConfig] from the parent, + /// user-created `Config`. + fn base_config(&self) -> &BaseConfig; + + // Implementers have to provide these. + /// This trait should return the [BaseConfig] from the parent, + /// user-created `Config`. + fn base_config_mut(&mut self) -> &mut BaseConfig; + + // Default implementation to output the config as a JSON file. + /// Convert the current config to a [String], to be written to + /// a file. + /// + /// The default writes a JSON file, but any format can be used, + /// using for example the various Serde libraries (`serde_yaml`, etc) - + /// just overwrite this method. + fn serialize_config(&self) -> Result { + Ok(serde_json::to_string(&self)?) + } + + /// Default implementation to load a config from a JSON file. + /// Reads from a string. + /// + /// If you change the serialization format to use something else + /// than JSON, you need to also overwrite that function with the + /// format you chose. + fn deserialize_config(data: &str) -> Result { + Ok(serde_json::from_str(data)?) + } + + /// Load a config from the specified path, using `deserialize_config`. + /// + /// This method can be overriden in the very unlikely case + /// the user wants to do something Serde cannot. + fn from_path(path: &str) -> Result { + let data = fs::read_to_string(path)?; + Self::deserialize_config(&data) + } + + // This default impl is what requires the `Serialize` supertrait + /// Write the configuration to a file using + /// [AppConfigTrait::serialize_config]. + /// + /// This method can be overriden + /// to not use [AppConfigTrait::serialize_config], in the very unlikely + /// case the user wants to do something Serde cannot. + fn write(&self) -> Result<()> { + let serialized = self.serialize_config()?; + fs::write(&self.base_config().config_path, serialized)?; + Ok(()) + } +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +/// The minimum configuration an application needs to work with +/// a [Library]. +pub struct BaseConfig { + config_path: PathBuf, + database_path: PathBuf, + features_version: u16, +} + +impl BaseConfig { + pub(crate) fn get_default_data_folder() -> Result { + let path = match env::var("XDG_DATA_HOME") { + Ok(path) => Path::new(&path).join("bliss-rs"), + Err(_) => { + data_local_dir() + .with_context(|| "No suitable path found to store bliss' song database. Consider specifying such a path.")? + .join("bliss-rs") + }, + }; + Ok(path) + } + + /// Create a new, basic config. Upon calls of `Config.write()`, it will be + /// written to `config_path`. + // + /// Any path omitted will instead default to a "clever" path using + /// data directory inference. + pub fn new(config_path: Option, database_path: Option) -> Result { + let config_path = { + // User provided a path; let the future file creation determine + // whether it points to something valid or not + if let Some(path) = config_path { + path + } else { + Self::get_default_data_folder()?.join(Path::new("config.json")) + } + }; + + let database_path = { + if let Some(path) = database_path { + path + } else { + Self::get_default_data_folder()?.join(Path::new("songs.db")) + } + }; + + Ok(Self { + config_path, + database_path, + features_version: FEATURES_VERSION, + }) + } +} + +impl AppConfigTrait for BaseConfig { + fn base_config(&self) -> &BaseConfig { + self + } + + fn base_config_mut(&mut self) -> &mut BaseConfig { + self + } +} + +/// A struct used to hold a collection of [Song]s, with convenience +/// methods to add, remove and update songs. +/// +/// Provide it either the `BaseConfig`, or a `Config` extending +/// `BaseConfig`. +/// TODO code example +pub struct Library { + /// The configuration struct, containing both information + /// from `BaseConfig` as well as user-defined values. + pub config: Config, + /// SQL connection to the database. + pub sqlite_conn: Arc>, +} + +/// Struct holding both a Bliss song, as well as any extra info +/// that a user would want to store in the database related to that +/// song. +/// +/// The only constraint is that `extra_info` must be serializable, so, +/// something like +/// ```no_compile +/// #[derive(Serialize)] +/// struct ExtraInfo { +/// ignore: bool, +/// unique_id: i64, +/// } +/// let extra_info = ExtraInfo { ignore: true, unique_id = 123 }; +/// let song = LibrarySong { bliss_song: song, extra_info }; +/// ``` +/// is totally possible. +#[derive(Debug, PartialEq, Clone)] +pub struct LibrarySong { + /// Actual bliss song, containing the song's metadata, as well + /// as the bliss analysis. + pub bliss_song: Song, + /// User-controlled information regarding that specific song. + pub extra_info: T, +} + +// TODO add logging statement +// TODO concrete examples +// TODO example LibrarySong without any extra_info +// TODO maybe return number of elements updated / deleted / whatev in analysis +// functions? +// TODO add full rescan +// TODO a song_from_path with custom filters +// TODO "smart" playlist +// TODO should it really use anyhow errors? +impl Library { + /// Create a new [Library] object from the given Config struct that + /// implements the [AppConfigTrait]. + /// writing the configuration to the file given in + /// `config.config_path`. + /// + /// This function should only be called once, when a user wishes to + /// create a completely new "library". + /// Otherwise, load an existing library file using + /// [Library::from_config_path]. + pub fn new(config: Config) -> Result { + if !config + .base_config() + .config_path + .parent() + .ok_or_else(|| { + BlissError::ProviderError(format!( + "specified path {} is not a valid file path.", + config.base_config().config_path.display() + )) + })? + .is_dir() + { + create_dir_all(config.base_config().config_path.parent().unwrap())?; + } + let sqlite_conn = Connection::open(&config.base_config().database_path)?; + sqlite_conn.execute( + " + create table if not exists song ( + id integer primary key, + path text not null unique, + duration float, + album_artist text, + artist text, + title text, + album text, + track_number text, + genre text, + cue_path text, + audio_file_path text, + stamp timestamp default current_timestamp, + version integer, + analyzed boolean default false, + extra_info json, + error text + ); + ", + [], + )?; + sqlite_conn.execute("pragma foreign_keys = on;", [])?; + sqlite_conn.execute( + " + create table if not exists feature ( + id integer primary key, + song_id integer not null, + feature real not null, + feature_index integer not null, + unique(song_id, feature_index), + foreign key(song_id) references song(id) on delete cascade + ) + ", + [], + )?; + config.write()?; + Ok(Library { + config, + sqlite_conn: Arc::new(Mutex::new(sqlite_conn)), + }) + } + + /// Load a library from a configuration path. + /// + /// If no configuration path is provided, the path is + /// set using default data folder path. + pub fn from_config_path(config_path: Option) -> Result { + let config_path: Result = + config_path.map_or_else(|| Ok(BaseConfig::new(None, None)?.config_path), Ok); + let config_path = config_path?; + let data = fs::read_to_string(config_path)?; + let config = Config::deserialize_config(&data)?; + let sqlite_conn = Connection::open(&config.base_config().database_path)?; + let mut library = Library { + config, + sqlite_conn: Arc::new(Mutex::new(sqlite_conn)), + }; + if !library.version_sanity_check()? { + warn!( + "Songs have been analyzed with different versions of bliss; \ + older versions will be ignored from playlists. Update your \ + bliss library to correct the issue." + ); + } + Ok(library) + } + + /// Check whether the library contains songs analyzed with different, + /// incompatible versions of bliss. + /// + /// Returns true if the database is clean (only one version of the + /// features), and false otherwise. + pub fn version_sanity_check(&mut self) -> Result { + let connection = self + .sqlite_conn + .lock() + .map_err(|e| BlissError::ProviderError(e.to_string()))?; + let count: u32 = connection + .query_row("select count(distinct version) from song", [], |row| { + row.get(0) + }) + .optional()? + .unwrap_or(0); + Ok(count <= 1) + } + + /// Create a new [Library] object from a minimal configuration setup, + /// writing it to `config_path`. + pub fn new_from_base( + config_path: Option, + database_path: Option, + ) -> Result + where + BaseConfig: Into, + { + let base = BaseConfig::new(config_path, database_path)?; + let config = base.into(); + Self::new(config) + } + + /// Build a playlist of `playlist_length` items from an already analyzed + /// song in the library at `song_path`. + /// + /// It uses a simple euclidean distance between songs, and deduplicates songs + /// that are too close. + pub fn playlist_from( + &self, + song_path: &str, + playlist_length: usize, + ) -> Result>> { + let first_song: LibrarySong = self.song_from_path(song_path)?; + let mut songs = self.songs_from_library()?; + closest_to_first_song_by_key( + &first_song, + &mut songs, + euclidean_distance, + |s: &LibrarySong| s.bliss_song.to_owned(), + ); + songs.sort_by_cached_key(|song| n32(first_song.bliss_song.distance(&song.bliss_song))); + dedup_playlist_by_key(&mut songs, None, |s: &LibrarySong| { + s.bliss_song.to_owned() + }); + songs.truncate(playlist_length); + Ok(songs) + } + + /// Build a playlist of `playlist_length` items from an already analyzed + /// song in the library at `song_path`, using distance metric `distance`, + /// the sorting function `sort_by` and deduplicating if `dedup` is set to + /// `true`. + /// + /// You can use ready to use distance metrics such as + /// [euclidean_distance], and ready to use sorting functions like + /// [closest_to_first_song_by_key]. + /// + /// In most cases, you just want to use [Library::playlist_from]. + /// Use `playlist_from_custom` if you want to experiment with different + /// distance metrics / sorting functions. + /// + /// Example: + /// `library.playlist_from_song_custom(song_path, 20, euclidean_distance, + /// closest_to_first_song_by_key, true)`. + /// TODO path here too + pub fn playlist_from_custom( + &self, + song_path: &str, + playlist_length: usize, + distance: G, + mut sort_by: F, + dedup: bool, + ) -> Result>> + where + F: FnMut(&LibrarySong, &mut Vec>, G, fn(&LibrarySong) -> Song), + G: DistanceMetric + Copy, + { + let first_song: LibrarySong = self.song_from_path(song_path).map_err(|_| { + BlissError::ProviderError(format!("song '{}' has not been analyzed", song_path)) + })?; + let mut songs = self.songs_from_library()?; + sort_by(&first_song, &mut songs, distance, |s: &LibrarySong| { + s.bliss_song.to_owned() + }); + if dedup { + dedup_playlist_custom_distance_by_key( + &mut songs, + None, + distance, + |s: &LibrarySong| s.bliss_song.to_owned(), + ); + } + songs.truncate(playlist_length); + Ok(songs) + } + + /// Make a playlist of `number_albums` albums closest to the album + /// with title `album_title`. + /// The playlist starts with the album with `album_title`, and contains + /// `number_albums` on top of that one. + /// + /// Returns the songs of each album ordered by bliss' `track_number`. + pub fn album_playlist_from( + &self, + album_title: String, + number_albums: usize, + ) -> Result>> { + let album = self.songs_from_album(&album_title)?; + // Every song should be from the same album. Hopefully... + let songs = self.songs_from_library()?; + let playlist = closest_album_to_group_by_key(album, songs, |s| s.bliss_song.to_owned())?; + + let mut album_count = 0; + let mut index = 0; + let mut current_album = Some(album_title); + for song in playlist.iter() { + if song.bliss_song.album != current_album { + album_count += 1; + if album_count > number_albums { + break; + } + current_album = song.bliss_song.album.to_owned(); + } + index += 1; + } + let playlist = &playlist[..index]; + Ok(playlist.to_vec()) + } + + /// Analyze and store all songs in `paths` that haven't been already analyzed. + /// + /// Use this function if you don't have any extra data to bundle with each song. + /// + /// If your library + /// contains CUE files, pass the CUE file path only, and not individual + /// CUE track names: passing `vec![file.cue]` will add + /// individual tracks with the `cue_info` field set in the database. + pub fn update_library>( + &mut self, + paths: Vec

, + show_progress_bar: bool, + ) -> Result<()> { + let paths_extra_info = paths.into_iter().map(|path| (path, ())).collect::>(); + self.update_library_convert_extra_info(paths_extra_info, show_progress_bar, |x, _, _| x) + } + + /// Analyze and store all songs in `paths_extra_info` that haven't already + /// been analyzed, along with some extra metadata serializable, and known + /// before song analysis. + pub fn update_library_extra_info>( + &mut self, + paths_extra_info: Vec<(P, T)>, + show_progress_bar: bool, + ) -> Result<()> { + self.update_library_convert_extra_info( + paths_extra_info, + show_progress_bar, + |extra_info, _, _| extra_info, + ) + } + + /// Analyze and store all songs in `paths_extra_info` that haven't + /// been already analyzed, as well as handling extra, user-specified metadata, + /// that can't directly be serializable, + /// or that need input from the analyzed Song to be processed. If you + /// just want to analyze and store songs along with some directly + /// serializable values, consider using [Library::update_library_extra_info], + /// or [Library::update_library] if you just want the analyzed songs + /// stored as is. + /// + /// `paths_extra_info` is a tuple made out of song paths, along + /// with any extra info you want to store for each song. + /// If your library + /// contains CUE files, pass the CUE file path only, and not individual + /// CUE track names: passing `vec![file.cue]` will add + /// individual tracks with the `cue_info` field set in the database. + /// + /// `convert_extra_info` is a function that you should specify how + /// to convert that extra info to something serializable. + // TODO have a `delete` option + pub fn update_library_convert_extra_info< + T: Serialize + DeserializeOwned, + U, + P: Into, + >( + &mut self, + paths_extra_info: Vec<(P, U)>, + show_progress_bar: bool, + convert_extra_info: fn(U, &Song, &Self) -> T, + ) -> Result<()> { + let existing_paths = { + let connection = self + .sqlite_conn + .lock() + .map_err(|e| BlissError::ProviderError(e.to_string()))?; + let mut path_statement = connection.prepare( + " + select + path + from song where analyzed = true and version = ? order by id + ", + )?; + #[allow(clippy::let_and_return)] + let return_value = path_statement + .query_map([FEATURES_VERSION], |row| { + Ok(row.get_unwrap::(0)) + })? + .map(|x| PathBuf::from(x.unwrap())) + .collect::>(); + return_value + }; + + let paths_to_analyze = paths_extra_info + .into_iter() + .map(|(x, y)| (x.into(), y)) + .filter(|(path, _)| !existing_paths.contains(path)) + .collect::>(); + + self.analyze_paths_convert_extra_info( + paths_to_analyze, + show_progress_bar, + convert_extra_info, + ) + } + + /// Analyze and store all songs in `paths`. + /// + /// Updates the value of `features_version` in the config, using bliss' + /// latest version. + /// + /// Use this function if you don't have any extra data to bundle with each song. + /// + /// If your library + /// contains CUE files, pass the CUE file path only, and not individual + /// CUE track names: passing `vec![file.cue]` will add + /// individual tracks with the `cue_info` field set in the database. + pub fn analyze_paths>( + &mut self, + paths: Vec

, + show_progress_bar: bool, + ) -> Result<()> { + let paths_extra_info = paths.into_iter().map(|path| (path, ())).collect::>(); + self.analyze_paths_convert_extra_info(paths_extra_info, show_progress_bar, |x, _, _| x) + } + + /// Analyze and store all songs in `paths_extra_info`, along with some + /// extra metadata serializable, and known before song analysis. + /// + /// Updates the value of `features_version` in the config, using bliss' + /// latest version. + /// If your library + /// contains CUE files, pass the CUE file path only, and not individual + /// CUE track names: passing `vec![file.cue]` will add + /// individual tracks with the `cue_info` field set in the database. + pub fn analyze_paths_extra_info< + T: Serialize + DeserializeOwned + std::fmt::Debug, + P: Into, + >( + &mut self, + paths_extra_info: Vec<(P, T)>, + show_progress_bar: bool, + ) -> Result<()> { + self.analyze_paths_convert_extra_info( + paths_extra_info, + show_progress_bar, + |extra_info, _, _| extra_info, + ) + } + + /// Analyze and store all songs in `paths_extra_info`, along with some + /// extra, user-specified metadata, that can't directly be serializable, + /// or that need input from the analyzed Song to be processed. + /// If you just want to analyze and store songs, along with some + /// directly serializable metadata values, consider using + /// [Library::analyze_paths_extra_info], or [Library::analyze_paths] for + /// the simpler use cases. + /// + /// Updates the value of `features_version` in the config, using bliss' + /// latest version. + /// + /// `paths_extra_info` is a tuple made out of song paths, along + /// with any extra info you want to store for each song. If your library + /// contains CUE files, pass the CUE file path only, and not individual + /// CUE track names: passing `vec![file.cue]` will add + /// individual tracks with the `cue_info` field set in the database. + /// + /// `convert_extra_info` is a function that you should specify + /// to convert that extra info to something serializable. + pub fn analyze_paths_convert_extra_info< + T: Serialize + DeserializeOwned, + U, + P: Into, + >( + &mut self, + paths_extra_info: Vec<(P, U)>, + show_progress_bar: bool, + convert_extra_info: fn(U, &Song, &Self) -> T, + ) -> Result<()> { + let number_songs = paths_extra_info.len(); + if number_songs == 0 { + log::info!("No (new) songs found."); + return Ok(()); + } + log::info!( + "Analyzing {} songs, this might take some time…", + number_songs + ); + let pb = if show_progress_bar { + ProgressBar::new(number_songs.try_into().unwrap()) + } else { + ProgressBar::hidden() + }; + let style = ProgressStyle::default_bar() + .template("[{elapsed_precise}] {bar:40} {pos:>7}/{len:7} {wide_msg}")? + .progress_chars("##-"); + pb.set_style(style); + + let mut paths_extra_info: HashMap = paths_extra_info + .into_iter() + .map(|(x, y)| (x.into(), y)) + .collect(); + let mut cue_extra_info: HashMap = HashMap::new(); + + let results = analyze_paths(paths_extra_info.keys()); + let mut success_count = 0; + let mut failure_count = 0; + for (path, result) in results { + if show_progress_bar { + pb.set_message(format!("Analyzing {}", path.display())); + } + match result { + Ok(song) => { + let is_cue = song.cue_info.is_some(); + // If it's a song that's part of a CUE, its path will be + // something like `testcue.flac/CUE_TRACK001`, so we need + // to get the path of the main CUE file. + let path = { + if let Some(cue_info) = song.cue_info.to_owned() { + cue_info.cue_path + } else { + path + } + }; + // Some magic to avoid having to depend on T: Clone, because + // all CUE tracks on a CUE file have the same extra_info. + // This serializes the data, store the serialized version + // in a hashmap, and then deserializes that when needed. + let extra = { + if is_cue && paths_extra_info.contains_key(&path) { + let extra = paths_extra_info.remove(&path).unwrap(); + let e = convert_extra_info(extra, &song, self); + cue_extra_info.insert( + path, + serde_json::to_string(&e) + .map_err(|e| BlissError::ProviderError(e.to_string()))?, + ); + e + } else if is_cue { + let serialized_extra_info = + cue_extra_info.get(&path).unwrap().to_owned(); + serde_json::from_str(&serialized_extra_info).unwrap() + } else { + let extra = paths_extra_info.remove(&path).unwrap(); + convert_extra_info(extra, &song, self) + } + }; + let library_song = LibrarySong:: { + bliss_song: song, + extra_info: extra, + }; + self.store_song(&library_song)?; + success_count += 1; + } + Err(e) => { + log::error!( + "Analysis of song '{}' failed: {} The error has been stored.", + path.display(), + e + ); + + self.store_failed_song(path, e)?; + failure_count += 1; + } + }; + pb.inc(1); + } + pb.finish_with_message(format!( + "Analyzed {} song(s) successfully. {} Failure(s).", + success_count, failure_count + )); + + log::info!( + "Analyzed {} song(s) successfully. {} Failure(s).", + success_count, + failure_count, + ); + + self.config.base_config_mut().features_version = FEATURES_VERSION; + self.config.write()?; + + Ok(()) + } + + // Get songs from a songs / features statement. + // BEWARE that the two songs and features query MUST be the same + fn _songs_from_statement( + &self, + songs_statement: &str, + features_statement: &str, + params: P, + ) -> Result>> { + let connection = self + .sqlite_conn + .lock() + .map_err(|e| BlissError::ProviderError(e.to_string()))?; + let mut songs_statement = connection.prepare(songs_statement)?; + let mut features_statement = connection.prepare(features_statement)?; + let song_rows = songs_statement.query_map(params.to_owned(), |row| { + Ok((row.get(12)?, Self::_song_from_row_closure(row)?)) + })?; + let feature_rows = + features_statement.query_map(params, |row| Ok((row.get(1)?, row.get(0)?)))?; + + let mut feature_iterator = feature_rows.into_iter().peekable(); + let mut songs = Vec::new(); + // Poor man's way to double check that each feature correspond to the + // right song, and group them. + for row in song_rows { + let song_id: u32 = row.as_ref().unwrap().0; + let mut chunk: Vec = Vec::with_capacity(NUMBER_FEATURES); + + while let Some(first_value) = feature_iterator.peek() { + let (song_feature_id, feature): (u32, f32) = *first_value.as_ref().unwrap(); + if song_feature_id == song_id { + chunk.push(feature); + feature_iterator.next(); + } else { + break; + }; + } + let mut song = row.unwrap().1; + song.bliss_song.analysis = Analysis { + internal_analysis: chunk.try_into().map_err(|_| { + BlissError::ProviderError(format!( + "Song with ID {} and path {} has a different feature \ + number than expected. Please rescan or update \ + the song library.", + song_id, + song.bliss_song.path.display(), + )) + })?, + }; + songs.push(song); + } + Ok(songs) + } + + /// Retrieve all songs which have been analyzed with + /// current bliss version. + /// + /// Returns an error if one or several songs have a different number of + /// features than they should, indicating the offending song id. + /// + // TODO maybe the error should make the song id / song path + // accessible easily? + pub fn songs_from_library( + &self, + ) -> Result>> { + let songs_statement = " + select + path, artist, title, album, album_artist, + track_number, genre, duration, version, extra_info, cue_path, + audio_file_path, id + from song where analyzed = true and version = ? order by id + "; + let features_statement = " + select + feature, song.id from feature join song on song.id = feature.song_id + where song.analyzed = true and song.version = ? order by song_id, feature_index + "; + let params = params![self.config.base_config().features_version]; + self._songs_from_statement(songs_statement, features_statement, params) + } + + /// Get a LibrarySong from a given album title. + /// + /// This will return all songs with corresponding bliss "album" tag, + /// and will order them by track number. + pub fn songs_from_album( + &self, + album_title: &str, + ) -> Result>> { + let params = params![album_title, self.config.base_config().features_version]; + let songs_statement = " + select + path, artist, title, album, album_artist, + track_number, genre, duration, version, extra_info, cue_path, + audio_file_path, id + from song where album = ? and analyzed = true and version = ? + order + by cast(track_number as integer); + "; + + // Get the song's analysis, and attach it to the existing song. + let features_statement = " + select + feature, song.id from feature join song on song.id = feature.song_id + where album=? and analyzed = true and version = ? + order by cast(track_number as integer); + "; + let songs = self._songs_from_statement(songs_statement, features_statement, params)?; + if songs.is_empty() { + bail!(BlissError::ProviderError(String::from( + "target album was not found in the database.", + ))); + }; + Ok(songs) + } + + /// Get a LibrarySong from a given file path. + /// TODO pathbuf here too + pub fn song_from_path( + &self, + song_path: &str, + ) -> Result> { + let connection = self + .sqlite_conn + .lock() + .map_err(|e| BlissError::ProviderError(e.to_string()))?; + // Get the song's metadata. The analysis is populated yet. + let mut song = connection.query_row( + " + select + path, artist, title, album, album_artist, + track_number, genre, duration, version, extra_info, + cue_path, audio_file_path + from song where path=? and analyzed = true + ", + params![song_path], + Self::_song_from_row_closure, + )?; + + // Get the song's analysis, and attach it to the existing song. + let mut stmt = connection.prepare( + " + select + feature from feature join song on song.id = feature.song_id + where song.path = ? order by feature_index + ", + )?; + let analysis_vector = Analysis { + internal_analysis: stmt + .query_map(params![song_path], |row| row.get(0)) + .unwrap() + .into_iter() + .map(|x| x.unwrap()) + .collect::>() + .try_into() + .map_err(|_| { + BlissError::ProviderError(format!( + "song has more or less than {} features", + NUMBER_FEATURES + )) + })?, + }; + song.bliss_song.analysis = analysis_vector; + Ok(song) + } + + fn _song_from_row_closure( + row: &Row, + ) -> Result, RusqliteError> { + let path: String = row.get(0)?; + + let cue_path: Option = row.get(10)?; + let audio_file_path: Option = row.get(11)?; + let mut cue_info = None; + if let Some(cue_path) = cue_path { + cue_info = Some(CueInfo { + cue_path: PathBuf::from(cue_path), + audio_file_path: PathBuf::from(audio_file_path.unwrap()), + }) + }; + + let song = Song { + path: PathBuf::from(path), + artist: row.get(1).unwrap(), + title: row.get(2).unwrap(), + album: row.get(3).unwrap(), + album_artist: row.get(4).unwrap(), + track_number: row.get(5).unwrap(), + genre: row.get(6).unwrap(), + analysis: Analysis { + internal_analysis: [0.; NUMBER_FEATURES], + }, + duration: Duration::from_secs_f64(row.get(7).unwrap()), + features_version: row.get(8).unwrap(), + cue_info, + }; + + let serialized: Option = row.get(9).unwrap(); + let serialized = serialized.unwrap_or_else(|| "null".into()); + let extra_info = serde_json::from_str(&serialized).unwrap(); + Ok(LibrarySong { + bliss_song: song, + extra_info, + }) + } + + /// Store a [Song] in the database, overidding any existing + /// song with the same path by that one. + // TODO to_str() returns an option; return early and avoid panicking + pub fn store_song( + &mut self, + library_song: &LibrarySong, + ) -> Result<(), BlissError> { + let mut sqlite_conn = self.sqlite_conn.lock().unwrap(); + let tx = sqlite_conn + .transaction() + .map_err(|e| BlissError::ProviderError(e.to_string()))?; + let song = &library_song.bliss_song; + let (cue_path, audio_file_path) = match &song.cue_info { + Some(c) => ( + Some(c.cue_path.to_string_lossy()), + Some(c.audio_file_path.to_string_lossy()), + ), + None => (None, None), + }; + tx.execute( + " + insert into song ( + path, artist, title, album, album_artist, + duration, track_number, genre, analyzed, version, extra_info, + cue_path, audio_file_path + ) + values ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13 + ) + on conflict(path) + do update set + artist=excluded.artist, + title=excluded.title, + album=excluded.album, + track_number=excluded.track_number, + album_artist=excluded.album_artist, + duration=excluded.duration, + genre=excluded.genre, + analyzed=excluded.analyzed, + version=excluded.version, + extra_info=excluded.extra_info, + cue_path=excluded.cue_path, + audio_file_path=excluded.audio_file_path + ", + params![ + song.path.to_str(), + song.artist, + song.title, + song.album, + song.album_artist, + song.duration.as_secs_f64(), + song.track_number, + song.genre, + true, + song.features_version, + serde_json::to_string(&library_song.extra_info) + .map_err(|e| BlissError::ProviderError(e.to_string()))?, + cue_path, + audio_file_path, + ], + ) + .map_err(|e| BlissError::ProviderError(e.to_string()))?; + + // Override existing features. + tx.execute( + "delete from feature where song_id in (select id from song where path = ?1);", + params![song.path.to_str()], + ) + .map_err(|e| BlissError::ProviderError(e.to_string()))?; + + for (index, feature) in song.analysis.as_vec().iter().enumerate() { + tx.execute( + " + insert into feature (song_id, feature, feature_index) + values ((select id from song where path = ?1), ?2, ?3) + on conflict(song_id, feature_index) do update set feature=excluded.feature; + ", + params![song.path.to_str(), feature, index], + ) + .map_err(|e| BlissError::ProviderError(e.to_string()))?; + } + tx.commit() + .map_err(|e| BlissError::ProviderError(e.to_string()))?; + Ok(()) + } + + /// Store an errored [Song](Song) in the SQLite database. + /// + /// If there already is an existing song with that path, replace it by + /// the latest failed result. + pub fn store_failed_song>( + &mut self, + song_path: P, + e: BlissError, + ) -> Result<()> { + self.sqlite_conn + .lock() + .unwrap() + .execute( + " + insert or replace into song (path, error) values (?1, ?2) + ", + [ + song_path.into().to_string_lossy().to_string(), + e.to_string(), + ], + ) + .map_err(|e| BlissError::ProviderError(e.to_string()))?; + Ok(()) + } + + /// Delete a song with path `song_path` from the database. + /// + /// Errors out if the song is not in the database. + pub fn delete_song>(&mut self, song_path: P) -> Result<()> { + let song_path = song_path.into(); + let count = self + .sqlite_conn + .lock() + .unwrap() + .execute( + " + delete from song where path = ?1; + ", + [song_path.to_str()], + ) + .map_err(|e| BlissError::ProviderError(e.to_string()))?; + if count == 0 { + bail!(BlissError::ProviderError(format!( + "tried to delete song {}, not existing in the database.", + song_path.display(), + ))); + } + Ok(()) + } +} + +#[cfg(test)] +fn data_local_dir() -> Option { + Some(PathBuf::from("/local/directory")) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::{Analysis, NUMBER_FEATURES}; + use ndarray::Array1; + use pretty_assertions::assert_eq; + use serde::{de::DeserializeOwned, Deserialize}; + use std::{convert::TryInto, fmt::Debug, sync::MutexGuard, time::Duration}; + use tempdir::TempDir; + + #[derive(Deserialize, Serialize, Debug, PartialEq, Clone, Default)] + struct ExtraInfo { + ignore: bool, + metadata_bliss_does_not_have: String, + } + + #[derive(Deserialize, Serialize, PartialEq, Eq, Debug, Clone)] + struct CustomConfig { + #[serde(flatten)] + base_config: BaseConfig, + second_path_to_music_library: String, + ignore_wav_files: bool, + } + + impl AppConfigTrait for CustomConfig { + fn base_config(&self) -> &BaseConfig { + &self.base_config + } + + fn base_config_mut(&mut self) -> &mut BaseConfig { + &mut self.base_config + } + } + + // Returning the TempDir here, so it doesn't go out of scope, removing + // the directory. + // + // Setup a test library made of 3 analyzed songs, with every field being different, + // as well as an unanalyzed song and a song analyzed with a previous version. + fn setup_test_library() -> ( + Library, + TempDir, + ( + LibrarySong, + LibrarySong, + LibrarySong, + LibrarySong, + LibrarySong, + LibrarySong, + LibrarySong, + ), + ) { + let config_dir = TempDir::new("coucou").unwrap(); + let config_file = config_dir.path().join("config.json"); + let database_file = config_dir.path().join("bliss.db"); + let library = + Library::::new_from_base(Some(config_file), Some(database_file)).unwrap(); + + let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES) + .map(|x| x as f32 / 10.) + .collect::>() + .try_into() + .unwrap(); + let song = Song { + path: "/path/to/song1001".into(), + artist: Some("Artist1001".into()), + title: Some("Title1001".into()), + album: Some("An Album1001".into()), + album_artist: Some("An Album Artist1001".into()), + track_number: Some("03".into()), + genre: Some("Electronica1001".into()), + analysis: Analysis { + internal_analysis: analysis_vector, + }, + duration: Duration::from_secs(310), + features_version: 1, + cue_info: None, + }; + let first_song = LibrarySong { + bliss_song: song, + extra_info: ExtraInfo { + ignore: true, + metadata_bliss_does_not_have: String::from("/path/to/charlie1001"), + }, + }; + let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES) + .map(|x| x as f32 + 10.) + .collect::>() + .try_into() + .unwrap(); + + let song = Song { + path: "/path/to/song2001".into(), + artist: Some("Artist2001".into()), + title: Some("Title2001".into()), + album: Some("An Album2001".into()), + album_artist: Some("An Album Artist2001".into()), + track_number: Some("02".into()), + genre: Some("Electronica2001".into()), + analysis: Analysis { + internal_analysis: analysis_vector, + }, + duration: Duration::from_secs(410), + features_version: 1, + cue_info: None, + }; + let second_song = LibrarySong { + bliss_song: song, + extra_info: ExtraInfo { + ignore: false, + metadata_bliss_does_not_have: String::from("/path/to/charlie2001"), + }, + }; + + let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES) + .map(|x| x as f32 / 2.) + .collect::>() + .try_into() + .unwrap(); + let song = Song { + path: "/path/to/song5001".into(), + artist: Some("Artist5001".into()), + title: Some("Title5001".into()), + album: Some("An Album1001".into()), + album_artist: Some("An Album Artist5001".into()), + track_number: Some("01".into()), + genre: Some("Electronica5001".into()), + analysis: Analysis { + internal_analysis: analysis_vector, + }, + duration: Duration::from_secs(610), + features_version: 1, + cue_info: None, + }; + let third_song = LibrarySong { + bliss_song: song, + extra_info: ExtraInfo { + ignore: false, + metadata_bliss_does_not_have: String::from("/path/to/charlie5001"), + }, + }; + + let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES) + .map(|x| x as f32 * 0.9) + .collect::>() + .try_into() + .unwrap(); + let song = Song { + path: "/path/to/song6001".into(), + artist: Some("Artist6001".into()), + title: Some("Title6001".into()), + album: Some("An Album2001".into()), + album_artist: Some("An Album Artist6001".into()), + track_number: Some("01".into()), + genre: Some("Electronica6001".into()), + analysis: Analysis { + internal_analysis: analysis_vector, + }, + duration: Duration::from_secs(710), + features_version: 1, + cue_info: None, + }; + let fourth_song = LibrarySong { + bliss_song: song, + extra_info: ExtraInfo { + ignore: false, + metadata_bliss_does_not_have: String::from("/path/to/charlie6001"), + }, + }; + + let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES) + .map(|x| x as f32 * 50.) + .collect::>() + .try_into() + .unwrap(); + let song = Song { + path: "/path/to/song7001".into(), + artist: Some("Artist7001".into()), + title: Some("Title7001".into()), + album: Some("An Album7001".into()), + album_artist: Some("An Album Artist7001".into()), + track_number: Some("01".into()), + genre: Some("Electronica7001".into()), + analysis: Analysis { + internal_analysis: analysis_vector, + }, + duration: Duration::from_secs(810), + features_version: 1, + cue_info: None, + }; + let fifth_song = LibrarySong { + bliss_song: song, + extra_info: ExtraInfo { + ignore: false, + metadata_bliss_does_not_have: String::from("/path/to/charlie7001"), + }, + }; + + let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES) + .map(|x| x as f32 * 100.) + .collect::>() + .try_into() + .unwrap(); + + let song = Song { + path: "/path/to/cuetrack.cue/CUE_TRACK001".into(), + artist: Some("CUE Artist".into()), + title: Some("CUE Title 01".into()), + album: Some("CUE Album".into()), + album_artist: Some("CUE Album Artist".into()), + track_number: Some("01".into()), + genre: None, + analysis: Analysis { + internal_analysis: analysis_vector, + }, + duration: Duration::from_secs(810), + features_version: 1, + cue_info: Some(CueInfo { + cue_path: PathBuf::from("/path/to/cuetrack.cue"), + audio_file_path: PathBuf::from("/path/to/cuetrack.flac"), + }), + }; + let sixth_song = LibrarySong { + bliss_song: song, + extra_info: ExtraInfo { + ignore: false, + metadata_bliss_does_not_have: String::from("/path/to/charlie7001"), + }, + }; + + let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES) + .map(|x| x as f32 * 101.) + .collect::>() + .try_into() + .unwrap(); + + let song = Song { + path: "/path/to/cuetrack.cue/CUE_TRACK002".into(), + artist: Some("CUE Artist".into()), + title: Some("CUE Title 02".into()), + album: Some("CUE Album".into()), + album_artist: Some("CUE Album Artist".into()), + track_number: Some("02".into()), + genre: None, + analysis: Analysis { + internal_analysis: analysis_vector, + }, + duration: Duration::from_secs(910), + features_version: 1, + cue_info: Some(CueInfo { + cue_path: PathBuf::from("/path/to/cuetrack.cue"), + audio_file_path: PathBuf::from("/path/to/cuetrack.flac"), + }), + }; + let seventh_song = LibrarySong { + bliss_song: song, + extra_info: ExtraInfo { + ignore: false, + metadata_bliss_does_not_have: String::from("/path/to/charlie7001"), + }, + }; + + { + let connection = library.sqlite_conn.lock().unwrap(); + connection + .execute( + " + insert into song ( + id, path, artist, title, album, album_artist, track_number, + genre, duration, analyzed, version, extra_info, + cue_path, audio_file_path + ) values ( + 1001, '/path/to/song1001', 'Artist1001', 'Title1001', 'An Album1001', + 'An Album Artist1001', '03', 'Electronica1001', 310, true, + 1, '{\"ignore\": true, \"metadata_bliss_does_not_have\": + \"/path/to/charlie1001\"}', null, null + ), + ( + 2001, '/path/to/song2001', 'Artist2001', 'Title2001', 'An Album2001', + 'An Album Artist2001', '02', 'Electronica2001', 410, true, + 1, '{\"ignore\": false, \"metadata_bliss_does_not_have\": + \"/path/to/charlie2001\"}', null, null + ), + ( + 3001, '/path/to/song3001', null, null, null, + null, null, null, null, false, 1, '{}', null, null + ), + ( + 4001, '/path/to/song4001', 'Artist4001', 'Title4001', 'An Album4001', + 'An Album Artist4001', '01', 'Electronica4001', 510, true, + 0, '{\"ignore\": false, \"metadata_bliss_does_not_have\": + \"/path/to/charlie4001\"}', null, null + ), + ( + 5001, '/path/to/song5001', 'Artist5001', 'Title5001', 'An Album1001', + 'An Album Artist5001', '01', 'Electronica5001', 610, true, + 1, '{\"ignore\": false, \"metadata_bliss_does_not_have\": + \"/path/to/charlie5001\"}', null, null + ), + ( + 6001, '/path/to/song6001', 'Artist6001', 'Title6001', 'An Album2001', + 'An Album Artist6001', '01', 'Electronica6001', 710, true, + 1, '{\"ignore\": false, \"metadata_bliss_does_not_have\": + \"/path/to/charlie6001\"}', null, null + ), + ( + 7001, '/path/to/song7001', 'Artist7001', 'Title7001', 'An Album7001', + 'An Album Artist7001', '01', 'Electronica7001', 810, true, + 1, '{\"ignore\": false, \"metadata_bliss_does_not_have\": + \"/path/to/charlie7001\"}', null, null + ), + ( + 7002, '/path/to/cuetrack.cue/CUE_TRACK001', 'CUE Artist', + 'CUE Title 01', 'CUE Album', + 'CUE Album Artist', '01', null, 810, true, + 1, '{\"ignore\": false, \"metadata_bliss_does_not_have\": + \"/path/to/charlie7001\"}', '/path/to/cuetrack.cue', + '/path/to/cuetrack.flac' + ), + ( + 7003, '/path/to/cuetrack.cue/CUE_TRACK002', 'CUE Artist', + 'CUE Title 02', 'CUE Album', + 'CUE Album Artist', '02', null, 910, true, + 1, '{\"ignore\": false, \"metadata_bliss_does_not_have\": + \"/path/to/charlie7001\"}', '/path/to/cuetrack.cue', + '/path/to/cuetrack.flac' + ), + ( + 8001, '/path/to/song8001', 'Artist8001', 'Title8001', 'An Album1001', + 'An Album Artist8001', '03', 'Electronica8001', 910, true, + 0, '{\"ignore\": false, \"metadata_bliss_does_not_have\": + \"/path/to/charlie8001\"}', null, null + ), + ( + 9001, './data/s16_stereo_22_5kHz.flac', 'Artist9001', 'Title9001', + 'An Album9001', 'An Album Artist8001', '03', 'Electronica8001', + 1010, true, 0, '{\"ignore\": false, \"metadata_bliss_does_not_have\": + \"/path/to/charlie7001\"}', null, null + ); + ", + [], + ) + .unwrap(); + for index in 0..NUMBER_FEATURES { + connection + .execute( + " + insert into feature(song_id, feature, feature_index) + values + (1001, ?2, ?1), + (2001, ?3, ?1), + (3001, ?4, ?1), + (5001, ?5, ?1), + (6001, ?6, ?1), + (7001, ?7, ?1), + (7002, ?8, ?1), + (7003, ?9, ?1); + ", + params![ + index, + index as f32 / 10., + index as f32 + 10., + index as f32 / 10. + 1., + index as f32 / 2., + index as f32 * 0.9, + index as f32 * 50., + index as f32 * 100., + index as f32 * 101., + ], + ) + .unwrap(); + } + // Imaginary version 0 of bliss with less features. + for index in 0..NUMBER_FEATURES - 5 { + connection + .execute( + " + insert into feature(song_id, feature, feature_index) + values + (8001, ?2, ?1), + (9001, ?3, ?1); + ", + params![index, index as f32 / 20., index + 1], + ) + .unwrap(); + } + } + ( + library, + config_dir, + ( + first_song, + second_song, + third_song, + fourth_song, + fifth_song, + sixth_song, + seventh_song, + ), + ) + } + + fn _library_song_from_database( + connection: MutexGuard, + song_path: &str, + ) -> LibrarySong { + let mut song = connection + .query_row( + " + select + path, artist, title, album, album_artist, + track_number, genre, duration, version, extra_info, + cue_path, audio_file_path + from song where path=? + ", + params![song_path], + |row| { + let path: String = row.get(0)?; + let cue_path: Option = row.get(10)?; + let audio_file_path: Option = row.get(11)?; + let mut cue_info = None; + if let Some(cue_path) = cue_path { + cue_info = Some(CueInfo { + cue_path: PathBuf::from(cue_path), + audio_file_path: PathBuf::from(audio_file_path.unwrap()), + }) + }; + let song = Song { + path: PathBuf::from(path), + artist: row.get(1).unwrap(), + title: row.get(2).unwrap(), + album: row.get(3).unwrap(), + album_artist: row.get(4).unwrap(), + track_number: row.get(5).unwrap(), + genre: row.get(6).unwrap(), + analysis: Analysis { + internal_analysis: [0.; NUMBER_FEATURES], + }, + duration: Duration::from_secs_f64(row.get(7).unwrap()), + features_version: row.get(8).unwrap(), + cue_info, + }; + + let serialized: String = row.get(9).unwrap(); + let extra_info = serde_json::from_str(&serialized).unwrap(); + Ok(LibrarySong { + bliss_song: song, + extra_info, + }) + }, + ) + .expect("Song does not exist in the db."); + let mut stmt = connection + .prepare( + " + select + feature from feature join song on song.id = feature.song_id + where song.path = ? order by feature_index + ", + ) + .unwrap(); + let analysis_vector = Analysis { + internal_analysis: stmt + .query_map(params![song_path], |row| row.get(0)) + .unwrap() + .into_iter() + .map(|x| x.unwrap()) + .collect::>() + .try_into() + .unwrap(), + }; + song.bliss_song.analysis = analysis_vector; + song + } + + fn _basic_song_from_database(connection: MutexGuard, song_path: &str) -> Song { + let mut expected_song = connection + .query_row( + " + select + path, artist, title, album, album_artist, + track_number, genre, duration, version + from song where path=? and analyzed = true + ", + params![song_path], + |row| { + let path: String = row.get(0)?; + Ok(Song { + path: PathBuf::from(path), + artist: row.get(1).unwrap(), + title: row.get(2).unwrap(), + album: row.get(3).unwrap(), + album_artist: row.get(4).unwrap(), + track_number: row.get(5).unwrap(), + genre: row.get(6).unwrap(), + analysis: Analysis { + internal_analysis: [0.; NUMBER_FEATURES], + }, + duration: Duration::from_secs_f64(row.get(7).unwrap()), + features_version: row.get(8).unwrap(), + cue_info: None, + }) + }, + ) + .expect("Song is probably not in the db"); + let mut stmt = connection + .prepare( + " + select + feature from feature join song on song.id = feature.song_id + where song.path = ? order by feature_index + ", + ) + .unwrap(); + let expected_analysis_vector = Analysis { + internal_analysis: stmt + .query_map(params![song_path], |row| row.get(0)) + .unwrap() + .into_iter() + .map(|x| x.unwrap()) + .collect::>() + .try_into() + .unwrap(), + }; + expected_song.analysis = expected_analysis_vector; + expected_song + } + + fn _generate_basic_song(path: Option) -> Song { + let path = path.unwrap_or_else(|| "/path/to/song".into()); + // Add some "randomness" to the features + let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES) + .map(|x| x as f32 + 0.1) + .collect::>() + .try_into() + .unwrap(); + Song { + path: path.into(), + artist: Some("An Artist".into()), + title: Some("Title".into()), + album: Some("An Album".into()), + album_artist: Some("An Album Artist".into()), + track_number: Some("03".into()), + genre: Some("Electronica".into()), + analysis: Analysis { + internal_analysis: analysis_vector, + }, + duration: Duration::from_secs(80), + features_version: 1, + cue_info: None, + } + } + + fn _generate_library_song(path: Option) -> LibrarySong { + let song = _generate_basic_song(path); + let extra_info = ExtraInfo { + ignore: true, + metadata_bliss_does_not_have: "FoobarIze".into(), + }; + LibrarySong { + bliss_song: song, + extra_info, + } + } + + #[test] + fn test_library_playlist_song_not_existing() { + let (library, _temp_dir, _) = setup_test_library(); + assert!(library + .playlist_from::("not-existing", 2) + .is_err()); + } + + #[test] + fn test_library_playlist_crop() { + let (library, _temp_dir, _) = setup_test_library(); + let songs: Vec> = + library.playlist_from("/path/to/song2001", 2).unwrap(); + assert_eq!(2, songs.len()); + } + + #[test] + fn test_library_simple_playlist() { + let (library, _temp_dir, _) = setup_test_library(); + let songs: Vec> = + library.playlist_from("/path/to/song2001", 20).unwrap(); + assert_eq!( + vec![ + "/path/to/song2001", + "/path/to/song6001", + "/path/to/song5001", + "/path/to/song1001", + "/path/to/song7001", + "/path/to/cuetrack.cue/CUE_TRACK001", + "/path/to/cuetrack.cue/CUE_TRACK002", + ], + songs + .into_iter() + .map(|s| s.bliss_song.path.to_string_lossy().to_string()) + .collect::>(), + ) + } + + #[test] + fn test_library_custom_playlist_distance() { + let (library, _temp_dir, _) = setup_test_library(); + let distance = + |a: &Array1, b: &Array1| (a.get(1).unwrap() - b.get(1).unwrap()).abs(); + let songs: Vec> = library + .playlist_from_custom( + "/path/to/song2001", + 20, + distance, + closest_to_first_song_by_key, + true, + ) + .unwrap(); + assert_eq!( + vec![ + "/path/to/song2001", + "/path/to/song6001", + "/path/to/song5001", + "/path/to/song1001", + "/path/to/song7001", + "/path/to/cuetrack.cue/CUE_TRACK001", + "/path/to/cuetrack.cue/CUE_TRACK002", + ], + songs + .into_iter() + .map(|s| s.bliss_song.path.to_string_lossy().to_string()) + .collect::>(), + ) + } + + fn custom_sort( + _: &LibrarySong, + songs: &mut Vec>, + _distance: impl DistanceMetric, + key_fn: F, + ) where + F: Fn(&LibrarySong) -> Song, + { + songs.sort_by_key(|song| key_fn(song).path); + } + + #[test] + fn test_library_custom_playlist_sort() { + let (library, _temp_dir, _) = setup_test_library(); + let songs: Vec> = library + .playlist_from_custom( + "/path/to/song2001", + 20, + euclidean_distance, + custom_sort, + true, + ) + .unwrap(); + assert_eq!( + vec![ + "/path/to/cuetrack.cue/CUE_TRACK001", + "/path/to/cuetrack.cue/CUE_TRACK002", + "/path/to/song1001", + "/path/to/song2001", + "/path/to/song5001", + "/path/to/song6001", + "/path/to/song7001", + ], + songs + .into_iter() + .map(|s| s.bliss_song.path.to_string_lossy().to_string()) + .collect::>(), + ) + } + + #[test] + fn test_library_custom_playlist_dedup() { + let (library, _temp_dir, _) = setup_test_library(); + let distance = |a: &Array1, b: &Array1| { + ((a.get(1).unwrap() - b.get(1).unwrap()).abs() / 30.).floor() + }; + let songs: Vec> = library + .playlist_from_custom( + "/path/to/song2001", + 20, + distance, + closest_to_first_song_by_key, + true, + ) + .unwrap(); + assert_eq!( + vec![ + "/path/to/song1001", + "/path/to/song7001", + "/path/to/cuetrack.cue/CUE_TRACK001" + ], + songs + .into_iter() + .map(|s| s.bliss_song.path.to_string_lossy().to_string()) + .collect::>(), + ); + + let distance = + |a: &Array1, b: &Array1| ((a.get(1).unwrap() - b.get(1).unwrap()).abs()); + let songs: Vec> = library + .playlist_from_custom( + "/path/to/song2001", + 20, + distance, + closest_to_first_song_by_key, + false, + ) + .unwrap(); + assert_eq!( + vec![ + "/path/to/song2001", + "/path/to/song6001", + "/path/to/song5001", + "/path/to/song1001", + "/path/to/song7001", + "/path/to/cuetrack.cue/CUE_TRACK001", + "/path/to/cuetrack.cue/CUE_TRACK002", + ], + songs + .into_iter() + .map(|s| s.bliss_song.path.to_string_lossy().to_string()) + .collect::>(), + ) + } + + #[test] + fn test_library_album_playlist() { + let (library, _temp_dir, _) = setup_test_library(); + let album: Vec> = library + .album_playlist_from("An Album1001".to_string(), 20) + .unwrap(); + assert_eq!( + vec![ + // First album. + "/path/to/song5001".to_string(), + "/path/to/song1001".to_string(), + // Second album, well ordered. + "/path/to/song6001".to_string(), + "/path/to/song2001".to_string(), + // Third album. + "/path/to/song7001".to_string(), + // Fourth album. + "/path/to/cuetrack.cue/CUE_TRACK001".to_string(), + "/path/to/cuetrack.cue/CUE_TRACK002".to_string(), + ], + album + .into_iter() + .map(|s| s.bliss_song.path.to_string_lossy().to_string()) + .collect::>(), + ) + } + + #[test] + fn test_library_album_playlist_crop() { + let (library, _temp_dir, _) = setup_test_library(); + let album: Vec> = library + .album_playlist_from("An Album1001".to_string(), 1) + .unwrap(); + assert_eq!( + vec![ + // First album. + "/path/to/song5001".to_string(), + "/path/to/song1001".to_string(), + // Second album, well ordered. + "/path/to/song6001".to_string(), + "/path/to/song2001".to_string(), + ], + album + .into_iter() + .map(|s| s.bliss_song.path.to_string_lossy().to_string()) + .collect::>(), + ) + } + + #[test] + fn test_library_songs_from_album() { + let (library, _temp_dir, _) = setup_test_library(); + let album: Vec> = library.songs_from_album("An Album1001").unwrap(); + assert_eq!( + vec![ + "/path/to/song5001".to_string(), + "/path/to/song1001".to_string() + ], + album + .into_iter() + .map(|s| s.bliss_song.path.to_string_lossy().to_string()) + .collect::>(), + ) + } + + #[test] + fn test_library_songs_from_album_proper_features_version() { + let (library, _temp_dir, _) = setup_test_library(); + let album: Vec> = library.songs_from_album("An Album1001").unwrap(); + assert_eq!( + vec![ + "/path/to/song5001".to_string(), + "/path/to/song1001".to_string() + ], + album + .into_iter() + .map(|s| s.bliss_song.path.to_string_lossy().to_string()) + .collect::>(), + ) + } + + #[test] + fn test_library_songs_from_album_not_existing() { + let (library, _temp_dir, _) = setup_test_library(); + assert!(library + .songs_from_album::("not-existing") + .is_err()); + } + + #[test] + fn test_library_delete_song_non_existing() { + let (mut library, _temp_dir, _) = setup_test_library(); + { + let connection = library.sqlite_conn.lock().unwrap(); + let count: u32 = connection + .query_row( + "select count(*) from feature join song on song.id = feature.song_id where song.path = ?", + ["not-existing"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 0); + let count: u32 = connection + .query_row( + "select count(*) from song where path = ?", + ["not-existing"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 0); + } + assert!(library.delete_song("not-existing").is_err()); + } + + #[test] + fn test_library_delete_song() { + let (mut library, _temp_dir, _) = setup_test_library(); + { + let connection = library.sqlite_conn.lock().unwrap(); + let count: u32 = connection + .query_row( + "select count(*) from feature join song on song.id = feature.song_id where song.path = ?", + ["/path/to/song1001"], + |row| row.get(0), + ) + .unwrap(); + assert!(count >= 1); + let count: u32 = connection + .query_row( + "select count(*) from song where path = ?", + ["/path/to/song1001"], + |row| row.get(0), + ) + .unwrap(); + assert!(count >= 1); + } + + library.delete_song("/path/to/song1001").unwrap(); + + { + let connection = library.sqlite_conn.lock().unwrap(); + let count: u32 = connection + .query_row( + "select count(*) from feature join song on song.id = feature.song_id where song.path = ?", + ["/path/to/song1001"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(0, count); + let count: u32 = connection + .query_row( + "select count(*) from song where path = ?", + ["/path/to/song1001"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(0, count); + } + } + + #[test] + fn test_analyze_paths_cue() { + let (mut library, _temp_dir, _) = setup_test_library(); + library.config.base_config_mut().features_version = 0; + { + let sqlite_conn = + Connection::open(&library.config.base_config().database_path).unwrap(); + sqlite_conn.execute("delete from song", []).unwrap(); + } + + let paths = vec![ + "./data/s16_mono_22_5kHz.flac", + "./data/testcue.cue", + "non-existing", + ]; + library.analyze_paths(paths.to_owned(), false).unwrap(); + let expected_analyzed_paths = vec![ + "./data/s16_mono_22_5kHz.flac", + "./data/testcue.cue/CUE_TRACK001", + "./data/testcue.cue/CUE_TRACK002", + "./data/testcue.cue/CUE_TRACK003", + ]; + { + let connection = library.sqlite_conn.lock().unwrap(); + let mut stmt = connection + .prepare( + " + select + path from song where analyzed = true and path not like '%song%' + order by path + ", + ) + .unwrap(); + let paths = stmt + .query_map(params![], |row| row.get(0)) + .unwrap() + .map(|x| x.unwrap()) + .collect::>(); + + assert_eq!(paths, expected_analyzed_paths); + } + { + let connection = library.sqlite_conn.lock().unwrap(); + let song: LibrarySong<()> = + _library_song_from_database(connection, "./data/testcue.cue/CUE_TRACK001"); + assert!(song.bliss_song.cue_info.is_some()); + } + } + + #[test] + fn test_analyze_paths() { + let (mut library, _temp_dir, _) = setup_test_library(); + library.config.base_config_mut().features_version = 0; + + let paths = vec![ + "./data/s16_mono_22_5kHz.flac", + "./data/s16_stereo_22_5kHz.flac", + "non-existing", + ]; + library.analyze_paths(paths.to_owned(), false).unwrap(); + let songs = paths[..2] + .iter() + .map(|path| { + let connection = library.sqlite_conn.lock().unwrap(); + _library_song_from_database(connection, path) + }) + .collect::>>(); + let expected_songs = paths[..2] + .iter() + .zip(vec![(), ()].into_iter()) + .map(|(path, expected_extra_info)| LibrarySong { + bliss_song: Song::from_path(path).unwrap(), + extra_info: expected_extra_info, + }) + .collect::>>(); + assert_eq!(songs, expected_songs); + assert_eq!( + library.config.base_config_mut().features_version, + FEATURES_VERSION + ); + } + + #[test] + fn test_analyze_paths_convert_extra_info() { + let (mut library, _temp_dir, _) = setup_test_library(); + library.config.base_config_mut().features_version = 0; + let paths = vec![ + ("./data/s16_mono_22_5kHz.flac", true), + ("./data/s16_stereo_22_5kHz.flac", false), + ("non-existing", false), + ]; + library + .analyze_paths_convert_extra_info(paths.to_owned(), true, |b, _, _| ExtraInfo { + ignore: b, + metadata_bliss_does_not_have: String::from("coucou"), + }) + .unwrap(); + library + .analyze_paths_convert_extra_info(paths.to_owned(), false, |b, _, _| ExtraInfo { + ignore: b, + metadata_bliss_does_not_have: String::from("coucou"), + }) + .unwrap(); + let songs = paths[..2] + .iter() + .map(|(path, _)| { + let connection = library.sqlite_conn.lock().unwrap(); + _library_song_from_database(connection, path) + }) + .collect::>>(); + let expected_songs = paths[..2] + .iter() + .zip( + vec![ + ExtraInfo { + ignore: true, + metadata_bliss_does_not_have: String::from("coucou"), + }, + ExtraInfo { + ignore: false, + metadata_bliss_does_not_have: String::from("coucou"), + }, + ] + .into_iter(), + ) + .map(|((path, _extra_info), expected_extra_info)| LibrarySong { + bliss_song: Song::from_path(path).unwrap(), + extra_info: expected_extra_info, + }) + .collect::>>(); + assert_eq!(songs, expected_songs); + assert_eq!( + library.config.base_config_mut().features_version, + FEATURES_VERSION + ); + } + + #[test] + fn test_analyze_paths_extra_info() { + let (mut library, _temp_dir, _) = setup_test_library(); + + let paths = vec![ + ( + "./data/s16_mono_22_5kHz.flac", + ExtraInfo { + ignore: true, + metadata_bliss_does_not_have: String::from("hey"), + }, + ), + ( + "./data/s16_stereo_22_5kHz.flac", + ExtraInfo { + ignore: false, + metadata_bliss_does_not_have: String::from("hello"), + }, + ), + ( + "non-existing", + ExtraInfo { + ignore: true, + metadata_bliss_does_not_have: String::from("coucou"), + }, + ), + ]; + library + .analyze_paths_extra_info(paths.to_owned(), false) + .unwrap(); + let songs = paths[..2] + .iter() + .map(|(path, _)| { + let connection = library.sqlite_conn.lock().unwrap(); + _library_song_from_database(connection, path) + }) + .collect::>>(); + let expected_songs = paths[..2] + .iter() + .zip( + vec![ + ExtraInfo { + ignore: true, + metadata_bliss_does_not_have: String::from("hey"), + }, + ExtraInfo { + ignore: false, + metadata_bliss_does_not_have: String::from("hello"), + }, + ] + .into_iter(), + ) + .map(|((path, _extra_info), expected_extra_info)| LibrarySong { + bliss_song: Song::from_path(path).unwrap(), + extra_info: expected_extra_info, + }) + .collect::>>(); + assert_eq!(songs, expected_songs); + } + + #[test] + // Check that a song already in the database is not + // analyzed again on updates. + fn test_update_skip_analyzed() { + let (mut library, _temp_dir, _) = setup_test_library(); + library.config.base_config_mut().features_version = 0; + + for input in vec![ + ("./data/s16_mono_22_5kHz.flac", true), + ("./data/s16_mono_22_5khz.flac", false), + ] + .into_iter() + { + let paths = vec![input.to_owned()]; + library + .update_library_convert_extra_info(paths.to_owned(), false, |b, _, _| ExtraInfo { + ignore: b, + metadata_bliss_does_not_have: String::from("coucou"), + }) + .unwrap(); + let song = { + let connection = library.sqlite_conn.lock().unwrap(); + _library_song_from_database::(connection, "./data/s16_mono_22_5kHz.flac") + }; + let expected_song = { + LibrarySong { + bliss_song: Song::from_path("./data/s16_mono_22_5kHz.flac").unwrap(), + extra_info: ExtraInfo { + ignore: true, + metadata_bliss_does_not_have: String::from("coucou"), + }, + } + }; + assert_eq!(song, expected_song); + assert_eq!( + library.config.base_config_mut().features_version, + FEATURES_VERSION + ); + } + } + + fn _get_song_analyzed(connection: MutexGuard, path: String) -> bool { + let mut stmt = connection + .prepare( + " + select + analyzed from song + where song.path = ? + ", + ) + .unwrap(); + stmt.query_row([path], |row| row.get(0)).unwrap() + } + + #[test] + fn test_update_library_override_old_features() { + let (mut library, _temp_dir, _) = setup_test_library(); + let path: String = "./data/s16_stereo_22_5kHz.flac".into(); + + { + let connection = library.sqlite_conn.lock().unwrap(); + let mut stmt = connection + .prepare( + " + select + feature from feature join song on song.id = feature.song_id + where song.path = ? order by feature_index + ", + ) + .unwrap(); + let analysis_vector = stmt + .query_map(params![path], |row| row.get(0)) + .unwrap() + .into_iter() + .map(|x| x.unwrap()) + .collect::>(); + assert_eq!( + analysis_vector, + vec![1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12., 13., 14., 15.] + ) + } + + library + .update_library(vec![path.to_owned()], false) + .unwrap(); + + let connection = library.sqlite_conn.lock().unwrap(); + let mut stmt = connection + .prepare( + " + select + feature from feature join song on song.id = feature.song_id + where song.path = ? order by feature_index + ", + ) + .unwrap(); + let analysis_vector = Analysis { + internal_analysis: stmt + .query_map(params![path], |row| row.get(0)) + .unwrap() + .into_iter() + .map(|x| x.unwrap()) + .collect::>() + .try_into() + .unwrap(), + }; + let expected_analysis_vector = Song::from_path(path).unwrap().analysis; + assert_eq!(analysis_vector, expected_analysis_vector); + } + + #[test] + fn test_update_library() { + let (mut library, _temp_dir, _) = setup_test_library(); + library.config.base_config_mut().features_version = 0; + + { + let connection = library.sqlite_conn.lock().unwrap(); + // Make sure that we tried to "update" song4001 with the new features. + assert!(_get_song_analyzed(connection, "/path/to/song4001".into())); + } + + let paths = vec![ + "./data/s16_mono_22_5kHz.flac", + "./data/s16_stereo_22_5kHz.flac", + "/path/to/song4001", + "non-existing", + ]; + library.update_library(paths.to_owned(), false).unwrap(); + library.update_library(paths.to_owned(), true).unwrap(); + + let songs = paths[..2] + .iter() + .map(|path| { + let connection = library.sqlite_conn.lock().unwrap(); + _library_song_from_database(connection, path) + }) + .collect::>>(); + let expected_songs = paths[..2] + .iter() + .zip(vec![(), ()].into_iter()) + .map(|(path, expected_extra_info)| LibrarySong { + bliss_song: Song::from_path(path).unwrap(), + extra_info: expected_extra_info, + }) + .collect::>>(); + + assert_eq!(songs, expected_songs); + { + let connection = library.sqlite_conn.lock().unwrap(); + // Make sure that we tried to "update" song4001 with the new features. + assert!(!_get_song_analyzed(connection, "/path/to/song4001".into())); + } + assert_eq!( + library.config.base_config_mut().features_version, + FEATURES_VERSION + ); + } + + #[test] + fn test_update_extra_info() { + let (mut library, _temp_dir, _) = setup_test_library(); + library.config.base_config_mut().features_version = 0; + + { + let connection = library.sqlite_conn.lock().unwrap(); + // Make sure that we tried to "update" song4001 with the new features. + assert!(_get_song_analyzed(connection, "/path/to/song4001".into())); + } + + let paths = vec![ + ("./data/s16_mono_22_5kHz.flac", true), + ("./data/s16_stereo_22_5kHz.flac", false), + ("/path/to/song4001", false), + ("non-existing", false), + ]; + library + .update_library_extra_info(paths.to_owned(), false) + .unwrap(); + let songs = paths[..2] + .iter() + .map(|(path, _)| { + let connection = library.sqlite_conn.lock().unwrap(); + _library_song_from_database(connection, path) + }) + .collect::>>(); + let expected_songs = paths[..2] + .iter() + .zip(vec![true, false].into_iter()) + .map(|((path, _extra_info), expected_extra_info)| LibrarySong { + bliss_song: Song::from_path(path).unwrap(), + extra_info: expected_extra_info, + }) + .collect::>>(); + assert_eq!(songs, expected_songs); + { + let connection = library.sqlite_conn.lock().unwrap(); + // Make sure that we tried to "update" song4001 with the new features. + assert!(!_get_song_analyzed(connection, "/path/to/song4001".into())); + } + assert_eq!( + library.config.base_config_mut().features_version, + FEATURES_VERSION + ); + } + + #[test] + fn test_update_convert_extra_info() { + let (mut library, _temp_dir, _) = setup_test_library(); + library.config.base_config_mut().features_version = 0; + + { + let connection = library.sqlite_conn.lock().unwrap(); + // Make sure that we tried to "update" song4001 with the new features. + assert!(_get_song_analyzed(connection, "/path/to/song4001".into())); + } + + let paths = vec![ + ("./data/s16_mono_22_5kHz.flac", true), + ("./data/s16_stereo_22_5kHz.flac", false), + ("/path/to/song4001", false), + ("non-existing", false), + ]; + library + .update_library_convert_extra_info(paths.to_owned(), false, |b, _, _| ExtraInfo { + ignore: b, + metadata_bliss_does_not_have: String::from("coucou"), + }) + .unwrap(); + let songs = paths[..2] + .iter() + .map(|(path, _)| { + let connection = library.sqlite_conn.lock().unwrap(); + _library_song_from_database(connection, path) + }) + .collect::>>(); + let expected_songs = paths[..2] + .iter() + .zip( + vec![ + ExtraInfo { + ignore: true, + metadata_bliss_does_not_have: String::from("coucou"), + }, + ExtraInfo { + ignore: false, + metadata_bliss_does_not_have: String::from("coucou"), + }, + ] + .into_iter(), + ) + .map(|((path, _extra_info), expected_extra_info)| LibrarySong { + bliss_song: Song::from_path(path).unwrap(), + extra_info: expected_extra_info, + }) + .collect::>>(); + assert_eq!(songs, expected_songs); + { + let connection = library.sqlite_conn.lock().unwrap(); + // Make sure that we tried to "update" song4001 with the new features. + assert!(!_get_song_analyzed(connection, "/path/to/song4001".into())); + } + assert_eq!( + library.config.base_config_mut().features_version, + FEATURES_VERSION + ); + } + + #[test] + fn test_song_from_path() { + let (library, _temp_dir, _) = setup_test_library(); + let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES) + .map(|x| x as f32 + 10.) + .collect::>() + .try_into() + .unwrap(); + + let song = Song { + path: "/path/to/song2001".into(), + artist: Some("Artist2001".into()), + title: Some("Title2001".into()), + album: Some("An Album2001".into()), + album_artist: Some("An Album Artist2001".into()), + track_number: Some("02".into()), + genre: Some("Electronica2001".into()), + analysis: Analysis { + internal_analysis: analysis_vector, + }, + duration: Duration::from_secs(410), + features_version: 1, + cue_info: None, + }; + let expected_song = LibrarySong { + bliss_song: song, + extra_info: ExtraInfo { + ignore: false, + metadata_bliss_does_not_have: String::from("/path/to/charlie2001"), + }, + }; + + let song = library + .song_from_path::("/path/to/song2001") + .unwrap(); + assert_eq!(song, expected_song) + } + + #[test] + fn test_store_failed_song() { + let (mut library, _temp_dir, _) = setup_test_library(); + library + .store_failed_song( + "/some/failed/path", + BlissError::ProviderError("error with the analysis".into()), + ) + .unwrap(); + let connection = library.sqlite_conn.lock().unwrap(); + let (error, analyzed): (String, bool) = connection + .query_row( + " + select + error, analyzed + from song where path=? + ", + params!["/some/failed/path"], + |row| Ok((row.get_unwrap(0), row.get_unwrap(1))), + ) + .unwrap(); + assert_eq!( + error, + String::from( + "error happened with the music library provider - error with the analysis" + ) + ); + assert_eq!(analyzed, false); + let count_features: u32 = connection + .query_row( + " + select + count(*) from feature join song + on song.id = feature.song_id where path=? + ", + params!["/some/failed/path"], + |row| Ok(row.get_unwrap(0)), + ) + .unwrap(); + assert_eq!(count_features, 0); + } + + #[test] + fn test_songs_from_library() { + let (library, _temp_dir, expected_library_songs) = setup_test_library(); + + let library_songs = library.songs_from_library::().unwrap(); + assert_eq!(library_songs.len(), 7); + assert_eq!( + expected_library_songs, + ( + library_songs[0].to_owned(), + library_songs[1].to_owned(), + library_songs[2].to_owned(), + library_songs[3].to_owned(), + library_songs[4].to_owned(), + library_songs[5].to_owned(), + library_songs[6].to_owned(), + ) + ); + } + + #[test] + fn test_songs_from_library_screwed_db() { + let (library, _temp_dir, _) = setup_test_library(); + { + let connection = library.sqlite_conn.lock().unwrap(); + connection + .execute( + "insert into feature (song_id, feature, feature_index) + values (2001, 1.5, 21) + ", + [], + ) + .unwrap(); + } + + let error = library.songs_from_library::().unwrap_err(); + assert_eq!( + error.to_string(), + String::from( + "error happened with the music library provider - \ + Song with ID 2001 and path /path/to/song2001 has a \ + different feature number than expected. Please rescan or \ + update the song library.", + ), + ); + } + + #[test] + fn test_song_from_path_not_analyzed() { + let (library, _temp_dir, _) = setup_test_library(); + let error = library.song_from_path::("/path/to/song4001"); + assert!(error.is_err()); + } + + #[test] + fn test_song_from_path_not_found() { + let (library, _temp_dir, _) = setup_test_library(); + let error = library.song_from_path::("/path/to/song4001"); + assert!(error.is_err()); + } + + #[test] + fn test_get_default_data_folder_no_default_path() { + env::set_var("XDG_DATA_HOME", "/home/foo/.local/share/"); + assert_eq!( + PathBuf::from("/home/foo/.local/share/bliss-rs"), + BaseConfig::get_default_data_folder().unwrap() + ); + env::remove_var("XDG_DATA_HOME"); + + assert_eq!( + PathBuf::from("/local/directory/bliss-rs"), + BaseConfig::get_default_data_folder().unwrap() + ); + } + + #[test] + fn test_library_new_default_write() { + let (library, _temp_dir, _) = setup_test_library(); + let config_content = fs::read_to_string(&library.config.base_config().config_path).unwrap(); + assert_eq!( + config_content, + format!( + "{{\"config_path\":\"{}\",\"database_path\":\"{}\",\"features_version\":{}}}", + library.config.base_config().config_path.display(), + library.config.base_config().database_path.display(), + FEATURES_VERSION, + ) + ); + } + + #[test] + fn test_library_new_create_database() { + let (library, _temp_dir, _) = setup_test_library(); + let sqlite_conn = Connection::open(&library.config.base_config().database_path).unwrap(); + sqlite_conn + .execute( + " + insert into song ( + id, path, artist, title, album, album_artist, + track_number, genre, stamp, version, duration, analyzed, + extra_info + ) + values ( + 1, '/random/path', 'Some Artist', 'A Title', 'Some Album', + 'Some Album Artist', '01', 'Electronica', '2022-01-01', + 1, 250, true, '{\"key\": \"value\"}' + ); + ", + [], + ) + .unwrap(); + sqlite_conn + .execute( + " + insert into feature(id, song_id, feature, feature_index) + values (2000, 1, 1.1, 1) + on conflict(song_id, feature_index) do update set feature=excluded.feature; + ", + [], + ) + .unwrap(); + } + + #[test] + fn test_library_store_song() { + let (mut library, _temp_dir, _) = setup_test_library(); + let song = _generate_basic_song(None); + let library_song = LibrarySong { + bliss_song: song.to_owned(), + extra_info: (), + }; + library.store_song(&library_song).unwrap(); + let connection = library.sqlite_conn.lock().unwrap(); + let expected_song = _basic_song_from_database(connection, &song.path.to_string_lossy()); + assert_eq!(expected_song, song); + } + + #[test] + fn test_library_extra_info() { + let (mut library, _temp_dir, _) = setup_test_library(); + let song = _generate_library_song(None); + library.store_song(&song).unwrap(); + let connection = library.sqlite_conn.lock().unwrap(); + let returned_song = + _library_song_from_database(connection, &song.bliss_song.path.to_string_lossy()); + assert_eq!(returned_song, song); + } + + #[test] + fn test_from_config_path_non_existing() { + assert!( + Library::::from_config_path(Some(PathBuf::from("non-existing"))).is_err() + ); + } + + #[test] + fn test_from_config_path() { + let config_dir = TempDir::new("coucou").unwrap(); + let config_file = config_dir.path().join("config.json"); + let database_file = config_dir.path().join("bliss.db"); + + // In reality, someone would just do that with `(None, None)` to get the default + // paths. + let base_config = + BaseConfig::new(Some(config_file.to_owned()), Some(database_file)).unwrap(); + + let config = CustomConfig { + base_config, + second_path_to_music_library: "/path/to/somewhere".into(), + ignore_wav_files: true, + }; + // Test that it is possible to store a song in a library instance, + // make that instance go out of scope, load the library again, and + // get the stored song. + let song = _generate_library_song(None); + { + let mut library = Library::new(config.to_owned()).unwrap(); + library.store_song(&song).unwrap(); + } + + let library: Library = Library::from_config_path(Some(config_file)).unwrap(); + let connection = library.sqlite_conn.lock().unwrap(); + let returned_song = + _library_song_from_database(connection, &song.bliss_song.path.to_string_lossy()); + + assert_eq!(library.config, config); + assert_eq!(song, returned_song); + } + + #[test] + fn test_config_serialize_deserialize() { + let config_dir = TempDir::new("coucou").unwrap(); + let config_file = config_dir.path().join("config.json"); + let database_file = config_dir.path().join("bliss.db"); + + // In reality, someone would just do that with `(None, None)` to get the default + // paths. + let base_config = + BaseConfig::new(Some(config_file.to_owned()), Some(database_file)).unwrap(); + + let config = CustomConfig { + base_config, + second_path_to_music_library: "/path/to/somewhere".into(), + ignore_wav_files: true, + }; + config.write().unwrap(); + + assert_eq!( + config, + CustomConfig::from_path(&config_file.to_string_lossy()).unwrap(), + ); + } + + #[test] + fn test_library_sanity_check_fail() { + let (mut library, _temp_dir, _) = setup_test_library(); + assert!(!library.version_sanity_check().unwrap()); + } + + #[test] + fn test_library_sanity_check_ok() { + let (mut library, _temp_dir, _) = setup_test_library(); + { + let sqlite_conn = + Connection::open(&library.config.base_config().database_path).unwrap(); + sqlite_conn + .execute("delete from song where version != 1", []) + .unwrap(); + } + assert!(library.version_sanity_check().unwrap()); + } + + #[test] + fn test_library_create_all_dirs() { + let config_dir = TempDir::new("coucou") + .unwrap() + .path() + .join("path") + .join("to"); + assert!(!config_dir.is_dir()); + let config_file = config_dir.join("config.json"); + let database_file = config_dir.join("bliss.db"); + Library::::new_from_base(Some(config_file), Some(database_file)).unwrap(); + assert!(config_dir.is_dir()); + } +} diff --git a/src/playlist.rs b/src/playlist.rs index 1b7a78b..f285774 100644 --- a/src/playlist.rs +++ b/src/playlist.rs @@ -7,6 +7,8 @@ //! They will yield different styles of playlists, so don't hesitate to //! experiment with them if the default (euclidean distance for now) doesn't //! suit you. +// TODO on the `by_key` functions: maybe Fn(&T) -> &Song is enough? Compared +// to -> Song use crate::{BlissError, BlissResult, Song, NUMBER_FEATURES}; use ndarray::{Array, Array1, Array2, Axis}; use ndarray_stats::QuantileExt; @@ -47,6 +49,23 @@ pub fn closest_to_first_song( songs.sort_by_cached_key(|song| n32(first_song.custom_distance(song, &distance))); } +/// Sort `songs` in place by putting songs close to `first_song` first +/// using the `distance` metric. +/// +/// Sort songs with a key extraction function, useful for when you have a +/// structure like `CustomSong { bliss_song: Song, something_else: bool }` +pub fn closest_to_first_song_by_key( + first_song: &T, + #[allow(clippy::ptr_arg)] songs: &mut Vec, + distance: impl DistanceMetric, + key_fn: F, +) where + F: Fn(&T) -> Song, +{ + let first_song = key_fn(first_song); + songs.sort_by_cached_key(|song| n32(first_song.custom_distance(&key_fn(song), &distance))); +} + /// Sort `songs` in place using the `distance` metric and ordering by /// the smallest distance between each song. /// @@ -71,6 +90,43 @@ pub fn song_to_song(first_song: &Song, songs: &mut Vec, distance: impl Dis *songs = new_songs; } +/// Sort `songs` in place using the `distance` metric and ordering by +/// the smallest distance between each song. +/// +/// If the generated playlist is `[song1, song2, song3, song4]`, it means +/// song2 is closest to song1, song3 is closest to song2, and song4 is closest +/// to song3. +/// +/// Note that this has a tendency to go from one style to the other very fast, +/// and it can be slow on big libraries. +/// +/// Sort songs with a key extraction function, useful for when you have a +/// structure like `CustomSong { bliss_song: Song, something_else: bool }` +// TODO: maybe Clone is not needed? +pub fn song_to_song_by_key( + first_song: &T, + songs: &mut Vec, + distance: impl DistanceMetric, + key_fn: F, +) where + F: Fn(&T) -> Song, +{ + let mut new_songs: Vec = Vec::with_capacity(songs.len()); + let mut bliss_song = key_fn(&first_song.to_owned()); + + while !songs.is_empty() { + let distances: Array1 = Array::from_shape_fn(songs.len(), |i| { + bliss_song.custom_distance(&key_fn(&songs[i]), &distance) + }); + let idx = distances.argmin().unwrap(); + let song = songs[idx].to_owned(); + bliss_song = key_fn(&songs[idx]).to_owned(); + new_songs.push(song.to_owned()); + songs.retain(|s| s != &song); + } + *songs = new_songs; +} + /// Remove duplicate songs from a playlist, in place. /// /// Two songs are considered duplicates if they either have the same, @@ -86,6 +142,29 @@ pub fn dedup_playlist(songs: &mut Vec, distance_threshold: Option) { dedup_playlist_custom_distance(songs, distance_threshold, euclidean_distance); } +/// Remove duplicate songs from a playlist, in place. +/// +/// Two songs are considered duplicates if they either have the same, +/// non-empty title and artist name, or if they are close enough in terms +/// of distance. +/// +/// Dedup songs with a key extraction function, useful for when you have a +/// structure like `CustomSong { bliss_song: Song, something_else: bool }` you +/// want to deduplicate. +/// +/// # Arguments +/// +/// * `songs`: The playlist to remove duplicates from. +/// * `distance_threshold`: The distance threshold under which two songs are +/// considered identical. If `None`, a default value of 0.05 will be used. +/// * `key_fn`: A function used to retrieve the bliss [Song] from `T`. +pub fn dedup_playlist_by_key(songs: &mut Vec, distance_threshold: Option, key_fn: F) +where + F: Fn(&T) -> Song, +{ + dedup_playlist_custom_distance_by_key(songs, distance_threshold, euclidean_distance, key_fn); +} + /// Remove duplicate songs from a playlist, in place, using a custom distance /// metric. /// @@ -115,6 +194,45 @@ pub fn dedup_playlist_custom_distance( }); } +/// Remove duplicate songs from a playlist, in place, using a custom distance +/// metric. +/// +/// Two songs are considered duplicates if they either have the same, +/// non-empty title and artist name, or if they are close enough in terms +/// of distance. +/// +/// Dedup songs with a key extraction function, useful for when you have a +/// structure like `CustomSong { bliss_song: Song, something_else: bool }` +/// you want to deduplicate. +/// +/// # Arguments +/// +/// * `songs`: The playlist to remove duplicates from. +/// * `distance_threshold`: The distance threshold under which two songs are +/// considered identical. If `None`, a default value of 0.05 will be used. +/// * `distance`: A custom distance metric. +/// * `key_fn`: A function used to retrieve the bliss [Song] from `T`. +pub fn dedup_playlist_custom_distance_by_key( + songs: &mut Vec, + distance_threshold: Option, + distance: impl DistanceMetric, + key_fn: F, +) where + F: Fn(&T) -> Song, +{ + songs.dedup_by(|s1, s2| { + let s1 = key_fn(s1); + let s2 = key_fn(s2); + n32(s1.custom_distance(&s2, &distance)) < distance_threshold.unwrap_or(0.05) + || (s1.title.is_some() + && s2.title.is_some() + && s1.artist.is_some() + && s2.artist.is_some() + && s1.title == s2.title + && s1.artist == s2.artist) + }); +} + /// Return a list of albums in a `pool` of songs that are similar to /// songs in `group`, discarding songs that don't belong to an album. /// It basically makes an "album" playlist from the `pool` of songs. @@ -203,6 +321,114 @@ pub fn closest_album_to_group(group: Vec, pool: Vec) -> BlissResult< Ok(playlist) } +/// Return a list of albums in a `pool` of songs that are similar to +/// songs in `group`, discarding songs that don't belong to an album. +/// It basically makes an "album" playlist from the `pool` of songs. +/// +/// `group` should be ordered by track number. +/// +/// Songs from `group` would usually just be songs from an album, but not +/// necessarily - they are discarded from `pool` no matter what. +/// +/// Order songs with a key extraction function, useful for when you have a +/// structure like `CustomSong { bliss_song: Song, something_else: bool }` +/// you want to order. +/// +/// # Arguments +/// +/// * `group` - A small group of songs, e.g. an album. +/// * `pool` - A pool of songs to find similar songs in, e.g. a user's song +/// library. +/// * `key_fn`: A function used to retrieve the bliss [Song] from `T`. +/// +/// # Returns +/// +/// A vector of T, including `group` at the beginning, that you +/// most likely want to plug in your audio player by using something like +/// `ret.map(|song| song.path.to_owned()).collect::>()`. +// TODO: maybe Clone is not needed? +pub fn closest_album_to_group_by_key( + group: Vec, + pool: Vec, + key_fn: F, +) -> BlissResult> +where + F: Fn(&T) -> Song, +{ + let mut albums_analysis: HashMap> = HashMap::new(); + let mut albums = Vec::new(); + + // Remove songs from the group from the pool. + let pool = pool + .into_iter() + .filter(|s| !group.contains(s)) + .collect::>(); + for song in &pool { + let song = key_fn(song); + if let Some(album) = song.album { + if let Some(analysis) = albums_analysis.get_mut(&album as &str) { + analysis + .push_row(song.analysis.as_arr1().view()) + .map_err(|e| { + BlissError::ProviderError(format!("while computing distances: {}", e)) + })?; + } else { + let mut array = Array::zeros((1, song.analysis.as_arr1().len())); + array.assign(&song.analysis.as_arr1()); + albums_analysis.insert(album.to_owned(), array); + } + } + } + let mut group_analysis = Array::zeros((group.len(), NUMBER_FEATURES)); + for (song, mut column) in group.iter().zip(group_analysis.axis_iter_mut(Axis(0))) { + let song = key_fn(song); + column.assign(&song.analysis.as_arr1()); + } + let first_analysis = group_analysis + .mean_axis(Axis(0)) + .ok_or_else(|| BlissError::ProviderError(String::from("Mean of empty slice")))?; + for (album, analysis) in albums_analysis.iter() { + let mean_analysis = analysis + .mean_axis(Axis(0)) + .ok_or_else(|| BlissError::ProviderError(String::from("Mean of empty slice")))?; + let album = album.to_owned(); + albums.push((album, mean_analysis.to_owned())); + } + + albums.sort_by_key(|(_, analysis)| n32(euclidean_distance(&first_analysis, analysis))); + let mut playlist = group; + for (album, _) in albums { + let mut al = pool + .iter() + .filter(|s| { + let s = key_fn(s); + s.album.is_some() && s.album.as_ref().unwrap() == &album.to_string() + }) + .map(|s| s.to_owned()) + .collect::>(); + al.sort_by(|s1, s2| { + let s1 = key_fn(s1); + let s2 = key_fn(s2); + let track_number1 = s1 + .track_number + .to_owned() + .unwrap_or_else(|| String::from("")); + let track_number2 = s2 + .track_number + .to_owned() + .unwrap_or_else(|| String::from("")); + if let Ok(x) = track_number1.parse::() { + if let Ok(y) = track_number2.parse::() { + return x.cmp(&y); + } + } + s1.track_number.cmp(&s2.track_number) + }); + playlist.extend_from_slice(&al); + } + Ok(playlist) +} + #[cfg(test)] mod test { use super::*; @@ -210,6 +436,12 @@ mod test { use ndarray::arr1; use std::path::Path; + #[derive(Debug, Clone, PartialEq)] + struct CustomSong { + something: bool, + bliss_song: Song, + } + #[test] fn test_dedup_playlist_custom_distance() { let first_song = Song { @@ -316,6 +548,91 @@ mod test { fourth_song.to_owned(), ] ); + + let first_song = CustomSong { + bliss_song: first_song, + something: true, + }; + let second_song = CustomSong { + bliss_song: second_song, + something: true, + }; + let first_song_dupe = CustomSong { + bliss_song: first_song_dupe, + something: true, + }; + let third_song = CustomSong { + bliss_song: third_song, + something: true, + }; + let fourth_song = CustomSong { + bliss_song: fourth_song, + something: true, + }; + + let fifth_song = CustomSong { + bliss_song: fifth_song, + something: true, + }; + + let mut playlist = vec![ + first_song.to_owned(), + first_song_dupe.to_owned(), + second_song.to_owned(), + third_song.to_owned(), + fourth_song.to_owned(), + fifth_song.to_owned(), + ]; + dedup_playlist_custom_distance_by_key(&mut playlist, None, euclidean_distance, |s| { + s.bliss_song.to_owned() + }); + assert_eq!( + playlist, + vec![ + first_song.to_owned(), + second_song.to_owned(), + fourth_song.to_owned(), + ], + ); + let mut playlist = vec![ + first_song.to_owned(), + first_song_dupe.to_owned(), + second_song.to_owned(), + third_song.to_owned(), + fourth_song.to_owned(), + fifth_song.to_owned(), + ]; + dedup_playlist_custom_distance_by_key(&mut playlist, Some(20.), cosine_distance, |s| { + s.bliss_song.to_owned() + }); + assert_eq!(playlist, vec![first_song.to_owned()]); + let mut playlist = vec![ + first_song.to_owned(), + first_song_dupe.to_owned(), + second_song.to_owned(), + third_song.to_owned(), + fourth_song.to_owned(), + fifth_song.to_owned(), + ]; + dedup_playlist_by_key(&mut playlist, Some(20.), |s| s.bliss_song.to_owned()); + assert_eq!(playlist, vec![first_song.to_owned()]); + let mut playlist = vec![ + first_song.to_owned(), + first_song_dupe.to_owned(), + second_song.to_owned(), + third_song.to_owned(), + fourth_song.to_owned(), + fifth_song.to_owned(), + ]; + dedup_playlist_by_key(&mut playlist, None, |s| s.bliss_song.to_owned()); + assert_eq!( + playlist, + vec![ + first_song.to_owned(), + second_song.to_owned(), + fourth_song.to_owned(), + ] + ); } #[test] @@ -358,20 +675,64 @@ mod test { }; let mut songs = vec![ first_song.to_owned(), + third_song.to_owned(), first_song_dupe.to_owned(), second_song.to_owned(), - third_song.to_owned(), fourth_song.to_owned(), ]; song_to_song(&first_song, &mut songs, euclidean_distance); assert_eq!( songs, vec![ - first_song, + first_song.to_owned(), first_song_dupe.to_owned(), + second_song.to_owned(), + third_song.to_owned(), + fourth_song.to_owned(), + ], + ); + + let first_song = CustomSong { + bliss_song: first_song, + something: true, + }; + let second_song = CustomSong { + bliss_song: second_song, + something: true, + }; + let first_song_dupe = CustomSong { + bliss_song: first_song_dupe, + something: true, + }; + let third_song = CustomSong { + bliss_song: third_song, + something: true, + }; + let fourth_song = CustomSong { + bliss_song: fourth_song, + something: true, + }; + + let mut songs: Vec = vec![ + first_song.to_owned(), + first_song_dupe.to_owned(), + third_song.to_owned(), + fourth_song.to_owned(), + second_song.to_owned(), + ]; + + song_to_song_by_key(&first_song, &mut songs, euclidean_distance, |s| { + s.bliss_song.to_owned() + }); + + assert_eq!( + songs, + vec![ + first_song, + first_song_dupe, second_song, third_song, - fourth_song + fourth_song, ], ); } @@ -431,6 +792,46 @@ mod test { fifth_song.to_owned(), ]; closest_to_first_song(&first_song, &mut songs, euclidean_distance); + + let first_song = CustomSong { + bliss_song: first_song, + something: true, + }; + let second_song = CustomSong { + bliss_song: second_song, + something: true, + }; + let first_song_dupe = CustomSong { + bliss_song: first_song_dupe, + something: true, + }; + let third_song = CustomSong { + bliss_song: third_song, + something: true, + }; + let fourth_song = CustomSong { + bliss_song: fourth_song, + something: true, + }; + + let fifth_song = CustomSong { + bliss_song: fifth_song, + something: true, + }; + + let mut songs: Vec = vec![ + first_song.to_owned(), + first_song_dupe.to_owned(), + second_song.to_owned(), + third_song.to_owned(), + fourth_song.to_owned(), + fifth_song.to_owned(), + ]; + + closest_to_first_song_by_key(&first_song, &mut songs, euclidean_distance, |s| { + s.bliss_song.to_owned() + }); + assert_eq!( songs, vec![ @@ -538,5 +939,46 @@ mod test { ], closest_album_to_group(group, pool.to_owned()).unwrap(), ); + + let first_song = CustomSong { + bliss_song: first_song, + something: true, + }; + let second_song = CustomSong { + bliss_song: second_song, + something: true, + }; + let third_song = CustomSong { + bliss_song: third_song, + something: true, + }; + let fourth_song = CustomSong { + bliss_song: fourth_song, + something: true, + }; + + let fifth_song = CustomSong { + bliss_song: fifth_song, + something: true, + }; + + let pool = vec![ + first_song.to_owned(), + fourth_song.to_owned(), + third_song.to_owned(), + second_song.to_owned(), + fifth_song.to_owned(), + ]; + let group = vec![first_song.to_owned(), third_song.to_owned()]; + assert_eq!( + vec![ + first_song.to_owned(), + third_song.to_owned(), + fourth_song.to_owned(), + second_song.to_owned() + ], + closest_album_to_group_by_key(group, pool.to_owned(), |s| s.bliss_song.to_owned()) + .unwrap(), + ); } } diff --git a/src/song.rs b/src/song.rs index 53807ae..d534672 100644 --- a/src/song.rs +++ b/src/song.rs @@ -62,6 +62,7 @@ pub struct Song { /// Song's album's artist name, read from the metadata pub album_artist: Option, /// Song's tracked number, read from the metadata + /// TODO normalize this into an integer pub track_number: Option, /// Song's genre, read from the metadata (`""` if empty) pub genre: Option,