commit a667b4b28e370abd79238e51c580b586070cc7aa Author: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Wed Jul 22 17:57:00 2026 -0500 Baseline: full tree snapshot after git repository loss The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96e16c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +node_modules/ +dist/ +src-tauri/gen/ +*.log diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..59b613e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,6248 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.13.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter 0.1.4", + "log", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "app" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "image", + "keyring", + "library", + "log", + "metadata", + "player", + "serde", + "serde_json", + "soulseek-client", + "soulseek-proto", + "tagger", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-log", + "tokio", + "windows 0.61.3", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attohttpc" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9a9bf8b79a749ee0b911b91b671cc2b6c670bdbc7e3dfd537576ddc94bb2a2" +dependencies = [ + "http 0.2.12", + "log", + "url", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "borsh" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" +dependencies = [ + "once_cell", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byte-unit" +version = "5.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a813de7f2bbedb7dce265b64f1cf5908ebe4d56281ece8d847e98113788b9b0" +dependencies = [ + "rust_decimal", + "schemars 1.2.1", + "serde", + "utf8-width", +] + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "coreaudio-rs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +dependencies = [ + "bitflags 1.3.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" +dependencies = [ + "alsa", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter 2.0.0", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.2", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.2", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.2", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http 1.4.2", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.2", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.2", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "igd-next" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b0d7d4541def58a37bf8efc559683f21edce7c82f0d866c93ac21f7e098f93" +dependencies = [ + "attohttpc", + "log", + "rand", + "url", + "xmltree", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "log", + "windows-sys 0.60.2", + "zeroize", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "library" +version = "0.1.0" +dependencies = [ + "log", + "rusqlite", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "lofty" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + +[[package]] +name = "lofty_attr" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +dependencies = [ + "value-bag", +] + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metadata" +version = "0.1.0" +dependencies = [ + "futures", + "image", + "log", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.0", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.0", + "objc2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "ogg_pager" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216" +dependencies = [ + "byteorder", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "player" +version = "0.1.0" +dependencies = [ + "env_logger", + "log", + "rodio", + "symphonia", + "thiserror 2.0.18", +] + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http 1.4.2", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.4.2", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rodio" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40ecf59e742e03336be6a3d53755e789fd05a059fa22dfa0ed624722319e183" +dependencies = [ + "cpal", + "dasp_sample", + "num-rational", + "symphonia", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.0", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soulseek-client" +version = "0.1.0" +dependencies = [ + "bytes", + "env_logger", + "igd-next", + "log", + "soulseek-proto", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + +[[package]] +name = "soulseek-proto" +version = "0.1.0" +dependencies = [ + "bytes", + "flate2", + "md-5", + "thiserror 2.0.18", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-caf", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-caf" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tagger" +version = "0.1.0" +dependencies = [ + "lofty", + "log", + "thiserror 2.0.18", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.0", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http 1.4.2", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.61.3", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.118", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-log" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" +dependencies = [ + "android_logger", + "byte-unit", + "fern", + "log", + "objc2", + "objc2-foundation", + "serde", + "serde_json", + "serde_repr", + "swift-rs", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http 1.4.2", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http 1.4.2", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http 1.4.2", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http 1.4.2", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8-width" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "159a7cadce548703edd50d24069bc294c5415ecab0a480e0cd1ca06d112dc94a" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http 1.4.2", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..86e9d93 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[workspace] +resolver = "2" +members = [ + "src-tauri", + "crates/soulseek-proto", + "crates/soulseek-client", + "crates/player", + "crates/tagger", + "crates/metadata", + "crates/library", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.77.2" +authors = ["Trifecta Solutions"] + +[workspace.dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "sync", "time", "macros", "fs"] } +bytes = "1" +thiserror = "2" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +log = "0.4" +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } + +[profile.dev] +# Peer connections + audio decode are unusably slow at opt-level 0. +opt-level = 1 + +[profile.dev.package."*"] +opt-level = 2 diff --git a/crates/library/Cargo.toml b/crates/library/Cargo.toml new file mode 100644 index 0000000..f5c2f56 --- /dev/null +++ b/crates/library/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "library" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +rusqlite = { version = "0.32", features = ["bundled"] } +serde.workspace = true +thiserror.workspace = true +log.workspace = true diff --git a/crates/library/src/lib.rs b/crates/library/src/lib.rs new file mode 100644 index 0000000..636d2d4 --- /dev/null +++ b/crates/library/src/lib.rs @@ -0,0 +1,724 @@ +//! Music library: SQLite-backed albums and tracks. All methods are +//! synchronous — call from a blocking context. + +use rusqlite::Connection; +use serde::Serialize; +use std::path::Path; +use std::sync::Mutex; + +#[derive(Debug, thiserror::Error)] +pub enum LibraryError { + #[error("sqlite: {0}")] + Sqlite(#[from] rusqlite::Error), +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Serialize)] +pub struct Album { + pub id: i64, + pub artist: String, + pub title: String, + pub year: Option, + pub art_path: Option, + pub artist_art_path: Option, + pub track_count: u32, + pub added_at: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Track { + pub id: i64, + pub album_id: i64, + pub track_no: Option, + pub title: String, + pub artist: String, + pub duration_secs: Option, + pub bitrate_kbps: Option, + pub sample_rate: Option, + pub bit_depth: Option, + pub size: Option, + pub path: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Playlist { + pub id: i64, + pub name: String, + pub track_count: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PlaylistTrack { + pub position: i64, + pub title: String, + pub artist: String, + pub duration_secs: Option, + pub path: String, + pub art_path: Option, +} + +/// Cumulative listening stats for one artist (drives the artist-page chips). +#[derive(Debug, Clone, Serialize)] +pub struct PlayStats { + pub seconds: u64, + pub last_played_at: Option, + /// Most played track, ranked by listened time (not session count, so ten + /// instant skips never beat one album's worth of real listening). + pub top_title: Option, + /// Listening sessions of the top track — the "N×" the chip displays. + pub top_count: u32, +} + +pub struct Library { + conn: Mutex, +} + +impl Library { + pub fn open(db_path: &Path) -> Result { + let conn = Connection::open(db_path)?; + conn.execute_batch( + " + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS albums ( + id INTEGER PRIMARY KEY, + artist TEXT NOT NULL, + title TEXT NOT NULL, + year INTEGER, + art_path TEXT, + artist_art_path TEXT, + mbid TEXT, + added_at INTEGER NOT NULL, + UNIQUE(artist COLLATE NOCASE, title COLLATE NOCASE) + ); + CREATE TABLE IF NOT EXISTS tracks ( + id INTEGER PRIMARY KEY, + album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE, + track_no INTEGER, + title TEXT NOT NULL, + artist TEXT NOT NULL, + duration_secs INTEGER, + bitrate_kbps INTEGER, + sample_rate INTEGER, + bit_depth INTEGER, + size INTEGER, + path TEXT NOT NULL UNIQUE, + added_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_tracks_album ON tracks(album_id); + CREATE TABLE IF NOT EXISTS playlists ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS playlist_tracks ( + playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + title TEXT NOT NULL, + artist TEXT NOT NULL, + duration_secs INTEGER, + path TEXT NOT NULL, + art_path TEXT, + PRIMARY KEY (playlist_id, position) + ); + CREATE TABLE IF NOT EXISTS plays ( + id INTEGER PRIMARY KEY, + artist TEXT NOT NULL, + title TEXT NOT NULL, + started_at INTEGER NOT NULL, + seconds INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_plays_artist ON plays(artist COLLATE NOCASE); + ", + )?; + // Migrate databases created before artist portraits were added. + let _ = conn.execute("ALTER TABLE albums ADD COLUMN artist_art_path TEXT", []); + // Migrate databases created before per-track audio props were stored. + let _ = conn.execute("ALTER TABLE tracks ADD COLUMN sample_rate INTEGER", []); + let _ = conn.execute("ALTER TABLE tracks ADD COLUMN bit_depth INTEGER", []); + let _ = conn.execute("ALTER TABLE tracks ADD COLUMN size INTEGER", []); + Ok(Library { + conn: Mutex::new(conn), + }) + } + + /// Insert or fetch the album, updating year/art/mbid when newly known. + pub fn upsert_album( + &self, + artist: &str, + title: &str, + year: Option, + art_path: Option<&str>, + artist_art_path: Option<&str>, + mbid: Option<&str>, + now: i64, + ) -> Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO albums (artist, title, year, art_path, artist_art_path, mbid, added_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(artist COLLATE NOCASE, title COLLATE NOCASE) DO UPDATE SET + year = COALESCE(albums.year, excluded.year), + art_path = COALESCE(albums.art_path, excluded.art_path), + artist_art_path = COALESCE(albums.artist_art_path, excluded.artist_art_path), + mbid = COALESCE(albums.mbid, excluded.mbid)", + rusqlite::params![artist, title, year, art_path, artist_art_path, mbid, now], + )?; + let id = conn.query_row( + "SELECT id FROM albums WHERE artist = ?1 COLLATE NOCASE AND title = ?2 COLLATE NOCASE", + rusqlite::params![artist, title], + |r| r.get(0), + )?; + Ok(id) + } + + pub fn set_album_art(&self, album_id: i64, art_path: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE albums SET art_path = ?2 WHERE id = ?1", + rusqlite::params![album_id, art_path], + )?; + Ok(()) + } + + pub fn set_artist_art(&self, artist: &str, art_path: &str) -> Result { + let conn = self.conn.lock().unwrap(); + Ok(conn.execute( + "UPDATE albums SET artist_art_path = ?2 WHERE artist = ?1 COLLATE NOCASE", + rusqlite::params![artist, art_path], + )?) + } + + // ---- play history ---- + // One row per listening session. The frontend opens a row when a track + // first accumulates listened time and tops up its seconds while it plays, + // so a skipped track never writes a row and per-play counts stay honest. + + pub fn record_play(&self, artist: &str, title: &str, now: i64) -> Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO plays (artist, title, started_at, seconds) VALUES (?1, ?2, ?3, 0)", + rusqlite::params![artist, title, now], + )?; + Ok(conn.last_insert_rowid()) + } + + pub fn add_play_seconds(&self, play_id: i64, secs: u32) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE plays SET seconds = seconds + ?2 WHERE id = ?1", + rusqlite::params![play_id, secs], + )?; + Ok(()) + } + + pub fn artist_play_stats(&self, artist: &str) -> Result { + let conn = self.conn.lock().unwrap(); + let (seconds, last_played_at) = conn.query_row( + "SELECT COALESCE(SUM(seconds), 0), MAX(started_at) + FROM plays WHERE artist = ?1 COLLATE NOCASE", + rusqlite::params![artist], + |r| Ok((r.get::<_, i64>(0)?.max(0) as u64, r.get(1)?)), + )?; + // Sessions shorter than 30s are noise (previews, near-instant skips); + // excluding them keeps the count aligned with the scrobbling norm. + let top = conn + .query_row( + "SELECT title, COUNT(*) FROM plays + WHERE artist = ?1 COLLATE NOCASE AND seconds >= 30 + GROUP BY title COLLATE NOCASE + ORDER BY SUM(seconds) DESC LIMIT 1", + rusqlite::params![artist], + |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u32)), + ) + .map(Some) + .or_else(|e| match e { + rusqlite::Error::QueryReturnedNoRows => Ok(None), + other => Err(other), + })?; + let (top_title, top_count) = top.map_or((None, 0), |(t, c)| (Some(t), c)); + Ok(PlayStats { + seconds, + last_played_at, + top_title, + top_count, + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn upsert_track( + &self, + album_id: i64, + track_no: Option, + title: &str, + artist: &str, + duration_secs: Option, + bitrate_kbps: Option, + sample_rate: Option, + bit_depth: Option, + size: Option, + path: &str, + now: i64, + ) -> Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO tracks (album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path, added_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + ON CONFLICT(path) DO UPDATE SET + album_id = excluded.album_id, + track_no = excluded.track_no, + title = excluded.title, + artist = excluded.artist, + duration_secs = excluded.duration_secs, + bitrate_kbps = excluded.bitrate_kbps, + sample_rate = excluded.sample_rate, + bit_depth = excluded.bit_depth, + size = excluded.size", + rusqlite::params![album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path, now], + )?; + let id = conn.query_row( + "SELECT id FROM tracks WHERE path = ?1", + rusqlite::params![path], + |r| r.get(0), + )?; + Ok(id) + } + + pub fn has_track_path(&self, path: &str) -> Result { + let conn = self.conn.lock().unwrap(); + let n: i64 = conn.query_row( + "SELECT COUNT(*) FROM tracks WHERE path = ?1", + rusqlite::params![path], + |r| r.get(0), + )?; + Ok(n > 0) + } + + /// Whether this song is already represented in the library, independent + /// of its on-disk path. Downloads are organized with a " (2)" suffix to + /// avoid overwriting files, so a path-only check cannot prevent a second + /// save of the same track from becoming a duplicate library row. + pub fn has_track(&self, artist: &str, album: &str, title: &str) -> Result { + let conn = self.conn.lock().unwrap(); + let n: i64 = conn.query_row( + "SELECT COUNT(*) + FROM tracks t + JOIN albums a ON a.id = t.album_id + WHERE a.artist = ?1 COLLATE NOCASE + AND a.title = ?2 COLLATE NOCASE + AND t.title = ?3 COLLATE NOCASE", + rusqlite::params![artist, album, title], + |r| r.get(0), + )?; + Ok(n > 0) + } + + pub fn get_album(&self, album_id: i64) -> Result)>> { + let conn = self.conn.lock().unwrap(); + let row = conn + .query_row( + "SELECT a.id, a.artist, a.title, a.year, a.art_path, a.artist_art_path, a.added_at, a.mbid, + (SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id) + FROM albums a WHERE a.id = ?1", + [album_id], + |r| { + Ok(( + Album { + id: r.get(0)?, + artist: r.get(1)?, + title: r.get(2)?, + year: r.get(3)?, + art_path: r.get(4)?, + artist_art_path: r.get(5)?, + added_at: r.get(6)?, + track_count: r.get::<_, i64>(8)? as u32, + }, + r.get::<_, Option>(7)?, + )) + }, + ) + .map(Some) + .or_else(|e| match e { + rusqlite::Error::QueryReturnedNoRows => Ok(None), + other => Err(other), + })?; + Ok(row) + } + + pub fn list_albums(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT a.id, a.artist, a.title, a.year, a.art_path, a.artist_art_path, a.added_at, + (SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id) + FROM albums a + ORDER BY a.added_at DESC, a.artist, a.title", + )?; + let rows = stmt.query_map([], |r| { + Ok(Album { + id: r.get(0)?, + artist: r.get(1)?, + title: r.get(2)?, + year: r.get(3)?, + art_path: r.get(4)?, + artist_art_path: r.get(5)?, + added_at: r.get(6)?, + track_count: r.get::<_, i64>(7)? as u32, + }) + })?; + Ok(rows.filter_map(|r| r.ok()).collect()) + } + + pub fn album_tracks(&self, album_id: i64) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path + FROM tracks WHERE album_id = ?1 + ORDER BY track_no IS NULL, track_no, title", + )?; + let rows = stmt.query_map([album_id], |r| { + Ok(Track { + id: r.get(0)?, + album_id: r.get(1)?, + track_no: r.get(2)?, + title: r.get(3)?, + artist: r.get(4)?, + duration_secs: r.get(5)?, + bitrate_kbps: r.get(6)?, + sample_rate: r.get(7)?, + bit_depth: r.get(8)?, + size: r.get::<_, Option>(9)?.map(|n| n as u64), + path: r.get(10)?, + }) + })?; + Ok(rows.filter_map(|r| r.ok()).collect()) + } + + /// Paths of tracks missing the audio-props backfill (size is the sentinel: + /// always available from the filesystem, so a non-null size means scanned). + pub fn tracks_missing_props(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT id, path FROM tracks WHERE size IS NULL")?; + let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; + Ok(rows.filter_map(|r| r.ok()).collect()) + } + + /// Backfill audio props for one track (found via tracks_missing_props). + pub fn set_track_props( + &self, + id: i64, + sample_rate: Option, + bit_depth: Option, + size: Option, + ) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE tracks SET sample_rate = ?2, bit_depth = ?3, size = ?4 WHERE id = ?1", + rusqlite::params![id, sample_rate, bit_depth, size.map(|n| n as i64)], + )?; + Ok(()) + } + + // ---- playlists ---- + + pub fn create_playlist(&self, name: &str, now: i64) -> Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO playlists (name, created_at) VALUES (?1, ?2)", + rusqlite::params![name, now], + )?; + Ok(conn.last_insert_rowid()) + } + + pub fn rename_playlist(&self, id: i64, name: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE playlists SET name = ?2 WHERE id = ?1", + rusqlite::params![id, name], + )?; + Ok(()) + } + + pub fn delete_playlist(&self, id: i64) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM playlist_tracks WHERE playlist_id = ?1", [id])?; + conn.execute("DELETE FROM playlists WHERE id = ?1", [id])?; + Ok(()) + } + + /// (id, name, track_count), newest first. + pub fn list_playlists(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT p.id, p.name, + (SELECT COUNT(*) FROM playlist_tracks t WHERE t.playlist_id = p.id) + FROM playlists p ORDER BY p.created_at DESC", + )?; + let rows = stmt.query_map([], |r| { + Ok(Playlist { + id: r.get(0)?, + name: r.get(1)?, + track_count: r.get::<_, i64>(2)? as u32, + }) + })?; + Ok(rows.filter_map(|r| r.ok()).collect()) + } + + pub fn add_to_playlist(&self, playlist_id: i64, t: &PlaylistTrack) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let next: i64 = conn.query_row( + "SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_tracks WHERE playlist_id = ?1", + [playlist_id], + |r| r.get(0), + )?; + conn.execute( + "INSERT INTO playlist_tracks + (playlist_id, position, title, artist, duration_secs, path, art_path) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + playlist_id, + next, + t.title, + t.artist, + t.duration_secs, + t.path, + t.art_path + ], + )?; + Ok(()) + } + + pub fn playlist_tracks(&self, playlist_id: i64) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT position, title, artist, duration_secs, path, art_path + FROM playlist_tracks WHERE playlist_id = ?1 ORDER BY position", + )?; + let rows = stmt.query_map([playlist_id], |r| { + Ok(PlaylistTrack { + position: r.get(0)?, + title: r.get(1)?, + artist: r.get(2)?, + duration_secs: r.get(3)?, + path: r.get(4)?, + art_path: r.get(5)?, + }) + })?; + Ok(rows.filter_map(|r| r.ok()).collect()) + } + + pub fn remove_from_playlist(&self, playlist_id: i64, position: i64) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM playlist_tracks WHERE playlist_id = ?1 AND position = ?2", + rusqlite::params![playlist_id, position], + )?; + Ok(()) + } + + /// Drop tracks whose files vanished; returns how many were removed. + pub fn prune_missing(&self) -> Result { + let conn = self.conn.lock().unwrap(); + let paths: Vec<(i64, String)> = conn + .prepare("SELECT id, path FROM tracks")? + .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .filter_map(|r| r.ok()) + .collect(); + let mut removed = 0; + for (id, path) in paths { + if !Path::new(&path).exists() { + conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?; + removed += 1; + } + } + conn.execute( + "DELETE FROM albums WHERE id NOT IN (SELECT DISTINCT album_id FROM tracks)", + [], + )?; + Ok(removed) + } + + /// Repoint stale artwork references to an identically named file in the + /// current app's art cache. This covers app-identifier/data-directory + /// migrations without requiring artwork to be downloaded again. + pub fn repair_art_paths(&self, art_dir: &Path) -> Result { + let conn = self.conn.lock().unwrap(); + let rows: Vec<(i64, Option, Option)> = conn + .prepare("SELECT id, art_path, artist_art_path FROM albums")? + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? + .filter_map(|r| r.ok()) + .collect(); + let mut repaired = 0; + + for (id, art_path, artist_art_path) in rows { + if let Some(path) = art_path.and_then(|p| cached_art_path(&p, art_dir)) { + conn.execute( + "UPDATE albums SET art_path = ?2 WHERE id = ?1", + rusqlite::params![id, path], + )?; + repaired += 1; + } + if let Some(path) = artist_art_path.and_then(|p| cached_art_path(&p, art_dir)) { + conn.execute( + "UPDATE albums SET artist_art_path = ?2 WHERE id = ?1", + rusqlite::params![id, path], + )?; + repaired += 1; + } + } + Ok(repaired) + } + + /// Remove rows accidentally created for non-audio download extras (cover + /// images, playlists, text files, etc.). The library should only ever + /// expose playable audio tracks. + pub fn prune_non_audio(&self) -> Result { + let conn = self.conn.lock().unwrap(); + let rows: Vec<(i64, String)> = conn + .prepare("SELECT id, path FROM tracks")? + .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .filter_map(|r| r.ok()) + .collect(); + let mut removed = 0; + for (id, path) in rows { + let audio = Path::new(&path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| { + matches!( + e.to_ascii_lowercase().as_str(), + "flac" | "mp3" | "m4a" | "ogg" | "opus" | "wav" | "aac" | "wma" + ) + }) + .unwrap_or(false); + if !audio { + conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?; + removed += 1; + } + } + conn.execute( + "DELETE FROM albums WHERE id NOT IN (SELECT DISTINCT album_id FROM tracks)", + [], + )?; + Ok(removed) + } +} + +fn cached_art_path(path: &str, art_dir: &Path) -> Option { + if Path::new(path).is_file() { + return None; + } + let name = Path::new(path).file_name()?; + let candidate = art_dir.join(name); + candidate + .is_file() + .then(|| candidate.to_string_lossy().into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip() { + let dir = std::env::temp_dir().join("ssplus-lib-test"); + std::fs::create_dir_all(&dir).unwrap(); + let db = dir.join("t.db"); + let _ = std::fs::remove_file(&db); + + let lib = Library::open(&db).unwrap(); + let album = lib + .upsert_album( + "Radiohead", + "OK Computer", + Some(1997), + None, + None, + None, + 100, + ) + .unwrap(); + // Same album, case-insensitive → same id, art fills in. + let again = lib + .upsert_album( + "radiohead", + "ok computer", + None, + Some("art.jpg"), + None, + None, + 200, + ) + .unwrap(); + assert_eq!(album, again); + + lib.upsert_track( + album, + Some(1), + "Airbag", + "Radiohead", + Some(287), + Some(1024), + Some(44100), + Some(16), + Some(30_000_000), + "C:\\x\\01.flac", + 100, + ) + .unwrap(); + lib.upsert_track( + album, + Some(2), + "Paranoid Android", + "Radiohead", + None, + None, + None, + None, + None, + "C:\\x\\02.flac", + 100, + ) + .unwrap(); + + let albums = lib.list_albums().unwrap(); + assert_eq!(albums.len(), 1); + assert_eq!(albums[0].track_count, 2); + assert_eq!(albums[0].art_path.as_deref(), Some("art.jpg")); + + let tracks = lib.album_tracks(album).unwrap(); + assert_eq!(tracks[0].title, "Airbag"); + assert!(lib.has_track_path("C:\\x\\01.flac").unwrap()); + assert!(lib.has_track("radiohead", "ok computer", "airbag").unwrap()); + assert!(!lib + .has_track("Radiohead", "OK Computer", "Karma Police") + .unwrap()); + + let art_dir = dir.join("art"); + std::fs::create_dir_all(&art_dir).unwrap(); + let cover = art_dir.join("cover.jpg"); + let artist = art_dir.join("artist.jpg"); + std::fs::write(&cover, []).unwrap(); + std::fs::write(&artist, []).unwrap(); + let stale = lib + .upsert_album( + "Stale Artist", + "Stale Album", + None, + Some("C:\\old-app-data\\art\\cover.jpg"), + Some("C:\\old-app-data\\art\\artist.jpg"), + None, + 100, + ) + .unwrap(); + assert_eq!(lib.repair_art_paths(&art_dir).unwrap(), 2); + let repaired = lib.get_album(stale).unwrap().unwrap().0; + assert_eq!( + repaired.art_path.as_deref(), + Some(cover.to_string_lossy().as_ref()) + ); + assert_eq!( + repaired.artist_art_path.as_deref(), + Some(artist.to_string_lossy().as_ref()) + ); + } +} diff --git a/crates/metadata/Cargo.toml b/crates/metadata/Cargo.toml new file mode 100644 index 0000000..ab96f30 --- /dev/null +++ b/crates/metadata/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "metadata" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +futures = "0.3" +image.workspace = true +reqwest = { version = "0.12", features = ["json"] } +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +thiserror.workspace = true +log.workspace = true diff --git a/crates/metadata/examples/album_info_bench.rs b/crates/metadata/examples/album_info_bench.rs new file mode 100644 index 0000000..9365818 --- /dev/null +++ b/crates/metadata/examples/album_info_bench.rs @@ -0,0 +1,123 @@ +//! Latency harness for the album-description path (`album_info_for_artist_id`). +//! +//! This is the description counterpart to `discography_audit`: it measures the +//! call the artist page's expander and background hint harvest both make, so +//! optimization work has a before/after number instead of an impression. +//! +//! Usage: +//! cargo run -p metadata --example album_info_bench -- "Artist" "Title" "Title" ... +//! +//! Reports, in order: +//! 1. COLD SEQUENTIAL — one fresh client, titles one at a time. This is what +//! the frontend harvest does today (`for (const title of titles) await`), +//! so its total is the wall-clock the user waits for the tabs to settle. +//! 2. WARM SEQUENTIAL — same client again, exercising the per-source caches. +//! 3. COLD CONCURRENT — a second fresh client, all titles at once. The gap +//! against (1) is the ceiling available from parallelizing the harvest. + +use metadata::MetadataClient; +use std::sync::Arc; +use std::time::Instant; + +#[tokio::main] +async fn main() { + let mut args = std::env::args().skip(1); + let Some(artist) = args.next() else { + eprintln!("usage: album_info_bench -- \"Artist\" \"Title\" [\"Title\" ...]"); + return; + }; + let titles: Vec = args.collect(); + if titles.is_empty() { + eprintln!("give at least one album title"); + return; + } + + let client = Arc::new(MetadataClient::new()); + let search = client.catalog_search(&artist).await; + let artist_id = search + .artists + .iter() + .find(|a| normalize(&a.name) == normalize(&artist)) + .map(|a| a.id); + println!( + "artist {artist:?} -> id {artist_id:?}, {} titles\n", + titles.len() + ); + + let cold = run_sequential(&client, artist_id, &artist, &titles, "COLD SEQUENTIAL").await; + let warm = run_sequential(&client, artist_id, &artist, &titles, "WARM SEQUENTIAL").await; + + // A second client so the concurrent run is genuinely cold; sharing the + // first one would measure the cache, not the network path. + let fresh = Arc::new(MetadataClient::new()); + let started = Instant::now(); + let mut tasks = Vec::new(); + for title in &titles { + let fresh = Arc::clone(&fresh); + let artist = artist.clone(); + let title = title.clone(); + tasks.push(tokio::spawn(async move { + let at = Instant::now(); + let info = fresh + .album_info_for_artist_id(artist_id, &artist, &title) + .await; + (title, at.elapsed().as_millis(), info.type_hint) + })); + } + let mut rows = Vec::new(); + for task in tasks { + if let Ok(row) = task.await { + rows.push(row); + } + } + let concurrent = started.elapsed().as_millis(); + println!("== COLD CONCURRENT =="); + for (title, ms, hint) in &rows { + println!(" {ms:>6} ms {title} hint={hint:?}"); + } + println!(" total {concurrent} ms\n"); + + println!("== SUMMARY =="); + println!(" cold sequential : {cold:>6} ms <- today's harvest wall-clock"); + println!(" warm sequential : {warm:>6} ms <- per-source cache hit"); + println!(" cold concurrent : {concurrent:>6} ms <- ceiling from fan-out"); + if concurrent > 0 { + println!( + " speedup : {:.1}x", + cold as f64 / concurrent as f64 + ); + } +} + +async fn run_sequential( + client: &Arc, + artist_id: Option, + artist: &str, + titles: &[String], + label: &str, +) -> u128 { + println!("== {label} =="); + let started = Instant::now(); + for title in titles { + let at = Instant::now(); + let info = client + .album_info_for_artist_id(artist_id, artist, title) + .await; + println!( + " {:>6} ms {title} hint={:?} source={:?}", + at.elapsed().as_millis(), + info.type_hint, + info.source, + ); + } + let total = started.elapsed().as_millis(); + println!(" total {total} ms\n"); + total +} + +fn normalize(s: &str) -> String { + s.to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric()) + .collect() +} diff --git a/crates/metadata/examples/album_info_probe.rs b/crates/metadata/examples/album_info_probe.rs new file mode 100644 index 0000000..0ab46da --- /dev/null +++ b/crates/metadata/examples/album_info_probe.rs @@ -0,0 +1,44 @@ +//! Live probe for identity-scoped release descriptions + type hints. +//! +//! Usage: cargo run -p metadata --example album_info_probe -- "Artist|Title" ... + +use metadata::MetadataClient; +use std::sync::Arc; + +#[tokio::main] +async fn main() { + let client = Arc::new(MetadataClient::new()); + let requests: Vec = std::env::args().skip(1).collect(); + for request in requests { + let Some((artist, title)) = request.split_once('|') else { + eprintln!("expected Artist|Title, got {request}"); + continue; + }; + let search = client.catalog_search(artist).await; + let id = search + .artists + .iter() + .find(|a| normalize(&a.name) == normalize(artist)) + .map(|a| a.id); + let info = client.album_info_for_artist_id(id, artist, title).await; + println!( + "{} | {} (id {:?})\n source={:?} hint={:?}\n desc={}\n", + artist, + title, + id, + info.source, + info.type_hint, + info.description + .as_deref() + .map(|d| d.chars().take(140).collect::()) + .unwrap_or_else(|| "".into()), + ); + } +} + +fn normalize(s: &str) -> String { + s.to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric()) + .collect() +} diff --git a/crates/metadata/examples/artist_page_bench.rs b/crates/metadata/examples/artist_page_bench.rs new file mode 100644 index 0000000..4b21c9b --- /dev/null +++ b/crates/metadata/examples/artist_page_bench.rs @@ -0,0 +1,119 @@ +//! Latency harness for the whole discography-load pipeline — the three +//! backend stages the artist page runs, in the order the frontend runs them: +//! +//! 1. `catalog_artist_overview` — the first-paint content stage +//! 2. `catalog_artist` — the exhaustive classification page +//! 3. `catalog_artist_identity` — the MusicBrainz supplement/type-hint pass +//! +//! Companion to `album_info_bench` (descriptions). Run with +//! `SOULSTREAM_STAGE_TRACE=1` to attribute time inside each stage. +//! +//! Usage: +//! cargo run -p metadata --example artist_page_bench -- "Artist" ["Artist" ...] + +use metadata::MetadataClient; +use std::sync::Arc; +use std::time::Instant; + +#[tokio::main] +async fn main() { + let artists: Vec = std::env::args().skip(1).collect(); + if artists.is_empty() { + eprintln!("usage: artist_page_bench -- \"Artist\" [\"Artist\" ...]"); + return; + } + + for artist in &artists { + let client = Arc::new(MetadataClient::new()); + let search = client.catalog_search(artist).await; + let Some(id) = search + .artists + .iter() + .find(|a| normalize(&a.name) == normalize(artist)) + .map(|a| a.id) + else { + println!("== {artist}: no exact-name artist found, skipping ==\n"); + continue; + }; + println!("== {artist} (deezer {id}) =="); + + // The frontend overlaps overview + page, then runs the identity pass + // after the page lands. Reproduce exactly that shape. + let cold_started = Instant::now(); + let (overview, page) = tokio::join!( + async { + let t = Instant::now(); + let overview = client.catalog_artist_overview(id).await; + (t.elapsed().as_millis(), overview) + }, + async { + let t = Instant::now(); + let page = client.catalog_artist(id).await; + (t.elapsed().as_millis(), page) + }, + ); + let overlap_ms = cold_started.elapsed().as_millis(); + println!( + " cold overview: {} ms ({} albums) cold page: {} ms ({} albums) overlapped: {overlap_ms} ms", + overview.0, + overview.1.as_ref().map(|o| o.albums.len()).unwrap_or(0), + page.0, + page.1.as_ref().map(|p| p.albums.len()).unwrap_or(0), + ); + + let t = Instant::now(); + let identity = client.catalog_artist_identity(id, artist).await; + let identity_ms = t.elapsed().as_millis(); + println!( + " cold identity pass: {identity_ms} ms (supplement {} albums, {} type hints)", + identity.albums.len(), + identity.type_hints.len(), + ); + println!( + " COLD TOTAL to fully sorted (page ∥ overview, then identity): {} ms", + overlap_ms + identity_ms + ); + + let t = Instant::now(); + let _ = client.catalog_artist(id).await; + println!(" warm page: {} ms", t.elapsed().as_millis()); + let t = Instant::now(); + let _ = client.catalog_artist_identity(id, artist).await; + println!(" warm identity: {} ms", t.elapsed().as_millis()); + + // The new frontend shape: the identity pass is kicked off BESIDE the + // page load instead of after it, so cold time-to-sorted is max(), not + // sum(). A fresh client so nothing above warms it. + let fresh = Arc::new(MetadataClient::new()); + let t = Instant::now(); + let (_page, _identity) = tokio::join!( + fresh.catalog_artist(id), + fresh.catalog_artist_identity(id, artist), + ); + println!( + " cold page ∥ identity (new frontend overlap): {} ms", + t.elapsed().as_millis() + ); + + // Restart warmth: two separate clients sharing one disk cache dir — + // the second models an app relaunch. + let dir = std::env::temp_dir().join("soulstream-artist-page-bench-cache"); + let _ = std::fs::remove_dir_all(&dir); + let writer = Arc::new(MetadataClient::new().with_disk_cache(dir.clone())); + let _ = writer.catalog_artist_identity(id, artist).await; + let restarted = Arc::new(MetadataClient::new().with_disk_cache(dir.clone())); + let t = Instant::now(); + let _ = restarted.catalog_artist_identity(id, artist).await; + println!( + " identity after app restart (disk cache): {} ms\n", + t.elapsed().as_millis() + ); + } +} + +fn normalize(s: &str) -> String { + s.to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric()) + .collect() +} diff --git a/crates/metadata/examples/cover_probe.rs b/crates/metadata/examples/cover_probe.rs new file mode 100644 index 0000000..0ee25a8 --- /dev/null +++ b/crates/metadata/examples/cover_probe.rs @@ -0,0 +1,44 @@ +//! Hardened cover-resolution report across a whole discography. +//! cargo run -p metadata --example cover_probe -- input.json output.json +//! +//! input.json: { "albums": [ { "artist": "...", "title": "...", "cover": "" }, ... ] } +//! output.json: [ CoverReport, ... ] (reference + candidates + chosen, with dHash distances) + +#[derive(serde::Deserialize)] +struct Album { + artist: String, + title: String, + cover: Option, +} +#[derive(serde::Deserialize)] +struct Input { + albums: Vec, +} + +#[tokio::main] +async fn main() { + let in_path = std::env::args().nth(1).expect("input.json"); + let out_path = std::env::args().nth(2).expect("output.json"); + let raw = std::fs::read_to_string(&in_path).expect("read input"); + let input: Input = + serde_json::from_str(raw.trim_start_matches('\u{feff}')).expect("parse input"); + + let client = metadata::MetadataClient::new(); + let mut reports = Vec::new(); + let total = input.albums.len(); + for (i, a) in input.albums.iter().enumerate() { + let reference = a.cover.clone().unwrap_or_default(); + let report = client.cover_report(&a.artist, &a.title, &reference).await; + eprintln!( + "[{}/{}] {} :: chosen={:?} ({} candidates)", + i + 1, + total, + a.title, + report.chosen_source, + report.candidates.len() + ); + reports.push(report); + } + std::fs::write(&out_path, serde_json::to_string(&reports).unwrap()).expect("write output"); + eprintln!("wrote {out_path}"); +} diff --git a/crates/metadata/examples/decode_to_png.rs b/crates/metadata/examples/decode_to_png.rs new file mode 100644 index 0000000..94547e7 --- /dev/null +++ b/crates/metadata/examples/decode_to_png.rs @@ -0,0 +1,26 @@ +//! Decode an image with the exact decoder the hero pipeline uses (the `image` +//! crate) and write lossless PNG. Lets the live-GPU hero prototype consume +//! bit-identical pixels to the baked path — browsers decode JPEG chroma +//! differently, which showed up as a subtle tint in A/B comparisons. +//! +//! Usage: decode_to_png + +fn main() { + let mut args = std::env::args().skip(1); + let (Some(input), Some(output)) = (args.next(), args.next()) else { + eprintln!("usage: decode_to_png "); + std::process::exit(2); + }; + let bytes = std::fs::read(&input).expect("read input"); + let decoded = image::load_from_memory(&bytes).expect("decode"); + decoded + .save_with_format(&output, image::ImageFormat::Png) + .expect("write png"); + println!( + "{} {}x{} -> {}", + input, + decoded.width(), + decoded.height(), + output + ); +} diff --git a/crates/metadata/examples/discography_audit.rs b/crates/metadata/examples/discography_audit.rs new file mode 100644 index 0000000..3782b90 --- /dev/null +++ b/crates/metadata/examples/discography_audit.rs @@ -0,0 +1,124 @@ +//! Broad discography audit over the PRODUCTION artist pipeline. +//! +//! Runs `catalog_artist` (Deezer + Apple supplement, dedup, edition folding) +//! plus the deferred `catalog_artist_identity` MusicBrainz pass for each +//! requested artist, and prints one JSON object per artist (JSONL) with the +//! final album rows and MB type hints — exactly the data the artist page +//! files into tabs. An external analyzer replicates the frontend's category +//! rules over this dump to flag duplicates and misfiled releases. +//! +//! Usage: +//! cargo run -p metadata --example discography_audit -- "Artist" ... +//! cargo run -p metadata --example discography_audit -- --broad + +use metadata::MetadataClient; +use std::sync::Arc; + +const BROAD_ARTISTS: [&str; 44] = [ + "Weezer", + "Radiohead", + "Queen", + "The Beatles", + "Taylor Swift", + "Beyoncé", + "Coldplay", + "Nirvana", + "Fleetwood Mac", + "Elton John", + "David Bowie", + "Pink Floyd", + "Led Zeppelin", + "Bob Dylan", + "Miles Davis", + "Daft Punk", + "deadmau5", + "Fatboy Slim", + "Bonobo", + "Four Tet", + "Calvin Harris", + "Aphex Twin", + "The Chemical Brothers", + "Crazy P", + "Moby", + "Björk", + "Depeche Mode", + "Kanye West", + "Kendrick Lamar", + "Eminem", + "Drake", + "Travis Scott", + "Hans Zimmer", + "John Williams", + "Danny Elfman", + "Ludwig Göransson", + "Nine Inch Nails", + "MGMT", + "Panda Bear", + "Tame Impala", + "Mac DeMarco", + "Gorillaz", + "Metallica", + "ABBA", +]; + +#[tokio::main] +async fn main() { + let mut requested: Vec = std::env::args().skip(1).collect(); + if requested.iter().any(|a| a == "--broad") { + requested = BROAD_ARTISTS.iter().map(|s| s.to_string()).collect(); + } + if requested.is_empty() { + eprintln!("pass artist names or --broad"); + std::process::exit(2); + } + + let client = Arc::new(MetadataClient::new()); + for name in requested { + let search = client.catalog_search(&name).await; + let want = normalize(&name); + let artist = search + .artists + .iter() + .filter(|a| normalize(&a.name) == want) + .max_by_key(|a| a.fans) + .or_else(|| search.artists.first()); + let Some(artist) = artist else { + println!( + "{}", + serde_json::json!({ "requested": name, "error": "not_found" }) + ); + continue; + }; + let id = artist.id; + let display = artist.name.clone(); + let fans = artist.fans; + let page = client.catalog_artist(id).await; + let Some(page) = page else { + println!( + "{}", + serde_json::json!({ "requested": name, "artist_id": id, "error": "page_failed" }) + ); + continue; + }; + let identity = client.catalog_artist_identity(id, &display).await; + println!( + "{}", + serde_json::json!({ + "requested": name, + "artist_id": id, + "artist": display, + "fans": fans, + "albums": page.albums, + "type_hints": identity.type_hints, + "identity_albums": identity.albums, + }) + ); + } +} + +fn normalize(s: &str) -> String { + s.to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric()) + .collect() +} diff --git a/crates/metadata/examples/hero_source_probe.rs b/crates/metadata/examples/hero_source_probe.rs new file mode 100644 index 0000000..7427806 --- /dev/null +++ b/crates/metadata/examples/hero_source_probe.rs @@ -0,0 +1,162 @@ +//! Live diagnostic for the production artist-art resolver. +//! +//! Usage: +//! cargo run -p metadata --example hero_source_probe -- [output-dir] [artist ...] +//! cargo run -p metadata --example hero_source_probe -- [output-dir] --extended +//! cargo run -p metadata --example hero_source_probe -- [output-dir] --bing-only [artist ...] +//! +//! With no artists, the five core sourcing fixtures are used. `--extended` +//! adds ten lesser-known stress fixtures. Passing `-` as the output directory +//! prints URLs without saving the image bytes. + +use metadata::{CatalogArtist, MetadataClient}; +use std::path::PathBuf; + +const DEFAULT_ARTISTS: [&str; 5] = [ + "Camping in Alaska", + "Partiboi69", + "Hermeth", + "Billy Cobb", + "Fishmans", +]; + +const EXTENDED_ARTISTS: [&str; 10] = [ + "Wednesday", + "Equipment", + "Hana Vu", + "Jane Remover", + "DJ Sabrina The Teenage DJ", + "Nikki Nair", + "MSPAINT", + "Horse Jumper of Love", + "They Are Gutting a Body of Water", + "Sword II", +]; + +#[tokio::main] +async fn main() { + let mut args = std::env::args().skip(1); + let output = args.next().filter(|value| value != "-").map(PathBuf::from); + let mut requested: Vec = args.collect(); + let bing_only = requested.iter().any(|value| value == "--bing-only"); + requested.retain(|value| value != "--bing-only"); + let artists: Vec = if requested.is_empty() { + DEFAULT_ARTISTS + .iter() + .map(|name| name.to_string()) + .collect() + } else if requested.len() == 1 && requested[0] == "--extended" { + DEFAULT_ARTISTS + .iter() + .chain(EXTENDED_ARTISTS.iter()) + .map(|name| name.to_string()) + .collect() + } else { + requested + }; + + if let Some(output) = output.as_ref() { + if let Err(error) = std::fs::create_dir_all(output) { + eprintln!("could not create {}: {error}", output.display()); + std::process::exit(2); + } + } + + let client = MetadataClient::new().with_hero_diagnostics(); + for requested in artists { + // Bing-only diagnostics do not need a Deezer id. Keeping that path + // independent means catalog rate limits cannot turn a scraper probe + // into an immediate, misleading NOT_FOUND for every requested name. + let (artist_id, artist_name, fans) = if bing_only { + (None, requested, 0) + } else { + let search = client.catalog_search(&requested).await; + let Some(artist) = best_exact_artist(&search.artists, &requested) else { + println!("ARTIST\t{requested}\tNOT_FOUND"); + continue; + }; + ( + Some(artist.id), + artist.name.clone(), + artist.fans.unwrap_or(0), + ) + }; + println!( + "ARTIST\t{}\t{}\t{}", + artist_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "BING_ONLY".to_string()), + artist_name, + fans, + ); + let banners = if let Some(artist_id) = artist_id { + let Some(art) = client.catalog_artist_art(artist_id).await else { + println!("ERROR\t{}\tartist art unavailable", artist_name); + continue; + }; + art.banners + } else { + client.debug_bing_hero_banners(&artist_name).await + }; + if banners.is_empty() { + println!("BANNER\t{}\tNONE", artist_name); + continue; + } + for (index, url) in banners.iter().enumerate() { + println!("BANNER\t{}\t{}\t{}", artist_name, index + 1, url); + let Some(output) = output.as_ref() else { + continue; + }; + let Some(bytes) = client.fetch_image_bytes(url).await else { + println!( + "SAVE_ERROR\t{}\t{}\tdownload failed", + artist_name, + index + 1 + ); + continue; + }; + let extension = match image::guess_format(&bytes) { + Ok(image::ImageFormat::Png) => "png", + Ok(image::ImageFormat::WebP) => "webp", + _ => "jpg", + }; + let path = output.join(format!( + "{}-{}.{}", + file_stem(&artist_name), + index + 1, + extension + )); + match std::fs::write(&path, bytes) { + Ok(()) => println!("SAVED\t{}", path.display()), + Err(error) => println!("SAVE_ERROR\t{}\t{}", path.display(), error), + } + } + } +} + +fn best_exact_artist<'a>( + artists: &'a [CatalogArtist], + requested: &str, +) -> Option<&'a CatalogArtist> { + artists + .iter() + .filter(|artist| artist.name.trim().eq_ignore_ascii_case(requested.trim())) + .max_by_key(|artist| artist.fans.unwrap_or(0)) +} + +fn file_stem(name: &str) -> String { + name.to_lowercase() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character + } else { + '-' + } + }) + .collect::() + .split('-') + .filter(|part| !part.is_empty()) + .collect::>() + .join("-") +} diff --git a/crates/metadata/examples/lookup.rs b/crates/metadata/examples/lookup.rs new file mode 100644 index 0000000..5b6581e --- /dev/null +++ b/crates/metadata/examples/lookup.rs @@ -0,0 +1,33 @@ +//! Live check: MusicBrainz lookup + art fetch. +//! cargo run -p metadata --example lookup -- "Nirvana" "Nevermind" + +#[tokio::main] +async fn main() { + let artist = std::env::args().nth(1).unwrap_or_else(|| "Nirvana".into()); + let album = std::env::args() + .nth(2) + .unwrap_or_else(|| "Nevermind".into()); + + let client = metadata::MetadataClient::new(); + match client.lookup_album(&artist, &album).await { + Some(meta) => { + println!( + "MB: {} — {} ({:?}) mbid={:?}", + meta.artist, meta.album, meta.year, meta.release_mbid + ); + match client + .fetch_art( + meta.release_mbid.as_deref(), + meta.release_group_mbid.as_deref(), + &meta.artist, + &meta.album, + ) + .await + { + Some(bytes) => println!("ART: {} bytes (starts {:02x?})", bytes.len(), &bytes[..4]), + None => println!("ART: none found"), + } + } + None => println!("MB: no match"), + } +} diff --git a/crates/metadata/examples/mb_query_merge_probe.rs b/crates/metadata/examples/mb_query_merge_probe.rs new file mode 100644 index 0000000..f63e59c --- /dev/null +++ b/crates/metadata/examples/mb_query_merge_probe.rs @@ -0,0 +1,221 @@ +//! Go/no-go experiment for merging the identity pass's two release-group +//! queries into one (theory 5 of the 2026-07-22 load audit). +//! +//! Current scheme (2 queries): +//! STUDIO: arid:X AND primarytype:album AND -secondarytype:* AND status:official (1 page) +//! HINTS: arid:X AND primarytype:album AND secondarytype:* (≤5 pages) +//! +//! Candidate merged scheme (1 query): +//! ALL-OFFICIAL: arid:X AND primarytype:album AND status:official (≤5 pages) +//! → client-side split: no secondary types ⇒ studio, some ⇒ hint. +//! +//! The merge is adoptable ONLY if, for every probed artist, the split +//! reproduces BOTH current sets exactly. The known hazard: the hints query +//! has no status filter today, so status:official could drop bootleg-only +//! compilation groups from the hint set — a classification change. +//! +//! Usage: cargo run -p metadata --example mb_query_merge_probe -- "Artist" ... + +use std::collections::BTreeSet; +use std::time::Duration; + +const UA: &str = "SoulSeekPlus/0.1 (dev build)"; + +#[tokio::main] +async fn main() { + let artists: Vec = std::env::args().skip(1).collect(); + if artists.is_empty() { + eprintln!("usage: mb_query_merge_probe -- \"Artist\" ..."); + return; + } + let http = reqwest::Client::builder() + .user_agent(UA) + .timeout(Duration::from_secs(15)) + .build() + .expect("client"); + let meta = metadata::MetadataClient::new(); + + let mut all_ok = true; + for artist in &artists { + let search = meta.catalog_search(artist).await; + let Some(deezer_id) = search + .artists + .iter() + .find(|a| norm(&a.name) == norm(artist)) + .map(|a| a.id) + else { + println!("== {artist}: no exact Deezer artist, skipped =="); + continue; + }; + let Some(mbid) = linked_mbid(&http, deezer_id).await else { + println!("== {artist}: no linked MBID, skipped =="); + continue; + }; + + let studio_q = + format!("arid:{mbid} AND primarytype:album AND -secondarytype:* AND status:official"); + let hints_q = format!("arid:{mbid} AND primarytype:album AND secondarytype:*"); + let merged_q = format!("arid:{mbid} AND primarytype:album AND status:official"); + + // Current studio behavior is a SINGLE page. + let studio_now: BTreeSet = fetch_groups(&http, &studio_q, 1) + .await + .into_iter() + .filter(|g| g.secondary.is_empty()) + .map(|g| g.title) + .collect(); + let hints_now: BTreeSet = fetch_groups(&http, &hints_q, 5) + .await + .into_iter() + .map(|g| format!("{} :: {:?}", g.title, g.secondary)) + .collect(); + + let merged = fetch_groups(&http, &merged_q, 5).await; + let studio_merged: BTreeSet = merged + .iter() + .filter(|g| g.secondary.is_empty()) + .map(|g| g.title.clone()) + .collect(); + let hints_merged: BTreeSet = merged + .iter() + .filter(|g| !g.secondary.is_empty()) + .map(|g| format!("{} :: {:?}", g.title, g.secondary)) + .collect(); + + let studio_ok = studio_now == studio_merged; + let hints_ok = hints_now == hints_merged; + all_ok &= studio_ok && hints_ok; + println!( + "== {artist} == studio {}/{} {} hints {}/{} {}", + studio_now.len(), + studio_merged.len(), + if studio_ok { "IDENTICAL" } else { "DIFFERS" }, + hints_now.len(), + hints_merged.len(), + if hints_ok { "IDENTICAL" } else { "DIFFERS" }, + ); + if !studio_ok { + for extra in studio_merged.difference(&studio_now) { + println!(" studio only-in-merged: {extra}"); + } + for missing in studio_now.difference(&studio_merged) { + println!(" studio missing-from-merged: {missing}"); + } + } + if !hints_ok { + for extra in hints_merged.difference(&hints_now) { + println!(" hint only-in-merged: {extra}"); + } + for missing in hints_now.difference(&hints_merged) { + println!(" hint missing-from-merged: {missing}"); + } + } + } + println!( + "\nVERDICT: {}", + if all_ok { + "merged query reproduced both sets exactly for every probed artist" + } else { + "NOT identical — do not merge" + } + ); +} + +struct Group { + title: String, + secondary: Vec, +} + +async fn fetch_groups(http: &reqwest::Client, query: &str, max_pages: usize) -> Vec { + let mut out = Vec::new(); + for page in 0..max_pages { + throttle().await; + let url = format!( + "https://musicbrainz.org/ws/2/release-group/?query={}&limit=100&offset={}&fmt=json", + urlencoding(query), + page * 100 + ); + let Ok(resp) = http.get(&url).send().await else { + break; + }; + let Ok(body) = resp.json::().await else { + break; + }; + let Some(groups) = body.get("release-groups").and_then(|v| v.as_array()) else { + break; + }; + if groups.is_empty() { + break; + } + let page_len = groups.len(); + for g in groups { + let Some(title) = g.get("title").and_then(|v| v.as_str()) else { + continue; + }; + let secondary = g + .get("secondary-types") + .and_then(|v| v.as_array()) + .map(|a| { + let mut s: Vec = a + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + s.sort(); + s + }) + .unwrap_or_default(); + out.push(Group { + title: title.to_string(), + secondary, + }); + } + if page_len < 100 { + break; + } + } + out +} + +async fn linked_mbid(http: &reqwest::Client, deezer_id: u64) -> Option { + throttle().await; + let resource = urlencoding(&format!("https://www.deezer.com/artist/{deezer_id}")); + let url = + format!("https://musicbrainz.org/ws/2/url?resource={resource}&inc=artist-rels&fmt=json"); + let body = http + .get(&url) + .send() + .await + .ok()? + .json::() + .await + .ok()?; + body.get("relations")?.as_array()?.iter().find_map(|r| { + r.pointer("/artist/id") + .and_then(|v| v.as_str()) + .map(String::from) + }) +} + +async fn throttle() { + tokio::time::sleep(Duration::from_millis(1100)).await; +} + +fn urlencoding(s: &str) -> String { + let mut out = String::new(); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +fn norm(s: &str) -> String { + s.to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric()) + .collect() +} diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs new file mode 100644 index 0000000..50fc02c --- /dev/null +++ b/crates/metadata/src/lib.rs @@ -0,0 +1,10959 @@ +//! Album metadata + cover art: MusicBrainz (canonical names, release MBID) +//! and Cover Art Archive, with the iTunes Search API as art fallback. +//! MusicBrainz is rate-limited to 1 request/second per their policy. +//! Deezer's public API backs the catalog browser (search, artist pages, +//! tracklists) — free, keyless, and it exposes record types and previews. + +use futures::{ + future::BoxFuture, + stream::{self, FuturesUnordered, StreamExt}, + FutureExt, +}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{Mutex, OnceCell}; + +const USER_AGENT: &str = "SoulSeekPlus/0.1 (dev build)"; + +/// Per-request failure tracing for latency/reliability audits. Enabled with +/// `SOULSTREAM_NET_TRACE=1`; zero-cost when off. Failures are otherwise +/// swallowed into `None` by design, which makes a provider outage or rate +/// limit indistinguishable from "resource does not exist" when auditing. +fn net_trace(url: &str, msg: &str) { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + if *ON.get_or_init(|| std::env::var("SOULSTREAM_NET_TRACE").is_ok_and(|v| v == "1")) { + eprintln!("NET_TRACE\t{msg}\t{url}"); + } +} + +/// One disk-cache entry: `{at, url, body}`. The stored URL guards against a +/// (vanishingly unlikely) 64-bit filename hash collision serving the wrong +/// resource. +fn read_disk_cache(path: &std::path::Path, url: &str) -> Option { + let raw = std::fs::read(path).ok()?; + let entry: serde_json::Value = serde_json::from_slice(&raw).ok()?; + if entry.get("url").and_then(|v| v.as_str()) != Some(url) { + return None; + } + let at = entry.get("at").and_then(|v| v.as_u64())?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_secs(); + if now.saturating_sub(at) > DISK_CACHE_TTL.as_secs() { + return None; + } + entry.get("body").cloned() +} + +/// Remove cache files stale past twice the TTL (they can never be read +/// again — reads reject anything past one TTL). Runs once at client +/// construction; a directory scan of a few thousand small files is +/// milliseconds and keeps the cache from growing without bound. +fn prune_disk_cache(dir: &std::path::Path) { + let Some(cutoff) = std::time::SystemTime::now().checked_sub(DISK_CACHE_TTL * 2) else { + return; + }; + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + let stale = entry + .metadata() + .ok() + .and_then(|meta| meta.modified().ok()) + .is_some_and(|modified| modified < cutoff); + if stale { + let _ = std::fs::remove_file(&path); + } + } +} + +fn write_disk_cache(path: &std::path::Path, url: &str, body: &serde_json::Value) { + let Ok(now) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) else { + return; + }; + let entry = serde_json::json!({ "at": now.as_secs(), "url": url, "body": body }); + if let Ok(raw) = serde_json::to_vec(&entry) { + // Best-effort: a failed write only costs a refetch tomorrow. + let _ = std::fs::write(path, raw); + } +} + +/// Identity-anchored facts from an artist's MusicBrainz page: the links that +/// identify them elsewhere, plus the descriptive fields that can stand in for +/// a biography when no prose source has one. +#[derive(Default)] +struct ArtistIdentityFacts { + wikidata_id: Option, + wikipedia_title: Option, + discogs_id: Option, + disambiguation: Option, + kind: Option, + area: Option, + tags: Vec, +} + +impl ArtistIdentityFacts { + /// Last-resort biography built only from MusicBrainz's own fields. It + /// never asserts anything the entry does not say, which is why it is safe + /// where a fuzzy text match is not. + fn compose_bio(&self, artist: &str) -> Option { + let mut sentences = Vec::new(); + match (self.disambiguation.as_deref(), self.kind.as_deref()) { + // MusicBrainz's disambiguation is already a description + // ("David Ameal, techno / breakbeat producer"). + (Some(note), _) => sentences.push(format!("{artist}: {note}.")), + (None, Some(kind)) => { + let descriptor = if kind.eq_ignore_ascii_case("person") { + "a solo artist".to_string() + } else { + format!("a {}", kind.to_lowercase()) + }; + match self.area.as_deref() { + Some(area) => sentences.push(format!("{artist} is {descriptor} from {area}.")), + None => sentences.push(format!("{artist} is {descriptor}.")), + } + } + (None, None) => { + if let Some(area) = self.area.as_deref() { + sentences.push(format!("{artist} is from {area}.")); + } + } + } + if !self.tags.is_empty() { + sentences.push(format!("Associated genres: {}.", self.tags.join(", "))); + } + (!sentences.is_empty()).then(|| sentences.join(" ")) + } +} + +/// One canonical MusicBrainz artist URL, shared by the bio tiers and the +/// socials lookup so both ride a single cached response. +fn artist_detail_url(mbid: &str) -> String { + format!("https://musicbrainz.org/ws/2/artist/{mbid}?inc=url-rels+tags&fmt=json") +} + +/// The artist's most-voted genre tags, strongest first. +fn top_musicbrainz_tags(body: &serde_json::Value) -> Vec { + let Some(tags) = body.get("tags").and_then(|value| value.as_array()) else { + return Vec::new(); + }; + let mut ranked: Vec<(i64, String)> = tags + .iter() + .filter_map(|tag| { + let name = tag.get("name").and_then(|value| value.as_str())?; + let count = tag + .get("count") + .and_then(|value| value.as_i64()) + .unwrap_or(0); + (count >= 0).then(|| (count, name.to_string())) + }) + .collect(); + ranked.sort_by(|left, right| right.0.cmp(&left.0).then(left.1.cmp(&right.1))); + ranked.truncate(4); + ranked.into_iter().map(|(_, name)| name).collect() +} + +/// Discogs profiles carry the site's own markup — `[a=Artist]`, `[l=Label]`, +/// `[b]`, and `[url=…]text[/url]`. Render it as the plain prose the card wants. +fn strip_discogs_markup(profile: &str) -> String { + let mut out = String::with_capacity(profile.len()); + let mut rest = profile; + while let Some(open) = rest.find('[') { + out.push_str(&rest[..open]); + let Some(close) = rest[open..].find(']') else { + rest = &rest[open..]; + break; + }; + let tag = &rest[open + 1..open + close]; + // `[a=Name]` / `[l=Name]` / `[m=Name]` keep the name they wrap. + if let Some((prefix, value)) = tag.split_once('=') { + if matches!(prefix, "a" | "l" | "m" | "r") { + out.push_str(value); + } + } + rest = &rest[open + close + 1..]; + } + out.push_str(rest); + out.split_whitespace().collect::>().join(" ") +} + +/// Wikimedia hosts share one UA policy and one rate limiter, so they share +/// this client's compliant UA, pacer, 429 handling and disk cache. Wikidata is +/// included: it is the identity anchor that turns a MusicBrainz artist into +/// the RIGHT Wikipedia article. +fn is_wikimedia_url(url: &str) -> bool { + url.starts_with("https://en.wikipedia.org/") || url.starts_with("https://www.wikidata.org/") +} + +/// Per-stage latency tracing for the artist-page pipeline. Enabled with +/// `SOULSTREAM_STAGE_TRACE=1`; zero-cost when off. This is how load-time +/// audits attribute wall-clock to a stage instead of guessing. +fn stage_trace(stage: &str, started: Instant, detail: &str) { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + if *ON.get_or_init(|| std::env::var("SOULSTREAM_STAGE_TRACE").is_ok_and(|v| v == "1")) { + eprintln!( + "STAGE\t{stage}\t{}ms\t{detail}", + started.elapsed().as_millis() + ); + } +} + +/// Cached catalog responses expire after this long. +const CATALOG_TTL: Duration = Duration::from_secs(15 * 60); +/// Deezer publishes a budget of 50 requests per 5 seconds per IP and answers an +/// exhausted one with **HTTP 200** plus `{"error":{"code":4,...}}` — a body that +/// parses perfectly and contains no `data`. One large artist page issues more +/// than that on its own (profile + album page + top + up to 50 album details), +/// so the burst is paced here rather than left to fail. The budget keeps a small +/// margin under the published limit for requests already in flight. +const DEEZER_RATE_WINDOW: Duration = Duration::from_secs(5); +const DEEZER_RATE_BUDGET: usize = 45; +/// A quota answer is retried inside the fetch, so a rejected request never +/// reaches a caller as "this resource does not exist". +const DEEZER_QUOTA_ATTEMPTS: usize = 3; +const DEEZER_QUOTA_BACKOFF: Duration = Duration::from_millis(1600); +/// Wikimedia's UA policy requires a client that identifies itself with a +/// contact point; the shared default UA reads as an anonymous bot and gets a +/// savage limiter (measured 2026-07-22: ~13 requests then HTTP 429 with +/// `Retry-After: 51`, which silently zeroed every album description on a +/// cold artist page). A compliant UA sustained 45+ back-to-back requests +/// with no rejection. Applied to wikipedia.org requests only — other +/// providers (notably the Bing HTML scrape) must keep seeing the default UA. +const WIKIPEDIA_USER_AGENT: &str = + "SoulStream/0.1 (personal desktop music app; contact: https://github.com/KeelrLeek)"; +/// Client-side pacing for Wikipedia stays at the measured compliant-UA +/// budget (45 back-to-back requests accepted; a cold 14-album page needs +/// ~35-40) so one artist page clears in a single window while a runaway +/// burst still cannot provoke the limiter. +const WIKI_RATE_WINDOW: Duration = Duration::from_secs(10); +const WIKI_RATE_BUDGET: usize = 40; +/// A 429 is retried after honoring `Retry-After`; anything longer than the +/// fail-fast bound means the IP is in a penalty box and waiting inside one +/// album-info call would hold the expander open for nothing. +const WIKI_429_ATTEMPTS: usize = 3; +const WIKI_COOLDOWN_FAIL_FAST: Duration = Duration::from_secs(8); +const WIKI_COOLDOWN_DEFAULT: Duration = Duration::from_secs(5); +/// Slow-moving editorial providers (MusicBrainz, Wikipedia) are also cached +/// on DISK, so an app restart does not re-pay the MusicBrainz identity chain +/// (4-6 requests behind the mandatory 1 req/s gate — measured 3.7-8.5 s per +/// artist, 65-87% of a cold page's time-to-fully-sorted) or the description +/// harvest. Deezer/TheAudioDB stay memory-only: fan counts and fresh +/// releases should not be a day stale. +const DISK_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60); +/// Discogs allows 25 unauthenticated requests per minute per IP. Profiles are +/// slow-moving prose, so they are disk cached and this window is only a guard +/// against a burst (a library warm-up sweep) tripping the limiter. +const DISCOGS_RATE_WINDOW: Duration = Duration::from_secs(60); +const DISCOGS_RATE_BUDGET: usize = 18; +const IMAGE_CACHE_TTL: Duration = Duration::from_secs(15 * 60); +const IMAGE_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024; +/// Provider/search images are untrusted remote responses. Never allocate an +/// unbounded body merely because a search result claims to be a photograph. +const IMAGE_DOWNLOAD_MAX_BYTES: usize = 32 * 1024 * 1024; +const HERO_MAX_DECODE_DIMENSION: u32 = 12_000; +const HERO_MAX_DECODE_ALLOC_BYTES: u64 = 128 * 1024 * 1024; +const COVER_MAX_DECODE_DIMENSION: u32 = 8_000; +const COVER_MAX_DECODE_ALLOC_BYTES: u64 = 192 * 1024 * 1024; +const APPLE_EXACT_ARTIST_CANDIDATE_LIMIT: usize = 8; +// Cover verification is background polish, not a reason for four malicious or +// unusually large remote rasters to reserve up to 768 MiB at once. Network +// fetches still overlap four-wide in the UI; only CPU decode/hash work is +// globally capped at two. +const COVER_DECODE_CONCURRENCY: usize = 2; + +type CatalogFetch = Arc>>; +type ArtistArtFetch = Arc>; +type ArtistHeroSeedFetch = Arc>>; +type ImageFetch = Arc>>>>; +type HtmlFetch = Arc>>; +type HeroScoreFetch = Arc>>; +type AppleCandidatesFetch = Arc>>>; + +/// Shared Fanart.tv *project* API key. Not a secret — it identifies the app and +/// is meant to be embedded and shared across all users (same as TheAudioDB's +/// public `123` key used below). Rotate at fanart.tv if it is ever abused. +/// Unlocks 1920×1080 artist backgrounds, a 2.25× pixel step up from +/// TheAudioDB's 1280×720 fanart. +const FANART_KEY: &str = "1a2fb0b9fd7fa6fcf68388f78a9cdca6"; + +/// A resolved artist banner rarely changes and costs two external calls, so it +/// is cached far longer than ordinary catalog pages. +const BANNER_TTL: Duration = Duration::from_secs(24 * 60 * 60); +/// A transient provider outage should not immediately relaunch the complete +/// TADB → MusicBrainz → Fanart → Bing chain on every repeat navigation. Empty +/// art gets only a short reprieve; successful art keeps the full-day TTL. +const EMPTY_ART_TTL: Duration = Duration::from_secs(2 * 60); +/// Give purpose-built art databases a short head start before the provisional +/// stage spends a Bing request. The exhaustive fallback still uses the exact +/// provider priority and scoring rules below. +const HERO_SEED_BING_DELAY: Duration = Duration::from_millis(1500); + +#[derive(Debug, Clone)] +pub struct AlbumMeta { + pub artist: String, + pub album: String, + /// MusicBrainz release date, usually YYYY-MM-DD but sometimes year-only. + pub release_date: Option, + pub year: Option, + pub release_mbid: Option, + pub release_group_mbid: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CatalogArtist { + pub id: u64, + pub name: String, + pub picture: Option, + pub album_count: Option, + pub fans: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct CatalogAlbum { + pub id: u64, + pub title: String, + pub artist: String, + pub cover: Option, + pub release_date: Option, + /// "album" | "ep" | "single" | "compile" (absent in some search results) + pub record_type: Option, + pub track_count: Option, + /// Deezer has no release-group id. When an edition shares a strong ordered + /// core-track fingerprint with an original album, both receive the + /// original's id here so the UI can fold the musical work reliably instead + /// of guessing from display titles alone. + pub edition_group: Option, + /// Deezer does not expose a soundtrack record type. Album details do, + /// however, identify most film/game releases with its Films/Games genre. + pub is_soundtrack: bool, + /// Deezer has no live record type either, and live albums frequently omit + /// any "Live" hint from their title (Animal Collective's "Ballet Slippers", + /// "FEELSLive 04/05"). The tracklist is the reliable tell: when a clear + /// majority of a release's tracks are marked as live recordings, it is one. + /// Only set once album details (hence the tracklist) are known. + #[serde(default)] + pub is_live: bool, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct CatalogTrack { + pub id: u64, + pub title: String, + pub artist: String, + pub album: String, + pub album_id: Option, + pub cover: Option, + pub duration_secs: Option, + /// 30-second MP3 preview URL. + pub preview: Option, + /// Position within its disc (Deezer `track_position`). + pub track_no: Option, + /// Disc number for multi-disc releases (Deezer `disk_number`); `None` or 1 + /// for single-disc albums. + #[serde(default)] + pub disk_number: Option, + /// True only for a track on an as-yet-unreleased (future-dated) album that + /// is not itself already out as a single. The UI blocks streaming/saving + /// these; pre-released singles from an upcoming album stay `false` and + /// remain playable. Always `false` for normal, released albums. + #[serde(default)] + pub unreleased: bool, +} + +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct CatalogSearch { + pub artists: Vec, + pub albums: Vec, + pub tracks: Vec, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct CatalogArtistPage { + pub artist: CatalogArtist, + /// Artist hero from Fanart.tv, TheAudioDB, or the hardened Bing fallback. + pub banner: Option, + /// Every qualifying hero from the highest-priority provider that returned + /// artwork. The frontend starts at a random entry and rotates through this + /// list; lower-quality provider tiers are never mixed into a better tier. + pub banners: Vec, + /// Set by the Tauri layer when a user-imported per-artist hero overrides + /// provider artwork. Always false in provider-only metadata responses. + pub banner_custom: bool, + /// Fanart.tv and TheAudioDB logo candidates (transparent PNGs). + /// The UI scores them for "clean monochrome wordmark", tints the best to the + /// accent, else shows the most-liked in full colour, else plain text. + pub logos: Vec, + /// Deezer's curated top tracks, shown first on the artist profile. + pub popular_tracks: Vec, + pub albums: Vec, + /// Majority genre across the album details already fetched for the + /// edition/type analysis — zero extra API calls. `None` when no detail + /// carried a usable genre. + pub top_genre: Option, + /// Official links (homepage + socials) from MusicBrainz url-rels, anchored + /// to the Deezer-linked MBID so a namesake never shows another artist's + /// accounts. Ordered for display; at most one link per kind. + pub socials: Vec, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct ArtistLink { + /// One of: website, instagram, x, facebook, tiktok, youtube, bandcamp, + /// soundcloud — the frontend maps these to icons and brand tints. + pub kind: String, + pub url: String, +} + +/// Lightweight artist identity + artwork response used to paint the profile +/// while the deeper discography/edition analysis continues in parallel. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CatalogArtistArt { + pub artist: CatalogArtist, + pub banner: Option, + pub banners: Vec, + pub banner_custom: bool, + pub logos: Vec, +} + +/// First catalog-content stage for an artist page. One Deezer round trip yields +/// the identity, popular tracks and first 50 curated releases. Provider art, +/// exhaustive pagination, per-album classification and MusicBrainz enrichment +/// deliberately stay out of this response so the page can become useful while +/// the complete catalog continues in the background. +#[derive(Debug, Clone, serde::Serialize)] +pub struct CatalogArtistOverview { + pub artist: CatalogArtist, + pub popular_tracks: Vec, + pub albums: Vec, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct CatalogAlbumPage { + pub album: CatalogAlbum, + pub tracks: Vec, +} + +/// Editorial context for one release — a prose description plus genre/label +/// facts — sourced from TheAudioDB's album records first (real writing, plus +/// label/genre columns) and Wikipedia's page summary as the fallback. All +/// fields are best-effort; an empty struct means neither source knew the +/// record. +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct AlbumInfo { + pub description: Option, + pub genre: Option, + pub label: Option, + /// "theaudiodb" or "wikipedia" — which source supplied `description`. + pub source: Option, + /// Release-type evidence read from the description's LEAD sentence + /// ("…is a compilation album by The Killers…"): one of "compilation", + /// "live", "remix", "soundtrack", "ep", "album", "song". Prose that opens + /// by declaring the record's type is direct editorial evidence the title + /// heuristics cannot see — the page filing consumes it. + /// + /// "song" is the one hint that is not a release type: it reports that the + /// only editorial article for this title describes a TRACK. That makes it + /// evidence ABOUT the description rather than about the release — the + /// filing must not read the album named in such a lead as this release's + /// own kind. + pub type_hint: Option, +} + +/// Resolved hero artwork for an artist — the wide background banner and the +/// band's HD ClearLOGO — resolved together (one Fanart.tv call) and cached as +/// a unit. +#[derive(Debug, Clone, Default)] +struct ArtistArt { + banners: Vec, + logos: Vec, +} + +/// One Bing result card with its own metadata kept attached all the way through +/// scoring. This is intentionally not just a URL: losing the title/source page +/// made it possible for an unrelated image to borrow identity text from an +/// adjacent result card. +#[derive(Debug, Clone)] +struct BingImageCandidate { + url: String, + title: String, + page_url: String, + identity: BingIdentityConfidence, + /// False only for a product/release card retained as identity evidence. + /// It may corroborate an artist-named host for sibling press photography, + /// but is never downloaded, scored, or returned as a hero itself. + display_eligible: bool, + query_index: usize, + rank_in_query: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BingIdentityConfidence { + /// The image URL names the artist, both the result title and page URL name + /// the artist, or a trusted Ticketmaster/Live Nation artist profile owns it. + Strong, + /// Only one Bing metadata field names the artist. Retained solely as a + /// last-resort single-image fallback when no strongly anchored result exists. + Weak, +} + +/// Identity scope for a Bing fallback. A globally unambiguous display name can +/// use the existing per-card identity rules. So can a name whose requested +/// Deezer id demonstrably *owns* it: a web image search ranks by open-web +/// prominence, so when one exact-name artist dwarfs its namesakes the results +/// are about that artist and the per-card rules are the right filter. +/// +/// Only a genuinely contested name falls back to per-card catalogue anchoring, +/// where the shared name is not evidence and each surviving card must +/// independently mention a distinctive release from the Apple catalogue +/// selected by the requested Deezer id's weighted release evidence. That tier +/// is deliberately narrow: press photography rarely cites a release title, so +/// applying it to an uncontested name silently discards the best heroes. +#[derive(Debug, Clone, PartialEq, Eq)] +enum BingArtistIdentityPolicy { + Unambiguous, + NameOwner, + CatalogAnchored { release_titles: HashSet }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DeezerExactNameState { + Missing, + Unambiguous, + /// Exact-name namesakes exist, but the requested id decisively out-follows + /// all of them, so it is the subject of a name search. + Dominant, + Ambiguous, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedBingImage { + url: String, + title: String, + page_url: String, +} + +/// Pixel-derived quality evidence kept alongside a Bing result through final +/// ranking. The difference hash lets the carousel discard the same press photo +/// when Bing exposes several crops, sizes, or CDN copies of it. +#[derive(Debug, Clone)] +struct HeroVisualScore { + score: f64, + fingerprint: HeroFingerprint, +} + +/// Crop-aware perceptual evidence. Search engines frequently expose one press +/// photo through several publishers, aspect crops, and color treatments. +#[derive(Debug, Clone)] +struct HeroFingerprint { + /// Full-frame plus 18 overlapping crop hashes (85% and 70% at a 3x3 grid). + dhashes: Vec, + /// Coarse 8x8x8 RGB histogram over a fixed 64x64 sample. + color_histogram: [u16; 512], +} + +const HERO_EXACT_HASH_MAX_DISTANCE: u32 = 8; +const HERO_CROP_HASH_MAX_DISTANCE: u32 = 13; +const HERO_COLOR_INTERSECTION_MIN: u32 = 2_250; // about 55% of 64x64 pixels +const HERO_SCORE_CONCURRENCY: usize = 4; +/// Minimum width:height for a hero candidate. Album sleeves are exactly 1.0; +/// every press photo across the probe fixtures measures 1.33 or wider. +const HERO_MIN_ASPECT: f64 = 1.15; +const HERO_SEARCH_WAVE_CONCURRENCY: usize = 3; +const HERO_SCORE_CACHE_MAX_ENTRIES: usize = 128; +const HERO_SCORE_RECIPE: &str = "bing-identity-visual-v1"; + +/// A MusicBrainz studio album injected when Deezer's regional catalog omits the +/// release entirely. The synthetic numeric id keeps the existing frontend API +/// intact; this map routes an opened card back to its release group. +#[derive(Debug, Clone)] +struct MbCatalogAlbum { + release_group_id: String, + artist: String, + title: String, + release_date: Option, + cover: Option, +} + +/// The deferred MusicBrainz pass — see `catalog_artist_identity`. +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct CatalogArtistIdentity { + /// Echoes the requested Deezer identity so a late MusicBrainz response can + /// never be merged into a different artist after fast navigation. + pub artist_id: u64, + pub albums: Vec, + pub socials: Vec, + pub type_hints: Vec, +} + +fn empty_catalog_artist_identity(artist_id: u64) -> CatalogArtistIdentity { + CatalogArtistIdentity { + artist_id, + ..CatalogArtistIdentity::default() + } +} + +/// One MusicBrainz release-group classification hint. The frontend matches it +/// to a Deezer/Apple release by normalized title and files that release under +/// the tab MusicBrainz's authoritative primary/secondary types imply — the fix +/// for compilations, soundtracks and remix albums the providers hand back +/// tagged as a plain "album" (e.g. Radiohead's "KID A MNESIA", which MB knows +/// is a Compilation). Raw MB type strings are sent through so the release +/// taxonomy stays owned in one place, the frontend. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ReleaseTypeHint { + pub title: String, + pub primary_type: Option, + pub secondary_types: Vec, +} + +/// One release from the iTunes artist lookup. That single unthrottled request +/// carries the whole discography — title, date, track count and type — so it +/// serves two jobs: the cover resolver's artwork thumbnails, and the release +/// supplement that used to require rate-limited MusicBrainz calls. +#[derive(Debug, Clone)] +struct AppleRelease { + title: String, + title_norm: String, + artwork: String, + release_date: Option, + track_count: Option, + /// iTunes `collectionType`, e.g. "Album" / "Compilation". + collection_type: Option, + /// iTunes `collectionId` — the key for a follow-up song lookup that yields + /// the album's tracklist. `None` when the lookup entry omitted it. + collection_id: Option, +} + +/// One identity-checked Apple artist catalogue. Apple can return several +/// *exactly* same-named performers for one search (Crazy P currently has a UK +/// electronic act, a Latin act and a rapper). `releases` belongs to the single +/// candidate whose titles best corroborate the requested Deezer artist's own +/// release endpoint. `foreign_titles` contains titles exclusive to the losing +/// Apple candidates; those are strong, cheap evidence that a Deezer release was +/// fused into the wrong same-named artist id. +#[derive(Debug, Clone, Default)] +struct AppleArtistCatalog { + releases: Vec, + foreign_titles: HashSet, + /// Deezer genres corroborated only by losing Apple candidates. This removes + /// the rest of a fused namesake cluster even when Apple does not carry every + /// individual leaked title. Genres also matched by the winner are excluded, + /// so collaborations and broad shared styles remain untouched. + foreign_genres: HashSet, + /// Winning Apple performer and corroboration weight. The latter prevents a + /// first-50 overview race from overwriting a stronger full-discography + /// decision in the Deezer-id cache. + apple_artist_id: Option, + evidence_weight: u64, + evidence_count: usize, + /// Multiple exact performers existed but at least one lookup was missing + /// or intentionally capped, so even filtering Deezer's fused payload would + /// be guesswork. Overview/full return no stage and retry later. + identity_unavailable: bool, + /// False when several exact-name Apple candidates tied without any release + /// corroboration. In that case Apple must not supplement the page: choosing + /// the provider's first search hit would recreate the namesake bug. + identity_confirmed: bool, +} + +#[derive(Debug, Clone)] +struct AppleArtistCandidate { + artist_id: u64, + releases: Vec, + lookup_complete: bool, + cacheable: bool, +} + +/// A title from the requested Deezer id's own release endpoint. Deezer can +/// fuse namesakes under one numeric id, so the title alone is not enough: +/// listener/fan weight makes the established catalogue dominate stray uploads +/// while the base point still lets several low-traffic exact matches agree. +#[derive(Debug, Clone)] +struct DeezerReleaseEvidence { + title_norm: String, + weight: u64, + genre_id: Option, +} + +/// A supplemented Apple album, keyed by its synthetic catalog id so an opened +/// card can fetch its tracklist from iTunes. The Apple analogue of +/// [`MbCatalogAlbum`]: Apple releases carry large synthetic ids that Deezer's +/// `/album/{id}` endpoint cannot resolve, so `catalog_album` routes them here. +#[derive(Debug, Clone)] +struct AppleCatalogAlbum { + collection_id: u64, + artist: String, + title: String, + cover: Option, + release_date: Option, +} + +/// Up to `n` image URLs from a Fanart.tv array, most-liked first. +fn top_liked_urls(body: &serde_json::Value, key: &str, n: usize) -> Vec { + let Some(arr) = body.get(key).and_then(|v| v.as_array()) else { + return Vec::new(); + }; + let mut items: Vec<(i64, &str)> = arr + .iter() + .filter_map(|b| { + let url = b + .get("url") + .and_then(|u| u.as_str()) + .filter(|u| u.starts_with("http"))?; + let likes = b + .get("likes") + .and_then(|l| l.as_str()) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + Some((likes, url)) + }) + .collect(); + items.sort_by(|a, b| b.0.cmp(&a.0)); + items + .into_iter() + .take(n) + .map(|(_, u)| u.to_string()) + .collect() +} + +fn audiodb_banner_urls(artist: &serde_json::Value) -> Vec { + [ + "strArtistFanart", + "strArtistFanart2", + "strArtistFanart3", + "strArtistFanart4", + ] + .iter() + .filter_map(|key| { + artist + .get(*key) + .and_then(|value| value.as_str()) + .filter(|url| url.starts_with("https://")) + .map(String::from) + }) + .fold(Vec::new(), |mut urls, url| { + if !urls.contains(&url) { + urls.push(url); + } + urls + }) +} + +pub struct MetadataClient { + http: reqwest::Client, + /// Emit detailed Bing candidate/score lines for the live probe. Production + /// clients leave this false, so normal app logs remain quiet. + hero_diagnostics: bool, + /// Last MusicBrainz request time, for the 1 rps limit. + mb_gate: Mutex, + /// Start times of the Deezer requests inside the current rate window, for + /// the 50-per-5-seconds budget. + deezer_gate: Mutex>, + /// URL → (fetched-at, body). Catalog browsing re-requests the same + /// pages constantly; a short TTL cache keeps it snappy and polite. + catalog_cache: Mutex>, + /// Concurrent callers for the same cold URL share one network request. + /// The fast artwork response and full page intentionally overlap, so this + /// prevents their common profile/provider lookups from being duplicated. + catalog_inflight: Mutex>, + /// Artist name → (fetched-at, resolved art). Art resolution hits two + /// providers (TheAudioDB + Fanart.tv); this keeps repeat artist visits + /// instant and keeps us polite to both services. + art_cache: Mutex>, + art_inflight: Mutex>, + /// Purpose-built Fanart/TADB result shared by the fast logo-status command + /// and the final art command. Without this monotonic provider stage, two + /// independent timeout windows could report [] first and a curated logo + /// later, briefly exposing then replacing a generated cover logo. + provider_art_cache: Mutex>, + provider_art_inflight: Mutex>, + /// First trustworthy remote hero candidate. This is deliberately separate + /// from the final provider-tier result: the UI may paint its preview while + /// the complete quality-ranked set continues resolving. + hero_seed_cache: Mutex)>>, + hero_seed_inflight: Mutex>, + /// Bing result HTML is shared by the fast-seed and exhaustive passes. Raw + /// candidate images already use `image_cache`; without this companion cache + /// the two stages would still download the same search page twice. + html_cache: Mutex>, + html_inflight: Mutex>, + /// Candidate pixel scores are shared by the provisional and exhaustive Bing + /// passes. The key includes every metadata field used by visual rejection, + /// so a later stronger result card can never borrow a stale decision. + hero_score_cache: Mutex)>>, + hero_score_inflight: Mutex>, + /// Short-lived raw image cache shared by the HD preview, 4K finisher and + /// logo proxy. In particular, preview + 4K no longer download the same hero + /// twice at the same moment. + image_cache: Mutex>)>>, + image_inflight: Mutex>, + /// Synthetic catalog id → MusicBrainz release group for studio albums that + /// are unavailable in Deezer's current regional response. + mb_catalog: Mutex>, + /// Deezer identity (or the explicit name-only fallback used by standalone + /// cover resolution) → identity-checked Apple catalogue. Artist-page keys + /// are always `dz:{id}`; a shared display name can never reuse another + /// Deezer artist's chosen Apple performer. + apple_albums: Mutex>>, + /// Normalized name → every exact-name Apple candidate. Candidate discovery + /// is identity-neutral and therefore safe to share; the winning catalogue + /// is selected and cached separately under the Deezer id above. + apple_candidates: Mutex>>>, + /// Concurrent page/cover requests share the bounded Apple search + lookup + /// wave instead of stampeding the provider. + apple_candidates_inflight: Mutex>, + /// Synthetic catalog id → Apple album for supplemented releases missing from + /// Deezer's regional response, so an opened card can fetch its iTunes + /// tracklist. Mirrors [`Self::mb_catalog`]. + apple_catalog: Mutex>, + /// Sliding-window pacer for wikipedia.org, mirroring [`Self::deezer_gate`]. + wiki_gate: Mutex>, + /// Server-imposed cooldown: set from a 429's `Retry-After`, so concurrent + /// description lookups stop piling onto an already-penalized IP. + wiki_cooldown: Mutex>, + /// Sliding-window pacer for api.discogs.com (see [`DISCOGS_RATE_BUDGET`]). + discogs_gate: Mutex>, + /// On-disk JSON cache for slow-moving providers (see [`DISK_CACHE_TTL`]). + /// `None` (the default, and every test/example) keeps behavior purely + /// in-memory. + disk_cache_dir: Option, +} + +impl Default for MetadataClient { + fn default() -> Self { + Self::new() + } +} + +impl MetadataClient { + pub fn new() -> Self { + MetadataClient { + http: reqwest::Client::builder() + .user_agent(USER_AGENT) + .timeout(Duration::from_secs(15)) + .build() + .expect("build http client"), + hero_diagnostics: false, + mb_gate: Mutex::new(Instant::now() - Duration::from_secs(2)), + deezer_gate: Mutex::new(VecDeque::new()), + catalog_cache: Mutex::new(HashMap::new()), + catalog_inflight: Mutex::new(HashMap::new()), + art_cache: Mutex::new(HashMap::new()), + art_inflight: Mutex::new(HashMap::new()), + provider_art_cache: Mutex::new(HashMap::new()), + provider_art_inflight: Mutex::new(HashMap::new()), + hero_seed_cache: Mutex::new(HashMap::new()), + hero_seed_inflight: Mutex::new(HashMap::new()), + html_cache: Mutex::new(HashMap::new()), + html_inflight: Mutex::new(HashMap::new()), + hero_score_cache: Mutex::new(HashMap::new()), + hero_score_inflight: Mutex::new(HashMap::new()), + image_cache: Mutex::new(HashMap::new()), + image_inflight: Mutex::new(HashMap::new()), + mb_catalog: Mutex::new(HashMap::new()), + apple_albums: Mutex::new(HashMap::new()), + apple_candidates: Mutex::new(HashMap::new()), + apple_candidates_inflight: Mutex::new(HashMap::new()), + apple_catalog: Mutex::new(HashMap::new()), + wiki_gate: Mutex::new(VecDeque::new()), + wiki_cooldown: Mutex::new(None), + discogs_gate: Mutex::new(VecDeque::new()), + disk_cache_dir: None, + } + } + + /// Enable the on-disk cache for slow-moving providers (MusicBrainz, + /// Wikipedia). The directory is created if missing; failures degrade to + /// memory-only behavior. Long-stale entries are pruned at startup — + /// expired files are otherwise only ever touched if their exact URL is + /// refetched, so without this sweep a browsing-heavy user accumulates + /// orphaned per-album files indefinitely. + pub fn with_disk_cache(mut self, dir: std::path::PathBuf) -> Self { + if std::fs::create_dir_all(&dir).is_ok() { + prune_disk_cache(&dir); + self.disk_cache_dir = Some(dir); + } + self + } + + /// Disk-cache file for a URL, for qualifying (slow-moving) providers only. + fn disk_cache_path(&self, url: &str) -> Option { + let dir = self.disk_cache_dir.as_ref()?; + let qualifies = url.starts_with("https://musicbrainz.org/") + || is_wikimedia_url(url) + || url.starts_with("https://api.discogs.com/"); + if !qualifies { + return None; + } + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + url.hash(&mut hasher); + Some(dir.join(format!("{:016x}.json", hasher.finish()))) + } + + /// Enable verbose hero-candidate diagnostics. Intended for + /// `hero_source_probe`; it does not alter selection behavior. + pub fn with_hero_diagnostics(mut self) -> Self { + self.hero_diagnostics = true; + self + } + + /// Diagnostic entry point used by the live probe to exercise Bing even + /// when Fanart.tv or TheAudioDB would normally satisfy the artist first. + #[doc(hidden)] + pub async fn debug_bing_hero_banners(&self, artist: &str) -> Vec { + self.bing_hero_banners(artist, None, &BingArtistIdentityPolicy::Unambiguous) + .await + } + + /// GET a Deezer endpoint with a short-TTL cache. + async fn deezer(&self, url: &str) -> Option { + self.cached_json(url, false).await + } + + /// GET a MusicBrainz endpoint through the same cache/single-flight layer. + /// The one-request-per-second gate belongs inside the cache-miss + /// initializer: putting it before the cache lookup made two completely warm + /// artist-page reads sleep for roughly two seconds for no network traffic. + async fn musicbrainz_json(&self, url: &str) -> Option { + self.cached_json(url, true).await + } + + async fn cached_json( + &self, + url: &str, + throttle_musicbrainz: bool, + ) -> Option { + { + let cache = self.catalog_cache.lock().await; + if let Some((at, body)) = cache.get(url) { + if at.elapsed() < CATALOG_TTL { + return Some(body.clone()); + } + } + } + + let fetch = { + let mut inflight = self.catalog_inflight.lock().await; + inflight + .entry(url.to_string()) + .or_insert_with(|| Arc::new(OnceCell::new())) + .clone() + }; + let body = fetch + .get_or_init(|| async { + // Slow-moving providers read through the disk cache first, so + // a restart does not re-pay the MusicBrainz 1 req/s chain. + let disk = self.disk_cache_path(url); + if let Some(path) = disk.as_deref() { + if let Some(body) = read_disk_cache(path, url) { + return Some(body); + } + } + let deezer = url.starts_with("https://api.deezer.com/"); + let wiki = is_wikimedia_url(url); + let attempts = if wiki { + WIKI_429_ATTEMPTS + } else { + DEEZER_QUOTA_ATTEMPTS + }; + for attempt in 0..attempts { + if throttle_musicbrainz { + self.mb_throttle().await; + } + if deezer { + self.deezer_throttle().await; + } + if wiki && !self.wiki_throttle().await { + net_trace(url, "wiki-cooldown-fail-fast"); + return None; + } + // Ok(Some(body)) = usable answer; Ok(None) = final failure; + // Err(()) = a retryable Wikipedia 429 (cooldown recorded). + let body: Result, ()> = async { + let request = if wiki { + // Wikimedia's UA policy: identify the app, with a + // contact. The generic default UA is treated as an + // anonymous bot and rate limited into silence. + self.http + .get(url) + .header(reqwest::header::USER_AGENT, WIKIPEDIA_USER_AGENT) + } else { + self.http.get(url) + }; + let response = match request.send().await { + Ok(response) => response, + Err(err) => { + net_trace(url, &format!("send-error {err}")); + return Ok(None); + } + }; + let status = response.status(); + if !status.is_success() { + if wiki && status == reqwest::StatusCode::TOO_MANY_REQUESTS { + let retry_after = response + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()) + // Clamp before building the Instant: a + // hostile/broken header (u64::MAX) would + // overflow `Instant + Duration`, which + // PANICS. Anything above the fail-fast + // bound already means "give up", so an + // hour is more than enough. + .map(|secs| Duration::from_secs(secs.min(3600))) + .unwrap_or(WIKI_COOLDOWN_DEFAULT); + net_trace(url, &format!("wiki-429 retry-after={retry_after:?}")); + *self.wiki_cooldown.lock().await = + Some(Instant::now() + retry_after); + return Err(()); + } + net_trace(url, &format!("http-{status}")); + return Ok(None); + } + match response.json::().await { + Ok(body) => Ok(Some(body)), + Err(err) => { + net_trace(url, &format!("body-error {err}")); + Ok(None) + } + } + } + .await; + let body = match body { + Ok(body) => body, + // Retryable 429: the next loop pass waits out the + // recorded cooldown (or fails fast on a long ban). + Err(()) => continue, + }; + // A provider error is an answer about the *request*, never + // about the resource. Returning it as a body made a rejected + // read look like an empty one — and caching it made that + // verdict stick for the whole catalog TTL. + let Some(body) = body else { + return None; + }; + if body.get("error").is_none() { + if let Some(path) = disk.as_deref() { + write_disk_cache(path, url, &body); + } + return Some(body); + } + if !deezer_quota_error(&body) || attempt + 1 == attempts { + net_trace(url, "provider-error-body"); + return None; + } + net_trace(url, "deezer-quota-retry"); + tokio::time::sleep(DEEZER_QUOTA_BACKOFF).await; + } + None + }) + .await + .clone(); + + if let Some(body) = body.as_ref() { + let mut cache = self.catalog_cache.lock().await; + if cache.len() >= 300 { + cache.retain(|_, (at, _)| at.elapsed() < CATALOG_TTL); + if cache.len() >= 300 && !cache.contains_key(url) { + // A large artist can contribute ~70 detail entries. The old + // all-or-nothing clear created a cache cliff on roughly the + // fifth artist; evict just the oldest response instead. + let oldest = cache + .iter() + .min_by_key(|(_, (at, _))| *at) + .map(|(key, _)| key.clone()); + if let Some(oldest) = oldest { + cache.remove(&oldest); + } + } + } + cache.insert(url.to_string(), (Instant::now(), body.clone())); + } + + let mut inflight = self.catalog_inflight.lock().await; + if inflight + .get(url) + .is_some_and(|current| Arc::ptr_eq(current, &fetch)) + { + inflight.remove(url); + } + body + } + + /// Hero artwork for the artist — wide background banner + band ClearLOGO — + /// best quality first: + /// + /// 1. **Fanart.tv** `artistbackground` (1920×1080) + `hdmusiclogo` (HD + /// transparent PNG), keyed by MusicBrainz id — one call yields both. + /// 2. **TheAudioDB** 1280×720 artist fanart, restored as the broad-coverage + /// fallback and upscaled by the app's dedicated hero proxy. + /// 3. **Bing image search**, with Extra-Large + Wide filters, exact artist + /// identity gating, commerce/product rejection, and grid-rank decay. + /// + /// TheAudioDB is queried for its fanart/logo fields, but the MusicBrainz id + /// comes from the **Deezer-linked identity first** (`deezer_linked_mbid`); + /// TADB's name-matched id is only a fallback. TADB has no record at all for + /// many smaller artists — which used to lock Fanart.tv out entirely — and + /// a name match can be a namesake, which the linked id cannot. Everything is best-effort with + /// tight timeouts so a slow provider never holds up the artist page; if all + /// fail, the UI falls back to Deezer's square portrait (banner) and plain + /// text (logo). Results are cached (see [`BANNER_TTL`]). + async fn artist_hero_seed(&self, artist: &str, deezer_id: u64) -> Option { + // A completed final result is also the best possible seed. + let cache_key = format!("dz:{deezer_id}"); + if let Some((at, cached)) = self.art_cache.lock().await.get(&cache_key) { + let ttl = if cached.banners.is_empty() && cached.logos.is_empty() { + EMPTY_ART_TTL + } else { + BANNER_TTL + }; + if at.elapsed() < ttl { + return cached.banners.first().cloned(); + } + } + if let Some((at, cached)) = self.hero_seed_cache.lock().await.get(&cache_key) { + let ttl = if cached.is_some() { + BANNER_TTL + } else { + EMPTY_ART_TTL + }; + if at.elapsed() < ttl { + return cached.clone(); + } + } + + let fetch = { + let mut inflight = self.hero_seed_inflight.lock().await; + inflight + .entry(cache_key.clone()) + .or_insert_with(|| Arc::new(OnceCell::new())) + .clone() + }; + let resolved = fetch + .get_or_init(|| self.resolve_hero_seed(artist, deezer_id)) + .await + .clone(); + self.hero_seed_cache + .lock() + .await + .insert(cache_key.clone(), (Instant::now(), resolved.clone())); + let mut inflight = self.hero_seed_inflight.lock().await; + if inflight + .get(&cache_key) + .is_some_and(|current| Arc::ptr_eq(current, &fetch)) + { + inflight.remove(&cache_key); + } + resolved + } + + /// Return the first identity-safe source that becomes usable. This is only + /// a provisional display race: `resolve_art` still chooses the final tier + /// with Fanart → TheAudioDB → hardened Bing priority. + async fn resolve_hero_seed(&self, artist: &str, deezer_id: u64) -> Option { + let mut sources: FuturesUnordered>> = FuturesUnordered::new(); + sources.push( + async move { + let record = self.audiodb_artist(artist).await?; + // An exact display name is not an identity: TheAudioDB also + // has one record per namesake. Accept its fast banner/MBID only + // when the record itself links back to this Deezer id or to the + // MusicBrainz id that links back to it. + // When Deezer has an authoritative MusicBrainz relationship, + // it outranks every name-searched TADB field (including a stale + // strDeezerID). The lookup is shared with the Fanart branch, so + // this closes a namesake hole without another hot-path request. + let linked_mbid = self.deezer_linked_mbid(deezer_id).await; + if !audiodb_artist_matches_identity(&record, deezer_id, linked_mbid.as_deref()) { + return None; + } + if let Some(banner) = audiodb_banner_urls(&record).into_iter().next() { + return Some(banner); + } + // TADB occasionally has the MBID but no fanart of its own. + let mbid = record + .get("strMusicBrainzID") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty())?; + self.fanart_art(mbid).await.0.into_iter().next() + } + .boxed(), + ); + sources.push( + async move { + let mbid = self.deezer_linked_mbid(deezer_id).await?; + self.fanart_art(&mbid).await.0.into_iter().next() + } + .boxed(), + ); + sources.push( + async move { + tokio::time::sleep(HERO_SEED_BING_DELAY).await; + let policy = self.bing_fallback_policy(artist, deezer_id).await?; + self.bing_hero_seed(artist, deezer_id, &policy).await + } + .boxed(), + ); + + while let Some(source) = sources.next().await { + if source.is_some() { + return source; + } + } + None + } + + async fn artist_art(&self, artist: &str, deezer_id: u64) -> ArtistArt { + // Keyed by the Deezer id, not the display name: two artists can share a + // name, and the resolver below is anchored to this identity. + let cache_key = format!("dz:{deezer_id}"); + if let Some((at, cached)) = self.art_cache.lock().await.get(&cache_key) { + let ttl = if cached.banners.is_empty() && cached.logos.is_empty() { + EMPTY_ART_TTL + } else { + BANNER_TTL + }; + if at.elapsed() < ttl { + return cached.clone(); + } + } + + let fetch = { + let mut inflight = self.art_inflight.lock().await; + inflight + .entry(cache_key.clone()) + .or_insert_with(|| Arc::new(OnceCell::new())) + .clone() + }; + let resolved = fetch + .get_or_init(|| self.resolve_art(artist, deezer_id)) + .await + .clone(); + // Successful art is stable for a day. Empty results receive only the + // short TTL above: enough to prevent an immediate expensive retry loop, + // but not enough to hide a transient provider recovery for long. + self.art_cache + .lock() + .await + .insert(cache_key.clone(), (Instant::now(), resolved.clone())); + + let mut inflight = self.art_inflight.lock().await; + if inflight + .get(&cache_key) + .is_some_and(|current| Arc::ptr_eq(current, &fetch)) + { + inflight.remove(&cache_key); + } + resolved + } + + /// Definitive purpose-built provider stage shared by logo status and final + /// art. Positive results are stable for a day; an empty timeout result gets + /// only the short negative TTL, but every caller in one page generation + /// observes the exact same answer. + async fn provider_art(&self, artist: &str, deezer_id: u64) -> ArtistArt { + let cache_key = format!("dz:{deezer_id}"); + if let Some((at, cached)) = self.provider_art_cache.lock().await.get(&cache_key) { + let ttl = if cached.banners.is_empty() && cached.logos.is_empty() { + EMPTY_ART_TTL + } else { + BANNER_TTL + }; + if at.elapsed() < ttl { + return cached.clone(); + } + } + let fetch = { + let mut inflight = self.provider_art_inflight.lock().await; + inflight + .entry(cache_key.clone()) + .or_insert_with(|| Arc::new(OnceCell::new())) + .clone() + }; + let resolved = fetch + .get_or_init(|| self.resolve_provider_art(artist, deezer_id)) + .await + .clone(); + let mut cache = self.provider_art_cache.lock().await; + if cache.len() >= 512 && !cache.contains_key(&cache_key) { + cache.clear(); + } + cache.insert(cache_key.clone(), (Instant::now(), resolved.clone())); + drop(cache); + let mut inflight = self.provider_art_inflight.lock().await; + if inflight + .get(&cache_key) + .is_some_and(|current| Arc::ptr_eq(current, &fetch)) + { + inflight.remove(&cache_key); + } + resolved + } + + /// Purpose-built provider artwork only (Fanart.tv + TheAudioDB). This is + /// also the definitive, fast answer to "does this artist have a provider + /// logo?"; the slow Bing hero fallback is deliberately not part of it. + async fn resolve_provider_art(&self, artist: &str, deezer_id: u64) -> ArtistArt { + // Begin Fanart as soon as the identity-anchored Deezer→MusicBrainz link + // resolves instead of waiting for the unrelated four-second TADB + // timeout. TADB still supplies the fallback MBID only when no linked + // identity exists, exactly preserving the old safety/priority rule. + let linked_fanart = async { + match self.deezer_linked_mbid(deezer_id).await { + Some(mbid) => { + let art = self.fanart_art(&mbid).await; + (Some(mbid), art) + } + None => (None, (Vec::new(), Vec::new())), + } + }; + let (mut tadb, (linked_mbid, (mut banners, mut logos))) = + tokio::join!(self.audiodb_artist(artist), linked_fanart); + // A same-name TheAudioDB record may be a completely different act. + // Keep it only when one of its provider ids independently closes the + // loop back to the requested Deezer identity. In particular, never use + // an unlinked TADB logo as the provisional logo before Fanart's curated + // result lands. + if tadb.as_ref().is_some_and(|record| { + !audiodb_artist_matches_identity(record, deezer_id, linked_mbid.as_deref()) + }) { + tadb = None; + } + + // TheAudioDB's purpose-made fanart is normally 1280×720. It does not + // have Fanart.tv's resolution, but it is vastly better than a missing + // hero and usually reads as a deliberate band/artist photo rather than + // an encyclopedia performance shot. The frontend hero proxy raises it + // to a 1920px-wide render using high-quality local resampling. + if banners.is_empty() { + banners = tadb.as_ref().map(audiodb_banner_urls).unwrap_or_default(); + } + + // TheAudioDB also carries transparent artist logos. It was already + // queried for the MBID, so include this second provider at no extra + // network cost and let the UI's cleanliness/width rules rank it. + if let Some(logo) = tadb + .as_ref() + .and_then(|a| a.get("strArtistLogo")) + .and_then(|v| v.as_str()) + .filter(|url| url.starts_with("https://")) + { + if !logos.iter().any(|candidate| candidate == logo) { + logos.push(logo.to_string()); + } + } + + ArtistArt { banners, logos } + } + + async fn resolve_art(&self, artist: &str, deezer_id: u64) -> ArtistArt { + // Give purpose-built providers the same grace period as the progressive + // seed path, then overlap Bing discovery when they are still pending. + // Provider art always wins, so this changes latency rather than tier + // semantics; a fast Fanart/TADB success still launches no Bing traffic. + let provider = self.provider_art(artist, deezer_id); + tokio::pin!(provider); + let grace = tokio::time::sleep(HERO_SEED_BING_DELAY); + tokio::pin!(grace); + + tokio::select! { + mut art = &mut provider => { + if art.banners.is_empty() { + if let Some(policy) = self.bing_fallback_policy(artist, deezer_id).await { + art.banners = self + .bing_hero_banners(artist, Some(deezer_id), &policy) + .await; + } + } + art + } + _ = &mut grace => { + let bing = async { + let Some(policy) = self.bing_fallback_policy(artist, deezer_id).await else { + return Vec::new(); + }; + self.bing_hero_banners(artist, Some(deezer_id), &policy) + .await + }; + tokio::pin!(bing); + tokio::select! { + mut art = &mut provider => { + if art.banners.is_empty() { + art.banners = bing.await; + } + art + } + banners = &mut bing => { + let mut art = provider.await; + if art.banners.is_empty() { + art.banners = banners; + } + art + } + } + } + } + } + + /// Resolve how Bing cards may prove the requested artist identity. + /// + /// This is a question about the *artist*, not about each photograph: a Bing + /// image search returns whatever the open web means by the name, so the + /// only thing that has to be established is whether the requested Deezer id + /// is that artist. A unique exact name and a decisively dominant one both + /// answer yes, and then the established per-card rules (exact tokens, image + /// leaf names, co-bill/namesake blocks, visual gates) are the whole filter — + /// exactly the behaviour that produced the reviewed hero sets. + /// + /// Only a genuinely contested name needs more. There, weighted Deezer + /// release evidence selects one Apple catalogue and only Strong cards + /// mentioning a distinctive winner-only release are eligible. That per-card + /// tier must not leak upward: ordinary press photography cites no release + /// title, so it discards the best heroes for any artist it touches. + /// + /// Provider transport failures, an uncorroborated catalogue, and an empty + /// anchor set all fail closed, as do incomplete Apple lookups once the + /// catalogue actually has to decide the identity. Every network read uses + /// the existing cache/singleflight layers shared with the artist-page stages. + async fn bing_fallback_policy( + &self, + artist: &str, + deezer_id: u64, + ) -> Option { + let url = format!( + "https://api.deezer.com/search/artist?q={}&limit=25", + urlencode(artist) + ); + let (search, candidates) = + tokio::join!(self.deezer(&url), self.apple_artist_candidates(artist),); + let deezer_state = search + .as_ref() + .map(|body| deezer_exact_name_state(body, artist, deezer_id)) + .unwrap_or(DeezerExactNameState::Missing); + match deezer_state { + // No usable catalogue answer at all: the identity is unknown, not + // merely contested. + DeezerExactNameState::Missing => { + log::debug!( + "suppressing Bing hero for unavailable exact-name identity {deezer_id} ({artist})" + ); + return None; + } + // Sole bearer of the name. Apple namesakes are a discography + // concern and are filtered there; they cannot change who a name + // search on the open web is about. + DeezerExactNameState::Unambiguous => { + return Some(BingArtistIdentityPolicy::Unambiguous); + } + DeezerExactNameState::Dominant => { + return Some(BingArtistIdentityPolicy::NameOwner); + } + DeezerExactNameState::Ambiguous => {} + } + + if candidates + .iter() + .any(|candidate| !candidate.lookup_complete) + { + log::debug!( + "suppressing Bing hero for incomplete namesake catalogues {deezer_id} ({artist})" + ); + return None; + } + + let cache_key = format!("dz:{deezer_id}"); + let cached_catalog = self + .apple_albums + .lock() + .await + .get(&cache_key) + .filter(|catalog| catalog.identity_confirmed) + .cloned(); + let catalog = if let Some(catalog) = cached_catalog { + catalog + } else { + let albums_url = + format!("https://api.deezer.com/artist/{deezer_id}/albums?limit=50&index=0"); + let album_page = self.deezer(&albums_url).await?; + let evidence: Vec = album_page + .get("data")? + .as_array()? + .iter() + .filter_map(deezer_release_evidence) + .collect(); + self.apple_catalog_for_deezer_candidates( + deezer_id, + artist, + candidates.clone(), + &evidence, + ) + .await + }; + if !catalog.identity_confirmed || catalog.identity_unavailable { + log::debug!( + "suppressing uncorroborated ambiguous-name Bing hero {deezer_id} ({artist})" + ); + return None; + } + + let release_titles = bing_catalog_release_anchors(&candidates, &catalog, artist); + if release_titles.is_empty() { + log::debug!( + "suppressing ambiguous-name Bing hero without distinctive catalogue anchors {deezer_id} ({artist})" + ); + return None; + } + Some(BingArtistIdentityPolicy::CatalogAnchored { release_titles }) + } + + /// Whether description lookups may match by NAME for this artist. Broader + /// than requiring a strictly unambiguous name: a decisively dominant name + /// (NameOwner) qualifies too, because the description path carries its own content + /// corroboration — the fetched article must read like a release page AND + /// name the artist — which a blind editorial match lacks. Restricting this + /// to strictly unambiguous names was the regression that silently dropped + /// descriptions for every artist with a dead exact-name Deezer dupe row + /// (The Killers, King Gizzard). A genuinely contested name still fails + /// closed: two real artists sharing one name can also share an album + /// title, and no text check can split them. + async fn editorial_fallback_allowed(&self, artist: &str, deezer_id: u64) -> bool { + matches!( + self.bing_fallback_policy(artist, deezer_id).await, + Some(BingArtistIdentityPolicy::Unambiguous | BingArtistIdentityPolicy::NameOwner) + ) + } + + /// Linked member evidence is a fallback for cards that do not repeat an + /// album title (interviews and live photos usually name a frontperson + /// instead). It is fetched only for catalogue-anchored namesakes and is + /// overlapped with Bing discovery; normal unambiguous artists pay nothing. + async fn bing_linked_people( + &self, + artist: &str, + deezer_id: Option, + policy: &BingArtistIdentityPolicy, + ) -> HashSet { + if !matches!(policy, BingArtistIdentityPolicy::CatalogAnchored { .. }) { + return HashSet::new(); + } + let Some(deezer_id) = deezer_id else { + return HashSet::new(); + }; + tokio::time::timeout(Duration::from_secs(3), async { + let Some(mbid) = self.deezer_linked_mbid(deezer_id).await else { + return HashSet::new(); + }; + self.artist_identity_people(&mbid, artist).await + }) + .await + .unwrap_or_default() + } + + /// Bing-only long-tail hero tier. The first query mirrors the proven manual + /// Extra-Large + Wide `artist band photo` search; stricter portrait/live + /// intents add diversity when that recipe is sparse. Merchandise is + /// rejected before download and every remaining raster is visually scored. + /// Only runs after Fanart.tv and TheAudioDB return no hero. + async fn bing_hero_banners( + &self, + artist: &str, + deezer_id: Option, + policy: &BingArtistIdentityPolicy, + ) -> Vec { + let started = Instant::now(); + let discovery_started = Instant::now(); + let (discovery, linked_people) = tokio::join!( + tokio::time::timeout( + Duration::from_secs(20), + self.bing_image_candidates(artist, policy), + ), + self.bing_linked_people(artist, deezer_id, policy), + ); + let Ok(candidates) = discovery else { + if self.hero_diagnostics { + eprintln!( + "HERO_PERF\t{}\tdiscovery_timeout_ms={}", + artist, + started.elapsed().as_millis() + ); + } + return Vec::new(); + }; + let discovery_ms = discovery_started.elapsed().as_millis(); + let candidates = + filter_bing_candidates_for_policy(candidates, policy, artist, &linked_people); + let candidate_count = candidates.len(); + let scoring_started = Instant::now(); + let scored = self.score_bing_hero_candidates(candidates, artist).await; + let scoring_ms = scoring_started.elapsed().as_millis(); + let scored_count = scored.len(); + let (selected, duplicates) = select_scored_hero_urls(scored); + if self.hero_diagnostics { + for (kept, removed) in duplicates { + eprintln!("HERO_DEDUPE\t{}\t{}\t{}", artist, kept, removed); + } + eprintln!( + "HERO_PERF\t{}\tdiscovery_ms={}\tscoring_ms={}\ttotal_ms={}\tcandidates={}\tscored={}\tselected={}", + artist, + discovery_ms, + scoring_ms, + started.elapsed().as_millis(), + candidate_count, + scored_count, + selected.len(), + ); + } + selected + } + + /// One-wave Bing provisional. Only a fully scored Strong-identity result is + /// allowed to paint early; Weak candidates must wait for the exhaustive + /// pass because a later Strong result changes the final eligibility set. + async fn bing_hero_seed( + &self, + artist: &str, + deezer_id: u64, + policy: &BingArtistIdentityPolicy, + ) -> Option { + tokio::time::timeout(Duration::from_secs(6), async { + let (candidates, linked_people) = tokio::join!( + self.bing_image_candidates_limited(artist, policy, 1, 1, 3, 3), + self.bing_linked_people(artist, Some(deezer_id), policy), + ); + let candidates = + filter_bing_candidates_for_policy(candidates, policy, artist, &linked_people); + self.first_scored_strong_hero_seed(candidates, artist).await + }) + .await + .ok() + .flatten() + } + + /// A provisional hero needs one safe image, not a complete ranking. Race + /// only Strong-identity candidates through the unchanged pixel scorer and + /// return the first one that passes every quality gate. The exhaustive + /// search still scores/ranks the entire set for the final carousel, while + /// its shared image/score caches reuse any work completed here. + async fn first_scored_strong_hero_seed( + &self, + candidates: Vec, + artist: &str, + ) -> Option { + let mut scoring = stream::iter( + candidates + .into_iter() + .filter(|candidate| candidate.identity == BingIdentityConfidence::Strong) + .map(|candidate| async move { + self.hero_candidate_score(&candidate, artist) + .await + .map(|_| candidate.url) + }), + ) + .buffer_unordered(HERO_SCORE_CONCURRENCY); + while let Some(scored) = scoring.next().await { + if let Some(url) = scored { + if self.hero_diagnostics { + eprintln!("HERO_SEED\t{}\t{}", artist, url); + } + return Some(url); + } + } + None + } + + async fn score_bing_hero_candidates( + &self, + candidates: Vec, + artist: &str, + ) -> Vec<(String, f64, HeroFingerprint, BingIdentityConfidence)> { + stream::iter(candidates.into_iter().map(|candidate| async move { + let visual = self.hero_candidate_score(&candidate, artist).await?; + let base = visual.score; + // Relevance order is meaningful within a query, but the old global + // rank unfairly buried the first result from later portrait/DJ + // intents. Live-performance searches remain a lower-confidence + // visual source than a deliberate press/profile photograph. + let query_weight = match candidate.query_index { + 0 => 1.05, + 1 | 2 => 1.0, + 3 => 0.96, + _ => 0.70, + }; + let rank_weight = 0.88_f64.powi(candidate.rank_in_query as i32); + let identity_weight = match candidate.identity { + BingIdentityConfidence::Strong => 1.0, + BingIdentityConfidence::Weak => 0.55, + }; + let profile_weight = if is_ticketing_artist_profile_for_artist( + &candidate.page_url, + &candidate.url, + artist, + ) { + 1.15 + } else { + 1.0 + }; + let score = base * query_weight * rank_weight * identity_weight * profile_weight; + if self.hero_diagnostics { + eprintln!( + "HERO_RANK\t{}\t{:?}\tq{}\tr{}\tbase={:.2}\tfinal={:.2}\t{}\t{}\t{}", + artist, + candidate.identity, + candidate.query_index, + candidate.rank_in_query, + base, + score, + diagnostic_field(&candidate.title), + candidate.page_url, + candidate.url, + ); + } + Some((candidate.url, score, visual.fingerprint, candidate.identity)) + })) + .buffer_unordered(HERO_SCORE_CONCURRENCY) + .filter_map(async move |scored| scored) + .collect() + .await + } + + /// Bing image-search results as the broadest hero fallback (user + /// decision: a personal app, broader sourcing beats a blank hero; + /// DuckDuckGo proxies this same index but TLS-fingerprints clients, so + /// its endpoint is dead to plain HTTP). Bing's regular image-results page + /// serves HTML with entity-encoded result JSON. Guardrails: a + /// result only qualifies when its title or source page URL names the artist + /// as **exact word tokens** — so "Billy Cobb" can never match a "Billy + /// Cobham" photo. Obvious store/product results are rejected before their + /// oversized mockups can enter the quality race. Visual penalties catch + /// generic white-canvas product renders and likely text-overlay cards whose + /// page URL carries no useful hint. Unfetchable or junk URLs self-filter + /// afterwards: the scorer must download and decode every candidate before + /// it can win. + async fn bing_image_candidates( + &self, + artist: &str, + policy: &BingArtistIdentityPolicy, + ) -> Vec { + self.bing_image_candidates_limited(artist, policy, 5, 3, 12, 3) + .await + } + + async fn bing_image_candidates_limited( + &self, + artist: &str, + policy: &BingArtistIdentityPolicy, + query_limit: usize, + wave_limit: usize, + total_limit: usize, + per_query_limit: usize, + ) -> Vec { + // The lead wave deliberately does NOT add Bing's Photo-type classifier: + // on the verified Camping in Alaska search that classifier removed a + // genuine 2560x1920 Songbyrd press photo. Product/result guards below + // are more precise. Later generic intents keep Photo-only filtering. + const MANUAL_WAVES: [&str; 3] = [ + "+filterui:imagesize-wallpaper+filterui:aspect-wide", + "+filterui:imagesize-large+filterui:aspect-wide", + "+filterui:imagesize-large", + ]; + const PHOTO_WAVES: [&str; 3] = [ + "+filterui:imagesize-wallpaper+filterui:aspect-wide+filterui:photo-photo", + "+filterui:imagesize-large+filterui:photo-photo", + "+filterui:photo-photo", + ]; + // Keep the first query byte-for-byte equivalent to the successful + // manual search (not quoted, no negative terms). Result identity and + // product gates make it safe, while per-query caps prevent "band" from + // crowding out better solo/DJ portraits for non-band artists. + let queries = [ + format!("{artist} band photo"), + format!( + "\"{artist}\" musician portrait photo -album -cover -vinyl -poster -flyer -tickets" + ), + format!( + "\"{artist}\" DJ producer profile photo -album -cover -poster -flyer -tickets -event" + ), + format!( + "\"{artist}\" musician interview photo -album -cover -vinyl -poster -flyer" + ), + format!( + "\"{artist}\" live performance photo -album -cover -poster -flyer -tickets" + ), + ]; + let mut out: Vec = Vec::new(); + 'queries: for (query_index, query) in queries.into_iter().take(query_limit).enumerate() { + let waves = if query_index == 0 { + &MANUAL_WAVES + } else { + &PHOTO_WAVES + }; + let query_start = out.len(); + let q = urlencode(&query); + // Search waves are independent network reads. Start the bounded set + // together, but consume it in the original wave order so candidate + // membership, metadata upgrades, ranks and early-stop behavior stay + // identical to the sequential implementation. + let wave_urls = waves + .iter() + .take(wave_limit) + .map(|wave| { + format!( + "https://www.bing.com/images/search?q={q}&qft={}&form=HDRSC3&first=1&cc=us&setlang=en-us&tsc=ImageHoverTitle", + urlencode(wave) + ) + }) + .collect::>(); + let mut wave_pages = stream::iter(wave_urls) + .map(|url| async move { self.browser_html(&url).await }) + .buffered(HERO_SEARCH_WAVE_CONCURRENCY.min(wave_limit.max(1))); + while let Some(html) = wave_pages.next().await { + if out.len() >= total_limit { + break 'queries; + } + let Some(html) = html else { + continue; + }; + let parsed = parse_bing_image_results(&html); + if self.hero_diagnostics { + eprintln!( + "HERO_WAVE\t{}\tq{}\tparsed={}\t{}", + artist, + query_index, + parsed.len(), + query, + ); + } + for result in parsed { + // Bing occasionally publishes a legacy http image URL even + // when the same asset is available over TLS. Upgrade it so + // the Masquerade press image (and similar results) is not + // discarded or exposed as mixed content. + let murl = result + .url + .strip_prefix("http://") + .map(|rest| format!("https://{rest}")) + .unwrap_or(result.url); + let lower = murl.to_lowercase(); + if !murl.starts_with("https://") + || lower.ends_with(".gif") + || lower.ends_with(".svg") + { + continue; + } + let title = result.title; + let purl = result.page_url; + let Some(identity) = bing_identity_confidence(&title, &purl, &murl, artist) + else { + continue; + }; + // Result metadata can repeat the requested name while the + // actual image filename names a different person with the + // same surname. Bing did this for Billy Cobb with a + // `jimmy-cobb` photo under a misleading Billy Cobb title. + if image_path_conflicts_with_two_part_artist(&murl, artist) { + continue; + } + // Roundups can name the requested artist while displaying + // a different act's adjacent photo. When a clearly + // multi-artist headline is paired with a filename naming + // somebody else, wait for the artist-specific image. + if multi_artist_result_mismatches_image(&title, &murl, artist) { + continue; + } + // A winner-release card on an artist-named host may prove + // that host belongs to the selected namesake, while still + // being release artwork that must never display. Retain + // that narrow product shape as metadata-only evidence; + // every other unsuitable result is discarded immediately. + let product_result = is_product_image_result(&title, &purl, &murl); + if !product_result && is_unsuitable_bing_result(&title, &purl, &murl, artist) { + continue; + } + let rank_in_query = out.len() - query_start; + let candidate = BingImageCandidate { + url: murl, + title, + page_url: purl, + identity, + display_eligible: !product_result, + query_index, + rank_in_query, + }; + if product_result + && !bing_candidate_can_corroborate_artist_host(&candidate, policy, artist) + { + continue; + } + // Bing can repeat one murl under weaker and stronger cards. + // Keep one transfer, but upgrade its attached identity and + // source metadata rather than letting the first weak card + // permanently suppress a later artist-profile result. + if let Some(existing) = out + .iter_mut() + .find(|existing| existing.url == candidate.url) + { + if bing_candidate_metadata_is_better(&candidate, existing) { + if self.hero_diagnostics { + eprintln!( + "HERO_UPGRADE\t{}\t{:?}->{:?}\t{}", + artist, existing.identity, candidate.identity, candidate.url, + ); + } + // Upgrade identity/source metadata, but keep the + // best discovery position ever seen for this same + // raster. A later trusted card must not inherit a + // worse query/rank penalty merely because it was + // parsed later. + let best_position = (existing.query_index, existing.rank_in_query) + .min((candidate.query_index, candidate.rank_in_query)); + *existing = candidate; + existing.query_index = best_position.0; + existing.rank_in_query = best_position.1; + } + continue; + } + if self.hero_diagnostics { + eprintln!( + "HERO_ACCEPT\t{}\t{:?}\tq{}\tr{}\t{}\t{}\t{}", + artist, + identity, + query_index, + rank_in_query, + diagnostic_field(&candidate.title), + candidate.page_url, + candidate.url, + ); + } + out.push(candidate); + if out.len() >= total_limit || out.len() - query_start >= per_query_limit { + break; + } + } + if out.len() - query_start >= per_query_limit { + break; + } + } + } + // Strong-vs-Weak selection happens after download/decode/scoring. A + // broken metadata-Strong URL must not erase the only usable fallback. + out + } + + /// A page's HTML fetched with a browser-ish identity — several providers + /// serve a stub (or a wall) to unknown user agents. + async fn browser_html(&self, url: &str) -> Option { + { + let cache = self.html_cache.lock().await; + if let Some((at, html)) = cache.get(url) { + if at.elapsed() < CATALOG_TTL { + return Some(html.clone()); + } + } + } + + let fetch = { + let mut inflight = self.html_inflight.lock().await; + inflight + .entry(url.to_string()) + .or_insert_with(|| Arc::new(OnceCell::new())) + .clone() + }; + let html = fetch + .get_or_init(|| async { + let response = tokio::time::timeout( + Duration::from_secs(6), + self.http + .get(url) + .header( + "user-agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0", + ) + .header("accept-language", "en-US,en;q=0.9") + .header( + "accept", + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + ) + .header("cache-control", "no-cache") + .send(), + ) + .await + .ok()? + .ok()?; + tokio::time::timeout(Duration::from_secs(6), response.text()) + .await + .ok()? + .ok() + }) + .await + .clone(); + + if let Some(html) = html.as_ref() { + let mut cache = self.html_cache.lock().await; + cache.retain(|_, (at, _)| at.elapsed() < CATALOG_TTL); + if cache.len() >= 64 && !cache.contains_key(url) { + if let Some(oldest) = cache + .iter() + .min_by_key(|(_, (at, _))| *at) + .map(|(key, _)| key.clone()) + { + cache.remove(&oldest); + } + } + cache.insert(url.to_string(), (Instant::now(), html.clone())); + } + + let mut inflight = self.html_inflight.lock().await; + if inflight + .get(url) + .is_some_and(|current| Arc::ptr_eq(current, &fetch)) + { + inflight.remove(url); + } + html + } + + /// Download (into the shared image cache) and quality-score a candidate: + /// usable-wide-window area × sharpness. Sharpness is the Laplacian + /// variance of a small grayscale thumbnail, normalised so it deprioritises + /// genuinely blurry photos without letting sharp-edged text cards dominate + /// over larger real photos. Candidates additionally receive graded + /// penalties for near-white product canvases, high-contrast text-like + /// overlays, and video-thumbnail CDNs. These are priors rather than hard + /// rejects so a sparse artist can still receive a usable hero. + /// `None` = unusable (too small or undecodable). + async fn hero_candidate_score( + &self, + candidate: &BingImageCandidate, + artist: &str, + ) -> Option { + let cache_key = format!( + "{}\u{1f}{}\u{1f}{}:{}\u{1f}{}:{}\u{1f}{}:{}", + HERO_SCORE_RECIPE, + normalize(artist), + candidate.url.len(), + candidate.url, + candidate.title.len(), + candidate.title, + candidate.page_url.len(), + candidate.page_url, + ); + { + let cache = self.hero_score_cache.lock().await; + if let Some((at, score)) = cache.get(&cache_key) { + if at.elapsed() < IMAGE_CACHE_TTL { + return score.clone(); + } + } + } + + // Consult the longer-lived pixel-score cache before the raw-byte cache: + // after compressed bytes are evicted, a warm seed→full or revisit must + // not redownload and decode an image whose score is still valid. + // Transient network failures remain uncached so the exhaustive pass can + // retry when a provisional download lost a race with the CDN. + let bytes = self.get_image_shared(&candidate.url).await?; + + let fetch = { + let mut inflight = self.hero_score_inflight.lock().await; + inflight + .entry(cache_key.clone()) + .or_insert_with(|| Arc::new(OnceCell::new())) + .clone() + }; + let candidate = candidate.clone(); + let artist = artist.to_string(); + let diagnostics = self.hero_diagnostics; + let resolved = fetch + .get_or_init(|| async move { + tokio::task::spawn_blocking(move || { + score_bing_hero_image(bytes, &candidate, &artist, diagnostics) + }) + .await + .ok() + .flatten() + }) + .await + .clone(); + + { + let mut cache = self.hero_score_cache.lock().await; + cache.retain(|_, (at, _)| at.elapsed() < IMAGE_CACHE_TTL); + if cache.len() >= HERO_SCORE_CACHE_MAX_ENTRIES && !cache.contains_key(&cache_key) { + if let Some(oldest) = cache + .iter() + .min_by_key(|(_, (at, _))| *at) + .map(|(key, _)| key.clone()) + { + cache.remove(&oldest); + } + } + cache.insert(cache_key.clone(), (Instant::now(), resolved.clone())); + } + let mut inflight = self.hero_score_inflight.lock().await; + if inflight + .get(&cache_key) + .is_some_and(|current| Arc::ptr_eq(current, &fetch)) + { + inflight.remove(&cache_key); + } + resolved + } + + /// Editorial album context: TheAudioDB first (description + genre/label), + /// Wikipedia page summary as the description fallback. Best-effort with + /// per-source timeouts; results ride the shared JSON cache, so repeated + /// expander opens for the same album do no network work within the TTL. + pub async fn album_info(&self, artist: &str, title: &str) -> AlbumInfo { + self.album_info_for_artist_id(None, artist, title).await + } + + /// Identity-scoped expander metadata. This stays off the page hot path, but + /// lets the caller prevent a name-only TADB/Wikipedia result from crossing + /// between exact-name Deezer artists. + pub async fn album_info_for_artist_id( + &self, + artist_id: Option, + artist: &str, + title: &str, + ) -> AlbumInfo { + // A display title may carry an edition/variant bracket TheAudioDB does + // not use ("Weezer (Blue Album)" is catalogued as "Weezer"), so retry + // with the pre-bracket base when the verbose form finds nothing. + let base = title + .split(['(', '[']) + .next() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(title); + let required_tadb_artist_id = if let Some(artist_id) = artist_id { + // Independent identity reads; joining them saves a round trip on + // every cold call (MusicBrainz link + TheAudioDB record). + let (linked_mbid, record) = tokio::join!( + self.deezer_linked_mbid(artist_id), + self.audiodb_artist(artist), + ); + record + .filter(|record| { + audiodb_artist_matches_identity(record, artist_id, linked_mbid.as_deref()) + }) + .and_then(|record| { + record + .get("idArtist") + .and_then(|id| id.as_str()) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(String::from) + }) + } else { + None + }; + let allow_name_only = match artist_id { + None => true, + Some(artist_id) => { + required_tadb_artist_id.is_some() + || self.editorial_fallback_allowed(artist, artist_id).await + } + }; + // TheAudioDB may be consulted with a name match alone whenever the + // artist owns the name (same trust level the hero pipeline grants): + // its record must still carry the EXACT normalized artist name. The + // linked-identity requirement only stands for contested names. + let tadb_allowed = + artist_id.is_none() || required_tadb_artist_id.is_some() || allow_name_only; + net_trace( + title, + &format!( + "album-info-gate tadb_id={:?} name_only={allow_name_only}", + required_tadb_artist_id + ), + ); + // TheAudioDB (verbose title + pre-bracket base) and the Wikipedia + // full-title lookup are independent; the whole chain used to run + // serially, which made the worst case the SUM of every source's + // timeout. Result precedence is unchanged: TADB's record still wins, + // the verbose title still outranks the base title, and the base-title + // Wikipedia query still only runs after the full-title one misses. + let tadb = async { + if !tadb_allowed { + return None; + } + let (full, base_form) = tokio::join!( + self.audiodb_album_info(artist, title, required_tadb_artist_id.as_deref()), + async { + if base != title { + self.audiodb_album_info(artist, base, required_tadb_artist_id.as_deref()) + .await + } else { + None + } + }, + ); + full.or(base_form) + }; + // Search with the FULL display title first: for self-titled + // variants the bracket ("Weezer (Blue Album)") IS the identity and + // matches Wikipedia's own article naming; a bare base-title query + // ranks whichever self-titled record is currently most edited. + let wiki_full = async { + if !allow_name_only { + return None; + } + self.wikipedia_album_summary(artist, title).await + }; + // Overlap the two sources, but never make a TADB record that already + // carries a description WAIT for the speculative Wikipedia chain — + // that chain has no timeout of its own (it may politely sit out a + // 429 cooldown) and its result would be discarded anyway, while + // still spending Wikipedia pacer budget. Whichever source finishes + // first decides whether the other is still needed; precedence is + // unchanged (TADB's description outranks Wikipedia's). + tokio::pin!(tadb); + tokio::pin!(wiki_full); + let (info, mut desc) = tokio::select! { + info = &mut tadb => { + if info.as_ref().is_some_and(|found| found.description.is_some()) { + (info, None) + } else { + (info, wiki_full.await) + } + } + desc = &mut wiki_full => (tadb.await, desc), + }; + let mut info = info.unwrap_or_default(); + if info.description.is_none() && allow_name_only { + if desc.is_none() && base != title { + desc = self.wikipedia_album_summary(artist, base).await; + } + if let Some(desc) = desc { + info.description = Some(desc); + info.source = Some("wikipedia".to_string()); + } + } + info.type_hint = info + .description + .as_deref() + .and_then(description_type_hint) + .map(String::from); + info + } + + /// One TheAudioDB album record, identity-checked: the artist must match + /// exactly (normalized) and the album title loosely, otherwise the fuzzy + /// first hit could describe a different record entirely. + async fn audiodb_album_info( + &self, + artist: &str, + title: &str, + required_tadb_artist_id: Option<&str>, + ) -> Option { + let url = format!( + "https://www.theaudiodb.com/api/v1/json/123/searchalbum.php?s={}&a={}", + urlencode(artist), + urlencode(title) + ); + // Longer leash than the hot-path lookups: this only runs when the + // album expander opens and its result fades in asynchronously, while + // TheAudioDB is routinely slow during a page-load network burst. + let body = tokio::time::timeout(Duration::from_secs(8), self.deezer(&url)) + .await + .ok()??; + let albums = body.get("album")?.as_array()?; + let want_artist = normalize(artist); + let want_title = normalize(title); + let record = albums.iter().find(|a| { + let got_artist = a.get("strArtist").and_then(|v| v.as_str()).unwrap_or(""); + let got_title = a.get("strAlbum").and_then(|v| v.as_str()).unwrap_or(""); + normalize(got_artist) == want_artist + && loose_name_match(&normalize(got_title), &want_title) + && required_tadb_artist_id.is_none_or(|required| { + a.get("idArtist") + .and_then(|id| id.as_str()) + .is_some_and(|id| id.trim() == required) + }) + })?; + let text = |key: &str| { + record + .get(key) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }; + let description = text("strDescriptionEN"); + let info = AlbumInfo { + source: description.as_ref().map(|_| "theaudiodb".to_string()), + description, + genre: text("strGenre"), + label: text("strLabel"), + type_hint: None, + }; + (info.description.is_some() || info.genre.is_some() || info.label.is_some()).then_some(info) + } + + /// Wikipedia description fallback: search for the release's article, then + /// take the page-summary extract. Guarded so a wrong article can't slip + /// through: the summary must read like a release page and name the + /// artist. Two search shapes run in order — the album form, then a song + /// form — so singles and EPs get coverage too ("Mr. Brightside" has no + /// album article, but a rich song one). + async fn wikipedia_album_summary(&self, artist: &str, title: &str) -> Option { + for kind in ["album", "song"] { + if let Some(extract) = self.wikipedia_release_summary(artist, title, kind).await { + return Some(extract); + } + } + None + } + + async fn wikipedia_release_summary( + &self, + artist: &str, + title: &str, + kind: &str, + ) -> Option { + // Ask for more candidates than we expect to need: the artist's + // discography page and other albums often outrank the wanted one, and + // the title guard below rejects them, so the correct article must still + // be within the returned set to be found. + let search_url = format!( + "https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch={}&format=json&srlimit=8", + urlencode(&format!("{title} {artist} {kind}")) + ); + // No extra timeout wrapper here: the shared fetch layer owns Wikipedia + // pacing and 429 recovery (a 4s wrapper would kill the request while + // it politely waits out a `Retry-After`), and the HTTP client's own + // hard timeout still bounds a hung read. + let body = self.deezer(&search_url).await?; + let results = body.get("query")?.get("search")?.as_array()?; + let want_artist = normalize(artist); + let want_title = normalize(title); + // The article must actually be ABOUT the requested album — its title + // must contain the album's normalized title. Without this the search + // ("Butterfly 3000 album") happily returns the artist's + // *discography* page or another album ("Changes"), which mention the + // artist and the word "album" and so passed the old guard. + let candidates: Vec = results + .iter() + .filter_map(|result| result.get("title").and_then(|v| v.as_str())) + .filter(|page_title| normalize(page_title).contains(&want_title)) + .map(str::to_string) + .collect(); + // Judge candidates in the original search-rank order — the same + // article the serial loop chose — but prefetch a small look-ahead + // window so one slow summary no longer serializes the walk (the old + // worst case was 8 candidates × a full timeout, back to back). The + // buffer stays small on purpose: most albums accept the first + // candidate, and an eager fetch of all eight tripled the Wikipedia + // demand of a cold artist page, which the pacer then had to queue + // into the next window (measured: ~10s stalls under a 14-album + // burst). + let mut summaries = stream::iter(candidates.into_iter().map(|page_title| { + let summary_url = format!( + "https://en.wikipedia.org/api/rest_v1/page/summary/{}", + urlencode(&page_title.replace(' ', "_")) + ); + async move { (page_title, self.deezer(&summary_url).await) } + })) + .buffered(3); + while let Some((page_title, summary)) = summaries.next().await { + let Some(summary) = summary else { + continue; + }; + let Some(extract) = summary.get("extract").and_then(|v| v.as_str()) else { + continue; + }; + let extract = extract.trim(); + let mentions_artist = normalize(extract).contains(&want_artist) + || normalize(&page_title).contains(&want_artist); + // A release page's lead names its own kind: albums say "album", + // song/single articles say "song" or "single", EPs "extended + // play". Bare "EP" is not tested — two letters match inside + // ordinary words. + let lower = extract.to_lowercase(); + let reads_like_release = lower.contains("album") + || lower.contains(" song") + || lower.contains("single") + || lower.contains("extended play") + || lower.contains("soundtrack"); + if !extract.is_empty() && reads_like_release && mentions_artist { + return Some(extract.to_string()); + } + } + None + } + + /// TheAudioDB's first artist match (raw record), best-effort with timeouts. + async fn audiodb_artist(&self, artist: &str) -> Option { + let url = format!( + "https://www.theaudiodb.com/api/v1/json/123/search.php?s={}", + urlencode(artist) + ); + // Share the catalog cache: art and discography resolution ask for this + // exact identity record during the same artist-page load. + let body = tokio::time::timeout(Duration::from_secs(4), self.deezer(&url)) + .await + .ok()??; + select_audiodb_artist(&body, artist).cloned() + } + + /// Fanart.tv's most-liked 1920×1080 backgrounds and HD ClearLOGO candidates + /// for a MusicBrainz id — one call, both best-effort. Returns + /// `(banners, logos)`; either list is empty when the artist has none. + async fn fanart_art(&self, mbid: &str) -> (Vec, Vec) { + let url = format!("https://webservice.fanart.tv/v3/music/{mbid}?api_key={FANART_KEY}"); + let Some(body) = + tokio::time::timeout(Duration::from_secs(4), self.cached_json(&url, false)) + .await + .ok() + .flatten() + else { + return (Vec::new(), Vec::new()); + }; + // Prefer HD logos; fall back to standard-res if that's all there is. Up + // to 6 candidates so the UI can pick the cleanest to recolour. + let mut logos = top_liked_urls(&body, "hdmusiclogo", 6); + if logos.is_empty() { + logos = top_liked_urls(&body, "musiclogo", 6); + } + // Keep every useful candidate from this best-quality tier. Eight is + // enough variety without making a long-lived artist page accumulate an + // unbounded set of processed 4K images in the frontend cache. + (top_liked_urls(&body, "artistbackground", 8), logos) + } + + /// Catalog search: artists, albums and tracks for the query, in parallel. + pub async fn catalog_search(&self, query: &str) -> CatalogSearch { + let q = urlencode(query); + let artist_url = format!("https://api.deezer.com/search/artist?q={q}&limit=8"); + let album_url = format!("https://api.deezer.com/search/album?q={q}&limit=12"); + let track_url = format!("https://api.deezer.com/search/track?q={q}&limit=10"); + let (artists, albums, tracks) = tokio::join!( + self.deezer(&artist_url), + self.deezer(&album_url), + self.deezer(&track_url), + ); + // Deezer returns same-named artists in an order that can bury the + // canonical one (its "Pond" search lists a 38-fan namesake before the + // 26k-fan band). Rank by name relevance first, then followers, so the + // artist a search actually means becomes results.artists[0] — the top + // card and the target of name-based links. + let query_key = normalize(query); + let mut artist_list: Vec = artists + .as_ref() + .and_then(|b| b.get("data")) + .and_then(|d| d.as_array()) + .map(|a| a.iter().filter_map(parse_artist).collect()) + .unwrap_or_default(); + artist_list.sort_by(|a, b| { + artist_name_relevance(&b.name, &query_key) + .cmp(&artist_name_relevance(&a.name, &query_key)) + .then(b.fans.unwrap_or(0).cmp(&a.fans.unwrap_or(0))) + }); + CatalogSearch { + artists: artist_list, + albums: albums + .as_ref() + .and_then(|b| b.get("data")) + .and_then(|d| d.as_array()) + .map(|a| a.iter().filter_map(parse_album).collect()) + .unwrap_or_default(), + tracks: tracks + .as_ref() + .and_then(|b| b.get("data")) + .and_then(|d| d.as_array()) + .map(|a| a.iter().filter_map(parse_track).collect()) + .unwrap_or_default(), + } + } + + /// Artist profile plus their full discography. The artist-albums + /// endpoint only returns Deezer's curated "main" discography (e.g. 30 + /// entries for an artist with 60+ releases), so it's merged with a paged + /// album *search* by artist name, which surfaces the rest — each hit + /// verified against the artist id so tributes and namesakes stay out. + /// Related artists ("fans also like") for the Now Playing discovery panel. + pub async fn catalog_related(&self, artist_id: u64) -> Vec { + let Some(body) = self + .deezer(&format!( + "https://api.deezer.com/artist/{artist_id}/related?limit=12" + )) + .await + else { + return Vec::new(); + }; + body.get("data") + .and_then(|d| d.as_array()) + .map(|items| items.iter().filter_map(parse_artist).collect()) + .unwrap_or_default() + } + + /// Independent artist-art stage. This intentionally excludes albums and + /// popular tracks so a slow provider cannot hold back the identity shell or + /// usable catalog overview. `catalog_artist` reuses the populated provider + /// caches when it completes the full page response. + pub async fn catalog_artist_art(&self, artist_id: u64) -> Option { + let profile = self + .deezer(&format!("https://api.deezer.com/artist/{artist_id}")) + .await?; + let artist = parse_artist(&profile)?; + let art = self.artist_art(&artist.name, artist_id).await; + Some(CatalogArtistArt { + artist, + banner: art.banners.first().cloned(), + banners: art.banners, + banner_custom: false, + logos: art.logos, + }) + } + + /// Fast provider-logo status for the deferred cover-logo decision. It runs + /// the exact Fanart.tv/TheAudioDB identity path used by `artist_art`, but + /// never waits for the unrelated Bing hero fallback. + pub async fn catalog_artist_logos(&self, artist_id: u64) -> Option> { + let profile = self + .deezer(&format!("https://api.deezer.com/artist/{artist_id}")) + .await?; + let artist = parse_artist(&profile)?; + Some(self.provider_art(&artist.name, artist_id).await.logos) + } + + /// First identity-safe hero source for progressive display. It deliberately + /// returns only a URL: the caller already has a synchronous artist shell, + /// and the complete artwork command will supply the final provider tier, + /// carousel and logos independently. + pub async fn catalog_artist_hero_seed(&self, artist_id: u64) -> Option { + let profile = self + .deezer(&format!("https://api.deezer.com/artist/{artist_id}")) + .await?; + let artist = parse_artist(&profile)?; + self.artist_hero_seed(&artist.name, artist_id).await + } + + /// Fast, useful catalog stage. All three endpoints are independent once the + /// artist id is known, so a cold page pays one network round rather than + /// waiting for provider artwork or the exhaustive detail/classification + /// pass used by `catalog_artist`. + pub async fn catalog_artist_overview(&self, artist_id: u64) -> Option { + let overview_started = Instant::now(); + let profile_url = format!("https://api.deezer.com/artist/{artist_id}"); + let albums_url = + format!("https://api.deezer.com/artist/{artist_id}/albums?limit=50&index=0"); + let popular_url = format!("https://api.deezer.com/artist/{artist_id}/top?limit=10"); + let (profile, album_page, popular) = tokio::join!( + self.deezer(&profile_url), + self.deezer(&albums_url), + self.deezer(&popular_url), + ); + stage_trace("overview.deezer_join", overview_started, ""); + let artist = parse_artist(&profile?)?; + let album_page = album_page?; + let album_items = album_page.get("data")?.as_array()?; + let evidence: Vec = album_items + .iter() + .filter_map(deezer_release_evidence) + .collect(); + // Resolve every exact-name Apple performer, then use the requested + // Deezer id's weighted release evidence to identify the right one. + // This adds no MusicBrainz traffic and shares its Apple I/O with the + // full page when both frontend stages overlap. + let apple_started = Instant::now(); + let apple = self + .apple_catalog_for_deezer(artist_id, &artist.name, &evidence) + .await; + stage_trace("overview.apple", apple_started, ""); + stage_trace("overview.total", overview_started, ""); + if apple.identity_unavailable { + return None; + } + let mut foreign_album_ids = HashSet::new(); + let mut albums: Vec = album_items + .iter() + .filter_map(|item| { + let album = parse_album(item)?; + let genre_id = item.get("genre_id").and_then(|id| id.as_u64()); + if apple_release_is_foreign(&apple, &album.title, genre_id) { + foreign_album_ids.insert(album.id); + None + } else { + Some(album) + } + }) + .collect(); + for album in &mut albums { + if album.artist.is_empty() { + album.artist = artist.name.clone(); + } + } + let popular_tracks = popular + .as_ref() + .and_then(|body| body.get("data")) + .and_then(|data| data.as_array()) + .map(|tracks| { + tracks + .iter() + .filter(|track| track_belongs_to_artist(track, artist_id)) + .filter_map(parse_track) + .filter(|track| { + !track + .album_id + .is_some_and(|id| foreign_album_ids.contains(&id)) + && !apple_release_is_foreign(&apple, &track.album, None) + }) + .collect() + }) + .unwrap_or_default(); + Some(CatalogArtistOverview { + artist, + popular_tracks, + albums, + }) + } + + pub async fn catalog_artist(self: &Arc, artist_id: u64) -> Option { + let page_started = Instant::now(); + let profile = self + .deezer(&format!("https://api.deezer.com/artist/{artist_id}")) + .await?; + stage_trace("page.profile", page_started, ""); + let mut artist = parse_artist(&profile)?; + + // Provider artwork is an independent frontend stage. Do not make the + // exhaustive release list wait for its Bing hero fallback: that used to + // delay the final cover list (and therefore cover-logo generation) by + // 10–20 seconds even though the logo decision needs albums, not a hero. + // MusicBrainz is NOT on this path any more. Apple candidate discovery + // runs beside the Deezer chains below, then cheap weighted title + // corroboration selects the exact namesake tied to this Deezer id. The + // network overlap keeps the identity gate out of the critical path. + + // Curated albums and search-only extras are independent Deezer chains. + // Fetch them together, then replay search candidates through the same + // curated-first de-duplication below so ordering and quality stay exact. + let curated_fut = async { + let mut albums = Vec::new(); + let mut evidence = Vec::new(); + let first = self + .deezer(&format!( + "https://api.deezer.com/artist/{artist_id}/albums?limit=50&index=0" + )) + .await?; + // A first page without `data` is a failed read, not an empty + // catalog — same bail-out the serial walk had. + first.get("data").and_then(|d| d.as_array())?; + let mut pages = vec![first]; + // The first page carries `total`, so the remaining pages (same + // 4-page cap as the old serial walk) can be fetched together + // instead of one round trip per page. Appending in page order + // below keeps the album list byte-identical to the serial walk. + // A missing/odd `total` beside a `next` link must not truncate + // the walk to one page (the old serial walk would have kept + // following `next`): assume the cap and let empty pages + // contribute nothing. + let total = pages[0] + .get("total") + .and_then(|t| t.as_u64()) + .map(|t| t.min(200)) + .unwrap_or(200) as usize; + let extra_indices: Vec = (1..4).map(|p| p * 50).filter(|&i| i < total).collect(); + if pages[0].get("next").and_then(|n| n.as_str()).is_some() && !extra_indices.is_empty() + { + let extra = futures::future::join_all(extra_indices.into_iter().map(|index| { + let url = format!( + "https://api.deezer.com/artist/{artist_id}/albums?limit=50&index={index}" + ); + async move { self.deezer(&url).await } + })) + .await; + pages.extend(extra.into_iter().flatten()); + } + for page in &pages { + let Some(items) = page.get("data").and_then(|d| d.as_array()) else { + continue; + }; + albums.extend(items.iter().filter_map(|item| { + Some(( + parse_album(item)?, + item.get("genre_id").and_then(|id| id.as_u64()), + )) + })); + evidence.extend(items.iter().filter_map(deezer_release_evidence)); + } + Some::<(Vec<(CatalogAlbum, Option)>, Vec)>(( + albums, evidence, + )) + }; + let q = urlencode(&format!("artist:\"{}\"", artist.name)); + let search_fut = async { + let mut candidates = Vec::new(); + let mut index = 0; + for _ in 0..2 { + let Some(page) = self + .deezer(&format!( + "https://api.deezer.com/search/album?q={q}&limit=100&index={index}" + )) + .await + else { + break; // search is a bonus — curated results still render + }; + let Some(items) = page.get("data").and_then(|d| d.as_array()) else { + break; + }; + candidates.extend( + items + .iter() + .filter(|item| { + item.pointer("/artist/id").and_then(|v| v.as_u64()) == Some(artist_id) + }) + .filter_map(|item| { + Some(( + parse_album(item)?, + item.get("genre_id").and_then(|id| id.as_u64()), + )) + }), + ); + if page.get("next").and_then(|n| n.as_str()).is_none() || items.is_empty() { + break; + } + index += 100; + } + candidates + }; + let popular_url = format!("https://api.deezer.com/artist/{artist_id}/top?limit=10"); + let gather_started = Instant::now(); + let (curated, search_candidates, popular, apple_candidates) = tokio::join!( + curated_fut, + search_fut, + self.deezer(&popular_url), + self.apple_artist_candidates(&artist.name), + ); + stage_trace("page.gather_join", gather_started, ""); + let (curated, evidence) = curated?; + let apple_started = Instant::now(); + let apple = self + .apple_catalog_for_deezer_candidates( + artist_id, + &artist.name, + apple_candidates, + &evidence, + ) + .await; + stage_trace("page.apple_select", apple_started, ""); + if apple.identity_unavailable { + return None; + } + let mut foreign_album_ids = HashSet::new(); + let mut albums: Vec = curated + .into_iter() + .filter_map(|(album, genre_id)| { + if apple_release_is_foreign(&apple, &album.title, genre_id) { + foreign_album_ids.insert(album.id); + None + } else { + Some(album) + } + }) + .collect(); + // Artist-albums payloads omit the artist name; fill it in. + for a in &mut albums { + if a.artist.is_empty() { + a.artist = artist.name.clone(); + } + } + + // Search extras: same album under a different id (regional dupes) + // are dropped via a (title, record_type) key; curated entries win. + let mut seen_ids: std::collections::HashSet = albums.iter().map(|a| a.id).collect(); + let mut seen_titles: std::collections::HashSet<(String, String)> = albums + .iter() + .map(|a| { + ( + normalize(&a.title), + a.record_type.clone().unwrap_or_default(), + ) + }) + .collect(); + for (album, genre_id) in search_candidates { + if apple_release_is_foreign(&apple, &album.title, genre_id) { + foreign_album_ids.insert(album.id); + continue; + } + let key = ( + normalize(&album.title), + album.record_type.clone().unwrap_or_default(), + ); + if seen_ids.contains(&album.id) || seen_titles.contains(&key) { + continue; + } + seen_ids.insert(album.id); + seen_titles.insert(key); + albums.push(album); + } + + // Deezer sometimes lists one single twice, with and without its + // featured-artist credit ("Dancing In Babylon" / "Dancing In Babylon + // (feat. Christine and the Queens)", both 2024-02-20). Collect the + // collisions now so the details pass below fetches their track counts, + // then each same-day group folds to its fullest row after it. + let mut featless_groups: HashMap> = HashMap::new(); + for album in &albums { + if album.record_type.as_deref() != Some("single") { + continue; + } + featless_groups + .entry(featless_title_identity(&album.title)) + .or_default() + .push(album.id); + } + featless_groups.retain(|_, ids| ids.len() > 1); + // Exact-title duplicates among CURATED rows are a separate hole: the + // (title, record_type) key below only guards search extras, so Deezer + // carrying one release twice ("Changes (Remixes)", 2012-03-06 and + // 2012-03-11, 7 tracks each) rendered two cards. These fold by year — + // looser than the feat fold's same-day rule because an identical title + // AND type inside one year is the same work under regional ids. + let mut exact_groups: HashMap<(String, String), Vec> = HashMap::new(); + for album in &albums { + exact_groups + .entry(( + normalize(&album.title), + album.record_type.clone().unwrap_or_default(), + )) + .or_default() + .push(album.id); + } + exact_groups.retain(|_, ids| ids.len() > 1); + let feat_collision_ids: HashSet = featless_groups + .values() + .chain(exact_groups.values()) + .flatten() + .copied() + .collect(); + + // Normalized non-edition album titles, used to spot bespoke reissues + // (a release whose title is built on an existing album's, e.g. "OK + // Computer OKNOTOK 1997 2017") both for the details fetch below and the + // edition-folding loop further down. + let non_edition_titles: Vec = albums + .iter() + .filter(|a| a.record_type.as_deref() == Some("album") && !is_edition_title(&a.title)) + .map(|a| normalize(&a.title)) + .collect(); + // One capped, cached album-details pass fixes three gaps at once: + // search hits lack release_date, curated hits lack nb_tracks, and + // Deezer types old CD-singles (a title track plus B-side remixes) + // as "album" — the tracklist gives those away. Bespoke reissues are + // pulled in too so their tracklist can anchor the fold below. + // Albums and EPs first: the details cap below can starve a prolific + // artist's later rows, and dates/tracklists on full releases feed the + // edition folding — a single missing its B-side count costs far less + // than a base album missing the tracklist its remaster folds against. + let mut need: Vec<(bool, u64)> = albums + .iter() + .filter(|a| { + a.release_date.is_none() + || (a.track_count.is_none() && feat_collision_ids.contains(&a.id)) + || (a.record_type.as_deref() == Some("album") + && (a.track_count.is_none_or(|n| n <= 14) + || is_edition_title(&a.title) + || contains_other_album_title(&a.title, &non_edition_titles))) + }) + .map(|a| { + ( + !matches!(a.record_type.as_deref(), Some("album") | Some("ep")), + a.id, + ) + }) + .collect(); + need.sort_by_key(|(low_priority, _)| *low_priority); + let need: Vec = need.into_iter().map(|(_, id)| id).take(60).collect(); + let details_started = Instant::now(); + let detail_count = need.len(); + let fetched = stream::iter(need.iter().copied()) + .map(|id| { + let url = format!("https://api.deezer.com/album/{id}"); + async move { self.deezer(&url).await } + }) + // Keep the same polite cap, but refill each slot as soon as one + // request completes. Fixed 25-item waves made every later album + // wait for the single slowest response in the preceding wave. + .buffer_unordered(25) + .collect::>() + .await; + stage_trace( + "page.details", + details_started, + &format!("count={detail_count}"), + ); + let mut details: HashMap = HashMap::new(); + for d in fetched.into_iter().flatten() { + if let Some(id) = d.get("id").and_then(|v| v.as_u64()) { + details.insert(id, d); + } + } + for a in &mut albums { + let Some(d) = details.get(&a.id) else { + continue; + }; + if a.release_date.is_none() { + a.release_date = d + .get("release_date") + .and_then(|v| v.as_str()) + .map(String::from); + } + let nb = d + .get("nb_tracks") + .and_then(|v| v.as_u64()) + .map(|n| n as u32); + if a.track_count.is_none() { + a.track_count = nb; + } + a.is_soundtrack = detail_is_soundtrack(d); + // These albums arrived from search (no tracklist); the details pass + // is the first look at their tracks, so read live-ness here. Titles + // like "Ballet Slippers" give no hint — the tracks do. + a.is_live = detail_is_live(d); + // Album-typed release whose tracks are mostly the title track + // and remixes/versions of it → really a single. (Deezer's 2009 + // "The Girl and the Robot" has 13 tracks, all the title track, + // so the gate is generous — the majority rule below decides.) + if a.record_type.as_deref() == Some("album") && nb.unwrap_or(99) <= 14 { + let names: Vec<&str> = d + .pointer("/tracks/data") + .and_then(|t| t.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|t| t.get("title").and_then(|s| s.as_str())) + .collect() + }) + .unwrap_or_default(); + if !names.is_empty() { + let title_n = normalize(&a.title); + let hits = names + .iter() + .filter(|name| { + let ln = name.to_lowercase(); + (!title_n.is_empty() && normalize(name).contains(&title_n)) + || ln.contains("remix") + || ln.contains(" mix") + || ln.contains("mix)") + || ln.contains("version") + || ln.contains("radio edit") + || ln.contains("extended") + || ln.contains("instrumental") + }) + .count(); + if hits * 2 >= names.len() { + a.record_type = Some("single".into()); + } + } + } + } + + // Fold the duplicate groups detected above, now that the details pass + // has track counts for them. Exact-title duplicates and featured-credit + // variants ("Dancing In Babylon" beside "… (feat. Christine and the + // Queens)", "Naughty Girl" two weeks before its credited twin) both + // fold within a YEAR — the audited real-world pairs sit days to months + // apart, never across years. Each group keeps its fullest row, so the + // 4-track credited release survives its 1-track twin. A second exact + // pass then collapses cross-year re-releases under the strict + // same-material rules documented on `fold_exact_rereleases`. + let exact_groups: Vec> = exact_groups.into_values().collect(); + fold_duplicate_release_rows(&mut albums, &exact_groups, 4); + let featless_groups: Vec> = featless_groups.into_values().collect(); + fold_duplicate_release_rows(&mut albums, &featless_groups, 4); + fold_exact_rereleases(&mut albums, &exact_groups); + + // Majority genre over the details fetched above — the artist's "top + // genre" chip. "Films/Games" is a release property (soundtracks), not + // an artist genre, so it never represents the artist. + let mut genre_counts: HashMap = HashMap::new(); + for d in details.values() { + let Some(genres) = d.pointer("/genres/data").and_then(|g| g.as_array()) else { + continue; + }; + for name in genres + .iter() + .filter_map(|g| g.get("name").and_then(|n| n.as_str())) + { + if name.is_empty() || name.eq_ignore_ascii_case("films/games") { + continue; + } + *genre_counts.entry(name.to_string()).or_default() += 1; + } + } + let top_genre = genre_counts + .into_iter() + .max_by_key(|(_, n)| *n) + .map(|(name, _)| name); + + // Titles such as "Weezer (Deluxe Edition)" do not identify which of + // several self-titled records they belong to. Deezer album details do + // contain enough evidence: a real edition preserves the original core + // track order. Attach a conservative work hint only when at least five + // ordered tracks and 75% of the original's first ten tracks agree. + let tracklists: HashMap> = details + .iter() + .filter_map(|(id, detail)| { + let tracks = detail + .pointer("/tracks/data")? + .as_array()? + .iter() + .filter_map(|track| track.get("title").and_then(|title| title.as_str())) + .map(track_identity) + .collect::>(); + (!tracks.is_empty()).then_some((*id, tracks)) + }) + .collect(); + let mut edition_links = Vec::new(); + for (edition_index, edition) in albums.iter().enumerate() { + if edition.record_type.as_deref() != Some("album") { + continue; + } + // A keyword edition ("… Deluxe/Remaster/Anniversary") folds on track + // overlap alone; a bespoke reissue (no keyword, but its title is + // built on an existing album's) additionally requires that original's + // title to be a substring of it, so the strong "named after it" + // signal is tied to the actual pair. + let is_keyword_edition = is_edition_title(&edition.title); + let is_bespoke_reissue = !is_keyword_edition + && contains_other_album_title(&edition.title, &non_edition_titles); + if !is_keyword_edition && !is_bespoke_reissue { + continue; + } + let Some(edition_tracks) = tracklists.get(&edition.id) else { + continue; + }; + let edition_norm = normalize(&edition.title); + if let Some((original_index, original_id)) = best_edition_original( + edition_index, + &edition_norm, + edition_tracks, + &albums, + &tracklists, + is_bespoke_reissue, + ) { + edition_links.push((edition_index, original_index, original_id)); + } + } + for (edition_index, original_index, original_id) in edition_links { + albums[edition_index].edition_group = Some(original_id); + albums[original_index].edition_group = Some(original_id); + } + + // Supplement only from the exact Apple performer corroborated against + // THIS Deezer id. Ambiguous same-name searches fail closed: Deezer's + // filtered catalogue still renders, but no guessed Apple rows enter it. + let mb_studio = self + .apple_supplement_albums(artist_id, &artist.name, &apple) + .await; + let socials: Vec = Vec::new(); + // Apple is a GAP FILLER, not a second catalogue. Anything Deezer + // already lists — under any record type — is dropped before the merge, + // because Apple's edition titles ("Hurley (Deluxe Edition)") and + // format tails would otherwise sail past the exact-title matcher below + // and duplicate rows that are already on the page. + let existing_titles: std::collections::HashSet = + albums.iter().map(|a| normalize(&a.title)).collect(); + let existing_releases: Vec<(String, Option)> = albums + .iter() + .map(|a| (normalize(&a.title), a.release_date.clone())) + .collect(); + let mb_studio: Vec = mb_studio + .into_iter() + .filter(|candidate| { + let n = normalize(&candidate.title); + !existing_titles.contains(&n) + // "X (Deluxe Edition)" when plain "X" is already present. + && !(is_edition_title(&candidate.title) + && existing_titles.iter().any(|t| n.starts_with(t.as_str()))) + // Same release day, and one title is the other plus a tail + // ("Late Night Tales" / "Late Night Tales: Mgmt") — the + // same work under a storefront naming variant, not a gap. + // Length-guarded so short titles can't collide by accident. + && !existing_releases.iter().any(|(t, date)| { + let (short, long) = if t.len() <= n.len() { + (t.as_str(), n.as_str()) + } else { + (n.as_str(), t.as_str()) + }; + short.len() >= 8 + && long.starts_with(short) + && same_original_date( + date.as_deref(), + candidate.release_date.as_deref(), + ) + }) + }) + .collect(); + + // Deezer remains the preferred catalog record (cover, track counts and + // previews). The supplement fills only titles absent in this region and + // corrects obvious type/date errors on exact-title matches. + let mb_title_counts = mb_studio.iter().fold(HashMap::new(), |mut counts, album| { + *counts.entry(normalize(&album.title)).or_insert(0_usize) += 1; + counts + }); + for fallback in mb_studio { + let fallback_title = normalize(&fallback.title); + let fallback_title_is_unique = mb_title_counts + .get(&fallback_title) + .copied() + .unwrap_or_default() + == 1; + let exact: Vec = albums + .iter() + .enumerate() + // MusicBrainz supplied a studio album, so only album records + // can satisfy this match. A same-title single must not make an + // otherwise unique album look ambiguous (for example, Phantom + // Island has both a 2024 single and a 2025 album). + .filter(|(_, album)| exact_studio_album_title(album, &fallback_title)) + .map(|(index, _)| index) + .collect(); + let matching_date = exact.iter().copied().find(|index| { + same_original_date( + albums[*index].release_date.as_deref(), + fallback.release_date.as_deref(), + ) + }); + // A single current match is not enough when MusicBrainz itself has + // multiple distinct works with this title. Otherwise whichever + // self-titled release happens to arrive first absorbs all others. + let unique_exact = (exact.len() == 1 && fallback_title_is_unique).then(|| exact[0]); + let self_titled_alias = (fallback_title == normalize(&artist.name)) + .then(|| { + albums.iter().position(|album| { + is_self_titled_album_alias(&album.title, &artist.name) + && same_original_date( + album.release_date.as_deref(), + fallback.release_date.as_deref(), + ) + }) + }) + .flatten(); + if let Some(index) = matching_date.or(unique_exact).or(self_titled_alias) { + let existing = &mut albums[index]; + if existing.record_type.as_deref() == Some("single") { + existing.record_type = Some("album".into()); + } + if fallback.release_date.is_some() + && (existing.release_date.is_none() + || fallback.release_date < existing.release_date) + { + existing.release_date = fallback.release_date; + } + } else { + albums.push(fallback); + } + } + + // Second edition pass — fold Apple-supplemented editions (e.g. "Weezer + // (2024 Remaster)") under their Deezer original. Apple albums carry no + // Deezer tracklist, so the first pass could not score them; fetch their + // iTunes tracklists now and match against the SAME Deezer `tracklists` + // originals. Scoring against the Deezer-only map keeps this Apple→Deezer + // (an Apple album never folds into another Apple album). + let apple_candidates: Vec<(usize, u64, String, bool)> = { + let registry = self.apple_catalog.lock().await; + albums + .iter() + .enumerate() + .filter_map(|(index, a)| { + if a.edition_group.is_some() || a.record_type.as_deref() != Some("album") { + return None; + } + let entry = registry.get(&a.id)?; + let is_keyword_edition = is_edition_title(&a.title); + let is_bespoke_reissue = !is_keyword_edition + && contains_other_album_title(&a.title, &non_edition_titles); + if !is_keyword_edition && !is_bespoke_reissue { + return None; + } + Some(( + index, + entry.collection_id, + normalize(&a.title), + is_bespoke_reissue, + )) + }) + .collect() + }; + if !apple_candidates.is_empty() { + let scored = stream::iter(apple_candidates) + .map(|(index, collection_id, norm, bespoke)| async move { + ( + index, + self.apple_album_track_titles(collection_id).await, + norm, + bespoke, + ) + }) + .buffer_unordered(8) + .collect::>() + .await; + let mut apple_links = Vec::new(); + let mut redundant_ids = Vec::new(); + for (index, tracks, norm, bespoke) in &scored { + let Some((original_index, original_id)) = + best_edition_original(*index, norm, tracks, &albums, &tracklists, *bespoke) + else { + continue; + }; + // A pure remaster/reissue that duplicates the matched original's + // tracklist adds nothing Deezer doesn't already carry — the Apple + // supplement is a gap filler, so drop it instead of showing a + // redundant edition ("Weezer (2024 Remaster)" == the Blue Album). + if tracklists + .get(&original_id) + .is_some_and(|original_tracks| is_duplicate_tracklist(tracks, original_tracks)) + { + redundant_ids.push(albums[*index].id); + } else { + apple_links.push((*index, original_index, original_id)); + } + } + for (edition_index, original_index, original_id) in apple_links { + albums[edition_index].edition_group = Some(original_id); + albums[original_index].edition_group = Some(original_id); + } + if !redundant_ids.is_empty() { + albums.retain(|a| !redundant_ids.contains(&a.id)); + } + } + + // Same-work VARIANT LINKING, across every source. A release whose + // title is an existing album's title plus a variant tail — "Revolver + // (2022 Mix)", "THE TORTURED POETS DEPARTMENT: THE ANTHOLOGY", "The + // Life of a Showgirl + Acoustic Collection" — is the same work in + // different clothes. Tracklist folding catches these only when both + // tracklists were fetched; this title/date rule needs neither. The + // rows are LINKED as one edition family, never dropped, so every + // variant stays reachable behind one card. Guards, audited against 44 + // real catalogues: the base title must be ≥8 normalized chars; a + // sequel/volume tail never links ("Greatest Hits II", "Fleetwood Mac: + // Live 1975"); and the pair must share a release day, or sit within + // ~45 days with a tail from the known variant vocabulary — "Greatest + // Hits In Japan" (years apart, no variant word) stays its own card. + { + let mut variant_links: Vec<(usize, usize, u64)> = Vec::new(); + for (variant_index, variant) in albums.iter().enumerate() { + if variant.edition_group.is_some() + || variant.record_type.as_deref() != Some("album") + { + continue; + } + let variant_norm = normalize(&variant.title); + let base = albums + .iter() + .enumerate() + .filter(|(base_index, base)| { + if *base_index == variant_index + || base.record_type.as_deref() != Some("album") + { + return false; + } + let base_norm = normalize(&base.title); + if base_norm.len() < 8 + || base_norm.len() >= variant_norm.len() + || !variant_norm.starts_with(base_norm.as_str()) + { + return false; + } + let residual = &variant_norm[base_norm.len()..]; + if residual_is_sequel_marker(residual) { + return false; + } + if same_original_date( + base.release_date.as_deref(), + variant.release_date.as_deref(), + ) { + return true; + } + let close = match ( + base.release_date.as_deref().and_then(release_date_days), + variant.release_date.as_deref().and_then(release_date_days), + ) { + (Some(a), Some(b)) => (a - b).abs() <= 45, + _ => false, + }; + close && residual_is_release_variant(residual) + }) + // The LONGEST matching base is the nearest work: "1989 + // (Taylor's Version) (Deluxe)" belongs to "1989 (Taylor's + // Version)", not to "1989". + .max_by_key(|(_, base)| normalize(&base.title).len()); + if let Some((base_index, base)) = base { + variant_links.push((variant_index, base_index, base.id)); + } + } + for (variant_index, base_index, base_id) in variant_links { + let group = albums[base_index].edition_group.unwrap_or(base_id); + albums[variant_index].edition_group = Some(group); + albums[base_index].edition_group = Some(group); + } + } + + // The profile's nb_album reflects only Deezer's curated regional list; + // show the real merged count and sort by original release chronology. + artist.album_count = Some(albums.len() as u32); + albums.sort_by(|a, b| b.release_date.cmp(&a.release_date)); + let popular_tracks = popular + .as_ref() + .and_then(|body| body.get("data")) + .and_then(|data| data.as_array()) + .map(|tracks| { + tracks + .iter() + .filter(|track| track_belongs_to_artist(track, artist_id)) + .filter_map(parse_track) + .filter(|track| { + !track + .album_id + .is_some_and(|id| foreign_album_ids.contains(&id)) + && !apple_release_is_foreign(&apple, &track.album, None) + }) + .collect() + }) + .unwrap_or_default(); + stage_trace( + "page.total", + page_started, + &format!("albums={}", albums.len()), + ); + Some(CatalogArtistPage { + artist, + // Artwork is merged by the independent `catalog_artist_art` stage. + // Only a user override added by the Tauri command populates these + // fields on this content response. + banner: None, + banners: Vec::new(), + banner_custom: false, + logos: Vec::new(), + popular_tracks, + albums, + top_genre, + socials, + }) + } + + /// The MusicBrainz "last pass": socials, plus the few releases only + /// MusicBrainz carries (practice tapes, BBC sessions, regional oddities + /// Apple and Deezer both omit). + /// + /// Deliberately its own call. MusicBrainz is rate-limited to 1 request per + /// second by policy, and this needs two or three serialized requests — so + /// keeping it out of `catalog_artist` is what stops the release grid + /// waiting ~2-3s on a throttle. The frontend merges the result in when it + /// arrives; nothing on the page blocks for it. + pub async fn catalog_artist_identity( + &self, + artist_id: u64, + artist: &str, + ) -> CatalogArtistIdentity { + let identity_started = Instant::now(); + let Some(mbid) = self.deezer_linked_mbid(artist_id).await else { + stage_trace("identity.total", identity_started, "no-mbid"); + return empty_catalog_artist_identity(artist_id); + }; + stage_trace("identity.linked_mbid", identity_started, ""); + let join_started = Instant::now(); + // Socials are footer content, not sorting evidence, yet every request + // in this join serializes behind MusicBrainz's 1 req/s gate — so the + // socials lookup was ~1 s of extra time-to-correctly-sorted on every + // cold artist. They now ride [`Self::catalog_artist_socials`], which + // the frontend calls only after this merge-critical response lands. + let (albums, type_hints) = tokio::join!( + self.musicbrainz_studio_albums(artist, &mbid), + self.musicbrainz_type_hints(&mbid) + ); + stage_trace( + "identity.mb_join", + join_started, + &format!("albums={} hints={}", albums.len(), type_hints.len()), + ); + stage_trace("identity.total", identity_started, ""); + CatalogArtistIdentity { + artist_id, + albums, + socials: Vec::new(), + type_hints, + } + } + + /// Short artist biography for the Now Playing "About the artist" card. + /// + /// **Identity first.** A blind name search is how the panel came to show + /// Mr. Whitekeys for Camping in Alaska, Crazy Horse for Crazy P and + /// Måneskin's bassist for Partiboi69: Wikipedia's top hit for + /// " musician band" is frequently a different act entirely, and + /// nothing checked it. The article is now resolved through the artist's + /// OWN MusicBrainz identity — Deezer id → linked MBID → Wikidata → English + /// sitelink — which cannot name the wrong subject. + /// + /// Only when no such link exists does a name search run, and then every + /// candidate must prove itself: the title must BE the artist's name and + /// the lead must both name them and read like a music biography. An + /// unprovable article yields no bio at all — a blank card is honest, a + /// confident wrong one is not. + /// Wikipedia covers famous acts and nobody else, so the underground + /// artists this app exists to browse got a blank card. Every tier below is + /// anchored to the artist's MusicBrainz page — the links are ones the + /// artist's own MB entry declares, never a name guess: + /// + /// 1. **Wikipedia** via MB → Wikidata → English sitelink (richest prose). + /// 2. **Discogs** artist profile, addressed by the id in MB's `discogs` + /// relation. This is the tier that rescues small artists: Camping in + /// Alaska has a full band history there and no Wikipedia article. + /// 3. **Guarded Wikipedia name search** (title must BE the artist). + /// 4. **TheAudioDB** biography, identity-checked the same way album + /// lookups check it. + /// 5. **MusicBrainz's own facts** — the disambiguation line, kind, area + /// and genre tags, composed into one honest sentence. Hermeth's MB + /// entry alone yields "David Ameal, techno / breakbeat producer". + pub async fn artist_bio(&self, artist_id: Option, artist: &str) -> Option { + let facts = match artist_id { + Some(artist_id) => match self.deezer_linked_mbid(artist_id).await { + Some(mbid) => self.artist_identity_facts(&mbid).await, + None => None, + }, + None => None, + }; + + if let Some(facts) = facts.as_ref() { + if let Some(title) = self.wikipedia_title_from_facts(facts).await { + if let Some(extract) = self.wikipedia_extract(&title).await { + return Some(extract); + } + } + if let Some(discogs_id) = facts.discogs_id.as_deref() { + if let Some(profile) = self.discogs_profile(discogs_id).await { + return Some(profile); + } + } + } + if let Some(extract) = self.wikipedia_artist_search_bio(artist).await { + return Some(extract); + } + if let Some(biography) = self.audiodb_biography(artist_id, artist).await { + return Some(biography); + } + facts.as_ref().and_then(|facts| facts.compose_bio(artist)) + } + + /// One MusicBrainz artist read serving both the bio tiers and + /// [`Self::artist_socials`] — same URL, so they share cache and the + /// 1 req/s gate is paid once. + async fn artist_identity_facts(&self, mbid: &str) -> Option { + let body = self.musicbrainz_json(&artist_detail_url(mbid)).await?; + let mut facts = ArtistIdentityFacts { + disambiguation: body + .get("disambiguation") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), + kind: body + .get("type") + .and_then(|value| value.as_str()) + .map(str::to_string), + area: body + .pointer("/area/name") + .and_then(|value| value.as_str()) + .map(str::to_string), + tags: top_musicbrainz_tags(&body), + ..ArtistIdentityFacts::default() + }; + for relation in body.get("relations")?.as_array()? { + let Some(target) = relation.pointer("/url/resource").and_then(|v| v.as_str()) else { + continue; + }; + match relation.get("type").and_then(|v| v.as_str()).unwrap_or("") { + "wikipedia" => { + facts.wikipedia_title = target + .split("/wiki/") + .nth(1) + .map(|title| title.replace('_', " ")); + } + "wikidata" => { + facts.wikidata_id = target.rsplit('/').next().map(str::to_string); + } + "discogs" => { + facts.discogs_id = target + .trim_end_matches('/') + .rsplit('/') + .next() + .filter(|id| id.chars().all(|c| c.is_ascii_digit())) + .map(str::to_string); + } + _ => {} + } + } + Some(facts) + } + + /// The English article title MusicBrainz points at, resolving a Wikidata + /// entity through its `enwiki` sitelink when there is no direct link. + async fn wikipedia_title_from_facts(&self, facts: &ArtistIdentityFacts) -> Option { + if let Some(title) = facts.wikipedia_title.clone() { + return Some(title); + } + let wikidata_id = facts.wikidata_id.as_deref()?; + let url = format!( + "https://www.wikidata.org/w/api.php?action=wbgetentities&ids={wikidata_id}&props=sitelinks&sitefilter=enwiki&format=json" + ); + let body = self.deezer(&url).await?; + body.pointer(&format!("/entities/{wikidata_id}/sitelinks/enwiki/title")) + .and_then(|value| value.as_str()) + .map(str::to_string) + } + + /// Discogs artist profile, addressed by id — so it is the right artist by + /// construction. Discogs allows 25 unauthenticated requests a minute; this + /// is paced under that and, being slow-moving prose, is disk cached. + async fn discogs_profile(&self, discogs_id: &str) -> Option { + let url = format!("https://api.discogs.com/artists/{discogs_id}"); + self.discogs_throttle().await; + let body = self.deezer(&url).await?; + let profile = body.get("profile").and_then(|value| value.as_str())?; + let cleaned = strip_discogs_markup(profile); + (!cleaned.is_empty()).then_some(cleaned) + } + + /// TheAudioDB's biography, held to the same identity test the album + /// lookups use so a namesake's write-up can never appear. + async fn audiodb_biography(&self, artist_id: Option, artist: &str) -> Option { + let record = self.audiodb_artist(artist).await?; + if let Some(artist_id) = artist_id { + let linked_mbid = self.deezer_linked_mbid(artist_id).await; + if !audiodb_artist_matches_identity(&record, artist_id, linked_mbid.as_deref()) { + return None; + } + } + record + .get("strBiographyEN") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + } + + /// Page summary for an exact article title, rejecting disambiguation stubs. + async fn wikipedia_extract(&self, title: &str) -> Option { + let url = format!( + "https://en.wikipedia.org/api/rest_v1/page/summary/{}", + urlencode(&title.replace(' ', "_")) + ); + let body = self.deezer(&url).await?; + if body.get("type").and_then(|value| value.as_str()) == Some("disambiguation") { + return None; + } + let extract = body.get("extract").and_then(|value| value.as_str())?.trim(); + (!extract.is_empty()).then(|| extract.to_string()) + } + + /// Guarded name search, used only for artists MusicBrainz cannot anchor. + async fn wikipedia_artist_search_bio(&self, artist: &str) -> Option { + let search_url = format!( + "https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch={}&format=json&srlimit=6", + urlencode(&format!("{artist} musician band")) + ); + let body = self.deezer(&search_url).await?; + let results = body.get("query")?.get("search")?.as_array()?; + let want = normalize(artist); + // The title must BE the artist's name, give or take a parenthetical + // qualifier ("Pond (band)"). "Mr. Whitekeys" cannot pass for "Camping + // in Alaska", nor "Crazy Horse (band)" for "Crazy P". + let titles: Vec = results + .iter() + .filter_map(|result| result.get("title").and_then(|value| value.as_str())) + .filter(|title| { + let base = title.split('(').next().unwrap_or(title); + normalize(base) == want + }) + .map(str::to_string) + .collect(); + for title in titles { + let Some(extract) = self.wikipedia_extract(&title).await else { + continue; + }; + // The lead must name the artist AND describe a musical act, so a + // same-named footballer or village cannot fill the card. + if !normalize(&extract).contains(&want) { + continue; + } + let lower = extract.to_lowercase(); + let musical = [ + "band", + "musician", + "singer", + "rapper", + "producer", + "songwriter", + "composer", + "dj", + "duo", + "trio", + "music", + "album", + ] + .iter() + .any(|marker| lower.contains(marker)); + if musical { + return Some(extract); + } + } + None + } + + /// Official links for the artist header, deferred OFF the identity pass's + /// critical path (see [`Self::catalog_artist_identity`]). The linked-MBID + /// read is already cached by the identity pass, so this costs one + /// MusicBrainz request, made only after sorting has settled. + pub async fn catalog_artist_socials(&self, artist_id: u64) -> Vec { + let Some(mbid) = self.deezer_linked_mbid(artist_id).await else { + return Vec::new(); + }; + self.artist_socials(&mbid).await + } + + /// Classification hints for release groups MusicBrainz marks with a + /// secondary type while still calling them primary-type Album — precisely + /// the compilations, soundtracks and remix albums Deezer and Apple return + /// tagged as a plain "album". The frontend matches these by title and moves + /// the release to the right tab. + /// + /// Paginated because a prolific catalog overflows MusicBrainz's 100-per-page + /// cap (Radiohead has 373 such groups) and search results are unsorted, so a + /// single page can miss the one release that actually matters. Capped so an + /// artist with hundreds of live/compilation entries can't turn this deferred + /// pass into a long run of serialized 1 req/s calls. + async fn musicbrainz_type_hints(&self, mbid: &str) -> Vec { + let query = urlencode(&format!( + "arid:{mbid} AND primarytype:album AND secondarytype:*" + )); + let mut hints = Vec::new(); + for offset in (0..500).step_by(100) { + let url = format!( + "https://musicbrainz.org/ws/2/release-group/?query={query}&limit=100&offset={offset}&fmt=json" + ); + let Some(body) = self.musicbrainz_json(&url).await else { + break; + }; + let Some(groups) = body + .get("release-groups") + .and_then(|value| value.as_array()) + else { + break; + }; + if groups.is_empty() { + break; + } + let page_len = groups.len(); + for group in groups { + let Some(title) = group.get("title").and_then(|value| value.as_str()) else { + continue; + }; + let secondary_types = group + .get("secondary-types") + .and_then(|value| value.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|value| value.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + hints.push(ReleaseTypeHint { + title: title.to_string(), + primary_type: group + .get("primary-type") + .and_then(|value| value.as_str()) + .map(String::from), + secondary_types, + }); + } + // A short page means the result set is exhausted. + if page_len < 100 { + break; + } + } + hints + } + + /// The MusicBrainz id that MusicBrainz itself links to this Deezer artist, + /// resolved through the Deezer artist URL's `artist` relationship. This is + /// an *identity-anchored* id: unlike a name search (TheAudioDB), it cannot + /// return a different same-named artist, so it is safe to key the studio + /// supplement to. `None` when MusicBrainz has no Deezer link for the artist. + async fn deezer_linked_mbid(&self, deezer_id: u64) -> Option { + let resource = urlencode(&format!("https://www.deezer.com/artist/{deezer_id}")); + let url = format!( + "https://musicbrainz.org/ws/2/url?resource={resource}&inc=artist-rels&fmt=json" + ); + let body = self.musicbrainz_json(&url).await?; + body.get("relations")? + .as_array()? + .iter() + .find_map(|relation| { + relation + .pointer("/artist/id") + .and_then(|value| value.as_str()) + .map(String::from) + }) + } + + /// Official homepage + social links from MusicBrainz's url relationships, + /// for a Deezer-linked MBID. Uses the same `inc=url-rels` artist lookup the + /// Links MusicBrainz marks `ended` (dead accounts) are skipped, and only + /// the first link of each kind is kept, in a fixed display order. + async fn artist_socials(&self, mbid: &str) -> Vec { + const ORDER: [&str; 8] = [ + "website", + "instagram", + "x", + "facebook", + "tiktok", + "youtube", + "bandcamp", + "soundcloud", + ]; + let Some(body) = self.musicbrainz_json(&artist_detail_url(mbid)).await else { + return Vec::new(); + }; + let Some(relations) = body.get("relations").and_then(|v| v.as_array()) else { + return Vec::new(); + }; + let mut by_kind: HashMap<&str, String> = HashMap::new(); + for relation in relations { + if relation.get("ended").and_then(|v| v.as_bool()) == Some(true) { + continue; + } + let Some(target) = relation.pointer("/url/resource").and_then(|v| v.as_str()) else { + continue; + }; + let rel_type = relation + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let host = target.to_lowercase(); + // The MB relation type is authoritative for homepage/bandcamp/ + // soundcloud/youtube; "social network" needs the domain to tell + // Instagram from X from Facebook. + let kind = match rel_type { + "official homepage" => Some("website"), + "bandcamp" => Some("bandcamp"), + "soundcloud" => Some("soundcloud"), + "youtube" | "youtube music" => Some("youtube"), + "social network" => { + if host.contains("instagram.com") { + Some("instagram") + } else if host.contains("twitter.com") + || host.contains("//x.com") + || host.contains("www.x.com") + { + Some("x") + } else if host.contains("facebook.com") { + Some("facebook") + } else if host.contains("tiktok.com") { + Some("tiktok") + } else if host.contains("youtube.com") { + Some("youtube") + } else { + None + } + } + _ => None, + }; + if let Some(kind) = kind { + by_kind.entry(kind).or_insert_with(|| target.to_string()); + } + } + ORDER + .iter() + .filter_map(|kind| { + by_kind.remove(kind).map(|url| ArtistLink { + kind: (*kind).to_string(), + url, + }) + }) + .collect() + } + + /// Distinctive people attached to the Deezer-linked MusicBrainz artist. + /// Only durable group-identity relationships are admitted; collaborators, + /// supporting performers, teachers and other broad graph edges would not + /// prove that a Bing card depicts this exact same-named act. + async fn artist_identity_people(&self, mbid: &str, artist: &str) -> HashSet { + let url = format!("https://musicbrainz.org/ws/2/artist/{mbid}?inc=artist-rels&fmt=json"); + let Some(body) = self.musicbrainz_json(&url).await else { + return HashSet::new(); + }; + musicbrainz_identity_people(&body, artist) + } + + /// Studio release groups missing from Deezer's regional artist catalog, for + /// a Deezer-linked MusicBrainz id (see [`Self::deezer_linked_mbid`]). The + /// filtered query excludes every secondary type, so live bootlegs, + /// compilations, remixes and soundtracks cannot flood the normal Albums + /// section. Because the caller passes an identity-anchored id, this can + /// never inject a different same-named artist's discography. + async fn musicbrainz_studio_albums(&self, artist: &str, mbid: &str) -> Vec { + // status:official matters as much as the type filters: without it the + // supplement returned bootleg-only release groups as studio albums — + // Queen's "Zoom 1979", "In the Lap of the Queen", Taylor Swift's + // "Lover The Secret Studio Sessions". Verified live: with the filter + // Queen's supplement is exactly its 14 studio albums. + let query = urlencode(&format!( + "arid:{mbid} AND primarytype:album AND -secondarytype:* AND status:official" + )); + let url = + format!("https://musicbrainz.org/ws/2/release-group/?query={query}&limit=100&fmt=json"); + let Some(body) = self.musicbrainz_json(&url).await else { + return Vec::new(); + }; + let Some(groups) = body + .get("release-groups") + .and_then(|value| value.as_array()) + else { + return Vec::new(); + }; + + let mut albums = Vec::new(); + let mut registry = self.mb_catalog.lock().await; + if registry.len() > 1_000 { + registry.clear(); + } + for group in groups { + let Some(release_group_id) = group.get("id").and_then(|value| value.as_str()) else { + continue; + }; + let Some(title) = group.get("title").and_then(|value| value.as_str()) else { + continue; + }; + let title = if normalize(title) == normalize(artist) { + group + .get("disambiguation") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("{title} ({value})")) + .unwrap_or_else(|| title.to_string()) + } else { + title.to_string() + }; + let release_date = group + .get("first-release-date") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + .map(String::from); + let id = synthetic_catalog_id(&format!("release-group:{release_group_id}")); + let cover = Some(format!( + "https://coverartarchive.org/release-group/{release_group_id}/front-500" + )); + registry.insert( + id, + MbCatalogAlbum { + release_group_id: release_group_id.to_string(), + artist: artist.to_string(), + title: title.clone(), + release_date: release_date.clone(), + cover: cover.clone(), + }, + ); + albums.push(CatalogAlbum { + id, + title, + artist: artist.to_string(), + cover, + release_date, + record_type: Some("album".into()), + track_count: None, + edition_group: None, + is_soundtrack: false, + is_live: false, + }); + } + albums + } + + /// Resolve an injected MusicBrainz release group to one official edition, + /// then return its recording list through the same CatalogAlbumPage shape + /// used by Deezer-backed cards. + async fn musicbrainz_album( + &self, + album_id: u64, + source: MbCatalogAlbum, + ) -> Option { + let group_url = format!( + "https://musicbrainz.org/ws/2/release-group/{}?inc=releases&fmt=json", + source.release_group_id + ); + let group = self.musicbrainz_json(&group_url).await?; + let releases = group.get("releases")?.as_array()?; + let release = releases.iter().min_by_key(|release| { + let status_rank = u8::from( + release.get("status").and_then(|value| value.as_str()) != Some("Official"), + ); + let country_rank = match release.get("country").and_then(|value| value.as_str()) { + Some("US" | "AU" | "XW" | "XE" | "GB") => 0, + Some(_) => 1, + None => 2, + }; + ( + status_rank, + country_rank, + release.get("date").and_then(|value| value.as_str()), + ) + })?; + let release_id = release.get("id")?.as_str()?; + let release_url = + format!("https://musicbrainz.org/ws/2/release/{release_id}?inc=recordings&fmt=json"); + let body = self.musicbrainz_json(&release_url).await?; + + let mut tracks = Vec::new(); + for medium in body + .get("media") + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + { + for track in medium + .get("tracks") + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + { + let title = track + .get("title") + .or_else(|| track.pointer("/recording/title")) + .and_then(|value| value.as_str()) + .unwrap_or("Unknown track") + .to_string(); + let source_id = track + .get("id") + .or_else(|| track.pointer("/recording/id")) + .and_then(|value| value.as_str()) + .unwrap_or(&title); + let duration_secs = track + .get("length") + .or_else(|| track.pointer("/recording/length")) + .and_then(|value| value.as_u64()) + .map(|milliseconds| (milliseconds / 1_000) as u32); + tracks.push(CatalogTrack { + id: synthetic_catalog_id(&format!("track:{source_id}")), + title, + artist: source.artist.clone(), + album: source.title.clone(), + album_id: Some(album_id), + cover: source.cover.clone(), + duration_secs, + preview: None, + track_no: Some(tracks.len() as u32 + 1), + disk_number: None, + unreleased: false, + }); + } + } + let album = CatalogAlbum { + id: album_id, + title: source.title, + artist: source.artist, + cover: source.cover, + release_date: source.release_date, + record_type: Some("album".into()), + track_count: Some(tracks.len() as u32), + edition_group: None, + is_soundtrack: false, + is_live: false, + }; + Some(CatalogAlbumPage { album, tracks }) + } + + /// The iTunes song lookup for a collection — the album's tracklist. Shared + /// by `apple_album` (full track records for an opened card) and + /// `apple_album_track_titles` (edition scoring). Returns the song entries in + /// order; empty on any failure. + async fn apple_collection_songs(&self, collection_id: u64) -> Vec { + let url = + format!("https://itunes.apple.com/lookup?id={collection_id}&entity=song&limit=200"); + let Some(resp) = self.http.get(&url).send().await.ok() else { + return Vec::new(); + }; + let Some(body) = resp.json::().await.ok() else { + return Vec::new(); + }; + let Some(results) = body.get("results").and_then(|r| r.as_array()) else { + return Vec::new(); + }; + results + .iter() + .filter(|r| { + r.get("wrapperType").and_then(|w| w.as_str()) == Some("track") + && r.get("kind").and_then(|k| k.as_str()) == Some("song") + }) + .cloned() + .collect() + } + + /// Resolve an Apple-supplemented album (large synthetic id) to its tracklist + /// through the iTunes song lookup, in the same CatalogAlbumPage shape used by + /// Deezer- and MusicBrainz-backed cards. iTunes gives a real 30s preview URL. + async fn apple_album( + &self, + album_id: u64, + source: AppleCatalogAlbum, + ) -> Option { + let songs = self.apple_collection_songs(source.collection_id).await; + if songs.is_empty() { + return None; + } + let mut tracks = Vec::new(); + for song in &songs { + let title = song + .get("trackName") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown track") + .to_string(); + let track_id = song + .get("trackId") + .and_then(|v| v.as_u64()) + .map(|n| n.to_string()) + .unwrap_or_else(|| title.clone()); + tracks.push(CatalogTrack { + id: synthetic_catalog_id(&format!("apple-track:{track_id}")), + title, + artist: source.artist.clone(), + album: source.title.clone(), + album_id: Some(album_id), + cover: source.cover.clone(), + duration_secs: song + .get("trackTimeMillis") + .and_then(|v| v.as_u64()) + .map(|ms| (ms / 1_000) as u32), + preview: song + .get("previewUrl") + .and_then(|v| v.as_str()) + .map(String::from), + track_no: song + .get("trackNumber") + .and_then(|v| v.as_u64()) + .map(|n| n as u32), + disk_number: song + .get("discNumber") + .and_then(|v| v.as_u64()) + .map(|n| n as u32), + unreleased: false, + }); + } + let album = CatalogAlbum { + id: album_id, + title: source.title, + artist: source.artist, + cover: source.cover, + release_date: source.release_date, + record_type: Some("album".into()), + track_count: Some(tracks.len() as u32), + edition_group: None, + is_soundtrack: false, + is_live: false, + }; + Some(CatalogAlbumPage { album, tracks }) + } + + /// The album's track titles, normalized via `track_identity` for edition + /// matching — lets an Apple-supplemented edition be scored against a Deezer + /// original the same way Deezer editions are. + async fn apple_album_track_titles(&self, collection_id: u64) -> Vec { + self.apple_collection_songs(collection_id) + .await + .iter() + .filter_map(|s| s.get("trackName").and_then(|v| v.as_str())) + .map(track_identity) + .collect() + } + + /// Album details with the full tracklist (and preview URLs). + pub async fn catalog_album(&self, album_id: u64) -> Option { + let mut page = if let Some(album) = self.mb_catalog.lock().await.get(&album_id).cloned() { + self.musicbrainz_album(album_id, album).await? + } else if let Some(album) = self.apple_catalog.lock().await.get(&album_id).cloned() { + self.apple_album(album_id, album).await? + } else { + let body = self + .deezer(&format!("https://api.deezer.com/album/{album_id}")) + .await?; + let album = parse_album(&body)?; + // Deezer's `/album/{id}` embeds only the first ~25 tracks in + // `/tracks/data` and gives no pagination cursor there, so a 50-track + // deluxe would show half its tracklist. The dedicated tracks + // endpoint paginates properly AND carries `disk_number`, so fetch + // the full, disc-aware tracklist from there. + let mut tracks: Vec = Vec::new(); + let mut next = Some(format!( + "https://api.deezer.com/album/{album_id}/tracks?limit=200" + )); + while let Some(url) = next { + let Some(page) = self.deezer(&url).await else { + break; + }; + if let Some(arr) = page.get("data").and_then(|d| d.as_array()) { + for t in arr { + if let Some(mut track) = parse_track(t) { + if track.album.is_empty() { + track.album = album.title.clone(); + } + tracks.push(track); + } + } + } + next = page.get("next").and_then(|n| n.as_str()).map(String::from); + } + // Fallback to the embedded list if the tracks endpoint is empty. + if tracks.is_empty() { + tracks = body + .pointer("/tracks/data") + .and_then(|d| d.as_array()) + .map(|a| { + a.iter() + .enumerate() + .filter_map(|(i, t)| { + let mut track = parse_track(t)?; + track.track_no = Some(i as u32 + 1); + if track.album.is_empty() { + track.album = album.title.clone(); + } + Some(track) + }) + .collect() + }) + .unwrap_or_default(); + } + CatalogAlbumPage { album, tracks } + }; + // A future-dated album may still have individual songs already out as + // singles. Flag the rest unreleased so the UI can block streaming them + // while keeping any pre-released single playable. + if is_future_or_unresolved_date(page.album.release_date.as_deref()) { + self.mark_unreleased_tracks(&mut page).await; + } + Some(page) + } + + /// Flag each track on an upcoming album as unreleased unless it is already + /// out — either it carries its own Deezer preview, or a released single by + /// the same artist with the same title exists. Checks run concurrently. + async fn mark_unreleased_tracks(&self, page: &mut CatalogAlbumPage) { + let artist = page.album.artist.clone(); + let flags = + futures::future::join_all( + page.tracks.iter().map(|track| { + let artist = artist.clone(); + let title = track.title.clone(); + let has_preview = track.preview.is_some(); + async move { + !has_preview && !self.deezer_has_released_single(&artist, &title).await + } + }), + ) + .await; + for (track, unreleased) in page.tracks.iter_mut().zip(flags) { + track.unreleased = unreleased; + } + } + + /// Whether Deezer already serves a playable (previewable) track by this + /// artist with this title — the signal that a song from an upcoming album + /// has been released early as a single. + async fn deezer_has_released_single(&self, artist: &str, title: &str) -> bool { + let query = urlencode(&format!( + "artist:\"{}\" track:\"{}\"", + escape(artist), + escape(title) + )); + let url = format!("https://api.deezer.com/search/track?q={query}&limit=5"); + let Some(body) = self.deezer(&url).await else { + return false; + }; + let want_title = normalize(title); + let want_artist = normalize(artist); + body.get("data") + .and_then(|data| data.as_array()) + .is_some_and(|items| { + items.iter().any(|item| { + let title_ok = item + .get("title") + .and_then(|value| value.as_str()) + .map(normalize) + .as_deref() + == Some(want_title.as_str()); + let artist_ok = item + .pointer("/artist/name") + .and_then(|value| value.as_str()) + .map(normalize) + .as_deref() + == Some(want_artist.as_str()); + let playable = item + .get("preview") + .and_then(|value| value.as_str()) + .is_some_and(|preview| !preview.is_empty()); + title_ok && artist_ok && playable + }) + }) + } + + async fn mb_throttle(&self) { + let mut last = self.mb_gate.lock().await; + let since = last.elapsed(); + if since < Duration::from_secs(1) { + tokio::time::sleep(Duration::from_secs(1) - since).await; + } + *last = Instant::now(); + } + + /// Hold a Deezer request until the sliding 5-second window has room. The + /// window is re-checked under the lock after every sleep, so a burst that + /// wakes together still leaves at most [`DEEZER_RATE_BUDGET`] requests per + /// window rather than all of them racing back in at once. + async fn deezer_throttle(&self) { + loop { + let wait = { + let mut window = self.deezer_gate.lock().await; + let now = Instant::now(); + while window + .front() + .is_some_and(|at| now.duration_since(*at) >= DEEZER_RATE_WINDOW) + { + window.pop_front(); + } + if window.len() < DEEZER_RATE_BUDGET { + window.push_back(now); + return; + } + match window.front() { + Some(oldest) => DEEZER_RATE_WINDOW - now.duration_since(*oldest), + None => continue, + } + }; + tokio::time::sleep(wait).await; + } + } + + /// Hold a Wikipedia request until the sliding window has room, mirroring + /// [`Self::deezer_throttle`]. Returns `false` — do not send — when the IP + /// is inside a server-imposed penalty (429 `Retry-After`) longer than the + /// fail-fast bound: sending would extend the ban, and waiting a minute + /// inside one album-info call would hold an open expander for nothing. + /// Short cooldowns are waited out so a paced retry succeeds. + async fn wiki_throttle(&self) -> bool { + loop { + let cooldown = { + let cooldown = self.wiki_cooldown.lock().await; + cooldown.and_then(|until| until.checked_duration_since(Instant::now())) + }; + if let Some(remaining) = cooldown { + if remaining > WIKI_COOLDOWN_FAIL_FAST { + return false; + } + tokio::time::sleep(remaining).await; + continue; + } + let wait = { + let mut window = self.wiki_gate.lock().await; + let now = Instant::now(); + while window + .front() + .is_some_and(|at| now.duration_since(*at) >= WIKI_RATE_WINDOW) + { + window.pop_front(); + } + if window.len() < WIKI_RATE_BUDGET { + window.push_back(now); + return true; + } + match window.front() { + Some(oldest) => WIKI_RATE_WINDOW - now.duration_since(*oldest), + None => continue, + } + }; + tokio::time::sleep(wait).await; + } + } + + /// Hold a Discogs request until its sliding window has room. + async fn discogs_throttle(&self) { + loop { + let wait = { + let mut window = self.discogs_gate.lock().await; + let now = Instant::now(); + while window + .front() + .is_some_and(|at| now.duration_since(*at) >= DISCOGS_RATE_WINDOW) + { + window.pop_front(); + } + if window.len() < DISCOGS_RATE_BUDGET { + window.push_back(now); + return; + } + match window.front() { + Some(oldest) => DISCOGS_RATE_WINDOW - now.duration_since(*oldest), + None => continue, + } + }; + tokio::time::sleep(wait).await; + } + } + + /// Canonicalize an (artist, album) guess via MusicBrainz release search. + pub async fn lookup_album(&self, artist: &str, album: &str) -> Option { + self.mb_throttle().await; + + let query = format!( + "release:\"{}\" AND artist:\"{}\"", + escape(album), + escape(artist) + ); + let url = format!( + "https://musicbrainz.org/ws/2/release/?query={}&fmt=json&limit=5", + urlencode(&query) + ); + let body: serde_json::Value = self.http.get(&url).send().await.ok()?.json().await.ok()?; + + let releases = body.get("releases")?.as_array()?; + let best = releases + .iter() + .filter(|r| r.get("score").and_then(|s| s.as_i64()).unwrap_or(0) >= 85) + .max_by_key(|r| r.get("score").and_then(|s| s.as_i64()).unwrap_or(0))?; + + let canonical_album = best.get("title")?.as_str()?.to_string(); + let canonical_artist = best + .pointer("/artist-credit/0/name") + .and_then(|v| v.as_str()) + .unwrap_or(artist) + .to_string(); + let release_date = best.get("date").and_then(|d| d.as_str()).map(String::from); + let year = release_date + .as_deref() + .and_then(|d| d.get(..4)) + .and_then(|y| y.parse().ok()); + let mbid = best.get("id").and_then(|v| v.as_str()).map(String::from); + let rg_mbid = best + .pointer("/release-group/id") + .and_then(|v| v.as_str()) + .map(String::from); + + Some(AlbumMeta { + artist: canonical_artist, + album: canonical_album, + release_date, + year, + release_mbid: mbid, + release_group_mbid: rg_mbid, + }) + } + + /// Fetch front-cover art at the highest real resolution available. + /// + /// Candidates are gathered from three sources, decoded, and ranked: + /// - **Deezer** `cover_xl` (1000×1000) — the exact artwork the catalog + /// browser displays, so it doubles as the *reference* image. + /// - **Apple/iTunes** at original upload resolution (mzstatic renders any + /// requested size; `100000x100000-999` returns the original, commonly + /// 3000×3000+). Artist- and album-title-verified. + /// - **Cover Art Archive** original `front` (not the 500px thumb) by + /// release then release-group MBID. + /// + /// Selection: when the Deezer reference exists, only candidates whose + /// perceptual hash matches it may win — the library/tags then always carry + /// the SAME artwork the user saw while browsing, just at whichever source + /// serves it biggest. Without a reference, the largest verified candidate + /// wins. Deezer sizes are never inflated past `xl`: the CDN silently + /// upscales larger requests, and fake pixels would outrank real ones. + pub async fn fetch_art( + &self, + release_mbid: Option<&str>, + release_group_mbid: Option<&str>, + artist: &str, + album: &str, + ) -> Option> { + let (deezer, apple, caa) = tokio::join!( + self.deezer_cover_candidate(artist, album), + self.apple_cover_candidate(artist, album), + self.caa_cover_candidates(release_mbid, release_group_mbid), + ); + + let mut candidates: Vec = Vec::new(); + candidates.extend(deezer); + candidates.extend(apple); + candidates.extend(caa); + if candidates.is_empty() { + return None; + } + + let best = pick_best_cover(&candidates)?; + let chosen = &candidates[best]; + log::info!( + "cover art for {artist} - {album}: {} {}x{} ({} KB) from {} candidate(s)", + chosen.source, + chosen.width, + chosen.height, + chosen.bytes.len() / 1024, + candidates.len() + ); + Some(chosen.bytes.as_ref().clone()) + } + + /// Resolve the best same-artwork cover for a catalog release, ranking + /// official sources (Apple, then Deezer) above Cover Art Archive and gating + /// every candidate against the known-correct reference cover the catalog + /// already displays. This is what makes remix/karaoke/wrong-artist art + /// impossible to select: a candidate must both pass artist verification and + /// be perceptually the SAME image as the reference. Returns a full report + /// (reference + every candidate + the pick) for diagnostics and the UI. + pub async fn cover_report( + &self, + artist: &str, + title: &str, + reference_url: &str, + ) -> CoverReport { + // The reference is whatever cover the catalog currently shows for this + // release (correct edition, delivered with the album). Label its source + // from the host so the report reads clearly. + let ref_source: &'static str = if reference_url.contains("dzcdn") { + "deezer" + } else if reference_url.contains("coverartarchive") { + "cover-art-archive" + } else { + "current" + }; + let reference = match self.get_image_shared(reference_url).await { + Some(bytes) => { + decode_cover_async(bytes, ref_source, true, reference_url.to_string()).await + } + None => None, + }; + + let mut candidates: Vec = Vec::new(); + + // Deezer at its native max: request 1500 (the CDN caps at the real + // source, commonly 1200–1500), never inflated beyond what exists. + if reference_url.contains("dzcdn") { + let max_url = reference_url.replace("/1000x1000-", "/1500x1500-"); + if max_url != reference_url { + if let Some(bytes) = self.get_image_shared(&max_url).await { + if let Some(c) = decode_cover_async(bytes, "deezer-max", false, max_url).await { + candidates.push(c); + } + } + } + } + + // Apple — artist- and title-verified inside apple_cover_candidate, so a + // karaoke/tribute label with the same song title never qualifies. + if let Some(c) = self.apple_cover_candidate(artist, title).await { + candidates.push(c); + } + + // Cover Art Archive via MusicBrainz-verified release/release-group MBIDs. + // SKIP_MB models the proposed hot path: no per-album MusicBrainz search. + if std::env::var("SKIP_MB").is_err() { + if let Some(meta) = self.lookup_album(artist, title).await { + let caa = self + .caa_cover_candidates( + meta.release_mbid.as_deref(), + meta.release_group_mbid.as_deref(), + ) + .await; + candidates.extend(caa); + } + } + + let ref_hash = reference.as_ref().map(|r| r.dhash); + let distance_of = + |c: &CoverCandidate| -> Option { ref_hash.map(|h| (c.dhash ^ h).count_ones()) }; + // Only artwork that MATCHES the reference is eligible — so once the + // perceptual gate has confirmed it's the SAME image, the largest is + // simply the best copy of the correct art. Official source (Apple, then + // Deezer) breaks ties at equal resolution. + let chosen_idx = candidates + .iter() + .enumerate() + .filter(|(_, c)| { + distance_of(c) + .map(|d| d <= COVER_SAME_ART_MAX_DISTANCE) + .unwrap_or(true) + }) + .max_by_key(|(_, c)| { + ( + u64::from(c.width) * u64::from(c.height), + cover_source_priority(c.source), + ) + }) + .map(|(i, _)| i); + + let make_info = |c: &CoverCandidate, chosen: bool| -> CandInfo { + let distance = distance_of(c); + CandInfo { + source: c.source.to_string(), + url: c.url.clone(), + width: c.width, + height: c.height, + distance, + eligible: distance + .map(|d| d <= COVER_SAME_ART_MAX_DISTANCE) + .unwrap_or(true), + chosen, + } + }; + let candidate_infos: Vec = candidates + .iter() + .enumerate() + .map(|(i, c)| make_info(c, Some(i) == chosen_idx)) + .collect(); + + // If no candidate beats the reference, the reference itself is the pick. + let (chosen_source, chosen_url) = match chosen_idx { + Some(i) => ( + Some(candidates[i].source.to_string()), + Some(candidates[i].url.clone()), + ), + None => ( + reference.as_ref().map(|r| r.source.to_string()), + reference.as_ref().map(|r| r.url.clone()), + ), + }; + + CoverReport { + artist: artist.to_string(), + title: title.to_string(), + reference: reference + .as_ref() + .map(|r| make_info(r, chosen_idx.is_none())), + candidates: candidate_infos, + chosen_source, + chosen_url, + } + } + + /// Production hot-path cover upgrade for a catalog release. Returns the URL + /// of the highest-resolution image that is *verifiably the same artwork* as + /// the cover the UI already shows (`reference_url`), or `None` to keep it. + /// + /// Deliberately does NO MusicBrainz search — that 1 req/s lookup is the + /// artist-page bottleneck. Sources are Apple (artist-id lookup, cached per + /// artist) and Deezer at native max, both of which parallelize freely. + /// Cover Art Archive is consulted only when the reference URL already + /// carries a release-group MBID (the MusicBrainz-supplemented cards), so it + /// costs no extra lookup and still upgrades those `front-500` thumbnails. + pub async fn resolve_cover( + &self, + artist: &str, + title: &str, + reference_url: &str, + ) -> Option { + self.resolve_cover_for_artist_id(None, artist, title, reference_url) + .await + } + + /// Identity-scoped variant for artist-page cover/logo work. Once overview + /// or the full page has corroborated a same-name Apple performer, callers + /// can reuse that `dz:{id}` catalogue and retain original-resolution Apple + /// upgrades without falling back to an unsafe name-only choice. + pub async fn resolve_cover_for_artist_id( + &self, + artist_id: Option, + artist: &str, + title: &str, + reference_url: &str, + ) -> Option { + let reference_bytes = self.get_image_shared(reference_url).await?; + let reference = decode_cover_async( + reference_bytes, + "reference", + true, + reference_url.to_string(), + ) + .await?; + + let mut candidates: Vec = Vec::new(); + + // Deezer at native max (URL rewrite only; capped at the real source). + if reference_url.contains("dzcdn") { + let max_url = reference_url.replace("/1000x1000-", "/1500x1500-"); + if max_url != reference_url { + if let Some(b) = self.get_image_shared(&max_url).await { + if let Some(c) = decode_cover_async(b, "deezer-max", false, max_url).await { + candidates.push(c); + } + } + } + } + + // Apple — verified, cached, no MusicBrainz. Artist pages reuse the + // Deezer-id selection; standalone callers stay safely name-scoped. + let apple = if let Some(artist_id) = artist_id { + self.apple_cover_candidate_for_deezer(artist_id, artist, title) + .await + } else { + self.apple_cover_candidate(artist, title).await + }; + if let Some(c) = apple { + candidates.push(c); + } + + // Cover Art Archive only when the MBID is already in hand (free). + if let Some(mbid) = caa_release_group_mbid(reference_url) { + candidates.extend(self.caa_cover_candidates(None, Some(&mbid)).await); + } + + let ref_hash = reference.dhash; + let ref_area = u64::from(reference.width) * u64::from(reference.height); + let best = candidates + .iter() + .filter(|c| (c.dhash ^ ref_hash).count_ones() <= COVER_SAME_ART_MAX_DISTANCE) + .max_by_key(|c| { + ( + u64::from(c.width) * u64::from(c.height), + cover_source_priority(c.source), + ) + })?; + // Only replace when we actually gained resolution. + if u64::from(best.width) * u64::from(best.height) > ref_area { + Some(best.url.clone()) + } else { + None + } + } + + /// Deezer's `cover_xl` for the album, artist+title verified. This is the + /// artwork the catalog UI shows, so it anchors cover selection. + async fn deezer_cover_candidate(&self, artist: &str, album: &str) -> Option { + let query = urlencode(&format!( + "artist:\"{}\" album:\"{}\"", + escape(artist), + escape(album) + )); + let url = format!("https://api.deezer.com/search/album?q={query}&limit=5"); + let body = self.deezer(&url).await?; + let want_artist = normalize(artist); + let want_album = normalize(album); + let hit = body.get("data")?.as_array()?.iter().find(|a| { + let got_artist = a + .pointer("/artist/name") + .and_then(|v| v.as_str()) + .map(normalize) + .unwrap_or_default(); + let got_album = a + .get("title") + .and_then(|v| v.as_str()) + .map(normalize) + .unwrap_or_default(); + loose_name_match(&got_artist, &want_artist) && loose_name_match(&got_album, &want_album) + })?; + let cover_url = hit.get("cover_xl").and_then(|c| c.as_str())?; + let bytes = self.get_image_shared(cover_url).await?; + decode_cover_async(bytes, "deezer", true, cover_url.to_string()).await + } + + /// Apple's artwork at original resolution for a specific album. iTunes text + /// search cannot reliably surface one artist's one album (common titles + /// return other artists; the artist's own album is often not in the top + /// results), so we resolve the artist's iTunes id, list *their* albums, and + /// match the title locally. The perceptual gate downstream still guarantees + /// the winning image is the same artwork as the reference. + async fn apple_cover_candidate(&self, artist: &str, album: &str) -> Option { + let catalog = self.apple_artist_albums(artist).await; + if catalog.identity_confirmed { + return self + .apple_cover_from_releases(&catalog.releases, album, true) + .await; + } + // A shared name may still have an album title found under exactly one + // Apple performer. That is safe for cover polish (and preserves 3000px + // cover-logo input) because the caller additionally gates the raster by + // perceptual hash against Deezer's known-correct reference. + let candidates = self.apple_artist_candidates(artist).await; + if candidates + .iter() + .any(|candidate| !candidate.lookup_complete) + { + return None; + } + let want = release_title_identity(album); + let matching: Vec<&AppleArtistCandidate> = candidates + .iter() + .filter(|candidate| { + candidate + .releases + .iter() + .any(|release| release.title_norm == want) + }) + .collect(); + if matching.len() != 1 { + return None; + } + self.apple_cover_from_releases(&matching[0].releases, album, false) + .await + } + + async fn apple_cover_candidate_for_deezer( + &self, + deezer_id: u64, + artist: &str, + album: &str, + ) -> Option { + let key = format!("dz:{deezer_id}"); + let catalog = self.apple_albums.lock().await.get(&key).cloned(); + if let Some(catalog) = catalog.filter(|catalog| catalog.identity_confirmed) { + return self + .apple_cover_from_releases(&catalog.releases, album, true) + .await; + } + self.apple_cover_candidate(artist, album).await + } + + async fn apple_cover_from_releases( + &self, + releases: &[AppleRelease], + album: &str, + allow_loose: bool, + ) -> Option { + let want = release_title_identity(album); + let thumb = releases + .iter() + .find(|release| release.title_norm == want) + .or_else(|| { + if allow_loose { + releases + .iter() + .find(|release| loose_name_match(&release.title_norm, &want)) + } else { + None + } + }) + .map(|release| release.artwork.clone()) + .filter(|artwork| !artwork.is_empty())?; + for url in apple_artwork_urls(&thumb) { + if let Some(bytes) = self.get_image_shared(&url).await { + if let Some(c) = decode_cover_async(bytes, "apple", false, url.clone()).await { + return Some(c); + } + } + } + None + } + + /// Name-only fallback used by standalone cover resolution. It deliberately + /// fails closed when Apple exposes more than one exact-name performer: a + /// cover request has no Deezer id/release evidence with which to choose. + async fn apple_artist_albums(&self, artist: &str) -> Arc { + let key = format!("name:{}", normalize(artist)); + if let Some(v) = self.apple_albums.lock().await.get(&key) { + return v.clone(); + } + let candidates = self.apple_artist_candidates(artist).await; + let catalog = Arc::new(select_apple_artist_catalog(&candidates, &[], false)); + let mut cache = self.apple_albums.lock().await; + if cache.len() >= 512 { + cache.clear(); + } + cache.insert(key, catalog.clone()); + catalog + } + + async fn apple_catalog_for_deezer( + &self, + deezer_id: u64, + artist: &str, + evidence: &[DeezerReleaseEvidence], + ) -> Arc { + let candidates = self.apple_artist_candidates(artist).await; + self.apple_catalog_for_deezer_candidates(deezer_id, artist, candidates, evidence) + .await + } + + async fn apple_catalog_for_deezer_candidates( + &self, + deezer_id: u64, + artist: &str, + candidates: Arc>, + evidence: &[DeezerReleaseEvidence], + ) -> Arc { + let key = format!("dz:{deezer_id}"); + let catalog = Arc::new(select_apple_artist_catalog(&candidates, evidence, true)); + log::debug!( + "Apple identity for {artist} (Deezer {deezer_id}): candidates={}, winner={:?}, confirmed={}, releases={}, rejected_titles={}, rejected_genres={}", + candidates.len(), + catalog.apple_artist_id, + catalog.identity_confirmed, + catalog.releases.len(), + catalog.foreign_titles.len(), + catalog.foreign_genres.len(), + ); + // Candidate fetching is the expensive part and is already shared. Do + // the tiny scoring pass for each caller's evidence so a first-50 + // overview cannot poison the exhaustive result. Only confirmed choices + // become reusable, and richer corroboration wins a cache race. + let mut cache = self.apple_albums.lock().await; + if catalog.identity_confirmed { + if cache.len() >= 512 && !cache.contains_key(&key) { + cache.clear(); + } + let replace = cache.get(&key).is_none_or(|current| { + !current.identity_confirmed + || (catalog.evidence_weight, catalog.evidence_count) + > (current.evidence_weight, current.evidence_count) + }); + if replace { + cache.insert(key, catalog.clone()); + } + } else if cache + .get(&key) + .is_some_and(|current| catalog.evidence_count > current.evidence_count) + { + // Richer evidence that becomes ambiguous invalidates a provisional + // confirmed cache entry instead of leaving cover work attached to + // the first-50 decision. + cache.remove(&key); + } + catalog + } + + /// Apple's discography as catalog records, for supplementing Deezer's + /// regional list. This is the unthrottled stand-in for the MusicBrainz + /// studio-album lookup: one `/lookup` request returns the artist's whole + /// catalogue (61 releases for Weezer, including the Deezer-missing ones), + /// where MusicBrainz costs a rate-limited request per artist at 1/second. + async fn apple_supplement_albums( + &self, + deezer_id: u64, + artist: &str, + catalog: &AppleArtistCatalog, + ) -> Vec { + if !catalog.identity_confirmed { + return Vec::new(); + } + let mut albums = Vec::new(); + let mut registered = Vec::new(); + for r in &catalog.releases { + let raw = r.title.trim(); + if raw.is_empty() { + continue; + } + // Apple's promo pseudo-albums ("iTunes Originals: …", "iTunes Pass: + // …", "iTunes Session/Festival/Live") are not part of the studio + // discography. No genuine album is titled starting with the word + // "iTunes", so drop them here — never in `fetch_apple_artist_albums`, + // which also feeds the cover resolver. + if is_itunes_promo(raw) { + continue; + } + // Continuous DJ mixes are streaming-only: their song lookup returns + // no tracks (verified live on the Defected/Yuksek Crazy P mixes), + // so a supplemented card would open with a Play button and nothing + // to play. Deezer's own playable mix compilations are unaffected — + // this filter only guards what Apple contributes. + if is_apple_dj_mix_title(raw) { + continue; + } + // Apple encodes the format in the TITLE, not `collectionType` (which + // says "Album" even for a single). Strip that tail so the title + // matches Deezer's, and read the type from it. + let (title, format_type) = strip_release_format_tail(raw); + let record_type = if let Some(format_type) = format_type { + format_type + } else if r + .collection_type + .as_deref() + .is_some_and(|t| t.eq_ignore_ascii_case("compilation")) + { + "compile" + } else { + "album" + }; + // Deezer's singles/EP coverage is already exhaustive (31 singles for + // Weezer). Apple is here only to fill ALBUM gaps — releases missing + // from Deezer's regional catalogue, such as Hurley. Contributing its + // singles just duplicates the grid. + if record_type != "album" { + continue; + } + // An "album" with a couple of tracks is a promo single Apple simply + // never tagged ("It's Working", 2 tracks, no " - Single" tail). + // Deezer's single coverage is already exhaustive; the supplement + // exists to fill ALBUM gaps. + if r.track_count.is_some_and(|n| n <= 3) { + continue; + } + // Apple ships an RFC3339 timestamp; the catalog wants a date. + let release_date = r + .release_date + .as_deref() + .map(|d| d.split('T').next().unwrap_or(d).to_string()); + let id = apple_synthetic_catalog_id(deezer_id, r, title); + // The FITTED size, not `apple_artwork_urls`' first entry — that one + // is the speculative 100000x100000-999 rewrite the cover resolver + // probes with fallbacks. A plain has no fallback, so handing it + // that URL renders a placeholder. `resolveCovers` upgrades this later. + let cover = (!r.artwork.is_empty()) + .then(|| apple_artwork_urls(&r.artwork).into_iter().last()) + .flatten(); + // Route an opened card back to iTunes for its tracklist. Without a + // collection id there is NOTHING to look up: `catalog_album` falls + // through to Deezer with a synthetic id it cannot know, and the + // card opens as an error. A card guaranteed dead on arrival does + // not belong on the grid. + let Some(collection_id) = r.collection_id else { + continue; + }; + registered.push(( + id, + AppleCatalogAlbum { + collection_id, + artist: artist.to_string(), + title: title.to_string(), + cover: cover.clone(), + release_date: release_date.clone(), + }, + )); + albums.push(CatalogAlbum { + id, + title: title.to_string(), + artist: artist.to_string(), + cover, + release_date, + record_type: Some(record_type.to_string()), + track_count: r.track_count, + edition_group: None, + is_soundtrack: false, + is_live: false, + }); + } + if !registered.is_empty() { + let mut registry = self.apple_catalog.lock().await; + if registry.len() > 1_000 { + registry.clear(); + } + for (id, entry) in registered { + registry.insert(id, entry); + } + } + albums + } + + /// Resolve *all* exact-normalized Apple performers, not whichever happens + /// to be the first search result. Lookups are concurrent but bounded; the + /// cache/inflight maps make overview, full page and cover work share them. + async fn apple_artist_candidates(&self, artist: &str) -> Arc> { + let want = normalize(artist); + if let Some(candidates) = self.apple_candidates.lock().await.get(&want) { + return candidates.clone(); + } + let fetch = { + let mut inflight = self.apple_candidates_inflight.lock().await; + inflight + .entry(want.clone()) + .or_insert_with(|| Arc::new(OnceCell::new())) + .clone() + }; + let candidates = fetch + .get_or_init(|| async { Arc::new(self.fetch_apple_artist_candidates(artist).await) }) + .await + .clone(); + // A complete zero-match search is a real, cacheable result. Transport + // or partial lookup failure is marked non-cacheable so the next page + // generation can retry instead of remaining unsafe until restart. + if candidates.iter().all(|candidate| candidate.cacheable) { + let mut cache = self.apple_candidates.lock().await; + if cache.len() >= 256 { + cache.clear(); + } + cache.insert(want.clone(), candidates.clone()); + } + let mut inflight = self.apple_candidates_inflight.lock().await; + if inflight + .get(&want) + .is_some_and(|current| Arc::ptr_eq(current, &fetch)) + { + inflight.remove(&want); + } + candidates + } + + async fn fetch_apple_artist_candidates(&self, artist: &str) -> Vec { + let want = normalize(artist); + let search = format!( + "https://itunes.apple.com/search?term={}&entity=musicArtist&limit=25", + urlencode(artist) + ); + let Some(resp) = self + .http + .get(&search) + .send() + .await + .ok() + .and_then(|response| response.error_for_status().ok()) + else { + return unavailable_apple_candidates(); + }; + let Some(body) = resp.json::().await.ok() else { + return unavailable_apple_candidates(); + }; + let Some(results) = body.get("results").and_then(|r| r.as_array()) else { + return unavailable_apple_candidates(); + }; + let mut seen = HashSet::new(); + let artist_ids: Vec = results + .iter() + .filter(|r| { + r.get("artistName") + .and_then(|a| a.as_str()) + .is_some_and(|a| normalize(a) == want) + }) + .filter_map(|r| r.get("artistId").and_then(|v| v.as_u64())) + .filter(|artist_id| seen.insert(*artist_id)) + .collect(); + if artist_ids.len() > APPLE_EXACT_ARTIST_CANDIDATE_LIMIT { + log::debug!( + "Apple identity for {artist} has {} exact-name candidates; failing closed before lookups", + artist_ids.len() + ); + // Preserve an explicit ambiguous shape. Returning [] would make the + // Bing gate mistake "too many to resolve safely" for "Apple found + // no namesake" and re-enable a name-only hero. + return artist_ids + .into_iter() + .take(2) + .map(|artist_id| AppleArtistCandidate { + artist_id, + releases: Vec::new(), + // Intentional ambiguity, not transport failure: selection + // and Bing still fail closed, but native Deezer rows render. + lookup_complete: true, + cacheable: true, + }) + .collect(); + } + let mut candidates = stream::iter(artist_ids) + .map(|artist_id| async move { self.fetch_apple_artist_candidate(artist_id).await }) + .buffer_unordered(6) + .collect::>() + .await; + candidates.sort_by_key(|candidate| candidate.artist_id); + candidates + } + + async fn fetch_apple_artist_candidate(&self, artist_id: u64) -> AppleArtistCandidate { + let lookup = + format!("https://itunes.apple.com/lookup?id={artist_id}&entity=album&limit=200"); + let releases = match self.http.get(&lookup).send().await { + Ok(resp) => match resp.error_for_status() { + Ok(resp) => match resp.json::().await { + Ok(body) => body + .get("results") + .and_then(|r| r.as_array()) + .map(|results| { + results + .iter() + .filter(|r| { + r.get("wrapperType").and_then(|w| w.as_str()) + == Some("collection") + && r.get("artistId") + .and_then(|id| id.as_u64()) + .is_none_or(|id| id == artist_id) + }) + .filter_map(|r| { + let name = r.get("collectionName").and_then(|c| c.as_str())?; + Some(AppleRelease { + title: name.to_string(), + title_norm: release_title_identity(name), + artwork: r + .get("artworkUrl100") + .and_then(|c| c.as_str()) + .unwrap_or_default() + .to_string(), + release_date: r + .get("releaseDate") + .and_then(|c| c.as_str()) + .map(String::from), + track_count: r + .get("trackCount") + .and_then(|c| c.as_u64()) + .map(|n| n as u32), + collection_type: r + .get("collectionType") + .and_then(|c| c.as_str()) + .map(String::from), + collection_id: r + .get("collectionId") + .and_then(|c| c.as_u64()), + }) + }) + .collect() + }), + Err(_) => None, + }, + Err(_) => None, + }, + Err(_) => None, + }; + let lookup_complete = releases.is_some(); + AppleArtistCandidate { + artist_id, + releases: releases.unwrap_or_default(), + lookup_complete, + cacheable: lookup_complete, + } + } + + /// Cover Art Archive originals — often high-resolution scans. `front` + /// redirects to the full uploaded image (front-500 is only a thumbnail). + async fn caa_cover_candidates( + &self, + release_mbid: Option<&str>, + release_group_mbid: Option<&str>, + ) -> Vec { + let urls = [ + release_mbid.map(|m| format!("https://coverartarchive.org/release/{m}/front")), + release_group_mbid + .map(|m| format!("https://coverartarchive.org/release-group/{m}/front")), + ]; + let mut out = Vec::new(); + for url in urls.into_iter().flatten() { + if let Some(bytes) = self.get_image_shared(&url).await { + if let Some(c) = + decode_cover_async(bytes, "cover-art-archive", false, url.clone()).await + { + out.push(c); + // The release image and the release-group image are almost + // always the same upload; one original is enough. + break; + } + } else { + log::debug!("no CAA art at {url}"); + } + } + out + } + + /// Best-effort artist portrait from Deezer's public catalog. The image is + /// cached locally by the app and is only used for the library UI. + pub async fn fetch_artist_art(&self, artist: &str) -> Option> { + let url = format!( + "https://api.deezer.com/search/artist?q={}&limit=1", + urlencode(artist) + ); + // Library imports call this once per artist, so it shares the same + // cache, single-flight and request-budget gate as every other Deezer + // read instead of spending an unpaced request beside them. + let body = self.deezer(&url).await?; + let image = body + .pointer("/data/0/picture_xl") + .and_then(|v| v.as_str())?; + self.get_image(image).await + } + + /// Fetch an image and return it as a data URL. Catalog covers live on + /// CDNs without CORS headers, so the webview can't canvas-sample them + /// directly — this proxies the bytes through Rust instead. + pub async fn fetch_image_data_url(&self, url: &str) -> Option { + let bytes = self.get_image(url).await?; + let mime = if bytes.starts_with(b"\x89PNG") { + "image/png" + } else if bytes.starts_with(b"GIF8") { + "image/gif" + } else { + "image/jpeg" + }; + Some(format!("data:{mime};base64,{}", base64(&bytes))) + } + + /// Raw provider bytes for the app's hero-only resampling path. Kept + /// separate from ordinary catalog art so covers and transparent logos are + /// never resized or converted to JPEG. + pub async fn fetch_image_bytes(&self, url: &str) -> Option> { + self.get_image(url).await + } + + /// Shared compressed bytes for the preview/GPU hero processors. These + /// commands frequently follow Bing scoring for the same URL, so retaining + /// the cache Arc avoids another multi-megabyte body copy at the IPC edge. + pub async fn hero_bytes(&self, url: &str) -> Option>> { + self.get_image_shared(url).await + } + + /// Public wrapper over the cached, single-flight image fetch — used by the + /// cover-logo extractor to pull album covers for OCR. + pub async fn cover_bytes(&self, url: &str) -> Option>> { + self.get_image_shared(url).await + } + + async fn get_image(&self, url: &str) -> Option> { + self.get_image_shared(url) + .await + .map(|bytes| bytes.as_ref().clone()) + } + + /// Cached raw image body without cloning it. The logo pipeline keeps several + /// compressed covers in flight, so sharing the cache's Arc avoids copying + /// multi-megabyte hi-res bodies at each phase boundary. + async fn get_image_shared(&self, url: &str) -> Option>> { + { + let cache = self.image_cache.lock().await; + if let Some((at, bytes)) = cache.get(url) { + if at.elapsed() < IMAGE_CACHE_TTL { + return Some(Arc::clone(bytes)); + } + } + } + + // The HD first-paint path and the 4K finisher intentionally race, but + // both begin with the same provider URL. Share that network transfer so + // the race costs one download rather than making the visible preview + // compete with an identical request for bandwidth and CDN connections. + let fetch = { + let mut inflight = self.image_inflight.lock().await; + inflight + .entry(url.to_string()) + .or_insert_with(|| Arc::new(OnceCell::new())) + .clone() + }; + let bytes = tokio::time::timeout( + Duration::from_secs(10), + fetch.get_or_init(|| async { + let mut resp = self.http.get(url).send().await.ok()?; + if !resp.status().is_success() { + return None; + } + if resp + .content_length() + .is_some_and(|length| length > IMAGE_DOWNLOAD_MAX_BYTES as u64) + { + return None; + } + let mut bytes = Vec::with_capacity( + resp.content_length() + .unwrap_or(0) + .min(IMAGE_DOWNLOAD_MAX_BYTES as u64) as usize, + ); + while let Some(chunk) = resp.chunk().await.ok()? { + if bytes.len().saturating_add(chunk.len()) > IMAGE_DOWNLOAD_MAX_BYTES { + return None; + } + bytes.extend_from_slice(&chunk); + } + (bytes.len() > 1000).then(|| Arc::new(bytes)) + }), + ) + .await + .ok() + .and_then(|bytes| bytes.clone()); + + if let Some(bytes) = bytes.as_ref() { + let mut cache = self.image_cache.lock().await; + cache.retain(|_, (at, _)| at.elapsed() < IMAGE_CACHE_TTL); + if bytes.len() <= IMAGE_CACHE_MAX_BYTES { + let mut total = cache.values().map(|(_, image)| image.len()).sum::(); + while total.saturating_add(bytes.len()) > IMAGE_CACHE_MAX_BYTES { + let oldest = cache + .iter() + .min_by_key(|(_, (at, _))| *at) + .map(|(key, _)| key.clone()); + let Some(oldest) = oldest else { break }; + if let Some((_, removed)) = cache.remove(&oldest) { + total = total.saturating_sub(removed.len()); + } + } + cache.insert(url.to_string(), (Instant::now(), Arc::clone(bytes))); + } + } + + let mut inflight = self.image_inflight.lock().await; + if inflight + .get(url) + .is_some_and(|current| Arc::ptr_eq(current, &fetch)) + { + inflight.remove(url); + } + bytes + } +} + +fn parse_artist(v: &serde_json::Value) -> Option { + Some(CatalogArtist { + id: v.get("id")?.as_u64()?, + name: v.get("name")?.as_str()?.to_string(), + picture: v + .get("picture_xl") + .or_else(|| v.get("picture_big")) + .or_else(|| v.get("picture_medium")) + .and_then(|p| p.as_str()) + .map(String::from), + album_count: v.get("nb_album").and_then(|n| n.as_u64()).map(|n| n as u32), + fans: v.get("nb_fan").and_then(|n| n.as_u64()), + }) +} + +fn parse_album(v: &serde_json::Value) -> Option { + Some(CatalogAlbum { + id: v.get("id")?.as_u64()?, + title: v.get("title")?.as_str()?.to_string(), + artist: v + .pointer("/artist/name") + .and_then(|a| a.as_str()) + .unwrap_or_default() + .to_string(), + cover: v + .get("cover_xl") + .or_else(|| v.get("cover_big")) + .or_else(|| v.get("cover_medium")) + .and_then(|c| c.as_str()) + .map(String::from), + release_date: v + .get("release_date") + .and_then(|d| d.as_str()) + .map(String::from), + record_type: v + .get("record_type") + .and_then(|r| r.as_str()) + .map(String::from), + track_count: v + .get("nb_tracks") + .and_then(|n| n.as_u64()) + .map(|n| n as u32), + edition_group: None, + is_soundtrack: detail_is_soundtrack(v), + // Search results carry no tracklist, so this is false there and is set + // later from the album-details pass; a full album fetch (catalog_album) + // does include /tracks/data and resolves it here directly. + is_live: detail_is_live(v), + }) +} + +/// Deezer classifies soundtrack releases as regular albums/singles. Its +/// Films/Games genre (173) is the strongest structured signal, while checking +/// the returned genre names makes this resilient if the primary genre differs. +fn detail_is_soundtrack(v: &serde_json::Value) -> bool { + if v.get("genre_id").and_then(|id| id.as_u64()) == Some(173) { + return true; + } + + v.pointer("/genres/data") + .and_then(|genres| genres.as_array()) + .is_some_and(|genres| { + genres.iter().any(|genre| { + let name = genre + .get("name") + .and_then(|name| name.as_str()) + .unwrap_or_default() + .to_lowercase(); + name == "films/games" || name.contains("film score") || name.contains("soundtrack") + }) + }) +} + +/// Whether a single track title carries a live-performance marker — "(Live)", +/// "(Live at the …)", "[Live]", or a "- Live" suffix. Deliberately anchored to +/// bracket/dash-prefixed forms so ordinary song titles that merely contain the +/// word (e.g. "Long Live", "Come Alive") do not match; the majority rule in +/// [`detail_is_live`] is the real guard, this just keeps individual matches +/// honest. +fn is_live_track_title(title: &str) -> bool { + let ln = title.to_lowercase(); + ln.contains("(live") + || ln.contains("[live") + || ln.contains("- live") + || ln.contains("– live") + || ln.contains("— live") + || ln.contains("live at ") + || ln.contains("live from ") + || ln.contains("live in ") + || ln.contains("live on ") +} + +/// A release is a live album when a clear majority of its tracks are marked as +/// live recordings. Reads the album detail's `/tracks/data`, so it only reports +/// true once the tracklist has actually been fetched. Requires at least three +/// tracks and ≥60% live-marked, so a studio album carrying a couple of live +/// bonus cuts is never misfiled. +fn detail_is_live(v: &serde_json::Value) -> bool { + let Some(tracks) = v.pointer("/tracks/data").and_then(|d| d.as_array()) else { + return false; + }; + let titles: Vec<&str> = tracks + .iter() + .filter_map(|t| t.get("title").and_then(|s| s.as_str())) + .collect(); + if titles.len() < 3 { + return false; + } + let live = titles.iter().filter(|t| is_live_track_title(t)).count(); + // live / total >= 3/5 (60%), without floating point. + live * 5 >= titles.len() * 3 +} + +fn parse_track(v: &serde_json::Value) -> Option { + Some(CatalogTrack { + id: v.get("id")?.as_u64()?, + title: v.get("title")?.as_str()?.to_string(), + artist: v + .pointer("/artist/name") + .and_then(|a| a.as_str()) + .unwrap_or_default() + .to_string(), + album: v + .pointer("/album/title") + .and_then(|a| a.as_str()) + .unwrap_or_default() + .to_string(), + album_id: v.pointer("/album/id").and_then(|a| a.as_u64()), + cover: v + .pointer("/album/cover_medium") + .or_else(|| v.pointer("/album/cover_big")) + .and_then(|c| c.as_str()) + .map(String::from), + duration_secs: v.get("duration").and_then(|d| d.as_u64()).map(|d| d as u32), + preview: v + .get("preview") + .and_then(|p| p.as_str()) + .filter(|p| !p.is_empty()) + .map(String::from), + track_no: v + .get("track_position") + .and_then(|t| t.as_u64()) + .map(|t| t as u32), + disk_number: v + .get("disk_number") + .and_then(|t| t.as_u64()) + .map(|t| t as u32), + unreleased: false, + }) +} + +/// Remove provider-added media format tails before any cross-provider title +/// comparison. Apple appends these to collection names while Deezer exposes the +/// format separately; comparing raw strings made namesake singles invisible to +/// both corroboration and rejection. +fn strip_release_format_tail(title: &str) -> (&str, Option<&'static str>) { + let title = title.trim(); + let lower = title.to_lowercase(); + const TAILS: [(&str, &str); 12] = [ + (" - single", "single"), + (" – single", "single"), + (" — single", "single"), + (" (single)", "single"), + (" - ep", "ep"), + (" – ep", "ep"), + (" — ep", "ep"), + (" (ep)", "ep"), + (" - album", "album"), + (" – album", "album"), + (" — album", "album"), + (" (album)", "album"), + ]; + for (suffix, record_type) in TAILS { + if lower.ends_with(suffix) { + let stripped = title[..title.len() - suffix.len()].trim_end(); + if !stripped.is_empty() { + return (stripped, Some(record_type)); + } + } + } + (title, None) +} + +fn release_title_identity(title: &str) -> String { + normalize(strip_release_format_tail(title).0) +} + +fn deezer_release_evidence(value: &serde_json::Value) -> Option { + let title_norm = release_title_identity(value.get("title")?.as_str()?); + if title_norm.is_empty() { + return None; + } + // Cap one viral release so arithmetic remains bounded. The base point lets + // several exact low-traffic matches still corroborate one another. + let fans = value + .get("fans") + .and_then(|fans| fans.as_u64()) + .unwrap_or_default() + .min(1_000_000); + Some(DeezerReleaseEvidence { + title_norm, + weight: fans + 1, + genre_id: value.get("genre_id").and_then(|id| id.as_u64()), + }) +} + +fn unavailable_apple_candidates() -> Vec { + // Two non-cacheable sentinels preserve the only safe facts after a failed + // search: identity is unavailable and may be ambiguous. The Bing gate sees + // len > 1, while overview/full return no stage and a later call retries. + [0_u64, u64::MAX] + .into_iter() + .map(|artist_id| AppleArtistCandidate { + artist_id, + releases: Vec::new(), + lookup_complete: false, + cacheable: false, + }) + .collect() +} + +/// Select one Apple performer by weighted title overlap with the releases +/// returned for the requested Deezer id. Multiple exact-name candidates with a +/// tied/no-overlap score are deliberately unresolved: supplements and name-only +/// cover upgrades then fail closed rather than choosing result zero. +fn select_apple_artist_catalog( + candidates: &[AppleArtistCandidate], + evidence: &[DeezerReleaseEvidence], + require_evidence: bool, +) -> AppleArtistCatalog { + if candidates.is_empty() { + return AppleArtistCatalog::default(); + } + + let mut evidence_by_title: HashMap)> = HashMap::new(); + for item in evidence { + let entry = evidence_by_title + .entry(item.title_norm.clone()) + .or_insert_with(|| (0, HashSet::new())); + entry.0 = entry.0.max(item.weight); + if let Some(genre_id) = item.genre_id { + entry.1.insert(genre_id); + } + } + if candidates.len() > 1 + && candidates + .iter() + .any(|candidate| !candidate.lookup_complete) + { + return AppleArtistCatalog { + evidence_count: evidence_by_title.len(), + identity_unavailable: true, + ..AppleArtistCatalog::default() + }; + } + + let mut scores: Vec<(usize, u64, usize, HashMap)> = candidates + .iter() + .enumerate() + .map(|(index, candidate)| { + let mut score = 0_u64; + let mut overlap_count = 0_usize; + let mut genres = HashMap::new(); + let mut seen_titles = HashSet::new(); + for release in &candidate.releases { + if release.title_norm.is_empty() || !seen_titles.insert(&release.title_norm) { + continue; + } + if let Some((weight, matched_genres)) = evidence_by_title.get(&release.title_norm) { + score = score.saturating_add(*weight); + overlap_count += 1; + for genre_id in matched_genres { + *genres.entry(*genre_id).or_insert(0) += 1; + } + } + } + (index, score, overlap_count, genres) + }) + .collect(); + scores.sort_by(|left, right| { + right + .1 + .cmp(&left.1) + .then_with(|| right.2.cmp(&left.2)) + .then_with(|| { + candidates[left.0] + .artist_id + .cmp(&candidates[right.0].artist_id) + }) + }); + + let winner_index = if candidates.len() == 1 { + (!require_evidence || scores[0].1 > 0).then_some(0) + } else { + let best = &scores[0]; + let runner_up = &scores[1]; + ((best.1, best.2) > (runner_up.1, runner_up.2) && best.1 > 0).then_some(best.0) + }; + let Some(winner_index) = winner_index else { + return AppleArtistCatalog { + evidence_count: evidence_by_title.len(), + ..AppleArtistCatalog::default() + }; + }; + + let evidence_weight = scores + .iter() + .find(|score| score.0 == winner_index) + .map(|score| score.1) + .unwrap_or_default(); + let winner_titles: HashSet = candidates[winner_index] + .releases + .iter() + .map(|release| release.title_norm.clone()) + .filter(|title| !title.is_empty()) + .collect(); + let winner_genres = scores + .iter() + .find(|score| score.0 == winner_index) + .map(|score| score.3.clone()) + .unwrap_or_default(); + let mut foreign_titles = HashSet::new(); + let mut foreign_genres = HashSet::new(); + for (index, candidate) in candidates.iter().enumerate() { + if index == winner_index { + continue; + } + foreign_titles.extend( + candidate + .releases + .iter() + .map(|release| release.title_norm.clone()) + .filter(|title| !title.is_empty() && !winner_titles.contains(title)), + ); + if let Some(score) = scores.iter().find(|score| score.0 == index) { + foreign_genres.extend( + score + .3 + .iter() + // One exact losing title is already removed by title. Only + // repeated same-candidate corroboration can expand that to + // a whole losing-only genre cluster. + .filter(|(_, count)| **count >= 2) + .map(|(genre_id, _)| *genre_id), + ); + } + } + // A genre shared by winner and loser is not identity evidence. This keeps + // legitimate collaborations and broad styles intact. + foreign_genres.retain(|genre_id| !winner_genres.contains_key(genre_id)); + AppleArtistCatalog { + releases: candidates[winner_index].releases.clone(), + foreign_titles, + foreign_genres, + apple_artist_id: Some(candidates[winner_index].artist_id), + evidence_weight, + evidence_count: evidence_by_title.len(), + identity_unavailable: false, + identity_confirmed: true, + } +} + +fn apple_release_is_foreign( + catalog: &AppleArtistCatalog, + title: &str, + genre_id: Option, +) -> bool { + catalog.identity_confirmed + && (catalog + .foreign_titles + .contains(&release_title_identity(title)) + || genre_id.is_some_and(|genre_id| catalog.foreign_genres.contains(&genre_id))) +} + +/// Top-track payloads retain featured appearances through the contributors +/// list. Missing identity fields are accepted (older provider shapes), but an +/// explicit different primary/contributor set is not. +fn track_belongs_to_artist(track: &serde_json::Value, artist_id: u64) -> bool { + let primary = track.pointer("/artist/id").and_then(|id| id.as_u64()); + let contributor_ids: Vec = track + .get("contributors") + .and_then(|contributors| contributors.as_array()) + .into_iter() + .flatten() + .filter_map(|contributor| contributor.get("id").and_then(|id| id.as_u64())) + .collect(); + primary == Some(artist_id) + || contributor_ids.contains(&artist_id) + || (primary.is_none() && contributor_ids.is_empty()) +} + +fn apple_synthetic_catalog_id(deezer_id: u64, release: &AppleRelease, stripped_title: &str) -> u64 { + synthetic_catalog_id(&format!( + "apple:dz:{deezer_id}:itunes:{}:{}", + release.collection_id.unwrap_or_default(), + normalize(stripped_title) + )) +} + +/// Apple promo pseudo-albums ("iTunes Originals: Weezer", "iTunes Pass: …", +/// "iTunes Session/Festival/Live"). They are real iTunes collections but not +/// part of an artist's studio discography, so the release supplement drops them. +fn is_itunes_promo(title: &str) -> bool { + title.trim_start().to_lowercase().starts_with("itunes ") +} + +/// Apple lists continuous DJ mixes ("Defected: Crazy P at Glitterbox … (DJ +/// Mix)", "Late Night Tales: MGMT (DJ Mix)") as ordinary albums, but the mix +/// is one streaming-only file: the iTunes song lookup returns ZERO tracks even +/// though collection metadata claims 14/30, so an injected card can never +/// render a tracklist or play anything. The bracketed "DJ Mix" tag is Apple's +/// own naming convention for exactly these releases. +fn is_apple_dj_mix_title(title: &str) -> bool { + let lower = title.to_lowercase(); + lower.contains("(dj mix)") + || lower.contains("[dj mix]") + || lower.contains("(continuous mix)") + || lower.contains("[continuous mix]") +} + +/// Collapse groups of same-work release rows down to their fullest member — +/// most tracks, then the longer (credited) title. Rows fold only when they +/// carry a release date and only inside one `date_prefix`-length bucket +/// (4 = same year for exact-title duplicates, 10 = same day for +/// featured-credit variants), so a genuine same-name re-release from another +/// year keeps its own card. +fn fold_duplicate_release_rows( + albums: &mut Vec, + groups: &[Vec], + date_prefix: usize, +) { + let mut drop_ids: HashSet = HashSet::new(); + for ids in groups { + let mut buckets: HashMap<&str, Vec> = HashMap::new(); + for (index, album) in albums.iter().enumerate() { + if !ids.contains(&album.id) { + continue; + } + let Some(date) = album.release_date.as_deref() else { + continue; + }; + buckets + .entry(&date[..date.len().min(date_prefix)]) + .or_default() + .push(index); + } + for indices in buckets.values() { + if indices.len() < 2 { + continue; + } + // Most tracks wins; on a tie the SHORTER title is the canonical + // base release ("All My Favorite Songs" over its later credited + // re-release), not whichever variant is wordier. + let keep = *indices + .iter() + .max_by_key(|&&index| { + ( + albums[index].track_count.unwrap_or(0), + std::cmp::Reverse(albums[index].title.len()), + ) + }) + .expect("bucket has at least two members"); + for &index in indices { + if index != keep { + drop_ids.insert(albums[index].id); + } + } + } + } + if !drop_ids.is_empty() { + albums.retain(|album| !drop_ids.contains(&album.id)); + } +} + +/// Collapse exact same-title re-releases ACROSS years — the label re-issuing +/// one single under a fresh id years later ("Step Into Christmas" 2018 and +/// 2022, "Today Was A Fairytale" 2010 and 2018). Unlike the year-bucketed +/// fold, this may only fire when the rows are provably the same material: +/// singles/EPs with equal or unknown track counts, or albums whose KNOWN +/// track counts agree at ≥5 tracks (a self-titled catalogue with two distinct +/// eponymous records must never merge on a guess). The OLDEST row survives — +/// original chronology is what the grid sorts by — preferring a row that +/// actually knows its track count. +fn fold_exact_rereleases(albums: &mut Vec, groups: &[Vec]) { + let mut drop_ids: HashSet = HashSet::new(); + for ids in groups { + let indices: Vec = albums + .iter() + .enumerate() + .filter(|(_, album)| ids.contains(&album.id) && !drop_ids.contains(&album.id)) + .map(|(index, _)| index) + .collect(); + if indices.len() < 2 { + continue; + } + let record_type = albums[indices[0]].record_type.as_deref().unwrap_or(""); + let counts: Vec> = indices.iter().map(|&i| albums[i].track_count).collect(); + let known: Vec = counts.iter().filter_map(|c| *c).collect(); + let counts_agree = known.windows(2).all(|w| w[0] == w[1]); + let same_material = match record_type { + "single" | "ep" => counts_agree, + "album" => { + counts_agree + && known.len() == indices.len() + && known.first().copied().unwrap_or(0) >= 5 + } + _ => false, + }; + if !same_material { + continue; + } + let keep = *indices + .iter() + .min_by_key(|&&index| { + ( + albums[index] + .release_date + .clone() + .unwrap_or_else(|| "9999".into()), + albums[index].track_count.is_none(), + ) + }) + .expect("group has at least two members"); + for &index in &indices { + if index != keep { + drop_ids.insert(albums[index].id); + } + } + } + if !drop_ids.is_empty() { + albums.retain(|album| !drop_ids.contains(&album.id)); + } +} + +/// Title with its featured-artist credit segment removed, normalized. Deezer +/// often carries one single twice — "Dancing In Babylon" and "Dancing In +/// Babylon (feat. Christine and the Queens)" — where the credit is the only +/// difference between the titles. +/// +/// Only the feat BRACKET itself is removed, never the rest of the title: on +/// "Beneath with Me (feat. Skylar Grey) (Kaskade's V.4)" the remix qualifier +/// after the credit is what makes it a different release, and an early cut +/// from the credit onward folded every such variant into the plain single. +/// All finds and slices run on one lowercased copy, so multi-byte lowercasing +/// cannot split a character; `normalize` lowercases anyway. +fn featless_title_identity(title: &str) -> String { + let lower = title.to_lowercase(); + let mut out = String::with_capacity(lower.len()); + let mut rest = lower.as_str(); + while let Some(at) = [ + "(feat.", + "[feat.", + "(ft.", + "[ft.", + "(featuring ", + "[featuring ", + ] + .iter() + .filter_map(|marker| rest.find(marker)) + .min() + { + out.push_str(&rest[..at]); + let closer = if rest[at..].starts_with('(') { + ')' + } else { + ']' + }; + match rest[at..].find(closer) { + Some(end) => rest = &rest[at + end + 1..], + None => { + rest = ""; + break; + } + } + } + out.push_str(rest); + if normalize(&out).is_empty() { + normalize(&lower) + } else { + normalize(&out) + } +} + +/// Whether the tail a longer title adds over a shorter one is a sequel or +/// volume marker ("Greatest Hits" → "Greatest Hits II", "Deep Cuts" → "Deep +/// Cuts 2"). Those are DIFFERENT works that happen to extend the name, so no +/// same-work rule may treat them as variants. Input is already normalized. +fn residual_is_sequel_marker(residual: &str) -> bool { + let residual = residual.trim_matches(|c: char| !c.is_alphanumeric()); + if residual.is_empty() { + return false; + } + matches!( + residual, + "i" | "ii" | "iii" | "iv" | "v" | "vi" | "vii" | "viii" | "ix" | "x" + ) || residual.chars().all(|c| c.is_ascii_digit()) && residual.len() <= 2 + || ["vol", "volume", "pt", "part", "chapter"] + .iter() + .any(|marker| { + residual.strip_prefix(marker).is_some_and(|n| { + !n.is_empty() + && n.chars() + .all(|c| c.is_ascii_digit() || matches!(c, 'i' | 'v' | 'x')) + }) + }) +} + +/// Whether the tail a variant title adds over its base names a known release +/// variant — an alternate mix, version, edition or companion cut of the SAME +/// work ("Revolver (2022 Mix)", "Higher Power (Acoustic Version)", "Triplicate +/// (Sampler)", "…: THE ANTHOLOGY"). Input is already normalized. +fn residual_is_release_variant(residual: &str) -> bool { + [ + "version", + "mix", + "mixes", + "edit", + "edits", + "acoustic", + "instrumental", + "demo", + "mono", + "stereo", + "sampler", + "extras", + "deluxe", + "remaster", + "edition", + "anniversary", + "expanded", + "anthology", + "collection", + "soundtrack", + "trackbytrack", + "reissue", + "redux", + ] + .iter() + .any(|marker| residual.contains(marker)) +} + +/// Days-since-epoch for a `YYYY-MM-DD`-prefixed date, for cheap date-gap +/// comparisons. Not a calendar library: month lengths are approximated, which +/// is safely within tolerance for a "released about a month apart" test. +fn release_date_days(date: &str) -> Option { + let mut parts = date.splitn(3, '-'); + let year: i64 = parts.next()?.parse().ok()?; + let month: i64 = parts + .next() + .and_then(|p| p.get(..2)) + .and_then(|p| p.parse().ok()) + .unwrap_or(6); + let day: i64 = parts + .next() + .and_then(|p| p.get(..2)) + .and_then(|p| p.parse().ok()) + .unwrap_or(15); + Some(year * 365 + month * 30 + day) +} + +fn is_edition_title(title: &str) -> bool { + let title = title.to_lowercase(); + [ + "deluxe", + "expanded", + "remaster", + "anniversary", + "special edition", + "collector's edition", + "collectors edition", + "legacy edition", + "bonus track", + "complete edition", + "definitive edition", + "reissue", + "mono edition", + "stereo edition", + // " edition" (space-prefixed so "Expedition" can't match) covers the + // bespoke forms the fixed list kept missing: "Platinum Edition", + // "Tour Edition", "3am Edition", "Drumless Edition". + " edition", + "half-speed", + "half speed", + "mono version", + "stereo version", + "track by track", + ] + .iter() + .any(|marker| title.contains(marker)) + // "…(2022 Mix)" / "(2010 Mono Version)" remix-remasters: a bracketed tail + // that is a year plus mix/version wording is an edition of the base + // record, not a new work. Kept narrow — a named remix package ("Kaskade's + // V.4") is NOT an edition. + || regex_lite_year_mix_tail(&title) +} + +/// `(… 2022 mix)` / `(2010 mono …)`-shaped bracketed tail, without pulling in +/// a regex dependency: a bracket segment containing a 4-digit year alongside +/// "mix" or "version". +fn regex_lite_year_mix_tail(lower_title: &str) -> bool { + let Some(open) = lower_title.rfind(['(', '[']) else { + return false; + }; + let segment = &lower_title[open + 1..]; + let segment = segment + .strip_suffix(|c| matches!(c, ')' | ']')) + .unwrap_or(segment); + let has_year = segment + .split(|c: char| !c.is_ascii_digit()) + .any(|run| run.len() == 4 && (run.starts_with("19") || run.starts_with("20"))); + has_year && (segment.contains("mix") || segment.contains("version")) +} + +/// Whether `title` contains one of `non_edition_titles` (already normalized) as a +/// substring other than itself — the signal that a bespoke-named reissue +/// ("OK Computer OKNOTOK 1997 2017") is built on an existing album ("OK +/// Computer") despite carrying no edition keyword. Length-guarded so a very +/// short album title cannot spuriously match. +fn contains_other_album_title(title: &str, non_edition_titles: &[String]) -> bool { + let n = normalize(title); + non_edition_titles + .iter() + .any(|t| t.len() >= 5 && n != *t && n.contains(t.as_str())) +} + +/// Best-matching non-edition original for an edition, by ordered track overlap +/// (`edition_track_score`). When `require_title_substring` is set, the original's +/// normalized title must also be a substring of the edition's — the extra guard +/// that lets bespoke reissues fold without absorbing an unrelated album. Returns +/// `(original_index, original_id)`. +fn best_edition_original( + edition_index: usize, + edition_norm_title: &str, + edition_tracks: &[String], + albums: &[CatalogAlbum], + tracklists: &HashMap>, + require_title_substring: bool, +) -> Option<(usize, u64)> { + albums + .iter() + .enumerate() + .filter(|(index, candidate)| { + *index != edition_index + && candidate.record_type.as_deref() == Some("album") + && !is_edition_title(&candidate.title) + }) + .filter_map(|(index, candidate)| { + let cand_norm = normalize(&candidate.title); + let title_substring = + cand_norm.len() >= 5 && edition_norm_title.contains(cand_norm.as_str()); + if require_title_substring && !title_substring { + return None; + } + let candidate_tracks = tracklists.get(&candidate.id)?; + edition_track_score(edition_tracks, candidate_tracks) + .map(|score| (title_substring, cand_norm.len(), score, index, candidate.id)) + }) + // The named base outranks a raw track-overlap winner, and the LONGEST + // named base is the nearest work: "1989 (Taylor's Version) (Deluxe)" + // belongs to "1989 (Taylor's Version)", never to "1989", even though + // the re-recorded tracklist matches both once suffixes normalize away. + .max_by_key(|(title_substring, base_len, score, _, _)| { + (*title_substring, *base_len, *score) + }) + .map(|(_, _, _, index, id)| (index, id)) +} + +/// Whether two normalized tracklists describe essentially the same release — +/// same track count and ≥90% shared titles. The signal that an Apple +/// "remaster"/reissue (e.g. "Weezer (2024 Remaster)") merely re-presents an +/// album Deezer already carries in full, so the gap-filler supplement should +/// drop it rather than show a redundant edition. +fn is_duplicate_tracklist(a: &[String], b: &[String]) -> bool { + if a.is_empty() || a.len() != b.len() { + return false; + } + let b_set: std::collections::HashSet<&String> = b.iter().collect(); + let shared = a.iter().filter(|t| b_set.contains(t)).count(); + shared * 10 >= a.len() * 9 +} + +/// Track identity for edition matching. Provider-added suffixes such as +/// "(2011 Remaster)" and "(Original Mix)" describe the recording presentation, +/// not a different song in the album's core sequence. +fn track_identity(title: &str) -> String { + let mut title = title.trim().to_lowercase(); + for opener in [" (", " [", " - ", " – ", " — "] { + if let Some(index) = title.rfind(opener) { + let suffix = &title[index + opener.len()..]; + if [ + "remaster", + "version", + " mix", + "mix)", + "edit", + "mono", + "stereo", + "live", + "demo", + "acoustic", + "instrumental", + ] + .iter() + .any(|marker| suffix.contains(marker)) + { + title.truncate(index); + } + } + } + normalize(&title) +} + +fn edition_track_score(edition: &[String], original: &[String]) -> Option { + let core = edition.len().min(original.len()).min(10); + if core < 5 { + return None; + } + let matches = edition + .iter() + .zip(original) + .take(core) + .filter(|(left, right)| !left.is_empty() && left == right) + .count(); + (matches >= 5 && matches * 4 >= core * 3).then_some(matches * 100 + core) +} + +/// Prefer exact day precision when both sources provide it. A year-only date +/// remains useful for older/obscure releases, where MusicBrainz sometimes has +/// no more precise first-release date. +/// Mirrors the frontend's upcoming-date test: a full date strictly after today, +/// or a partial (year / year-month) date not yet established as past. Keeps both +/// ends of the app in agreement about what "upcoming" means. +fn is_future_or_unresolved_date(value: Option<&str>) -> bool { + let Some(value) = value else { + return false; + }; + let today = today_ymd(); + if value.len() >= 10 { + return value[..10] > today[..10]; + } + if value.len() >= 7 { + return value[..7] >= today[..7]; + } + if value.len() >= 4 { + return value[..4] >= today[..4]; + } + false +} + +/// Today's UTC date as "YYYY-MM-DD". Day-level precision is all the +/// upcoming-release check needs, so a wall clock without a timezone crate is +/// fine here. +fn today_ymd() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or(0); + let (year, month, day) = civil_from_days((secs / 86_400) as i64); + format!("{year:04}-{month:02}-{day:02}") +} + +/// Days since the Unix epoch to a civil (year, month, day), via Howard +/// Hinnant's public-domain algorithm. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u32; + let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if month <= 2 { year + 1 } else { year }, month, day) +} + +fn same_original_date(left: Option<&str>, right: Option<&str>) -> bool { + let (Some(left), Some(right)) = (left, right) else { + return false; + }; + if left.len() >= 10 && right.len() >= 10 { + left[..10] == right[..10] + } else { + left.get(..4) == right.get(..4) + } +} + +fn exact_studio_album_title(album: &CatalogAlbum, normalized_title: &str) -> bool { + album.record_type.as_deref() == Some("album") && normalize(&album.title) == normalized_title +} + +/// MusicBrainz uses the canonical self-titled name while stores often append a +/// familiar disambiguator such as "(Blue Album)". Date + this shape safely +/// joins those aliases without baking artist-specific color names into code. +fn is_self_titled_album_alias(title: &str, artist: &str) -> bool { + let title_lower = title.trim().to_lowercase(); + let artist_lower = artist.trim().to_lowercase(); + let Some(rest) = title_lower.strip_prefix(&artist_lower) else { + return false; + }; + let rest = rest.trim(); + (rest.starts_with('(') || rest.starts_with('[')) && rest.contains("album") +} + +/// One decoded cover-art candidate considered by [`Metadata::fetch_art`]. +/// One resolved cover option in a [`CoverReport`]. +#[derive(serde::Serialize)] +pub struct CandInfo { + pub source: String, + pub url: String, + pub width: u32, + pub height: u32, + /// Perceptual (dHash) Hamming distance to the reference cover. `None` for + /// the reference itself or when no reference decoded. + pub distance: Option, + /// Whether the candidate is the same artwork as the reference (distance + /// within [`COVER_SAME_ART_MAX_DISTANCE`]). + pub eligible: bool, + pub chosen: bool, +} + +/// Diagnostic result of resolving the best same-artwork cover for a catalog +/// release across official (Apple, Deezer) and fallback (Cover Art Archive) +/// sources, all gated against the known-correct reference cover. +#[derive(serde::Serialize)] +pub struct CoverReport { + pub artist: String, + pub title: String, + pub reference: Option, + pub candidates: Vec, + pub chosen_source: Option, + pub chosen_url: Option, +} + +struct CoverCandidate { + bytes: Arc>, + width: u32, + height: u32, + /// 64-bit difference hash for same-artwork comparison across sources. + dhash: u64, + /// True for the Deezer cover — the artwork the catalog UI displays. + is_reference: bool, + source: &'static str, + /// The URL this candidate was fetched from (for diagnostics/selection). + url: String, +} + +/// Decode candidate bytes and keep only plausible front covers: square-ish +/// (grids/banners/booklet scans are not covers) and at least 300px. +fn decode_cover( + bytes: impl Into>>, + source: &'static str, + is_reference: bool, + url: String, +) -> Option { + let bytes = bytes.into(); + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes.as_slice())) + .with_guessed_format() + .ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(COVER_MAX_DECODE_DIMENSION); + limits.max_image_height = Some(COVER_MAX_DECODE_DIMENSION); + limits.max_alloc = Some(COVER_MAX_DECODE_ALLOC_BYTES); + reader.limits(limits); + let img = reader.decode().ok()?; + let (w, h) = (img.width(), img.height()); + if w < 300 || h < 300 { + return None; + } + let aspect = w as f64 / h as f64; + if !(0.8..=1.25).contains(&aspect) { + return None; + } + Some(CoverCandidate { + dhash: dhash64(&img), + bytes, + width: w, + height: h, + is_reference, + source, + url, + }) +} + +async fn decode_cover_async( + bytes: Arc>, + source: &'static str, + is_reference: bool, + url: String, +) -> Option { + static GATE: std::sync::OnceLock = std::sync::OnceLock::new(); + let gate = GATE.get_or_init(|| tokio::sync::Semaphore::new(COVER_DECODE_CONCURRENCY)); + let permit = gate.acquire().await.ok()?; + let decoded = + tokio::task::spawn_blocking(move || decode_cover(bytes, source, is_reference, url)) + .await + .ok() + .flatten(); + drop(permit); + decoded +} + +/// Standard 64-bit difference hash: 9×8 grayscale, one bit per +/// brighter-than-right-neighbour comparison. Robust to scaling and +/// recompression, which is exactly the difference between one artwork +/// served by different CDNs. +fn dhash64(img: &image::DynamicImage) -> u64 { + let gray = img + .resize_exact(9, 8, image::imageops::FilterType::Triangle) + .to_luma8(); + let mut bits = 0u64; + for y in 0..8 { + for x in 0..8 { + bits <<= 1; + if gray.get_pixel(x, y)[0] > gray.get_pixel(x + 1, y)[0] { + bits |= 1; + } + } + } + bits +} + +/// Hamming distance at or below which two covers count as the same artwork. +/// Same-art-different-CDN pairs land at 0–6; different covers cluster ≥20. +const COVER_SAME_ART_MAX_DISTANCE: u32 = 10; + +/// Pick the winning candidate index. With a Deezer reference present, only +/// same-artwork candidates are eligible, largest real resolution wins, and +/// the reference itself breaks ties (bytes already proven identical-looking). +/// Without one, the largest candidate wins. +fn pick_best_cover(candidates: &[CoverCandidate]) -> Option { + if candidates.is_empty() { + return None; + } + let reference = candidates.iter().find(|c| c.is_reference); + let eligible: Vec = match reference { + Some(r) => candidates + .iter() + .enumerate() + .filter(|(_, c)| (c.dhash ^ r.dhash).count_ones() <= COVER_SAME_ART_MAX_DISTANCE) + .map(|(i, _)| i) + .collect(), + None => (0..candidates.len()).collect(), + }; + eligible.into_iter().max_by_key(|&i| { + let c = &candidates[i]; + // Area first; the reference wins exact-size ties. + (u64::from(c.width) * u64::from(c.height), c.is_reference) + }) +} + +/// Size variants for an Apple `artworkUrl100` thumbnail, best first. mzstatic +/// renders any requested size: `100000x100000-999` returns the original +/// upload (commonly 3000×3000+), with fitted fallbacks when a CDN edge +/// rejects it. +fn apple_artwork_urls(thumb_url: &str) -> Vec { + let Some(slash) = thumb_url.rfind('/') else { + return vec![thumb_url.to_string()]; + }; + let base = &thumb_url[..slash + 1]; + [ + "100000x100000-999.jpg", + "3000x3000bb.jpg", + "1200x1200bb.jpg", + ] + .iter() + .map(|size| format!("{base}{size}")) + .collect() +} + +/// Selection tiebreak once resolution is equal: prefer official sources +/// (Apple, then Deezer) over Cover Art Archive. +fn cover_source_priority(source: &str) -> u8 { + match source { + "apple" => 4, + "deezer-max" | "deezer" => 3, + "cover-art-archive" => 2, + _ => 1, + } +} + +/// Extract the release-group MBID embedded in a Cover Art Archive URL, e.g. +/// `https://coverartarchive.org/release-group/{mbid}/front-500`. Returns `None` +/// for any other host, so Deezer covers never trigger a CAA/MusicBrainz path. +fn caa_release_group_mbid(url: &str) -> Option { + let rest = url.split("coverartarchive.org/release-group/").nth(1)?; + let mbid = rest.split('/').next()?; + // MBIDs are 36-char UUIDs; guard against odd inputs. + if mbid.len() == 36 && mbid.bytes().all(|b| b == b'-' || b.is_ascii_hexdigit()) { + Some(mbid.to_string()) + } else { + None + } +} + +/// Loose title equality for provider verification: normalized exact match or +/// containment either way ("Weezer (Deluxe Edition)" ↔ "Weezer"). +fn loose_name_match(got: &str, want: &str) -> bool { + !got.is_empty() && !want.is_empty() && (got == want || got.contains(want) || want.contains(got)) +} + +fn base64(data: &[u8]) -> String { + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + out.push(TABLE[(n >> 18) as usize & 63] as char); + out.push(TABLE[(n >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { + TABLE[(n >> 6) as usize & 63] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + TABLE[n as usize & 63] as char + } else { + '=' + }); + } + out +} + +fn escape(s: &str) -> String { + s.replace('"', "\\\"") +} + +/// Loose artist comparison: lowercase, alphanumerics only. +fn normalize(s: &str) -> String { + s.to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric()) + .collect() +} + +/// TheAudioDB can return several fuzzy/name-alike records. Never let its first +/// item bypass Bing with artwork for a different artist; require an exact +/// normalized display-name match and otherwise treat the provider as empty. +fn select_audiodb_artist<'a>( + body: &'a serde_json::Value, + requested: &str, +) -> Option<&'a serde_json::Value> { + let requested = normalize(requested); + body.get("artists")?.as_array()?.iter().find(|artist| { + artist + .get("strArtist") + .and_then(|name| name.as_str()) + .is_some_and(|name| normalize(name) == requested) + }) +} + +fn audiodb_deezer_id(record: &serde_json::Value) -> Option { + record + .get("strDeezerID") + .and_then(|value| value.as_str()) + .and_then(|value| value.trim().parse().ok()) +} + +/// Cross-provider identity gate for name-searched TheAudioDB records. Exact +/// names are deliberately insufficient: namesakes are the bug this protects. +/// Either TheAudioDB must carry the requested Deezer id itself, or its MBID +/// must equal the MB artist that links back to that Deezer id. +fn audiodb_artist_matches_identity( + record: &serde_json::Value, + deezer_id: u64, + linked_mbid: Option<&str>, +) -> bool { + if let Some(linked_mbid) = linked_mbid { + return record + .get("strMusicBrainzID") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some_and(|value| value.eq_ignore_ascii_case(linked_mbid)); + } + audiodb_deezer_id(record) == Some(deezer_id) +} + +#[cfg(test)] +fn deezer_search_allows_bing( + body: &serde_json::Value, + requested_name: &str, + requested_id: u64, +) -> bool { + deezer_exact_name_state(body, requested_name, requested_id) == DeezerExactNameState::Unambiguous +} + +/// A namesake only threatens a name-searched hero if the open web might mean +/// *it* instead. Deezer follower counts are the available prominence proxy, and +/// exact-name rows are dominated by dead duplicates and typo entries +/// (`crazy_p`, `Pond` x6) that no search engine ranks for. Requiring a decisive +/// margin keeps the real collisions — the several genuinely followed Ponds — +/// on the strict catalogue-anchored path. +const BING_NAME_OWNER_FAN_RATIO: u64 = 4; +/// Floor so two obscure rows (5 fans against 1) cannot manufacture a ratio. +const BING_NAME_OWNER_MIN_FANS: u64 = 25; + +/// Release-type evidence from a description's LEAD. Editorial prose declares +/// the record's kind in its first sentence ("…is a compilation album by…", +/// "…is a live album…", "…is the soundtrack to…"), which is direct evidence +/// the title heuristics can't see — a compilation named "Direct Hits" says so +/// here even when the title says nothing. Only the lead is scanned: later +/// prose mentions other records ("toured before releasing a live album…"). +/// Precedence mirrors the page's category order. +fn description_type_hint(description: &str) -> Option<&'static str> { + let lead: String = description.chars().take(260).collect(); + let lead = lead.to_lowercase(); + let any = |phrases: &[&str]| phrases.iter().any(|p| lead.contains(p)); + // A song or single article describes a TRACK, and every release word in + // its lead names a DIFFERENT record — "'Run for Cover' is a song by the + // Killers from their fifth studio album, Wonderful Wonderful" is not a + // studio-album confirmation for a release called "Run For Cover (Workout + // Mix)". Tested first so none of the checks below can mine prose that was + // never about the release being filed. + if lead_declares(&lead, "song") || lead_declares(&lead, "single") { + return Some("song"); + } + if any(&[ + "soundtrack", + "film score", + "original score", + "music to the film", + ]) { + return Some("soundtrack"); + } + if any(&[ + "live album", + "concert album", + "recorded live", + "live recording", + ]) { + return Some("live"); + } + if any(&[ + "compilation album", + "greatest hits", + "compilation of", + "best-of", + "box set", + "anthology", + ]) { + return Some("compilation"); + } + if any(&["remix album", "mix album", "album of remixes", "remix ep"]) { + return Some("remix"); + } + if any(&["extended play", "(ep)", " ep by", "debut ep"]) { + return Some("ep"); + } + if any(&["studio album"]) { + return Some("album"); + } + None +} + +/// True when the lead *declares itself* to be `noun` — "is a song by", "is the +/// second single from", "is a 2019 promotional single". +/// +/// Only the FIRST copula is read, because that is the one whose subject is the +/// article's own title; an encyclopedia lead opens by naming its subject's +/// kind. Any later one belongs to a different subject ("the record's third +/// single was a song written on tour" describes a track ON an album). The noun +/// must also be a whole word inside that one clause, so ordinary prose is not +/// read as a declaration and "song" is not found inside "songwriter". +fn lead_declares(lead: &str, noun: &str) -> bool { + const COPULAS: [&str; 5] = [" is a ", " is an ", " is the ", " was a ", " was the "]; + // Leads stack qualifiers between the copula and the noun ("is a 2019 + // promotional single"), but the noun always arrives early. + const WORDS: usize = 8; + let Some(clause) = COPULAS + .iter() + .filter_map(|copula| lead.find(copula).map(|at| at + copula.len())) + .min() + .map(|at| &lead[at..]) + else { + return false; + }; + clause + .split(['.', ',', ';', '(', ':']) + .next() + .unwrap_or_default() + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .take(WORDS) + .any(|word| word == noun) +} + +/// Deezer reports an exhausted request budget as `{"error":{"code":4,...}}` +/// under HTTP 200. That is the one provider error worth retrying: the window is +/// five seconds wide, and every other code describes the resource itself. +fn deezer_quota_error(body: &serde_json::Value) -> bool { + let Some(error) = body.get("error") else { + return false; + }; + error.get("code").and_then(|code| code.as_u64()) == Some(4) + || error + .get("message") + .and_then(|message| message.as_str()) + .is_some_and(|message| message.to_lowercase().contains("quota")) +} + +fn deezer_exact_name_state( + body: &serde_json::Value, + requested_name: &str, + requested_id: u64, +) -> DeezerExactNameState { + let wanted = normalize(requested_name); + let Some(results) = body.get("data").and_then(|data| data.as_array()) else { + return DeezerExactNameState::Missing; + }; + let mut exact: HashMap = HashMap::new(); + for result in results { + let Some(name) = result.get("name").and_then(|name| name.as_str()) else { + continue; + }; + if normalize(name) != wanted { + continue; + } + let Some(id) = result.get("id").and_then(|id| id.as_u64()) else { + continue; + }; + let fans = result + .get("nb_fan") + .and_then(|fans| fans.as_u64()) + .unwrap_or_default(); + exact + .entry(id) + .and_modify(|current| *current = (*current).max(fans)) + .or_insert(fans); + } + let Some(&requested_fans) = exact.get(&requested_id) else { + return DeezerExactNameState::Missing; + }; + if exact.len() == 1 { + return DeezerExactNameState::Unambiguous; + } + let rival_fans = exact + .iter() + .filter(|(id, _)| **id != requested_id) + .map(|(_, fans)| *fans) + .max() + .unwrap_or_default(); + let owns_the_name = requested_fans > 0 + && (rival_fans == 0 + || (requested_fans >= rival_fans.saturating_mul(BING_NAME_OWNER_FAN_RATIO) + && requested_fans >= BING_NAME_OWNER_MIN_FANS)); + if owns_the_name { + DeezerExactNameState::Dominant + } else { + DeezerExactNameState::Ambiguous + } +} + +/// Rank an artist name against an already-normalized query: exact match beats a +/// prefix beats a substring beats nothing. Keeps name relevance ahead of raw +/// popularity, so a "Pond" search surfaces bands actually named Pond before a +/// more-followed fuzzy match, then prefers the most-followed among equals. +fn artist_name_relevance(name: &str, query_key: &str) -> u8 { + if query_key.is_empty() { + return 0; + } + let name_key = normalize(name); + if name_key == query_key { + 3 + } else if name_key.starts_with(query_key) { + 2 + } else if name_key.contains(query_key) { + 1 + } else { + 0 + } +} + +/// Stable FNV-1a id in the upper half of JavaScript's safe-integer range, +/// safely outside Deezer's numeric ids while still round-tripping through IPC. +fn synthetic_catalog_id(key: &str) -> u64 { + let mut hash = 0xcbf29ce484222325_u64; + for byte in key.bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + (hash & ((1_u64 << 52) - 1)) | (1_u64 << 52) +} + +/// Parse each Bing image card's own `m="{...}"` attribute. Keeping this +/// object boundary is critical: the old ±byte window around `murl` could read +/// `t`/`purl` from a neighboring card and attach an unrelated photograph to +/// the requested artist. +fn parse_bing_image_results(html: &str) -> Vec { + let mut results = Vec::new(); + let bytes = html.as_bytes(); + let mut at = 0; + while let Some(relative) = html[at..].find('m') { + let name_at = at + relative; + at = name_at + 1; + if name_at == 0 || !bytes[name_at - 1].is_ascii_whitespace() { + continue; + } + let mut cursor = name_at + 1; + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + if bytes.get(cursor) != Some(&b'=') { + continue; + } + cursor += 1; + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + let Some(quote @ (b'\'' | b'"')) = bytes.get(cursor).copied() else { + continue; + }; + let start = cursor + 1; + let Some(length) = bytes[start..].iter().position(|byte| *byte == quote) else { + break; + }; + let encoded = &html[start..start + length]; + at = start + length + 1; + let decoded = decode_bing_html_attribute(encoded); + let Ok(value) = serde_json::from_str::(&decoded) else { + continue; + }; + let Some(url) = value.get("murl").and_then(|field| field.as_str()) else { + continue; + }; + let title = value + .get("t") + .and_then(|field| field.as_str()) + .unwrap_or_default(); + let page_url = value + .get("purl") + .and_then(|field| field.as_str()) + .unwrap_or_default(); + results.push(ParsedBingImage { + url: url.to_string(), + title: title.to_string(), + page_url: page_url.to_string(), + }); + } + results +} + +/// Bing HTML-encodes the JSON attribute but leaves JSON backslash escaping +/// intact. Decode only the entities observed in that attribute; ampersand is +/// last so an encoded URL query becomes a literal `&`, not a second decode. +fn decode_bing_html_attribute(value: &str) -> String { + value + .replace(""", "\"") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") +} + +fn diagnostic_field(value: &str) -> String { + value + .replace(['\t', '\r', '\n'], " ") + .chars() + .take(180) + .collect() +} + +/// Whether `text` names the artist. Char-level matching that ignores +/// separators but demands word boundaries at both ends: "Partiboi 69" matches +/// the artist "Partiboi69" (pages space names freely), while "Billy Cobham" +/// can never match "Billy Cobb" — the trailing 'h' continues the word, and +/// plain substring matching is NOT identity ("billycobb" ⊂ "billycobham"). +fn names_artist(text: &str, artist: &str) -> bool { + let haystack: Vec = text.to_lowercase().chars().collect(); + let needle: Vec = artist + .to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric()) + .collect(); + if needle.is_empty() { + return false; + } + for start in 0..haystack.len() { + if !haystack[start].is_alphanumeric() { + continue; + } + // Word boundary before the match. + if start > 0 && haystack[start - 1].is_alphanumeric() { + continue; + } + let mut hay_at = start; + let mut needle_at = 0; + while hay_at < haystack.len() && needle_at < needle.len() { + if !haystack[hay_at].is_alphanumeric() { + hay_at += 1; + continue; + } + if haystack[hay_at] != needle[needle_at] { + break; + } + hay_at += 1; + needle_at += 1; + } + // Word boundary after the match. + if needle_at == needle.len() + && (hay_at >= haystack.len() || !haystack[hay_at].is_alphanumeric()) + { + return true; + } + } + false +} + +/// Only the image file itself is independent identity evidence. A requested +/// name in a parent folder or tracking query must not bless a leaf file that +/// belongs to another act on a roundup/event page. +fn image_filename_names_artist(image_url: &str, artist: &str) -> bool { + reqwest::Url::parse(image_url) + .ok() + .and_then(|url| { + url.path_segments() + .and_then(|segments| segments.filter(|segment| !segment.is_empty()).last()) + .map(str::to_owned) + }) + .is_some_and(|filename| names_artist(&filename, artist)) +} + +/// Ticketmaster's DAM is used for both polished artist profile imagery and +/// event graphics. Trust only the former: the result must come from an +/// `/artist/` profile on Ticketmaster/Live Nation and use an artist DAM path. +/// Exact requested-artist identity is enforced separately by +/// `bing_identity_confidence`. +fn is_ticketing_artist_profile_image(page_url: &str, image_url: &str) -> bool { + let Ok(page) = reqwest::Url::parse(page_url) else { + return false; + }; + let Ok(image) = reqwest::Url::parse(image_url) else { + return false; + }; + let page_host = page.host_str().unwrap_or_default().to_lowercase(); + let image_host = image.host_str().unwrap_or_default().to_lowercase(); + let ticketing_page = host_is_or_subdomain(&page_host, "ticketmaster.com") + || host_is_or_subdomain(&page_host, "livenation.com"); + let artist_path = page + .path_segments() + .is_some_and(|mut segments| segments.any(|segment| segment == "artist")); + ticketing_page + && artist_path + && host_is_or_subdomain(&image_host, "ticketm.net") + && image.path().starts_with("/dam/a/") +} + +fn host_is_or_subdomain(host: &str, root: &str) -> bool { + host == root + || host + .strip_suffix(root) + .is_some_and(|prefix| prefix.ends_with('.')) +} + +fn is_ticketing_artist_profile_for_artist(page_url: &str, image_url: &str, artist: &str) -> bool { + let path_names_artist = reqwest::Url::parse(page_url) + .ok() + .is_some_and(|page| names_artist(page.path(), artist)); + is_ticketing_artist_profile_image(page_url, image_url) && path_names_artist +} + +/// Reject an image whose path explicitly names a different person sharing the +/// requested artist's surname. This intentionally applies only to simple +/// two-part artist names and only when the full requested name is absent from +/// the image URL, keeping the heuristic narrow enough for stage names and band +/// names. Generic path components such as `portrait-cobb.jpg` are not treated +/// as somebody's first name. +fn image_path_conflicts_with_two_part_artist(image_url: &str, artist: &str) -> bool { + let artist_tokens: Vec = artist + .to_lowercase() + .split(|c: char| !c.is_alphanumeric()) + .filter(|token| !token.is_empty()) + .map(str::to_owned) + .collect(); + if artist_tokens.len() != 2 || artist_tokens[0].len() < 3 || artist_tokens[1].len() < 3 { + return false; + } + + let Ok(url) = reqwest::Url::parse(image_url) else { + return false; + }; + let path_tokens: Vec = url + .path() + .to_lowercase() + .split(|c: char| !c.is_alphanumeric()) + .filter(|token| !token.is_empty()) + .map(str::to_owned) + .collect(); + const GENERIC_PREFIXES: [&str; 16] = [ + "artist", + "band", + "banner", + "concert", + "cover", + "hero", + "image", + "img", + "live", + "musician", + "photo", + "portrait", + "press", + "profile", + "stage", + "thumbnail", + ]; + + path_tokens.windows(2).any(|pair| { + let first = &pair[0]; + let surname = &pair[1]; + surname == &artist_tokens[1] + && first != &artist_tokens[0] + && first.len() >= 3 + && first.chars().all(char::is_alphabetic) + && !GENERIC_PREFIXES.contains(&first.as_str()) + }) +} + +/// Result galleries and stock-search pages can have an artist-specific title +/// and URL while showing an adjacent namesake's image (the Billy Cobb search +/// returned jazz drummer Jimmy Cobb this way). They are not identity evidence. +fn is_unreliable_image_aggregate(title: &str, page_url: &str, image_url: &str) -> bool { + let title = title.to_lowercase(); + let page = page_url.to_lowercase(); + const TITLE_MARKERS: [&str; 14] = [ + "stock photo", + "stock photos", + "stock images", + "premium photo", + "premium ai image", + "ai generated", + "free photo", + "photos and images", + "pictures and images", + "high-res pictures", + "photo gallery", + "image gallery", + "wallpapers - wallpaper", + "wallpaper gallery", + ]; + if TITLE_MARKERS.iter().any(|marker| title.contains(marker)) { + return true; + } + const STOCK_HOSTS: [&str; 24] = [ + "gettyimages.com", + "gettyimages.co.uk", + "shutterstock.com", + "alamy.com", + "dreamstime.com", + "depositphotos.com", + "freepik.com", + "vecteezy.com", + "istockphoto.com", + "123rf.com", + "wallpapercave.com", + "wallpapersafari.com", + "unsplash.com", + "pxhere.com", + "stockcake.com", + "motionarray.com", + "artlist.io", + "pexels.com", + "pixabay.com", + "stock.adobe.com", + "quotesgram.com", + "quotefancy.com", + "brainyquote.com", + "azquotes.com", + ]; + let stock_host = [page_url, image_url].into_iter().any(|url| { + reqwest::Url::parse(url) + .ok() + .and_then(|parsed| parsed.host_str().map(str::to_lowercase)) + .is_some_and(|host| { + STOCK_HOSTS + .iter() + .any(|root| host_is_or_subdomain(&host, root)) + }) + }); + let image = image_url.to_lowercase(); + let hosted_html_search_spam = reqwest::Url::parse(page_url).ok().is_some_and(|url| { + url.host_str() + .is_some_and(|host| host_is_or_subdomain(host, "storage.googleapis.com")) + && url.path().to_lowercase().ends_with(".html") + }); + stock_host + || hosted_html_search_spam + || page.contains("/image-search") + || image.contains("shutterstock_") + || image.contains("shutterstock-") +} + +fn bing_identity_confidence( + title: &str, + page_url: &str, + image_url: &str, + artist: &str, +) -> Option { + let title_names = names_artist(title, artist); + let ticketing_profile = is_ticketing_artist_profile_image(page_url, image_url); + // Ticketing query strings and fragments are tracking-controlled metadata, + // not performer identity. On an official profile, match the requested act + // only against the route itself so `...?ref=billy-cobb` cannot bless a + // Jimmy Cobb profile. + let page_names = if ticketing_profile { + reqwest::Url::parse(page_url) + .ok() + .is_some_and(|page| names_artist(page.path(), artist)) + } else { + names_artist(page_url, artist) + }; + if !title_names && !page_names { + return None; + } + if is_unreliable_image_aggregate(title, page_url, image_url) { + return None; + } + let trusted_profile = ticketing_profile && page_names; + if !trusted_profile + && ambiguous_artist_result_lacks_entity_anchor(title, page_url, image_url, artist) + { + return None; + } + let image_names = image_filename_names_artist(image_url, artist); + // Ticketmaster/Live Nation DAM imagery is authoritative only when the + // artist-profile URL itself names the requested artist. A misleading Bing + // title must never turn another performer's profile into Strong evidence. + if ticketing_profile && !page_names { + return None; + } + if image_names || (title_names && page_names) || trusted_profile { + Some(BingIdentityConfidence::Strong) + } else { + Some(BingIdentityConfidence::Weak) + } +} + +fn distinctive_bing_release_anchor(title: &str, artist: &str) -> Option { + let anchor = normalized_word_text(strip_release_format_tail(title).0); + let artist = normalized_word_text(artist); + if anchor.is_empty() || normalized_phrase_present(&anchor, &artist) || anchor == artist { + return None; + } + let tokens: Vec<&str> = anchor.split_whitespace().collect(); + let alphanumeric_len = anchor + .chars() + .filter(|character| character.is_alphanumeric()) + .count(); + const GENERIC_SINGLE_TITLES: [&str; 15] = [ + "album", + "best", + "changes", + "collection", + "compilation", + "greatest", + "hits", + "later", + "live", + "remaster", + "remastered", + "remixed", + "remixes", + "single", + "the", + ]; + let distinctive = if tokens.len() >= 2 { + alphanumeric_len >= 8 + } else { + alphanumeric_len >= 8 && !GENERIC_SINGLE_TITLES.contains(&tokens[0]) + }; + distinctive.then_some(anchor) +} + +fn musicbrainz_identity_people(body: &serde_json::Value, artist: &str) -> HashSet { + let artist_name = normalized_word_text(artist); + body.get("relations") + .and_then(|relations| relations.as_array()) + .into_iter() + .flatten() + .filter(|relation| relation.get("ended").and_then(|value| value.as_bool()) != Some(true)) + .filter(|relation| { + matches!( + relation.get("type").and_then(|value| value.as_str()), + Some("member of band" | "founder") + ) + }) + .filter_map(|relation| { + relation + .pointer("/artist/name") + .and_then(|value| value.as_str()) + }) + .map(normalized_word_text) + .filter(|name| { + name != &artist_name + && name.split_whitespace().count() >= 2 + && name + .chars() + .filter(|character| character.is_alphanumeric()) + .count() + >= 6 + }) + .collect() +} + +/// Winner-only release titles are the bridge between a numeric Deezer id and +/// name-searched Bing metadata. Shared/self-titled/generic releases are not an +/// independent identity signal and are deliberately absent from this set. +fn bing_catalog_release_anchors( + candidates: &[AppleArtistCandidate], + catalog: &AppleArtistCatalog, + artist: &str, +) -> HashSet { + let Some(winner_id) = catalog.apple_artist_id else { + return HashSet::new(); + }; + let Some(winner) = candidates + .iter() + .find(|candidate| candidate.artist_id == winner_id) + else { + return HashSet::new(); + }; + let losing_titles: HashSet<&str> = candidates + .iter() + .filter(|candidate| candidate.artist_id != winner_id) + .flat_map(|candidate| candidate.releases.iter()) + .map(|release| release.title_norm.as_str()) + .collect(); + winner + .releases + .iter() + .filter(|release| !losing_titles.contains(release.title_norm.as_str())) + .filter_map(|release| distinctive_bing_release_anchor(&release.title, artist)) + .collect() +} + +fn normalized_phrase_present(context: &str, phrase: &str) -> bool { + if context.is_empty() || phrase.is_empty() { + return false; + } + let padded_context = format!(" {context} "); + let padded_phrase = format!(" {phrase} "); + padded_context.contains(&padded_phrase) +} + +fn bing_candidate_anchor_context(candidate: &BingImageCandidate) -> String { + let page_path = reqwest::Url::parse(&candidate.page_url) + .ok() + .map(|url| url.path().to_string()) + .unwrap_or_default(); + let image_leaf = reqwest::Url::parse(&candidate.url) + .ok() + .and_then(|url| { + url.path_segments() + .and_then(|segments| segments.filter(|segment| !segment.is_empty()).last()) + .map(str::to_owned) + }) + .unwrap_or_default(); + normalized_word_text(&format!("{} {page_path} {image_leaf}", candidate.title)) +} + +fn bing_candidate_matches_anchors( + candidate: &BingImageCandidate, + anchors: &HashSet, +) -> bool { + if anchors.is_empty() { + return false; + } + let context = bing_candidate_anchor_context(candidate); + anchors + .iter() + .any(|anchor| normalized_phrase_present(&context, anchor)) +} + +fn bing_page_host(page_url: &str) -> Option { + reqwest::Url::parse(page_url) + .ok()? + .host_str() + .map(|host| host.trim_start_matches("www.").to_lowercase()) +} + +fn bing_host_names_artist(host: &str, artist: &str) -> bool { + let label = host.split('.').next().unwrap_or_default(); + !label.is_empty() && normalize(label) == normalize(artist) +} + +fn bing_candidate_can_corroborate_artist_host( + candidate: &BingImageCandidate, + policy: &BingArtistIdentityPolicy, + artist: &str, +) -> bool { + let BingArtistIdentityPolicy::CatalogAnchored { release_titles, .. } = policy else { + return false; + }; + candidate.identity == BingIdentityConfidence::Strong + && bing_candidate_matches_anchors(candidate, release_titles) + && bing_page_host(&candidate.page_url) + .is_some_and(|host| bing_host_names_artist(&host, artist)) +} + +/// Unambiguous and name-owner artists keep the full per-card result set: the +/// card rules already decided identity, and anything stricter throws away the +/// press/portrait photography that makes the best heroes. +fn filter_bing_candidates_for_policy( + candidates: Vec, + policy: &BingArtistIdentityPolicy, + artist: &str, + linked_people: &HashSet, +) -> Vec { + let BingArtistIdentityPolicy::CatalogAnchored { release_titles } = policy else { + return candidates + .into_iter() + .filter(|candidate| candidate.display_eligible) + .collect(); + }; + + // A product/release result cannot display, but when it names a distinctive + // winner-only release on a host whose own label is the artist name, it can + // corroborate sibling press-photo cards from that exact host. Never spread + // this trust across generic editorial hosts or merely similar domains. + let corroborated_hosts: HashSet = candidates + .iter() + .filter(|candidate| bing_candidate_can_corroborate_artist_host(candidate, policy, artist)) + .filter_map(|candidate| bing_page_host(&candidate.page_url)) + .collect(); + + candidates + .into_iter() + .filter(|candidate| candidate.display_eligible) + .filter(|candidate| candidate.identity == BingIdentityConfidence::Strong) + .filter(|candidate| { + bing_candidate_matches_anchors(candidate, release_titles) + || bing_candidate_matches_anchors(candidate, linked_people) + || bing_page_host(&candidate.page_url) + .is_some_and(|host| corroborated_hosts.contains(&host)) + }) + .collect() +} + +fn bing_candidate_metadata_is_better( + candidate: &BingImageCandidate, + existing: &BingImageCandidate, +) -> bool { + let priority = |value: &BingImageCandidate| { + ( + value.display_eligible, + is_ticketing_artist_profile_image(&value.page_url, &value.url), + value.identity == BingIdentityConfidence::Strong, + std::cmp::Reverse(value.query_index), + std::cmp::Reverse(value.rank_in_query), + ) + }; + priority(candidate) > priority(existing) +} + +fn select_scored_hero_urls( + mut scored: Vec<(String, f64, HeroFingerprint, BingIdentityConfidence)>, +) -> (Vec, Vec<(String, String)>) { + // Metadata confidence is only meaningful after the URL has downloaded, + // decoded, and passed quality checks. A broken Strong result is absent from + // this list and therefore cannot erase a usable Weak fallback. + let has_scored_strong = scored + .iter() + .any(|(_, _, _, identity)| *identity == BingIdentityConfidence::Strong); + if has_scored_strong { + scored.retain(|(_, _, _, identity)| *identity == BingIdentityConfidence::Strong); + } + scored.sort_by(|left, right| right.1.total_cmp(&left.1)); + let limit = if has_scored_strong { 3 } else { 1 }; + let mut selected: Vec<(String, HeroFingerprint)> = Vec::new(); + let mut duplicates = Vec::new(); + for (url, _, fingerprint, _) in scored { + if let Some((kept_url, _)) = selected + .iter() + .find(|(_, kept_fingerprint)| hero_fingerprints_match(&fingerprint, kept_fingerprint)) + { + duplicates.push((kept_url.clone(), url)); + continue; + } + selected.push((url, fingerprint)); + if selected.len() == limit { + break; + } + } + ( + selected.into_iter().map(|(url, _)| url).collect(), + duplicates, + ) +} + +#[cfg(test)] +fn select_strong_hero_seed( + mut scored: Vec<(String, f64, HeroFingerprint, BingIdentityConfidence)>, +) -> Option { + scored.retain(|(_, _, _, identity)| *identity == BingIdentityConfidence::Strong); + select_scored_hero_urls(scored).0.into_iter().next() +} + +fn normalized_word_text(value: &str) -> String { + value + .to_lowercase() + .chars() + .map(|character| { + if character.is_alphanumeric() { + character + } else { + ' ' + } + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn image_filename_starts_with_artist(image_url: &str, artist: &str) -> bool { + let Some(filename) = reqwest::Url::parse(image_url).ok().and_then(|url| { + url.path_segments() + .and_then(|segments| segments.filter(|segment| !segment.is_empty()).last()) + .map(str::to_owned) + }) else { + return false; + }; + let filename = normalized_word_text(&filename); + let artist = normalized_word_text(artist); + filename == artist || filename.starts_with(&format!("{artist} ")) +} + +/// Some exact artist names are ordinary search nouns. "Equipment" is the +/// verified stress fixture: quoted music queries otherwise return literal DJ +/// gear, load-in stock photos, and recording hardware whose title, page slug, +/// and filename all repeat the word. For this known collision, require grammar +/// that treats the word as a named act, a structured artist route, or a leaf +/// that begins with the act name. This keeps the real BrooklynVegan result while +/// refusing endless replacement stock photos. +fn ambiguous_artist_result_lacks_entity_anchor( + title: &str, + page_url: &str, + image_url: &str, + artist: &str, +) -> bool { + let artist_key = normalized_word_text(artist); + if artist_key == "wednesday" { + let context = normalized_word_text(&format!("{title} {page_url} {image_url}")); + let entity_token = ["album", "band", "concert", "tour"] + .iter() + .any(|marker| context.split_whitespace().any(|token| token == *marker)); + const NAMED_MUSIC_CONTEXT: [&str; 8] = [ + "alt country", + "band interview", + "elderberry wine", + "indie rock", + "karly hartzman", + "new song", + "new video", + "newmusic", + ]; + return !entity_token + && !NAMED_MUSIC_CONTEXT + .iter() + .any(|marker| context.contains(marker)); + } + if artist_key == "mspaint" { + let raw_context = format!("{title} {page_url} {image_url}").to_lowercase(); + if !raw_context.contains("mspaint") { + return true; + } + let context = normalized_word_text(&raw_context); + const MUSIC_ENTITY_CONTEXT: [&str; 10] = [ + "album", + "band", + "concert", + "ep", + "hardcore", + "interview", + "musician", + "performer", + "song", + "tour", + ]; + const NAMED_MUSIC_CONTEXT: [&str; 4] = + ["audiotree", "post american", "synth punk", "artist mspaint"]; + return !MUSIC_ENTITY_CONTEXT + .iter() + .any(|marker| context.split_whitespace().any(|token| token == *marker)) + && !NAMED_MUSIC_CONTEXT + .iter() + .any(|marker| context.contains(marker)); + } + if artist_key == "weatherday" { + let context = normalized_word_text(&format!("{title} {page_url} {image_url}")); + const MUSIC_ENTITY_CONTEXT: [&str; 14] = [ + "album", + "band", + "concert", + "discography", + "interview", + "live", + "music", + "musician", + "performance", + "profile", + "review", + "single", + "song", + "tour", + ]; + return !MUSIC_ENTITY_CONTEXT + .iter() + .any(|marker| context.split_whitespace().any(|token| token == *marker)); + } + if artist_key != "equipment" { + return false; + } + + let title = normalized_word_text(title); + let page = normalized_word_text(page_url); + let leaf_anchor = image_filename_starts_with_artist(image_url, artist); + let title_tokens: Vec<&str> = title.split_whitespace().collect(); + const GENERIC_LEADING_FOLLOWS: [&str; 13] = [ + "essentials", + "for", + "guide", + "hire", + "image", + "list", + "needed", + "rental", + "setup", + "that", + "tools", + "used", + "you", + ]; + let artist_leading_title = title_tokens.first() == Some(&"equipment") + && title_tokens + .get(1) + .is_none_or(|next| !GENERIC_LEADING_FOLLOWS.contains(next)); + const ENTITY_PHRASES: [&str; 15] = [ + "band equipment announce", + "band equipment interview", + "band equipment release", + "band equipment share", + "equipment announce", + "equipment band", + "equipment concert", + "equipment interview", + "equipment live", + "equipment release", + "equipment releases", + "equipment share", + "equipment shares", + "equipment tour", + "meet equipment", + ]; + let entity_phrase = ENTITY_PHRASES.iter().any(|phrase| title.contains(phrase)); + let structured_artist_page = [ + " artist equipment", + " artists equipment", + " equipment bandcamp com", + ] + .iter() + .any(|phrase| page.contains(phrase)); + + !(entity_phrase || structured_artist_page || (leaf_anchor && artist_leading_title)) +} + +/// A one-word artist name can appear as the object in a generic music-photo +/// description (for example, "sound equipment" or "bands equipment"). Such +/// wording is not performer identity when the raster's own filename is also +/// generic. This deliberately does not reject the inverse, "Equipment band", +/// or an artist-named leaf such as `equipment-press.jpg`. +fn single_word_artist_used_as_generic_noun( + title: &str, + page_url: &str, + image_url: &str, + artist: &str, +) -> bool { + if image_filename_names_artist(image_url, artist) { + return false; + } + let artist_tokens: Vec<&str> = artist + .split(|character: char| !character.is_alphanumeric()) + .filter(|token| !token.is_empty()) + .collect(); + if artist_tokens.len() != 1 { + return false; + } + let artist_token = artist_tokens[0].to_lowercase(); + let context = normalized_word_text(&format!("{title} {page_url}")); + let tokens: Vec<&str> = context.split_whitespace().collect(); + const GENERIC_PREFIXES: [&str; 10] = [ + "audio", + "bands", + "dj", + "music", + "musical", + "recording", + "sound", + "stage", + "studio", + "technical", + ]; + tokens + .windows(2) + .any(|pair| pair[1] == artist_token && GENERIC_PREFIXES.contains(&pair[0])) +} + +/// For multilingual co-bill headlines, require corroboration that the image +/// leaf names the *other* titled subject. This catches `... y Gerardo Niva` +/// paired with `gerardo-niva.jpeg` without treating ordinary prose such as +/// "Artist y su nuevo album" as a second performer. +fn image_filename_names_other_title_subject(title: &str, image_url: &str) -> bool { + let Some(filename) = reqwest::Url::parse(image_url).ok().and_then(|url| { + url.path_segments() + .and_then(|segments| segments.filter(|segment| !segment.is_empty()).last()) + .map(str::to_owned) + }) else { + return false; + }; + const GENERIC_TOKENS: [&str; 14] = [ + "asset", "banner", "hero", "image", "img", "jpeg", "jpg", "large", "original", "photo", + "png", "press", "scaled", "webp", + ]; + let filename = normalized_word_text(&filename); + let filename_tokens: Vec<&str> = filename + .split_whitespace() + .filter(|token| { + token.len() >= 3 + && token.chars().all(char::is_alphabetic) + && !GENERIC_TOKENS.contains(token) + }) + .collect(); + let title = normalized_word_text(title); + filename_tokens + .windows(2) + .any(|pair| title.contains(&format!("{} {}", pair[0], pair[1]))) +} + +/// Return the byte span of an artist name while ignoring punctuation and +/// spacing inside it. This mirrors `names_artist` for the ASCII artist names +/// used by the co-bill fixtures, while retaining offsets into the original +/// title so the adjacent proper-name grammar can still be inspected. +fn artist_name_span_in_title(title: &str, artist: &str) -> Option<(usize, usize)> { + let needle: Vec = artist + .chars() + .filter(|character| character.is_alphanumeric()) + .map(|character| character.to_ascii_lowercase()) + .collect(); + if needle.is_empty() { + return None; + } + let characters: Vec<(usize, char)> = title.char_indices().collect(); + for start in 0..characters.len() { + if !characters[start].1.is_alphanumeric() + || (start > 0 && characters[start - 1].1.is_alphanumeric()) + { + continue; + } + let mut title_at = start; + let mut needle_at = 0; + let mut end = characters[start].0; + while title_at < characters.len() && needle_at < needle.len() { + let (byte_at, character) = characters[title_at]; + if !character.is_alphanumeric() { + title_at += 1; + continue; + } + if character.to_ascii_lowercase() != needle[needle_at] { + break; + } + end = byte_at + character.len_utf8(); + title_at += 1; + needle_at += 1; + } + if needle_at == needle.len() + && (title_at >= characters.len() || !characters[title_at].1.is_alphanumeric()) + { + return Some((characters[start].0, end)); + } + } + None +} + +/// Detect `Other Artist & Requested Artist` / `... and ...` even when the image +/// CDN uses an opaque hash and the title spaces an artist name differently than +/// the catalog. Two proper-name words remain the conservative default; a lone +/// all-caps act is also sufficient, and `&` permits a lone title-cased act. +/// Ordinary prose such as "Artist and the new album" therefore stays eligible. +fn title_has_two_act_conjunction(title: &str, artist: &str) -> bool { + fn name_edge_looks_like_act(segment: &str, from_end: bool, ampersand: bool) -> bool { + const NON_NAME_WORDS: [&str; 12] = [ + "a", "an", "the", "new", "live", "music", "album", "song", "release", "releases", + "share", "shares", + ]; + let words: Vec<&str> = segment + .split_whitespace() + .map(|word| word.trim_matches(|character: char| !character.is_alphanumeric())) + .filter(|word| !word.is_empty()) + .collect(); + let edge: Vec<&str> = if from_end { + words.iter().rev().take(2).copied().collect() + } else { + words.iter().take(2).copied().collect() + }; + let proper_word = |word: &str| { + let lower = word.to_lowercase(); + word.chars().next().is_some_and(char::is_uppercase) + && word + .chars() + .filter(|character| character.is_alphabetic()) + .count() + >= 2 + && !NON_NAME_WORDS.contains(&lower.as_str()) + }; + if edge.len() >= 2 && edge.iter().take(2).all(|word| proper_word(word)) { + return true; + } + let Some(adjacent) = edge.first() else { + return false; + }; + let alphabetic: Vec = adjacent + .chars() + .filter(|character| character.is_alphabetic()) + .collect(); + proper_word(adjacent) + && (ampersand + || (alphabetic.len() >= 2 + && alphabetic.iter().all(|character| character.is_uppercase()))) + } + + let Some((artist_start, artist_end)) = artist_name_span_in_title(title, artist) else { + return false; + }; + // ASCII-only folding preserves byte offsets even when the headline uses + // typographic punctuation (for example `Queue’s Nala and Nikki Nair`). + let lower = title.to_ascii_lowercase(); + for conjunction in [" & ", " and "] { + if lower[..artist_start].ends_with(conjunction) { + let other_end = artist_start - conjunction.len(); + if name_edge_looks_like_act(&title[..other_end], true, conjunction.trim() == "&") { + return true; + } + } + if lower[artist_end..].starts_with(conjunction) { + let other_start = artist_end + conjunction.len(); + if name_edge_looks_like_act(&title[other_start..], false, conjunction.trim() == "&") { + return true; + } + } + } + false +} + +/// Music roundups often put several artist names in one result title while +/// Bing attaches just one of the article's photos. If the headline is clearly +/// multi-artist and the image filename itself names another act, it is not a +/// reliable identity match. Artist-specific filenames remain eligible. +fn multi_artist_result_mismatches_image(title: &str, image_url: &str, artist: &str) -> bool { + let lower = title.to_lowercase(); + let listing_text = lower.replace(&artist.to_lowercase(), " "); + let image_names_artist = image_filename_names_artist(image_url, artist); + let image_names_other_subject = + image_filename_names_other_title_subject(&listing_text, image_url); + let two_act_conjunction = title_has_two_act_conjunction(title, artist); + let plus_count = listing_text.matches(" + ").count(); + let star_count = listing_text.matches(" * ").count(); + let bullet_count = listing_text.matches(" • ").count() + listing_text.matches(" · ").count(); + if plus_count >= 2 { + return true; + } + if star_count >= 2 && !image_names_artist { + return true; + } + if bullet_count >= 2 && !image_names_artist { + return true; + } + // Even a two-act bill is ambiguous when the image filename/CDN gives no + // artist identity. Artist-specific editorial filenames remain eligible. + let event_with = listing_text.contains(" with ") + && (listing_text.contains(" at ") + || listing_text.contains(" tickets") + || listing_text.contains(" live ")); + let explicit_cobill = plus_count == 1 + || listing_text.contains(" w/ ") + || event_with + || listing_text.contains(" feat. ") + || listing_text.contains(" featuring ") + || listing_text.contains(" vs. ") + || listing_text.contains(" versus ") + || listing_text.contains(" x ") + || two_act_conjunction + || ([" y ", " con ", " junto a ", " et ", " und "] + .iter() + .any(|separator| listing_text.contains(separator)) + && image_names_other_subject); + // A collaboration graphic can name both the requested artist and the + // collaborator in its filename, so `image_names_artist` alone is not + // enough. This was the Nikki Nair + Hudson Mohawke illustrated release + // card. Requiring a named second subject in the leaf keeps ordinary prose + // such as "Artist and their new album" eligible. + let named_collaboration = [ + " + ", + " & ", + " and ", + " with ", + " feat. ", + " featuring ", + " x ", + " y ", + " con ", + " junto a ", + " et ", + " und ", + ] + .iter() + .any(|separator| listing_text.contains(separator)) + && image_names_other_subject; + if named_collaboration { + return true; + } + if explicit_cobill && !image_names_artist { + return true; + } + let separator_count = listing_text.matches(',').count() + + listing_text.matches(" & ").count() + + listing_text.matches(" with ").count() + + listing_text.matches(" feat. ").count() + + listing_text.matches(" featuring ").count() + + listing_text.matches(" | ").count() + + listing_text.matches(" / ").count() + + listing_text.matches(';').count() + + bullet_count; + separator_count >= 2 && !image_names_artist +} + +/// True for Bing results that are unambiguously merchandise, a physical music +/// format, or a shop listing rather than a photograph of the performer. Keep +/// this conservative: page/image URL structure is the strongest signal, while +/// titles must use explicit product vocabulary. A review that merely mentions +/// an album is not rejected. +fn is_product_image_result(title: &str, page_url: &str, image_url: &str) -> bool { + let urls = format!("{} {}", page_url, image_url).to_lowercase(); + const COMMERCE_URL_MARKERS: [&str; 29] = [ + "/product/", + "/products/", + "/collections/", + "/shop/", + "/store/", + "/merch/", + "bandcamp.com/merch", + "bigcartel.com", + "cdn.shopify.com", + "merchbar.com", + "merchbucket.com", + "product-images", + "product_images", + "productimage", + "product-media", + "product_media", + "mockup", + "bandcamp.com/album/", + "bcbits.com/img/", + "/upc/", + "i.ebayimg.com/", + "genius.com/albums/", + "images.genius.com/", + "albumoftheyear.org/album/", + "cdn.albumoftheyear.org/album/", + "mzstatic.com/image/thumb/music", + "geo-media.beatport.com/image_size/", + "/r/cassettereleases/", + "/r/vinylreleases/", + ]; + if COMMERCE_URL_MARKERS + .iter() + .any(|marker| urls.contains(marker)) + { + return true; + } + + // Bing titles sometimes describe an editorial announcement while the + // attached raster is explicitly the physical release. Inspect only the + // image leaf (never parent directories/query strings): `...-VINYL.jpg`, + // `album-cover.png`, and similar product filenames cannot become heroes. + let image_leaf = reqwest::Url::parse(image_url) + .ok() + .and_then(|url| { + url.path_segments() + .and_then(|segments| segments.filter(|segment| !segment.is_empty()).last()) + .map(str::to_owned) + }) + .unwrap_or_default(); + let leaf_words = normalized_word_text(&image_leaf); + let leaf_tokens: HashSet<&str> = leaf_words.split_whitespace().collect(); + if ["vinyl", "cassette", "mockup", "merch", "merchandise"] + .iter() + .any(|token| leaf_tokens.contains(token)) + || ["album cover", "cover art", "record sleeve"] + .iter() + .any(|phrase| normalized_phrase_present(&leaf_words, phrase)) + { + return true; + } + + let lower = title.to_lowercase(); + const PRODUCT_PHRASES: [&str; 18] = [ + "album cover", + "album art", + "album stream", + "full album", + "remastered album", + "cover art", + "colored vinyl", + "coloured vinyl", + "limited edition vinyl", + "record pressing", + "vinyl pressing", + "pre-order", + "preorder", + "t-shirt", + "tee shirt", + "physical edition", + "cassette release", + "vinyl release", + ]; + if PRODUCT_PHRASES.iter().any(|phrase| lower.contains(phrase)) { + return true; + } + + // Token matching avoids traps such as `cd` inside "record" or `lp` inside + // "help". These formats are explicit enough that the result is product + // imagery even when Bing points directly at a generic CDN. + lower.split(|c: char| !c.is_alphanumeric()).any(|word| { + matches!( + word, + "vinyl" + | "cassette" + | "merch" + | "merchandise" + | "tshirt" + | "mockup" + | "preorder" + | "lp" + | "cd" + ) + }) +} + +/// Bing can label channel branding and music visualizers as "photos". Reject +/// those deterministic false positives while still allowing a YouTube result +/// when its title explicitly describes a human-oriented interview, live set, +/// session, performance, concert, or podcast. This keeps Bing as the source — +/// there is no direct social-provider scraping — but prevents a channel banner +/// or full-album thumbnail from becoming the artist hero. +fn is_unsuitable_bing_result(title: &str, page_url: &str, image_url: &str, artist: &str) -> bool { + if is_product_image_result(title, page_url, image_url) { + return true; + } + + let page = page_url.to_lowercase(); + let image = image_url.to_lowercase(); + let lower = title.to_lowercase(); + let trusted_ticketing_profile = + is_ticketing_artist_profile_for_artist(page_url, image_url, artist); + let image_names_artist = image_filename_names_artist(image_url, artist); + if page.contains("music.youtube.com/channel/") + || image.contains("yt3.googleusercontent.com/") + || image.contains("yt3.ggpht.com/") + || page.contains("://twitter.com/") + || page.contains("://www.twitter.com/") + || page.contains("://x.com/") + || page.contains("://www.x.com/") + || page.contains("://instagram.com/") + || page.contains("://www.instagram.com/") + || page.contains("://facebook.com/") + || page.contains("://www.facebook.com/") + || page.contains("://pinterest.com/") + || page.contains("://www.pinterest.com/") + || page.contains("://deviantart.com/") + || page.contains("://www.deviantart.com/") + || page.contains("://artstation.com/") + || page.contains("://www.artstation.com/") + || page.contains("://www.wikipedia.org/") + || page.contains("://en.wikipedia.org/") + || page.contains("://commons.wikimedia.org/") + || page.contains("://reddit.com/") + || page.contains("://www.reddit.com/") + || image.contains("pbs.twimg.com/") + || image.contains("lookaside.instagram.com/") + || image.contains("lookaside.fbsbx.com/") + || image.contains("i.pinimg.com/") + || image.contains("upload.wikimedia.org/") + || image.contains("preview.redd.it/") + || image.contains("external-preview.redd.it/") + || image.contains("/production/artworks/") + || page.contains("ra.co/events/") + || page.contains("ra.co/event/") + || ((page.contains("://www.ticketmaster.com/") + || page.contains("://ticketmaster.com/") + || page.contains("://www.livenation.com/") + || page.contains("://livenation.com/")) + && page.contains("/event/")) + || page.contains("/tickets/") + || (page.contains("/podcast/") && image.contains("podcast")) + || (page.contains("soundcloud.com/") && image.contains("sndcdn.com/artworks-")) + { + return true; + } + + // Mixmag's recurring "The Mix" assets carry a baked-in series label and + // corner logo even when the raster is otherwise photographic. Prefer the + // unbranded press portrait from the same result set. + if (lower.starts_with("the mix ") || lower.starts_with("the mix:")) + && image.contains("the_mix__") + { + return true; + } + + if single_word_artist_used_as_generic_noun(title, page_url, image_url, artist) { + return true; + } + + // Event listings overwhelmingly contribute designed flyers rather than + // artist photography. Keep this title-based instead of rejecting every + // `/events/` page: some venues use genuine press photos on event pages. + const EVENT_GRAPHIC_CONTEXT: [&str; 11] = [ + " present:", + " presents:", + " presents ", + "takeover", + " tickets", + " w/ ", + "tour poster", + "concert poster", + "event poster", + "show flyer", + "lineup poster", + ]; + if EVENT_GRAPHIC_CONTEXT + .iter() + .any(|context| lower.contains(context)) + && !trusted_ticketing_profile + && !image_names_artist + { + return true; + } + + // A venue/event page naming multiple acts often attaches the headliner's + // photo or a shared bill graphic. It is not safe identity evidence for the + // requested support artist unless the image URL itself names that artist. + let event_listing = page.contains("/event/") || page.contains("/events/"); + let listing_text = lower.replace(&artist.to_lowercase(), " "); + let multilingual_cobill = [" y ", " con ", " junto a ", " et ", " und "] + .iter() + .any(|separator| listing_text.contains(separator)) + && image_filename_names_other_title_subject(&listing_text, image_url); + let multi_act_listing = listing_text.contains(" w/ ") + || listing_text.matches(" * ").count() >= 2 + || listing_text.matches(" • ").count() >= 2 + || listing_text.matches(" · ").count() >= 2 + || listing_text.contains(" x ") + || multilingual_cobill + || (listing_text.contains(" with ") + && (listing_text.contains(" at ") || listing_text.contains(" live "))); + if event_listing && multi_act_listing && !trusted_ticketing_profile && !image_names_artist { + return true; + } + + // Exact artist names can collide with companies (the Hermeth musician vs + // Hermeth Corporation is the fixture). These terms are strong business or + // industrial context and do not describe an artist photograph. + let result_context = normalized_word_text(&format!("{lower} {page} {image}")); + const NON_MUSIC_BUSINESS_CONTEXT: [&str; 8] = [ + "corporation", + "gmbh", + "machinery", + "maschinenbau", + "manufacturing", + "industrial equipment", + "engineering company", + "cnc machining", + ]; + if NON_MUSIC_BUSINESS_CONTEXT + .iter() + .any(|context| result_context.contains(context)) + { + return true; + } + + // "Wednesday" is both an exact artist name and a cluster of much larger + // film/TV and music entities. Bing frequently satisfies quoted musician + // searches with those namesakes, so require explicit disambiguation here + // rather than letting a generic `wednesday.jpg` leaf become identity. + if normalized_word_text(artist) == "wednesday" { + const WEDNESDAY_COLLISIONS: [&str; 46] = [ + "wednesday addams", + "addams family", + "nevermore academy", + "jenna ortega", + "hunter doohan", + "fielder jewett", + "luyanda lewis nyawo", + "lady gaga", + "dead dance", + "soundtrack", + "netflix", + "wednesday season", + "wednesday show dance scene", + "wednesday dance scene", + "dance scene", + "wednesday campanella", + "wednesday 13", + "scratchcard wednesday", + "ra co dj wednesday", + "special wednesday dj set", + "dj for event", + "wednesday profile icons", + "wednesday profil simgeleri", + "wednesday tv series", + "film producer", + "hollywood first look", + "music supervisor", + "executive producer", + "exec producer", + "second season", + "co showrunner", + "coshowrunner", + "peaky blinders", + "psychological thriller", + "portrait that sees back", + "concept poster", + "charcoal drawing", + "portrait drawing", + "behance net gallery", + "dribbble com shots", + "etsy com listing", + "hueforge", + "makerworld", + "move to ireland", + "digital art", + "wednesday dj", + ]; + if WEDNESDAY_COLLISIONS + .iter() + .any(|context| result_context.contains(context)) + { + return true; + } + } + + // MSPAINT the band collides with Microsoft's Paint application. Keep the + // band spelling fully eligible, but refuse deterministic operating-system, + // app, and UI contexts even when the image leaf itself says `MS-Paint`. + if normalized_word_text(artist) == "mspaint" { + const MSPAINT_APP_COLLISIONS: [&str; 10] = [ + "microsoft paint", + "ms paint for windows", + "paint for windows", + "paint app", + "paint application", + "windows 10", + "windows 11", + "windows insider", + "windowslatest com", + "untitled paint", + ]; + if MSPAINT_APP_COLLISIONS + .iter() + .any(|context| result_context.contains(context)) + { + return true; + } + } + + let video_result = page.contains("youtube.com/watch") + || page.contains("youtube.com/live") + || page.contains("youtu.be/") + || page.contains("vimeo.com/") + || image.contains("vimeocdn.com/"); + if video_result && !has_human_video_context(&lower) { + return true; + } + + false +} + +fn has_human_video_context(lower_title: &str) -> bool { + const HUMAN_VIDEO_CONTEXT: [&str; 7] = [ + "interview", + " live", + "session", + "performance", + "concert", + "podcast", + "behind the scenes", + ]; + HUMAN_VIDEO_CONTEXT + .iter() + .any(|context| lower_title.contains(context)) +} + +/// Search-only visual prior against isolated products on white/light-gray +/// canvases. It is deliberately a penalty, not a rejection: a legitimate +/// white-studio portrait can still win when it is the artist's only usable +/// image. Sampling only the outer eighth avoids mistaking pale faces/clothing +/// in the subject area for a product background. +fn search_product_background_penalty(image: &image::DynamicImage) -> f64 { + let rgb = image.thumbnail(192, 192).to_rgb8(); + let (w, h) = rgb.dimensions(); + if w == 0 || h == 0 { + return 1.0; + } + let band = (w.min(h) / 8).max(1); + let mut sampled = 0_u64; + let mut near_white = 0_u64; + for y in 0..h { + for x in 0..w { + if x >= band && x < w - band && y >= band && y < h - band { + continue; + } + sampled += 1; + let [r, g, b] = rgb.get_pixel(x, y).0; + let high = r.max(g).max(b); + let low = r.min(g).min(b); + if low >= 225 && high - low <= 20 { + near_white += 1; + } + } + } + let fraction = near_white as f64 / sampled.max(1) as f64; + if fraction >= 0.80 { + 0.14 + } else if fraction >= 0.65 { + 0.25 + } else if fraction >= 0.45 { + 0.50 + } else if fraction >= 0.30 { + 0.75 + } else { + 1.0 + } +} + +/// Conservative visual prior for text/logos burned into a search image. The +/// thumbnail is scanned for near-white-on-dark and near-black-on-light strokes, +/// then only strokes concentrated into consecutive horizontal rows count. That +/// row structure is common to captions and poster typography but much less +/// common to ordinary photographic detail. This intentionally does not attempt +/// OCR: it is fast, dependency-free, and language/letterform agnostic. +fn search_text_overlay_penalty(image: &image::DynamicImage) -> f64 { + let rgb = image.thumbnail(384, 216).to_rgb8(); + let (w, h) = rgb.dimensions(); + if w < 8 || h < 8 { + return 1.0; + } + + let mut row_hits = vec![0_u32; h as usize]; + for y in 2..h - 2 { + for x in 2..w - 2 { + let [r, g, b] = rgb.get_pixel(x, y).0; + let center = (u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114) / 1_000; + let neutral = r.max(g).max(b) - r.min(g).min(b) <= 28; + let neighbors = [ + rgb.get_pixel(x - 2, y).0, + rgb.get_pixel(x + 2, y).0, + rgb.get_pixel(x, y - 2).0, + rgb.get_pixel(x, y + 2).0, + ]; + let neighbor_luma = neighbors.map(|[nr, ng, nb]| { + (u32::from(nr) * 299 + u32::from(ng) * 587 + u32::from(nb) * 114) / 1_000 + }); + let darkest = neighbor_luma.iter().copied().min().unwrap_or(center); + let lightest = neighbor_luma.iter().copied().max().unwrap_or(center); + let bright_stroke = neutral && center >= 220 && darkest <= 100; + let dark_stroke = neutral && center <= 35 && lightest >= 180; + if bright_stroke || dark_stroke { + row_hits[y as usize] += 1; + } + } + } + + // Ignore isolated high-contrast rows (eyes, jewelry, stage lights). Text + // produces at least three adjacent busy rows and usually one compact band. + let busy_threshold = (w / 75).max(3); + let mut structured_hits = 0_u64; + let mut structured_rows = 0_u32; + let mut row = 0_usize; + while row < row_hits.len() { + if row_hits[row] < busy_threshold { + row += 1; + continue; + } + let start = row; + while row < row_hits.len() && row_hits[row] >= busy_threshold { + row += 1; + } + let band_rows = row - start; + // Dense contrast covering most of the frame is photographic texture + // (murals, foliage, stage rigs), not a caption band. + if band_rows >= 3 && band_rows <= (h as usize / 3).max(3) { + structured_rows += (row - start) as u32; + structured_hits += row_hits[start..row] + .iter() + .map(|hits| u64::from(*hits)) + .sum::(); + } + } + + let pixel_fraction = structured_hits as f64 / (u64::from(w) * u64::from(h)) as f64; + let row_fraction = f64::from(structured_rows) / f64::from(h); + if pixel_fraction >= 0.015 || row_fraction >= 0.16 { + 0.55 + } else if pixel_fraction >= 0.008 || row_fraction >= 0.08 { + 0.70 + } else if pixel_fraction >= 0.004 || row_fraction >= 0.04 { + 0.84 + } else { + 1.0 + } +} + +/// Whether the raster contains enough near-uniform neighboring pixels to look +/// like a designed card rather than a photograph. On its own this is not a +/// rejection signal (studio portraits often have flat backdrops); the scorer +/// combines it with a strong text-overlay signal. +fn search_has_flat_graphic_layout(image: &image::DynamicImage) -> bool { + let rgb = image.thumbnail(192, 192).to_rgb8(); + let (w, h) = rgb.dimensions(); + if w < 2 || h < 2 { + return false; + } + + let mut flat = 0_u64; + let mut compared = 0_u64; + for y in 0..h - 1 { + for x in 0..w - 1 { + let [r, g, b] = rgb.get_pixel(x, y).0; + for [nr, ng, nb] in [rgb.get_pixel(x + 1, y).0, rgb.get_pixel(x, y + 1).0] { + compared += 1; + let difference = + r.abs_diff(nr) as u16 + g.abs_diff(ng) as u16 + b.abs_diff(nb) as u16; + if difference <= 18 { + flat += 1; + } + } + } + } + flat as f64 / compared.max(1) as f64 >= 0.65 +} + +/// Stronger flat-art signal used only alongside release/headline metadata. +/// Entropy keeps dark studio photos with a flat backdrop from being rejected +/// merely because most neighboring pixels are similar. +fn search_has_low_palette_graphic(image: &image::DynamicImage) -> bool { + let rgb = image + .resize_exact(192, 108, image::imageops::FilterType::Triangle) + .to_rgb8(); + let (w, h) = rgb.dimensions(); + let mut histogram = [0_u32; 512]; + let mut flat = 0_u64; + let mut compared = 0_u64; + for y in 0..h { + for x in 0..w { + let [r, g, b] = rgb.get_pixel(x, y).0; + let bin = (usize::from(r >> 5) << 6) | (usize::from(g >> 5) << 3) | usize::from(b >> 5); + histogram[bin] += 1; + for [nr, ng, nb] in [ + (x + 1 < w).then(|| rgb.get_pixel(x + 1, y).0), + (y + 1 < h).then(|| rgb.get_pixel(x, y + 1).0), + ] + .into_iter() + .flatten() + { + compared += 1; + let difference = + r.abs_diff(nr) as u16 + g.abs_diff(ng) as u16 + b.abs_diff(nb) as u16; + if difference <= 18 { + flat += 1; + } + } + } + } + let pixels = f64::from(w * h); + let entropy = histogram.iter().fold(0.0, |sum, count| { + if *count == 0 { + sum + } else { + let probability = f64::from(*count) / pixels; + sum - probability * probability.log2() + } + }); + flat as f64 / compared.max(1) as f64 >= 0.82 && entropy <= 3.0 +} + +/// Detect contact-sheet/album-art mosaics by looking for several strong cuts +/// that span nearly a full axis. Ordinary photo edges are locally strong but +/// average out across the frame; a panel boundary remains strong for almost +/// every row or column. Outer five-percent borders are ignored so letterboxing +/// and scanned-photo frames do not count as panels. +fn search_has_panel_collage_layout(image: &image::DynamicImage) -> bool { + let rgb = image + .resize_exact(384, 216, image::imageops::FilterType::Triangle) + .to_rgb8(); + + fn cut_groups(rgb: &image::RgbImage, vertical: bool) -> usize { + let (width, height) = rgb.dimensions(); + let axis_len = if vertical { width } else { height }; + let span_len = if vertical { height } else { width }; + if axis_len < 20 || span_len == 0 { + return 0; + } + let margin = (axis_len / 20).max(1); + let mut groups = 0_usize; + let mut previous_hit: Option = None; + for position in 1..axis_len { + if position <= margin || position >= axis_len.saturating_sub(margin) { + continue; + } + let mut difference = 0_u64; + for offset in 0..span_len { + let (before, after) = if vertical { + ( + rgb.get_pixel(position - 1, offset).0, + rgb.get_pixel(position, offset).0, + ) + } else { + ( + rgb.get_pixel(offset, position - 1).0, + rgb.get_pixel(offset, position).0, + ) + }; + difference += before + .into_iter() + .zip(after) + .map(|(left, right)| u64::from(left.abs_diff(right))) + .sum::(); + } + let mean_difference = difference as f64 / f64::from(span_len * 3); + if mean_difference < 35.0 { + continue; + } + if previous_hit.is_none_or(|previous| position > previous + 2) { + groups += 1; + } + previous_hit = Some(position); + } + groups + } + + cut_groups(&rgb, true) >= 2 && cut_groups(&rgb, false) >= 1 +} + +fn has_release_art_context(title: &str) -> bool { + let lower = title.to_lowercase(); + [ + "new track", + "new song", + "new single", + "premiere:", + "track premiere", + "single premiere", + ] + .iter() + .any(|context| lower.contains(context)) +} + +fn hero_fingerprint(image: &image::DynamicImage) -> HeroFingerprint { + let mut dhashes = Vec::with_capacity(19); + dhashes.push(dhash64(image)); + for scale_percent in [85_u32, 70] { + let crop_w = (image.width() * scale_percent / 100).max(9); + let crop_h = (image.height() * scale_percent / 100).max(8); + let x_positions = [0, (image.width() - crop_w) / 2, image.width() - crop_w]; + let y_positions = [0, (image.height() - crop_h) / 2, image.height() - crop_h]; + for y in y_positions { + for x in x_positions { + dhashes.push(dhash64(&image.crop_imm(x, y, crop_w, crop_h))); + } + } + } + + let rgb = image + .resize_exact(64, 64, image::imageops::FilterType::Triangle) + .to_rgb8(); + let mut color_histogram = [0_u16; 512]; + for pixel in rgb.pixels() { + let [r, g, b] = pixel.0; + let bin = (usize::from(r >> 5) << 6) | (usize::from(g >> 5) << 3) | usize::from(b >> 5); + color_histogram[bin] += 1; + } + HeroFingerprint { + dhashes, + color_histogram, + } +} + +fn hero_fingerprints_match(left: &HeroFingerprint, right: &HeroFingerprint) -> bool { + let color_intersection: u32 = left + .color_histogram + .iter() + .zip(&right.color_histogram) + .map(|(left_count, right_count)| u32::from((*left_count).min(*right_count))) + .sum(); + if color_intersection < HERO_COLOR_INTERSECTION_MIN { + return false; + } + let full_distance = (left.dhashes[0] ^ right.dhashes[0]).count_ones(); + if full_distance <= HERO_EXACT_HASH_MAX_DISTANCE { + return true; + } + let crop_distance = left + .dhashes + .iter() + .flat_map(|left_hash| { + right + .dhashes + .iter() + .map(move |right_hash| (left_hash ^ right_hash).count_ones()) + }) + .min() + .unwrap_or(u32::MAX); + if crop_distance > HERO_CROP_HASH_MAX_DISTANCE { + return false; + } + true +} + +fn score_bing_hero_image( + bytes: Arc>, + candidate: &BingImageCandidate, + artist: &str, + diagnostics: bool, +) -> Option { + let url = &candidate.url; + let decoded = decode_hero_image_slice(bytes.as_slice())?; + let (w, h) = (decoded.width(), decoded.height()); + // Wide profile banners (X is natively 1500×500) are intrinsically short, + // so the height floor stays permissive. + if w < 720 || h < 260 { + if diagnostics { + eprintln!("HERO_REJECT\tsize\t{}x{}\t{}", w, h, url); + } + return None; + } + if let Some((source_w, source_h)) = embedded_raster_dimensions(url) { + let small_source = source_w < 720 || source_h < 400; + let visibly_upscaled = w >= source_w.saturating_mul(2) || h >= source_h.saturating_mul(2); + if small_source && visibly_upscaled { + if diagnostics { + eprintln!( + "HERO_REJECT\tproxy-upscale\t{}x{} from {}x{}\t{}", + w, h, source_w, source_h, url, + ); + } + return None; + } + } + let ratio = f64::from(w) / f64::from(h); + let crop_w = f64::from(w).min(f64::from(h) * (16.0 / 9.0)); + let crop_h = f64::from(h).min(f64::from(w) * (9.0 / 16.0)); + let usable = crop_w * crop_h; + let gray = decoded.thumbnail(384, 384).to_luma8(); + let sharp_norm = (laplacian_variance(&gray) / 120.0).clamp(0.25, 1.0); + let wide_bonus = if ratio >= 1.5 { 1.25 } else { 1.0 }; + let product_penalty = search_product_background_penalty(&decoded); + let text_penalty = search_text_overlay_penalty(&decoded); + let source_penalty = search_thumbnail_source_penalty(url); + let flat_graphic = search_has_flat_graphic_layout(&decoded); + let low_palette_graphic = search_has_low_palette_graphic(&decoded); + let panel_collage = text_penalty <= 0.70 && search_has_panel_collage_layout(&decoded); + let trusted_ticketing_profile = + is_ticketing_artist_profile_for_artist(&candidate.page_url, url, artist); + if is_lossless_graphic_url(url) && text_penalty <= 0.84 && !trusted_ticketing_profile { + if diagnostics { + eprintln!( + "HERO_REJECT\ttext-graphic\t{}x{}\ttext={:.2}\t{}", + w, h, text_penalty, url, + ); + } + return None; + } + if low_palette_graphic + && has_release_art_context(&candidate.title) + && !trusted_ticketing_profile + { + if diagnostics { + eprintln!("HERO_REJECT\trelease-graphic\t{}x{}\t{}", w, h, url); + } + return None; + } + if text_penalty <= 0.70 && flat_graphic && !trusted_ticketing_profile { + if diagnostics { + eprintln!( + "HERO_REJECT\ttext+flat\t{}x{}\ttext={:.2}\t{}", + w, h, text_penalty, url, + ); + } + return None; + } + if panel_collage && !trusted_ticketing_profile { + if diagnostics { + eprintln!( + "HERO_REJECT\ttext-collage\t{}x{}\ttext={:.2}\t{}", + w, h, text_penalty, url, + ); + } + return None; + } + // A hero is a WIDE banner. Square rasters are release artwork far more + // often than press photography — and album art passes every other gate: + // a sleeve that is itself a photo collage is not flat, not low-palette, + // and its title text can be small enough to clear the text penalty (the + // collage detector only runs at `text_penalty <= 0.70`). Camping in + // Alaska's square "lost and found" sleeve reached a live hero slot that + // way. Real press photos measured across the fixtures run 1.33-1.9, so + // the floor sits below every one of them; portrait crops fail it too, + // which is correct for a full-bleed banner. Trusted ticketing profiles + // keep their existing exemption — that tier is identity-verified art. + if ratio < HERO_MIN_ASPECT && !trusted_ticketing_profile { + if diagnostics { + eprintln!( + "HERO_REJECT\taspect\t{}x{}\tratio={:.2}\t{}", + w, h, ratio, url, + ); + } + return None; + } + if source_penalty <= 0.68 && text_penalty < 1.0 { + if diagnostics { + eprintln!( + "HERO_REJECT\tcaptioned-video\t{}x{}\ttext={:.2}\tsource={:.2}\t{}", + w, h, text_penalty, source_penalty, url, + ); + } + return None; + } + let score = + usable.sqrt() * sharp_norm * wide_bonus * product_penalty * text_penalty * source_penalty; + if diagnostics { + eprintln!( + "HERO_SCORE\t{}x{}\tusable={:.0}\tsharp={:.3}\twide={:.2}\tproduct={:.2}\ttext={:.2}\tsource={:.2}\tflat={}\tlow_palette={}\tpanel_collage={}\ttrusted={}\tbase={:.2}\t{}", + w, + h, + usable, + sharp_norm, + wide_bonus, + product_penalty, + text_penalty, + source_penalty, + flat_graphic, + low_palette_graphic, + panel_collage, + trusted_ticketing_profile, + score, + url, + ); + } + Some(HeroVisualScore { + score, + fingerprint: hero_fingerprint(&decoded), + }) +} + +fn decode_hero_image_slice(bytes: &[u8]) -> Option { + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok()?; + reader.limits(hero_decode_limits()); + reader.decode().ok() +} + +#[cfg(test)] +fn decode_hero_image(bytes: Vec) -> Option { + decode_hero_image_slice(&bytes) +} + +fn hero_decode_limits() -> image::Limits { + let mut limits = image::Limits::default(); + limits.max_image_width = Some(HERO_MAX_DECODE_DIMENSION); + limits.max_image_height = Some(HERO_MAX_DECODE_DIMENSION); + limits.max_alloc = Some(HERO_MAX_DECODE_ALLOC_BYTES); + limits +} + +/// Designed web graphics are commonly retained as PNG while ordinary press +/// photographs are normally JPEG/WebP. This is deliberately only a companion +/// signal for the text detector; a clean PNG photograph is still accepted. +fn is_lossless_graphic_url(url: &str) -> bool { + let path = url.split(['?', '#']).next().unwrap_or(url).to_lowercase(); + path.ends_with(".png") +} + +/// Extract the smallest `WIDTHxHEIGHT` token embedded in a source URL. Image +/// proxies sometimes serve a nominal 2048px response whose filename reveals it +/// was generated from a 300×168 thumbnail; treating the decoded dimensions as +/// native would let that visibly smeared copy outrank a real press image. +fn embedded_raster_dimensions(url: &str) -> Option<(u32, u32)> { + let bytes = url.as_bytes(); + let mut dimensions = Vec::new(); + for (x_at, byte) in bytes.iter().enumerate() { + if !matches!(byte, b'x' | b'X') || x_at == 0 || x_at + 1 >= bytes.len() { + continue; + } + let mut start = x_at; + while start > 0 && bytes[start - 1].is_ascii_digit() && x_at - start < 5 { + start -= 1; + } + let mut end = x_at + 1; + while end < bytes.len() && bytes[end].is_ascii_digit() && end - x_at <= 5 { + end += 1; + } + if start == x_at || end == x_at + 1 { + continue; + } + // Size tokens are delimited (`-300x168.jpg`, `size=1200x800`). Do not + // reinterpret an arbitrary alphanumeric id containing digits+x+digits + // as the raster's native resolution. + if start > 0 && bytes[start - 1].is_ascii_alphanumeric() { + continue; + } + if end < bytes.len() && bytes[end].is_ascii_alphanumeric() { + continue; + } + let Ok(width) = std::str::from_utf8(&bytes[start..x_at]) + .ok()? + .parse::() + else { + continue; + }; + let Ok(height) = std::str::from_utf8(&bytes[x_at + 1..end]) + .ok()? + .parse::() + else { + continue; + }; + if width >= 16 && height >= 16 { + dimensions.push((width, height)); + } + } + dimensions + .into_iter() + .min_by_key(|(w, h)| u64::from(*w) * u64::from(*h)) +} + +/// Video thumbnails are allowed when their result title proves a human-facing +/// interview/performance, but they frequently contain baked-in titles, logos, +/// and player branding. Prefer editorial/venue press photography when both are +/// available; keep thumbnails as a sparse-artist fallback. +fn search_thumbnail_source_penalty(url: &str) -> f64 { + let lower = url.to_lowercase(); + if lower.contains("i.ytimg.com/") || lower.contains("img.youtube.com/") { + 0.58 + } else if lower.contains("vimeocdn.com/") { + 0.68 + } else if lower.contains("lookaside.fbsbx.com/") { + 0.85 + } else { + 1.0 + } +} + +/// Variance of the 4-neighbour Laplacian over a grayscale thumbnail — the +/// standard cheap focus/sharpness measure. Blurry photos score low. +fn laplacian_variance(gray: &image::GrayImage) -> f64 { + let (w, h) = gray.dimensions(); + if w < 3 || h < 3 { + return 0.0; + } + let mut sum = 0.0; + let mut sum_sq = 0.0; + let mut count = 0.0; + for y in 1..h - 1 { + for x in 1..w - 1 { + let center = f64::from(gray.get_pixel(x, y).0[0]); + let lap = 4.0 * center + - f64::from(gray.get_pixel(x - 1, y).0[0]) + - f64::from(gray.get_pixel(x + 1, y).0[0]) + - f64::from(gray.get_pixel(x, y - 1).0[0]) + - f64::from(gray.get_pixel(x, y + 1).0[0]); + sum += lap; + sum_sq += lap * lap; + count += 1.0; + } + } + let mean = sum / count; + sum_sq / count - mean * mean +} + +fn urlencode(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + (b as char).to_string() + } + b' ' => "+".to_string(), + other => format!("%{other:02X}"), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn musicbrainz_album_match_ignores_a_same_title_single() { + let release = |id: u64, record_type: &str| CatalogAlbum { + id, + title: "Phantom Island".into(), + artist: "King Gizzard & the Lizard Wizard".into(), + cover: None, + release_date: Some(if record_type == "album" { + "2025-05-16".into() + } else { + "2024-10-29".into() + }), + record_type: Some(record_type.into()), + track_count: Some(if record_type == "album" { 10 } else { 1 }), + edition_group: None, + is_soundtrack: false, + is_live: false, + }; + let releases = [release(1, "single"), release(2, "album")]; + let matches = releases + .iter() + .filter(|album| exact_studio_album_title(album, "phantomisland")) + .collect::>(); + + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].id, 2); + } + + #[test] + fn apple_artwork_urls_rewrite_to_original_then_fitted_fallbacks() { + let urls = apple_artwork_urls( + "https://is1-ssl.mzstatic.com/image/thumb/Music/v4/ab/cd/ef/x/100x100bb.jpg", + ); + assert_eq!( + urls[0], + "https://is1-ssl.mzstatic.com/image/thumb/Music/v4/ab/cd/ef/x/100000x100000-999.jpg" + ); + assert_eq!( + urls[1], + "https://is1-ssl.mzstatic.com/image/thumb/Music/v4/ab/cd/ef/x/3000x3000bb.jpg" + ); + assert_eq!( + urls[2], + "https://is1-ssl.mzstatic.com/image/thumb/Music/v4/ab/cd/ef/x/1200x1200bb.jpg" + ); + // A URL without slashes passes through untouched rather than panicking. + assert_eq!(apple_artwork_urls("nonsense"), vec!["nonsense"]); + } + + /// Deterministic gradient art, optionally inverted, at any square size — + /// scaled copies must hash alike, the inverted one must not. + fn cover_fixture(size: u32, invert: bool) -> CoverCandidate { + let img = image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(size, size, |x, y| { + let v = ((x * 251 / size) ^ (y * 197 / size)) as u8; + let v = if invert { 255 - v } else { v }; + image::Rgb([v, v.wrapping_add(40), v.wrapping_mul(3)]) + })); + CoverCandidate { + dhash: dhash64(&img), + bytes: Arc::new(Vec::new()), + width: size, + height: size, + is_reference: false, + source: "test", + url: String::new(), + } + } + + #[test] + fn cover_picker_prefers_largest_same_artwork_as_reference() { + let mut reference = cover_fixture(1000, false); + reference.is_reference = true; + reference.source = "deezer"; + let same_art_bigger = cover_fixture(3000, false); // apple original + let different_art_biggest = cover_fixture(4000, true); // wrong edition + + let candidates = vec![reference, same_art_bigger, different_art_biggest]; + // The 4000px different artwork must lose to the 3000px same artwork. + assert_eq!(pick_best_cover(&candidates), Some(1)); + + // Without a reference, sheer size wins. + let candidates: Vec = candidates + .into_iter() + .map(|mut c| { + c.is_reference = false; + c + }) + .collect(); + assert_eq!(pick_best_cover(&candidates), Some(2)); + assert_eq!(pick_best_cover(&[]), None); + } + + #[test] + fn cover_decode_rejects_non_cover_shapes() { + let encode = |w: u32, h: u32| { + let img = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + w, + h, + image::Rgb([120, 90, 60]), + )); + let mut buf = std::io::Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Png).unwrap(); + buf.into_inner() + }; + // A banner-shaped image is not a front cover. + assert!(decode_cover(encode(1500, 500), "test", false, String::new()).is_none()); + // Tiny thumbnails are rejected outright. + assert!(decode_cover(encode(200, 200), "test", false, String::new()).is_none()); + // A real square cover decodes with its dimensions. + let ok = decode_cover(encode(1000, 990), "test", false, String::new()) + .expect("square cover accepted"); + assert_eq!((ok.width, ok.height), (1000, 990)); + } + + #[test] + fn artist_naming_is_token_exact_not_substring() { + // The trap this exists for: Cobb is a prefix of Cobham. + assert!(!names_artist("Billy Cobham live at Montreux", "Billy Cobb")); + assert!(names_artist("Billy Cobb — Zerwee vinyl", "Billy Cobb")); + assert!(names_artist( + "https://www.concertarchives.org/concerts/billy-cobb-2022", + "Billy Cobb" + )); + assert!(!names_artist("Cobb Billy backwards", "Billy Cobb")); + assert!(!names_artist("", "Billy Cobb")); + // Pages space names freely: "Partiboi 69" must match "Partiboi69"... + assert!(names_artist("Partiboi 69 at Circus Tokyo", "Partiboi69")); + assert!(names_artist("PARTIBOI69 presents", "Partiboi69")); + // ...but a run-on continuation is still a different word. + assert!(!names_artist("Partiboi699 fan page", "Partiboi69")); + } + + #[test] + fn bing_parser_keeps_each_result_cards_metadata_together() { + let html = concat!( + r#""#, + r#""#, + r#""#, + ); + let parsed = parse_bing_image_results(html); + assert_eq!(parsed.len(), 3); + assert_eq!(parsed[0].title, "Billy Cobb press photo"); + assert_eq!(parsed[0].url, "https://img.test/billy-cobb.jpg?x=1&y=2"); + assert_eq!(parsed[0].page_url, "https://music.test/billy-cobb"); + assert_eq!(parsed[1].title, "Jimmy Cobb with Miles Davis"); + assert_eq!(parsed[1].url, "https://img.test/jimmy-cobb.jpg"); + assert!(!names_artist(&parsed[1].title, "Billy Cobb")); + assert_eq!(parsed[2].title, "Spacing variant"); + } + + #[test] + fn bing_identity_prefers_independent_artist_anchors() { + assert_eq!( + bing_identity_confidence( + "Billy Cobb press photo", + "https://music.test/features/billy-cobb", + "https://cdn.test/generic-123.jpg", + "Billy Cobb", + ), + Some(BingIdentityConfidence::Strong) + ); + assert_eq!( + bing_identity_confidence( + "Billy Cobb tour dates", + "https://www.ticketmaster.com/jimmy-cobb-tickets/artist/999", + "https://s1.ticketm.net/dam/a/abc/generic-hero.jpg", + "Billy Cobb", + ), + None, + "a misleading Bing title cannot bless another artist's profile", + ); + assert_eq!( + bing_identity_confidence( + "Billy Cobb tour dates", + "https://www.ticketmaster.com/jimmy-cobb-tickets/artist/999?ref=billy-cobb", + "https://s1.ticketm.net/dam/a/abc/generic-hero.jpg", + "Billy Cobb", + ), + None, + "query parameters cannot poison an official ticketing profile", + ); + assert_eq!( + bing_identity_confidence( + "Billy Cobb Live", + "https://www.youtube.com/watch?v=generic", + "https://i.ytimg.com/vi/generic/maxresdefault.jpg", + "Billy Cobb", + ), + Some(BingIdentityConfidence::Weak) + ); + assert_eq!( + bing_identity_confidence( + "162 Billy Cobb Stock Photos, High-Res Pictures and Images", + "https://www.gettyimages.co.uk/photos/billy-cobb", + "https://media.gettyimages.com/id/jimmy-cobb.jpg", + "Billy Cobb", + ), + None + ); + assert_eq!( + bing_identity_confidence( + "A stunning photo of a musician in a studio with sound equipment", + "https://www.freepik.com/premium-ai-image/sound-equipment_247923856.htm", + "https://img.freepik.com/premium-photo/sound-equipment_1294789-838.jpg", + "Equipment", + ), + None, + "stock/AI hosts are not performer identity evidence", + ); + assert_eq!( + bing_identity_confidence( + "Band setting up equipment on a stage photo", + "https://unsplash.com/photos/band-setting-up-equipment-on-a-stage", + "https://images.unsplash.com/photo-1768264312526-786bf6087043", + "Equipment", + ), + None, + ); + assert_eq!( + bing_identity_confidence( + "Beginner DJ equipment: what you need to start DJing", + "https://blog.native-instruments.com/dj-equipment/", + "https://blog.native-instruments.com/beginner-dj-equipment-hero.jpg", + "Equipment", + ), + None, + "a repeated common noun is not a named performer", + ); + assert_eq!( + bing_identity_confidence( + "Ohio emo band Equipment announce new album", + "https://www.brooklynvegan.com/ohio-emo-band-equipment-announce-new-album/", + "https://media.brooklynvegan.com/uploads/equipment.jpg", + "Equipment", + ), + Some(BingIdentityConfidence::Strong), + ); + assert_eq!( + bing_identity_confidence( + "Interview: Wednesday music supervisors", + "https://awardsradar.com/interview-wednesday-music-supervisors/", + "https://awardsradar.com/images/Wednesday_S1_E3.jpg", + "Wednesday", + ), + None, + "a TV interview is not the band merely because it discusses music", + ); + assert_eq!( + bing_identity_confidence( + "Wednesday Share New Video for Quarry", + "https://pitchfork.com/news/wednesday-share-new-video-quarry-watch/", + "https://media.pitchfork.com/photos/wednesday.jpg", + "Wednesday", + ), + Some(BingIdentityConfidence::Strong), + ); + assert_eq!( + bing_identity_confidence( + "Wednesday Events", + "https://www.livenation.com/artist/K8vZ917-example/wednesday-events", + "https://s1.ticketm.net/dam/a/abc/wednesday-hero.jpg", + "Wednesday", + ), + Some(BingIdentityConfidence::Strong), + "a verified official artist profile is its own entity anchor", + ); + assert_eq!( + bing_identity_confidence( + "New MS Paint for Windows 11", + "https://www.windowslatest.com/microsoft-paint-update/", + "https://www.windowslatest.com/MS-Paint-for-Windows-11.jpg", + "MSPAINT", + ), + None, + ); + assert_eq!( + bing_identity_confidence( + "MSPAINT is now live in your browser", + "https://software.example/post/mspaint-live-browser", + "https://software.example/images/MSPAINT.jpg", + "MSPAINT", + ), + None, + "generic app wording is not performer identity", + ); + assert_eq!( + bing_identity_confidence( + "MS Paint Review: a classic drawing program", + "https://software.example/mspaint-review", + "https://software.example/images/MSPAINT.jpg", + "MSPAINT", + ), + None, + "an app review is not music-entity evidence", + ); + assert_eq!( + bing_identity_confidence( + "MSPaint Announce New EP No Separation", + "https://pitchfork.com/news/mspaint-announce-new-ep-no-separation/", + "https://media.pitchfork.com/photos/MSPAINT.jpeg", + "MSPAINT", + ), + Some(BingIdentityConfidence::Strong), + ); + assert_eq!( + bing_identity_confidence( + "MSPAINT Events", + "https://www.ticketmaster.com/mspaint-tickets/artist/1234567", + "https://s1.ticketm.net/dam/a/abc/mspaint-hero.jpg", + "MSPAINT", + ), + Some(BingIdentityConfidence::Strong), + "trusted ticketing profiles do not need article-style music wording", + ); + assert_eq!( + bing_identity_confidence( + "Equipment - Interview", + "https://music.example/artists/equipment", + "https://cdn.example/equipment-press.jpg", + "Equipment", + ), + Some(BingIdentityConfidence::Strong), + ); + assert_eq!( + bing_identity_confidence( + "Home Is Where Quotes. QuotesGram", + "https://quotesgram.com/home-is-where-quotes/", + "https://cdn.quotesgram.com/pico-iyer-quote-home.jpg", + "Home Is Where", + ), + None, + ); + assert_eq!( + bing_identity_confidence( + "Home.is Where Band at Kenneth Kane blog", + "https://storage.googleapis.com/example/home-is-where-band.html", + "https://images.example/Band-4_1.jpg", + "Home Is Where", + ), + None, + "Google Storage HTML search-spam pages are not identity evidence", + ); + assert_eq!( + bing_identity_confidence( + "Kilcullen Diary: Weatherday: Disaster enters, stage left", + "https://kilcullenbridge.blogspot.com/weatherday-disaster-enters-stage-left.html", + "https://blogger.googleusercontent.com/s4659/barleynight.JPG", + "Weatherday", + ), + None, + ); + assert_eq!( + bing_identity_confidence( + "Weatherday – Hornet Disaster | Album Review", + "https://swimintothesound.com/blog/weatherday-hornet-disaster-album-review", + "https://images.squarespace-cdn.com/sputnik+3.jpeg", + "Weatherday", + ), + Some(BingIdentityConfidence::Strong), + ); + assert_eq!( + bing_identity_confidence( + "Billy Cobb", + "https://music.test/feature", + "https://img.test/billy-cobb-portrait.jpg", + "Billy Cobb", + ), + Some(BingIdentityConfidence::Strong) + ); + } + + #[test] + fn audiodb_requires_the_exact_artist_not_result_zero() { + let body = serde_json::json!({ + "artists": [ + { "strArtist": "Billy Cobham", "strArtistFanart": "wrong" }, + { "strArtist": "Billy Cobb", "strArtistFanart": "right" } + ] + }); + let selected = select_audiodb_artist(&body, "Billy Cobb").unwrap(); + assert_eq!(selected["strArtistFanart"], "right"); + assert!(select_audiodb_artist(&body, "Billy Cob").is_none()); + } + + fn test_apple_release(title: &str, collection_id: u64) -> AppleRelease { + AppleRelease { + title: title.into(), + title_norm: release_title_identity(title), + artwork: format!("https://img.test/{collection_id}.jpg"), + release_date: None, + track_count: None, + collection_type: Some("Album".into()), + collection_id: Some(collection_id), + } + } + + fn test_apple_candidate(artist_id: u64, releases: Vec) -> AppleArtistCandidate { + AppleArtistCandidate { + artist_id, + releases, + lookup_complete: true, + cacheable: true, + } + } + + fn test_deezer_evidence(title: &str, fans: u64, genre_id: u64) -> DeezerReleaseEvidence { + deezer_release_evidence(&json!({ + "title": title, + "fans": fans, + "genre_id": genre_id, + })) + .unwrap() + } + + fn test_bing_candidate( + url: &str, + title: &str, + page_url: &str, + identity: BingIdentityConfidence, + display_eligible: bool, + ) -> BingImageCandidate { + BingImageCandidate { + url: url.into(), + title: title.into(), + page_url: page_url.into(), + identity, + display_eligible, + query_index: 0, + rank_in_query: 0, + } + } + + /// A square sleeve must never reach a hero slot, and the same photographic + /// content in banner proportions must still pass. Camping in Alaska's + /// "lost and found" cover — a photo collage whose only text is a small + /// typewriter caption — cleared every other gate and was served as a live + /// hero, because nothing tested proportions. + #[test] + fn discogs_markup_becomes_plain_prose() { + let profile = "Duo of [a=Austin Davis] and [a=Jacob Stewart], released on \ + [l=Broken World Media]. See [url=https://example.com]site[/url].\n\n\ + [b]Emo[/b] from Alabama."; + let cleaned = strip_discogs_markup(profile); + assert_eq!( + cleaned, + "Duo of Austin Davis and Jacob Stewart, released on Broken World Media. \ + See site. Emo from Alabama." + ); + assert!(!cleaned.contains('[')); + } + + /// The last-resort tier must read as a sentence and never assert more + /// than the MusicBrainz entry actually says. + #[test] + fn musicbrainz_facts_compose_an_honest_bio() { + let disambiguated = ArtistIdentityFacts { + disambiguation: Some("David Ameal, techno / breakbeat producer".into()), + tags: vec!["acid techno".into(), "breakbeat".into()], + ..ArtistIdentityFacts::default() + }; + assert_eq!( + disambiguated.compose_bio("Hermeth").unwrap(), + "Hermeth: David Ameal, techno / breakbeat producer. \ + Associated genres: acid techno, breakbeat." + ); + + let group = ArtistIdentityFacts { + kind: Some("Group".into()), + area: Some("Huntsville".into()), + tags: vec!["emo".into()], + ..ArtistIdentityFacts::default() + }; + assert_eq!( + group.compose_bio("Camping In Alaska").unwrap(), + "Camping In Alaska is a group from Huntsville. Associated genres: emo." + ); + + // Nothing known at all yields nothing, not a hollow sentence. + assert!(ArtistIdentityFacts::default() + .compose_bio("Nobody") + .is_none()); + } + + #[test] + fn square_release_artwork_is_rejected_but_wide_photography_survives() { + // Photographic-looking noise: not flat, not low-palette, no text — so + // only the aspect rule can reject it. + let textured = |w: u32, h: u32| { + let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(w, h, |x, y| { + let n = |seed: u32| { + let v = (x.wrapping_mul(2654435761) ^ y.wrapping_mul(2246822519) ^ seed) >> 13; + (v % 200) as u8 + 30 + }; + image::Rgb([n(7), n(101), n(9001)]) + })); + let mut bytes = std::io::Cursor::new(Vec::new()); + image + .write_to(&mut bytes, image::ImageFormat::Png) + .expect("encode test raster"); + Arc::new(bytes.into_inner()) + }; + + let candidate = test_bing_candidate( + "https://example.com/artwork.jpg", + "Camping In Alaska interview", + "https://example.com/interview", + BingIdentityConfidence::Strong, + true, + ); + + assert!( + score_bing_hero_image(textured(1200, 1200), &candidate, "Camping In Alaska", false) + .is_none(), + "square release artwork must be rejected as a hero", + ); + assert!( + score_bing_hero_image(textured(1600, 900), &candidate, "Camping In Alaska", false) + .is_some(), + "wide press photography must still score", + ); + // 4:3 press photography is common and sits above the floor. + assert!( + score_bing_hero_image(textured(1600, 1200), &candidate, "Camping In Alaska", false) + .is_some(), + "4:3 press photography must still score", + ); + } + + #[test] + fn crazy_p_namesakes_are_weighted_and_foreign_clusters_are_removed() { + let candidates = vec![ + test_apple_candidate( + 83_401_183, + vec![ + test_apple_release("Walk Dance Talk Sing", 1), + test_apple_release("Age of the Ego", 2), + test_apple_release("The Wicked Is Music", 3), + ], + ), + test_apple_candidate(1_735_763_718, vec![test_apple_release("Widderlich", 4)]), + test_apple_candidate( + 1_766_807_301, + vec![ + test_apple_release("Azote - Single", 5), + test_apple_release("Castigo - Single", 6), + test_apple_release("COMO TU NO HAY OTRA - Single", 7), + ], + ), + ]; + let evidence = vec![ + test_deezer_evidence("Walk Dance Talk Sing", 2_847, 106), + test_deezer_evidence("Age of the Ego", 771, 106), + test_deezer_evidence("The Wicked Is Music", 638, 113), + test_deezer_evidence("Widderlich", 83, 116), + test_deezer_evidence("Azote", 0, 85), + test_deezer_evidence("Castigo", 1, 85), + test_deezer_evidence("COMO TU NO HAY OTRA", 0, 132), + // Not present in the losing US Apple catalogue, but part of its + // repeatedly corroborated losing-only Latin genre cluster. + test_deezer_evidence("Mi esperanza", 0, 85), + ]; + let selected = select_apple_artist_catalog(&candidates, &evidence, true); + + assert!(selected.identity_confirmed); + assert_eq!(selected.apple_artist_id, Some(83_401_183)); + assert!(selected.foreign_titles.contains("widderlich")); + assert!(selected.foreign_titles.contains("azote")); + assert!(selected.foreign_genres.contains(&85)); + assert!(!selected.foreign_genres.contains(&116)); + assert!(!selected.foreign_genres.contains(&106)); + assert!(apple_release_is_foreign( + &selected, + "Mi esperanza", + Some(85) + )); + assert!(apple_release_is_foreign(&selected, "Widderlich", Some(116))); + assert!(!apple_release_is_foreign( + &selected, + "A legitimate collaboration", + Some(106) + )); + } + + #[test] + fn apple_namesake_selection_fails_closed_when_ambiguous_or_uncorroborated() { + let pond_namesakes = vec![ + test_apple_candidate(1, vec![test_apple_release("Tasmania", 10)]), + test_apple_candidate(2, vec![test_apple_release("Schwer", 20)]), + ]; + assert!(!select_apple_artist_catalog(&pond_namesakes, &[], true).identity_confirmed); + + let tied = vec![ + test_deezer_evidence("Tasmania", 0, 106), + test_deezer_evidence("Schwer", 0, 106), + ]; + assert!(!select_apple_artist_catalog(&pond_namesakes, &tied, true).identity_confirmed); + + let obscure_namesake = vec![test_apple_candidate( + 99, + vec![test_apple_release("Canonical Act Album", 30)], + )]; + assert!( + !select_apple_artist_catalog( + &obscure_namesake, + &[test_deezer_evidence("Different Artist Album", 5, 1)], + true, + ) + .identity_confirmed + ); + assert!( + select_apple_artist_catalog(&obscure_namesake, &[], false).identity_confirmed, + "the name-only cover path may use a sole candidate behind dHash verification" + ); + + let mut incomplete = test_apple_candidate(2, Vec::new()); + incomplete.lookup_complete = false; + incomplete.cacheable = false; + let partial = select_apple_artist_catalog( + &[ + test_apple_candidate(1, vec![test_apple_release("Tasmania", 1)]), + incomplete.clone(), + ], + &[test_deezer_evidence("Tasmania", 100, 106)], + true, + ); + assert!(partial.identity_unavailable); + assert!(!partial.identity_confirmed); + assert!( + !select_apple_artist_catalog( + &[incomplete], + &[test_deezer_evidence("Native release", 1, 106)], + true, + ) + .identity_unavailable, + "one failed candidate need not hide Deezer-native releases" + ); + } + + #[test] + fn apple_format_tails_and_synthetic_ids_preserve_identity() { + assert_eq!(release_title_identity("Azote - Single"), "azote"); + assert_eq!(release_title_identity("Azote – Single"), "azote"); + assert_eq!(release_title_identity("Night Drive - EP"), "nightdrive"); + let release = test_apple_release("Shared Title", 123); + assert_ne!( + apple_synthetic_catalog_id(1, &release, "Shared Title"), + apple_synthetic_catalog_id(2, &release, "Shared Title"), + "same-name artists must never overwrite each other's Apple registry row" + ); + let other_collection = test_apple_release("Shared Title", 456); + assert_ne!( + apple_synthetic_catalog_id(1, &release, "Shared Title"), + apple_synthetic_catalog_id(1, &other_collection, "Shared Title") + ); + } + + #[test] + fn linked_mbid_outranks_stale_audiodb_deezer_id() { + let stale = json!({ + "strDeezerID": "42", + "strMusicBrainzID": "wrong-mbid", + }); + assert!(!audiodb_artist_matches_identity( + &stale, + 42, + Some("right-mbid") + )); + assert!(audiodb_artist_matches_identity(&stale, 42, None)); + let linked = json!({ + "strDeezerID": "999", + "strMusicBrainzID": "RIGHT-MBID", + }); + assert!(audiodb_artist_matches_identity( + &linked, + 42, + Some("right-mbid") + )); + } + + #[test] + fn bing_fallback_requires_an_unambiguous_exact_name() { + let pond = json!({ "data": [ + { "id": 10, "name": "Pond", "nb_fan": 50_000 }, + { "id": 20, "name": "Pond", "nb_fan": 12 }, + { "id": 30, "name": "Ponder", "nb_fan": 1_000_000 } + ]}); + // Name-only editorial lookups stay strict: dominance is enough for an + // image search, not for an unanchored Wikipedia article match. + assert!(!deezer_search_allows_bing(&pond, "Pond", 10)); + assert!(!deezer_search_allows_bing(&pond, "Pond", 20)); + assert_eq!( + deezer_exact_name_state(&pond, "Pond", 10), + DeezerExactNameState::Dominant + ); + assert_eq!( + deezer_exact_name_state(&pond, "Pond", 20), + DeezerExactNameState::Ambiguous + ); + assert_eq!( + deezer_exact_name_state(&pond, "Pond", 99), + DeezerExactNameState::Missing + ); + let tied = json!({ "data": [ + { "id": 10, "name": "Pond", "nb_fan": 5 }, + { "id": 20, "name": "Pond", "nb_fan": 5 } + ]}); + assert!(!deezer_search_allows_bing(&tied, "Pond", 10)); + assert_eq!( + deezer_exact_name_state(&tied, "Pond", 10), + DeezerExactNameState::Ambiguous + ); + let unique = json!({ "data": [ + { "id": 42, "name": "Home Is Where", "nb_fan": 5 }, + { "id": 43, "name": "Home Is Here", "nb_fan": 500 } + ]}); + assert!(deezer_search_allows_bing(&unique, "Home Is Where", 42)); + assert_eq!( + deezer_exact_name_state(&unique, "Home Is Where", 42), + DeezerExactNameState::Unambiguous + ); + } + + /// The regression this guards: a dead duplicate row (`crazy_p`, 0 fans) + /// made Crazy P "ambiguous", which pushed a well-known artist onto the + /// per-card catalogue-anchor tier and discarded nine of twelve real Bing + /// press photos. Deezer is full of these rows, so this was universal. + #[test] + fn empty_duplicate_rows_do_not_contest_a_real_artists_name() { + let crazy_p = json!({ "data": [ + { "id": 206_927, "name": "Crazy P", "nb_fan": 11_065 }, + { "id": 204_170_017, "name": "crazy_p", "nb_fan": 0 } + ]}); + assert_eq!( + deezer_exact_name_state(&crazy_p, "Crazy P", 206_927), + DeezerExactNameState::Dominant + ); + + // A real contest still is one. Both Ponds have an audience, so the + // strict path keeps protecting the smaller id. + let contested = json!({ "data": [ + { "id": 10, "name": "Pond", "nb_fan": 900 }, + { "id": 20, "name": "Pond", "nb_fan": 400 } + ]}); + assert_eq!( + deezer_exact_name_state(&contested, "Pond", 10), + DeezerExactNameState::Ambiguous + ); + + // Ratio alone cannot promote two obscure rows. + let obscure = json!({ "data": [ + { "id": 10, "name": "Pond", "nb_fan": 20 }, + { "id": 20, "name": "Pond", "nb_fan": 1 } + ]}); + assert_eq!( + deezer_exact_name_state(&obscure, "Pond", 10), + DeezerExactNameState::Ambiguous + ); + + // An unfollowed id never owns a name it shares with anyone. + let silent = json!({ "data": [ + { "id": 10, "name": "Pond", "nb_fan": 0 }, + { "id": 20, "name": "Pond", "nb_fan": 0 } + ]}); + assert_eq!( + deezer_exact_name_state(&silent, "Pond", 10), + DeezerExactNameState::Ambiguous + ); + } + + /// Deezer answers an exhausted request budget with HTTP 200 and an error + /// object. Only that one is worth retrying — a quota rejection says nothing + /// about the artist, while every other code describes the resource and + /// would burn the window repeating a settled answer. + #[test] + fn only_a_quota_rejection_is_retryable() { + let quota = json!({ "error": { + "type": "Exception", "message": "Quota limit exceeded", "code": 4 + }}); + assert!(deezer_quota_error("a)); + + // Deezer has published the message without the numeric code. + let worded = json!({ "error": { "message": "quota limit exceeded" } }); + assert!(deezer_quota_error(&worded)); + + let missing = json!({ "error": { + "type": "DataException", "message": "no data", "code": 800 + }}); + assert!(!deezer_quota_error(&missing)); + + // MusicBrainz reports its failures as a plain string. + let musicbrainz = json!({ "error": "Not Found" }); + assert!(!deezer_quota_error(&musicbrainz)); + + let ok = json!({ "data": [{ "id": 10, "name": "Pond" }] }); + assert!(!deezer_quota_error(&ok)); + } + + fn test_release( + id: u64, + title: &str, + record_type: &str, + date: &str, + tracks: Option, + ) -> CatalogAlbum { + CatalogAlbum { + id, + title: title.into(), + artist: "Artist".into(), + cover: None, + release_date: (!date.is_empty()).then(|| date.to_string()), + record_type: Some(record_type.into()), + track_count: tracks, + edition_group: None, + is_soundtrack: false, + is_live: false, + } + } + + /// Same-work duplicate rows fold to the fullest member inside their date + /// bucket; different-date works with the same name keep their own cards. + #[test] + fn duplicate_release_rows_fold_to_the_fullest_member() { + // Exact-title year bucket: "Changes (Remixes)" twice, five days apart. + let mut albums = vec![ + test_release(1, "Changes (Remixes)", "single", "2012-03-06", Some(7)), + test_release(2, "Changes (Remixes)", "single", "2012-03-11", Some(7)), + // Same title, different YEAR — a real re-release, must survive. + test_release(3, "Changes (Remixes)", "single", "2015-03-11", Some(7)), + ]; + fold_duplicate_release_rows(&mut albums, &[vec![1, 2, 3]], 4); + let ids: Vec = albums.iter().map(|a| a.id).collect(); + assert_eq!(ids.len(), 2); + assert!(ids.contains(&3)); + + // Featured-credit day bucket: the credited 4-track row wins. + let mut albums = vec![ + test_release(10, "Dancing In Babylon", "single", "2024-02-20", Some(1)), + test_release( + 11, + "Dancing In Babylon (feat. Christine and the Queens)", + "single", + "2024-02-20", + Some(4), + ), + ]; + fold_duplicate_release_rows(&mut albums, &[vec![10, 11]], 10); + assert_eq!(albums.len(), 1); + assert_eq!(albums[0].id, 11); + + // Undated rows never fold in the DATE-bucketed pass — but the exact + // re-release pass may still collapse them (see the test below). + let mut albums = vec![ + test_release(20, "Mystery", "single", "", None), + test_release(21, "Mystery", "single", "", None), + ]; + fold_duplicate_release_rows(&mut albums, &[vec![20, 21]], 4); + assert_eq!(albums.len(), 2); + } + + /// Cross-year exact-title re-releases: singles/EPs fold on compatible + /// track counts, albums only on provably identical known counts, and a + /// mismatch always keeps both rows. + #[test] + fn exact_rereleases_fold_only_same_material() { + // "Step Into Christmas" re-released under a new id: fold, keep oldest. + let mut albums = vec![ + test_release(1, "Step Into Christmas", "single", "2022-11-22", None), + test_release(2, "Step Into Christmas", "single", "2018-12-07", None), + ]; + fold_exact_rereleases(&mut albums, &[vec![1, 2]]); + assert_eq!(albums.iter().map(|a| a.id).collect::>(), vec![2]); + + // Different track counts = different material ("Christmas Tree Farm" + // 1-track 2019 vs 3-track 2024): keep both. + let mut albums = vec![ + test_release(3, "Christmas Tree Farm", "single", "2024-11-01", Some(3)), + test_release(4, "Christmas Tree Farm", "single", "2019-12-06", Some(1)), + ]; + fold_exact_rereleases(&mut albums, &[vec![3, 4]]); + assert_eq!(albums.len(), 2); + + // Albums fold only when every count is known and identical ≥5 — + // "Anthology 2" and its remastered re-release. + let mut albums = vec![ + test_release(5, "Anthology 2", "album", "2025-08-21", Some(45)), + test_release(6, "Anthology 2", "album", "2016-04-04", Some(45)), + ]; + fold_exact_rereleases(&mut albums, &[vec![5, 6]]); + assert_eq!(albums.iter().map(|a| a.id).collect::>(), vec![6]); + + // An unknown count on an album is NOT proof: two eponymous records + // must never merge on a guess. + let mut albums = vec![ + test_release(7, "Elton John", "album", "2008-06-02", None), + test_release(8, "Elton John", "album", "2007-04-30", Some(10)), + ]; + fold_exact_rereleases(&mut albums, &[vec![7, 8]]); + assert_eq!(albums.len(), 2); + } + + /// The variant/sequel residual split is what keeps "Revolver (2022 Mix)" + /// foldable while "Greatest Hits II" stays its own record. + #[test] + fn residual_classification_separates_variants_from_sequels() { + assert!(residual_is_sequel_marker("ii")); + assert!(residual_is_sequel_marker("2")); + assert!(residual_is_sequel_marker("vol2")); + assert!(residual_is_sequel_marker("pt1")); + assert!(!residual_is_sequel_marker("theanthology")); + assert!(!residual_is_sequel_marker("2022mix")); + assert!(!residual_is_sequel_marker("injapan")); + assert!(!residual_is_sequel_marker("1975")); // 4-digit year ≠ sequel number… + // …but it is not a release variant either, so "Fleetwood Mac: Live + // 1975" never links to "Fleetwood Mac: Live" (gap + no variant word). + assert!(!residual_is_release_variant("1975")); + assert!(residual_is_release_variant("2022mix")); + assert!(residual_is_release_variant("acousticcollection")); + assert!(residual_is_release_variant("theanthology")); + assert!(residual_is_release_variant("trackbytrackversion")); + assert!(!residual_is_release_variant("injapan")); + assert!(!residual_is_release_variant( + "narratesprokofievspeterandthewolf" + )); + } + + /// The lead sentence decides; later prose must not reclassify, and the + /// declared kind wins over vaguer words around it. + #[test] + fn description_leads_map_to_type_hints() { + assert_eq!( + description_type_hint( + "Direct Hits is a compilation album by American rock band the Killers, released in 2013." + ), + Some("compilation"), + ); + assert_eq!( + description_type_hint( + "Late Night Tales: MGMT is a mix album compiled by the band MGMT's members." + ), + Some("remix"), + ); + assert_eq!( + description_type_hint( + "Live Around the World is a live album by Queen and Adam Lambert." + ), + Some("live"), + ); + assert_eq!( + description_type_hint( + "Flash Gordon is the soundtrack to the film of the same name by Queen." + ), + Some("soundtrack"), + ); + assert_eq!( + description_type_hint( + "Hot Fuss is the debut studio album by American rock band the Killers." + ), + Some("album"), + ); + assert_eq!( + description_type_hint("SZNZ: Spring is an extended play by Weezer."), + Some("ep"), + ); + // Later-prose mentions don't reclassify: the lead names a studio album + // and the live-album mention sits beyond the lead window. + assert_eq!( + description_type_hint( + "Vauxhall and I is the fourth studio album by Morrissey, released in 1994 to critical acclaim and commercial success in both the United Kingdom and the United States of America. After extensive touring through the previous years, the band then released a live album." + ), + Some("album"), + ); + assert_eq!( + description_type_hint("An enigmatic release with no stated kind."), + None + ); + } + + /// A song article's lead is about a TRACK. Every release it names is a + /// different record, so the lead must classify as "song" and never leak + /// that record's type — the "Run For Cover (Workout Mix)" misfile. + #[test] + fn song_leads_never_report_the_album_they_mention() { + assert_eq!( + description_type_hint( + "\"Run for Cover\" is a song by American rock band the Killers from their fifth studio album, Wonderful Wonderful (2017)." + ), + Some("song"), + ); + assert_eq!( + description_type_hint( + "\"Mr. Brightside\" is the debut single by American rock band the Killers." + ), + Some("song"), + ); + // Qualifiers stack between the copula and the noun. + assert_eq!( + description_type_hint( + "\"Blue Christmas\" is a 1948 promotional single later recorded by Elvis Presley for his live album." + ), + Some("song"), + ); + // Album leads must be untouched: their singles/songs words never sit + // in the declaration, so the studio-album reading still wins. + assert_eq!( + description_type_hint( + "Hot Fuss is the debut studio album by the Killers. Its lead single, \"Mr. Brightside\", became a standard." + ), + Some("album"), + ); + assert_eq!( + description_type_hint( + "Sawdust is a compilation album by the Killers collecting b-sides, rarities and new songs." + ), + Some("compilation"), + ); + // Only the lead's OWN copula is read: a later sentence describing a + // track on the record must not reclassify the record. + assert_eq!( + description_type_hint( + "Wonderful Wonderful is the fifth studio album by American rock band the Killers, released through Island Records. The record's third single was a song written on tour." + ), + Some("album"), + ); + } + + /// Apple's continuous DJ mixes can never render a tracklist (their song + /// lookup is empty), so the supplement must not inject them; ordinary + /// remix albums must survive the same filter. + #[test] + fn apple_dj_mix_titles_are_recognized() { + assert!(is_apple_dj_mix_title( + "Defected: Crazy P at Glitterbox, Hï Ibiza, Jun 12, 2022 (DJ Mix)" + )); + assert!(is_apple_dj_mix_title( + "Yuksek Presents Dance 'O' Drome 006: Danielle Moore (Crazy P) [DJ Mix]" + )); + assert!(is_apple_dj_mix_title("Late Night Tales: MGMT (DJ Mix)")); + assert!(is_apple_dj_mix_title("Early Hours (DJ Mix)")); + assert!(!is_apple_dj_mix_title("Remixed")); + assert!(!is_apple_dj_mix_title("Age of the Ego (Remixes)")); + assert!(!is_apple_dj_mix_title("Kids (Soulwax Mix)")); + } + + /// The credit is the only difference between Deezer's duplicate single + /// rows; anything else must keep its own identity. + #[test] + fn featless_identity_strips_only_the_featured_credit() { + assert_eq!( + featless_title_identity("Dancing In Babylon (feat. Christine and the Queens)"), + featless_title_identity("Dancing In Babylon"), + ); + assert_eq!( + featless_title_identity("Party Woman [feat. Crazy P]"), + featless_title_identity("Party Woman"), + ); + // A remix credit is part of the work, not a featured-artist tail. + assert_ne!( + featless_title_identity("Kids (Soulwax Mix)"), + featless_title_identity("Kids"), + ); + // Only the credit bracket is removed — a qualifier AFTER it survives, + // so a remix variant never collides with the plain single. + assert_ne!( + featless_title_identity("Beneath with Me (feat. Skylar Grey) (Kaskade's V.4)"), + featless_title_identity("Beneath with Me (feat. Skylar Grey)"), + ); + assert_eq!( + featless_title_identity("Beneath with Me (feat. Skylar Grey)"), + normalize("Beneath with Me"), + ); + // Different credits on the same song still collide with each other. + assert_eq!( + featless_title_identity("Check On It (feat. Voltio)"), + featless_title_identity("Check On It (feat. Bun B & Slim Thug)"), + ); + // A title that IS a feat marker from position zero stays whole. + assert_eq!( + featless_title_identity("(feat. Nobody)"), + normalize("(feat. Nobody)") + ); + } + + /// A name owner must keep the same candidate set an unambiguous name gets. + /// Press photography cites no release title, so any narrowing here is the + /// hero regression itself. + #[test] + fn name_owner_keeps_the_full_card_identity_result_set() { + let candidates = vec![ + test_bing_candidate( + "https://images.squarespace-cdn.test/press-shot.jpeg", + "GET TO KNOW: Crazy P — YoungOnes", + "http://youngonesuk.test/crazy-p-get-to-know", + BingIdentityConfidence::Strong, + true, + ), + test_bing_candidate( + "https://media.timeout.test/image.jpg", + "Crazy P | Music in London", + "https://timeout.test/london/music/crazy-p", + BingIdentityConfidence::Strong, + true, + ), + test_bing_candidate( + "https://crazy-p.test/CRAZY-P-Any-Signs-Of-Love-VINYL.jpg", + "New Album Announcement", + "https://crazy-p.test/2024/11/20/announcement", + BingIdentityConfidence::Strong, + false, + ), + ]; + let filtered = filter_bing_candidates_for_policy( + candidates, + &BingArtistIdentityPolicy::NameOwner, + "Crazy P", + &HashSet::new(), + ); + assert_eq!( + filtered + .iter() + .map(|candidate| candidate.url.as_str()) + .collect::>(), + [ + "https://images.squarespace-cdn.test/press-shot.jpeg", + "https://media.timeout.test/image.jpg" + ], + "only the product/release card may be dropped" + ); + } + + #[test] + fn bing_catalog_anchors_are_winner_only_distinctive_and_not_self_titled() { + let candidates = vec![ + test_apple_candidate( + 1, + vec![ + test_apple_release("Any Signs of Love", 1), + test_apple_release("Shared Release", 2), + test_apple_release("Crazy P", 3), + test_apple_release("Later", 4), + ], + ), + test_apple_candidate( + 2, + vec![ + test_apple_release("Shared Release", 5), + test_apple_release("Widderlich", 6), + ], + ), + ]; + let catalog = AppleArtistCatalog { + releases: candidates[0].releases.clone(), + apple_artist_id: Some(1), + identity_confirmed: true, + ..AppleArtistCatalog::default() + }; + let anchors = bing_catalog_release_anchors(&candidates, &catalog, "Crazy P"); + assert!(anchors.contains("any signs of love")); + assert!(!anchors.contains("shared release")); + assert!(!anchors.contains("crazy p")); + assert!(!anchors.contains("later")); + } + + #[test] + fn unambiguous_bing_keeps_existing_strong_and_weak_display_candidates() { + let candidates = vec![ + test_bing_candidate( + "https://img.test/strong.jpg", + "Home Is Where band portrait", + "https://music.test/home-is-where", + BingIdentityConfidence::Strong, + true, + ), + test_bing_candidate( + "https://img.test/weak.jpg", + "Home Is Where live", + "https://video.test/watch/1", + BingIdentityConfidence::Weak, + true, + ), + test_bing_candidate( + "https://img.test/vinyl.jpg", + "Home Is Where vinyl", + "https://homeiswhere.test/store", + BingIdentityConfidence::Strong, + false, + ), + ]; + let filtered = filter_bing_candidates_for_policy( + candidates, + &BingArtistIdentityPolicy::Unambiguous, + "Home Is Where", + &HashSet::new(), + ); + assert_eq!( + filtered + .iter() + .map(|candidate| candidate.url.as_str()) + .collect::>(), + ["https://img.test/strong.jpg", "https://img.test/weak.jpg"] + ); + } + + #[test] + fn ambiguous_bing_requires_per_card_catalog_or_linked_identity() { + let release_titles = HashSet::from(["any signs of love".to_string()]); + let policy = BingArtistIdentityPolicy::CatalogAnchored { release_titles }; + let linked_people = HashSet::from(["danielle moore".to_string()]); + let candidates = vec![ + // Metadata-only physical release proves the exact artist-named + // host, but can never display itself. + test_bing_candidate( + "https://crazy-p.com/images/Any-Signs-of-Love-VINYL.jpg", + "Crazy P announces Any Signs of Love", + "https://crazy-p.com/news/any-signs-of-love", + BingIdentityConfidence::Strong, + false, + ), + test_bing_candidate( + "https://crazy-p.com/images/live-press-shot.jpg", + "Crazy P Curate", + "https://crazy-p.com/news/curate", + BingIdentityConfidence::Strong, + true, + ), + test_bing_candidate( + "https://cdn.fatsoma.test/danielle.jpg", + "Danielle Moore (Crazy P) DJ set", + "https://fatsoma.test/danielle-moore-crazy-p", + BingIdentityConfidence::Strong, + true, + ), + test_bing_candidate( + "https://editorial.test/crazy-p.jpg", + "Crazy P discuss Any Signs of Love", + "https://editorial.test/interview/any-signs-of-love", + BingIdentityConfidence::Strong, + true, + ), + test_bing_candidate( + "https://namesake.test/crazy-p.jpg", + "Crazy P live", + "https://namesake.test/artists/crazy-p", + BingIdentityConfidence::Strong, + true, + ), + test_bing_candidate( + "https://weak.test/any-signs.jpg", + "Crazy P Any Signs of Love", + "https://weak.test/post/1", + BingIdentityConfidence::Weak, + true, + ), + // A release card on a generic publisher cannot bless unrelated + // sibling pages across that publisher's entire host. + test_bing_candidate( + "https://publisher.test/vinyl.jpg", + "Crazy P Any Signs of Love vinyl", + "https://publisher.test/releases/any-signs-of-love", + BingIdentityConfidence::Strong, + false, + ), + test_bing_candidate( + "https://publisher.test/other.jpg", + "Crazy P live gallery", + "https://publisher.test/gallery/crazy-p", + BingIdentityConfidence::Strong, + true, + ), + ]; + let filtered = + filter_bing_candidates_for_policy(candidates, &policy, "Crazy P", &linked_people); + let urls: HashSet<&str> = filtered + .iter() + .map(|candidate| candidate.url.as_str()) + .collect(); + assert_eq!(urls.len(), 3); + assert!(urls.contains("https://crazy-p.com/images/live-press-shot.jpg")); + assert!(urls.contains("https://cdn.fatsoma.test/danielle.jpg")); + assert!(urls.contains("https://editorial.test/crazy-p.jpg")); + assert!(!urls.contains("https://crazy-p.com/images/Any-Signs-of-Love-VINYL.jpg")); + assert!(!urls.contains("https://namesake.test/crazy-p.jpg")); + assert!(!urls.contains("https://weak.test/any-signs.jpg")); + assert!(!urls.contains("https://publisher.test/other.jpg")); + } + + #[test] + fn linked_identity_people_accept_only_current_members_and_founders() { + let body = json!({ "relations": [ + { "type": "member of band", "ended": false, "artist": { "name": "Danielle Moore" } }, + { "type": "founder", "ended": false, "artist": { "name": "Chris Todd" } }, + { "type": "member of band", "ended": true, "artist": { "name": "Former Person" } }, + { "type": "collaboration", "ended": false, "artist": { "name": "Unrelated Guest" } }, + { "type": "member of band", "ended": false, "artist": { "name": "Crazy P" } } + ]}); + let people = musicbrainz_identity_people(&body, "Crazy P"); + assert_eq!( + people, + HashSet::from(["danielle moore".to_string(), "chris todd".to_string()]) + ); + } + + #[test] + fn top_tracks_keep_real_collaborations_but_reject_other_explicit_ids() { + let collaboration = json!({ + "artist": { "id": 9 }, + "contributors": [{ "id": 42 }, { "id": 9 }] + }); + assert!(track_belongs_to_artist(&collaboration, 42)); + assert!(!track_belongs_to_artist(&collaboration, 77)); + assert!(track_belongs_to_artist(&json!({}), 42)); + } + + #[test] + fn empty_deferred_identity_echoes_the_requested_artist() { + let empty = empty_catalog_artist_identity(20_6927); + assert_eq!(empty.artist_id, 20_6927); + assert!(empty.albums.is_empty()); + assert!(empty.socials.is_empty()); + assert!(empty.type_hints.is_empty()); + } + + #[test] + fn bing_product_results_are_rejected_without_blocking_editorial_pages() { + assert!(is_product_image_result( + "Billy Cobb — Zerwee vinyl", + "https://needlejuicerecords.com/products/zerwee", + "https://cdn.shopify.com/files/zerwee-product.png", + )); + assert!(is_product_image_result( + "Billy Cobb cassette reissue", + "https://example.test/news/billy-cobb", + "https://images.example.test/zerwee.jpg", + )); + assert!(is_product_image_result( + "Billy Cobb official album cover", + "https://music.example.test/billy-cobb", + "https://images.example.test/cover.jpg", + )); + assert!(is_product_image_result( + "Billy Cobb - Trash", + "https://www.albumoftheyear.org/album/billy-cobb-trash.php", + "https://cdn.albumoftheyear.org/album/161873-trash.jpg", + )); + assert!(is_product_image_result( + "Billy Cobb - Bear Album", + "https://genius.com/albums/Billy-cobb/Bear-album", + "https://images.genius.com/cover.png", + )); + assert!(is_product_image_result( + "Billy Cobb: Forgotten Words", + "https://music.apple.com/us/album/forgotten-words/123", + "https://is1-ssl.mzstatic.com/image/thumb/Music122/v4/artwork.jpg/1200x1200bf-60.jpg", + )); + assert!(is_product_image_result( + "Electro Vibes featuring Hermeth", + "https://www.beatport.com/release/electro-vibes/123", + "https://geo-media.beatport.com/image_size/1400x1400/cover.jpg", + )); + assert!(is_product_image_result( + "Billy Cobb - Halloween IV : r/CassetteReleases", + "https://www.reddit.com/r/CassetteReleases/comments/example/", + "https://external-preview.redd.it/billy-cobb-halloween-iv.jpg", + )); + assert!(is_product_image_result( + "Crazy P announce Any Signs of Love", + "https://crazy-p.com/news/any-signs-of-love", + "https://crazy-p.com/images/CRAZY-P-Any-Signs-Of-Love-VINYL.jpg", + )); + assert!(is_product_image_result( + "Crazy P release announcement", + "https://crazy-p.com/news/new-record", + "https://crazy-p.com/images/any-signs-of-love-album-cover.jpg", + )); + assert!(!is_product_image_result( + "Billy Cobb discusses his new album", + "https://music.example.test/interviews/billy-cobb", + "https://images.example.test/billy-cobb-portrait.jpg", + )); + assert!(!is_product_image_result( + "Camping in Alaska live at Songbyrd", + "https://music.example.test/features/camping-in-alaska", + "https://images.example.test/camping-in-alaska-band.jpg", + )); + } + + #[test] + fn bing_channel_branding_and_album_videos_are_rejected() { + assert!(is_unsuitable_bing_result( + "Billy Cobb", + "https://music.youtube.com/channel/example", + "https://yt3.googleusercontent.com/channel-banner", + "Billy Cobb", + )); + assert!(is_unsuitable_bing_result( + "Billy Cobb (@COBBTIMUS_PRIME) / Twitter", + "https://twitter.com/cobbtimus_prime", + "https://pbs.twimg.com/media/product-photo.jpg", + "Billy Cobb", + )); + assert!(is_unsuitable_bing_result( + "Billy Cobb", + "https://www.facebook.com/BillyCobbStaffie/", + "https://lookaside.fbsbx.com/lookaside/crawler/media/", + "Billy Cobb", + )); + assert!(is_unsuitable_bing_result( + "Billy Cobb - Zerwee", + "https://www.reddit.com/r/weezer/comments/example/", + "https://preview.redd.it/physical-record.jpg", + "Billy Cobb", + )); + assert!(is_unsuitable_bing_result( + "Billy Cobb dance performance", + "https://www.pinterest.com/pin/example/", + "https://i.pinimg.com/736x/unrelated-namesake.jpg", + "Billy Cobb", + )); + assert!(is_unsuitable_bing_result( + "MSPaint self portrait by an artist", + "https://www.deviantart.com/example/art/mspaint-portrait", + "https://images.example/mspaint.png", + "MSPAINT", + )); + assert!(is_unsuitable_bing_result( + "MSPaint (band) - Wikipedia", + "https://en.wikipedia.org/wiki/MSPAINT_(band)", + "https://upload.wikimedia.org/mspaint.jpg", + "MSPAINT", + )); + assert!(is_unsuitable_bing_result( + "Wednesday Addams portrait", + "https://entertainment.example/netflix-wednesday-addams-jenna-ortega", + "https://images.example/wednesday.jpg", + "Wednesday", + )); + assert!(is_unsuitable_bing_result( + "Media Tried And Failed To Make 'Wednesday' A Racism Scandal", + "https://thefederalist.com/when-media-tried-to-turn-netflixs-wednesday-into-a-racism-controversy/", + "https://thefederalist.com/wp-content/uploads/wednesday--scaled.jpg", + "Wednesday", + )); + assert!(is_unsuitable_bing_result( + "Wednesday's Original Dance Scene Would Have Been Worse", + "https://screenrant.com/wednesday-show-dance-scene-original-plan/", + "https://static1.srcdn.com/jenna-ortega-wednesday-dancing-scene.jpg", + "Wednesday", + )); + assert!(is_unsuitable_bing_result( + "‘Wednesday’ On Netflix: Season 2 News & More Updates", + "https://hollywoodlife.com/feature/wednesday-season-2-updates/", + "https://hollywoodlife.com/wp-content/uploads/wednesday-update-embed.jpg", + "Wednesday", + )); + assert!(is_unsuitable_bing_result( + "Wednesday executive producer reveals plans for a second season", + "https://entertainment.example/wednesday-coshowrunner-teases-second-season", + "https://images.example/untitled-design.png", + "Wednesday", + )); + assert!(!is_unsuitable_bing_result( + "Wednesday Announce 2024 U.S. Tour and New Documentary", + "https://pitchfork.com/news/wednesday-announce-2024-us-tour/", + "https://media.pitchfork.com/photos/wednesday-band.jpg", + "Wednesday", + )); + assert!(!is_unsuitable_bing_result( + "Wednesday Interview - Rolling Stone Australia", + "https://au.rollingstone.com/t/wednesday-interview/", + "https://images.example/wednesday-band-interview.jpg", + "Wednesday", + )); + assert!(is_unsuitable_bing_result( + "What is the song in Wednesday’s dance scene?", + "https://www.nme.com/news/tv/what-song-wednesdays-dance-scene-3359077", + "https://www.nme.com/wp-content/uploads/Wednesday-dance-main.jpg", + "Wednesday", + )); + assert!(is_unsuitable_bing_result( + "Wednesday 13 — Just A Rock N Roll Junkie", + "https://music.example/wednesday-13-interview/", + "https://images.example/wednesday-13.jpg", + "Wednesday", + )); + assert!(is_unsuitable_bing_result( + "Wednesday 13 Events", + "https://www.ticketmaster.com/wednesday-13-tickets/artist/1234567", + "https://s1.ticketm.net/dam/a/abc/wednesday-13-hero.jpg", + "Wednesday", + )); + assert!(is_unsuitable_bing_result( + "Wednesday DJ for event", + "https://fantasiaevents.gr/wednesday-party/", + "https://fantasiaevents.gr/wp-content/uploads/dj-for-event.jpg", + "Wednesday", + )); + assert!(is_unsuitable_bing_result( + "Lady Gaga released new song Dead Dance from Wednesday soundtrack", + "https://music.example/lady-gaga-dead-dance", + "https://images.example/lady-gaga-dead-dance-wednesday.jpg", + "Wednesday", + )); + assert!(is_unsuitable_bing_result( + "MS Paint for Windows 11 gets a new design", + "https://www.windowslatest.com/2021/12/02/microsoft-paint-update/", + "https://www.windowslatest.com/wp-content/uploads/2021/12/MS-Paint-for-Windows-11.jpg", + "MSPAINT", + )); + assert!(!is_unsuitable_bing_result( + "MSPaint Announce New EP No Separation", + "https://pitchfork.com/news/mspaint-announce-new-ep-no-separation/", + "https://media.pitchfork.com/photos/MSPAINT.jpeg", + "MSPAINT", + )); + assert!(is_unsuitable_bing_result( + "The Mix 013: Nikki Nair - Mixmag.net", + "https://mixmag.net/feature/the-mix-013-nikki-nair", + "https://cdn.craft.cloud/assets/THE_MIX__NIKKI_NAIR_WEB_2.jpg", + "Nikki Nair", + )); + assert!(!is_unsuitable_bing_result( + "The Mix 013: Nikki Nair - Mixmag.net", + "https://mixmag.net/feature/the-mix-013-nikki-nair", + "https://cdn.craft.cloud/assets/nikki-nair-press-photo.jpg", + "Nikki Nair", + )); + assert!(is_unsuitable_bing_result( + "Bands Equipment at Nathan Oneill blog", + "https://storage.googleapis.com/example/bands-equipment.html", + "https://argoargo.example/wp-content/uploads/IMG_8529.jpg", + "Equipment", + )); + assert!(!is_unsuitable_bing_result( + "Equipment band interview", + "https://music.example/artists/equipment", + "https://cdn.example/equipment-press.jpg", + "Equipment", + )); + assert!(!is_unsuitable_bing_result( + "Billy Cobb - Tour Dates & Concert Schedule", + "https://www.livenation.com/artist/K8vZ917hLqV/billy-cobb-events", + "https://s1.ticketm.net/dam/a/f7b/cc2a0ae7-77cb-4573-919e-323f2abd1f7b_TABLET_LANDSCAPE_LARGE_16_9.jpg", + "Billy Cobb", + )); + assert!(!is_unsuitable_bing_result( + "Billy Cobb Tickets, 2026 Concert Tour Dates", + "https://www.ticketmaster.com/billy-cobb-tickets/artist/3025436", + "https://s1.ticketm.net/dam/a/f7b/cc2a0ae7-77cb-4573-919e-323f2abd1f7b_RETINA_LANDSCAPE_16_9.jpg", + "Billy Cobb", + )); + assert!(is_unsuitable_bing_result( + "Billy Cobb + Other Band Tickets", + "https://www.ticketmaster.com/event/12345", + "https://s1.ticketm.net/dam/a/event-poster_TABLET_LANDSCAPE.jpg", + "Billy Cobb", + )); + assert!(is_unsuitable_bing_result( + "Billy Cobb - Zerwee (Full 2021 Remastered Album Stream)", + "https://www.youtube.com/watch?v=album", + "https://i.ytimg.com/vi/album/maxresdefault.jpg", + "Billy Cobb", + )); + assert!(is_unsuitable_bing_result( + "1955 - Billy Cobb Cover - YouTube", + "https://www.youtube.com/watch?v=cover", + "https://i.ytimg.com/vi/cover/maxresdefault.jpg", + "Billy Cobb", + )); + assert!(is_unsuitable_bing_result( + "NASTY! presents High Fidelity and Hermeth", + "https://venue.example/events/hermeth", + "https://static.example/event-flyer.webp", + "Hermeth", + )); + assert!(is_unsuitable_bing_result( + "Hermeth - Black Gojira (Weith Remix)", + "https://vimeo.com/visualizer", + "https://i.vimeocdn.com/video/visualizer", + "Hermeth", + )); + assert!(is_unsuitable_bing_result( + "Raw: Hermeth, Paris", + "https://shotgun.live/events/hermeth", + "https://res.cloudinary.com/shotgun/production/artworks/banner.jpg", + "Hermeth", + )); + assert!(is_unsuitable_bing_result( + "SLUMLUV and DR3 Sounds present: DJ MELL G & Hermeth", + "https://ra.co/events/1612040", + "https://imgproxy.ra.co/event-art.jpg", + "Hermeth", + )); + assert!(is_unsuitable_bing_result( + "Stream K-65 with Hermeth", + "https://soundcloud.com/swufm/k-65-with-hermeth", + "https://i1.sndcdn.com/artworks-example-t500x500.jpg", + "Hermeth", + )); + assert!(is_unsuitable_bing_result( + "Technopol Mix 063 | Hermeth", + "https://www.technopol.net/podcast/technopol-mix-063-hermeth/", + "https://www.technopol.net/uploads/Podcast-Hermeth-FormatCarre.png", + "Hermeth", + )); + assert!(is_unsuitable_bing_result( + "Hermeth (Brainwaves) – By Foggy, Freakuency & Gym Nasty", + "https://www.secrettelaviv.com/tickets/hermeth-brainwaves", + "https://www.secrettelaviv.com/uploads/hermeth-event.jpg", + "Hermeth", + )); + assert!(is_unsuitable_bing_result( + "Hermeth Corporation - Hermeth", + "https://www.hermeth.de/hermeth-corporation/", + "https://www.hermeth.de/uploads/hermeth.jpg", + "Hermeth", + )); + assert!(!is_unsuitable_bing_result( + "Camping In Alaska Interview (2022) - YouTube", + "https://www.youtube.com/watch?v=interview", + "https://i.ytimg.com/vi/interview/maxresdefault.jpg", + "Camping in Alaska", + )); + assert!(!is_unsuitable_bing_result( + "Billy Cobb Live 1/29/23 - YouTube", + "https://www.youtube.com/watch?v=live", + "https://i.ytimg.com/vi/live/maxresdefault.jpg", + "Billy Cobb", + )); + assert!(!is_unsuitable_bing_result( + "Hermeth live performance", + "https://vimeo.com/live-set", + "https://i.vimeocdn.com/video/live-set", + "Hermeth", + )); + } + + #[test] + fn multi_artist_roundups_require_an_artist_specific_image() { + let title = + "Die Groove DJ-Charts mit Bearcubs, FJAAK, Hermeth, Osmosis Jones, Revan und UU"; + assert!(multi_artist_result_mismatches_image( + title, + "https://groove.de/images/Osmosis-Jones_Liam-Kavanagh.jpg", + "Hermeth", + )); + assert!(!multi_artist_result_mismatches_image( + title, + "https://groove.de/images/Hermeth_Danny-Leal.jpg", + "Hermeth", + )); + assert!(!multi_artist_result_mismatches_image( + "Camping in Alaska: Millions of YouTube views, but they are still going", + "https://www.al.com/images/band-photo.jpg", + "Camping in Alaska", + )); + assert!(multi_artist_result_mismatches_image( + "PARTIBOI69 + JUICY ROMANCE + CESOUS (50CL)", + "https://venue.example/images/partiboi69-lineup.jpg", + "Partiboi69", + )); + assert!(multi_artist_result_mismatches_image( + "Billy Cobb + Other Artist", + "https://venue.example/images/generic-stage.jpg", + "Billy Cobb", + )); + assert!(!multi_artist_result_mismatches_image( + "Billy Cobb + Other Artist", + "https://venue.example/images/billy-cobb-stage.jpg", + "Billy Cobb", + )); + assert!(multi_artist_result_mismatches_image( + "Soccer Mommy with Hana Vu at The Truman", + "https://cdn.example/images/soccer-mommy.jpg", + "Hana Vu", + )); + assert!(multi_artist_result_mismatches_image( + "Horse Jumper of Love - w/ Strange Mangers and Sapien Joyride", + "https://venue.example/images/HJ-1920.png", + "Horse Jumper of Love", + )); + assert!(multi_artist_result_mismatches_image( + "Michael Cera Palin * Hey, ily * Bottom Bracket * Life Looks Good", + "https://venue.example/images/generic-band.jpg", + "Hey, ily", + )); + assert!(!multi_artist_result_mismatches_image( + "Michael Cera Palin * Hey, ily * Bottom Bracket * Life Looks Good", + "https://venue.example/images/hey-ily-band.jpg", + "Hey, ily", + )); + assert!(multi_artist_result_mismatches_image( + "HEY, ILY • Blind Equation • Flyover States • Red Sun • Endswell", + "https://venue.example/images/red-sun-okc-900x667.jpeg", + "Hey, ily", + )); + assert!(!multi_artist_result_mismatches_image( + "HEY, ILY • Blind Equation • Flyover States • Red Sun • Endswell", + "https://venue.example/images/hey-ily-press.jpeg", + "Hey, ily", + )); + assert!(!is_unsuitable_bing_result( + "HEY, ILY • Blind Equation • Flyover States • Red Sun • Endswell", + "https://venue.example/events/hey-ily-and-friends", + "https://venue.example/images/hey-ily-press.jpeg", + "Hey, ily", + )); + assert!(is_unsuitable_bing_result( + "HEY, ILY • Blind Equation • Flyover States • Red Sun • Endswell", + "https://venue.example/events/hey-ily-and-friends", + "https://venue.example/images/red-sun-okc.jpeg", + "Hey, ily", + )); + assert!(!multi_artist_result_mismatches_image( + "Earth, Wind & Fire band photo", + "https://cdn.example/images/generic-press.jpg", + "Earth, Wind & Fire", + )); + assert!(multi_artist_result_mismatches_image( + "Earth, Wind & Fire • Chicago • Toto", + "https://cdn.example/images/chicago.jpg", + "Earth, Wind & Fire", + )); + assert!(multi_artist_result_mismatches_image( + "Partiboi69 y Gerardo Niva nos sumergen en una noche de baile", + "https://wololosound.com/wp-content/uploads/gerardo-niva.jpeg", + "Partiboi69", + )); + assert!(!multi_artist_result_mismatches_image( + "Partiboi69 y Gerardo Niva nos sumergen en una noche de baile", + "https://press.example/images/partiboi69-press.jpeg", + "Partiboi69", + )); + assert!(!multi_artist_result_mismatches_image( + "Partiboi69 y su nuevo album llegan esta semana", + "https://press.example/images/generic-press.jpeg", + "Partiboi69", + )); + assert!(multi_artist_result_mismatches_image( + "Hudson Mohawke and Nikki Nair Release Set The Roof EP", + "https://dubiks.com/images/NIKKI-NAIR-HUDSON-MOHAWKE-2023.webp", + "Nikki Nair", + )); + assert!(multi_artist_result_mismatches_image( + "Hudson Mohawke & Nikki Nair", + "https://cdn.example/images/abc123.webp", + "Nikki Nair", + )); + assert!(multi_artist_result_mismatches_image( + "Hudson Mohawke and Nikki Nair announce a tour", + "https://cdn.example/images/abc123.webp", + "Nikki Nair", + )); + assert!(multi_artist_result_mismatches_image( + "FJAAK & Hermeth", + "https://cdn.example/images/opaque-hash.webp", + "Hermeth", + )); + assert!(multi_artist_result_mismatches_image( + "FJAAK and Hermeth announce a show", + "https://cdn.example/images/opaque-hash.webp", + "Hermeth", + )); + assert!(multi_artist_result_mismatches_image( + "Gerardo Niva & Partiboi 69", + "https://cdn.example/images/opaque-hash.webp", + "Partiboi69", + )); + assert!(multi_artist_result_mismatches_image( + "Gerardo Niva and Partiboi 69 announce a show", + "https://cdn.example/images/opaque-hash.webp", + "Partiboi69", + )); + assert!(multi_artist_result_mismatches_image( + "Aundy Crenshaw Queue’s Nala and Nikki Nair’s new collaboration", + "https://cdn.example/images/opaque-hash.webp", + "Nikki Nair", + )); + assert!(!multi_artist_result_mismatches_image( + "Nikki Nair discusses music and his new label", + "https://press.example/images/Nikki-Nair-Profile-Photo.jpg", + "Nikki Nair", + )); + assert!(!multi_artist_result_mismatches_image( + "Nikki Nair and the new album", + "https://cdn.example/images/abc123.webp", + "Nikki Nair", + )); + assert!(!multi_artist_result_mismatches_image( + "Partiboi 69 and the new album", + "https://cdn.example/images/abc123.webp", + "Partiboi69", + )); + } + + #[test] + fn conflicting_person_name_in_image_path_is_rejected() { + assert!(image_path_conflicts_with_two_part_artist( + "https://img.example/schlagzeuger-jimmy-cobb-von-links-mit-miles-davis.jpg", + "Billy Cobb", + )); + assert!(!image_path_conflicts_with_two_part_artist( + "https://s1.ticketm.net/dam/a/f7b/billy-cobb_TABLET_LANDSCAPE.jpg", + "Billy Cobb", + )); + assert!(!image_path_conflicts_with_two_part_artist( + "https://press.example/images/portrait-cobb.jpg", + "Billy Cobb", + )); + assert!(!image_path_conflicts_with_two_part_artist( + "https://press.example/images/a7cba5e4-hero.jpg", + "Billy Cobb", + )); + assert!(!image_path_conflicts_with_two_part_artist( + "https://press.example/images/not-the-band.jpg", + "Camping in Alaska", + )); + assert!(image_path_conflicts_with_two_part_artist( + "https://press.example/billy-cobb/jimmy-cobb.jpg", + "Billy Cobb", + )); + assert!(image_path_conflicts_with_two_part_artist( + "https://press.example/images/jimmy-cobb.jpg?ref=billy-cobb", + "Billy Cobb", + )); + assert!(image_filename_names_artist( + "https://press.example/roundups/other-act/hey-ily-press.jpg?ref=generic", + "Hey, ily", + )); + assert!(!image_filename_names_artist( + "https://press.example/hey-ily/red-sun.jpg?ref=hey-ily", + "Hey, ily", + )); + } + + #[test] + fn graphic_and_proxy_url_hints_are_conservative() { + assert!(is_lossless_graphic_url( + "https://press.example/interview-card.PNG?width=1200" + )); + assert!(!is_lossless_graphic_url( + "https://press.example/artist-photo.webp?width=1200" + )); + assert_eq!( + embedded_raster_dimensions( + "https://cdn.example/c8b38bd6-300x168.jpg?rect=0,9,300,150&w=2048&h=1024" + ), + Some((300, 168)) + ); + assert_eq!( + embedded_raster_dimensions("https://press.example/hero-credit-2000x1500-1068x801.jpg"), + Some((1068, 801)) + ); + assert_eq!( + embedded_raster_dimensions( + "https://s1.ticketm.net/dam/a/id_TABLET_LANDSCAPE_LARGE_16_9.jpg" + ), + None + ); + assert_eq!( + embedded_raster_dimensions("https://cdn.example/abc300x168def/photo.jpg"), + None + ); + assert!(is_ticketing_artist_profile_image( + "https://www.livenation.com/artist/K8vZ917hLqV/billy-cobb-events", + "https://s1.ticketm.net/dam/a/id/hero.jpg", + )); + assert!(!is_ticketing_artist_profile_image( + "https://evil.test/https://www.ticketmaster.com/artist/billy-cobb", + "https://evil.test/ticketm.net/dam/a/hero.jpg", + )); + } + + #[test] + fn white_product_canvas_is_penalized_but_not_rejected() { + let white = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 160, + 90, + image::Rgb([248, 248, 248]), + )); + let dark = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 160, + 90, + image::Rgb([30, 28, 32]), + )); + assert_eq!(search_product_background_penalty(&white), 0.14); + assert_eq!(search_product_background_penalty(&dark), 1.0); + + // A subject-colored center does not let a white product canvas evade + // the border heuristic. + let mut mockup = image::RgbImage::from_pixel(160, 90, image::Rgb([250, 250, 250])); + for y in 20..70 { + for x in 45..115 { + mockup.put_pixel(x, y, image::Rgb([40, 60, 90])); + } + } + let mockup = image::DynamicImage::ImageRgb8(mockup); + assert_eq!(search_product_background_penalty(&mockup), 0.14); + } + + #[test] + fn structured_high_contrast_text_is_penalized() { + let clean = image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(384, 216, |x, y| { + let shade = 55 + ((x + y) % 120) as u8; + image::Rgb([shade, shade.saturating_add(12), shade.saturating_add(24)]) + })); + assert_eq!(search_text_overlay_penalty(&clean), 1.0); + + // Repeated narrow white glyph-like strokes form a caption band. This + // avoids a font dependency while exercising the same row structure. + let mut captioned = image::RgbImage::from_pixel(384, 216, image::Rgb([45, 48, 52])); + for glyph_x in (24..360).step_by(8) { + for y in 176..193 { + for x in glyph_x..glyph_x + 4 { + captioned.put_pixel(x, y, image::Rgb([248, 248, 248])); + } + } + } + let captioned = image::DynamicImage::ImageRgb8(captioned); + assert!(search_text_overlay_penalty(&captioned) <= 0.70); + assert!(search_has_flat_graphic_layout(&captioned)); + + let textured = + image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(192, 108, |x, y| { + image::Rgb([ + ((x * 47 + y * 13) % 256) as u8, + ((x * 17 + y * 61) % 256) as u8, + ((x * 79 + y * 29) % 256) as u8, + ]) + })); + assert!(!search_has_flat_graphic_layout(&textured)); + assert!(!search_has_low_palette_graphic(&textured)); + + let illustration = + image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(384, 216, |x, y| { + if x < 192 && y < 108 { + image::Rgb([12, 20, 92]) + } else if x >= 192 && y < 108 { + image::Rgb([245, 178, 92]) + } else { + image::Rgb([128, 24, 142]) + } + })); + assert!(search_has_low_palette_graphic(&illustration)); + assert!(has_release_art_context( + "NEW TRACK: Artist - Under Your Spell" + )); + assert!(!has_release_art_context("Artist press photo and interview")); + + let panel_colors = [ + image::Rgb([180, 25, 30]), + image::Rgb([20, 130, 210]), + image::Rgb([235, 190, 35]), + image::Rgb([35, 170, 80]), + image::Rgb([95, 35, 155]), + image::Rgb([225, 80, 170]), + image::Rgb([25, 185, 185]), + image::Rgb([225, 225, 215]), + ]; + let panel_grid = + image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(800, 400, |x, y| { + panel_colors[((y / 200) * 4 + (x / 200)) as usize] + })); + assert!(search_has_panel_collage_layout(&panel_grid)); + assert!(!search_has_panel_collage_layout(&textured)); + + let one_architectural_edge = + image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(800, 400, |x, y| { + let base = ((x * 11 + y * 7) % 90) as u8; + if x < 400 { + image::Rgb([40 + base, 55 + base, 70 + base]) + } else { + image::Rgb([125 + base, 115 + base, 105 + base]) + } + })); + assert!(!search_has_panel_collage_layout(&one_architectural_edge)); + } + + #[test] + fn hero_selection_waits_for_scored_identity_and_deduplicates_crops() { + let original = + image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(800, 600, |x, y| { + match ((x / 40) + (y / 30)) % 4 { + 0 => image::Rgb([26, 48, 84]), + 1 => image::Rgb([174, 92, 58]), + 2 => image::Rgb([62, 132, 108]), + _ => image::Rgb([216, 184, 126]), + } + })); + let cropped = original.crop_imm(120, 90, 560, 420).resize_exact( + 1200, + 900, + image::imageops::FilterType::Triangle, + ); + let distinct = + image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(800, 600, |x, y| { + image::Rgb([((x ^ y) % 256) as u8, (x % 256) as u8, (y % 256) as u8]) + })); + let original_fingerprint = hero_fingerprint(&original); + let cropped_fingerprint = hero_fingerprint(&cropped); + let distinct_fingerprint = hero_fingerprint(&distinct); + let crop_distance = original_fingerprint + .dhashes + .iter() + .flat_map(|left| { + cropped_fingerprint + .dhashes + .iter() + .map(move |right| (left ^ right).count_ones()) + }) + .min() + .unwrap(); + let color_intersection: u32 = original_fingerprint + .color_histogram + .iter() + .zip(&cropped_fingerprint.color_histogram) + .map(|(left, right)| u32::from((*left).min(*right))) + .sum(); + assert!( + hero_fingerprints_match(&original_fingerprint, &cropped_fingerprint), + "crop distance {crop_distance}, color intersection {color_intersection}" + ); + assert!(!hero_fingerprints_match( + &original_fingerprint, + &distinct_fingerprint + )); + + // dHash alone sees all uniform fields as identical. Color evidence + // prevents unrelated flat red/blue images from collapsing together. + let flat_red = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 800, + 450, + image::Rgb([220, 20, 20]), + )); + let flat_blue = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 800, + 450, + image::Rgb([20, 20, 220]), + )); + let red_fingerprint = hero_fingerprint(&flat_red); + let blue_fingerprint = hero_fingerprint(&flat_blue); + assert_eq!(red_fingerprint.dhashes[0], blue_fingerprint.dhashes[0]); + assert!(!hero_fingerprints_match( + &red_fingerprint, + &blue_fingerprint + )); + + // With no successfully scored Strong image, retain exactly one Weak + // fallback. Once a Strong image scores, Weak results are excluded. + let (weak_only, _) = select_scored_hero_urls(vec![ + ( + "weak-a".into(), + 20.0, + original_fingerprint.clone(), + BingIdentityConfidence::Weak, + ), + ( + "weak-b".into(), + 10.0, + distinct_fingerprint.clone(), + BingIdentityConfidence::Weak, + ), + ]); + assert_eq!(weak_only, vec!["weak-a"]); + assert_eq!( + select_strong_hero_seed(vec![ + ( + "weak-a".into(), + 20.0, + original_fingerprint.clone(), + BingIdentityConfidence::Weak, + ), + ( + "weak-b".into(), + 10.0, + distinct_fingerprint.clone(), + BingIdentityConfidence::Weak, + ), + ]), + None, + "a provisional Bing hero must never expose a Weak-only result", + ); + let (strong_only, _) = select_scored_hero_urls(vec![ + ( + "weak".into(), + 100.0, + distinct_fingerprint.clone(), + BingIdentityConfidence::Weak, + ), + ( + "strong".into(), + 10.0, + original_fingerprint.clone(), + BingIdentityConfidence::Strong, + ), + ( + "strong-crop".into(), + 9.0, + cropped_fingerprint, + BingIdentityConfidence::Strong, + ), + ]); + assert_eq!(strong_only, vec!["strong"]); + assert_eq!( + select_strong_hero_seed(vec![ + ( + "weak".into(), + 100.0, + distinct_fingerprint, + BingIdentityConfidence::Weak, + ), + ( + "strong".into(), + 10.0, + original_fingerprint, + BingIdentityConfidence::Strong, + ), + ]), + Some("strong".into()), + ); + } + + #[test] + fn duplicate_bing_urls_upgrade_to_stronger_metadata() { + let existing = BingImageCandidate { + url: "https://s1.ticketm.net/dam/a/id/hero.jpg".into(), + title: "Billy Cobb live".into(), + page_url: "https://youtube.com/watch?v=x".into(), + identity: BingIdentityConfidence::Weak, + display_eligible: true, + query_index: 0, + rank_in_query: 0, + }; + let trusted = BingImageCandidate { + url: existing.url.clone(), + title: "Billy Cobb tour dates".into(), + page_url: "https://www.livenation.com/artist/id/billy-cobb-events".into(), + identity: BingIdentityConfidence::Strong, + display_eligible: true, + query_index: 4, + rank_in_query: 2, + }; + assert!(bing_candidate_metadata_is_better(&trusted, &existing)); + assert!(!bing_candidate_metadata_is_better(&existing, &trusted)); + } + + #[test] + fn hero_decode_limits_bound_remote_image_allocations() { + let limits = hero_decode_limits(); + assert_eq!(limits.max_alloc, Some(HERO_MAX_DECODE_ALLOC_BYTES)); + assert!(limits + .check_dimensions(HERO_MAX_DECODE_DIMENSION, HERO_MAX_DECODE_DIMENSION) + .is_ok()); + assert!(limits + .check_dimensions(HERO_MAX_DECODE_DIMENSION + 1, 1) + .is_err()); + + let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 32, + 32, + image::Rgb([90, 120, 150]), + )); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + assert!(decode_hero_image(bytes.into_inner()).is_some()); + } + + #[test] + fn video_thumbnail_sources_are_softly_deprioritized() { + assert_eq!( + search_thumbnail_source_penalty("https://i.ytimg.com/vi/interview/maxresdefault.jpg"), + 0.58 + ); + assert_eq!( + search_thumbnail_source_penalty("https://press.example/artist-photo.jpg"), + 1.0 + ); + } + + fn album_with(titles: &[&str]) -> serde_json::Value { + json!({ "tracks": { "data": titles.iter().map(|t| json!({ "title": t })).collect::>() } }) + } + + #[test] + fn live_album_from_untitled_tracks() { + // Animal Collective "Ballet Slippers" — real tracklist, title gives no + // hint, but every track is marked live. + let ballet = album_with(&[ + "In The Flowers (Live June 2, 2009 Boulder, CO)", + "Who Could Win A Rabbit (Live June 9, 2009 Ft. Lauderdale, FL)", + "Summertime Clothes (Live June 9, 2009 Ft. Lauderdale, FL)", + "Bleed (Live May 30, 2009 Las Vegas, NV)", + "My Girls (Live May 30, 2009 Las Vegas, NV)", + "Brother Sport (Live June 2, 2009 Boulder, CO)", + ]); + assert!(detail_is_live(&ballet)); + + // "FEELSLive 04/05" — the "Live" in the title has no word boundary, but + // the tracks settle it. + let feels = album_with(&[ + "Wastered (Live Bowery Ballroom 8/15/04)", + "Loch Raven (Live Haverford College 4/23/05)", + "Grass (Live Ottobar 11/19/04)", + "Turn Into Something (Live Coltrane 4/22/05)", + ]); + assert!(detail_is_live(&feels)); + + // "(Live)" suffix form. + assert!(detail_is_live(&album_with(&[ + "Amanita (Live)", + "My Girls (Live)", + "Peacebone (Live)", + ]))); + } + + #[test] + fn studio_album_is_not_live() { + // Animal Collective "Time Skiffs" — clean studio tracklist. + let time_skiffs = album_with(&[ + "Dragon Slayer", + "Car Keys", + "Prester John", + "Strung with Everything", + "Cherokee", + "We Go Back", + ]); + assert!(!detail_is_live(&time_skiffs)); + + // A studio album carrying a couple of live bonus cuts stays studio: 2 of + // 8 live is well under the 60% majority. + let deluxe = album_with(&[ + "Track One", + "Track Two", + "Track Three", + "Track Four", + "Track Five", + "Track Six", + "Encore (Live)", + "Closer (Live)", + ]); + assert!(!detail_is_live(&deluxe)); + + // Ordinary titles containing the word "live" are not live markers. + assert!(!is_live_track_title("Long Live")); + assert!(!is_live_track_title("Come Alive")); + assert!(is_live_track_title("My Girls (Live)")); + assert!(is_live_track_title("Encore - Live")); + } + + #[test] + fn too_few_tracks_or_no_tracklist_is_not_live() { + // Fewer than three tracks: not enough to call it a live album. + assert!(!detail_is_live(&album_with(&[ + "A Song (Live)", + "Another (Live)" + ]))); + // No tracklist at all (e.g. a search result) resolves false, not a panic. + assert!(!detail_is_live(&json!({ "title": "Some Album" }))); + } +} diff --git a/crates/player/Cargo.toml b/crates/player/Cargo.toml new file mode 100644 index 0000000..e5234f0 --- /dev/null +++ b/crates/player/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "player" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +rodio = { version = "0.21", features = ["symphonia-all"] } +symphonia = { version = "0.5", features = ["all"] } +thiserror.workspace = true +log.workspace = true + +[dev-dependencies] +env_logger = "0.11" diff --git a/crates/player/examples/play.rs b/crates/player/examples/play.rs new file mode 100644 index 0000000..2d13f5a --- /dev/null +++ b/crates/player/examples/play.rs @@ -0,0 +1,41 @@ +//! Local playback smoke test: play a file, seek, report positions. +//! +//! cargo run -p player --example play -- "C:\path\to\file.flac" + +use player::{Player, PlayerEvent}; +use std::time::Duration; + +fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("debug")).init(); + let path = std::env::args().nth(1).expect("usage: play "); + + let (player, events) = Player::spawn(); + player.play_local(&path, None); + + let mut last_pos = 0.0; + let start = std::time::Instant::now(); + let mut seeked = false; + + while start.elapsed() < Duration::from_secs(12) { + match events.recv_timeout(Duration::from_millis(500)) { + Ok(PlayerEvent::Playing) => println!("PLAYING"), + Ok(PlayerEvent::Position { secs, duration }) => { + last_pos = secs; + println!("pos={secs:.1} dur={duration:?}"); + if secs > 3.0 && !seeked { + seeked = true; + println!("SEEK to 60s…"); + player.seek(60.0); + } + } + Ok(PlayerEvent::Error(e)) => { + println!("ERROR: {e}"); + return; + } + Ok(ev) => println!("{ev:?}"), + Err(_) => {} + } + } + println!("final pos: {last_pos:.1} (expect > 60 if seek worked)"); + player.stop(); +} diff --git a/crates/player/examples/preview_probe.rs b/crates/player/examples/preview_probe.rs new file mode 100644 index 0000000..9b8b7dd --- /dev/null +++ b/crates/player/examples/preview_probe.rs @@ -0,0 +1,100 @@ +//! Phase 0.3 — native transport probe (isolated diagnostic, not feature code). +//! +//! Answers the native column's core questions with the existing crate, no new +//! dependencies: +//! +//! 1. Can a SECOND `Player` instance coexist with a "main" one — two sinks, +//! two event channels, no shared state? +//! 2. Does a real Deezer preview (MPEG-1 Layer III, 128k CBR, ~480 KB) +//! decode and play through rodio/symphonia? +//! 3. Do pause / resume / stop behave on the second instance while the +//! first is mid-track, without cross-talk on either event channel? +//! +//! The preview file is downloaded OUTSIDE this probe (curl) and passed as +//! argv[1]; argv[2] is any long local track for the "main" side. Run: +//! +//! cargo run -p player --example preview_probe -- preview.mp3 main.mp3 + +use std::sync::mpsc::RecvTimeoutError; +use std::time::{Duration, Instant}; + +use player::{Player, PlayerEvent}; + +fn drain( + label: &str, + rx: &std::sync::mpsc::Receiver, + for_ms: u64, + log: &mut Vec, +) { + let end = Instant::now() + Duration::from_millis(for_ms); + loop { + let left = end.saturating_duration_since(Instant::now()); + if left.is_zero() { + break; + } + match rx.recv_timeout(left) { + Ok(ev) => log.push(format!("{label}: {ev:?}")), + Err(RecvTimeoutError::Timeout) => break, + Err(RecvTimeoutError::Disconnected) => { + log.push(format!("{label}: channel closed")); + break; + } + } + } +} + +fn main() { + let mut args = std::env::args().skip(1); + let preview = args.next().expect("argv[1]: downloaded preview mp3"); + let main_track = args.next(); + + let mut log = Vec::new(); + + // "Main" player — only if a second file was provided; the coexistence + // half of the probe is skipped otherwise. + let main_pair = main_track.map(|p| { + let (main, main_rx) = Player::spawn(); + main.play_local(&p, None); + (main, main_rx, p) + }); + + // Preview player: a wholly separate instance and event channel. + let (prev, prev_rx) = Player::spawn(); + let t0 = Instant::now(); + prev.play_local(&preview, Some(30.0)); + + drain("preview", &prev_rx, 2500, &mut log); + let started = log.iter().any(|l| l.contains("Playing")); + println!("PROBE preview-starts: {started} ({} ms to first events)", t0.elapsed().as_millis()); + + prev.pause(); + drain("preview", &prev_rx, 600, &mut log); + let paused = log.iter().any(|l| l.contains("Paused")); + println!("PROBE preview-pauses: {paused}"); + + prev.resume(); + drain("preview", &prev_rx, 900, &mut log); + + if let Some((_main, main_rx, ref path)) = main_pair { + let mut main_log = Vec::new(); + drain("main", &main_rx, 1200, &mut main_log); + let main_alive = main_log.iter().any(|l| l.contains("Playing") || l.contains("Position")); + println!("PROBE main-coexists: {main_alive} ({path})"); + // cross-talk check: no preview-channel event may appear on main's + // channel or vice versa — separate receivers make this structural, + // so what we actually assert is that both channels stayed live. + println!("PROBE channels-independent: true (separate mpsc receivers by construction)"); + } else { + println!("PROBE main-coexists: skipped (no second file)"); + } + + prev.stop(); + drain("preview", &prev_rx, 600, &mut log); + let stopped = log.iter().any(|l| l.contains("Stopped")); + println!("PROBE preview-stops: {stopped}"); + + println!("--- event log ---"); + for l in &log { + println!("{l}"); + } +} diff --git a/crates/player/examples/wf.rs b/crates/player/examples/wf.rs new file mode 100644 index 0000000..3a8e267 --- /dev/null +++ b/crates/player/examples/wf.rs @@ -0,0 +1,17 @@ +//! Print an ASCII preview of a file's waveform shape. +//! cargo run -p player --example wf -- "C:\path\to\file.flac" + +fn main() { + let path = std::path::PathBuf::from(std::env::args().nth(1).expect("usage: wf ")); + let t = std::time::Instant::now(); + let peaks = player::compute_waveform(&path, 80); + println!("{} buckets in {:?}", peaks.len(), t.elapsed()); + let glyphs = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let bars: String = peaks + .iter() + .map(|&v| glyphs[((v * 8.0) as usize).min(8)]) + .collect(); + println!("{bars}"); + let flat = peaks.iter().filter(|&&v| v > 0.95).count(); + println!("buckets >0.95: {flat}/{}", peaks.len()); +} diff --git a/crates/player/src/lib.rs b/crates/player/src/lib.rs new file mode 100644 index 0000000..648931e --- /dev/null +++ b/crates/player/src/lib.rs @@ -0,0 +1,904 @@ +//! Audio playback: rodio/symphonia on a dedicated audio thread, with a +//! growing-file source for play-while-downloading. +//! +//! The audio thread owns the output device and stays responsive: decoder +//! construction (which blocks until enough of the file exists) happens on +//! short-lived helper threads, tagged with a generation counter so stale +//! decodes are discarded when the user has already moved on. + +mod source; +mod waveform; + +pub use source::StreamProgress; +pub use waveform::compute_waveform; + +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use rodio::cpal::traits::{DeviceTrait, HostTrait}; +use rodio::{buffer::SamplesBuffer, Decoder, Sink, Source}; + +use source::GrowingFile; + +#[derive(Debug, Clone)] +pub enum PlayerEvent { + /// Decoder is being set up (streaming: waiting for first bytes). + Buffering, + Playing, + Paused, + Stopped, + Position { + secs: f64, + duration: Option, + }, + /// A track ended. `gapless` means the next decoder was already queued in + /// the audio sink and has begun without an output-device interruption. + TrackEnded { + gapless: bool, + }, + Error(String), +} + +enum Cmd { + Play { + path: PathBuf, + stream: Option>, + total_bytes: u64, + duration_hint: Option, + }, + /// Decode and append the next local track before the active one ends. + Preload { + path: PathBuf, + duration_hint: Option, + }, + Pause, + Resume, + Seek(f64), + Scratch { + secs: f64, + rate: f32, + }, + EndScratch { + secs: f64, + }, + Volume(f32), + SetOutputDevice { + device: Option, + reply: mpsc::Sender>, + }, + Stop, +} + +#[derive(Clone)] +struct TrackSource { + path: PathBuf, + stream: Option>, + total_bytes: u64, + duration_hint: Option, +} + +struct ReadyDecoder { + generation: u64, + decoder: Decoder>, + track: TrackSource, + preload: bool, + resume_at: Option, + start_paused: bool, +} + +impl ReadyDecoder { + /// True duration for the timeline/seek range. The peer's search-metadata + /// hint can be wildly wrong (a bogus 30s on a 41-minute live track), and + /// on its own it would truncate the seek bar. The decoder reads the real + /// length from the container (FLAC STREAMINFO, an MP3's byte length), so + /// we take whichever is LONGER: a short hint can't truncate a genuinely + /// long file, and a still-partial decoder can't under-report against a + /// good hint. + fn track_duration(&self) -> Option { + reconcile_duration( + self.track.duration_hint, + self.decoder.total_duration().map(|d| d.as_secs_f64()), + ) + } +} + +/// Pick the timeline duration from the peer's metadata hint and the decoder's +/// own reading: take whichever is longer. See [`ReadyDecoder::track_duration`]. +fn reconcile_duration(hint: Option, decoded: Option) -> Option { + match (hint, decoded) { + (Some(h), Some(d)) => Some(h.max(d)), + (h, d) => h.or(d), + } +} + +struct PendingTrack { + duration: Option, + track: TrackSource, +} + +/// Forwards a boundary event from rodio's real-time source iterator exactly +/// when a decoder is exhausted. This lets the UI change its metadata after +/// the next queued source has already started producing samples. +struct EndNotifier { + inner: S, + queued: Arc, + event_tx: mpsc::Sender, + sent: bool, +} + +impl Iterator for EndNotifier +where + S: Source, +{ + type Item = S::Item; + + fn next(&mut self) -> Option { + match self.inner.next() { + Some(sample) => Some(sample), + None => { + if !self.sent { + self.sent = true; + let remaining = self.queued.fetch_sub(1, Ordering::SeqCst); + let _ = self.event_tx.send(PlayerEvent::TrackEnded { + gapless: remaining > 1, + }); + } + None + } + } + } +} + +impl Source for EndNotifier +where + S: Source, +{ + fn current_span_len(&self) -> Option { + self.inner.current_span_len() + } + fn channels(&self) -> rodio::ChannelCount { + self.inner.channels() + } + fn sample_rate(&self) -> rodio::SampleRate { + self.inner.sample_rate() + } + fn total_duration(&self) -> Option { + self.inner.total_duration() + } + // The gapless boundary wrapper must remain transparent to transport + // controls. Without this delegation, Source's default implementation + // reports `NotSupported` and makes every seek a no-op. + fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> { + self.inner.try_seek(pos) + } +} + +#[derive(Clone)] +pub struct Player { + cmd_tx: mpsc::Sender, +} + +impl Player { + /// Spawn the audio thread. Events stream out on the receiver. + pub fn spawn() -> (Player, mpsc::Receiver) { + Self::spawn_with_device(None) + } + + /// Spawn with a persisted output-device preference. `None` follows the + /// operating system's default output device. + pub fn spawn_with_device( + output_device: Option, + ) -> (Player, mpsc::Receiver) { + let (cmd_tx, cmd_rx) = mpsc::channel::(); + let (event_tx, event_rx) = mpsc::channel::(); + + std::thread::Builder::new() + .name("audio".into()) + .spawn(move || audio_thread(cmd_rx, event_tx, output_device)) + .expect("spawn audio thread"); + + (Player { cmd_tx }, event_rx) + } + + pub fn play_local(&self, path: impl Into, duration_hint: Option) { + let _ = self.cmd_tx.send(Cmd::Play { + path: path.into(), + stream: None, + total_bytes: 0, + duration_hint, + }); + } + + /// Queue a local file behind the current source. The decoder is prepared + /// on the helper thread, so disk I/O never interrupts audible playback. + pub fn preload_local(&self, path: impl Into, duration_hint: Option) { + let _ = self.cmd_tx.send(Cmd::Preload { + path: path.into(), + duration_hint, + }); + } + + /// Play a file that is still being downloaded. `progress` must be kept + /// updated by the download; `total_bytes` is the expected final size. + pub fn play_stream( + &self, + path: impl Into, + progress: Arc, + total_bytes: u64, + duration_hint: Option, + ) { + let _ = self.cmd_tx.send(Cmd::Play { + path: path.into(), + stream: Some(progress), + total_bytes, + duration_hint, + }); + } + + pub fn pause(&self) { + let _ = self.cmd_tx.send(Cmd::Pause); + } + + pub fn resume(&self) { + let _ = self.cmd_tx.send(Cmd::Resume); + } + + pub fn seek(&self, secs: f64) { + let _ = self.cmd_tx.send(Cmd::Seek(secs)); + } + + pub fn scratch(&self, secs: f64, rate: f32) { + let _ = self.cmd_tx.send(Cmd::Scratch { secs, rate }); + } + + pub fn end_scratch(&self, secs: f64) { + let _ = self.cmd_tx.send(Cmd::EndScratch { secs }); + } + + pub fn set_volume(&self, volume: f32) { + let _ = self.cmd_tx.send(Cmd::Volume(volume.clamp(0.0, 2.0))); + } + + /// Change devices immediately. The audio thread acknowledges only after + /// the endpoint has opened successfully, so callers can safely persist it. + pub fn set_output_device(&self, device: Option) -> Result<(), String> { + let (reply, result) = mpsc::channel(); + self.cmd_tx + .send(Cmd::SetOutputDevice { device, reply }) + .map_err(|_| "audio thread is unavailable".to_string())?; + result + .recv_timeout(Duration::from_secs(5)) + .map_err(|_| "timed out while opening the audio device".to_string())? + } + + pub fn stop(&self) { + let _ = self.cmd_tx.send(Cmd::Stop); + } +} + +/// Names reported by the platform audio host, suitable for a device picker. +pub fn output_device_names() -> Result, String> { + let host = rodio::cpal::default_host(); + let mut names: Vec = host + .output_devices() + .map_err(|e| format!("could not list audio devices: {e}"))? + .filter_map(|device| device.name().ok()) + .collect(); + names.sort_by_key(|name| name.to_lowercase()); + names.dedup(); + Ok(names) +} + +fn default_output_device_name() -> Option { + rodio::cpal::default_host() + .default_output_device()? + .name() + .ok() +} + +fn open_output_stream(device_name: Option<&str>) -> Result<(rodio::OutputStream, String), String> { + let host = rodio::cpal::default_host(); + let device = match device_name { + Some(wanted) => host + .output_devices() + .map_err(|e| format!("could not list audio devices: {e}"))? + .find(|device| device.name().ok().as_deref() == Some(wanted)) + .ok_or_else(|| format!("audio device not found: {wanted}"))?, + None => host + .default_output_device() + .ok_or_else(|| "Windows has no default audio output device".to_string())?, + }; + let actual_name = device.name().unwrap_or_else(|_| "Unknown device".into()); + let builder = rodio::OutputStreamBuilder::from_device(device) + .map_err(|e| format!("could not configure {actual_name}: {e}"))?; + let stream = builder + .open_stream_or_fallback() + .map_err(|e| format!("could not open {actual_name}: {e}"))?; + Ok((stream, actual_name)) +} + +fn start_decoder( + track: TrackSource, + generation: u64, + preload: bool, + resume_at: Option, + start_paused: bool, + tx: mpsc::Sender>, +) { + std::thread::Builder::new() + .name( + if preload { + "decode-preload" + } else { + "decode-setup" + } + .into(), + ) + .spawn(move || { + let (reader, byte_len): (Box, u64) = match track.stream.clone() { + Some(progress) => { + match GrowingFile::open(&track.path, track.total_bytes, progress) { + Ok(g) => (Box::new(g), track.total_bytes), + Err(e) => { + let _ = tx.send(Err((generation, e.to_string()))); + return; + } + } + } + None => match std::fs::File::open(&track.path) { + Ok(f) => { + let len = f.metadata().map(|m| m.len()).unwrap_or(0); + (Box::new(std::io::BufReader::new(f)), len) + } + Err(e) => { + let _ = tx.send(Err((generation, e.to_string()))); + return; + } + }, + }; + let mut builder = Decoder::builder().with_data(reader).with_seekable(true); + if byte_len > 0 { + builder = builder.with_byte_len(byte_len); + } + match builder.build() { + Ok(decoder) => { + let _ = tx.send(Ok(ReadyDecoder { + generation, + decoder, + track, + preload, + resume_at, + start_paused, + })); + } + Err(e) => { + let _ = tx.send(Err((generation, format!("decode failed: {e}")))); + } + } + }) + .ok(); +} + +fn audio_thread( + cmd_rx: mpsc::Receiver, + event_tx: mpsc::Sender, + initial_device: Option, +) { + // Open the output device lazily and keep it across tracks. If the device + // is momentarily busy (e.g. another instance still releasing it), we + // retry on the next Play instead of killing the thread for the session. + let (mut output_stream, mut current_device_name) = + match open_output_stream(initial_device.as_deref()) { + Ok((stream, name)) => (Some(stream), Some(name)), + Err(e) => { + log::warn!("could not open configured audio output: {e}"); + (None, None) + } + }; + let mut selected_device = initial_device; + let mut last_default_check = Instant::now(); + + let (decoder_tx, decoder_rx) = mpsc::channel::>(); + + let mut sink: Option = None; + let mut generation: u64 = 0; + let mut duration: Option = None; + let mut volume: f32 = 0.7; + let mut playing = false; + // The active stream source, so we can abort its blocking reads. + let mut active_stream: Option> = None; + // Number of sources handed to the sink but not yet exhausted. Kept in an + // atomic because the rodio mixer consumes sources on its own audio thread. + let queued_sources = Arc::new(AtomicUsize::new(0)); + let mut waiting_preload: Option = None; + // Durations of sources queued behind the active one. When a gapless + // boundary passes, the front entry becomes the new track's duration — + // otherwise Position events keep reporting the finished track's length. + let mut pending_tracks: VecDeque = VecDeque::new(); + let mut last_queued: usize = 0; + // Scratch playback is possible only for a stable, complete local file. + let mut local_path: Option = None; + let mut active_track: Option = None; + let mut scratching = false; + let mut scratch_was_playing = false; + + let emit = |ev: PlayerEvent| { + let _ = event_tx.send(ev); + }; + + loop { + let mut output_change: Option<(Option, Option>>)> = + None; + // Commands, with a 60 ms tick for a visibly smooth playhead without + // flooding the UI with frame-rate IPC events. + match cmd_rx.recv_timeout(Duration::from_millis(60)) { + Ok(Cmd::Play { + path, + stream, + total_bytes, + duration_hint, + }) => { + // Abort whatever was playing or buffering. + generation += 1; + if let Some(s) = active_stream.take() { + s.abort(); + } + if let Some(s) = sink.take() { + s.stop(); + } + queued_sources.store(0, Ordering::SeqCst); + waiting_preload = None; + pending_tracks.clear(); + playing = false; + scratching = false; + let track = TrackSource { + path, + stream, + total_bytes, + duration_hint, + }; + local_path = track.stream.is_none().then(|| track.path.clone()); + duration = duration_hint; + emit(PlayerEvent::Buffering); + + active_stream = track.stream.clone(); + active_track = Some(track.clone()); + start_decoder(track, generation, false, None, false, decoder_tx.clone()); + } + Ok(Cmd::Preload { + path, + duration_hint, + }) => { + // Streams are intentionally excluded: a growing file can + // block while waiting for bytes, which would defeat a clean + // album transition. Local files are fully available. + start_decoder( + TrackSource { + path, + stream: None, + total_bytes: 0, + duration_hint, + }, + generation, + true, + None, + false, + decoder_tx.clone(), + ); + } + Ok(Cmd::Pause) => { + if let Some(s) = &sink { + s.pause(); + playing = false; + emit(PlayerEvent::Paused); + } + } + Ok(Cmd::Resume) => { + if let Some(s) = &sink { + s.play(); + playing = true; + emit(PlayerEvent::Playing); + } + } + Ok(Cmd::Seek(secs)) => { + if let Some(s) = &sink { + if let Err(e) = s.try_seek(Duration::from_secs_f64(secs.max(0.0))) { + log::debug!("seek failed: {e}"); + } + } + } + Ok(Cmd::Scratch { secs, rate }) => { + let (Some(path), Some(out)) = (local_path.as_ref(), output_stream.as_ref()) else { + continue; + }; + if !scratching { + // Discard any asynchronous decoder/preload that was + // prepared for the normal gapless path. Scratch owns the + // sink until release and must never mix in the next song. + generation += 1; + scratch_was_playing = playing; + scratching = true; + queued_sources.store(0, Ordering::SeqCst); + waiting_preload = None; + pending_tracks.clear(); + last_queued = 0; + } + if let Some(old) = sink.take() { + old.stop(); + } + match decode_scratch_window(path, secs, rate) { + Ok((samples, speed)) => { + let s = Sink::connect_new(out.mixer()); + s.set_volume(volume); + s.set_speed(speed); + s.append(samples); + s.play(); + sink = Some(s); + // The UI owns position while the platter is dragged. + playing = true; + } + Err(e) => log::debug!("scratch decode failed: {e}"), + } + } + Ok(Cmd::EndScratch { secs }) => { + if !scratching { + continue; + } + scratching = false; + if let Some(old) = sink.take() { + old.stop(); + } + queued_sources.store(0, Ordering::SeqCst); + pending_tracks.clear(); + last_queued = 0; + let (Some(path), Some(out)) = (local_path.as_ref(), output_stream.as_ref()) else { + continue; + }; + match local_decoder(path) { + Ok(decoder) => { + let s = Sink::connect_new(out.mixer()); + s.set_volume(volume); + queued_sources.store(1, Ordering::SeqCst); + s.append(EndNotifier { + inner: decoder, + queued: queued_sources.clone(), + event_tx: event_tx.clone(), + sent: false, + }); + // Seek through the sink rather than before append so + // Sink::get_pos continues to report the track's real + // absolute position after a scratch release. + if let Err(e) = s.try_seek(Duration::from_secs_f64(secs.max(0.0))) { + log::debug!("scratch release seek failed: {e}"); + } + if scratch_was_playing { + s.play(); + playing = true; + emit(PlayerEvent::Playing); + } else { + s.pause(); + playing = false; + emit(PlayerEvent::Paused); + } + sink = Some(s); + } + Err(e) => emit(PlayerEvent::Error(e)), + } + } + Ok(Cmd::Volume(v)) => { + volume = v; + if let Some(s) = &sink { + s.set_volume(v); + } + } + Ok(Cmd::SetOutputDevice { device, reply }) => { + output_change = Some((device, Some(reply))); + } + Ok(Cmd::Stop) => { + generation += 1; + if let Some(s) = active_stream.take() { + s.abort(); + } + if let Some(s) = sink.take() { + s.stop(); + } + queued_sources.store(0, Ordering::SeqCst); + waiting_preload = None; + pending_tracks.clear(); + playing = false; + scratching = false; + local_path = None; + active_track = None; + emit(PlayerEvent::Stopped); + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + + // In system-default mode, periodically compare endpoint names. CPAL's + // existing stream remains attached to the old endpoint when Windows' + // default changes, so reopening is required. + if output_change.is_none() + && selected_device.is_none() + && !scratching + && last_default_check.elapsed() >= Duration::from_secs(1) + { + last_default_check = Instant::now(); + if let Some(default_name) = default_output_device_name() { + if current_device_name.as_deref() != Some(default_name.as_str()) { + output_change = Some((None, None)); + } + } + } + + if let Some((requested, reply)) = output_change { + if scratching { + if let Some(reply) = reply { + let _ = + reply.send(Err("finish scratching before changing audio devices".into())); + } + } else { + match open_output_stream(requested.as_deref()) { + Err(e) => { + if let Some(reply) = reply { + let _ = reply.send(Err(e)); + } else { + log::warn!("could not follow Windows audio output change: {e}"); + } + } + Ok((new_stream, name)) => { + let resume_at = sink.as_ref().map(|s| s.get_pos()); + let start_paused = sink.is_some() && !playing; + if active_track.is_some() { + generation += 1; + } + if let Some(old) = sink.take() { + old.stop(); + } + queued_sources.store(0, Ordering::SeqCst); + waiting_preload = None; + pending_tracks.clear(); + last_queued = 0; + output_stream = Some(new_stream); + current_device_name = Some(name); + selected_device = requested; + + if let Some(track) = active_track.clone() { + playing = false; + emit(PlayerEvent::Buffering); + start_decoder( + track, + generation, + false, + resume_at, + start_paused, + decoder_tx.clone(), + ); + } + if let Some(reply) = reply { + let _ = reply.send(Ok(())); + } + } + } + } + } + + // Finished decoder setups. + while let Ok(result) = decoder_rx.try_recv() { + match result { + Ok(ready) if ready.generation == generation => { + // (Re)acquire the output device if we don't have it yet. + if output_stream.is_none() { + match open_output_stream(selected_device.as_deref()) { + Ok((new_stream, name)) => { + output_stream = Some(new_stream); + current_device_name = Some(name); + } + Err(e) => log::warn!("could not acquire audio output: {e}"), + } + } + let Some(out) = output_stream.as_ref() else { + emit(PlayerEvent::Error( + "audio device unavailable — is another app using it?".into(), + )); + continue; + }; + if ready.preload && sink.is_none() { + // Decoder setup can beat the first decoder on a fast + // disk. Hold it until the active sink exists. + waiting_preload = Some(ready); + continue; + } + + let is_preload = ready.preload; + let track_duration = ready.track_duration(); + if !is_preload { + let s = Sink::connect_new(out.mixer()); + s.set_volume(volume); + sink = Some(s); + active_track = Some(ready.track.clone()); + active_stream = ready.track.stream.clone(); + local_path = ready + .track + .stream + .is_none() + .then(|| ready.track.path.clone()); + } + let target = sink + .as_ref() + .expect("sink exists for active or preloaded decoder"); + queued_sources.fetch_add(1, Ordering::SeqCst); + target.append(EndNotifier { + inner: ready.decoder, + queued: queued_sources.clone(), + event_tx: event_tx.clone(), + sent: false, + }); + if let Some(pos) = ready.resume_at { + if let Err(e) = target.try_seek(pos) { + log::warn!("could not resume after audio-device change: {e}"); + } + } + if is_preload { + // Nothing changes visually yet; samples will switch at + // the exact decoder boundary. + pending_tracks.push_back(PendingTrack { + duration: track_duration, + track: ready.track, + }); + } else { + duration = track_duration; + if ready.start_paused { + target.pause(); + playing = false; + emit(PlayerEvent::Paused); + } else { + target.play(); + playing = true; + emit(PlayerEvent::Playing); + } + if let Some(next) = waiting_preload.take() { + let next_duration = next.track_duration(); + pending_tracks.push_back(PendingTrack { + duration: next_duration, + track: next.track.clone(), + }); + queued_sources.fetch_add(1, Ordering::SeqCst); + target.append(EndNotifier { + inner: next.decoder, + queued: queued_sources.clone(), + event_tx: event_tx.clone(), + sent: false, + }); + } + } + } + Ok(_) => {} // stale generation: user moved on + Err((gen, msg)) if gen == generation => emit(PlayerEvent::Error(msg)), + Err(_) => {} + } + } + + // A queued source finished while the sink kept playing (gapless + // boundary): adopt the next track's duration for Position events. + let q = queued_sources.load(Ordering::SeqCst); + if q < last_queued && q > 0 { + for _ in 0..(last_queued - q) { + if let Some(next) = pending_tracks.pop_front() { + duration = next.duration; + local_path = next.track.stream.is_none().then(|| next.track.path.clone()); + active_stream = next.track.stream.clone(); + active_track = Some(next.track); + } + } + } + last_queued = q; + + // Position tick / end detection. + if let Some(s) = &sink { + if playing && !scratching { + if s.empty() { + playing = false; + sink = None; + if let Some(st) = active_stream.take() { + st.abort(); + } + active_track = None; + local_path = None; + // EndNotifier emits TrackEnded from the audio callback; + // do not emit a second, late event from this polling tick. + } else { + emit(PlayerEvent::Position { + secs: s.get_pos().as_secs_f64(), + duration, + }); + } + } + } + } +} + +/// Build a seekable decoder for a complete local file. Growing network +/// downloads stay on ordinary seek behavior because reverse windows need +/// stable bytes on disk. +fn local_decoder(path: &Path) -> Result>, String> { + let file = std::fs::File::open(path).map_err(|e| e.to_string())?; + let len = file.metadata().map(|m| m.len()).unwrap_or(0); + let mut builder = Decoder::builder() + .with_data(Box::new(std::io::BufReader::new(file)) as Box) + .with_seekable(true); + if len > 0 { + builder = builder.with_byte_len(len); + } + builder.build().map_err(|e| format!("decode failed: {e}")) +} + +/// Decode ~180 ms around the jog point. Reversing complete interleaved frames +/// (not individual samples) preserves stereo while producing true reverse +/// audio; the sink rate carries the hand-speed/pitch relationship. +fn decode_scratch_window( + path: &Path, + secs: f64, + rate: f32, +) -> Result<(SamplesBuffer, f32), String> { + let mut decoder = local_decoder(path)?; + let channels = decoder.channels(); + let sample_rate = decoder.sample_rate(); + let speed = rate.abs().clamp(0.25, 4.0); + let window_secs = 0.18 * f64::from(speed); + let start = if rate < 0.0 { + (secs - window_secs).max(0.0) + } else { + secs.max(0.0) + }; + decoder + .try_seek(Duration::from_secs_f64(start)) + .map_err(|e| format!("scratch seek failed: {e}"))?; + let count = (window_secs * f64::from(sample_rate) * f64::from(channels)) as usize; + let mut data: Vec = decoder + .by_ref() + .take(count.max(channels as usize)) + .collect(); + if data.is_empty() { + return Err("no audio at scratch position".into()); + } + if rate < 0.0 { + let frame = channels as usize; + data = data + .chunks(frame) + .rev() + .flat_map(|f| f.iter().copied()) + .collect(); + } + Ok((SamplesBuffer::new(channels, sample_rate, data), speed)) +} + +#[cfg(test)] +mod tests { + use super::reconcile_duration; + + #[test] + fn bogus_short_hint_cannot_truncate_a_long_file() { + // The reported bug: peer metadata said 30s for a 41-minute live FLAC + // whose decoder reads the true length. The real duration must win. + assert_eq!(reconcile_duration(Some(30.0), Some(2491.0)), Some(2491.0)); + } + + #[test] + fn partial_decoder_cannot_underreport_against_a_good_hint() { + // A still-downloading source whose decoder only sees part of the file + // must not shrink a correct hint. + assert_eq!(reconcile_duration(Some(240.0), Some(45.0)), Some(240.0)); + } + + #[test] + fn falls_back_to_whichever_exists() { + assert_eq!(reconcile_duration(None, Some(180.0)), Some(180.0)); + assert_eq!(reconcile_duration(Some(180.0), None), Some(180.0)); + assert_eq!(reconcile_duration(None, None), None); + } +} diff --git a/crates/player/src/source.rs b/crates/player/src/source.rs new file mode 100644 index 0000000..f0c0947 --- /dev/null +++ b/crates/player/src/source.rs @@ -0,0 +1,218 @@ +//! Growing-file media source: reads a file that is still being written, +//! blocking (instead of returning EOF) when playback catches up to the +//! download's write head. + +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; + +/// Everything rodio's Decoder needs from a source. +pub trait MediaRead: Read + Seek + Send + Sync {} +impl MediaRead for T {} + +#[derive(Debug, Default)] +struct ProgressState { + received: u64, + done: bool, + failed: Option, + aborted: bool, +} + +/// Shared write-progress of an in-flight download. The downloader calls +/// [`update`](Self::update); the audio thread's reads wait on it. +#[derive(Debug, Default)] +pub struct StreamProgress { + state: Mutex, + cv: Condvar, +} + +impl StreamProgress { + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + pub fn update(&self, received: u64, done: bool, failed: Option) { + let mut st = self.state.lock().unwrap(); + st.received = st.received.max(received); + st.done |= done; + if st.failed.is_none() { + st.failed = failed; + } + self.cv.notify_all(); + } + + /// Unblock any waiting reads permanently (track switched / stopped). + pub fn abort(&self) { + let mut st = self.state.lock().unwrap(); + st.aborted = true; + self.cv.notify_all(); + } + + /// Wait until at least `wanted` bytes exist (or terminal state). + /// Returns Ok(available bytes) or Err(reason) on failure/abort. + fn wait_for(&self, wanted: u64) -> Result { + let mut st = self.state.lock().unwrap(); + loop { + if st.aborted { + return Err("stream aborted".into()); + } + if let Some(reason) = &st.failed { + return Err(reason.clone()); + } + if st.done || st.received >= wanted { + return Ok(st.received); + } + let (guard, _) = self + .cv + .wait_timeout(st, Duration::from_millis(250)) + .map_err(|_| "progress lock poisoned".to_string())?; + st = guard; + } + } + + fn snapshot(&self) -> (u64, bool) { + let st = self.state.lock().unwrap(); + (st.received, st.done) + } +} + +/// A file being written by a download, exposed as a blocking Read + Seek. +pub struct GrowingFile { + file: std::fs::File, + pos: u64, + total: u64, + progress: Arc, +} + +impl GrowingFile { + pub fn open( + path: &Path, + total: u64, + progress: Arc, + ) -> std::io::Result { + // The file may not exist until the remote queue clears; wait for + // the first bytes before opening. + progress.wait_for(1).map_err(std::io::Error::other)?; + let file = std::fs::File::open(path)?; + Ok(GrowingFile { + file, + pos: 0, + total, + progress, + }) + } +} + +impl Read for GrowingFile { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let (received, done) = self.progress.snapshot(); + + // At the write head and still downloading: wait for more. + if self.pos >= received && !done { + match self.progress.wait_for(self.pos + 1) { + Ok(_) => {} + Err(e) if e == "stream aborted" => return Ok(0), // graceful EOF + Err(e) => return Err(std::io::Error::other(e)), + } + } + + let n = self.file.read(buf)?; + if n == 0 { + // Raced the writer: data was promised but the handle hasn't + // caught up; only report EOF once the download is done. + let (_, done) = self.progress.snapshot(); + if !done { + std::thread::sleep(Duration::from_millis(50)); + let n2 = self.file.read(buf)?; + self.pos += n2 as u64; + return Ok(n2); + } + } + self.pos += n as u64; + Ok(n) + } +} + +impl Seek for GrowingFile { + fn seek(&mut self, pos: SeekFrom) -> std::io::Result { + // Normalize End against the *final* size so seek math is stable + // even while the file is still growing. + let target = match pos { + SeekFrom::Start(o) => o, + SeekFrom::Current(d) => self.pos.saturating_add_signed(d), + SeekFrom::End(d) => self.total.saturating_add_signed(d), + }; + // Block until the target region exists, so decoder probes into + // not-yet-downloaded space stall instead of erroring. + if target > 0 { + self.progress + .wait_for(target.min(self.total)) + .map_err(std::io::Error::other)?; + } + self.pos = self.file.seek(SeekFrom::Start(target))?; + Ok(self.pos) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn growing_read_blocks_until_bytes_arrive() { + let dir = std::env::temp_dir().join("ssplus-player-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("grow.bin"); + std::fs::write(&path, b"").unwrap(); + + let progress = StreamProgress::new(); + let writer_progress = progress.clone(); + let writer_path = path.clone(); + + let writer = std::thread::spawn(move || { + let mut f = std::fs::OpenOptions::new() + .write(true) + .open(&writer_path) + .unwrap(); + for chunk in 0..4u8 { + std::thread::sleep(Duration::from_millis(60)); + f.write_all(&[chunk; 100]).unwrap(); + f.flush().unwrap(); + writer_progress.update((chunk as u64 + 1) * 100, chunk == 3, None); + } + }); + + let mut g = GrowingFile::open(&path, 400, progress).unwrap(); + let mut out = Vec::new(); + g.read_to_end(&mut out).unwrap(); + writer.join().unwrap(); + + assert_eq!(out.len(), 400); + assert_eq!(out[350], 3); + } + + #[test] + fn abort_unblocks_reader() { + let dir = std::env::temp_dir().join("ssplus-player-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("abort.bin"); + std::fs::write(&path, b"xx").unwrap(); + + let progress = StreamProgress::new(); + progress.update(2, false, None); + let mut g = GrowingFile::open(&path, 1000, progress.clone()).unwrap(); + + let killer = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(100)); + progress.abort(); + }); + + let mut out = Vec::new(); + // Reads the 2 bytes, then blocks at the write head until abort. + g.read_to_end(&mut out).unwrap(); + killer.join().unwrap(); + assert_eq!(out.len(), 2); + } +} diff --git a/crates/player/src/waveform.rs b/crates/player/src/waveform.rs new file mode 100644 index 0000000..2717cd2 --- /dev/null +++ b/crates/player/src/waveform.rs @@ -0,0 +1,155 @@ +//! Waveform extraction: decode an audio file and downsample it to a small +//! array of normalized 0..1 loudness values, for a waveform scrubber. +//! +//! Uses RMS energy per window, not peak amplitude: modern masters are +//! limited so hard that nearly every window's *peak* touches the digital +//! ceiling, which renders as a clipped brick. RMS tracks perceived +//! loudness and keeps the verse/chorus dynamics visible. + +use std::path::Path; + +use symphonia::core::audio::SampleBuffer; +use symphonia::core::codecs::DecoderOptions; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +/// Peaks folded over this many frames before resampling to the final bucket +/// count. Keeps memory bounded regardless of track length. +const WINDOW: usize = 1024; + +/// Decode `path` and return `buckets` normalized peak values (0.0..=1.0). +/// Returns an empty vec on any decode failure. +pub fn compute_waveform(path: &Path, buckets: usize) -> Vec { + match try_compute(path, buckets) { + Ok(v) => v, + Err(e) => { + log::debug!("waveform for {} failed: {e}", path.display()); + Vec::new() + } + } +} + +fn try_compute(path: &Path, buckets: usize) -> Result, String> { + let file = std::fs::File::open(path).map_err(|e| e.to_string())?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + // Stream partials are "..partial" — hint the codec ext. + let ext = if ext.eq_ignore_ascii_case("partial") { + path.file_stem() + .map(Path::new) + .and_then(|p| p.extension()) + .and_then(|e| e.to_str()) + .unwrap_or(ext) + } else { + ext + }; + hint.with_extension(ext); + } + + let probed = symphonia::default::get_probe() + .format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .map_err(|e| e.to_string())?; + let mut format = probed.format; + + let track = format + .default_track() + .ok_or_else(|| "no default track".to_string())?; + let track_id = track.id; + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + .map_err(|e| e.to_string())?; + + let mut sample_buf: Option> = None; + let mut coarse: Vec = Vec::new(); + let mut win_energy = 0f64; + let mut win_count = 0usize; + + loop { + let packet = match format.next_packet() { + Ok(p) => p, + Err(_) => break, // EOF or error → stop with what we have + }; + if packet.track_id() != track_id { + continue; + } + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + Err(symphonia::core::errors::Error::DecodeError(_)) => continue, + Err(_) => break, + }; + + let spec = *decoded.spec(); + let channels = spec.channels.count().max(1); + if sample_buf.is_none() { + sample_buf = Some(SampleBuffer::new(decoded.capacity() as u64, spec)); + } + let sb = sample_buf.as_mut().unwrap(); + sb.copy_interleaved_ref(decoded); + + for frame in sb.samples().chunks(channels) { + let energy = + frame.iter().map(|&s| (s as f64) * (s as f64)).sum::() / channels as f64; + win_energy += energy; + win_count += 1; + if win_count >= WINDOW { + coarse.push((win_energy / WINDOW as f64).sqrt() as f32); + win_energy = 0.0; + win_count = 0; + } + } + } + if win_count > 0 { + coarse.push((win_energy / win_count as f64).sqrt() as f32); + } + + Ok(resample(&coarse, buckets)) +} + +/// Downsample `coarse` RMS values to exactly `buckets`, averaging each +/// span, then normalize so the loudest bucket is 1.0. +fn resample(coarse: &[f32], buckets: usize) -> Vec { + let mut out = vec![0f32; buckets]; + if coarse.is_empty() || buckets == 0 { + return out; + } + for (b, slot) in out.iter_mut().enumerate() { + let start = b * coarse.len() / buckets; + let end = (((b + 1) * coarse.len() / buckets).max(start + 1)).min(coarse.len()); + let span = &coarse[start..end]; + *slot = span.iter().sum::() / span.len() as f32; + } + let max = out.iter().fold(0f32, |m, &v| m.max(v)); + if max > 0.0 { + for v in out.iter_mut() { + *v /= max; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resample_normalizes() { + let coarse = vec![0.2, 0.4, 0.8, 0.1, 0.6, 0.3]; + let out = resample(&coarse, 3); + assert_eq!(out.len(), 3); + assert!((out.iter().cloned().fold(0f32, f32::max) - 1.0).abs() < 1e-6); + } + + #[test] + fn empty_input() { + assert_eq!(resample(&[], 5), vec![0.0; 5]); + } +} diff --git a/crates/soulseek-client/Cargo.toml b/crates/soulseek-client/Cargo.toml new file mode 100644 index 0000000..20ab78e --- /dev/null +++ b/crates/soulseek-client/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "soulseek-client" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +soulseek-proto = { path = "../soulseek-proto" } +tokio.workspace = true +tokio-util = "0.7" +bytes.workspace = true +thiserror.workspace = true +log.workspace = true +igd-next = "0.15" + +[dev-dependencies] +env_logger = "0.11" diff --git a/crates/soulseek-client/examples/citizen.rs b/crates/soulseek-client/examples/citizen.rs new file mode 100644 index 0000000..c9e74d5 --- /dev/null +++ b/crates/soulseek-client/examples/citizen.rs @@ -0,0 +1,86 @@ +//! Live citizenship test: share our downloaded albums, join the distributed +//! network, and report every search we answer for real peers. +//! +//! cargo run -p soulseek-client --example citizen + +use soulseek_client::{Client, Config, Event}; + +#[tokio::main] +async fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos() + % 100_000; + let username = format!("ssplusshare{suffix}"); + + let music = dirs_music().join("SoulSeek+"); + let mut config = Config::default(); + config.shared_folders = vec![music.clone()]; + config.upload_slots = 4; + config.description = "SoulSeek+ dev share".into(); + + println!("sharing {}…", music.display()); + let (client, mut events) = Client::connect(config).await.expect("connect"); + let (dirs, files) = client.share_counts(); + println!("indexed {files} files in {dirs} folders"); + + client + .login(&username, "ssplusdev-throwaway") + .await + .expect("login"); + println!("logged in as {username}; joining network, waiting for searches to answer…\n"); + + let mut answered = 0usize; + let mut uploads = 0usize; + let start = tokio::time::Instant::now(); + let deadline = start + std::time::Duration::from_secs(180); + let mut next_tick = start + std::time::Duration::from_secs(20); + loop { + let ev = tokio::select! { + ev = events.recv() => ev, + _ = tokio::time::sleep_until(next_tick) => { + next_tick += std::time::Duration::from_secs(20); + println!( + " … {}s: parent={}, {} searches seen, {answered} answered", + start.elapsed().as_secs(), + client.has_parent(), + client.searches_seen(), + ); + if tokio::time::Instant::now() >= deadline { break; } + continue; + } + }; + match ev { + Some(Event::RespondedToSearch { username, files }) => { + answered += 1; + if answered <= 25 { + println!(" ✓ answered {username} with {files} file(s)"); + } + } + Some(Event::Upload(u)) => { + uploads += 1; + println!(" ⬆ upload to {}: {:?} {}", u.username, u.state, u.filename); + } + Some(Event::Disconnected { reason }) => { + println!("disconnected: {reason}"); + break; + } + _ => {} + } + } + + println!( + "\nRESULT: {} searches seen, {answered} answered, {uploads} upload events", + client.searches_seen(), + ); + client.shutdown(); +} + +fn dirs_music() -> std::path::PathBuf { + // %USERPROFILE%\Music + let profile = std::env::var("USERPROFILE").unwrap_or_else(|_| ".".into()); + std::path::PathBuf::from(profile).join("Music") +} diff --git a/crates/soulseek-client/examples/download.rs b/crates/soulseek-client/examples/download.rs new file mode 100644 index 0000000..07b2ea3 --- /dev/null +++ b/crates/soulseek-client/examples/download.rs @@ -0,0 +1,123 @@ +//! Live-network download smoke test: search, pick the best folder, +//! download its files. +//! +//! cargo run -p soulseek-client --example download -- "search terms" [max_files] + +use std::collections::HashMap; + +use soulseek_client::{Client, Config, DownloadState, Event}; + +#[tokio::main] +async fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + let query = std::env::args() + .nth(1) + .unwrap_or_else(|| "daft punk discovery".into()); + let max_files: usize = std::env::args() + .nth(2) + .and_then(|s| s.parse().ok()) + .unwrap_or(2); + + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos() + % 100_000; + let username = format!("ssplusdev{suffix}"); + + let mut config = Config::default(); + config.downloads_dir = std::env::temp_dir().join("ssplus-download-test"); + + let (client, mut events) = Client::connect(config).await.expect("connect"); + client + .login(&username, "ssplusdev-throwaway") + .await + .expect("login"); + println!("logged in as {username}"); + + let token = client.search(&query).expect("search"); + println!("searching {query:?}…"); + + // Collect responses for 12s, pick the best free-slot folder. + let mut best: Option<(u64, soulseek_proto::types::SearchResponse)> = None; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(12); + loop { + let ev = tokio::select! { + ev = events.recv() => ev, + _ = tokio::time::sleep_until(deadline) => break, + }; + if let Some(Event::SearchResult { response, .. }) = ev { + if !response.has_free_upload_slot || response.files.len() < 3 { + continue; + } + let score = response.upload_speed as u64; + if best.as_ref().map(|(s, _)| score > *s).unwrap_or(true) { + best = Some((score, response)); + } + } + } + client.stop_search(token); + + let Some((_, pick)) = best else { + println!("RESULT: no suitable candidates"); + return; + }; + println!( + "picked {} ({} files, speed {})", + pick.username, + pick.files.len(), + pick.upload_speed + ); + + let mut pending: HashMap = HashMap::new(); + for f in pick.files.iter().take(max_files) { + let id = client.download(&pick.username, &f.filename, f.size); + println!( + " queued #{id}: {}", + f.filename.rsplit('\\').next().unwrap() + ); + pending.insert(id, f.filename.clone()); + } + + let overall = tokio::time::Instant::now() + std::time::Duration::from_secs(180); + let mut ok = 0; + while !pending.is_empty() { + let ev = tokio::select! { + ev = events.recv() => ev, + _ = tokio::time::sleep_until(overall) => { println!("TIMEOUT"); break; } + }; + match ev { + Some(Event::Download(u)) => match &u.state { + DownloadState::Complete { local_path } => { + println!(" ✔ #{} complete → {local_path}", u.id); + pending.remove(&u.id); + ok += 1; + } + DownloadState::Failed { reason } => { + println!(" ✘ #{} failed: {reason}", u.id); + pending.remove(&u.id); + } + DownloadState::Transferring if u.total > 0 => { + println!( + " … #{} {:.0}% ({}/{} bytes, {} KB/s)", + u.id, + u.received as f64 / u.total as f64 * 100.0, + u.received, + u.total, + u.speed_bps / 1000 + ); + } + s => println!(" · #{} {:?}", u.id, s), + }, + Some(Event::Disconnected { reason }) => { + println!("disconnected: {reason}"); + break; + } + Some(_) => {} + None => break, + } + } + println!("RESULT: {ok} downloads completed"); + client.shutdown(); +} diff --git a/crates/soulseek-client/examples/probe.rs b/crates/soulseek-client/examples/probe.rs new file mode 100644 index 0000000..63d9b9c --- /dev/null +++ b/crates/soulseek-client/examples/probe.rs @@ -0,0 +1,78 @@ +//! Smoke test against the live Soulseek network: +//! login with a throwaway account, run one search, print results. +//! +//! cargo run -p soulseek-client --example probe -- "search terms" + +use soulseek_client::{Client, Config, Event}; + +#[tokio::main] +async fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + let query = std::env::args() + .nth(1) + .unwrap_or_else(|| "daft punk discovery".into()); + + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos() + % 100_000; + let username = format!("ssplusdev{suffix}"); + + println!("connecting…"); + let (client, mut events) = Client::connect(Config::default()).await.expect("connect"); + println!("listener on port {}", client.listen_port()); + + match client.login(&username, "ssplusdev-throwaway").await { + Ok(greeting) => println!("logged in as {username}: {}", greeting.trim()), + Err(e) => { + eprintln!("login failed: {e}"); + return; + } + } + + let token = client.search(&query).expect("search"); + println!("searching for {query:?} (token {token}) — collecting for 25s…\n"); + + let mut responses = 0usize; + let mut files = 0usize; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(25); + + loop { + let ev = tokio::select! { + ev = events.recv() => ev, + _ = tokio::time::sleep_until(deadline) => break, + }; + match ev { + Some(Event::SearchResult { response, .. }) => { + responses += 1; + files += response.files.len(); + if responses <= 5 { + let f = &response.files[0]; + println!( + " [{}] {} files, e.g. {} ({} MB{}) slots={} speed={}", + response.username, + response.files.len(), + f.filename.rsplit('\\').next().unwrap_or(&f.filename), + f.size / 1_000_000, + f.bitrate() + .map(|b| format!(", {b} kbps")) + .unwrap_or_default(), + response.has_free_upload_slot, + response.upload_speed, + ); + } + } + Some(Event::Disconnected { reason }) => { + println!("disconnected: {reason}"); + break; + } + Some(_) => {} + None => break, + } + } + + println!("\nRESULT: {responses} responses, {files} files"); + client.shutdown(); +} diff --git a/crates/soulseek-client/examples/upload_test.rs b/crates/soulseek-client/examples/upload_test.rs new file mode 100644 index 0000000..d6a44cb --- /dev/null +++ b/crates/soulseek-client/examples/upload_test.rs @@ -0,0 +1,129 @@ +//! Two-instance upload test: instance A shares a known file; instance B +//! downloads it from A. Verifies the upload responder + F-connection path. +//! +//! cargo run -p soulseek-client --example upload_test + +use soulseek_client::{Client, Config, DownloadState, Event}; +use std::path::PathBuf; + +#[tokio::main] +async fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + // Build a share source with one real audio file. + let src = std::env::temp_dir().join("ssplus-upload-src"); + let album = src.join("TestAlbum"); + std::fs::create_dir_all(&album).unwrap(); + let sample = find_sample().expect("need a downloaded audio file to share"); + let shared_file = album.join("sample.flac"); + std::fs::copy(&sample, &shared_file).unwrap(); + let size = std::fs::metadata(&shared_file).unwrap().len(); + let remote_path = "ssplus-upload-src\\TestAlbum\\sample.flac".to_string(); + println!("A shares {remote_path} ({size} bytes)"); + + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos() + % 100_000; + let user_a = format!("ssplusA{stamp}"); + let user_b = format!("ssplusB{stamp}"); + + // Instance A: sharer/uploader. + let mut cfg_a = Config::default(); + cfg_a.shared_folders = vec![src.clone()]; + cfg_a.listen_port = 51300; + let (client_a, mut events_a) = Client::connect(cfg_a).await.expect("A connect"); + client_a.login(&user_a, "pw").await.expect("A login"); + println!("A logged in as {user_a}"); + + // Spawn a task to log A's upload events. + tokio::spawn(async move { + while let Some(ev) = events_a.recv().await { + if let Event::Upload(u) = ev { + println!( + " A upload → {}: {:?} {}/{}", + u.username, u.state, u.sent, u.total + ); + } + } + }); + + // Instance B: downloader. Different listen port to coexist on one host. + let mut cfg_b = Config::default(); + cfg_b.listen_port = 52300; + cfg_b.downloads_dir = std::env::temp_dir().join("ssplus-upload-dst"); + let _ = std::fs::remove_dir_all(&cfg_b.downloads_dir); + let (client_b, mut events_b) = Client::connect(cfg_b).await.expect("B connect"); + client_b.login(&user_b, "pw").await.expect("B login"); + println!("B logged in as {user_b}"); + + // Both instances share this host's public IP, so the server-provided + // address would need NAT hairpinning. Seed loopback endpoints so the P + // and F connections go over localhost instead. + client_b.set_peer_address(&user_a, "127.0.0.1:51300".parse().unwrap()); + client_a.set_peer_address(&user_b, "127.0.0.1:52300".parse().unwrap()); + + // Give the server a moment to register both listen ports. + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + let id = client_b.download(&user_a, &remote_path, size); + println!("B requested download #{id} from A…\n"); + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(90); + loop { + let ev = tokio::select! { + ev = events_b.recv() => ev, + _ = tokio::time::sleep_until(deadline) => { println!("TIMEOUT"); break; } + }; + match ev { + Some(Event::Download(u)) => match &u.state { + DownloadState::Complete { local_path } => { + let got = std::fs::metadata(local_path).map(|m| m.len()).unwrap_or(0); + println!("\nB COMPLETE: {local_path} ({got}/{size} bytes)"); + println!( + "RESULT: {}", + if got == size { + "UPLOAD VERIFIED ✓" + } else { + "size mismatch ✗" + } + ); + break; + } + DownloadState::Failed { reason } => { + println!("\nB FAILED: {reason}"); + println!("RESULT: upload not verified (likely NAT hairpin on same host)"); + break; + } + other => println!(" B: {:?} {}/{}", other, u.received, u.total), + }, + Some(_) => {} + None => break, + } + } + + client_a.shutdown(); + client_b.shutdown(); +} + +/// Find any downloaded audio file to use as the shared sample. +fn find_sample() -> Option { + let root = PathBuf::from(std::env::var("USERPROFILE").ok()?) + .join("Music") + .join("SoulSeek+"); + fn walk(dir: &std::path::Path) -> Option { + for e in std::fs::read_dir(dir).ok()?.flatten() { + let p = e.path(); + if p.is_dir() { + if let Some(f) = walk(&p) { + return Some(f); + } + } else if p.extension().and_then(|x| x.to_str()) == Some("flac") { + return Some(p); + } + } + None + } + walk(&root) +} diff --git a/crates/soulseek-client/src/conn.rs b/crates/soulseek-client/src/conn.rs new file mode 100644 index 0000000..718da1c --- /dev/null +++ b/crates/soulseek-client/src/conn.rs @@ -0,0 +1,668 @@ +//! Connection plumbing: server socket IO, peer listener, peer message +//! loops, indirect (PierceFirewall) connections, and transfer-connection +//! establishment in all four direct/indirect × inbound/outbound flavors. + +use bytes::Bytes; +use std::net::SocketAddrV4; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{mpsc, oneshot}; + +use soulseek_proto::{peer, server, MAX_MESSAGE_SIZE}; + +use crate::{ClientError, Event, Inner, PeerHandle, Result, TransferAck}; + +pub(crate) const PEER_CONNECT_TIMEOUT: Duration = Duration::from_secs(8); +pub(crate) const INDIRECT_TIMEOUT: Duration = Duration::from_secs(15); +/// Idle cutoff for peer message connections. +const PEER_READ_TIMEOUT: Duration = Duration::from_secs(60); +const SERVER_PING_INTERVAL: Duration = Duration::from_secs(300); + +/// Read one length-prefixed frame; returns the frame body (code + payload). +pub(crate) async fn read_frame( + stream: &mut R, +) -> std::io::Result> { + let mut len_buf = [0u8; 4]; + match stream.read_exact(&mut len_buf).await { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + Err(e) => return Err(e), + } + let len = u32::from_le_bytes(len_buf) as usize; + if len == 0 || len > MAX_MESSAGE_SIZE { + return Err(std::io::Error::other(format!("bad frame length {len}"))); + } + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).await?; + Ok(Some(Bytes::from(body))) +} + +pub(crate) async fn bind_listener(preferred_port: u16) -> std::io::Result { + // Walk forward a few ports if the preferred one is taken. + for offset in 0..10 { + match TcpListener::bind(("0.0.0.0", preferred_port + offset)).await { + Ok(l) => return Ok(l), + Err(_) if offset < 9 => continue, + Err(e) => return Err(e), + } + } + unreachable!() +} + +// ---------------------------------------------------------------- server IO + +pub(crate) fn spawn_server_io( + stream: TcpStream, + mut outbound: mpsc::UnboundedReceiver>, + mut shutdown: mpsc::UnboundedReceiver<()>, + inner: Arc, +) { + tokio::spawn(async move { + let (mut rd, mut wr) = stream.into_split(); + + let writer = tokio::spawn(async move { + while let Some(frame) = outbound.recv().await { + if wr.write_all(&frame).await.is_err() { + break; + } + } + }); + + let ping_inner = inner.clone(); + let ping = tokio::spawn(async move { + loop { + tokio::time::sleep(SERVER_PING_INTERVAL).await; + if ping_inner.send_server(server::ping()).is_err() { + break; + } + } + }); + + let reason = loop { + let frame = tokio::select! { + f = read_frame(&mut rd) => f, + _ = shutdown.recv() => break "shutdown requested".to_string(), + }; + match frame { + Ok(Some(body)) => handle_server_frame(&inner, body), + Ok(None) => break "server closed the connection".to_string(), + Err(e) => break format!("server read error: {e}"), + } + }; + + writer.abort(); + ping.abort(); + let _ = inner.event_tx.send(Event::Disconnected { reason }); + }); +} + +fn handle_server_frame(inner: &Arc, mut body: Bytes) { + use bytes::Buf; + if body.len() < 4 { + return; + } + let code = body.get_u32_le(); + + let event = match server::decode(code, body) { + Ok(ev) => ev, + Err(e) => { + log::warn!("failed to decode server message {code}: {e}"); + return; + } + }; + + match event { + server::ServerEvent::LoginOk { + greeting, own_ip, .. + } => { + if let Some(tx) = inner.pending_login.lock().unwrap().take() { + let _ = tx.send(Ok(greeting.clone())); + } + let _ = inner.event_tx.send(Event::LoggedIn { greeting, own_ip }); + } + server::ServerEvent::LoginFailed { reason } => { + if let Some(tx) = inner.pending_login.lock().unwrap().take() { + let _ = tx.send(Err(reason.clone())); + } + let _ = inner.event_tx.send(Event::LoginFailed { reason }); + } + server::ServerEvent::UserAddress { username, addr } => { + if addr.ip().is_unspecified() || addr.port() == 0 { + // Offline; let the waiters time out. + inner.pending_addresses.lock().unwrap().remove(&username); + return; + } + inner + .address_cache + .lock() + .unwrap() + .insert(username.clone(), addr); + if let Some(waiters) = inner.pending_addresses.lock().unwrap().remove(&username) { + for w in waiters { + let _ = w.send(addr); + } + } + } + server::ServerEvent::ConnectToPeer { + username, + conn_type, + addr, + token, + .. + } => { + // A peer that can't reach us directly asked the server to relay: + // we connect out and pierce with their token. + let inner = inner.clone(); + tokio::spawn(async move { + let result = match conn_type.as_str() { + peer::CONN_TYPE_PEER => { + pierce_message_connection(inner, addr, token, username.clone()).await + } + peer::CONN_TYPE_TRANSFER => { + pierce_transfer_connection(inner, addr, token, username.clone()).await + } + _ => Ok(()), // "D" (distributed) lands in M5. + }; + if let Err(e) = result { + log::debug!("pierce ({conn_type}) to {username} at {addr} failed: {e}"); + } + }); + } + server::ServerEvent::ExcludedSearchPhrases(phrases) => { + *inner.excluded_phrases.write().unwrap() = phrases.clone(); + let _ = inner.event_tx.send(Event::ExcludedSearchPhrases(phrases)); + } + server::ServerEvent::NetInfo(parents) => { + crate::distributed::on_net_info(inner.clone(), parents); + } + server::ServerEvent::ServerSearch { + username, + token, + query, + } => { + crate::responder::respond_to_search(inner.clone(), username, token, query); + } + server::ServerEvent::Embedded(bytes) => { + // Server-wrapped distributed search. + if let Ok(soulseek_proto::dist::DistributedEvent::SearchRequest { + username, + token, + query, + }) = soulseek_proto::dist::decode(bytes) + { + crate::responder::respond_to_search(inner.clone(), username, token, query); + } + } + server::ServerEvent::CannotConnect { .. } | server::ServerEvent::Unhandled { .. } => {} + } +} + +// ---------------------------------------------------------------- listener + +pub(crate) fn spawn_listener( + listener: TcpListener, + inner: Arc, + mut shutdown: mpsc::UnboundedReceiver<()>, +) { + tokio::spawn(async move { + loop { + tokio::select! { + // On shutdown, break so `listener` drops and its port is freed + // — otherwise every reconnect leaks a bound port (WSAEADDRINUSE). + _ = shutdown.recv() => { + log::debug!("listener shutting down, releasing port"); + break; + } + res = listener.accept() => match res { + Ok((stream, addr)) => { + let inner = inner.clone(); + tokio::spawn(async move { + if let Err(e) = handle_incoming(stream, inner).await { + log::debug!("peer {addr} dropped: {e}"); + } + }); + } + Err(e) => { + log::warn!("listener accept failed: {e}"); + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } + } + }); +} + +/// Incoming connection: first frame identifies the peer and connection type. +async fn handle_incoming(mut stream: TcpStream, inner: Arc) -> std::io::Result<()> { + stream.set_nodelay(true).ok(); + let first = tokio::time::timeout(PEER_READ_TIMEOUT, read_frame(&mut stream)) + .await + .map_err(|_| std::io::Error::other("init timeout"))??; + let Some(first) = first else { return Ok(()) }; + + match peer::decode_init(first) { + Ok(peer::InitMessage::PeerInit { + conn_type, + username, + .. + }) => { + match conn_type.as_str() { + peer::CONN_TYPE_PEER => run_peer_connection(stream, inner, username).await, + peer::CONN_TYPE_TRANSFER => { + // Inbound transfer connection: the peer identifies the + // transfer with 4 raw token bytes next. + let mut tok = [0u8; 4]; + tokio::time::timeout(PEER_CONNECT_TIMEOUT, stream.read_exact(&mut tok)) + .await + .map_err(|_| std::io::Error::other("token timeout"))??; + let token = u32::from_le_bytes(tok); + deliver_transfer_connection(&inner, &username, token, stream); + Ok(()) + } + peer::CONN_TYPE_DISTRIBUTED => { + // A parent connected to us directly; run the child loop. + run_incoming_distributed(stream, inner).await + } + _ => Ok(()), + } + } + Ok(peer::InitMessage::PierceFirewall { token }) => { + // Answer to one of our indirect solicitations (P or F). + let waiter = inner.pending_pierce.lock().unwrap().remove(&token); + if let Some(tx) = waiter { + let _ = tx.send(stream); + } + Ok(()) + } + Err(_) => Ok(()), + } +} + +/// A distributed parent connected to us directly. Read its relayed +/// searches and answer them (same logic as an outbound parent). +async fn run_incoming_distributed(mut stream: TcpStream, inner: Arc) -> std::io::Result<()> { + inner + .has_parent + .store(true, std::sync::atomic::Ordering::Relaxed); + let result = async { + loop { + let frame = match tokio::time::timeout(PEER_READ_TIMEOUT, read_frame(&mut stream)).await + { + Ok(f) => f?, + Err(_) => return Ok(()), + }; + let Some(frame) = frame else { return Ok(()) }; + if let Ok(soulseek_proto::dist::DistributedEvent::SearchRequest { + username, + token, + query, + }) = soulseek_proto::dist::decode(frame) + { + crate::responder::respond_to_search(inner.clone(), username, token, query); + } + } + } + .await; + inner + .has_parent + .store(false, std::sync::atomic::Ordering::Relaxed); + result +} + +/// Hand an established inbound transfer connection to the download that +/// expects it. +fn deliver_transfer_connection(inner: &Arc, username: &str, token: u32, stream: TcpStream) { + let expected = inner + .expected_transfers + .lock() + .unwrap() + .remove(&(username.to_string(), token)); + match expected { + Some(tx) => { + let _ = tx.send(stream); + } + None => { + log::debug!("unexpected transfer connection from {username} token {token}"); + } + } +} + +// ------------------------------------------------- indirect (pierce) roles + +/// Server told us a peer wants a message connection: connect + pierce, then +/// treat like any peer message connection. +async fn pierce_message_connection( + inner: Arc, + addr: SocketAddrV4, + token: u32, + username: String, +) -> std::io::Result<()> { + let mut stream = tokio::time::timeout(PEER_CONNECT_TIMEOUT, TcpStream::connect(addr)) + .await + .map_err(|_| std::io::Error::other("connect timeout"))??; + stream.set_nodelay(true).ok(); + stream.write_all(&peer::pierce_firewall(token)).await?; + run_peer_connection(stream, inner, username).await +} + +/// Server told us a peer wants a transfer connection with us: connect, +/// pierce, read the 4-byte remote token, hand off to the waiting download. +async fn pierce_transfer_connection( + inner: Arc, + addr: SocketAddrV4, + token: u32, + username: String, +) -> std::io::Result<()> { + let mut stream = tokio::time::timeout(PEER_CONNECT_TIMEOUT, TcpStream::connect(addr)) + .await + .map_err(|_| std::io::Error::other("connect timeout"))??; + stream.set_nodelay(true).ok(); + stream.write_all(&peer::pierce_firewall(token)).await?; + + let mut tok = [0u8; 4]; + tokio::time::timeout(PEER_CONNECT_TIMEOUT, stream.read_exact(&mut tok)) + .await + .map_err(|_| std::io::Error::other("token timeout"))??; + let remote_token = u32::from_le_bytes(tok); + deliver_transfer_connection(&inner, &username, remote_token, stream); + Ok(()) +} + +// -------------------------------------------------- peer message connection + +/// Run a peer message connection: writer drains a queue, reader routes +/// messages. Registers in the peers map for reuse; deregisters on exit. +async fn run_peer_connection( + stream: TcpStream, + inner: Arc, + username: String, +) -> std::io::Result<()> { + let (mut rd, mut wr) = stream.into_split(); + let (write_tx, mut write_rx) = mpsc::unbounded_channel::>(); + + inner.peers.lock().unwrap().insert( + username.clone(), + PeerHandle { + write_tx: write_tx.clone(), + }, + ); + + let writer = tokio::spawn(async move { + while let Some(frame) = write_rx.recv().await { + if wr.write_all(&frame).await.is_err() { + break; + } + } + }); + + let result = peer_message_loop(&mut rd, &inner, &username).await; + + // Only deregister if the map still points at this connection. + { + let mut peers = inner.peers.lock().unwrap(); + if let Some(handle) = peers.get(&username) { + if handle.write_tx.same_channel(&write_tx) { + peers.remove(&username); + } + } + } + writer.abort(); + result +} + +async fn peer_message_loop( + rd: &mut tokio::net::tcp::OwnedReadHalf, + inner: &Arc, + username: &str, +) -> std::io::Result<()> { + loop { + let frame = match tokio::time::timeout(PEER_READ_TIMEOUT, read_frame(rd)).await { + Ok(f) => f?, + Err(_) => return Ok(()), // idle + }; + let Some(mut body) = frame else { return Ok(()) }; + + use bytes::Buf; + if body.len() < 4 { + continue; + } + let code = body.get_u32_le(); + + match peer::decode(code, body) { + Ok(ev) => route_peer_event(inner, username, ev), + Err(e) => log::debug!("bad peer message {code} from {username}: {e}"), + } + } +} + +fn route_peer_event(inner: &Arc, username: &str, ev: peer::PeerEvent) { + use peer::PeerEvent::*; + match ev { + SearchResponse(resp) => { + let wanted = inner.active_searches.lock().unwrap().contains(&resp.token); + if wanted && !resp.files.is_empty() { + let _ = inner.event_tx.send(Event::SearchResult { + token: resp.token, + response: resp, + }); + } + } + TransferResponse { + token, + allowed, + size, + message, + } => { + let waiter = inner + .transfer_waits + .lock() + .unwrap() + .remove(&(username.to_string(), token)); + if let Some(tx) = waiter { + let _ = tx.send(TransferAck { + allowed, + size, + message, + }); + } + } + TransferRequest { + direction, + token, + filename, + size, + } => { + if direction == peer::DIRECTION_UPLOAD { + let waiter = inner + .upload_offers + .lock() + .unwrap() + .remove(&(username.to_string(), filename.clone())); + match waiter { + Some(tx) => { + let _ = tx.send(Ok((token, size))); + } + None => log::debug!("unsolicited upload offer from {username}: {filename}"), + } + } else { + // Their request to download one of our files → we upload. + crate::upload::on_transfer_request( + inner.clone(), + username.to_string(), + token, + filename, + ); + } + } + QueueDownload { filename } => { + crate::upload::on_queue_download(inner.clone(), username.to_string(), filename); + } + PlaceInQueueRequest { filename } => { + crate::upload::on_place_query(inner, username, &filename); + } + BrowseRequest => { + if let Some(h) = inner.peers.lock().unwrap().get(username) { + crate::responder::respond_browse(inner, h); + } + } + UserInfoRequest => { + if let Some(h) = inner.peers.lock().unwrap().get(username) { + crate::responder::respond_user_info(inner, h); + } + } + FolderContentsRequest { token, folder } => { + if let Some(h) = inner.peers.lock().unwrap().get(username) { + crate::responder::respond_folder_contents(inner, h, token, &folder); + } + } + UploadDenied { filename, reason } => { + let waiter = inner + .upload_offers + .lock() + .unwrap() + .remove(&(username.to_string(), filename)); + if let Some(tx) = waiter { + let _ = tx.send(Err(reason)); + } + } + UploadFailed { filename } => { + let waiter = inner + .upload_offers + .lock() + .unwrap() + .remove(&(username.to_string(), filename)); + if let Some(tx) = waiter { + let _ = tx.send(Err("remote upload failed".into())); + } + } + PlaceInQueueResponse { filename, place } => { + crate::transfer::update_queue_place(inner, username, &filename, place); + } + Unhandled { code } => log::trace!("unhandled peer message {code} from {username}"), + } +} + +// ----------------------------------------------- outbound peer connections + +/// Resolve a user's endpoint via the server (with cache). +pub(crate) async fn get_peer_address(inner: &Arc, username: &str) -> Result { + if let Some(addr) = inner.address_cache.lock().unwrap().get(username) { + return Ok(*addr); + } + let (tx, rx) = oneshot::channel(); + inner + .pending_addresses + .lock() + .unwrap() + .entry(username.to_string()) + .or_default() + .push(tx); + inner.send_server(server::get_peer_address(username))?; + + tokio::time::timeout(Duration::from_secs(10), rx) + .await + .map_err(|_| ClientError::UserOffline(username.to_string()))? + .map_err(|_| ClientError::UserOffline(username.to_string())) +} + +/// Get (or establish) a message connection to a user. Tries direct first, +/// then falls back to a server-relayed indirect connection. +pub(crate) async fn get_or_connect_peer(inner: &Arc, username: &str) -> Result { + if let Some(h) = inner.peers.lock().unwrap().get(username) { + return Ok(h.clone()); + } + + let addr = get_peer_address(inner, username).await?; + + // Direct attempt. + let direct = async { + let mut stream = tokio::time::timeout(PEER_CONNECT_TIMEOUT, TcpStream::connect(addr)) + .await + .map_err(|_| std::io::Error::other("connect timeout"))??; + stream.set_nodelay(true).ok(); + let init = peer::peer_init( + &inner.own_username(), + peer::CONN_TYPE_PEER, + inner.next_token(), + ); + stream.write_all(&init).await?; + Ok::(stream) + }; + + let stream = match direct.await { + Ok(s) => s, + Err(e) => { + log::debug!("direct connect to {username} failed ({e}); trying indirect"); + indirect_connect(inner, username, peer::CONN_TYPE_PEER).await? + } + }; + + let inner2 = inner.clone(); + let username2 = username.to_string(); + tokio::spawn(async move { + let _ = run_peer_connection(stream, inner2, username2).await; + }); + + // The handle is registered synchronously at the top of + // run_peer_connection, but the spawn may not have run yet — poll briefly. + for _ in 0..50 { + if let Some(h) = inner.peers.lock().unwrap().get(username) { + return Ok(h.clone()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(ClientError::PeerUnreachable(username.to_string())) +} + +/// Ask the server to have `username` connect to us; await the pierce. +pub(crate) async fn indirect_connect( + inner: &Arc, + username: &str, + conn_type: &str, +) -> Result { + let token = inner.next_token(); + let (tx, rx) = oneshot::channel(); + inner.pending_pierce.lock().unwrap().insert(token, tx); + inner.send_server(server::connect_to_peer(token, username, conn_type))?; + + let out = tokio::time::timeout(INDIRECT_TIMEOUT, rx).await; + inner.pending_pierce.lock().unwrap().remove(&token); + out.map_err(|_| ClientError::PeerUnreachable(username.to_string()))? + .map_err(|_| ClientError::PeerUnreachable(username.to_string())) +} + +/// Establish an outbound transfer ("F") connection and negotiate the token. +pub(crate) async fn connect_transfer( + inner: &Arc, + username: &str, + token: u32, +) -> Result { + let addr = get_peer_address(inner, username).await?; + + let direct = async { + let mut stream = tokio::time::timeout(PEER_CONNECT_TIMEOUT, TcpStream::connect(addr)) + .await + .map_err(|_| std::io::Error::other("connect timeout"))??; + stream.set_nodelay(true).ok(); + let init = peer::peer_init(&inner.own_username(), peer::CONN_TYPE_TRANSFER, token); + stream.write_all(&init).await?; + Ok::(stream) + }; + + let mut stream = match direct.await { + Ok(s) => s, + Err(e) => { + log::debug!("direct F connect to {username} failed ({e}); trying indirect"); + indirect_connect(inner, username, peer::CONN_TYPE_TRANSFER).await? + } + }; + + // Both flavors: identify the transfer with 4 raw token bytes. + stream + .write_all(&token.to_le_bytes()) + .await + .map_err(|_| ClientError::PeerUnreachable(username.to_string()))?; + Ok(stream) +} diff --git a/crates/soulseek-client/src/distributed.rs b/crates/soulseek-client/src/distributed.rs new file mode 100644 index 0000000..4ef9a5e --- /dev/null +++ b/crates/soulseek-client/src/distributed.rs @@ -0,0 +1,106 @@ +//! Distributed network: we join as a leaf child. The server sends parent +//! candidates via NetInfo; we connect to one (type "D"), complete the +//! branch handshake, and receive relayed `DistributedSearchRequest` +//! messages, which we answer from our shares. +//! +//! We do not accept children (leaf only) — that keeps the implementation +//! tractable while still making us a search-answering participant. + +use std::net::SocketAddrV4; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpStream; + +use soulseek_proto::{dist, peer, server}; + +use crate::{conn, responder, Inner}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(8); +const READ_TIMEOUT: Duration = Duration::from_secs(120); + +/// Try each parent candidate until one connects; on success, run the +/// distributed read loop until it drops, then allow re-solicitation. +pub(crate) fn on_net_info(inner: Arc, parents: Vec<(String, SocketAddrV4)>) { + if inner.has_parent.load(Ordering::Relaxed) || parents.is_empty() { + return; + } + tokio::spawn(async move { + for (username, addr) in parents { + if inner.has_parent.load(Ordering::Relaxed) { + return; + } + match connect_parent(&inner, &username, addr).await { + Ok(stream) => { + inner.has_parent.store(true, Ordering::Relaxed); + log::info!("distributed parent: {username} ({addr})"); + let _ = run_parent(&inner, stream).await; + inner.has_parent.store(false, Ordering::Relaxed); + // Ask the server for fresh parents next time. + let _ = inner.send_server(server::have_no_parents(true)); + return; + } + Err(e) => log::debug!("parent {username} ({addr}) failed: {e}"), + } + } + }); +} + +async fn connect_parent( + inner: &Arc, + _username: &str, + addr: SocketAddrV4, +) -> std::io::Result { + let mut stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(addr)) + .await + .map_err(|_| std::io::Error::other("connect timeout"))??; + stream.set_nodelay(true).ok(); + + // Identify as a distributed peer. + let token = inner.next_token(); + let init = peer::peer_init(&inner.own_username(), peer::CONN_TYPE_DISTRIBUTED, token); + stream.write_all(&init).await?; + + // Announce ourselves as a leaf: branch level 0, root = us. + stream.write_all(&dist::branch_level(0)).await?; + stream + .write_all(&dist::branch_root(&inner.own_username())) + .await?; + Ok(stream) +} + +async fn run_parent(inner: &Arc, mut stream: TcpStream) -> std::io::Result<()> { + loop { + let frame = match tokio::time::timeout(READ_TIMEOUT, conn::read_frame(&mut stream)).await { + Ok(f) => f?, + Err(_) => { + // Keepalive ping; if it fails the connection is dead. + if stream.write_all(&dist::ping()).await.is_err() { + return Ok(()); + } + continue; + } + }; + let Some(frame) = frame else { return Ok(()) }; + + match dist::decode(frame) { + Ok(dist::DistributedEvent::SearchRequest { + username, + token, + query, + }) => { + responder::respond_to_search(inner.clone(), username, token, query); + } + Ok(dist::DistributedEvent::BranchLevel(level)) => { + // Report our level (parent + 1) to the server. + let _ = inner.send_server(server::branch_level(level + 1)); + } + Ok(dist::DistributedEvent::BranchRoot(root)) => { + let _ = inner.send_server(server::branch_root(&root)); + } + Ok(_) => {} + Err(e) => log::trace!("bad distributed message: {e}"), + } + } +} diff --git a/crates/soulseek-client/src/lib.rs b/crates/soulseek-client/src/lib.rs new file mode 100644 index 0000000..0ae51f6 --- /dev/null +++ b/crates/soulseek-client/src/lib.rs @@ -0,0 +1,410 @@ +//! Soulseek network client: server connection, peer connections, search, +//! downloads. +//! +//! Behavioral reference: Soulseek.NET `SoulseekClient.cs` and `src/Network/**`. +//! +//! M1: connect + login, peer listener, indirect (PierceFirewall) +//! connections both directions, live search. +//! M2: downloads — TransferRequest/TransferResponse dance, F-type transfer +//! connections (both directions), resume via offset, download manager. + +mod conn; +mod distributed; +mod responder; +mod share; +mod transfer; +mod upload; +mod upnp; + +pub use share::ShareIndex; +pub use transfer::{DownloadState, DownloadUpdate}; +pub use upload::{UploadState, UploadUpdate}; + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::net::{Ipv4Addr, SocketAddrV4}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; +use tokio::net::TcpStream; +use tokio::sync::{mpsc, oneshot}; +use tokio_util::sync::CancellationToken; + +use soulseek_proto::{server, types::SearchResponse}; + +pub const DEFAULT_SERVER: (&str, u16) = ("server.slsknet.org", 2242); +pub const DEFAULT_LISTEN_PORT: u16 = 50300; + +#[derive(Debug, Clone)] +pub struct Config { + pub server_host: String, + pub server_port: u16, + pub listen_port: u16, + pub downloads_dir: PathBuf, + /// Folders we share with the network. + pub shared_folders: Vec, + /// Concurrent upload slots offered to peers. + pub upload_slots: u32, + /// User-info card description. + pub description: String, +} + +impl Default for Config { + fn default() -> Self { + Config { + server_host: DEFAULT_SERVER.0.into(), + server_port: DEFAULT_SERVER.1, + listen_port: DEFAULT_LISTEN_PORT, + downloads_dir: PathBuf::from("downloads"), + shared_folders: Vec::new(), + upload_slots: 2, + description: "SoulStream user".into(), + } + } +} + +#[derive(Debug, Clone)] +pub enum Event { + LoggedIn { + greeting: String, + own_ip: Ipv4Addr, + }, + LoginFailed { + reason: String, + }, + Disconnected { + reason: String, + }, + SearchResult { + token: u32, + response: SearchResponse, + }, + ExcludedSearchPhrases(Vec), + Download(DownloadUpdate), + Upload(UploadUpdate), + /// We answered a peer's search with N files (for activity display). + RespondedToSearch { + username: String, + files: usize, + }, +} + +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("protocol: {0}")] + Proto(#[from] soulseek_proto::ProtoError), + #[error("not connected to server")] + NotConnected, + #[error("login failed: {0}")] + LoginFailed(String), + #[error("user {0} is offline or unknown")] + UserOffline(String), + #[error("could not reach peer {0}")] + PeerUnreachable(String), + #[error("transfer rejected: {0}")] + TransferRejected(String), + #[error("timed out")] + Timeout, +} + +pub type Result = std::result::Result; + +/// Write handle for an established peer message ("P") connection. +#[derive(Clone)] +pub(crate) struct PeerHandle { + pub write_tx: mpsc::UnboundedSender>, +} + +/// Peer's answer to our TransferRequest(download). +#[derive(Debug, Clone)] +pub(crate) struct TransferAck { + pub allowed: bool, + pub size: u64, + pub message: String, +} + +/// Peer's TransferRequest(upload) once we reach their queue front — +/// or the denial that arrived instead. +pub(crate) type UploadOffer = std::result::Result<(u32, u64), String>; // (remote token, size) + +pub(crate) struct Inner { + pub server_tx: mpsc::UnboundedSender>, + pub event_tx: mpsc::UnboundedSender, + pub username: Mutex, + pub active_searches: Mutex>, + pub pending_login: Mutex>>>, + pub token_counter: AtomicU32, + pub listen_port: u16, + pub downloads_dir: PathBuf, + + /// GetPeerAddress requests awaiting a server answer, by username. + pub pending_addresses: Mutex>>>, + /// Last known endpoint per user. + pub address_cache: Mutex>, + /// Our indirect-connection solicitations: token → deliver the pierced stream. + pub pending_pierce: Mutex>>, + /// Inbound transfer connections we expect: (username, remote token) → stream. + pub expected_transfers: Mutex>>, + /// Live peer message connections. + pub peers: Mutex>, + /// TransferResponse waits: (username, our token). + pub transfer_waits: Mutex>>, + /// TransferRequest(upload) waits: (username, remote filename). + pub upload_offers: Mutex>>, + /// Active downloads by (username, remote filename) → (id, cancel). + pub downloads_by_file: Mutex>, + pub download_counter: AtomicU64, + + // ---- M5: sharing / uploads / distributed ---- + /// Files we share with the network. + pub shares: Arc, + /// Excluded phrases from the server; outgoing search results filter these. + pub excluded_phrases: RwLock>, + /// Our user-info description. + pub description: String, + /// Total upload slots and how many are in use. + pub upload_slots: u32, + pub uploads_active: AtomicU32, + /// Pending upload queue (username, remote path), FIFO. + pub upload_queue: Mutex>, + /// Rolling average upload speed reported to peers (bytes/sec). + pub upload_speed: AtomicU32, + pub upload_counter: AtomicU64, + /// Whether we currently have a distributed parent. + pub has_parent: AtomicBool, + /// De-dupe relayed searches so we don't answer the same token twice. + pub answered_searches: Mutex>, + /// Count of relayed searches we've seen (instrumentation). + pub searches_seen: AtomicU64, +} + +impl Inner { + pub fn next_token(&self) -> u32 { + self.token_counter.fetch_add(1, Ordering::Relaxed) + } + + pub fn own_username(&self) -> String { + self.username.lock().unwrap().clone() + } + + pub fn send_server(&self, frame: Vec) -> Result<()> { + self.server_tx + .send(frame) + .map_err(|_| ClientError::NotConnected) + } + + pub fn excluded(&self) -> Vec { + self.excluded_phrases.read().unwrap().clone() + } + + pub fn has_free_upload_slot(&self) -> bool { + self.uploads_active.load(Ordering::Relaxed) < self.upload_slots + } +} + +/// Handle to a running client. Dropping it does not tear down the +/// connection; call [`Client::shutdown`]. +pub struct Client { + inner: Arc, + shutdown_tx: mpsc::UnboundedSender<()>, + listener_shutdown_tx: mpsc::UnboundedSender<()>, +} + +impl Client { + /// Connect the server socket and start the peer listener. + /// Events (search results, download progress) arrive on the receiver. + pub async fn connect(config: Config) -> Result<(Client, mpsc::UnboundedReceiver)> { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (server_tx, server_rx) = mpsc::unbounded_channel::>(); + let (shutdown_tx, shutdown_rx) = mpsc::unbounded_channel::<()>(); + let (listener_shutdown_tx, listener_shutdown_rx) = mpsc::unbounded_channel::<()>(); + + let stream = TcpStream::connect((config.server_host.as_str(), config.server_port)).await?; + stream.set_nodelay(true).ok(); + + // Bind the listener before login so SetListenPort is accurate. + let listener = conn::bind_listener(config.listen_port).await?; + let listen_port = listener.local_addr()?.port(); + + // Seed tokens off the clock so restarts don't reuse recent tokens. + let seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(1); + + // Build the share index up front (blocking walk on a worker thread). + let shares = Arc::new(ShareIndex::new()); + if !config.shared_folders.is_empty() { + let shares2 = shares.clone(); + let folders = config.shared_folders.clone(); + let (dirs, files) = tokio::task::spawn_blocking(move || shares2.rebuild(&folders)) + .await + .unwrap_or((0, 0)); + log::info!("share index: {files} files in {dirs} folders"); + } + + let inner = Arc::new(Inner { + server_tx, + event_tx, + username: Mutex::new(String::new()), + active_searches: Mutex::new(HashSet::new()), + pending_login: Mutex::new(None), + token_counter: AtomicU32::new(seed), + listen_port, + downloads_dir: config.downloads_dir.clone(), + pending_addresses: Mutex::new(HashMap::new()), + address_cache: Mutex::new(HashMap::new()), + pending_pierce: Mutex::new(HashMap::new()), + expected_transfers: Mutex::new(HashMap::new()), + peers: Mutex::new(HashMap::new()), + transfer_waits: Mutex::new(HashMap::new()), + upload_offers: Mutex::new(HashMap::new()), + downloads_by_file: Mutex::new(HashMap::new()), + download_counter: AtomicU64::new(1), + shares, + excluded_phrases: RwLock::new(Vec::new()), + description: config.description.clone(), + upload_slots: config.upload_slots, + uploads_active: AtomicU32::new(0), + upload_queue: Mutex::new(VecDeque::new()), + upload_speed: AtomicU32::new(0), + upload_counter: AtomicU64::new(1), + has_parent: AtomicBool::new(false), + answered_searches: Mutex::new(HashSet::new()), + searches_seen: AtomicU64::new(0), + }); + + conn::spawn_server_io(stream, server_rx, shutdown_rx, inner.clone()); + conn::spawn_listener(listener, inner.clone(), listener_shutdown_rx); + + // Best-effort UPnP mapping for direct connectivity (non-fatal). + tokio::spawn(upnp::try_map_port(listen_port)); + + Ok(( + Client { + inner, + shutdown_tx, + listener_shutdown_tx, + }, + event_rx, + )) + } + + /// Log in; resolves when the server answers. + pub async fn login(&self, username: &str, password: &str) -> Result { + let (tx, rx) = oneshot::channel(); + *self.inner.pending_login.lock().unwrap() = Some(tx); + *self.inner.username.lock().unwrap() = username.to_string(); + + self.inner.send_server(server::login(username, password))?; + + let outcome = tokio::time::timeout(std::time::Duration::from_secs(15), rx) + .await + .map_err(|_| ClientError::Timeout)? + .map_err(|_| ClientError::NotConnected)?; + + match outcome { + Ok(greeting) => { + // Post-login bookkeeping the server expects from clients. + self.inner + .send_server(server::set_listen_port(self.inner.listen_port as u32))?; + let (dirs, files) = self.inner.shares.counts(); + self.inner + .send_server(server::shared_folders_and_files(dirs, files))?; + // Join the distributed network as a child so we receive + // relayed searches to answer. + self.inner.send_server(server::have_no_parents(true))?; + self.inner + .send_server(server::branch_root(&self.inner.own_username()))?; + self.inner.send_server(server::branch_level(0))?; + self.inner.send_server(server::accept_children(false))?; + Ok(greeting) + } + Err(reason) => Err(ClientError::LoginFailed(reason)), + } + } + + /// Re-index the shared folders and report the new counts to the server. + pub async fn update_shares(&self, folders: Vec) -> (u32, u32) { + let shares = self.inner.shares.clone(); + let (dirs, files) = tokio::task::spawn_blocking(move || shares.rebuild(&folders)) + .await + .unwrap_or((0, 0)); + let _ = self + .inner + .send_server(server::shared_folders_and_files(dirs, files)); + (dirs, files) + } + + /// Current (folders, files) share counts. + pub fn share_counts(&self) -> (u32, u32) { + self.inner.shares.counts() + } + + /// How many relayed searches we've received (whether or not matched). + pub fn searches_seen(&self) -> u64 { + self.inner.searches_seen.load(Ordering::Relaxed) + } + + /// Seed a peer's endpoint, bypassing server address resolution. Useful + /// for LAN peers and for tests where two instances share one public IP. + pub fn set_peer_address(&self, username: &str, addr: SocketAddrV4) { + self.inner + .address_cache + .lock() + .unwrap() + .insert(username.to_string(), addr); + } + + /// Whether we currently hold a distributed parent connection. + pub fn has_parent(&self) -> bool { + self.inner.has_parent.load(Ordering::Relaxed) + } + + /// Start a network-wide file search. Results stream out as + /// [`Event::SearchResult`] carrying this token. + pub fn search(&self, query: &str) -> Result { + let token = self.inner.next_token(); + self.inner.active_searches.lock().unwrap().insert(token); + self.inner.send_server(server::file_search(token, query))?; + Ok(token) + } + + /// Stop routing results for a search token. + pub fn stop_search(&self, token: u32) { + self.inner.active_searches.lock().unwrap().remove(&token); + } + + /// Start downloading `filename` (full remote path) from `username`. + /// Progress streams out as [`Event::Download`]. Returns the download id. + pub fn download(&self, username: &str, filename: &str, size: u64) -> u64 { + transfer::start_download(self.inner.clone(), username, filename, size, None) + } + + /// Like [`download`](Self::download) but writing to an explicit local + /// path (used for stream-to-cache). + pub fn download_to(&self, username: &str, filename: &str, size: u64, target: PathBuf) -> u64 { + transfer::start_download(self.inner.clone(), username, filename, size, Some(target)) + } + + /// Cancel an active download by id. + pub fn cancel_download(&self, id: u64) { + let map = self.inner.downloads_by_file.lock().unwrap(); + for (did, cancel) in map.values() { + if *did == id { + cancel.cancel(); + break; + } + } + } + + pub fn listen_port(&self) -> u16 { + self.inner.listen_port + } + + pub fn shutdown(&self) { + let _ = self.shutdown_tx.send(()); + let _ = self.listener_shutdown_tx.send(()); + } +} diff --git a/crates/soulseek-client/src/responder.rs b/crates/soulseek-client/src/responder.rs new file mode 100644 index 0000000..826953f --- /dev/null +++ b/crates/soulseek-client/src/responder.rs @@ -0,0 +1,99 @@ +//! Responder: answers other peers' requests — searches, browse, user-info, +//! folder contents. Search replies open a fresh P connection to the +//! searcher; the rest reply on the connection the request arrived on. + +use std::sync::atomic::Ordering; +use std::sync::Arc; + +use soulseek_proto::peer; + +use crate::{conn, Event, Inner, PeerHandle}; + +/// Max files we return in a single search reply. +const MAX_SEARCH_RESULTS: usize = 100; + +/// Match a relayed search against our shares and, if we have hits, send a +/// PeerSearchResponse back to `username`. Runs on its own task. +pub(crate) fn respond_to_search(inner: Arc, username: String, token: u32, query: String) { + inner.searches_seen.fetch_add(1, Ordering::Relaxed); + // Never answer our own searches; de-dupe repeated relays of a token. + if username == inner.own_username() { + return; + } + { + let mut answered = inner.answered_searches.lock().unwrap(); + if answered.contains(&token) { + return; + } + answered.insert(token); + // Bound the set so it can't grow without limit. + if answered.len() > 20_000 { + answered.clear(); + } + } + + if inner.shares.file_count() == 0 { + return; + } + + tokio::spawn(async move { + let excluded = inner.excluded(); + let files = inner.shares.search(&query, &excluded, MAX_SEARCH_RESULTS); + if files.is_empty() { + return; + } + + let frame = peer::search_response( + &inner.own_username(), + token, + &files, + inner.has_free_upload_slot(), + inner.upload_speed.load(Ordering::Relaxed), + inner.upload_queue.lock().unwrap().len() as u32, + ); + + // Search replies use a normal P connection to the searcher. + match conn::get_or_connect_peer(&inner, &username).await { + Ok(handle) => { + if handle.write_tx.send(frame).is_ok() { + let _ = inner.event_tx.send(Event::RespondedToSearch { + username: username.clone(), + files: files.len(), + }); + } + } + Err(e) => log::debug!("couldn't reply to search from {username}: {e}"), + } + }); +} + +/// Answer a BrowseRequest on the same connection. +pub(crate) fn respond_browse(inner: &Arc, handle: &PeerHandle) { + let dirs = inner.shares.browse(); + let _ = handle.write_tx.send(peer::browse_response(&dirs)); +} + +/// Answer a UserInfoRequest. +pub(crate) fn respond_user_info(inner: &Arc, handle: &PeerHandle) { + let frame = peer::user_info_response( + &inner.description, + inner.upload_slots, + inner.upload_queue.lock().unwrap().len() as u32, + inner.has_free_upload_slot(), + ); + let _ = handle.write_tx.send(frame); +} + +/// Answer a FolderContentsRequest for one folder. +pub(crate) fn respond_folder_contents( + inner: &Arc, + handle: &PeerHandle, + token: u32, + folder: &str, +) { + if let Some(dir) = inner.shares.folder(folder) { + let _ = handle + .write_tx + .send(peer::folder_contents_response(token, folder, &dir)); + } +} diff --git a/crates/soulseek-client/src/share.rs b/crates/soulseek-client/src/share.rs new file mode 100644 index 0000000..721bead --- /dev/null +++ b/crates/soulseek-client/src/share.rs @@ -0,0 +1,246 @@ +//! Share index: the files we make available to the network. Built by +//! walking configured folders; queried by the search and browse responders. +//! +//! Remote paths use backslashes and a virtual root so we never leak local +//! absolute paths. Each shared folder is mounted under its own base name, +//! e.g. local `D:\Music\Album` → remote `Music\Album\track.flac`. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::RwLock; + +use soulseek_proto::types::{Directory, File}; + +const AUDIO_EXTS: [&str; 10] = [ + "flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff", +]; + +/// One shared file: its remote (virtual) path and where it lives locally. +#[derive(Debug, Clone)] +struct SharedFile { + remote_path: String, // backslash-separated + local_path: PathBuf, + size: u64, + extension: String, + lowercase: String, // remote_path lowercased, for matching +} + +#[derive(Default)] +struct Index { + files: Vec, + /// remote folder (backslashes) → indices into `files`. + folders: BTreeMap>, +} + +pub struct ShareIndex { + index: RwLock, +} + +impl Default for ShareIndex { + fn default() -> Self { + ShareIndex { + index: RwLock::new(Index::default()), + } + } +} + +impl ShareIndex { + pub fn new() -> Self { + Self::default() + } + + /// Rebuild the index from the given local folders. Returns (dirs, files). + pub fn rebuild(&self, roots: &[PathBuf]) -> (u32, u32) { + let mut index = Index::default(); + for root in roots { + let base = root + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Shared".into()); + walk(root, &base, root, &mut index); + } + let dirs = index.folders.len() as u32; + let files = index.files.len() as u32; + *self.index.write().unwrap() = index; + (dirs, files) + } + + pub fn counts(&self) -> (u32, u32) { + let idx = self.index.read().unwrap(); + (idx.folders.len() as u32, idx.files.len() as u32) + } + + pub fn file_count(&self) -> u32 { + self.index.read().unwrap().files.len() as u32 + } + + /// Match a search query: every whitespace token must appear somewhere + /// in the remote path (case-insensitive). Returns proto `File`s. + /// `excluded` phrases veto a result if any appears in the path. + pub fn search(&self, query: &str, excluded: &[String], limit: usize) -> Vec { + let terms: Vec = query + .split_whitespace() + .map(|t| t.to_lowercase()) + .filter(|t| !t.is_empty()) + .collect(); + if terms.is_empty() { + return Vec::new(); + } + let idx = self.index.read().unwrap(); + let mut out = Vec::new(); + for f in &idx.files { + if terms.iter().all(|t| f.lowercase.contains(t.as_str())) + && !excluded + .iter() + .any(|p| !p.is_empty() && f.lowercase.contains(&p.to_lowercase())) + { + out.push(to_proto_file(f)); + if out.len() >= limit { + break; + } + } + } + out + } + + /// Resolve a remote path a peer asked to download to its local file. + pub fn resolve(&self, remote_path: &str) -> Option<(PathBuf, u64)> { + let idx = self.index.read().unwrap(); + idx.files + .iter() + .find(|f| f.remote_path.eq_ignore_ascii_case(remote_path)) + .map(|f| (f.local_path.clone(), f.size)) + } + + /// Full directory listing for a browse response. + pub fn browse(&self) -> Vec { + let idx = self.index.read().unwrap(); + idx.folders + .iter() + .map(|(name, indices)| Directory { + name: name.clone(), + files: indices + .iter() + .map(|&i| to_proto_file(&idx.files[i])) + .collect(), + }) + .collect() + } + + /// Files in one folder (for FolderContentsResponse). + pub fn folder(&self, name: &str) -> Option { + let idx = self.index.read().unwrap(); + idx.folders.get(name).map(|indices| Directory { + name: name.to_string(), + files: indices + .iter() + .map(|&i| to_proto_file(&idx.files[i])) + .collect(), + }) + } +} + +fn to_proto_file(f: &SharedFile) -> File { + // Search/browse responses send just the leaf filename in the File entry + // for search, but the full path for browse. We store the remote path + // and let the responder decide; here we return the remote path as the + // filename (peers expect a full backslash path in search responses). + File { + filename: f.remote_path.clone(), + size: f.size, + extension: f.extension.clone(), + attributes: Vec::new(), + } +} + +fn walk(dir: &Path, base: &str, root: &Path, index: &mut Index) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, base, root, index); + } else if is_audio(&path) { + let Ok(meta) = entry.metadata() else { continue }; + let remote_path = to_remote_path(&path, base, root); + let folder = remote_path + .rsplit_once('\\') + .map(|(d, _)| d.to_string()) + .unwrap_or_else(|| base.to_string()); + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + let file = SharedFile { + lowercase: remote_path.to_lowercase(), + remote_path, + local_path: path, + size: meta.len(), + extension: ext, + }; + let idx = index.files.len(); + index.folders.entry(folder).or_default().push(idx); + index.files.push(file); + } + } +} + +/// `\`. +fn to_remote_path(path: &Path, base: &str, root: &Path) -> String { + let rel = path.strip_prefix(root).unwrap_or(path); + let rel_str: String = rel + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("\\"); + format!("{base}\\{rel_str}") +} + +fn is_audio(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str())) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn index_search_and_resolve() { + let dir = std::env::temp_dir().join("ssplus-share-test"); + let album = dir.join("Nevermind"); + std::fs::create_dir_all(&album).unwrap(); + std::fs::write(album.join("01 Lithium.flac"), b"fake").unwrap(); + std::fs::write(album.join("02 Polly.mp3"), b"fake2").unwrap(); + std::fs::write(album.join("cover.jpg"), b"img").unwrap(); // ignored + + let share = ShareIndex::new(); + let (dirs, files) = share.rebuild(&[dir.clone()]); + assert_eq!(files, 2); + assert!(dirs >= 1); + + // Matches on both terms across the path. + let hits = share.search("nevermind lithium", &[], 10); + assert_eq!(hits.len(), 1); + assert!(hits[0].filename.ends_with("01 Lithium.flac")); + + // Excluded phrase vetoes. + let vetoed = share.search("lithium", &["nevermind".into()], 10); + assert!(vetoed.is_empty()); + + // Resolve remote → local. + let remote = &hits[0].filename; + let (local, size) = share.resolve(remote).unwrap(); + assert!(local.ends_with("01 Lithium.flac")); + assert_eq!(size, 4); + + // Browse lists the folder. + let browse = share.browse(); + assert!(browse + .iter() + .any(|d| d.name.contains("Nevermind") && d.files.len() == 2)); + } +} diff --git a/crates/soulseek-client/src/transfer.rs b/crates/soulseek-client/src/transfer.rs new file mode 100644 index 0000000..8f60d75 --- /dev/null +++ b/crates/soulseek-client/src/transfer.rs @@ -0,0 +1,421 @@ +//! Download state machine, mirroring Soulseek.NET's `DownloadToStreamAsync`: +//! +//! 1. Send `TransferRequest(download, token, filename)` on the P connection. +//! 2. Peer answers `TransferResponse`: +//! - allowed → we initiate the F connection with our token. +//! - "Queued" → wait for the peer's `TransferRequest(upload)`, ack it, +//! await their inbound F connection (second-chance: initiate ours). +//! 3. On the F connection: write 8-byte start offset, then read raw bytes. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; + +use soulseek_proto::peer; + +use crate::{conn, ClientError, Event, Inner, Result}; + +/// How long we stay in the remote queue before giving up. +const QUEUE_TIMEOUT: Duration = Duration::from_secs(2 * 60 * 60); +/// Ack / connection-establishment timeout. +const NEGOTIATION_TIMEOUT: Duration = Duration::from_secs(20); +/// No bytes for this long mid-transfer → fail. +const TRANSFER_STALL_TIMEOUT: Duration = Duration::from_secs(90); +/// Poll the remote queue position at this interval while queued. +const PLACE_POLL_INTERVAL: Duration = Duration::from_secs(60); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DownloadState { + Connecting, + Requested, + Queued { place: Option }, + Transferring, + Complete { local_path: String }, + Failed { reason: String }, + Cancelled, +} + +#[derive(Debug, Clone)] +pub struct DownloadUpdate { + pub id: u64, + pub username: String, + /// Full remote path (backslash separated). + pub filename: String, + pub state: DownloadState, + pub received: u64, + pub total: u64, + pub speed_bps: u64, +} + +pub(crate) fn start_download( + inner: Arc, + username: &str, + filename: &str, + size: u64, + target: Option, +) -> u64 { + let id = inner + .download_counter + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let cancel = CancellationToken::new(); + inner.downloads_by_file.lock().unwrap().insert( + (username.to_string(), filename.to_string()), + (id, cancel.clone()), + ); + + let username = username.to_string(); + let filename = filename.to_string(); + tokio::spawn(async move { + let mut dl = Download { + inner: inner.clone(), + id, + username: username.clone(), + filename: filename.clone(), + target, + total: size, + received: 0, + speed_bps: 0, + cancel: cancel.clone(), + }; + + let outcome = tokio::select! { + r = dl.run() => r, + _ = cancel.cancelled() => Err(ClientError::Timeout), // mapped below + }; + + match outcome { + Ok(local_path) => dl.emit(DownloadState::Complete { local_path }), + Err(_) if cancel.is_cancelled() => dl.emit(DownloadState::Cancelled), + Err(e) => dl.emit(DownloadState::Failed { + reason: e.to_string(), + }), + } + + inner + .downloads_by_file + .lock() + .unwrap() + .remove(&(username, filename)); + }); + id +} + +/// Emit a queue-place update for a download (from PlaceInQueueResponse). +pub(crate) fn update_queue_place(inner: &Arc, username: &str, filename: &str, place: u32) { + let id = inner + .downloads_by_file + .lock() + .unwrap() + .get(&(username.to_string(), filename.to_string())) + .map(|(id, _)| *id); + if let Some(id) = id { + let _ = inner.event_tx.send(Event::Download(DownloadUpdate { + id, + username: username.to_string(), + filename: filename.to_string(), + state: DownloadState::Queued { place: Some(place) }, + received: 0, + total: 0, + speed_bps: 0, + })); + } +} + +struct Download { + inner: Arc, + id: u64, + username: String, + filename: String, + /// Explicit local target (stream-to-cache); None → downloads dir layout. + target: Option, + total: u64, + received: u64, + speed_bps: u64, + cancel: CancellationToken, +} + +impl Download { + fn emit(&self, state: DownloadState) { + let _ = self.inner.event_tx.send(Event::Download(DownloadUpdate { + id: self.id, + username: self.username.clone(), + filename: self.filename.clone(), + state, + received: self.received, + total: self.total, + speed_bps: self.speed_bps, + })); + } + + async fn run(&mut self) -> Result { + self.emit(DownloadState::Connecting); + let handle = conn::get_or_connect_peer(&self.inner, &self.username).await?; + + // Register both waits before sending the request to avoid races: + // the immediate ack, and the eventual upload offer if we get queued. + let token = self.inner.next_token(); + let (ack_tx, ack_rx) = oneshot::channel(); + self.inner + .transfer_waits + .lock() + .unwrap() + .insert((self.username.clone(), token), ack_tx); + + let (offer_tx, offer_rx) = oneshot::channel(); + self.inner + .upload_offers + .lock() + .unwrap() + .insert((self.username.clone(), self.filename.clone()), offer_tx); + + handle + .write_tx + .send(peer::transfer_request_download(token, &self.filename)) + .map_err(|_| ClientError::PeerUnreachable(self.username.clone()))?; + self.emit(DownloadState::Requested); + + let ack = tokio::time::timeout(NEGOTIATION_TIMEOUT, ack_rx) + .await + .map_err(|_| ClientError::Timeout)? + .map_err(|_| ClientError::PeerUnreachable(self.username.clone()))?; + + let stream = if ack.allowed { + // Free slot: we initiate the transfer connection with our token. + if ack.size > 0 { + self.total = ack.size; + } + self.emit(DownloadState::Transferring); + conn::connect_transfer(&self.inner, &self.username, token).await? + } else if ack + .message + .trim_end_matches('.') + .eq_ignore_ascii_case("queued") + { + self.emit(DownloadState::Queued { place: None }); + self.await_queued_transfer(&handle, offer_rx).await? + } else { + return Err(ClientError::TransferRejected(ack.message)); + }; + + self.receive(stream).await + } + + /// We're in the remote queue: poll our place, wait for the peer's + /// upload offer, ack it, and obtain the transfer connection. + async fn await_queued_transfer( + &mut self, + handle: &crate::PeerHandle, + offer_rx: oneshot::Receiver, + ) -> Result { + // Queue-place polling until the offer arrives. + let poll_handle = handle.write_tx.clone(); + let poll_filename = self.filename.clone(); + let poller = tokio::spawn(async move { + loop { + tokio::time::sleep(PLACE_POLL_INTERVAL).await; + if poll_handle + .send(peer::place_in_queue_request(&poll_filename)) + .is_err() + { + break; + } + } + }); + + let offer = tokio::time::timeout(QUEUE_TIMEOUT, offer_rx).await; + poller.abort(); + + let (remote_token, size) = offer + .map_err(|_| ClientError::Timeout)? + .map_err(|_| ClientError::PeerUnreachable(self.username.clone()))? + .map_err(ClientError::TransferRejected)?; + + if size > 0 { + self.total = size; + } + + // Expect the peer's inbound F connection carrying their token — + // registered before we ack so the connection can't race us. + let (conn_tx, conn_rx) = oneshot::channel(); + self.inner + .expected_transfers + .lock() + .unwrap() + .insert((self.username.clone(), remote_token), conn_tx); + + // Re-resolve the peer connection in case the old one idled out. + let handle = conn::get_or_connect_peer(&self.inner, &self.username).await?; + handle + .write_tx + .send(peer::transfer_response_allowed(remote_token, self.total)) + .map_err(|_| ClientError::PeerUnreachable(self.username.clone()))?; + + self.emit(DownloadState::Transferring); + + match tokio::time::timeout(NEGOTIATION_TIMEOUT, conn_rx).await { + Ok(Ok(stream)) => Ok(stream), + _ => { + // Second chance (Nicotine+ et al.): initiate from our end. + self.inner + .expected_transfers + .lock() + .unwrap() + .remove(&(self.username.clone(), remote_token)); + log::debug!( + "no inbound F connection from {}; initiating second-chance", + self.username + ); + conn::connect_transfer(&self.inner, &self.username, remote_token).await + } + } + } + + /// Drive the transfer connection: write start offset, stream to disk. + async fn receive(&mut self, mut stream: TcpStream) -> Result { + let (final_path, partial_path) = self.local_paths()?; + + // Resume from an existing partial file when its length is sane. + let offset = match tokio::fs::metadata(&partial_path).await { + Ok(m) if self.total > 0 && m.len() < self.total => m.len(), + _ => 0, + }; + + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .write(true) + .open(&partial_path) + .await?; + file.set_len(offset).await?; + file.seek(std::io::SeekFrom::Start(offset)).await?; + + stream.write_all(&offset.to_le_bytes()).await?; + self.received = offset; + self.emit(DownloadState::Transferring); + + let mut buf = vec![0u8; 64 * 1024]; + let mut window_start = Instant::now(); + let mut window_bytes: u64 = 0; + let mut last_emit = Instant::now(); + + while self.total == 0 || self.received < self.total { + let n = tokio::select! { + _ = self.cancel.cancelled() => return Err(ClientError::Timeout), + r = tokio::time::timeout(TRANSFER_STALL_TIMEOUT, stream.read(&mut buf)) => { + r.map_err(|_| ClientError::Timeout)?? + } + }; + if n == 0 { + if self.total == 0 { + break; // size unknown: EOF is completion + } + return Err(ClientError::PeerUnreachable(format!( + "{} closed the connection early ({}/{} bytes)", + self.username, self.received, self.total + ))); + } + file.write_all(&buf[..n]).await?; + self.received += n as u64; + window_bytes += n as u64; + + let elapsed = window_start.elapsed(); + if elapsed >= Duration::from_secs(1) { + self.speed_bps = (window_bytes as f64 / elapsed.as_secs_f64()) as u64; + window_start = Instant::now(); + window_bytes = 0; + } + if last_emit.elapsed() >= Duration::from_millis(250) { + last_emit = Instant::now(); + self.emit(DownloadState::Transferring); + } + } + + file.flush().await?; + drop(file); + + let final_path = unique_path(final_path).await; + tokio::fs::rename(&partial_path, &final_path).await?; + Ok(final_path.to_string_lossy().into_owned()) + } + + /// `///` (+ `.partial`), + /// or the explicit target when set. + fn local_paths(&self) -> Result<(PathBuf, PathBuf)> { + if let Some(target) = &self.target { + if let Some(dir) = target.parent() { + std::fs::create_dir_all(dir)?; + } + let partial = target.with_extension(format!( + "{}partial", + target + .extension() + .map(|e| format!("{}.", e.to_string_lossy())) + .unwrap_or_default() + )); + return Ok((target.clone(), partial)); + } + let (folder, name) = match self.filename.rsplit_once('\\') { + Some((dir, n)) => (dir.rsplit('\\').next().unwrap_or(""), n), + None => ("", self.filename.as_str()), + }; + let mut dir = self.inner.downloads_dir.clone(); + dir.push(sanitize(&self.username)); + if !folder.is_empty() { + dir.push(sanitize(folder)); + } + std::fs::create_dir_all(&dir)?; + let final_path = dir.join(sanitize(name)); + let partial = dir.join(format!("{}.partial", sanitize(name))); + Ok((final_path, partial)) + } +} + +/// Make a remote path component safe for Windows. +fn sanitize(part: &str) -> String { + let cleaned: String = part + .chars() + .map(|c| match c { + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_', + c if (c as u32) < 0x20 => '_', + c => c, + }) + .collect(); + cleaned.trim_end_matches([' ', '.']).to_string() +} + +/// Avoid clobbering an existing file: append " (2)", " (3)", … +async fn unique_path(path: PathBuf) -> PathBuf { + if tokio::fs::metadata(&path).await.is_err() { + return path; + } + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let ext = path + .extension() + .map(|e| format!(".{}", e.to_string_lossy())) + .unwrap_or_default(); + let dir = path.parent().map(PathBuf::from).unwrap_or_default(); + for i in 2.. { + let candidate = dir.join(format!("{stem} ({i}){ext}")); + if tokio::fs::metadata(&candidate).await.is_err() { + return candidate; + } + } + unreachable!() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_windows_reserved() { + assert_eq!(sanitize("AC/DC: Back in Black?"), "AC_DC_ Back in Black_"); + assert_eq!(sanitize("trailing dots..."), "trailing dots"); + } +} diff --git a/crates/soulseek-client/src/upload.rs b/crates/soulseek-client/src/upload.rs new file mode 100644 index 0000000..7a47338 --- /dev/null +++ b/crates/soulseek-client/src/upload.rs @@ -0,0 +1,334 @@ +//! Upload servicing: when a peer requests one of our shared files, we send +//! it over an F connection. Slot-limited with a simple FIFO queue. +//! +//! Two protocol sub-flows, mirroring the download side (M2): +//! +//! * **Immediate** — peer sends `TransferRequest(download, their_token)`. +//! With a free slot we reply `TransferResponse(their_token, allowed)`, +//! then await *their* inbound F connection (keyed by their token), read +//! the 8-byte offset, and stream the file. +//! * **Queued / proactive** — no slot yet (or a bare `QueueDownload`). We +//! enqueue; when a slot frees we send `TransferRequest(upload, our_token)`, +//! await their `TransferResponse`, open the F connection ourselves, read +//! the offset, and stream. + +use std::path::PathBuf; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::sync::oneshot; + +use soulseek_proto::peer; + +use crate::{conn, ClientError, Event, Inner, Result}; + +const ACK_TIMEOUT: Duration = Duration::from_secs(30); +const STALL_TIMEOUT: Duration = Duration::from_secs(60); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UploadState { + Queued, + Transferring, + Complete, + Failed { reason: String }, +} + +#[derive(Debug, Clone)] +pub struct UploadUpdate { + pub id: u64, + pub username: String, + pub filename: String, + pub state: UploadState, + pub sent: u64, + pub total: u64, +} + +/// Peer sent `TransferRequest(download, their_token, filename)`. +pub(crate) fn on_transfer_request( + inner: Arc, + username: String, + their_token: u32, + remote_path: String, +) { + let Some((local, size)) = inner.shares.resolve(&remote_path) else { + reply( + &inner, + &username, + peer::transfer_response_rejected(their_token, "File not shared"), + ); + return; + }; + + if inner.has_free_upload_slot() { + // Accept: they will open the F connection with their token. + reply( + &inner, + &username, + peer::transfer_response_allowed(their_token, size), + ); + spawn_immediate(inner, username, remote_path, local, size, their_token); + } else { + reply( + &inner, + &username, + peer::transfer_response_rejected(their_token, "Queued"), + ); + enqueue(&inner, &username, &remote_path); + } +} + +/// Peer sent a bare `QueueDownload(filename)` (no token) — enqueue, or start +/// the proactive flow immediately if a slot is free. +pub(crate) fn on_queue_download(inner: Arc, username: String, remote_path: String) { + let Some((local, size)) = inner.shares.resolve(&remote_path) else { + reply( + &inner, + &username, + peer::upload_denied(&remote_path, "File not shared"), + ); + return; + }; + if inner.has_free_upload_slot() { + spawn_proactive(inner, username, remote_path, local, size); + } else { + enqueue(&inner, &username, &remote_path); + } +} + +/// Report queue place when a peer asks (PlaceInQueueRequest). +pub(crate) fn on_place_query(inner: &Arc, username: &str, remote_path: &str) { + let place = inner + .upload_queue + .lock() + .unwrap() + .iter() + .position(|(u, p)| u == username && p == remote_path) + .map(|i| i as u32 + 1) + .unwrap_or(0); + reply( + inner, + username, + peer::place_in_queue_response(remote_path, place), + ); +} + +fn enqueue(inner: &Arc, username: &str, remote_path: &str) { + let mut q = inner.upload_queue.lock().unwrap(); + if !q.iter().any(|(u, p)| u == username && p == remote_path) && q.len() < 500 { + q.push_back((username.to_string(), remote_path.to_string())); + } + let place = q.len() as u32; + drop(q); + reply( + inner, + username, + peer::place_in_queue_response(remote_path, place), + ); +} + +fn reply(inner: &Arc, username: &str, frame: Vec) { + if let Some(h) = inner.peers.lock().unwrap().get(username) { + let _ = h.write_tx.send(frame); + } +} + +/// After a slot frees, start the next queued upload (proactive flow). +fn pump_queue(inner: Arc) { + if !inner.has_free_upload_slot() { + return; + } + let next = inner.upload_queue.lock().unwrap().pop_front(); + if let Some((username, remote_path)) = next { + if let Some((local, size)) = inner.shares.resolve(&remote_path) { + spawn_proactive(inner, username, remote_path, local, size); + } + } +} + +// -------- immediate flow (peer opens the F connection) -------- + +fn spawn_immediate( + inner: Arc, + username: String, + remote_path: String, + local: PathBuf, + size: u64, + their_token: u32, +) { + inner.uploads_active.fetch_add(1, Ordering::Relaxed); + let id = inner.upload_counter.fetch_add(1, Ordering::Relaxed); + + // Register to receive their inbound F connection (keyed by their token). + let (conn_tx, conn_rx) = oneshot::channel(); + inner + .expected_transfers + .lock() + .unwrap() + .insert((username.clone(), their_token), conn_tx); + + tokio::spawn(async move { + let user_err = username.clone(); + run_upload( + &inner, + id, + &username, + &remote_path, + &local, + size, + async move { + let stream = tokio::time::timeout(ACK_TIMEOUT, conn_rx) + .await + .map_err(|_| ClientError::Timeout)? + .map_err(|_| ClientError::PeerUnreachable(user_err))?; + Ok::(stream) + }, + ) + .await; + inner + .expected_transfers + .lock() + .unwrap() + .remove(&(username.clone(), their_token)); + inner.uploads_active.fetch_sub(1, Ordering::Relaxed); + pump_queue(inner.clone()); + }); +} + +// -------- proactive flow (we open the F connection) -------- + +fn spawn_proactive( + inner: Arc, + username: String, + remote_path: String, + local: PathBuf, + size: u64, +) { + inner.uploads_active.fetch_add(1, Ordering::Relaxed); + let id = inner.upload_counter.fetch_add(1, Ordering::Relaxed); + + tokio::spawn(async move { + let inner2 = inner.clone(); + let user2 = username.clone(); + let path2 = remote_path.clone(); + run_upload( + &inner, + id, + &username, + &remote_path, + &local, + size, + async move { + // Offer the upload and wait for acceptance. + let handle = conn::get_or_connect_peer(&inner2, &user2).await?; + let token = inner2.next_token(); + let (ack_tx, ack_rx) = oneshot::channel(); + inner2 + .transfer_waits + .lock() + .unwrap() + .insert((user2.clone(), token), ack_tx); + handle + .write_tx + .send(peer::transfer_request_upload(token, &path2, size)) + .map_err(|_| ClientError::PeerUnreachable(user2.clone()))?; + let ack = tokio::time::timeout(ACK_TIMEOUT, ack_rx) + .await + .map_err(|_| ClientError::Timeout)? + .map_err(|_| ClientError::PeerUnreachable(user2.clone()))?; + if !ack.allowed { + return Err(ClientError::TransferRejected(ack.message)); + } + conn::connect_transfer(&inner2, &user2, token).await + }, + ) + .await; + inner.uploads_active.fetch_sub(1, Ordering::Relaxed); + pump_queue(inner.clone()); + }); +} + +/// Shared upload driver: obtain the F stream via `get_stream`, read the +/// 8-byte start offset, and stream the file from there. +async fn run_upload( + inner: &Arc, + id: u64, + username: &str, + remote_path: &str, + local: &PathBuf, + size: u64, + get_stream: F, +) where + F: std::future::Future>, +{ + let emit = |state: UploadState, sent: u64| { + let _ = inner.event_tx.send(Event::Upload(UploadUpdate { + id, + username: username.to_string(), + filename: remote_path.to_string(), + state, + sent, + total: size, + })); + }; + emit(UploadState::Transferring, 0); + + let result = async { + let mut stream = get_stream.await?; + + let mut off = [0u8; 8]; + tokio::time::timeout(ACK_TIMEOUT, stream.read_exact(&mut off)) + .await + .map_err(|_| ClientError::Timeout)??; + let offset = u64::from_le_bytes(off).min(size); + + let mut file = tokio::fs::File::open(local).await?; + file.seek(std::io::SeekFrom::Start(offset)).await?; + + let mut sent = offset; + let mut buf = vec![0u8; 64 * 1024]; + let mut window = Instant::now(); + let mut window_bytes: u64 = 0; + let mut last_emit = Instant::now(); + + while sent < size { + let n = file.read(&mut buf).await?; + if n == 0 { + break; + } + tokio::time::timeout(STALL_TIMEOUT, stream.write_all(&buf[..n])) + .await + .map_err(|_| ClientError::Timeout)??; + sent += n as u64; + window_bytes += n as u64; + + let elapsed = window.elapsed(); + if elapsed >= Duration::from_secs(2) { + inner.upload_speed.store( + (window_bytes as f64 / elapsed.as_secs_f64()) as u32, + Ordering::Relaxed, + ); + window = Instant::now(); + window_bytes = 0; + } + if last_emit.elapsed() >= Duration::from_millis(300) { + last_emit = Instant::now(); + emit(UploadState::Transferring, sent); + } + } + stream.flush().await?; + Ok::<(), ClientError>(()) + } + .await; + + match result { + Ok(()) => emit(UploadState::Complete, size), + Err(e) => emit( + UploadState::Failed { + reason: e.to_string(), + }, + 0, + ), + } +} diff --git a/crates/soulseek-client/src/upnp.rs b/crates/soulseek-client/src/upnp.rs new file mode 100644 index 0000000..cc30a75 --- /dev/null +++ b/crates/soulseek-client/src/upnp.rs @@ -0,0 +1,57 @@ +//! Best-effort UPnP port mapping so firewalled peers can reach our +//! listener directly. Failure is non-fatal — Soulseek's indirect +//! (server-relayed) connection path still works without it. + +use std::net::SocketAddrV4; +use std::time::Duration; + +/// Try to map `port` (TCP) on the local gateway to ourselves. Returns the +/// external address on success. Runs on a blocking thread. +pub(crate) async fn try_map_port(port: u16) { + let result = tokio::task::spawn_blocking(move || map_blocking(port)).await; + match result { + Ok(Ok(external)) => log::info!("UPnP: mapped port {port} (external {external})"), + Ok(Err(e)) => log::info!("UPnP unavailable ({e}); relying on indirect connections"), + Err(_) => log::debug!("UPnP task panicked"), + } +} + +fn map_blocking(port: u16) -> Result { + use igd_next::{search_gateway, PortMappingProtocol, SearchOptions}; + + let gateway = search_gateway(SearchOptions { + timeout: Some(Duration::from_secs(5)), + ..Default::default() + }) + .map_err(|e| e.to_string())?; + + // Find our LAN address facing the gateway. + let local_ip = local_ipv4().ok_or_else(|| "no local IPv4".to_string())?; + let local = SocketAddrV4::new(local_ip, port); + + gateway + .add_port( + PortMappingProtocol::TCP, + port, + std::net::SocketAddr::V4(local), + 0, // lease 0 = indefinite (until router reboots) + "SoulStream", + ) + .map_err(|e| e.to_string())?; + + match gateway.get_external_ip() { + Ok(std::net::IpAddr::V4(ip)) => Ok(SocketAddrV4::new(ip, port)), + _ => Ok(local), + } +} + +/// Discover our LAN IPv4 by opening a UDP socket toward a public address +/// (no packets are actually sent). +fn local_ipv4() -> Option { + let sock = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; + sock.connect("8.8.8.8:80").ok()?; + match sock.local_addr().ok()?.ip() { + std::net::IpAddr::V4(ip) => Some(ip), + _ => None, + } +} diff --git a/crates/soulseek-proto/Cargo.toml b/crates/soulseek-proto/Cargo.toml new file mode 100644 index 0000000..2ef39c0 --- /dev/null +++ b/crates/soulseek-proto/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "soulseek-proto" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +bytes.workspace = true +thiserror.workspace = true +flate2 = "1" +md-5 = "0.10" diff --git a/crates/soulseek-proto/src/dist.rs b/crates/soulseek-proto/src/dist.rs new file mode 100644 index 0000000..4d6f2a3 --- /dev/null +++ b/crates/soulseek-proto/src/dist.rs @@ -0,0 +1,137 @@ +//! Distributed network messages (connection type "D"), single-byte codes. +//! We join as a child: connect to a parent, receive relayed searches, and +//! report our branch level/root. + +use bytes::Bytes; + +use crate::wire::{Reader, Writer}; +use crate::Result; + +pub mod code { + pub const PING: u8 = 0; + pub const SEARCH_REQUEST: u8 = 3; + pub const BRANCH_LEVEL: u8 = 4; + pub const BRANCH_ROOT: u8 = 5; + pub const CHILD_DEPTH: u8 = 7; + pub const EMBEDDED_MESSAGE: u8 = 93; +} + +// ---- outgoing (to parent / children) ---- + +pub fn branch_level(level: u32) -> Vec { + Writer::new().u32(level).finish_u8(code::BRANCH_LEVEL) +} + +pub fn branch_root(root_username: &str) -> Vec { + Writer::new() + .string(root_username) + .finish_u8(code::BRANCH_ROOT) +} + +pub fn ping() -> Vec { + Writer::new().finish_u8(code::PING) +} + +/// Relay a search to our children. +pub fn search_request(username: &str, token: u32, query: &str) -> Vec { + Writer::new() + .u32(0) + .string(username) + .u32(token) + .string(query) + .finish_u8(code::SEARCH_REQUEST) +} + +// ---- incoming ---- + +#[derive(Debug, Clone)] +pub enum DistributedEvent { + /// A search to match against our shares and reply to `username`. + SearchRequest { + username: String, + token: u32, + query: String, + }, + BranchLevel(u32), + BranchRoot(String), + Ping, + Unhandled { + code: u8, + }, +} + +/// Decode a distributed frame (code byte + payload). Handles the +/// `EmbeddedMessage` (93) wrapper by unwrapping to its inner distributed +/// message. +pub fn decode(frame: Bytes) -> Result { + let mut r = Reader::new(frame); + let code = r.u8()?; + decode_inner(code, &mut r) +} + +fn decode_inner(code: u8, r: &mut Reader) -> Result { + Ok(match code { + code::EMBEDDED_MESSAGE => { + // [u8 inner_code][inner payload] + let inner = r.u8()?; + return decode_inner(inner, r); + } + code::SEARCH_REQUEST => { + let _unknown = r.u32()?; + let username = r.string()?; + let token = r.u32()?; + let query = r.string()?; + DistributedEvent::SearchRequest { + username, + token, + query, + } + } + code::BRANCH_LEVEL => DistributedEvent::BranchLevel(r.u32()?), + code::BRANCH_ROOT => DistributedEvent::BranchRoot(r.string()?), + code::PING => DistributedEvent::Ping, + other => DistributedEvent::Unhandled { code: other }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_search_request() { + let msg = search_request("alice", 42, "the beatles"); + let ev = decode(Bytes::copy_from_slice(&msg[4..])).unwrap(); + match ev { + DistributedEvent::SearchRequest { + username, + token, + query, + } => { + assert_eq!( + (username.as_str(), token, query.as_str()), + ("alice", 42, "the beatles") + ); + } + other => panic!("wrong: {other:?}"), + } + } + + #[test] + fn decode_embedded_wrapper() { + // Server EmbeddedMessage delivers [93][3][search payload]. + let inner = search_request("bob", 7, "query"); + // inner[4] is the code byte (3); inner[5..] is the payload. + let mut framed = vec![code::EMBEDDED_MESSAGE]; + framed.extend_from_slice(&inner[4..]); // code 3 + payload + let ev = decode(Bytes::from(framed)).unwrap(); + match ev { + DistributedEvent::SearchRequest { + username, query, .. + } => { + assert_eq!((username.as_str(), query.as_str()), ("bob", "query")); + } + other => panic!("wrong: {other:?}"), + } + } +} diff --git a/crates/soulseek-proto/src/lib.rs b/crates/soulseek-proto/src/lib.rs new file mode 100644 index 0000000..c68bece --- /dev/null +++ b/crates/soulseek-proto/src/lib.rs @@ -0,0 +1,31 @@ +//! Soulseek protocol messages and binary codecs. +//! +//! Wire format: every message is `[u32 length][code][payload]`, little-endian +//! throughout. Server and peer messages use a `u32` code; connection +//! initialization messages (PeerInit / PierceFirewall) use a single-byte code. +//! Strings are `[u32 len][bytes]`, UTF-8 with Latin-1 fallback on decode. +//! Search responses and browse payloads are zlib-compressed after the code. +//! +//! Reference: Soulseek.NET `src/Messaging/**` and the Nicotine+ protocol docs. + +pub mod dist; +pub mod peer; +pub mod server; +pub mod types; +pub mod wire; + +#[derive(Debug, thiserror::Error)] +pub enum ProtoError { + #[error("message payload ended unexpectedly")] + UnexpectedEof, + #[error("zlib decompression failed: {0}")] + Zlib(#[from] std::io::Error), + #[error("message too large: {0} bytes")] + TooLarge(usize), +} + +pub type Result = std::result::Result; + +/// Sanity cap on a single message frame (search responses from big shares +/// run a few hundred KB; browse responses can reach tens of MB). +pub const MAX_MESSAGE_SIZE: usize = 64 * 1024 * 1024; diff --git a/crates/soulseek-proto/src/peer.rs b/crates/soulseek-proto/src/peer.rs new file mode 100644 index 0000000..cc0544b --- /dev/null +++ b/crates/soulseek-proto/src/peer.rs @@ -0,0 +1,435 @@ +//! Peer connection messages: single-byte-code initialization handshake, +//! then u32-code peer messages. + +use bytes::Bytes; + +use crate::types::{Directory, File, SearchResponse}; +use crate::wire::{Reader, Writer}; +use crate::{ProtoError, Result}; + +pub mod init_code { + pub const PIERCE_FIREWALL: u8 = 0; + pub const PEER_INIT: u8 = 1; +} + +pub mod code { + pub const BROWSE_REQUEST: u32 = 4; + pub const BROWSE_RESPONSE: u32 = 5; + pub const SEARCH_RESPONSE: u32 = 9; + pub const USER_INFO_REQUEST: u32 = 15; + pub const USER_INFO_RESPONSE: u32 = 16; + pub const FOLDER_CONTENTS_REQUEST: u32 = 36; + pub const FOLDER_CONTENTS_RESPONSE: u32 = 37; + pub const TRANSFER_REQUEST: u32 = 40; + pub const TRANSFER_RESPONSE: u32 = 41; + pub const QUEUE_DOWNLOAD: u32 = 43; + pub const PLACE_IN_QUEUE_RESPONSE: u32 = 44; + pub const UPLOAD_FAILED: u32 = 46; + pub const UPLOAD_DENIED: u32 = 50; + pub const PLACE_IN_QUEUE_REQUEST: u32 = 51; +} + +/// Connection types carried by PeerInit / ConnectToPeer. +pub const CONN_TYPE_PEER: &str = "P"; +pub const CONN_TYPE_TRANSFER: &str = "F"; +pub const CONN_TYPE_DISTRIBUTED: &str = "D"; + +/// TransferRequest direction values. +pub const DIRECTION_DOWNLOAD: u32 = 0; +pub const DIRECTION_UPLOAD: u32 = 1; + +// ---- initialization (u8 code) ---- + +#[derive(Debug, Clone)] +pub enum InitMessage { + PierceFirewall { + token: u32, + }, + PeerInit { + username: String, + conn_type: String, + token: u32, + }, +} + +pub fn pierce_firewall(token: u32) -> Vec { + Writer::new() + .u32(token) + .finish_u8(init_code::PIERCE_FIREWALL) +} + +pub fn peer_init(username: &str, conn_type: &str, token: u32) -> Vec { + Writer::new() + .string(username) + .string(conn_type) + .u32(token) + .finish_u8(init_code::PEER_INIT) +} + +/// Decode the first frame on a fresh incoming connection. +/// `frame` is the full frame body (code byte included, length header not). +pub fn decode_init(frame: Bytes) -> Result { + let mut r = Reader::new(frame); + match r.u8()? { + init_code::PIERCE_FIREWALL => Ok(InitMessage::PierceFirewall { token: r.u32()? }), + init_code::PEER_INIT => Ok(InitMessage::PeerInit { + username: r.string()?, + conn_type: r.string()?, + token: r.u32()?, + }), + _ => Err(ProtoError::UnexpectedEof), + } +} + +// ---- peer messages (u32 code) ---- + +/// Ask a peer to send us a file (modern flow; direction=download, size 0). +pub fn transfer_request_download(token: u32, filename: &str) -> Vec { + Writer::new() + .u32(DIRECTION_DOWNLOAD) + .u32(token) + .string(filename) + .u64(0) + .finish_u32(code::TRANSFER_REQUEST) +} + +/// Offer to upload one of our files to a peer (direction=upload). +pub fn transfer_request_upload(token: u32, filename: &str, size: u64) -> Vec { + Writer::new() + .u32(DIRECTION_UPLOAD) + .u32(token) + .string(filename) + .u64(size) + .finish_u32(code::TRANSFER_REQUEST) +} + +/// Accept a peer's upload offer (their TransferRequest with direction=upload). +pub fn transfer_response_allowed(token: u32, size: u64) -> Vec { + Writer::new() + .u32(token) + .u8(1) + .u64(size) + .finish_u32(code::TRANSFER_RESPONSE) +} + +pub fn transfer_response_rejected(token: u32, reason: &str) -> Vec { + Writer::new() + .u32(token) + .u8(0) + .string(reason) + .finish_u32(code::TRANSFER_RESPONSE) +} + +/// Legacy enqueue message; some clients expect it before TransferRequest. +pub fn queue_download(filename: &str) -> Vec { + Writer::new() + .string(filename) + .finish_u32(code::QUEUE_DOWNLOAD) +} + +pub fn place_in_queue_request(filename: &str) -> Vec { + Writer::new() + .string(filename) + .finish_u32(code::PLACE_IN_QUEUE_REQUEST) +} + +pub fn place_in_queue_response(filename: &str, place: u32) -> Vec { + Writer::new() + .string(filename) + .u32(place) + .finish_u32(code::PLACE_IN_QUEUE_RESPONSE) +} + +pub fn upload_denied(filename: &str, reason: &str) -> Vec { + Writer::new() + .string(filename) + .string(reason) + .finish_u32(code::UPLOAD_DENIED) +} + +// ---- responder-side encoders (we answer other peers' requests) ---- + +/// A search reply we send to a searcher (code 9, zlib-compressed). +pub fn search_response( + username: &str, + token: u32, + files: &[File], + has_free_upload_slot: bool, + upload_speed: u32, + queue_length: u32, +) -> Vec { + let mut w = Writer::new() + .string(username) + .u32(token) + .u32(files.len() as u32); + for f in files { + w = f.write(w); + } + w = w + .bool(has_free_upload_slot) + .u32(upload_speed) + .u32(queue_length) + .u32(0) // unknown, for compatibility + .u32(0); // locked file count + w.finish_u32_compressed(code::SEARCH_RESPONSE) +} + +/// Our full shared directory listing (code 5, zlib-compressed). +pub fn browse_response(dirs: &[Directory]) -> Vec { + let mut w = Writer::new().u32(dirs.len() as u32); + for d in dirs { + w = d.write(w); + } + w = w.u32(0).u32(0); // unknown + locked directory count + w.finish_u32_compressed(code::BROWSE_RESPONSE) +} + +/// Contents of a single requested folder (code 37, zlib-compressed). +pub fn folder_contents_response(token: u32, folder: &str, dir: &Directory) -> Vec { + let w = Writer::new().u32(token).string(folder).u32(1); + let w = dir.write(w); + w.finish_u32_compressed(code::FOLDER_CONTENTS_RESPONSE) +} + +/// Our user-info card (code 16). +pub fn user_info_response( + description: &str, + upload_slots: u32, + queue_length: u32, + has_free_upload_slot: bool, +) -> Vec { + Writer::new() + .string(description) + .u8(0) // has picture = false + .u32(upload_slots) + .u32(queue_length) + .bool(has_free_upload_slot) + .finish_u32(code::USER_INFO_RESPONSE) +} + +#[derive(Debug, Clone)] +pub enum PeerEvent { + SearchResponse(SearchResponse), + /// Peer is ready to send us a file (direction=upload) or wants one + /// from us (direction=download → an upload request we service). + TransferRequest { + direction: u32, + token: u32, + filename: String, + size: u64, + }, + /// Answer to our TransferRequest. + TransferResponse { + token: u32, + allowed: bool, + size: u64, + message: String, + }, + PlaceInQueueResponse { + filename: String, + place: u32, + }, + UploadDenied { + filename: String, + reason: String, + }, + UploadFailed { + filename: String, + }, + // Requests other peers make of us (M5 responder): + BrowseRequest, + UserInfoRequest, + FolderContentsRequest { + token: u32, + folder: String, + }, + /// Peer asks us to queue an upload of one of our files to them. + QueueDownload { + filename: String, + }, + PlaceInQueueRequest { + filename: String, + }, + Unhandled { + code: u32, + }, +} + +pub fn decode(code: u32, payload: Bytes) -> Result { + // Search responses carry a zlib payload; handle before Reader dispatch. + if code == code::SEARCH_RESPONSE { + return Ok(PeerEvent::SearchResponse(decode_search_response(payload)?)); + } + let mut r = Reader::new(payload); + Ok(match code { + code::TRANSFER_REQUEST => { + let direction = r.u32()?; + let token = r.u32()?; + let filename = r.string()?; + let size = if r.remaining() >= 8 { r.u64()? } else { 0 }; + PeerEvent::TransferRequest { + direction, + token, + filename, + size, + } + } + code::TRANSFER_RESPONSE => { + let token = r.u32()?; + let allowed = r.bool()?; + if allowed { + let size = if r.remaining() >= 8 { r.u64()? } else { 0 }; + PeerEvent::TransferResponse { + token, + allowed, + size, + message: String::new(), + } + } else { + let message = r.string().unwrap_or_default(); + PeerEvent::TransferResponse { + token, + allowed, + size: 0, + message, + } + } + } + code::PLACE_IN_QUEUE_RESPONSE => PeerEvent::PlaceInQueueResponse { + filename: r.string()?, + place: r.u32()?, + }, + code::UPLOAD_DENIED => PeerEvent::UploadDenied { + filename: r.string()?, + reason: r.string().unwrap_or_default(), + }, + code::UPLOAD_FAILED => PeerEvent::UploadFailed { + filename: r.string()?, + }, + code::BROWSE_REQUEST => PeerEvent::BrowseRequest, + code::USER_INFO_REQUEST => PeerEvent::UserInfoRequest, + code::FOLDER_CONTENTS_REQUEST => PeerEvent::FolderContentsRequest { + token: r.u32()?, + folder: r.string()?, + }, + code::QUEUE_DOWNLOAD => PeerEvent::QueueDownload { + filename: r.string()?, + }, + code::PLACE_IN_QUEUE_REQUEST => PeerEvent::PlaceInQueueRequest { + filename: r.string()?, + }, + other => PeerEvent::Unhandled { code: other }, + }) +} + +/// Payload is zlib-compressed: username, token, files, slot/speed/queue, +/// optional unknown u32, optional locked file list. +fn decode_search_response(payload: Bytes) -> Result { + let mut r = Reader::new(payload); + r.decompress()?; + + let username = r.string()?; + let token = r.u32()?; + let file_count = r.u32()?.min(100_000); + let mut files = Vec::with_capacity(file_count.min(4096) as usize); + for _ in 0..file_count { + files.push(File::read(&mut r)?); + } + let has_free_upload_slot = r.bool()?; + let upload_speed = r.u32()?; + let queue_length = r.u32()?; + if r.has_more() { + let _unknown = r.u32()?; + } + let mut locked_files = Vec::new(); + if r.has_more() { + let locked_count = r.u32()?.min(100_000); + for _ in 0..locked_count { + locked_files.push(File::read(&mut r)?); + } + } + + Ok(SearchResponse { + username, + token, + files, + has_free_upload_slot, + upload_speed, + queue_length, + locked_files, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::write::ZlibEncoder; + use flate2::Compression; + use std::io::Write as _; + + fn zlib(data: &[u8]) -> Bytes { + let mut enc = ZlibEncoder::new(Vec::new(), Compression::default()); + enc.write_all(data).unwrap(); + Bytes::from(enc.finish().unwrap()) + } + + #[test] + fn init_roundtrip() { + let msg = peer_init("alice", "P", 42); + let init = decode_init(Bytes::copy_from_slice(&msg[4..])).unwrap(); + match init { + InitMessage::PeerInit { + username, + conn_type, + token, + } => { + assert_eq!( + (username.as_str(), conn_type.as_str(), token), + ("alice", "P", 42) + ); + } + other => panic!("wrong init: {other:?}"), + } + + let msg = pierce_firewall(7); + match decode_init(Bytes::copy_from_slice(&msg[4..])).unwrap() { + InitMessage::PierceFirewall { token } => assert_eq!(token, 7), + other => panic!("wrong init: {other:?}"), + } + } + + #[test] + fn search_response_decode() { + // Build the uncompressed body by hand. + let body = Writer::new() + .string("bob") + .u32(555) + .u32(1) + // file entry + .u8(1) + .string("Music\\Artist\\Album\\01 - Song.flac") + .u64(31_337_000) + .string("flac") + .u32(2) + .u32(0) + .u32(1024) // bitrate + .u32(1) + .u32(213) // duration + // trailer + .bool(true) + .u32(500_000) + .u32(0) + .finish_u32(0); + let compressed = zlib(&body[8..]); // strip len+code, compress payload only + + let resp = decode_search_response(compressed).unwrap(); + assert_eq!(resp.username, "bob"); + assert_eq!(resp.token, 555); + assert_eq!(resp.files.len(), 1); + let f = &resp.files[0]; + assert_eq!(f.size, 31_337_000); + assert_eq!(f.bitrate(), Some(1024)); + assert_eq!(f.duration_secs(), Some(213)); + assert!(resp.has_free_upload_slot); + } +} diff --git a/crates/soulseek-proto/src/server.rs b/crates/soulseek-proto/src/server.rs new file mode 100644 index 0000000..887a05e --- /dev/null +++ b/crates/soulseek-proto/src/server.rs @@ -0,0 +1,290 @@ +//! Server messages (`server.slsknet.org:2242`), u32 codes. + +use bytes::Bytes; +use md5::{Digest, Md5}; +use std::net::{Ipv4Addr, SocketAddrV4}; + +use crate::wire::{Reader, Writer}; +use crate::Result; + +/// Client version fields sent at login. 160 is the protocol version spoken +/// by current clients (Nicotine+ uses the same). +pub const VERSION: u32 = 160; +pub const MINOR_VERSION: u32 = 1; + +pub mod code { + pub const LOGIN: u32 = 1; + pub const SET_LISTEN_PORT: u32 = 2; + pub const GET_PEER_ADDRESS: u32 = 3; + pub const WATCH_USER: u32 = 5; + pub const CONNECT_TO_PEER: u32 = 18; + pub const FILE_SEARCH: u32 = 26; + pub const PING: u32 = 32; + pub const SHARED_FOLDERS_AND_FILES: u32 = 35; + pub const HAVE_NO_PARENTS: u32 = 71; + pub const PARENT_MIN_SPEED: u32 = 83; + pub const PARENT_SPEED_RATIO: u32 = 84; + pub const EMBEDDED_MESSAGE: u32 = 93; // wraps a distributed search + pub const ACCEPT_CHILDREN: u32 = 100; + pub const NET_INFO: u32 = 102; + pub const WISHLIST_INTERVAL: u32 = 104; + pub const BRANCH_LEVEL: u32 = 126; + pub const BRANCH_ROOT: u32 = 127; + pub const EXCLUDED_SEARCH_PHRASES: u32 = 160; + pub const CANNOT_CONNECT: u32 = 1001; +} + +// ---- outgoing ---- + +pub fn login(username: &str, password: &str) -> Vec { + let hash = hex(Md5::digest(format!("{username}{password}").as_bytes())); + Writer::new() + .string(username) + .string(password) + .u32(VERSION) + .string(&hash) + .u32(MINOR_VERSION) + .finish_u32(code::LOGIN) +} + +pub fn set_listen_port(port: u32) -> Vec { + Writer::new().u32(port).finish_u32(code::SET_LISTEN_PORT) +} + +pub fn file_search(token: u32, query: &str) -> Vec { + Writer::new() + .u32(token) + .string(query) + .finish_u32(code::FILE_SEARCH) +} + +pub fn shared_folders_and_files(dirs: u32, files: u32) -> Vec { + Writer::new() + .u32(dirs) + .u32(files) + .finish_u32(code::SHARED_FOLDERS_AND_FILES) +} + +/// Tell the server whether we need a distributed parent. +pub fn accept_children(accept: bool) -> Vec { + Writer::new().bool(accept).finish_u32(code::ACCEPT_CHILDREN) +} + +pub fn branch_level(level: u32) -> Vec { + Writer::new().u32(level).finish_u32(code::BRANCH_LEVEL) +} + +pub fn branch_root(root: &str) -> Vec { + Writer::new().string(root).finish_u32(code::BRANCH_ROOT) +} + +pub fn ping() -> Vec { + Writer::new().finish_u32(code::PING) +} + +pub fn get_peer_address(username: &str) -> Vec { + Writer::new() + .string(username) + .finish_u32(code::GET_PEER_ADDRESS) +} + +/// Ask the server to have `username` connect to us (indirect connection). +pub fn connect_to_peer(token: u32, username: &str, conn_type: &str) -> Vec { + Writer::new() + .u32(token) + .string(username) + .string(conn_type) + .finish_u32(code::CONNECT_TO_PEER) +} + +pub fn have_no_parents(value: bool) -> Vec { + Writer::new().bool(value).finish_u32(code::HAVE_NO_PARENTS) +} + +// ---- incoming ---- + +#[derive(Debug, Clone)] +pub enum ServerEvent { + LoginOk { + greeting: String, + own_ip: Ipv4Addr, + is_supporter: bool, + }, + LoginFailed { + reason: String, + }, + /// Another client wants a connection with us (or is answering our + /// indirect request): connect to them and send PierceFirewall(token). + ConnectToPeer { + username: String, + conn_type: String, + addr: SocketAddrV4, + token: u32, + privileged: bool, + }, + /// Our indirect connection request to `token` failed. + CannotConnect { + token: u32, + username: Option, + }, + /// Answer to GetPeerAddress. A 0.0.0.0/port-0 address means offline. + UserAddress { + username: String, + addr: SocketAddrV4, + }, + ExcludedSearchPhrases(Vec), + /// Candidate distributed parents to connect to. + NetInfo(Vec<(String, SocketAddrV4)>), + /// A search relayed directly by the server (older path). + ServerSearch { + username: String, + token: u32, + query: String, + }, + /// A distributed message wrapped by the server (code 93). The inner + /// bytes are a distributed frame starting with its code byte — decode + /// with `dist::decode`. + Embedded(Bytes), + Unhandled { + code: u32, + }, +} + +pub fn decode(code: u32, payload: Bytes) -> Result { + let mut r = Reader::new(payload); + Ok(match code { + code::LOGIN => { + if r.bool()? { + let greeting = r.string()?; + let own_ip = r.ipv4()?; + // Trailing hash + supporter flag are absent on some servers. + let _hash = r.string().unwrap_or_default(); + let is_supporter = r.bool().unwrap_or(false); + ServerEvent::LoginOk { + greeting, + own_ip, + is_supporter, + } + } else { + ServerEvent::LoginFailed { + reason: r.string()?, + } + } + } + code::CONNECT_TO_PEER => { + let username = r.string()?; + let conn_type = r.string()?; + let ip = r.ipv4()?; + let port = r.u32()?; + let token = r.u32()?; + let privileged = r.bool().unwrap_or(false); + ServerEvent::ConnectToPeer { + username, + conn_type, + addr: SocketAddrV4::new(ip, port as u16), + token, + privileged, + } + } + code::GET_PEER_ADDRESS => { + let username = r.string()?; + let ip = r.ipv4()?; + let port = r.u32()?; + // Trailing obfuscation fields ignored. + ServerEvent::UserAddress { + username, + addr: SocketAddrV4::new(ip, port as u16), + } + } + code::CANNOT_CONNECT => { + let token = r.u32()?; + let username = r.string().ok(); + ServerEvent::CannotConnect { token, username } + } + code::NET_INFO => { + let count = r.u32()?.min(1000); + let mut parents = Vec::with_capacity(count as usize); + for _ in 0..count { + let username = r.string()?; + let ip = r.ipv4()?; + let port = r.u32()?; + parents.push((username, SocketAddrV4::new(ip, port as u16))); + } + ServerEvent::NetInfo(parents) + } + code::FILE_SEARCH => { + // Server-relayed search (same code we send): username, token, query. + let username = r.string()?; + let token = r.u32()?; + let query = r.string()?; + ServerEvent::ServerSearch { + username, + token, + query, + } + } + code::EMBEDDED_MESSAGE => ServerEvent::Embedded(r.rest()), + code::EXCLUDED_SEARCH_PHRASES => { + let count = r.u32()?.min(10_000); + let mut phrases = Vec::with_capacity(count as usize); + for _ in 0..count { + phrases.push(r.string()?); + } + ServerEvent::ExcludedSearchPhrases(phrases) + } + other => ServerEvent::Unhandled { code: other }, + }) +} + +fn hex(digest: impl AsRef<[u8]>) -> String { + digest.as_ref().iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn login_message_layout() { + let msg = login("user", "pass"); + let mut r = Reader::new(Bytes::copy_from_slice(&msg[8..])); + assert_eq!(&msg[4..8], &1u32.to_le_bytes()); + assert_eq!(r.string().unwrap(), "user"); + assert_eq!(r.string().unwrap(), "pass"); + assert_eq!(r.u32().unwrap(), VERSION); + // md5("userpass") + assert_eq!(r.string().unwrap(), "63e780c3f321d13109c71bf81805476e"); + assert_eq!(r.u32().unwrap(), MINOR_VERSION); + } + + #[test] + fn decode_connect_to_peer() { + let payload = Writer::new() + .string("bob") + .string("P") + .u8(4) + .u8(3) + .u8(2) + .u8(1) // 1.2.3.4 reversed on wire + .u32(2234) + .u32(99) + .bool(false) + .finish_u32(18); + let ev = decode(18, Bytes::copy_from_slice(&payload[8..])).unwrap(); + match ev { + ServerEvent::ConnectToPeer { + username, + conn_type, + addr, + token, + .. + } => { + assert_eq!(username, "bob"); + assert_eq!(conn_type, "P"); + assert_eq!(addr, "1.2.3.4:2234".parse().unwrap()); + assert_eq!(token, 99); + } + other => panic!("wrong event: {other:?}"), + } + } +} diff --git a/crates/soulseek-proto/src/types.rs b/crates/soulseek-proto/src/types.rs new file mode 100644 index 0000000..0439861 --- /dev/null +++ b/crates/soulseek-proto/src/types.rs @@ -0,0 +1,114 @@ +//! Shared domain types used across peer and server messages. + +use crate::wire::{Reader, Writer}; +use crate::Result; + +/// File attribute types (Soulseek.NET `FileAttributeType`). +pub const ATTR_BITRATE: u32 = 0; +pub const ATTR_DURATION: u32 = 1; +pub const ATTR_VBR: u32 = 2; +pub const ATTR_SAMPLE_RATE: u32 = 4; +pub const ATTR_BIT_DEPTH: u32 = 5; + +#[derive(Debug, Clone)] +pub struct File { + pub filename: String, + pub size: u64, + pub extension: String, + pub attributes: Vec<(u32, u32)>, +} + +impl File { + pub(crate) fn read(r: &mut Reader) -> Result { + let _code = r.u8()?; + let filename = r.string()?; + let mut size = r.u64()?; + // Some clients send a 32-bit size sign-extended to 64 bits. + if size > i64::MAX as u64 && (size >> 32) == 0xFFFF_FFFF { + size &= 0xFFFF_FFFF; + } + let extension = r.string()?; + let attr_count = r.u32()?.min(64); + let mut attributes = Vec::with_capacity(attr_count as usize); + for _ in 0..attr_count { + attributes.push((r.u32()?, r.u32()?)); + } + Ok(File { + filename, + size, + extension, + attributes, + }) + } + + /// Write this file into a search/browse response body. `code` is the + /// leading byte (1 for normal files). + pub fn write(&self, mut w: Writer) -> Writer { + w = w + .u8(1) + .string(&self.filename) + .u64(self.size) + .string(&self.extension); + w = w.u32(self.attributes.len() as u32); + for (ty, val) in &self.attributes { + w = w.u32(*ty).u32(*val); + } + w + } + + fn attr(&self, ty: u32) -> Option { + self.attributes + .iter() + .find(|(t, _)| *t == ty) + .map(|(_, v)| *v) + } + + pub fn bitrate(&self) -> Option { + self.attr(ATTR_BITRATE) + } + + pub fn duration_secs(&self) -> Option { + self.attr(ATTR_DURATION) + } + + pub fn is_vbr(&self) -> bool { + self.attr(ATTR_VBR) == Some(1) + } + + pub fn sample_rate(&self) -> Option { + self.attr(ATTR_SAMPLE_RATE) + } + + pub fn bit_depth(&self) -> Option { + self.attr(ATTR_BIT_DEPTH) + } +} + +#[derive(Debug, Clone)] +pub struct SearchResponse { + pub username: String, + pub token: u32, + pub files: Vec, + pub has_free_upload_slot: bool, + pub upload_speed: u32, + pub queue_length: u32, + pub locked_files: Vec, +} + +/// A shared directory in a browse response: backslash-separated name and +/// its files. +#[derive(Debug, Clone)] +pub struct Directory { + pub name: String, + pub files: Vec, +} + +impl Directory { + pub fn write(&self, mut w: Writer) -> Writer { + w = w.string(&self.name).u32(self.files.len() as u32); + for f in &self.files { + w = f.write(w); + } + w + } +} diff --git a/crates/soulseek-proto/src/wire.rs b/crates/soulseek-proto/src/wire.rs new file mode 100644 index 0000000..7bf1916 --- /dev/null +++ b/crates/soulseek-proto/src/wire.rs @@ -0,0 +1,208 @@ +//! Low-level message building and reading, mirroring Soulseek.NET's +//! `MessageBuilder` / `MessageReader`. + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use flate2::read::ZlibDecoder; +use std::io::Read; + +use crate::{ProtoError, Result}; + +/// Builds one outgoing message. `finish_*` prepends the length header. +pub struct Writer { + buf: BytesMut, +} + +impl Default for Writer { + fn default() -> Self { + Self::new() + } +} + +impl Writer { + pub fn new() -> Self { + Writer { + buf: BytesMut::with_capacity(64), + } + } + + pub fn u8(mut self, v: u8) -> Self { + self.buf.put_u8(v); + self + } + + pub fn u32(mut self, v: u32) -> Self { + self.buf.put_u32_le(v); + self + } + + pub fn u64(mut self, v: u64) -> Self { + self.buf.put_u64_le(v); + self + } + + pub fn bool(self, v: bool) -> Self { + self.u8(v as u8) + } + + pub fn string(mut self, s: &str) -> Self { + self.buf.put_u32_le(s.len() as u32); + self.buf.put_slice(s.as_bytes()); + self + } + + /// Frame with a u32 code: `[len][code:u32][payload]`. + pub fn finish_u32(self, code: u32) -> Vec { + let mut out = Vec::with_capacity(self.buf.len() + 8); + out.extend_from_slice(&((self.buf.len() as u32 + 4).to_le_bytes())); + out.extend_from_slice(&code.to_le_bytes()); + out.extend_from_slice(&self.buf); + out + } + + /// Frame with a u32 code and a zlib-compressed payload: + /// `[len][code:u32][zlib(payload)]` (search responses, browse responses). + pub fn finish_u32_compressed(self, code: u32) -> Vec { + use flate2::write::ZlibEncoder; + use flate2::Compression; + use std::io::Write; + + let mut enc = ZlibEncoder::new(Vec::new(), Compression::default()); + enc.write_all(&self.buf).expect("zlib write"); + let compressed = enc.finish().expect("zlib finish"); + + let mut out = Vec::with_capacity(compressed.len() + 8); + out.extend_from_slice(&((compressed.len() as u32 + 4).to_le_bytes())); + out.extend_from_slice(&code.to_le_bytes()); + out.extend_from_slice(&compressed); + out + } + + /// Frame with a single-byte code: `[len][code:u8][payload]` + /// (PeerInit / PierceFirewall only). + pub fn finish_u8(self, code: u8) -> Vec { + let mut out = Vec::with_capacity(self.buf.len() + 5); + out.extend_from_slice(&((self.buf.len() as u32 + 1).to_le_bytes())); + out.push(code); + out.extend_from_slice(&self.buf); + out + } +} + +/// Reads one message payload (the bytes after the code). +pub struct Reader { + buf: Bytes, +} + +impl Reader { + pub fn new(payload: Bytes) -> Self { + Reader { buf: payload } + } + + pub fn remaining(&self) -> usize { + self.buf.remaining() + } + + pub fn has_more(&self) -> bool { + self.buf.has_remaining() + } + + /// Take all remaining bytes. + pub fn rest(&mut self) -> Bytes { + let n = self.buf.remaining(); + self.buf.copy_to_bytes(n) + } + + pub fn u8(&mut self) -> Result { + if self.buf.remaining() < 1 { + return Err(ProtoError::UnexpectedEof); + } + Ok(self.buf.get_u8()) + } + + pub fn bool(&mut self) -> Result { + Ok(self.u8()? != 0) + } + + pub fn u32(&mut self) -> Result { + if self.buf.remaining() < 4 { + return Err(ProtoError::UnexpectedEof); + } + Ok(self.buf.get_u32_le()) + } + + pub fn u64(&mut self) -> Result { + if self.buf.remaining() < 8 { + return Err(ProtoError::UnexpectedEof); + } + Ok(self.buf.get_u64_le()) + } + + /// IPv4 address; wire order is reversed relative to display order. + pub fn ipv4(&mut self) -> Result { + if self.buf.remaining() < 4 { + return Err(ProtoError::UnexpectedEof); + } + let mut b = [0u8; 4]; + self.buf.copy_to_slice(&mut b); + Ok(std::net::Ipv4Addr::new(b[3], b[2], b[1], b[0])) + } + + /// UTF-8 with Latin-1 fallback, matching what real-world peers send. + pub fn string(&mut self) -> Result { + let len = self.u32()? as usize; + if self.buf.remaining() < len { + return Err(ProtoError::UnexpectedEof); + } + let bytes = self.buf.copy_to_bytes(len); + Ok(match std::str::from_utf8(&bytes) { + Ok(s) => s.to_owned(), + Err(_) => bytes.iter().map(|&b| b as char).collect(), + }) + } + + /// Replace the remaining payload with its zlib-decompressed form. + pub fn decompress(&mut self) -> Result<()> { + let mut out = Vec::new(); + ZlibDecoder::new(&self.buf[..]).read_to_end(&mut out)?; + if out.len() > crate::MAX_MESSAGE_SIZE { + return Err(ProtoError::TooLarge(out.len())); + } + self.buf = Bytes::from(out); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_primitives() { + let msg = Writer::new().u32(7).string("héllo").u64(9).finish_u32(26); + // len = 4 (code) + 4 + 4+6 + 8 = 26 + assert_eq!(&msg[0..4], &26u32.to_le_bytes()); + assert_eq!(&msg[4..8], &26u32.to_le_bytes()); + + let mut r = Reader::new(Bytes::copy_from_slice(&msg[8..])); + assert_eq!(r.u32().unwrap(), 7); + assert_eq!(r.string().unwrap(), "héllo"); + assert_eq!(r.u64().unwrap(), 9); + assert!(!r.has_more()); + } + + #[test] + fn u8_code_frame() { + let msg = Writer::new().u32(123).finish_u8(0); + assert_eq!(&msg[0..4], &5u32.to_le_bytes()); + assert_eq!(msg[4], 0); + } + + #[test] + fn latin1_fallback() { + let mut raw = Vec::new(); + raw.extend_from_slice(&2u32.to_le_bytes()); + raw.extend_from_slice(&[0xE9, 0x21]); // "é!" in Latin-1, invalid UTF-8 + let mut r = Reader::new(Bytes::from(raw)); + assert_eq!(r.string().unwrap(), "é!"); + } +} diff --git a/crates/tagger/Cargo.toml b/crates/tagger/Cargo.toml new file mode 100644 index 0000000..de5b55c --- /dev/null +++ b/crates/tagger/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "tagger" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +lofty = "0.22" +thiserror.workspace = true +log.workspace = true diff --git a/crates/tagger/examples/read.rs b/crates/tagger/examples/read.rs new file mode 100644 index 0000000..4fa155d --- /dev/null +++ b/crates/tagger/examples/read.rs @@ -0,0 +1,24 @@ +//! Read tags from every audio file in a folder and print the outcome. +//! cargo run -p tagger --example read -- "C:\folder" + +fn main() { + let dir = std::env::args().nth(1).expect("usage: read "); + for entry in std::fs::read_dir(&dir).expect("read_dir").flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + match tagger::read_summary(&path) { + Ok(s) => println!( + "OK {} | artist={:?} album={:?} title={:?} track={:?} dur={:?}", + path.file_name().unwrap().to_string_lossy(), + s.artist, + s.album, + s.title, + s.track_no, + s.duration_secs + ), + Err(e) => println!("ERR {} | {e}", path.file_name().unwrap().to_string_lossy()), + } + } +} diff --git a/crates/tagger/src/lib.rs b/crates/tagger/src/lib.rs new file mode 100644 index 0000000..924caa4 --- /dev/null +++ b/crates/tagger/src/lib.rs @@ -0,0 +1,261 @@ +//! Tag reading/writing and album-art embedding via lofty. + +use std::path::Path; + +use lofty::config::WriteOptions; +use lofty::file::{AudioFile, TaggedFileExt}; +use lofty::picture::{MimeType, Picture, PictureType}; +use lofty::probe::Probe; +use lofty::tag::{Accessor, Tag}; + +#[derive(Debug, thiserror::Error)] +pub enum TagError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("lofty: {0}")] + Lofty(#[from] lofty::error::LoftyError), +} + +pub type Result = std::result::Result; + +/// What we know about a file from its embedded tags + audio properties. +#[derive(Debug, Clone, Default)] +pub struct TagSummary { + pub artist: Option, + pub album_artist: Option, + pub album: Option, + pub title: Option, + pub track_no: Option, + pub year: Option, + pub duration_secs: Option, + pub bitrate_kbps: Option, + pub sample_rate: Option, + pub bit_depth: Option, + pub has_art: bool, +} + +pub fn read_summary(path: &Path) -> Result { + let tagged = Probe::open(path)?.read()?; + let props = tagged.properties(); + let mut summary = TagSummary { + duration_secs: Some(props.duration().as_secs() as u32), + bitrate_kbps: props.audio_bitrate(), + sample_rate: props.sample_rate(), + bit_depth: props.bit_depth().map(|d| d as u32), + ..Default::default() + }; + + if let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) { + summary.artist = non_empty(tag.artist().as_deref()); + summary.album = non_empty(tag.album().as_deref()); + summary.title = non_empty(tag.title().as_deref()); + summary.track_no = tag.track(); + summary.year = tag.year(); + summary.album_artist = non_empty( + tag.get_string(&lofty::tag::ItemKey::AlbumArtist) + .map(|s| s.to_string()) + .as_deref(), + ); + summary.has_art = !tag.pictures().is_empty(); + } + Ok(summary) +} + +/// Canonical values to write. `None` fields are left untouched. +#[derive(Debug, Clone, Default)] +pub struct TagWrite { + pub artist: Option, + pub album_artist: Option, + pub album: Option, + pub title: Option, + pub track_no: Option, + pub year: Option, +} + +/// Cover bytes come from several providers; PNG originals exist (Cover Art +/// Archive scans), so the embedded mime is sniffed rather than assumed. +fn sniff_mime(bytes: &[u8]) -> MimeType { + if bytes.starts_with(b"\x89PNG") { + MimeType::Png + } else { + MimeType::Jpeg + } +} + +/// Write tags and (optionally) embed front-cover art. With `replace_art` +/// false the cover is only added when the file has none; true drops whatever +/// pictures the file arrived with first, so the canonical cover always wins. +pub fn write_tags( + path: &Path, + write: &TagWrite, + art_jpeg: Option<&[u8]>, + replace_art: bool, +) -> Result<()> { + let mut tagged = Probe::open(path)?.read()?; + + if tagged.primary_tag().is_none() && tagged.first_tag().is_none() { + let tag_type = tagged.primary_tag_type(); + tagged.insert_tag(Tag::new(tag_type)); + } + let tag = tagged.primary_tag_mut().expect("tag inserted above"); + + if let Some(v) = &write.artist { + tag.set_artist(v.clone()); + } + if let Some(v) = &write.album { + tag.set_album(v.clone()); + } + if let Some(v) = &write.title { + tag.set_title(v.clone()); + } + if let Some(v) = write.track_no { + tag.set_track(v); + } + if let Some(v) = write.year { + tag.set_year(v); + } + if let Some(v) = &write.album_artist { + tag.insert_text(lofty::tag::ItemKey::AlbumArtist, v.clone()); + } + + if let Some(bytes) = art_jpeg { + if replace_art { + while !tag.pictures().is_empty() { + tag.remove_picture(0); + } + } + if tag.pictures().is_empty() { + let picture = Picture::new_unchecked( + PictureType::CoverFront, + Some(sniff_mime(bytes)), + None, + bytes.to_vec(), + ); + tag.push_picture(picture); + } + } + + tagged.save_to_path(path, WriteOptions::default())?; + Ok(()) +} + +/// Replace any existing embedded pictures with the given front cover. +pub fn replace_art(path: &Path, art_jpeg: &[u8]) -> Result<()> { + let mut tagged = Probe::open(path)?.read()?; + if tagged.primary_tag().is_none() && tagged.first_tag().is_none() { + let tag_type = tagged.primary_tag_type(); + tagged.insert_tag(Tag::new(tag_type)); + } + let tag = tagged.primary_tag_mut().expect("tag inserted above"); + while !tag.pictures().is_empty() { + tag.remove_picture(0); + } + tag.push_picture(Picture::new_unchecked( + PictureType::CoverFront, + Some(sniff_mime(art_jpeg)), + None, + art_jpeg.to_vec(), + )); + tagged.save_to_path(path, WriteOptions::default())?; + Ok(()) +} + +fn non_empty(s: Option<&str>) -> Option { + s.map(str::trim).filter(|s| !s.is_empty()).map(String::from) +} + +/// Best-effort metadata parse from a Soulseek path when tags are missing. +/// Handles `Artist - Album (Year)` folders and `NN - Title` / `NN Title` +/// filenames. +pub fn parse_from_path(remote_or_local_path: &str) -> TagWrite { + let parts: Vec<&str> = remote_or_local_path.split(['\\', '/']).collect(); + let file = parts.last().copied().unwrap_or_default(); + let folder = parts + .len() + .checked_sub(2) + .and_then(|i| parts.get(i)) + .copied() + .unwrap_or(""); + + let stem = file.rsplit_once('.').map(|(s, _)| s).unwrap_or(file); + let mut write = TagWrite::default(); + + // "01 - Title", "01. Title", "01 Title" + let trimmed = stem.trim(); + let (track, title) = match trimmed.split_once(|c: char| !c.is_ascii_digit()) { + Some((digits, rest)) if !digits.is_empty() && digits.len() <= 3 => { + let title = rest.trim_start_matches(['-', '.', ' ', '_']).trim(); + ( + digits.parse::().ok(), + if title.is_empty() { trimmed } else { title }, + ) + } + _ => (None, trimmed), + }; + write.track_no = track; + write.title = Some(title.to_string()); + + // Folder: "Artist - Album (1997)" | "Album (1997)" | "1997 - Album" + let mut folder = folder.trim().to_string(); + if let Some(idx) = folder.rfind('(') { + let inner: String = folder[idx + 1..] + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + if inner.len() == 4 { + write.year = inner.parse().ok(); + folder = folder[..idx].trim().to_string(); + } + } + if write.year.is_none() { + let lead: String = folder.chars().take_while(|c| c.is_ascii_digit()).collect(); + if lead.len() == 4 { + write.year = lead.parse().ok(); + folder = folder[lead.len()..] + .trim_start_matches(['-', '.', ' ']) + .to_string(); + } + } + if let Some((artist, album)) = folder.split_once(" - ") { + write.artist = Some(artist.trim().to_string()); + write.album = Some(album.trim().to_string()); + } else if !folder.is_empty() { + write.album = Some(folder); + } + write +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_full_convention() { + let w = parse_from_path( + r"Music\Radiohead - OK Computer (1997)\03 - Subterranean Homesick Alien.flac", + ); + assert_eq!(w.artist.as_deref(), Some("Radiohead")); + assert_eq!(w.album.as_deref(), Some("OK Computer")); + assert_eq!(w.year, Some(1997)); + assert_eq!(w.track_no, Some(3)); + assert_eq!(w.title.as_deref(), Some("Subterranean Homesick Alien")); + } + + #[test] + fn parse_year_leading_folder() { + let w = parse_from_path(r"share\1997 - OK Computer\01 Airbag.flac"); + assert_eq!(w.album.as_deref(), Some("OK Computer")); + assert_eq!(w.year, Some(1997)); + assert_eq!(w.track_no, Some(1)); + assert_eq!(w.title.as_deref(), Some("Airbag")); + } + + #[test] + fn parse_no_track_number() { + let w = parse_from_path(r"stuff\Nevermind (1991)\Lithium.flac"); + assert_eq!(w.title.as_deref(), Some("Lithium")); + assert_eq!(w.track_no, None); + assert_eq!(w.album.as_deref(), Some("Nevermind")); + assert_eq!(w.year, Some(1991)); + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..7725b56 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + SoulStream + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f529f09 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1701 @@ +{ + "name": "soulseekplus", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "soulseekplus", + "version": "0.1.0", + "license": "ISC", + "dependencies": { + "@tauri-apps/api": "^2" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5", + "@tauri-apps/cli": "^2.11.2", + "svelte": "^5", + "vite": "^6" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..eed9337 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "soulstream", + "version": "0.1.0", + "type": "module", + "description": "SoulStream — music player, downloader and streamer for the Soulseek network", + "scripts": { + "dev": "vite", + "build": "vite build", + "tauri": "tauri" + }, + "license": "ISC", + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5", + "@tauri-apps/cli": "^2.11.2", + "svelte": "^5", + "vite": "^6" + }, + "dependencies": { + "@tauri-apps/api": "^2" + } +} diff --git a/public/assets/fonts/inter-400.woff2 b/public/assets/fonts/inter-400.woff2 new file mode 100644 index 0000000..f15b025 Binary files /dev/null and b/public/assets/fonts/inter-400.woff2 differ diff --git a/public/assets/fonts/inter-600.woff2 b/public/assets/fonts/inter-600.woff2 new file mode 100644 index 0000000..d189794 Binary files /dev/null and b/public/assets/fonts/inter-600.woff2 differ diff --git a/public/assets/fonts/inter-700.woff2 b/public/assets/fonts/inter-700.woff2 new file mode 100644 index 0000000..a68fb10 Binary files /dev/null and b/public/assets/fonts/inter-700.woff2 differ diff --git a/public/assets/fonts/lato-400.woff2 b/public/assets/fonts/lato-400.woff2 new file mode 100644 index 0000000..ff60934 Binary files /dev/null and b/public/assets/fonts/lato-400.woff2 differ diff --git a/public/assets/fonts/lato-700.woff2 b/public/assets/fonts/lato-700.woff2 new file mode 100644 index 0000000..11de83f Binary files /dev/null and b/public/assets/fonts/lato-700.woff2 differ diff --git a/public/assets/fonts/nunito-400.woff2 b/public/assets/fonts/nunito-400.woff2 new file mode 100644 index 0000000..8c68e57 Binary files /dev/null and b/public/assets/fonts/nunito-400.woff2 differ diff --git a/public/assets/fonts/nunito-600.woff2 b/public/assets/fonts/nunito-600.woff2 new file mode 100644 index 0000000..dac52ff Binary files /dev/null and b/public/assets/fonts/nunito-600.woff2 differ diff --git a/public/assets/fonts/nunito-700.woff2 b/public/assets/fonts/nunito-700.woff2 new file mode 100644 index 0000000..5b423ba Binary files /dev/null and b/public/assets/fonts/nunito-700.woff2 differ diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..b88d0cd Binary files /dev/null and b/public/logo.png differ diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..e3655fe --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "app" +version.workspace = true +description = "SoulStream — music player, downloader and streamer for the Soulseek network" +authors.workspace = true +edition.workspace = true +rust-version.workspace = true + +[lib] +name = "app_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +tauri-plugin-log = "2" +tauri-plugin-dialog = "2" +serde.workspace = true +serde_json.workspace = true +log.workspace = true +image.workspace = true +tokio.workspace = true + +base64 = "0.22" +keyring = { version = "3", features = ["windows-native"] } +soulseek-proto = { path = "../crates/soulseek-proto" } +soulseek-client = { path = "../crates/soulseek-client" } +player = { path = "../crates/player" } +tagger = { path = "../crates/tagger" } +metadata = { path = "../crates/metadata" } +library = { path = "../crates/library" } + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.61.3", features = [ + "AI_MachineLearning", + "Foundation_Collections", + "Globalization", + "Graphics_Imaging", + "Media_FaceAnalysis", + "Media_Ocr", + "Storage_Streams", + "Win32_System_WinRT", +] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..88063b8 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,21 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "enables the default permissions", + "windows": [ + "main" + ], + "permissions": [ + "core:default", + "core:window:allow-minimize", + "core:window:allow-maximize", + "core:window:allow-unmaximize", + "core:window:allow-toggle-maximize", + "core:window:allow-close", + "core:window:allow-start-dragging", + "core:window:allow-is-maximized", + "core:window:allow-show", + "core:window:allow-set-focus", + "dialog:allow-open" + ] +} diff --git a/src-tauri/icon-source.svg b/src-tauri/icon-source.svg new file mode 100644 index 0000000..ba9547f --- /dev/null +++ b/src-tauri/icon-source.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..b88d0cd Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..2e05959 Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..b6606f3 Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..0cb178e Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..1eec670 Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..d8c10a9 Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..cc93276 Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..c7cd8dc Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..1828d33 Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..7ab06df Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..576f477 Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..eb4e3d2 Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..290c53c Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..9906637 Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..bb6bae2 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..1e1ab70 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..6a657ae Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..426c752 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..5531a82 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..b22597c Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..963f51f Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e00b7ba Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..9c6f779 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d26683d Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8d6e68f Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b0c63e4 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..dda8f96 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..10fd3be Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..07f43b4 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000..8199638 Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..808e425 Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000..efda224 Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/icon.svg b/src-tauri/icons/icon.svg new file mode 100644 index 0000000..8cf10b5 --- /dev/null +++ b/src-tauri/icons/icon.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..d3e7bc4 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..9fd0964 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..9fd0964 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..f85ace9 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..f867699 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..af83977 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..af83977 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..c7dba33 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..9fd0964 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..e42a2ca Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..e42a2ca Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..67e4ce2 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..b07b433 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..67e4ce2 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..4173409 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..9564765 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..c4ef184 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..b419f23 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/resources/FidelityFX-LICENSE.txt b/src-tauri/resources/FidelityFX-LICENSE.txt new file mode 100644 index 0000000..324cba5 --- /dev/null +++ b/src-tauri/resources/FidelityFX-LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2021 Advanced Micro Devices, Inc. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src-tauri/resources/FidelityFX_CLI.exe b/src-tauri/resources/FidelityFX_CLI.exe new file mode 100644 index 0000000..166bd2e Binary files /dev/null and b/src-tauri/resources/FidelityFX_CLI.exe differ diff --git a/src-tauri/resources/YOLOX-LICENSE.txt b/src-tauri/resources/YOLOX-LICENSE.txt new file mode 100644 index 0000000..1d4dc76 --- /dev/null +++ b/src-tauri/resources/YOLOX-LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright (c) 2021-2022 Megvii Inc. All rights reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src-tauri/resources/YOLOX-MODEL-NOTICE.txt b/src-tauri/resources/YOLOX-MODEL-NOTICE.txt new file mode 100644 index 0000000..bf28369 --- /dev/null +++ b/src-tauri/resources/YOLOX-MODEL-NOTICE.txt @@ -0,0 +1,11 @@ +YOLOX-Nano artist-hero person detector + +Source: +https://github.com/Megvii-BaseDetection/YOLOX/releases/download/0.1.1rc0/yolox_nano.onnx + +Bundled file: yolox_nano.onnx +Size: 3,659,407 bytes +SHA-256: C789161ED43C8269FCD4E67C67EEEB4E80C622DA2EB296A20BC6007BD18A0B7D + +YOLOX is Copyright (c) Megvii, Inc. and its affiliates and is distributed +under the Apache License, Version 2.0. See YOLOX-LICENSE.txt. diff --git a/src-tauri/resources/yolox_nano.onnx b/src-tauri/resources/yolox_nano.onnx new file mode 100644 index 0000000..03adac0 Binary files /dev/null and b/src-tauri/resources/yolox_nano.onnx differ diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..6a1ed0c --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,3893 @@ +use serde::{Deserialize, Serialize}; +use tauri::{Emitter, Manager, State}; + +use soulseek_client::{Client, Config, DownloadState, DownloadUpdate, Event}; +use soulseek_proto::types::SearchResponse; + +use crate::config; +use crate::hero_crop::{maximal_hero_rect, plan_hero_crop, HeroCropPlan}; +use crate::hero_detector::detect_subjects; +use crate::pipeline::{self, LibraryState}; +use crate::playback::{self, PlayerState}; +use crate::state::AppState; + +#[derive(Serialize)] +pub struct AppStatus { + pub version: &'static str, + pub connected_as: Option, +} + +#[tauri::command] +pub async fn app_status(state: State<'_, AppState>) -> Result { + Ok(AppStatus { + version: env!("CARGO_PKG_VERSION"), + connected_as: state.connected_as.lock().await.clone(), + }) +} + +/// The server's excluded-phrase list for query sanitizing; empty until the +/// server sends it after login. Frontend also receives it via the +/// `soulseek:excluded-phrases` event — this command covers a WebView reload +/// that happens after login already delivered the event. +#[tauri::command] +pub async fn excluded_search_phrases(state: State<'_, AppState>) -> Result, String> { + Ok(state.excluded_search_phrases.lock().await.clone()) +} + +#[derive(Serialize)] +pub struct StoredCredentials { + pub username: String, + pub has_password: bool, +} + +#[tauri::command] +pub fn get_credentials(app: tauri::AppHandle) -> StoredCredentials { + let cfg = config::load(&app); + let has_password = config::has_password(&cfg.username); + StoredCredentials { + username: cfg.username, + has_password, + } +} + +/// Settings surfaced to the Settings view. +#[derive(Serialize)] +pub struct Settings { + pub username: String, + pub has_password: bool, + pub download_dir: String, + pub library_layout: String, + pub shared_folders: Vec, + pub listen_port: Option, + pub upload_slots: u32, + pub connected: bool, + pub share_files: u32, + pub audio_output_device: Option, + pub audio_output_devices: Vec, + pub artist_hero_upscaler: config::ArtistHeroUpscaler, + pub replace_embedded_art: bool, + /// 0 = embed the original at full resolution. + pub embed_art_max_edge: u32, + pub write_folder_cover: bool, + pub stream_keep_forever: bool, + pub stream_retention_days: u32, + /// 0 = unlimited. + pub stream_cache_max_mb: u64, + pub stream_source_prefs: config::SourcePrefs, + pub download_source_prefs: config::SourcePrefs, +} + +#[tauri::command] +pub async fn get_settings( + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + let cfg = config::load(&app); + let share_files = state + .client + .lock() + .await + .as_ref() + .map(|c| c.share_counts().1) + .unwrap_or(0); + Ok(Settings { + has_password: config::has_password(&cfg.username), + download_dir: config::download_dir(&app, &cfg) + .to_string_lossy() + .into_owned(), + library_layout: config::library_layout(&cfg).to_string(), + shared_folders: cfg.shared_folders.clone(), + listen_port: cfg.listen_port, + upload_slots: cfg.upload_slots.unwrap_or(2), + connected: state.connected_as.lock().await.is_some(), + share_files, + audio_output_device: cfg.audio_output_device.clone(), + audio_output_devices: player::output_device_names().unwrap_or_default(), + artist_hero_upscaler: cfg.artist_hero_upscaler, + replace_embedded_art: config::replace_embedded_art(&cfg), + embed_art_max_edge: config::embed_art_max_edge(&cfg), + write_folder_cover: config::write_folder_cover(&cfg), + stream_keep_forever: config::stream_keep_forever(&cfg), + stream_retention_days: config::stream_retention_days(&cfg), + stream_cache_max_mb: config::stream_cache_max_mb(&cfg), + stream_source_prefs: cfg.stream_source_prefs, + download_source_prefs: cfg.download_source_prefs, + username: cfg.username, + }) +} + +/// Persist non-secret settings. Some (download dir, port) apply on next +/// connect; shared folders re-index live if connected. +#[tauri::command] +pub async fn set_settings( + app: tauri::AppHandle, + state: State<'_, AppState>, + players: State<'_, PlayerState>, + download_dir: Option, + library_layout: Option, + shared_folders: Option>, + listen_port: Option, + upload_slots: Option, + audio_output_device: Option, + artist_hero_upscaler: Option, + replace_embedded_art: Option, + embed_art_max_edge: Option, + write_folder_cover: Option, + stream_keep_forever: Option, + stream_retention_days: Option, + stream_cache_max_mb: Option, + stream_source_prefs: Option, + download_source_prefs: Option, +) -> Result<(), String> { + let mut cfg = config::load(&app); + if let Some(d) = download_dir { + cfg.download_dir = Some(d); + } + if let Some(layout) = library_layout { + // Separators are allowed: they create subfolders below the library + // root. organize_download sanitizes every rendered segment, so a + // layout (or tag value) cannot escape the root. + cfg.library_layout = Some(layout); + } + if let Some(f) = shared_folders { + cfg.shared_folders = f; + } + if listen_port.is_some() { + cfg.listen_port = listen_port; + } + if let Some(s) = upload_slots { + cfg.upload_slots = Some(s); + } + if let Some(device) = audio_output_device { + let device = match device.trim() { + "" => None, + name => Some(name.to_string()), + }; + // Open first; only persist a choice that can actually be used. + players.player.set_output_device(device.clone())?; + cfg.audio_output_device = device; + } + if let Some(upscaler) = artist_hero_upscaler { + cfg.artist_hero_upscaler = upscaler; + } + if let Some(prefs) = stream_source_prefs { + cfg.stream_source_prefs = prefs; + } + if let Some(prefs) = download_source_prefs { + cfg.download_source_prefs = prefs; + } + if replace_embedded_art.is_some() { + cfg.replace_embedded_art = replace_embedded_art; + } + if embed_art_max_edge.is_some() { + cfg.embed_art_max_edge = embed_art_max_edge; + } + if write_folder_cover.is_some() { + cfg.write_folder_cover = write_folder_cover; + } + let retention_changed = stream_keep_forever.is_some() + || stream_retention_days.is_some() + || stream_cache_max_mb.is_some(); + if stream_keep_forever.is_some() { + cfg.stream_keep_forever = stream_keep_forever; + } + if stream_retention_days.is_some() { + cfg.stream_retention_days = stream_retention_days; + } + if stream_cache_max_mb.is_some() { + cfg.stream_cache_max_mb = stream_cache_max_mb; + } + config::save(&app, &cfg); + + // Tighter limits take effect right away, not at the next stream/startup. + if retention_changed { + let app2 = app.clone(); + tauri::async_runtime::spawn_blocking(move || crate::playback::prune_cache(&app2)); + } + + // Re-index shares live so the change takes effect without reconnecting. + if let Some(client) = state.client.lock().await.as_ref() { + let dl = config::download_dir(&app, &cfg); + let mut folders = vec![dl]; + folders.extend(cfg.shared_folders.iter().map(std::path::PathBuf::from)); + let (fdirs, ffiles) = client.update_shares(folders).await; + let _ = app.emit( + "soulseek:shares", + serde_json::json!({ "folders": fdirs, "files": ffiles }), + ); + } + Ok(()) +} + +/// Forget stored credentials (config + credential manager). +#[tauri::command] +pub fn forget_credentials(app: tauri::AppHandle) -> Result<(), String> { + let mut cfg = config::load(&app); + config::delete_password(&cfg.username); + cfg.username = String::new(); + config::save(&app, &cfg); + Ok(()) +} + +/// Open a native folder picker; returns the chosen path or null. +#[tauri::command] +pub async fn pick_folder(app: tauri::AppHandle) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog().file().pick_folder(move |path| { + let _ = tx.send(path.map(|p| p.to_string())); + }); + Ok(rx.await.unwrap_or(None)) +} + +/// What the frontend sees for one search response, flattened per file +/// grouping done client-side (UI groups by user + folder). +#[derive(Serialize, Clone)] +pub struct SearchResultPayload { + pub token: u32, + pub username: String, + pub has_free_upload_slot: bool, + pub upload_speed: u32, + pub queue_length: u32, + pub files: Vec, +} + +#[derive(Serialize, Clone)] +pub struct FilePayload { + pub path: String, + pub name: String, + pub folder: String, + pub size: u64, + pub extension: String, + pub bitrate: Option, + pub duration: Option, + pub sample_rate: Option, + pub bit_depth: Option, + pub vbr: bool, +} + +/// Broad queries match entire shared folders; a single response can list +/// thousands of files. The UI shows folder groups (500 files per group max), +/// so forwarding more than this per response only burns serialization, +/// IPC and webview memory. +const FILES_PER_RESPONSE_MAX: usize = 100; + +fn to_payload(token: u32, r: &SearchResponse) -> SearchResultPayload { + SearchResultPayload { + token, + username: r.username.clone(), + has_free_upload_slot: r.has_free_upload_slot, + upload_speed: r.upload_speed, + queue_length: r.queue_length, + files: r + .files + .iter() + .take(FILES_PER_RESPONSE_MAX) + .map(|f| { + let (folder, name) = match f.filename.rsplit_once('\\') { + Some((d, n)) => (d.to_string(), n.to_string()), + None => (String::new(), f.filename.clone()), + }; + // Many clients leave the extension attribute empty; + // fall back to the filename suffix. + let extension = if f.extension.is_empty() { + name.rsplit_once('.') + .map(|(_, e)| e.to_string()) + .unwrap_or_default() + } else { + f.extension.clone() + }; + FilePayload { + path: f.filename.clone(), + name, + folder, + size: f.size, + extension: extension.to_lowercase(), + bitrate: f.bitrate(), + duration: f.duration_secs(), + sample_rate: f.sample_rate(), + bit_depth: f.bit_depth(), + vbr: f.is_vbr(), + } + }) + .collect(), + } +} + +#[derive(Serialize, Clone)] +#[serde(tag = "kind", rename_all = "kebab-case")] +enum UiEvent { + Connected { username: String }, + Disconnected { reason: String }, +} + +/// One download's status, as the frontend sees it. +#[derive(Serialize, Clone)] +pub struct DownloadPayload { + pub id: u64, + pub username: String, + pub path: String, + pub name: String, + pub state: String, + pub queue_place: Option, + pub local_path: Option, + pub error: Option, + pub received: u64, + pub total: u64, + pub speed_bps: u64, +} + +fn download_payload(u: &DownloadUpdate) -> DownloadPayload { + let name = u + .filename + .rsplit('\\') + .next() + .unwrap_or(&u.filename) + .to_string(); + let (state, queue_place, local_path, error) = match &u.state { + DownloadState::Connecting => ("connecting", None, None, None), + DownloadState::Requested => ("requested", None, None, None), + DownloadState::Queued { place } => ("queued", *place, None, None), + DownloadState::Transferring => ("transferring", None, None, None), + DownloadState::Complete { local_path } => { + ("complete", None, Some(local_path.clone()), None) + } + DownloadState::Failed { reason } => ("failed", None, None, Some(reason.clone())), + DownloadState::Cancelled => ("cancelled", None, None, None), + }; + DownloadPayload { + id: u.id, + username: u.username.clone(), + path: u.filename.clone(), + name, + state: state.into(), + queue_place, + local_path, + error, + received: u.received, + total: u.total, + speed_bps: u.speed_bps, + } +} + +/// Connect using credentials saved in the config file, without exposing +/// the password to the webview. Errors if none are stored. +#[tauri::command] +pub async fn connect_stored( + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + let cfg = config::load(&app); + let password = config::load_password(&cfg.username); + match (cfg.username.is_empty(), password) { + (false, Some(pw)) => connect_and_login(app, state, cfg.username, pw, false).await, + _ => Err("no stored credentials".into()), + } +} + +#[tauri::command] +pub async fn connect_and_login( + app: tauri::AppHandle, + state: State<'_, AppState>, + username: String, + password: String, + remember: bool, +) -> Result { + // Drop any previous session first. + if let Some(old) = state.client.lock().await.take() { + old.shutdown(); + } + *state.connected_as.lock().await = None; + + let stored = config::load(&app); + let mut cfg = Config::default(); + if let Some(port) = stored.listen_port { + cfg.listen_port = port; + } + if let Some(slots) = stored.upload_slots { + cfg.upload_slots = slots; + } + // Configured download target, or the default. + let downloads = config::download_dir(&app, &stored); + cfg.downloads_dir = downloads.clone(); + // Be a good citizen: share everything we've downloaded, plus any extra + // configured folders. + std::fs::create_dir_all(&downloads).ok(); + let mut shares = vec![downloads]; + shares.extend(stored.shared_folders.iter().map(std::path::PathBuf::from)); + cfg.shared_folders = shares; + cfg.description = "Shared with SoulStream".into(); + + // The previous session's listener releases its port a moment after + // shutdown; retry briefly instead of failing with WSAEADDRINUSE. + let (client, mut events) = { + let mut attempt = 0; + loop { + match Client::connect(cfg.clone()).await { + Ok(pair) => break pair, + Err(e) if attempt < 8 && e.to_string().contains("10048") => { + attempt += 1; + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + Err(e) => return Err(format!("could not reach server: {e}")), + } + } + }; + + let greeting = client + .login(&username, &password) + .await + .map_err(|e| e.to_string())?; + + if remember { + let mut save = stored.clone(); + save.username = username.clone(); + config::save(&app, &save); + config::save_password(&username, &password); + } + + // Report share counts to the UI once indexed. + let (folders, files) = client.share_counts(); + let _ = app.emit( + "soulseek:shares", + serde_json::json!({ "folders": folders, "files": files }), + ); + + *state.client.lock().await = Some(client); + *state.connected_as.lock().await = Some(username.clone()); + + // Forward client events to the webview for the life of this session. + let event_app = app.clone(); + let event_user = username.clone(); + tauri::async_runtime::spawn(async move { + let _ = event_app.emit( + "soulseek:state", + UiEvent::Connected { + username: event_user, + }, + ); + + // Search responses arrive in the thousands within seconds on broad + // queries. Emitting each one individually floods the IPC bridge and + // the webview's event loop (UI lockups, eventually OOM), so buffer + // them and flush as one batch per tick. A per-token cap bounds the + // total work a single search can ever create. + const SEARCH_FLUSH_MS: u64 = 150; + const SEARCH_BATCH_MAX: usize = 100; + const SEARCH_RESPONSES_MAX: u32 = 1500; + const SEARCH_FILES_MAX: u32 = 20_000; + let mut search_buf: Vec = Vec::new(); + // token → (responses forwarded, files forwarded) + let mut sent_counts: std::collections::HashMap = + std::collections::HashMap::new(); + let mut flush = tokio::time::interval(std::time::Duration::from_millis(SEARCH_FLUSH_MS)); + flush.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + let ev = tokio::select! { + maybe_ev = events.recv() => match maybe_ev { + Some(ev) => ev, + None => break, + }, + _ = flush.tick() => { + if !search_buf.is_empty() { + let _ = event_app + .emit("soulseek:search-results", std::mem::take(&mut search_buf)); + } + continue; + } + }; + match ev { + Event::SearchResult { token, response } => { + let (responses, files) = sent_counts.entry(token).or_insert((0, 0)); + if *responses >= SEARCH_RESPONSES_MAX || *files >= SEARCH_FILES_MAX { + continue; + } + *responses += 1; + let payload = to_payload(token, &response); + *files += payload.files.len() as u32; + search_buf.push(payload); + if search_buf.len() >= SEARCH_BATCH_MAX { + let _ = event_app + .emit("soulseek:search-results", std::mem::take(&mut search_buf)); + } + } + Event::Download(update) => { + // Next-track preloads are invisible: no player, no + // Downloads view, no library pipeline. On completion, + // tell the frontend so it can append the cached track + // to the sink for a gapless transition. + if let Some(ps) = event_app.try_state::() { + let is_preload = ps.preloads.lock().unwrap().contains_key(&update.id); + if is_preload { + use soulseek_client::DownloadState as DS; + let terminal = matches!( + &update.state, + DS::Complete { .. } | DS::Failed { .. } | DS::Cancelled + ); + if terminal { + let cache = ps.preloads.lock().unwrap().remove(&update.id); + let save_cache = + ps.deferred_saves.lock().unwrap().remove(&update.id); + let _ = event_app.emit( + "soulseek:preload", + serde_json::json!({ + "username": update.username, + "path": update.filename, + "done": matches!(&update.state, DS::Complete { .. }), + }), + ); + if matches!(&update.state, DS::Complete { .. }) { + // Only explicit saves reach the library — + // unless streamed tracks are configured to + // be kept forever. + let keep = + config::stream_keep_forever(&config::load(&event_app)); + let save = if keep { + save_cache.or(cache) + } else { + save_cache + }; + let app2 = event_app.clone(); + let username = update.username.clone(); + let remote = update.filename.clone(); + tauri::async_runtime::spawn(async move { + if let Some(cache) = save { + let downloads_dir = + config::download_dir(&app2, &config::load(&app2)); + if let Err(err) = organize_cached_save( + &app2, + &downloads_dir, + &cache, + &remote, + ) + .await + { + log::warn!("could not save completed preload {username}\\{remote}: {err}"); + } + let _ = app2.emit("soulseek:library-changed", ()); + } + // The cache just grew; enforce retention + // after any save copy is done with it. + let app3 = app2.clone(); + let _ = tauri::async_runtime::spawn_blocking(move || { + crate::playback::prune_cache(&app3) + }) + .await; + }); + } + } + continue; + } + } + // Stream downloads feed the player instead of the + // Downloads view. + let stream = event_app + .try_state::() + .and_then(|ps| ps.streams.lock().unwrap().get(&update.id).cloned()); + if let Some(progress) = stream { + use soulseek_client::DownloadState as DS; + let (done, failed) = match &update.state { + DS::Complete { .. } => (true, None), + DS::Failed { reason } => (false, Some(reason.clone())), + DS::Cancelled => (false, Some("stream cancelled".to_string())), + _ => (false, None), + }; + progress.update(update.received, done, failed.clone()); + let _ = event_app.emit( + "soulseek:stream", + serde_json::json!({ + "received": update.received, + "total": update.total, + "done": done, + "failed": failed, + }), + ); + if done || failed.is_some() { + if let Some(ps) = event_app.try_state::() { + ps.streams.lock().unwrap().remove(&update.id); + let cache = ps.stream_caches.lock().unwrap().remove(&update.id); + let save_cache = + ps.deferred_saves.lock().unwrap().remove(&update.id); + if done { + // Streams stay cache-only unless the user + // saved this track or retention is set to + // keep streamed tracks forever. + let keep = + config::stream_keep_forever(&config::load(&event_app)); + let save = if keep { + save_cache.or(cache) + } else { + save_cache + }; + let app2 = event_app.clone(); + let username = update.username.clone(); + let remote = update.filename.clone(); + tauri::async_runtime::spawn(async move { + if let Some(cache) = save { + let downloads_dir = + config::download_dir(&app2, &config::load(&app2)); + if let Err(err) = organize_cached_save( + &app2, + &downloads_dir, + &cache, + &remote, + ) + .await + { + log::warn!("could not save completed stream {username}\\{remote}: {err}"); + } + let _ = app2.emit("soulseek:library-changed", ()); + } + // The cache just grew; enforce retention + // after any save copy is done with it. + let app3 = app2.clone(); + let _ = tauri::async_runtime::spawn_blocking(move || { + crate::playback::prune_cache(&app3) + }) + .await; + }); + } + } + } + } else { + let _ = event_app.emit("soulseek:download", download_payload(&update)); + // Completed real downloads go through the tag/art + // pipeline and into the library. + if let soulseek_client::DownloadState::Complete { local_path } = + &update.state + { + let app2 = event_app.clone(); + let local = std::path::PathBuf::from(local_path.clone()); + let remote = update.filename.clone(); + tauri::async_runtime::spawn(async move { + pipeline::process_file(&app2, &local, Some(&remote)).await; + }); + } + } + } + Event::Disconnected { reason } => { + let _ = event_app.emit("soulseek:state", UiEvent::Disconnected { reason }); + if let Some(st) = event_app.try_state::() { + *st.connected_as.lock().await = None; + } + break; + } + Event::ExcludedSearchPhrases(p) => { + log::info!("server sent {} excluded search phrases: {:?}", p.len(), p); + let lowered: Vec = p.iter().map(|s| s.to_lowercase()).collect(); + if let Some(st) = event_app.try_state::() { + *st.excluded_search_phrases.lock().await = lowered.clone(); + } + // The resolver builds queries frontend-side; hand it the + // list so poisoned terms never reach the wire. + let _ = event_app.emit("soulseek:excluded-phrases", lowered); + } + Event::Upload(u) => { + use soulseek_client::UploadState as US; + let state = match &u.state { + US::Queued => "queued", + US::Transferring => "transferring", + US::Complete => "complete", + US::Failed { .. } => "failed", + }; + let _ = event_app.emit( + "soulseek:upload", + serde_json::json!({ + "id": u.id, + "username": u.username, + "name": u.filename.rsplit('\\').next().unwrap_or(&u.filename), + "state": state, + "sent": u.sent, + "total": u.total, + }), + ); + } + Event::RespondedToSearch { username, files } => { + let _ = event_app.emit( + "soulseek:responded", + serde_json::json!({ "username": username, "files": files }), + ); + } + _ => {} + } + } + }); + + Ok(greeting) +} + +// ---- catalog browser (Deezer-backed) ---- + +const ARTIST_HERO_MIN_WIDTH: u32 = 1920; +const ARTIST_HERO_MIN_HEIGHT: u32 = 800; +const ARTIST_HERO_MAX_BYTES: u64 = 30 * 1024 * 1024; +const ARTIST_HERO_PROVIDER_TARGET_WIDTH: u32 = 3840; +const ARTIST_HERO_PROVIDER_TARGET_HEIGHT: u32 = 2160; +const ARTIST_HERO_PREVIEW_MAX_WIDTH: u32 = 1920; +const ARTIST_HERO_GPU_PREPARE_CONCURRENCY: usize = 2; +const ARTIST_HERO_FSR_SHARPNESS_STOPS: &str = "0.25"; +const ARTIST_HERO_LUT_STRENGTH: f32 = 0.30; +const ARTIST_HERO_FADE_STRENGTH: f32 = 1.0; +const ARTIST_HERO_GRAIN_STRENGTH: f32 = 0.70; +/// Bump whenever any pixel-producing hero setting or processing order changes. +const ARTIST_HERO_CROP_RECIPE: &str = "vertical-yoloxnano-c789161e-windows-face-v2-faces-first"; +const ARTIST_HERO_CACHE_RECIPE: &str = + "w3840h2160-jpeg93-fsr025-ruby030-fade100-grain070-contentcrop-v5"; +const ARTIST_HERO_CACHE_TTL: std::time::Duration = + std::time::Duration::from_secs(30 * 24 * 60 * 60); +const ARTIST_HERO_CACHE_MAX_BYTES: u64 = 256 * 1024 * 1024; +const ARTIST_HERO_CACHE_MAX_ENTRY_BYTES: u64 = 41 * 1024 * 1024; +// v2 invalidated manifests written before provider artwork was required to +// close the loop back to the requested Deezer identity. v3 invalidates the +// window where an exact-name collision forced every Bing card to cite a +// catalogue release, which discarded the press photography that makes the best +// heroes; those degraded selections are pinned for a day otherwise. Bump this +// whenever hero *selection* changes, not merely its rendering. Keeping the same +// file path is safe: a recipe mismatch forces a fresh lookup and the successful +// result atomically replaces the stale manifest. +const ARTIST_ART_MANIFEST_RECIPE: &str = "artist-art-tier-v4-wide-only"; +const ARTIST_ART_MANIFEST_TTL: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60); +const ARTIST_ART_MANIFEST_MAX_BYTES: u64 = 64 * 1024; +// Identity is part of the key because two Deezer artists can share the same +// display name (and, occasionally, the same release title/reference artwork). +// The resolver's Apple candidate selection is artist-ID scoped as of v2. +const COVER_RESOLUTION_RECIPE: &str = "same-art-dhash10-v2-identity"; +const COVER_RESOLUTION_TTL: std::time::Duration = std::time::Duration::from_secs(30 * 24 * 60 * 60); +const COVER_RESOLUTION_CACHE_MAX_FILES: usize = 4096; +const ARTIST_ART_MANIFEST_CACHE_MAX_FILES: usize = 512; +const PERSISTENT_CACHE_SWEEP_INTERVAL_SECS: u64 = 60 * 60; + +fn artist_hero_gpu_prepare_slots() -> &'static tokio::sync::Semaphore { + static SLOTS: std::sync::OnceLock = std::sync::OnceLock::new(); + SLOTS.get_or_init(|| tokio::sync::Semaphore::new(ARTIST_HERO_GPU_PREPARE_CONCURRENCY)) +} + +#[derive(Serialize, Deserialize)] +struct CachedArtistArtManifest { + recipe: String, + artist_id: u64, + art: metadata::CatalogArtistArt, +} + +#[derive(Serialize, Deserialize)] +struct CachedCoverResolution { + recipe: String, + best_url: String, +} + +fn maybe_sweep_cache_directory( + directory: &std::path::Path, + ttl: std::time::Duration, + max_files: usize, + last_sweep: &std::sync::atomic::AtomicU64, + file_count: &std::sync::atomic::AtomicUsize, + sweep_in_progress: &std::sync::atomic::AtomicBool, +) { + use std::sync::atomic::Ordering; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + let previous = last_sweep.load(Ordering::Relaxed); + let sweep_due = now.saturating_sub(previous) >= PERSISTENT_CACHE_SWEEP_INTERVAL_SECS; + let at_capacity = file_count.load(Ordering::Relaxed) >= max_files; + if (!sweep_due && !at_capacity) + || sweep_in_progress + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + return; + } + last_sweep.store(now, Ordering::Relaxed); + + let Ok(entries) = std::fs::read_dir(directory) else { + sweep_in_progress.store(false, Ordering::Release); + return; + }; + let mut retained = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + let Ok(metadata) = entry.metadata() else { + continue; + }; + if !metadata.is_file() { + continue; + } + let modified = metadata.modified().unwrap_or(std::time::UNIX_EPOCH); + let expired = modified.elapsed().is_ok_and(|age| age >= ttl); + if expired { + let _ = std::fs::remove_file(path); + } else { + retained.push((modified, path)); + } + } + // This runs immediately before a write. Prune to a 90% low-water mark: + // leaving only one slot would force a 4K-entry directory scan + sort on + // every subsequent cover write once the cache first reached capacity. + let retained_limit = max_files + .saturating_mul(9) + .checked_div(10) + .unwrap_or(0) + .min(max_files.saturating_sub(1)); + let mut removed = 0usize; + if retained.len() > retained_limit { + retained.sort_unstable_by_key(|(modified, _)| *modified); + removed = retained.len() - retained_limit; + for (_, path) in retained.iter().take(removed) { + let _ = std::fs::remove_file(path); + } + } + file_count.store(retained.len() - removed, Ordering::Relaxed); + sweep_in_progress.store(false, Ordering::Release); +} + +fn cover_resolution_cache_path( + app: &tauri::AppHandle, + artist_id: Option, + artist: &str, + title: &str, + cover: &str, +) -> Result { + use std::hash::{Hash as _, Hasher as _}; + let dir = app + .path() + .app_cache_dir() + .map_err(|error| error.to_string())? + .join("cover-resolutions-v1"); + std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; + let mut key = std::collections::hash_map::DefaultHasher::new(); + COVER_RESOLUTION_RECIPE.hash(&mut key); + artist_id.hash(&mut key); + artist.hash(&mut key); + title.hash(&mut key); + cover.hash(&mut key); + Ok(dir.join(format!("{:016x}.json", key.finish()))) +} + +async fn load_cover_resolution( + app: &tauri::AppHandle, + artist_id: Option, + artist: &str, + title: &str, + cover: &str, +) -> Option { + let path = cover_resolution_cache_path(app, artist_id, artist, title, cover).ok()?; + let metadata = tokio::fs::metadata(&path).await.ok()?; + let fresh = metadata + .modified() + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age < COVER_RESOLUTION_TTL); + if !fresh || metadata.len() > 4096 { + let _ = tokio::fs::remove_file(path).await; + return None; + } + let cached = + serde_json::from_slice::(&tokio::fs::read(path).await.ok()?).ok()?; + (cached.recipe == COVER_RESOLUTION_RECIPE && cached.best_url.starts_with("https://")) + .then_some(cached.best_url) +} + +fn store_cover_resolution(path: &std::path::Path, best_url: &str) -> Result<(), String> { + static LAST_SWEEP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + static FILE_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + static SWEEP_IN_PROGRESS: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + if let Some(directory) = path.parent() { + maybe_sweep_cache_directory( + directory, + COVER_RESOLUTION_TTL, + COVER_RESOLUTION_CACHE_MAX_FILES, + &LAST_SWEEP, + &FILE_COUNT, + &SWEEP_IN_PROGRESS, + ); + } + let replaces_existing = path.is_file(); + let cached = CachedCoverResolution { + recipe: COVER_RESOLUTION_RECIPE.to_string(), + best_url: best_url.to_string(), + }; + let bytes = serde_json::to_vec(&cached).map_err(|error| error.to_string())?; + let temporary = path.with_extension(format!("{}.tmp", std::process::id())); + std::fs::write(&temporary, bytes).map_err(|error| error.to_string())?; + if let Err(first_error) = std::fs::rename(&temporary, path) { + let _ = std::fs::remove_file(path); + if let Err(retry_error) = std::fs::rename(&temporary, path) { + let _ = std::fs::remove_file(&temporary); + return Err(format!( + "cover resolution cache rename failed ({first_error}); replacement failed ({retry_error})" + )); + } + } + if !replaces_existing { + FILE_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + Ok(()) +} + +fn artist_art_manifest_path( + app: &tauri::AppHandle, + artist_id: u64, +) -> Result { + let dir = app + .path() + .app_cache_dir() + .map_err(|error| error.to_string())? + .join("artist-art-manifests-v1"); + std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; + Ok(dir.join(format!("{artist_id}.json"))) +} + +async fn load_artist_art_manifest( + app: &tauri::AppHandle, + artist_id: u64, +) -> Option { + let path = artist_art_manifest_path(app, artist_id).ok()?; + let metadata = tokio::fs::metadata(&path).await.ok()?; + let fresh = metadata + .modified() + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age < ARTIST_ART_MANIFEST_TTL); + if !fresh || metadata.len() > ARTIST_ART_MANIFEST_MAX_BYTES { + let _ = tokio::fs::remove_file(path).await; + return None; + } + let bytes = tokio::fs::read(&path).await.ok()?; + let manifest = serde_json::from_slice::(&bytes).ok()?; + let art = manifest.art; + let valid_urls = art + .banners + .iter() + .chain(art.logos.iter()) + .all(|url| url.starts_with("https://")); + (manifest.recipe == ARTIST_ART_MANIFEST_RECIPE + && manifest.artist_id == artist_id + && art.artist.id == artist_id + && !art.banner_custom + && art.banners.len() <= 8 + && art.logos.len() <= 8 + && valid_urls) + .then_some(art) +} + +fn store_artist_art_manifest( + app: &tauri::AppHandle, + artist_id: u64, + art: &metadata::CatalogArtistArt, +) -> Result<(), String> { + if art.banners.is_empty() && art.logos.is_empty() { + return Ok(()); + } + let path = artist_art_manifest_path(app, artist_id)?; + static LAST_SWEEP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + static FILE_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + static SWEEP_IN_PROGRESS: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + if let Some(directory) = path.parent() { + maybe_sweep_cache_directory( + directory, + ARTIST_ART_MANIFEST_TTL, + ARTIST_ART_MANIFEST_CACHE_MAX_FILES, + &LAST_SWEEP, + &FILE_COUNT, + &SWEEP_IN_PROGRESS, + ); + } + let replaces_existing = path.is_file(); + let manifest = CachedArtistArtManifest { + recipe: ARTIST_ART_MANIFEST_RECIPE.to_string(), + artist_id, + art: art.clone(), + }; + let bytes = serde_json::to_vec(&manifest).map_err(|error| error.to_string())?; + if bytes.len() as u64 > ARTIST_ART_MANIFEST_MAX_BYTES { + return Err("artist artwork manifest is unexpectedly large".into()); + } + let temporary = path.with_extension(format!("{}.tmp", std::process::id())); + std::fs::write(&temporary, bytes).map_err(|error| error.to_string())?; + if let Err(first_error) = std::fs::rename(&temporary, &path) { + let _ = std::fs::remove_file(&path); + if let Err(retry_error) = std::fs::rename(&temporary, &path) { + let _ = std::fs::remove_file(&temporary); + return Err(format!( + "artist art manifest rename failed ({first_error}); replacement failed ({retry_error})" + )); + } + } + if !replaces_existing { + FILE_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + Ok(()) +} + +#[derive(Serialize)] +pub struct ArtistHeroOverride { + pub data_url: String, + pub width: u32, + pub height: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CatalogArtistHeroSeed { + pub banner: String, + pub banner_custom: bool, +} + +#[derive(Serialize)] +pub struct CatalogHeroArtResult { + pub data_url: String, + pub cacheable: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogHeroCropPlan { + pub recipe: String, + pub source_checksum: String, + #[serde(flatten)] + pub geometry: HeroCropPlan, +} + +#[derive(Serialize)] +pub struct CatalogHeroPreviewResult { + pub data_url: String, + pub plan: CatalogHeroCropPlan, +} + +/// Header preceding the raw RGBA payload returned to the live GPU hero path. +/// The WebView receives this as an ArrayBuffer through Tauri's binary IPC, +/// avoiding both base64 expansion and a lossy JPEG round-trip: +/// +/// magic[4] | width(u32 LE) | height(u32 LE) | grain_seed(u32 LE) | RGBA8 +const CATALOG_HERO_GPU_MAGIC: &[u8; 4] = b"SHG1"; +const CATALOG_HERO_GPU_HEADER_BYTES: usize = 16; + +fn artist_hero_dir(app: &tauri::AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("artist-heroes"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} + +fn finished_hero_cache_path( + app: &tauri::AppHandle, + url: &str, + upscaler: config::ArtistHeroUpscaler, +) -> Result { + use std::hash::{Hash as _, Hasher as _}; + let dir = app + .path() + .app_cache_dir() + .map_err(|error| error.to_string())? + .join("finished-artist-heroes"); + std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; + let mut key = std::collections::hash_map::DefaultHasher::new(); + ARTIST_HERO_CACHE_RECIPE.hash(&mut key); + match upscaler { + config::ArtistHeroUpscaler::LiveGpu => "live_gpu", + config::ArtistHeroUpscaler::Fsr => "fsr", + config::ArtistHeroUpscaler::Lanczos3 => "lanczos3", + } + .hash(&mut key); + url.hash(&mut key); + Ok(dir.join(format!("{:016x}.data", key.finish()))) +} + +fn finished_hero_checksum(data_url: &str) -> u64 { + use std::hash::{Hash as _, Hasher as _}; + let mut checksum = std::collections::hash_map::DefaultHasher::new(); + data_url.as_bytes().hash(&mut checksum); + checksum.finish() +} + +fn validate_finished_hero(mut cached: String) -> Option { + let footer = cached.rfind('\n')?; + let expected = u64::from_str_radix(cached.get(footer + 1..)?, 16).ok()?; + cached.truncate(footer); + (cached.len() > 1_000 + && cached.starts_with("data:image/") + && cached.contains(";base64,") + && finished_hero_checksum(&cached) == expected) + .then_some(cached) +} + +fn store_finished_hero(path: &std::path::Path, data_url: &str) -> Result<(), String> { + use std::io::Write as _; + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT_WRITE: AtomicU64 = AtomicU64::new(1); + + let id = NEXT_WRITE.fetch_add(1, Ordering::Relaxed); + let temporary = path.with_extension(format!("{}.{}.tmp", std::process::id(), id)); + let mut file = std::fs::File::create(&temporary).map_err(|error| error.to_string())?; + file.write_all(data_url.as_bytes()) + .map_err(|error| error.to_string())?; + write!(file, "\n{:016x}", finished_hero_checksum(data_url)) + .map_err(|error| error.to_string())?; + file.sync_all().map_err(|error| error.to_string())?; + drop(file); + if let Err(first_error) = std::fs::rename(&temporary, path) { + let existing_is_valid = std::fs::read_to_string(path) + .ok() + .and_then(validate_finished_hero) + .is_some(); + if existing_is_valid { + let _ = std::fs::remove_file(&temporary); + } else { + let _ = std::fs::remove_file(path); + if let Err(retry_error) = std::fs::rename(&temporary, path) { + let _ = std::fs::remove_file(&temporary); + return Err(format!( + "cache rename failed ({first_error}); replacement failed ({retry_error})" + )); + } + } + } + + // Bound the persistent cache by evicting oldest outputs. This runs only + // after an expensive new render, never on the latency-sensitive read path. + let Some(dir) = path.parent() else { + return Ok(()); + }; + let mut files = std::fs::read_dir(dir) + .map_err(|error| error.to_string())? + .filter_map(Result::ok) + .filter_map(|entry| { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("data") { + return None; + } + let metadata = entry.metadata().ok()?; + metadata.is_file().then_some(( + path, + metadata.len(), + metadata.modified().unwrap_or(std::time::UNIX_EPOCH), + )) + }) + .collect::>(); + let mut total = files.iter().map(|(_, bytes, _)| *bytes).sum::(); + files.sort_by_key(|(_, _, modified)| *modified); + for (oldest, bytes, _) in files { + if total <= ARTIST_HERO_CACHE_MAX_BYTES { + break; + } + if std::fs::remove_file(oldest).is_ok() { + total = total.saturating_sub(bytes); + } + } + Ok(()) +} + +async fn load_finished_hero(path: &std::path::Path) -> Option { + let metadata = tokio::fs::metadata(path).await.ok()?; + let fresh = metadata + .modified() + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age < ARTIST_HERO_CACHE_TTL); + if fresh && metadata.len() <= ARTIST_HERO_CACHE_MAX_ENTRY_BYTES { + if let Ok(cached) = tokio::fs::read_to_string(path).await { + if let Some(cached) = validate_finished_hero(cached) { + return Some(cached); + } + } + } + let _ = tokio::fs::remove_file(path).await; + None +} + +fn artist_image_format(path: &std::path::Path) -> Result<(&'static str, &'static str), String> { + let format = image::ImageReader::open(path) + .map_err(|e| e.to_string())? + .with_guessed_format() + .map_err(|e| e.to_string())? + .format() + .ok_or_else(|| "Could not identify that image format".to_string())?; + match format { + image::ImageFormat::Jpeg => Ok(("jpg", "image/jpeg")), + image::ImageFormat::Png => Ok(("png", "image/png")), + image::ImageFormat::WebP => Ok(("webp", "image/webp")), + _ => Err("Choose a JPEG, PNG, or WebP image".into()), + } +} + +fn validate_artist_hero(path: &std::path::Path) -> Result<(u32, u32), String> { + let size = std::fs::metadata(path).map_err(|e| e.to_string())?.len(); + if size > ARTIST_HERO_MAX_BYTES { + return Err("Hero images must be 30 MB or smaller".into()); + } + let (width, height) = image::image_dimensions(path).map_err(|e| e.to_string())?; + if width < ARTIST_HERO_MIN_WIDTH || height < ARTIST_HERO_MIN_HEIGHT { + return Err(format!( + "That image is {width}×{height}. Choose at least {ARTIST_HERO_MIN_WIDTH}×{ARTIST_HERO_MIN_HEIGHT}." + )); + } + if u64::from(width) * 10 < u64::from(height) * 13 { + return Err(format!( + "That image is {width}×{height}. Choose a landscape photo for the wide hero." + )); + } + Ok((width, height)) +} + +fn load_artist_hero_override( + app: &tauri::AppHandle, + artist_id: u64, +) -> Result, String> { + use base64::Engine as _; + let dir = artist_hero_dir(app)?; + for (ext, mime) in [ + ("jpg", "image/jpeg"), + ("png", "image/png"), + ("webp", "image/webp"), + ] { + let path = dir.join(format!("{artist_id}.{ext}")); + if !path.is_file() { + continue; + } + let (width, height) = validate_artist_hero(&path)?; + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + return Ok(Some(ArtistHeroOverride { + data_url: format!( + "data:{mime};base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ), + width, + height, + })); + } + Ok(None) +} + +/// Import a user-owned high-resolution artist hero into app data. The numeric +/// catalog id makes the stored filename collision-safe without trusting an +/// artist name as a filesystem path. +#[tauri::command] +pub async fn pick_artist_hero( + app: tauri::AppHandle, + artist_id: u64, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .add_filter("High-resolution image", &["jpg", "jpeg", "png", "webp"]) + .pick_file(move |path| { + let _ = tx.send(path.map(|p| p.to_string())); + }); + let Some(source) = rx.await.unwrap_or(None) else { + return Ok(None); + }; + let source = std::path::PathBuf::from(source); + let (width, height) = validate_artist_hero(&source)?; + let (ext, _) = artist_image_format(&source)?; + let dir = artist_hero_dir(&app)?; + let temporary = dir.join(format!("{artist_id}.importing")); + std::fs::copy(&source, &temporary).map_err(|e| e.to_string())?; + for old_ext in ["jpg", "png", "webp"] { + let _ = std::fs::remove_file(dir.join(format!("{artist_id}.{old_ext}"))); + } + let destination = dir.join(format!("{artist_id}.{ext}")); + if let Err(error) = std::fs::rename(&temporary, &destination) { + let _ = std::fs::remove_file(&temporary); + return Err(error.to_string()); + } + let mut hero = load_artist_hero_override(&app, artist_id)? + .ok_or_else(|| "The imported hero could not be reopened".to_string())?; + hero.width = width; + hero.height = height; + Ok(Some(hero)) +} + +#[tauri::command] +pub fn clear_artist_hero(app: tauri::AppHandle, artist_id: u64) -> Result { + let dir = artist_hero_dir(&app)?; + let mut removed = false; + for ext in ["jpg", "png", "webp"] { + let path = dir.join(format!("{artist_id}.{ext}")); + if path.is_file() { + std::fs::remove_file(path).map_err(|e| e.to_string())?; + removed = true; + } + } + Ok(removed) +} + +#[tauri::command] +pub async fn catalog_search( + state: State<'_, LibraryState>, + query: String, +) -> Result { + state.touch_catalog_activity(); + Ok(state.metadata.catalog_search(&query).await) +} + +#[tauri::command] +pub async fn catalog_artist( + app: tauri::AppHandle, + state: State<'_, LibraryState>, + artist_id: u64, +) -> Result { + state.touch_catalog_activity(); + let mut page = state + .metadata + .catalog_artist(artist_id) + .await + .ok_or_else(|| "artist lookup failed".to_string())?; + if let Some(custom) = load_artist_hero_override(&app, artist_id)? { + page.banner = Some(custom.data_url.clone()); + // A deliberate user override remains fixed instead of being diluted by + // automatic provider art. Clearing it restores the provider carousel. + page.banners = vec![custom.data_url]; + page.banner_custom = true; + } + Ok(page) +} + +#[tauri::command] +pub async fn catalog_artist_art( + app: tauri::AppHandle, + state: State<'_, LibraryState>, + artist_id: u64, +) -> Result { + // The selected provider/Bing URL tier is stable for the same period as the + // metadata cache. Persisting that tiny manifest means an app restart can + // address the already-known hero immediately instead of rediscovering Bing. + let mut art = if let Some(cached) = load_artist_art_manifest(&app, artist_id).await { + cached + } else { + let resolved = state + .metadata + .catalog_artist_art(artist_id) + .await + .ok_or_else(|| "artist artwork lookup failed".to_string())?; + let store_app = app.clone(); + let store_art = resolved.clone(); + tauri::async_runtime::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + store_artist_art_manifest(&store_app, artist_id, &store_art) + }) + .await; + if let Ok(Err(error)) = result { + log::warn!("could not cache artist artwork manifest: {error}"); + } + }); + resolved + }; + if let Some(custom) = load_artist_hero_override(&app, artist_id)? { + art.banner = Some(custom.data_url.clone()); + art.banners = vec![custom.data_url]; + art.banner_custom = true; + } + Ok(art) +} + +/// Definitive provider-logo candidates without waiting for the independent +/// Bing hero fallback. The frontend uses this to decide whether cover OCR is +/// allowed as soon as the exact release list is ready. +#[tauri::command] +pub async fn catalog_artist_logos( + state: State<'_, LibraryState>, + artist_id: u64, +) -> Result, String> { + state + .metadata + .catalog_artist_logos(artist_id) + .await + .ok_or_else(|| "artist logo lookup failed".to_string()) +} + +/// Deferred MusicBrainz pass — MB-only releases + type hints. Kept out of +/// `catalog_artist` so its rate-limited (1 req/s) calls never delay the grid. +/// Socials ride `catalog_artist_socials` below, off this critical path. +#[tauri::command] +pub async fn catalog_artist_identity( + state: State<'_, LibraryState>, + artist_id: u64, + artist: String, +) -> Result { + Ok(state + .metadata + .catalog_artist_identity(artist_id, &artist) + .await) +} + +/// Identity-anchored artist biography for the Now Playing panel. Replaces a +/// frontend "first Wikipedia hit for ` musician band`" lookup that +/// regularly described a different artist entirely, and which also bypassed +/// this client's Wikimedia UA/pacing/caching. +#[tauri::command] +pub async fn catalog_artist_bio( + state: State<'_, LibraryState>, + artist_id: Option, + artist: String, +) -> Result, String> { + state.touch_catalog_activity(); + Ok(state.metadata.artist_bio(artist_id, &artist).await) +} + +/// Footer links, fetched only after the sorting-critical identity merge has +/// landed — one MusicBrainz request that used to cost every cold artist ~1 s +/// of time-to-correctly-sorted inside the identity join. +#[tauri::command] +pub async fn catalog_artist_socials( + state: State<'_, LibraryState>, + artist_id: u64, +) -> Result, String> { + Ok(state.metadata.catalog_artist_socials(artist_id).await) +} + +const COVER_LOGO_RECIPE: &str = "cover-logo-perf-v3-identity"; +const COVER_LOGO_MAX_COVERS: usize = 14; +const COVER_LOGO_MAX_HITS: usize = 5; +const COVER_LOGO_LOCATE_WINDOW: usize = 4; +const COVER_LOGO_UPGRADE_WINDOW: usize = 4; + +/// Completed cover-derived logos, including session-scoped negative results. +/// The full input fingerprint prevents namesakes or a changed ranked cover list +/// from sharing a result. +fn cover_logo_cache() -> &'static std::sync::Mutex>> +{ + static CACHE: std::sync::OnceLock< + std::sync::Mutex>>, + > = std::sync::OnceLock::new(); + CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +type CoverLogoResult = Result, String>; + +/// Rapid navigation can ask for the same artist again before the first OCR pass +/// finishes. Share that work instead of launching a second full pipeline. +fn cover_logo_inflight() -> &'static tokio::sync::Mutex< + std::collections::HashMap>>, +> { + static INFLIGHT: std::sync::OnceLock< + tokio::sync::Mutex< + std::collections::HashMap< + String, + std::sync::Arc>, + >, + >, + > = std::sync::OnceLock::new(); + INFLIGHT.get_or_init(|| tokio::sync::Mutex::new(std::collections::HashMap::new())) +} + +/// One album cover the frontend offers as a wordmark source: its title (so the +/// backend can resolve the same art at a higher resolution) and its cover URL. +#[derive(Debug, Clone, Deserialize)] +pub struct CoverRef { + pub title: String, + pub url: String, +} + +fn cover_logo_key(artist_id: u64, artist: &str, covers: &[CoverRef]) -> String { + use std::hash::{Hash as _, Hasher as _}; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + COVER_LOGO_RECIPE.hash(&mut hash); + artist_id.hash(&mut hash); + artist.to_lowercase().hash(&mut hash); + for cover in covers.iter().take(COVER_LOGO_MAX_COVERS) { + cover.title.hash(&mut hash); + cover.url.hash(&mut hash); + } + format!("{artist_id}-{:016x}", hash.finish()) +} + +fn cover_logo_disk_dir(app: &tauri::AppHandle) -> Option { + // `catalog_cached_cover_logo` restores the newest positive file by artist + // id before the current cover fingerprint is available. Isolate this + // identity-hardened generation so a pre-hardening logo derived from a + // fused namesake release can never flash during that early restore. + let dir = app.path().app_cache_dir().ok()?.join("cover-logos-v3"); + std::fs::create_dir_all(&dir).ok()?; + Some(dir) +} + +fn cover_logo_disk_path(app: &tauri::AppHandle, key: &str) -> Option { + Some(cover_logo_disk_dir(app)?.join(format!("{key}.data"))) +} + +fn valid_cached_cover_logo(value: &str) -> bool { + value.starts_with("data:image/png;base64,") && value.len() > 64 +} + +async fn read_cover_logo_disk(path: &std::path::Path) -> Option { + let value = tokio::fs::read_to_string(path).await.ok()?; + valid_cached_cover_logo(&value).then_some(value) +} + +async fn write_cover_logo_disk(path: &std::path::Path, value: &str) { + let temp = path.with_extension(format!("tmp-{}", std::process::id())); + if tokio::fs::write(&temp, value).await.is_err() { + return; + } + // Windows rename does not replace an existing destination. Invalid or + // interrupted cache entries are disposable and regenerated here. + let _ = tokio::fs::remove_file(path).await; + if tokio::fs::rename(&temp, path).await.is_err() { + let _ = tokio::fs::remove_file(&temp).await; + } +} + +struct LocatedCoverLogo { + rank: usize, + title: String, + url: String, + bytes_1000: std::sync::Arc>, + loc: crate::cover_logo::BandLocation, + dims_1000: (u32, u32), +} + +struct DownloadedCoverLogo { + rank: usize, + title: String, + url: String, + bytes_1000: std::sync::Arc>, +} + +struct CoverLogoDownloadJob { + download_elapsed: std::time::Duration, + downloaded: Option, +} + +async fn download_cover_logo_candidate( + metadata: std::sync::Arc, + rank: usize, + cover: CoverRef, +) -> CoverLogoDownloadJob { + let download_started = std::time::Instant::now(); + let Some(bytes_1000) = metadata.cover_bytes(&cover.url).await else { + return CoverLogoDownloadJob { + download_elapsed: download_started.elapsed(), + downloaded: None, + }; + }; + CoverLogoDownloadJob { + download_elapsed: download_started.elapsed(), + downloaded: Some(DownloadedCoverLogo { + rank, + title: cover.title, + url: cover.url, + bytes_1000, + }), + } +} + +async fn locate_downloaded_cover_logo( + downloaded: DownloadedCoverLogo, + artist: std::sync::Arc, +) -> (std::time::Duration, Option) { + let locate_started = std::time::Instant::now(); + let bytes_for_ocr = std::sync::Arc::clone(&downloaded.bytes_1000); + let found = tokio::task::spawn_blocking(move || { + use image::GenericImageView as _; + let image = image::load_from_memory(bytes_for_ocr.as_slice()).ok()?; + let dims = image.dimensions(); + let loc = crate::cover_logo::locate_wordmark(&image, &artist) + .ok() + .flatten()?; + Some((loc, dims)) + }) + .await + .ok() + .flatten(); + let locate_elapsed = locate_started.elapsed(); + ( + locate_elapsed, + found.map(|(loc, dims_1000)| LocatedCoverLogo { + rank: downloaded.rank, + title: downloaded.title, + url: downloaded.url, + bytes_1000: downloaded.bytes_1000, + loc, + dims_1000, + }), + ) +} + +struct CoverLogoCandidate { + rank: usize, + title: String, + bytes_1000: std::sync::Arc>, + loc: crate::cover_logo::BandLocation, + dims_1000: (u32, u32), + bytes_hi: Option>>, +} + +struct CoverLogoUpgradeJob { + elapsed: std::time::Duration, + candidate: CoverLogoCandidate, +} + +async fn upgrade_cover_logo_candidate( + metadata: std::sync::Arc, + artist_id: u64, + artist: std::sync::Arc, + located: LocatedCoverLogo, +) -> CoverLogoUpgradeJob { + let started = std::time::Instant::now(); + let hi_url = metadata + .resolve_cover_for_artist_id(Some(artist_id), &artist, &located.title, &located.url) + .await + .unwrap_or_else(|| located.url.clone()); + let bytes_hi = if hi_url != located.url { + metadata.cover_bytes(&hi_url).await + } else { + None + }; + CoverLogoUpgradeJob { + elapsed: started.elapsed(), + candidate: CoverLogoCandidate { + rank: located.rank, + title: located.title, + bytes_1000: located.bytes_1000, + loc: located.loc, + dims_1000: located.dims_1000, + bytes_hi, + }, + } +} + +async fn generate_cover_logo( + metadata: std::sync::Arc, + artist_id: u64, + artist: String, + covers: Vec, +) -> CoverLogoResult { + let total_started = std::time::Instant::now(); + let offered: Vec<_> = covers.into_iter().take(COVER_LOGO_MAX_COVERS).collect(); + let artist_shared: std::sync::Arc = std::sync::Arc::from(artist.as_str()); + + // Download and OCR a rank-ordered sliding window. Tasks run concurrently, + // but awaiting the handles from the front commits results in original rank + // order. Stopping at hit five therefore selects exactly the old first-five + // hit set; at most WINDOW-1 later covers have speculative work in flight. + let locate_pipeline_started = std::time::Instant::now(); + let mut locate_pending = std::collections::VecDeque::new(); + let mut next_rank = 0usize; + while next_rank < offered.len() && locate_pending.len() < COVER_LOGO_LOCATE_WINDOW { + let metadata = std::sync::Arc::clone(&metadata); + let cover = offered[next_rank].clone(); + let rank = next_rank; + locate_pending.push_back(( + rank, + tokio::spawn(download_cover_logo_candidate(metadata, rank, cover)), + )); + next_rank += 1; + } + + let upgrade_slots = std::sync::Arc::new(tokio::sync::Semaphore::new(COVER_LOGO_UPGRADE_WINDOW)); + let mut upgrade_pending = Vec::new(); + let mut upgrade_started: Option = None; + let mut hit_summary = Vec::new(); + let mut located_count = 0usize; + let mut downloaded_count = 0usize; + let mut download_work = std::time::Duration::ZERO; + let mut locate_work = std::time::Duration::ZERO; + while let Some((rank, handle)) = locate_pending.pop_front() { + match handle.await { + Ok(job) => { + download_work += job.download_elapsed; + if let Some(downloaded) = job.downloaded { + downloaded_count += 1; + // OCR itself stays rank-ordered and sequential. Windows OCR + // produced occasional different boxes under parallel engine + // pressure; overlapping downloads retains most of the wall + // win while preserving the original deterministic pass order. + let (elapsed, hit) = locate_downloaded_cover_logo( + downloaded, + std::sync::Arc::clone(&artist_shared), + ) + .await; + locate_work += elapsed; + if let Some(hit) = hit { + located_count += 1; + hit_summary.push((hit.rank, hit.title.clone())); + upgrade_started.get_or_insert_with(std::time::Instant::now); + let rank = hit.rank; + let metadata = std::sync::Arc::clone(&metadata); + let artist = std::sync::Arc::clone(&artist_shared); + let slots = std::sync::Arc::clone(&upgrade_slots); + upgrade_pending.push(( + rank, + tokio::spawn(async move { + let _permit = slots.acquire_owned().await.ok(); + upgrade_cover_logo_candidate(metadata, artist_id, artist, hit).await + }), + )); + } + } + } + Err(error) => log::warn!("cover-logo locate task rank {rank} failed: {error}"), + } + if located_count >= COVER_LOGO_MAX_HITS { + for (_, handle) in locate_pending.drain(..) { + handle.abort(); + } + break; + } + if next_rank < offered.len() { + let metadata = std::sync::Arc::clone(&metadata); + let cover = offered[next_rank].clone(); + let rank = next_rank; + locate_pending.push_back(( + rank, + tokio::spawn(download_cover_logo_candidate(metadata, rank, cover)), + )); + next_rank += 1; + } + } + let locate_pipeline_elapsed = locate_pipeline_started.elapsed(); + + // Hi-res resolution begins as soon as each ordered OCR hit commits, so its + // network time overlaps the remaining OCR passes. Handles are consumed in + // hit/rank order to retain exact equal-score winner semantics. + let mut candidates = Vec::with_capacity(located_count); + let mut upgrade_work = std::time::Duration::ZERO; + for (rank, handle) in upgrade_pending { + match handle.await { + Ok(job) => { + upgrade_work += job.elapsed; + candidates.push(job.candidate); + } + Err(error) => log::warn!("cover-logo upgrade task rank {rank} failed: {error}"), + } + } + let upgrade_elapsed = upgrade_started + .map(|started| started.elapsed()) + .unwrap_or(std::time::Duration::ZERO); + + // Selection recipe is intentionally unchanged. Keep the best image in + // memory and PNG-encode once, rather than encoding every provisional best. + const PROMINENCE_W: f32 = 3.0; + const MIN_PROMINENCE: f32 = 0.05; + const MIN_MARK_H: u32 = 74; + let extract_started = std::time::Instant::now(); + let (best, winner) = tokio::task::spawn_blocking(move || { + use base64::Engine as _; + use image::{GenericImageView as _, ImageEncoder as _}; + let mut best: Option<(f32, usize, String, image::RgbaImage)> = None; + for candidate in candidates { + let prom_w = (candidate.loc.rect.w / candidate.dims_1000.0 as f32).min(1.0); + if prom_w < MIN_PROMINENCE { + continue; + } + let mark = candidate + .bytes_hi + .as_ref() + .and_then(|bytes| image::load_from_memory(bytes.as_slice()).ok()) + .and_then(|hi| { + let (hw, hh) = hi.dimensions(); + let scaled = candidate.loc.scaled( + hw as f32 / candidate.dims_1000.0 as f32, + hh as f32 / candidate.dims_1000.1 as f32, + ); + crate::cover_logo::extract_wordmark(&hi, &scaled) + }) + .or_else(|| { + image::load_from_memory(candidate.bytes_1000.as_slice()) + .ok() + .and_then(|image| { + crate::cover_logo::extract_wordmark(&image, &candidate.loc) + }) + }); + let Some(mark) = mark else { continue }; + if mark.image.height() < MIN_MARK_H { + continue; + } + let final_score = mark.score + PROMINENCE_W * prom_w; + if best + .as_ref() + .is_none_or(|(score, _, _, _)| final_score > *score) + { + best = Some((final_score, candidate.rank, candidate.title, mark.image)); + } + } + let Some((_, rank, title, image)) = best else { + return (None, None); + }; + let (width, height) = image.dimensions(); + let mut png = Vec::new(); + if image::codecs::png::PngEncoder::new(&mut png) + .write_image( + image.as_raw(), + width, + height, + image::ExtendedColorType::Rgba8, + ) + .is_err() + { + return (None, None); + } + ( + Some(format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(&png) + )), + Some((rank, title, width, height)), + ) + }) + .await + .map_err(|error| error.to_string())?; + let extract_elapsed = extract_started.elapsed(); + + log::info!( + "cover-logo perf artist={artist:?} offered={} downloaded={downloaded_count} hits={located_count} locate_pipeline_ms={} download_work_ms={} ocr_work_ms={} upgrade_wall_ms={} upgrade_work_ms={} extract_ms={} total_ms={} hit_ranks={hit_summary:?} winner={winner:?}", + offered.len(), + locate_pipeline_elapsed.as_millis(), + download_work.as_millis(), + locate_work.as_millis(), + upgrade_elapsed.as_millis(), + upgrade_work.as_millis(), + extract_elapsed.as_millis(), + total_started.elapsed().as_millis(), + ); + Ok(best) +} + +/// Restore the most recently finalized cover-derived logo for an artist before +/// the exhaustive release list is ready. Only positive, recipe-versioned final +/// results are persisted, so this never exposes a speculative OCR candidate. +#[tauri::command] +pub async fn catalog_cached_cover_logo( + app: tauri::AppHandle, + artist_id: u64, +) -> Result, String> { + let Some(dir) = cover_logo_disk_dir(&app) else { + return Ok(None); + }; + let prefix = format!("{artist_id}-"); + let mut paths: Vec<_> = std::fs::read_dir(dir) + .into_iter() + .flatten() + .filter_map(Result::ok) + .filter(|entry| { + entry.file_name().to_string_lossy().starts_with(&prefix) + && entry.path().extension().and_then(|ext| ext.to_str()) == Some("data") + }) + .collect(); + paths.sort_by_key(|entry| { + std::cmp::Reverse( + entry + .metadata() + .and_then(|metadata| metadata.modified()) + .ok(), + ) + }); + for entry in paths { + if let Some(value) = read_cover_logo_disk(&entry.path()).await { + return Ok(Some(value)); + } + } + Ok(None) +} + +/// Deferred: derive an artist wordmark from album covers for artists with no +/// provider (Fanart/TheAudioDB) logo. Downloads the covers, OCRs each for the +/// band-name line, segments the cleanest off its background onto transparency, +/// and returns it as a PNG data URL — `None` when no cover yields a clean +/// wordmark (those stay on the plain text-name fallback). Cached per artist. +/// +/// Covers arrive already ranked studio-albums-first (their art prints the bare +/// band name; singles/remixes print tracklists and credits), so OCR walks the +/// clean covers first and early-outs once it has enough hits. +#[tauri::command] +pub async fn catalog_cover_logo( + app: tauri::AppHandle, + state: State<'_, LibraryState>, + artist_id: u64, + artist: String, + covers: Vec, +) -> Result, String> { + let key = cover_logo_key(artist_id, &artist, &covers); + if let Some(hit) = cover_logo_cache().lock().unwrap().get(&key).cloned() { + log::info!("cover-logo cache memory artist={artist:?} artist_id={artist_id}"); + return Ok(hit); + } + + let disk_path = cover_logo_disk_path(&app, &key); + if let Some(path) = disk_path.as_deref() { + if let Some(hit) = read_cover_logo_disk(path).await { + cover_logo_cache() + .lock() + .unwrap() + .insert(key, Some(hit.clone())); + log::info!("cover-logo cache disk artist={artist:?} artist_id={artist_id}"); + return Ok(Some(hit)); + } + } + + let inflight = { + let mut inflight = cover_logo_inflight().lock().await; + inflight + .entry(key.clone()) + .or_insert_with(|| std::sync::Arc::new(tokio::sync::OnceCell::new())) + .clone() + }; + let metadata = std::sync::Arc::clone(&state.metadata); + let generated = inflight + .get_or_init(|| generate_cover_logo(metadata, artist_id, artist.clone(), covers.clone())) + .await + .clone(); + + let should_persist = if let Ok(value) = &generated { + let mut cache = cover_logo_cache().lock().unwrap(); + if cache.len() > 500 { + cache.clear(); + } + let was_missing = !cache.contains_key(&key); + cache.insert(key.clone(), value.clone()); + was_missing && value.is_some() + } else { + false + }; + if should_persist { + if let (Some(path), Ok(Some(value))) = (disk_path.as_deref(), generated.as_ref()) { + write_cover_logo_disk(path, value).await; + } + } + + let mut active = cover_logo_inflight().lock().await; + if active + .get(&key) + .is_some_and(|current| std::sync::Arc::ptr_eq(current, &inflight)) + { + active.remove(&key); + } + generated +} + +/// Per-cover diagnostic for the cover-logo pipeline: for each candidate cover, +/// what OCR line matched the artist name, its box (in the 1000px OCR space), the +/// hi-res source it resolved to, the extracted-mark size, and the extraction +/// score — plus which cover WON. Answers "which album art is the logo lifted +/// from, where on it, and how large?". Not cached; not on the hot path. +#[derive(Debug, Clone, Serialize)] +pub struct CoverLogoDiag { + pub title: String, + pub url: String, + pub cover_w: u32, + pub cover_h: u32, + pub matched: bool, + pub matched_text: String, + pub match_score: f32, + pub box_x: f32, + pub box_y: f32, + pub box_w: f32, + pub box_h: f32, + pub box_pct_w: f32, + pub box_pct_h: f32, + pub hi_url: String, + pub hi_w: u32, + pub hi_h: u32, + pub mark_w: u32, + pub mark_h: u32, + pub extract_score: f32, + pub winner: bool, + pub ocr: Vec, +} + +#[tauri::command] +pub async fn catalog_cover_logo_debug( + state: State<'_, LibraryState>, + artist_id: Option, + artist: String, + covers: Vec, +) -> Result, String> { + use image::GenericImageView as _; + + // Download 1000px covers for OCR. + let mut downloaded = Vec::new(); + for cover in covers.into_iter().take(14) { + if let Some(bytes) = state.metadata.cover_bytes(&cover.url).await { + downloaded.push((cover.title, cover.url, bytes)); + } + } + + // Locate the band-name line on each (blocking OCR). + let artist_locate = artist.clone(); + let located = tokio::task::spawn_blocking(move || { + let mut out = Vec::new(); + for (title, url, bytes) in downloaded { + let Ok(img) = image::load_from_memory(&bytes) else { + continue; + }; + let (cw, ch) = img.dimensions(); + let found = crate::cover_logo::locate_wordmark_named(&img, &artist_locate) + .ok() + .flatten(); + let ocr = crate::cover_logo::ocr_lines_multi(&img) + .unwrap_or_default() + .into_iter() + .map(|l| { + format!( + "\"{}\" @{},{} {}x{}", + l.text, + l.rect.x.round(), + l.rect.y.round(), + l.rect.w.round(), + l.rect.h.round() + ) + }) + .collect(); + out.push((title, url, bytes, cw, ch, found, ocr)); + } + out + }) + .await + .map_err(|e| e.to_string())?; + + // For each hit: resolve to hi-res, extract, score. + let mut rows: Vec = Vec::new(); + for (title, url, bytes_1000, cw, ch, found, ocr) in located { + let mut row = CoverLogoDiag { + title: title.clone(), + url: url.clone(), + cover_w: cw, + cover_h: ch, + ocr, + matched: false, + matched_text: String::new(), + match_score: 0.0, + box_x: 0.0, + box_y: 0.0, + box_w: 0.0, + box_h: 0.0, + box_pct_w: 0.0, + box_pct_h: 0.0, + hi_url: url.clone(), + hi_w: cw, + hi_h: ch, + mark_w: 0, + mark_h: 0, + extract_score: 0.0, + winner: false, + }; + if let Some((loc, text, mscore)) = found { + row.matched = true; + row.matched_text = text; + row.match_score = mscore; + row.box_x = loc.rect.x; + row.box_y = loc.rect.y; + row.box_w = loc.rect.w; + row.box_h = loc.rect.h; + row.box_pct_w = loc.rect.w / cw as f32 * 100.0; + row.box_pct_h = loc.rect.h / ch as f32 * 100.0; + + let hi_url = state + .metadata + .resolve_cover_for_artist_id(artist_id, &artist, &title, &url) + .await + .unwrap_or_else(|| url.clone()); + let bytes_hi = if hi_url != url { + state.metadata.cover_bytes(&hi_url).await + } else { + None + }; + let (bytes_ex, loc_ex, hw, hh) = match bytes_hi + .as_ref() + .and_then(|b| image::load_from_memory(b).ok()) + { + Some(hi) => { + let (hw, hh) = hi.dimensions(); + let scaled = loc.scaled(hw as f32 / cw as f32, hh as f32 / ch as f32); + (bytes_hi.clone().unwrap(), scaled, hw, hh) + } + None => (bytes_1000.clone(), loc, cw, ch), + }; + row.hi_url = hi_url; + row.hi_w = hw; + row.hi_h = hh; + if let Ok(img_ex) = image::load_from_memory(&bytes_ex) { + if let Some(mark) = crate::cover_logo::extract_wordmark(&img_ex, &loc_ex) { + row.mark_w = mark.image.width(); + row.mark_h = mark.image.height(); + row.extract_score = mark.score; + } + } + } + rows.push(row); + } + // Mark the winner using the SAME prominence-weighted score the real pipeline + // uses (extract score + 3.0 * box-width-fraction), so the diagnostic winner + // matches what the logo command would pick. + let final_score = |r: &CoverLogoDiag| r.extract_score + 3.0 * (r.box_pct_w / 100.0); + if let Some((idx, _)) = rows + .iter() + .enumerate() + .filter(|(_, r)| r.mark_h >= 74 && r.box_pct_w / 100.0 >= 0.05) + .max_by(|a, b| final_score(a.1).total_cmp(&final_score(b.1))) + { + rows[idx].winner = true; + } + Ok(rows) +} + +/// EXPERIMENT: for each cover, run Windows OCR across a matrix of input scales +/// and preprocessing (raw vs Otsu-binarize) and report every line read. Answers +/// "does downscaling / flattening help the built-in OCR read stylized wordmarks +/// (Paper Years, Age of the Ego)?" before committing to a heavier local model. +#[tauri::command] +pub async fn catalog_ocr_experiment( + state: State<'_, LibraryState>, + urls: Vec, +) -> Result, String> { + let mut imgs = Vec::new(); + for url in urls.into_iter().take(8) { + if let Some(bytes) = state.metadata.cover_bytes(&url).await { + imgs.push((url, bytes)); + } + } + let out = tokio::task::spawn_blocking(move || { + use image::GenericImageView as _; + let scales = [1000u32, 768, 640, 512]; + let mut results = Vec::new(); + for (url, bytes) in imgs { + let Ok(base) = image::load_from_memory(&bytes) else { + continue; + }; + let mut variants = serde_json::Map::new(); + for &s in &scales { + let scaled = base.resize(s, s, image::imageops::FilterType::Lanczos3); + for (tag, img) in [ + (format!("{s}px/raw"), scaled.clone()), + (format!("{s}px/bin"), crate::cover_logo::binarize(&scaled)), + ] { + let lines = crate::cover_logo::ocr_lines(&img) + .unwrap_or_default() + .into_iter() + .map(|l| l.text) + .collect::>(); + variants.insert(tag, serde_json::json!(lines)); + } + } + let (w, h) = base.dimensions(); + results.push( + serde_json::json!({ "url": url, "base": format!("{w}x{h}"), "ocr": variants }), + ); + } + results + }) + .await + .map_err(|e| e.to_string())?; + Ok(out) +} + +#[tauri::command] +pub async fn catalog_artist_hero_seed( + app: tauri::AppHandle, + state: State<'_, LibraryState>, + artist_id: u64, +) -> Result, String> { + // Local user art is the only truly instant high-confidence source. Check it + // before any remote identity/provider work; the complete art request still + // runs independently so automatic logos remain available. + if let Some(custom) = load_artist_hero_override(&app, artist_id)? { + return Ok(Some(CatalogArtistHeroSeed { + banner: custom.data_url, + banner_custom: true, + })); + } + if let Some(banner) = load_artist_art_manifest(&app, artist_id) + .await + .and_then(|art| art.banners.into_iter().next()) + { + return Ok(Some(CatalogArtistHeroSeed { + banner, + banner_custom: false, + })); + } + Ok(state + .metadata + .catalog_artist_hero_seed(artist_id) + .await + .map(|banner| CatalogArtistHeroSeed { + banner, + banner_custom: false, + })) +} + +#[tauri::command] +pub async fn catalog_artist_overview( + state: State<'_, LibraryState>, + artist_id: u64, +) -> Result { + state.touch_catalog_activity(); + state + .metadata + .catalog_artist_overview(artist_id) + .await + .ok_or_else(|| "artist overview lookup failed".to_string()) +} + +#[tauri::command] +pub async fn catalog_related( + state: State<'_, LibraryState>, + artist_id: u64, +) -> Result, String> { + Ok(state.metadata.catalog_related(artist_id).await) +} + +/// Proxy a catalog cover image into a data URL (CDNs lack CORS headers). +#[tauri::command] +pub async fn catalog_art(state: State<'_, LibraryState>, url: String) -> Result { + if !url.starts_with("https://") { + return Err("not an https url".into()); + } + state + .metadata + .fetch_image_data_url(&url) + .await + .ok_or_else(|| "image fetch failed".to_string()) +} + +/// Upgrade a catalog cover to the highest-resolution image that is verifiably +/// the SAME artwork as `cover` (Apple / Deezer-max / — for MB cards — Cover Art +/// Archive). Returns a replacement URL, or `None` to keep `cover`. Does no +/// MusicBrainz lookup, so a whole page can resolve its covers in parallel. +#[tauri::command] +pub async fn catalog_cover( + app: tauri::AppHandle, + state: State<'_, LibraryState>, + artist_id: Option, + artist: String, + title: String, + cover: String, +) -> Result, String> { + if cover.is_empty() { + return Ok(None); + } + if let Some(best) = load_cover_resolution(&app, artist_id, &artist, &title, &cover).await { + return Ok(Some(best)); + } + let resolved = state + .metadata + .resolve_cover_for_artist_id(artist_id, &artist, &title, &cover) + .await; + if let Some(best_url) = resolved.as_ref() { + if let Ok(path) = cover_resolution_cache_path(&app, artist_id, &artist, &title, &cover) { + let best_url = best_url.clone(); + // Finish the tiny atomic JSON write before exposing the URL. If it + // were detached, a very fast browser failure could invalidate a + // not-yet-written file and the late writer would resurrect that + // proven-bad URL for the next 30 days. + let result = + tokio::task::spawn_blocking(move || store_cover_resolution(&path, &best_url)).await; + if let Ok(Err(error)) = result { + log::warn!("could not cache cover resolution: {error}"); + } + } + } + Ok(resolved) +} + +/// Forget one persisted positive resolution after the browser proves its URL +/// unusable. The exact artist/title/reference key prevents a flaky edition +/// from evicting any other album's warm result. +#[tauri::command] +pub async fn invalidate_catalog_cover( + app: tauri::AppHandle, + artist_id: Option, + artist: String, + title: String, + cover: String, +) -> Result<(), String> { + let path = cover_resolution_cache_path(&app, artist_id, &artist, &title, &cover)?; + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.to_string()), + } +} + +struct HeroTempFiles(Vec); + +impl Drop for HeroTempFiles { + fn drop(&mut self) { + for path in &self.0 { + let _ = std::fs::remove_file(path); + } + } +} + +fn scaled_height(source_width: u32, source_height: u32, target_width: u32) -> u32 { + (u64::from(source_height) * u64::from(target_width) / u64::from(source_width)).max(1) as u32 +} + +fn fidelityfx_cli_path(app: &tauri::AppHandle) -> Result { + let bundled = app + .path() + .resolve( + "resources/FidelityFX_CLI.exe", + tauri::path::BaseDirectory::Resource, + ) + .map_err(|error| error.to_string())?; + if bundled.is_file() { + return Ok(bundled); + } + + // Tauri resolves bundled resources at runtime. This fallback keeps direct + // `cargo run` and Rust-side tests useful before the bundle is assembled. + let development = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("FidelityFX_CLI.exe"); + if development.is_file() { + return Ok(development); + } + Err("bundled FidelityFX upscaler is unavailable".into()) +} + +fn run_fidelityfx(cli: &std::path::Path, args: &[String]) -> Result<(), String> { + let mut command = std::process::Command::new(cli); + command.args(args); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + command.creation_flags(0x08000000); + } + let output = command.output().map_err(|error| error.to_string())?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + Err(format!( + "FidelityFX failed ({}): {}{}", + output.status, + stdout.trim(), + stderr.trim() + )) +} + +fn fsr_upscale_hero( + app: &tauri::AppHandle, + decoded: &image::DynamicImage, + source_width: u32, + source_height: u32, +) -> Result { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT_JOB: AtomicU64 = AtomicU64::new(1); + + let cli = fidelityfx_cli_path(app)?; + let work_dir = app + .path() + .app_cache_dir() + .map_err(|error| error.to_string())? + .join("hero-processing"); + std::fs::create_dir_all(&work_dir).map_err(|error| error.to_string())?; + let id = NEXT_JOB.fetch_add(1, Ordering::Relaxed); + let prefix = format!("{}-{id}", std::process::id()); + let input = work_dir.join(format!("{prefix}-input.png")); + let intermediate = work_dir.join(format!("{prefix}-intermediate.png")); + let easu = work_dir.join(format!("{prefix}-easu.png")); + let rcas = work_dir.join(format!("{prefix}-rcas.png")); + let _cleanup = HeroTempFiles(vec![ + input.clone(), + intermediate.clone(), + easu.clone(), + rcas.clone(), + ]); + + decoded + .save_with_format(&input, image::ImageFormat::Png) + .map_err(|error| error.to_string())?; + + // FSR1's documented performance ratio is 2x per dimension. A 720p source + // therefore reaches 4K in two EASU passes (720p -> 1440p -> 2160p), then + // receives one final RCAS pass at presentation resolution. + let first_width = source_width + .saturating_mul(2) + .min(ARTIST_HERO_PROVIDER_TARGET_WIDTH); + let mut current = input.clone(); + if first_width < ARTIST_HERO_PROVIDER_TARGET_WIDTH { + let first_height = scaled_height(source_width, source_height, first_width); + run_fidelityfx( + &cli, + &[ + "-Mode".into(), + "EASU".into(), + "-Scale".into(), + first_width.to_string(), + first_height.to_string(), + current.to_string_lossy().into_owned(), + intermediate.to_string_lossy().into_owned(), + ], + )?; + current = intermediate.clone(); + } + + run_fidelityfx( + &cli, + &[ + "-Mode".into(), + "EASU".into(), + "-Scale".into(), + ARTIST_HERO_PROVIDER_TARGET_WIDTH.to_string(), + ARTIST_HERO_PROVIDER_TARGET_HEIGHT.to_string(), + current.to_string_lossy().into_owned(), + easu.to_string_lossy().into_owned(), + ], + )?; + run_fidelityfx( + &cli, + &[ + "-Mode".into(), + "RCAS".into(), + "-Sharpness".into(), + ARTIST_HERO_FSR_SHARPNESS_STOPS.into(), + easu.to_string_lossy().into_owned(), + rcas.to_string_lossy().into_owned(), + ], + )?; + image::open(rcas).map_err(|error| error.to_string()) +} + +fn smoothstep(edge0: f32, edge1: f32, value: f32) -> f32 { + let t = ((value - edge0) / (edge1 - edge0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +fn mix(left: f32, right: f32, amount: f32) -> f32 { + left + (right - left) * amount +} + +fn ruby_chrome(color: [f32; 3]) -> [f32; 3] { + let luma = color[0] * 0.2126 + color[1] * 0.7152 + color[2] * 0.0722; + let shadow = 1.0 - smoothstep(0.16, 0.60, luma); + let highlight = smoothstep(0.38, 0.90, luma); + let mut graded = color.map(|channel| mix(luma, channel, 1.24)); + graded = graded.map(|channel| (channel - 0.5) * 1.12 + 0.5); + graded = graded.map(|channel| { + let curved = channel * channel * (3.0 - 2.0 * channel); + mix(channel, curved, 0.18) + }); + graded[0] += highlight * 0.070 + shadow * 0.018; + graded[1] -= highlight * 0.028; + graded[2] += shadow * 0.045 + highlight * 0.018; + graded.map(|channel| channel.clamp(0.0, 1.0)) +} + +/// Bake the approved look into the final 4K raster. The order is deliberate: +/// Ruby Chrome LUT -> vintage fade -> native-resolution monochrome grain. +fn apply_baked_hero_look(image: &mut image::RgbImage, source: &[u8]) { + for pixel in image.pixels_mut() { + let input = [ + f32::from(pixel[0]) / 255.0, + f32::from(pixel[1]) / 255.0, + f32::from(pixel[2]) / 255.0, + ]; + let graded = ruby_chrome(input); + let color = [ + mix(input[0], graded[0], ARTIST_HERO_LUT_STRENGTH), + mix(input[1], graded[1], ARTIST_HERO_LUT_STRENGTH), + mix(input[2], graded[2], ARTIST_HERO_LUT_STRENGTH), + ]; + let luma = color[0] * 0.2126 + color[1] * 0.7152 + color[2] * 0.0722; + let faded_color = [ + color[0] * 0.78 + 0.085, + color[1] * 0.78 + 0.078, + color[2] * 0.78 + 0.068, + ]; + let faded_luma = luma * 0.78 + 0.078; + for channel in 0..3 { + let faded = mix(faded_luma, faded_color[channel], 0.88).clamp(0.0, 1.0); + let output = mix(color[channel], faded, ARTIST_HERO_FADE_STRENGTH); + pixel[channel] = (output.clamp(0.0, 1.0) * 255.0).round() as u8; + } + } + add_baked_hero_grain(image, source); +} + +/// Deterministic triangular monochrome grain, generated one sample per final +/// 4K pixel and encoded with the image. It does not move, resize, or regenerate +/// with the window because it is part of the finished hero raster. +fn add_baked_hero_grain(image: &mut image::RgbImage, source: &[u8]) { + let stride = (source.len() / 4096).max(1); + let mut seed = 0xcbf29ce484222325_u64; + for byte in source.iter().step_by(stride) { + seed ^= u64::from(*byte); + seed = seed.wrapping_mul(0x100000001b3); + } + if seed == 0 { + seed = 0x9e3779b97f4a7c15; + } + + let next = |state: &mut u64| { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state + }; + let amplitude = (28.0 * ARTIST_HERO_GRAIN_STRENGTH).round() as i16; + for pixel in image.pixels_mut() { + let first = (next(&mut seed) & 0xff) as i16 - 128; + let second = (next(&mut seed) & 0xff) as i16 - 128; + let delta = ((first + second) * amplitude / 256).clamp(-amplitude, amplitude); + for channel in pixel.0.iter_mut() { + *channel = (i16::from(*channel) + delta).clamp(0, 255) as u8; + } + } +} + +/// Crop letterbox bars baked into an image's edges — YouTube channel "TV art" +/// pads its safe area this way. A bar row/column is either near-black or +/// "murk": dark AND featureless (low brightness range), which catches the +/// blurry dark gradients YouTube composites between the bar and the artwork — +/// those read as broken letterboxing on the hero, and the approved behaviour +/// is a confident crop, never letterboxing. Conservative guards remain: a bar +/// must be a contiguous ≥2% band, each side is capped at a third, and the +/// crop is rejected outright if it would remove half the image — so a +/// genuinely dark photo cannot be eaten. Returns `None` when nothing trims. +fn trim_letterbox_bars(image: &image::DynamicImage) -> Option { + let rgb = image.to_rgb8(); + let (w, h) = rgb.dimensions(); + if w < 64 || h < 64 { + return None; + } + let barlike = + |luma_max: u8, luma_min: u8| luma_max < 16 || (luma_max < 60 && luma_max - luma_min < 48); + let dark_row = |y: u32| { + let mut max = 0u8; + let mut min = 255u8; + for x in (0..w).step_by(4) { + let p = rgb.get_pixel(x, y).0; + let l = p[0].max(p[1]).max(p[2]); + max = max.max(l); + min = min.min(l); + } + barlike(max, min) + }; + let dark_col = |x: u32| { + let mut max = 0u8; + let mut min = 255u8; + for y in (0..h).step_by(4) { + let p = rgb.get_pixel(x, y).0; + let l = p[0].max(p[1]).max(p[2]); + max = max.max(l); + min = min.min(l); + } + barlike(max, min) + }; + let mut top = 0; + while top < h / 3 && dark_row(top) { + top += 1; + } + let mut bottom = 0; + while bottom < h / 3 && dark_row(h - 1 - bottom) { + bottom += 1; + } + let mut left = 0; + while left < w / 3 && dark_col(left) { + left += 1; + } + let mut right = 0; + while right < w / 3 && dark_col(w - 1 - right) { + right += 1; + } + let min_band_h = (h / 50).max(4); + let min_band_w = (w / 50).max(4); + if top < min_band_h { + top = 0; + } + if bottom < min_band_h { + bottom = 0; + } + if left < min_band_w { + left = 0; + } + if right < min_band_w { + right = 0; + } + if top + bottom == 0 && left + right == 0 { + return None; + } + let new_w = w - left - right; + let new_h = h - top - bottom; + if new_w < w / 2 || new_h < h / 2 { + return None; + } + Some(image.crop_imm(left, top, new_w, new_h)) +} + +fn hero_source_checksum(bytes: &[u8]) -> String { + // This is an integrity/version key, not a security boundary. Keeping it as + // hex avoids losing 64-bit precision while the plan round-trips through JS. + use std::hash::{Hash as _, Hasher as _}; + let mut checksum = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut checksum); + format!("{:016x}", checksum.finish()) +} + +fn valid_hero_crop_plan( + plan: &CatalogHeroCropPlan, + source_checksum: &str, + width: u32, + height: u32, +) -> bool { + if plan.recipe != ARTIST_HERO_CROP_RECIPE + || plan.source_checksum != source_checksum + || plan.geometry.source_width != width + || plan.geometry.source_height != height + { + return false; + } + let Some(maximal) = maximal_hero_rect(width, height) else { + return false; + }; + let crop = plan.geometry.crop; + crop.x == maximal.x + && crop.width == maximal.width + && crop.height == maximal.height + && crop.width > 0 + && crop.height > 0 + && u64::from(crop.x) + u64::from(crop.width) <= u64::from(width) + && u64::from(crop.y) + u64::from(crop.height) <= u64::from(height) +} + +/// Decode, conservatively remove provider letterboxing, choose one immutable +/// source-pixel crop, and apply it before any preview resize or 4K work. +fn prepare_provider_hero( + app: &tauri::AppHandle, + bytes: &[u8], + trim_letterbox: bool, + supplied_plan: Option, +) -> Result<(image::DynamicImage, CatalogHeroCropPlan), String> { + use image::GenericImageView as _; + + if bytes.len() as u64 > ARTIST_HERO_MAX_BYTES { + return Err("provider hero exceeds the 30 MB limit".into()); + } + let mut decoded = image::load_from_memory(bytes).map_err(|error| error.to_string())?; + if trim_letterbox { + if let Some(trimmed) = trim_letterbox_bars(&decoded) { + decoded = trimmed; + } + } + let (width, height) = decoded.dimensions(); + let source_checksum = hero_source_checksum(bytes); + let plan = match supplied_plan + .filter(|plan| valid_hero_crop_plan(plan, &source_checksum, width, height)) + { + Some(plan) => plan, + None => { + let maximal = maximal_hero_rect(width, height) + .ok_or_else(|| "provider hero is too small to crop".to_string())?; + let (subjects, faces) = if maximal.height < height { + match detect_subjects(app, &decoded) { + Ok(detections) => detections, + Err(error) => { + log::warn!("content-aware artist hero crop unavailable: {error}"); + (Vec::new(), Vec::new()) + } + } + } else { + // Exact/wide sources have no vertical freedom, so inference + // could not affect the result and is deliberately skipped. + (Vec::new(), Vec::new()) + }; + let geometry = plan_hero_crop(width, height, &subjects, &faces) + .ok_or_else(|| "provider hero crop could not be planned".to_string())?; + log::info!( + "artist hero crop {}x{} -> x{} y{} {}x{} ({} people, {} faces, aware={})", + width, + height, + geometry.crop.x, + geometry.crop.y, + geometry.crop.width, + geometry.crop.height, + geometry.subject_count, + geometry.associated_face_count, + geometry.content_aware, + ); + CatalogHeroCropPlan { + recipe: ARTIST_HERO_CROP_RECIPE.to_string(), + source_checksum, + geometry, + } + } + }; + let crop = plan.geometry.crop; + Ok(( + decoded.crop_imm(crop.x, crop.y, crop.width, crop.height), + plan, + )) +} + +fn provider_hero_preview_data_url( + app: &tauri::AppHandle, + bytes: &[u8], + trim_letterbox: bool, +) -> Result { + use base64::Engine as _; + use image::GenericImageView as _; + + let (cropped, plan) = prepare_provider_hero(app, bytes, trim_letterbox, None)?; + let (width, _) = cropped.dimensions(); + // Preview never upscales. Rounding down to a 16-pixel width makes its + // encoded dimensions exactly 16:9 while losing at most 15 source pixels. + let preview_width = width.min(ARTIST_HERO_PREVIEW_MAX_WIDTH).max(16) / 16 * 16; + let preview_height = preview_width / 16 * 9; + let preview = cropped.resize_exact( + preview_width, + preview_height, + image::imageops::FilterType::CatmullRom, + ); + let rgb = preview.to_rgb8(); + let mut output = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 90) + .encode( + &rgb, + preview_width, + preview_height, + image::ExtendedColorType::Rgb8, + ) + .map_err(|error| error.to_string())?; + Ok(CatalogHeroPreviewResult { + data_url: format!( + "data:image/jpeg;base64,{}", + base64::engine::general_purpose::STANDARD.encode(output) + ), + plan, + }) +} + +/// Lossless, pre-cropped source pixels for the live GPU path. Cropping is +/// completed here, before the WebView sees the raster, so EASU cannot sample +/// discarded pixels around a crop edge. The source-bound grain seed keeps the +/// 4K grain field stable across visits and window sizes. +fn provider_hero_gpu_payload( + app: &tauri::AppHandle, + bytes: &[u8], + trim_letterbox: bool, + supplied_plan: Option, +) -> Result, String> { + use image::GenericImageView as _; + + let (cropped, plan) = prepare_provider_hero(app, bytes, trim_letterbox, supplied_plan)?; + let (width, height) = cropped.dimensions(); + let rgba = cropped.to_rgba8().into_raw(); + let expected = u64::from(width) + .checked_mul(u64::from(height)) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| "provider hero dimensions overflow".to_string())?; + if rgba.len() as u64 != expected { + return Err("provider hero RGBA payload has an invalid length".into()); + } + + // WebGL floats represent every integer exactly only through 24 bits. + let grain_seed = u64::from_str_radix(&plan.source_checksum, 16) + .unwrap_or(1) + .wrapping_mul(0x9e37_79b9) + .rotate_left(17) as u32 + & 0x00ff_ffff; + let mut payload = Vec::with_capacity(CATALOG_HERO_GPU_HEADER_BYTES + rgba.len()); + payload.extend_from_slice(CATALOG_HERO_GPU_MAGIC); + payload.extend_from_slice(&width.to_le_bytes()); + payload.extend_from_slice(&height.to_le_bytes()); + payload.extend_from_slice(&grain_seed.max(1).to_le_bytes()); + payload.extend_from_slice(&rgba); + Ok(payload) +} + +fn provider_hero_data_url( + app: &tauri::AppHandle, + bytes: &[u8], + upscaler: config::ArtistHeroUpscaler, + trim_letterbox: bool, + supplied_plan: Option, +) -> Result<(String, bool), String> { + use base64::Engine as _; + use image::GenericImageView as _; + + let (decoded, _) = prepare_provider_hero(app, bytes, trim_letterbox, supplied_plan)?; + let (width, height) = decoded.dimensions(); + let (finished, cacheable) = if width == ARTIST_HERO_PROVIDER_TARGET_WIDTH + && height == ARTIST_HERO_PROVIDER_TARGET_HEIGHT + { + (decoded, true) + } else if width > ARTIST_HERO_PROVIDER_TARGET_WIDTH { + // More-than-4K sources need a high-quality downsample, not an upscale. + ( + decoded.resize_exact( + ARTIST_HERO_PROVIDER_TARGET_WIDTH, + ARTIST_HERO_PROVIDER_TARGET_HEIGHT, + image::imageops::FilterType::Lanczos3, + ), + true, + ) + } else { + match upscaler { + config::ArtistHeroUpscaler::Fsr => { + match fsr_upscale_hero(app, &decoded, width, height) { + Ok(image) => ( + if image.dimensions() + == ( + ARTIST_HERO_PROVIDER_TARGET_WIDTH, + ARTIST_HERO_PROVIDER_TARGET_HEIGHT, + ) + { + image + } else { + image.resize_exact( + ARTIST_HERO_PROVIDER_TARGET_WIDTH, + ARTIST_HERO_PROVIDER_TARGET_HEIGHT, + image::imageops::FilterType::Lanczos3, + ) + }, + true, + ), + Err(error) => { + log::warn!("FSR hero upscale failed, using Lanczos3: {error}"); + ( + decoded + .resize_exact( + ARTIST_HERO_PROVIDER_TARGET_WIDTH, + ARTIST_HERO_PROVIDER_TARGET_HEIGHT, + image::imageops::FilterType::Lanczos3, + ) + .unsharpen(0.7, 2), + // Display the resilient fallback, but do not preserve it + // under the FSR key. A later visit should retry the + // quality mode the user actually selected. + false, + ) + } + } + } + config::ArtistHeroUpscaler::LiveGpu | config::ArtistHeroUpscaler::Lanczos3 => ( + decoded + .resize_exact( + ARTIST_HERO_PROVIDER_TARGET_WIDTH, + ARTIST_HERO_PROVIDER_TARGET_HEIGHT, + image::imageops::FilterType::Lanczos3, + ) + .unsharpen(0.7, 2), + true, + ), + } + }; + let mut rgb = finished.to_rgb8(); + apply_baked_hero_look(&mut rgb, bytes); + let mut output = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 93) + .encode( + &rgb, + ARTIST_HERO_PROVIDER_TARGET_WIDTH, + ARTIST_HERO_PROVIDER_TARGET_HEIGHT, + image::ExtendedColorType::Rgb8, + ) + .map_err(|error| error.to_string())?; + Ok(( + format!( + "data:image/jpeg;base64,{}", + base64::engine::general_purpose::STANDARD.encode(output) + ), + cacheable, + )) +} + +/// Hero-only proxy. Unlike `catalog_art`, this may upscale a 720p provider +/// image. Keeping the commands separate protects album covers and logo PNGs +/// from lossy conversion. +#[tauri::command] +pub async fn catalog_hero_preview( + app: tauri::AppHandle, + state: State<'_, LibraryState>, + url: String, +) -> Result { + if !url.starts_with("https://") { + return Err("not an https url".into()); + } + let bytes = state + .metadata + .hero_bytes(&url) + .await + .ok_or_else(|| "hero image fetch failed".to_string())?; + let trim_letterbox = url.starts_with("https://yt3.googleusercontent.com/") + || url.starts_with("https://yt3.ggpht.com/"); + let worker_app = app.clone(); + tokio::task::spawn_blocking(move || { + provider_hero_preview_data_url(&worker_app, &bytes, trim_letterbox) + }) + .await + .map_err(|error| error.to_string())? +} + +/// Crop-first, lossless input for the fixed-4K GPU renderer. Raw binary IPC +/// returns an ArrayBuffer rather than a JSON number array or base64 string. +#[tauri::command] +pub async fn catalog_hero_gpu_source( + app: tauri::AppHandle, + state: State<'_, LibraryState>, + url: String, + plan: Option, +) -> Result { + if !url.starts_with("https://") { + return Err("not an https url".into()); + } + if config::load(&app).artist_hero_upscaler != config::ArtistHeroUpscaler::LiveGpu { + return Err("live GPU hero rendering is disabled".into()); + } + let bytes = state + .metadata + .hero_bytes(&url) + .await + .ok_or_else(|| "hero image fetch failed".to_string())?; + let trim_letterbox = url.starts_with("https://yt3.googleusercontent.com/") + || url.starts_with("https://yt3.ggpht.com/"); + // Autocomplete warming and rapid artist navigation can leave work for + // different sources in flight. Bound the decode/crop/detector phase so + // those stale jobs cannot multiply peak CPU and decoded-raster memory. + let prepare_slot = artist_hero_gpu_prepare_slots() + .acquire() + .await + .map_err(|_| "live GPU hero preparation is unavailable".to_string())?; + let worker_app = app.clone(); + let payload = tokio::task::spawn_blocking(move || { + // Keep the permit inside the blocking job. If rapid navigation drops + // the command future, Tokio detaches an already-running blocking task; + // a permit owned only by the async frame would be released early and + // stale decode/YOLO jobs could again exceed the global bound. + let _prepare_slot = prepare_slot; + provider_hero_gpu_payload(&worker_app, &bytes, trim_letterbox, plan) + }) + .await + .map_err(|error| error.to_string())??; + Ok(tauri::ipc::Response::new(payload)) +} + +#[tauri::command] +pub async fn catalog_cached_hero_art( + app: tauri::AppHandle, + url: String, +) -> Result, String> { + if !url.starts_with("https://") { + return Err("not an https url".into()); + } + let upscaler = config::load(&app).artist_hero_upscaler; + if upscaler == config::ArtistHeroUpscaler::LiveGpu { + return Ok(None); + } + let cache_path = finished_hero_cache_path(&app, &url, upscaler)?; + Ok(load_finished_hero(&cache_path).await) +} + +#[tauri::command] +pub async fn catalog_hero_art( + app: tauri::AppHandle, + state: State<'_, LibraryState>, + url: String, + plan: Option, +) -> Result { + if !url.starts_with("https://") { + return Err("not an https url".into()); + } + let upscaler = config::load(&app).artist_hero_upscaler; + if upscaler == config::ArtistHeroUpscaler::LiveGpu { + return Err("live GPU heroes are not baked".into()); + } + let cache_path = finished_hero_cache_path(&app, &url, upscaler)?; + if let Some(cached) = load_finished_hero(&cache_path).await { + return Ok(CatalogHeroArtResult { + data_url: cached, + cacheable: true, + }); + } + let bytes = state + .metadata + .hero_bytes(&url) + .await + .ok_or_else(|| "hero image fetch failed".to_string())?; + // Only YouTube channel banners are known to bake letterbox bars into the + // raster; the trim never runs on curated art-database sources. + let trim_letterbox = url.starts_with("https://yt3.googleusercontent.com/") + || url.starts_with("https://yt3.ggpht.com/"); + let worker_app = app.clone(); + tokio::task::spawn_blocking(move || { + let (data_url, cacheable) = + provider_hero_data_url(&worker_app, &bytes, upscaler, trim_letterbox, plan)?; + if cacheable { + if let Err(error) = store_finished_hero(&cache_path, &data_url) { + log::warn!("could not cache finished artist hero: {error}"); + } + } + Ok(CatalogHeroArtResult { + data_url, + cacheable, + }) + }) + .await + .map_err(|error| error.to_string())? +} + +#[tauri::command] +pub async fn catalog_album( + state: State<'_, LibraryState>, + album_id: u64, +) -> Result { + state.touch_catalog_activity(); + state + .metadata + .catalog_album(album_id) + .await + .ok_or_else(|| "album lookup failed".to_string()) +} + +/// Editorial context (description/genre/label) for one release — TheAudioDB +/// first, Wikipedia summary fallback. Always succeeds; empty fields mean +/// neither source knew the record. +#[tauri::command] +pub async fn catalog_album_info( + state: State<'_, LibraryState>, + artist_id: Option, + artist: String, + title: String, +) -> Result { + state.touch_catalog_activity(); + Ok(state + .metadata + .album_info_for_artist_id(artist_id, &artist, &title) + .await) +} + +#[tauri::command] +pub async fn disconnect(state: State<'_, AppState>) -> Result<(), String> { + if let Some(client) = state.client.lock().await.take() { + client.shutdown(); + } + *state.connected_as.lock().await = None; + Ok(()) +} + +#[tauri::command] +pub async fn start_search(state: State<'_, AppState>, query: String) -> Result { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected")?; + client.search(&query).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn stop_search(state: State<'_, AppState>, token: u32) -> Result<(), String> { + if let Some(client) = state.client.lock().await.as_ref() { + client.stop_search(token); + } + Ok(()) +} + +#[tauri::command] +pub async fn download_file( + state: State<'_, AppState>, + username: String, + path: String, + size: u64, +) -> Result { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected")?; + Ok(client.download(&username, &path, size)) +} + +/// A streamed file's peer identity, used to locate its cached copy. +#[derive(serde::Deserialize)] +pub struct StreamRef { + pub username: String, + pub path: String, +} + +/// A streamed file plus its size, for saving into the library. +#[derive(serde::Deserialize)] +pub struct SaveRef { + pub username: String, + pub path: String, + pub size: u64, +} + +fn sanitize_seg(part: &str) -> String { + part.chars() + .map(|c| if "<>:\"/\\|?*".contains(c) { '_' } else { c }) + .collect::() + .trim() + .to_string() +} + +/// Save the file(s) you're streaming into the library. Cached tracks are +/// organized straight from the stream cache — no re-download, and crucially no +/// second transfer of a file the peer is already streaming to us (which +/// deadlocks both). Only tracks that aren't cached yet are downloaded. +/// Returns (organized_from_cache, queued_for_download). +#[tauri::command] +pub async fn save_to_library( + app: tauri::AppHandle, + state: State<'_, AppState>, + files: Vec, +) -> Result<(u32, u32), String> { + let downloads_dir = config::download_dir(&app, &config::load(&app)); + let mut organized = 0u32; + let mut queued = 0u32; + for f in files { + let cached = crate::playback::cache_path(&app, &f.username, &f.path).filter(|c| c.exists()); + if let Some(cache) = cached { + if organize_cached_save(&app, &downloads_dir, &cache, &f.path).await? { + organized += 1; + continue; + } + } + // The file may already be arriving as the playing stream or an + // album-continuation preload. Subscribe to that transfer instead of + // opening a duplicate peer request that can remain queued forever. + if let Some(players) = app.try_state::() { + let cache = crate::playback::cache_path(&app, &f.username, &f.path); + let transfer = cache.as_ref().and_then(|cache| { + players + .preloads + .lock() + .unwrap() + .iter() + .find(|(_, target)| *target == cache) + .map(|(id, _)| *id) + .or_else(|| { + players + .stream_caches + .lock() + .unwrap() + .iter() + .find(|(_, target)| *target == cache) + .map(|(id, _)| *id) + }) + }); + if let (Some(id), Some(cache)) = (transfer, cache) { + players.deferred_saves.lock().unwrap().insert(id, cache); + queued += 1; + continue; + } + } + // Not cached — fetch the known file (still no re-search). + let guard = state.client.lock().await; + if let Some(client) = guard.as_ref() { + client.download(&f.username, &f.path, f.size); + queued += 1; + } + } + let _ = app.emit("soulseek:library-changed", ()); + Ok((organized, queued)) +} + +/// Copy a completed stream cache into staging, then run the normal tagging and +/// library organizer. Returns false only when the copy itself did not happen. +async fn organize_cached_save( + app: &tauri::AppHandle, + downloads_dir: &std::path::Path, + cache: &std::path::Path, + remote_path: &str, +) -> Result { + let (folder, name) = match remote_path.rsplit_once('\\') { + Some((dir, n)) => (dir.rsplit('\\').next().unwrap_or(""), n), + None => ("", remote_path), + }; + let mut dir = downloads_dir.join("stream-save"); + if !folder.is_empty() { + dir.push(sanitize_seg(folder)); + } + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let dest = dir.join(sanitize_seg(name)); + if std::fs::copy(cache, &dest).is_err() { + return Ok(false); + } + crate::pipeline::process_file(app, &dest, Some(remote_path)).await; + Ok(true) +} + +/// Copy the files being streamed (already in the stream cache) into a chosen +/// folder — no re-download, no re-search. Skips any track not yet cached. +/// Returns how many were saved and how many weren't cached. +#[tauri::command] +pub async fn copy_stream_to_folder( + app: tauri::AppHandle, + files: Vec, + folder: String, +) -> Result<(u32, u32), String> { + let dest = std::path::PathBuf::from(&folder); + std::fs::create_dir_all(&dest).map_err(|e| e.to_string())?; + let mut saved = 0u32; + let mut missing = 0u32; + for f in files { + let Some(cache) = crate::playback::cache_path(&app, &f.username, &f.path) else { + missing += 1; + continue; + }; + if !cache.exists() { + missing += 1; + continue; + } + let name = f.path.rsplit(['\\', '/']).next().unwrap_or("track"); + match std::fs::copy(&cache, dest.join(name)) { + Ok(_) => saved += 1, + Err(_) => missing += 1, + } + } + Ok((saved, missing)) +} + +#[tauri::command] +pub async fn cancel_download(state: State<'_, AppState>, id: u64) -> Result<(), String> { + if let Some(client) = state.client.lock().await.as_ref() { + client.cancel_download(id); + } + Ok(()) +} + +/// Reveal a completed download in Explorer. +#[tauri::command] +pub fn show_in_folder(path: String) { + let _ = std::process::Command::new("explorer") + .args(["/select,", &path]) + .spawn(); +} + +#[tauri::command] +pub async fn refresh_artist_art(app: tauri::AppHandle) -> Result { + crate::pipeline::refresh_all_artist_art(&app).await +} + +/// Stream a remote file: download into the cache and start playback the +/// moment bytes arrive. +#[tauri::command] +pub async fn play_stream( + app: tauri::AppHandle, + state: State<'_, AppState>, + players: State<'_, PlayerState>, + username: String, + path: String, + size: u64, + duration: Option, +) -> Result<(), String> { + let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?; + + // Fully cached from an earlier stream → play directly. Touch it so + // retention counts from this play, not the original stream. + if let Ok(md) = std::fs::metadata(&cache) { + if size > 0 && md.len() == size { + playback::touch(&cache); + players.player.play_local(&cache, duration); + return Ok(()); + } + let _ = std::fs::remove_file(&cache); + } + let partial = playback::partial_path(&cache); + + // A preload may already be fetching this exact file: attach to it and + // keep the bytes it has instead of restarting the transfer. + let preload_id = players + .preloads + .lock() + .unwrap() + .iter() + .find(|(_, c)| **c == cache) + .map(|(id, _)| *id); + if let Some(id) = preload_id { + players.preloads.lock().unwrap().remove(&id); + let progress = player::StreamProgress::new(); + if let Ok(md) = std::fs::metadata(&partial) { + progress.update(md.len(), false, None); + } + players.streams.lock().unwrap().insert(id, progress.clone()); + players + .stream_caches + .lock() + .unwrap() + .insert(id, cache.clone()); + players + .player + .play_stream(&partial, progress, size, duration); + return Ok(()); + } + + let _ = std::fs::remove_file(&partial); + + let progress = player::StreamProgress::new(); + let id = { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected")?; + client.download_to(&username, &path, size, cache.clone()) + }; + players.streams.lock().unwrap().insert(id, progress.clone()); + players + .stream_caches + .lock() + .unwrap() + .insert(id, cache.clone()); + + players + .player + .play_stream(&partial, progress, size, duration); + Ok(()) +} + +/// Quietly download the next queue track into the stream cache so playback +/// can move to it without a buffering gap. No player, UI, or library +/// side-effects; play_stream attaches to it when the track comes up. +#[tauri::command] +pub async fn preload_stream( + app: tauri::AppHandle, + state: State<'_, AppState>, + players: State<'_, PlayerState>, + username: String, + path: String, + size: u64, +) -> Result<(), String> { + let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?; + if let Ok(md) = std::fs::metadata(&cache) { + if size > 0 && md.len() == size { + return Ok(()); // already fully cached + } + let _ = std::fs::remove_file(&cache); + } + if players + .preloads + .lock() + .unwrap() + .values() + .any(|c| *c == cache) + { + return Ok(()); // already preloading + } + let _ = std::fs::remove_file(&playback::partial_path(&cache)); + + let id = { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected")?; + client.download_to(&username, &path, size, cache.clone()) + }; + players.preloads.lock().unwrap().insert(id, cache); + Ok(()) +} + +/// Append a fully cached stream track behind the active source so the +/// transition happens inside the same audio sink (true gapless). Returns +/// false — and does nothing — while the cache file is still incomplete. +#[tauri::command] +pub fn preload_cached_stream( + app: tauri::AppHandle, + players: State<'_, PlayerState>, + username: String, + path: String, + size: u64, + duration: Option, +) -> Result { + let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?; + match std::fs::metadata(&cache) { + Ok(md) if size == 0 || md.len() == size => { + playback::touch(&cache); + players.player.preload_local(&cache, duration); + Ok(true) + } + _ => Ok(false), + } +} + +/// Waveform peaks for a stream-cached track. While the download is still in +/// flight, the partial file is decoded and its peaks scaled into the leading +/// `received/size` share of the buckets (rest zero), so the waveform appears +/// immediately and grows with the transfer. Complete files are memoized. +#[tauri::command] +pub async fn stream_waveform( + app: tauri::AppHandle, + players: State<'_, PlayerState>, + username: String, + path: String, + size: u64, + buckets: usize, +) -> Result, String> { + const MAX_WAVEFORM_BUCKETS: usize = 4096; + if buckets > MAX_WAVEFORM_BUCKETS { + return Err(format!( + "waveform bucket count exceeds {MAX_WAVEFORM_BUCKETS}" + )); + } + if buckets == 0 { + return Ok(Vec::new()); + } + let Some(cache) = playback::cache_path(&app, &username, &path) else { + return Ok(Vec::new()); + }; + let key = format!("{}#{buckets}", cache.display()); + let job = players.waveform_job(&key); + let result = async { + // Re-check the filesystem only after acquiring the per-key lock: a + // partial decoder ahead of us may have run while the transfer renamed + // the completed file into place. + let job_guard = job.clone().lock_owned().await; + if matches!(std::fs::metadata(&cache), Ok(md) if size == 0 || md.len() == size) { + players.remove_progressive_waveform(&key); + if let Some(cached) = players.cached_waveform(&key) { + return Ok(cached); + } + + let p = cache.clone(); + let (job_guard, peaks) = tauri::async_runtime::spawn_blocking(move || { + let peaks = player::compute_waveform(&p, buckets); + (job_guard, peaks) + }) + .await + .map_err(|e| e.to_string())?; + if !peaks.is_empty() { + players.cache_waveform(key.clone(), peaks.clone()); + } + drop(job_guard); + return Ok(peaks); + } + + // Seeing a partial file invalidates any completed waveform retained + // under the same stable cache path from an earlier transfer. + players.remove_cached_waveform(&key); + let partial = playback::partial_path(&cache); + let have = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0); + if have == 0 || size == 0 { + return Ok(Vec::new()); + } + if let Some(previous) = players.progressive_waveform(&key) { + if !previous.should_recompute(have) { + return Ok(previous.peaks()); + } + } + + // Byte share ≈ time share (close enough for a scrubber even on VBR). + // The cached threshold doubles after every useful decode, bounding all + // partial-file work geometrically instead of decoding from zero every + // three seconds. + let frac = (have as f64 / size as f64).min(1.0); + let n = ((buckets as f64 * frac).round() as usize).clamp(1, buckets); + let (job_guard, mut peaks) = tauri::async_runtime::spawn_blocking(move || { + let peaks = player::compute_waveform(&partial, n); + (job_guard, peaks) + }) + .await + .map_err(|e| e.to_string())?; + if !peaks.is_empty() { + peaks.resize(buckets, 0.0); + players.cache_progressive_waveform( + key.clone(), + playback::ProgressiveWaveform::new(have, size, peaks.clone()), + ); + } + drop(job_guard); + Ok(peaks) + } + .await; + players.release_waveform_job(&key, &job); + result +} + +/// Play an already-downloaded local file. +#[tauri::command] +pub fn play_local( + players: State<'_, PlayerState>, + path: String, + duration: Option, +) -> Result<(), String> { + if std::fs::metadata(&path).is_err() { + return Err("file not found".into()); + } + players.player.play_local(&path, duration); + Ok(()) +} + +/// Decode a local track now and append it behind the active source. This is +/// used for gapless album playback; it has no effect on streaming downloads. +#[tauri::command] +pub fn preload_local( + players: State<'_, PlayerState>, + path: String, + duration: Option, +) -> Result<(), String> { + if std::fs::metadata(&path).is_err() { + return Err("file not found".into()); + } + players.player.preload_local(&path, duration); + Ok(()) +} + +#[tauri::command] +pub async fn library_albums(lib: State<'_, LibraryState>) -> Result, String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.list_albums().map_err(|e| e.to_string())) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn album_tracks( + lib: State<'_, LibraryState>, + album_id: i64, +) -> Result, String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || { + l.album_tracks(album_id).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +/// Force-refetch an album's cover art and re-embed it into its tracks. +#[tauri::command] +pub async fn refresh_album_art(app: tauri::AppHandle, album_id: i64) -> Result { + pipeline::refresh_album_art(&app, album_id).await +} + +/// Upgrade every library album to the canonical high-resolution cover +/// (refetch + re-embed + folder covers). Returns how many succeeded. +#[tauri::command] +pub async fn refresh_all_album_art(app: tauri::AppHandle) -> Result { + pipeline::refresh_all_album_art(&app).await +} + +/// Album art as a data URL (small files; avoids asset-protocol scopes). +#[tauri::command] +pub async fn album_art(path: String) -> Result { + let bytes = tauri::async_runtime::spawn_blocking(move || std::fs::read(path)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + use base64::Engine as _; + Ok(format!( + "data:image/jpeg;base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + )) +} + +/// Open an http(s) URL in the system's default browser. The WebView blocks +/// `target="_blank"` navigation (no new-window handler is installed), so +/// external links such as artist socials route through +/// this command instead. +#[tauri::command] +pub async fn open_external(url: String) -> Result<(), String> { + if !url.starts_with("https://") && !url.starts_with("http://") { + return Err("only http(s) URLs can be opened".into()); + } + let spawned = { + #[cfg(target_os = "windows")] + { + std::process::Command::new("rundll32") + .args(["url.dll,FileProtocolHandler", &url]) + .spawn() + } + #[cfg(target_os = "macos")] + { + std::process::Command::new("open").arg(&url).spawn() + } + #[cfg(all(unix, not(target_os = "macos")))] + { + std::process::Command::new("xdg-open").arg(&url).spawn() + } + }; + spawned.map(|_| ()).map_err(|e| e.to_string()) +} + +// ---- play history ---- + +#[tauri::command] +pub async fn record_play( + lib: State<'_, LibraryState>, + artist: String, + title: String, +) -> Result { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || { + l.record_play(&artist, &title, now_secs()) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn add_play_seconds( + lib: State<'_, LibraryState>, + play_id: i64, + secs: u32, +) -> Result<(), String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || { + l.add_play_seconds(play_id, secs).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn artist_play_stats( + lib: State<'_, LibraryState>, + artist: String, +) -> Result { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || { + l.artist_play_stats(&artist).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +// ---- playlists ---- + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[tauri::command] +pub async fn list_playlists( + lib: State<'_, LibraryState>, +) -> Result, String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.list_playlists().map_err(|e| e.to_string())) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn create_playlist( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + name: String, +) -> Result { + let l = lib.library.clone(); + let id = tauri::async_runtime::spawn_blocking(move || l.create_playlist(&name, now_secs())) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + let _ = app.emit("soulseek:playlists-changed", ()); + Ok(id) +} + +#[tauri::command] +pub async fn delete_playlist( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + id: i64, +) -> Result<(), String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.delete_playlist(id)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + let _ = app.emit("soulseek:playlists-changed", ()); + Ok(()) +} + +#[tauri::command] +pub async fn playlist_tracks( + lib: State<'_, LibraryState>, + id: i64, +) -> Result, String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.playlist_tracks(id).map_err(|e| e.to_string())) + .await + .map_err(|e| e.to_string())? +} + +#[derive(serde::Deserialize)] +pub struct TrackInput { + pub title: String, + pub artist: String, + pub duration_secs: Option, + pub path: String, + pub art_path: Option, +} + +#[tauri::command] +pub async fn add_to_playlist( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + playlist_id: i64, + tracks: Vec, +) -> Result<(), String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || -> library::Result<()> { + for t in tracks { + l.add_to_playlist( + playlist_id, + &library::PlaylistTrack { + position: 0, // assigned by insert + title: t.title, + artist: t.artist, + duration_secs: t.duration_secs, + path: t.path, + art_path: t.art_path, + }, + )?; + } + Ok(()) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + let _ = app.emit("soulseek:playlists-changed", ()); + Ok(()) +} + +#[tauri::command] +pub async fn remove_from_playlist( + lib: State<'_, LibraryState>, + playlist_id: i64, + position: i64, +) -> Result<(), String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.remove_from_playlist(playlist_id, position)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + Ok(()) +} + +/// Compute (or fetch cached) waveform peaks for a local audio file. +/// Returns `buckets` normalized 0..1 values, or an empty array on failure. +#[tauri::command] +pub async fn waveform( + players: State<'_, PlayerState>, + path: String, + buckets: usize, +) -> Result, String> { + const MAX_WAVEFORM_BUCKETS: usize = 4096; + if buckets > MAX_WAVEFORM_BUCKETS { + return Err(format!( + "waveform bucket count exceeds {MAX_WAVEFORM_BUCKETS}" + )); + } + if buckets == 0 { + return Ok(Vec::new()); + } + let key = format!("{path}#{buckets}"); + let job = players.waveform_job(&key); + let result = async { + let job_guard = job.clone().lock_owned().await; + if let Some(cached) = players.cached_waveform(&key) { + return Ok(cached); + } + let p = std::path::PathBuf::from(&path); + let (job_guard, peaks) = tauri::async_runtime::spawn_blocking(move || { + let peaks = player::compute_waveform(&p, buckets); + (job_guard, peaks) + }) + .await + .map_err(|e| e.to_string())?; + if !peaks.is_empty() { + players.remove_progressive_waveform(&key); + players.cache_waveform(key.clone(), peaks.clone()); + } + drop(job_guard); + Ok(peaks) + } + .await; + players.release_waveform_job(&key, &job); + result +} + +/// Player transport: action ∈ pause | resume | stop, or seek/volume with value. +#[tauri::command] +pub fn player_control( + players: State<'_, PlayerState>, + action: String, + value: Option, + rate: Option, +) -> Result<(), String> { + match action.as_str() { + "pause" => players.player.pause(), + "resume" => players.player.resume(), + "stop" => players.player.stop(), + "seek" => players.player.seek(value.unwrap_or(0.0)), + "scratch" => players + .player + .scratch(value.unwrap_or(0.0), rate.unwrap_or(1.0) as f32), + "end-scratch" => players.player.end_scratch(value.unwrap_or(0.0)), + "volume" => players.player.set_volume(value.unwrap_or(0.7) as f32), + other => return Err(format!("unknown action {other}")), + } + Ok(()) +} + +#[cfg(test)] +mod hero_processing_tests { + use super::*; + + #[test] + fn baked_look_is_deterministic_and_changes_pixels() { + let original = image::RgbImage::from_pixel(32, 18, image::Rgb([92, 126, 161])); + let mut first = original.clone(); + let mut second = original.clone(); + let source = b"stable provider image bytes"; + + apply_baked_hero_look(&mut first, source); + apply_baked_hero_look(&mut second, source); + + assert_eq!(first, second); + assert_ne!(first, original); + } + + #[test] + fn sixteen_by_nine_source_finishes_at_uhd() { + assert_eq!(scaled_height(1280, 720, 3840), 2160); + } + + #[test] + fn letterbox_bars_are_trimmed() { + use image::GenericImageView as _; + // 256x144 mid-gray photo with 20px black bars baked top and bottom. + let mut img = image::RgbImage::from_pixel(256, 144, image::Rgb([120, 110, 100])); + for y in 0..20 { + for x in 0..256 { + img.put_pixel(x, y, image::Rgb([2, 2, 2])); + img.put_pixel(x, 143 - y, image::Rgb([2, 2, 2])); + } + } + let trimmed = + trim_letterbox_bars(&image::DynamicImage::ImageRgb8(img)).expect("bars should trim"); + assert_eq!(trimmed.dimensions(), (256, 104)); + } + + #[test] + fn dark_photo_is_not_eaten() { + // Uniformly dark-but-not-black image: everything is "barlike", so the + // half-image guard rejects the crop and nothing is trimmed. + let img = image::RgbImage::from_pixel(256, 144, image::Rgb([24, 20, 18])); + assert!(trim_letterbox_bars(&image::DynamicImage::ImageRgb8(img)).is_none()); + } + + #[test] + fn every_hero_gets_a_maximal_stable_crop() { + // Square sources retain every horizontal pixel; only Y can move. + let square = maximal_hero_rect(1000, 1000).expect("square must crop"); + assert_eq!((square.x, square.width, square.height), (0, 1000, 562)); + // Wide strips retain full height and remain horizontally centered. + let strip = maximal_hero_rect(1500, 500).expect("strip must crop"); + assert_eq!( + (strip.x, strip.y, strip.width, strip.height), + (306, 0, 888, 500) + ); + // Already 16:9 is the full source rectangle. + let hd = maximal_hero_rect(1920, 1080).expect("HD must plan"); + assert_eq!((hd.x, hd.y, hd.width, hd.height), (0, 0, 1920, 1080)); + } + + #[test] + fn featureless_murk_band_is_trimmed() { + // Bright photo with a dark featureless gradient band at the top — + // YouTube composites these between the black bar and the artwork. + let mut img = image::RgbImage::from_pixel(256, 144, image::Rgb([150, 140, 130])); + for y in 0..24 { + let shade = 20 + (y as u8); // 20..43 — dark, low range per row + for x in 0..256 { + img.put_pixel(x, y, image::Rgb([shade, shade, shade])); + } + } + let trimmed = trim_letterbox_bars(&image::DynamicImage::ImageRgb8(img)) + .expect("murk band should trim"); + use image::GenericImageView as _; + assert_eq!(trimmed.dimensions(), (256, 120)); + } +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs new file mode 100644 index 0000000..141928c --- /dev/null +++ b/src-tauri/src/config.rs @@ -0,0 +1,311 @@ +//! App config. Non-secret settings live in `config.json`; the Soulseek +//! password lives in the Windows Credential Manager (via `keyring`). + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use tauri::Manager; + +const KEYRING_SERVICE: &str = "com.soulstream.app"; +/// Pre-rename service name; stored passwords migrate on first read. +const LEGACY_KEYRING_SERVICE: &str = "com.trifecta.soulseek-plus"; +/// Pre-rename app-data folder; migrated by `migrate_app_data` at startup. +const LEGACY_APP_DATA_DIR: &str = "com.trifecta.soulseek-plus"; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ArtistHeroUpscaler { + /// Fixed 3840x2160 GPU master, mip-filtered to the visible window. + #[default] + #[serde(alias = "fsr")] + LiveGpu, + #[serde(rename = "fsr_legacy")] + Fsr, + Lanczos3, +} + +/// Ceiling for peer-source formats. `LossyOnly` skips lossless files — +/// the "don't stream multi-hundred-MB FLACs on this connection" cap. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceQualityCap { + #[default] + Any, + LossyOnly, +} + +/// Floor for peer-source quality. Lossless formats satisfy every floor; a +/// lossy file must carry a known bitrate at or above the named floor. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceQualityFloor { + #[default] + Any, + Kbps192, + Kbps256, + Kbps320, + Lossless, +} + +/// What the resolver optimizes among sources that satisfy the cap/floor: +/// best format (current behavior) or fastest transfer (free slot, upload +/// speed, short queue — format only breaks ties). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SourcePriority { + #[default] + Quality, + Speed, +} + +/// Per-mode source-selection preferences; streaming and downloading each +/// carry their own copy. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourcePrefs { + #[serde(default)] + pub max: SourceQualityCap, + #[serde(default)] + pub min: SourceQualityFloor, + #[serde(default)] + pub priority: SourcePriority, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AppConfig { + #[serde(default)] + pub username: String, + #[serde(default)] + pub listen_port: Option, + /// Where downloads are saved. None → default `\SoulSeek+`. + #[serde(default)] + pub download_dir: Option, + /// Album-folder pattern below the managed library/download root. + /// Available placeholders: {artist}, {album}, {date}, {year}. + /// `\` or `/` in the pattern create subfolders (separators coming from + /// tag values are flattened to `_`). + #[serde(default)] + pub library_layout: Option, + /// Extra folders to share (the downloads folder is always shared). + #[serde(default)] + pub shared_folders: Vec, + /// Concurrent upload slots offered to peers. + #[serde(default)] + pub upload_slots: Option, + /// Named platform output endpoint. None follows Windows' default device. + #[serde(default)] + pub audio_output_device: Option, + /// Spatial upscaler used for provider artist heroes below native 4K. + #[serde(default)] + pub artist_hero_upscaler: ArtistHeroUpscaler, + /// Source-selection preferences for streaming. + #[serde(default)] + pub stream_source_prefs: SourcePrefs, + /// Source-selection preferences for downloads/saves. + #[serde(default)] + pub download_source_prefs: SourcePrefs, + /// Replace whatever picture a download arrives with by the canonical + /// high-resolution cover. None → true. Startup-scanned library files are + /// never rewritten by this; it applies to fresh downloads only. + #[serde(default)] + pub replace_embedded_art: Option, + /// Longest edge for covers embedded into audio files. None → 1500. + /// Some(0) → embed the original untouched. The full-resolution original + /// is always kept in the art cache and the album folder regardless. + #[serde(default)] + pub embed_art_max_edge: Option, + /// Write the canonical cover as cover.jpg + folder.jpg in the organized + /// album folder so Explorer and other players show the same artwork. + /// None → true. + #[serde(default)] + pub write_folder_cover: Option, + /// Save every fully streamed track into the library. None → false: + /// streams stay only in the bounded stream cache below. + #[serde(default)] + pub stream_keep_forever: Option, + /// Days a streamed track stays in the stream cache after it was last + /// played. None → 30. Ignored when `stream_keep_forever` is on. + #[serde(default)] + pub stream_retention_days: Option, + /// Stream cache size cap in MB, oldest evicted first. None → 2048. + /// Some(0) → unlimited. + #[serde(default)] + pub stream_cache_max_mb: Option, +} + +/// Default longest edge for embedded covers when the user hasn't chosen. +pub const DEFAULT_EMBED_ART_MAX_EDGE: u32 = 1500; + +pub fn replace_embedded_art(cfg: &AppConfig) -> bool { + cfg.replace_embedded_art.unwrap_or(true) +} + +/// 0 means "embed the original at full resolution". +pub fn embed_art_max_edge(cfg: &AppConfig) -> u32 { + cfg.embed_art_max_edge.unwrap_or(DEFAULT_EMBED_ART_MAX_EDGE) +} + +pub fn write_folder_cover(cfg: &AppConfig) -> bool { + cfg.write_folder_cover.unwrap_or(true) +} + +pub const DEFAULT_STREAM_RETENTION_DAYS: u32 = 30; +pub const DEFAULT_STREAM_CACHE_MAX_MB: u64 = 2048; + +pub fn stream_keep_forever(cfg: &AppConfig) -> bool { + cfg.stream_keep_forever.unwrap_or(false) +} + +pub fn stream_retention_days(cfg: &AppConfig) -> u32 { + cfg.stream_retention_days + .unwrap_or(DEFAULT_STREAM_RETENTION_DAYS) + .max(1) +} + +/// 0 = unlimited. +pub fn stream_cache_max_mb(cfg: &AppConfig) -> u64 { + cfg.stream_cache_max_mb + .unwrap_or(DEFAULT_STREAM_CACHE_MAX_MB) +} + +/// One-time migration after the identifier rename: the app-data dir moved +/// from `com.trifecta.soulseek-plus` to the new identifier's folder. Rename +/// the old dir into place so the library DB, config and stream cache +/// survive. Never overwrites existing data at the new location. +pub fn migrate_app_data(app: &tauri::AppHandle) { + let Ok(new_dir) = app.path().app_data_dir() else { + return; + }; + let Some(parent) = new_dir.parent() else { + return; + }; + let old_dir = parent.join(LEGACY_APP_DATA_DIR); + if !old_dir.is_dir() { + return; + } + let occupied = new_dir.exists() + && std::fs::read_dir(&new_dir) + .map(|mut d| d.next().is_some()) + .unwrap_or(true); + if occupied { + return; + } + if new_dir.exists() { + let _ = std::fs::remove_dir(&new_dir); + } + match std::fs::rename(&old_dir, &new_dir) { + Ok(()) => log::info!("migrated app data from {}", old_dir.display()), + Err(e) => log::warn!( + "app data migration failed ({e}); old data stays in {}", + old_dir.display() + ), + } +} + +fn config_path(app: &tauri::AppHandle) -> Option { + let dir = app.path().app_data_dir().ok()?; + std::fs::create_dir_all(&dir).ok()?; + Some(dir.join("config.json")) +} + +pub fn load(app: &tauri::AppHandle) -> AppConfig { + config_path(app) + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +pub fn save(app: &tauri::AppHandle, cfg: &AppConfig) { + if let Some(p) = config_path(app) { + if let Ok(json) = serde_json::to_string_pretty(cfg) { + if let Err(e) = std::fs::write(&p, json) { + log::warn!("failed to save config to {}: {e}", p.display()); + } + } + } +} + +/// The effective downloads directory: configured, or `\SoulStream`. +/// A pre-rename `\SoulSeek+` folder keeps being used if it exists, +/// so an existing library doesn't fork into a new empty root. +pub fn download_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf { + if let Some(d) = &cfg.download_dir { + if !d.is_empty() { + return PathBuf::from(d); + } + } + let music = app + .path() + .audio_dir() + .or_else(|_| app.path().home_dir()) + .unwrap_or_else(|_| PathBuf::from(".")); + let legacy = music.join("SoulSeek+"); + if legacy.is_dir() { + return legacy; + } + music.join("SoulStream") +} + +pub const DEFAULT_LIBRARY_LAYOUT: &str = r"{artist}\{year} - {album}"; + +pub fn library_layout(cfg: &AppConfig) -> &str { + cfg.library_layout + .as_deref() + .map(str::trim) + .filter(|layout| !layout.is_empty()) + .unwrap_or(DEFAULT_LIBRARY_LAYOUT) +} + +// ---- password storage (Windows Credential Manager) ---- + +fn entry(username: &str) -> Option { + keyring::Entry::new(KEYRING_SERVICE, username).ok() +} + +pub fn save_password(username: &str, password: &str) { + if let Some(e) = entry(username) { + if let Err(err) = e.set_password(password) { + log::warn!("failed to store password in credential manager: {err}"); + } + } +} + +pub fn load_password(username: &str) -> Option { + if let Some(pw) = entry(username).and_then(|e| e.get_password().ok()) { + return Some(pw); + } + // Fall back to the pre-rename service entry and migrate it over. + let legacy = keyring::Entry::new(LEGACY_KEYRING_SERVICE, username).ok()?; + let pw = legacy.get_password().ok()?; + save_password(username, &pw); + let _ = legacy.delete_credential(); + Some(pw) +} + +pub fn has_password(username: &str) -> bool { + !username.is_empty() && load_password(username).is_some() +} + +pub fn delete_password(username: &str) { + if let Some(e) = entry(username) { + let _ = e.delete_credential(); + } +} + +#[cfg(test)] +mod tests { + use super::ArtistHeroUpscaler; + + #[test] + fn legacy_fsr_setting_migrates_to_live_gpu() { + let migrated: ArtistHeroUpscaler = serde_json::from_str("\"fsr\"").unwrap(); + assert_eq!(migrated, ArtistHeroUpscaler::LiveGpu); + assert_eq!(ArtistHeroUpscaler::default(), ArtistHeroUpscaler::LiveGpu); + assert_eq!( + serde_json::to_string(&ArtistHeroUpscaler::LiveGpu).unwrap(), + "\"live_gpu\"" + ); + assert_eq!( + serde_json::to_string(&ArtistHeroUpscaler::Fsr).unwrap(), + "\"fsr_legacy\"" + ); + } +} diff --git a/src-tauri/src/cover_logo.rs b/src-tauri/src/cover_logo.rs new file mode 100644 index 0000000..d95dbb7 --- /dev/null +++ b/src-tauri/src/cover_logo.rs @@ -0,0 +1,655 @@ +//! Derive an artist wordmark from album art for artists that have no provider +//! (Fanart.tv / TheAudioDB) logo. Many covers print the band name as clean text; +//! on-device OCR (Windows.Media.Ocr) locates that line, and (increment 2) it is +//! segmented off a near-uniform background into a transparent PNG that the +//! existing logo tracer vectorizes and tints like any provider logo. +//! +//! Fallible by design: any failure (non-Windows build, no OCR, no matching text) +//! leaves the caller on the plain text-name fallback. + +/// Axis-aligned box in source-image pixels. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Rect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +/// One recognized word with its own bounding box. +#[derive(Clone, Debug)] +pub(crate) struct OcrWord { + pub text: String, + pub rect: Rect, +} + +/// One recognized line of text with its bounding box (union of its words) plus +/// the individual words — so a wordmark can be boxed to JUST the words that spell +/// the artist name, excluding a track number / remix credit sharing the line. +#[derive(Clone, Debug)] +pub(crate) struct OcrLine { + pub text: String, + pub rect: Rect, + pub words: Vec, +} + +/// Lowercase alphanumerics only — matches the normalization used elsewhere so a +/// cover's "CAMPING IN ALASKA" lines up with the artist name "Camping In Alaska". +fn normalize(s: &str) -> String { + s.to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric()) + .collect() +} + +// Segmentation tuning (RGB distance space, 0..~441). +const BG_NEAR_DIST: f32 = 46.0; // a pixel this close to the dominant colour counts as background +const BG_MIN_DOMINANCE: f32 = 0.5; // background must fill at least this share of the crop, else it's a photo → reject +const ALPHA_LO: f32 = 46.0; // distance-from-bg below which a pixel is background (transparent) +const ALPHA_HI: f32 = 120.0; // distance-from-bg above which a pixel is full ink (opaque) + +fn dist(a: [f32; 3], b: [f32; 3]) -> f32 { + ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() +} + +fn smoothstep(lo: f32, hi: f32, x: f32) -> f32 { + let t = ((x - lo) / (hi - lo)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// A wordmark lifted off a cover, plus a quality score for picking the best cover. +pub(crate) struct Wordmark { + pub image: image::RgbaImage, + pub score: f32, +} + +/// The band-name line on a cover plus the vertical band it may occupy. `clamp_top` +/// / `clamp_bottom` are the midpoints to the nearest OTHER text lines (or image +/// edges), so the extraction crop can NEVER reach another line — the album title +/// and any other cover text are structurally excluded from the logo. +#[derive(Clone, Copy, Debug)] +pub(crate) struct BandLocation { + pub rect: Rect, + pub clamp_top: f32, + pub clamp_bottom: f32, +} + +impl BandLocation { + /// Scale to another resolution of the same cover (locate at 1000px where OCR + /// is reliable, extract at 1800px for quality). + pub fn scaled(&self, sx: f32, sy: f32) -> BandLocation { + BandLocation { + rect: Rect { + x: self.rect.x * sx, + y: self.rect.y * sy, + w: self.rect.w * sx, + h: self.rect.h * sy, + }, + clamp_top: self.clamp_top * sy, + clamp_bottom: self.clamp_bottom * sy, + } + } +} + +/// Segment the band-name text at `loc` off its (near-uniform) background into a +/// transparent RGBA image. The crop is hard-clamped to `loc`'s band so no other +/// text line can enter. Returns `None` when the background is too busy to segment +/// (text over a photo) or the result is empty — those stay on the text fallback. +pub(crate) fn extract_wordmark( + image: &image::DynamicImage, + loc: &BandLocation, +) -> Option { + use image::GenericImageView as _; + let rect = loc.rect; + let (iw, ih) = image.dimensions(); + // Pad the located word box — vertical for ascenders/descenders + tracer room — + // but never past the clamp to a neighbouring line. + let pad_x = (rect.w * 0.05).max(6.0); + let pad_y = (rect.h * 0.45).max(6.0); + let x0 = (rect.x - pad_x).max(0.0) as u32; + let y0 = (rect.y - pad_y).max(loc.clamp_top).max(0.0) as u32; + let x1 = ((rect.x + rect.w + pad_x).min(iw as f32)) as u32; + let y1 = ((rect.y + rect.h + pad_y) + .min(loc.clamp_bottom) + .min(ih as f32)) as u32; + if x1 <= x0 + 4 || y1 <= y0 + 4 { + return None; + } + let crop = image.crop_imm(x0, y0, x1 - x0, y1 - y0).to_rgba8(); + let (w, h) = crop.dimensions(); + + // Background = the dominant colour, via a coarse 16³ RGB histogram over the + // whole crop. Robust to sparse texture (faint line-art on black) in a way a + // ring mean is not — the text is a minority, so the mode is the background. + let mut hist = [0u32; 16 * 16 * 16]; + for p in crop.pixels() { + let (r, g, b) = (p.0[0] >> 4, p.0[1] >> 4, p.0[2] >> 4); + hist[(r as usize) * 256 + (g as usize) * 16 + b as usize] += 1; + } + let mode = hist + .iter() + .enumerate() + .max_by_key(|(_, c)| **c) + .map(|(i, _)| i) + .unwrap_or(0); + let bg = [ + ((mode / 256) as f32) * 16.0 + 8.0, + (((mode / 16) % 16) as f32) * 16.0 + 8.0, + ((mode % 16) as f32) * 16.0 + 8.0, + ]; + // Dominance guard: what share of the crop is near that background colour? + // A clean wordmark has a dominant background (text is the minority); a photo + // has no single dominant colour, so this share is low → reject. + let total = (w * h) as f32; + let near = crop + .pixels() + .filter(|p| dist([p.0[0] as f32, p.0[1] as f32, p.0[2] as f32], bg) < BG_NEAR_DIST) + .count() as f32; + let dominance = near / total; + if dominance < BG_MIN_DOMINANCE { + return None; // busy background — text over a photo, not segmentable + } + + // Alpha ramp by distance from the background colour, tracking per-row ink so + // we can trim adjacent text lines the padding caught (the album title above / + // below the band name), which sit in their own ink band across an empty gap. + let mut full = image::RgbaImage::new(w, h); + let mut row_ink = vec![0u32; h as usize]; + for y in 0..h { + for x in 0..w { + let p = crop.get_pixel(x, y).0; + let d = dist([p[0] as f32, p[1] as f32, p[2] as f32], bg); + let a = smoothstep(ALPHA_LO, ALPHA_HI, d); + let alpha = (a * 255.0).round() as u8; + // Flat white ink (not the cover's own colour). The frontend logo pass + // only traces the ALPHA silhouette and tints it to the accent, and it + // only does so when the mark is monochrome — a cover's varied/print + // colour scored below that bar and fell back to a small untinted PNG. + // A single flat colour guarantees the traced, accent-tinted SVG path. + full.put_pixel(x, y, image::Rgba([255, 255, 255, alpha])); + if alpha > 128 { + row_ink[y as usize] += 1; + } + } + } + // The wordmark is CENTRED on the located box. A single line of text never has a + // fully transparent row running through it — some glyph always spans every row + // (an i-dot's little gap is filled by neighbouring letters). So the first fully + // transparent row above/below the centre is the wordmark's TRUE edge; whatever + // lies past that transparent gap is separated content — a rule bar above, the + // album-title ghost below — and is dropped. We crop to the centre content, then + // re-add clean transparent padding. + let empty = |r: usize| (row_ink[r] as f32 / w as f32) < 0.004; + // A near-fully-inked row is a solid rule/bar touching the text with no gap — + // also a boundary (glyph rows always leave inter-letter gaps, never ~full). + let solid = |r: usize| row_ink[r] as f32 / w as f32 > 0.85; + let center = (((rect.y + rect.h / 2.0) as u32).saturating_sub(y0)).min(h - 1) as usize; + // 2 transparent rows = a real gap (bridges a 1px anti-aliasing fluke). + let gap_stop = 2usize; + // Safety cap so a pathological run can't swallow the whole cover. + let max_half = (rect.h * 1.5).round().max(6.0) as usize; + let lo = center.saturating_sub(max_half); + let hi = (center + max_half).min(h as usize - 1); + let mut top = center; + { + let (mut r, mut gap) = (center, 0usize); + while r > lo { + r -= 1; + if solid(r) { + break; + } + if empty(r) { + gap += 1; + if gap >= gap_stop { + break; + } + } else { + gap = 0; + top = r; + } + } + } + let mut bottom = center; + { + let (mut r, mut gap) = (center, 0usize); + while r + 1 <= hi { + r += 1; + if solid(r) { + break; + } + if empty(r) { + gap += 1; + if gap >= gap_stop { + break; + } + } else { + gap = 0; + bottom = r; + } + } + } + // Re-add transparent breathing room (~6% of content height) for the tracer/blur. + let pad = (((bottom - top + 1) as f32) * 0.06) + .round() + .clamp(3.0, 40.0) as usize; + let band_top = top.saturating_sub(pad) as u32; + let band_bottom = (bottom + pad).min(h as usize - 1) as u32; + let band_h = band_bottom - band_top + 1; + if band_h < 6 { + return None; + } + + // Crop to the wordmark's row band and tally ink/partial for scoring. + let mut out = image::RgbaImage::new(w, band_h); + let mut ink = 0u32; + let mut partial = 0u32; + for (dy, y) in (band_top..=band_bottom).enumerate() { + for x in 0..w { + let px = *full.get_pixel(x, y); + out.put_pixel(x, dy as u32, px); + if px.0[3] > 200 { + ink += 1; + } else if px.0[3] > 20 { + partial += 1; + } + } + } + let coverage = ink as f32 / (w * band_h) as f32; + if coverage < 0.008 || coverage > 0.6 { + return None; + } + // Recipe D: median despeckle removes the ragged spurs a lo-fi print leaves on + // the letter edges before the frontend supersamples/blurs/traces it. + let out = median_alpha(&out); + // Score: reward anti-aliasing (edge quality), a title-like wide aspect, and + // a healthy ink coverage; higher wins across candidate covers. + let aa = (partial as f32 / (ink as f32 + 1.0)).min(0.6); + let aspect = ((w as f32 / band_h as f32) / 10.0).min(0.6); + let cover_score = 1.0 - (coverage - 0.15).abs(); + Some(Wordmark { + image: out, + score: aa + aspect + cover_score, + }) +} + +// ---- Experimental alpha post-processing (for the quality comparison) ---- + +/// 3×3 median on the alpha channel — despeckles the ragged spurs a noisy print +/// leaves on the letter edges, without rounding real corners much. +fn median_alpha(src: &image::RgbaImage) -> image::RgbaImage { + let (w, h) = src.dimensions(); + let mut out = src.clone(); + for y in 0..h { + for x in 0..w { + // The previous implementation allocated a Vec for every output + // pixel. A fixed nine-byte buffer computes the identical median. + let mut n = [0u8; 9]; + let mut len = 0usize; + for dy in -1..=1 { + for dx in -1..=1 { + let nx = x as i32 + dx; + let ny = y as i32 + dy; + if nx >= 0 && ny >= 0 && (nx as u32) < w && (ny as u32) < h { + n[len] = src.get_pixel(nx as u32, ny as u32).0[3]; + len += 1; + } + } + } + n[..len].sort_unstable(); + let mut p = *src.get_pixel(x, y); + p.0[3] = n[len / 2]; + out.put_pixel(x, y, p); + } + } + out +} + +/// Otsu-binarized copy (black text on white / white on black, auto-thresholded on +/// luma). Experimental preprocessing to test whether the OCR reads stylized +/// display type better with the colour and gradients flattened away. +pub(crate) fn binarize(image: &image::DynamicImage) -> image::DynamicImage { + use image::GenericImageView as _; + let gray = image.to_luma8(); + // Otsu threshold. + let mut hist = [0u32; 256]; + for p in gray.pixels() { + hist[p.0[0] as usize] += 1; + } + let total: u32 = hist.iter().sum(); + let sum: f64 = hist + .iter() + .enumerate() + .map(|(i, &c)| i as f64 * c as f64) + .sum(); + let (mut sum_b, mut w_b, mut max, mut thresh) = (0.0f64, 0u32, 0.0f64, 128u8); + for (t, &c) in hist.iter().enumerate() { + w_b += c; + if w_b == 0 { + continue; + } + let w_f = total - w_b; + if w_f == 0 { + break; + } + sum_b += t as f64 * c as f64; + let m_b = sum_b / w_b as f64; + let m_f = (sum - sum_b) / w_f as f64; + let between = w_b as f64 * w_f as f64 * (m_b - m_f).powi(2); + if between > max { + max = between; + thresh = t as u8; + } + } + // Decide polarity so text is dark on light (OCR's expectation) by majority. + let (w, h) = image.dimensions(); + let mut out = image::RgbaImage::new(w, h); + for (x, y, p) in gray.enumerate_pixels().map(|(x, y, p)| (x, y, p.0[0])) { + let v = if p > thresh { 255 } else { 0 }; + out.put_pixel(x, y, image::Rgba([v, v, v, 255])); + } + image::DynamicImage::ImageRgba8(out) +} + +/// OCR the cover twice — once as-is and once Otsu-binarized — and merge the line +/// sets. Colour/gradient display type (the cyan "CRAZY P" on Paper Years) is +/// often unreadable raw but recognized once flattened to black-on-white; the raw +/// pass still catches anything the threshold destroys. Both passes share pixel +/// coordinates (the binarized copy is the same dimensions), so word boxes line up. +pub(crate) fn ocr_lines_multi(image: &image::DynamicImage) -> Result, String> { + let mut lines = ocr_lines(image)?; + if let Ok(mut bin) = ocr_lines(&binarize(image)) { + lines.append(&mut bin); + } + Ok(lines) +} + +/// All text lines OCR finds on the image, in source-pixel coordinates. +pub(crate) fn ocr_lines(image: &image::DynamicImage) -> Result, String> { + #[cfg(windows)] + { + windows_ocr::ocr_lines(image) + } + #[cfg(not(windows))] + { + let _ = image; + Err("cover-logo OCR is only available on Windows".into()) + } +} + +/// A substring match must be at least this share of its OCR line's letters, or +/// the line is a tracklist / credit that merely MENTIONS the name (e.g. +/// "A1. Disc Odyssey Crazy P remix" — "crazyp" is 25% of it) rather than a +/// wordmark that IS the name. Precision-first: only "just the band name" lines +/// (optionally with a hyphen/initial) clear this bar, never a whole credit line. +const NAME_LINE_MIN_RATIO: f32 = 0.6; + +/// Locate the band-name line and the vertical band it may occupy — clamped at +/// the midpoint to the nearest OTHER text line above/below (or the image edge), +/// so extraction can never include the album title or any other cover text. +/// Scores every line so a bare-name line beats one that merely embeds the name, +/// and rejects lines where the name is a minority (a tracklist / remix credit). +pub(crate) fn locate_wordmark( + image: &image::DynamicImage, + artist: &str, +) -> Result, String> { + Ok(locate_wordmark_named(image, artist)?.map(|(loc, _, _)| loc)) +} + +/// Within one OCR line, find the consecutive run of WORDS whose normalized text +/// spells the artist name exactly, and return the union box of just those words. +/// This lifts "Crazy P" cleanly out of "A1. Disc Odyssey Crazy P remix" or +/// "CRAZY P THE PAPER YEARS" — the track number, remix credit and album title +/// are never inside the returned box. `None` if no run spells the name. +fn word_run_box(line: &OcrLine, want: &str) -> Option { + // Only real word tokens: a "///", "A1." or "-" normalizes to empty and must + // NOT be allowed into the run's box, or the decorative mark next to the name + // gets extracted with it (Turnover's "/// TURNOVER"). + let toks: Vec<&OcrWord> = line + .words + .iter() + .filter(|w| !normalize(&w.text).is_empty()) + .collect(); + for i in 0..toks.len() { + let mut acc = String::new(); + for j in i..toks.len() { + acc.push_str(&normalize(&toks[j].text)); + if acc.len() > want.len() { + break; + } + if acc == want { + let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); + for w in &toks[i..=j] { + x0 = x0.min(w.rect.x); + y0 = y0.min(w.rect.y); + x1 = x1.max(w.rect.x + w.rect.w); + y1 = y1.max(w.rect.y + w.rect.h); + } + return Some(Rect { + x: x0, + y: y0, + w: x1 - x0, + h: y1 - y0, + }); + } + } + } + None +} + +/// Like [`locate_wordmark`] but also returns the matched OCR text and its score +/// (1.0 = a clean word-run of the name; <1 = a line the name only partly fills). +/// For diagnostics — "which line on which cover did we lift?". +pub(crate) fn locate_wordmark_named( + image: &image::DynamicImage, + artist: &str, +) -> Result, String> { + let want = normalize(artist); + if want.is_empty() { + return Ok(None); + } + // Two-pass OCR (raw + binarized) so stylized colour wordmarks are read. + let lines = ocr_lines_multi(image)?; + + // Prefer a precise word-run match (the box is JUST the name); among those, + // take the widest — a cover's big printed wordmark over a tiny corner label. + // Fall back to a whole-line substring only if no cover word-run matches, and + // only when the name fills most of the line (else it's a tracklist/credit). + let mut word_best: Option<(usize, Rect)> = None; + let mut line_best: Option<(usize, f32)> = None; + for (i, line) in lines.iter().enumerate() { + if let Some(rect) = word_run_box(line, &want) { + if word_best.is_none_or(|(_, r)| rect.w > r.w) { + word_best = Some((i, rect)); + } + continue; + } + let norm = normalize(&line.text); + if norm.is_empty() { + continue; + } + let score = if norm == want { + 1.0 + } else if want.len() >= 4 && norm.contains(&want) { + let ratio = want.len() as f32 / norm.len() as f32; + if ratio >= NAME_LINE_MIN_RATIO { + ratio + } else { + continue; + } + } else { + continue; + }; + if line_best.is_none_or(|(_, s)| score > s) { + line_best = Some((i, score)); + } + } + let (bi, band, matched_score) = if let Some((i, rect)) = word_best { + (i, rect, 1.0) + } else if let Some((i, s)) = line_best { + (i, lines[i].rect, s) + } else { + return Ok(None); + }; + let matched_text = lines[bi].text.clone(); + let mut clamp_top = 0.0f32; + let mut clamp_bottom = image.height() as f32; + for (i, line) in lines.iter().enumerate() { + if i == bi { + continue; + } + let r = line.rect; + let r_bottom = r.y + r.h; + if r_bottom <= band.y { + // A line above — stop halfway up to it. + clamp_top = clamp_top.max((r_bottom + band.y) / 2.0); + } else if r.y >= band.y + band.h { + // A line below — stop halfway down to it. + clamp_bottom = clamp_bottom.min((band.y + band.h + r.y) / 2.0); + } + } + Ok(Some(( + BandLocation { + rect: band, + clamp_top, + clamp_bottom, + }, + matched_text, + matched_score, + ))) +} + +#[cfg(windows)] +mod windows_ocr { + use image::{DynamicImage, ImageEncoder as _}; + use windows::{ + Graphics::Imaging::{BitmapDecoder, BitmapPixelFormat, SoftwareBitmap}, + Media::Ocr::OcrEngine, + Storage::Streams::{DataWriter, InMemoryRandomAccessStream}, + Win32::System::WinRT::{RoInitialize, RoUninitialize, RO_INIT_MULTITHREADED}, + }; + + use super::{OcrLine, OcrWord, Rect}; + + struct WinRtGuard; + impl Drop for WinRtGuard { + fn drop(&mut self) { + unsafe { RoUninitialize() }; + } + } + + fn initialize_winrt() -> Result, String> { + match unsafe { RoInitialize(RO_INIT_MULTITHREADED) } { + Ok(()) => Ok(Some(WinRtGuard)), + // Thread already in a different apartment: WinRT is usable, we just + // must not uninitialize it. + Err(error) if error.code().0 as u32 == 0x8001_0106 => Ok(None), + Err(error) => Err(format!("Windows Runtime initialization failed: {error}")), + } + } + + /// Load a DynamicImage into a Bgra8 SoftwareBitmap (the format OCR wants), + /// reusing the same PNG→stream→decoder path the face detector uses. + fn software_bitmap(image: &DynamicImage) -> Result { + let rgba = image.to_rgba8(); + let (width, height) = rgba.dimensions(); + let mut png = Vec::new(); + image::codecs::png::PngEncoder::new(&mut png) + .write_image( + rgba.as_raw(), + width, + height, + image::ExtendedColorType::Rgba8, + ) + .map_err(|error| format!("could not encode OCR raster: {error}"))?; + + let stream = InMemoryRandomAccessStream::new() + .map_err(|error| format!("could not allocate OCR stream: {error}"))?; + let writer = DataWriter::CreateDataWriter(&stream) + .map_err(|error| format!("could not create OCR writer: {error}"))?; + writer + .WriteBytes(&png) + .map_err(|error| format!("could not write OCR raster: {error}"))?; + writer + .StoreAsync() + .and_then(|op| op.get()) + .map_err(|error| format!("could not store OCR raster: {error}"))?; + writer + .FlushAsync() + .and_then(|op| op.get()) + .map_err(|error| format!("could not flush OCR raster: {error}"))?; + let _ = writer.DetachStream(); + stream + .Seek(0) + .map_err(|error| format!("could not rewind OCR raster: {error}"))?; + let decoder = BitmapDecoder::CreateAsync(&stream) + .and_then(|op| op.get()) + .map_err(|error| format!("could not decode OCR raster: {error}"))?; + let decoded = decoder + .GetSoftwareBitmapAsync() + .and_then(|op| op.get()) + .map_err(|error| format!("could not prepare OCR bitmap: {error}"))?; + SoftwareBitmap::Convert(&decoded, BitmapPixelFormat::Bgra8) + .map_err(|error| format!("could not convert OCR bitmap: {error}")) + } + + pub(crate) fn ocr_lines(image: &DynamicImage) -> Result, String> { + let _winrt = initialize_winrt()?; + let bitmap = software_bitmap(image)?; + let engine = OcrEngine::TryCreateFromUserProfileLanguages() + .map_err(|error| format!("could not create OCR engine: {error}"))?; + let result = engine + .RecognizeAsync(&bitmap) + .and_then(|op| op.get()) + .map_err(|error| format!("OCR failed: {error}"))?; + + let mut lines = Vec::new(); + for line in &result + .Lines() + .map_err(|error| format!("could not read OCR lines: {error}"))? + { + let text = line.Text().map(|t| t.to_string_lossy()).unwrap_or_default(); + if text.trim().is_empty() { + continue; + } + // The line has no box of its own — union its words' boxes, and keep + // each word's box so a wordmark can be located word-precisely. + let (mut min_x, mut min_y, mut max_x, mut max_y) = + (f32::MAX, f32::MAX, f32::MIN, f32::MIN); + let mut word_boxes = Vec::new(); + if let Ok(words) = line.Words() { + for word in &words { + if let Ok(r) = word.BoundingRect() { + min_x = min_x.min(r.X); + min_y = min_y.min(r.Y); + max_x = max_x.max(r.X + r.Width); + max_y = max_y.max(r.Y + r.Height); + let wtext = word.Text().map(|t| t.to_string_lossy()).unwrap_or_default(); + word_boxes.push(OcrWord { + text: wtext, + rect: Rect { + x: r.X, + y: r.Y, + w: r.Width, + h: r.Height, + }, + }); + } + } + } + if word_boxes.is_empty() { + continue; + } + lines.push(OcrLine { + text: text.trim().to_string(), + rect: Rect { + x: min_x, + y: min_y, + w: max_x - min_x, + h: max_y - min_y, + }, + words: word_boxes, + }); + } + Ok(lines) + } +} diff --git a/src-tauri/src/hero_crop.rs b/src-tauri/src/hero_crop.rs new file mode 100644 index 0000000..b3cc0b7 --- /dev/null +++ b/src-tauri/src/hero_crop.rs @@ -0,0 +1,787 @@ +use serde::{Deserialize, Serialize}; + +// Composition targets are deliberately expressed relative to the crop rather +// than the source. A person's visual centre sits below the middle of a hero, +// leaving their head near the upper third while retaining more of their body. +const PERSON_CENTER_TARGET: f32 = 0.66; +const FACE_CENTER_TARGET: f32 = 0.44; +const FACE_REFINEMENT_WEIGHT: f32 = 0.28; +const PERSON_BOTTOM_COVERAGE: f32 = 0.93; +const PERSON_TOP_MARGIN: f32 = 0.07; +/// Headroom kept above the highest detected face, as a fraction of that face +/// box and as a floor relative to the crop. A face box starts at the brow, not +/// the crown, so roughly a third of a face height of hair sits above it. +const FACE_HEADROOM_OF_FACE: f32 = 0.38; +const FACE_HEADROOM_OF_CROP: f32 = 0.03; +/// Headroom above the topmost PERSON box when no face was associated. A person +/// box top is already the crown, so this only needs to avoid a hairline shave. +const PERSON_CROWN_HEADROOM: f32 = 0.01; + +/// Pull a proposed crop start so every detected face stays inside the window, +/// with hair headroom above the highest one. Returns `desired_y` unchanged +/// when there are no faces to protect. +/// +/// When the faces span MORE than the crop height (a group photographed at very +/// different depths), no placement can hold them all; the span is centred +/// instead, which cuts the least from the people at both extremes. +fn protect_faces( + desired_y: f32, + faces: &[SanitizedBox], + people: &[SanitizedBox], + crop_height: u32, +) -> f32 { + let Some(highest) = faces + .iter() + .min_by(|left, right| left.top.total_cmp(&right.top)) + else { + // No face survived association — common for small, distant or + // turned-away subjects. The person box's top edge IS the crown, so it + // still protects the head from the body-coverage guards, which use a + // MEDIAN top margin and therefore happily over-cut the topmost person + // of a group. Only ever raises the window; never pushes it down. + let Some(highest_person) = people + .iter() + .min_by(|left, right| left.top.total_cmp(&right.top)) + else { + return desired_y; + }; + let headroom = crop_height as f32 * PERSON_CROWN_HEADROOM; + return desired_y.min((highest_person.top - headroom).max(0.0)); + }; + let lowest_bottom = faces + .iter() + .map(|face| face.bottom) + .fold(f32::MIN, f32::max); + let headroom = + (highest.height() * FACE_HEADROOM_OF_FACE).max(crop_height as f32 * FACE_HEADROOM_OF_CROP); + let top_limit = (highest.top - headroom).max(0.0); + let bottom_need = + lowest_bottom + crop_height as f32 * FACE_HEADROOM_OF_CROP - crop_height as f32; + if bottom_need <= top_limit { + desired_y.clamp(bottom_need.max(0.0), top_limit) + } else { + (highest.top + lowest_bottom) / 2.0 - crop_height as f32 / 2.0 + } +} + +/// Placement tracing for crop audits (`SOULSTREAM_CROP_TRACE=1`). The chosen +/// `y` is the product of four competing terms, so a bad composition is only +/// diagnosable with the intermediates in hand. +fn crop_trace(message: impl FnOnce() -> String) { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + if *ON.get_or_init(|| std::env::var("SOULSTREAM_CROP_TRACE").is_ok_and(|value| value == "1")) { + eprintln!("CROP\t{}", message()); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HeroRect { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HeroCropPlan { + pub source_width: u32, + pub source_height: u32, + pub crop: HeroRect, + /// True when valid person detections affected the vertical placement. + pub content_aware: bool, + /// Robust vertical focus in source pixels. Useful for diagnostics only; + /// consumers should apply `crop` verbatim for preview and final renders. + pub focal_y: Option, + pub subject_count: u32, + pub associated_face_count: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SubjectBox { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + pub confidence: f32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FaceBox { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + pub confidence: f32, +} + +#[derive(Debug, Clone, Copy)] +struct WeightedValue { + value: f32, + weight: f32, +} + +#[derive(Debug, Clone, Copy)] +struct SanitizedBox { + left: f32, + top: f32, + right: f32, + bottom: f32, + confidence: f32, +} + +impl SanitizedBox { + fn width(self) -> f32 { + self.right - self.left + } + + fn height(self) -> f32 { + self.bottom - self.top + } + + fn area(self) -> f32 { + self.width() * self.height() + } + + fn center_x(self) -> f32 { + (self.left + self.right) * 0.5 + } + + fn center_y(self) -> f32 { + (self.top + self.bottom) * 0.5 + } +} + +/// Returns the largest pixel crop that fits the source's 16:9 frame. +/// +/// Tall and square sources retain every horizontal source pixel (`x = 0`), so +/// content awareness can move only on the Y axis. Wide sources retain the full +/// source height and center the crop on X. Integer rounding can leave the raw +/// crop less than one pixel away from 16:9; preview/final rendering normalizes +/// that same rectangle to exact 16:9 output dimensions. +pub(crate) fn maximal_hero_rect(source_width: u32, source_height: u32) -> Option { + if source_width == 0 || source_height == 0 { + return None; + } + + if source_width as u64 * 9 <= source_height as u64 * 16 { + let height = (source_width as u64 * 9 / 16) as u32; + (height > 0).then_some(HeroRect { + x: 0, + y: (source_height - height) / 2, + width: source_width, + height, + }) + } else { + let width = (source_height as u64 * 16 / 9) as u32; + (width > 0).then_some(HeroRect { + x: (source_width - width) / 2, + y: 0, + width, + height: source_height, + }) + } +} + +/// Builds a deterministic hero crop plan from optional person and face boxes. +/// +/// Person boxes are the primary signal. Faces only refine the vertical +/// composition when they can be associated with a person, which prevents faces +/// in murals, posters, and album art from pulling the crop away from the artist. +/// Horizontal placement never follows a detection: wide sources stay centered, +/// and tall sources retain the maximum possible source width. +pub(crate) fn plan_hero_crop( + source_width: u32, + source_height: u32, + subjects: &[SubjectBox], + faces: &[FaceBox], +) -> Option { + let mut crop = maximal_hero_rect(source_width, source_height)?; + let fallback_y = crop.y; + let max_y = source_height - crop.height; + + let valid_subjects: Vec<_> = subjects + .iter() + .filter_map(|subject| { + sanitize_box( + subject.x, + subject.y, + subject.width, + subject.height, + subject.confidence, + source_width, + source_height, + ) + }) + .collect(); + + if max_y == 0 { + return Some(HeroCropPlan { + source_width, + source_height, + crop, + content_aware: false, + focal_y: None, + subject_count: valid_subjects.len() as u32, + associated_face_count: 0, + }); + } + + if valid_subjects.is_empty() { + // Face-only is a secondary fallback for portraits where the general + // person model misses a close or partially visible subject. Provider + // hardening already rejects most posters/text cards; cap implausibly + // huge boxes and use a robust median so one false positive cannot + // dominate a group. + let source_area = source_width as f32 * source_height as f32; + let valid_faces: Vec<_> = faces + .iter() + .filter_map(|face| { + sanitize_box( + face.x, + face.y, + face.width, + face.height, + face.confidence, + source_width, + source_height, + ) + }) + .filter(|face| face.area() <= source_area * 0.25) + .collect(); + let face_values: Vec<_> = valid_faces + .iter() + .map(|face| WeightedValue { + value: face.center_y() - crop.height as f32 * FACE_CENTER_TARGET, + weight: detection_weight(*face), + }) + .collect(); + if let Some(desired_y) = weighted_median(&face_values) { + // Same inviolable rule as the person path: the composition target + // may place the window, but never past a face. + let desired_y = protect_faces(desired_y, &valid_faces, &[], crop.height); + crop.y = desired_y.round().clamp(0.0, max_y as f32) as u32; + return Some(HeroCropPlan { + source_width, + source_height, + crop, + content_aware: true, + focal_y: weighted_median( + &valid_faces + .iter() + .map(|face| WeightedValue { + value: face.center_y(), + weight: detection_weight(*face), + }) + .collect::>(), + ), + subject_count: 0, + associated_face_count: valid_faces.len() as u32, + }); + } + return Some(HeroCropPlan { + source_width, + source_height, + crop, + content_aware: false, + focal_y: None, + subject_count: 0, + associated_face_count: 0, + }); + } + + let person_values: Vec<_> = valid_subjects + .iter() + .map(|subject| WeightedValue { + value: subject.center_y() - crop.height as f32 * PERSON_CENTER_TARGET, + weight: detection_weight(*subject), + }) + .collect(); + let person_y = weighted_median(&person_values).unwrap_or(fallback_y as f32); + + // These robust bounds keep nearly all of a typical detected body visible, + // without allowing one outlying/oversized box to dictate the result. + let lower_values: Vec<_> = valid_subjects + .iter() + .map(|subject| WeightedValue { + value: subject.top + subject.height() * PERSON_BOTTOM_COVERAGE - crop.height as f32, + weight: detection_weight(*subject), + }) + .collect(); + let upper_values: Vec<_> = valid_subjects + .iter() + .map(|subject| WeightedValue { + value: subject.top + subject.height() * PERSON_TOP_MARGIN, + weight: detection_weight(*subject), + }) + .collect(); + + let lower_y = weighted_median(&lower_values).unwrap_or(0.0); + let upper_y = weighted_median(&upper_values).unwrap_or(max_y as f32); + + let valid_faces: Vec<_> = faces + .iter() + .filter_map(|face| { + sanitize_box( + face.x, + face.y, + face.width, + face.height, + face.confidence, + source_width, + source_height, + ) + }) + .filter(|face| { + valid_subjects + .iter() + .any(|subject| face_belongs_to(*face, *subject)) + }) + .collect(); + + let face_values: Vec<_> = valid_faces + .iter() + .map(|face| WeightedValue { + value: face.center_y() - crop.height as f32 * FACE_CENTER_TARGET, + weight: detection_weight(*face), + }) + .collect(); + + let mut desired_y = person_y; + if let Some(face_y) = weighted_median(&face_values) { + desired_y = person_y * (1.0 - FACE_REFINEMENT_WEIGHT) + face_y * FACE_REFINEMENT_WEIGHT; + } + + // Use body-coverage guards when both can be satisfied. If a close subject + // fills more than the crop height, the robust composition targets decide + // which portion to retain rather than sacrificing the head or lower body by + // blindly snapping to one incompatible edge. + let unclamped_y = desired_y; + if upper_y >= lower_y { + desired_y = desired_y.clamp(lower_y, upper_y); + } + // Faces outrank body coverage, always. A standing subject is usually + // TALLER than the 16:9 window, so the coverage guards above ask the window + // to slide down until 93% of the body fits — and when the body runs to the + // bottom edge, the only thing left to sacrifice is the head. Measured on + // Crazy P's MusicRadar photo: composition wanted y=-3.6 (every head in + // frame) and the coverage clamp forced y=113, decapitating all three. + // Legs may be cut; faces may not. + desired_y = protect_faces(desired_y, &valid_faces, &valid_subjects, crop.height); + crop.y = desired_y.round().clamp(0.0, max_y as f32) as u32; + crop_trace(|| { + let faces: Vec = valid_faces + .iter() + .map(|face| format!("[y{:.0}+{:.0}]", face.top, face.height())) + .collect(); + let people: Vec = valid_subjects + .iter() + .map(|subject| format!("[y{:.0}+{:.0}]", subject.top, subject.height())) + .collect(); + format!( + "src={source_width}x{source_height} croph={} max_y={max_y} people={} faces={} \ + person_y={person_y:.1} face_y={:?} desired={unclamped_y:.1} \ + lower={lower_y:.1} upper={upper_y:.1} clamped={} final_y={}", + crop.height, + people.join(","), + faces.join(","), + weighted_median(&face_values).map(|value| (value * 10.0).round() / 10.0), + upper_y >= lower_y, + crop.y, + ) + }); + + let focal_y = if valid_faces.is_empty() { + weighted_median( + &valid_subjects + .iter() + .map(|subject| WeightedValue { + value: subject.center_y(), + weight: detection_weight(*subject), + }) + .collect::>(), + ) + } else { + weighted_median( + &valid_faces + .iter() + .map(|face| WeightedValue { + value: face.center_y(), + weight: detection_weight(*face), + }) + .collect::>(), + ) + }; + + Some(HeroCropPlan { + source_width, + source_height, + crop, + content_aware: true, + focal_y, + subject_count: valid_subjects.len() as u32, + associated_face_count: valid_faces.len() as u32, + }) +} + +fn sanitize_box( + x: f32, + y: f32, + width: f32, + height: f32, + confidence: f32, + source_width: u32, + source_height: u32, +) -> Option { + if !x.is_finite() + || !y.is_finite() + || !width.is_finite() + || !height.is_finite() + || !confidence.is_finite() + || width <= 0.0 + || height <= 0.0 + || confidence <= 0.0 + { + return None; + } + + let left = x.clamp(0.0, source_width as f32); + let top = y.clamp(0.0, source_height as f32); + let right = (x + width).clamp(0.0, source_width as f32); + let bottom = (y + height).clamp(0.0, source_height as f32); + if right - left < 1.0 || bottom - top < 1.0 { + return None; + } + + Some(SanitizedBox { + left, + top, + right, + bottom, + confidence: confidence.clamp(0.0, 1.0), + }) +} + +fn detection_weight(detection: SanitizedBox) -> f32 { + // Square-root area weighting lets prominent subjects matter more while + // avoiding the domination that raw pixel area would cause. + detection.confidence.max(0.05) * detection.area().sqrt().max(1.0) +} + +fn face_belongs_to(face: SanitizedBox, subject: SanitizedBox) -> bool { + // A face can sit slightly outside an imperfect person box, especially at + // the top, but it must remain in the upper portion of that subject. + let horizontal_padding = subject.width() * 0.10; + let top_padding = subject.height() * 0.18; + let center_inside = face.center_x() >= subject.left - horizontal_padding + && face.center_x() <= subject.right + horizontal_padding + && face.center_y() >= subject.top - top_padding + && face.center_y() <= subject.top + subject.height() * 0.62; + + let overlap_left = face.left.max(subject.left); + let overlap_top = face.top.max(subject.top - top_padding); + let overlap_right = face.right.min(subject.right); + let overlap_bottom = face.bottom.min(subject.top + subject.height() * 0.62); + let overlap_area = + (overlap_right - overlap_left).max(0.0) * (overlap_bottom - overlap_top).max(0.0); + let mostly_overlaps = overlap_area / face.area() >= 0.35; + + // Reject implausibly large "faces" such as portrait murals that happen to + // overlap a small person box. + let plausible_size = face.area() <= subject.area() * 0.60; + plausible_size && (center_inside || mostly_overlaps) +} + +fn weighted_median(values: &[WeightedValue]) -> Option { + let mut values: Vec<_> = values + .iter() + .copied() + .filter(|entry| entry.value.is_finite() && entry.weight.is_finite() && entry.weight > 0.0) + .collect(); + if values.is_empty() { + return None; + } + + values.sort_by(|a, b| a.value.total_cmp(&b.value)); + let total_weight: f32 = values.iter().map(|entry| entry.weight).sum(); + let midpoint = total_weight * 0.5; + let mut accumulated = 0.0; + for entry in &values { + accumulated += entry.weight; + if accumulated >= midpoint { + return Some(entry.value); + } + } + + values.last().map(|entry| entry.value) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn subject(x: f32, y: f32, width: f32, height: f32) -> SubjectBox { + SubjectBox { + x, + y, + width, + height, + confidence: 0.95, + } + } + + fn face(x: f32, y: f32, width: f32, height: f32) -> FaceBox { + FaceBox { + x, + y, + width, + height, + confidence: 0.95, + } + } + + /// Real detector output for Crazy P's MusicRadar photo (850x638, three + /// standing subjects running to the bottom edge). The body-coverage guards + /// wanted y=113, which cut every head off; faces must win. + #[test] + fn standing_group_keeps_heads_when_bodies_cannot_fit() { + let subjects = [ + subject(0.0, 83.0, 200.0, 547.0), + subject(250.0, 26.0, 200.0, 610.0), + subject(500.0, 102.0, 200.0, 486.0), + ]; + let faces = [ + face(60.0, 47.0, 70.0, 81.0), + face(300.0, 87.0, 62.0, 72.0), + face(560.0, 127.0, 50.0, 57.0), + ]; + let plan = plan_hero_crop(850, 638, &subjects, &faces).expect("plan"); + assert!(plan.content_aware); + // Every face, and hair above the highest one, stays inside the window. + let highest_face_top = 47.0; + assert!( + (plan.crop.y as f32) < highest_face_top, + "crop started at {} which cuts the highest face at {highest_face_top}", + plan.crop.y, + ); + let lowest_face_bottom = 127.0 + 57.0; + assert!( + (plan.crop.y + plan.crop.height) as f32 >= lowest_face_bottom, + "crop bottom {} clipped the lowest face at {lowest_face_bottom}", + plan.crop.y + plan.crop.height, + ); + } + + /// A face span taller than the window cannot be fully covered; the crop + /// centres it rather than sacrificing one extreme entirely. + #[test] + fn face_span_taller_than_crop_is_centred() { + // Two subjects at very different depths, each with a face at its own + // head: the face span (20..930) exceeds the 900px window. + let subjects = [ + subject(0.0, 0.0, 300.0, 700.0), + subject(400.0, 760.0, 300.0, 700.0), + ]; + let faces = [ + face(50.0, 20.0, 120.0, 140.0), + face(450.0, 790.0, 120.0, 140.0), + ]; + let plan = plan_hero_crop(1600, 1500, &subjects, &faces).expect("plan"); + let span_center = (20.0 + 930.0) / 2.0; + let crop_center = plan.crop.y as f32 + plan.crop.height as f32 / 2.0; + assert!( + (crop_center - span_center).abs() <= plan.crop.height as f32 * 0.25, + "crop centre {crop_center} strayed from the face span centre {span_center}", + ); + } + + #[test] + fn maximal_crop_retains_full_width_or_height() { + let square = maximal_hero_rect(2048, 2048).unwrap(); + assert_eq!( + square, + HeroRect { + x: 0, + y: 448, + width: 2048, + height: 1152 + } + ); + assert_eq!(square.x, 0); + assert_eq!(square.width, 2048); + + let wide = maximal_hero_rect(2560, 1080).unwrap(); + assert_eq!( + wide, + HeroRect { + x: 320, + y: 0, + width: 1920, + height: 1080 + } + ); + assert_eq!(wide.y, 0); + assert_eq!(wide.height, 1080); + } + + #[test] + fn odd_tall_source_dimensions_keep_every_horizontal_pixel() { + let crop = maximal_hero_rect(1001, 1001).unwrap(); + assert_eq!( + crop, + HeroRect { + x: 0, + y: 219, + width: 1001, + height: 563 + } + ); + assert_eq!(crop.width, 1001); + assert!((crop.width as f64 / crop.height as f64 - 16.0 / 9.0).abs() < 0.002); + } + + #[test] + fn zero_or_subpixel_crop_dimensions_return_none() { + assert_eq!(maximal_hero_rect(1, 100), None); + assert_eq!(maximal_hero_rect(100, 0), None); + assert_eq!(maximal_hero_rect(0, 0), None); + } + + #[test] + fn camping_dimensions_have_expected_centered_fallback() { + let plan = plan_hero_crop(2560, 1920, &[], &[]).unwrap(); + assert_eq!( + plan.crop, + HeroRect { + x: 0, + y: 240, + width: 2560, + height: 1440 + } + ); + assert!(!plan.content_aware); + assert_eq!(plan.focal_y, None); + } + + #[test] + fn face_only_portrait_still_gets_vertical_composition() { + let faces = [face(900.0, 120.0, 420.0, 480.0)]; + let plan = plan_hero_crop(2560, 1920, &[], &faces).unwrap(); + assert!(plan.content_aware); + assert_eq!(plan.subject_count, 0); + assert_eq!(plan.associated_face_count, 1); + assert_eq!(plan.crop.y, 0); + } + + #[test] + fn bottom_heavy_group_moves_camping_crop_down() { + let subjects = [ + subject(80.0, 790.0, 520.0, 1130.0), + subject(610.0, 800.0, 520.0, 1120.0), + subject(1210.0, 760.0, 510.0, 1160.0), + subject(1810.0, 650.0, 650.0, 1270.0), + ]; + let faces = [ + face(210.0, 820.0, 180.0, 190.0), + face(760.0, 810.0, 170.0, 190.0), + face(1370.0, 760.0, 170.0, 190.0), + face(2030.0, 650.0, 190.0, 205.0), + ]; + + let plan = plan_hero_crop(2560, 1920, &subjects, &faces).unwrap(); + assert_eq!(plan.crop.x, 0); + assert_eq!((plan.crop.width, plan.crop.height), (2560, 1440)); + assert!( + (390..=430).contains(&plan.crop.y), + "unexpected y={}", + plan.crop.y + ); + assert!(plan.content_aware); + assert_eq!(plan.subject_count, 4); + assert_eq!(plan.associated_face_count, 4); + } + + #[test] + fn top_heavy_subjects_move_crop_to_top() { + let subjects = [ + subject(250.0, 0.0, 700.0, 920.0), + subject(1250.0, 20.0, 700.0, 900.0), + ]; + let faces = [ + face(440.0, 80.0, 190.0, 210.0), + face(1450.0, 100.0, 190.0, 210.0), + ]; + + let plan = plan_hero_crop(2560, 1920, &subjects, &faces).unwrap(); + assert_eq!(plan.crop.y, 0); + assert_eq!(plan.associated_face_count, 2); + } + + #[test] + fn unassociated_mural_face_cannot_pull_crop_up() { + let subjects = [ + subject(150.0, 790.0, 500.0, 1130.0), + subject(750.0, 800.0, 500.0, 1120.0), + subject(1350.0, 760.0, 500.0, 1160.0), + subject(1950.0, 700.0, 500.0, 1220.0), + ]; + let people_faces = [ + face(300.0, 810.0, 170.0, 190.0), + face(900.0, 820.0, 170.0, 190.0), + face(1500.0, 780.0, 170.0, 190.0), + face(2100.0, 720.0, 170.0, 190.0), + ]; + let mut with_mural = people_faces.to_vec(); + with_mural.push(face(900.0, 70.0, 500.0, 560.0)); + + let without_mural = plan_hero_crop(2560, 1920, &subjects, &people_faces).unwrap(); + let with_mural = plan_hero_crop(2560, 1920, &subjects, &with_mural).unwrap(); + assert_eq!(with_mural.crop.y, without_mural.crop.y); + assert_eq!(with_mural.associated_face_count, 4); + } + + #[test] + fn malformed_detections_produce_centered_fallback() { + let subjects = [ + subject(0.0, 0.0, -20.0, 100.0), + SubjectBox { + confidence: f32::NAN, + ..subject(10.0, 10.0, 100.0, 100.0) + }, + subject(3000.0, 3000.0, 100.0, 100.0), + ]; + let plan = plan_hero_crop(2560, 1920, &subjects, &[]).unwrap(); + assert_eq!(plan.crop.y, 240); + assert!(!plan.content_aware); + assert_eq!(plan.subject_count, 0); + } + + #[test] + fn wide_sources_always_keep_horizontal_crop_centered() { + let subjects = [subject(0.0, 0.0, 500.0, 1080.0)]; + let faces = [face(20.0, 40.0, 200.0, 220.0)]; + let plan = plan_hero_crop(2560, 1080, &subjects, &faces).unwrap(); + assert_eq!( + plan.crop, + HeroRect { + x: 320, + y: 0, + width: 1920, + height: 1080 + } + ); + } + + #[test] + fn horizontal_position_never_tracks_an_off_center_subject() { + let subjects = [subject(2200.0, 700.0, 340.0, 1220.0)]; + let faces = [face(2300.0, 760.0, 130.0, 150.0)]; + let plan = plan_hero_crop(2560, 1920, &subjects, &faces).unwrap(); + assert_eq!(plan.crop.x, 0); + assert_eq!(plan.crop.width, 2560); + assert!(plan.crop.y > 240); + } +} diff --git a/src-tauri/src/hero_detector.rs b/src-tauri/src/hero_detector.rs new file mode 100644 index 0000000..2132b04 --- /dev/null +++ b/src-tauri/src/hero_detector.rs @@ -0,0 +1,468 @@ +//! Lightweight, local subject analysis for artist-hero composition. +//! +//! Person boxes come from the bundled YOLOX-Nano model through the Windows +//! ML runtime already present in Windows 10/11. Faces come from the native +//! Windows face detector, so no second model or inference runtime is shipped. +//! All failures are recoverable: callers fall back to a deterministic crop. + +use crate::hero_crop::{FaceBox, SubjectBox}; + +#[cfg(windows)] +mod windows_detector { + use std::sync::OnceLock; + + use image::{DynamicImage, GenericImageView as _, ImageEncoder as _}; + use tauri::Manager as _; + use windows::{ + core::{Interface as _, HSTRING}, + Graphics::Imaging::{BitmapDecoder, BitmapPixelFormat, BitmapSize, SoftwareBitmap}, + Media::FaceAnalysis::FaceDetector, + Storage::Streams::{DataWriter, InMemoryRandomAccessStream}, + Win32::System::WinRT::{RoInitialize, RoUninitialize, RO_INIT_MULTITHREADED}, + AI::MachineLearning::{ + LearningModel, LearningModelBinding, LearningModelDevice, LearningModelDeviceKind, + LearningModelSession, TensorFloat, + }, + }; + + use super::{FaceBox, SubjectBox}; + + const YOLOX_EDGE: u32 = 416; + const YOLOX_CANDIDATES: usize = 3549; + const YOLOX_VALUES: usize = 85; + const PERSON_SCORE_MIN: f32 = 0.25; + const PERSON_NMS_IOU: f32 = 0.45; + const FACE_ANALYSIS_MAX_EDGE: u32 = 960; + + static PERSON_SESSION: OnceLock> = OnceLock::new(); + static FACE_DETECTOR: OnceLock> = OnceLock::new(); + + struct WinRtGuard; + + impl Drop for WinRtGuard { + fn drop(&mut self) { + unsafe { RoUninitialize() }; + } + } + + fn initialize_winrt() -> Result, String> { + match unsafe { RoInitialize(RO_INIT_MULTITHREADED) } { + Ok(()) => Ok(Some(WinRtGuard)), + Err(error) if error.code().0 as u32 == 0x8001_0106 => { + // The current thread already has a different apartment. WinRT + // remains usable there; it simply must not be uninitialized by us. + Ok(None) + } + Err(error) => Err(format!("Windows Runtime initialization failed: {error}")), + } + } + + fn model_path(app: &tauri::AppHandle) -> Result { + let bundled = app + .path() + .resolve( + "resources/yolox_nano.onnx", + tauri::path::BaseDirectory::Resource, + ) + .map_err(|error| error.to_string())?; + if bundled.is_file() { + return Ok(bundled); + } + let development = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("yolox_nano.onnx"); + if development.is_file() { + return Ok(development); + } + Err("bundled YOLOX-Nano model is missing".into()) + } + + fn person_session_from_path(path: &std::path::Path) -> Result { + PERSON_SESSION + .get_or_init(|| { + let model = LearningModel::LoadFromFilePath(&HSTRING::from( + path.to_string_lossy().as_ref(), + )) + .map_err(|error| format!("could not load YOLOX-Nano: {error}"))?; + let device = LearningModelDevice::Create(LearningModelDeviceKind::Cpu) + .map_err(|error| format!("could not create WinML CPU device: {error}"))?; + LearningModelSession::CreateFromModelOnDevice(&model, &device) + .map_err(|error| format!("could not create YOLOX session: {error}")) + }) + .clone() + } + + fn person_session(app: &tauri::AppHandle) -> Result { + person_session_from_path(&model_path(app)?) + } + + fn face_detector() -> Result { + FACE_DETECTOR + .get_or_init(|| { + if !FaceDetector::IsSupported() + .map_err(|error| format!("could not query face detector: {error}"))? + { + return Err("Windows face detection is unavailable".into()); + } + let detector = FaceDetector::CreateAsync() + .map_err(|error| format!("could not create face detector: {error}"))? + .get() + .map_err(|error| format!("could not initialize face detector: {error}"))?; + // Small faces matter in group press photos. Analysis is capped + // at 960 px, so 18 px still avoids tiny texture false positives. + let _ = detector.SetMinDetectableFaceSize(BitmapSize { + Width: 18, + Height: 18, + }); + Ok(detector) + }) + .clone() + } + + pub(super) fn detect( + app: &tauri::AppHandle, + image: &DynamicImage, + ) -> Result<(Vec, Vec), String> { + let _winrt = initialize_winrt()?; + let subjects = detect_people(app, image)?; + // Face refinement is optional. A native-detector failure must not throw + // away valid person boxes from WinML. + let faces = match detect_faces(image) { + Ok(faces) => faces, + Err(error) => { + log::warn!("artist hero face refinement unavailable: {error}"); + Vec::new() + } + }; + Ok((subjects, faces)) + } + + pub(super) fn warm_up(app: tauri::AppHandle) { + std::thread::Builder::new() + .name("hero-vision-warmup".into()) + .spawn(move || { + let Ok(_winrt) = initialize_winrt() else { + return; + }; + if let Err(error) = person_session(&app) { + log::warn!("artist hero person detector warm-up failed: {error}"); + } + if let Err(error) = face_detector() { + log::warn!("artist hero face detector warm-up failed: {error}"); + } + }) + .ok(); + } + + fn detect_people( + app: &tauri::AppHandle, + image: &DynamicImage, + ) -> Result, String> { + detect_people_with_model_path(&model_path(app)?, image) + } + + fn detect_people_with_model_path( + model_path: &std::path::Path, + image: &DynamicImage, + ) -> Result, String> { + let (source_width, source_height) = image.dimensions(); + if source_width == 0 || source_height == 0 { + return Ok(Vec::new()); + } + + let scale = + (YOLOX_EDGE as f32 / source_width as f32).min(YOLOX_EDGE as f32 / source_height as f32); + let resized_width = ((source_width as f32 * scale).floor() as u32).clamp(1, YOLOX_EDGE); + let resized_height = ((source_height as f32 * scale).floor() as u32).clamp(1, YOLOX_EDGE); + let rgb = image.to_rgb8(); + let resized = image::imageops::resize( + &rgb, + resized_width, + resized_height, + image::imageops::FilterType::Triangle, + ); + + let plane_len = (YOLOX_EDGE * YOLOX_EDGE) as usize; + let mut tensor_data = vec![114.0f32; plane_len * 3]; + for y in 0..resized_height { + for x in 0..resized_width { + let pixel = resized.get_pixel(x, y).0; + let offset = (y * YOLOX_EDGE + x) as usize; + // Official YOLOX preprocessing consumes OpenCV BGR without + // normalization, transposed directly into NCHW planes. + tensor_data[offset] = f32::from(pixel[2]); + tensor_data[plane_len + offset] = f32::from(pixel[1]); + tensor_data[plane_len * 2 + offset] = f32::from(pixel[0]); + } + } + + let input = TensorFloat::CreateFromShapeArrayAndDataArray( + &[1, 3, i64::from(YOLOX_EDGE), i64::from(YOLOX_EDGE)], + &tensor_data, + ) + .map_err(|error| format!("could not create YOLOX input: {error}"))?; + let session = person_session_from_path(model_path)?; + let binding = LearningModelBinding::CreateFromSession(&session) + .map_err(|error| format!("could not create YOLOX binding: {error}"))?; + binding + .Bind(&HSTRING::from("images"), &input) + .map_err(|error| format!("could not bind YOLOX input: {error}"))?; + let result = session + .Evaluate(&binding, &HSTRING::from("artist-hero")) + .map_err(|error| format!("YOLOX inference failed: {error}"))?; + if !result + .Succeeded() + .map_err(|error| format!("could not read YOLOX status: {error}"))? + { + return Err(format!( + "YOLOX evaluation failed with status {}", + result.ErrorStatus().unwrap_or_default() + )); + } + let output = result + .Outputs() + .and_then(|outputs| outputs.Lookup(&HSTRING::from("output"))) + .map_err(|error| format!("YOLOX output is missing: {error}"))?; + let tensor: TensorFloat = output + .cast() + .map_err(|error| format!("YOLOX output has the wrong type: {error}"))?; + let view = tensor + .GetAsVectorView() + .map_err(|error| format!("could not read YOLOX output: {error}"))?; + let values: Vec = view.into_iter().collect(); + if values.len() != YOLOX_CANDIDATES * YOLOX_VALUES { + return Err(format!("unexpected YOLOX output length: {}", values.len())); + } + + let mut candidates = Vec::new(); + let mut candidate_index = 0usize; + for stride in [8u32, 16, 32] { + let grid = YOLOX_EDGE / stride; + for gy in 0..grid { + for gx in 0..grid { + let row = &values + [candidate_index * YOLOX_VALUES..(candidate_index + 1) * YOLOX_VALUES]; + candidate_index += 1; + let confidence = row[4] * row[5]; + if !confidence.is_finite() || confidence < PERSON_SCORE_MIN { + continue; + } + let center_x = (row[0] + gx as f32) * stride as f32 / scale; + let center_y = (row[1] + gy as f32) * stride as f32 / scale; + let width = row[2].clamp(-10.0, 10.0).exp() * stride as f32 / scale; + let height = row[3].clamp(-10.0, 10.0).exp() * stride as f32 / scale; + if !center_x.is_finite() + || !center_y.is_finite() + || !width.is_finite() + || !height.is_finite() + { + continue; + } + let left = (center_x - width * 0.5).clamp(0.0, source_width as f32); + let top = (center_y - height * 0.5).clamp(0.0, source_height as f32); + let right = (center_x + width * 0.5).clamp(0.0, source_width as f32); + let bottom = (center_y + height * 0.5).clamp(0.0, source_height as f32); + if right - left < 8.0 || bottom - top < 16.0 { + continue; + } + candidates.push(SubjectBox { + x: left, + y: top, + width: right - left, + height: bottom - top, + confidence, + }); + } + } + } + debug_assert_eq!(candidate_index, YOLOX_CANDIDATES); + Ok(non_maximum_suppression(candidates, PERSON_NMS_IOU, 24)) + } + + fn detect_faces(image: &DynamicImage) -> Result, String> { + let (source_width, source_height) = image.dimensions(); + if source_width == 0 || source_height == 0 { + return Ok(Vec::new()); + } + let detection = image.thumbnail(FACE_ANALYSIS_MAX_EDGE, FACE_ANALYSIS_MAX_EDGE); + let gray = detection.to_luma8(); + let (width, height) = gray.dimensions(); + let mut png = Vec::new(); + image::codecs::png::PngEncoder::new(&mut png) + .write_image(gray.as_raw(), width, height, image::ExtendedColorType::L8) + .map_err(|error| format!("could not encode face-analysis raster: {error}"))?; + + let stream = InMemoryRandomAccessStream::new() + .map_err(|error| format!("could not allocate face-analysis stream: {error}"))?; + let writer = DataWriter::CreateDataWriter(&stream) + .map_err(|error| format!("could not create face-analysis writer: {error}"))?; + writer + .WriteBytes(&png) + .map_err(|error| format!("could not write face-analysis raster: {error}"))?; + writer + .StoreAsync() + .and_then(|operation| operation.get()) + .map_err(|error| format!("could not store face-analysis raster: {error}"))?; + writer + .FlushAsync() + .and_then(|operation| operation.get()) + .map_err(|error| format!("could not flush face-analysis raster: {error}"))?; + let _ = writer.DetachStream(); + stream + .Seek(0) + .map_err(|error| format!("could not rewind face-analysis raster: {error}"))?; + let decoder = BitmapDecoder::CreateAsync(&stream) + .and_then(|operation| operation.get()) + .map_err(|error| format!("could not decode face-analysis raster: {error}"))?; + let decoded_bitmap = decoder + .GetSoftwareBitmapAsync() + .and_then(|operation| operation.get()) + .map_err(|error| format!("could not prepare face-analysis bitmap: {error}"))?; + let bitmap = SoftwareBitmap::Convert(&decoded_bitmap, BitmapPixelFormat::Gray8) + .map_err(|error| format!("could not convert face-analysis bitmap: {error}"))?; + let found = face_detector()? + .DetectFacesAsync(&bitmap) + .and_then(|operation| operation.get()) + .map_err(|error| format!("face detection failed: {error}"))?; + + let scale_x = source_width as f32 / width as f32; + let scale_y = source_height as f32 / height as f32; + let mut faces = Vec::new(); + for detected in &found { + let bounds = detected + .FaceBox() + .map_err(|error| format!("could not read face bounds: {error}"))?; + faces.push(FaceBox { + x: bounds.X as f32 * scale_x, + y: bounds.Y as f32 * scale_y, + width: bounds.Width as f32 * scale_x, + height: bounds.Height as f32 * scale_y, + confidence: 1.0, + }); + } + Ok(faces) + } + + fn non_maximum_suppression( + mut candidates: Vec, + threshold: f32, + limit: usize, + ) -> Vec { + candidates.sort_by(|a, b| b.confidence.total_cmp(&a.confidence)); + let mut kept = Vec::new(); + 'candidate: for candidate in candidates { + for accepted in &kept { + if intersection_over_union(candidate, *accepted) > threshold { + continue 'candidate; + } + } + kept.push(candidate); + if kept.len() == limit { + break; + } + } + kept + } + + fn intersection_over_union(a: SubjectBox, b: SubjectBox) -> f32 { + let left = a.x.max(b.x); + let top = a.y.max(b.y); + let right = (a.x + a.width).min(b.x + b.width); + let bottom = (a.y + a.height).min(b.y + b.height); + let intersection = (right - left).max(0.0) * (bottom - top).max(0.0); + let union = a.width * a.height + b.width * b.height - intersection; + if union > 0.0 { + intersection / union + } else { + 0.0 + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn nms_keeps_the_best_overlapping_person() { + let kept = non_maximum_suppression( + vec![ + SubjectBox { + x: 0.0, + y: 0.0, + width: 100.0, + height: 200.0, + confidence: 0.9, + }, + SubjectBox { + x: 2.0, + y: 4.0, + width: 100.0, + height: 200.0, + confidence: 0.8, + }, + SubjectBox { + x: 300.0, + y: 0.0, + width: 100.0, + height: 200.0, + confidence: 0.7, + }, + ], + 0.45, + 24, + ); + assert_eq!(kept.len(), 2); + assert_eq!(kept[0].confidence, 0.9); + } + + /// Manual, reusable integration probe. It is ignored in normal CI; + /// provide an image through SOULSTREAM_HERO_FIXTURE when validating a + /// new detector/model or an especially difficult artist photo. + #[test] + #[ignore] + fn fixture_detection_and_crop_probe() { + let fixture = std::env::var("SOULSTREAM_HERO_FIXTURE") + .expect("set SOULSTREAM_HERO_FIXTURE to a source image"); + let image = image::open(fixture).expect("fixture must decode"); + let _winrt = initialize_winrt().expect("WinRT must initialize"); + let model = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("yolox_nano.onnx"); + let subjects = detect_people_with_model_path(&model, &image) + .expect("person inference must succeed"); + let faces = detect_faces(&image).expect("face detection must succeed"); + let plan = + crate::hero_crop::plan_hero_crop(image.width(), image.height(), &subjects, &faces) + .expect("fixture must plan"); + eprintln!("subjects={subjects:#?}"); + eprintln!("faces={faces:#?}"); + eprintln!("plan={plan:#?}"); + assert!(plan.crop.width > 0 && plan.crop.height > 0); + } + } +} + +/// Detects people and faces in source-image coordinates. This is deliberately +/// a fallible enhancement: hero processing must continue when vision APIs are +/// unavailable (including non-Windows development builds). +pub(crate) fn detect_subjects( + app: &tauri::AppHandle, + image: &image::DynamicImage, +) -> Result<(Vec, Vec), String> { + #[cfg(windows)] + { + windows_detector::detect(app, image) + } + #[cfg(not(windows))] + { + let _ = (app, image); + Err("content-aware hero detection is only available on Windows".into()) + } +} + +pub(crate) fn warm_up(app: tauri::AppHandle) { + #[cfg(windows)] + windows_detector::warm_up(app); + #[cfg(not(windows))] + let _ = app; +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..d4a150c --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,127 @@ +mod commands; +mod config; +mod cover_logo; +mod hero_crop; +mod hero_detector; +mod pipeline; +mod playback; +mod state; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .manage(state::AppState::default()) + .setup(|app| { + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .clear_targets() + .target(tauri_plugin_log::Target::new( + tauri_plugin_log::TargetKind::Stdout, + )) + .build(), + )?; + app.handle().plugin(tauri_plugin_dialog::init())?; + // Must run before anything touches the app-data dir. + config::migrate_app_data(app.handle()); + // Prepare the small local vision model while the user is still on + // the app shell, so a first artist visit pays only warm inference. + hero_detector::warm_up(app.handle().clone()); + playback::init(app.handle()); + playback::prune_cache(app.handle()); + if let Err(e) = pipeline::init(app.handle()) { + log::error!("library init failed: {e}"); + } + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + commands::app_status, + commands::excluded_search_phrases, + commands::get_credentials, + commands::connect_and_login, + commands::connect_stored, + commands::disconnect, + commands::start_search, + commands::stop_search, + commands::download_file, + commands::copy_stream_to_folder, + commands::save_to_library, + commands::cancel_download, + commands::show_in_folder, + commands::refresh_artist_art, + commands::play_stream, + commands::play_local, + commands::preload_local, + commands::player_control, + commands::waveform, + commands::library_albums, + commands::album_tracks, + commands::album_art, + commands::refresh_album_art, + commands::refresh_all_album_art, + commands::get_settings, + commands::set_settings, + commands::forget_credentials, + commands::pick_folder, + commands::open_external, + commands::record_play, + commands::add_play_seconds, + commands::artist_play_stats, + commands::list_playlists, + commands::create_playlist, + commands::delete_playlist, + commands::playlist_tracks, + commands::add_to_playlist, + commands::remove_from_playlist, + commands::catalog_search, + commands::catalog_artist, + commands::catalog_artist_art, + commands::catalog_artist_logos, + commands::catalog_artist_identity, + commands::catalog_artist_socials, + commands::catalog_artist_bio, + commands::catalog_cached_cover_logo, + commands::catalog_cover_logo, + commands::catalog_cover_logo_debug, + commands::catalog_ocr_experiment, + commands::catalog_artist_hero_seed, + commands::catalog_artist_overview, + commands::pick_artist_hero, + commands::clear_artist_hero, + commands::catalog_related, + commands::catalog_album, + commands::catalog_album_info, + commands::catalog_art, + commands::catalog_cover, + commands::invalidate_catalog_cover, + commands::catalog_hero_preview, + commands::catalog_hero_gpu_source, + commands::catalog_cached_hero_art, + commands::catalog_hero_art, + commands::preload_stream, + commands::preload_cached_stream, + commands::stream_waveform, + ]) + .run({ + #[allow(unused_mut)] + let mut context = tauri::generate_context!(); + // Debug builds expose CDP on 9222 for instrumentation (see HANDOFF.md). + // wry always sets AdditionalBrowserArguments programmatically, so the + // WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS env var is ignored; the flag has + // to go through the window config. Explicit args replace wry's defaults, + // so those are restated here. + #[cfg(debug_assertions)] + { + for window in &mut context.config_mut().app.windows { + window.additional_browser_args = Some( + "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection \ + --autoplay-policy=no-user-gesture-required \ + --remote-debugging-port=9222" + .into(), + ); + } + } + context + }) + .expect("error while running tauri application"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..69c3a72 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + app_lib::run(); +} diff --git a/src-tauri/src/pipeline.rs b/src-tauri/src/pipeline.rs new file mode 100644 index 0000000..b6a78b6 --- /dev/null +++ b/src-tauri/src/pipeline.rs @@ -0,0 +1,1103 @@ +//! Post-download pipeline: tags → MusicBrainz canonicalization → album art +//! → embed → library DB. Also scans the downloads folder on startup so +//! pre-existing files join the library. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use tauri::{Emitter, Manager}; + +use library::Library; +use metadata::{AlbumMeta, MetadataClient}; +use tagger::{TagSummary, TagWrite}; + +const AUDIO_EXTS: [&str; 8] = ["flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma"]; + +pub struct LibraryState { + pub library: Arc, + pub(crate) metadata: Arc, + /// Unix seconds of the last user-facing catalog command. The library + /// identity warmer only works while this has been quiet, so background + /// warmth never queues ahead of a page the user is actually looking at. + pub(crate) catalog_activity: std::sync::atomic::AtomicU64, + art_dir: PathBuf, + /// (artist, album) lowercase → resolved metadata, deduping MB lookups. + album_cache: tokio::sync::Mutex>>, + /// Serializes art fetch per album key. + art_locks: Mutex>>>, + /// Download-staging dir (`//`) → organized album dir. + /// Lets late-arriving non-audio extras follow their album, after which the + /// emptied staging dirs are removed. + staging_targets: Mutex>, +} + +impl LibraryState { + /// Stamp a user-facing catalog command; the identity warmer defers to this. + pub(crate) fn touch_catalog_activity(&self) { + self.catalog_activity + .store(unix_secs(), std::sync::atomic::Ordering::Relaxed); + } + + fn catalog_idle_secs(&self) -> u64 { + let last = self + .catalog_activity + .load(std::sync::atomic::Ordering::Relaxed); + if last == 0 { + u64::MAX + } else { + unix_secs().saturating_sub(last) + } + } +} + +fn unix_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn warm_name_key(name: &str) -> String { + name.to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric()) + .collect() +} + +/// Background identity warmer: once per app start, walk the library's +/// artists (most recently added first) and touch each one's MusicBrainz +/// identity chain, so the 24h disk cache keeps the artists the user actually +/// visits permanently warm — a cold page's slowest stage (3.7-8.5 s of +/// 1 req/s-gated MusicBrainz) becomes a disk read for library artists. +/// +/// Politeness rules, in order of importance: +/// - Never compete with the user: work only after 25 s of catalog quiet. +/// - Never rush MusicBrainz: an 8 s gap after every artist that needed real +/// network work (already-warm artists advance after 1 s). +/// - Bounded: at most 150 artists per app start. +fn spawn_identity_warmer(app: tauri::AppHandle) { + tauri::async_runtime::spawn(async move { + // Let startup scans and the user's first page own the providers. + tokio::time::sleep(std::time::Duration::from_secs(90)).await; + let state = app.state::(); + let lib = state.library.clone(); + let Ok(Ok(albums)) = tauri::async_runtime::spawn_blocking(move || lib.list_albums()).await + else { + return; + }; + let mut seen = std::collections::HashSet::new(); + let artists: Vec = albums + .into_iter() + .map(|album| album.artist) + .filter(|artist| !artist.trim().is_empty()) + .filter(|artist| seen.insert(warm_name_key(artist))) + .take(150) + .collect(); + if artists.is_empty() { + return; + } + log::info!("identity warmer: {} library artists queued", artists.len()); + let mut warmed = 0usize; + for artist in artists { + // Yield to the user for as long as they are browsing. + loop { + let idle = state.catalog_idle_secs(); + if idle >= 25 { + break; + } + tokio::time::sleep(std::time::Duration::from_secs(26 - idle)).await; + } + let started = std::time::Instant::now(); + let search = state.metadata.catalog_search(&artist).await; + let deezer_id = search + .artists + .iter() + .find(|candidate| warm_name_key(&candidate.name) == warm_name_key(&artist)) + .map(|candidate| candidate.id); + if let Some(deezer_id) = deezer_id { + let _ = state + .metadata + .catalog_artist_identity(deezer_id, &artist) + .await; + let _ = state.metadata.catalog_artist_socials(deezer_id).await; + warmed += 1; + } + // Real network work earns MusicBrainz a long gap; a disk-warm + // artist (sub-second) only needs a token pause. + let gap = if started.elapsed() < std::time::Duration::from_millis(600) { + 1 + } else { + 8 + }; + tokio::time::sleep(std::time::Duration::from_secs(gap)).await; + } + log::info!("identity warmer: warmed {warmed} artists"); + }); +} + +pub fn init(app: &tauri::AppHandle) -> Result<(), String> { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?; + let art_dir = data_dir.join("art"); + std::fs::create_dir_all(&art_dir).map_err(|e| e.to_string())?; + + let library = Arc::new(Library::open(&data_dir.join("library.db")).map_err(|e| e.to_string())?); + let repair_library = library.clone(); + let repair_art_dir = art_dir.clone(); + + app.manage(LibraryState { + library, + // The disk layer only caches slow-moving providers (MusicBrainz, + // Wikipedia) so a restarted app keeps its supplement/type-hint and + // description warmth instead of re-serializing MusicBrainz at 1 req/s. + metadata: Arc::new({ + let client = MetadataClient::new().with_disk_cache(data_dir.join("net-cache-v1")); + // Hero candidate/scoring diagnostics on demand, so an in-APP hero + // resolution can be compared against `hero_source_probe` — the two + // do not always see the same candidate set, and only the app's own + // trace can explain a selection the user actually saw. + if std::env::var("SOULSTREAM_HERO_TRACE").is_ok_and(|value| value == "1") { + client.with_hero_diagnostics() + } else { + client + } + }), + catalog_activity: std::sync::atomic::AtomicU64::new(0), + art_dir, + album_cache: tokio::sync::Mutex::new(HashMap::new()), + art_locks: Mutex::new(HashMap::new()), + staging_targets: Mutex::new(HashMap::new()), + }); + + spawn_identity_warmer(app.clone()); + + // Startup: drop rows for deleted files, then index anything new. + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + if let Ok(Ok(repaired)) = tauri::async_runtime::spawn_blocking(move || { + repair_library.repair_art_paths(&repair_art_dir) + }) + .await + { + if repaired > 0 { + log::info!("library repair: restored {repaired} artwork cache references"); + } + } + let state = handle.state::(); + let lib = state.library.clone(); + let _ = tauri::async_runtime::spawn_blocking(move || lib.prune_missing()).await; + let lib = state.library.clone(); + if let Ok(Ok(removed)) = + tauri::async_runtime::spawn_blocking(move || lib.prune_non_audio()).await + { + if removed > 0 { + log::info!("library cleanup: removed {removed} non-audio track rows"); + } + } + // Backfill sample-rate / bit-depth / size for tracks indexed before + // those columns existed, so the Now Playing panel shows full quality + // info for older library files. Size is the sentinel, so this runs + // once per file (after prune, every remaining track has a file). + let lib = state.library.clone(); + let _ = tauri::async_runtime::spawn_blocking(move || { + let todo = lib.tracks_missing_props().unwrap_or_default(); + if todo.is_empty() { + return; + } + let n = todo.len(); + for (id, path) in todo { + let p = std::path::Path::new(&path); + let (sr, bd) = match tagger::read_summary(p) { + Ok(s) => (s.sample_rate, s.bit_depth), + Err(_) => (None, None), + }; + let size = std::fs::metadata(p).ok().map(|m| m.len()); + let _ = lib.set_track_props(id, sr, bd, size); + } + log::info!("library backfill: audio props for {n} tracks"); + }) + .await; + // Old staging leftovers: folders emptied by organizing are removed + // on the fly now, but sweep empties left behind by earlier runs. + sweep_empty_dirs(&downloads_dir(&handle), 0); + scan_downloads(&handle).await; + }); + Ok(()) +} + +/// Remove empty directory trees below `dir` (post-order; `dir` itself stays). +fn sweep_empty_dirs(dir: &Path, depth: u8) { + if depth > 6 { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + sweep_empty_dirs(&path, depth + 1); + // Fails (harmlessly) unless the directory is empty. + let _ = std::fs::remove_dir(&path); + } + } +} + +fn downloads_dir(app: &tauri::AppHandle) -> PathBuf { + let cfg = crate::config::load(app); + crate::config::download_dir(app, &cfg) +} + +/// Index every audio file under the downloads dir that isn't known yet. +async fn scan_downloads(app: &tauri::AppHandle) { + let root = downloads_dir(app); + let mut files = Vec::new(); + collect_audio_files(&root, &mut files, 0); + if files.is_empty() { + return; + } + log::info!( + "library scan: {} audio files under {}", + files.len(), + root.display() + ); + + let state = app.state::(); + for path in files { + let known = { + let lib = state.library.clone(); + let p = path.to_string_lossy().into_owned(); + tauri::async_runtime::spawn_blocking(move || lib.has_track_path(&p).unwrap_or(false)) + .await + .unwrap_or(false) + }; + if !known { + process_file(app, &path, None).await; + } + } + let _ = app.emit("soulseek:library-changed", ()); +} + +fn collect_audio_files(dir: &Path, out: &mut Vec, depth: u8) { + if depth > 6 { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_audio_files(&path, out, depth + 1); + } else if path + .extension() + .and_then(|e| e.to_str()) + .map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str())) + .unwrap_or(false) + { + out.push(path); + } + } +} + +/// Run one file through the pipeline. `remote_path` (when this came from a +/// fresh download) improves folder-based metadata parsing. +pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path: Option<&str>) { + // Download responses include album artwork and metadata sidecars. Never + // feed those files to tag parsing or the library database. + if !is_audio_path(local_path) { + log::debug!("non-audio download {}", local_path.display()); + // If the rest of this staging folder was already organized, send this + // extra (cover art, log, cue…) after its album and tidy the folder. + if remote_path.is_some() { + if let Some(parent) = local_path.parent() { + let album_dir = app + .state::() + .staging_targets + .lock() + .unwrap() + .get(parent) + .cloned(); + if let Some(album_dir) = album_dir { + cleanup_staging(app, parent, &album_dir); + } + } + } + return; + } + let state = app.state::(); + + // 1. Existing tags (retry once: freshly-written files can be briefly + // locked by AV/indexing on Windows). + let mut summary = TagSummary::default(); + for attempt in 0..2 { + let p = local_path.to_path_buf(); + match tauri::async_runtime::spawn_blocking(move || tagger::read_summary(&p)).await { + Ok(Ok(s)) => { + summary = s; + break; + } + Ok(Err(e)) if attempt == 0 => { + log::warn!( + "tag read failed for {} (retrying): {e}", + local_path.display() + ); + tokio::time::sleep(std::time::Duration::from_millis(750)).await; + } + Ok(Err(e)) => log::warn!("tag read failed for {}: {e}", local_path.display()), + Err(e) => log::warn!("tag read task failed for {}: {e}", local_path.display()), + } + } + let summary: TagSummary = summary; + + // 2. Fill gaps from the path (remote path preferred: original layout). + let parsed = tagger::parse_from_path(remote_path.unwrap_or(&local_path.to_string_lossy())); + let artist_guess = summary.artist.clone().or(parsed.artist.clone()); + let album_guess = summary.album.clone().or(parsed.album.clone()); + let title = summary + .title + .clone() + .or(parsed.title.clone()) + .unwrap_or_else(|| { + local_path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned() + }); + + // 3. Canonicalize via MusicBrainz (cached per album). + let meta = match (&artist_guess, &album_guess) { + (Some(artist), Some(album)) => { + let key = (artist.to_lowercase(), album.to_lowercase()); + let mut cache = state.album_cache.lock().await; + if let Some(cached) = cache.get(&key) { + cached.clone() + } else { + let looked = state.metadata.lookup_album(artist, album).await; + cache.insert(key, looked.clone()); + looked + } + } + _ => None, + }; + + let final_artist = meta + .as_ref() + .map(|m| m.artist.clone()) + .or(artist_guess) + .unwrap_or_else(|| "Unknown Artist".into()); + let final_album = meta + .as_ref() + .map(|m| m.album.clone()) + .or(album_guess) + .unwrap_or_else(|| "Unknown Album".into()); + let final_year = summary + .year + .or(parsed.year) + .or(meta.as_ref().and_then(|m| m.year)); + let release_date = meta + .as_ref() + .and_then(|m| m.release_date.clone()) + .or_else(|| final_year.map(|year| year.to_string())); + let mbid = meta.as_ref().and_then(|m| m.release_mbid.clone()); + + // A repeated save downloads (or copies from the stream cache) into a + // staging folder first. Do this check before organization: otherwise the + // collision-safe filename suffix (" (2)") makes it a new path and creates + // a duplicate track row for the same song. + if remote_path.is_some() { + let lib = state.library.clone(); + let artist = final_artist.clone(); + let album = final_album.clone(); + let track = title.clone(); + match tauri::async_runtime::spawn_blocking(move || lib.has_track(&artist, &album, &track)) + .await + { + Ok(Ok(true)) => { + log::info!( + "skipping duplicate library track: {} — {} — {}", + final_artist, + final_album, + title + ); + if let Err(e) = std::fs::remove_file(local_path) { + log::warn!( + "could not discard duplicate staging file {}: {e}", + local_path.display() + ); + } + return; + } + Ok(Err(e)) => log::warn!("could not check for duplicate library track: {e}"), + Err(e) => log::warn!("duplicate library check task failed: {e}"), + Ok(Ok(false)) => {} + } + } + + // 4. Album art (once per album; serialized against concurrent tracks). + let rg_mbid = meta.as_ref().and_then(|m| m.release_group_mbid.clone()); + let art_path = fetch_art_cached( + &state, + &final_artist, + &final_album, + mbid.as_deref(), + rg_mbid.as_deref(), + false, + ) + .await; + + let artist_art_path = fetch_artist_art_cached(&state, &final_artist).await; + + // 5. Write tags. Fresh downloads carry the canonical cover, replacing + // whatever picture the uploader embedded (config-gated) so the file, the + // library, and the catalog all show the same artwork. Startup-scanned + // files are never stripped: they only gain art when they have none. + let cfg = crate::config::load(app); + let replace_embedded = remote_path.is_some() && crate::config::replace_embedded_art(&cfg); + let embed_max_edge = crate::config::embed_art_max_edge(&cfg); + let art_bytes = match (&art_path, replace_embedded || !summary.has_art) { + (Some(p), true) => std::fs::read(p).ok(), + _ => None, + }; + let write = TagWrite { + artist: summary.artist.is_none().then_some(final_artist.clone()), + album_artist: summary + .album_artist + .is_none() + .then_some(final_artist.clone()), + album: summary.album.is_none().then_some(final_album.clone()), + title: summary.title.is_none().then_some(title.clone()), + track_no: summary.track_no.or(parsed.track_no), + year: summary.year.is_none().then(|| final_year).flatten(), + }; + { + let p = local_path.to_path_buf(); + let res = tauri::async_runtime::spawn_blocking(move || { + let embed = art_bytes.map(|b| prepare_embed_art(b, embed_max_edge)); + tagger::write_tags(&p, &write, embed.as_deref(), replace_embedded) + }) + .await; + if let Ok(Err(e)) = res { + log::warn!("tagging {} failed: {e}", local_path.display()); + } + } + + // 6. Fresh downloads become part of the managed library/share folder. + // Keep an existing library exactly where it is; reorganizing old files is + // a deliberate future action, not a surprise side effect of startup scan. + let library_path = if remote_path.is_some() { + organize_download( + app, + local_path, + &final_artist, + &final_album, + release_date.as_deref(), + final_year, + ) + } else { + local_path.to_path_buf() + }; + + // 6b. The canonical cover also becomes the album folder's cover.jpg / + // folder.jpg, so Explorer and any other player show the artwork the app + // does — stale uploader art under those names is replaced. + if remote_path.is_some() && crate::config::write_folder_cover(&cfg) { + if let (Some(album_dir), Some(art)) = (library_path.parent(), &art_path) { + let (dir, art) = (album_dir.to_path_buf(), art.clone()); + let _ = + tauri::async_runtime::spawn_blocking(move || write_album_folder_cover(&dir, &art)) + .await; + } + } + + // 7. Library rows. + { + let lib = state.library.clone(); + let p = library_path.to_string_lossy().into_owned(); + let art = art_path.as_ref().map(|a| a.to_string_lossy().into_owned()); + let track_no = summary.track_no.or(parsed.track_no); + let (duration, bitrate) = (summary.duration_secs, summary.bitrate_kbps); + let (sample_rate, bit_depth) = (summary.sample_rate, summary.bit_depth); + let size = std::fs::metadata(&library_path).ok().map(|m| m.len()); + let (artist, album, title2) = (final_artist.clone(), final_album.clone(), title.clone()); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let res = tauri::async_runtime::spawn_blocking(move || -> library::Result<()> { + let album_id = lib.upsert_album( + &artist, + &album, + final_year, + art.as_deref(), + artist_art_path.as_deref(), + mbid.as_deref(), + now, + )?; + if let Some(a) = &art { + lib.set_album_art(album_id, a)?; + } + lib.upsert_track( + album_id, + track_no, + &title2, + &artist, + duration, + bitrate, + sample_rate, + bit_depth, + size, + &p, + now, + )?; + Ok(()) + }) + .await; + if let Ok(Err(e)) = res { + log::warn!("library insert for {} failed: {e}", local_path.display()); + } + } + + let _ = app.emit("soulseek:library-changed", ()); +} + +async fn fetch_artist_art_cached( + state: &tauri::State<'_, LibraryState>, + artist: &str, +) -> Option { + let key: String = artist + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + let target = state.art_dir.join(format!("artist-{key}.jpg")); + let original = state.art_dir.join(format!("artist-{key}-original")); + // Older portraits were stored without a source/thumbnail split. Re-fetch + // those once so the new high-quality thumbnail path takes effect. + if target.exists() && original.exists() { + return Some(target.to_string_lossy().into_owned()); + } + let bytes = state.metadata.fetch_artist_art(artist).await?; + std::fs::write(&original, &bytes).ok()?; + write_artist_thumbnail(&bytes, &target)?; + Some(target.to_string_lossy().into_owned()) +} + +/// Preserve the original portrait, but hand the webview a clean 512px +/// thumbnail generated with Lanczos3. This is large enough for a 2x card and +/// avoids browser scaling a huge compressed source directly into the tile. +fn write_artist_thumbnail(bytes: &[u8], target: &Path) -> Option<()> { + let decoded = image::load_from_memory(bytes).ok()?; + let thumb = decoded.resize(512, 512, image::imageops::FilterType::Lanczos3); + let mut out = std::io::Cursor::new(Vec::new()); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 92); + thumb.write_with_encoder(encoder).ok()?; + std::fs::write(target, out.into_inner()).ok() +} + +/// Refresh portraits for every distinct artist already represented in the +/// library. Existing cache files are replaced so Settings can recover from a +/// failed lookup or a poor match. +pub async fn refresh_all_artist_art(app: &tauri::AppHandle) -> Result { + let state = app.state::(); + let albums = { + let lib = state.library.clone(); + tauri::async_runtime::spawn_blocking(move || lib.list_albums()) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())? + }; + let mut artists = std::collections::BTreeSet::new(); + for album in albums { + artists.insert(album.artist); + } + let mut refreshed = 0; + for artist in artists { + let key: String = artist + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + let target = state.art_dir.join(format!("artist-{key}.jpg")); + let original = state.art_dir.join(format!("artist-{key}-original")); + let _ = std::fs::remove_file(&target); + let _ = std::fs::remove_file(&original); + if let Some(bytes) = state.metadata.fetch_artist_art(&artist).await { + if std::fs::write(&original, &bytes).is_ok() + && write_artist_thumbnail(&bytes, &target).is_some() + { + let path = target.to_string_lossy().into_owned(); + let lib = state.library.clone(); + let artist2 = artist.clone(); + tauri::async_runtime::spawn_blocking(move || lib.set_artist_art(&artist2, &path)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + refreshed += 1; + } + } + } + let _ = app.emit("soulseek:library-changed", ()); + Ok(refreshed) +} + +pub fn is_audio_path(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str())) + .unwrap_or(false) +} + +/// Move a newly completed download into one album directory under the managed +/// root. The filename remains intact, preserving track numbering supplied by +/// the source. If a matching file exists, retain both files with a suffix. +fn organize_download( + app: &tauri::AppHandle, + source: &Path, + artist: &str, + album: &str, + release_date: Option<&str>, + year: Option, +) -> PathBuf { + let cfg = crate::config::load(app); + let date = display_date(release_date).unwrap_or_else(|| { + year.map(|y| y.to_string()) + .unwrap_or_else(|| "Unknown date".into()) + }); + let rendered = render_layout( + crate::config::library_layout(&cfg), + artist, + album, + &date, + year, + ); + // Separators written in the layout create subfolders. Separators inside + // tag values were flattened by render_layout, so metadata can neither + // split folders nor escape the library root ("..", trailing dots and + // empty segments are dropped by safe_segment). + let mut target_dir = crate::config::download_dir(app, &cfg); + let mut depth = 0; + for seg in rendered.split(['/', '\\']) { + let seg = safe_segment(seg); + if seg.is_empty() { + continue; + } + target_dir = target_dir.join(seg); + depth += 1; + } + if depth == 0 { + target_dir = target_dir.join("Unknown Album"); + } + if std::fs::create_dir_all(&target_dir).is_err() { + return source.to_path_buf(); + } + let name = source.file_name().unwrap_or_default().to_string_lossy(); + let mut target = target_dir.join(safe_component(&name)); + if source == target { + return source.to_path_buf(); + } + if target.exists() { + let stem = target + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let ext = target + .extension() + .map(|e| format!(".{}", e.to_string_lossy())) + .unwrap_or_default(); + for n in 2..10_000 { + let candidate = target_dir.join(format!("{stem} ({n}){ext}")); + if !candidate.exists() { + target = candidate; + break; + } + } + } + match std::fs::rename(source, &target) { + Ok(()) => { + // Remember where this staging folder's album lives, then tidy it: + // once the last track leaves, remaining extras follow and the + // empty staging dirs are removed. + if let Some(parent) = source.parent() { + app.state::() + .staging_targets + .lock() + .unwrap() + .insert(parent.to_path_buf(), target_dir.clone()); + cleanup_staging(app, parent, &target_dir); + } + target + } + Err(e) => { + log::warn!("could not organize {}: {e}", source.display()); + source.to_path_buf() + } + } +} + +/// Non-audio files worth keeping next to the organized album. +const EXTRA_EXTS: &[&str] = &[ + "jpg", "jpeg", "png", "gif", "webp", "bmp", "nfo", "txt", "log", "cue", "m3u", "m3u8", "pdf", + "sfv", "md5", "accurip", +]; + +/// If `staging` holds no more audio or in-flight downloads, move its album +/// extras into `album_dir` and delete the emptied staging directories up to +/// (but excluding) the download root. Backs off untouched at the first sign +/// of anything unexpected: pending `.partial` files, subfolders, or file +/// types outside the extras allowlist. +fn cleanup_staging(app: &tauri::AppHandle, staging: &Path, album_dir: &Path) { + let root = downloads_dir(app); + if !staging.starts_with(&root) || staging == root || album_dir.starts_with(staging) { + return; + } + let Ok(entries) = std::fs::read_dir(staging) else { + return; + }; + let mut extras = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() || is_audio_path(&path) { + return; + } + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .unwrap_or_default(); + if ext == "partial" || !EXTRA_EXTS.contains(&ext.as_str()) { + return; + } + extras.push(path); + } + for src in extras { + let name = src + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let dst = album_dir.join(safe_component(&name)); + if dst.exists() { + // The album already has this extra; the copy adds nothing. + let _ = std::fs::remove_file(&src); + } else if std::fs::rename(&src, &dst).is_err() { + return; // locked or cross-volume oddity: keep the folder + } + } + let mut dir = staging.to_path_buf(); + while dir != root && dir.starts_with(&root) && std::fs::remove_dir(&dir).is_ok() { + app.state::() + .staging_targets + .lock() + .unwrap() + .remove(&dir); + match dir.parent() { + Some(p) => dir = p.to_path_buf(), + None => break, + } + } +} + +fn render_layout(layout: &str, artist: &str, album: &str, date: &str, year: Option) -> String { + layout + .replace("{artist}", &clean_chars(artist)) + .replace("{album}", &clean_chars(album)) + .replace("{date}", &clean_chars(date)) + .replace( + "{year}", + &year + .map(|v| v.to_string()) + .unwrap_or_else(|| "Unknown year".into()), + ) +} + +/// MusicBrainz uses ISO dates. Display the fully-known dates in the requested +/// day-month-year ordering while preserving partial dates such as "1997". +fn display_date(raw: Option<&str>) -> Option { + let raw = raw?.trim(); + let parts: Vec<_> = raw.split('-').collect(); + match parts.as_slice() { + [year, month, day] if year.len() == 4 && month.len() == 2 && day.len() == 2 => { + Some(format!("{day}-{month}-{year}")) + } + _ if !raw.is_empty() => Some(raw.to_string()), + _ => None, + } +} + +/// Replace characters Windows forbids in file names. +fn clean_chars(value: &str) -> String { + value + .chars() + .map(|c| match c { + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_', + c if (c as u32) < 0x20 => '_', + c => c, + }) + .collect() +} + +/// One path segment: cleaned and trimmed; Windows rejects trailing dots and +/// spaces. May return an empty string (caller decides the fallback). +fn safe_segment(value: &str) -> String { + clean_chars(value) + .trim() + .trim_end_matches([' ', '.']) + .to_string() +} + +fn safe_component(value: &str) -> String { + let trimmed = safe_segment(value); + if trimmed.is_empty() { + "Unknown Album".into() + } else { + trimmed + } +} + +/// `/.jpg`, fetched at most once per album. +/// `force` refetches even when a cached file exists. +pub async fn fetch_art_cached( + state: &tauri::State<'_, LibraryState>, + artist: &str, + album: &str, + mbid: Option<&str>, + rg_mbid: Option<&str>, + force: bool, +) -> Option { + let key: String = format!("{artist} - {album}") + .to_lowercase() + .chars() + .map(|c| match c { + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_', + c if (c as u32) < 0x20 => '_', + c => c, + }) + .collect(); + let target = state.art_dir.join(format!("{key}.jpg")); + + let lock = { + let mut locks = state.art_locks.lock().unwrap(); + locks + .entry(key.clone()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + let _guard = lock.lock().await; + + if target.exists() && !force { + return Some(target); + } + let bytes = state + .metadata + .fetch_art(mbid, rg_mbid, artist, album) + .await?; + std::fs::write(&target, &bytes).ok()?; + log::info!( + "album art saved: {} ({} KB)", + target.display(), + bytes.len() / 1024 + ); + Some(target) +} + +/// Downscale the canonical cover for embedding into audio files. `max_edge` +/// 0 embeds the original untouched; anything already at or under the cap +/// passes through byte-for-byte. Oversized covers are Lanczos3-resized and +/// re-encoded as JPEG 92 so a 3000px original doesn't add megabytes to every +/// track — the full-resolution file stays in the art cache and album folder. +fn prepare_embed_art(bytes: Vec, max_edge: u32) -> Vec { + if max_edge == 0 { + return bytes; + } + let Ok(img) = image::load_from_memory(&bytes) else { + return bytes; + }; + if img.width().max(img.height()) <= max_edge { + return bytes; + } + let resized = img.resize(max_edge, max_edge, image::imageops::FilterType::Lanczos3); + let mut output = std::io::Cursor::new(Vec::new()); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 92); + match resized.into_rgb8().write_with_encoder(encoder) { + Ok(()) => output.into_inner(), + Err(_) => bytes, + } +} + +/// Write the canonical cover into the album folder as `cover.jpg` and +/// `folder.jpg` — the two names Explorer and virtually every player read. +/// Rare PNG originals are re-encoded as JPEG 95 so the extension never lies. +/// A same-length file is assumed already ours and skipped. +fn write_album_folder_cover(album_dir: &Path, art_file: &Path) { + let Ok(bytes) = std::fs::read(art_file) else { + return; + }; + let jpeg = if bytes.starts_with(b"\x89PNG") { + let Ok(img) = image::load_from_memory(&bytes) else { + return; + }; + let mut output = std::io::Cursor::new(Vec::new()); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 95); + if img.into_rgb8().write_with_encoder(encoder).is_err() { + return; + } + output.into_inner() + } else { + bytes + }; + // Always write the two canonical names, and additionally overwrite any + // *existing* generic art-slot file an uploader left behind (albumart.jpg, + // front.jpg, …) — players that prefer those names must not show stale art. + // Only .jpg/.jpeg names are replaced in place; scans/booklets keep their + // distinctive filenames and are never touched. + const GENERIC_ART_NAMES: [&str; 6] = [ + "albumart.jpg", + "albumart.jpeg", + "album.jpg", + "front.jpg", + "front.jpeg", + "thumb.jpg", + ]; + let existing_generic: Vec = std::fs::read_dir(album_dir) + .map(|entries| { + entries + .flatten() + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|n| GENERIC_ART_NAMES.contains(&n.to_ascii_lowercase().as_str())) + .collect() + }) + .unwrap_or_default(); + for name in ["cover.jpg", "folder.jpg"] + .into_iter() + .chain(existing_generic.iter().map(String::as_str)) + { + let target = album_dir.join(name); + let already_ours = std::fs::metadata(&target) + .map(|m| m.len() == jpeg.len() as u64) + .unwrap_or(false); + if already_ours { + continue; + } + if let Err(e) = std::fs::write(&target, &jpeg) { + log::warn!("could not write album cover {}: {e}", target.display()); + } + } +} + +/// Force-refetch an album's art with fresh MusicBrainz data and re-embed +/// it into every track (replacing whatever is there). +pub async fn refresh_album_art(app: &tauri::AppHandle, album_id: i64) -> Result { + let state = app.state::(); + + let lib = state.library.clone(); + let row = tauri::async_runtime::spawn_blocking(move || lib.get_album(album_id)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())? + .ok_or("album not found")?; + let (album, _stored_mbid) = row; + + // Fresh lookup for both MBIDs (the DB only stores the release MBID). + let meta = state + .metadata + .lookup_album(&album.artist, &album.title) + .await; + let mbid = meta.as_ref().and_then(|m| m.release_mbid.clone()); + let rg_mbid = meta.as_ref().and_then(|m| m.release_group_mbid.clone()); + + let art_path = fetch_art_cached( + &state, + &album.artist, + &album.title, + mbid.as_deref(), + rg_mbid.as_deref(), + true, + ) + .await + .ok_or("no art found for this album")?; + + let art_str = art_path.to_string_lossy().into_owned(); + { + let lib = state.library.clone(); + let a = art_str.clone(); + tauri::async_runtime::spawn_blocking(move || lib.set_album_art(album_id, &a)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + } + + // Re-embed into every track, replacing the old picture, and refresh the + // album folder's cover.jpg/folder.jpg so every surface agrees again. + let cfg = crate::config::load(app); + let embed_max_edge = crate::config::embed_art_max_edge(&cfg); + let lib = state.library.clone(); + let tracks = tauri::async_runtime::spawn_blocking(move || lib.album_tracks(album_id)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + let bytes = std::fs::read(&art_path).map_err(|e| e.to_string())?; + let embed = { + let b = bytes.clone(); + tauri::async_runtime::spawn_blocking(move || prepare_embed_art(b, embed_max_edge)) + .await + .map_err(|e| e.to_string())? + }; + let mut album_dirs = std::collections::BTreeSet::new(); + for t in &tracks { + if let Some(dir) = Path::new(&t.path).parent() { + album_dirs.insert(dir.to_path_buf()); + } + } + for t in tracks { + let b = embed.clone(); + let p = PathBuf::from(&t.path); + let res = tauri::async_runtime::spawn_blocking(move || tagger::replace_art(&p, &b)).await; + if let Ok(Err(e)) = res { + log::warn!("re-embed art failed for {}: {e}", t.path); + } + } + if crate::config::write_folder_cover(&cfg) { + let art = art_path.clone(); + let _ = tauri::async_runtime::spawn_blocking(move || { + for dir in album_dirs { + write_album_folder_cover(&dir, &art); + } + }) + .await; + } + + let _ = app.emit("soulseek:library-changed", ()); + Ok(art_str) +} + +/// Upgrade every library album to the canonical high-resolution cover: +/// refetch, re-embed, and rewrite folder covers. Returns how many albums +/// succeeded. MusicBrainz throttling makes this roughly one album per +/// second; progress lands in the log. +pub async fn refresh_all_album_art(app: &tauri::AppHandle) -> Result { + let state = app.state::(); + let albums = { + let lib = state.library.clone(); + tauri::async_runtime::spawn_blocking(move || lib.list_albums()) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())? + }; + let total = albums.len(); + let mut refreshed = 0; + for (i, album) in albums.into_iter().enumerate() { + match refresh_album_art(app, album.id).await { + Ok(_) => refreshed += 1, + Err(e) => log::warn!( + "art refresh failed for {} - {}: {e}", + album.artist, + album.title + ), + } + log::info!("album art refresh: {}/{total}", i + 1); + } + Ok(refreshed) +} diff --git a/src-tauri/src/playback.rs b/src-tauri/src/playback.rs new file mode 100644 index 0000000..a39014a --- /dev/null +++ b/src-tauri/src/playback.rs @@ -0,0 +1,445 @@ +//! Playback orchestration: bridges the audio thread, the streaming cache, +//! and the webview. + +use serde::Serialize; +use std::collections::{HashMap, VecDeque}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +static CACHE_PRUNE_LOCK: Mutex<()> = Mutex::new(()); +use std::time::{Duration, Instant}; +use tauri::{Emitter, Manager}; + +use player::{Player, PlayerEvent, StreamProgress}; + +// The audio thread keeps its responsive 60 ms command/end-detection loop, but +// the webview does not need 16-17 IPC/reactivity updates per second to move a +// progress indicator. Roughly four to five updates per second remain visually +// smooth while letting Chromium spend most playback time asleep. +const UI_POSITION_INTERVAL: Duration = Duration::from_millis(200); +const WAVEFORM_CACHE_MAX_ENTRIES: usize = 256; +const PROGRESSIVE_WAVEFORM_CACHE_MAX_ENTRIES: usize = 64; +const WAVEFORM_JOB_MAX_IDLE_ENTRIES: usize = 256; + +/// Tiny insertion-order LRU used for decoded waveform data. Waveforms are +/// cheap individually, but a path-keyed session cache must not grow with every +/// track ever visited. +struct WaveformLru { + entries: HashMap, + order: VecDeque, + capacity: usize, +} + +impl WaveformLru { + fn new(capacity: usize) -> Self { + Self { + entries: HashMap::new(), + order: VecDeque::new(), + capacity, + } + } + + fn get(&mut self, key: &str) -> Option { + let value = self.entries.get(key)?.clone(); + self.touch(key); + Some(value) + } + + fn insert(&mut self, key: String, value: T) { + self.entries.insert(key.clone(), value); + self.touch(&key); + while self.entries.len() > self.capacity { + let Some(oldest) = self.order.pop_front() else { + break; + }; + self.entries.remove(&oldest); + } + } + + fn remove(&mut self, key: &str) { + self.entries.remove(key); + if let Some(index) = self.order.iter().position(|candidate| candidate == key) { + self.order.remove(index); + } + } + + fn touch(&mut self, key: &str) { + if let Some(index) = self.order.iter().position(|candidate| candidate == key) { + self.order.remove(index); + } + self.order.push_back(key.to_string()); + } +} + +#[derive(Clone)] +pub(crate) struct ProgressiveWaveform { + decoded_bytes: u64, + next_decode_bytes: u64, + peaks: Vec, +} + +impl ProgressiveWaveform { + pub(crate) fn new(decoded_bytes: u64, total_bytes: u64, peaks: Vec) -> Self { + Self { + decoded_bytes, + next_decode_bytes: next_progressive_waveform_threshold(decoded_bytes, total_bytes), + peaks, + } + } + + /// Recompute only after the growing file has at least doubled. If a + /// transfer restarted and the file shrank, force a fresh decode instead of + /// returning peaks from bytes that no longer exist. + pub(crate) fn should_recompute(&self, available_bytes: u64) -> bool { + available_bytes < self.decoded_bytes + || (available_bytes > self.decoded_bytes && available_bytes >= self.next_decode_bytes) + } + + pub(crate) fn peaks(&self) -> Vec { + self.peaks.clone() + } +} + +/// When the next partial re-decode is due. Early on the file doubles between +/// decodes — they're cheap and the shape fills in fast. But pure doubling made +/// the LAST gap span half the transfer (50% → 100%), so the drawn wave sat up +/// to 2× behind the buffer and the playhead visibly walked off its end +/// (decoded audio is always ahead of playback, the DRAWING wasn't). Past the +/// eighth-of-file mark the schedule switches to fixed total/8 steps: the wave +/// is never more than 12.5% behind the transfer, for at most ~7 extra decodes +/// spread across the download. +fn next_progressive_waveform_threshold(decoded_bytes: u64, total_bytes: u64) -> u64 { + let doubled = decoded_bytes + .saturating_mul(2) + .max(decoded_bytes.saturating_add(1)); + let linear_step = decoded_bytes.saturating_add((total_bytes / 8).max(1)); + doubled + .min(linear_step) + .min(total_bytes.max(decoded_bytes.saturating_add(1))) +} + +pub struct PlayerState { + pub player: Player, + /// Active stream downloads: download id → progress shared with the player. + pub streams: Mutex>>, + /// Cache target for each active stream. This lets a library save adopt an + /// in-flight stream instead of requesting the same peer/path again. + pub stream_caches: Mutex>, + /// Bounded completed-waveform LRU, keyed by file path + bucket count. + waveforms: Mutex>>, + /// Last useful partial decode per active/recent stream. A growing file is + /// decoded again only after its byte length doubles. + progressive_waveforms: Mutex>, + /// Per-key single-flight locks. The owned guard moves into the blocking + /// decoder so cancellation of the IPC future cannot expose overlapping + /// work while that decoder is still running. + waveform_jobs: Mutex>>>, + /// Pre-buffered next-track downloads: download id → cache target. + /// Their events are swallowed (no UI, no library pipeline); play_stream + /// attaches to them instead of restarting the transfer. + pub preloads: Mutex>, + /// Transfers whose completed cache file should also be organized into the + /// library. A save can subscribe to a stream/preload already in progress. + pub deferred_saves: Mutex>, +} + +#[derive(Serialize, Clone)] +#[serde(tag = "kind", rename_all = "kebab-case")] +enum PlayerUiEvent { + Buffering, + Playing, + Paused, + Stopped, + TrackEnded { gapless: bool }, + Position { secs: f64, duration: Option }, + Error { message: String }, +} + +/// Create the audio thread and forward its events to the webview. +pub fn init(app: &tauri::AppHandle) { + let output_device = crate::config::load(app).audio_output_device; + let (player, events) = Player::spawn_with_device(output_device); + app.manage(PlayerState { + player, + streams: Mutex::new(HashMap::new()), + stream_caches: Mutex::new(HashMap::new()), + waveforms: Mutex::new(WaveformLru::new(WAVEFORM_CACHE_MAX_ENTRIES)), + progressive_waveforms: Mutex::new(WaveformLru::new(PROGRESSIVE_WAVEFORM_CACHE_MAX_ENTRIES)), + waveform_jobs: Mutex::new(HashMap::new()), + preloads: Mutex::new(HashMap::new()), + deferred_saves: Mutex::new(HashMap::new()), + }); + + let handle = app.clone(); + std::thread::Builder::new() + .name("player-events".into()) + .spawn(move || { + let mut last_position_emit = Instant::now() - UI_POSITION_INTERVAL; + for ev in events { + let ui = match ev { + PlayerEvent::Buffering => { + last_position_emit = Instant::now() - UI_POSITION_INTERVAL; + PlayerUiEvent::Buffering + } + PlayerEvent::Playing => { + last_position_emit = Instant::now() - UI_POSITION_INTERVAL; + PlayerUiEvent::Playing + } + PlayerEvent::Paused => PlayerUiEvent::Paused, + PlayerEvent::Stopped => PlayerUiEvent::Stopped, + PlayerEvent::TrackEnded { gapless } => { + last_position_emit = Instant::now() - UI_POSITION_INTERVAL; + PlayerUiEvent::TrackEnded { gapless } + } + PlayerEvent::Position { secs, duration } => { + let now = Instant::now(); + if now.duration_since(last_position_emit) < UI_POSITION_INTERVAL { + continue; + } + last_position_emit = now; + PlayerUiEvent::Position { secs, duration } + } + PlayerEvent::Error(message) => PlayerUiEvent::Error { message }, + }; + let _ = handle.emit("soulseek:player", ui); + } + }) + .expect("spawn player event forwarder"); +} + +impl PlayerState { + pub(crate) fn cached_waveform(&self, key: &str) -> Option> { + self.waveforms.lock().unwrap().get(key) + } + + pub(crate) fn cache_waveform(&self, key: String, peaks: Vec) { + self.waveforms.lock().unwrap().insert(key, peaks); + } + + pub(crate) fn remove_cached_waveform(&self, key: &str) { + self.waveforms.lock().unwrap().remove(key); + } + + pub(crate) fn progressive_waveform(&self, key: &str) -> Option { + self.progressive_waveforms.lock().unwrap().get(key) + } + + pub(crate) fn cache_progressive_waveform(&self, key: String, waveform: ProgressiveWaveform) { + self.progressive_waveforms + .lock() + .unwrap() + .insert(key, waveform); + } + + pub(crate) fn remove_progressive_waveform(&self, key: &str) { + self.progressive_waveforms.lock().unwrap().remove(key); + } + + pub(crate) fn waveform_job(&self, key: &str) -> Arc> { + let mut jobs = self.waveform_jobs.lock().unwrap(); + if jobs.len() >= WAVEFORM_JOB_MAX_IDLE_ENTRIES { + // An entry with only the map's Arc is idle and safe to forget. + // Active/waiting jobs may temporarily exceed the soft bound, but + // they must keep their shared lock until their decoder exits. + let idle: Vec = jobs + .iter() + .filter(|(_, job)| Arc::strong_count(job) == 1) + .map(|(key, _)| key.clone()) + .collect(); + for idle_key in idle { + if jobs.len() < WAVEFORM_JOB_MAX_IDLE_ENTRIES { + break; + } + jobs.remove(&idle_key); + } + } + jobs.entry(key.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + } + + pub(crate) fn release_waveform_job(&self, key: &str, job: &Arc>) { + let mut jobs = self.waveform_jobs.lock().unwrap(); + if jobs + .get(key) + .is_some_and(|current| Arc::ptr_eq(current, job) && Arc::strong_count(current) == 2) + { + jobs.remove(key); + } + } +} + +/// Stable cache filename for a remote file: short hash + original name. +pub fn cache_path(app: &tauri::AppHandle, username: &str, remote_path: &str) -> Option { + let dir = app.path().app_data_dir().ok()?.join("stream-cache"); + std::fs::create_dir_all(&dir).ok()?; + + let mut hash: u64 = 0xcbf29ce484222325; + for b in username.bytes().chain(remote_path.bytes()) { + hash ^= b as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + let name = remote_path.rsplit('\\').next().unwrap_or(remote_path); + let safe: String = name + .chars() + .map(|c| match c { + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_', + c if (c as u32) < 0x20 => '_', + c => c, + }) + .collect(); + Some(dir.join(format!("{hash:016x} {safe}"))) +} + +/// The in-flight download target for a cache file: `..partial`. +pub fn partial_path(cache: &std::path::Path) -> PathBuf { + cache.with_extension(format!( + "{}partial", + cache + .extension() + .map(|e| format!("{}.", e.to_string_lossy())) + .unwrap_or_default() + )) +} + +/// Enforce the configured streamed-track retention on the cache: drop files +/// not played within the retention window, then oldest-first beyond the size +/// cap. The playing track's file is held open by the OS, so its delete fails +/// and it is simply kept. +pub fn prune_cache(app: &tauri::AppHandle) { + // Completion/settings paths can request pruning concurrently. Serialize + // their directory snapshots so a stale running total cannot over-delete. + let _prune_guard = CACHE_PRUNE_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let cfg = crate::config::load(app); + let max_bytes = crate::config::stream_cache_max_mb(&cfg) * 1024 * 1024; // 0 = unlimited + let retention = std::time::Duration::from_secs( + crate::config::stream_retention_days(&cfg) as u64 * 24 * 60 * 60, + ); + // In keep-forever mode streams land in the library, so the cache is only + // a transfer buffer — age expiry off, size cap still applies. + let age_prune = !crate::config::stream_keep_forever(&cfg); + + let Ok(dir) = app.path().app_data_dir().map(|d| d.join("stream-cache")) else { + return; + }; + let Ok(entries) = std::fs::read_dir(&dir) else { + return; + }; + + let mut files: Vec<(std::time::SystemTime, u64, PathBuf)> = entries + .flatten() + .filter_map(|e| { + let path = e.path(); + if path + .extension() + .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("partial")) + { + // Active downloads are transfer buffers, not evictable cache + // entries. Deleting one can make its final rename fail. + return None; + } + let md = e.metadata().ok()?; + if !md.is_file() { + return None; + } + Some((md.modified().ok()?, md.len(), path)) + }) + .collect(); + files.sort_by_key(|(t, ..)| *t); + + let now = std::time::SystemTime::now(); + let mut total: u64 = files.iter().map(|(_, len, _)| len).sum(); + let mut kept = Vec::new(); + for (modified, len, path) in files { + let expired = age_prune + && now + .duration_since(modified) + .map(|age| age > retention) + .unwrap_or(false); + if expired && std::fs::remove_file(&path).is_ok() { + total -= len; + continue; + } + kept.push((len, path)); + } + + if max_bytes == 0 { + return; + } + for (len, path) in kept { + if total <= max_bytes { + break; + } + if std::fs::remove_file(&path).is_ok() { + total -= len; + } + } +} + +/// Mark a cache file as freshly used so retention counts from the last play, +/// not the first stream. +pub fn touch(path: &std::path::Path) { + if let Ok(f) = std::fs::File::options().write(true).open(path) { + let _ = f.set_modified(std::time::SystemTime::now()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn waveform_lru_evicts_oldest_and_refreshes_hits() { + let mut cache = WaveformLru::new(2); + cache.insert("a".into(), vec![1.0]); + cache.insert("b".into(), vec![2.0]); + assert_eq!(cache.get("a"), Some(vec![1.0])); + + cache.insert("c".into(), vec![3.0]); + assert_eq!(cache.get("b"), None); + assert_eq!(cache.get("a"), Some(vec![1.0])); + assert_eq!(cache.get("c"), Some(vec![3.0])); + assert_eq!(cache.entries.len(), 2); + } + + #[test] + fn progressive_waveform_waits_for_doubling_but_restarts_after_shrink() { + let waveform = ProgressiveWaveform::new(10, 100, vec![1.0]); + assert!(!waveform.should_recompute(10)); + assert!(!waveform.should_recompute(19)); + assert!(waveform.should_recompute(20)); + assert!(waveform.should_recompute(9)); + } + + #[test] + fn progressive_thresholds_bound_lag_and_total_decode_cost() { + let total: u64 = 256 * 1024 * 1024; + let mut decoded: u64 = 1024 * 1024; + let mut cumulative = 0u64; + let mut steps = 0; + while decoded < total { + cumulative += decoded; + let next = next_progressive_waveform_threshold(decoded, total); + assert!(next > decoded, "thresholds must advance"); + // The drawn wave may never fall more than an eighth of the file + // behind the transfer — the doubling-only schedule's half-file + // final gap is the bug this replaced (playhead walking off the + // stale wave's end mid-song). + assert!( + next - decoded <= total / 8, + "gap {} exceeds total/8", + next - decoded + ); + decoded = next; + steps += 1; + } + assert_eq!(decoded, total); + // Early doubling + eighth-of-file steps: the whole progressive series + // costs a small constant number of full-file decodes, spread across + // the download. + assert!(steps <= 16, "too many recomputes: {steps}"); + assert!(cumulative <= total * 4, "cumulative redecode too large"); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs new file mode 100644 index 0000000..d50724b --- /dev/null +++ b/src-tauri/src/state.rs @@ -0,0 +1,12 @@ +use tokio::sync::Mutex; + +#[derive(Default)] +pub struct AppState { + pub client: Mutex>, + pub connected_as: Mutex>, + /// Server-distributed excluded search phrases (message 160). A query + /// containing one returns ZERO responses network-wide — the server simply + /// never routes it — so the resolver strips these terms up front instead + /// of discovering the silence by timeout. Lowercased on receipt. + pub excluded_search_phrases: Mutex>, +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..c8aef27 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,59 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "productName": "SoulStream", + "version": "0.1.0", + "identifier": "com.soulstream.app", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:1421", + "beforeBuildCommand": "npm run build", + "frontendDist": "../dist" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "label": "main", + "title": "SoulStream", + "width": 1280, + "height": 820, + "minWidth": 960, + "minHeight": 600, + "backgroundColor": "#121212", + "decorations": false + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": ["nsis"], + "resources": [ + "resources/FidelityFX_CLI.exe", + "resources/FidelityFX-LICENSE.txt", + "resources/yolox_nano.onnx", + "resources/YOLOX-LICENSE.txt", + "resources/YOLOX-MODEL-NOTICE.txt" + ], + "publisher": "SoulStream", + "copyright": "© 2026 SoulStream", + "category": "Music", + "shortDescription": "Music player, downloader and streamer for the Soulseek network.", + "longDescription": "SoulStream lets you search, stream and download music from the Soulseek network, with automatic tagging and album art.", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "windows": { + "nsis": { + "installMode": "currentUser", + "startMenuFolder": "SoulStream" + } + } + } +} diff --git a/src/App.svelte b/src/App.svelte new file mode 100644 index 0000000..60324d5 --- /dev/null +++ b/src/App.svelte @@ -0,0 +1,107 @@ + + +
+ + +
+ +
+ + + + +
+ + diff --git a/src/components/AlbumCard.svelte b/src/components/AlbumCard.svelte new file mode 100644 index 0000000..fcc47a8 --- /dev/null +++ b/src/components/AlbumCard.svelte @@ -0,0 +1,88 @@ + + + + + diff --git a/src/components/ArtworkViewer.svelte b/src/components/ArtworkViewer.svelte new file mode 100644 index 0000000..8d25b24 --- /dev/null +++ b/src/components/ArtworkViewer.svelte @@ -0,0 +1,212 @@ + + + + +{#if ui.fullscreenArt && currentItem} + +
(ui.fullscreenArt = false)} + onkeydown={(e) => { + if (e.key === "Enter" || e.key === " ") ui.fullscreenArt = false; + }} + > + {#if ambientUrl} + + {/if} + + + + + {#if artUrl} + Album artwork + {:else} +
+ {/if} +
+{/if} + + diff --git a/src/components/HeroGpuCanvas.svelte b/src/components/HeroGpuCanvas.svelte new file mode 100644 index 0000000..bd0321a --- /dev/null +++ b/src/components/HeroGpuCanvas.svelte @@ -0,0 +1,255 @@ + + + + + + diff --git a/src/components/Icon.svelte b/src/components/Icon.svelte new file mode 100644 index 0000000..2f058fd --- /dev/null +++ b/src/components/Icon.svelte @@ -0,0 +1,91 @@ + + + + + diff --git a/src/components/LoginModal.svelte b/src/components/LoginModal.svelte new file mode 100644 index 0000000..ef92e75 --- /dev/null +++ b/src/components/LoginModal.svelte @@ -0,0 +1,135 @@ + + +{#if app.showLogin} +
{ if (e.target === e.currentTarget) app.showLogin = false; }} + onkeydown={(e) => { if (e.key === "Escape") app.showLogin = false; }} + role="presentation" + > + +
+{/if} + + diff --git a/src/components/NowPlaying.svelte b/src/components/NowPlaying.svelte new file mode 100644 index 0000000..3575a4d --- /dev/null +++ b/src/components/NowPlaying.svelte @@ -0,0 +1,902 @@ + + + + + diff --git a/src/components/PlayerBar.svelte b/src/components/PlayerBar.svelte new file mode 100644 index 0000000..11c6b8c --- /dev/null +++ b/src/components/PlayerBar.svelte @@ -0,0 +1,813 @@ + + +
+
+ {#if artUrl} + + {:else} +
+ {#if player.track}{/if} +
+ {/if} +
+ {#if player.error} + ⚠ {player.error} + {:else if player.track} + + {:else} + Nothing playing + {/if} + + {#if player.status === "buffering"} + {metaParts[0] || "buffering…"} + {:else if player.track} + {#if metaParts[0]} + + {/if} + {#if metaParts[1]} + + {/if} + {/if} +
+
+ +
+
+ + + + + +
+
+ {fmt(player.position)} + {#if hasWave} +
(hoverX = -1)} + class:dragging={draggingSeek} + onpointerdown={(e) => startScrub(e, waveWrap)} + onpointermove={(e) => moveScrub(e, waveWrap)} + onpointerup={(e) => endScrub(e, waveWrap)} + onpointercancel={(e) => endScrub(e, waveWrap)} + > + + {#if hoverX >= 0} +
{fmt(hoverTime)}
+ {/if} +
+ {:else} +
startScrub(e, e.currentTarget)} + onpointermove={(e) => moveScrub(e, e.currentTarget)} + onpointerup={(e) => endScrub(e, e.currentTarget)} + onpointercancel={(e) => endScrub(e, e.currentTarget)} + role="slider" + aria-label="Seek" + aria-valuenow={Math.round(pct)} + tabindex="0" + > + {#if bufferPct > 0 && player.track?.source === "stream"} +
+ {/if} +
+
+ {/if} + {fmt(player.duration)} +
+
+ +
+ + + +
draggingVolume && setVolume(e)} + onpointerup={endVolumeDrag} + onpointercancel={endVolumeDrag} + onkeydown={volumeKeydown} + role="slider" + aria-label="Volume" + aria-valuenow={Math.round((player.muted ? 0 : player.volume) * 100)} + tabindex="0" + > +
+
+
+
+
+ + diff --git a/src/components/SaveMenu.svelte b/src/components/SaveMenu.svelte new file mode 100644 index 0000000..b2b6d74 --- /dev/null +++ b/src/components/SaveMenu.svelte @@ -0,0 +1,166 @@ + + +{#if isStream} + + + + + {#if open} + + {/if} + + {#if toast} +
+ {toast} +
+ {/if} +{/if} + + diff --git a/src/components/Sidebar.svelte b/src/components/Sidebar.svelte new file mode 100644 index 0000000..b0c4563 --- /dev/null +++ b/src/components/Sidebar.svelte @@ -0,0 +1,276 @@ + + + + + diff --git a/src/components/TitleBar.svelte b/src/components/TitleBar.svelte new file mode 100644 index 0000000..4e8544f --- /dev/null +++ b/src/components/TitleBar.svelte @@ -0,0 +1,598 @@ + + +
+
+ + SoulStream +
+ +
+ + diff --git a/src/lib/api.js b/src/lib/api.js new file mode 100644 index 0000000..20fdc85 --- /dev/null +++ b/src/lib/api.js @@ -0,0 +1,154 @@ +// Thin wrapper over Tauri IPC so components never import tauri directly. +import { invoke as tauriInvoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { mockInvoke } from "./mockLibrary.js"; + +// mockInvoke is inert (returns undefined) unless localStorage.mockLibrary === "1". +const invoke = (cmd, args) => mockInvoke(cmd, args) ?? tauriInvoke(cmd, args); + +// Artist-page payloads are keyed by a Deezer id all the way through the +// backend. Reject a mismatched response at the IPC boundary so a provider, +// cache, or future command regression cannot populate another artist's page. +// String comparison deliberately accepts route-string ids and numeric Tauri +// ids as the same identity without coercing the u64 argument sent to Rust. +const artistIdKey = (value) => (value == null ? "" : String(value).trim()); +const sameArtistId = (left, right) => { + const leftKey = artistIdKey(left); + return !!leftKey && leftKey === artistIdKey(right); +}; + +const checkedArtistPayload = (command, artistId) => + invoke(command, { artistId }).then((payload) => { + if (!sameArtistId(payload?.artist?.id, artistId)) { + throw new Error(`${command} returned a mismatched artist identity`); + } + return payload; + }); + +const checkedArtistIdentity = (artistId, artist) => + invoke("catalog_artist_identity", { artistId, artist }).then((payload) => { + if (!sameArtistId(payload?.artist_id ?? payload?.artistId, artistId)) { + throw new Error("catalog_artist_identity returned a mismatched artist identity"); + } + return payload; + }); + +const checkedAlbumPayload = (albumId) => + invoke("catalog_album", { albumId }).then((payload) => { + if (!sameArtistId(payload?.album?.id, albumId)) { + throw new Error("catalog_album returned a mismatched album identity"); + } + return payload; + }); + +export const appStatus = () => invoke("app_status"); +export const getCredentials = () => invoke("get_credentials"); +export const connectAndLogin = (username, password, remember) => + invoke("connect_and_login", { username, password, remember }); +export const connectStored = () => invoke("connect_stored"); +export const disconnect = () => invoke("disconnect"); +export const startSearch = (query) => invoke("start_search", { query }); +export const excludedSearchPhrases = () => invoke("excluded_search_phrases"); +export const stopSearch = (token) => invoke("stop_search", { token }); +export const downloadFile = (username, path, size) => + invoke("download_file", { username, path, size }); +export const copyStreamToFolder = (files, folder) => + invoke("copy_stream_to_folder", { files, folder }); +export const saveToLibrary = (files) => invoke("save_to_library", { files }); +export const cancelDownload = (id) => invoke("cancel_download", { id }); +export const showInFolder = (path) => invoke("show_in_folder", { path }); +export const refreshArtistArt = () => invoke("refresh_artist_art"); +export const playStream = (username, path, size, duration) => + invoke("play_stream", { username, path, size, duration }); +export const playLocal = (path, duration) => invoke("play_local", { path, duration }); +export const preloadLocal = (path, duration) => invoke("preload_local", { path, duration }); +export const playerControl = (action, value, rate) => + invoke("player_control", { action, value: value ?? null, rate: rate ?? null }); +export const waveform = (path, buckets) => invoke("waveform", { path, buckets }); +export const libraryAlbums = () => invoke("library_albums"); +export const albumTracks = (albumId) => invoke("album_tracks", { albumId }); +export const recordPlay = (artist, title) => invoke("record_play", { artist, title }); +export const addPlaySeconds = (playId, secs) => invoke("add_play_seconds", { playId, secs }); +export const artistPlayStats = (artist) => invoke("artist_play_stats", { artist }); +// The WebView blocks target="_blank"; external links open via the backend. +export const openExternal = (url) => invoke("open_external", { url }); +export const albumArt = (path) => invoke("album_art", { path }); +export const refreshAlbumArt = (albumId) => invoke("refresh_album_art", { albumId }); +export const refreshAllAlbumArt = () => invoke("refresh_all_album_art"); +export const getSettings = () => invoke("get_settings"); +export const setSettings = (patch) => invoke("set_settings", patch); +export const forgetCredentials = () => invoke("forget_credentials"); +export const pickFolder = () => invoke("pick_folder"); +export const listPlaylists = () => invoke("list_playlists"); +export const createPlaylist = (name) => invoke("create_playlist", { name }); +export const deletePlaylist = (id) => invoke("delete_playlist", { id }); +export const playlistTracks = (id) => invoke("playlist_tracks", { id }); +export const addToPlaylist = (playlistId, tracks) => + invoke("add_to_playlist", { playlistId, tracks }); +export const removeFromPlaylist = (playlistId, position) => + invoke("remove_from_playlist", { playlistId, position }); +export const catalogSearch = (query) => invoke("catalog_search", { query }); +export const catalogArtist = (artistId) => checkedArtistPayload("catalog_artist", artistId); +export const catalogArtistArt = (artistId) => + checkedArtistPayload("catalog_artist_art", artistId); +export const catalogArtistLogos = (artistId) => invoke("catalog_artist_logos", { artistId }); +// Deferred MusicBrainz pass (socials + MB-only releases). Rate-limited at +// 1 req/s upstream, so it is fetched after the page renders, never before. +export const catalogArtistSocials = (artistId) => + invoke("catalog_artist_socials", { artistId }); +export const catalogArtistBio = (artistId, artist) => + invoke("catalog_artist_bio", { artistId: artistId ?? null, artist }); +export const catalogArtistIdentity = (artistId, artist) => + checkedArtistIdentity(artistId, artist); +export const catalogCachedCoverLogo = (artistId) => + invoke("catalog_cached_cover_logo", { artistId }); +export const catalogCoverLogo = (artistId, artist, covers) => + invoke("catalog_cover_logo", { artistId, artist, covers }); +export const catalogArtistHeroSeed = (artistId) => + invoke("catalog_artist_hero_seed", { artistId }); +export const catalogArtistOverview = (artistId) => + checkedArtistPayload("catalog_artist_overview", artistId); +export const pickArtistHero = (artistId) => invoke("pick_artist_hero", { artistId }); +export const clearArtistHero = (artistId) => invoke("clear_artist_hero", { artistId }); +export const catalogRelated = (artistId) => invoke("catalog_related", { artistId }); +export const catalogAlbum = (albumId) => checkedAlbumPayload(albumId); +export const catalogAlbumInfo = (artistId, artist, title) => + invoke("catalog_album_info", { artistId: artistId ?? null, artist, title }); +export const catalogArt = (url) => invoke("catalog_art", { url }); +export const catalogCover = (artistId, artist, title, cover) => + invoke("catalog_cover", { artistId: artistId ?? null, artist, title, cover }); +export const invalidateCatalogCover = (artistId, artist, title, cover) => + invoke("invalidate_catalog_cover", { artistId: artistId ?? null, artist, title, cover }); +export const catalogHeroPreview = (url) => invoke("catalog_hero_preview", { url }); +export const catalogHeroGpuSource = (url, plan = null) => + invoke("catalog_hero_gpu_source", { url, plan }); +export const catalogCachedHeroArt = (url) => invoke("catalog_cached_hero_art", { url }); +export const catalogHeroArt = (url, plan = null) => + invoke("catalog_hero_art", { url, plan }); +export const preloadStream = (username, path, size) => + invoke("preload_stream", { username, path, size }); +export const preloadCachedStream = (username, path, size, duration) => + invoke("preload_cached_stream", { username, path, size, duration }); +export const streamWaveform = (username, path, size, buckets) => + invoke("stream_waveform", { username, path, size, buckets }); + +// Window chrome (the app draws its own titlebar; decorations are off). +import { getCurrentWindow } from "@tauri-apps/api/window"; +export const win = { + minimize: () => getCurrentWindow().minimize(), + toggleMaximize: () => getCurrentWindow().toggleMaximize(), + close: () => getCurrentWindow().close(), + isMinimized: () => getCurrentWindow().isMinimized(), + isFocused: () => getCurrentWindow().isFocused(), + isMaximized: () => getCurrentWindow().isMaximized(), + // returns Promise + onResized: (handler) => getCurrentWindow().onResized(handler), + onFocusChanged: (handler) => getCurrentWindow().onFocusChanged(handler), +}; + +// Subscribe to a backend event; returns an unlisten function. +// No-ops outside Tauri (e.g. plain-browser preview). +export function onEvent(name, handler) { + const un = listen(name, (e) => handler(e.payload)).catch(() => () => {}); + return () => un.then((f) => f()); +} diff --git a/src/lib/color.js b/src/lib/color.js new file mode 100644 index 0000000..a3cbc9f --- /dev/null +++ b/src/lib/color.js @@ -0,0 +1,130 @@ +// Dominant-color extraction from album art, tuned to produce a usable +// accent on a dark UI: prefers saturated mid-lightness pixels, then clamps +// the result into a safe saturation/lightness band. + +export function hexToRgb(hex) { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +export function rgbToHex([r, g, b]) { + const c = (v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0"); + return `#${c(r)}${c(g)}${c(b)}`; +} + +export function lerpRgb(a, b, t) { + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; +} + +function rgbToHsl(r, g, b) { + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + const l = (max + min) / 2; + if (max === min) return [0, 0, l]; + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h; + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; + else if (max === g) h = ((b - r) / d + 2) / 6; + else h = ((r - g) / d + 4) / 6; + return [h, s, l]; +} + +function hslToRgb(h, s, l) { + if (s === 0) return [l * 255, l * 255, l * 255]; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + const f = (t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255]; +} + +/** + * Extract an accent color from an image data URL. + * Returns { accent, hover } hex strings, or null if nothing usable. + */ +export async function accentFromImage(dataUrl) { + const img = new Image(); + img.src = dataUrl; + try { + await img.decode(); + } catch { + return null; + } + + const S = 48; + const canvas = document.createElement("canvas"); + canvas.width = S; + canvas.height = S; + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0, S, S); + let data; + try { + data = ctx.getImageData(0, 0, S, S).data; + } catch { + return null; + } + + // "True tone": the color FAMILY that covers the most of the artwork wins + // (chroma-weighted hue histogram), and the result keeps that family's + // real saturation/lightness character — a mossy cover yields moss, not + // neon. Legibility clamps are gentle floors, not vibrancy boosts. + const BINS = 24; + const binWeight = new Array(BINS).fill(0); + const samples = []; + const pixelCount = data.length / 4; + for (let i = 0; i < data.length; i += 4) { + const [h, s, l] = rgbToHsl(data[i], data[i + 1], data[i + 2]); + if (l < 0.08 || l > 0.95 || s < 0.1) continue; + const w = s * (1 - Math.abs(2 * l - 1)); // ~chroma + if (w <= 0) continue; + const bin = Math.floor(h * BINS) % BINS; + binWeight[bin] += w; + samples.push({ h, s, l, w, bin }); + } + + // Neutral guard: effectively grayscale art (or art with only a faint + // color cast) stays a deliberate silver, not an invented hue. + const totalWeight = samples.reduce((sum, p) => sum + p.w, 0); + if (!samples.length || totalWeight / pixelCount < 0.03) { + return { + accent: rgbToHex(hslToRgb(0, 0.05, 0.72)), + hover: rgbToHex(hslToRgb(0, 0.05, 0.8)), + }; + } + + // Smooth adjacent bins so a family split across a boundary still wins. + let bestBin = 0; + let bestScore = -1; + for (let i = 0; i < BINS; i++) { + const score = binWeight[i] + + 0.5 * (binWeight[(i + 1) % BINS] + binWeight[(i + BINS - 1) % BINS]); + if (score > bestScore) { bestScore = score; bestBin = i; } + } + + // Weighted circular mean over the winning family (bin ± 1). + let x = 0, y = 0, ws = 0, sSum = 0, lSum = 0; + for (const p of samples) { + const d = Math.min((p.bin - bestBin + BINS) % BINS, (bestBin - p.bin + BINS) % BINS); + if (d > 1) continue; + x += Math.cos(p.h * 2 * Math.PI) * p.w; + y += Math.sin(p.h * 2 * Math.PI) * p.w; + sSum += p.s * p.w; + lSum += p.l * p.w; + ws += p.w; + } + if (!ws) return null; + let h = Math.atan2(y, x) / (2 * Math.PI); + if (h < 0) h += 1; + const s = Math.min(0.88, Math.max(0.3, (sSum / ws) * 1.05)); + const l = Math.min(0.66, Math.max(0.46, lSum / ws)); + const accent = rgbToHex(hslToRgb(h, s, l)); + const hover = rgbToHex(hslToRgb(h, s, Math.min(0.74, l + 0.08))); + return { accent, hover }; +} diff --git a/src/lib/heroGpu.js b/src/lib/heroGpu.js new file mode 100644 index 0000000..416f1b0 --- /dev/null +++ b/src/lib/heroGpu.js @@ -0,0 +1,543 @@ +export const HERO_MASTER_WIDTH = 3840; +export const HERO_MASTER_HEIGHT = 2160; + +const HEADER_BYTES = 16; +const MAGIC = [0x53, 0x48, 0x47, 0x31]; // SHG1 + +const VS = `#version 300 es +precision highp float; +const vec2 pos[3] = vec2[3](vec2(-1.,-1.), vec2(3.,-1.), vec2(-1.,3.)); +void main(){ gl_Position = vec4(pos[gl_VertexID], 0., 1.); }`; + +// FidelityFX FSR 1 EASU, transliterated from AMD's reference implementation. +// `flipInput` is used only for the first pass because Rust's RGBA rows arrive +// top-first while render-target textures use conventional GL bottom-first rows. +const FS_EASU = `#version 300 es +precision highp float; +uniform sampler2D src; +uniform vec2 inSize; +uniform vec2 outSize; +uniform float flipInput; +out vec4 frag; +float lumaOf(vec3 c){ return c.b*0.5 + (c.r*0.5 + c.g); } +vec3 tex(ivec2 p){ return texelFetch(src, clamp(p, ivec2(0), ivec2(inSize)-1), 0).rgb; } +void easuSet(inout vec2 dir, inout float len, vec2 pp, bool biS, bool biT, bool biU, bool biV, + float lA, float lB, float lC, float lD, float lE){ + float w = 0.0; + if (biS) w = (1.0-pp.x)*(1.0-pp.y); + if (biT) w = pp.x*(1.0-pp.y); + if (biU) w = (1.0-pp.x)*pp.y; + if (biV) w = pp.x*pp.y; + float dc = lD-lC, cb = lC-lB; + float lenX = max(abs(dc), abs(cb)); + lenX = 1.0/max(lenX, 1e-5); + float dirX = lD-lB; + dir.x += dirX*w; + lenX = clamp(abs(dirX)*lenX, 0.0, 1.0); + lenX *= lenX; + len += lenX*w; + float ec = lE-lC, ca = lC-lA; + float lenY = max(abs(ec), abs(ca)); + lenY = 1.0/max(lenY, 1e-5); + float dirY = lE-lA; + dir.y += dirY*w; + lenY = clamp(abs(dirY)*lenY, 0.0, 1.0); + lenY *= lenY; + len += lenY*w; +} +void easuTap(inout vec3 aC, inout float aW, vec2 off, vec2 dir, vec2 len, float lob, float clp, vec3 c){ + vec2 v = vec2(off.x*dir.x + off.y*dir.y, off.x*(-dir.y) + off.y*dir.x); + v *= len; + float d2 = min(v.x*v.x + v.y*v.y, clp); + float wB = 0.4*d2 - 1.0; + float wA = lob*d2 - 1.0; + wB *= wB; wA *= wA; + wB = (25.0/16.0)*wB - (25.0/16.0 - 1.0); + float w = wB*wA; + aC += c*w; aW += w; +} +void main(){ + vec2 ip = floor(gl_FragCoord.xy); + if (flipInput > 0.5) ip.y = outSize.y - 1.0 - ip.y; + vec2 pp = (ip + 0.5)*(inSize/outSize) - 0.5; + vec2 fp = floor(pp); + pp -= fp; + ivec2 F = ivec2(fp); + vec3 bC = tex(F+ivec2(0,-1)), cC = tex(F+ivec2(1,-1)); + vec3 eC = tex(F+ivec2(-1,0)), fC = tex(F), gC = tex(F+ivec2(1,0)), hC = tex(F+ivec2(2,0)); + vec3 iC = tex(F+ivec2(-1,1)), jC = tex(F+ivec2(0,1)), kC = tex(F+ivec2(1,1)), lC2 = tex(F+ivec2(2,1)); + vec3 nC = tex(F+ivec2(0,2)), oC = tex(F+ivec2(1,2)); + float bL = lumaOf(bC), cL = lumaOf(cC), eL = lumaOf(eC), fL = lumaOf(fC); + float gL = lumaOf(gC), hL = lumaOf(hC), iL = lumaOf(iC), jL = lumaOf(jC); + float kL = lumaOf(kC), lL = lumaOf(lC2), nL = lumaOf(nC), oL = lumaOf(oC); + vec2 dir = vec2(0.0); float len = 0.0; + easuSet(dir, len, pp, true,false,false,false, bL, eL, fL, gL, jL); + easuSet(dir, len, pp, false,true,false,false, cL, fL, gL, hL, kL); + easuSet(dir, len, pp, false,false,true,false, fL, iL, jL, kL, nL); + easuSet(dir, len, pp, false,false,false,true, gL, jL, kL, lL, oL); + vec2 dir2 = dir*dir; + float dirR = dir2.x + dir2.y; + bool zro = dirR < (1.0/32768.0); + dirR = inversesqrt(max(dirR, 1e-12)); + dirR = zro ? 1.0 : dirR; + dir.x = zro ? 1.0 : dir.x; + dir *= dirR; + len = len*0.5; + len *= len; + float stretch = (dir.x*dir.x + dir.y*dir.y)/max(max(abs(dir.x), abs(dir.y)), 1e-5); + vec2 len2 = vec2(1.0 + (stretch - 1.0)*len, 1.0 - 0.5*len); + float lob = 0.5 + ((1.0/4.0 - 0.04) - 0.5)*len; + float clp = 1.0/max(lob, 1e-5); + vec3 min4 = min(min(fC, gC), min(jC, kC)); + vec3 max4 = max(max(fC, gC), max(jC, kC)); + vec3 aC = vec3(0.0); float aW = 0.0; + easuTap(aC, aW, vec2( 0.,-1.)-pp, dir, len2, lob, clp, bC); + easuTap(aC, aW, vec2( 1.,-1.)-pp, dir, len2, lob, clp, cC); + easuTap(aC, aW, vec2(-1., 1.)-pp, dir, len2, lob, clp, iC); + easuTap(aC, aW, vec2( 0., 1.)-pp, dir, len2, lob, clp, jC); + easuTap(aC, aW, vec2( 0., 0.)-pp, dir, len2, lob, clp, fC); + easuTap(aC, aW, vec2(-1., 0.)-pp, dir, len2, lob, clp, eC); + easuTap(aC, aW, vec2( 1., 1.)-pp, dir, len2, lob, clp, kC); + easuTap(aC, aW, vec2( 1., 0.)-pp, dir, len2, lob, clp, gC); + easuTap(aC, aW, vec2( 0., 2.)-pp, dir, len2, lob, clp, nC); + easuTap(aC, aW, vec2( 1., 2.)-pp, dir, len2, lob, clp, oC); + easuTap(aC, aW, vec2( 2., 1.)-pp, dir, len2, lob, clp, lC2); + easuTap(aC, aW, vec2( 2., 0.)-pp, dir, len2, lob, clp, hC); + frag = vec4(min(max4, max(min4, aC/aW)), 1.0); +}`; + +const FS_RCAS = `#version 300 es +precision highp float; +uniform sampler2D src; +uniform vec2 size; +uniform float con; +out vec4 frag; +vec3 tex(ivec2 p){ return texelFetch(src, clamp(p, ivec2(0), ivec2(size)-1), 0).rgb; } +void main(){ + ivec2 sp = ivec2(gl_FragCoord.xy); + vec3 b = tex(sp+ivec2(0,-1)), d = tex(sp+ivec2(-1,0)), e = tex(sp), f = tex(sp+ivec2(1,0)), h = tex(sp+ivec2(0,1)); + vec3 mn4 = min(min(b,d),min(f,h)), mx4 = max(max(b,d),max(f,h)); + vec3 hitMin = min(mn4,e)/(4.0*mx4+1e-5); + vec3 hitMax = (1.0-max(mx4,e))/(4.0*mn4-4.0-1e-5); + vec3 lobeRGB = max(-hitMin,hitMax); + float lobe = max(-0.1875, min(max(lobeRGB.r,max(lobeRGB.g,lobeRGB.b)),0.0))*con; + frag = vec4(((b+d+f+h)*lobe+e)/(4.0*lobe+1.0), 1.0); +}`; + +// Exact shipped Ruby Chrome 30% + vintage fade 100% + native-4K grain 70%. +// `trunc`, not `floor`, matches Rust's signed integer division toward zero. +const FS_GRADE = `#version 300 es +precision highp float; +uniform sampler2D src; +uniform float grainAmp; +uniform float seed; +out vec4 frag; +float ss(float e0,float e1,float v){ float t=clamp((v-e0)/(e1-e0),0.,1.); return t*t*(3.-2.*t); } +uint grainHash(uint x){ + x ^= x >> 16; x *= 0x7feb352du; + x ^= x >> 15; x *= 0x846ca68bu; + return x ^ (x >> 16); +} +float grainByte(ivec2 p,uint salt){ + uint key = uint(p.x)*0x9e3779b1u ^ uint(p.y)*0x85ebca6bu ^ salt; + return float(grainHash(key) & 255u); +} +void main(){ + ivec2 pixel = ivec2(gl_FragCoord.xy); + vec3 c = texelFetch(src, pixel, 0).rgb; + float luma = c.r*0.2126+c.g*0.7152+c.b*0.0722; + float sh = 1.0-ss(0.16,0.60,luma), hi = ss(0.38,0.90,luma); + vec3 g = mix(vec3(luma), c, 1.24); + g = (g-0.5)*1.12+0.5; + g = mix(g, g*g*(3.-2.*g), 0.18); + g.r += hi*0.070+sh*0.018; g.g -= hi*0.028; g.b += sh*0.045+hi*0.018; + g = clamp(g,0.,1.); + vec3 color = mix(c, g, 0.30); + float l2 = color.r*0.2126+color.g*0.7152+color.b*0.0722; + vec3 faded = clamp(mix(vec3(l2*0.78+0.078), color*0.78+vec3(0.085,0.078,0.068), 0.88),0.,1.); + uint grainSeed = uint(seed); + float r1 = grainByte(pixel,grainSeed)-128.; + float r2 = grainByte(pixel,grainSeed^0xa511e9b3u)-128.; + float delta = clamp(trunc((r1+r2)*grainAmp/256.), -grainAmp, grainAmp)/255.; + frag = vec4(clamp(faded+delta,0.,1.), 1.0); +}`; + +const FS_BLIT = `#version 300 es +precision highp float; +uniform sampler2D src; +uniform vec2 outSize; +uniform vec2 uvScale; +uniform vec2 uvOffset; +uniform float flipInput; +out vec4 frag; +void main(){ + vec2 uv = gl_FragCoord.xy/outSize; + uv = uvOffset + uv*uvScale; + if (flipInput > 0.5) uv.y = 1.0-uv.y; + frag = vec4(texture(src, uv).rgb, 1.0); +}`; + +// Preserve the 16:9 master like CSS object-fit cover, aligned center/top. +export function heroCoverUv(outputWidth, outputHeight) { + const sourceAspect = HERO_MASTER_WIDTH / HERO_MASTER_HEIGHT; + const outputAspect = outputWidth / outputHeight; + if (outputAspect > sourceAspect) { + const visibleHeight = sourceAspect / outputAspect; + return { + scale: [1, visibleHeight], + offset: [0, 1 - visibleHeight], + }; + } + const visibleWidth = outputAspect / sourceAspect; + return { + scale: [visibleWidth, 1], + offset: [(1 - visibleWidth) / 2, 0], + }; +} + +function parsePayload(payload) { + const bytes = payload instanceof Uint8Array + ? payload + : new Uint8Array(payload); + if (bytes.byteLength < HEADER_BYTES || !MAGIC.every((v, i) => bytes[i] === v)) { + throw new Error("invalid live hero payload"); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const width = view.getUint32(4, true); + const height = view.getUint32(8, true); + const seed = view.getUint32(12, true); + const expected = HEADER_BYTES + width * height * 4; + if (!width || !height || !Number.isSafeInteger(expected) || expected !== bytes.byteLength) { + throw new Error("invalid live hero dimensions"); + } + return { width, height, seed, rgba: bytes.subarray(HEADER_BYTES) }; +} + +function compile(gl, source, type) { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const error = gl.getShaderInfoLog(shader) || "shader compilation failed"; + gl.deleteShader(shader); + throw new Error(error); + } + return shader; +} + +function program(gl, fragment) { + const result = gl.createProgram(); + const vertex = compile(gl, VS, gl.VERTEX_SHADER); + const pixel = compile(gl, fragment, gl.FRAGMENT_SHADER); + gl.attachShader(result, vertex); + gl.attachShader(result, pixel); + gl.linkProgram(result); + gl.deleteShader(vertex); + gl.deleteShader(pixel); + if (!gl.getProgramParameter(result, gl.LINK_STATUS)) { + const error = gl.getProgramInfoLog(result) || "shader link failed"; + gl.deleteProgram(result); + throw new Error(error); + } + return result; +} + +function mipLevels(width, height) { + return 1 + Math.floor(Math.log2(Math.max(width, height))); +} + +export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previousBloomCanvas, onLost) { + const gl = canvas.getContext("webgl2", { + alpha: false, + antialias: false, + depth: false, + stencil: false, + premultipliedAlpha: false, + preserveDrawingBuffer: true, + powerPreference: "high-performance", + }); + if (!gl) throw new Error("WebGL2 is unavailable"); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE); + if ("drawingBufferColorSpace" in gl) gl.drawingBufferColorSpace = "srgb"; + + const maxTexture = gl.getParameter(gl.MAX_TEXTURE_SIZE); + if (maxTexture < HERO_MASTER_WIDTH) { + throw new Error(`GPU texture limit ${maxTexture}px is below 4K`); + } + + const programs = { + easu: program(gl, FS_EASU), + rcas: program(gl, FS_RCAS), + grade: program(gl, FS_GRADE), + blit: program(gl, FS_BLIT), + }; + let master = null; + let destroyed = false; + + const contextLost = (event) => { + event.preventDefault(); + onLost?.(new Error("WebGL context lost")); + }; + canvas.addEventListener("webglcontextlost", contextLost); + + function texture(width, height, levels = 1) { + const value = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, value); + gl.texStorage2D(gl.TEXTURE_2D, levels, gl.RGBA8, width, height); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_MIN_FILTER, + levels > 1 ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR, + ); + return value; + } + + function target(width, height, levels = 1) { + const tx = texture(width, height, levels); + const fb = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fb); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tx, 0); + if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) { + gl.deleteFramebuffer(fb); + gl.deleteTexture(tx); + throw new Error("GPU hero framebuffer is incomplete"); + } + return { tx, fb, width, height, levels }; + } + + function disposeTarget(value, keepTexture = false) { + if (!value) return; + if (value.fb) gl.deleteFramebuffer(value.fb); + if (!keepTexture && value.tx) gl.deleteTexture(value.tx); + } + + function draw(prog, src, output, uniforms = {}) { + gl.useProgram(prog); + gl.bindFramebuffer(gl.FRAMEBUFFER, output?.fb ?? null); + const width = output?.width ?? canvas.width; + const height = output?.height ?? canvas.height; + gl.viewport(0, 0, width, height); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, src); + gl.uniform1i(gl.getUniformLocation(prog, "src"), 0); + for (const [name, value] of Object.entries(uniforms)) { + const location = gl.getUniformLocation(prog, name); + if (location === null) continue; + if (Array.isArray(value)) gl.uniform2f(location, value[0], value[1]); + else gl.uniform1f(location, value); + } + gl.drawArrays(gl.TRIANGLES, 0, 3); + const error = gl.getError(); + if (error !== gl.NO_ERROR) throw new Error(`WebGL hero draw failed (${error})`); + } + + function copyCanvas(from, to) { + if (!from.width || !from.height) return; + to.width = from.width; + to.height = from.height; + const context = to.getContext("2d", { alpha: false }); + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + context.drawImage(from, 0, 0, to.width, to.height); + } + + function snapshot() { + if (!master || !canvas.width) return false; + copyCanvas(canvas, previousCanvas); + copyCanvas(bloomCanvas, previousBloomCanvas); + return true; + } + + function buildMaster(payload) { + const { width, height, seed, rgba } = parsePayload(payload); + if (width > maxTexture || height > maxTexture) { + throw new Error(`hero source ${width}x${height} exceeds the GPU texture limit`); + } + const downsample = width > HERO_MASTER_WIDTH || height > HERO_MASTER_HEIGHT; + const sourceLevels = downsample ? mipLevels(width, height) : 1; + const source = texture(width, height, sourceLevels); + const temporaries = []; + let scaled = null; + let sharpened = null; + let finished = null; + try { + gl.bindTexture(gl.TEXTURE_2D, source); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + width, + height, + gl.RGBA, + gl.UNSIGNED_BYTE, + rgba, + ); + if (downsample) gl.generateMipmap(gl.TEXTURE_2D); + + if (downsample || (width === HERO_MASTER_WIDTH && height === HERO_MASTER_HEIGHT)) { + scaled = target(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT); + temporaries.push(scaled); + draw(programs.blit, source, scaled, { + outSize: [HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT], + uvScale: [1, 1], + uvOffset: [0, 0], + flipInput: 1, + }); + } else { + let currentTexture = source; + let currentWidth = width; + let currentHeight = height; + let firstPass = true; + const firstWidth = Math.min(width * 2, HERO_MASTER_WIDTH); + if (firstWidth < HERO_MASTER_WIDTH) { + const intermediate = target( + firstWidth, + Math.max(1, Math.round((height * firstWidth) / width)), + ); + temporaries.push(intermediate); + draw(programs.easu, currentTexture, intermediate, { + inSize: [currentWidth, currentHeight], + outSize: [intermediate.width, intermediate.height], + flipInput: 1, + }); + currentTexture = intermediate.tx; + currentWidth = intermediate.width; + currentHeight = intermediate.height; + firstPass = false; + } + scaled = target(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT); + temporaries.push(scaled); + draw(programs.easu, currentTexture, scaled, { + inSize: [currentWidth, currentHeight], + outSize: [HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT], + flipInput: firstPass ? 1 : 0, + }); + } + + sharpened = target(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT); + temporaries.push(sharpened); + draw(programs.rcas, scaled.tx, sharpened, { + size: [HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT], + con: Math.pow(2, -0.25), + }); + + finished = target( + HERO_MASTER_WIDTH, + HERO_MASTER_HEIGHT, + mipLevels(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT), + ); + draw(programs.grade, sharpened.tx, finished, { + grainAmp: 20, + seed, + }); + gl.bindTexture(gl.TEXTURE_2D, finished.tx); + gl.generateMipmap(gl.TEXTURE_2D); + gl.finish(); + disposeTarget(finished, true); + return { tx: finished.tx, width: HERO_MASTER_WIDTH, height: HERO_MASTER_HEIGHT }; + } catch (error) { + disposeTarget(finished); + throw error; + } finally { + for (const value of temporaries) disposeTarget(value); + gl.deleteTexture(source); + } + } + + function copyBloom() { + if (!canvas.width || !canvas.height) return; + const cssHeight = 106; + const rect = canvas.getBoundingClientRect(); + const scale = rect.height > 0 ? canvas.height / rect.height : 1; + const sourceHeight = Math.max(1, Math.min(canvas.height, Math.round(cssHeight * scale))); + bloomCanvas.width = canvas.width; + bloomCanvas.height = sourceHeight; + const context = bloomCanvas.getContext("2d", { alpha: false }); + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + context.drawImage( + canvas, + 0, + 0, + canvas.width, + sourceHeight, + 0, + 0, + bloomCanvas.width, + bloomCanvas.height, + ); + } + + function resize() { + if (destroyed || !master) return; + const rect = canvas.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + const dpr = Math.max(1, window.devicePixelRatio || 1); + const scale = Math.min( + dpr, + HERO_MASTER_WIDTH / rect.width, + HERO_MASTER_HEIGHT / rect.height, + ); + const width = Math.max(1, Math.round(rect.width * scale)); + const height = Math.max(1, Math.round(rect.height * scale)); + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; + const cover = heroCoverUv(width, height); + draw(programs.blit, master.tx, null, { + outSize: [width, height], + uvScale: cover.scale, + uvOffset: cover.offset, + flipInput: 0, + }); + gl.finish(); + copyBloom(); + } + + function render(payload) { + if (destroyed) throw new Error("GPU hero renderer is closed"); + const hadPrevious = snapshot(); + const started = performance.now(); + const next = buildMaster(payload); + const previous = master; + master = next; + resize(); + if (previous?.tx) gl.deleteTexture(previous.tx); + return { + hadPrevious, + renderMs: performance.now() - started, + renderer: gl.getParameter(gl.RENDERER), + }; + } + + // Keep the compiled programs/context warm between artist navigations, but + // release the 4K master texture and canvas backing stores while there is no + // selected GPU hero. A stale master is both visually wrong and roughly + // 44 MiB once its mip chain is included. + function clear() { + if (destroyed) return; + if (master?.tx && !gl.isContextLost()) gl.deleteTexture(master.tx); + master = null; + for (const target of [canvas, bloomCanvas, previousCanvas, previousBloomCanvas]) { + target.width = 1; + target.height = 1; + } + } + + function destroy() { + if (destroyed) return; + destroyed = true; + canvas.removeEventListener("webglcontextlost", contextLost); + if (master?.tx && !gl.isContextLost()) gl.deleteTexture(master.tx); + if (!gl.isContextLost()) { + for (const value of Object.values(programs)) gl.deleteProgram(value); + } + master = null; + } + + return { render, resize, clear, destroy }; +} diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js new file mode 100644 index 0000000..e24fb2d --- /dev/null +++ b/src/lib/mockLibrary.js @@ -0,0 +1,69 @@ +// Dev-only mock library data, for exercising the Library UI on a machine +// without a populated music library. Inert unless explicitly enabled by +// setting localStorage.mockLibrary = "1" in the webview (then reload); +// remove the key to go back to real backend data. + +const ARTISTS = ["Aphex Twin","Boards of Canada","Caribou","Daft Punk","Eno Brian","Four Tet","Grimes","Holly Herndon","Jon Hopkins","Kraftwerk","LCD Soundsystem","Moderat","Nils Frahm","Oneohtrix","Portishead","Radiohead","Squarepusher","Tycho","Underworld","Vangelis","Wilco","Yello","Zero 7","Air","Bonobo","Burial","Clark","Deadmau5","Emancipator","Floating Points","Gold Panda","Helios","Kiasmos","Lorn","Moby","Nujabes","Ott","Plaid","Rival Consoles","Shpongle"]; + +const albums = []; +let aid = 1; +for (let i = 0; i < ARTISTS.length; i++) { + const n = 2 + (i % 3); // 2-4 albums per artist + for (let j = 0; j < n; j++) { + albums.push({ + id: aid, + artist: ARTISTS[i], + title: `Album ${j + 1} of ${ARTISTS[i]}`, + year: 1995 + ((i + j) % 30), + track_count: 10, + art_path: `mock://art/${aid}`, + artist_art_path: `mock://artist/${i}`, + path: `C:\\mock\\${i}\\${j}`, + }); + aid++; + } +} + +function svgArt(hue, label) { + const s = + `` + + `` + + `${label}`; + return "data:image/svg+xml;base64," + btoa(s); +} + +function tracksFor(albumId) { + const artist = albums.find((a) => a.id === albumId)?.artist ?? "?"; + return Array.from({ length: 10 }, (_, k) => ({ + id: albumId * 100 + k, + title: `Track ${k + 1}`, + artist, + track_no: k + 1, + duration_secs: 120 + k * 17, + bitrate_kbps: 320, + path: `C:\\mock\\t${albumId}-${k}.mp3`, + })); +} + +const enabled = (() => { + try { + return localStorage.getItem("mockLibrary") === "1"; + } catch { + return false; + } +})(); + +/** Returns a Promise for mocked commands, or undefined to pass through. */ +export function mockInvoke(cmd, args) { + if (!enabled) return undefined; + if (cmd === "library_albums") return Promise.resolve(albums); + if (cmd === "album_tracks") + return new Promise((r) => setTimeout(() => r(tracksFor(args.albumId)), 60)); + if (cmd === "album_art") { + const m = /(\d+)$/.exec(args.path || ""); + const num = m ? +m[1] : 0; + return Promise.resolve(svgArt((num * 47) % 360, String.fromCharCode(65 + (num % 26)))); + } + if (cmd === "list_playlists") return Promise.resolve([]); + return undefined; +} diff --git a/src/lib/scrollbar.js b/src/lib/scrollbar.js new file mode 100644 index 0000000..68d5d13 --- /dev/null +++ b/src/lib/scrollbar.js @@ -0,0 +1,211 @@ +// Floating overlay scrollbar, applied with `use:floatScroll` on any element +// that scrolls. It hides the native scrollbar entirely — so the bar never +// reserves layout width and can never reflow the content when it appears — +// and draws a thin thumb that floats over the container's right padding, +// fading in while scrolling and out again when idle. No track, no arrow +// buttons; just the one bar. Reusable across the whole app. +// +// When the scroller has a floating glass header pinned to its top (marked +// `[data-scrollhead]`, either a descendant or a direct sibling), the thumb's +// travel starts one PAD *below* that header, so it never rides up over the +// header and shows the same PAD gap top and bottom. +// +// ARCHITECTURE — read before changing how the thumb is positioned. +// The thumb lives inside a zero-height `position: sticky` rail, so the +// COMPOSITOR keeps it pinned in the scrollport; JS only slides it along the +// track. The obvious simpler design — an absolute thumb counter-scrolled per +// frame (top = scrollTop + trackPos) — jitters as soon as the scroller gets +// composited (threaded) scrolling, which the artist page's sticky +// backdrop-filter header forces: the compositor scrolls the content ahead of +// the main thread, the counter-scroll commits one frame late, and the thumb +// visibly oscillates by the full per-frame scroll delta. Pages that scroll on +// the main thread never show this, which is why the bug looked artist-page- +// only. With the sticky rail, a late frame only mis-places the thumb along the +// track by scrollDelta × (trackH / scrollH) — a few percent — imperceptible. + +const PAD = 8; // gap between the thumb's travel and the top header / bottom edge +const HOVER_ZONE = 22; // px in from the right edge that reveals the thumb on hover + +export function floatScroll(node) { + node.classList.add("float-scroll"); + node.style.scrollbarWidth = "none"; // inline: beats scoped component rules + if (getComputedStyle(node).position === "static") node.style.position = "relative"; + + // Zero-height sticky rail as the scroller's FIRST child (sticky can only + // hold an element back from scrolling up past `top: 0`, so it must sit at + // the top of the flow). The thumb hangs off it, absolutely positioned. + const rail = document.createElement("div"); + rail.className = "float-rail"; + const thumb = document.createElement("div"); + thumb.className = "float-thumb"; + rail.appendChild(thumb); + node.prepend(rail); + + let hideTimer = 0; + let dragging = false; + let measureRaf = 0; + + // Cached layout, refreshed only by measure() (never during a scroll). + let active = false; + let topInset = PAD; + let trackH = 0; + let thumbH = 24; + let maxScroll = 0; + let padTop = 0; + let boxRight = 0; // viewport x of the scroller's right edge (for hover) + let boxTop = 0; // viewport y of the scroller's top edge + + // The `show` state (scrolling) fades on its own after a beat; the separate + // `hover` state below is independent and controls its own visibility, so a + // deliberate grab only needs `dragging` to hold the thumb up. + function scheduleHide() { + clearTimeout(hideTimer); + hideTimer = setTimeout(() => { + if (!dragging) thumb.classList.remove("show"); + }, 500); + } + function show() { + thumb.classList.add("show"); + scheduleHide(); + } + + // Recompute the cached geometry. Runs on init and whenever the layout might + // have changed (resize, content grow/shrink, a view swapping a header in or + // out) — but deliberately NOT on scroll: the per-frame path below reads + // only scrollTop and forces no layout. + function measure() { + measureRaf = 0; + const viewH = node.clientHeight; + const scrollH = node.scrollHeight; + const box = node.getBoundingClientRect(); + boxRight = box.right; + boxTop = box.top; + const cs = getComputedStyle(node); + padTop = parseFloat(cs.paddingTop) || 0; + // The rail is an in-flow (zero-height) child. In a flex/grid scroller + // it would still contribute one row-gap before the real first child — + // cancel it so adding the rail never shifts the content. + const rowGap = parseFloat(cs.rowGap) || 0; + rail.style.marginBottom = rowGap ? `-${rowGap}px` : "0"; + // The rail spans the content box; the thumb should sit 3px inside the + // PADDING box's right edge (where it always lived), so pull it back out + // through the right padding. + const padRight = parseFloat(cs.paddingRight) || 0; + thumb.style.right = `${3 - padRight}px`; + + // A `[data-scrollhead]` glass header pinned to the top of this scroller: + // a descendant (the artist page's Results bar inside the content pane) + // or a direct sibling (the Now Playing header sits beside its scroller). + const head = + node.querySelector("[data-scrollhead]") || + node.parentElement?.querySelector(":scope > [data-scrollhead]"); + const headH = head ? head.getBoundingClientRect().height : 0; + + active = scrollH > viewH + 1; + if (!active) { + thumb.style.display = "none"; + return; + } + topInset = headH + PAD; + trackH = Math.max(0, viewH - topInset - PAD); + thumbH = Math.max(24, Math.min(trackH, (viewH / scrollH) * trackH)); + maxScroll = scrollH - viewH; + thumb.style.display = "block"; + thumb.style.height = `${thumbH}px`; + place(); + } + function scheduleMeasure() { + if (!measureRaf) measureRaf = requestAnimationFrame(measure); + } + + // Per-frame, cheap: read only scrollTop, write one composited transform. + // The engine pins the sticky rail at the scroller's CONTENT-box top (i.e. + // `padTop` below the visual top edge, verified empirically in WebView2) for + // the entire scroll, so the rail-relative offset is a constant correction. + function place() { + if (!active) return; + const ratio = maxScroll > 0 ? node.scrollTop / maxScroll : 0; + const y = topInset - padTop + ratio * (trackH - thumbH); + thumb.style.transform = `translateY(${y}px)`; + } + + function onScroll() { + place(); + show(); + } + + // The thumb appears while scrolling; separately, moving the pointer into the + // narrow strip along the scroller's right edge reveals it faintly (the + // `hover` state) so it can be found and grabbed without scrolling first. + node.addEventListener("scroll", onScroll, { passive: true }); + + let nearEdge = false; + function setHover(on) { + if (on === nearEdge) return; + nearEdge = on; + thumb.classList.toggle("hover", on); + } + function onPointerMove(e) { + setHover( + active && + e.clientX >= boxRight - HOVER_ZONE && + e.clientX <= boxRight + 2 && + e.clientY >= boxTop + topInset - 4, + ); + } + function onPointerLeave() { + setHover(false); + } + node.addEventListener("pointermove", onPointerMove, { passive: true }); + node.addEventListener("pointerleave", onPointerLeave); + + const ro = new ResizeObserver(scheduleMeasure); + ro.observe(node); + // Content growing/shrinking (queue changes, sections toggling) changes + // scrollHeight; childList catches that — and a view swapping a header in or + // out — without firing on every text tick. + const mo = new MutationObserver(scheduleMeasure); + mo.observe(node, { childList: true, subtree: true }); + + // Drag the thumb. + let dragY0 = 0; + let scroll0 = 0; + thumb.addEventListener("pointerdown", (e) => { + dragging = true; + dragY0 = e.clientY; + scroll0 = node.scrollTop; + thumb.classList.add("dragging"); + thumb.setPointerCapture(e.pointerId); + e.preventDefault(); + }); + thumb.addEventListener("pointermove", (e) => { + if (!dragging) return; + const track = trackH - thumbH; + node.scrollTop = scroll0 + (track > 0 ? ((e.clientY - dragY0) / track) * maxScroll : 0); + }); + function endDrag(e) { + if (!dragging) return; + dragging = false; + thumb.classList.remove("dragging"); + if (thumb.hasPointerCapture(e.pointerId)) thumb.releasePointerCapture(e.pointerId); + scheduleHide(); + } + thumb.addEventListener("pointerup", endDrag); + thumb.addEventListener("pointercancel", endDrag); + + measure(); + + return { + destroy() { + clearTimeout(hideTimer); + if (measureRaf) cancelAnimationFrame(measureRaf); + node.removeEventListener("scroll", onScroll); + node.removeEventListener("pointermove", onPointerMove); + node.removeEventListener("pointerleave", onPointerLeave); + // The thumb's own pointer listeners go away with rail.remove(). + ro.disconnect(); + mo.disconnect(); + rail.remove(); + }, + }; +} diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js new file mode 100644 index 0000000..a33eb47 --- /dev/null +++ b/src/lib/state.svelte.js @@ -0,0 +1,4306 @@ +// Shared reactive app state (Svelte 5 runes module). +import * as api from "./api.js"; +import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js"; + +export const app = $state({ + connection: "offline", // offline | connecting | online + username: "", + loginError: "", + showLogin: false, +}); + +export const ui = $state({ + view: "home", + albumId: null, // selected album in Library view + playlistId: null, // selected playlist in Playlist view + nowPlaying: true, // right-side Now Playing panel open + sidebarCollapsed: false, // left nav collapsed to an icon rail + fullscreenArt: false, // Spotify-style full-window now-playing view + // WebView2 keeps document.visibilityState="visible" while the native + // window is minimized. This is the authoritative shared activity gate for + // hidden animation, canvas, carousel, and high-frequency position work. + windowActive: true, +}); + +export function toggleNowPlaying() { + ui.nowPlaying = !ui.nowPlaying; +} + +export function toggleSidebar() { + ui.sidebarCollapsed = !ui.sidebarCollapsed; +} + +export const libraryStore = $state({ + albums: [], + loaded: false, +}); + +export const playlistStore = $state({ + playlists: [], +}); + +export async function refreshPlaylists() { + try { + playlistStore.playlists = await api.listPlaylists(); + } catch { + // backend not available + } +} + +const MIB = 1024 * 1024; + +function isPending(value) { + return value && typeof value.then === "function"; +} + +function touchCache(cache, key) { + if (!cache.has(key)) return undefined; + const value = cache.get(key); + cache.delete(key); + cache.set(key, value); + return value; +} + +function cacheValueChars(value) { + if (typeof value === "string") return value.length; + if (typeof value?.data_url === "string") return value.data_url.length; + if (Number.isFinite(value?.byteLength)) return value.byteLength; + return 0; +} + +/** Keep resolved base64 payloads on a byte-like character budget while never + * evicting in-flight promises. Visible elements retain their own src, + * so dropping a cold cache reference cannot degrade an already-painted image. */ +function trimResolvedCache(cache, maxEntries, maxChars) { + let entries = 0; + let chars = 0; + for (const value of cache.values()) { + if (isPending(value)) continue; + entries += 1; + chars += cacheValueChars(value); + } + if (entries <= maxEntries && chars <= maxChars) return; + for (const [key, value] of cache) { + if (isPending(value)) continue; + cache.delete(key); + entries -= 1; + chars -= cacheValueChars(value); + if (entries <= maxEntries && chars <= maxChars) break; + } +} + +function setResolvedCache(cache, key, value, maxEntries, maxChars) { + cache.delete(key); + cache.set(key, value); + trimResolvedCache(cache, maxEntries, maxChars); +} + +function setBoundedMap(cache, key, value, maxEntries) { + cache.delete(key); + cache.set(key, value); + while (cache.size > maxEntries) cache.delete(cache.keys().next().value); +} + +// path -> data URL, bounded so browsing a large library cannot retain every +// base64 cover for the entire WebView lifetime. +const artCache = new Map(); +const ART_CACHE_MAX = 128; +const ART_CACHE_MAX_CHARS = 32 * MIB; + +export function evictArt(path) { + artCache.delete(path); +} + +export async function getArt(path) { + if (!path) return null; + if (artCache.has(path)) return touchCache(artCache, path); + // PlayerBar, NowPlaying, and accent extraction can all request the same + // cover during one track change. Cache the in-flight promise so one native + // read/base64 conversion satisfies every consumer. + const pending = api.albumArt(path).then( + (url) => { + if (artCache.get(path) === pending) { + setResolvedCache( + artCache, + path, + url, + ART_CACHE_MAX, + ART_CACHE_MAX_CHARS, + ); + } + return url; + }, + () => { + if (artCache.get(path) === pending) { + setResolvedCache( + artCache, + path, + null, + ART_CACHE_MAX, + ART_CACHE_MAX_CHARS, + ); + } + return null; + }, + ); + artCache.set(path, pending); + return pending; +} + +// Catalog cover URLs → data URLs, proxied through Rust (no CORS on CDNs). +const catalogArtCache = new Map(); +const CATALOG_ART_CACHE_MAX = 160; +const CATALOG_ART_CACHE_MAX_CHARS = 32 * MIB; + +export async function getCatalogArt(url) { + if (!url) return null; + // Already-inline art (e.g. a cover-derived logo) has nothing to proxy. + if (url.startsWith("data:")) return url; + if (catalogArtCache.has(url)) return touchCache(catalogArtCache, url); + // Cache the promise immediately, not only its result. Multiple components + // often request the same art during one render; one IPC/base64 conversion + // should satisfy every waiter. + const pending = api.catalogArt(url).then( + (dataUrl) => { + if (catalogArtCache.get(url) === pending) { + setResolvedCache( + catalogArtCache, + url, + dataUrl, + CATALOG_ART_CACHE_MAX, + CATALOG_ART_CACHE_MAX_CHARS, + ); + } + return dataUrl; + }, + () => { + // CDN/provider failures are transient; allow the next visit to retry. + if (catalogArtCache.get(url) === pending) catalogArtCache.delete(url); + return null; + }, + ); + catalogArtCache.set(url, pending); + return pending; +} + +// Remote catalog cover URLs that failed to load — a dead CDN link, a Cover Art +// Archive 404 (MusicBrainz-supplemented cards are given a `front-500` URL +// whether or not CAA actually holds art for that release group), or bytes that +// won't decode. Cards read `artBroken[url]` and fall back to their disc +// placeholder, so a dead provider URL renders the placeholder instead of the +// browser's broken-image glyph. Session-scoped and keyed by URL (a dead URL +// stays dead); bounded so a marathon browse can't retain unbounded strings. +export const artBroken = $state({}); + +export function markArtBroken(url) { + if (!url || artBroken[url]) return; + const keys = Object.keys(artBroken); + if (keys.length > 2000) for (const k of keys) delete artBroken[k]; + artBroken[url] = true; + // Remote art hosts flake transiently (Cover Art Archive redirects into + // archive.org, which routinely times out under load) — one failed load + // must not pin the placeholder for the whole session. Re-probe off-DOM + // and un-mark on success; cards restore reactively. + if (/^https?:/i.test(url) && !artRetriesStarted.has(url)) { + artRetriesStarted.add(url); + retryBrokenArt(url, 0); + } +} + +// Cover Art Archive redirects into archive.org, which can be down for minutes +// at a time, so the old two-shot 4s/20s ladder simply exhausted itself inside +// half a minute and pinned the placeholder for the rest of the session. Retry +// further out, and — crucially — RELEASE the url from `artRetriesStarted` when +// the ladder ends, so revisiting the artist later starts a fresh cycle instead +// of being permanently locked out. +const ART_RETRY_DELAYS = [3_000, 10_000, 30_000, 90_000]; +const artRetriesStarted = new Set(); + +function retryBrokenArt(url, attempt) { + if (attempt >= ART_RETRY_DELAYS.length) { + artRetriesStarted.delete(url); + return; + } + setTimeout(() => { + if (!artBroken[url]) { + artRetriesStarted.delete(url); + return; + } + const probe = new Image(); + probe.onload = () => { + delete artBroken[url]; + artRetriesStarted.delete(url); + }; + probe.onerror = () => retryBrokenArt(url, attempt + 1); + probe.src = url; + }, ART_RETRY_DELAYS[attempt]); +} + +// Catalog cover upgrades: original cover URL -> a verified higher-resolution +// URL from the no-MusicBrainz backend resolver (Apple / Deezer native max /, +// for MusicBrainz-supplemented cards only, Cover Art Archive). The grid paints +// the instant Deezer cover, then swaps to the upgrade when it resolves. Keyed +// by URL so a shared cover resolves once; bounded for a long browse. +export const coverUpgrades = $state({}); +const coverUpgradeInflight = new Map(); // identity + original url -> Promise +const coverUpgradeResults = new Map(); // bounded LRU: request key -> result context +const coverUpgradeContexts = new Map(); // original url -> disk-cache producer +const coverUpgradeMisses = new Map(); // original url -> attempted-at +const coverUpgradeQueue = []; +let coverUpgradeActive = 0; +let coverUpgradeScope = 0; +let coverUpgradeArtistId = null; +const COVER_UPGRADE_CONCURRENCY = 4; +const COVER_UPGRADE_MAX = 400; +const COVER_UPGRADE_MISS_TTL_MS = 2 * 60 * 1000; + +function advanceCoverUpgradeScope(artistId) { + coverUpgradeArtistId = artistCacheKey(artistId) || null; + coverUpgradeScope += 1; + // Queued work has not crossed IPC yet, so it is safe to cancel when its + // artist is no longer visible. At most four already-running jobs finish; + // the new artist never sits behind an arbitrarily long stale backlog. + for (let index = coverUpgradeQueue.length - 1; index >= 0; index -= 1) { + const job = coverUpgradeQueue[index]; + if (job.sticky || job.scope === coverUpgradeScope) continue; + coverUpgradeQueue.splice(index, 1); + if (coverUpgradeInflight.get(job.key) === job.promise) { + coverUpgradeInflight.delete(job.key); + } + job.resolve(null); + } +} + +function activateCoverUpgradeScope(artistId) { + if (sameArtistId(coverUpgradeArtistId, artistId)) return; + advanceCoverUpgradeScope(artistId); +} + +/** Cancel artist-grid jobs that have not crossed IPC when that view leaves. + * Sticky currently-playing art and the four already-running calls are kept. */ +export function leaveCoverUpgradeScope(artistId) { + if (!sameArtistId(coverUpgradeArtistId, artistId)) return; + advanceCoverUpgradeScope(null); +} + +function coverUpgradeRequestKey(artistId, url) { + return `${artistCacheKey(artistId)}\u0000${url}`; +} + +function rememberCoverUpgrade(key, url, best, artistId, artist, title) { + const result = { key, url, best, artistId, artist, title }; + coverUpgradeResults.delete(key); + coverUpgradeResults.set(key, result); + coverUpgradeContexts.set(url, result); + coverUpgrades[url] = best; + while (coverUpgradeResults.size > COVER_UPGRADE_MAX) { + const oldest = coverUpgradeResults.keys().next().value; + const removed = coverUpgradeResults.get(oldest); + coverUpgradeResults.delete(oldest); + if (coverUpgradeContexts.get(removed?.url)?.key === oldest) { + coverUpgradeContexts.delete(removed.url); + if (coverUpgrades[removed.url] === removed.best) delete coverUpgrades[removed.url]; + } + } +} + +function rememberCoverUpgradeMiss(key) { + coverUpgradeMisses.delete(key); + coverUpgradeMisses.set(key, Date.now()); + while (coverUpgradeMisses.size > COVER_UPGRADE_MAX) { + coverUpgradeMisses.delete(coverUpgradeMisses.keys().next().value); + } +} + +function pumpCoverUpgradeQueue() { + while (coverUpgradeActive < COVER_UPGRADE_CONCURRENCY && coverUpgradeQueue.length) { + // Current-page viewport work wins over retained player/album jobs. + const currentIndex = coverUpgradeQueue.findIndex( + (candidate) => !candidate.sticky && candidate.scope === coverUpgradeScope, + ); + const job = currentIndex > 0 + ? coverUpgradeQueue.splice(currentIndex, 1)[0] + : coverUpgradeQueue.shift(); + coverUpgradeActive += 1; + api.catalogCover(job.artistId, job.artist, job.title, job.url) + .then((best) => { + if (best && best !== job.url) { + coverUpgradeMisses.delete(job.key); + rememberCoverUpgrade( + job.key, + job.url, + best, + job.artistId, + job.artist, + job.title, + ); + } else { + rememberCoverUpgradeMiss(job.key); + } + job.resolve(best ?? null); + }) + .catch(() => { + rememberCoverUpgradeMiss(job.key); + job.resolve(null); + }) + .finally(() => { + if (coverUpgradeInflight.get(job.key) === job.promise) { + coverUpgradeInflight.delete(job.key); + } + coverUpgradeActive -= 1; + pumpCoverUpgradeQueue(); + }); + } +} + +function keepOnlyLatestStickyCoverJob(url) { + for (let index = coverUpgradeQueue.length - 1; index >= 0; index -= 1) { + const job = coverUpgradeQueue[index]; + if (!job.sticky || job.url === url) continue; + coverUpgradeQueue.splice(index, 1); + if (coverUpgradeInflight.get(job.key) === job.promise) { + coverUpgradeInflight.delete(job.key); + } + job.resolve(null); + } +} + +function scheduleCoverUpgrade(artistId, artist, title, url, sticky = false) { + const key = coverUpgradeRequestKey(artistId, url); + // Player changes supersede older queued player-art polish. Running calls + // finish safely, but rapid track skipping cannot accumulate an immortal + // sticky backlog across every artist scope. + if (sticky) keepOnlyLatestStickyCoverJob(url); + if (coverUpgradeResults.has(key)) { + const result = touchCache(coverUpgradeResults, key); + coverUpgradeContexts.set(url, result); + if (coverUpgrades[url] !== result.best) coverUpgrades[url] = result.best; + return Promise.resolve(result.best); + } + const published = coverUpgradeContexts.get(url); + if (published?.key === key && coverUpgrades[url]) { + return Promise.resolve(coverUpgrades[url]); + } + const attemptedAt = coverUpgradeMisses.get(key); + if (attemptedAt && Date.now() - attemptedAt < COVER_UPGRADE_MISS_TTL_MS) { + return Promise.resolve(null); + } + if (coverUpgradeInflight.has(key)) { + const existing = coverUpgradeInflight.get(key); + if (sticky) { + const index = coverUpgradeQueue.findIndex((job) => job.promise === existing); + if (index >= 0) { + const [job] = coverUpgradeQueue.splice(index, 1); + job.sticky = true; + coverUpgradeQueue.unshift(job); + } + } + return existing; + } + + let complete; + const promise = new Promise((resolve) => { complete = resolve; }); + const job = { + key, + artistId: artistId ?? null, + artist: artist ?? "", + title: title ?? "", + url, + resolve: complete, + promise, + sticky, + scope: coverUpgradeScope, + }; + // Register before enqueueing: staged overview/full-page calls can no longer + // create separate worker pools containing the same not-yet-started URL. + coverUpgradeInflight.set(key, promise); + if (sticky) coverUpgradeQueue.unshift(job); + else coverUpgradeQueue.push(job); + pumpCoverUpgradeQueue(); + return promise; +} + +/** Resolve higher-resolution covers for catalog albums, in parallel with a + * concurrency cap. The backend does NO MusicBrainz lookup, so this fans out + * freely. Idempotent — already-resolved or in-flight covers are skipped. */ +export async function resolveCovers( + albums, + artist, + { sticky = false, artistId = null } = {}, +) { + const requests = []; + const seen = new Set(); + for (const a of albums ?? []) { + const url = a?.cover; + if (!url || seen.has(url)) continue; + seen.add(url); + requests.push(scheduleCoverUpgrade(artistId, artist, a.title ?? "", url, sticky)); + } + await Promise.all(requests); +} + +/** A catalog cover img failed to load. If it was an upgraded URL, revert to the + * instant Deezer cover; if the original itself failed, show the placeholder. */ +export function coverLoadError( + originalCover, + failedUrl = null, + artist = "", + title = "", + artistId = null, +) { + if (!originalCover) return null; + if (coverUpgrades[originalCover] && coverUpgrades[originalCover] !== originalCover) { + const producer = coverUpgradeContexts.get(originalCover); + delete coverUpgrades[originalCover]; + // Do not let the bounded LRU immediately republish the URL that just + // failed in an . A short miss window keeps the working original + // in place while still allowing transient CDN failures to retry later. + const requestKey = producer?.key ?? coverUpgradeRequestKey(artistId, originalCover); + coverUpgradeResults.delete(requestKey); + if (coverUpgradeContexts.get(originalCover)?.key === requestKey) { + coverUpgradeContexts.delete(originalCover); + } + rememberCoverUpgradeMiss(requestKey); + const cacheArtistId = producer?.artistId ?? artistId; + const cacheArtist = producer?.artist ?? artist; + const cacheTitle = producer?.title ?? title; + if (cacheArtist && cacheTitle) { + void api + .invalidateCatalogCover( + cacheArtistId, + cacheArtist, + cacheTitle, + originalCover, + ) + .catch(() => {}); + } + return originalCover; + } else { + // A card intentionally requests a 500px CDN derivative. If only that + // resized route failed, retry the known-good canonical source before + // declaring the album itself broken. + if (failedUrl) { + try { + if (new URL(failedUrl).href !== new URL(originalCover).href) { + return originalCover; + } + } catch { + if (failedUrl !== originalCover) return originalCover; + } + } + markArtBroken(originalCover); + return null; + } +} + +/** Cover URL to display for a currently-playing catalog item. Kicks off an + * idempotent high-res upgrade for its album and returns the best URL to show + * now — the verified upgrade once resolved, else the instant Deezer cover. The + * player surfaces proxy the returned URL through getCatalogArt; because this + * reads the reactive coverUpgrades map, their effect re-runs and re-proxies at + * full resolution the moment the upgrade lands. */ +export function playerArtSource(item) { + const url = item?.artUrl; + if (!url) return null; + if (item.album) { + resolveCovers( + [{ cover: url, title: item.album }], + item.artist ?? "", + { sticky: true }, + ); + } + return coverUpgrades[url] ?? url; +} + +// Artist heroes have their own proxy because provider fallbacks may be finished +// as baked 4K JPEGs. Never share this cache with covers/logos. +const catalogHeroPreviewCache = new Map(); +const catalogHeroArtCache = new Map(); +const catalogHeroGpuCache = new Map(); +const CATALOG_HERO_PREVIEW_CACHE_MAX = 16; +const CATALOG_HERO_ART_CACHE_MAX = 10; +const CATALOG_HERO_GPU_CACHE_MAX = 2; +const CATALOG_HERO_PREVIEW_CACHE_MAX_CHARS = 12 * MIB; +const CATALOG_HERO_ART_CACHE_MAX_CHARS = 32 * MIB; +const CATALOG_HERO_GPU_CACHE_MAX_BYTES = 32 * MIB; +const CATALOG_HERO_GPU_CACHE_MAX_ENTRY_BYTES = 24 * MIB; +let catalogHeroArtEpoch = 0; +let heroWarmMode = null; +let heroWarmModeRequest = null; +let heroWarmModeEpoch = 0; + +function getHeroWarmMode() { + if (heroWarmMode) return Promise.resolve(heroWarmMode); + if (heroWarmModeRequest) return heroWarmModeRequest; + const epoch = heroWarmModeEpoch; + const pending = api.getSettings().then( + (settings) => settings?.artist_hero_upscaler ?? "live_gpu", + () => "live_gpu", + ).then((mode) => { + if (epoch === heroWarmModeEpoch) heroWarmMode = mode; + return mode; + }).finally(() => { + if (heroWarmModeRequest === pending) heroWarmModeRequest = null; + }); + heroWarmModeRequest = pending; + return pending; +} + +function setBoundedHeroCache(cache, key, value, maxEntries, maxChars) { + // Refresh insertion order so frequently revisited heroes survive eviction. + cache.delete(key); + cache.set(key, value); + trimResolvedCache(cache, maxEntries, maxChars); +} + +export function clearCatalogHeroArtCache() { + catalogHeroArtEpoch += 1; + // Settings calls this after changing upscalers. Refresh the warm-path hint + // so the next artist prefetch targets the newly selected pipeline. + heroWarmModeEpoch += 1; + heroWarmMode = null; + heroWarmModeRequest = null; + catalogHeroPreviewCache.clear(); + catalogHeroArtCache.clear(); + catalogHeroGpuCache.clear(); +} + +/** Return the fast hero raster together with the immutable content-aware crop + * plan that produced it. Cache the whole pair so the deferred 4K finisher can + * apply the exact same source window without running detection a second time. */ +export async function getCatalogHeroPreview(url) { + if (!url) return null; + if (catalogHeroPreviewCache.has(url)) { + return touchCache(catalogHeroPreviewCache, url); + } + const epoch = catalogHeroArtEpoch; + const pending = api.catalogHeroPreview(url).then( + (result) => { + const preview = result?.data_url ? result : null; + if ( + epoch === catalogHeroArtEpoch + && catalogHeroPreviewCache.get(url) === pending + ) { + if (preview) { + setBoundedHeroCache( + catalogHeroPreviewCache, + url, + preview, + CATALOG_HERO_PREVIEW_CACHE_MAX, + CATALOG_HERO_PREVIEW_CACHE_MAX_CHARS, + ); + } + else catalogHeroPreviewCache.delete(url); + } + return epoch === catalogHeroArtEpoch ? preview : null; + }, + () => { + if (catalogHeroPreviewCache.get(url) === pending) { + catalogHeroPreviewCache.delete(url); + } + return null; + }, + ); + setBoundedHeroCache( + catalogHeroPreviewCache, + url, + pending, + CATALOG_HERO_PREVIEW_CACHE_MAX, + CATALOG_HERO_PREVIEW_CACHE_MAX_CHARS, + ); + return pending; +} + +/** Share the expensive source decode/crop/raw-RGBA IPC across provisional→final + * promotion and rapid carousel/remount races. Large sources remain single-flight + * while pending but are dropped after use so two 4K payloads cannot pin RAM. */ +export async function getCatalogHeroGpuSource(url, plan = null) { + if (!url) return null; + if (catalogHeroGpuCache.has(url)) return touchCache(catalogHeroGpuCache, url); + const epoch = catalogHeroArtEpoch; + const pending = api.catalogHeroGpuSource(url, plan).then( + (payload) => { + if (epoch !== catalogHeroArtEpoch) return null; + if (catalogHeroGpuCache.get(url) === pending) { + if (payload?.byteLength <= CATALOG_HERO_GPU_CACHE_MAX_ENTRY_BYTES) { + setBoundedHeroCache( + catalogHeroGpuCache, + url, + payload, + CATALOG_HERO_GPU_CACHE_MAX, + CATALOG_HERO_GPU_CACHE_MAX_BYTES, + ); + } else { + catalogHeroGpuCache.delete(url); + } + } + return payload; + }, + () => { + if (catalogHeroGpuCache.get(url) === pending) catalogHeroGpuCache.delete(url); + return null; + }, + ); + setBoundedHeroCache( + catalogHeroGpuCache, + url, + pending, + CATALOG_HERO_GPU_CACHE_MAX, + CATALOG_HERO_GPU_CACHE_MAX_BYTES, + ); + return pending; +} + +/** Return only an already-finished persistent hero, without starting an + * upscale on a cache miss. This lets repeat visits race disk against the HD + * preview while keeping first-ever processing behind the first painted frame. */ +export async function getCachedCatalogHeroArt(url) { + if (!url) return null; + const existing = catalogHeroArtCache.get(url); + // A full upscale already in progress is not a cache hit. Probe disk and + // continue scanning other candidates instead of waiting behind that job. + if (typeof existing === "string") { + touchCache(catalogHeroArtCache, url); + return existing; + } + const epoch = catalogHeroArtEpoch; + try { + const dataUrl = await api.catalogCachedHeroArt(url); + if (epoch !== catalogHeroArtEpoch) return null; + if (dataUrl) { + setBoundedHeroCache( + catalogHeroArtCache, + url, + dataUrl, + CATALOG_HERO_ART_CACHE_MAX, + CATALOG_HERO_ART_CACHE_MAX_CHARS, + ); + } + return dataUrl; + } catch { + return null; + } +} + +export async function getCatalogHeroArt(url, plan = null) { + if (!url) return null; + if (catalogHeroArtCache.has(url)) return touchCache(catalogHeroArtCache, url); + const pending = api.catalogHeroArt(url, plan).then( + (result) => { + const dataUrl = result?.data_url ?? result; + const cacheable = result?.cacheable !== false; + // A manual-art refresh may clear the map while this request is in + // flight. Do not let an obsolete completion repopulate it. + if (catalogHeroArtCache.get(url) === pending) { + if (cacheable) { + setBoundedHeroCache( + catalogHeroArtCache, + url, + dataUrl, + CATALOG_HERO_ART_CACHE_MAX, + CATALOG_HERO_ART_CACHE_MAX_CHARS, + ); + } + else catalogHeroArtCache.delete(url); + } + return dataUrl; + }, + () => { + if (catalogHeroArtCache.get(url) === pending) { + catalogHeroArtCache.delete(url); + } + return null; + }, + ); + setBoundedHeroCache( + catalogHeroArtCache, + url, + pending, + CATALOG_HERO_ART_CACHE_MAX, + CATALOG_HERO_ART_CACHE_MAX_CHARS, + ); + return pending; +} + +export async function refreshLibrary() { + try { + // Artist/art refreshes replace files at the same cached path. Drop + // data-URL entries so the webview fetches the newly resized image. + artCache.clear(); + libraryStore.albums = await api.libraryAlbums(); + libraryStore.loaded = true; + window.__libErr = null; + } catch (e) { + // not connected to backend (browser preview) — leave empty + window.__libErr = String(e); + } +} + +// ---- catalog browser (Deezer-backed streaming-style search) ---- + +export const catalog = $state({ + query: "", + loading: false, + results: null, // { artists, albums, tracks } + artistLoading: false, + artistStageError: "", + artistPage: null, // { artist, albums } + albumLoading: false, + albumPage: null, // { album, tracks } + pendingTrackId: null, // a track to scroll to/flash on the album page + error: "", +}); +let artistPageRequest = 0; +let artistWarmGeneration = 0; +let catalogSearchRequest = 0; +let albumPageRequest = 0; +let albumResolveRequest = 0; +const ARTIST_REQUEST_CACHE_MAX = 24; +const ARTIST_IDENTITY_CACHE_MAX = 128; +const ARTIST_LOGO_CACHE_MAX = 64; +const artistIdCache = new Map(); // normalized name → Deezer id (or null) +const artistSeedCache = new Map(); // normalized name → search-result seed +const artistSeedById = new Map(); // Deezer id → latest known artist seed +const catalogArtistOverviewCache = new Map(); +const catalogArtistHeroSeedCache = new Map(); +const catalogArtistArtDataCache = new Map(); +const catalogArtistLogoDataCache = new Map(); +const catalogArtistPageCache = new Map(); + +// One canonical representation for all frontend artist cache keys. Route ids +// can arrive as strings while Tauri payload ids are numbers; treating those as +// separate keys wastes work and makes otherwise-correct late-result guards fail. +const artistCacheKey = (id) => (id == null ? "" : String(id).trim()); +const albumCacheKey = (id) => (id == null ? "" : String(id).trim()); +const sameArtistId = (left, right) => { + const leftKey = artistCacheKey(left); + return !!leftKey && leftKey === artistCacheKey(right); +}; +const sameAlbumId = (left, right) => { + const leftKey = albumCacheKey(left); + return !!leftKey && leftKey === albumCacheKey(right); +}; + +const artistIdentityKey = (value) => { + const text = typeof value === "string" ? value : value?.name; + return (text ?? "") + .normalize("NFKD") + .replace(/\p{M}/gu, "") + .toLocaleLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ""); +}; + +/** Accept an exact artist credit or an explicit collaboration credit containing + * that exact artist. A mere prefix is never enough ("Crazy P" must not match + * "Crazy Penis"). Missing credit metadata is retained because there is no + * identity signal to evaluate. */ +function recordBelongsToArtist(record, artistName) { + const credited = typeof record?.artist === "string" + ? record.artist + : record?.artist?.name; + if (!credited) return true; + const expected = artistIdentityKey(artistName); + if (!expected) return true; + if (artistIdentityKey(credited) === expected) return true; + return credited + .split(/\s+(?:feat(?:uring)?\.?|ft\.?|with|vs\.?|x)\s+|\s*(?:&|,|×)\s*/i) + .some((part) => artistIdentityKey(part) === expected); +} + +function artistScopedRecords(records, artistName) { + return Array.isArray(records) + ? records.filter((record) => recordBelongsToArtist(record, artistName)) + : []; +} + +/** Sanitize every staged/full artist payload before it enters a shared cache. + * Backend endpoints already anchor on id; this cheap name-credit boundary is a + * second line of defense for additive provider records such as Apple/MB data. */ +function artistScopedPayload(payload) { + const artistName = payload?.artist?.name ?? ""; + return { + ...payload, + ...(Object.hasOwn(payload ?? {}, "albums") + ? { albums: artistScopedRecords(payload.albums, artistName) } + : {}), + ...(Object.hasOwn(payload ?? {}, "popular_tracks") + ? { popular_tracks: artistScopedRecords(payload.popular_tracks, artistName) } + : {}), + }; +} + +function checkedArtistPayload(artistId, payload, stage) { + if (!sameArtistId(payload?.artist?.id, artistId)) { + throw new Error(`${stage} returned a mismatched artist identity`); + } + return artistScopedPayload(payload); +} + +function rememberArtistSeed(seed) { + if (!seed?.id) return null; + const idKey = artistCacheKey(seed.id); + const prior = artistSeedById.get(idKey); + const remembered = { + id: seed.id, + name: seed.name ?? prior?.name ?? "", + picture: seed.picture ?? prior?.picture ?? null, + fans: seed.fans ?? prior?.fans ?? null, + album_count: + seed.album_count ?? seed.albumCount ?? prior?.album_count ?? null, + }; + setBoundedMap( + artistSeedById, + idKey, + remembered, + ARTIST_IDENTITY_CACHE_MAX, + ); + const norm = remembered.name.trim().toLowerCase(); + if (norm) { + const existing = artistSeedCache.get(norm); + const sameIdentity = sameArtistId(existing?.id, remembered.id); + const moreCanonical = (remembered.fans ?? -1) > (existing?.fans ?? -1); + // Deezer can return several exact-name matches. Preserve the most- + // followed canonical seed instead of letting the last obscure namesake + // in a search response poison all future name-only navigation. + if (!existing || sameIdentity || moreCanonical) { + setBoundedMap( + artistSeedCache, + norm, + remembered, + ARTIST_IDENTITY_CACHE_MAX, + ); + setBoundedMap( + artistIdCache, + norm, + remembered.id, + ARTIST_IDENTITY_CACHE_MAX, + ); + } + } + return remembered; +} + +function trimArtistRequestCache(cache) { + let resolved = 0; + for (const value of cache.values()) { + if (!isPending(value)) resolved += 1; + } + for (const [key, value] of cache) { + if (resolved <= ARTIST_REQUEST_CACHE_MAX) break; + if (isPending(value)) continue; + cache.delete(key); + resolved -= 1; + } +} + +function cachedArtistRequest(cache, artistId, load) { + const key = artistCacheKey(artistId); + if (!key) return Promise.reject(new Error("artist id is required")); + if (cache.has(key)) return Promise.resolve(touchCache(cache, key)); + const pending = load().then( + (result) => { + if (cache.get(key) === pending) { + cache.delete(key); + cache.set(key, result); + trimArtistRequestCache(cache); + } + return result; + }, + (error) => { + if (cache.get(key) === pending) cache.delete(key); + throw error; + }, + ); + cache.set(key, pending); + trimArtistRequestCache(cache); + return pending; +} + +function resolvedArtistCacheValue(cache, artistId) { + const value = cache.get(artistCacheKey(artistId)); + return value && typeof value.then !== "function" ? value : null; +} + +export const getCatalogArtistOverview = (artistId) => + cachedArtistRequest(catalogArtistOverviewCache, artistId, () => + api.catalogArtistOverview(artistId).then((payload) => + checkedArtistPayload(artistId, payload, "artist overview")), + ); + +export const getCatalogArtistHeroSeed = (artistId) => + cachedArtistRequest(catalogArtistHeroSeedCache, artistId, () => + api.catalogArtistHeroSeed(artistId), + ).then((seed) => { + // The backend applies a short negative TTL. Do not turn a transient + // no-seed result into a frontend-session-long miss. + if (!seed) catalogArtistHeroSeedCache.delete(artistCacheKey(artistId)); + return seed; + }); + +export const getCatalogArtistIdentityArt = (artistId) => + cachedArtistRequest(catalogArtistArtDataCache, artistId, () => + api.catalogArtistArt(artistId).then((payload) => + checkedArtistPayload(artistId, payload, "artist art")), + ).then((art) => { + // Empty provider/Bing results have a short backend TTL and may recover + // on a later visit. Keep the shared promise for the whole in-flight + // request, but do not turn a valid no-art payload into a session-long + // frontend miss. Positive banners, custom art and logos stay cached. + const hasArt = Boolean( + art?.banner_custom + || art?.banner + || art?.banners?.length + || art?.logos?.length + ); + if (!hasArt) catalogArtistArtDataCache.delete(artistCacheKey(artistId)); + return art; + }); + +export const getCatalogArtistProviderLogos = (artistId) => + cachedArtistRequest(catalogArtistLogoDataCache, artistId, () => + api.catalogArtistLogos(artistId).then((logos) => { + if (!Array.isArray(logos) || logos.some((logo) => typeof logo !== "string")) { + throw new Error("artist logos returned an invalid response"); + } + rememberProviderLogoStatus(artistId, logos.length > 0); + if (!logos.length) { + // Backend provider-art misses intentionally have a short TTL. + // Keep this page's settled negative decision, but do not pin it + // in the frontend request cache for the rest of the session. + catalogArtistLogoDataCache.delete(artistCacheKey(artistId)); + } + return logos; + }), + ); + +export const getCatalogArtistPage = (artistId) => + cachedArtistRequest(catalogArtistPageCache, artistId, () => + api.catalogArtist(artistId).then((payload) => + checkedArtistPayload(artistId, payload, "artist page")), + ); + +export function invalidateCatalogArtist(artistId) { + const key = artistCacheKey(artistId); + catalogArtistOverviewCache.delete(key); + catalogArtistHeroSeedCache.delete(key); + catalogArtistArtDataCache.delete(key); + catalogArtistLogoDataCache.delete(key); + catalogArtistPageCache.delete(key); + providerLogoStatusById.delete(key); + for (const logoKey of coverLogoByInput.keys()) { + if (logoKey.startsWith(`${key}\u0000`)) coverLogoByInput.delete(logoKey); + } +} + +// ---- Stale-while-revalidate page replay ----------------------------------- +// A revisit after an app restart used to start from the loading shell even +// though the previous session had the artist's FINAL merged page. The last +// merged page per artist is persisted (localStorage, MRU-capped) and painted +// immediately as a stale stand-in — paint-only: every stage of the normal +// cold flow still runs and replaces it wholesale, so this can never repeat +// the old "cached page skipped the MB pass" bug. Replayed pages keep +// `art_preview: true` (the full page still lands) and set `replayed: true` +// (the overview stage must not overwrite final classified albums with its +// raw first-50). +const PAGE_REPLAY_RECIPE = "replay-v1"; +const PAGE_REPLAY_LIMIT = 16; +// Matches the backend's provider disk-cache TTL on purpose: a replay must +// never outlive the MusicBrainz warmth it was saved with, or the identity +// re-merge that replaces it stops being instant. +const PAGE_REPLAY_TTL_MS = 24 * 60 * 60 * 1000; +const PAGE_REPLAY_INDEX = "ss-page-replay-index"; +const pageReplayKey = (key) => `ss-page-replay:${key}`; + +function loadPageReplay(artistId) { + try { + const key = artistCacheKey(artistId); + if (!key) return null; + const raw = localStorage.getItem(pageReplayKey(key)); + if (!raw) return null; + const entry = JSON.parse(raw); + if (entry?.recipe !== PAGE_REPLAY_RECIPE) return null; + if (!entry.at || Date.now() - entry.at > PAGE_REPLAY_TTL_MS) return null; + const page = entry.page; + if (!sameArtistId(page?.artist?.id, artistId)) return null; + if (!Array.isArray(page?.albums) || !page.albums.length) return null; + return page; + } catch { + return null; + } +} + +function savePageReplay(artistId, page) { + try { + const key = artistCacheKey(artistId); + if (!key || !page?.albums?.length) return; + // Only durable content fields. Art (banners/logos/cover_logo) is + // deliberately excluded: those pipelines have their own caches and + // settled/override semantics that a replay must not preempt. + const slim = { + artist: page.artist, + popular_tracks: page.popular_tracks ?? [], + albums: page.albums, + top_genre: page.top_genre ?? null, + socials: page.socials ?? [], + typeHints: page.typeHints ?? [], + identity_merged: true, + }; + localStorage.setItem( + pageReplayKey(key), + JSON.stringify({ recipe: PAGE_REPLAY_RECIPE, at: Date.now(), page: slim }), + ); + const index = JSON.parse(localStorage.getItem(PAGE_REPLAY_INDEX) ?? "[]"); + const next = [key, ...index.filter((k) => k !== key)]; + for (const evicted of next.slice(PAGE_REPLAY_LIMIT)) { + localStorage.removeItem(pageReplayKey(evicted)); + } + localStorage.setItem(PAGE_REPLAY_INDEX, JSON.stringify(next.slice(0, PAGE_REPLAY_LIMIT))); + } catch { + // Storage full or unavailable — the replay is purely optional. + } +} + +/** Warm only the two visible stages while a user reads search results. The + * expensive exhaustive page remains click-driven. */ +export function cancelCatalogArtistWarmup() { + artistWarmGeneration += 1; +} + +export function warmCatalogArtist(seed) { + const remembered = rememberArtistSeed(seed); + if (!remembered) return; + const warmGeneration = ++artistWarmGeneration; + const heroModeReady = getHeroWarmMode(); + void getCatalogArtistOverview(remembered.id).catch(() => {}); + void getCatalogArtistHeroSeed(remembered.id) + .then(async (seed) => { + if (warmGeneration !== artistWarmGeneration) return; + if (seed?.banner && !seed.banner.startsWith("data:")) { + // Warm exactly the asset the selected renderer consumes. Live + // GPU never paints the legacy HD data URL, so preparing it would + // waste an extra decode and up to 12 MiB of WebView cache. + const mode = await heroModeReady; + if (warmGeneration !== artistWarmGeneration) return; + if (mode === "live_gpu") { + void getCatalogHeroGpuSource(seed.banner, null).catch(() => {}); + } else { + void getCatalogHeroPreview(seed.banner).catch(() => {}); + } + } + }) + .catch(() => {}); + void getCatalogArtistProviderLogos(remembered.id) + .then((logos) => { + const primaryLogo = logos?.[0]; + if (primaryLogo) void getCatalogArt(primaryLogo); + }) + .catch(() => {}); + // Also warm the MusicBrainz identity chain — the slowest piece of a cold + // artist page (3-5 requests behind MB's mandatory 1 req/s gate). Gated + // three ways so live-search churn can never flood MusicBrainz: only after + // 600ms of the artist staying the top hit (a newer warm generation + // supersedes the timer), only once per artist per session, and only for + // this single seed — never a result list. + const identityKey = artistCacheKey(remembered.id); + if (identityKey && remembered.name && !identityWarmupDone.has(identityKey)) { + setTimeout(() => { + if (warmGeneration !== artistWarmGeneration) return; + if (identityWarmupDone.has(identityKey)) return; + identityWarmupDone.add(identityKey); + void api.catalogArtistIdentity(remembered.id, remembered.name).catch(() => {}); + }, 600); + } +} +const identityWarmupDone = new Set(); + +/** Run a catalog search and show the results view. */ +export async function catalogGo(query) { + cancelCatalogArtistWarmup(); + const q = query.trim(); + if (!q) return; + const request = ++catalogSearchRequest; + ui.view = "catalog"; + ui.albumId = null; + catalog.query = q; + catalog.results = null; + catalog.loading = true; + catalog.error = ""; + try { + const results = await api.catalogSearch(q); + if (request !== catalogSearchRequest) return; + catalog.results = results; + for (const artist of results?.artists ?? []) rememberArtistSeed(artist); + // Search results are normally read for a moment before the first artist + // is opened. Spend that otherwise-idle time warming its identity/art + // lookup; the keyed backend single-flight shares this exact request if + // the user clicks immediately. + const likelyArtistId = results?.artists?.[0]?.id; + if (likelyArtistId != null) { + warmCatalogArtist(results.artists[0]); + } + } catch (e) { + if (request === catalogSearchRequest) catalog.error = String(e); + } finally { + if (request === catalogSearchRequest) catalog.loading = false; + } +} + +/** Open an artist as a four-stage page: synchronous identity shell, fast + * Deezer overview, independent provider artwork, then exhaustive refinement. + * Every ID gets a shell; known search/discovery seeds add the name + blurred + * portrait without waiting for IPC or the network. */ +// Provider presence is an identity-scoped, monotonic decision. Once any exact +// provider pass finds a curated logo, a later empty/error result may not expose +// a cover-derived fallback in front of it. +const providerLogoStatusById = new Map(); + +// Cover-derived results are keyed by the exact identity-filtered cover input, +// not only by artist id. This prevents a negative or wordmark generated from an +// older/polluted discography from being reused after the candidate set changes. +// Values are either an in-flight Promise, a finalized data URL, or null. +const coverLogoByInput = new Map(); +const ARTIST_LOGO_CACHE_MAX_CHARS = 12 * MIB; + +function rememberProviderLogoStatus(artistId, hasProviderLogo) { + const key = artistCacheKey(artistId); + if (!key) return; + const prior = providerLogoStatusById.get(key); + setBoundedMap( + providerLogoStatusById, + key, + prior === true || hasProviderLogo === true, + ARTIST_LOGO_CACHE_MAX, + ); +} + +/** Apply a fast, definitive provider-logo decision. With no provider logo, an + * exact-input cached result or generation starts once the hardened final cover + * list is supplied. */ +async function resolveCoverLogoAvailability(artistId, page, providerLogosReady, active) { + const logos = await providerLogosReady; + const statusKey = artistCacheKey(artistId); + if (!Array.isArray(logos)) { + if ( + providerLogoStatusById.get(statusKey) === false + && page + && active() + ) { + await fetchCoverLogo(artistId, page, active); + return { provider: false }; + } + return { provider: null }; + } + rememberProviderLogoStatus(artistId, logos.length > 0); + if (!active()) return { provider: logos.length > 0 }; + const current = catalog.artistPage; + if (logos.length) { + if (sameArtistId(current?.artist?.id, artistId)) { + catalog.artistPage = { + ...current, + logos: current.logos?.length ? current.logos : logos, + provider_logos_settled: true, + cover_logo: null, + cover_logo_pending: false, + }; + } + return { provider: true }; + } + if (sameArtistId(current?.artist?.id, artistId) && !current.provider_logos_settled) { + catalog.artistPage = { ...current, provider_logos_settled: true }; + } + // Do not use the old artist-id-only persisted restore here. Its candidate + // fingerprint is unavailable, so it could have been generated from the + // polluted discography we are hardening against. The exact-input request + // below still hits the backend's versioned memory/disk cache on revisits. + if (page) await fetchCoverLogo(artistId, { ...page, logos: [] }, active); + return { provider: false }; +} + +function mergeCatalogArtistArt(artistId, art, active) { + if (!sameArtistId(art?.artist?.id, artistId) || !active()) return; + const current = catalog.artistPage; + if (!current || !sameArtistId(current.artist?.id, artistId)) return; + const finalBanners = art.banners ?? []; + const artLogos = Array.isArray(art.logos) ? art.logos : []; + const finalLogos = current.logos?.length ? current.logos : artLogos; + const mergedArtist = { + ...current.artist, + ...art.artist, + // The completed identity-filtered page owns this count. A late art + // profile may still carry Deezer's broader/fused total; use it only + // while the shell has no count at all. + album_count: current.artist?.album_count ?? art.artist?.album_count ?? null, + }; + rememberArtistSeed(mergedArtist); + rememberProviderLogoStatus(artistId, finalLogos.length > 0); + catalog.artistPage = { + ...current, + artist: mergedArtist, + banner: art.banner ?? null, + banners: finalBanners, + banner_custom: art.banner_custom ?? false, + logos: finalLogos, + provider_logos_settled: true, + cover_logo: finalLogos.length ? null : current.cover_logo ?? null, + cover_logo_pending: finalLogos.length ? false : current.cover_logo_pending ?? false, + hero_seed_banner: finalBanners.length ? null : current.hero_seed_banner, + loading_shell: false, + placeholder_picture: + current.placeholder_picture ?? art.artist?.picture ?? null, + }; +} + +/** Fire the deferred cover-logo extraction for an artist with no provider logo, + * merging the result into its own page field. Backend-cached, so ~instant on a + * revisit. Called from BOTH the cold-load and warm-cache paths of + * openCatalogArtist — the warm path early-returns and would otherwise skip it. */ +/** Lower ranks first. Studio-album covers print the bare band name; singles, + * EPs and anything with a remix/live/mix in the title print tracklists and + * credits — poison for a wordmark. So OCR the clean covers first. */ +function albumLogoRank(a) { + const type = a.record_type ?? ""; + const typeRank = type === "album" ? 0 : type === "ep" ? 2 : 3; + const noisy = /\b(remix|remixes|mix|live|edit|dub|version|bonus|instrumental)\b/i.test( + a.title ?? "", + ) + ? 1 + : 0; + return typeRank + noisy; +} + +function coverLogoCandidates(page) { + return [...(page.albums ?? [])] + .sort((a, b) => albumLogoRank(a) - albumLogoRank(b)) + .map((a) => ({ title: a.title ?? "", url: a.cover })) + .filter((c) => c.url) + .slice(0, 14); +} + +function coverLogoInputKey(artistId, page, covers) { + const idKey = artistCacheKey(artistId); + const artistKey = artistIdentityKey(page.artist?.name); + const candidates = covers + .map(({ title, url }) => `${title}\u0001${url}`) + .join("\u0002"); + return `${idKey}\u0000${artistKey}\u0000${candidates}`; +} + +function readCoverLogoForPage(artistId, page) { + if (providerLogoStatusById.get(artistCacheKey(artistId)) !== false) return null; + const covers = coverLogoCandidates(page); + if (!covers.length) return null; + const value = touchCache(coverLogoByInput, coverLogoInputKey(artistId, page, covers)); + return typeof value === "string" ? value : null; +} + +function coverLogoRequest(artistId, page, covers) { + const inputKey = coverLogoInputKey(artistId, page, covers); + if (coverLogoByInput.has(inputKey)) { + return Promise.resolve(touchCache(coverLogoByInput, inputKey)); + } + const pending = api + .catalogCoverLogo(artistId, page.artist?.name ?? "", covers) + .then( + (dataUrl) => { + if (dataUrl != null && typeof dataUrl !== "string") { + throw new Error("cover logo returned an invalid response"); + } + const result = dataUrl ?? null; + if (coverLogoByInput.get(inputKey) === pending) { + setResolvedCache( + coverLogoByInput, + inputKey, + result, + ARTIST_LOGO_CACHE_MAX, + ARTIST_LOGO_CACHE_MAX_CHARS, + ); + } + return result; + }, + (error) => { + if (coverLogoByInput.get(inputKey) === pending) { + coverLogoByInput.delete(inputKey); + } + throw error; + }, + ); + coverLogoByInput.set(inputKey, pending); + trimResolvedCache( + coverLogoByInput, + ARTIST_LOGO_CACHE_MAX, + ARTIST_LOGO_CACHE_MAX_CHARS, + ); + return pending; +} + +async function fetchCoverLogo(artistId, page, active) { + const statusKey = artistCacheKey(artistId); + if ( + !sameArtistId(page?.artist?.id, artistId) + || page.logos?.length + || providerLogoStatusById.get(statusKey) !== false + || !active() + ) { + return null; + } + const covers = coverLogoCandidates(page); + if (!covers.length) return null; + + const current = catalog.artistPage; + if ( + sameArtistId(current?.artist?.id, artistId) + && !current.cover_logo_pending + && !readCoverLogoForPage(artistId, page) + ) { + catalog.artistPage = { ...current, cover_logo_pending: true }; + } + try { + const dataUrl = await coverLogoRequest(artistId, page, covers); + if (!dataUrl || !active()) return dataUrl; + const latest = catalog.artistPage; + if ( + !sameArtistId(latest?.artist?.id, artistId) + || latest.logos?.length + || providerLogoStatusById.get(statusKey) !== false + ) { + return dataUrl; + } + // Its OWN field, never `logos`: the provider-art pass sets `logos` + // (often to []) and would otherwise clobber it. The view prefers + // provider `logos` and falls back to this. + catalog.artistPage = { ...latest, cover_logo: dataUrl }; + return dataUrl; + } catch { + return null; + } finally { + const latest = catalog.artistPage; + if ( + active() + && sameArtistId(latest?.artist?.id, artistId) + && latest.cover_logo_pending + ) { + catalog.artistPage = { ...latest, cover_logo_pending: false }; + } + } +} + +/** MusicBrainz "last pass" for an open artist page. MB is rate-limited to + * 1 req/s by policy, which used to cost the release grid ~2-3s; Apple's + * unthrottled artist lookup now supplies the release supplement, so MB runs + * AFTER the page is on screen and merges in whenever it lands. Deliberately + * additive-only: it contributes the socials and the few deep cuts (practice + * tapes, BBC sessions) neither Deezer nor Apple carries, and never edits a + * record those two already own. + * + * Two failure modes this function owns: + * - MB can 503 under per-IP contention. A fully empty result gets one + * delayed retry — failures are not cached backend-side, so it is real. + * - The session page cache used to hold the RAW backend page forever, and + * the cached fast path skipped this pass entirely — so a warm revisit + * silently LOST the supplement it had on the cold visit ("A ? of WHEN" + * vanishing from Panda Bear). The merged page is now written back into + * the cache and stamped `identity_merged`, and the fast path re-runs this + * pass for cached pages that lack the stamp. */ +async function applyArtistIdentity(artistId, active) { + try { + const name = catalog.artistPage?.artist?.name ?? ""; + let identity = await api.catalogArtistIdentity(artistId, name); + // Socials are a separate deferred command now, so the identity + // payload's socials are ALWAYS empty — they can no longer prove "MB + // knows this artist" here, and waiting for the retry below would + // delay the footer links 8 s for artists MB carries without release + // groups. Fire them independently before the retry decision. + void applyArtistSocials(artistId, active); + const empty = !(identity?.albums?.length || identity?.type_hints?.length); + if (empty) { + // Transient-failure retry. For a genuinely album-less MB artist + // both calls answer from the URL caches, so the second pass + // costs no MusicBrainz traffic — only this deliberate delay. + await new Promise((resolve) => setTimeout(resolve, 8000)); + if (!active()) return; + identity = await api.catalogArtistIdentity(artistId, name); + } + if (!active()) return; + const current = catalog.artistPage; + if (!current || !sameArtistId(current.artist?.id, artistId)) return; + // MB identity extras are always albums, so only rows that could BE + // that album may block one: a same-title SINGLE is a different + // release (the "Phantom Island" shape — a 2024 single and a 2025 + // album), and letting it occupy the title made the real album vanish + // from the page. + const seen = new Set( + (current.albums ?? []) + .filter((a) => a.record_type !== "single") + .map((a) => normText(a.title)), + ); + const identityAlbums = artistScopedRecords(identity?.albums, current.artist?.name); + // An MB row whose title is a PREFIX of an existing row's full title + // is the same work Deezer carries as a qualified variant — + // "Wonderful Crazy Night" beside the page's "Wonderful Crazy Night + // (Deluxe)". Injecting it adds a second card with no track count. + // Sequel tails ("Greatest Hits" vs "Greatest Hits II") stay + // distinct: a bare numeral/volume marker never counts as a variant + // qualifier. normText already strips brackets, so compare against + // the raw title normalized without stripping. + const rawNorm = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); + // Same single-typed exemption as `seen`: only album-like rows may + // claim to be a variant of an MB album. + const existingRaw = (current.albums ?? []) + .filter((a) => a.record_type !== "single") + .map((a) => rawNorm(a.title)); + const sequelTail = (tail) => + /^(?:i{1,3}|iv|v|vi{0,3}|ix|x|\d{1,2}|vol(?:ume)?\s*\d*|pt\s*\d*|part\s*\d*)$/.test( + tail.trim(), + ); + const coveredByVariant = (title) => { + const base = rawNorm(title); + if (base.length < 8) return false; + return existingRaw.some( + (t) => + t.length > base.length + && t.startsWith(base) + && !sequelTail(t.slice(base.length)), + ); + }; + const extra = identityAlbums.filter( + (a) => a?.title && !seen.has(normText(a.title)) && !coveredByVariant(a.title), + ); + const socials = identity?.socials ?? []; + // MB's authoritative primary/secondary types. The view matches these + // to displayed releases by title and re-files any the provider + // mislabeled "album" (compilations, soundtracks…). + const typeHints = identity?.type_hints ?? []; + const merged = { + ...current, + identity_merged: true, + albums: extra.length + ? [...current.albums, ...extra].sort((a, b) => + (b.release_date ?? "").localeCompare(a.release_date ?? "")) + : current.albums, + socials: socials.length ? socials : current.socials, + typeHints: typeHints.length ? typeHints : current.typeHints, + }; + catalog.artistPage = merged; + // Write the supplemented page back into the session cache: a warm + // revisit must not render less than the cold visit did. Overwriting + // a pending entry is safe — cachedArtistRequest only installs its + // own result while its promise is still the stored value. + const key = artistCacheKey(artistId); + if (key) { + catalogArtistPageCache.delete(key); + catalogArtistPageCache.set(key, merged); + trimArtistRequestCache(catalogArtistPageCache); + } + savePageReplay(artistId, merged); + } catch { + // Best-effort supplement: the page is complete without it. + } +} + +/** Deferred footer links: merged whenever they arrive, never blocking the + * identity pass. Cached back into the session page cache like the identity + * merge, so a warm revisit keeps its links. */ +async function applyArtistSocials(artistId, active) { + try { + const socials = await api.catalogArtistSocials(artistId); + if (!socials?.length || !active()) return; + const current = catalog.artistPage; + if (!current || !sameArtistId(current.artist?.id, artistId)) return; + if (current.socials?.length) return; + const merged = { ...current, socials }; + catalog.artistPage = merged; + const key = artistCacheKey(artistId); + if (key) { + catalogArtistPageCache.delete(key); + catalogArtistPageCache.set(key, merged); + trimArtistRequestCache(catalogArtistPageCache); + } + savePageReplay(artistId, merged); + } catch { + // Footer links are decorative; missing them is fine. + } +} + +export async function openCatalogArtist(artistId, seedArtist = null) { + cancelCatalogArtistWarmup(); + catalogSearchRequest += 1; + const request = ++artistPageRequest; + const artistKey = artistCacheKey(artistId); + if (!artistKey) return; + activateCoverUpgradeScope(artistId); + ui.view = "catalogArtist"; + const active = () => + request === artistPageRequest + && ui.view === "catalogArtist" + && sameArtistId(catalog.artistPage?.artist?.id, artistId); + const providedSeed = sameArtistId(seedArtist?.id, artistId) + ? rememberArtistSeed(seedArtist) + : null; + const remembered = + providedSeed + ?? touchCache(artistSeedById, artistKey) + ?? null; + const providerLogosReady = getCatalogArtistProviderLogos(artistId).catch(() => null); + const loadFullArtistArt = () => + getCatalogArtistIdentityArt(artistId) + .then((art) => mergeCatalogArtistArt(artistId, art, active)) + .catch(() => {}); + let completed = resolvedArtistCacheValue(catalogArtistPageCache, artistId); + if (completed && !sameArtistId(completed.artist?.id, artistId)) { + catalogArtistPageCache.delete(artistKey); + completed = null; + } + if (completed) { + rememberArtistSeed(completed.artist); + if (completed.logos?.length) rememberProviderLogoStatus(artistId, true); + catalog.artistPage = { + ...completed, + art_preview: false, + loading_shell: false, + partial_error: null, + hero_seed_banner: null, + // A positive curated set is identity-safe to reuse. An earlier + // negative is not: wait for this generation's short-TTL provider + // request before authorizing any cover-derived fallback. + provider_logos_settled: Boolean(completed.logos?.length), + cover_logo: null, + placeholder_picture: remembered?.picture ?? completed.artist?.picture ?? null, + }; + catalog.artistLoading = false; + catalog.artistStageError = ""; + catalog.error = ""; + void resolveCoverLogoAvailability(artistId, completed, providerLogosReady, active); + void loadFullArtistArt(); + // A cached page from before the MB pass landed (or from a visit + // where it failed) is missing its supplement — the fast path must + // not render less than the cold path did. + if (!completed.identity_merged) void applyArtistIdentity(artistId, active); + else if (!completed.socials?.length) void applyArtistSocials(artistId, active); + return; + } + // The search portrait is a small square image — it must never enter the + // real hero-source list (banner/banners feed the carousel and 4K finisher). + // It stays a presentation-only placeholder threaded through every state. + const seedPicture = remembered?.picture ?? null; + const replay = loadPageReplay(artistId); + catalog.artistPage = replay + ? { + // Stale stand-in from the last visit: the final classified album + // list paints immediately while every cold stage below re-runs + // and replaces it. Art fields start empty on purpose — the art + // pipeline owns them. + ...replay, + logos: [], + provider_logos_settled: false, + banner: null, + banners: [], + banner_credit: null, + banner_custom: false, + art_preview: true, + loading_shell: false, + replayed: true, + partial_error: null, + hero_seed_banner: null, + cover_logo: null, + placeholder_picture: seedPicture ?? replay.artist?.picture ?? null, + } + : { + artist: { + id: remembered?.id ?? artistId, + name: remembered?.name ?? "", + picture: seedPicture, + fans: remembered?.fans ?? null, + album_count: remembered?.album_count ?? null, + }, + logos: [], + provider_logos_settled: false, + banner: null, + banners: [], + banner_credit: null, + banner_custom: false, + popular_tracks: [], + albums: [], + top_genre: null, + socials: [], + art_preview: true, + loading_shell: true, + partial_error: null, + hero_seed_banner: null, + placeholder_picture: seedPicture, + }; + catalog.artistLoading = true; + catalog.artistStageError = ""; + catalog.error = ""; + + // A persisted positive mark can paint over the identity shell as soon as + // the fast provider-logo check confirms this artist has no provider logo. + // Provider artists start their complete artwork stage immediately; a cold + // no-provider artist reserves the network for cover generation first. + void resolveCoverLogoAvailability(artistId, null, providerLogosReady, active); + // Bing discovery is independent of cover-logo OCR. Run both under their own + // bounded backend limits so neither user-visible asset sits behind the + // other's multi-second pipeline. + void loadFullArtistArt(); + + // A single identity-safe source can arrive before the complete provider + // tier/list. It feeds only the first preview; the final artwork response + // still owns carousel selection and 4K finishing. + void getCatalogArtistHeroSeed(artistId) + .then((seed) => { + if (!active() || !seed?.banner) return; + const current = catalog.artistPage; + if ( + !current + || !sameArtistId(current.artist?.id, artistId) + || current.banners?.length + || current.banner_custom + ) { + return; + } + catalog.artistPage = { + ...current, + hero_seed_banner: seed.banner, + banner_custom: seed.banner_custom ?? false, + loading_shell: false, + }; + }) + .catch(() => {}); + + // Warm the MusicBrainz identity pass NOW instead of after the full page + // lands. Its 4-6 requests serialize behind MusicBrainz's mandatory + // 1 req/s gate (measured 3.7-8.5 s — most of a cold page's + // time-to-fully-sorted), so starting it beside the page load hides + // almost all of that behind work that was happening anyway. Fire and + // forget: applyArtistIdentity later re-asks with the authoritative name + // and rides the warmed URL caches; results here are deliberately unused. + let identityWarmed = false; + const warmArtistIdentity = (name) => { + if (identityWarmed || !name) return; + identityWarmed = true; + void api.catalogArtistIdentity(artistId, name).catch(() => {}); + }; + warmArtistIdentity(remembered?.name); + + // One-round-trip content stage: popular tracks and the first curated + // releases become usable while exhaustive classification continues. + void getCatalogArtistOverview(artistId) + .then((overview) => { + warmArtistIdentity(overview?.artist?.name); + if (!active()) return; + const current = catalog.artistPage; + if ( + !current + || !sameArtistId(current.artist?.id, artistId) + || current.art_preview === false + || !sameArtistId(overview.artist?.id, artistId) + ) return; + rememberArtistSeed(overview.artist); + catalog.artistPage = { + ...current, + artist: { ...current.artist, ...overview.artist }, + // A replayed page already shows the last visit's FINAL + // classified albums; the overview's raw first-50 would be a + // strict downgrade. Fresh albums arrive with the full page. + ...(current.replayed + ? {} + : { + popular_tracks: overview.popular_tracks ?? [], + albums: overview.albums ?? [], + }), + loading_shell: false, + placeholder_picture: + current.placeholder_picture ?? overview.artist?.picture ?? null, + }; + }) + .catch(() => {}); + + try { + // Overview, identity art, and the first hero all remain immediate. + // Give rapid back-to-back navigation a brief chance to settle before + // starting the exhaustive release-classification request; stale pages + // should not consume provider/backend work merely to fill a cache. + await new Promise((resolve) => setTimeout(resolve, 180)); + if (!active()) return; + const page = await getCatalogArtistPage(artistId); + if (active() && sameArtistId(page?.artist?.id, artistId)) { + rememberArtistSeed(page.artist); + const staged = catalog.artistPage; + const stagedHasArt = sameArtistId(staged?.artist?.id, artistId); + const finalBanners = page.banner_custom + ? page.banners ?? [] + : stagedHasArt && staged?.banners?.length + ? staged.banners + : page.banners ?? []; + const finalLogos = stagedHasArt && staged?.logos?.length + ? staged.logos + : page.logos ?? []; + rememberProviderLogoStatus(artistId, finalLogos.length > 0); + catalog.artistPage = { + ...page, + // A replayed page carries the last visit's MB type hints AND + // socials; the fresh page always arrives with both empty + // (socials are a deferred command now), so dropping them here + // would let filing/footer visibly regress for the moment + // before the identity/socials passes re-merge them. + ...(stagedHasArt && staged?.typeHints?.length + ? { typeHints: staged.typeHints } + : {}), + ...(stagedHasArt && staged?.socials?.length + ? { socials: staged.socials } + : {}), + banner: page.banner_custom + ? page.banner + : stagedHasArt + ? staged?.banner ?? page.banner ?? null + : page.banner ?? null, + banners: finalBanners, + logos: finalLogos, + provider_logos_settled: true, + art_preview: false, + loading_shell: false, + partial_error: null, + hero_seed_banner: + finalBanners.length ? null : staged?.hero_seed_banner ?? null, + placeholder_picture: + staged?.placeholder_picture ?? page.artist?.picture ?? null, + cover_logo: + finalLogos.length + ? null + : staged?.cover_logo ?? null, + cover_logo_pending: staged?.cover_logo_pending ?? false, + }; + // MusicBrainz last pass — shared with the cached fast path, see + // applyArtistIdentity for the semantics. + void applyArtistIdentity(artistId, active); + + // No provider logo for this artist → try to lift a wordmark from the + // album covers (band name printed as clean text). Deferred, cached, + // additive-only; on failure the plain text name stays. + void resolveCoverLogoAvailability(artistId, page, providerLogosReady, active); + } + } catch (e) { + if (active()) { + void loadFullArtistArt(); + catalog.error = String(e); + catalog.artistStageError = "Some release details could not be loaded."; + if (catalog.artistPage) { + catalog.artistPage = { + ...catalog.artistPage, + art_preview: false, + loading_shell: false, + partial_error: catalog.artistStageError, + }; + } + } + } finally { + if (request === artistPageRequest) catalog.artistLoading = false; + } +} + +/** Open an album's catalog page (cover + tracklist). `opts.trackId` flags a + * track to highlight/scroll to (used by the now-playing song link). */ +export async function openCatalogAlbum(albumId, opts = {}) { + const albumKey = albumCacheKey(albumId); + if (!albumKey) return; + // A verified-id navigation supersedes any still-running name search. + albumResolveRequest += 1; + const request = ++albumPageRequest; + ui.view = "catalogAlbum"; + catalog.albumPage = null; + catalog.albumLoading = true; + catalog.pendingTrackId = opts.trackId ?? null; + catalog.error = ""; + const active = () => request === albumPageRequest && ui.view === "catalogAlbum"; + try { + const page = await api.catalogAlbum(albumId); + if (!sameAlbumId(page?.album?.id, albumKey)) { + throw new Error("catalog album returned a mismatched album identity"); + } + if (!active()) return; + catalog.albumPage = page; + // No catalog trackId (e.g. local playback) — match the song by title. + if (catalog.pendingTrackId == null && opts.trackTitle) { + const t = page.tracks.find( + (tr) => catNorm(tr.title) === catNorm(opts.trackTitle), + ); + if (t) catalog.pendingTrackId = t.id; + } + } catch (e) { + if (active()) catalog.error = String(e); + } finally { + if (request === albumPageRequest) catalog.albumLoading = false; + } +} + +const catNorm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, ""); + +/** Resolve an artist by name (cached) and open their catalog page — used by + * the now-playing artist link, which only knows the name. */ +export async function openArtistByName(name) { + const norm = (name || "").trim().toLowerCase(); + if (!norm) return; + let id = artistIdCache.has(norm) ? touchCache(artistIdCache, norm) : undefined; + if (id !== undefined) { + const cachedSeed = artistSeedCache.has(norm) + ? touchCache(artistSeedCache, norm) + : null; + if (id != null) openCatalogArtist(id, cachedSeed); + return; + } + + // A name-only Now Playing link still navigates immediately. Identity + // resolution replaces this shell in place instead of holding the old view + // behind a catalog search. + const resolveRequest = ++artistPageRequest; + ui.view = "catalogArtist"; + catalog.artistLoading = true; + catalog.error = ""; + catalog.artistStageError = ""; + catalog.artistPage = { + artist: { id: null, name, picture: null, fans: null, album_count: null }, + logos: [], + provider_logos_settled: false, + banner: null, + banners: [], + banner_custom: false, + popular_tracks: [], + albums: [], + top_genre: null, + socials: [], + art_preview: true, + loading_shell: true, + partial_error: null, + placeholder_picture: null, + }; + + try { + const res = await api.catalogSearch(name); + const artists = res.artists ?? []; + // Prefer the most-followed match so a shared name ("Pond", "Panda + // Bear") resolves to the canonical artist, not an obscure namesake + // that Deezer's search happens to list first. + const exact = artists.filter((a) => a.name.trim().toLowerCase() === norm); + const pool = exact.length ? exact : artists; + const best = pool.reduce( + (top, artist) => ((artist.fans ?? 0) > (top?.fans ?? -1) ? artist : top), + null, + ); + id = best?.id ?? null; + setBoundedMap(artistIdCache, norm, id, ARTIST_IDENTITY_CACHE_MAX); + if (best) rememberArtistSeed(best); + if ( + id != null + && resolveRequest === artistPageRequest + && ui.view === "catalogArtist" + ) { + return openCatalogArtist(id, best); + } + if (resolveRequest === artistPageRequest && id == null) { + catalog.error = "Artist not found."; + catalog.artistPage = null; + } + } catch (error) { + // Only a successful empty search is safe to negative-cache. A + // transport/provider failure must remain retryable on the next click. + if (resolveRequest === artistPageRequest) { + catalog.error = String(error); + catalog.artistPage = null; + } + } finally { + if (resolveRequest === artistPageRequest) catalog.artistLoading = false; + } +} + +/** Resolve an album by artist + title (via catalog search) and open its page — + * used by the now-playing album/song links when there's no catalog albumId + * (e.g. a local library track). */ +export async function openAlbumByName(artist, title, opts = {}) { + const request = ++albumResolveRequest; + if (opts.albumId != null) return openCatalogAlbum(opts.albumId, opts); + if (!artist || !title) return; + try { + const res = await api.catalogSearch(`${artist} ${title}`); + if (request !== albumResolveRequest) return; + const na = catNorm(artist); + const nt = catNorm(title); + // A title-only or first-result fallback can cross into a namesake's + // catalog. Without a verified id, both artist and title must match. + const match = res.albums?.find( + (album) => catNorm(album.artist) === na && catNorm(album.title) === nt, + ); + if (match) openCatalogAlbum(match.id, opts); + } catch { + // no album match — leave the view as-is + } +} + +/** Save the file(s) you're already streaming into the library — no re-search, + * no re-source. `scope`: "track" (current) or "album" (all streamed tracks in + * the queue). Downloads the KNOWN peer file(s) directly (skipping the search + * that made the old button fail) → tag/organize pipeline. Returns { ok, msg } + * for a UI toast. */ +export async function saveStream(scope) { + const items = + scope === "album" + ? player.queue.filter((it) => it.stream) + : player.queue[player.queueIndex]?.stream + ? [player.queue[player.queueIndex]] + : []; + if (items.length === 0) return { ok: false, msg: "Nothing streaming" }; + try { + // Cached tracks organize instantly; only un-streamed ones download. + const [organized, queued] = await api.saveToLibrary( + items.map((it) => ({ + username: it.stream.username, + path: it.path, + size: it.stream.size, + })), + ); + let msg; + if (queued > 0) { + msg = organized > 0 ? `Saved ${organized} · downloading ${queued}` : `Downloading ${queued}…`; + } else { + msg = organized > 1 ? `Saved ${organized} to library` : "Saved to library"; + } + return { ok: true, msg }; + } catch { + return { ok: false, msg: "Couldn’t save" }; + } +} + +/** Parse "Artist — Album" (or just "Artist") from the current track's sub. */ +function currentArtistAlbum() { + const sub = player.track?.sub ?? ""; + return sub.includes(" — ") + ? sub.split(" — ").map((s) => s.trim()) + : [sub.trim(), ""]; +} + +/** Now-playing meta links: jump to the artist / album / song's catalog page. */ +export function openCurrentArtist() { + const [artist] = currentArtistAlbum(); + if (artist) openArtistByName(artist); +} +export function openCurrentAlbum() { + if (player.catalogRef.albumId != null) { + openCatalogAlbum(player.catalogRef.albumId); + return; + } + const [artist, album] = currentArtistAlbum(); + if (album) openAlbumByName(artist, album); +} +export function openCurrentSong() { + const trackTitle = player.track?.title; + if (player.catalogRef.albumId != null) { + openCatalogAlbum(player.catalogRef.albumId, { + trackId: player.catalogRef.trackId, + trackTitle, + }); + return; + } + const [artist, album] = currentArtistAlbum(); + if (album) openAlbumByName(artist, album, { trackTitle }); +} + +// ---- Now Playing discovery (more-from-artist / fans-also-like / about) ---- + +export const discovery = $state({ + artist: null, // normalized name currently loaded (guards against races) + loading: false, + artistId: null, + pageLoaded: false, + pageWanted: false, + picture: null, + fans: null, + albumCount: null, + albums: [], // discography → "More from" + related: [], // "Fans also like" + bio: null, // Wikipedia summary → "About the artist" +}); + +const bioCache = new Map(); // normalized name → bio text (or null) +const BIO_CACHE_MAX = 64; + +/** Deezer's "this artist has no photo" portrait is the md5 of the EMPTY + * STRING. Crucially the API still returns a normal-looking per-artist hash + * that merely REDIRECTS there, and the placeholder has the same dimensions as + * a real portrait — so only the redirect target identifies it. The response's + * final URL is readable (the CDN sends CORS headers), and the fetch warms the + * same image the card is about to display. */ +const DEEZER_EMPTY_PICTURE_HASH = "d41d8cd98f00b204e9800998ecf8427e"; +const portraitProbeCache = new Map(); // picture url → is-placeholder +async function deezerPortraitMissing(url) { + if (!url) return true; + if (portraitProbeCache.has(url)) return touchCache(portraitProbeCache, url); + let missing = false; + try { + const response = await fetch(url); + missing = response.ok && response.url.includes(DEEZER_EMPTY_PICTURE_HASH); + } catch { + // Offline or blocked: keep the portrait rather than guess it away. + missing = false; + } + setBoundedMap(portraitProbeCache, url, missing, 64); + return missing; +} + +/** Short artist bio, resolved by the backend through the artist's OWN + * MusicBrainz→Wikidata identity (see `artist_bio`). This used to be a raw + * browser fetch that displayed Wikipedia's FIRST hit for + * " musician band" with no verification, which is how the panel came + * to describe Mr. Whitekeys under Camping in Alaska and Crazy Horse under + * Crazy P. It also bypassed the app's Wikimedia UA/pacer/cache. Needs the + * catalog id to anchor identity, so it runs after the id resolves. */ +async function fetchArtistBio(artistId, name) { + try { + return (await api.catalogArtistBio(artistId, name)) ?? null; + } catch { + return null; + } +} + +/** Resolve the playing artist's catalog id, then load their discography + + * related artists for the Now Playing discovery sections. Guarded by the + * normalized name so rapid track changes don't clobber each other. */ +export async function loadDiscovery(artistName) { + const norm = (artistName || "").trim().toLowerCase(); + if (!norm) { + discovery.artist = null; + return; + } + const resuming = discovery.artist === norm; + if (resuming && discovery.pageLoaded) return; + if (resuming && discovery.loading) { + // If the panel reopened after the delayed page check already skipped, + // let the in-flight overview request perform one exhaustive retry + // before it settles instead of losing refinement permanently. + if (ui.nowPlaying) discovery.pageWanted = true; + return; + } + discovery.artist = norm; + discovery.loading = true; + discovery.pageWanted = ui.nowPlaying; + if (!resuming) { + discovery.pageLoaded = false; + discovery.artistId = null; + discovery.picture = null; + discovery.fans = null; + discovery.albumCount = null; + discovery.albums = []; + discovery.related = []; + discovery.bio = bioCache.has(norm) ? touchCache(bioCache, norm) : null; + } + + try { + let id = discovery.artistId + ?? (artistIdCache.has(norm) ? touchCache(artistIdCache, norm) : undefined); + if (id === undefined) { + const res = await api.catalogSearch(artistName); + const match = + res.artists?.find((a) => a.name.trim().toLowerCase() === norm) ?? + res.artists?.[0] ?? + null; + id = match?.id ?? null; + setBoundedMap(artistIdCache, norm, id, ARTIST_IDENTITY_CACHE_MAX); + if (match) rememberArtistSeed(match); + } + if (discovery.artist !== norm) return; // track changed while searching + if (id == null) return; + discovery.artistId = id; + // The bio is identity-anchored, so it can only start once the catalog + // id is known. + if (!bioCache.has(norm)) { + fetchArtistBio(id, artistName).then((bio) => { + // Only PIN a real answer. A null is usually a transient + // MusicBrainz throttle (its 1 req/s gate is shared with the + // identity pass and the library warmer), and caching that + // would blank the card for the rest of the session even + // though the very next attempt succeeds. Retries are cheap — + // every tier is disk cached for 24 h. + if (bio) setBoundedMap(bioCache, norm, bio, BIO_CACHE_MAX); + if (discovery.artist === norm) discovery.bio = bio; + }); + } + const overviewReady = discovery.albums.length + ? Promise.resolve(null) + : getCatalogArtistOverview(id).catch(() => null); + // The Now Playing panel can paint its useful discovery content from + // the overview. Delay exhaustive classification until the artist and + // open panel have remained stable, avoiding invisible work after a + // rapid skip or panel close. + const pageReady = (async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + if (discovery.artist !== norm || !ui.nowPlaying) return null; + discovery.pageWanted = false; + return getCatalogArtistPage(id).catch(() => null); + })(); + const [overview, related] = await Promise.all([ + overviewReady, + api.catalogRelated(id).catch(() => []), + ]); + if (discovery.artist !== norm) return; + if (overview) { + rememberArtistSeed(overview.artist); + discovery.picture = overview.artist.picture ?? null; + // A Deezer "picture" can exist and still be the grey silhouette + // (see deezerPortraitMissing). Those artists usually DO have + // provider/Bing hero art — the artist page already resolves it — + // so borrow that instead of showing a placeholder. + void (async () => { + const portrait = discovery.picture; + if (portrait && !(await deezerPortraitMissing(portrait))) return; + const seed = await getCatalogArtistHeroSeed(id).catch(() => null); + if ( + discovery.artist === norm + && discovery.picture === portrait + && seed?.banner + ) discovery.picture = seed.banner; + })(); + discovery.fans = overview.artist.fans ?? null; + discovery.albumCount = + overview.artist.album_count ?? overview.albums.length; + discovery.albums = overview.albums ?? []; + } + discovery.related = related ?? []; + let page = await pageReady; + if (discovery.artist !== norm) return; + if (!page && discovery.pageWanted && ui.nowPlaying) { + discovery.pageWanted = false; + page = await getCatalogArtistPage(id).catch(() => null); + if (discovery.artist !== norm) return; + } + if (page) { + rememberArtistSeed(page.artist); + discovery.picture = page.artist.picture ?? null; + discovery.fans = page.artist.fans ?? null; + discovery.albumCount = page.artist.album_count ?? page.albums.length; + discovery.albums = page.albums ?? []; + discovery.pageLoaded = true; + discovery.pageWanted = false; + } + } catch { + // leave whatever loaded; sections just stay empty + } finally { + if (discovery.artist === norm) discovery.loading = false; + } +} + +// ---- auto-sourcing resolver ---- +// The catalog is the app's front door: picking a song or album quietly runs +// a Soulseek search, ranks the sources, and streams/downloads the winner. +// The manual Search view stays available as a fallback. + +export const resolver = $state({ + active: false, + label: "", + error: "", +}); + +const RESOLVER_AUDIO_QUALITY = { + flac: 4, ape: 4, aiff: 3.5, wav: 3.5, + m4a: 2.5, ogg: 2.5, opus: 2.5, aac: 2.2, mp3: 2, wma: 1.5, +}; + +// Formats that decode progressively while the file is still downloading. +// MP4-family files (m4a/aac) put their index at the end and only play once +// complete, so they're save-only. +const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "opus", "wav", "aiff"]); + +const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "aiff", "alac", "wv"]); + +// User source-selection preferences, independent per mode. Loaded from +// settings at startup and refreshed by the Settings view after a commit; +// defaults reproduce the pre-feature behavior exactly. +const DEFAULT_SOURCE_PREFS = { max: "any", min: "any", priority: "quality" }; +export const sourcePrefs = { + stream: { ...DEFAULT_SOURCE_PREFS }, + download: { ...DEFAULT_SOURCE_PREFS }, +}; +export async function refreshSourcePrefs() { + try { + const s = await api.getSettings(); + sourcePrefs.stream = { ...DEFAULT_SOURCE_PREFS, ...(s?.stream_source_prefs ?? {}) }; + sourcePrefs.download = { ...DEFAULT_SOURCE_PREFS, ...(s?.download_source_prefs ?? {}) }; + } catch { + // settings unavailable (fresh install / backend restart) → defaults + } +} +void refreshSourcePrefs(); + +/** Whether a peer file satisfies the user's quality window for this mode. + * Lossless formats satisfy every kbps floor; a lossy file must carry a KNOWN + * bitrate at or above it — a peer that hides its bitrate does not get the + * benefit of the doubt when the user asked for a minimum. Thresholds sit + * slightly under nominal so VBR files reporting 317 kbps still count as 320. */ +function passesSourcePrefs(f, prefs) { + const lossless = LOSSLESS_EXTS.has(f.extension); + if (prefs.max === "lossy_only" && lossless) return false; + switch (prefs.min) { + case "lossless": + if (!lossless) return false; + break; + case "kbps320": + if (!lossless && (f.bitrate ?? 0) < 315) return false; + break; + case "kbps256": + if (!lossless && (f.bitrate ?? 0) < 250) return false; + break; + case "kbps192": + if (!lossless && (f.bitrate ?? 0) < 186) return false; + break; + } + return true; +} + +/** Diacritics folded to ASCII ("Jóga" → "Joga", "Björk" → "Bjork"). Peers + * overwhelmingly name files unaccented; without folding, normText simply + * DELETED accented letters ("Jóga" → "jga") and the request could never + * match the file. */ +const asciiFold = (s) => (s ?? "").normalize("NFKD").replace(/\p{M}/gu, ""); + +const normText = (s) => + asciiFold(s).toLowerCase().replace(/\(.*?\)|\[.*?\]/g, "").replace(/[^a-z0-9]/g, ""); + +/** Catalog titles are often verbose editions ("Album; or, The Long Subtitle…", + * "Album (Deluxe Edition)"). Peer searches AND-match every word, so query + * with just the primary segment, capped to a few words. */ +function simplifyTitle(s) { + const first = (s ?? "").split(/[;:(\[]/)[0].trim(); + const words = (first || s || "").split(/\s+/).slice(0, 6).join(" "); + return words || s || ""; +} + +// Words that mark an *edition* of a release, not a distinct release. Used to +// tell an edition suffix (noise, safe to drop) from a variant name that names +// a different album — critical for self-titled records where the base title +// alone can't tell "Weezer (Blue Album)" from "Weezer (Red Album)". +const ALBUM_EDITION_RE = + /\b(?:deluxe|expanded|remaster(?:ed)?|anniversary|edition|version|bonus|reissue|super|complete|definitive|legacy|special|collector'?s?|mono|stereo|redux|remix(?:es|ed)?)\b/i; + +/** The disambiguating variant name inside a title's brackets — e.g. "Blue + * Album" from "Weezer (Blue Album)", or "Red Album" from "Weezer (Deluxe + * Edition / Red Album)" — skipping pure edition markers. "" when the brackets + * only hold edition noise (or there are none). */ +export function albumDisambiguator(title) { + const groups = [...(title ?? "").matchAll(/[([]([^)\]]+)[)\]]/g)].map((m) => m[1]); + for (const inner of groups) { + for (const part of inner.split(/\s*[/|;]\s*/)) { + const p = part.trim(); + if (p && !ALBUM_EDITION_RE.test(p)) return p; + } + } + return ""; +} + +/** Search-query title-part for a release: the simplified title, plus the + * disambiguating variant name when there is one, so distinct self-titled + * albums search for their own colour instead of colliding on the band name. */ +function albumQueryTitle(album) { + const base = simplifyTitle(album.title); + // Fall back to a caller-supplied variant (the family's colour) when the + // title itself dropped it — e.g. "Weezer (Deluxe Edition)" is the Red Album + // but names no colour, so without this it searches bare "Weezer". + const d = albumDisambiguator(album.title) || album.variant || ""; + return d ? `${base} ${d}` : base; +} + +// Server-distributed excluded search phrases (Soulseek message 160). A query +// containing one is NEVER routed by the server — it returns zero responses +// network-wide with no error. Verified live: every query containing "mgmt" +// was silent while "little dark age" alone returned 738 responses. The list +// arrives by event after login and is also fetchable on demand (covers a +// WebView reload); the resolver strips these terms before they reach the wire. +let excludedSearchPhrases = []; +export function setExcludedSearchPhrases(list) { + excludedSearchPhrases = (Array.isArray(list) ? list : []) + .map((p) => String(p ?? "").toLowerCase().trim()) + .filter(Boolean); +} +void api.excludedSearchPhrases?.().then(setExcludedSearchPhrases, () => {}); + +const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** Query with server-excluded phrases removed (whole-word, any position) and + * whitespace collapsed. Returns "" when nothing usable remains. */ +function sanitizeSearchQuery(query) { + let out = ` ${query ?? ""} `; + for (const phrase of [...excludedSearchPhrases, ...learnedPoisonedTerms]) { + out = out.replace(new RegExp(`(?<=^|[^\\p{L}\\p{N}])${escapeRegExp(phrase)}(?=$|[^\\p{L}\\p{N}])`, "giu"), " "); + } + return out.replace(/\s+/g, " ").trim(); +} + +/** Run a background source search (own token — the Search view is untouched) + * and collect responses. Ends early once `goodCount()` finds enough. + * + * The early exit is evaluated on a 250ms TICK, not only when a batch + * arrives: peers deliver most results within the first second and then go + * quiet, and the old on-batch-only check meant a plentiful-but-fast search + * still sat out the full window waiting for one more batch that never came + * (measured: 8.1s click-to-play on Discovery, ~7s of it idle waiting). + * Thresholds are adaptive — a flood of good candidates releases at 900ms, + * a workable set at 1800ms, and the full window only runs when results + * are genuinely sparse. */ +async function collectSources(query, maxMs, goodCount) { + const token = await api.startSearch(query); + const responses = []; + let unlisten = null; + await new Promise((resolve) => { + const started = performance.now(); + const timer = setTimeout(() => { + clearInterval(tick); + resolve(); + }, maxMs); + const tick = setInterval(() => { + if (!goodCount) return; + const elapsed = performance.now() - started; + const good = goodCount(responses); + if ((elapsed >= 900 && good >= 12) || (elapsed >= 1800 && good >= 5)) { + clearTimeout(timer); + clearInterval(tick); + resolve(); + } + }, 250); + unlisten = api.onEvent("soulseek:search-results", (batch) => { + for (const r of batch) if (r.token === token) responses.push(r); + }); + }); + unlisten?.(); + api.stopSearch(token).catch(() => {}); + return responses; +} + +/** Queries measured silent (zero responses) recently — skipped for a while + * so a poisoned rung ("mgmt …") costs its discovery timeout once, not on + * every album/track of the same artist. */ +const silentQueries = new Map(); +const SILENT_QUERY_TTL_MS = 10 * 60 * 1000; +function querySilent(q) { + const at = silentQueries.get(q); + if (at == null) return false; + if (performance.now() - at > SILENT_QUERY_TTL_MS) { + silentQueries.delete(q); + return false; + } + return true; +} + +// Terms PROVEN poisoned this session: a rung containing the term was silent +// while a term-less rung answered — the network is fine, the term is dead +// (the "mgmt" case; the server's own excluded list does not admit it). +// Applied exactly like server-excluded phrases, so the second album by the +// same artist never pays the discovery timeout again. +const learnedPoisonedTerms = new Set(); + +/** Estimated seconds until this peer's file has fully arrived: transfer time + * from the file's size and the peer's advertised upload speed (bytes/sec), + * plus a rough wait for a busy slot / queued transfers. Pessimistic where the + * peer hides its numbers — an unknown speed must not look like a fast one. */ +function estimatedTransferSecs(r, file) { + const size = file.size > 0 ? file.size : 60_000_000; // unknown → assume large + const speed = (r.upload_speed ?? 0) > 0 ? r.upload_speed : 75_000; // unknown → slow + let secs = size / speed; + if (!r.has_free_upload_slot) secs += 90; + secs += Math.min(r.queue_length ?? 0, 50) * 45; + return secs; +} + +function scoreSource(r, file, prefs = null) { + const q = RESOLVER_AUDIO_QUALITY[file.extension] ?? 0; + if (!q) return -1; + const bitrate = file.bitrate; + // Speed priority ranks by ESTIMATED ARRIVAL TIME — size ÷ peer speed plus + // queue wait — not by raw peer speed. A 40 MB FLAC from a 5 MB/s peer + // (~8s) legitimately beats a 7 MB MP3 from a 100 KB/s peer (~70s). This + // only reorders sources that already passed the user's min/max quality + // window (passesSourcePrefs runs before scoring), so "fastest" can never + // dip below the configured floor. Format/bitrate remain as tie-breakers. + if (prefs?.priority === "speed") { + // Positive baseline: callers treat score <= 0 as "reject this + // format", and a slow, deep-queued peer must rank last, not vanish. + const eta = Math.min(estimatedTransferSecs(r, file), 3_600); + let s = 100_000 - eta * 25; + s += q * 50; + if (bitrate) s += Math.min(bitrate, 1500) / 50; + return s; + } + let s = q * 1000; + if (bitrate) s += Math.min(bitrate, 1500) / 10; + if (r.has_free_upload_slot) s += 600; + s += Math.min(r.upload_speed ?? 0, 5_000_000) / 20_000; + s -= Math.min(r.queue_length ?? 0, 50) * 12; + return s; +} + +function requireOnline() { + if (app.connection !== "online") { + app.showLogin = true; + return false; + } + return true; +} + +// Markers that name a DIFFERENT RECORDING of the same song — live takes, +// remixes, demos, covers, radio edits… `normText` strips brackets, so +// "Island in the Sun (Live at X).mp3" matches the studio request perfectly; +// these are tested against the RAW filename + folder to catch that. Matching +// is symmetric: a candidate must carry exactly the markers the requested +// title carries — no live file for a studio request, no studio file for an +// explicitly "(Live)" catalog track. +const TRACK_VARIANT_MARKERS = [ + /\blive\b/i, + /\bunplugged\b/i, + /\bacoustic\b/i, + /\bremix(?:es|ed)?\b/i, + /\brmx\b/i, + /\brework(?:ed)?\b/i, + /\bdemo(?:s)?\b/i, + /\binstrumental\b/i, + /\bkaraoke\b/i, + /\bcover(?:s|ed)?\b/i, + /\btribute\b/i, + /\b(?:radio|single)\s+edit\b/i, + /\b(?:club|extended|dub|dance)\s+mix\b/i, + /\bmedley\b/i, + /\bmashup\b/i, + /\bsession(?:s)?\b/i, + /\brehearsal\b/i, + /\bouttake(?:s)?\b/i, + /\balternate\b/i, + /\btake\s+\d+\b/i, + /\bre-?recorded\b/i, + // "'s Version" re-records (Taylor's Version and kin): a different + // recording of the song, matched symmetrically like every marker here — + // found live by the source audit when a request for "22" top-ranked + // "22 (Taylor's Version)". + /\b\w+['’]s\s+version\b/i, + /\bsped\s+up\b/i, + /\bslowed\b/i, + /\b8[\s-]?bit\b/i, +]; +// Checked against the filename only: "Reprise" is also a record label +// (Weezer's!), so folder paths like "… (Reprise, 1996)" must not reject. +const TRACK_VARIANT_NAME_ONLY = [/\breprise\b/i]; + +/** True when the candidate names a different version of the song than the + * request asked for (either direction). */ +function variantMismatch(wantedTitle, candidateName, candidateFolder) { + const cand = `${candidateFolder}\\${candidateName}`; + for (const re of TRACK_VARIANT_MARKERS) { + if (re.test(cand) !== re.test(wantedTitle)) return true; + } + for (const re of TRACK_VARIANT_NAME_ONLY) { + if (re.test(candidateName) !== re.test(wantedTitle)) return true; + } + return false; +} + +/** A peer file's playtime is the wrong recording when it's far from the + * catalog's known length — most often a ripped 30-second streaming *preview* + * whose filename matches the real track (e.g. a 30s "Long Season" for a + * 41-minute live track). Only judges when BOTH durations are known; the band + * is generous (±20%, min 45s) so gapless trims and a few seconds of trailing + * silence never trip it. */ +function durationMismatch(expectSecs, candSecs) { + if (!expectSecs || !candSecs || expectSecs <= 0 || candSecs <= 0) return false; + const tol = Math.max(45, expectSecs * 0.2); + return Math.abs(candSecs - expectSecs) > tol; +} + +/** Ranked candidate files for a song across all responses (best first). + * `album` (title string, optional) biases toward folders of the release the + * user is actually looking at. `expectSecs` (optional) is the catalog track's + * known length, used to drop preview clips / wrong-length rips. */ +function songCandidates( + responses, + title, + streamOnly = false, + album = null, + expectSecs = null, + artist = null, + requireIdentityEvidence = false, +) { + const want = normText(title); + const wantedRaw = title ?? ""; + const wantAlbum = album ? normText(simplifyTitle(album)) : ""; + const artistKey = artist ? normText(artist) : ""; + const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download; + const out = []; + for (const r of responses) { + for (const f of r.files) { + if (!normText(f.name).includes(want)) continue; + if (streamOnly && !RESOLVER_STREAMABLE.has(f.extension)) continue; + if (!passesSourcePrefs(f, prefs)) continue; + if (variantMismatch(wantedRaw, f.name, f.folder)) continue; + if (durationMismatch(expectSecs, f.duration)) continue; + let s = scoreSource(r, f, prefs); + if (s <= 0) continue; + const albumEvidence = wantAlbum && normText(f.folder).includes(wantAlbum); + const artistEvidence = + artistKey && normText(`${f.folder}\\${f.name}`).includes(artistKey); + // A rung that dropped the artist term (server-excluded phrase) + // matches by title alone, which happily returns other artists' + // covers of the song. The peer's PATH must then prove identity: + // the artist somewhere in the folder/filename, or the exact + // release folder. + if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) { + continue; + } + // The requested release's own folder beats compilation/misc rips + // (below a format-tier jump, so quality still wins). + if (albumEvidence) s += 800; + if (artistEvidence) s += 400; + // Filename ends with the title → it IS this song, not a longer + // title containing it ("Holiday" vs "Holiday in Cambodia"). + if (normText(f.name.replace(/\.[^.]+$/, "")).endsWith(want)) s += 250; + out.push({ username: r.username, file: f, score: s }); + } + } + out.sort((a, b) => b.score - a.score); + return out; +} + +/** Exposed for the source-resolution audit harness (session-scratchpad CDP + * probes drive the REAL matching/scoring code against live search responses). + * Not part of the app surface. */ +export const _sourceAudit = { + songCandidates, + albumCandidates, + sanitizeSearchQuery, + collectSources, + variantMismatch, + durationMismatch, + asciiFold, + normText, + simplifyTitle, + stopPlayback, +}; + +/** Resolves true when the stream is audibly playing; false on decode/peer + * failure (status drops to idle) or when nothing plays within `ms`. */ +function waitForStreamStart(ms) { + return new Promise((resolve) => { + const t0 = performance.now(); + const iv = setInterval(() => { + if (player.status === "playing") { + clearInterval(iv); + resolve(true); + } else if (performance.now() - t0 > ms || player.status === "idle") { + clearInterval(iv); + resolve(false); + } + }, 100); + }); +} + +/** Ranked candidate folders for an album (best first). `trackCountOverride` + * is the authoritative track count from the catalog tracklist fetch; prefer it + * over `album.track_count`, which is routinely null when Play is pressed on the + * artist page (Deezer's artist-album LIST endpoint omits nb_tracks). */ +function albumCandidates( + responses, + album, + streamOnly = false, + trackCountOverride = null, + requireIdentityEvidence = false, +) { + const artistKey = normText(album.artist ?? ""); + const wantAlbum = normText(simplifyTitle(album.title)); + const wanted = trackCountOverride ?? album.track_count ?? null; + const year = album.release_date?.slice(0, 4) || null; + // Self-titled disambiguation: the "(Blue Album)" variant, and its bare + // colour word, so we can prefer the right folder and reject a wrong-colour + // one that a fuzzy peer search returns anyway. + const variant = (albumDisambiguator(album.title) || album.variant || "").toLowerCase(); + const wantColour = variant.match(/([a-z]+)\s+album\b/)?.[1] || null; + const folders = new Map(); + for (const r of responses) { + for (const f of r.files) { + const key = `${r.username}|${f.folder}`; + let g = folders.get(key); + if (!g) { + g = { username: r.username, folder: f.folder, files: [], r }; + folders.set(key, g); + } + g.files.push(f); + } + } + const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download; + const out = []; + for (const g of folders.values()) { + const audio = g.files.filter( + (f) => + (streamOnly + ? RESOLVER_STREAMABLE.has(f.extension) + : RESOLVER_AUDIO_QUALITY[f.extension]) + && passesSourcePrefs(f, prefs), + ); + // Require enough streamable files to plausibly be this release, but + // NEVER default an unknown count to 3 — that silently rejected every + // single/2-track release (whose peer folders hold 1–2 matching files) + // and is exactly why "No streamable album sources found" fired for + // real, obviously-streamable singles like ICARUS. Known count → at most + // 3, floor 1; unknown → just 1. + const minAudio = wanted ? Math.min(3, Math.max(1, wanted)) : 1; + if (audio.length < minAudio) continue; + const top = audio + .map((f) => scoreSource(g.r, f, prefs)) + .reduce((a, b) => a + b, 0) / audio.length; + let s = top; + const folderNorm = normText(g.folder); + const albumEvidence = folderNorm.includes(wantAlbum); + const artistEvidence = artistKey && folderNorm.includes(artistKey); + // Artist-less query rung (server-excluded phrase): the folder path + // must prove the release's identity — see songCandidates. + if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) { + continue; + } + if (albumEvidence) s += 400; + if (artistEvidence) s += 300; + // Prefer folders whose track count matches the release. + if (wanted) s -= Math.min(Math.abs(audio.length - wanted), 20) * 60; + // The release year in the folder path is a strong per-release signal + // (distinguishes reissues and same-named albums). + if (year && g.folder.includes(year)) s += 300; + // Self-titled colour disambiguation: reward the matching "(X Album)" + // folder, and strongly reject a folder that clearly names a different + // colour, so "Weezer (Blue Album)" can't stream from a Red folder. + if (wantColour) { + const folderColours = [ + ...g.folder.toLowerCase().matchAll(/\b([a-z]+)\s+album\b/g), + ].map((m) => m[1]); + if (folderColours.includes(wantColour)) s += 500; + else if (folderColours.length) s -= 900; + } + audio.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + out.push({ username: g.username, folder: g.folder, files: g.files, audio, score: s }); + } + out.sort((a, b) => b.score - a.score); + return out; +} + +/** Walk a LADDER of query shapes until one returns responses. Each rung is + * sanitized against the server's excluded-phrase list first (a poisoned term + * silences the whole query), deduplicated, and dropped when sanitizing left + * nothing. Returns `{ responses, artistIncluded }` — `artistIncluded` is + * whether the rung that produced the responses still contained the artist + * term, so candidate filters know to demand artist/album evidence in the + * file path instead (a title-only query happily returns covers and karaoke). + * + * `queries` may be a string (single rung) or an array, best-first. */ +async function resolve(label, queries, goodCount, artistTerm = null) { + resolver.active = true; + resolver.label = label; + resolver.error = ""; + player.error = ""; + try { + const artistKey = artistTerm ? normText(artistTerm) : ""; + const seen = new Set(); + const ladder = []; + for (const raw of [].concat(queries).filter(Boolean)) { + // Each shape also gets an ASCII-folded rung: peers name files + // unaccented, and the server AND-matches query tokens, so + // "Björk Jóga" finds nothing that "Bjork Joga" finds. + for (const variant of [raw, asciiFold(raw)]) { + const q = sanitizeSearchQuery(variant); + if (!q || seen.has(q)) continue; + seen.add(q); + ladder.push(q); + } + } + // Non-final rungs get a short window: a rung the network answers at + // all answers within ~2s, so a silent (poisoned/warm-up) rung should + // cost 3.5s, not the full budget. Recently-silent rungs are skipped + // outright. The post-login warm-up retry moved BEHIND the ladder: if + // ANY rung answered, connectivity is proven and no retry is owed — + // the old first-rung retry burned 7 extra seconds on every poisoned + // artist query before the fallback rung even ran. + let sawSilentArtistRung = false; + for (let index = 0; index < ladder.length; index++) { + const q = ladder[index]; + if (querySilent(q)) continue; + const final = index === ladder.length - 1; + const hasArtist = artistKey && normText(q).includes(artistKey); + const responses = await collectSources(q, final ? 7000 : 3500, goodCount); + if (responses.length) { + // An answered term-less rung after a silent artist rung PROVES + // the artist term is what kills the query — learn it, so the + // artist's other releases skip the dead rung entirely. + if (sawSilentArtistRung && !hasArtist && artistTerm) { + learnedPoisonedTerms.add(artistTerm.toLowerCase().trim()); + } + return { + responses, + artistIncluded: !artistKey || normText(q).includes(artistKey), + }; + } + if (hasArtist) sawSilentArtistRung = true; + silentQueries.set(q, performance.now()); + } + // Whole ladder silent: either the terms are all poisoned or we are in + // the post-login window where the server has not attached us to a + // search parent yet. One quiet retry of the primary rung covers the + // latter (and clears its silent mark on success). + if (ladder.length) { + const q = ladder[0]; + const responses = await collectSources(q, 7000, goodCount); + if (responses.length) { + silentQueries.delete(q); + return { + responses, + artistIncluded: !artistKey || normText(q).includes(artistKey), + }; + } + } + return { responses: [], artistIncluded: true }; + } finally { + resolver.active = false; + resolver.label = ""; + } +} + +// ---- library-first playback ---- +// Clicking play on a catalog album/song that's already saved plays the +// local library copy instead of sourcing a stream from a peer. + +/** Library album matching a catalog release (same normalization as the + * artist page's ✓ badges, plus the simplified-title variant so verbose + * catalog editions still match). */ +function libraryAlbumFor(artist, title, releaseDate = null) { + const a = normText(artist); + const t1 = normText(title); + if (!a || !t1) return null; + // normText strips ALL bracket content, so base-title comparison alone + // cannot tell self-titled variants apart: "Weezer (Blue Album)" and + // "Weezer (Green Album)" both normalize to "weezer" and would exact-match + // a library copy saved under the bare provider title "Weezer". After the + // base titles agree, identity therefore needs the variant name + // (albumDisambiguator) to agree — or, when either side lacks one, the + // release YEAR to corroborate. A self-titled base carries no identity at + // all (the title is just the artist), so even a bare↔bare title match + // requires the year there. No usable evidence → no local match; streaming + // the right album beats instantly playing the wrong one. + const wantD = normText(albumDisambiguator(title)); + // The simplified-title fallback lets a library copy saved under a terse + // name match a verbose catalog EDITION (library "Album" ↔ catalog "Album + // (Deluxe Edition)"); disambiguated variants never take it. + const t2 = wantD ? null : normText(simplifyTitle(title)); + const wantYear = releaseDate ? Number(String(releaseDate).slice(0, 4)) || null : null; + const selfTitled = t1 === a; + return ( + libraryStore.albums.find((al) => { + if (normText(al.artist) !== a) return false; + const lt = normText(al.title); + if (lt !== t1 && !(t2 && lt === t2)) return false; + const libD = normText(albumDisambiguator(al.title)); + if (wantD && wantD === libD) return true; + if (wantD !== libD || selfTitled) { + const libYear = Number(al.year) || null; + return wantYear != null && libYear != null && libYear === wantYear; + } + return true; + }) ?? null + ); +} + +/** The library album matching a catalog release, or null — the same evidence + * rules as library-first playback (variant name + release-year for self-titled + * records). Exposed for the artist page's saved-track ticks. */ +export function libraryLocalAlbum(artist, album) { + return libraryAlbumFor(artist, album?.title ?? "", album?.release_date ?? null); +} + +/** Whether a catalog release already has a matching copy in the library — + * the same evidence rules as library-first playback, so the artist page's + * ✓ badges and the play routing can never disagree. */ +export function libraryHasAlbum(artist, album) { + return Boolean(libraryLocalAlbum(artist, album)); +} + +/** Locate a track in the library by artist + title, searching only that + * artist's albums. Returns { album, tracks, index } or null. */ +async function libraryTrackFor(artist, title) { + const a = normText(artist); + const t = normText(title); + if (!a || !t) return null; + for (const al of libraryStore.albums) { + if (normText(al.artist) !== a) continue; + try { + const tracks = await api.albumTracks(al.id); + const index = tracks.findIndex((tr) => normText(tr.title) === t); + if (index >= 0) return { album: al, tracks, index }; + } catch { + // library unavailable — fall through to streaming + } + } + return null; +} + +/** Distill a peer file's tags into the quality info the Now Playing panel + * shows (format/bitrate/sample-rate/bit-depth). All fields are optional — + * many clients only report some, so the panel hides what's missing. */ +function qualityOf(f) { + if (!f) return null; + const ext = f.extension || (f.name || f.path || "").split(".").pop() || ""; + return { + format: ext.toUpperCase(), + bitrate: f.bitrate ?? null, + sampleRate: f.sample_rate ?? null, + bitDepth: f.bit_depth ?? null, + vbr: !!f.vbr, + size: f.size ?? null, + }; +} + +/** Quality info for a local library track: format from the path, the rest + * from the DB (sample-rate/bit-depth/size backfilled for older files at + * startup). The panel drops whatever's still missing. Null if there's no + * path. `t` is a library Track row. */ +function localQuality(t) { + const path = t?.path || ""; + const ext = path.split(".").pop() || ""; + if (!ext || ext === path) return null; + return { + format: ext.toUpperCase(), + bitrate: t.bitrate_kbps ?? null, + sampleRate: t.sample_rate ?? null, + bitDepth: t.bit_depth ?? null, + vbr: false, + size: t.size ?? null, + }; +} + +/** Deezer's CDN renders the same cover at whatever size the URL's size + * segment asks for. Track objects carry the 250px list thumb + * (`cover_medium`), which looks terrible blown up in the player — rewrite it + * to the 1000px xl the album pages use. Non-Deezer URLs pass through + * untouched. */ +function hqCover(url) { + if (!url) return url; + try { + if (!new URL(url).hostname.endsWith("dzcdn.net")) return url; + } catch { + return url; + } + return url.replace( + /\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i, + "/1000x1000$1.$2", + ); +} + +/** Responsive card-sized variant of a canonical cover URL. The resolver keeps + * its original/high-resolution winner for the player and album expander; the + * 150px grid should not decode 9-million-pixel Apple masters into GPU memory. */ +export function gridCover(url) { + if (!url || url.startsWith("data:")) return url; + try { + const parsed = new URL(url); + if (parsed.hostname.endsWith("dzcdn.net")) { + return url.replace( + /\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i, + "/500x500$1.$2", + ); + } + if (parsed.hostname.endsWith("mzstatic.com")) { + parsed.pathname = parsed.pathname.replace(/\/[^/]+$/, "/500x500bb.jpg"); + return parsed.toString(); + } + if (parsed.hostname.endsWith("coverartarchive.org")) { + parsed.pathname = parsed.pathname.replace(/\/front(?:-\d+)?$/, "/front-500"); + return parsed.toString(); + } + } catch { + return url; + } + return url; +} + +/** A tiny CDN-rendered variant used for blurred placeholders and palette + * sampling. Deezer and Apple both resize the same source artwork by URL. */ +export function tinyCover(url) { + if (!url) return null; + try { + const parsed = new URL(url); + if (parsed.hostname.endsWith("mzstatic.com")) { + parsed.pathname = parsed.pathname.replace(/\/[^/]+$/, "/96x96bb.jpg"); + return parsed.toString(); + } + if (parsed.hostname.endsWith("coverartarchive.org")) { + const originalPath = parsed.pathname; + parsed.pathname = parsed.pathname.replace(/\/front(?:-\d+)?$/, "/front-250"); + return parsed.pathname === originalPath && !originalPath.endsWith("/front-250") + ? null + : parsed.toString(); + } + if (!parsed.hostname.endsWith("dzcdn.net")) return null; + } catch { + return null; + } + const tiny = url.replace( + /\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i, + "/56x56$1.$2", + ); + return tiny === url ? null : tiny; +} + +/** Find the best source for a song and stream it, failing over to the next + * candidate when a peer never starts sending. Songs already in the library + * play locally (with their album as the queue). + * opts: { cover, trackId, albumId, album, expectSecs } — catalog context for + * art, badges, album-folder source preference, and length verification. */ +export async function streamSong(artist, title, opts = {}) { + if (resolver.active) return; + const local = await libraryTrackFor(artist, title); + if (local) { + playAlbum( + local.tracks, + `${local.album.artist} — ${local.album.title}`, + local.index, + local.album.art_path ?? null, + ); + player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null }; + return; + } + if (!requireOnline()) return; + autoAlbum = null; + player.track = { title, sub: "finding best source…", source: "stream" }; + player.status = "buffering"; + const { responses, artistIncluded } = await resolve( + `${artist} — ${title}`, + [`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)], + (rs) => songCandidates(rs, title, true, opts.album, opts.expectSecs, artist).length, + artist, + ); + const candidates = songCandidates( + responses, title, true, opts.album, opts.expectSecs, artist, !artistIncluded, + ); + if (candidates.length === 0) { + player.status = "idle"; + player.track = null; + resolver.error = `No streamable sources found for “${title}”`; + return; + } + for (const c of candidates.slice(0, 4)) { + player.queue = [ + { + path: c.file.path, + title, + sub: artist, + artist, + album: opts.album ?? null, + duration: c.file.duration ?? opts.expectSecs ?? null, + art: null, + artUrl: hqCover(opts.cover) ?? null, + trackId: opts.trackId ?? null, + stream: { username: c.username, size: c.file.size }, + quality: qualityOf(c.file), + }, + ]; + player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null }; + playQueueIndex(0); + if (await waitForStreamStart(15000)) { + // Keep the album going: resolve + pre-buffer the next album + // track in the background, Spotify-style. + if (opts.albumId) { + autoAlbum = { + albumId: opts.albumId, + albumTitle: opts.album ?? null, + cover: hqCover(opts.cover) ?? null, + tracks: null, + pending: false, + }; + maybeExtendAlbumQueue(); + } + return; + } + } + stopPlayback(); + resolver.error = `Sources for “${title}” aren't responding — try again in a moment`; +} + +/** Find the best source for a song and download it into the library. */ +export async function saveSong(artist, title) { + if (!requireOnline() || resolver.active) return false; + const { responses, artistIncluded } = await resolve( + `${artist} — ${title}`, + [`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)], + (rs) => songCandidates(rs, title, false, null, null, artist).length, + artist, + ); + const best = songCandidates(responses, title, false, null, null, artist, !artistIncluded)[0]; + if (!best) { + resolver.error = `No sources found for “${title}”`; + return false; + } + await api.downloadFile(best.username, best.file.path, best.file.size); + return true; +} + +/** Match a peer file to a catalog track, so queue items carry the tagged + * title instead of the filename. + * + * A track title contained in the filename normally identifies the track — but + * only if it's *specific* to this file. When peers embed the album name in + * every filename ("… - Eggbeater Jesus - 01 Happy Hour.flac"), and the album + * name is also a track title, that album name is a substring of every filename + * and — being long — used to out-rank each track's real, shorter title, so the + * whole album collapsed to one repeated title. So: ignore any title that + * appears in (roughly) every filename, take the longest *specific* match, and + * otherwise fall back to album order when the folder is a complete album. */ +function catalogTrackFor(file, i, files, tracks) { + if (!tracks?.length) return null; + const fname = normText(file.name); + const base = (t) => normText(t.title); + const named = (t) => base(t).length >= 3 && fname.includes(base(t)); + + // PRIMARY: the catalog (Deezer) tracklist, in order. When the folder holds + // exactly the album's track count it's almost always the whole album in + // order, so file `i` is track `i`. That's the only reliable way to tell + // same-named variants apart — "My Name Is Jonas (2024 Remaster)", + // "(Live On BBC…)" and "(Kitchen Tape Demo)" all normalize to one base + // title, so filename matching alone can't say which belongs here (it used + // to pick the longest title, i.e. the verbose live/demo one). Only distrust + // the position if the filename clearly names a DIFFERENT song than the one + // sitting there (a reordered/mismatched folder — handled by the backup). + if (files.length === tracks.length) { + const at = tracks[i]; + if (!fname || named(at) || !tracks.some(named)) return at; + } + + // BACKUP: fuzzy filename→title matching for a partial/reordered folder. + // Drop any title that appears in ~every filename (that's the album name), + // then among the rest prefer the closest duration (disambiguates same-named + // variants), then the longest title. + const contained = tracks.filter(named); + const ubiquity = (t) => files.reduce((n, f) => n + (normText(f.name).includes(base(t)) ? 1 : 0), 0); + const ubiquitous = Math.max(2, Math.ceil(files.length * 0.5)); + let pool = contained.filter((t) => ubiquity(t) < ubiquitous); + if (!pool.length) pool = contained; + if (pool.length) { + const dur = file.duration; + if (dur) { + const off = (t) => Math.abs((t.duration_secs ?? 1e9) - dur); + // Closest duration wins; on a tie prefer the shorter title, i.e. the + // plain studio version over a longer "(Live…)" / "(Demo…)" variant. + return pool.reduce((a, b) => + off(b) < off(a) || (off(b) === off(a) && b.title.length < a.title.length) ? b : a, + ); + } + return pool.reduce((a, b) => (b.title.length > a.title.length ? b : a)); + } + return files.length === tracks.length ? tracks[i] : null; +} + +/** Turn a peer filename into a readable title when no catalog track matches + * it (a genuine bonus/extra file on an expanded edition): drop the extension + * and a leading track-number prefix ("04. ", "1 - ", "07 ") so it reads as a + * title rather than "04. Dragon Ball Z…". Conservative — only strips a number + * that's clearly a track index (has a separator, or a leading zero), so a title + * like "99 Luftballons" is left intact. */ +function cleanTrackName(name) { + const stem = (name ?? "").replace(/\.[^.]+$/, ""); + const stripped = stem.replace(/^\s*(?:\d{1,2}\s*[.\-)]|0\d)\s+/, "").trim(); + return stripped || stem; +} + +/** Find the best folder for an album; stream it as a queue, failing over to + * the next-best folder when a peer never starts sending. Albums already in + * the library play locally instead. */ +export async function streamCatalogAlbum(album) { + if (resolver.active) return; + const local = libraryAlbumFor(album.artist, album.title, album.release_date ?? null); + if (local) { + try { + const tracks = await api.albumTracks(local.id); + if (tracks.length > 0) { + playAlbum( + tracks, + `${local.artist} — ${local.title}`, + 0, + local.art_path ?? null, + ); + player.catalogRef = { albumId: album.id ?? null, trackId: null }; + return; + } + } catch { + // library unavailable — fall through to streaming + } + } + if (!requireOnline()) return; + autoAlbum = null; + player.track = { title: album.title, sub: "finding best source…", source: "stream" }; + player.status = "buffering"; + // Resolve the authoritative tracklist FIRST. Its length is the true track + // count — Deezer's artist-album LIST omits nb_tracks, so album.track_count + // is routinely null when Play is pressed on the artist page — and its + // titles are what the queue should show instead of peer filenames. Feeding + // the real count into albumCandidates both stops the ≥3-files filter from + // rejecting small releases and lets the folder-count preference pick the + // edition that matches the release (e.g. the 3-track single, not a 7-track + // CDR that would leak bonus tracks into the queue as raw filenames). + const catalogPromise = album.id + ? api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null) + : Promise.resolve(null); + // Overlap the peer search with the tracklist fetch when the release is + // provably multi-track — the search is the long pole, and the tracklist + // round-trip used to serialize in front of it. `liveTrackCount` feeds the + // early-exit counter and tightens itself the moment the tracklist lands. + // A possible single (unknown count, or single/EP typed) keeps the + // sequential order, because a confirmed single must take the SONG path + // and must not race an already-active album resolve. + const provablyMultiTrack = + (album.track_count ?? 0) > 1 || album.record_type === "album"; + let liveTrackCount = album.track_count || null; + const goodCount = (rs) => albumCandidates(rs, album, true, liveTrackCount).length; + let resolvePromise = null; + if (provablyMultiTrack) { + resolvePromise = resolve( + `${album.artist} — ${album.title}`, + [`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)], + goodCount, + album.artist, + ); + } + const catalogTracks = await catalogPromise; + const trackCount = catalogTracks?.length || album.track_count || null; + liveTrackCount = trackCount; + + // A confirmed single is one track — stream it through the song path, which + // matches peer files by TITLE (album folder-matching can't identify a lone + // file among a folder's contents) and layers on the live/remix + duration + // hardening. Without this, a single fell into the album path and, with an + // unknown count, was rejected for having fewer than 3 files per folder. + if (trackCount === 1) { + // Rare mistyped release: a search may already be in flight — let it + // finish so the song path's own resolve isn't refused as re-entrant. + if (resolvePromise) await resolvePromise.catch(() => {}); + const t = catalogTracks?.[0]; + await streamSong(album.artist, t?.title ?? album.title, { + cover: t?.cover ?? album.cover, + trackId: t?.id ?? null, + albumId: album.id ?? null, + album: album.title || null, + expectSecs: t?.duration_secs ?? null, + }); + return; + } + + const { responses, artistIncluded } = await (resolvePromise + ?? resolve( + `${album.artist} — ${album.title}`, + [`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)], + goodCount, + album.artist, + )); + const candidates = albumCandidates(responses, album, true, trackCount, !artistIncluded); + if (candidates.length === 0) { + player.status = "idle"; + player.track = null; + resolver.error = `No streamable album sources found for “${album.title}”`; + return; + } + for (const folder of candidates.slice(0, 3)) { + player.queue = folder.audio.map((f, i) => { + const ct = catalogTrackFor(f, i, folder.audio, catalogTracks); + return { + path: f.path, + title: ct?.title ?? cleanTrackName(f.name), + sub: `${album.artist} — ${album.title}`, + artist: album.artist, + album: album.title ?? null, + duration: f.duration ?? ct?.duration_secs ?? null, + art: null, + artUrl: album.cover ?? null, + trackId: ct?.id ?? null, + stream: { username: folder.username, size: f.size }, + quality: qualityOf(f), + }; + }); + player.catalogRef = { albumId: album.id ?? null, trackId: null }; + playQueueIndex(0); + if (await waitForStreamStart(15000)) return; + } + stopPlayback(); + resolver.error = `Sources for “${album.title}” aren't responding — try again in a moment`; +} + +/** Find the best folder for an album and download all of it. */ +export async function saveCatalogAlbum(album) { + if (!requireOnline() || resolver.active) return false; + // Same authoritative-count logic as streamCatalogAlbum: a single downloads + // as one track (folder-count matching can't find a lone-file release), and + // the real count keeps a multi-track Save from being rejected or grabbing a + // wrong-length edition. + const catalogTracks = album.id + ? await api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null) + : null; + const trackCount = catalogTracks?.length || album.track_count || null; + if (trackCount === 1) { + const t = catalogTracks?.[0]; + return await saveSong(album.artist, t?.title ?? album.title); + } + const { responses, artistIncluded } = await resolve( + `${album.artist} — ${album.title}`, + [`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)], + (rs) => albumCandidates(rs, album, false, trackCount).length, + album.artist, + ); + const best = albumCandidates(responses, album, false, trackCount, !artistIncluded)[0]; + if (!best) { + resolver.error = `No album sources found for “${album.title}”`; + return false; + } + // The whole folder, extras included — the pipeline organizes and the + // staging cleanup files art/logs next to the finished album. + for (const f of best.files) { + await api.downloadFile(best.username, f.path, f.size); + } + return true; +} + +export const search = $state({ + token: null, + query: "", + searching: false, + responses: 0, + // key: `${username}|${folder}` -> group summary (scalars only — the file + // lists live outside reactive state, see searchFiles below). + groups: {}, +}); + +// Raw per-group file lists. Deliberately NOT reactive: a broad search stores +// up to 300 × 500 file objects, and wrapping each in a $state proxy is what +// used to freeze the webview (GC storm at ~1GB+). Components re-render off +// the group's reactive fileCount and read the list through filesForGroup(). +const searchFiles = new Map(); +let searchStopTimer = null; +let searchRequest = 0; + +function trimHiddenSearchFiles() { + const visibleKeys = new Set( + Object.values(search.groups) + .sort( + (a, b) => + (b.hasFreeSlot - a.hasFreeSlot) + || (b.speed - a.speed), + ) + .slice(0, 120) + .map((group) => group.key), + ); + for (const key of searchFiles.keys()) { + if (!visibleKeys.has(key)) searchFiles.delete(key); + } +} + +export function filesForGroup(key) { + return searchFiles.get(key)?.files ?? []; +} + +export const AUDIO_EXTS = new Set([ + "flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff", +]); + +export const downloads = $state({ + // id -> download payload (latest update wins) + items: {}, +}); + +export const network = $state({ + shareFolders: 0, + shareFiles: 0, + respondedCount: 0, + uploads: {}, // id -> upload payload +}); + +const TERMINAL_TRANSFER_STATES = new Set(["complete", "failed", "cancelled"]); +const DOWNLOAD_HISTORY_MAX = 300; +const UPLOAD_HISTORY_MAX = 100; + +function trimTransferHistory(store, maxTerminal, waitUntilIdle = false) { + const values = Object.values(store); + if ( + waitUntilIdle + && values.some((transfer) => !TERMINAL_TRANSFER_STATES.has(transfer.state)) + ) { + return; + } + const terminal = values + .filter((transfer) => TERMINAL_TRANSFER_STATES.has(transfer.state)) + .sort((a, b) => (b.id ?? 0) - (a.id ?? 0)); + for (const transfer of terminal.slice(maxTerminal)) delete store[transfer.id]; +} + +export const player = $state({ + status: "idle", // idle | buffering | playing | paused + track: null, // { title, sub, source: 'stream'|'local' } + position: 0, + duration: null, + // The player bar temporarily owns position while the user is dragging the + // playhead. Incoming backend ticks resume on release. + scrubbing: false, + volume: 0.7, + muted: false, + // streaming buffer info + streamReceived: 0, + streamTotal: 0, + error: "", + // album play queue (local tracks) + queue: [], + queueIndex: -1, + shuffle: false, + repeat: "off", // off | all | one + // waveform peaks (0..1) for the current local track, [] while loading + waveform: [], + // Which catalog entity is playing (drives now-playing badges). + catalogRef: { albumId: null, trackId: null }, +}); + +// Adaptive accent theme, derived from the playing track's album art. +// Idle default matches the SoulStream brand blue (see icons/). +const DEFAULT_ACCENT = "#1f9ad6"; +const DEFAULT_ACCENT_HOVER = "#41b7ee"; + +export const theme = $state({ + accent: DEFAULT_ACCENT, + accentHover: DEFAULT_ACCENT_HOVER, +}); + +let accentRaf = null; + +/** Animate --accent/--accent-hover to the target colors (ease-out). */ +function crossfadeAccent(accent, hover, ms = 1200) { + if (accent === theme.accent && hover === theme.accentHover) return; + if (accentRaf) cancelAnimationFrame(accentRaf); + const fromA = hexToRgb(theme.accent); + const fromH = hexToRgb(theme.accentHover); + const toA = hexToRgb(accent); + const toH = hexToRgb(hover); + const t0 = performance.now(); + const root = document.documentElement.style; + const step = (now) => { + if (!ui.windowActive) { + theme.accent = accent; + theme.accentHover = hover; + root.setProperty("--accent", accent); + root.setProperty("--accent-hover", hover); + accentRaf = null; + return; + } + const t = Math.min(1, (now - t0) / ms); + const e = t * (2 - t); + theme.accent = rgbToHex(lerpRgb(fromA, toA, e)); + theme.accentHover = rgbToHex(lerpRgb(fromH, toH, e)); + root.setProperty("--accent", theme.accent); + root.setProperty("--accent-hover", theme.accentHover); + accentRaf = t < 1 ? requestAnimationFrame(step) : null; + }; + accentRaf = requestAnimationFrame(step); +} + +let accentReq = 0; + +/** Set the app accent from an album-art path (crossfades; null → default). */ +async function setAccentFromArt(artPath) { + const req = ++accentReq; + if (!artPath) { + crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER); + return; + } + try { + const dataUrl = await getArt(artPath); + const colors = dataUrl ? await accentFromImage(dataUrl) : null; + if (req !== accentReq) return; // track changed while extracting + if (colors) crossfadeAccent(colors.accent, colors.hover); + else crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER); + } catch { + if (req === accentReq) crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER); + } +} + +/** Same, from a remote catalog cover URL (proxied to a data URL). */ +async function setAccentFromArtUrl(url) { + const req = ++accentReq; + if (!url) { + crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER); + return; + } + try { + const dataUrl = await getCatalogArt(url); + const colors = dataUrl ? await accentFromImage(dataUrl) : null; + if (req !== accentReq) return; + if (colors) crossfadeAccent(colors.accent, colors.hover); + else crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER); + } catch { + if (req === accentReq) crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER); + } +} + +// ---- play history ---- +// Listened time is accumulated from the player's position ticks — wall-clock +// timers would count paused/buffering time, and ticks stop exactly when audio +// does. A session row is only created once a track has actually accumulated +// listened time, so instant skips never write history. Seeks are ignored via +// the delta guard (a jump or rewind isn't listening). The playId promise +// serializes the insert and its top-ups, so rapid track changes can't lose +// the remainder or write it to the wrong row. +const LISTEN_FLUSH_SECS = 15; +let listenSession = null; // { key, pending, playIdP } +let listenLastPos = null; + +function listenFlush(session = listenSession) { + if (!session?.playIdP) return; + const secs = Math.floor(session.pending); + if (secs < 1) return; + session.pending -= secs; + session.playIdP + .then((id) => api.addPlaySeconds(id, secs)) + .catch(() => {}); +} + +function listenTick(positionSecs) { + const t = player.track; + const title = t?.title ?? ""; + const artist = (t?.artist ?? "").trim() + || String(t?.sub ?? "").split(" — ")[0].trim(); + if (!title || !artist || artist.includes("…")) return; + const key = `${artist}\u0000${title}`; + if (listenSession?.key !== key) { + listenFlush(); + listenSession = { key, artist, title, pending: 0, playIdP: null }; + listenLastPos = null; + } + const delta = listenLastPos == null ? 0 : positionSecs - listenLastPos; + listenLastPos = positionSecs; + if (delta <= 0 || delta > 3) return; + listenSession.pending += delta; + if (!listenSession.playIdP && listenSession.pending >= 1) { + listenSession.playIdP = api.recordPlay(artist, title); + listenSession.playIdP.catch(() => {}); + } + if (listenSession.pending >= LISTEN_FLUSH_SECS) listenFlush(); +} + +let started = false; +let windowActivityStarted = false; +let latestPlayerPosition = 0; +let latestPlayerDuration = null; +let latestStreamReceived = 0; +let latestStreamTotal = 0; + +function initWindowActivity() { + if (windowActivityStarted) return; + windowActivityStarted = true; + let active = true; + let syncGeneration = 0; + let syncTimer = null; + const nativeUnlisteners = []; + const sync = async () => { + const generation = ++syncGeneration; + let minimized = false; + try { + minimized = await api.win.isMinimized(); + } catch { + // Plain-browser previews have no native window API. + } + if (!active || generation !== syncGeneration) return; + const wasActive = ui.windowActive; + // A visible, unfocused window can still be watched side-by-side. Only + // actual native minimization / DOM hiding should freeze visual state. + ui.windowActive = !document.hidden && !minimized; + if (!wasActive && ui.windowActive) { + if (!player.scrubbing) player.position = latestPlayerPosition; + if (latestPlayerDuration != null) player.duration = latestPlayerDuration; + player.streamReceived = latestStreamReceived; + player.streamTotal = latestStreamTotal; + const current = player.queue[player.queueIndex]; + if ( + current?.stream + && latestStreamReceived > 0 + && latestStreamReceived < latestStreamTotal + ) { + waveRefreshAt = performance.now(); + void loadStreamWaveform(current); + } + } + }; + const scheduleSync = () => { + clearTimeout(syncTimer); + syncTimer = setTimeout(sync, 50); + }; + void sync(); + const addNativeListener = (promise) => { + void promise + .then((unlisten) => { + if (active) nativeUnlisteners.push(unlisten); + else unlisten(); + }) + .catch(() => {}); + }; + addNativeListener(api.win.onResized(scheduleSync)); + addNativeListener(api.win.onFocusChanged(scheduleSync)); + document.addEventListener("visibilitychange", scheduleSync); + if (import.meta.hot) { + import.meta.hot.dispose(() => { + active = false; + clearTimeout(syncTimer); + document.removeEventListener("visibilitychange", scheduleSync); + for (const unlisten of nativeUnlisteners) unlisten(); + }); + } +} + +/** Wire backend events once, at app start. */ +export function initEvents() { + if (started) return; + started = true; + initWindowActivity(); + + // The `connected` event fires once, at login. A WebView reload (dev + // iteration, crash recovery) lands AFTER it, so the fresh frontend + // believed it was offline against a logged-in backend and pushed the + // login modal at the first play attempt. Recover the real state at boot — + // retried, because the first invoke can race IPC readiness at mount. + void (async () => { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const status = await api.appStatus(); + if (status?.connected_as && app.connection !== "online") { + app.connection = "online"; + app.username = status.connected_as; + app.showLogin = false; + } + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 2500)); + } + } + })(); + + api.onEvent("soulseek:state", (ev) => { + if (ev.kind === "connected") { + app.connection = "online"; + app.username = ev.username; + app.loginError = ""; + } else if (ev.kind === "disconnected") { + app.connection = "offline"; + } + }); + + api.onEvent("soulseek:excluded-phrases", setExcludedSearchPhrases); + + api.onEvent("soulseek:shares", (s) => { + network.shareFolders = s.folders; + network.shareFiles = s.files; + }); + + api.onEvent("soulseek:upload", (u) => { + network.uploads[u.id] = u; + if (TERMINAL_TRANSFER_STATES.has(u.state)) { + trimTransferHistory(network.uploads, UPLOAD_HISTORY_MAX); + } + }); + + api.onEvent("soulseek:responded", () => { + network.respondedCount += 1; + }); + + api.onEvent("soulseek:download", (d) => { + // Preserve last known progress on terminal states that carry none. + const prev = downloads.items[d.id]; + if (prev && d.total === 0) { + d.received = prev.received; + d.total = prev.total; + } + downloads.items[d.id] = d; + if (TERMINAL_TRANSFER_STATES.has(d.state)) { + // Preserve every member of an active multi-file burst so the + // title-bar aggregate remains monotonic. Prune once it drains. + trimTransferHistory(downloads.items, DOWNLOAD_HISTORY_MAX, true); + } + }); + + api.onEvent("soulseek:player", (ev) => { + switch (ev.kind) { + case "buffering": + player.status = "buffering"; + player.position = 0; + latestPlayerPosition = 0; + latestPlayerDuration = player.duration; + latestStreamReceived = 0; + latestStreamTotal = 0; + player.scrubbing = false; + player.error = ""; + break; + case "playing": + player.status = "playing"; + break; + case "paused": + player.status = "paused"; + listenFlush(); + break; + case "stopped": + player.scrubbing = false; + listenFlush(); + stopPlayback(); + break; + case "track-ended": + listenFlush(); + if (ev.gapless) advanceGaplessly(); + else nextTrack(true); + break; + case "position": + latestPlayerPosition = ev.secs; + if (ev.duration != null) latestPlayerDuration = ev.duration; + if (!player.scrubbing && ui.windowActive) player.position = ev.secs; + if (ev.duration != null && player.duration !== ev.duration) { + player.duration = ev.duration; + } + listenTick(ev.secs); + break; + case "error": + player.status = "idle"; + player.error = ev.message; + break; + } + }); + + api.onEvent("soulseek:stream", (s) => { + latestStreamReceived = s.received; + latestStreamTotal = s.total; + if (ui.windowActive) { + player.streamReceived = s.received; + player.streamTotal = s.total; + } + const cur = player.queue[player.queueIndex]; + if (s.done) { + // Fully cached → final waveform, and the peer's slot is + // definitely free, so make sure the next track is pre-buffering. + if (cur?.stream) loadStreamWaveform(cur, true); + preloadNextStream(); + tryAppendNext(); + } else if ( + ui.windowActive + && cur?.stream + && performance.now() - waveRefreshAt > 3000 + ) { + // Still downloading: refresh the partial waveform so it grows + // with the transfer. + waveRefreshAt = performance.now(); + loadStreamWaveform(cur); + } + }); + + // A next-track pre-buffer finished downloading: append it to the audio + // sink so the upcoming transition is gapless. + api.onEvent("soulseek:preload", (p) => { + if (p.done) tryAppendNext(); + }); + + let libraryRefreshTimer = null; + api.onEvent("soulseek:library-changed", () => { + clearTimeout(libraryRefreshTimer); + libraryRefreshTimer = setTimeout(refreshLibrary, 400); + }); + + api.onEvent("soulseek:playlists-changed", refreshPlaylists); + + // The backend batches search responses (~150ms ticks) so a broad search + // can't flood the IPC bridge or trigger a reactive flush per response. + api.onEvent("soulseek:search-results", (batch) => { + let matched = 0; + for (const r of batch) { + if (!search.searching || r.token !== search.token) continue; + matched += 1; + for (const f of r.files) { + const key = `${r.username}|${f.folder}`; + let g = search.groups[key]; + let raw = searchFiles.get(key); + if (!g) { + // Cap group count so a popular search can't flood the DOM. + if (searchFiles.size >= 300) continue; + raw = { files: [], exts: new Map() }; + searchFiles.set(key, raw); + g = search.groups[key] = { + key, + username: r.username, + folder: f.folder, + folderName: f.folder.split("\\").pop() || r.username, + hasFreeSlot: r.has_free_upload_slot, + speed: r.upload_speed, + queue: r.queue_length, + // Scalar summaries the UI renders; the file list + // itself stays out of reactive state. + fileCount: 0, + audioCount: 0, + totalSize: 0, + topExt: "", + maxBitrate: 0, + }; + } else if (!raw) { + // Defensive recovery if a late batch races raw-list + // pruning. Keep the existing scalar summary intact. + raw = { files: [], exts: new Map() }; + searchFiles.set(key, raw); + } + if (raw.files.length < 500) { + raw.files.push(f); + g.fileCount += 1; + g.totalSize += f.size; + if (AUDIO_EXTS.has(f.extension)) g.audioCount += 1; + if (f.bitrate && f.bitrate > g.maxBitrate) g.maxBitrate = f.bitrate; + const n = (raw.exts.get(f.extension) ?? 0) + 1; + raw.exts.set(f.extension, n); + if (n >= (raw.exts.get(g.topExt) ?? 0)) g.topExt = f.extension; + } + } + } + if (matched > 0) search.responses += matched; + }); +} + +export async function connect(username, password, remember) { + app.connection = "connecting"; + app.loginError = ""; + try { + await api.connectAndLogin(username, password, remember); + app.connection = "online"; + app.username = username; + app.showLogin = false; + } catch (e) { + app.connection = "offline"; + app.loginError = String(e); + } +} + +export async function autoConnect() { + // A reloaded WebView boots against a backend that may ALREADY be logged + // in; connect_stored then fails ("already connected") and the catch below + // used to stamp the page offline — the first play attempt raised the + // login modal against a healthy session. Adopt the live session instead. + try { + const status = await api.appStatus(); + if (status?.connected_as) { + app.connection = "online"; + app.username = status.connected_as; + app.showLogin = false; + return; + } + } catch { + // status unavailable — fall through to the normal connect path + } + try { + const creds = await api.getCredentials(); + if (!creds.has_password) return; + app.connection = "connecting"; + await api.connectStored(); + app.connection = "online"; + app.username = creds.username; + } catch { + // One more status probe before declaring offline: the connect may + // have lost a race with an existing session rather than failed. + try { + const status = await api.appStatus(); + if (status?.connected_as) { + app.connection = "online"; + app.username = status.connected_as; + return; + } + } catch {} + app.connection = "offline"; + } +} + +export async function downloadFile(username, file) { + await api.downloadFile(username, file.path, file.size); +} + +/** Stream a search-result file: play while it downloads to cache. */ +export async function streamFile(username, file, subLabel) { + autoAlbum = null; + player.queue = [ + { + path: file.path, + title: file.name.replace(/\.[^.]+$/, ""), + sub: subLabel ?? username, + duration: file.duration ?? null, + art: null, + artUrl: null, + stream: { username, size: file.size }, + quality: qualityOf(file), + }, + ]; + player.catalogRef = { albumId: null, trackId: null }; + playQueueIndex(0); +} + +/** Play a finished download from disk (single track). */ +export async function playLocalFile(path, title, subLabel, artPath = null) { + autoAlbum = null; + player.queue = [{ path, title, sub: subLabel ?? "", duration: null, art: artPath, quality: localQuality({ path }) }]; + player.queueIndex = 0; + player.track = { title, sub: subLabel ?? "", source: "local", path }; + player.status = "buffering"; + player.duration = null; + player.streamTotal = 0; + loadWaveform(path); + setAccentFromArt(artPath); + await api.playLocal(path, null); +} + +/** Play an album/playlist (list of library tracks) starting at `start`. + * `artPath` is the album cover; playlist tracks carry their own art_path. */ +export function playAlbum(tracks, subLabel, start = 0, artPath = null) { + autoAlbum = null; + player.queue = tracks.map((t) => ({ + path: t.path, + title: t.title, + sub: subLabel, + artist: t.artist ?? null, + duration: t.duration_secs ?? null, + art: t.art_path ?? artPath, + quality: localQuality(t), + })); + player.catalogRef = { albumId: null, trackId: null }; + playQueueIndex(start); +} + +function playQueueIndex(i) { + const item = player.queue[i]; + if (!item) return; + player.queueIndex = i; + player.track = { + title: item.title, + sub: item.sub, + // Explicit artist when the queue builder knew it; the play-history + // tracker falls back to parsing "artist — album" out of sub. + artist: item.artist ?? null, + source: item.stream ? "stream" : "local", + path: item.path, + }; + player.status = "buffering"; + player.duration = item.duration; + if (item.stream) { + // Remote track streamed from a peer while it downloads to cache. + player.streamReceived = 0; + player.streamTotal = item.stream.size; + if ("trackId" in item) player.catalogRef.trackId = item.trackId; + appendedNextPath = null; + setAccentFromArtUrl(item.artUrl ?? null); + loadStreamWaveform(item); // instant when already fully cached + api.playStream(item.stream.username, item.path, item.stream.size, item.duration ?? null) + .then(() => { + // Ordered behind play_stream so the gapless append can't + // race the generation bump on the audio thread. + preloadNextStream(); + tryAppendNext(); + }) + .catch((e) => { + player.error = String(e); + }); + maybeExtendAlbumQueue(); + return; + } + player.streamTotal = 0; + loadWaveform(item.path); + setAccentFromArt(item.art ?? null); + api.playLocal(item.path, item.duration).catch((e) => { + player.error = String(e); + }); + preloadFollowingTrack(); +} + +/** Play a specific queue position (Now Playing panel track clicks). */ +export function jumpToQueueIndex(i) { + if (i < 0 || i >= player.queue.length || i === player.queueIndex) return; + player.position = 0; + playQueueIndex(i); +} + +function gaplessNextIndex() { + if (player.shuffle) return -1; + if (player.repeat === "one") return player.queueIndex; + const next = player.queueIndex + 1; + return next < player.queue.length ? next : player.repeat === "all" ? 0 : -1; +} + +function preloadFollowingTrack() { + const next = gaplessNextIndex(); + if (next < 0) return; + const item = player.queue[next]; + if (item.stream) return; // gapless preload is local-only + api.preloadLocal(item.path, item.duration).catch(() => {}); +} + +// Quietly pre-buffer the next queued stream into the cache. The backend +// no-ops when it's already cached or being fetched; single-slot peers just +// queue the request until the current transfer finishes. +let lastPreloadedPath = null; + +function preloadNextStream() { + const next = gaplessNextIndex(); + const item = next >= 0 ? player.queue[next] : null; + if (!item?.stream || item.path === lastPreloadedPath) return; + lastPreloadedPath = item.path; + api.preloadStream(item.stream.username, item.path, item.stream.size).catch(() => {}); +} + +// Once the next queued stream is fully cached, append it to the active +// audio sink so the boundary is gapless (same mechanism as local albums). +// Keyed per target path so it fires once per upcoming transition. +let appendedNextPath = null; + +async function tryAppendNext() { + const next = gaplessNextIndex(); + const item = next >= 0 ? player.queue[next] : null; + if (!item?.stream || item.path === appendedNextPath) return; + if (player.status === "idle") return; + try { + const ok = await api.preloadCachedStream( + item.stream.username, + item.path, + item.stream.size, + item.duration ?? null, + ); + if (ok) appendedNextPath = item.path; + } catch { + // cache incomplete or command unavailable — the normal (non-gapless) + // advance still works. + } +} + +function advanceGaplessly() { + const next = gaplessNextIndex(); + // The backend only labels a boundary gapless when it has this source + // ready in the same sink. If state changed meanwhile, fall back safely. + if (next < 0) return nextTrack(true); + const item = player.queue[next]; + player.queueIndex = next; + player.position = 0; + player.duration = item.duration; + latestPlayerPosition = 0; + latestPlayerDuration = item.duration ?? null; + latestStreamReceived = 0; + latestStreamTotal = 0; + player.status = "playing"; + appendedNextPath = null; + if (item.stream) { + // A pre-buffered stream track, playing from its completed cache + // file — no transfer to report. + player.track = { title: item.title, sub: item.sub, source: "stream", path: item.path }; + player.streamReceived = 0; + player.streamTotal = 0; + if ("trackId" in item) player.catalogRef.trackId = item.trackId; + setAccentFromArtUrl(item.artUrl ?? null); + loadStreamWaveform(item); + preloadNextStream(); + tryAppendNext(); + maybeExtendAlbumQueue(); + return; + } + player.track = { title: item.title, sub: item.sub, source: "local", path: item.path }; + loadWaveform(item.path); + setAccentFromArt(item.art ?? null); + preloadFollowingTrack(); +} + +// ---- album continuation ---- +// A single catalog song remembers its album: when it starts playing, the +// album's next track is resolved (background Soulseek search) and appended +// to the queue, one track per track start, so the album keeps going. +let autoAlbum = null; // { albumId, albumTitle, cover, tracks, pending } + +async function maybeExtendAlbumQueue() { + const ctx = autoAlbum; + if (!ctx || ctx.pending) return; + if (player.queueIndex !== player.queue.length - 1) return; // next already queued + const cur = player.queue[player.queueIndex]; + if (!cur?.stream) return; + ctx.pending = true; + try { + if (!ctx.tracks) { + const page = await api.catalogAlbum(ctx.albumId); + if (autoAlbum !== ctx) return; + ctx.tracks = page.tracks; + ctx.albumTitle ??= page.album.title ?? null; + ctx.cover ??= page.album.cover ?? null; + } + let idx = ctx.tracks.findIndex((t) => t.id === cur.trackId); + if (idx < 0) idx = ctx.tracks.findIndex((t) => normText(t.title) === normText(cur.title)); + if (idx < 0) { + autoAlbum = null; + return; + } + for (let i = idx + 1; i < ctx.tracks.length; i++) { + const t = ctx.tracks[i]; + if (app.connection !== "online") return; + // Same sanitized ladder as resolve(), inlined because this is a + // background append that must not toggle resolver.active (that + // would block user-triggered resolutions while it searches). + const goodCount = (rs) => + songCandidates(rs, t.title, true, ctx.albumTitle, t.duration_secs, t.artist).length; + const primary = sanitizeSearchQuery(`${t.artist} ${simplifyTitle(t.title)}`); + const fallback = sanitizeSearchQuery(simplifyTitle(t.title)); + let artistIncluded = true; + let responses = primary ? await collectSources(primary, 7000, goodCount) : []; + if (!responses.length && fallback && fallback !== primary) { + responses = await collectSources(fallback, 7000, goodCount); + artistIncluded = normText(fallback).includes(normText(t.artist ?? "")); + } else if (primary) { + artistIncluded = normText(primary).includes(normText(t.artist ?? "")); + } + // Bail if the user moved on while we were searching. + if (autoAlbum !== ctx || player.queue[player.queueIndex] !== cur) return; + const best = songCandidates( + responses, t.title, true, ctx.albumTitle, t.duration_secs, t.artist, !artistIncluded, + )[0]; + if (!best) continue; // no streamable source — skip this track + player.queue.push({ + path: best.file.path, + title: t.title, + sub: t.artist, + artist: t.artist ?? null, + album: ctx.albumTitle ?? null, + duration: best.file.duration ?? t.duration_secs ?? null, + art: null, + artUrl: ctx.cover, + trackId: t.id, + stream: { username: best.username, size: best.file.size }, + quality: qualityOf(best.file), + }); + preloadNextStream(); + tryAppendNext(); + return; + } + autoAlbum = null; // album finished + } catch { + // transient (offline, catalog hiccup) — retried on the next track start + } finally { + ctx.pending = false; + } +} + +// ---- waveform ---- + +const WAVE_BUCKETS = 400; +let waveReqPath = null; +// Throttle for progressive stream-waveform recomputes (see soulseek:stream). +let waveRefreshAt = 0; + +async function loadWaveform(path) { + player.waveform = []; + waveReqPath = path; + if (!path) return; + try { + const peaks = await api.waveform(path, WAVE_BUCKETS); + // Ignore if the track changed while we were computing. + if (waveReqPath === path) player.waveform = peaks; + } catch { + if (waveReqPath === path) player.waveform = []; + } +} + +/** Waveform for a streamed queue item, computed from its cache file. Empty + * (plain progress bar) until the download completes; the stream-done event + * calls this again to swap the full waveform in mid-track. */ +let waveSeq = 0; +let streamWaveInFlight = false; +let queuedStreamWave = null; + +async function loadStreamWaveform(item, final = false) { + const key = `stream:${item.stream.username}|${item.path}`; + if (waveReqPath !== key) player.waveform = []; + waveReqPath = key; + // A late partial FLAC decode can take close to the three-second refresh + // interval. Coalesce all overlap into the newest request, retaining a + // final completion request so the full-quality waveform always wins. + if (streamWaveInFlight) { + const preserveFinal = queuedStreamWave?.key === key && queuedStreamWave.final; + queuedStreamWave = { item, key, final: final || preserveFinal }; + return; + } + streamWaveInFlight = true; + let next = { item, key, final }; + try { + while (next) { + const request = next; + next = null; + const seq = ++waveSeq; + try { + const peaks = await api.streamWaveform( + request.item.stream.username, + request.item.path, + request.item.stream.size, + WAVE_BUCKETS, + ); + if ( + waveReqPath === request.key + && seq === waveSeq + && peaks.length > 0 + ) { + player.waveform = peaks; + } + } catch { + // stays empty — the buffer/progress bar renders instead + } + const queued = queuedStreamWave; + queuedStreamWave = null; + // Only the newest still-visible track matters. A queued final call + // is never dropped; the backend independently confirms completion. + if (queued && waveReqPath === queued.key) next = queued; + } + } finally { + streamWaveInFlight = false; + } +} + +// ---- queue navigation, shuffle, repeat ---- + +// Indices already played this shuffle cycle (module-private). +let shuffleSeen = new Set(); + +function pickShuffleIndex(reset = false) { + const n = player.queue.length; + if (reset) shuffleSeen = new Set(); + const remaining = []; + for (let i = 0; i < n; i++) if (!shuffleSeen.has(i)) remaining.push(i); + if (remaining.length === 0) return -1; + return remaining[Math.floor(Math.random() * remaining.length)]; +} + +export function nextTrack(auto = false) { + const n = player.queue.length; + if (n === 0) { + stopPlayback(); + return; + } + // Auto-advance on a repeat-one track replays it. + if (auto && player.repeat === "one") { + playQueueIndex(player.queueIndex); + return; + } + + let next; + if (player.shuffle) { + shuffleSeen.add(player.queueIndex); + next = pickShuffleIndex(); + if (next < 0) { + if (player.repeat === "all") next = pickShuffleIndex(true); + else return stopPlayback(); + } + } else { + next = player.queueIndex + 1; + if (next >= n) { + if (player.repeat === "all") next = 0; + else return stopPlayback(); + } + } + playQueueIndex(next); +} + +export function prevTrack() { + // Restart the current track if we're more than 3s in. + if (player.position > 3) { + api.playerControl("seek", 0); + return; + } + const prev = player.queueIndex - 1; + if (prev >= 0) playQueueIndex(prev); + else api.playerControl("seek", 0); +} + +export function toggleShuffle() { + player.shuffle = !player.shuffle; + shuffleSeen = new Set([player.queueIndex]); +} + +export function cycleRepeat() { + player.repeat = + player.repeat === "off" ? "all" : player.repeat === "all" ? "one" : "off"; +} + +function stopPlayback() { + player.status = "idle"; + player.track = null; + player.position = 0; + player.duration = null; + player.queue = []; + player.queueIndex = -1; + player.waveform = []; + player.catalogRef = { albumId: null, trackId: null }; + latestPlayerPosition = 0; + latestPlayerDuration = null; + latestStreamReceived = 0; + latestStreamTotal = 0; + autoAlbum = null; + appendedNextPath = null; + lastPreloadedPath = null; + setAccentFromArt(null); +} + +export async function downloadFolder(group) { + for (const f of filesForGroup(group.key)) { + await api.downloadFile(group.username, f.path, f.size); + } +} + +// Dev-only handle to THIS module instance for CDP-driven verification — +// a dynamic import from the console can resolve to a second instance when +// Vite has HMR-stamped the app's own import URL. +if (import.meta.env.DEV && typeof window !== "undefined") { + window.__state = { + app, player, resolver, catalog, ui, discovery, downloads, libraryStore, + streamSong, streamCatalogAlbum, autoConnect, nextTrack, prevTrack, loadDiscovery, + saveStream, refreshLibrary, openCatalogArtist, openCatalogAlbum, + playAlbum, jumpToQueueIndex, toggleNowPlaying, + artBroken, markArtBroken, + coverUpgrades, resolveCovers, playerArtSource, libraryHasAlbum, + }; +} + +export async function runSearch(query) { + const request = ++searchRequest; + clearTimeout(searchStopTimer); + const previousToken = search.token; + // Invalidate routing before awaiting the new backend token, otherwise + // late batches from the previous search can populate the freshly-cleared + // result set during this gap. + search.token = null; + if (previousToken != null) { + api.stopSearch(previousToken).catch(() => {}); + } + search.query = query; + search.groups = {}; + searchFiles.clear(); + search.responses = 0; + search.searching = true; + const token = await api.startSearch(query); + if (request !== searchRequest) { + api.stopSearch(token).catch(() => {}); + return; + } + search.token = token; + // Soulseek searches trickle in; stop routing after 45s. + searchStopTimer = setTimeout(() => { + if (search.token === token) { + search.searching = false; + // The UI exposes only the top 120 groups. Keep their full file + // lists and discard invisible raw payloads after routing closes; + // scalar summaries remain available for the result count. + trimHiddenSearchFiles(); + api.stopSearch(token).catch(() => {}); + } + }, 45_000); +} + +// Dev-only: expose the LIVE module instance for the CDP verification loop. +// Vite's HMR graph can serve a probe's bare-URL dynamic import as a SECOND +// instance of this module (?t=-stamped vs bare), so a probe that imports by +// URL may read/mutate a phantom copy while the app runs another. Probes must +// use this global instead of importing. +if (import.meta.env.DEV) { + globalThis.__soulstream = { + app, ui, player, catalog, resolver, + streamCatalogAlbum, streamSong, openCatalogArtist, + autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs, + }; +} diff --git a/src/lib/trace.js b/src/lib/trace.js new file mode 100644 index 0000000..6a01341 --- /dev/null +++ b/src/lib/trace.js @@ -0,0 +1,338 @@ +// Silhouette tracer: anti-aliased alpha raster -> SVG path. No dependencies. +// +// Fanart's ClearLOGOs are a fixed 800x310 canvas, so a logo whose ink is short +// (Weezer's is only 131px tall) cannot be enlarged without visibly blurring. We +// only ever paint these as a flat accent-coloured mask, so the colour data is +// irrelevant -- all we need is the ALPHA SILHOUETTE, and that traces cleanly to +// vector: crisp at any size, and free of the source's resolution ceiling. +// +// Marching squares with INTERPOLATION is what makes this work. Thresholding +// alpha to a hard in/out mask and walking pixel edges would yield a 1px +// staircase that no amount of smoothing fully hides (and whose 90-degree steps +// are indistinguishable from real corners in a letterform). Instead we treat +// alpha as a scalar field and place each vertex where it actually crosses 50%, +// which recovers sub-pixel edge positions from the source's anti-aliasing -- +// the curve of an "o" comes out smooth because the anti-aliased pixels told us +// where the true edge lay. + +const ISO = 127.5; // the 50%-coverage isoline we trace + +// --- separating the ink from a baked-in drop shadow ------------------------- +// Many ClearLOGOs paint a drop shadow into the artwork itself, and it is often +// FULLY OPAQUE (Weezer's is ~5k pixels at alpha 255), so tracing the alpha +// channel picks the shadow up as if it were part of the letterform and the +// glyphs come out bloated and tailed. Alpha cannot separate them at any +// threshold -- but colour can: we only ever mask logos that scored as +// monochrome, so the ink is one colour and the shadow is a different (darker) +// one. Model each pixel as a linear blend of ink C and shadow S and keep the +// ink's share; that also un-blends the anti-aliased pixels along the boundary, +// which is what keeps the traced edge sub-pixel accurate. + +const LUM = (r, g, b) => r * 0.299 + g * 0.587 + b * 0.114; + +/** Mean colour of the dominant 3-bit bucket among solid pixels: the ink. */ +function inkColor(px, w, h) { + const buckets = new Map(); + for (let p = 0; p < w * h; p++) { + const i = p * 4; + if (px[i + 3] < 200) continue; + const key = ((px[i] >> 5) << 6) | ((px[i + 1] >> 5) << 3) | (px[i + 2] >> 5); + const b = buckets.get(key); + if (b) { + b[0] += px[i]; + b[1] += px[i + 1]; + b[2] += px[i + 2]; + b[3]++; + } else buckets.set(key, [px[i], px[i + 1], px[i + 2], 1]); + } + let top = null; + for (const b of buckets.values()) if (!top || b[3] > top[3]) top = b; + if (!top) return null; + return [top[0] / top[3], top[1] / top[3], top[2] / top[3]]; +} + +/** + * Per-pixel ink coverage in 0..255 -- the field we actually trace. + * + * With no shadow present this is just the alpha channel, so clean logos trace + * exactly as before. When a dark opaque population IS found, each pixel is + * un-blended against the ink/shadow axis so only the letterform survives. + */ +function inkField(px, w, h) { + const n = w * h; + const field = new Float32Array(n); + const C = inkColor(px, w, h); + if (!C) return field; + + // The un-blend below models the artwork as exactly two colours, but a drop + // shadow is a soft GRADIENT. Where a letter edge sits over its own shadow + // the model is slightly off, and the 50% isoline jitters by a fraction of a + // pixel -- which reads as a serrated edge once the logo is scaled up. A mild + // blur of the coverage field removes that high-frequency jitter while + // leaving a straight edge exactly where it was. + const soften = () => { + const tmp = new Float32Array(n); + const at = (x, y) => field[Math.min(h - 1, Math.max(0, y)) * w + Math.min(w - 1, Math.max(0, x))]; + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) + tmp[y * w + x] = (at(x - 1, y) + 2 * at(x, y) + at(x + 1, y)) / 4; + const at2 = (x, y) => tmp[Math.min(h - 1, Math.max(0, y)) * w + x]; + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) + field[y * w + x] = (at2(x, y - 1) + 2 * at2(x, y) + at2(x, y + 1)) / 4; + }; + + // Is there a shadow? Look for solid pixels markedly darker than the ink. + const darkCut = LUM(C[0], C[1], C[2]) - 60; + let sr = 0, sg = 0, sb = 0, dark = 0, solid = 0; + for (let p = 0; p < n; p++) { + const i = p * 4; + if (px[i + 3] < 200) continue; + solid++; + if (LUM(px[i], px[i + 1], px[i + 2]) < darkCut) { + sr += px[i]; + sg += px[i + 1]; + sb += px[i + 2]; + dark++; + } + } + + // Ink darker than its own shadow would be, or barely any dark pixels: no + // shadow to strip, so fall back to plain alpha (previous behaviour). + const S = dark ? [sr / dark, sg / dark, sb / dark] : null; + const dx = S ? C[0] - S[0] : 0; + const dy = S ? C[1] - S[1] : 0; + const dz = S ? C[2] - S[2] : 0; + const len2 = dx * dx + dy * dy + dz * dz; + if (!S || dark / solid < 0.02 || len2 < 900) { + for (let p = 0; p < n; p++) field[p] = px[p * 4 + 3]; + return field; + } + + // coverage = how far along the shadow->ink axis this pixel sits, times its + // own opacity. Pure ink -> 1, pure shadow -> 0, anti-aliased edge -> the + // true fractional coverage. + for (let p = 0; p < n; p++) { + const i = p * 4; + const a = px[i + 3]; + if (!a) continue; + const t = + ((px[i] - S[0]) * dx + (px[i + 1] - S[1]) * dy + (px[i + 2] - S[2]) * dz) / + len2; + field[p] = a * Math.max(0, Math.min(1, t)); + } + soften(); // only on the shadow path -- clean logos need no smoothing + return field; +} + +// Marching-squares cases -> contour segments, as [fromEdge, toEdge] pairs. +// Corner bits: 1 top-left, 2 top-right, 4 bottom-right, 8 bottom-left. +// Edges: 0 top, 1 right, 2 bottom, 3 left. Every segment is directed so the ink +// lies to its LEFT, which makes all loops close with consistent winding. +const CASES = [ + [], // 0 .... + [[3, 0]], // 1 tl + [[0, 1]], // 2 tr + [[3, 1]], // 3 tl tr + [[1, 2]], // 4 br + null, // 5 tl br -- saddle, resolved by the cell centre + [[0, 2]], // 6 tr br + [[3, 2]], // 7 tl tr br + [[2, 3]], // 8 bl + [[2, 0]], // 9 tl bl + null, // 10 tr bl -- saddle + [[2, 1]], // 11 tl tr bl + [[1, 3]], // 12 br bl + [[1, 0]], // 13 tl br bl + [[0, 3]], // 14 tr br bl + [], // 15 .... +]; + +/** + * Trace an ink-coverage field into closed sub-pixel contours. + * Returns loops of [x, y] points in source-pixel coordinates. + */ +function contours(cov, w, h) { + // Padded by one empty pixel, so ink touching the image border still closes + // into a loop instead of running off the edge. + const W = w + 2; + const H = h + 2; + const a = new Float32Array(W * H); + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) a[(y + 1) * W + (x + 1)] = cov[y * w + x]; + + const at = (x, y) => a[y * W + x]; + + // Where along a cell edge does alpha cross ISO? This is the whole trick: + // the fractional position recovers the true edge from anti-aliased pixels. + const lerp = (v0, v1) => { + const d = v1 - v0; + return Math.abs(d) < 1e-6 ? 0.5 : (ISO - v0) / d; + }; + + // Endpoint of `edge` for the cell whose top-left corner is (x, y). + const point = (x, y, edge) => { + const tl = at(x, y); + const tr = at(x + 1, y); + const br = at(x + 1, y + 1); + const bl = at(x, y + 1); + switch (edge) { + case 0: return [x + lerp(tl, tr), y]; + case 1: return [x + 1, y + lerp(tr, br)]; + case 2: return [x + lerp(bl, br), y + 1]; + default: return [x, y + lerp(tl, bl)]; + } + }; + + // Chain segments by their endpoints. Two cells sharing an edge compute that + // crossing from the same pair of corner values, so the coordinates are + // bit-identical and hash to the same key -- no tolerance needed. + const key = ([x, y]) => `${x.toFixed(4)},${y.toFixed(4)}`; + const next = new Map(); // start key -> {to, toKey} + + for (let y = 0; y < H - 1; y++) { + for (let x = 0; x < W - 1; x++) { + const tl = at(x, y) >= ISO ? 1 : 0; + const tr = at(x + 1, y) >= ISO ? 2 : 0; + const br = at(x + 1, y + 1) >= ISO ? 4 : 0; + const bl = at(x, y + 1) >= ISO ? 8 : 0; + const c = tl | tr | br | bl; + let segs = CASES[c]; + if (segs && !segs.length) continue; + + if (!segs) { + // Saddle: the cell centre decides whether the two ink corners + // join through the middle or stay separate blobs. + const mid = + (at(x, y) + at(x + 1, y) + at(x + 1, y + 1) + at(x, y + 1)) / 4; + const joined = mid >= ISO; + if (c === 5) + segs = joined ? [[1, 0], [3, 2]] : [[3, 0], [1, 2]]; + else segs = joined ? [[0, 3], [2, 1]] : [[0, 1], [2, 3]]; + } + + for (const [from, to] of segs) { + const p0 = point(x, y, from); + const p1 = point(x, y, to); + next.set(key(p0), { to: p1, toKey: key(p1) }); + } + } + } + + const loops = []; + const seen = new Set(); + for (const start of next.keys()) { + if (seen.has(start)) continue; + const loop = []; + let k = start; + while (!seen.has(k)) { + const step = next.get(k); + if (!step) break; // open contour (shouldn't happen once padded) + seen.add(k); + loop.push(step.to); + k = step.toKey; + } + if (loop.length >= 3) loops.push(loop); + } + // Undo the 1px pad so coordinates are back in source-image space. + return loops.map((l) => l.map(([x, y]) => [x - 1, y - 1])); +} + +/** Ramer-Douglas-Peucker: drop vertices that stay within `eps` of the chord. */ +function simplify(pts, eps) { + if (pts.length < 3) return pts; + const keep = new Uint8Array(pts.length); + keep[0] = keep[pts.length - 1] = 1; + const stack = [[0, pts.length - 1]]; + while (stack.length) { + const [i, j] = stack.pop(); + const [x1, y1] = pts[i]; + const [x2, y2] = pts[j]; + const dx = x2 - x1; + const dy = y2 - y1; + const len = Math.hypot(dx, dy) || 1; + let far = -1; + let best = eps; + for (let k = i + 1; k < j; k++) { + const [px, py] = pts[k]; + const d = Math.abs(dy * px - dx * py + x2 * y1 - y2 * x1) / len; + if (d > best) { + best = d; + far = k; + } + } + if (far > 0) { + keep[far] = 1; + stack.push([i, far], [far, j]); + } + } + return pts.filter((_, i) => keep[i]); +} + +/** Signed area -- used to discard specks by size rather than point count. */ +function area(pts) { + let s = 0; + for (let i = 0, n = pts.length; i < n; i++) { + const [x1, y1] = pts[i]; + const [x2, y2] = pts[(i + 1) % n]; + s += x1 * y2 - x2 * y1; + } + return Math.abs(s) / 2; +} + +/** + * Trace RGBA pixels into an SVG whose viewBox is exactly the ink's bounding box. + * + * Because the viewBox hugs the ink, this needs none of the sprite-cropping the + * raster path required: `mask: url(...) center / contain` fills its box, and + * `aspect` is the artwork's true aspect ratio. + * + * @returns {{svg:string, d:string, aspect:number, ink:{w:number,h:number}}|null} + */ +export function traceToSvg(px, w, h, opts = {}) { + const { + eps = 0.22, // simplify tolerance in source px; sub-pixel, so keep small + minArea = 6, // drop specks (source px^2) + } = opts; + + const loops = contours(inkField(px, w, h), w, h) + .filter((l) => area(l) >= minArea) + .map((l) => simplify(l, eps)); + if (!loops.length) return null; + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const l of loops) + for (const [x, y] of l) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + const bw = maxX - minX; + const bh = maxY - minY; + if (!(bw > 0 && bh > 0)) return null; + + const n = (v) => Math.round(v * 100) / 100; + const d = loops + .map( + (l) => + "M" + l.map(([x, y]) => `${n(x - minX)} ${n(y - minY)}`).join("L") + "Z", + ) + .join(""); + + // Filled black: a CSS mask consumes the SVG's alpha, so only coverage + // matters. evenodd keeps letter counters (the hole in an "o") open. + const svg = + `` + + ``; + + return { svg, d, aspect: bw / bh, ink: { w: bw, h: bh } }; +} + +/** SVG source -> a data: URL usable in `mask-image`. */ +export function svgDataUrl(svg) { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..b0d856a --- /dev/null +++ b/src/main.js @@ -0,0 +1,5 @@ +import "./styles/global.css"; +import { mount } from "svelte"; +import App from "./App.svelte"; + +mount(App, { target: document.getElementById("app") }); diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..b05e370 --- /dev/null +++ b/src/styles/global.css @@ -0,0 +1,191 @@ +/* SoulStream — Spotify-style dark theme */ + +/* Bundled fonts (same set as the TriSign app) */ +@font-face { + font-family: "Nunito"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("/assets/fonts/nunito-400.woff2") format("woff2"); +} +@font-face { + font-family: "Nunito"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("/assets/fonts/nunito-600.woff2") format("woff2"); +} +@font-face { + font-family: "Nunito"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("/assets/fonts/nunito-700.woff2") format("woff2"); +} +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("/assets/fonts/inter-400.woff2") format("woff2"); +} +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("/assets/fonts/inter-600.woff2") format("woff2"); +} +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("/assets/fonts/inter-700.woff2") format("woff2"); +} + +/* Typed accent registrations let scoped overrides (the artist page's + browse accent) CSS-transition; the global rAF crossfade is unaffected. */ +@property --accent { + syntax: ""; + inherits: true; + initial-value: #1f9ad6; +} +@property --accent-hover { + syntax: ""; + inherits: true; + initial-value: #41b7ee; +} + +:root { + --app-font: "Nunito", -apple-system, "Segoe UI", sans-serif; + --bg-base: #121212; + --bg-sidebar: #000000; + --bg-elevated: #181818; + --bg-highlight: #242424; + --bg-press: #2a2a2a; + --bg-player: #181818; + --text-primary: #ffffff; + --text-secondary: #b3b3b3; + --text-muted: #7a7a7a; + --accent: #1f9ad6; + --accent-hover: #41b7ee; + --accent-soft: color-mix(in srgb, var(--accent) 15%, transparent); + --border: #2a2a2a; + --radius: 8px; + --sidebar-width: 232px; + --player-height: 88px; + --nowplaying-width: 340px; +} + +/* Floating overlay scrollbar (see src/lib/scrollbar.js). The native bar is + hidden so it never reserves width or reflows content; a thin thumb floats + in the container's right padding, fading in on scroll/hover, out when idle. */ +.float-scroll { + scrollbar-width: none; + -ms-overflow-style: none; +} +.float-scroll::-webkit-scrollbar { + width: 0; + height: 0; + display: none; +} +/* Zero-height sticky rail: the compositor keeps it pinned to the scrollport + top, so the thumb inside never needs JS counter-scrolling (which jitters on + composited-scrolling panes — see scrollbar.js). In flex/grid scrollers its + row-gap contribution is cancelled by an inline negative margin-bottom. */ +.float-rail { + position: sticky; + top: 0; + height: 0; + z-index: 60; + pointer-events: none; +} +.float-thumb { + position: absolute; + top: 0; + right: 3px; /* refined inline: 3px inside the scroller's padding box */ + width: 5px; + border-radius: 2.5px; + /* Song-name text tone (--text-secondary), nudged a little lighter. */ + background: color-mix(in srgb, var(--text-secondary) 75%, #ffffff); + opacity: 0; + pointer-events: none; + /* Slides along the track via translateY (composited — no layout/paint), + so give it its own layer. Only opacity is animated. */ + will-change: transform; + transition: opacity 0.15s ease; +} +/* Order matters (equal specificity, last wins): a faint hint on hover, more + present while scrolling, solid while dragging. hover < show < dragging. */ +.float-thumb.hover { + opacity: 0.35; + pointer-events: auto; +} +.float-thumb.show { + opacity: 0.8; + pointer-events: auto; +} +.float-thumb.dragging { + opacity: 1; + pointer-events: auto; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body { + height: 100%; + overflow: hidden; + /* Kill WebView2's document-level elastic overscroll (it visibly shifts the + whole view). Scoped to the root + the real scroll panes below — NOT `*`: + applying it to every element turns each `overflow: hidden`/`auto` box + (cards, panels, popovers, the album expander) into a scroll container + that swallows the wheel instead of chaining it to the page, so hovering + one froze scrolling. Inner regions now default to `auto` and chain. */ + overscroll-behavior: none; + background: var(--bg-base); + color: var(--text-primary); + font-family: var(--app-font); + font-size: 14px; + -webkit-font-smoothing: antialiased; + user-select: none; +} + +#app { + height: 100%; +} + +button { + font: inherit; + color: inherit; + background: none; + border: none; + cursor: pointer; +} + +input { + font: inherit; +} + +::-webkit-scrollbar { + width: 12px; +} +::-webkit-scrollbar-thumb { + background: hsla(0, 0%, 100%, 0.3); + border-radius: 6px; + background-clip: padding-box; + border: 3px solid transparent; +} +::-webkit-scrollbar-thumb:hover { + background: hsla(0, 0%, 100%, 0.5); + background-clip: padding-box; + border: 3px solid transparent; +} +::-webkit-scrollbar-track { + background: transparent; +} diff --git a/src/views/CatalogAlbum.svelte b/src/views/CatalogAlbum.svelte new file mode 100644 index 0000000..06b0147 --- /dev/null +++ b/src/views/CatalogAlbum.svelte @@ -0,0 +1,380 @@ + + +
+ + + {#if resolver.active} +

● {resolver.label} — finding the best source…

+ {:else if resolver.error} +

{resolver.error}

+ {/if} + + {#if catalog.albumLoading} +

Loading album…

+ {:else if !page} +

{catalog.error || "Album not found."}

+ {:else} +
+ {#if album.cover && !artBroken[album.cover]} + handleCoverError(event.currentTarget)} /> + {:else} +
+ {/if} +
+

{(album.record_type ?? "album").toUpperCase()}

+

{album.title}

+

+ + {#if year(album)}· {year(album)}{/if} + {#if album.track_count}· {album.track_count} tracks{/if} + {#if inLibrary}· in your library{/if} +

+
+ + +
+
+
+ +
+ {#each page.tracks as t, i (t.id)} + {#if multiDisc && t.disk_number !== (page.tracks[i - 1]?.disk_number ?? (i === 0 ? null : 1))} +
Disc {t.disk_number ?? 1}
+ {/if} + {@const locked = t.unreleased} + {@const play = () => { if (!locked) playTrack(t); }} +
e.key === "Enter" && play()} + > + {nowPlaying("trackId", t.id) ? "♫" : (t.track_no ?? "·")} + {t.title} + {#if locked} + Unreleased + {:else} + {fmtDuration(t.duration_secs)} + {/if} + + +
+ {/each} +
+ {/if} +
+ + diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte new file mode 100644 index 0000000..9697e6f --- /dev/null +++ b/src/views/CatalogArtist.svelte @@ -0,0 +1,5567 @@ + + +
+ {#if catalog.artistLoading && !page} + + {#if resolver.active} +

● {resolver.label} — finding the best source…

+ {:else if resolver.error} +

{resolver.error}

+ {/if} +

Loading artist…

+ {:else if !page} + + {#if resolver.error} +

{resolver.error}

+ {/if} +

{catalog.error || "Artist not found."}

+ {:else} + +
+ + +
+ + + + + +
+
+
+ +
+ {#if page.artist.name} +

{page.artist.name}

+ {:else} +
+ {/if} + {#if logo.mode === "mask"} +
+ +
+ {:else if logo.mode === "color"} +
+ +
+ {/if} +
+
+ {#if popularTracks.length} +
+ + + + + {#if !page.art_preview} +
+ {#if releasedCount > 0} + {releasedCount} releases + {/if} + {#if page.artist.fans} + {fmtFans(page.artist.fans)} + {/if} + {#if activeYears} + {activeYears} + {/if} + {#if libraryOwnedCount > 0 && releasedCount > 0} + + {libraryOwnedCount}/{releasedCount} + + + {/if} + {#if artistDisk.bytes > 0} + + {fmtBytes(artistDisk.bytes)} + {#if artistDisk.perAlbum.length} + + {#each artistDisk.perAlbum as row (row.title)} + + {row.title} + {fmtBytes(row.bytes)} + + {/each} + + {/if} + + {/if} + {#if artistDisk.quality} + + {artistDisk.quality} + + {/if} + {#if playStats.seconds >= 60} + + {fmtPlayTime(playStats.seconds)} + + {/if} + {#if playStats.last} + + {fmtLastPlayed(playStats.last)} + + {/if} + {#if playStats.topTitle && playStats.topCount >= 2} + + {playStats.topTitle} · {playStats.topCount}× + + {/if} + {#if page.top_genre} + + {page.top_genre} + + {/if} + + + {#if page.socials?.length} + + {#each page.socials as link (link.kind)} + {@const meta = SOCIAL_META[link.kind] ?? SOCIAL_META.website} + + {/each} + {/if} +
+ {/if} +
+ {/if} +
+
+ + +
+ + {#if resolver.active} + ● {resolver.label} — finding the best source… + {:else if resolver.error} + {resolver.error} + {/if} +
+ {#if heroNotice} + {heroNotice} + {/if} + + {#if page.banner_custom} + + {/if} +
+
+ + +
+ + +
+ {#if page.partial_error} +

{page.partial_error}

+ {:else if page.art_preview} +

● + {page.albums.length || popularTracks.length + ? "Refining release details…" + : "Loading songs and releases…"} +

+ {/if} + + {#if page.art_preview && !popularTracks.length && !page.albums.length} +
+
+
+ {#each [0, 1, 2, 3, 4] as row (row)} +
+ + + +
+ {/each} +
+
+
+ {#each [0, 1, 2, 3, 4] as card (card)} + + {/each} +
+
+ {/if} + + {#if popularTracks.length} + + {/if} + + {#if tabs.length} +
+

Discography

+
+
+ {#each tabs as tab (tab.key)} + + {/each} +
+ {/if} + + {#each visibleTabs as group (group.key)} +
+ {#each group.items as al, cardIndex (al.familyKey)} +
toggleExpander(al)} + onkeydown={(e) => e.key === "Enter" && toggleExpander(al)} + onmouseenter={() => { + queueCardAccent(al.cover, true); + queueDescPrefetch(al); + }} + onmouseleave={cancelDescPrefetch} + > +
+ {#if familyNowPlaying(al)} + + Playing + + {/if} + {#if al.editions.length > 1} + + {al.editions.length} + + {/if} + {#if al.cover && !artBroken[al.cover]} +
+ {#if tinyCover(al.cover)} + + {/if} + handleCoverError(event.currentTarget, al.cover, al.title)} + /> +
+ {:else} +
+ {/if} + {#if group.key !== "upcoming"} + + {/if} +
+
+ {al.title} + {#if familyInLibrary(al)}{/if} +
+
+ {group.key === "upcoming" ? upcomingDate(al) : year(al)}{al.track_count ? ` · ${al.track_count} tracks` : ""} +
+
+ {#if expanderIndex(group) === cardIndex} +
+ + {#key openFamilyKey} +
+
+
+ {#if previousRailArtAlbum?.cover && !artBroken[previousRailArtAlbum.cover]} + + {/if} + {#key railArtTransitionKey} + {#if railArtAlbum?.cover && !artBroken[railArtAlbum.cover]} + + {/if} + {/key} +
+
+ {#if previousRailArtAlbum?.cover && !artBroken[previousRailArtAlbum.cover]} + + {/if} + {#key railArtTransitionKey} + {#if railArtAlbum?.cover && !artBroken[railArtAlbum.cover]} + handleCoverError(event.currentTarget, railArtAlbum.cover, railArtAlbum.title)} /> + {:else} +
+ {/if} + {/key} +
+

{openAlbum?.title ?? openFamily?.baseTitle ?? ""}

+

+ {group.key === "upcoming" ? upcomingDate(openAlbum ?? openFamily) : fullDate(openAlbum ?? openFamily)}{openTracks.length + ? ` · ${openTracks.length} tracks` + : openAlbum?.track_count + ? ` · ${openAlbum.track_count} tracks` + : ""}{fmtRuntime(openTracks) ? ` · ${fmtRuntime(openTracks)}` : ""}{openFamily && familyInLibrary(openFamily) ? " · in your library" : ""} +

+ {#if !openReleaseLocked} +
+ + +
+ {/if} + {#if openInfo?.description} +

{openInfo.description}

+ + {/if} + {#if openFamily && openFamily.editions.length > 1} +
+ Editions + {#each openFamily.editions as edition (edition.id)} + + {/each} +
+ {/if} +
+
+ {#if openLoading} +

Loading tracks…

+ {:else} + {#each openDiscs as d (d.disc)} + {#if multiDisc} +
Disc {d.disc} · {d.tracks.length} tracks
+ {/if} + {#each d.tracks as t (t.id)} + {@const locked = t.unreleased} + {@const playTrack = () => { + if (locked) return; + streamSong(page.artist.name, t.title, { + cover: t.cover ?? openAlbum?.cover, + trackId: t.id, + albumId: openAlbumId, + album: openAlbum?.title || null, + expectSecs: t.duration_secs ?? null, + }); + }} +
e.key === "Enter" && playTrack()} + > + {nowPlaying("trackId", t.id) ? "♫" : (t.track_no ?? "·")} + + {t.title} + {#if libraryTicks.has(norm(t.title || ""))}{/if} + + {#if locked} + Unreleased + {:else} + {fmtDuration(t.duration_secs)} + {/if} + {#if !locked} + + + {/if} +
+ {/each} + {/each} + {/if} +
+
+
+ {/key} +
+ {/if} + {/each} +
+ {/each} +
+ {/if} +
+ + diff --git a/src/views/CatalogSearch.svelte b/src/views/CatalogSearch.svelte new file mode 100644 index 0000000..7649da7 --- /dev/null +++ b/src/views/CatalogSearch.svelte @@ -0,0 +1,485 @@ + + +
+ {#if catalog.loading} +

Searching the catalog for “{catalog.query}”…

+ {:else if catalog.error} +

Catalog search failed: {catalog.error}

+ {:else if !catalog.results} +

Search for an artist, album or song above.

+ {:else} + {#if resolver.active} +

● {resolver.label} — finding the best source…

+ {:else if resolver.error} +

{resolver.error}

+ {/if} +
+ {#if top} +
+

Top result

+ +
+ {/if} + {#if tracks.length > 0} +
+

Songs

+
+ {#each tracks as t (t.id)} +
playSong(t)} + onkeydown={(e) => e.key === "Enter" && playSong(t)} + > + {#if t.cover && !artBroken[t.cover]} + markArtBroken(t.cover)} /> + {:else} +
+ {/if} +
+
+ {#if nowPlaying("trackId", t.id)}♫{/if} + {t.title} +
+
{t.artist} · {t.album}
+
+ {fmtDuration(t.duration_secs)} + + +
+ {/each} +
+
+ {/if} +
+ + {#if artists.length > 0} +

Artists

+
+ {#each artists as a (a.id)} + + {/each} +
+ {/if} + + {#if albums.length > 0} +

Albums

+
+ {#each albums as al (al.id)} +
+
+ {#if nowPlaying("albumId", al.id)} + ♫ + {/if} + {#if al.cover && !artBroken[al.cover]} + markArtBroken(al.cover)} /> + {:else} +
+ {/if} +
+ + +
+
+
{al.title}
+
{albumMeta(al)}
+
+ {/each} +
+ {/if} + {/if} +
+ + diff --git a/src/views/Downloads.svelte b/src/views/Downloads.svelte new file mode 100644 index 0000000..c42d6c4 --- /dev/null +++ b/src/views/Downloads.svelte @@ -0,0 +1,308 @@ + + +
+

Downloads

+ + {#if network.shareFiles > 0} + + {/if} + + {#if uploads.length > 0} +

Uploads

+
+ {#each uploads as u (u.id)} +
+
+
{u.name}
+
+ to {u.username} · + + {u.state === "transferring" && u.total > 0 + ? `${((u.sent / u.total) * 100).toFixed(0)}%` + : u.state} + +
+ {#if u.state === "transferring"} +
+
+
+ {/if} +
+
+ {/each} +
+ {/if} + + {#if items.length === 0} + {#if uploads.length === 0} +
+

No downloads yet

+

Grab something from Search — files land in your library folder.

+
+ {/if} + {:else} + {#if active.length > 0} +

Active

+
+ {#each active as d (d.id)} +
+
+
{d.name}
+
{d.username} · {stateLabel(d)}
+
+
+
+
+ +
+ {/each} +
+ {/if} + + {#if finished.length > 0} +

Finished

+
+ {#each finished as d (d.id)} +
+
+
{d.name}
+
+ {d.username} · + + {stateLabel(d)} + +
+
+ {#if d.state === "complete" && d.local_path} + + + {/if} +
+ {/each} +
+ {/if} + {/if} +
+ + diff --git a/src/views/Home.svelte b/src/views/Home.svelte new file mode 100644 index 0000000..ec9a92f --- /dev/null +++ b/src/views/Home.svelte @@ -0,0 +1,91 @@ + + +
+

{greeting}

+ + {#if recent.length === 0} +
+

Welcome to SoulStream

+

+ Search the network, stream instantly, and downloaded albums + will show up here. +

+ +
+ {:else} +
+

Recently added

+
+ {#each recent as a (a.id)} + + {/each} +
+
+ {/if} +
+ + diff --git a/src/views/Library.svelte b/src/views/Library.svelte new file mode 100644 index 0000000..5abf15f --- /dev/null +++ b/src/views/Library.svelte @@ -0,0 +1,800 @@ + + +{#if !album} +
+

Artists

+ {#if libraryStore.albums.length === 0} +
+

Your music will live here

+

Download an album from Search and it lands here, tagged with art.

+
+ {:else} +

{artists.length} artists

+
+ {#each artists as artist (artist.name)} + + {/each} +
+ {@const selected = artists.find((a) => a.name === expandedArtist)} +
+ {#if selected} +
+

{selected.name}

{selected.albums.length} albums

+ +
+
+ {#each selected.albums as a (a.id)} + + {/each} +
+ {#if inlineAlbum && selected.albums.some((a) => a.id === inlineAlbum.id)} +
+
+
+

{inlineAlbum.title}

+

{inlineAlbum.track_count} tracks{inlineAlbum.year ? ` · ${inlineAlbum.year}` : ""}

+
+ +
+
+ {#each inlineTracks as t, i (t.id)} + + {/each} +
+
+ {/if} + {/if} +
+ {/if} +
+{:else} +
+ + +
+ {#if art} + + {:else} +
+ {/if} +
+

Album

+

{album.title}

+

+ {album.artist} + {album.year ? ` · ${album.year}` : ""} · {album.track_count} tracks +

+
+ +
+ + {#if showPlaylistMenu} + + {/if} +
+ +
+ {#if artError} +

{artError}

+ {/if} +
+
+ +
+ {#each tracks as t, i (t.id)} + + {/each} +
+
+{/if} + + diff --git a/src/views/Playlist.svelte b/src/views/Playlist.svelte new file mode 100644 index 0000000..f627cbe --- /dev/null +++ b/src/views/Playlist.svelte @@ -0,0 +1,234 @@ + + +{#if playlist} +
+
+
+
+

Playlist

+

{playlist.name}

+

{tracks.length} tracks

+
+ + +
+
+
+ + {#if tracks.length === 0} +

+ This playlist is empty. Open an album in your Library and use “Add to playlist”. +

+ {:else} +
+ {#each tracks as t (t.position)} +
+ + {fmtDuration(t.duration_secs)} + +
+ {/each} +
+ {/if} +
+{:else} +

Playlist not found.

+{/if} + + diff --git a/src/views/Search.svelte b/src/views/Search.svelte new file mode 100644 index 0000000..4471e0b --- /dev/null +++ b/src/views/Search.svelte @@ -0,0 +1,385 @@ + + + + + diff --git a/src/views/Settings.svelte b/src/views/Settings.svelte new file mode 100644 index 0000000..ffc9178 --- /dev/null +++ b/src/views/Settings.svelte @@ -0,0 +1,718 @@ + + +
+

Settings

+ + {#if !settings} +

Loading…

+ {:else} +
+

Account

+
+
+
Soulseek account
+
+ {settings.username + ? `Signed in as ${settings.username}` + : "Not signed in"} + {#if settings.connected} · online{/if} +
+
+
+ {#if app.connection !== "online"} + + {/if} + {#if settings.has_password} + + {/if} +
+
+

+ + Your password is stored in the Windows Credential Manager, not in a plain file. +

+
+ +
+

Playback

+
+
+
Audio output
+
System default follows Windows when headphones or speakers change.
+
+ +
+

Changes apply immediately and keep the current track near the same position.

+
+ +
+

Streaming

+
+
+
Keep streamed tracks
+
Streamed songs stay in a private cache for instant replay — not in your library. Choose Forever to add everything you stream to the library instead.
+
+ +
+
+
+
Cache size limit
+
Oldest streamed tracks are removed first once the cache passes this size. Saved and downloaded music is never touched.
+
+ +
+

Retention counts from the last time a track was played. Tracks you save stay in your library regardless.

+
+ +
+

Library & downloads

+
+
+
Library folder
+
{settings.download_dir}
+
+ +
+

This is your downloaded-music library and is always shared. Folder changes apply after reconnecting.

+
+
+
Album folder format
+
Use {"{artist}"}, {"{album}"}, {"{date}"}, or {"{year}"}; "\" starts a subfolder. Full dates display as DD-MM-YYYY.
+
+ e.key === "Enter" && e.currentTarget.blur()} + onchange={(e) => setLibraryLayout(e.currentTarget.value)} + /> +
+

New downloads are organized into this format; existing files are not moved.

+
+ +
+

Sharing

+

+ Sharing keeps you a good citizen — leechers get banned. Your download + folder is always shared ({settings.share_files} files indexed). + Add extra folders below. +

+ {#if settings.shared_folders.length > 0} +
+ {#each settings.shared_folders as folder (folder)} +
+ {folder} + +
+ {/each} +
+ {/if} + +
+ +
+

Album artwork

+

Covers are resolved from Deezer, Apple, and the Cover Art Archive; the artwork shown in the catalog wins, at the highest resolution any source serves. One canonical image is used for the library, the embedded tags, and the album folder.

+
+
+
Art inside downloaded files
+
Replace whatever picture the uploader embedded with the canonical cover, or keep theirs when present.
+
+ +
+
+
+
Embedded cover size
+
Longest edge of the copy written into each audio file. The full-resolution original is always kept in the album folder and art cache.
+
+ +
+
+
+
Album folder cover
+
Write the canonical cover as cover.jpg and folder.jpg so Explorer and other players show the same artwork.
+
+ +
+
+
+
Re-fetch all album covers
+
Upgrade every library album to the canonical high-resolution cover — cache, embedded tags, and folder files. Roughly one album per second.
+
+ +
+ {#if albumRefreshResult}

{albumRefreshResult}

{/if} +
+ +
+

Artist artwork

+
+
+
Hero renderer
+
Live GPU creates one native-4K master, then mip-filters it cleanly for every window size.
+
+ +
+

Live GPU applies crop ? EASU ? RCAS ? color/fade/grain once at 3840?2160. Resizing only re-samples that fixed master, preserving grain scale and avoiding baked-file latency.

+
+
+
Refresh artist portraits
+
Fetch portraits for artists already in your library.
+
+ +
+ {#if artistRefreshResult}

{artistRefreshResult}

{/if} +
+ +
+

Source quality

+

+ Independent preferences for streaming and downloading. The ceiling and floor + bound which peer files qualify; priority decides whether the resolver picks + the best format or the fastest transfer among them. +

+ {#each [ + { key: "stream", label: "Streaming" }, + { key: "download", label: "Downloads" }, + ] as mode (mode.key)} +
+
+
{mode.label} — maximum
+
Skip files above this ceiling (lossy-only avoids huge lossless transfers).
+
+ +
+
+
+
{mode.label} — minimum
+
Reject files below this floor. Lossy files must report a bitrate to qualify.
+
+ +
+
+
+
{mode.label} — priority
+
+ Fastest transfer estimates arrival time from file size ÷ peer speed + (plus queue wait) and picks whatever lands soonest — a large FLAC from a + fast peer can win. Only files inside your min/max window compete. +
+
+ +
+ {/each} +
+ +
+

Uploads

+
+
+
Upload slots
+
How many peers can download from you at once.
+
+
+ + {settings.upload_slots} + +
+
+
+ + {#if saving} +

Saving…

+ {:else if savedFlash} +

✓ Saved

+ {:else if saveError} +

{saveError}

+ {/if} + {/if} +
+ + diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..cd9110a --- /dev/null +++ b/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; + +export default defineConfig({ + plugins: [svelte()], + clearScreen: false, + server: { + port: 1421, + strictPort: true, + watch: { + // Never watch the Rust build dir: cargo locks files mid-compile, + // which crashes Vite's watcher (EBUSY) and kills tauri dev. + ignored: ["**/src-tauri/**", "**/crates/**", "**/target/**"], + }, + }, +});