From a667b4b28e370abd79238e51c580b586070cc7aa Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:57:00 -0500 Subject: [PATCH] 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. --- .gitignore | 5 + Cargo.lock | 6248 +++++++++ Cargo.toml | 33 + crates/library/Cargo.toml | 11 + crates/library/src/lib.rs | 724 + crates/metadata/Cargo.toml | 15 + crates/metadata/examples/album_info_bench.rs | 123 + crates/metadata/examples/album_info_probe.rs | 44 + crates/metadata/examples/artist_page_bench.rs | 119 + crates/metadata/examples/cover_probe.rs | 44 + crates/metadata/examples/decode_to_png.rs | 26 + crates/metadata/examples/discography_audit.rs | 124 + crates/metadata/examples/hero_source_probe.rs | 162 + crates/metadata/examples/lookup.rs | 33 + .../metadata/examples/mb_query_merge_probe.rs | 221 + crates/metadata/src/lib.rs | 10959 ++++++++++++++++ crates/player/Cargo.toml | 14 + crates/player/examples/play.rs | 41 + crates/player/examples/preview_probe.rs | 100 + crates/player/examples/wf.rs | 17 + crates/player/src/lib.rs | 904 ++ crates/player/src/source.rs | 218 + crates/player/src/waveform.rs | 155 + crates/soulseek-client/Cargo.toml | 17 + crates/soulseek-client/examples/citizen.rs | 86 + crates/soulseek-client/examples/download.rs | 123 + crates/soulseek-client/examples/probe.rs | 78 + .../soulseek-client/examples/upload_test.rs | 129 + crates/soulseek-client/src/conn.rs | 668 + crates/soulseek-client/src/distributed.rs | 106 + crates/soulseek-client/src/lib.rs | 410 + crates/soulseek-client/src/responder.rs | 99 + crates/soulseek-client/src/share.rs | 246 + crates/soulseek-client/src/transfer.rs | 421 + crates/soulseek-client/src/upload.rs | 334 + crates/soulseek-client/src/upnp.rs | 57 + crates/soulseek-proto/Cargo.toml | 11 + crates/soulseek-proto/src/dist.rs | 137 + crates/soulseek-proto/src/lib.rs | 31 + crates/soulseek-proto/src/peer.rs | 435 + crates/soulseek-proto/src/server.rs | 290 + crates/soulseek-proto/src/types.rs | 114 + crates/soulseek-proto/src/wire.rs | 208 + crates/tagger/Cargo.toml | 10 + crates/tagger/examples/read.rs | 24 + crates/tagger/src/lib.rs | 261 + index.html | 12 + package-lock.json | 1701 +++ package.json | 21 + public/assets/fonts/inter-400.woff2 | Bin 0 -> 23664 bytes public/assets/fonts/inter-600.woff2 | Bin 0 -> 24452 bytes public/assets/fonts/inter-700.woff2 | Bin 0 -> 24356 bytes public/assets/fonts/lato-400.woff2 | Bin 0 -> 23580 bytes public/assets/fonts/lato-700.woff2 | Bin 0 -> 23040 bytes public/assets/fonts/nunito-400.woff2 | Bin 0 -> 16316 bytes public/assets/fonts/nunito-600.woff2 | Bin 0 -> 16556 bytes public/assets/fonts/nunito-700.woff2 | Bin 0 -> 16228 bytes public/logo.png | Bin 0 -> 12759 bytes src-tauri/Cargo.toml | 45 + src-tauri/build.rs | 3 + src-tauri/capabilities/default.json | 21 + src-tauri/icon-source.svg | 30 + src-tauri/icons/128x128.png | Bin 0 -> 12759 bytes src-tauri/icons/128x128@2x.png | Bin 0 -> 33996 bytes src-tauri/icons/32x32.png | Bin 0 -> 1893 bytes src-tauri/icons/64x64.png | Bin 0 -> 4908 bytes src-tauri/icons/Square107x107Logo.png | Bin 0 -> 9963 bytes src-tauri/icons/Square142x142Logo.png | Bin 0 -> 14999 bytes src-tauri/icons/Square150x150Logo.png | Bin 0 -> 16107 bytes src-tauri/icons/Square284x284Logo.png | Bin 0 -> 37620 bytes src-tauri/icons/Square30x30Logo.png | Bin 0 -> 1719 bytes src-tauri/icons/Square310x310Logo.png | Bin 0 -> 42695 bytes src-tauri/icons/Square44x44Logo.png | Bin 0 -> 3024 bytes src-tauri/icons/Square71x71Logo.png | Bin 0 -> 5589 bytes src-tauri/icons/Square89x89Logo.png | Bin 0 -> 8016 bytes src-tauri/icons/StoreLogo.png | Bin 0 -> 3343 bytes .../android/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../icons/android/mipmap-hdpi/ic_launcher.png | Bin 0 -> 1992 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 4781 bytes .../android/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 1817 bytes .../icons/android/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1971 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 3287 bytes .../android/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 1768 bytes .../android/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4447 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 6398 bytes .../mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 3948 bytes .../android/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 7090 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 9875 bytes .../mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 5950 bytes .../android/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 9296 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 13496 bytes .../mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 7901 bytes .../android/values/ic_launcher_background.xml | 4 + src-tauri/icons/icon.icns | Bin 0 -> 102081 bytes src-tauri/icons/icon.ico | Bin 0 -> 304886 bytes src-tauri/icons/icon.png | Bin 0 -> 78220 bytes src-tauri/icons/icon.svg | 22 + src-tauri/icons/ios/AppIcon-20x20@1x.png | Bin 0 -> 645 bytes src-tauri/icons/ios/AppIcon-20x20@2x-1.png | Bin 0 -> 1262 bytes src-tauri/icons/ios/AppIcon-20x20@2x.png | Bin 0 -> 1262 bytes src-tauri/icons/ios/AppIcon-20x20@3x.png | Bin 0 -> 1911 bytes src-tauri/icons/ios/AppIcon-29x29@1x.png | Bin 0 -> 852 bytes src-tauri/icons/ios/AppIcon-29x29@2x-1.png | Bin 0 -> 1715 bytes src-tauri/icons/ios/AppIcon-29x29@2x.png | Bin 0 -> 1715 bytes src-tauri/icons/ios/AppIcon-29x29@3x.png | Bin 0 -> 2668 bytes src-tauri/icons/ios/AppIcon-40x40@1x.png | Bin 0 -> 1262 bytes src-tauri/icons/ios/AppIcon-40x40@2x-1.png | Bin 0 -> 2417 bytes src-tauri/icons/ios/AppIcon-40x40@2x.png | Bin 0 -> 2417 bytes src-tauri/icons/ios/AppIcon-40x40@3x.png | Bin 0 -> 3631 bytes src-tauri/icons/ios/AppIcon-512@2x.png | Bin 0 -> 43725 bytes src-tauri/icons/ios/AppIcon-60x60@2x.png | Bin 0 -> 3631 bytes src-tauri/icons/ios/AppIcon-60x60@3x.png | Bin 0 -> 5531 bytes src-tauri/icons/ios/AppIcon-76x76@1x.png | Bin 0 -> 2283 bytes src-tauri/icons/ios/AppIcon-76x76@2x.png | Bin 0 -> 4692 bytes src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png | Bin 0 -> 4942 bytes src-tauri/resources/FidelityFX-LICENSE.txt | 19 + src-tauri/resources/FidelityFX_CLI.exe | Bin 0 -> 145408 bytes src-tauri/resources/YOLOX-LICENSE.txt | 201 + src-tauri/resources/YOLOX-MODEL-NOTICE.txt | 11 + src-tauri/resources/yolox_nano.onnx | Bin 0 -> 3659407 bytes src-tauri/src/commands.rs | 3893 ++++++ src-tauri/src/config.rs | 311 + src-tauri/src/cover_logo.rs | 655 + src-tauri/src/hero_crop.rs | 787 ++ src-tauri/src/hero_detector.rs | 468 + src-tauri/src/lib.rs | 127 + src-tauri/src/main.rs | 6 + src-tauri/src/pipeline.rs | 1103 ++ src-tauri/src/playback.rs | 445 + src-tauri/src/state.rs | 12 + src-tauri/tauri.conf.json | 59 + src/App.svelte | 107 + src/components/AlbumCard.svelte | 88 + src/components/ArtworkViewer.svelte | 212 + src/components/HeroGpuCanvas.svelte | 255 + src/components/Icon.svelte | 91 + src/components/LoginModal.svelte | 135 + src/components/NowPlaying.svelte | 902 ++ src/components/PlayerBar.svelte | 813 ++ src/components/SaveMenu.svelte | 166 + src/components/Sidebar.svelte | 276 + src/components/TitleBar.svelte | 598 + src/lib/api.js | 154 + src/lib/color.js | 130 + src/lib/heroGpu.js | 543 + src/lib/mockLibrary.js | 69 + src/lib/scrollbar.js | 211 + src/lib/state.svelte.js | 4306 ++++++ src/lib/trace.js | 338 + src/main.js | 5 + src/styles/global.css | 191 + src/views/CatalogAlbum.svelte | 380 + src/views/CatalogArtist.svelte | 5567 ++++++++ src/views/CatalogSearch.svelte | 485 + src/views/Downloads.svelte | 308 + src/views/Home.svelte | 91 + src/views/Library.svelte | 800 ++ src/views/Playlist.svelte | 234 + src/views/Search.svelte | 385 + src/views/Settings.svelte | 718 + vite.config.js | 16 + 161 files changed, 53170 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/library/Cargo.toml create mode 100644 crates/library/src/lib.rs create mode 100644 crates/metadata/Cargo.toml create mode 100644 crates/metadata/examples/album_info_bench.rs create mode 100644 crates/metadata/examples/album_info_probe.rs create mode 100644 crates/metadata/examples/artist_page_bench.rs create mode 100644 crates/metadata/examples/cover_probe.rs create mode 100644 crates/metadata/examples/decode_to_png.rs create mode 100644 crates/metadata/examples/discography_audit.rs create mode 100644 crates/metadata/examples/hero_source_probe.rs create mode 100644 crates/metadata/examples/lookup.rs create mode 100644 crates/metadata/examples/mb_query_merge_probe.rs create mode 100644 crates/metadata/src/lib.rs create mode 100644 crates/player/Cargo.toml create mode 100644 crates/player/examples/play.rs create mode 100644 crates/player/examples/preview_probe.rs create mode 100644 crates/player/examples/wf.rs create mode 100644 crates/player/src/lib.rs create mode 100644 crates/player/src/source.rs create mode 100644 crates/player/src/waveform.rs create mode 100644 crates/soulseek-client/Cargo.toml create mode 100644 crates/soulseek-client/examples/citizen.rs create mode 100644 crates/soulseek-client/examples/download.rs create mode 100644 crates/soulseek-client/examples/probe.rs create mode 100644 crates/soulseek-client/examples/upload_test.rs create mode 100644 crates/soulseek-client/src/conn.rs create mode 100644 crates/soulseek-client/src/distributed.rs create mode 100644 crates/soulseek-client/src/lib.rs create mode 100644 crates/soulseek-client/src/responder.rs create mode 100644 crates/soulseek-client/src/share.rs create mode 100644 crates/soulseek-client/src/transfer.rs create mode 100644 crates/soulseek-client/src/upload.rs create mode 100644 crates/soulseek-client/src/upnp.rs create mode 100644 crates/soulseek-proto/Cargo.toml create mode 100644 crates/soulseek-proto/src/dist.rs create mode 100644 crates/soulseek-proto/src/lib.rs create mode 100644 crates/soulseek-proto/src/peer.rs create mode 100644 crates/soulseek-proto/src/server.rs create mode 100644 crates/soulseek-proto/src/types.rs create mode 100644 crates/soulseek-proto/src/wire.rs create mode 100644 crates/tagger/Cargo.toml create mode 100644 crates/tagger/examples/read.rs create mode 100644 crates/tagger/src/lib.rs create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/assets/fonts/inter-400.woff2 create mode 100644 public/assets/fonts/inter-600.woff2 create mode 100644 public/assets/fonts/inter-700.woff2 create mode 100644 public/assets/fonts/lato-400.woff2 create mode 100644 public/assets/fonts/lato-700.woff2 create mode 100644 public/assets/fonts/nunito-400.woff2 create mode 100644 public/assets/fonts/nunito-600.woff2 create mode 100644 public/assets/fonts/nunito-700.woff2 create mode 100644 public/logo.png create mode 100644 src-tauri/Cargo.toml create mode 100644 src-tauri/build.rs create mode 100644 src-tauri/capabilities/default.json create mode 100644 src-tauri/icon-source.svg create mode 100644 src-tauri/icons/128x128.png create mode 100644 src-tauri/icons/128x128@2x.png create mode 100644 src-tauri/icons/32x32.png create mode 100644 src-tauri/icons/64x64.png create mode 100644 src-tauri/icons/Square107x107Logo.png create mode 100644 src-tauri/icons/Square142x142Logo.png create mode 100644 src-tauri/icons/Square150x150Logo.png create mode 100644 src-tauri/icons/Square284x284Logo.png create mode 100644 src-tauri/icons/Square30x30Logo.png create mode 100644 src-tauri/icons/Square310x310Logo.png create mode 100644 src-tauri/icons/Square44x44Logo.png create mode 100644 src-tauri/icons/Square71x71Logo.png create mode 100644 src-tauri/icons/Square89x89Logo.png create mode 100644 src-tauri/icons/StoreLogo.png create mode 100644 src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 src-tauri/icons/android/mipmap-hdpi/ic_launcher.png create mode 100644 src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png create mode 100644 src-tauri/icons/android/mipmap-mdpi/ic_launcher.png create mode 100644 src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png create mode 100644 src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png create mode 100644 src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png create mode 100644 src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png create mode 100644 src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png create mode 100644 src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 src-tauri/icons/android/values/ic_launcher_background.xml create mode 100644 src-tauri/icons/icon.icns create mode 100644 src-tauri/icons/icon.ico create mode 100644 src-tauri/icons/icon.png create mode 100644 src-tauri/icons/icon.svg create mode 100644 src-tauri/icons/ios/AppIcon-20x20@1x.png create mode 100644 src-tauri/icons/ios/AppIcon-20x20@2x-1.png create mode 100644 src-tauri/icons/ios/AppIcon-20x20@2x.png create mode 100644 src-tauri/icons/ios/AppIcon-20x20@3x.png create mode 100644 src-tauri/icons/ios/AppIcon-29x29@1x.png create mode 100644 src-tauri/icons/ios/AppIcon-29x29@2x-1.png create mode 100644 src-tauri/icons/ios/AppIcon-29x29@2x.png create mode 100644 src-tauri/icons/ios/AppIcon-29x29@3x.png create mode 100644 src-tauri/icons/ios/AppIcon-40x40@1x.png create mode 100644 src-tauri/icons/ios/AppIcon-40x40@2x-1.png create mode 100644 src-tauri/icons/ios/AppIcon-40x40@2x.png create mode 100644 src-tauri/icons/ios/AppIcon-40x40@3x.png create mode 100644 src-tauri/icons/ios/AppIcon-512@2x.png create mode 100644 src-tauri/icons/ios/AppIcon-60x60@2x.png create mode 100644 src-tauri/icons/ios/AppIcon-60x60@3x.png create mode 100644 src-tauri/icons/ios/AppIcon-76x76@1x.png create mode 100644 src-tauri/icons/ios/AppIcon-76x76@2x.png create mode 100644 src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png create mode 100644 src-tauri/resources/FidelityFX-LICENSE.txt create mode 100644 src-tauri/resources/FidelityFX_CLI.exe create mode 100644 src-tauri/resources/YOLOX-LICENSE.txt create mode 100644 src-tauri/resources/YOLOX-MODEL-NOTICE.txt create mode 100644 src-tauri/resources/yolox_nano.onnx create mode 100644 src-tauri/src/commands.rs create mode 100644 src-tauri/src/config.rs create mode 100644 src-tauri/src/cover_logo.rs create mode 100644 src-tauri/src/hero_crop.rs create mode 100644 src-tauri/src/hero_detector.rs create mode 100644 src-tauri/src/lib.rs create mode 100644 src-tauri/src/main.rs create mode 100644 src-tauri/src/pipeline.rs create mode 100644 src-tauri/src/playback.rs create mode 100644 src-tauri/src/state.rs create mode 100644 src-tauri/tauri.conf.json create mode 100644 src/App.svelte create mode 100644 src/components/AlbumCard.svelte create mode 100644 src/components/ArtworkViewer.svelte create mode 100644 src/components/HeroGpuCanvas.svelte create mode 100644 src/components/Icon.svelte create mode 100644 src/components/LoginModal.svelte create mode 100644 src/components/NowPlaying.svelte create mode 100644 src/components/PlayerBar.svelte create mode 100644 src/components/SaveMenu.svelte create mode 100644 src/components/Sidebar.svelte create mode 100644 src/components/TitleBar.svelte create mode 100644 src/lib/api.js create mode 100644 src/lib/color.js create mode 100644 src/lib/heroGpu.js create mode 100644 src/lib/mockLibrary.js create mode 100644 src/lib/scrollbar.js create mode 100644 src/lib/state.svelte.js create mode 100644 src/lib/trace.js create mode 100644 src/main.js create mode 100644 src/styles/global.css create mode 100644 src/views/CatalogAlbum.svelte create mode 100644 src/views/CatalogArtist.svelte create mode 100644 src/views/CatalogSearch.svelte create mode 100644 src/views/Downloads.svelte create mode 100644 src/views/Home.svelte create mode 100644 src/views/Library.svelte create mode 100644 src/views/Playlist.svelte create mode 100644 src/views/Search.svelte create mode 100644 src/views/Settings.svelte create mode 100644 vite.config.js 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 0000000000000000000000000000000000000000..f15b025d666269fb07daf0263e54231481e4289c GIT binary patch literal 23664 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 literal 0 HcmV?d00001 diff --git a/public/assets/fonts/inter-600.woff2 b/public/assets/fonts/inter-600.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d1897949fda5c7fc5f04622df43a3fcba5b89d84 GIT binary patch literal 24452 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 literal 0 HcmV?d00001 diff --git a/public/assets/fonts/inter-700.woff2 b/public/assets/fonts/inter-700.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..a68fb101cd9224aa7bb6ca80356b5132c40c6f3f GIT binary patch literal 24356 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 QOFh<%JiLFgmWQ+r07g396aWAK literal 0 HcmV?d00001 diff --git a/public/assets/fonts/lato-700.woff2 b/public/assets/fonts/lato-700.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..11de83feaf0fb872c91fce89534fd4e2d8bbda28 GIT binary patch literal 23040 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 literal 0 HcmV?d00001 diff --git a/public/assets/fonts/nunito-400.woff2 b/public/assets/fonts/nunito-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..8c68e5744e4d08f9d720f9607d42d683c125f9fd GIT binary patch literal 16316 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 literal 0 HcmV?d00001 diff --git a/public/assets/fonts/nunito-600.woff2 b/public/assets/fonts/nunito-600.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..dac52ff90d4d35049fb62e9703259ddd48d26007 GIT binary patch literal 16556 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 OD_83@4G(X(2mk;RyE=FP literal 0 HcmV?d00001 diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b88d0cdb81e36730ae8754913c96985968c40745 GIT binary patch literal 12759 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b88d0cdb81e36730ae8754913c96985968c40745 GIT binary patch literal 12759 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 literal 0 HcmV?d00001 diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2e059591931d4d36d45064190b1b115cdf88f35f GIT binary patch literal 33996 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 P1|aZs^>bP0l+XkKvV%E# literal 0 HcmV?d00001 diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..b6606f3f055eb4e6f3385f6ece98ac559adb4445 GIT binary patch literal 1893 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 Xa+Y)n(Jo6He+WE2l`b-L;c>F6J5q^N;P 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 literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d8c10a9c81bc2d25e642289a5428c3256ef463a0 GIT binary patch literal 14999 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 w9Qt1KaIaW9HKlmO`YXATM literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..cc93276ae7ffc6794fa85eb21cccbd6c0cc79e77 GIT binary patch literal 16107 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 literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c7cd8dc2edb30c498fe8dcc49396a0e154d1da41 GIT binary patch literal 37620 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 N002ovPDHLkV1ncKV!!|Z literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7ab06dff861d54bcd928ef5c59c6ed0bccf717d9 GIT binary patch literal 42695 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
    XDkKTawH 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

r5@|mm{*DI-CVglb&ca)o9?enK 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 M&AT=w+c=&1AIcN}dH?_b literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..576f4772a2ba1ba34eb7b645ce4f39c8d2251242 GIT binary patch literal 3024 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 literal 0 HcmV?d00001 diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..99066370d1f853b10da9f4224b6ba27a2efefba6 GIT binary patch literal 3343 zcmaJ^cRZVW+wXalv?W$iiK>K1jkY0zYB}Z!A*fwJY_)3F9#w>x5qs1g#ZgQFUmReS7~H}RfleV+Gy{qo)bH0KRbM%o!j?OT&=< zMlzmrEcEk=a$LiiGi>7+4OQd7{Mwu#FVlCRzENf#X5N}{nz)3pj+z?x-{N$5r&p_^ zTJGK+Q?6Dq;FF)RI9a)AWmzLu^Y9VQ?f$zr$@ZF$y)WSIkgk)Wqpb`+KgujJRw2Ki z`<9VIcrYias&CbbZcu{g{K}SS)({4aS0>mRZ3I1!@tP z2>!O_g!1NU6>zL8mRo!yFjIrTW~lD0Gtq<#4II6quPEC(dNg!mP#8=SdRf2L3d2-> zvDIu?R6n@sV2=j5pm|Gma|SioJn|obowtlira@V=uSmHX1e%f+?N7q;jT?pidc_w2 zZC>WDrRPia@e7>1hnEW$zd4ZE0Bj^DyCRWPosdB-sSsFg4CWUb@}+i7F{oL(E?35l zwej5Kpm4ihz%@%jfQ#~3dGB*`fQBfaN-oRk`4+Ft9WhK z2fLu|*A0c{&xZ30^~6qwcwur|P5NRvYZtt+wak*TB2he)a#{ge6&}}+s^!w(1T$nI z2)9`dxoSA}kW61^S|S&**A$7>DI{kBK z3i?`NejF(6Nn*B7(ls?Qfr8Mj&dsvif0lV%3t)Gr!P`6y4@{lTaxqa=G$o9rDGRxM zBC^yGaf7TLwansZOTMH_!~+>r8U~|f5$v}%jsP8ff2vUH@}4KAvzt7xvJ?w)JZG<4 zgQdW(UbNTYFAwW^*a$6&wlqLqmb#0B$X_9@Hnqd8;G)Wbn=dAU^b56m2(a!*th}oO z3G$Vc_Ir{h4VRt%#J>;sw4Ci1AM+u8-DH?+&MirY1ua$)rdY~T{mvqhF%FRAWRYlIG?>4(22h1UGOqs@xj+!qGV*Pf;QmrPiZjakmvgWN;&D zE$6{G?korR>mT=yrUn-{FYXeYx5cVU>G1lfFdO;OE>^m!B9J|kEzXkaM8|N9%qd3B3GPg1Rhhop&r?6V`gWh4f<;ij`>@4ZUcPt4J%?r z2%YZ*OW@pHzs;|g*OI9i4rg*872z`Sca{Zl#r)Z1jGohJKjWo4_hYVfK14Ko{BEiX z9wM0yyZ`8^URHbZTSp*iFl3^>Tq&ITJeVJY{}k{Pa<8a)R5RQ45_TkX{lU1jlxUg& z1q*EU)i##2IA~f>C5*gR8Tol)n!*bUG8x8KUf-&FS&|E_i(joFPH7}f@Npnlc6o0W zW^W|8W)R6^C(i|=lObffli;`KmAZRe!yhiD@CP6w5U5qwVc@jI>}tpzUm;JpC!yY- z4w$;rQ<0QH^9U0lBcED3eQouz2i=&j;K27j%H5e&rWr_ujW-*lGt6lMAYEL-r3fFN zn~fo6UGyTqRo3>nQ){mBy#&15dndWuv{u0mrPTQJ;g-cvdIH*Hv#Bl9ZGHiFIcw!d zu)5=Co!ZHhM)Z-f2&p`_75FXpK3K%%dXLG+1nZStX5HtT^Cvqx2>=kXvLGMW-Z{2c z^u1u6b+}2_Y@(}!W#$0v{e5UyzJTucr_o8LqGN9cr zrlxT%3?~cIde$q@4QpqQszO7>0h><1U>nr^c_Vm9_KQbYF{oa50%$)_Gk0GyzqEd% zA{7?&c)zCndFxaXuo}^ zP)ROMkZUj`7?j)^^&|OYn=yOB?Y89c_|V?W*|e8-^7ye;NWia)(sGkCwAJCaS@K3R z3+y;Y#+nj+g6w&L^QNg$LU{J7* ztABLn@S7gsVE81wHy13moQ}CF%i-=o%fGKJtbSJ6EK%s~w!ng$=LYbSa?3k~g<<;i zd_^`}uh6TTG}6X5SloVLr~mnTdK>O15Sk*BG6cc5~SVWU11P==Ze`ix;;1t^C>I|4+XSebHXyd zab<8B^>I_YAR+#0j$$X;`h4Fp`;TADAAMVkYpJ>~mZPZd^Y4sptMe&;{N?!nK^M}E4k{<z?^;Db1Ak<-+{zxZGgfXz)s{hqU>Gqie1CFUD(Ud65nQZS!`fKMlg@f_wR&!bfD| zHa^{8Wd4^ydhMljSB-&o|Q_D7`961J`1K>e2N>46SpTi+$2G@8H4{Ht^4=9 z%!fezS7j4g1z(($D!o4+EY86O?*pQID-%WO>k>gMRrS%o6Zp!7hvF!G&;7hr$e%mP z`ro>#sIRL%?wmF=Dl!45c%Mq)6Vn`qw)`u8e7*p6{+A(3Z5)S?>c)_XYkFf2#x_Rs z5XL(3U!w&`HD8}n@*P@Hd2qSR=`gocncY@e-uURsrkpfVjLzWy17c(LZGKuiwPkr_ zb}!UkKH)n=LeR<6;t(aD6uCQ^6ezP;xtmbhH@;&86T z6@0QAF2k|&wc9e1Lq&lyc?tWU#5HEiwARb$&Jn3qKkq;!PCkdy{KGA#Z{@1*+-J4r zDTJNsxdcN3u)u8J!7_ug$T2apWc=Lvbk}Yx%4+ES>MG4ThIS|)SoGmsH_>OG{-bjS N^NXIwQ#G4M{{v$XFQxzh literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bb6bae2b397e99b0edab74541c96de0373a94eb2 GIT binary patch literal 1992 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6a657aef09ccb491ba9f78418ce16c59bd9f833b GIT binary patch literal 1817 zcmV+!2j=*RP)u(fQ6hHUQ?5o|bLSLjj1PKHXA29|^)DYAVjUNyUgqI&QCdPll&&E%FF#ZR|_=qHi z;v*5m2O~-)_(6jYlq!#wO1Gu$K4xd;dd|6bXQ$hJ%^lL3T_aS8Lu=0+(}pTV|MaHJL6y(xvN`efwd+rQAeI%x z+L7ZbFtQB*!K3Cdw);bI+d!;4e_W}MA&UVZeDBzv*1*V>27nqGJbh3tnJ1PM0Jo#G zN#@%4GarjZ13|`fvr7TM!-J2zEkFsOKbL&X5upK%?`;f5E;j&?ut20^;NL2Ne{~Mj zj00j-0>F}do!S}r;ziYQvRg6$Nei5j8<78U6x6h%J%VFyukV4xvm3$M)B`Z*;!unB z>D0Cc)ck8T0iwT00Q8WD{8!`wq=?m*gv_fuA+={cTSMMRAK40$z@Z+z ze+q_TRr#b5h_*@zI(JQ|M8mv^JpM1Ccy1h&;~SA*y`gwgsFHd>&}C?d}pWZq+M(dY^^ zwDA2HI6qH9`p_0gzqB2)Z+r{I5d%JM@x9z8R0WELbZQjw|)R`62qmc>%iW6H$cH%5b63(>{IpW6jj8OQL%F$jc|XPW^Z^` zW_Z*{usQ>2K~pC7uIKpNyD|r|F9m=RC%JDk$lfFe0{{9P_>`@YLiRlwNIbnB?1%b6 z6?C$j7pnb689OQf*9UJb2ll-gJ=8=YqDI^{dJusw_*uH1kXj42){i%Oi$ACKZ)R$F zHw*e^92{$)3#<)2s0&XAgc<+>TdF}s0)R%(ynO*fUk*|Swm@vhYAx76cq+(u;qS^3 zN>GTsk%K&DYWI(6NWXSJ*bnxDg|y^n$Ve2m%9j=<%NhXc(5VpseJCM+DSCLnPeJ^F zUZXB)Zc)Y&Ay3d3c%7@%IA!Bm@GfS-!(>MpTNPa{{K{1;ADRJBb?FqLn4_&bhT+rW zFsY{i!stL$o~3i4@8?qr!vQeG6qDGFE>FM|dFB}v0YEJfv?>t=0DZ*r z<_VkIDL(@y{37>h4~(KdWQ~HcE`XYvh*(aSlNgm4B2)`o5XACursZ}RWF??;)12(2 z_D;};hz>y2$P?EddtXEWz!qS+5M)uM!3ZIZmQ{#OmGPNJCrh6zjB+|CwCDlVWKz_0 zLPHUATQB&N1+X{Wr41okh3Ld|3FuT#d{S!&!=^RVjY(`3R;Q?~ObpvlTJ$XdQ1yfz zX-0-y*UQ^6#OA?Tm*x!2;Z5CB$|+VQA-;D5^FZ_l&Uflqp5|!8J51mmOgE&K0h&cbYdBun0`s+EK3R2*APuFygvUIcszB!Z2xX z%z(@!z*^Gtjk`F=uZ>p{6WdftpCf z`NY>oGDrl&07QoX@O<*{uxP35s0TowQphDRt_hc-LK834F;%0ir_Mc~RMT~*767QK zj6>@JXgE=Z8{gS$vU00000NkvXX Hu0mjfqsT_~ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..426c752dc8f4d10f02152eea21d9713b0bd7fdfe GIT binary patch literal 1971 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 VwhaZ}pG*J%002ovPDHLkV1mQQCr1DP literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b22597c6b606ce1cd6907a2ce6ed9322eabe541b GIT binary patch literal 1768 zcmVP)A50zz|IE1{&j{ zzMv005HDz=B9ahLbZ^4W!tUIsPg!+NcTY1rm!6p&c1fIMcKUkytE#WQs&jfBxW_-v zy>5Wj>j!K;f0%kXKCaY2TfRR+prP`Q4_P*XZP^X)6HM>^5LeZJEk7Qm0A!os{cZB@ zo7wXb-ZcZp890T3bP2vX2EfeI?HOXNCGc`^}sT&gu)~8f!2CiQStavG;y}Y{f*P(+Kp0^B(xp z`Dw_$e+k@~9DvO*T;`8$Y5HK?hUD{GA@$mJ9SO;cx&5cHEfwktyo@TI!#bR2+PG6o zkpJ=;|7HP`HduWQtbcnKBzB|$9Sf+Up!n@|D1A2r$mfXZmYzIFkqzh=JeioPsT@>( zn+GZ}VhpQ|(tyjCGT>glt-*=+oryt6J~amMooQ{Lm2$wH%R~8>o4jwE@%fYfV!07k z1KM{6UbY$Z?RS5I%vy)j@#jPs#>lDgc8Oowqml zgO%>n*WY^Q0%XVfA$?*WEFQlIOD8Xby&!(b04NS_CEMm@f8MAH>-6 zv4@A4Zp*>fM!~7&PgC&nVO|#|uj(?N+P|$@3x)5Ja71@fNN%>hoDhv?ZoAVt$nzM9 zJ>$@K=ph|NwNTKqYOPBc-Y=CJf~Qb2IJNoPl=G|{qTksM{?Jr#9vtEkuv*l;2nGQN zyoFx2^Oqx5I`0P#gPblU_AhSFr-nFV{)I+PCJ7eI4ZbWf@NG_%1BEQ75s7oTJ<){P{oRSvgozE zfy@0UU5~F}eBJ*GY;2rn(wKx3-hrQ`vA37g6Ky?7Vp$fI~JR?Td zXiTw;CM>{VlX%*jVKRoZSO=r^0QlXD+_>L^Ku_rfz#dX1XYy$w-5GboVWw0f9f+mHm#RQxc zBerLgCwVH!0RgOwR!B7=ETgtR=XJTqNrAofK2WSc)CqWs(#$nl`jAbT>M5^DlPauC zk+KHkURR1iA(LVxG!f0W(kZR;B9&^fd}rdf1HN$%v|hI%pW`-fbDv_ZPeS70glWLG ztuq>Ldu@cLSn+4a!PzmQDJRVt*;WUfubFq$s{{U8WmE8~<{VdI0T+8>R41k{lScc^ zwE{fGGcFvAnMSWm@biZ!u_1*bgAYGgCfx*PGNgT-eg9#O;6&AE(aRgI3@(M!Pb%^?x<^|G!l_{QL(&tfD1~o{KI30000< KMNUMnLSTZGD@=(1 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..963f51f2f85e672ede2af1de0eaf61c6cba2d0f6 GIT binary patch literal 4447 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e00b7ba114db34b9b233386c8f60d4570f69f1b2 GIT binary patch literal 6398 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8d6e68ff759ba4c5bab30dc9315ab69d9849f582 GIT binary patch literal 9875 zcmX|H1z1z>_eUC;NOS3sl-iIEQ8r2GE@303lnw<%eF3c^;l;_wKysJ@Gm5o_8?@dKwqbvz;d*BD$cZi83N0I)}plQ<6h(>{c|o zh=>HOv{2Vgf+p8z?%lN+4W;-s*Kn9@wvF62evrU|@nE22&JyA6mF}UPc6`Ffu$Im* zb8FIXTi@mGvBz=ypG!xV^7JxgKmYr9TYRz>i_p5E>8;Hpn|Dn!`&YmktU8;MLLJP~pamx24zgO?)hNjZ$5eseg$A;I$aTD9k?zYha zD>2yaZe_t}fI%}pc1)AVO$E`i7=d`;O8-E}Cp1bcfZ@_T7VoS4$16^&??oQ)!bw3- z71fw;hH;-Yvz_vRDTMMlTK{-rmbFmF8r$5hb~7P1fEZ~%-n+B-bY|0VXgoTaYcol0 zx94Ue>H5yT=Yq)EooF72P1}eZY}KC6ySXAKgiA`sN1CQ1L2@>fooOvsUbLw>-_@QH z7J*d-B;Kf`W zP+@a9ZttK-kPwT=?n*YcEB21+*T&mp$U#VDFb=B)ccZ}UTX^*>?YFDnTl1%RVoJ^SJehuGTCcA!wi~*BVXxB6O@I)R%<^xKAbt)Pec|~#nH;OWcE*x z9a7@Iqy^F^|5Jxp;e!3Kqc&muADga$t49#*0gNHugZ| zkb!$L{IIF*0a$VI|9`XUhh=%Vu4a91kZ(?>uBNW?BA$>ErlP!bRU+gmiRxVF9sb6F zjMmCcd)$^9-oCtWzu+k!8}EOgA8*JqqfTF$1nYIsWXx~n9b|ll#rm6 zM?d7UwZy09yJ59t_|>E!Ia+wiJ}hiSb6@j~{yo}Lx{=M{SJHy2s(a&<=TypN;Ga%e1Zl9+g(RneDqljdr5Oc=c6Ky*T4JOMoZm(H;}S*ax|H3qnwP5EGa|g% zWxDTrD}P-5i(0yEW|?;Wbqvj^3I<9rmMN6}*tY1jbC)+KZy(?GsgSO;px#(;i1YT< z7j$@ZmR#GXJN1Y(C9X44%xfJuS&P(oii(!CtCl%)?uGWs-{FVPo=DWxEF~KZ_L+3-7`AoDmjGoM$e<0_iQDtO8bV3h@#{@<{{8dc(p| zDaBLWaFq7Tg4(s&>W|OtNC%ri#rR(zxLPnJAV7w<6){d29fZ)HR{6$;s-je)INiz+ zm&`n7Hl}aav$Ad}adXn;RCjnRke;;JTfS`9*xewgCU*0a#Oc2AQegGqKa?-UT7?dV`}EZhFcJU)CsqNt&FT0@>5y7+eZH6o91-e3`!7ELIk=4E>=GqJKie{(@4BdvKWgtbF{8F3a|mEGw~k z?}TaTHYHw^5^gZc*w`e<{mI&ai|hR8Lhg@$q6vXtU)Jq1tZErya8lJ~F1g_h>>1~9 z>C)F%jbB*PGW5~m z7N}S$-c2B($!x2vD>=eE5{7dSyaq<{wpL!-8O{sf$t`+YN!5%ZWPO1{O~7=k@WI+OJ!N7fdLbK}M|D^tz75aG-6{xi)HjR79~=~`W^NQJ8E zpO{A$+T;Wv?Q-KPdCFN7mcm0GI)`tiU#lW@xDlu*0hqD%RCoIz$)>In<1N3-jhql4 zI{78fH1tiYKAkodu5zy;po@L+(fq@Bu9W~b3t+M zVZL`4%e{TCvi$oo^58aHsr~Q?^)wejy}ys=A6|pV6Qd?o_CI|i9sXu*{N?bid!Cb$ zcUxr|kM{`X#ZPAf;}Dn&*VXH(;Ro>uAU#LCsI&Ets>^)@Gw|d}Q|{Z2gHQsh_W-?% zt*Aeb=Xg@BkJ}fekM2lnaN`!XaUS#pM=~R=<5~1Bo(yHkM4@XNis$@~pDxB4c2-V5 zA*_F!GM~20Cz+f5T=*?0tWJruI!%L|fvH6x8p(JG#Cl+iqUs379<`T0{G!D8UC?kq z^|uv2f^o{Ujz!3nfGs@VzF5rsKrAj+f0X81DDe3clRT7+n(cnKdSyTcMx~|>4V10PKDGY)_pPICi?S3@*tGU$%7&Kg6@7;I z6&f5l9e%G3L~f|jx9)q(dyW2Japzu)3~S%?KCgE80(}v}udu*;SWQ{ZaA7OvGOccV zvKxyt1fk_^*e#1^y_;7xj8FcleJ)(xcG=Dp@%C`qb!7uhw0EMuHk#^{XFlSsAg6=i zdyq2!=gvv`QhxC8p6!6jA(!s)`W;aNX$V;XEDx4fmA4Vqkb9XuaE3nR}Q-+-Fr(C{STDzw%BZ zrLpWFslp^*pqp9Pianh*3fukqUK?#qFL7-_>%`V$r===mb+qO=m)BH{e^LY<|6qy7 zjo`kjKz=T*rP+ir9rmr;Eolq`kFkAJOmdd)AFfqtpkiBONZ@`A{!p33tynH5HW_2Y z^oERb7+Jp#CD+e2s>)tdlznRH^NJ6Jg!@$y=+49RU$JY5zI%8!UgY`bzBKruKbJP0NqS#iT?INU&KEyflbdrTS*t%L4NGJ!jV`86wD*%2ioV8V3> zt(Aj0aXyYcQoVnb;EJyFm*jYQ`HXK}&viFj?C7qOW+@Z_zHUkpQrD`+MfMB^D~cJX zL4?P<3|7dppj@%fL;~6YeAP5)=-DYCw}iF`l*D5x0tKSFbf588tUM0zy4LLWA_eG+ z#OKS91_kx4juDY-k0`60QWwk3z5|8V0kd@{sFse*43fRIy&(zf7k?ir{>A;15oWvF z1jf=d04*%cS=$);#NA4|B6Is^(rYNW^D(7lj{q;Zg1XoxJV;3qqId{-qIJ~4g$6LAOk*x6p#`rt}Ojy-jUaZ-G;pktL?V~ZV-vy~%PO%IwEs&LQI)lh%zVOswYkR`(7+m|((Q7vpulrrYWAY*9?438@oI~?^rld;vaQ>h<-P6FU2VJ_ z*|con3oBJ@Ac0G>A}u9@_cJy}rfW7os@+&m?Z}3`sN0LP>d7#|ScgA{+XU^E8@JyH z+m&XT9X4NA(DR}jwq(?;HVga{peNN-QP}W9PgMW+$obBixxWdi0vdw%?cZy;co;J0 znp>J@DG!JRHpI|eXsaYf*}F=Ce;&_1%hJ$S99>J7wG^oM)vr8ADfXG$R^xqmm~N`m zrR!)-PPDLl)3=M9#S_J|rE{K)WaZzS)Z1!Gl6q9I;>nPqnpQRzheU=xgjisnK&-dh`*3gF9aUm- z%yJ7%1pdWK)>5K<`T6(G%_*1dE_>l3+=N~8=*Od*C#(H^i04qk4E<7buwr39zVqF4 zl+}~(A-4F{y*2}G*!t%y(kFcriB07`>0b{W%%QI}i>gY9hv(#D12-Y>?KdNG1GNy>6K$3ZGOnjcy09i7|ZwfaIL#@H@ zhx3@-+CXbZ!?Y&DPja@*mM~k>84~cXcw^n%aM`(1<*GsjaLD)AUVYtn{~S)6F;7)q zzm=U8)})&1*)j_!24oXyBe32|1|LM3xqdlNVi_;rSv(RnOrx$B;d20?UMlPnC2TPg zJGL_~_)!LK<6*1RkzTjK|2^DKd1OZHc_qiSBnzw0ey`Dw;N@g=Kz1bdd}by(1#34v zK>_RU?u$w90G*_djxtJqeN96EeUp7dpV)@UehhZv3$d$-Bar!+%|A7ZH)Z~lgFtlu zMnk?c7i-bPrQ)97TOXn^L39w6g`B17+*8)Cc}`tui;1Iw=_<=F9ISU6N2t4nqNU4= z#hQA=uZ*D>aePozJAmYg>sgP`rsiK_m?=78yYCnfud-E&_3Gzi_%Dbb2xxHNmioOO zU|ascj%aA#;*!hqMyRN8eUXmOg(ZDkX)da>()D$`(LPhk z&Dh=r%tnJH+J%K|_!GAme#F-E0X@>nfeh2TpW13jybb-x;C(DoKA$cxqoP+JHi60x_~ai^D$ zxpXI3?4qlSksrbX#s~kxjw2ET*a}>kX~5XP-pmKsvL<4%@3hXTD@vJ|(4J4>g~t(!AK-!N=#)rVaBgrPVc5iq35*yXm!-ujk<~PNdb% z6Ec`m*ROx|;i7Qm2`NwC>n6=P|2BN355G0#F{K=oju`hdy#5!~P=8bD^5)cHL%KU@ z@o`vfnt#)uyn&;?2hw{Nha0SoDPeEhD)pdP8kYc{cFxb#iWnj)=x@w!tU6;HUG2Fv zW9D}c?DqXwd}40uFh*cy0SPTyYS@%QfVy8wUUjA?>asC~ou3|N97$z$++Fe3 zy!(b&`OwL4e|uLVkjWJC##@DmPq+7)Ud@eh;I8F;5k|i2s7zh=^KE6Vm??w7-r_15 zq3+bp_jW?A?uMVTj#;WZHBz-}+plXR34zK;LLZN34Gr&99YkQ4fW}vi6fpLS+k;8@ zbenra$Egm<)yvJ26*&t%$z=0SXlC|eyA6VLzljg9C|(_=c7h_g4lpW%8mE!Y_{R@l zc<;HBtwy0eeBeIe>GvmHTpmD7WoIC!)(-L~ud@I4yya;Oo?#R~?Z)y_Q{#xLAB_dW zs4Mb}eG@a{AvurW9`B&02yD<+Ea@@uocX4y4x*=-;IIq0MhJUY3Yh5)oq@&Gr9}x@ zPlO|{dNB;>I)}@~OW^L`jHL{!(t}c>zy7=OhawiO6k}wn>hh-7PQbq80!S0xRvCF8?CYjgNh}pPbbHM%VAo*76UyGIZj3lWNPrmO`e#T2;U%~)xtV)sK1XE#X`{mEs%Im3$ zq!!yrhe54v20Daxi=F#}a6YR&Uz2szVR$~?av@^K^RTz<#>PxMZi(2PtG_~v&NudY zcaI=hjqEKBYC36I$seLEQqWWe{y$iOm3VBk6ctRV!PB&Wk$H2Y{0%@}M@t8+Jix|D zJ#T~WvYOiid|*3^JSjMj9xsH72u#KG^95vA=-lfPPB0qg5Q$EOC=J|UmVhDkMMeM8sgToa{saxJAt`+;X-bCkLm6=oHA_1y*pvEH#14 zX)$H9W+bqp2k2BMxwetf!P0z_>y9OMX=@pe+n_-c`x?|%VfowVzo{I>adZ3GB49y# zab7PBBCsdG*BA&j`vCcqqGXCR6|2P_TA+&tCjj-D!*VPpODwkV=b4xuys68fMyD>& z_uSHlSX$6o{Mtoq1q}?@UP;Oa_t;!|SFQ49fDz_USCB0_*AIjFca1DU?Kz;WWwa6A z<#7qVt2A|Ctj*OPxD0aPn=(7&{u)|7q?+S08DaQyW}mn z%SYQ{C|3@7e;~)Mc&xm%y7dovSW(qqg;V zjP*5f<}ii{CbyxjJ_-901(4Ho2I*MxhTUZ^@G%1`TPjT)#qPg%cW^JuSCY8kU+8dk z^-!9|=-Ad|pTCp4xfb~;?|H{l(fIb{P8bdqpkTScv6ZE!5tuSLc@dgBRyIOjP*Gj_ zB2%Wg(Wy}Xkp>L&v-MAaHB1^3R}?(?R(G7Zd8O(FHvoZMxs4cHS9Sh6cEh&bW34CZ zjkRn?MNYX+sX+#V@Xx%AIL)gc!~B9~*tTwIL6M3OQ-^}GsC#;^S4YJA_8n_Or1ODu zp#a0>HUmjmoRGr&v+*H?I`RZmRlvV6Y@=9kX&H1CL%?PpF_sB#L9@4}Xhfhpx;lanpYs z+&H#Lr{~_dbAlkvzx!+<^1ncu9_I=&YWaV*jjra;gfoT*t}3hVH%z3@eA_LSBSJ>P zJsOP3fDn1JXXAT+#9pWdQw_H6o)iDKK~gX)@C!w8 zkzW?ZBW3c-$|5J-c&Xy5T9mm^Zk%!}bhM{Q^Ki<3V*NLlim{H~0ep!bXGGbL&sLSI z>i$f+D9>US@a%2@J%d{k`ax&Djq>+LBzD|&i#I57AQW2LFTAAqkiM6YYr2V7L4W87 z*67ePDj4|RtyRIAyYMgKA`=N!+FIS$-(^mxMgGvA+`iMg@`A#beNUD{+?0VF%h@4=j82az!#T{x`qd0RT3_TtkFHt4)KEEDE>uw;Ro^BeaXbQ zO!RVKG@hR(5MTo`0(?>^M@m@HL$n5T4kTPd!L@-pTj-lEMx>_>7Fs>707~qi4k;qg z%5aY={BQy<)FM=7-{Xe|&=VkeS`OPP+dV0o4e|2uAj)e9q;58-7SVIio{k8Q1{%D~ zeRp0*a2btSrPGr^foBiPN#}(=RL)jdi#92CCHHE6_O-=dQ66+2lk^7jC**u z=}M}#IW%{Gsysfp+bSn2im%F!ct6T)Msq?cPNFSH@L}>g%$7MUgY3U4&+Wj~L~gu% zQsDM^YDm(tK+}-17lB@4dX+(I;{9`4C-W~L72IOxG0=f50&5M8M|NDSMf_*j4racI zho=4M`#Ran2+1JRJ`!92v07$A7E_IXvg>*B_O`;{$)=KHLC)*LG9NQVfx-{p{LAQ| zE4kN*v+0Qs;@L%%ceg>Uuav{S@EIisazZ^7&j8Q7v=cbnzKasq_5V1Arww8{1kc!Q z0OzT@#kK?i$o+UR?!3<1n@6fsnSt*r9fLL6tt zh~xd(F^e0xe5-SoATlxT9Y#3Y;h8Dd&9moBjBqQfHL2lExg>&c5$Kx;MoQ)jwXt(! zBxiPXc!1uT``DLvS>tT;bf7O?n_GN8UB~mko`Tq`(sQJzrmgy5aI@s*w73sy@j)oMh`fwKk*brl~LlV!nZH6;?TAGVTJ1BvjbxivlXO<&J zqGvnSFEPmQD7NBmlb-1nf!(ROyz}ZBe?QyVWtastQd3l!rtR3`ycpl>g&zh&hEwN4 z777WrNK1W{S4*pJ|9yJ(Vh?!3ZaCm{DoW2)<%DRQnS2t6ty5O{MBnkrW9R79SAZpq zb;eGi31VyXz4Yu_G@c*S(}9fzR$pfix<8!x?nw8HSOnHE-qc+2E^xy1k%b$@8Cwj{ z_r9Nk?cjM%?MuTmzh{HIGKn|2VOM*tq2p&qtb$LOy3?!k}7XHTwKDj@G@1q6IE4VgiDY zi*WubQKwD)q{}3RbvH3tjQ=w+TO==w^Xuiw&HXXkNZ|wdIPuUk*+-Il49*8Wo6UDx zaWr~S{W{zuNqBjno`euD*snm}Hqjv$>+W`~c#oDTKMKRyli^wxwcc&T2Fp^6j|}gkv0@h5GY|xNCmG<407G?sZt_s zsdB#X-<313_{cS^dn28AjDnR@mUGTtvVrj}vm5WQ)e^o=>_L79l|jviXZ|vPedq6M z!6`5$T0SpH*t`!H@_T#iZnSo<4rs8;m~{P9@#FB=wx>C$FRyyV|Ohd7XdKEaIk5;Tbm>jw`z= zYULiA>;DWBr@gk$k`66mmlH+NsY)+Qe<*kg?10*#5f@nKrj+rhVyB8i&eTE5W6v;x zb{^fn0ndaj{9ZFfSKIC{=eGxY^$XQ;p4!-2(2Z8%g3?T4v1vv__51fbBd+=(e`uew z+&%Rw`Y53FN-75r7@-kuYP*apq#k7R!$KB@(&nHar#*`$J1tAo$o>-v!VgT~butDI zH~8GiFb%IyCN_>1qEzl8tO%jk^~$8AJTY)={_sPcCN-^7KUeRb?(of7jrdK1T3yA_ z%>#GWyWcPrU4PzL)j5OA^~lopV8H7KuLfGQ%=qN04vIDWw>6Y#B|rS`%ndrCseMF9 zNv`zZ9eM|f582WPW|TRckU@0Y4csXc&Qmnis52egNw9{@N_)fUu*tO5i@&pG^l#N(8iUaaAlC6jm|dUWQe zQcayo27}#(RR>{zfC$7OWQysuiysR?$;G)*Acd&PS;ZmDC%R`=K<`WPn57%u-DqRj z_ssvI-)#$vuPVB^@dNC%t2dI!?&%TrQgqfwE>O9!>kbg%!>MeR;!dn&H|c|-g*ij* z7fI^8=i_CfFJSKn9VF-d7ivOlAeH|;w_4{`R(t2`N05f<)HvS-DXXIZ7h=V$&I~ML hLV`DQV1}c|+-l04)tLX@3lTwoS~v7iZ`AA`{vU2GYX<-T literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b0c63e48648611cf763f2158c8ed6edf5a4e93d5 GIT binary patch literal 5950 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 gATta=W{m0he_E(Mbs<Bl$YDkHpI|POh1{j7f z-tYYf?)~A7buc+`|^Xb|9?|KG&x@)dDVFLiK zC=}mIY5OjpnD`~q+Il^j`atVD469u}{N@ej=tLwF zjC1P*g+=vYQ@3%&Hht}d-aI(ndt-|W{OOsfbNuZOEnbMZ5>Z+RCR{#M{VsT6^>;Iy zi}rRV^~G?(bm8@wJexDkh*Y0v-BD}$fME&2Yga(KRdRD>DA<2sxwE2`g5&n4lFL2C z4JV|Xl>Kj$zI@OQ<+cq7L4pl|3n0vJ?JgQj^K+ZaL?nU{mkAQ~JMIZ-bjzDTqkQh> ztTe}*(P*CZ3X|N2{X)pg6*XsLG5?9t%%3jCH+1`wf2lK0#13+1|iqd#i`C z#m^4SKphI@F&`QL{c4Z2^)p~8OFoV2T^4~LaE zzjH(b@6T&pVKknPwrOaTSKOW&_Yodo{~CVOFZ`DD4{~5!rr(E?$ILm*v=`?l?R=>c zs(-S*F_GyQkTY`iCC^;^jB^!_>8Sqbhm3%_3s;uz2V_t3DXMEVF4Ev5fOKb%b!0vm zm5~N^3-?$-Mf2o{E{N-1p4Qp7`VYI(=7vB-!g}r-bfNUhsBr?Y`gH znkJuKlkj)ZRjGR>4{(>8)Wac00<0rGO~`1cLyh-0U@^NKdS8jlmGdZ=wzA81LpVaY zBIgOaeE%E{C55+~ks<@C3$a96K181xo)a`C!gN=gAqfeJSsI)~SoM-<-z4sJdhX!Z z<6a&;2J3E4=*ndtstiYAxCmQPM8LY2AIQ_=c9w-;>y#vS$>YK2Y`&H67tW^{wX}Zi zFv#Cp@SPjK1*+8JM+NI*_3Yu385aV!-tb1KIreKlijcco)m$~(Ynj{;6e5IkQ+Qm2O&i5zL_So7Lf`%fTb&@!ZrUc%=i zs(P&Z!x!OoLSLy~t_^xhr~=!_-u0R>OcPD4bN_ZF@@DByJECAM+^$TEHG3W63D#Q?g%O_NZ!! zy((o&0NUCP-P%_*=fK&;zsEJ+1j)j7-_p%`b4d|2hh7VQg;mjdHGz*$C`_ej^F`)? zvH3Ir{${%$3C2wqukR)8`NcE-#Gm{h)F6}`VZOD{HO;Pv+X@mVoqXS{-&q=Myo-U#3&$?PZDAjRDfVSUwj(m0T}CT z;b-{@OWbbcq)IwB;9k-%bd)CT4%_Y-qLdMFt6$D+H&@%;@-@<<{ZTY}lI(s1V}e zPK@Ui2VMefd${1Sb_G{u1tpCJ6-vvNw(E`TcrXrZ=*s@S;?av^KUTp_=HWV{iU>#h z^2BgTj#W9^hVBVJiCL@D#3q+S1EdV74hz~k5es0|aLNR3ls+1K&grVx)|;n4(P;X4 z^QNHccwI5$b1{C(>5wy#f$>=f(sSx0y67U6iHX%Xxc%J%^b%Erk7jVj=nM~d#8{QB zicaGYj|uh&{mP=mZbE)CoK@w63AkJ79(S0%ouvG~)ddx|@=S$$V!H zTrhgmS?16qIzlCSvY#X=b$tJTNjbuSPLh#ZEzzbnpDS7fIy0yS>D5|!3673;WQ*1D z^{z)r0CcU*g8*WM+j*GCCHDX!5qABGyUT^Sofb%~@qZ_!E|uhqZg8sFuhPq$V6xHm zASvMvwX$ijs63U2$2bApGD^F5J*UDXJre7kdT;Kk6X8$WU2Id0;(OCMb8E2a{U1fV zE6oxo^(M1Z9p6q>xlhgS(h~g6-xGf1m4Y89$uQxjn4>|+`_BAb)c&U3YagCcuo`Xp z8|1cb5V}}{#O>ngk9vYlQqGuVGJkaOv6TRWA4fJio%jZyAsVrRN7ox65`Oz0bO*v( z6!KR1+NJjv(w+Be_aSY5IEVsXNJ6{O1EU$&fdpOQwWhL2>+jM>3Kd@rgJ*SNZ$Ra!+N4c1j~)yWjU#fBa$ELqesC+s{Bj<1N0{LYU- z1Sh0G;;DlagqgGgoLt3jWShFjhH8W%{>{{nh_>W~n-F8)F^gSVSj7<+ZT|X16z)no zr`K0ngCO4)>Nb1vTF|Tk{m3&!64~%`xO_N$X$WkfQK9g2tvY4j>sq~8b_idFhpv%fGvv1S}lRDC-tVvl~o)rW;aR1`Gx*()N z;`{`lqg(*m^h<^I*#6f=RMTRP-E77U$veEt2?2y`Jn-XO`_yW1fC9a!dF&FMVTf%s zMuc?$qOs)BJ^ymc@=B|B$xL&yhGbO#UBPNqi4-#$>JR3VGc`JHtdFQpHZ74v{{K8h z5=}yKPc^U42#+1?XF1f_(p5R(#&2zZ@+8z27PQ25s%M{gTZMQIKdbjR&hS$dN0)b8 zTR*NotII4lDNf9WyD?M)2j$Gwz4Rdz|6zb_ws9CX&OQYQVIf9bbWocFL5*>dvOQy? zj@)k^?XYX}YUCbw-fqpozM)%S+`wn9O;U@|qQq>kkA-KoUMcZ5hyI5)*7;~NfctZ5 zjz+!J?yKQl?|sI+iTD1>fQ}b3eqZ~f1zSAYYs$+~FiNvrxyglm;TxL<8HTns%cWOTAm3O(@|~HA5=njaV~du*DMFLdx1=;Zw-0)# zYVCRZP=-Yy(sE4E;(i%ZjSGFO!r-sZD^jsz3w%qp~qh6ev_ z#SRW0A(E=O{zH?zyIc-qYufZQ`>^h1!Nj{qF2ZX{g$CJ@f;;VBzuJ<>sj{H)hk9II z@g!hU>|%e**Dj;#zN=girw_5UdEL)JrGxvp>u$|SGq0=;tmDbhBRgW0@Pu(CD?%1r z5-#{7eCc)H4}X<}UV*}M$o&HnIp8eRn7H-TyhOl*SP0Av@Ns@!E1CV3c_wKx8|=iW z&w61njpi!MxFx1}I!IqR$vbfK?ZO!ReONB9swb`SgMjL#S7d*zyTXQAEQoRA>?lPR zu6KiTSKD-~mN*>A`<`P#fFqyK zQO_i_%}Me4G(J%xw&`ekWL$t0Ywr7;aMBf^$$Knq+u~Ff-;BSjilPO-#I3kHlKCPo zDBjw)ffd8)8&k0|zHYd-V#O^%Pb~O>p*>$c{$yD503M|bvn$B>GEDA=qDWZksCV3V zxJ=2jb7ISgSzWTAx5}Ec$Dl?t@jV_Dw~%0qczV0N76DF&^7mGFNSqstac=m%dALUH zNpg&wh}Q@$9g|%qi-){)?|S!o^s2Ife`6z!8(q$Nb|E@hWy_&u{L<`Lo^537ud}Y5 z+v9Ol=Bb4oKN*jV*!u6;*3L|jAy5y8ruB2=FY8d7t?}CVZ#V$8M8xdbnmU1wYnutT zPoG5!tS*r?bbiyj9vw9n?rzi<_uunn;xLE`9y z*_J`o1RIpG3TGg$HwYd)fIBuD;i>k}@$a&4nb5hS2FVbq1?Zv)W(@pRSXNO)VmH7H zj`Yn{Gz_v!V>50vj0@a|HC-&gewU8COK*bZ&>3iI0HUr z`93Q{0=2{Ho9@0|Hy*!FW>2CiU>>8nFE-bV+xd}W`*&J=vpRn99d}Xb$jHR;g9|51 zuqCd)v>x!=x=>Ek3uM?Mc#nEj(m&skMcSY4IVRX98N}1|W`eG;Q5t_W=Yr}7`qb0^ z`IcXyvFLKSNFZ^IB=mqnTC(})F}3Au`-9+L8?8{?vlh|$uO?YS{toG+5%EF(;Q|Z~ zq%oOf?hn#w;q|L@4?Foyfw&7*1q({h18&$;iNfXc(99;#@bz*lyFZiYi7dxlxdf5p z1@kgw&HRx&Ni!yXxN&)HKea_FL9uSkBOkZg5NdNJ&CUIOoobpqE# zKn3-)4`ZwdE$ztfXnw&_g2#WkxFb7X>dGeMRwn4DV?FqWG$>?|Q9mBXc$!!6%9H_< zlyO7}P7wvvXdTK!VB=aj&jhp|QLa?jkEDuBs%kO*uXLHFHdrJwe4J>C{pNFJlI4<-mU3Y-_E*KP*-J=@8eNA>>|V z@0>~*wtsvfa%GV8>gPw+@b+-YP>h;i*1lGP-$yDnMkjaHIu+*WR#hqo{w$=dea2MU zAt`fP)#v=Sh2e2aYgp@3JZjSVo@7m*s{K|w{q=^L0X0wIhCH6h3J2VA^#ZgyZ-ST4 zCo_H%Hm3nZxR}8$VO0|w#hzNHS+}DFf4f0vrn1qK*kyb0c6mn^zPZ-y!wtEW-c={^ zH%2>L5-RD(dxvW_HDmVERg59@#Eoom-+ADoC#0CBvr0p_z)h=z@so92Hliy=(msYX znvRza^NQdFHxKOqI_`F%MuvmUGOQLL2eSP z#ITwAIMO#2i3GKW7%0qgA}a5IACmK@sgh(=AzrV=hZXZiwVZo@zlwIdqqr9u73$?5 z$V;sg8YZb8(Jcg$laI`_`LdPS8}=KeA;OFo+x(oqI@lN5flNOxB)#2wuB-)Tp~bW% zM5=^V(0`MhKd&ft85g|kTdhz)*RgzsF}WiUbQw@GT}R1)_hk`ANGp8%g>|pFQ4XUg zrVABx=`!Q4@P5@?$41QbvPi*~rz7`7SD;)p0ePOzL5+o3_D*_Xmg^9B9UBVn6Ojb2 z(p8$|fl}H&4bC1Fg%RC1Igzf=z&d;}hhxw-NPXE#{|o!_>7~&LMQu6Tw*}AN(aS`C zZ)aXEjKAIei(WBkIg@Svy+Hj$I}A*t9re~GKz-KF5qx8ZwQ@Bvue6bSYj!t3X;Zj_C ze!r5vm32dn4>ws>x3tukXiJM-t)kALoe)0^1Nrd)i|MllW4mB^9Eqr{$HAR?BP&*4 z^dwQ=7*AcJ92(j_J1?XGn79OTWy)SuIgIp<3&sP)9C3Kd7F~p20yGpNH}=wPlt}N- z1b+Y)yt}8ox)~%OTfjsExXH?!6s1I-)`F)5*a#%l`gSaA3={2y2 zzVtiUo{yF_V}q zwQr(;`keBLto`RuE}`pI@4!hS@9tscr(N%N++?hjJ)z`?I!~*tGdab=8?r;fe7>LJ zHe$al$>C~f6s)*x6O^K*&)qn-cS6G)h=}ex77sCGF*&91htMG5-}hr2j&?x2AXK># zv%9`wwg#_7qT^h)CMM;^VJHR+ABf{HWTkO)B1<4*Ce-%pZR5tz!#bs%sR)Pqlp{QJ zcV6e`?o8*}tP>9y2FmaL8}KgFA>J1?r#Z&MhTK)YC(tQw3&l84EDwV9iE=5vn&d|YHhGtgBkh5GR7tG#tgM5RI^<};7_|S|g{2}p;!Ck3Y&B)N zmkqx&*}Mrz4FQYmM7<56hY=C$P&qFg{fYjXZ-z&ts~Goi^=vFpx|7r)ZQ|%lG+LL_ z?DeSN751VN+9RntvK-O&sE3TAUXh@SkPZ__4ti~nJV8YqL0!_VA5!VY4vDuKMUlI8b3nBw6gD^eFXMF7la%1lb0Eu+WA(MC8^^inT2| z`Zkgx%N@fBD3Cd-U8alGqJC=a0&h1Q-cp@wPf%B~S*+}tM5R)EnOsXLa~XwOp&0P{ zDww~Z_w8361Ww2nuHA~nhk%`(r}D$hxTe4QM?UZB&*u^+c{g0J0{H^`k7S zC8CsCVbfUFM*9xiiAcd|0CE3E62s0KH}wskm>KHXU!ajKbb)PJWff&l7(8x6&Pw7! z7%Jj(u37Cq9HUg1=qU3#f@b70jYF&LLz;sOPPW^&>fpyWi;J*W1Ha=9G%wjBOo!wC zA%Dfkks>N zi(4X7<>hvBVjNk&8Y#Q!H*ZtEZU&1D7idq%t;s5^x}>m65#op~ws~qXq+*Re_3dsFuGl^JE_XSNiy$`ELa!qEsuQSyD7koI z#sVZL)G&Lh(4FS*Dz}pDpbv3DO8ITzq4E51H5>`)!s;BQq{ENMTk3+4jsHEu(lpvbd4Y_5^&iq^*byNadwQrL1<}2j<6zV z1ZNJ~Z-MWVMlD7^eb&rie0}*G+seH1`_DP2g~9mbiV!fFPE>3-*kvSwQ9LDRD7(Hi zgQkOEs`dr%k$#<7%&W;fZCjCzTurw3uX_{D`_`jF89gt8j+M6ehfDz9;X=OpUkHB~Aue_d3Y1@F4&q0& zuf>u%6kMZ_|fHh2iFmDk0 zM0&1uSp>MK_L=FHImqK~za&V$cNJLhbS+tKGLs;9ieE$~`dc&zeOtfH`X|`dY<|=L znl%|uXYdp^h@xYT0TW4u7iPUf_kL$g)JRf5W4m?E|I`2kg<&E4NDI6pf%8eg zuril-Z`_B_A=lcBn8<0~SgK5oqeBA;* zgHTr)Teduidgz5k5REA&QVWRkd>XkOUmtjeZ{gXDGKsB_CYRCK~;*DxMZ3MD1j3VIg0H z8FPeI1iez9f4+lWAiS?oJz0>JYDp^b_$CaDVI_nH>2-4hhmx03Gly1KnxO08ief&Q z4`4t*1`KGBOIr?g*%~7LIy>@G^0IgTJBJ17|2L<0yi)`{m_yg`B=s^&{P3^0&f_>% z1VV_e3$_n64DLgpBa)a0a1aA6`2X!gUn0IVDD6~X^pLi_!$rL@W7d39CZ0Yi)uq;W zdQ+oDQsqmxq6ecrCia>^B|p6CEJA;E@vjw6+PBV-4Hosp$g>n?d*T=(j;XAsesfGSuQ{o(@P)(C94{PRzPn6nM;(x7DdgAW5# zB%ChSylupyM0?5?9r*pK=jZ$*wG)_xCx*~PF^6cL-JiUa5qpkFT_x>*$gq%E-agYI zVQrQd*QMTg;Af_M{RVj)p>Se2E+PpF63`G7!_(vIc>px;3H%|w8&!>}jsGV$yhQL1 zcjiu1Pu6UwC|_aHqmYGa)VR^i4wqwJCeRC29hxOSvnK5!LNiqv0di7g>rY~+N-S_{6U#joSM~9^OT7--4p`s@&K0BS^yQeFW`)5Do zG3`9?w!46h)CbLVKbWXW9UHQ}LyXb78Vy#S@)+gb2gPRoDf3W}K)iZ&{ zmVb%#DZIkw1^dRMV7e4pIbq3Vr$XA+#y2XFzray z6@NyVh04M0>Rg54!cbBZOxh)NEW4lE-bYWA-&I~BI5g3Q+-yRMDfg>$(zQT|ARk+2*1j71 ze0nj9Wa0Rx0Ne1-Q{Ovg{=;CV76+FhN@%uY?EcSH68`W#AWFH=7l@9sUmn0Up0;)X z@HZ-xet+FVL8$Zi?P)p4;}}JtL*qBOZ|sd~A$3FH!{EPr)@_0lJx#v5qcu zY*~MRHYDf>Ob;2$E(9}YM=1W?^~hvM0$No(r2sKKX`LI6cs)%P{h=3)N> D*`gcQ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..10fd3be5b054661467bda7b9fc9373408454addc GIT binary patch literal 13496 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 YTR5H1se}CkZ?QE%Q(afBTE#Z(e{*j`=>Px# literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..819963833e01d4fe75586b3ee09df38766d1c76d GIT binary patch literal 102081 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 z