mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(enrichment): oximedia BPM/mood facts, mood search, and queue display (#863)
* feat(enrichment): oximedia BPM/mood facts, mood search, and queue UI Run client-side oximedia analysis after CPU seed and persist BPM, mood JSON, and searchable mood_tag facts. Add product mood groups (joy/sadness/dance/work/ romance) with Advanced Search filter on the local index, queue BPM/mood display, migration 008 mood_tag index, and refreshed licenses for oximedia crates. * fix(enrichment): keep mood_groups module comment in English * feat(search): virtual mood groups, anger filter, and Advanced Search UX Expand mood search via overlapping virtual groups (tag expansion only), add anger/Злость group, skip album/artist shortcuts for track-only filters, and simplify mood search UI (songs-only, hide type tabs). Fix CustomSelect spurious scrollbar on short option lists. * feat(analysis): unified track analysis plan and enqueue path Add TrackAnalysisPlan (waveform, LUFS, enrichment) with a single enqueue_track_analysis entry for all byte-backed triggers. Run enrichment when cache is full but library facts are missing; route playback, cache, and backfill through the planner. Fix browseTextSearch LocalSearchOpts tsc gap and remove obsolete read_seed_bytes_if_needed helper. * fix(analysis): wire playback dispatch, preload enrichment, and UI refresh Route stream, gapless, preload, and local-file playback through analysis_dispatch so BPM/mood enrichment runs when waveform/LUFS are already cached. Fix audio_preload cache-hit and hot-cache paths, emit preload-cancelled for retry, and add analysis:enrichment-updated plus content_cache_coverage key resolution. * fix(audio): preload local files from disk and stop analysis retry loop Seed hot/offline next tracks via LocalFilePlayback (512 MiB) instead of copying into the RAM preload slot. Keep bytePreloadingId set after preload-ready so progress ticks do not re-invoke audio_preload every second. * fix(enrichment): clippy, album bpm filter routing, and queue mood display Clippy-clean analysis_dispatch and engine imports; restrict track-derived album routing to mood_group/mood_tag only so bpm is skipped on album queries. Log enrichment plan errors with retry-all plan; filter queue mood labels to oximedia ids. * fix(enrichment): simplify mood_tag backfill branch in plan_track_enrichment Remove empty if-block; keep same behaviour when backfill fails and moods row exists. * docs: CHANGELOG and credits for track enrichment PR #863 * docs(credits): track enrichment PR #863 contributor line * chore(enrichment): clippy-clean plan branch and trim dead exports Collapse mood_tag backfill if for clippy; remove unused moodGroupById and OXIMEDIA_MOOD_LABELS re-exports; stop poll when server BPM is already known. * fix(enrichment): close R2/S1–S3 limits and Song Info BPM fallback Return TrackEnrichmentOutcome::Failed on oximedia errors so retries are not masked as complete; extract mood Advanced Search SQL, unify top-3 mood tag selection in mood_groups with TS invariant tests, and show measured BPM in Song Info when tag BPM is missing or zero. * fix(enrichment): restore offline coverage and show mood in Song Info Add unit tests for offline download cancel/clear registry after the analysis seed refactor dropped read_seed_bytes coverage; show localized mood labels in Song Info when library enrichment facts exist. * fix(enrichment): soft mood scoring and unblock offline cancel tests Replace oximedia quadrant happy/excited mapping with valence/arousal soft scores across all mood tags for display, storage, and backfill; fix offline cancel unit tests that deadlocked by calling clear while holding the global offline_cancel_flags mutex. * fix(enrichment): dedupe joy cluster and cap mood display at two labels Never show happy and excited together; pick one tag per V/A cluster, tighten oximedia recalibration, and limit queue/Song Info to two moods that pass a relative score floor. * fix(enrichment): disable oximedia mood labels in UI and search tags Oximedia 0.1.7 mood is a spectral energy heuristic, not independent mood weights; valence correlates with loud/bright audio and false-labels metal and lyrical tracks as happy. Hide queue/Song Info mood and stop writing mood_tag facts until a reliable detector lands; keep V/A/moods JSON stored. * fix(enrichment): disable oximedia mood analysis and add BPM advanced search Stop planning, running, and storing oximedia mood facts; purge accumulated mood rows via migration 009. Hide mood filters in Advanced Search, expose BPM range filter with dual-storage resolution, and show a BPM column in song results when that filter is active. * feat(search): analysis BPM priority, source tooltip, and filter UX Prefer analysis track_fact over file tags for BPM resolution; show source in list tooltips. Validate BPM range on blur, add clear button, fix double tooltip. * fix(enrichment): prefer analysis BPM in Song Info and queue tech row Show measured track_fact BPM before file tags until analysis completes; pick the highest-confidence analysis fact when several exist.
This commit is contained in:
@@ -40,6 +40,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Track enrichment — oximedia BPM/mood, mood search, queue display
|
||||||
|
|
||||||
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#863](https://github.com/Psychotoxical/psysonic/pull/863)**
|
||||||
|
|
||||||
|
* **Analysis:** client-side oximedia pass (60s center window) writes BPM, valence, arousal, moods JSON, and mood_tag facts to the local library index; unified playback→analysis dispatch covers stream, hot/offline files, preload, and gapless.
|
||||||
|
* **Queue:** measured BPM and top mood labels when the playback server's index is enabled; `analysis:enrichment-updated` refreshes the UI without waiting for poll.
|
||||||
|
* **Advanced Search:** virtual mood groups (joy, sadness, dance, work, romance, anger) filter via local index + mood_tag rows (migration 008).
|
||||||
|
* Mood search requires persisted mood_tag facts; queue display may still show labels from valence/arousal fallback before tags land.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Servers — edit existing profiles
|
### Servers — edit existing profiles
|
||||||
|
|
||||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#780](https://github.com/Psychotoxical/psysonic/pull/780)**
|
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#780](https://github.com/Psychotoxical/psysonic/pull/780)**
|
||||||
|
|||||||
Generated
+272
@@ -32,6 +32,12 @@ dependencies = [
|
|||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "allocator-api2"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alsa"
|
name = "alsa"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -334,6 +340,43 @@ version = "1.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "audio-core"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f93ebbf82d06013f4c41fe71303feb980cddd78496d904d06be627972de51a24"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "audioadapter"
|
||||||
|
version = "3.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91f87b70b051c5866680ad79f6743a42ccab264c009d1a71f4d33a3872ae60c8"
|
||||||
|
dependencies = [
|
||||||
|
"audio-core",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "audioadapter-buffers"
|
||||||
|
version = "3.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9097d67933fb083d382ce980430afdb758aada60846010aee6be068c06cef0ca"
|
||||||
|
dependencies = [
|
||||||
|
"audioadapter",
|
||||||
|
"audioadapter-sample",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "audioadapter-sample"
|
||||||
|
version = "3.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34ab94f2bc04a14e1f49ee5f222f66460e8a1b51627bdfedf34eed394d747938"
|
||||||
|
dependencies = [
|
||||||
|
"audio-core",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -865,6 +908,25 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-deque"
|
||||||
|
version = "0.8.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-epoch",
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-epoch"
|
||||||
|
version = "0.9.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.21"
|
version = "0.8.21"
|
||||||
@@ -1233,6 +1295,12 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
version = "3.0.9"
|
version = "3.0.9"
|
||||||
@@ -1997,6 +2065,11 @@ name = "hashbrown"
|
|||||||
version = "0.17.0"
|
version = "0.17.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||||
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
|
"foldhash 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashlink"
|
name = "hashlink"
|
||||||
@@ -3387,6 +3460,88 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oxifft"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc0fe3b5f76266f8b860d18c307cacd467213b257dc3098641e19bc3ac17350b"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.17.0",
|
||||||
|
"libm",
|
||||||
|
"num-complex",
|
||||||
|
"num-traits",
|
||||||
|
"oxifft-codegen",
|
||||||
|
"rayon",
|
||||||
|
"seahash",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"spin",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oxifft-codegen"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b6627e29854ccb428fbf56e95c1970882096a53dae8954759ab09eb5d62f8d5"
|
||||||
|
dependencies = [
|
||||||
|
"oxifft-codegen-impl",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oxifft-codegen-impl"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "660807d8bbd7453e595aca8523d6f6991dcba0ebd3509f2e5e0b98c1c348e920"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oximedia-audio"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0408efebc08a1df406d8e56e4b47c19bbb2b69109f1298d943a8d6a435f57b9"
|
||||||
|
dependencies = [
|
||||||
|
"audioadapter",
|
||||||
|
"audioadapter-buffers",
|
||||||
|
"bytes",
|
||||||
|
"oxifft",
|
||||||
|
"oximedia-core",
|
||||||
|
"rubato",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oximedia-core"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fb84c059565d2e515e8f7bf655dbda9719237cb4ec915de968bb599f9208efc1"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"serde",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oximedia-mir"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3dbabdb669520236114b2c9d009372a1e9ab7c71725e85aa3f9a465315239343"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"oxifft",
|
||||||
|
"oximedia-audio",
|
||||||
|
"oximedia-core",
|
||||||
|
"rayon",
|
||||||
|
"serde",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -3683,6 +3838,15 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "primal-check"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -3801,6 +3965,7 @@ dependencies = [
|
|||||||
"ebur128",
|
"ebur128",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"md5",
|
"md5",
|
||||||
|
"oximedia-mir",
|
||||||
"psysonic-core",
|
"psysonic-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -4063,6 +4228,35 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rayon"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
"rayon-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rayon-core"
|
||||||
|
version = "1.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-deque",
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "realfft"
|
||||||
|
version = "3.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677"
|
||||||
|
dependencies = [
|
||||||
|
"rustfft",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -4256,6 +4450,22 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rubato"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce96ead1a91f7895704a9f08ea5947dfc8bd7c1f2936a22295b655ec67e5c6ef"
|
||||||
|
dependencies = [
|
||||||
|
"audioadapter",
|
||||||
|
"audioadapter-buffers",
|
||||||
|
"num-complex",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
"realfft",
|
||||||
|
"visibility",
|
||||||
|
"windowfunctions",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rusqlite"
|
||||||
version = "0.39.0"
|
version = "0.39.0"
|
||||||
@@ -4286,6 +4496,20 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustfft"
|
||||||
|
version = "6.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
|
||||||
|
dependencies = [
|
||||||
|
"num-complex",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
"primal-check",
|
||||||
|
"strength_reduce",
|
||||||
|
"transpose",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "0.37.28"
|
version = "0.37.28"
|
||||||
@@ -4489,6 +4713,12 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "seahash"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -4907,6 +5137,12 @@ dependencies = [
|
|||||||
"zvariant 3.15.2",
|
"zvariant 3.15.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spin"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1527984ca054dfca79333baec451042863f485fbee01b7bf6d911de915cac865"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlite-wasm-rs"
|
name = "sqlite-wasm-rs"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -4931,6 +5167,12 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strength_reduce"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "string_cache"
|
name = "string_cache"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -6097,6 +6339,16 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "transpose"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"strength_reduce",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tray-icon"
|
name = "tray-icon"
|
||||||
version = "0.23.1"
|
version = "0.23.1"
|
||||||
@@ -6295,6 +6547,17 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "visibility"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vswhom"
|
name = "vswhom"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -6639,6 +6902,15 @@ dependencies = [
|
|||||||
"windows-version",
|
"windows-version",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windowfunctions"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "90628d739333b7c5d2ee0b70210b97b8cddc38440c682c96fd9e2c24c2db5f3a"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.44.0"
|
version = "0.44.0"
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ ebur128 = "0.1"
|
|||||||
md5 = "0.8"
|
md5 = "0.8"
|
||||||
rusqlite = { version = "0.39", features = ["bundled"] }
|
rusqlite = { version = "0.39", features = ["bundled"] }
|
||||||
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
|
||||||
|
oximedia-mir = { version = "0.1.7", default-features = false, features = ["tempo", "mood"] }
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ use ebur128::{EbuR128, Mode as Ebur128Mode};
|
|||||||
use symphonia::core::audio::SampleBuffer;
|
use symphonia::core::audio::SampleBuffer;
|
||||||
use symphonia::core::codecs::{Decoder, DecoderOptions, CODEC_TYPE_NULL};
|
use symphonia::core::codecs::{Decoder, DecoderOptions, CODEC_TYPE_NULL};
|
||||||
use symphonia::core::errors::Error as SymphoniaError;
|
use symphonia::core::errors::Error as SymphoniaError;
|
||||||
use symphonia::core::formats::{FormatOptions, FormatReader};
|
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo};
|
||||||
use symphonia::core::io::MediaSourceStream;
|
use symphonia::core::io::MediaSourceStream;
|
||||||
use symphonia::core::meta::MetadataOptions;
|
use symphonia::core::meta::MetadataOptions;
|
||||||
use symphonia::core::probe::Hint;
|
use symphonia::core::probe::Hint;
|
||||||
|
use symphonia::core::units::Time;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
use super::store::{now_unix_ts, AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry};
|
use super::store::{now_unix_ts, AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry};
|
||||||
@@ -69,6 +70,14 @@ pub fn seed_from_bytes_execute(
|
|||||||
sink.record_content_hash(server_id, track_id, &md5_16kb);
|
sink.record_content_hash(server_id, track_id, &md5_16kb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !server_id.is_empty() {
|
||||||
|
let _ = crate::track_enrichment::run_track_enrichment_if_needed(
|
||||||
|
app,
|
||||||
|
server_id,
|
||||||
|
track_id,
|
||||||
|
bytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,24 +103,22 @@ pub fn seed_from_bytes_into_cache(
|
|||||||
track_id: track_id.to_string(),
|
track_id: track_id.to_string(),
|
||||||
md5_16kb: md5_first_16kb(bytes),
|
md5_16kb: md5_first_16kb(bytes),
|
||||||
};
|
};
|
||||||
if let Some(existing) = cache.get_waveform(&key)? {
|
let coverage = cache.content_cache_coverage(server_id, track_id, &key.md5_16kb)?;
|
||||||
if !existing.bins.is_empty() {
|
if coverage.complete() {
|
||||||
if cache.loudness_row_exists_for_key(&key)? {
|
crate::app_deprintln!(
|
||||||
crate::app_deprintln!(
|
"[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} elapsed_ms={}",
|
||||||
"[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} bins_len={} elapsed_ms={}",
|
track_id,
|
||||||
track_id,
|
key.md5_16kb,
|
||||||
key.md5_16kb,
|
started.elapsed().as_millis()
|
||||||
existing.bins.len(),
|
);
|
||||||
started.elapsed().as_millis()
|
return Ok((SeedFromBytesOutcome::SkippedWaveformCacheHit, key.md5_16kb.clone()));
|
||||||
);
|
}
|
||||||
return Ok((SeedFromBytesOutcome::SkippedWaveformCacheHit, key.md5_16kb.clone()));
|
if coverage.has_waveform && !coverage.has_loudness {
|
||||||
}
|
crate::app_deprintln!(
|
||||||
crate::app_deprintln!(
|
"[analysis][waveform] waveform cache hit but loudness missing — full re-analysis track_id={} md5_16kb={}",
|
||||||
"[analysis][waveform] waveform cache hit but loudness missing — full re-analysis track_id={} md5_16kb={}",
|
track_id,
|
||||||
track_id,
|
key.md5_16kb
|
||||||
key.md5_16kb
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let mib = bytes.len() as f64 / (1024.0 * 1024.0);
|
let mib = bytes.len() as f64 / (1024.0 * 1024.0);
|
||||||
crate::app_deprintln!(
|
crate::app_deprintln!(
|
||||||
@@ -195,7 +202,7 @@ pub fn seed_from_bytes_into_cache(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn md5_first_16kb(bytes: &[u8]) -> String {
|
pub fn md5_first_16kb(bytes: &[u8]) -> String {
|
||||||
let n = bytes.len().min(16 * 1024);
|
let n = bytes.len().min(16 * 1024);
|
||||||
format!("{:x}", md5::compute(&bytes[..n]))
|
format!("{:x}", md5::compute(&bytes[..n]))
|
||||||
}
|
}
|
||||||
@@ -529,6 +536,169 @@ fn decode_scan_pcm(
|
|||||||
Some(PcmScanResult { bins, loudness })
|
Some(PcmScanResult { bins, loudness })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PCM window for short MIR-style analysis (typically 60 s from track center).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct PcmAnalysisWindow {
|
||||||
|
pub start_sec: f64,
|
||||||
|
pub duration_sec: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick a centered analysis window, or the full track when shorter than `window_sec`.
|
||||||
|
pub fn analysis_pcm_window(total_duration_sec: f64, window_sec: f64) -> PcmAnalysisWindow {
|
||||||
|
let total = total_duration_sec.max(0.0);
|
||||||
|
let window = window_sec.max(0.1);
|
||||||
|
if total <= window || !total.is_finite() {
|
||||||
|
return PcmAnalysisWindow {
|
||||||
|
start_sec: 0.0,
|
||||||
|
duration_sec: if total > 0.0 { total } else { window },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let start = ((total - window) / 2.0).max(0.0);
|
||||||
|
PcmAnalysisWindow {
|
||||||
|
start_sec: start,
|
||||||
|
duration_sec: window,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort container duration from codec metadata (seconds).
|
||||||
|
pub fn audio_duration_from_bytes(bytes: &[u8]) -> Option<f64> {
|
||||||
|
let session = open_decode_session(bytes)?;
|
||||||
|
let sample_rate = session
|
||||||
|
.format
|
||||||
|
.default_track()
|
||||||
|
.or_else(|| session.format.tracks().first())
|
||||||
|
.and_then(|t| t.codec_params.sample_rate)
|
||||||
|
.filter(|&sr| sr > 0)?;
|
||||||
|
let frames = session.timeline_hint?;
|
||||||
|
Some(frames as f64 / sample_rate as f64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode mono PCM for a time window. Seeks when `start_sec > 0`.
|
||||||
|
pub fn decode_mono_pcm_window(
|
||||||
|
bytes: &[u8],
|
||||||
|
start_sec: f64,
|
||||||
|
window_sec: f64,
|
||||||
|
) -> Result<(Vec<f32>, f32), String> {
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Err("empty audio buffer".to_string());
|
||||||
|
}
|
||||||
|
let DecodeSession {
|
||||||
|
mut format,
|
||||||
|
mut decoder,
|
||||||
|
track_id,
|
||||||
|
..
|
||||||
|
} = open_decode_session(bytes).ok_or_else(|| "failed to open audio decode session".to_string())?;
|
||||||
|
|
||||||
|
if start_sec.is_finite() && start_sec > 0.0 {
|
||||||
|
let time: Time = start_sec.max(0.0).into();
|
||||||
|
format
|
||||||
|
.seek(
|
||||||
|
SeekMode::Accurate,
|
||||||
|
SeekTo::Time {
|
||||||
|
time,
|
||||||
|
track_id: Some(track_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("pcm window seek failed: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
decode_mono_pcm_from_session(&mut format, &mut decoder, track_id, Some(window_sec))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode audio bytes to mono f32 PCM, optionally capped at `max_seconds`.
|
||||||
|
pub fn decode_mono_pcm_limited(
|
||||||
|
bytes: &[u8],
|
||||||
|
max_seconds: Option<f64>,
|
||||||
|
) -> Result<(Vec<f32>, f32), String> {
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Err("empty audio buffer".to_string());
|
||||||
|
}
|
||||||
|
let DecodeSession {
|
||||||
|
mut format,
|
||||||
|
mut decoder,
|
||||||
|
track_id,
|
||||||
|
..
|
||||||
|
} = open_decode_session(bytes).ok_or_else(|| "failed to open audio decode session".to_string())?;
|
||||||
|
decode_mono_pcm_from_session(&mut format, &mut decoder, track_id, max_seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_mono_pcm_from_session(
|
||||||
|
format: &mut Box<dyn FormatReader>,
|
||||||
|
decoder: &mut Box<dyn Decoder>,
|
||||||
|
track_id: u32,
|
||||||
|
max_seconds: Option<f64>,
|
||||||
|
) -> Result<(Vec<f32>, f32), String> {
|
||||||
|
let mut mono = Vec::new();
|
||||||
|
let mut sample_rate = 0_f32;
|
||||||
|
let mut max_frames: Option<u64> = None;
|
||||||
|
let mut loop_i: u32 = 0;
|
||||||
|
|
||||||
|
while let Ok(packet) = format.next_packet() {
|
||||||
|
if packet.track_id() != track_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let decoded = match decoder.decode(&packet) {
|
||||||
|
Ok(buf) => buf,
|
||||||
|
Err(SymphoniaError::DecodeError(_)) => continue,
|
||||||
|
Err(SymphoniaError::ResetRequired) => break,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
let spec = *decoded.spec();
|
||||||
|
let n_ch = spec.channels.count();
|
||||||
|
if n_ch == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if sample_rate <= 0.0 {
|
||||||
|
sample_rate = spec.rate as f32;
|
||||||
|
if sample_rate <= 0.0 {
|
||||||
|
return Err("invalid sample rate".to_string());
|
||||||
|
}
|
||||||
|
max_frames = max_seconds.and_then(|sec| {
|
||||||
|
if sec.is_finite() && sec > 0.0 {
|
||||||
|
Some((sec * sample_rate as f64).max(1.0) as u64)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||||||
|
samples.copy_interleaved_ref(decoded);
|
||||||
|
let slice = samples.samples();
|
||||||
|
if slice.len() < n_ch || !slice.len().is_multiple_of(n_ch) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let frames = slice.len() / n_ch;
|
||||||
|
for f in 0..frames {
|
||||||
|
if let Some(limit) = max_frames {
|
||||||
|
if mono.len() as u64 >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let base = f * n_ch;
|
||||||
|
let mut acc = 0.0_f32;
|
||||||
|
for c in 0..n_ch {
|
||||||
|
acc += slice[base + c];
|
||||||
|
}
|
||||||
|
mono.push(acc / (n_ch as f32));
|
||||||
|
}
|
||||||
|
if max_frames.is_some_and(|limit| mono.len() as u64 >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
loop_i = loop_i.wrapping_add(1);
|
||||||
|
if loop_i.is_multiple_of(128) {
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mono.is_empty() {
|
||||||
|
return Err("no PCM frames decoded".to_string());
|
||||||
|
}
|
||||||
|
Ok((mono, sample_rate))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -561,6 +731,20 @@ mod tests {
|
|||||||
assert_eq!(huge_down, -24.0);
|
assert_eq!(huge_down, -24.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn analysis_pcm_window_uses_center_for_long_tracks() {
|
||||||
|
let w = analysis_pcm_window(180.0, 60.0);
|
||||||
|
assert!((w.start_sec - 60.0).abs() < 1e-9);
|
||||||
|
assert!((w.duration_sec - 60.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn analysis_pcm_window_uses_full_track_when_short() {
|
||||||
|
let w = analysis_pcm_window(45.0, 60.0);
|
||||||
|
assert_eq!(w.start_sec, 0.0);
|
||||||
|
assert!((w.duration_sec - 45.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
// ── md5_first_16kb ────────────────────────────────────────────────────────
|
// ── md5_first_16kb ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ mod compute;
|
|||||||
mod store;
|
mod store;
|
||||||
|
|
||||||
pub use compute::{
|
pub use compute::{
|
||||||
recommended_gain_for_target, seed_from_bytes_execute, seed_from_bytes_into_cache,
|
analysis_pcm_window, audio_duration_from_bytes, decode_mono_pcm_limited,
|
||||||
SeedFromBytesOutcome,
|
decode_mono_pcm_window, md5_first_16kb, recommended_gain_for_target,
|
||||||
|
seed_from_bytes_execute, seed_from_bytes_into_cache, PcmAnalysisWindow, SeedFromBytesOutcome,
|
||||||
};
|
};
|
||||||
pub use store::{AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry};
|
pub use store::{AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry};
|
||||||
|
|||||||
@@ -39,6 +39,20 @@ pub struct TrackKey {
|
|||||||
pub md5_16kb: String,
|
pub md5_16kb: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Waveform / loudness rows present for a specific content fingerprint
|
||||||
|
/// (`md5_16kb`), after track-id variant + legacy server fallback.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ContentCacheCoverage {
|
||||||
|
pub has_waveform: bool,
|
||||||
|
pub has_loudness: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContentCacheCoverage {
|
||||||
|
pub fn complete(self) -> bool {
|
||||||
|
self.has_waveform && self.has_loudness
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct WaveformEntry {
|
pub struct WaveformEntry {
|
||||||
pub bins: Vec<u8>,
|
pub bins: Vec<u8>,
|
||||||
@@ -306,6 +320,59 @@ impl AnalysisCache {
|
|||||||
Ok(row.filter(|e| waveform_cache_blob_len_ok(&e.bins, e.bin_count)))
|
Ok(row.filter(|e| waveform_cache_blob_len_ok(&e.bins, e.bin_count)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lookup waveform + loudness for an exact content fingerprint, trying bare /
|
||||||
|
/// `stream:` track-id variants and the legacy `''` server pool (with lazy
|
||||||
|
/// re-tag onto `server_id` when a legacy hit occurs).
|
||||||
|
pub fn content_cache_coverage(
|
||||||
|
&self,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
md5_16kb: &str,
|
||||||
|
) -> Result<ContentCacheCoverage, String> {
|
||||||
|
let mut has_waveform = false;
|
||||||
|
let mut has_loudness = false;
|
||||||
|
let mut relabel = false;
|
||||||
|
for tid in track_id_cache_variants(track_id) {
|
||||||
|
if !server_id.is_empty() {
|
||||||
|
let key = TrackKey {
|
||||||
|
server_id: server_id.to_string(),
|
||||||
|
track_id: tid.clone(),
|
||||||
|
md5_16kb: md5_16kb.to_string(),
|
||||||
|
};
|
||||||
|
if self.get_waveform(&key)?.is_some() {
|
||||||
|
has_waveform = true;
|
||||||
|
}
|
||||||
|
if self.loudness_row_exists_for_key(&key)? {
|
||||||
|
has_loudness = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let legacy = TrackKey {
|
||||||
|
server_id: String::new(),
|
||||||
|
track_id: tid,
|
||||||
|
md5_16kb: md5_16kb.to_string(),
|
||||||
|
};
|
||||||
|
if self.get_waveform(&legacy)?.is_some() {
|
||||||
|
has_waveform = true;
|
||||||
|
if !server_id.is_empty() {
|
||||||
|
relabel = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.loudness_row_exists_for_key(&legacy)? {
|
||||||
|
has_loudness = true;
|
||||||
|
if !server_id.is_empty() {
|
||||||
|
relabel = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if relabel {
|
||||||
|
let _ = self.relabel_legacy_to_server(server_id, track_id);
|
||||||
|
}
|
||||||
|
Ok(ContentCacheCoverage {
|
||||||
|
has_waveform,
|
||||||
|
has_loudness,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// True when this exact `(track_id, md5_16kb)` has a loudness row for the current algo version.
|
/// True when this exact `(track_id, md5_16kb)` has a loudness row for the current algo version.
|
||||||
/// Used after `delete_loudness_for_track_id`: waveform may still be cached, but EBU data was removed.
|
/// Used after `delete_loudness_for_track_id`: waveform may still be cached, but EBU data was removed.
|
||||||
pub fn loudness_row_exists_for_key(&self, key: &TrackKey) -> Result<bool, String> {
|
pub fn loudness_row_exists_for_key(&self, key: &TrackKey) -> Result<bool, String> {
|
||||||
@@ -356,6 +423,25 @@ impl AnalysisCache {
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Latest `md5_16kb` fingerprint for `(server_id, track_id)` with legacy fallback.
|
||||||
|
pub fn get_latest_md5_16kb_for_track(
|
||||||
|
&self,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||||
|
if let Some(md5) = query_latest_md5_16kb_scoped(&conn, server_id, track_id)? {
|
||||||
|
return Ok(Some(md5));
|
||||||
|
}
|
||||||
|
if !server_id.is_empty() {
|
||||||
|
if let Some(md5) = query_latest_md5_16kb_scoped(&conn, "", track_id)? {
|
||||||
|
let _ = relabel_legacy_to_server(&conn, server_id, track_id);
|
||||||
|
return Ok(Some(md5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
/// Both waveform and loudness rows exist for this `(server_id, track_id)`
|
/// Both waveform and loudness rows exist for this `(server_id, track_id)`
|
||||||
/// (including the legacy `''` fallback) — a CPU seed from bytes/file would
|
/// (including the legacy `''` fallback) — a CPU seed from bytes/file would
|
||||||
/// only decode the file to immediately skip with `SkippedWaveformCacheHit`.
|
/// only decode the file to immediately skip with `SkippedWaveformCacheHit`.
|
||||||
@@ -445,6 +531,40 @@ fn query_latest_waveform_scoped(
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn query_latest_md5_16kb_scoped(
|
||||||
|
conn: &Connection,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
const SQL: &str = r#"
|
||||||
|
SELECT w.md5_16kb
|
||||||
|
FROM waveform_cache w
|
||||||
|
JOIN analysis_track a
|
||||||
|
ON a.server_id = w.server_id
|
||||||
|
AND a.track_id = w.track_id
|
||||||
|
AND a.md5_16kb = w.md5_16kb
|
||||||
|
WHERE w.server_id = ?1
|
||||||
|
AND w.track_id = ?2
|
||||||
|
AND a.waveform_algo_version = ?3
|
||||||
|
ORDER BY w.updated_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"#;
|
||||||
|
for tid in track_id_cache_variants(track_id) {
|
||||||
|
let row: Option<String> = conn
|
||||||
|
.query_row(SQL, params![server_id, tid, WAVEFORM_ALGO_VERSION], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if let Some(md5) = row {
|
||||||
|
if !md5.is_empty() {
|
||||||
|
return Ok(Some(md5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
/// Server-scoped variant of the "latest loudness for this track" lookup.
|
/// Server-scoped variant of the "latest loudness for this track" lookup.
|
||||||
fn query_latest_loudness_scoped(
|
fn query_latest_loudness_scoped(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
|
|||||||
@@ -126,44 +126,125 @@ pub fn analysis_backfill_shared(app: &tauri::AppHandle) -> Arc<AnalysisBackfillS
|
|||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode `bytes` for `track_id` via the cpu-seed queue. Returns `Ok(true)` when
|
use crate::track_analysis_plan::plan_track_analysis;
|
||||||
/// a loudness row exists in the cache after the seed (cache-hit short-circuits as
|
|
||||||
/// well as fresh decode hits).
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum EnqueueTrackAnalysisOutcome {
|
||||||
|
/// Waveform, LUFS, and enrichment facts are all current.
|
||||||
|
Complete,
|
||||||
|
/// Symphonia full-file decode queued (enrichment runs after seed when needed).
|
||||||
|
QueuedFullSeed,
|
||||||
|
/// Oximedia pass ran inline (waveform + LUFS already cached).
|
||||||
|
RanEnrichmentOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Single entry point** for byte-backed track analysis.
|
||||||
|
///
|
||||||
|
/// 1. Plan: waveform / LUFS gaps in analysis cache + enrichment facts in library.
|
||||||
|
/// 2. If nothing missing → no-op.
|
||||||
|
/// 3. If waveform or LUFS missing → CPU seed queue (Symphonia + EBU R128).
|
||||||
|
/// 4. Else if enrichment missing → oximedia 60 s window only.
|
||||||
|
pub async fn enqueue_track_analysis(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
high_priority: bool,
|
||||||
|
) -> Result<EnqueueTrackAnalysisOutcome, String> {
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Ok(EnqueueTrackAnalysisOutcome::Complete);
|
||||||
|
}
|
||||||
|
let content_hash = analysis_cache::md5_first_16kb(bytes);
|
||||||
|
let plan = plan_track_analysis(app, server_id, track_id, &content_hash);
|
||||||
|
if !plan.any() {
|
||||||
|
crate::app_deprintln!(
|
||||||
|
"[analysis] track complete track_id={} hash={}",
|
||||||
|
track_id,
|
||||||
|
content_hash
|
||||||
|
);
|
||||||
|
return Ok(EnqueueTrackAnalysisOutcome::Complete);
|
||||||
|
}
|
||||||
|
if plan.needs_full_cpu_seed() {
|
||||||
|
crate::app_deprintln!(
|
||||||
|
"[analysis] queue full seed track_id={} hash={} need_waveform={} need_loudness={} need_enrichment={}",
|
||||||
|
track_id,
|
||||||
|
content_hash,
|
||||||
|
plan.need_waveform,
|
||||||
|
plan.need_loudness,
|
||||||
|
plan.enrichment.any()
|
||||||
|
);
|
||||||
|
submit_analysis_cpu_seed(
|
||||||
|
app.clone(),
|
||||||
|
server_id.to_string(),
|
||||||
|
track_id.to_string(),
|
||||||
|
bytes.to_vec(),
|
||||||
|
high_priority,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(EnqueueTrackAnalysisOutcome::QueuedFullSeed);
|
||||||
|
}
|
||||||
|
if plan.needs_enrichment_only() {
|
||||||
|
crate::app_deprintln!(
|
||||||
|
"[analysis] enrichment-only track_id={} hash={}",
|
||||||
|
track_id,
|
||||||
|
content_hash
|
||||||
|
);
|
||||||
|
run_track_enrichment_from_bytes(app, server_id, track_id, bytes).await;
|
||||||
|
return Ok(EnqueueTrackAnalysisOutcome::RanEnrichmentOnly);
|
||||||
|
}
|
||||||
|
Ok(EnqueueTrackAnalysisOutcome::Complete)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-export for HTTP backfill gate (no bytes yet).
|
||||||
|
pub use crate::track_analysis_plan::track_analysis_needs_work;
|
||||||
|
|
||||||
|
/// Oximedia BPM/mood pass only — prefer [`enqueue_track_analysis`].
|
||||||
|
pub async fn run_track_enrichment_from_bytes(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
) {
|
||||||
|
if server_id.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let app = app.clone();
|
||||||
|
let sid = server_id.to_string();
|
||||||
|
let tid = track_id.to_string();
|
||||||
|
let data = bytes.to_vec();
|
||||||
|
let _ = tokio::task::spawn_blocking(move || {
|
||||||
|
crate::track_enrichment::run_track_enrichment_if_needed(&app, &sid, &tid, &data);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a local file and run [`enqueue_track_analysis`] (hot cache, offline, spill promote).
|
||||||
|
pub async fn enqueue_track_analysis_from_file(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
file_path: &std::path::Path,
|
||||||
|
high_priority: bool,
|
||||||
|
) -> Result<EnqueueTrackAnalysisOutcome, String> {
|
||||||
|
let bytes = tokio::fs::read(file_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Ok(EnqueueTrackAnalysisOutcome::Complete);
|
||||||
|
}
|
||||||
|
enqueue_track_analysis(app, server_id, track_id, &bytes, high_priority).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`].
|
||||||
pub async fn enqueue_analysis_seed(
|
pub async fn enqueue_analysis_seed(
|
||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
server_id: &str,
|
server_id: &str,
|
||||||
track_id: &str,
|
track_id: &str,
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
|
|
||||||
if cache.cpu_seed_redundant_for_track(server_id, track_id).unwrap_or(false) {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let high = analysis_backfill_is_current_track(app, track_id);
|
let high = analysis_backfill_is_current_track(app, track_id);
|
||||||
let outcome = submit_analysis_cpu_seed(
|
let outcome = enqueue_track_analysis(app, server_id, track_id, bytes, high).await?;
|
||||||
app.clone(),
|
Ok(!matches!(outcome, EnqueueTrackAnalysisOutcome::Complete))
|
||||||
server_id.to_string(),
|
|
||||||
track_id.to_string(),
|
|
||||||
bytes.to_vec(),
|
|
||||||
high,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
crate::app_eprintln!("[analysis] failed to seed {}: {}", track_id, e);
|
|
||||||
e
|
|
||||||
})?;
|
|
||||||
let has_loudness = app
|
|
||||||
.try_state::<analysis_cache::AnalysisCache>()
|
|
||||||
.and_then(|cache| cache.get_latest_loudness_for_track(server_id, track_id).ok().flatten())
|
|
||||||
.is_some();
|
|
||||||
crate::app_deprintln!(
|
|
||||||
"[analysis] seed result track_id={} bytes={} has_loudness={} outcome={outcome:?}",
|
|
||||||
track_id,
|
|
||||||
bytes.len(),
|
|
||||||
has_loudness
|
|
||||||
);
|
|
||||||
Ok(has_loudness)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn analysis_backfill_download_and_seed(
|
async fn analysis_backfill_download_and_seed(
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use psysonic_core::ports::PlaybackQueryHandle;
|
|||||||
use crate::analysis_cache;
|
use crate::analysis_cache;
|
||||||
use crate::analysis_runtime::{
|
use crate::analysis_runtime::{
|
||||||
analysis_backfill_is_current_track, analysis_backfill_shared, prune_analysis_queues,
|
analysis_backfill_is_current_track, analysis_backfill_shared, prune_analysis_queues,
|
||||||
AnalysisBackfillEnqueueKind,
|
track_analysis_needs_work, AnalysisBackfillEnqueueKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
@@ -228,12 +228,25 @@ pub fn analysis_enqueue_seed_from_url(
|
|||||||
}
|
}
|
||||||
if !force {
|
if !force {
|
||||||
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
|
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
|
||||||
if cache.get_latest_loudness_for_track(&server_id, &track_id)?.is_some() {
|
if cache.cpu_seed_redundant_for_track(&server_id, &track_id)? {
|
||||||
|
if server_id.is_empty() {
|
||||||
|
crate::app_deprintln!(
|
||||||
|
"[analysis] backfill skip (no server scope): {}",
|
||||||
|
track_id
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if !track_analysis_needs_work(&app, &server_id, &track_id)? {
|
||||||
|
crate::app_deprintln!(
|
||||||
|
"[analysis] backfill skip (analysis complete): {}",
|
||||||
|
track_id
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
crate::app_deprintln!(
|
crate::app_deprintln!(
|
||||||
"[analysis] backfill skip (already cached): {}",
|
"[analysis] backfill enqueue (analysis pending) track_id={}",
|
||||||
track_id
|
track_id
|
||||||
);
|
);
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
pub mod analysis_cache;
|
pub mod analysis_cache;
|
||||||
pub mod analysis_runtime;
|
pub mod analysis_runtime;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
pub mod track_analysis_plan;
|
||||||
|
pub mod track_enrichment;
|
||||||
|
|
||||||
// Re-export logging facade so submodules can write `crate::app_eprintln!()`
|
// Re-export logging facade so submodules can write `crate::app_eprintln!()`
|
||||||
// the same way they did when they lived in the top crate.
|
// the same way they did when they lived in the top crate.
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
//! Plan what a track still needs: waveform, LUFS, enrichment (BPM/mood), …
|
||||||
|
//!
|
||||||
|
//! All byte-backed enqueue paths should call [`crate::analysis_runtime::enqueue_track_analysis`],
|
||||||
|
//! which uses this module to decide full CPU seed vs enrichment-only vs no-op.
|
||||||
|
|
||||||
|
use psysonic_core::track_analysis::TrackAnalysisPlan;
|
||||||
|
use psysonic_core::track_enrichment::TrackEnrichmentPort;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
use crate::analysis_cache::AnalysisCache;
|
||||||
|
|
||||||
|
pub fn plan_track_analysis(
|
||||||
|
app: &AppHandle,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
content_hash: &str,
|
||||||
|
) -> TrackAnalysisPlan {
|
||||||
|
let (need_waveform, need_loudness) = cache_gaps(app, server_id, track_id, content_hash);
|
||||||
|
let enrichment = enrichment_plan(app, server_id, track_id, content_hash);
|
||||||
|
TrackAnalysisPlan {
|
||||||
|
need_waveform,
|
||||||
|
need_loudness,
|
||||||
|
enrichment,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plan from the latest cached fingerprint when bytes are not available yet (HTTP backfill gate).
|
||||||
|
pub fn plan_track_analysis_from_cache(
|
||||||
|
app: &AppHandle,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
) -> Result<TrackAnalysisPlan, String> {
|
||||||
|
let Some(cache) = app.try_state::<AnalysisCache>() else {
|
||||||
|
return Ok(TrackAnalysisPlan {
|
||||||
|
need_waveform: true,
|
||||||
|
need_loudness: true,
|
||||||
|
enrichment: Default::default(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let Some(md5) = cache.get_latest_md5_16kb_for_track(server_id, track_id)? else {
|
||||||
|
return Ok(TrackAnalysisPlan {
|
||||||
|
need_waveform: true,
|
||||||
|
need_loudness: true,
|
||||||
|
enrichment: Default::default(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
Ok(plan_track_analysis(app, server_id, track_id, &md5))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn track_analysis_needs_work(
|
||||||
|
app: &AppHandle,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
Ok(plan_track_analysis_from_cache(app, server_id, track_id)?.any())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_gaps(
|
||||||
|
app: &AppHandle,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
content_hash: &str,
|
||||||
|
) -> (bool, bool) {
|
||||||
|
cache_gaps_for_content(
|
||||||
|
app.try_state::<AnalysisCache>().as_deref(),
|
||||||
|
server_id,
|
||||||
|
track_id,
|
||||||
|
content_hash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enrichment_plan(
|
||||||
|
app: &AppHandle,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
content_hash: &str,
|
||||||
|
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
|
||||||
|
if server_id.is_empty() {
|
||||||
|
return Default::default();
|
||||||
|
}
|
||||||
|
app.try_state::<TrackEnrichmentPort>()
|
||||||
|
.map(|port| port.plan(server_id, track_id, content_hash))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_gaps_for_content(
|
||||||
|
cache: Option<&AnalysisCache>,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
content_hash: &str,
|
||||||
|
) -> (bool, bool) {
|
||||||
|
let Some(cache) = cache else {
|
||||||
|
return (true, true);
|
||||||
|
};
|
||||||
|
match cache.content_cache_coverage(server_id, track_id, content_hash) {
|
||||||
|
Ok(coverage) => (!coverage.has_waveform, !coverage.has_loudness),
|
||||||
|
Err(_) => (true, true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::analysis_cache::{LoudnessEntry, TrackKey, WaveformEntry};
|
||||||
|
|
||||||
|
fn seed_waveform_loudness(cache: &AnalysisCache, server_id: &str, track_id: &str, md5: &str) {
|
||||||
|
let key = TrackKey {
|
||||||
|
server_id: server_id.to_string(),
|
||||||
|
track_id: track_id.to_string(),
|
||||||
|
md5_16kb: md5.to_string(),
|
||||||
|
};
|
||||||
|
cache.touch_track_status(&key, "ready").unwrap();
|
||||||
|
cache
|
||||||
|
.upsert_waveform(
|
||||||
|
&key,
|
||||||
|
&WaveformEntry {
|
||||||
|
bins: vec![0u8; 1000],
|
||||||
|
bin_count: 500,
|
||||||
|
is_partial: false,
|
||||||
|
known_until_sec: 0.0,
|
||||||
|
duration_sec: 0.0,
|
||||||
|
updated_at: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
cache
|
||||||
|
.upsert_loudness(
|
||||||
|
&key,
|
||||||
|
&LoudnessEntry {
|
||||||
|
integrated_lufs: -14.0,
|
||||||
|
true_peak: 1.0,
|
||||||
|
recommended_gain_db: 0.0,
|
||||||
|
target_lufs: -14.0,
|
||||||
|
updated_at: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_gaps_true_when_empty() {
|
||||||
|
let cache = AnalysisCache::open_in_memory();
|
||||||
|
let (wf, ld) = cache_gaps_for_content(Some(&cache), "s1", "t1", "abc");
|
||||||
|
assert!(wf && ld);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_gaps_false_when_fingerprint_present() {
|
||||||
|
let cache = AnalysisCache::open_in_memory();
|
||||||
|
seed_waveform_loudness(&cache, "s1", "t1", "abc");
|
||||||
|
let (wf, ld) = cache_gaps_for_content(Some(&cache), "s1", "t1", "abc");
|
||||||
|
assert!(!wf && !ld);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_gaps_finds_stream_prefix_row_for_bare_track_id() {
|
||||||
|
let cache = AnalysisCache::open_in_memory();
|
||||||
|
seed_waveform_loudness(&cache, "s1", "stream:t1", "abc");
|
||||||
|
let (wf, ld) = cache_gaps_for_content(Some(&cache), "s1", "t1", "abc");
|
||||||
|
assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
//! Client-side track enrichment — oximedia BPM + mood into library facts.
|
||||||
|
|
||||||
|
use oximedia_mir::{mood, tempo, MirConfig};
|
||||||
|
use psysonic_core::track_enrichment::{
|
||||||
|
TrackEnrichmentFacts, TrackEnrichmentIntFact, TrackEnrichmentOutcome, TrackEnrichmentPort,
|
||||||
|
TrackEnrichmentPlan, TrackEnrichmentRealFact,
|
||||||
|
};
|
||||||
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
|
||||||
|
use crate::analysis_cache::{
|
||||||
|
analysis_pcm_window, audio_duration_from_bytes, decode_mono_pcm_window, md5_first_16kb,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const ENRICHMENT_WINDOW_SEC: f64 = 60.0;
|
||||||
|
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EnrichmentUpdatedPayload {
|
||||||
|
pub track_id: String,
|
||||||
|
pub server_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_enrichment_updated(app: &AppHandle, server_id: &str, track_id: &str) {
|
||||||
|
let _ = app.emit(
|
||||||
|
"analysis:enrichment-updated",
|
||||||
|
EnrichmentUpdatedPayload {
|
||||||
|
track_id: track_id.to_string(),
|
||||||
|
server_id: server_id.to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_track_enrichment_if_needed(
|
||||||
|
app: &AppHandle,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> TrackEnrichmentOutcome {
|
||||||
|
if server_id.is_empty() {
|
||||||
|
return TrackEnrichmentOutcome::SkippedNoServer;
|
||||||
|
}
|
||||||
|
let Some(port) = app.try_state::<TrackEnrichmentPort>() else {
|
||||||
|
return TrackEnrichmentOutcome::SkippedNoPort;
|
||||||
|
};
|
||||||
|
let content_hash = md5_first_16kb(bytes);
|
||||||
|
let plan = port.plan(server_id, track_id, &content_hash);
|
||||||
|
if !plan.any() {
|
||||||
|
return TrackEnrichmentOutcome::SkippedComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
match analyze_and_store(&port, server_id, track_id, &content_hash, bytes, plan) {
|
||||||
|
Ok(()) => {
|
||||||
|
crate::app_deprintln!(
|
||||||
|
"[analysis][enrichment] applied track_id={} server_id={} hash={}",
|
||||||
|
track_id,
|
||||||
|
server_id,
|
||||||
|
content_hash
|
||||||
|
);
|
||||||
|
emit_enrichment_updated(app, server_id, track_id);
|
||||||
|
TrackEnrichmentOutcome::Applied
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
crate::app_eprintln!(
|
||||||
|
"[analysis][enrichment] failed track_id={} server_id={}: {}",
|
||||||
|
track_id,
|
||||||
|
server_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
TrackEnrichmentOutcome::Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn analyze_and_store(
|
||||||
|
port: &TrackEnrichmentPort,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
content_hash: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
plan: TrackEnrichmentPlan,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let total_duration = audio_duration_from_bytes(bytes).unwrap_or(0.0);
|
||||||
|
let window = analysis_pcm_window(total_duration, ENRICHMENT_WINDOW_SEC);
|
||||||
|
let (mono, sample_rate) =
|
||||||
|
decode_mono_pcm_window(bytes, window.start_sec, window.duration_sec)?;
|
||||||
|
if mono.is_empty() || sample_rate <= 0.0 {
|
||||||
|
return Err("empty PCM window".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = MirConfig::default();
|
||||||
|
let mut facts = TrackEnrichmentFacts::default();
|
||||||
|
|
||||||
|
if plan.need_bpm {
|
||||||
|
let detector = tempo::TempoDetector::new(sample_rate, config.min_tempo, config.max_tempo);
|
||||||
|
let tempo = detector.detect(&mono).map_err(|e| format!("tempo: {e}"))?;
|
||||||
|
let bpm = tempo.bpm.round().clamp(20.0, 999.0) as i64;
|
||||||
|
facts.bpm = Some(TrackEnrichmentIntFact {
|
||||||
|
value: bpm,
|
||||||
|
confidence: tempo.confidence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if plan.need_valence || plan.need_arousal || plan.need_moods {
|
||||||
|
let detector = mood::MoodDetector::new(sample_rate);
|
||||||
|
let mood = detector.detect(&mono).map_err(|e| format!("mood: {e}"))?;
|
||||||
|
let confidence = mood.intensity.clamp(0.0, 1.0);
|
||||||
|
if plan.need_valence {
|
||||||
|
facts.valence = Some(TrackEnrichmentRealFact {
|
||||||
|
value: mood.valence as f64,
|
||||||
|
confidence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if plan.need_arousal {
|
||||||
|
facts.arousal = Some(TrackEnrichmentRealFact {
|
||||||
|
value: mood.arousal as f64,
|
||||||
|
confidence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if plan.need_moods && !mood.moods.is_empty() {
|
||||||
|
facts.moods = Some(
|
||||||
|
serde_json::to_string(&mood.moods).map_err(|e| format!("moods json: {e}"))?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
port.store(server_id, track_id, content_hash, &facts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
//! Unified playback → track analysis dispatch.
|
||||||
|
//!
|
||||||
|
//! Stream completion, hot/offline files, gapless chain, preload, and in-memory
|
||||||
|
//! replay all funnel through here before [`psysonic_analysis::analysis_runtime::enqueue_track_analysis`].
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
use crate::engine::{analysis_track_id_is_current_playback, AudioEngine};
|
||||||
|
use crate::helpers::{analysis_cache_track_id, current_playback_server_id_str};
|
||||||
|
use crate::state::ChainedInfo;
|
||||||
|
use crate::stream::{LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES, TRACK_STREAM_PROMOTE_MAX_BYTES};
|
||||||
|
|
||||||
|
/// Where playback obtained the bytes — used for logging and size caps only.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum TrackAnalysisOrigin {
|
||||||
|
InMemoryReplay,
|
||||||
|
StreamDownloadComplete,
|
||||||
|
LocalFilePlayback,
|
||||||
|
StreamSpillFile,
|
||||||
|
PrefetchOrCacheFile,
|
||||||
|
GaplessChainReady,
|
||||||
|
GaplessTransition,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn max_bytes_for_origin(origin: TrackAnalysisOrigin) -> usize {
|
||||||
|
match origin {
|
||||||
|
TrackAnalysisOrigin::LocalFilePlayback => LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES,
|
||||||
|
_ => TRACK_STREAM_PROMOTE_MAX_BYTES,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playback server scope: explicit IPC value, else pinned engine scope.
|
||||||
|
pub(crate) fn resolve_analysis_server_id(
|
||||||
|
explicit: Option<&str>,
|
||||||
|
engine: Option<&AudioEngine>,
|
||||||
|
) -> String {
|
||||||
|
explicit
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
engine
|
||||||
|
.map(current_playback_server_id_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_high_priority(
|
||||||
|
app: &AppHandle,
|
||||||
|
engine: Option<&AudioEngine>,
|
||||||
|
track_id: &str,
|
||||||
|
explicit: Option<bool>,
|
||||||
|
) -> bool {
|
||||||
|
explicit.unwrap_or_else(|| {
|
||||||
|
psysonic_analysis::analysis_runtime::analysis_backfill_is_current_track(app, track_id)
|
||||||
|
|| engine.is_some_and(|e| analysis_track_id_is_current_playback(e, track_id))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve `(server_id, high_priority)` when the caller has live engine state.
|
||||||
|
pub(crate) fn prepare_playback_analysis(
|
||||||
|
app: &AppHandle,
|
||||||
|
engine: &AudioEngine,
|
||||||
|
explicit_server_id: Option<&str>,
|
||||||
|
track_id: &str,
|
||||||
|
high_priority: Option<bool>,
|
||||||
|
) -> (String, bool) {
|
||||||
|
(
|
||||||
|
resolve_analysis_server_id(explicit_server_id, Some(engine)),
|
||||||
|
resolve_high_priority(app, Some(engine), track_id, high_priority),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_server_id_for_app(
|
||||||
|
app: &AppHandle,
|
||||||
|
explicit: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
let engine = app.try_state::<AudioEngine>();
|
||||||
|
resolve_analysis_server_id(explicit, engine.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn high_priority_for_app(
|
||||||
|
app: &AppHandle,
|
||||||
|
track_id: &str,
|
||||||
|
explicit: Option<bool>,
|
||||||
|
) -> bool {
|
||||||
|
let engine = app.try_state::<AudioEngine>();
|
||||||
|
resolve_high_priority(app, engine.as_deref(), track_id, explicit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gapless boundary: chained track became audible — run unified analysis if needed.
|
||||||
|
pub(crate) fn spawn_gapless_transition_analysis(app: &AppHandle, info: &ChainedInfo) {
|
||||||
|
let track_id = analysis_cache_track_id(
|
||||||
|
info.analysis_track_id.as_deref(),
|
||||||
|
&info.url,
|
||||||
|
);
|
||||||
|
let Some(track_id) = track_id else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let engine = app.state::<AudioEngine>();
|
||||||
|
let (sid, high) = prepare_playback_analysis(
|
||||||
|
app,
|
||||||
|
&engine,
|
||||||
|
info.server_id.as_deref(),
|
||||||
|
&track_id,
|
||||||
|
Some(true),
|
||||||
|
);
|
||||||
|
let bytes = (*info.raw_bytes).clone();
|
||||||
|
spawn_track_analysis_bytes(
|
||||||
|
app.clone(),
|
||||||
|
TrackAnalysisOrigin::GaplessTransition,
|
||||||
|
sid,
|
||||||
|
track_id,
|
||||||
|
bytes,
|
||||||
|
high,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte-backed analysis — the single audio-side entry before the analysis crate planner.
|
||||||
|
pub(crate) async fn dispatch_track_analysis_bytes(
|
||||||
|
app: &AppHandle,
|
||||||
|
origin: TrackAnalysisOrigin,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
high_priority: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let track_id = track_id.trim();
|
||||||
|
if track_id.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let max = max_bytes_for_origin(origin);
|
||||||
|
if bytes.len() > max {
|
||||||
|
crate::app_deprintln!(
|
||||||
|
"[analysis][dispatch] skip origin={origin:?} track_id={track_id} bytes={} max={max}",
|
||||||
|
bytes.len(),
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
crate::app_deprintln!(
|
||||||
|
"[analysis][dispatch] origin={origin:?} track_id={track_id} server_id={} size_mib={:.2} high={high_priority}",
|
||||||
|
if server_id.is_empty() { "''" } else { server_id },
|
||||||
|
bytes.len() as f64 / (1024.0 * 1024.0),
|
||||||
|
);
|
||||||
|
psysonic_analysis::analysis_runtime::enqueue_track_analysis(
|
||||||
|
app,
|
||||||
|
server_id,
|
||||||
|
track_id,
|
||||||
|
&bytes,
|
||||||
|
high_priority,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-blocking wrapper with optional play-generation supersede guard.
|
||||||
|
pub(crate) fn spawn_track_analysis_bytes(
|
||||||
|
app: AppHandle,
|
||||||
|
origin: TrackAnalysisOrigin,
|
||||||
|
server_id: String,
|
||||||
|
track_id: String,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
high_priority: bool,
|
||||||
|
generation_guard: Option<(u64, Arc<AtomicU64>)>,
|
||||||
|
) {
|
||||||
|
if track_id.trim().is_empty() || bytes.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Some((gen, gen_arc)) = generation_guard {
|
||||||
|
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(e) = dispatch_track_analysis_bytes(
|
||||||
|
&app,
|
||||||
|
origin,
|
||||||
|
&server_id,
|
||||||
|
&track_id,
|
||||||
|
bytes,
|
||||||
|
high_priority,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
crate::app_eprintln!(
|
||||||
|
"[analysis][dispatch] failed origin={origin:?} track_id={track_id}: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn spawn_track_analysis_file(
|
||||||
|
app: AppHandle,
|
||||||
|
origin: TrackAnalysisOrigin,
|
||||||
|
server_id: String,
|
||||||
|
track_id: String,
|
||||||
|
file_path: PathBuf,
|
||||||
|
high_priority: bool,
|
||||||
|
generation_guard: Option<(u64, Arc<AtomicU64>)>,
|
||||||
|
) {
|
||||||
|
if track_id.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Some((gen, gen_arc)) = &generation_guard {
|
||||||
|
if gen_arc.load(Ordering::SeqCst) != *gen {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bytes = match tokio::fs::read(&file_path).await {
|
||||||
|
Ok(b) if !b.is_empty() => b,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
if let Some((gen, gen_arc)) = generation_guard {
|
||||||
|
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(e) = dispatch_track_analysis_bytes(
|
||||||
|
&app,
|
||||||
|
origin,
|
||||||
|
&server_id,
|
||||||
|
&track_id,
|
||||||
|
bytes,
|
||||||
|
high_priority,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
crate::app_eprintln!(
|
||||||
|
"[analysis][dispatch] file failed origin={origin:?} track_id={track_id}: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -437,6 +437,7 @@ pub async fn audio_play(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Progress + ended detection ────────────────────────────────────────────
|
// ── Progress + ended detection ────────────────────────────────────────────
|
||||||
|
let analysis_app = app.clone();
|
||||||
spawn_progress_task(
|
spawn_progress_task(
|
||||||
gen,
|
gen,
|
||||||
state.generation.clone(),
|
state.generation.clone(),
|
||||||
@@ -446,6 +447,7 @@ pub async fn audio_play(
|
|||||||
state.crossfade_secs.clone(),
|
state.crossfade_secs.clone(),
|
||||||
done_flag,
|
done_flag,
|
||||||
app,
|
app,
|
||||||
|
Some(analysis_app),
|
||||||
state.samples_played.clone(),
|
state.samples_played.clone(),
|
||||||
state.current_sample_rate.clone(),
|
state.current_sample_rate.clone(),
|
||||||
state.current_channels.clone(),
|
state.current_channels.clone(),
|
||||||
@@ -480,6 +482,7 @@ pub async fn audio_chain_preload(
|
|||||||
fallback_db: f32,
|
fallback_db: f32,
|
||||||
hi_res_enabled: bool,
|
hi_res_enabled: bool,
|
||||||
analysis_track_id: Option<String>,
|
analysis_track_id: Option<String>,
|
||||||
|
server_id: Option<String>,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, AudioEngine>,
|
state: State<'_, AudioEngine>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -542,6 +545,27 @@ pub async fn audio_chain_preload(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty());
|
.filter(|s| !s.is_empty());
|
||||||
|
let analysis_server_id = server_id.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) {
|
||||||
|
let (sid, high) = crate::analysis_dispatch::prepare_playback_analysis(
|
||||||
|
&app,
|
||||||
|
&state,
|
||||||
|
analysis_server_id,
|
||||||
|
&track_id,
|
||||||
|
Some(false),
|
||||||
|
);
|
||||||
|
let bytes = (*raw_bytes).clone();
|
||||||
|
crate::analysis_dispatch::spawn_track_analysis_bytes(
|
||||||
|
app.clone(),
|
||||||
|
crate::analysis_dispatch::TrackAnalysisOrigin::GaplessChainReady,
|
||||||
|
sid,
|
||||||
|
track_id,
|
||||||
|
bytes,
|
||||||
|
high,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Only `gain_linear` is needed — `effective_volume` is intentionally NOT
|
// Only `gain_linear` is needed — `effective_volume` is intentionally NOT
|
||||||
// applied to the Sink here. `audio_chain_preload` runs ~30 s before the
|
// applied to the Sink here. `audio_chain_preload` runs ~30 s before the
|
||||||
@@ -623,6 +647,8 @@ pub async fn audio_chain_preload(
|
|||||||
|
|
||||||
*state.chained_info.lock().unwrap() = Some(ChainedInfo {
|
*state.chained_info.lock().unwrap() = Some(ChainedInfo {
|
||||||
url,
|
url,
|
||||||
|
analysis_track_id: logical_trim,
|
||||||
|
server_id: analysis_server_id.map(str::to_string),
|
||||||
raw_bytes,
|
raw_bytes,
|
||||||
duration_secs,
|
duration_secs,
|
||||||
replay_gain_linear: gain_linear,
|
replay_gain_linear: gain_linear,
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ pub(crate) async fn try_resume_after_device_change(
|
|||||||
// Inform the frontend of the new duration (keeps seekbar range correct).
|
// Inform the frontend of the new duration (keeps seekbar range correct).
|
||||||
app.emit("audio:playing", ps.built.duration_secs).ok();
|
app.emit("audio:playing", ps.built.duration_secs).ok();
|
||||||
|
|
||||||
|
let analysis_app = app.clone();
|
||||||
spawn_progress_task(
|
spawn_progress_task(
|
||||||
gen,
|
gen,
|
||||||
engine.generation.clone(),
|
engine.generation.clone(),
|
||||||
@@ -254,6 +255,7 @@ pub(crate) async fn try_resume_after_device_change(
|
|||||||
engine.crossfade_secs.clone(),
|
engine.crossfade_secs.clone(),
|
||||||
done_flag,
|
done_flag,
|
||||||
app.clone(),
|
app.clone(),
|
||||||
|
Some(analysis_app),
|
||||||
engine.samples_played.clone(),
|
engine.samples_played.clone(),
|
||||||
engine.current_sample_rate.clone(),
|
engine.current_sample_rate.clone(),
|
||||||
engine.current_channels.clone(),
|
engine.current_channels.clone(),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ use std::sync::{Arc, Mutex, RwLock};
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use rodio::Player;
|
use rodio::Player;
|
||||||
use tauri::{AppHandle, Manager};
|
|
||||||
|
|
||||||
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
|
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
|
||||||
|
|
||||||
@@ -459,7 +458,3 @@ pub fn refresh_http_user_agent(state: &AudioEngine, ua: &str) {
|
|||||||
*slot = client;
|
*slot = client;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub(crate) fn analysis_seed_high_priority_for_track(app: &AppHandle, track_id: &str) -> bool {
|
|
||||||
app.try_state::<AudioEngine>()
|
|
||||||
.is_some_and(|e| analysis_track_id_is_current_playback(&e, track_id))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -747,118 +747,6 @@ pub(crate) async fn fetch_data(
|
|||||||
Ok(Some(data))
|
Ok(Some(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When playback uses full track bytes already in RAM (gapless `reuse_chained_bytes`,
|
|
||||||
/// `preloaded`, or `stream_completed_cache` via `fetch_data`), the `psysonic-local`
|
|
||||||
/// disk-read seed path never runs. Submit the same full-buffer analysis via the cpu-seed queue so waveform /
|
|
||||||
/// loudness SQLite can fill **offline** without `analysis_enqueue_seed_from_url` HTTP.
|
|
||||||
pub(crate) fn spawn_analysis_seed_from_in_memory_bytes(
|
|
||||||
app: &AppHandle,
|
|
||||||
server_id: Option<&str>,
|
|
||||||
cache_track_id: Option<&str>,
|
|
||||||
gen: u64,
|
|
||||||
gen_arc: &Arc<AtomicU64>,
|
|
||||||
bytes: &[u8],
|
|
||||||
) {
|
|
||||||
let Some(track_id) = cache_track_id.map(str::trim).filter(|s| !s.is_empty()) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if bytes.is_empty() || bytes.len() > crate::stream::TRACK_STREAM_PROMOTE_MAX_BYTES {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let track_id = track_id.to_string();
|
|
||||||
let server_id = server_id.unwrap_or("").to_string();
|
|
||||||
let bytes = bytes.to_vec();
|
|
||||||
let app = app.clone();
|
|
||||||
let gen_arc = gen_arc.clone();
|
|
||||||
crate::app_deprintln!(
|
|
||||||
"[stream] in-memory play path: scheduling full-track analysis track_id={} size_mib={:.2}",
|
|
||||||
track_id,
|
|
||||||
bytes.len() as f64 / (1024.0 * 1024.0)
|
|
||||||
);
|
|
||||||
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), server_id, track_id.clone(), bytes, high).await {
|
|
||||||
crate::app_eprintln!(
|
|
||||||
"[analysis] in-memory play path seed failed for {}: {}",
|
|
||||||
track_id,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full-track analysis for a completed ranged stream spilled to disk (> RAM promote cap).
|
|
||||||
pub(crate) fn spawn_analysis_seed_from_spill_file(
|
|
||||||
app: &AppHandle,
|
|
||||||
server_id: Option<&str>,
|
|
||||||
track_id: &str,
|
|
||||||
spill_path: std::path::PathBuf,
|
|
||||||
gen: u64,
|
|
||||||
gen_arc: &Arc<AtomicU64>,
|
|
||||||
) {
|
|
||||||
let track_id = track_id.trim().to_string();
|
|
||||||
if track_id.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let server_id = server_id.unwrap_or("").to_string();
|
|
||||||
let app = app.clone();
|
|
||||||
let gen_arc = gen_arc.clone();
|
|
||||||
let max_bytes = crate::stream::LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let bytes = match tokio::fs::read(&spill_path).await {
|
|
||||||
Ok(b) if b.is_empty() => return,
|
|
||||||
Ok(b) if b.len() > max_bytes => {
|
|
||||||
crate::app_deprintln!(
|
|
||||||
"[stream] spill analysis skip track_id={} bytes={} max={}",
|
|
||||||
track_id,
|
|
||||||
b.len(),
|
|
||||||
max_bytes
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(e) => {
|
|
||||||
crate::app_eprintln!(
|
|
||||||
"[stream] spill analysis read failed track_id={}: {}",
|
|
||||||
track_id,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
crate::app_deprintln!(
|
|
||||||
"[stream] spill path: scheduling full-track analysis track_id={} size_mib={:.2}",
|
|
||||||
track_id,
|
|
||||||
bytes.len() as f64 / (1024.0 * 1024.0)
|
|
||||||
);
|
|
||||||
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
|
|
||||||
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(
|
|
||||||
app,
|
|
||||||
server_id,
|
|
||||||
track_id.clone(),
|
|
||||||
bytes,
|
|
||||||
high,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
crate::app_eprintln!(
|
|
||||||
"[analysis] spill path seed failed for {}: {}",
|
|
||||||
track_id,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// -1 dB headroom applied at full scale to prevent inter-sample clipping.
|
/// -1 dB headroom applied at full scale to prevent inter-sample clipping.
|
||||||
/// Modern masters are often at 0 dBFS; the EQ biquad chain and resampler
|
/// Modern masters are often at 0 dBFS; the EQ biquad chain and resampler
|
||||||
/// can produce inter-sample peaks slightly above ±1.0 → audible distortion.
|
/// can produce inter-sample peaks slightly above ±1.0 → audible distortion.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
pub use psysonic_core::{app_deprintln, app_eprintln, logging};
|
pub use psysonic_core::{app_deprintln, app_eprintln, logging};
|
||||||
|
|
||||||
pub mod autoeq_commands;
|
pub mod autoeq_commands;
|
||||||
|
mod analysis_dispatch;
|
||||||
mod codec;
|
mod codec;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
mod decode;
|
mod decode;
|
||||||
|
|||||||
@@ -9,19 +9,23 @@ use std::time::Duration;
|
|||||||
use ringbuf::traits::Split;
|
use ringbuf::traits::Split;
|
||||||
use ringbuf::{HeapCons, HeapRb};
|
use ringbuf::{HeapCons, HeapRb};
|
||||||
use symphonia::core::io::MediaSource;
|
use symphonia::core::io::MediaSource;
|
||||||
use tauri::{AppHandle, Emitter, Manager, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
|
use super::analysis_dispatch::{
|
||||||
|
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
|
||||||
|
TrackAnalysisOrigin,
|
||||||
|
};
|
||||||
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
|
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
|
||||||
use super::engine::{audio_http_client, AudioEngine};
|
use super::engine::{audio_http_client, AudioEngine};
|
||||||
use super::helpers::{
|
use super::helpers::{
|
||||||
content_type_to_hint, fetch_data, format_hint_from_content_disposition,
|
content_type_to_hint, fetch_data, format_hint_from_content_disposition,
|
||||||
normalize_stream_suffix_for_hint, resolve_playback_format_hint, sniff_stream_format_extension,
|
normalize_stream_suffix_for_hint, resolve_playback_format_hint, sniff_stream_format_extension,
|
||||||
spawn_analysis_seed_from_in_memory_bytes, same_playback_target,
|
same_playback_target,
|
||||||
STREAM_FORMAT_SNIFF_PROBE_BYTES,
|
STREAM_FORMAT_SNIFF_PROBE_BYTES,
|
||||||
};
|
};
|
||||||
use super::stream::{
|
use super::stream::{
|
||||||
ranged_download_task, track_download_task, AudioStreamReader,
|
ranged_download_task, track_download_task, AudioStreamReader,
|
||||||
LocalFileSource, RangedHttpSource, LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES,
|
LocalFileSource, RangedHttpSource,
|
||||||
TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_BUF_CAPACITY, TRACK_STREAM_MIN_BUF_CAPACITY,
|
TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_BUF_CAPACITY, TRACK_STREAM_MIN_BUF_CAPACITY,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,6 +66,33 @@ pub(super) struct PlayInputContext<'a> {
|
|||||||
pub reuse_chained_bytes: Option<Vec<u8>>,
|
pub reuse_chained_bytes: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn spawn_playback_analysis_bytes(
|
||||||
|
app: &AppHandle,
|
||||||
|
state: &State<'_, AudioEngine>,
|
||||||
|
ctx: &PlayInputContext<'_>,
|
||||||
|
origin: TrackAnalysisOrigin,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
) {
|
||||||
|
let Some(track_id) = ctx
|
||||||
|
.cache_id_for_tasks
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (sid, high) =
|
||||||
|
prepare_playback_analysis(app, state, ctx.server_id, track_id, None);
|
||||||
|
spawn_track_analysis_bytes(
|
||||||
|
app.clone(),
|
||||||
|
origin,
|
||||||
|
sid,
|
||||||
|
track_id.to_string(),
|
||||||
|
bytes,
|
||||||
|
high,
|
||||||
|
Some((ctx.gen, state.generation.clone())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolves the play input for `audio_play` honouring (in priority order):
|
/// Resolves the play input for `audio_play` honouring (in priority order):
|
||||||
/// 1. Reused chained bytes — manual skip onto pre-chained track.
|
/// 1. Reused chained bytes — manual skip onto pre-chained track.
|
||||||
/// 2. `psysonic-local://` files — open as seekable LocalFileSource.
|
/// 2. `psysonic-local://` files — open as seekable LocalFileSource.
|
||||||
@@ -77,14 +108,23 @@ pub(super) async fn select_play_input(
|
|||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
) -> Result<Option<PlayInput>, String> {
|
) -> Result<Option<PlayInput>, String> {
|
||||||
if let Some(d) = ctx.reuse_chained_bytes {
|
if let Some(d) = ctx.reuse_chained_bytes {
|
||||||
spawn_analysis_seed_from_in_memory_bytes(
|
if let Some(track_id) = ctx
|
||||||
app,
|
.cache_id_for_tasks
|
||||||
ctx.server_id,
|
.map(str::trim)
|
||||||
ctx.cache_id_for_tasks,
|
.filter(|s| !s.is_empty())
|
||||||
ctx.gen,
|
{
|
||||||
&state.generation,
|
let (sid, high) =
|
||||||
&d,
|
prepare_playback_analysis(app, state, ctx.server_id, track_id, None);
|
||||||
);
|
spawn_track_analysis_bytes(
|
||||||
|
app.clone(),
|
||||||
|
TrackAnalysisOrigin::InMemoryReplay,
|
||||||
|
sid,
|
||||||
|
track_id.to_string(),
|
||||||
|
d.clone(),
|
||||||
|
high,
|
||||||
|
Some((ctx.gen, state.generation.clone())),
|
||||||
|
);
|
||||||
|
}
|
||||||
return Ok(Some(PlayInput::Bytes(d)));
|
return Ok(Some(PlayInput::Bytes(d)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,13 +154,12 @@ pub(super) async fn select_play_input(
|
|||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
None => return Ok(None), // superseded while downloading
|
None => return Ok(None), // superseded while downloading
|
||||||
};
|
};
|
||||||
spawn_analysis_seed_from_in_memory_bytes(
|
spawn_playback_analysis_bytes(
|
||||||
app,
|
app,
|
||||||
ctx.server_id,
|
state,
|
||||||
ctx.cache_id_for_tasks,
|
&ctx,
|
||||||
ctx.gen,
|
TrackAnalysisOrigin::InMemoryReplay,
|
||||||
&state.generation,
|
data.clone(),
|
||||||
&data,
|
|
||||||
);
|
);
|
||||||
Ok(Some(PlayInput::Bytes(data)))
|
Ok(Some(PlayInput::Bytes(data)))
|
||||||
}
|
}
|
||||||
@@ -146,57 +185,17 @@ fn open_local_file_input(
|
|||||||
local_hint
|
local_hint
|
||||||
);
|
);
|
||||||
if let Some(seed_id) = ctx.cache_id_for_tasks {
|
if let Some(seed_id) = ctx.cache_id_for_tasks {
|
||||||
let skip_cpu_seed = app
|
let (sid, high) =
|
||||||
.try_state::<psysonic_analysis::analysis_cache::AnalysisCache>()
|
prepare_playback_analysis(app, state, ctx.server_id, seed_id, None);
|
||||||
.map(|c| {
|
spawn_track_analysis_file(
|
||||||
c.cpu_seed_redundant_for_track(ctx.server_id.unwrap_or(""), seed_id)
|
app.clone(),
|
||||||
.unwrap_or(false)
|
TrackAnalysisOrigin::LocalFilePlayback,
|
||||||
})
|
sid,
|
||||||
.unwrap_or(false);
|
seed_id.to_string(),
|
||||||
if !skip_cpu_seed {
|
std::path::PathBuf::from(path),
|
||||||
let path_owned = std::path::PathBuf::from(path);
|
high,
|
||||||
let app_seed = app.clone();
|
Some((ctx.gen, state.generation.clone())),
|
||||||
let gen_seed = ctx.gen;
|
);
|
||||||
let gen_arc_seed = state.generation.clone();
|
|
||||||
let seed_id = seed_id.to_string();
|
|
||||||
let seed_server = ctx.server_id.unwrap_or("").to_string();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let data = match tokio::fs::read(&path_owned).await {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(_) => return,
|
|
||||||
};
|
|
||||||
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if data.is_empty() || data.len() > LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES {
|
|
||||||
crate::app_deprintln!(
|
|
||||||
"[stream] psysonic-local: skip analysis seed track_id={} bytes={} (over {} MiB cap)",
|
|
||||||
seed_id,
|
|
||||||
data.len(),
|
|
||||||
LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES / (1024 * 1024)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
crate::app_deprintln!(
|
|
||||||
"[stream] psysonic-local: file read complete track_id={} size_mib={:.2} — full-track analysis (cpu-seed queue)",
|
|
||||||
seed_id,
|
|
||||||
data.len() as f64 / (1024.0 * 1024.0)
|
|
||||||
);
|
|
||||||
let high = crate::engine::analysis_seed_high_priority_for_track(&app_seed, &seed_id);
|
|
||||||
if let Err(e) =
|
|
||||||
psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app_seed.clone(), seed_server.clone(), seed_id.clone(), data, high).await
|
|
||||||
{
|
|
||||||
crate::app_eprintln!(
|
|
||||||
"[analysis] local-file seed failed for {}: {}",
|
|
||||||
seed_id,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let reader = LocalFileSource { file, len };
|
let reader = LocalFileSource { file, len };
|
||||||
Ok(PlayInput::SeekableMedia {
|
Ok(PlayInput::SeekableMedia {
|
||||||
@@ -762,14 +761,22 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
|||||||
effective_hint
|
effective_hint
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
spawn_analysis_seed_from_in_memory_bytes(
|
if let Some(track_id) = cache_id_for_tasks
|
||||||
app,
|
.map(str::trim)
|
||||||
server_id,
|
.filter(|s| !s.is_empty())
|
||||||
cache_id_for_tasks,
|
{
|
||||||
gen,
|
let (sid, high) =
|
||||||
&state.generation,
|
prepare_playback_analysis(app, state, server_id, track_id, None);
|
||||||
&data,
|
spawn_track_analysis_bytes(
|
||||||
);
|
app.clone(),
|
||||||
|
TrackAnalysisOrigin::StreamDownloadComplete,
|
||||||
|
sid,
|
||||||
|
track_id.to_string(),
|
||||||
|
data.clone(),
|
||||||
|
high,
|
||||||
|
Some((gen, state.generation.clone())),
|
||||||
|
);
|
||||||
|
}
|
||||||
match build_source_from_play_input(
|
match build_source_from_play_input(
|
||||||
PlayInput::Bytes(data.clone()),
|
PlayInput::Bytes(data.clone()),
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -3,15 +3,115 @@
|
|||||||
//! (which constructs the gapless source chain) and `audio_play` (which
|
//! (which constructs the gapless source chain) and `audio_play` (which
|
||||||
//! starts playback). All three live in this audio submodule.
|
//! starts playback). All three live in this audio submodule.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
|
use super::analysis_dispatch::{
|
||||||
|
dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file,
|
||||||
|
TrackAnalysisOrigin,
|
||||||
|
};
|
||||||
use super::engine::{audio_http_client, AudioEngine};
|
use super::engine::{audio_http_client, AudioEngine};
|
||||||
use super::helpers::{analysis_cache_track_id, same_playback_target};
|
use super::helpers::{analysis_cache_track_id, same_playback_target};
|
||||||
use super::state::PreloadedTrack;
|
use super::state::PreloadedTrack;
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct PreloadEventPayload {
|
||||||
|
url: String,
|
||||||
|
track_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_preload_analysis_bytes(
|
||||||
|
app: &AppHandle,
|
||||||
|
state: &State<'_, AudioEngine>,
|
||||||
|
url: &str,
|
||||||
|
data: &[u8],
|
||||||
|
analysis_track_id: Option<&str>,
|
||||||
|
server_id: Option<&str>,
|
||||||
|
) {
|
||||||
|
let Some(track_id) = analysis_cache_track_id(analysis_track_id, url) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (sid, high) = prepare_playback_analysis(
|
||||||
|
app,
|
||||||
|
state,
|
||||||
|
server_id,
|
||||||
|
&track_id,
|
||||||
|
// Next-track prefetch — never steal CPU from the audible track.
|
||||||
|
Some(false),
|
||||||
|
);
|
||||||
|
if let Err(e) = dispatch_track_analysis_bytes(
|
||||||
|
app,
|
||||||
|
TrackAnalysisOrigin::PrefetchOrCacheFile,
|
||||||
|
&sid,
|
||||||
|
&track_id,
|
||||||
|
data.to_vec(),
|
||||||
|
high,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
crate::app_eprintln!("[analysis] preload seed failed for {track_id}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_preload_analysis_file(
|
||||||
|
app: &AppHandle,
|
||||||
|
state: &State<'_, AudioEngine>,
|
||||||
|
url: &str,
|
||||||
|
file_path: PathBuf,
|
||||||
|
analysis_track_id: Option<&str>,
|
||||||
|
server_id: Option<&str>,
|
||||||
|
) {
|
||||||
|
let Some(track_id) = analysis_cache_track_id(analysis_track_id, url) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (sid, high) = prepare_playback_analysis(
|
||||||
|
app,
|
||||||
|
state,
|
||||||
|
server_id,
|
||||||
|
&track_id,
|
||||||
|
Some(false),
|
||||||
|
);
|
||||||
|
crate::app_deprintln!(
|
||||||
|
"[stream] audio_preload: local file analysis track_id={} path={}",
|
||||||
|
track_id,
|
||||||
|
file_path.display()
|
||||||
|
);
|
||||||
|
spawn_track_analysis_file(
|
||||||
|
app.clone(),
|
||||||
|
TrackAnalysisOrigin::LocalFilePlayback,
|
||||||
|
sid,
|
||||||
|
track_id,
|
||||||
|
file_path,
|
||||||
|
high,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_preload_ready(app: &AppHandle, url: String, track_id: Option<String>) {
|
||||||
|
let _ = app.emit(
|
||||||
|
"audio:preload-ready",
|
||||||
|
PreloadEventPayload {
|
||||||
|
url,
|
||||||
|
track_id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_preload_cancelled(app: &AppHandle, url: String, track_id: Option<String>) {
|
||||||
|
let _ = app.emit(
|
||||||
|
"audio:preload-cancelled",
|
||||||
|
PreloadEventPayload {
|
||||||
|
url,
|
||||||
|
track_id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn audio_preload(
|
pub async fn audio_preload(
|
||||||
url: String,
|
url: String,
|
||||||
@@ -21,49 +121,96 @@ pub async fn audio_preload(
|
|||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, AudioEngine>,
|
state: State<'_, AudioEngine>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
let logical_trim = analysis_track_id
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let track_id_for_events = logical_trim.clone();
|
||||||
|
|
||||||
|
let is_local = url.starts_with("psysonic-local://");
|
||||||
|
|
||||||
|
// Hot/offline cache: playback reads from disk — seed analysis from the file
|
||||||
|
// (512 MiB cap) without copying into the RAM preload slot.
|
||||||
|
if is_local {
|
||||||
|
let path = PathBuf::from(url.strip_prefix("psysonic-local://").unwrap());
|
||||||
|
if !path.is_file() {
|
||||||
|
crate::app_deprintln!(
|
||||||
|
"[stream] audio_preload: local file missing path={}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
seed_preload_analysis_file(
|
||||||
|
&app,
|
||||||
|
&state,
|
||||||
|
&url,
|
||||||
|
path,
|
||||||
|
logical_trim.as_deref(),
|
||||||
|
server_id.as_deref(),
|
||||||
|
);
|
||||||
|
emit_preload_ready(&app, url, track_id_for_events);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote URL — reuse in-memory bytes when a prior HTTP preload finished.
|
||||||
{
|
{
|
||||||
let preloaded = state.preloaded.lock().unwrap();
|
let cached = {
|
||||||
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
|
let preloaded = state.preloaded.lock().unwrap();
|
||||||
let _ = app.emit("audio:preload-ready", url.clone());
|
preloaded
|
||||||
|
.as_ref()
|
||||||
|
.filter(|p| same_playback_target(&p.url, &url))
|
||||||
|
.map(|p| p.data.clone())
|
||||||
|
};
|
||||||
|
if let Some(data) = cached {
|
||||||
|
if !data.is_empty() {
|
||||||
|
seed_preload_analysis_bytes(
|
||||||
|
&app,
|
||||||
|
&state,
|
||||||
|
&url,
|
||||||
|
&data,
|
||||||
|
logical_trim.as_deref(),
|
||||||
|
server_id.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _ = duration_hint; // kept in API for compatibility
|
||||||
|
|
||||||
// Throttle: wait 8 s before starting the background download so it does not
|
// Throttle: wait 8 s before starting the background download so it does not
|
||||||
// compete with the decode + sink-feed work of the just-started current track.
|
// compete with the decode + sink-feed work of the just-started current track.
|
||||||
// If the user skips during the wait the generation counter changes and we abort.
|
// If the user skips during the wait the generation counter changes and we abort.
|
||||||
let gen_snapshot = state.generation.load(Ordering::Relaxed);
|
let gen_snapshot = state.generation.load(Ordering::Relaxed);
|
||||||
tokio::time::sleep(Duration::from_secs(8)).await;
|
tokio::time::sleep(Duration::from_secs(8)).await;
|
||||||
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
|
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
|
||||||
|
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let data: Vec<u8> = if let Some(path) = url.strip_prefix("psysonic-local://") {
|
|
||||||
tokio::fs::read(path).await.map_err(|e| e.to_string())?
|
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
|
||||||
} else {
|
if !response.status().is_success() {
|
||||||
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
|
emit_preload_cancelled(&app, url, track_id_for_events);
|
||||||
if !response.status().is_success() {
|
return Ok(());
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
response.bytes().await.map_err(|e| e.to_string())?.into()
|
|
||||||
};
|
|
||||||
let _ = duration_hint; // kept in API for compatibility
|
|
||||||
let logical_trim = analysis_track_id
|
|
||||||
.as_ref()
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty());
|
|
||||||
if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) {
|
|
||||||
crate::app_deprintln!(
|
|
||||||
"[stream] audio_preload: bytes ready track_id={} size_mib={:.2} — invoking full-track analysis",
|
|
||||||
track_id,
|
|
||||||
data.len() as f64 / (1024.0 * 1024.0)
|
|
||||||
);
|
|
||||||
let high = crate::engine::analysis_track_id_is_current_playback(&state, &track_id);
|
|
||||||
let sid = server_id.clone().unwrap_or_default();
|
|
||||||
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), sid, track_id.clone(), data.clone(), high).await {
|
|
||||||
crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
let data: Vec<u8> = response.bytes().await.map_err(|e| e.to_string())?.into();
|
||||||
|
|
||||||
|
if !data.is_empty() {
|
||||||
|
seed_preload_analysis_bytes(
|
||||||
|
&app,
|
||||||
|
&state,
|
||||||
|
&url,
|
||||||
|
&data,
|
||||||
|
logical_trim.as_deref(),
|
||||||
|
server_id.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
let url_for_emit = url.clone();
|
let url_for_emit = url.clone();
|
||||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
|
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||||
let _ = app.emit("audio:preload-ready", url_for_emit);
|
emit_preload_ready(&app, url_for_emit, track_id_for_events);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
|
|||||||
crossfade_secs_arc: Arc<AtomicU32>,
|
crossfade_secs_arc: Arc<AtomicU32>,
|
||||||
initial_done: Arc<AtomicBool>,
|
initial_done: Arc<AtomicBool>,
|
||||||
emitter: E,
|
emitter: E,
|
||||||
|
analysis_app: Option<AppHandle>,
|
||||||
samples_played: Arc<AtomicU64>,
|
samples_played: Arc<AtomicU64>,
|
||||||
sample_rate_arc: Arc<AtomicU32>,
|
sample_rate_arc: Arc<AtomicU32>,
|
||||||
channels_arc: Arc<AtomicU32>,
|
channels_arc: Arc<AtomicU32>,
|
||||||
@@ -134,6 +135,10 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
|
|||||||
|
|
||||||
let chained = chained_arc.lock().unwrap().take();
|
let chained = chained_arc.lock().unwrap().take();
|
||||||
if let Some(info) = chained {
|
if let Some(info) = chained {
|
||||||
|
if let Some(app) = analysis_app.clone() {
|
||||||
|
crate::analysis_dispatch::spawn_gapless_transition_analysis(&app, &info);
|
||||||
|
}
|
||||||
|
|
||||||
// Swap to the chained source's done flag.
|
// Swap to the chained source's done flag.
|
||||||
current_done = info.source_done;
|
current_done = info.source_done;
|
||||||
|
|
||||||
@@ -381,6 +386,7 @@ mod tests {
|
|||||||
self.crossfade_secs.clone(),
|
self.crossfade_secs.clone(),
|
||||||
self.done.clone(),
|
self.done.clone(),
|
||||||
emitter,
|
emitter,
|
||||||
|
None,
|
||||||
self.samples_played.clone(),
|
self.samples_played.clone(),
|
||||||
self.sample_rate.clone(),
|
self.sample_rate.clone(),
|
||||||
self.channels.clone(),
|
self.channels.clone(),
|
||||||
@@ -518,6 +524,8 @@ mod tests {
|
|||||||
let chained_samples = Arc::new(AtomicU64::new(0));
|
let chained_samples = Arc::new(AtomicU64::new(0));
|
||||||
*h.chained.lock().unwrap() = Some(ChainedInfo {
|
*h.chained.lock().unwrap() = Some(ChainedInfo {
|
||||||
url: chain_url.clone(),
|
url: chain_url.clone(),
|
||||||
|
analysis_track_id: None,
|
||||||
|
server_id: None,
|
||||||
raw_bytes: Arc::new(Vec::new()),
|
raw_bytes: Arc::new(Vec::new()),
|
||||||
duration_secs: 200.0,
|
duration_secs: 200.0,
|
||||||
replay_gain_linear: 1.0,
|
replay_gain_linear: 1.0,
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ pub async fn audio_play_radio(
|
|||||||
state.crossfade_secs.clone(),
|
state.crossfade_secs.clone(),
|
||||||
done_flag,
|
done_flag,
|
||||||
app,
|
app,
|
||||||
|
None,
|
||||||
state.samples_played.clone(),
|
state.samples_played.clone(),
|
||||||
state.current_sample_rate.clone(),
|
state.current_sample_rate.clone(),
|
||||||
state.current_channels.clone(),
|
state.current_channels.clone(),
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ pub(crate) struct StreamCompletedSpill {
|
|||||||
pub(crate) struct ChainedInfo {
|
pub(crate) struct ChainedInfo {
|
||||||
/// The URL that was chained — used by audio_play to detect a pre-chain hit.
|
/// The URL that was chained — used by audio_play to detect a pre-chain hit.
|
||||||
pub(crate) url: String,
|
pub(crate) url: String,
|
||||||
|
/// Subsonic track id for analysis dispatch (from `audio_chain_preload`).
|
||||||
|
pub(crate) analysis_track_id: Option<String>,
|
||||||
|
/// Playback server scope for analysis writes.
|
||||||
|
pub(crate) server_id: Option<String>,
|
||||||
/// Raw file bytes (shared with the chained decoder). Lets manual skip reuse
|
/// Raw file bytes (shared with the chained decoder). Lets manual skip reuse
|
||||||
/// them instead of re-downloading after dropping the Sink queue.
|
/// them instead of re-downloading after dropping the Sink queue.
|
||||||
pub(crate) raw_bytes: Arc<Vec<u8>>,
|
pub(crate) raw_bytes: Arc<Vec<u8>>,
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ use super::{
|
|||||||
RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS,
|
RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS,
|
||||||
TRACK_STREAM_PROMOTE_MAX_BYTES,
|
TRACK_STREAM_PROMOTE_MAX_BYTES,
|
||||||
};
|
};
|
||||||
use crate::helpers::{
|
use crate::analysis_dispatch::{
|
||||||
install_stream_completed_spill, spawn_analysis_seed_from_spill_file, write_stream_spill_file,
|
dispatch_track_analysis_bytes, high_priority_for_app, resolve_server_id_for_app,
|
||||||
|
spawn_track_analysis_file, TrackAnalysisOrigin,
|
||||||
};
|
};
|
||||||
|
use crate::helpers::{install_stream_completed_spill, write_stream_spill_file};
|
||||||
use crate::state::StreamCompletedSpill;
|
use crate::state::StreamCompletedSpill;
|
||||||
|
|
||||||
/// Clears `AudioEngine::ranged_loudness_seed_hold` only if it still matches this play.
|
/// Clears `AudioEngine::ranged_loudness_seed_hold` only if it still matches this play.
|
||||||
@@ -644,10 +646,19 @@ pub(crate) async fn ranged_download_task(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(track_id) = cache_track_id {
|
if let Some(track_id) = cache_track_id {
|
||||||
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
|
let sid = resolve_server_id_for_app(&app, server_id.as_deref());
|
||||||
let sid = server_id.clone().unwrap_or_default();
|
let high = high_priority_for_app(&app, &track_id, None);
|
||||||
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), sid, track_id.clone(), data.clone(), high).await {
|
if let Err(e) = dispatch_track_analysis_bytes(
|
||||||
crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e);
|
&app,
|
||||||
|
TrackAnalysisOrigin::StreamDownloadComplete,
|
||||||
|
&sid,
|
||||||
|
&track_id,
|
||||||
|
data.clone(),
|
||||||
|
high,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
crate::app_eprintln!("[analysis] ranged seed failed for {track_id}: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||||
@@ -679,13 +690,16 @@ pub(crate) async fn ranged_download_task(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
install_stream_completed_spill(&spill_cache_slot, url, path.clone());
|
install_stream_completed_spill(&spill_cache_slot, url, path.clone());
|
||||||
spawn_analysis_seed_from_spill_file(
|
let sid = resolve_server_id_for_app(&app, server_id.as_deref());
|
||||||
&app,
|
let high = high_priority_for_app(&app, &track_id, None);
|
||||||
server_id.as_deref(),
|
spawn_track_analysis_file(
|
||||||
&track_id,
|
app.clone(),
|
||||||
|
TrackAnalysisOrigin::StreamSpillFile,
|
||||||
|
sid,
|
||||||
|
track_id,
|
||||||
path,
|
path,
|
||||||
gen,
|
high,
|
||||||
&gen_arc,
|
Some((gen, gen_arc.clone())),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -167,12 +167,22 @@ pub(crate) async fn track_download_task(
|
|||||||
track_id,
|
track_id,
|
||||||
capture.len() as f64 / (1024.0 * 1024.0)
|
capture.len() as f64 / (1024.0 * 1024.0)
|
||||||
);
|
);
|
||||||
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
|
let sid = crate::analysis_dispatch::resolve_server_id_for_app(
|
||||||
let sid = server_id.clone().unwrap_or_default();
|
&app,
|
||||||
if let Err(e) =
|
server_id.as_deref(),
|
||||||
psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), sid, track_id.clone(), capture.clone(), high).await
|
);
|
||||||
|
let high = crate::analysis_dispatch::high_priority_for_app(&app, &track_id, None);
|
||||||
|
if let Err(e) = crate::analysis_dispatch::dispatch_track_analysis_bytes(
|
||||||
|
&app,
|
||||||
|
crate::analysis_dispatch::TrackAnalysisOrigin::StreamDownloadComplete,
|
||||||
|
&sid,
|
||||||
|
&track_id,
|
||||||
|
capture.clone(),
|
||||||
|
high,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e);
|
crate::app_eprintln!("[analysis] track seed failed for {track_id}: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if gen_arc.load(Ordering::SeqCst) != gen {
|
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||||
|
|||||||
@@ -6,4 +6,6 @@
|
|||||||
|
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
|
pub mod track_analysis;
|
||||||
|
pub mod track_enrichment;
|
||||||
pub mod user_agent;
|
pub mod user_agent;
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//! Unified client-side track analysis plan (waveform / LUFS / enrichment facts).
|
||||||
|
//!
|
||||||
|
//! Planning logic lives in `psysonic-analysis::track_analysis_plan`; this module
|
||||||
|
//! holds the shared outcome type so future analysis modes can extend the plan
|
||||||
|
//! without pulling analysis-cache types into every crate.
|
||||||
|
|
||||||
|
use crate::track_enrichment::TrackEnrichmentPlan;
|
||||||
|
|
||||||
|
/// What still needs to be computed for a track at the current content fingerprint.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct TrackAnalysisPlan {
|
||||||
|
/// Waveform bins missing for `(server_id, track_id)` at the current algo version.
|
||||||
|
pub need_waveform: bool,
|
||||||
|
/// Integrated LUFS / true-peak row missing.
|
||||||
|
pub need_loudness: bool,
|
||||||
|
/// Oximedia BPM + mood facts (`track_fact` via library enrichment port).
|
||||||
|
pub enrichment: TrackEnrichmentPlan,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrackAnalysisPlan {
|
||||||
|
pub fn any(self) -> bool {
|
||||||
|
self.need_waveform || self.need_loudness || self.enrichment.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Symphonia full-file decode (waveform and/or EBU R128 loudness).
|
||||||
|
pub fn needs_full_cpu_seed(self) -> bool {
|
||||||
|
self.need_waveform || self.need_loudness
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Oximedia 60 s center window only — waveform + loudness already cached.
|
||||||
|
pub fn needs_enrichment_only(self) -> bool {
|
||||||
|
!self.needs_full_cpu_seed() && self.enrichment.any()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//! Shared types for client-side track enrichment (oximedia BPM / mood).
|
||||||
|
|
||||||
|
/// Which analysis facts still need to be computed for the current content hash.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct TrackEnrichmentPlan {
|
||||||
|
pub need_bpm: bool,
|
||||||
|
pub need_valence: bool,
|
||||||
|
pub need_arousal: bool,
|
||||||
|
/// Raw oximedia mood scores JSON (`{"calm":0.4,...}`).
|
||||||
|
pub need_moods: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrackEnrichmentPlan {
|
||||||
|
pub fn any(self) -> bool {
|
||||||
|
self.need_bpm || self.need_valence || self.need_arousal || self.need_moods
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct TrackEnrichmentIntFact {
|
||||||
|
pub value: i64,
|
||||||
|
pub confidence: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct TrackEnrichmentRealFact {
|
||||||
|
pub value: f64,
|
||||||
|
pub confidence: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Facts produced by oximedia for persistence via the library port.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct TrackEnrichmentFacts {
|
||||||
|
pub bpm: Option<TrackEnrichmentIntFact>,
|
||||||
|
pub valence: Option<TrackEnrichmentRealFact>,
|
||||||
|
pub arousal: Option<TrackEnrichmentRealFact>,
|
||||||
|
/// Oximedia `MoodResult.moods` serialized as JSON object (label → score).
|
||||||
|
pub moods: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TrackEnrichmentOutcome {
|
||||||
|
Applied,
|
||||||
|
/// Nothing to compute for the current content hash.
|
||||||
|
SkippedComplete,
|
||||||
|
/// Oximedia analysis or persistence failed; facts were not stored (retry on next seed).
|
||||||
|
Failed,
|
||||||
|
SkippedNoServer,
|
||||||
|
SkippedNoPort,
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlanFn = std::sync::Arc<
|
||||||
|
dyn Fn(&str, &str, &str) -> TrackEnrichmentPlan + Send + Sync + 'static,
|
||||||
|
>;
|
||||||
|
type StoreFn = std::sync::Arc<
|
||||||
|
dyn Fn(&str, &str, &str, &TrackEnrichmentFacts) -> Result<(), String> + Send + Sync + 'static,
|
||||||
|
>;
|
||||||
|
|
||||||
|
/// Library↔analysis port: plan missing facts and store computed results without
|
||||||
|
/// pulling `psysonic-library` into `psysonic-analysis`.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TrackEnrichmentPort {
|
||||||
|
plan: PlanFn,
|
||||||
|
store: StoreFn,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrackEnrichmentPort {
|
||||||
|
pub fn new<P, S>(plan: P, store: S) -> Self
|
||||||
|
where
|
||||||
|
P: Fn(&str, &str, &str) -> TrackEnrichmentPlan + Send + Sync + 'static,
|
||||||
|
S: Fn(&str, &str, &str, &TrackEnrichmentFacts) -> Result<(), String>
|
||||||
|
+ Send
|
||||||
|
+ Sync
|
||||||
|
+ 'static,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
plan: std::sync::Arc::new(plan),
|
||||||
|
store: std::sync::Arc::new(store),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn plan(&self, server_id: &str, track_id: &str, content_hash: &str) -> TrackEnrichmentPlan {
|
||||||
|
(self.plan)(server_id, track_id, content_hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store(
|
||||||
|
&self,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
content_hash: &str,
|
||||||
|
facts: &TrackEnrichmentFacts,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
(self.store)(server_id, track_id, content_hash, facts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Atomic mood tags for Advanced Search (EXISTS on track_fact).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_track_fact_mood_tag
|
||||||
|
ON track_fact(server_id, fact_kind, value_text, track_id)
|
||||||
|
WHERE fact_kind = 'mood_tag';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Oximedia mood heuristics were misleading; drop accumulated mood facts.
|
||||||
|
DELETE FROM track_fact
|
||||||
|
WHERE fact_kind IN ('mood_tag', 'moods', 'valence', 'arousal', 'mood_labels');
|
||||||
@@ -20,20 +20,17 @@ use crate::dto::{
|
|||||||
use crate::filter::{self, EntityKind, FilterOp, SqlFragment};
|
use crate::filter::{self, EntityKind, FilterOp, SqlFragment};
|
||||||
use crate::repos;
|
use crate::repos;
|
||||||
use crate::search::{
|
use crate::search::{
|
||||||
aliased_track_columns, fts_album_prefix_match_query, fts_column_prefix_query,
|
aliased_track_columns, aliased_track_columns_resolved_bpm, bpm_resolved_expr,
|
||||||
fts_query_meets_min_len, fts_track_prefix_match_query, library_scope_equals_sql,
|
fts_album_prefix_match_query, fts_column_prefix_query, fts_query_meets_min_len,
|
||||||
like_contains, PAGE_LIMIT_MAX,
|
fts_track_prefix_match_query, library_scope_equals_sql, like_contains, PAGE_LIMIT_MAX,
|
||||||
};
|
};
|
||||||
use crate::store::LibraryStore;
|
use crate::store::LibraryStore;
|
||||||
|
|
||||||
/// `bpm` dual-storage resolution (§5.13.4): prefer the hot `track.bpm`
|
/// `bpm` dual-storage resolution (§5.13.4): prefer analysis `track_fact(bpm)`,
|
||||||
/// column, fall back to the highest-priority `track_fact(bpm)` value. The
|
/// then hot `track.bpm` tag, then other fact sources.
|
||||||
/// spec's `not_found = 0` guard is dropped — the live `track_fact` schema
|
fn bpm_resolved_sql() -> String {
|
||||||
/// has no such column (that lives on `track_artifact`).
|
bpm_resolved_expr("t")
|
||||||
const BPM_RESOLVED_EXPR: &str = "COALESCE(t.bpm, (SELECT f.value_int FROM track_fact f \
|
}
|
||||||
WHERE f.server_id = t.server_id AND f.track_id = t.id AND f.fact_kind = 'bpm' \
|
|
||||||
ORDER BY CASE f.source_kind WHEN 'user' THEN 0 WHEN 'server_tag' THEN 1 \
|
|
||||||
WHEN 'analysis' THEN 2 ELSE 3 END LIMIT 1))";
|
|
||||||
|
|
||||||
const ALBUM_COLUMNS: &str = "a.server_id, a.id, a.name, a.artist, a.artist_id, \
|
const ALBUM_COLUMNS: &str = "a.server_id, a.id, a.name, a.artist, a.artist_id, \
|
||||||
a.song_count, a.duration_sec, a.year, a.genre, a.cover_art_id, a.starred_at, \
|
a.song_count, a.duration_sec, a.year, a.genre, a.cover_art_id, a.starred_at, \
|
||||||
@@ -183,7 +180,17 @@ fn build_track(
|
|||||||
applied.insert("starred".to_string());
|
applied.insert("starred".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let cols = aliased_track_columns("t");
|
let bpm_resolved = scalar.iter().any(|c| c.field == "bpm");
|
||||||
|
let cols = if bpm_resolved {
|
||||||
|
aliased_track_columns_resolved_bpm("t")
|
||||||
|
} else {
|
||||||
|
aliased_track_columns("t")
|
||||||
|
};
|
||||||
|
let map_track = if bpm_resolved {
|
||||||
|
map_track_row_resolved_bpm
|
||||||
|
} else {
|
||||||
|
map_track_row_default
|
||||||
|
};
|
||||||
if let Some(q) = text.and_then(fts_track_prefix_match_query) {
|
if let Some(q) = text.and_then(fts_track_prefix_match_query) {
|
||||||
applied.insert("text".to_string());
|
applied.insert("text".to_string());
|
||||||
let pool = fts_candidate_pool_size(limit, offset);
|
let pool = fts_candidate_pool_size(limit, offset);
|
||||||
@@ -205,7 +212,7 @@ fn build_track(
|
|||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
skip_totals,
|
skip_totals,
|
||||||
|r| repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row)),
|
map_track,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,10 +227,18 @@ fn build_track(
|
|||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
skip_totals,
|
skip_totals,
|
||||||
|r| repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row)),
|
map_track,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn map_track_row_default(row: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryTrackDto> {
|
||||||
|
repos::row_to_track_row(row).map(|r| LibraryTrackDto::from_row(&r))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_track_row_resolved_bpm(row: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryTrackDto> {
|
||||||
|
crate::search::row_to_track_dto_resolved_bpm(row)
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn build_album(
|
fn build_album(
|
||||||
store: &LibraryStore,
|
store: &LibraryStore,
|
||||||
@@ -235,9 +250,11 @@ fn build_album(
|
|||||||
skip_totals: bool,
|
skip_totals: bool,
|
||||||
applied: &mut BTreeSet<String>,
|
applied: &mut BTreeSet<String>,
|
||||||
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
|
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
|
||||||
let table = build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?;
|
if !scalar_requires_track_derived_entities(scalar) {
|
||||||
if !table.0.is_empty() || table.1 > 0 {
|
let table = build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?;
|
||||||
return Ok(table);
|
if !table.0.is_empty() || table.1 > 0 {
|
||||||
|
return Ok(table);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(q) = text.and_then(fts_album_prefix_match_query) {
|
if let Some(q) = text.and_then(fts_album_prefix_match_query) {
|
||||||
return build_album_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied);
|
return build_album_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied);
|
||||||
@@ -360,9 +377,11 @@ fn build_artist(
|
|||||||
skip_totals: bool,
|
skip_totals: bool,
|
||||||
applied: &mut BTreeSet<String>,
|
applied: &mut BTreeSet<String>,
|
||||||
) -> Result<(Vec<LibraryArtistDto>, u32), String> {
|
) -> Result<(Vec<LibraryArtistDto>, u32), String> {
|
||||||
let table = build_artist_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?;
|
if !scalar_requires_track_derived_entities(scalar) {
|
||||||
if !table.0.is_empty() || table.1 > 0 {
|
let table = build_artist_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?;
|
||||||
return Ok(table);
|
if !table.0.is_empty() || table.1 > 0 {
|
||||||
|
return Ok(table);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(q) = text.and_then(|t| fts_column_prefix_query("artist", t)) {
|
if let Some(q) = text.and_then(|t| fts_column_prefix_query("artist", t)) {
|
||||||
return build_artist_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied);
|
return build_artist_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied);
|
||||||
@@ -658,6 +677,14 @@ fn build_artist_from_fts(
|
|||||||
|
|
||||||
// ── clause resolution ──────────────────────────────────────────────────
|
// ── clause resolution ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Track-only filters that require joining through `track` (mood enrichment facts).
|
||||||
|
/// Other track-only fields (e.g. `bpm`) are skipped silently on album/artist queries.
|
||||||
|
fn scalar_requires_track_derived_entities(scalar: &[&LibraryFilterClause]) -> bool {
|
||||||
|
scalar
|
||||||
|
.iter()
|
||||||
|
.any(|c| matches!(c.field.as_str(), "mood_group" | "mood_tag"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve one scalar clause to a WHERE fragment for `entity`. `Ok(None)`
|
/// Resolve one scalar clause to a WHERE fragment for `entity`. `Ok(None)`
|
||||||
/// means the field is known but doesn't route to this entity (§5.13.3 skip).
|
/// means the field is known but doesn't route to this entity (§5.13.3 skip).
|
||||||
fn resolve_clause(
|
fn resolve_clause(
|
||||||
@@ -668,6 +695,14 @@ fn resolve_clause(
|
|||||||
if !applies {
|
if !applies {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
if c.field == "bpm" && entity == EntityKind::Track {
|
||||||
|
let col = bpm_resolved_sql();
|
||||||
|
let value = json_to_opt_i64(&c.field, c.value.as_ref())?;
|
||||||
|
let value_to = json_to_opt_i64(&c.field, c.value_to.as_ref())?;
|
||||||
|
return filter::compare_fragment(&c.field, &col, c.op, value, value_to)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
}
|
||||||
let col = match (c.field.as_str(), entity) {
|
let col = match (c.field.as_str(), entity) {
|
||||||
("genre", EntityKind::Track) => "t.genre",
|
("genre", EntityKind::Track) => "t.genre",
|
||||||
("genre", EntityKind::Album) => "a.genre",
|
("genre", EntityKind::Album) => "a.genre",
|
||||||
@@ -678,7 +713,9 @@ fn resolve_clause(
|
|||||||
// `starred` routes to artist in the registry, but the `artist`
|
// `starred` routes to artist in the registry, but the `artist`
|
||||||
// table has no `starred_at` column — skip rather than error.
|
// table has no `starred_at` column — skip rather than error.
|
||||||
("starred", EntityKind::Artist) => return Ok(None),
|
("starred", EntityKind::Artist) => return Ok(None),
|
||||||
("bpm", EntityKind::Track) => BPM_RESOLVED_EXPR,
|
("mood_group" | "mood_tag", EntityKind::Track) => {
|
||||||
|
return crate::advanced_search_mood::resolve_mood_clause(c);
|
||||||
|
}
|
||||||
// `text` is handled by the entity builder (FTS / LIKE), never here.
|
// `text` is handled by the entity builder (FTS / LIKE), never here.
|
||||||
("text", _) => return Ok(None),
|
("text", _) => return Ok(None),
|
||||||
// Registered but no v1 SQL builder (user_rating / suffix / bit_rate).
|
// Registered but no v1 SQL builder (user_rating / suffix / bit_rate).
|
||||||
@@ -1330,6 +1367,132 @@ mod tests {
|
|||||||
r.filters = vec![clause("bpm", FilterOp::Between, Some(json!(125)), Some(json!(130)))];
|
r.filters = vec![clause("bpm", FilterOp::Between, Some(json!(125)), Some(json!(130)))];
|
||||||
let resp = run_advanced_search(&store, &r).unwrap();
|
let resp = run_advanced_search(&store, &r).unwrap();
|
||||||
assert_eq!(resp.tracks.len(), 1, "bpm should resolve via track_fact fallback");
|
assert_eq!(resp.tracks.len(), 1, "bpm should resolve via track_fact fallback");
|
||||||
|
assert_eq!(resp.tracks[0].bpm, Some(128));
|
||||||
|
assert_eq!(resp.tracks[0].bpm_source.as_deref(), Some("analysis"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bpm_filter_prefers_analysis_fact_over_hot_tag() {
|
||||||
|
let store = LibraryStore::open_in_memory();
|
||||||
|
let mut a = track("s1", "t1", "A", "X", "Alb");
|
||||||
|
a.bpm = Some(90);
|
||||||
|
TrackRepository::new(&store).upsert_batch(&[a]).unwrap();
|
||||||
|
store
|
||||||
|
.with_conn("misc", |c| {
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO track_fact \
|
||||||
|
(server_id, track_id, fact_kind, value_int, source_kind, source_id, confidence, fetched_at) \
|
||||||
|
VALUES ('s1', 't1', 'bpm', 128, 'analysis', 'oximedia-60s-center', 1.0, 1)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let mut r = req("s1", &[EntityKind::Track]);
|
||||||
|
r.filters = vec![clause("bpm", FilterOp::Between, Some(json!(125)), Some(json!(130)))];
|
||||||
|
let resp = run_advanced_search(&store, &r).unwrap();
|
||||||
|
assert_eq!(resp.tracks.len(), 1);
|
||||||
|
assert_eq!(resp.tracks[0].bpm, Some(128));
|
||||||
|
assert_eq!(resp.tracks[0].bpm_source.as_deref(), Some("analysis"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bpm_source_is_tag_when_only_hot_column_set() {
|
||||||
|
let store = LibraryStore::open_in_memory();
|
||||||
|
let mut a = track("s1", "t1", "A", "X", "Alb");
|
||||||
|
a.bpm = Some(125);
|
||||||
|
TrackRepository::new(&store).upsert_batch(&[a]).unwrap();
|
||||||
|
let mut r = req("s1", &[EntityKind::Track]);
|
||||||
|
r.filters = vec![clause("bpm", FilterOp::Between, Some(json!(120)), Some(json!(130)))];
|
||||||
|
let resp = run_advanced_search(&store, &r).unwrap();
|
||||||
|
assert_eq!(resp.tracks.len(), 1);
|
||||||
|
assert_eq!(resp.tracks[0].bpm_source.as_deref(), Some("tag"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mood tag / group filters ─────────────────────────────────────
|
||||||
|
|
||||||
|
fn insert_mood_tag(store: &LibraryStore, server: &str, track: &str, tag: &str) {
|
||||||
|
store
|
||||||
|
.with_conn("misc", |c| {
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO track_fact \
|
||||||
|
(server_id, track_id, fact_kind, value_text, source_kind, source_id, confidence, fetched_at) \
|
||||||
|
VALUES (?1, ?2, 'mood_tag', ?3, 'analysis', ?4, 1.0, 1)",
|
||||||
|
rusqlite::params![server, track, tag, format!("oximedia-60s-center:{tag}")],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mood_group_joy_matches_happy_mood_tag() {
|
||||||
|
let store = LibraryStore::open_in_memory();
|
||||||
|
TrackRepository::new(&store)
|
||||||
|
.upsert_batch(&[
|
||||||
|
track("s1", "t1", "A", "X", "Alb"),
|
||||||
|
track("s1", "t2", "B", "X", "Alb"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
insert_mood_tag(&store, "s1", "t1", "happy");
|
||||||
|
let mut r = req("s1", &[EntityKind::Track]);
|
||||||
|
r.filters = vec![clause("mood_group", FilterOp::Eq, Some(json!("joy")), None)];
|
||||||
|
let resp = run_advanced_search(&store, &r).unwrap();
|
||||||
|
assert_eq!(resp.tracks.len(), 1);
|
||||||
|
assert_eq!(resp.tracks[0].id, "t1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mood_groups_overlap_work_and_romance_on_calm_peaceful_track() {
|
||||||
|
let store = LibraryStore::open_in_memory();
|
||||||
|
TrackRepository::new(&store)
|
||||||
|
.upsert_batch(&[track("s1", "t1", "Calm", "X", "Alb")])
|
||||||
|
.unwrap();
|
||||||
|
insert_mood_tag(&store, "s1", "t1", "calm");
|
||||||
|
insert_mood_tag(&store, "s1", "t1", "peaceful");
|
||||||
|
for group in ["work", "romance"] {
|
||||||
|
let mut r = req("s1", &[EntityKind::Track]);
|
||||||
|
r.filters = vec![clause("mood_group", FilterOp::Eq, Some(json!(group)), None)];
|
||||||
|
let resp = run_advanced_search(&store, &r).unwrap();
|
||||||
|
assert_eq!(resp.tracks.len(), 1, "group `{group}` should match calm/peaceful");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mood_group_in_joy_matches_happy_tag() {
|
||||||
|
let store = LibraryStore::open_in_memory();
|
||||||
|
TrackRepository::new(&store)
|
||||||
|
.upsert_batch(&[
|
||||||
|
track("s1", "t1", "A", "X", "Alb"),
|
||||||
|
track("s1", "t2", "B", "X", "Alb"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
insert_mood_tag(&store, "s1", "t1", "happy");
|
||||||
|
let mut r = req("s1", &[EntityKind::Track]);
|
||||||
|
r.filters = vec![clause(
|
||||||
|
"mood_group",
|
||||||
|
FilterOp::In,
|
||||||
|
Some(json!(["joy"])),
|
||||||
|
None,
|
||||||
|
)];
|
||||||
|
let resp = run_advanced_search(&store, &r).unwrap();
|
||||||
|
assert_eq!(resp.tracks.len(), 1);
|
||||||
|
assert_eq!(resp.tracks[0].id, "t1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mood_tag_eq_calm_matches_calm_fact() {
|
||||||
|
let store = LibraryStore::open_in_memory();
|
||||||
|
TrackRepository::new(&store)
|
||||||
|
.upsert_batch(&[
|
||||||
|
track("s1", "t1", "A", "X", "Alb"),
|
||||||
|
track("s1", "t2", "B", "X", "Alb"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
insert_mood_tag(&store, "s1", "t2", "calm");
|
||||||
|
let mut r = req("s1", &[EntityKind::Track]);
|
||||||
|
r.filters = vec![clause("mood_tag", FilterOp::Eq, Some(json!("calm")), None)];
|
||||||
|
let resp = run_advanced_search(&store, &r).unwrap();
|
||||||
|
assert_eq!(resp.tracks.len(), 1);
|
||||||
|
assert_eq!(resp.tracks[0].id, "t2");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── entity routing / errors ────────────────────────────────────────
|
// ── entity routing / errors ────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
//! Mood filter SQL for Advanced Search (`mood_group` / `mood_tag` clauses).
|
||||||
|
|
||||||
|
use rusqlite::types::Value as SqlValue;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::dto::LibraryFilterClause;
|
||||||
|
use crate::filter::{self, FilterOp, SqlFragment};
|
||||||
|
use crate::mood_groups;
|
||||||
|
|
||||||
|
pub fn resolve_mood_clause(c: &LibraryFilterClause) -> Result<Option<SqlFragment>, String> {
|
||||||
|
match c.field.as_str() {
|
||||||
|
"mood_group" => {
|
||||||
|
let group_ids = json_to_string_list(&c.field, c.op, c.value.as_ref())?;
|
||||||
|
mood_groups::normalize_mood_groups(&group_ids).map_err(|detail| {
|
||||||
|
filter::FilterError::BadValue {
|
||||||
|
field: c.field.clone(),
|
||||||
|
detail,
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
let tags = mood_groups::expand_mood_groups(&group_ids).map_err(|detail| {
|
||||||
|
filter::FilterError::BadValue {
|
||||||
|
field: c.field.clone(),
|
||||||
|
detail,
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
Ok(Some(mood_tag_exists_fragment(&tags)))
|
||||||
|
}
|
||||||
|
"mood_tag" => {
|
||||||
|
let tag_ids = json_to_string_list(&c.field, c.op, c.value.as_ref())?;
|
||||||
|
let tags = mood_groups::normalize_mood_tags(&tag_ids).map_err(|detail| {
|
||||||
|
filter::FilterError::BadValue {
|
||||||
|
field: c.field.clone(),
|
||||||
|
detail,
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
Ok(Some(mood_tag_exists_fragment(&tags)))
|
||||||
|
}
|
||||||
|
_ => unreachable!("resolve_mood_clause called for non-mood field"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mood_tag_exists_fragment(tags: &[String]) -> SqlFragment {
|
||||||
|
let placeholders = (0..tags.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
|
||||||
|
SqlFragment {
|
||||||
|
sql: format!(
|
||||||
|
"EXISTS (SELECT 1 FROM track_fact mf \
|
||||||
|
WHERE mf.server_id = t.server_id AND mf.track_id = t.id \
|
||||||
|
AND mf.fact_kind = 'mood_tag' AND mf.value_text IN ({placeholders}))"
|
||||||
|
),
|
||||||
|
params: tags
|
||||||
|
.iter()
|
||||||
|
.map(|t| SqlValue::Text(t.clone()))
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_to_string_list(
|
||||||
|
field: &str,
|
||||||
|
op: FilterOp,
|
||||||
|
v: Option<&Value>,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
match op {
|
||||||
|
FilterOp::Eq => {
|
||||||
|
let s = json_to_text(field, v)?;
|
||||||
|
Ok(vec![match s {
|
||||||
|
SqlValue::Text(t) => t,
|
||||||
|
_ => unreachable!(),
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
FilterOp::In => match v {
|
||||||
|
Some(Value::Array(items)) => {
|
||||||
|
if items.is_empty() {
|
||||||
|
return Err(filter::FilterError::BadValue {
|
||||||
|
field: field.to_string(),
|
||||||
|
detail: "operator `in` requires a non-empty array".to_string(),
|
||||||
|
}
|
||||||
|
.to_string());
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(items.len());
|
||||||
|
for item in items {
|
||||||
|
match item {
|
||||||
|
Value::String(s) => out.push(s.clone()),
|
||||||
|
_ => {
|
||||||
|
return Err(filter::FilterError::BadValue {
|
||||||
|
field: field.to_string(),
|
||||||
|
detail: "expected an array of strings".to_string(),
|
||||||
|
}
|
||||||
|
.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
_ => Err(filter::FilterError::BadValue {
|
||||||
|
field: field.to_string(),
|
||||||
|
detail: "operator `in` requires an array value".to_string(),
|
||||||
|
}
|
||||||
|
.to_string()),
|
||||||
|
}
|
||||||
|
_ => Err(filter::FilterError::UnsupportedOp {
|
||||||
|
field: field.to_string(),
|
||||||
|
op: op.as_str(),
|
||||||
|
}
|
||||||
|
.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_to_text(field: &str, v: Option<&Value>) -> Result<SqlValue, String> {
|
||||||
|
match v {
|
||||||
|
Some(Value::String(s)) => Ok(SqlValue::Text(s.clone())),
|
||||||
|
_ => Err(filter::FilterError::BadValue {
|
||||||
|
field: field.to_string(),
|
||||||
|
detail: "expected a string value".to_string(),
|
||||||
|
}
|
||||||
|
.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,9 @@ pub struct LibraryTrackDto {
|
|||||||
pub isrc: Option<String>,
|
pub isrc: Option<String>,
|
||||||
pub mbid_recording: Option<String>,
|
pub mbid_recording: Option<String>,
|
||||||
pub bpm: Option<i64>,
|
pub bpm: Option<i64>,
|
||||||
|
/// `'analysis'` | `'tag'` — only on Advanced Search rows with BPM dual-storage projection.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bpm_source: Option<String>,
|
||||||
pub replay_gain_track_db: Option<f64>,
|
pub replay_gain_track_db: Option<f64>,
|
||||||
pub replay_gain_album_db: Option<f64>,
|
pub replay_gain_album_db: Option<f64>,
|
||||||
|
|
||||||
@@ -149,6 +152,7 @@ impl LibraryTrackDto {
|
|||||||
isrc: row.isrc.clone(),
|
isrc: row.isrc.clone(),
|
||||||
mbid_recording: row.mbid_recording.clone(),
|
mbid_recording: row.mbid_recording.clone(),
|
||||||
bpm: row.bpm,
|
bpm: row.bpm,
|
||||||
|
bpm_source: None,
|
||||||
replay_gain_track_db: row.replay_gain_track_db,
|
replay_gain_track_db: row.replay_gain_track_db,
|
||||||
replay_gain_album_db: row.replay_gain_album_db,
|
replay_gain_album_db: row.replay_gain_album_db,
|
||||||
server_updated_at: row.server_updated_at,
|
server_updated_at: row.server_updated_at,
|
||||||
|
|||||||
@@ -0,0 +1,622 @@
|
|||||||
|
//! Client-side track enrichment — plan/store analysis facts (oximedia BPM/mood).
|
||||||
|
|
||||||
|
use psysonic_core::track_enrichment::{TrackEnrichmentFacts, TrackEnrichmentPlan};
|
||||||
|
|
||||||
|
use crate::dto::FactInputDto;
|
||||||
|
use crate::mood_groups;
|
||||||
|
use crate::repos::fact::FactRepository;
|
||||||
|
use crate::store::LibraryStore;
|
||||||
|
|
||||||
|
pub const OXIMEDIA_ENRICHMENT_SOURCE_KIND: &str = "analysis";
|
||||||
|
pub const OXIMEDIA_ENRICHMENT_SOURCE_ID: &str = "oximedia-60s-center";
|
||||||
|
|
||||||
|
/// Oximedia 0.1.7 mood is a spectral energy heuristic (not ML). Disabled until
|
||||||
|
/// the crate ships a reliable classifier; re-enable plan/store/analysis together.
|
||||||
|
pub const OXIMEDIA_MOOD_ANALYSIS_ENABLED: bool = false;
|
||||||
|
|
||||||
|
/// Derived mood tags for search/UI — requires analysis + a usable model.
|
||||||
|
pub const OXIMEDIA_MOOD_TAGS_ENABLED: bool = OXIMEDIA_MOOD_ANALYSIS_ENABLED;
|
||||||
|
|
||||||
|
const ENRICHMENT_KINDS: [&str; 5] = ["bpm", "valence", "arousal", "moods", "mood_tag"];
|
||||||
|
|
||||||
|
pub fn mood_tag_source_id(tag: &str) -> String {
|
||||||
|
format!("{OXIMEDIA_ENRICHMENT_SOURCE_ID}:{tag}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn plan_track_enrichment(
|
||||||
|
store: &LibraryStore,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
content_hash: &str,
|
||||||
|
now: i64,
|
||||||
|
) -> Result<TrackEnrichmentPlan, String> {
|
||||||
|
let repo = FactRepository::new(store);
|
||||||
|
let facts = repo.get(
|
||||||
|
server_id,
|
||||||
|
track_id,
|
||||||
|
&ENRICHMENT_KINDS.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
|
||||||
|
now,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let (need_valence, need_arousal, need_moods) = if OXIMEDIA_MOOD_ANALYSIS_ENABLED {
|
||||||
|
let mut need_moods = !fact_current(&facts, "moods", content_hash);
|
||||||
|
if OXIMEDIA_MOOD_TAGS_ENABLED {
|
||||||
|
if !mood_tags_current(&facts, content_hash)
|
||||||
|
&& !backfill_mood_tags_from_stored_facts(store, server_id, track_id, content_hash, now)?
|
||||||
|
&& !need_moods
|
||||||
|
{
|
||||||
|
need_moods = true;
|
||||||
|
} else if mood_tags_current(&facts, content_hash)
|
||||||
|
&& mood_tags_need_va_refresh(&facts, content_hash)
|
||||||
|
{
|
||||||
|
let _ = backfill_mood_tags_from_stored_facts(
|
||||||
|
store,
|
||||||
|
server_id,
|
||||||
|
track_id,
|
||||||
|
content_hash,
|
||||||
|
now,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(
|
||||||
|
!fact_current(&facts, "valence", content_hash),
|
||||||
|
!fact_current(&facts, "arousal", content_hash),
|
||||||
|
need_moods,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(false, false, false)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(TrackEnrichmentPlan {
|
||||||
|
need_bpm: !fact_current(&facts, "bpm", content_hash),
|
||||||
|
need_valence,
|
||||||
|
need_arousal,
|
||||||
|
need_moods,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store_track_enrichment_facts(
|
||||||
|
store: &LibraryStore,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
content_hash: &str,
|
||||||
|
facts: &TrackEnrichmentFacts,
|
||||||
|
now: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let repo = FactRepository::new(store);
|
||||||
|
if let Some(bpm) = facts.bpm {
|
||||||
|
repo.put(
|
||||||
|
server_id,
|
||||||
|
track_id,
|
||||||
|
&analysis_fact("bpm", None, Some(bpm.value), content_hash, bpm.confidence),
|
||||||
|
now,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
if OXIMEDIA_MOOD_ANALYSIS_ENABLED {
|
||||||
|
if let Some(valence) = facts.valence {
|
||||||
|
repo.put(
|
||||||
|
server_id,
|
||||||
|
track_id,
|
||||||
|
&analysis_fact(
|
||||||
|
"valence",
|
||||||
|
Some(valence.value),
|
||||||
|
None,
|
||||||
|
content_hash,
|
||||||
|
valence.confidence,
|
||||||
|
),
|
||||||
|
now,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
if let Some(arousal) = facts.arousal {
|
||||||
|
repo.put(
|
||||||
|
server_id,
|
||||||
|
track_id,
|
||||||
|
&analysis_fact(
|
||||||
|
"arousal",
|
||||||
|
Some(arousal.value),
|
||||||
|
None,
|
||||||
|
content_hash,
|
||||||
|
arousal.confidence,
|
||||||
|
),
|
||||||
|
now,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
if let Some(json) = &facts.moods {
|
||||||
|
if !json.is_empty() {
|
||||||
|
repo.put(
|
||||||
|
server_id,
|
||||||
|
track_id,
|
||||||
|
&analysis_fact_text("moods", json, content_hash, 1.0),
|
||||||
|
now,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tags = mood_tags_for_enrichment_facts(facts, 2);
|
||||||
|
if OXIMEDIA_MOOD_TAGS_ENABLED && !tags.is_empty() {
|
||||||
|
replace_mood_tag_facts(store, server_id, track_id, content_hash, &tags, now)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mood_tags_for_enrichment_facts(facts: &TrackEnrichmentFacts, limit: usize) -> Vec<String> {
|
||||||
|
if let (Some(v), Some(a)) = (facts.valence, facts.arousal) {
|
||||||
|
return mood_groups::top_mood_tag_ids_from_valence_arousal(v.value, a.value, limit);
|
||||||
|
}
|
||||||
|
if let Some(json) = &facts.moods {
|
||||||
|
return mood_groups::top_distinct_oximedia_mood_tag_ids_from_moods_json(json, limit);
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mood_tags_from_stored_facts(
|
||||||
|
facts: &[crate::dto::TrackFactDto],
|
||||||
|
content_hash: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let matches_hash =
|
||||||
|
|f: &&crate::dto::TrackFactDto| f.content_hash.as_deref() == Some(content_hash);
|
||||||
|
let valence = facts
|
||||||
|
.iter()
|
||||||
|
.find(|f| is_oximedia_primary_fact(f) && f.fact_kind == "valence" && matches_hash(f))
|
||||||
|
.and_then(|f| f.value_real);
|
||||||
|
let arousal = facts
|
||||||
|
.iter()
|
||||||
|
.find(|f| is_oximedia_primary_fact(f) && f.fact_kind == "arousal" && matches_hash(f))
|
||||||
|
.and_then(|f| f.value_real);
|
||||||
|
if let (Some(v), Some(a)) = (valence, arousal) {
|
||||||
|
return mood_groups::top_mood_tag_ids_from_valence_arousal(v, a, limit);
|
||||||
|
}
|
||||||
|
let Some(json) = facts
|
||||||
|
.iter()
|
||||||
|
.find(|f| is_oximedia_primary_fact(f) && f.fact_kind == "moods" && matches_hash(f))
|
||||||
|
.and_then(|f| f.value_text.as_deref())
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
mood_groups::top_distinct_oximedia_mood_tag_ids_from_moods_json(json, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backfill_mood_tags_from_stored_facts(
|
||||||
|
store: &LibraryStore,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
content_hash: &str,
|
||||||
|
now: i64,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let repo = FactRepository::new(store);
|
||||||
|
let facts = repo.get(
|
||||||
|
server_id,
|
||||||
|
track_id,
|
||||||
|
&["moods".into(), "valence".into(), "arousal".into()],
|
||||||
|
now,
|
||||||
|
)?;
|
||||||
|
let tags = mood_tags_from_stored_facts(&facts, content_hash, 2);
|
||||||
|
if tags.is_empty() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
replace_mood_tag_facts(store, server_id, track_id, content_hash, &tags, now)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mood_tags_need_va_refresh(facts: &[crate::dto::TrackFactDto], content_hash: &str) -> bool {
|
||||||
|
let expected = mood_tags_from_stored_facts(facts, content_hash, 2);
|
||||||
|
if expected.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut current: Vec<String> = facts
|
||||||
|
.iter()
|
||||||
|
.filter(|f| {
|
||||||
|
f.fact_kind == "mood_tag"
|
||||||
|
&& f.source_kind == OXIMEDIA_ENRICHMENT_SOURCE_KIND
|
||||||
|
&& f.source_id.starts_with(&format!("{OXIMEDIA_ENRICHMENT_SOURCE_ID}:"))
|
||||||
|
&& f.content_hash.as_deref() == Some(content_hash)
|
||||||
|
})
|
||||||
|
.filter_map(|f| f.value_text.clone())
|
||||||
|
.collect();
|
||||||
|
current.sort();
|
||||||
|
let mut expected_sorted = expected;
|
||||||
|
expected_sorted.sort();
|
||||||
|
current != expected_sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_mood_tag_facts(
|
||||||
|
store: &LibraryStore,
|
||||||
|
server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
content_hash: &str,
|
||||||
|
tags: &[String],
|
||||||
|
now: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let like_prefix = format!("{OXIMEDIA_ENRICHMENT_SOURCE_ID}:%");
|
||||||
|
store
|
||||||
|
.with_conn("enrichment.mood_tags_clear", |conn| {
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM track_fact \
|
||||||
|
WHERE server_id = ?1 AND track_id = ?2 \
|
||||||
|
AND fact_kind = 'mood_tag' \
|
||||||
|
AND source_kind = ?3 \
|
||||||
|
AND source_id LIKE ?4",
|
||||||
|
rusqlite::params![
|
||||||
|
server_id,
|
||||||
|
track_id,
|
||||||
|
OXIMEDIA_ENRICHMENT_SOURCE_KIND,
|
||||||
|
like_prefix,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let repo = FactRepository::new(store);
|
||||||
|
for tag in tags {
|
||||||
|
if !mood_groups::is_oximedia_mood_tag(tag) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
repo.put(
|
||||||
|
server_id,
|
||||||
|
track_id,
|
||||||
|
&mood_tag_fact(tag, content_hash),
|
||||||
|
now,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mood_tag_fact(tag: &str, content_hash: &str) -> FactInputDto {
|
||||||
|
FactInputDto {
|
||||||
|
fact_kind: "mood_tag".to_string(),
|
||||||
|
value_real: None,
|
||||||
|
value_int: None,
|
||||||
|
value_text: Some(tag.to_string()),
|
||||||
|
unit: None,
|
||||||
|
source_kind: OXIMEDIA_ENRICHMENT_SOURCE_KIND.to_string(),
|
||||||
|
source_id: mood_tag_source_id(tag),
|
||||||
|
confidence: 1.0,
|
||||||
|
content_hash: Some(content_hash.to_string()),
|
||||||
|
expires_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_oximedia_primary_fact(f: &crate::dto::TrackFactDto) -> bool {
|
||||||
|
f.source_kind == OXIMEDIA_ENRICHMENT_SOURCE_KIND && f.source_id == OXIMEDIA_ENRICHMENT_SOURCE_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fact_current(
|
||||||
|
facts: &[crate::dto::TrackFactDto],
|
||||||
|
fact_kind: &str,
|
||||||
|
content_hash: &str,
|
||||||
|
) -> bool {
|
||||||
|
facts.iter().any(|f| {
|
||||||
|
is_oximedia_primary_fact(f)
|
||||||
|
&& f.fact_kind == fact_kind
|
||||||
|
&& f.content_hash.as_deref() == Some(content_hash)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mood_tags_current(facts: &[crate::dto::TrackFactDto], content_hash: &str) -> bool {
|
||||||
|
facts.iter().any(|f| {
|
||||||
|
f.fact_kind == "mood_tag"
|
||||||
|
&& f.source_kind == OXIMEDIA_ENRICHMENT_SOURCE_KIND
|
||||||
|
&& f.source_id.starts_with(&format!("{OXIMEDIA_ENRICHMENT_SOURCE_ID}:"))
|
||||||
|
&& f.content_hash.as_deref() == Some(content_hash)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn analysis_fact_text(
|
||||||
|
fact_kind: &str,
|
||||||
|
value_text: &str,
|
||||||
|
content_hash: &str,
|
||||||
|
confidence: f32,
|
||||||
|
) -> FactInputDto {
|
||||||
|
FactInputDto {
|
||||||
|
fact_kind: fact_kind.to_string(),
|
||||||
|
value_real: None,
|
||||||
|
value_int: None,
|
||||||
|
value_text: Some(value_text.to_string()),
|
||||||
|
unit: None,
|
||||||
|
source_kind: OXIMEDIA_ENRICHMENT_SOURCE_KIND.to_string(),
|
||||||
|
source_id: OXIMEDIA_ENRICHMENT_SOURCE_ID.to_string(),
|
||||||
|
confidence: confidence.clamp(0.0, 1.0) as f64,
|
||||||
|
content_hash: Some(content_hash.to_string()),
|
||||||
|
expires_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn analysis_fact(
|
||||||
|
fact_kind: &str,
|
||||||
|
value_real: Option<f64>,
|
||||||
|
value_int: Option<i64>,
|
||||||
|
content_hash: &str,
|
||||||
|
confidence: f32,
|
||||||
|
) -> FactInputDto {
|
||||||
|
FactInputDto {
|
||||||
|
fact_kind: fact_kind.to_string(),
|
||||||
|
value_real,
|
||||||
|
value_int,
|
||||||
|
value_text: None,
|
||||||
|
unit: None,
|
||||||
|
source_kind: OXIMEDIA_ENRICHMENT_SOURCE_KIND.to_string(),
|
||||||
|
source_id: OXIMEDIA_ENRICHMENT_SOURCE_ID.to_string(),
|
||||||
|
confidence: confidence.clamp(0.0, 1.0) as f64,
|
||||||
|
content_hash: Some(content_hash.to_string()),
|
||||||
|
expires_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::dto::FactInputDto;
|
||||||
|
use psysonic_core::track_enrichment::{
|
||||||
|
TrackEnrichmentFacts, TrackEnrichmentIntFact, TrackEnrichmentRealFact,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn seed_track(store: &LibraryStore, server: &str, id: &str) {
|
||||||
|
store
|
||||||
|
.with_conn("misc", |c| {
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO track (server_id, id, title, synced_at, raw_json) \
|
||||||
|
VALUES (?1, ?2, 'T', 1, '{}')",
|
||||||
|
rusqlite::params![server, id],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_analysis_fact(
|
||||||
|
store: &LibraryStore,
|
||||||
|
kind: &str,
|
||||||
|
hash: &str,
|
||||||
|
value_int: Option<i64>,
|
||||||
|
value_real: Option<f64>,
|
||||||
|
value_text: Option<&str>,
|
||||||
|
) {
|
||||||
|
let repo = FactRepository::new(store);
|
||||||
|
repo.put(
|
||||||
|
"s1",
|
||||||
|
"t1",
|
||||||
|
&FactInputDto {
|
||||||
|
fact_kind: kind.into(),
|
||||||
|
value_real,
|
||||||
|
value_int,
|
||||||
|
value_text: value_text.map(str::to_string),
|
||||||
|
unit: None,
|
||||||
|
source_kind: OXIMEDIA_ENRICHMENT_SOURCE_KIND.into(),
|
||||||
|
source_id: OXIMEDIA_ENRICHMENT_SOURCE_ID.into(),
|
||||||
|
confidence: 0.9,
|
||||||
|
content_hash: Some(hash.into()),
|
||||||
|
expires_at: None,
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_requests_bpm_only_while_mood_analysis_disabled() {
|
||||||
|
let store = LibraryStore::open_in_memory();
|
||||||
|
seed_track(&store, "s1", "t1");
|
||||||
|
let plan = plan_track_enrichment(&store, "s1", "t1", "abc", 2).unwrap();
|
||||||
|
assert!(plan.need_bpm);
|
||||||
|
assert!(!plan.need_valence && !plan.need_arousal && !plan.need_moods);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_skips_current_hash_only() {
|
||||||
|
let store = LibraryStore::open_in_memory();
|
||||||
|
seed_track(&store, "s1", "t1");
|
||||||
|
put_analysis_fact(&store, "bpm", "abc", Some(120), None, None);
|
||||||
|
let plan = plan_track_enrichment(&store, "s1", "t1", "abc", 2).unwrap();
|
||||||
|
assert!(!plan.need_bpm);
|
||||||
|
assert!(!plan.need_valence && !plan.need_arousal && !plan.need_moods);
|
||||||
|
let plan2 = plan_track_enrichment(&store, "s1", "t1", "def", 2).unwrap();
|
||||||
|
assert!(plan2.need_bpm);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_skips_mood_analysis_while_oximedia_mood_disabled() {
|
||||||
|
let store = LibraryStore::open_in_memory();
|
||||||
|
seed_track(&store, "s1", "t1");
|
||||||
|
let plan = plan_track_enrichment(&store, "s1", "t1", "abc", 2).unwrap();
|
||||||
|
assert!(plan.need_bpm);
|
||||||
|
assert!(!plan.need_valence && !plan.need_arousal && !plan.need_moods);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "re-enable with OXIMEDIA_MOOD_TAGS_ENABLED"]
|
||||||
|
fn plan_refreshes_stale_quadrant_mood_tags_when_valence_arousal_present() {
|
||||||
|
let store = LibraryStore::open_in_memory();
|
||||||
|
seed_track(&store, "s1", "t1");
|
||||||
|
put_analysis_fact(&store, "moods", "abc", None, None, Some(r#"{"happy":0.9,"excited":0.8}"#));
|
||||||
|
put_analysis_fact(&store, "valence", "abc", None, Some(0.55), None);
|
||||||
|
put_analysis_fact(&store, "arousal", "abc", None, Some(0.42), None);
|
||||||
|
let repo = FactRepository::new(&store);
|
||||||
|
for tag in ["happy", "excited"] {
|
||||||
|
repo.put(
|
||||||
|
"s1",
|
||||||
|
"t1",
|
||||||
|
&FactInputDto {
|
||||||
|
fact_kind: "mood_tag".into(),
|
||||||
|
value_text: Some(tag.into()),
|
||||||
|
value_real: None,
|
||||||
|
value_int: None,
|
||||||
|
unit: None,
|
||||||
|
source_kind: OXIMEDIA_ENRICHMENT_SOURCE_KIND.into(),
|
||||||
|
source_id: mood_tag_source_id(tag),
|
||||||
|
confidence: 1.0,
|
||||||
|
content_hash: Some("abc".into()),
|
||||||
|
expires_at: None,
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let _ = plan_track_enrichment(&store, "s1", "t1", "abc", 2).unwrap();
|
||||||
|
let tags: Vec<_> = repo
|
||||||
|
.get("s1", "t1", &["mood_tag".into()], 3)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.fact_kind == "mood_tag")
|
||||||
|
.map(|f| f.value_text.unwrap_or_default())
|
||||||
|
.collect();
|
||||||
|
assert_ne!(tags, vec!["happy", "excited"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "re-enable with OXIMEDIA_MOOD_TAGS_ENABLED"]
|
||||||
|
fn plan_backfills_mood_tags_from_valence_arousal_over_quadrant_moods_json() {
|
||||||
|
let store = LibraryStore::open_in_memory();
|
||||||
|
seed_track(&store, "s1", "t1");
|
||||||
|
put_analysis_fact(&store, "moods", "abc", None, None, Some(r#"{"happy":0.9,"excited":0.8}"#));
|
||||||
|
put_analysis_fact(&store, "valence", "abc", None, Some(0.55), None);
|
||||||
|
put_analysis_fact(&store, "arousal", "abc", None, Some(0.42), None);
|
||||||
|
let plan = plan_track_enrichment(&store, "s1", "t1", "abc", 2).unwrap();
|
||||||
|
assert!(!plan.need_moods);
|
||||||
|
let repo = FactRepository::new(&store);
|
||||||
|
let tags: Vec<_> = repo
|
||||||
|
.get("s1", "t1", &["mood_tag".into()], 3)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.fact_kind == "mood_tag")
|
||||||
|
.map(|f| f.value_text.unwrap_or_default())
|
||||||
|
.collect();
|
||||||
|
assert_ne!(tags, vec!["happy", "excited"]);
|
||||||
|
assert!(!tags.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "re-enable with OXIMEDIA_MOOD_TAGS_ENABLED"]
|
||||||
|
fn plan_backfills_mood_tags_from_moods_json_without_reanalysis() {
|
||||||
|
let store = LibraryStore::open_in_memory();
|
||||||
|
seed_track(&store, "s1", "t1");
|
||||||
|
put_analysis_fact(
|
||||||
|
&store,
|
||||||
|
"moods",
|
||||||
|
"abc",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(r#"{"calm":0.6,"peaceful":0.4}"#),
|
||||||
|
);
|
||||||
|
let plan = plan_track_enrichment(&store, "s1", "t1", "abc", 2).unwrap();
|
||||||
|
assert!(!plan.need_moods, "moods JSON is current — no re-analysis");
|
||||||
|
let repo = FactRepository::new(&store);
|
||||||
|
let tags: Vec<_> = repo
|
||||||
|
.get("s1", "t1", &["mood_tag".into()], 3)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.fact_kind == "mood_tag")
|
||||||
|
.map(|f| f.value_text.unwrap_or_default())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(tags, vec!["calm"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_skips_mood_facts_while_oximedia_mood_disabled() {
|
||||||
|
let store = LibraryStore::open_in_memory();
|
||||||
|
seed_track(&store, "s1", "t1");
|
||||||
|
let facts = TrackEnrichmentFacts {
|
||||||
|
bpm: Some(TrackEnrichmentIntFact {
|
||||||
|
value: 128,
|
||||||
|
confidence: 0.9,
|
||||||
|
}),
|
||||||
|
valence: Some(TrackEnrichmentRealFact {
|
||||||
|
value: 0.4,
|
||||||
|
confidence: 1.0,
|
||||||
|
}),
|
||||||
|
arousal: Some(TrackEnrichmentRealFact {
|
||||||
|
value: 0.75,
|
||||||
|
confidence: 1.0,
|
||||||
|
}),
|
||||||
|
moods: Some(r#"{"happy":0.7,"excited":0.5}"#.into()),
|
||||||
|
};
|
||||||
|
store_track_enrichment_facts(&store, "s1", "t1", "abc", &facts, 10).unwrap();
|
||||||
|
let repo = FactRepository::new(&store);
|
||||||
|
let rows = repo.get("s1", "t1", &[], 20).unwrap();
|
||||||
|
assert!(rows.iter().any(|r| r.fact_kind == "bpm"));
|
||||||
|
assert!(!rows.iter().any(|r| {
|
||||||
|
matches!(
|
||||||
|
r.fact_kind.as_str(),
|
||||||
|
"mood_tag" | "moods" | "valence" | "arousal" | "mood_labels"
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "re-enable with OXIMEDIA_MOOD_TAGS_ENABLED"]
|
||||||
|
fn store_writes_mood_tag_rows_from_valence_arousal() {
|
||||||
|
let store = LibraryStore::open_in_memory();
|
||||||
|
seed_track(&store, "s1", "t1");
|
||||||
|
let facts = TrackEnrichmentFacts {
|
||||||
|
bpm: None,
|
||||||
|
valence: Some(TrackEnrichmentRealFact {
|
||||||
|
value: 0.4,
|
||||||
|
confidence: 1.0,
|
||||||
|
}),
|
||||||
|
arousal: Some(TrackEnrichmentRealFact {
|
||||||
|
value: 0.75,
|
||||||
|
confidence: 1.0,
|
||||||
|
}),
|
||||||
|
moods: Some(r#"{"happy":0.7,"excited":0.5}"#.into()),
|
||||||
|
};
|
||||||
|
store_track_enrichment_facts(&store, "s1", "t1", "abc", &facts, 10).unwrap();
|
||||||
|
let repo = FactRepository::new(&store);
|
||||||
|
let mood_tags: Vec<_> = repo
|
||||||
|
.get("s1", "t1", &[], 20)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|r| r.fact_kind == "mood_tag")
|
||||||
|
.map(|r| r.value_text.as_deref().unwrap_or("").to_string())
|
||||||
|
.collect();
|
||||||
|
assert_ne!(mood_tags, vec!["happy", "excited"]);
|
||||||
|
assert!(!mood_tags.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "re-enable with OXIMEDIA_MOOD_TAGS_ENABLED"]
|
||||||
|
fn store_writes_mood_tag_rows_from_moods_json_when_va_missing() {
|
||||||
|
let store = LibraryStore::open_in_memory();
|
||||||
|
seed_track(&store, "s1", "t1");
|
||||||
|
let facts = TrackEnrichmentFacts {
|
||||||
|
bpm: None,
|
||||||
|
valence: None,
|
||||||
|
arousal: None,
|
||||||
|
moods: Some(r#"{"happy":0.7,"excited":0.5}"#.into()),
|
||||||
|
};
|
||||||
|
store_track_enrichment_facts(&store, "s1", "t1", "abc", &facts, 10).unwrap();
|
||||||
|
let repo = FactRepository::new(&store);
|
||||||
|
let rows = repo.get("s1", "t1", &[], 20).unwrap();
|
||||||
|
assert!(rows.iter().any(|r| r.fact_kind == "moods"));
|
||||||
|
let mood_tags: Vec<_> = rows
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.fact_kind == "mood_tag")
|
||||||
|
.map(|r| r.value_text.as_deref().unwrap_or(""))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(mood_tags, vec!["happy"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_writes_only_provided_facts() {
|
||||||
|
let store = LibraryStore::open_in_memory();
|
||||||
|
seed_track(&store, "s1", "t1");
|
||||||
|
let facts = TrackEnrichmentFacts {
|
||||||
|
bpm: Some(TrackEnrichmentIntFact {
|
||||||
|
value: 128,
|
||||||
|
confidence: 0.8,
|
||||||
|
}),
|
||||||
|
valence: Some(TrackEnrichmentRealFact {
|
||||||
|
value: 0.4,
|
||||||
|
confidence: 1.0,
|
||||||
|
}),
|
||||||
|
arousal: None,
|
||||||
|
moods: None,
|
||||||
|
};
|
||||||
|
store_track_enrichment_facts(&store, "s1", "t1", "abc", &facts, 10).unwrap();
|
||||||
|
let repo = FactRepository::new(&store);
|
||||||
|
let rows = repo.get("s1", "t1", &[], 20).unwrap();
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
assert!(rows.iter().any(|r| r.fact_kind == "bpm" && r.value_int == Some(128)));
|
||||||
|
assert!(!rows.iter().any(|r| r.fact_kind == "valence"));
|
||||||
|
assert!(!rows.iter().any(|r| r.fact_kind == "arousal"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,6 +132,18 @@ pub const FILTER_FIELD_REGISTRY: &[FilterField] = &[
|
|||||||
id: "bpm",
|
id: "bpm",
|
||||||
entities: &[EntityKind::Track],
|
entities: &[EntityKind::Track],
|
||||||
ops: &[FilterOp::Gte, FilterOp::Lte, FilterOp::Between],
|
ops: &[FilterOp::Gte, FilterOp::Lte, FilterOp::Between],
|
||||||
|
status: FilterStatus::V1,
|
||||||
|
},
|
||||||
|
FilterField {
|
||||||
|
id: "mood_group",
|
||||||
|
entities: &[EntityKind::Track],
|
||||||
|
ops: &[FilterOp::Eq, FilterOp::In],
|
||||||
|
status: FilterStatus::SchemaV1UiLater,
|
||||||
|
},
|
||||||
|
FilterField {
|
||||||
|
id: "mood_tag",
|
||||||
|
entities: &[EntityKind::Track],
|
||||||
|
ops: &[FilterOp::Eq, FilterOp::In],
|
||||||
status: FilterStatus::SchemaV1UiLater,
|
status: FilterStatus::SchemaV1UiLater,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -290,10 +302,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bpm_is_schema_v1_but_ui_later() {
|
fn bpm_is_v1_with_dual_storage_resolution() {
|
||||||
// §5.13.3: bpm has a hot column + index from day one, but is hidden
|
assert_eq!(lookup("bpm").unwrap().status, FilterStatus::V1);
|
||||||
// from the v1 UI until §5.13.4 dual-storage resolution lands.
|
}
|
||||||
assert_eq!(lookup("bpm").unwrap().status, FilterStatus::SchemaV1UiLater);
|
|
||||||
|
#[test]
|
||||||
|
fn mood_group_is_schema_v1_ui_later_while_oximedia_mood_disabled() {
|
||||||
|
assert_eq!(
|
||||||
|
lookup("mood_group").unwrap().status,
|
||||||
|
FilterStatus::SchemaV1UiLater
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -9,11 +9,14 @@
|
|||||||
|
|
||||||
pub(crate) mod bulk_ingest;
|
pub(crate) mod bulk_ingest;
|
||||||
pub mod advanced_search;
|
pub mod advanced_search;
|
||||||
|
mod advanced_search_mood;
|
||||||
pub mod canonical;
|
pub mod canonical;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod cross_server;
|
pub mod cross_server;
|
||||||
pub mod dto;
|
pub mod dto;
|
||||||
|
pub mod enrichment;
|
||||||
pub mod filter;
|
pub mod filter;
|
||||||
|
pub mod mood_groups;
|
||||||
pub mod live_search;
|
pub mod live_search;
|
||||||
pub mod payload;
|
pub mod payload;
|
||||||
pub mod repos;
|
pub mod repos;
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ fn map_live_hit_row(row: &rusqlite::Row<'_>, offset: usize) -> rusqlite::Result<
|
|||||||
user_rating: row.get(offset + 18)?,
|
user_rating: row.get(offset + 18)?,
|
||||||
play_count: row.get(offset + 19)?,
|
play_count: row.get(offset + 19)?,
|
||||||
bpm: row.get(offset + 20)?,
|
bpm: row.get(offset + 20)?,
|
||||||
|
bpm_source: None,
|
||||||
played_at: None,
|
played_at: None,
|
||||||
server_path: None,
|
server_path: None,
|
||||||
library_id: None,
|
library_id: None,
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
//! Virtual mood groups and atomic mood tags for Advanced Search.
|
||||||
|
//!
|
||||||
|
//! Tracks store **atomic tags** in `track_fact` (`fact_kind = mood_tag`).
|
||||||
|
//! Product groups (joy, dance, …) are a static catalog only — each group
|
||||||
|
//! lists tag ids; search expands a group to `mood_tag IN (…)` with OR
|
||||||
|
//! semantics. Groups **may overlap** on purpose (e.g. joy and dance both
|
||||||
|
//! include `happy`). New tags can be added to the catalog without schema
|
||||||
|
//! changes.
|
||||||
|
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
/// Oximedia `MoodDetector` label ids shipped today (mirrors TS catalog).
|
||||||
|
pub const OXIMEDIA_MOOD_TAG_IDS: &[&str] = &[
|
||||||
|
"happy",
|
||||||
|
"excited",
|
||||||
|
"calm",
|
||||||
|
"peaceful",
|
||||||
|
"angry",
|
||||||
|
"tense",
|
||||||
|
"sad",
|
||||||
|
"melancholic",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Product mood group ids (i18n: `search.moodGroups.*`).
|
||||||
|
pub const MOOD_GROUP_IDS: &[&str] = &["joy", "sadness", "dance", "work", "romance", "anger"];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct MoodGroup {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub tags: &'static [&'static str],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Virtual groups → atomic tags. Overlaps are intentional.
|
||||||
|
pub const MOOD_GROUPS: &[MoodGroup] = &[
|
||||||
|
MoodGroup {
|
||||||
|
id: "joy",
|
||||||
|
tags: &["happy", "excited"],
|
||||||
|
},
|
||||||
|
MoodGroup {
|
||||||
|
id: "sadness",
|
||||||
|
tags: &["sad", "melancholic"],
|
||||||
|
},
|
||||||
|
MoodGroup {
|
||||||
|
id: "dance",
|
||||||
|
tags: &["excited", "happy", "tense", "angry"],
|
||||||
|
},
|
||||||
|
MoodGroup {
|
||||||
|
id: "work",
|
||||||
|
tags: &["calm", "peaceful"],
|
||||||
|
},
|
||||||
|
MoodGroup {
|
||||||
|
id: "romance",
|
||||||
|
tags: &["peaceful", "calm", "melancholic"],
|
||||||
|
},
|
||||||
|
MoodGroup {
|
||||||
|
id: "anger",
|
||||||
|
tags: &["angry", "tense"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn is_oximedia_mood_tag(id: &str) -> bool {
|
||||||
|
OXIMEDIA_MOOD_TAG_IDS.contains(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_valid_mood_group(id: &str) -> bool {
|
||||||
|
MOOD_GROUP_IDS.contains(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lookup_mood_group(id: &str) -> Option<&'static MoodGroup> {
|
||||||
|
MOOD_GROUPS.iter().find(|g| g.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Known tag ids for filters / validation (oximedia + any catalog-only tags).
|
||||||
|
pub fn is_known_mood_tag(id: &str) -> bool {
|
||||||
|
if is_oximedia_mood_tag(id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
MOOD_GROUPS.iter().any(|g| g.tags.contains(&id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand virtual group ids to deduplicated atomic tag ids (stable order).
|
||||||
|
pub fn expand_mood_groups(group_ids: &[String]) -> Result<Vec<String>, String> {
|
||||||
|
if group_ids.is_empty() {
|
||||||
|
return Err("expected at least one mood group".to_string());
|
||||||
|
}
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
for gid in group_ids {
|
||||||
|
let group = lookup_mood_group(gid)
|
||||||
|
.ok_or_else(|| format!("unknown mood group `{gid}`"))?;
|
||||||
|
for tag in group.tags {
|
||||||
|
if !out.iter().any(|t| t == tag) {
|
||||||
|
out.push((*tag).to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate mood-group ids for `mood_group` filters (`eq` / `in`).
|
||||||
|
pub fn normalize_mood_groups(group_ids: &[String]) -> Result<Vec<String>, String> {
|
||||||
|
if group_ids.is_empty() {
|
||||||
|
return Err("expected at least one mood group".to_string());
|
||||||
|
}
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
for id in group_ids {
|
||||||
|
if !is_valid_mood_group(id) {
|
||||||
|
return Err(format!("unknown mood group `{id}`"));
|
||||||
|
}
|
||||||
|
if !out.iter().any(|g| g == id) {
|
||||||
|
out.push(id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valence/arousal anchor in normalized mood space (see `mood_scores_from_valence_arousal`).
|
||||||
|
struct MoodVaAnchor {
|
||||||
|
id: &'static str,
|
||||||
|
v: f64,
|
||||||
|
a: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOOD_VA_ANCHORS: &[MoodVaAnchor] = &[
|
||||||
|
MoodVaAnchor { id: "happy", v: 0.75, a: 0.72 },
|
||||||
|
MoodVaAnchor { id: "excited", v: 0.55, a: 0.88 },
|
||||||
|
MoodVaAnchor { id: "calm", v: 0.65, a: 0.22 },
|
||||||
|
MoodVaAnchor { id: "peaceful", v: 0.78, a: 0.12 },
|
||||||
|
MoodVaAnchor { id: "angry", v: -0.72, a: 0.82 },
|
||||||
|
MoodVaAnchor { id: "tense", v: -0.35, a: 0.68 },
|
||||||
|
MoodVaAnchor { id: "sad", v: -0.75, a: 0.28 },
|
||||||
|
MoodVaAnchor { id: "melancholic", v: -0.55, a: 0.18 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOOD_VA_MAX_DIST: f64 = 1.35;
|
||||||
|
const MOOD_VA_VALENCE_BIAS: f64 = 0.12;
|
||||||
|
const MOOD_VA_VALENCE_SCALE: f64 = 1.4;
|
||||||
|
const MOOD_VA_AROUSAL_OFFSET: f64 = 0.48;
|
||||||
|
const MOOD_VA_AROUSAL_SCALE: f64 = 0.40;
|
||||||
|
const MOOD_DISPLAY_MIN_RELATIVE: f64 = 0.55;
|
||||||
|
const MOOD_DISPLAY_MIN_ABSOLUTE: f64 = 0.28;
|
||||||
|
|
||||||
|
/// Pairs shown as one mood in UI/search tags — never both `happy` and `excited`.
|
||||||
|
const MOOD_DISPLAY_CLUSTERS: &[&[&str]] = &[
|
||||||
|
&["happy", "excited"],
|
||||||
|
&["calm", "peaceful"],
|
||||||
|
&["angry", "tense"],
|
||||||
|
&["sad", "melancholic"],
|
||||||
|
];
|
||||||
|
|
||||||
|
fn mood_display_cluster(tag: &str) -> Option<usize> {
|
||||||
|
MOOD_DISPLAY_CLUSTERS
|
||||||
|
.iter()
|
||||||
|
.position(|cluster| cluster.contains(&tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft scores for all oximedia mood tags from raw valence/arousal.
|
||||||
|
///
|
||||||
|
/// Oximedia's built-in `map_to_moods` uses hard quadrant cutoffs and returns
|
||||||
|
/// only two labels (usually `happy` + `excited` for typical pop/rock). We
|
||||||
|
/// recalibrate V/A and score every catalog tag by distance to anchor points.
|
||||||
|
pub fn mood_scores_from_valence_arousal(valence: f64, arousal: f64) -> Vec<(String, f64)> {
|
||||||
|
let v = ((valence - MOOD_VA_VALENCE_BIAS) * MOOD_VA_VALENCE_SCALE).clamp(-1.0, 1.0);
|
||||||
|
let a = ((arousal - MOOD_VA_AROUSAL_OFFSET) / MOOD_VA_AROUSAL_SCALE).clamp(0.0, 1.0);
|
||||||
|
MOOD_VA_ANCHORS
|
||||||
|
.iter()
|
||||||
|
.map(|anchor| {
|
||||||
|
let dv = v - anchor.v;
|
||||||
|
let da = a - anchor.a;
|
||||||
|
let dist = (dv * dv + da * da).sqrt();
|
||||||
|
let score = (1.0 - dist / MOOD_VA_MAX_DIST).max(0.0);
|
||||||
|
(anchor.id.to_string(), score)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn top_distinct_oximedia_mood_tag_ids_from_scores(
|
||||||
|
scores: &[(String, f64)],
|
||||||
|
limit: usize,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut scored = scores.to_vec();
|
||||||
|
scored.sort_by(|a, b| {
|
||||||
|
b.1.partial_cmp(&a.1)
|
||||||
|
.unwrap_or(Ordering::Equal)
|
||||||
|
.then_with(|| a.0.cmp(&b.0))
|
||||||
|
});
|
||||||
|
scored.retain(|(k, _)| is_oximedia_mood_tag(k));
|
||||||
|
let top_score = scored.first().map(|(_, s)| *s).unwrap_or(0.0);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut used_clusters = HashSet::new();
|
||||||
|
for (tag, score) in scored {
|
||||||
|
if score < MOOD_DISPLAY_MIN_ABSOLUTE || score < top_score * MOOD_DISPLAY_MIN_RELATIVE {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(cluster) = mood_display_cluster(&tag) {
|
||||||
|
if !used_clusters.insert(cluster) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(tag);
|
||||||
|
if out.len() >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn top_distinct_oximedia_mood_tag_ids_from_moods_json(json: &str, limit: usize) -> Vec<String> {
|
||||||
|
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Some(obj) = parsed.as_object() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let scores: Vec<(String, f64)> = obj
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(k, v)| v.as_f64().map(|score| (k.clone(), score)))
|
||||||
|
.collect();
|
||||||
|
top_distinct_oximedia_mood_tag_ids_from_scores(&scores, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn top_mood_tag_ids_from_valence_arousal(
|
||||||
|
valence: f64,
|
||||||
|
arousal: f64,
|
||||||
|
limit: usize,
|
||||||
|
) -> Vec<String> {
|
||||||
|
top_distinct_oximedia_mood_tag_ids_from_scores(
|
||||||
|
&mood_scores_from_valence_arousal(valence, arousal),
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top oximedia mood tag ids by score (filter unknown labels first, then sort
|
||||||
|
/// by score desc, id asc). Mirrors TS `topOximediaMoodTagIds`.
|
||||||
|
pub fn top_oximedia_mood_tag_ids_from_moods_json(json: &str, limit: usize) -> Vec<String> {
|
||||||
|
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Some(obj) = parsed.as_object() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let scores: Vec<(String, f64)> = obj
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(k, v)| v.as_f64().map(|score| (k.clone(), score)))
|
||||||
|
.collect();
|
||||||
|
top_oximedia_mood_tag_ids_from_scores(&scores, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn top_oximedia_mood_tag_ids_from_scores(
|
||||||
|
scores: &[(String, f64)],
|
||||||
|
limit: usize,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut scored = scores.to_vec();
|
||||||
|
scored.sort_by(|a, b| {
|
||||||
|
b.1.partial_cmp(&a.1)
|
||||||
|
.unwrap_or(Ordering::Equal)
|
||||||
|
.then_with(|| a.0.cmp(&b.0))
|
||||||
|
});
|
||||||
|
scored
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(k, _)| is_oximedia_mood_tag(k))
|
||||||
|
.take(limit)
|
||||||
|
.map(|(k, _)| k)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate atomic mood-tag ids for direct `mood_tag` filters.
|
||||||
|
pub fn normalize_mood_tags(tag_ids: &[String]) -> Result<Vec<String>, String> {
|
||||||
|
if tag_ids.is_empty() {
|
||||||
|
return Err("expected at least one mood tag".to_string());
|
||||||
|
}
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
for id in tag_ids {
|
||||||
|
if !is_known_mood_tag(id) {
|
||||||
|
return Err(format!("unknown mood tag `{id}`"));
|
||||||
|
}
|
||||||
|
if !out.iter().any(|t| t == id) {
|
||||||
|
out.push(id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn joy_expands_to_happy_and_excited() {
|
||||||
|
assert_eq!(
|
||||||
|
expand_mood_groups(&["joy".into()]).unwrap(),
|
||||||
|
vec!["happy", "excited"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn groups_overlap_by_design() {
|
||||||
|
let joy = expand_mood_groups(&["joy".into()]).unwrap();
|
||||||
|
let dance = expand_mood_groups(&["dance".into()]).unwrap();
|
||||||
|
assert!(joy.iter().any(|t| dance.contains(t)));
|
||||||
|
let work = expand_mood_groups(&["work".into()]).unwrap();
|
||||||
|
let romance = expand_mood_groups(&["romance".into()]).unwrap();
|
||||||
|
assert!(work.iter().any(|t| romance.contains(t)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_oximedia_tags_appear_in_at_least_one_group() {
|
||||||
|
for tag in OXIMEDIA_MOOD_TAG_IDS {
|
||||||
|
assert!(
|
||||||
|
MOOD_GROUPS.iter().any(|g| g.tags.contains(tag)),
|
||||||
|
"oximedia tag `{tag}` must appear in a virtual group"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anger_expands_to_q3_tags() {
|
||||||
|
assert_eq!(
|
||||||
|
expand_mood_groups(&["anger".into()]).unwrap(),
|
||||||
|
vec!["angry", "tense"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_group_errors() {
|
||||||
|
assert!(expand_mood_groups(&["nope".into()]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn top_mood_tags_ignore_unknown_labels_before_limit() {
|
||||||
|
let json = r#"{"noise":0.99,"calm":0.2,"happy":0.9,"excited":0.5}"#;
|
||||||
|
assert_eq!(
|
||||||
|
top_oximedia_mood_tag_ids_from_moods_json(json, 3),
|
||||||
|
vec!["happy", "excited", "calm"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valence_arousal_never_returns_both_happy_and_excited() {
|
||||||
|
let tags = top_mood_tag_ids_from_valence_arousal(0.4, 0.75, 2);
|
||||||
|
assert!(
|
||||||
|
!(tags.contains(&"happy".to_string()) && tags.contains(&"excited".to_string())),
|
||||||
|
"got {tags:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(tags.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valence_arousal_soft_scores_differ_from_quadrant_happy_excited() {
|
||||||
|
let tags = top_mood_tag_ids_from_valence_arousal(0.4, 0.75, 2);
|
||||||
|
assert_ne!(tags, vec!["happy", "excited"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn low_arousal_prefers_calm_or_peaceful() {
|
||||||
|
let tags = top_mood_tag_ids_from_valence_arousal(0.55, 0.42, 2);
|
||||||
|
assert!(
|
||||||
|
tags.iter().any(|t| t == "calm" || t == "peaceful"),
|
||||||
|
"got {tags:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_valence_high_arousal_prefers_anger_quadrant() {
|
||||||
|
let tags = top_mood_tag_ids_from_valence_arousal(-0.45, 0.82, 2);
|
||||||
|
assert!(
|
||||||
|
tags.iter().any(|t| t == "angry" || t == "tense"),
|
||||||
|
"got {tags:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,6 +169,80 @@ pub(crate) fn aliased_track_columns(alias: &str) -> String {
|
|||||||
.join(", ")
|
.join(", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same projection as [`aliased_track_columns`], but `bpm` uses analysis fact +
|
||||||
|
/// tag dual-storage resolution (§5.13.4) and appends `bpm_source` for UI tooltips.
|
||||||
|
pub(crate) fn aliased_track_columns_resolved_bpm(alias: &str) -> String {
|
||||||
|
let base = aliased_track_columns_with_resolved_bpm_expr(alias);
|
||||||
|
format!("{base}, ({}) AS bpm_source", bpm_source_expr(alias))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aliased_track_columns_with_resolved_bpm_expr(alias: &str) -> String {
|
||||||
|
let bpm_expr = bpm_resolved_expr(alias);
|
||||||
|
crate::repos::track_columns()
|
||||||
|
.split(',')
|
||||||
|
.map(|c| {
|
||||||
|
let col = c.trim();
|
||||||
|
if col == "bpm" {
|
||||||
|
format!("({bpm_expr}) AS bpm")
|
||||||
|
} else {
|
||||||
|
format!("{alias}.{col}")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Oximedia / analysis `track_fact(bpm)` — preferred over hot `track.bpm` tag.
|
||||||
|
fn bpm_analysis_fact_subquery(table_alias: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"(SELECT f.value_int FROM track_fact f \
|
||||||
|
WHERE f.server_id = {table_alias}.server_id AND f.track_id = {table_alias}.id \
|
||||||
|
AND f.fact_kind = 'bpm' AND f.source_kind = 'analysis' \
|
||||||
|
AND f.value_int IS NOT NULL AND f.value_int > 0 \
|
||||||
|
ORDER BY f.confidence DESC LIMIT 1)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn bpm_resolved_expr(table_alias: &str) -> String {
|
||||||
|
let analysis = bpm_analysis_fact_subquery(table_alias);
|
||||||
|
let tag = format!(
|
||||||
|
"CASE WHEN {table_alias}.bpm IS NOT NULL AND {table_alias}.bpm > 0 \
|
||||||
|
THEN {table_alias}.bpm END"
|
||||||
|
);
|
||||||
|
let other_fact = format!(
|
||||||
|
"(SELECT f.value_int FROM track_fact f \
|
||||||
|
WHERE f.server_id = {table_alias}.server_id AND f.track_id = {table_alias}.id \
|
||||||
|
AND f.fact_kind = 'bpm' AND f.source_kind NOT IN ('analysis') \
|
||||||
|
AND f.value_int IS NOT NULL AND f.value_int > 0 \
|
||||||
|
ORDER BY CASE f.source_kind WHEN 'user' THEN 0 WHEN 'server_tag' THEN 1 ELSE 2 END LIMIT 1)"
|
||||||
|
);
|
||||||
|
format!("COALESCE({analysis}, {tag}, {other_fact})")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `'analysis'` when measured fact wins; `'tag'` when hot `track.bpm` is shown.
|
||||||
|
pub(crate) fn bpm_source_expr(table_alias: &str) -> String {
|
||||||
|
let analysis = bpm_analysis_fact_subquery(table_alias);
|
||||||
|
format!(
|
||||||
|
"CASE \
|
||||||
|
WHEN {analysis} IS NOT NULL THEN 'analysis' \
|
||||||
|
WHEN {table_alias}.bpm IS NOT NULL AND {table_alias}.bpm > 0 THEN 'tag' \
|
||||||
|
ELSE NULL END"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn track_projection_column_count() -> usize {
|
||||||
|
crate::repos::track_columns().split(',').count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a BPM-resolved Advanced Search row (extra trailing `bpm_source` column).
|
||||||
|
pub(crate) fn row_to_track_dto_resolved_bpm(
|
||||||
|
row: &rusqlite::Row<'_>,
|
||||||
|
) -> rusqlite::Result<crate::dto::LibraryTrackDto> {
|
||||||
|
let mut dto = crate::dto::LibraryTrackDto::from_row(&crate::repos::row_to_track_row(row)?);
|
||||||
|
dto.bpm_source = row.get(track_projection_column_count()).ok();
|
||||||
|
Ok(dto)
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a `%…%` LIKE pattern with the LIKE wildcards (`%`, `_`) and the
|
/// Build a `%…%` LIKE pattern with the LIKE wildcards (`%`, `_`) and the
|
||||||
/// `\` escape char escaped, for use with `LIKE ? ESCAPE '\'`. Shared by the
|
/// `\` escape char escaped, for use with `LIKE ? ESCAPE '\'`. Shared by the
|
||||||
/// Advanced Search album/artist name match and the cross-server fuzzy
|
/// Advanced Search album/artist name match and the cross-server fuzzy
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use tauri::Manager;
|
|||||||
|
|
||||||
/// Current head of the embedded migrations. Bump each time a new
|
/// Current head of the embedded migrations. Bump each time a new
|
||||||
/// `migrations/NNN_*.sql` is added.
|
/// `migrations/NNN_*.sql` is added.
|
||||||
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 7;
|
pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 9;
|
||||||
|
|
||||||
/// Lowest applied schema version the current code can advance from purely
|
/// Lowest applied schema version the current code can advance from purely
|
||||||
/// additively. If a DB carries a version below this, the breaking-bump hook
|
/// additively. If a DB carries a version below this, the breaking-bump hook
|
||||||
@@ -31,6 +31,9 @@ const MIGRATION_005_TRACK_GENRE_YEAR_INDEXES: &str =
|
|||||||
include_str!("../migrations/005_track_genre_year_indexes.sql");
|
include_str!("../migrations/005_track_genre_year_indexes.sql");
|
||||||
const MIGRATION_006_PLAY_SESSION: &str = include_str!("../migrations/006_play_session.sql");
|
const MIGRATION_006_PLAY_SESSION: &str = include_str!("../migrations/006_play_session.sql");
|
||||||
const MIGRATION_007_RESYNC_GEN: &str = include_str!("../migrations/007_resync_gen.sql");
|
const MIGRATION_007_RESYNC_GEN: &str = include_str!("../migrations/007_resync_gen.sql");
|
||||||
|
const MIGRATION_008_MOOD_TAG_INDEX: &str = include_str!("../migrations/008_mood_tag_index.sql");
|
||||||
|
const MIGRATION_009_PURGE_MOOD_FACTS: &str =
|
||||||
|
include_str!("../migrations/009_purge_mood_facts.sql");
|
||||||
|
|
||||||
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
|
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
|
||||||
/// defensively before applying so the source order can stay readable.
|
/// defensively before applying so the source order can stay readable.
|
||||||
@@ -42,6 +45,8 @@ const MIGRATIONS: &[(i64, &str)] = &[
|
|||||||
(5, MIGRATION_005_TRACK_GENRE_YEAR_INDEXES),
|
(5, MIGRATION_005_TRACK_GENRE_YEAR_INDEXES),
|
||||||
(6, MIGRATION_006_PLAY_SESSION),
|
(6, MIGRATION_006_PLAY_SESSION),
|
||||||
(7, MIGRATION_007_RESYNC_GEN),
|
(7, MIGRATION_007_RESYNC_GEN),
|
||||||
|
(8, MIGRATION_008_MOOD_TAG_INDEX),
|
||||||
|
(9, MIGRATION_009_PURGE_MOOD_FACTS),
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|||||||
+4
-2
@@ -1,4 +1,4 @@
|
|||||||
use psysonic_analysis::analysis_runtime::enqueue_analysis_seed;
|
use psysonic_analysis::analysis_runtime::enqueue_track_analysis;
|
||||||
use psysonic_audio as audio;
|
use psysonic_audio as audio;
|
||||||
use psysonic_core::user_agent::subsonic_wire_user_agent;
|
use psysonic_core::user_agent::subsonic_wire_user_agent;
|
||||||
|
|
||||||
@@ -154,7 +154,9 @@ pub async fn promote_stream_cache_to_hot_cache(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let _ = enqueue_analysis_seed(&app, &server_id, &track_id, &bytes).await;
|
let high =
|
||||||
|
psysonic_analysis::analysis_runtime::analysis_backfill_is_current_track(&app, &track_id);
|
||||||
|
let _ = enqueue_track_analysis(&app, &server_id, &track_id, &bytes, high).await;
|
||||||
|
|
||||||
let size = tokio::fs::metadata(&file_path)
|
let size = tokio::fs::metadata(&file_path)
|
||||||
.await
|
.await
|
||||||
|
|||||||
+33
-126
@@ -3,8 +3,9 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
use psysonic_analysis::analysis_cache;
|
use psysonic_analysis::analysis_runtime::{
|
||||||
use psysonic_analysis::analysis_runtime::enqueue_analysis_seed;
|
analysis_backfill_is_current_track, enqueue_track_analysis_from_file,
|
||||||
|
};
|
||||||
use crate::{offline_cancel_flags, DownloadSemaphore};
|
use crate::{offline_cancel_flags, DownloadSemaphore};
|
||||||
|
|
||||||
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
|
use crate::file_transfer::{finalize_streamed_download, subsonic_http_client};
|
||||||
@@ -17,36 +18,8 @@ pub async fn enqueue_analysis_seed_from_file(
|
|||||||
track_id: &str,
|
track_id: &str,
|
||||||
file_path: &std::path::Path,
|
file_path: &std::path::Path,
|
||||||
) {
|
) {
|
||||||
let cache = app.try_state::<analysis_cache::AnalysisCache>();
|
let high = analysis_backfill_is_current_track(app, track_id);
|
||||||
let cache_ref: Option<&analysis_cache::AnalysisCache> = cache.as_ref().map(|s| s.inner());
|
let _ = enqueue_track_analysis_from_file(app, server_id, track_id, file_path, high).await;
|
||||||
let Some(bytes) = read_seed_bytes_if_needed(cache_ref, server_id, track_id, file_path).await else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let _ = enqueue_analysis_seed(app, server_id, track_id, &bytes).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// AppHandle-free decision: returns the file bytes when seeding is required
|
|
||||||
/// (cache miss or no cache attached), or `None` when the cache says seeding is
|
|
||||||
/// redundant, the file can't be read, or the file is empty.
|
|
||||||
///
|
|
||||||
/// Pulled out of [`enqueue_analysis_seed_from_file`] so tests can drive every
|
|
||||||
/// branch with `AnalysisCache::open_in_memory()` plus a `tempfile::TempDir`.
|
|
||||||
pub(crate) async fn read_seed_bytes_if_needed(
|
|
||||||
cache: Option<&analysis_cache::AnalysisCache>,
|
|
||||||
server_id: &str,
|
|
||||||
track_id: &str,
|
|
||||||
file_path: &std::path::Path,
|
|
||||||
) -> Option<Vec<u8>> {
|
|
||||||
if let Some(cache) = cache {
|
|
||||||
if cache.cpu_seed_redundant_for_track(server_id, track_id).unwrap_or(false) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let bytes = tokio::fs::read(file_path).await.ok()?;
|
|
||||||
if bytes.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(bytes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AppHandle-free download primitive: ensures `cache_dir` exists, returns
|
/// AppHandle-free download primitive: ensures `cache_dir` exists, returns
|
||||||
@@ -376,100 +349,6 @@ mod tests {
|
|||||||
assert!(sibling.exists());
|
assert!(sibling.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── read_seed_bytes_if_needed (AppHandle-free) ──────────────────────────
|
|
||||||
|
|
||||||
use psysonic_analysis::analysis_cache::{
|
|
||||||
AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn populate_redundant_seed_rows(cache: &AnalysisCache, track_id: &str) {
|
|
||||||
// cpu_seed_redundant_for_track returns true when both waveform AND
|
|
||||||
// loudness rows exist for the current algo version.
|
|
||||||
let key = TrackKey {
|
|
||||||
server_id: String::new(),
|
|
||||||
track_id: track_id.to_string(),
|
|
||||||
md5_16kb: "deadbeef".to_string(),
|
|
||||||
};
|
|
||||||
cache.touch_track_status(&key, "ready").unwrap();
|
|
||||||
cache
|
|
||||||
.upsert_waveform(
|
|
||||||
&key,
|
|
||||||
&WaveformEntry {
|
|
||||||
bins: vec![0u8; 1000], // 2 * 500
|
|
||||||
bin_count: 500,
|
|
||||||
is_partial: false,
|
|
||||||
known_until_sec: 0.0,
|
|
||||||
duration_sec: 0.0,
|
|
||||||
updated_at: 1_700_000_000,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
cache
|
|
||||||
.upsert_loudness(
|
|
||||||
&key,
|
|
||||||
&LoudnessEntry {
|
|
||||||
integrated_lufs: -14.0,
|
|
||||||
true_peak: 1.0,
|
|
||||||
recommended_gain_db: 0.0,
|
|
||||||
target_lufs: -14.0,
|
|
||||||
updated_at: 1_700_000_000,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
|
||||||
async fn read_seed_bytes_returns_bytes_when_no_cache_attached() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let file = dir.path().join("track.mp3");
|
|
||||||
std::fs::write(&file, b"audio data").unwrap();
|
|
||||||
let bytes = read_seed_bytes_if_needed(None, "", "anything", &file).await;
|
|
||||||
assert_eq!(bytes.as_deref(), Some(b"audio data".as_slice()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
|
||||||
async fn read_seed_bytes_returns_bytes_when_cache_has_no_rows_for_track() {
|
|
||||||
let cache = AnalysisCache::open_in_memory();
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let file = dir.path().join("track.flac");
|
|
||||||
std::fs::write(&file, b"some bytes").unwrap();
|
|
||||||
let bytes = read_seed_bytes_if_needed(Some(&cache), "", "fresh-track", &file).await;
|
|
||||||
assert_eq!(bytes.as_deref(), Some(b"some bytes".as_slice()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
|
||||||
async fn read_seed_bytes_returns_none_when_cache_says_redundant() {
|
|
||||||
let cache = AnalysisCache::open_in_memory();
|
|
||||||
populate_redundant_seed_rows(&cache, "redundant-track");
|
|
||||||
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let file = dir.path().join("track.mp3");
|
|
||||||
std::fs::write(&file, b"would-be-decoded bytes").unwrap();
|
|
||||||
|
|
||||||
let bytes = read_seed_bytes_if_needed(Some(&cache), "", "redundant-track", &file).await;
|
|
||||||
assert!(
|
|
||||||
bytes.is_none(),
|
|
||||||
"redundant-cache short-circuit must skip the file read entirely"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
|
||||||
async fn read_seed_bytes_returns_none_for_missing_file() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let phantom = dir.path().join("never-written.mp3");
|
|
||||||
let bytes = read_seed_bytes_if_needed(None, "", "any", &phantom).await;
|
|
||||||
assert!(bytes.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
|
||||||
async fn read_seed_bytes_returns_none_for_empty_file() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let file = dir.path().join("empty.flac");
|
|
||||||
std::fs::write(&file, b"").unwrap();
|
|
||||||
let bytes = read_seed_bytes_if_needed(None, "", "any", &file).await;
|
|
||||||
assert!(bytes.is_none(), "empty file must not trigger seeding");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── resolve_offline_cache_dir ────────────────────────────────────────────
|
// ── resolve_offline_cache_dir ────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -511,6 +390,34 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert_eq!(err, "VOLUME_NOT_FOUND");
|
assert_eq!(err, "VOLUME_NOT_FOUND");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── offline download cancellation registry ───────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancel_offline_downloads_marks_ids_for_cancellation() {
|
||||||
|
use crate::offline_cancel_flags;
|
||||||
|
|
||||||
|
let id = "test-cancel-offline-dl";
|
||||||
|
clear_offline_cancel(id.to_string());
|
||||||
|
cancel_offline_downloads(vec![id.to_string()]);
|
||||||
|
{
|
||||||
|
let flags = offline_cancel_flags().lock().unwrap();
|
||||||
|
let flag = flags.get(id).expect("cancel flag registered");
|
||||||
|
assert!(flag.load(Ordering::Relaxed));
|
||||||
|
}
|
||||||
|
clear_offline_cancel(id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_offline_cancel_removes_flag_entry() {
|
||||||
|
use crate::offline_cancel_flags;
|
||||||
|
|
||||||
|
let id = "test-clear-offline-dl";
|
||||||
|
cancel_offline_downloads(vec![id.to_string()]);
|
||||||
|
clear_offline_cancel(id.to_string());
|
||||||
|
let flags = offline_cancel_flags().lock().unwrap();
|
||||||
|
assert!(!flags.contains_key(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the total size in bytes of all files in the offline cache directory (and optional custom dir).
|
/// Returns the total size in bytes of all files in the offline cache directory (and optional custom dir).
|
||||||
|
|||||||
@@ -274,6 +274,70 @@ pub fn run() {
|
|||||||
app.manage(query);
|
app.manage(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Track enrichment port (analysis → library facts) ───────────
|
||||||
|
{
|
||||||
|
use psysonic_core::track_enrichment::{TrackEnrichmentPlan, TrackEnrichmentPort};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn enrichment_now_unix_ms() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_for_enrichment_plan = app.handle().clone();
|
||||||
|
let app_for_enrichment_store = app.handle().clone();
|
||||||
|
let port = TrackEnrichmentPort::new(
|
||||||
|
move |server_id: &str, track_id: &str, content_hash: &str| {
|
||||||
|
let Some(runtime) =
|
||||||
|
app_for_enrichment_plan.try_state::<psysonic_library::LibraryRuntime>()
|
||||||
|
else {
|
||||||
|
return TrackEnrichmentPlan::default();
|
||||||
|
};
|
||||||
|
match psysonic_library::enrichment::plan_track_enrichment(
|
||||||
|
&runtime.store,
|
||||||
|
server_id,
|
||||||
|
track_id,
|
||||||
|
content_hash,
|
||||||
|
enrichment_now_unix_ms(),
|
||||||
|
) {
|
||||||
|
Ok(plan) => plan,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"[enrichment] plan failed server_id={server_id} track_id={track_id}: {e}"
|
||||||
|
);
|
||||||
|
TrackEnrichmentPlan {
|
||||||
|
need_bpm: true,
|
||||||
|
need_valence: true,
|
||||||
|
need_arousal: true,
|
||||||
|
need_moods: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move |server_id: &str,
|
||||||
|
track_id: &str,
|
||||||
|
content_hash: &str,
|
||||||
|
facts: &psysonic_core::track_enrichment::TrackEnrichmentFacts| {
|
||||||
|
let Some(runtime) =
|
||||||
|
app_for_enrichment_store.try_state::<psysonic_library::LibraryRuntime>()
|
||||||
|
else {
|
||||||
|
return Err("library runtime unavailable".into());
|
||||||
|
};
|
||||||
|
psysonic_library::enrichment::store_track_enrichment_facts(
|
||||||
|
&runtime.store,
|
||||||
|
server_id,
|
||||||
|
track_id,
|
||||||
|
content_hash,
|
||||||
|
facts,
|
||||||
|
enrichment_now_unix_ms(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
app.manage(port);
|
||||||
|
}
|
||||||
|
|
||||||
// Periodic analysis queue sizes (debug logging mode only).
|
// Periodic analysis queue sizes (debug logging mode only).
|
||||||
tauri::async_runtime::spawn(psysonic_analysis::analysis_runtime::analysis_queue_snapshot_loop());
|
tauri::async_runtime::spawn(psysonic_analysis::analysis_runtime::analysis_queue_snapshot_loop());
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export interface LibraryTrackDto {
|
|||||||
isrc?: string | null;
|
isrc?: string | null;
|
||||||
mbidRecording?: string | null;
|
mbidRecording?: string | null;
|
||||||
bpm?: number | null;
|
bpm?: number | null;
|
||||||
|
/** `'analysis'` | `'tag'` — Advanced Search BPM dual-storage projection only. */
|
||||||
|
bpmSource?: string | null;
|
||||||
replayGainTrackDb?: number | null;
|
replayGainTrackDb?: number | null;
|
||||||
replayGainAlbumDb?: number | null;
|
replayGainAlbumDb?: number | null;
|
||||||
serverUpdatedAt?: number | null;
|
serverUpdatedAt?: number | null;
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export interface SubsonicSong {
|
|||||||
played?: string;
|
played?: string;
|
||||||
/** Beats per minute, surfaced by Navidrome when the tag is set on the file. */
|
/** Beats per minute, surfaced by Navidrome when the tag is set on the file. */
|
||||||
bpm?: number;
|
bpm?: number;
|
||||||
|
/** Local index Advanced Search: `'tag'` (server/file tag) or `'analysis'` (measured). */
|
||||||
|
localBpmSource?: 'tag' | 'analysis';
|
||||||
replayGain?: {
|
replayGain?: {
|
||||||
trackGain?: number;
|
trackGain?: number;
|
||||||
albumGain?: number;
|
albumGain?: number;
|
||||||
|
|||||||
@@ -30,10 +30,13 @@ export default function CustomSelect({ value, options, onChange, className = '',
|
|||||||
if (!triggerRef.current) return;
|
if (!triggerRef.current) return;
|
||||||
const rect = triggerRef.current.getBoundingClientRect();
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
const MARGIN = 6;
|
const MARGIN = 6;
|
||||||
const maxH = 240;
|
const maxH = 320;
|
||||||
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||||
const spaceAbove = rect.top - MARGIN;
|
const spaceAbove = rect.top - MARGIN;
|
||||||
const useAbove = spaceBelow < 80 && spaceAbove > spaceBelow;
|
const useAbove = spaceBelow < 80 && spaceAbove > spaceBelow;
|
||||||
|
const viewportCap = Math.min(maxH, useAbove ? spaceAbove : spaceBelow);
|
||||||
|
const contentH = listRef.current?.scrollHeight ?? 0;
|
||||||
|
const needsScroll = contentH > viewportCap;
|
||||||
setDropStyle({
|
setDropStyle({
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: rect.left,
|
left: rect.left,
|
||||||
@@ -41,7 +44,8 @@ export default function CustomSelect({ value, options, onChange, className = '',
|
|||||||
...(useAbove
|
...(useAbove
|
||||||
? { bottom: window.innerHeight - rect.top + MARGIN }
|
? { bottom: window.innerHeight - rect.top + MARGIN }
|
||||||
: { top: rect.bottom + MARGIN }),
|
: { top: rect.bottom + MARGIN }),
|
||||||
maxHeight: Math.min(maxH, useAbove ? spaceAbove : spaceBelow),
|
maxHeight: needsScroll ? viewportCap : contentH || viewportCap,
|
||||||
|
overflowY: needsScroll ? 'auto' : 'hidden',
|
||||||
zIndex: 99998,
|
zIndex: 99998,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -49,7 +53,10 @@ export default function CustomSelect({ value, options, onChange, className = '',
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
updateDropStyle();
|
updateDropStyle();
|
||||||
}, [open]);
|
// Re-measure after layout so short lists (e.g. mood groups) don't get a spurious scrollbar.
|
||||||
|
const id = requestAnimationFrame(updateDropStyle);
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, [open, options]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface Props {
|
|||||||
loadingMore: boolean;
|
loadingMore: boolean;
|
||||||
/** Fetch the next page. Called as the sentinel nears the viewport. */
|
/** Fetch the next page. Called as the sentinel nears the viewport. */
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
|
/** Show a BPM column (Advanced Search when the BPM filter is active). */
|
||||||
|
showBpm?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,7 +21,7 @@ interface Props {
|
|||||||
* one chrome + paging path (no transform-positioned rows, so the sticky header
|
* one chrome + paging path (no transform-positioned rows, so the sticky header
|
||||||
* is never painted over — issue #841).
|
* is never painted over — issue #841).
|
||||||
*/
|
*/
|
||||||
export default function PagedSongList({ songs, hasMore, loadingMore, onLoadMore }: Props) {
|
export default function PagedSongList({ songs, hasMore, loadingMore, onLoadMore, showBpm }: Props) {
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Re-observe whenever `onLoadMore` changes identity (callers rebuild it after
|
// Re-observe whenever `onLoadMore` changes identity (callers rebuild it after
|
||||||
@@ -36,9 +38,9 @@ export default function PagedSongList({ songs, hasMore, loadingMore, onLoadMore
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SongListHeader />
|
<SongListHeader showBpm={showBpm} />
|
||||||
{songs.map(song => (
|
{songs.map(song => (
|
||||||
<SongRow key={song.id} song={song} />
|
<SongRow key={song.id} song={song} showBpm={showBpm} />
|
||||||
))}
|
))}
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div ref={sentinelRef} style={{ display: 'flex', justifyContent: 'center', padding: '1rem' }}>
|
<div ref={sentinelRef} style={{ display: 'flex', justifyContent: 'center', padding: '1rem' }}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getSong } from '../api/subsonicLibrary';
|
import { getSong } from '../api/subsonicLibrary';
|
||||||
|
import { libraryGetFacts } from '../api/library';
|
||||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
@@ -7,11 +8,19 @@ import { usePlayerStore } from '../store/playerStore';
|
|||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { ndGetSongPath } from '../api/navidromeAdmin';
|
import { ndGetSongPath } from '../api/navidromeAdmin';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { copyTextToClipboard } from '../utils/server/serverMagicString';
|
import { copyTextToClipboard } from '../utils/server/serverMagicString';
|
||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
import { formatTrackTime } from '../utils/format/formatDuration';
|
import { formatTrackTime } from '../utils/format/formatDuration';
|
||||||
import { formatLastSeen } from '../utils/componentHelpers/userMgmtHelpers';
|
import { formatLastSeen } from '../utils/componentHelpers/userMgmtHelpers';
|
||||||
|
import { libraryIsReady } from '../utils/library/libraryReady';
|
||||||
|
import {
|
||||||
|
formatQueueMoodLabels,
|
||||||
|
parseTrackEnrichmentFacts,
|
||||||
|
resolveQueueBpm,
|
||||||
|
type ParsedTrackEnrichment,
|
||||||
|
} from '../utils/library/trackEnrichment';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
|
|
||||||
function formatSize(bytes?: number): string | null {
|
function formatSize(bytes?: number): string | null {
|
||||||
@@ -61,6 +70,7 @@ export default function SongInfoModal() {
|
|||||||
useShallow(s => ({ songInfoModal: s.songInfoModal, closeSongInfo: s.closeSongInfo }))
|
useShallow(s => ({ songInfoModal: s.songInfoModal, closeSongInfo: s.closeSongInfo }))
|
||||||
);
|
);
|
||||||
const [song, setSong] = useState<SubsonicSong | null>(null);
|
const [song, setSong] = useState<SubsonicSong | null>(null);
|
||||||
|
const [enrichment, setEnrichment] = useState<ParsedTrackEnrichment | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
// Absolute filesystem path resolved via Navidrome's native API in parallel
|
// Absolute filesystem path resolved via Navidrome's native API in parallel
|
||||||
// with the Subsonic getSong call. Subsonic only ever returns a relative
|
// with the Subsonic getSong call. Subsonic only ever returns a relative
|
||||||
@@ -71,16 +81,40 @@ export default function SongInfoModal() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!songInfoModal.isOpen || !songInfoModal.songId) {
|
if (!songInfoModal.isOpen || !songInfoModal.songId) {
|
||||||
setSong(null);
|
setSong(null);
|
||||||
|
setEnrichment(null);
|
||||||
setAbsolutePath(null);
|
setAbsolutePath(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setEnrichment(null);
|
||||||
setAbsolutePath(null);
|
setAbsolutePath(null);
|
||||||
const songId = songInfoModal.songId;
|
const songId = songInfoModal.songId;
|
||||||
getSong(songId).then(s => {
|
void (async () => {
|
||||||
if (!cancelled) { setSong(s); setLoading(false); }
|
const s = await getSong(songId);
|
||||||
});
|
if (cancelled) return;
|
||||||
|
setSong(s);
|
||||||
|
setLoading(false);
|
||||||
|
if (!s) {
|
||||||
|
setEnrichment(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auth = useAuthStore.getState();
|
||||||
|
const sid = auth.activeServerId;
|
||||||
|
const indexEnabled = sid ? useLibraryIndexStore.getState().isIndexEnabled(sid) : false;
|
||||||
|
if (sid && indexEnabled && await libraryIsReady(sid)) {
|
||||||
|
try {
|
||||||
|
const facts = await libraryGetFacts(sid, songId);
|
||||||
|
if (!cancelled) {
|
||||||
|
setEnrichment(parseTrackEnrichmentFacts(facts, s.bpm ?? null));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setEnrichment(null);
|
||||||
|
}
|
||||||
|
} else if (!cancelled) {
|
||||||
|
setEnrichment(null);
|
||||||
|
}
|
||||||
|
})();
|
||||||
// Try the native API in parallel; only when the active server is Navidrome
|
// Try the native API in parallel; only when the active server is Navidrome
|
||||||
// and we have credentials. Failures are silent — modal falls back to
|
// and we have credentials. Failures are silent — modal falls back to
|
||||||
// whatever the Subsonic `path` field carried (typically nothing).
|
// whatever the Subsonic `path` field carried (typically nothing).
|
||||||
@@ -124,6 +158,17 @@ export default function SongInfoModal() {
|
|||||||
const hasReplayGain = song?.replayGain &&
|
const hasReplayGain = song?.replayGain &&
|
||||||
(song.replayGain.trackGain !== undefined || song.replayGain.albumGain !== undefined);
|
(song.replayGain.trackGain !== undefined || song.replayGain.albumGain !== undefined);
|
||||||
|
|
||||||
|
const displayBpm = song
|
||||||
|
? resolveQueueBpm(
|
||||||
|
enrichment ?? {
|
||||||
|
serverBpm: song.bpm != null && song.bpm > 0 ? song.bpm : null,
|
||||||
|
measuredBpm: null,
|
||||||
|
moodLabels: [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const displayMood = enrichment ? formatQueueMoodLabels(enrichment.moodLabels, t) : null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<>
|
<>
|
||||||
<div className="song-info-backdrop" onClick={closeSongInfo} />
|
<div className="song-info-backdrop" onClick={closeSongInfo} />
|
||||||
@@ -151,7 +196,8 @@ export default function SongInfoModal() {
|
|||||||
<Row label={t('songInfo.genre')} value={song.genre} />
|
<Row label={t('songInfo.genre')} value={song.genre} />
|
||||||
<Row label={t('songInfo.duration')} value={formatTrackTime(song.duration)} />
|
<Row label={t('songInfo.duration')} value={formatTrackTime(song.duration)} />
|
||||||
<Row label={t('songInfo.track')} value={trackLabel} />
|
<Row label={t('songInfo.track')} value={trackLabel} />
|
||||||
<Row label={t('songInfo.bpm')} value={song.bpm} />
|
<Row label={t('songInfo.bpm')} value={displayBpm} />
|
||||||
|
<Row label={t('songInfo.mood')} value={displayMood} />
|
||||||
<Row label={t('songInfo.playCount')} value={song.playCount} />
|
<Row label={t('songInfo.playCount')} value={song.playCount} />
|
||||||
<Row label={t('songInfo.lastPlayed')} value={song.played ? formatLastSeen(song.played, i18n.language, '—') : null} />
|
<Row label={t('songInfo.lastPlayed')} value={song.played ? formatLastSeen(song.played, i18n.language, '—') : null} />
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import { formatTrackTime } from '../utils/format/formatDuration';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
song: SubsonicSong;
|
song: SubsonicSong;
|
||||||
|
showBpm?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SongRow({ song }: Props) {
|
function SongRow({ song, showBpm }: Props) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
const enqueue = usePlayerStore(s => s.enqueue);
|
const enqueue = usePlayerStore(s => s.enqueue);
|
||||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||||
const isCurrent = usePlayerStore(s => s.currentTrack?.id === song.id);
|
const isCurrent = usePlayerStore(s => s.currentTrack?.id === song.id);
|
||||||
@@ -34,9 +36,16 @@ function SongRow({ song }: Props) {
|
|||||||
enqueue([songToTrack(song)]);
|
enqueue([songToTrack(song)]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bpmTooltip =
|
||||||
|
song.localBpmSource === 'analysis'
|
||||||
|
? t('search.bpmSourceAnalysis')
|
||||||
|
: song.localBpmSource === 'tag'
|
||||||
|
? t('search.bpmSourceTag')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`song-list-row${isCurrent ? ' is-current' : ''}`}
|
className={`song-list-row${isCurrent ? ' is-current' : ''}${showBpm ? ' song-list-row--with-bpm' : ''}`}
|
||||||
onDoubleClick={handlePlay}
|
onDoubleClick={handlePlay}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -102,21 +111,35 @@ function SongRow({ song }: Props) {
|
|||||||
<div className="song-list-row-cell song-list-row-genre truncate" title={song.genre ?? ''}>
|
<div className="song-list-row-cell song-list-row-genre truncate" title={song.genre ?? ''}>
|
||||||
{song.genre ?? '—'}
|
{song.genre ?? '—'}
|
||||||
</div>
|
</div>
|
||||||
|
{showBpm && (
|
||||||
|
<div
|
||||||
|
className="song-list-row-cell song-list-row-bpm"
|
||||||
|
data-tooltip={bpmTooltip}
|
||||||
|
>
|
||||||
|
{song.bpm != null && song.bpm > 0 ? song.bpm : '—'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="song-list-row-cell song-list-row-duration">{formatTrackTime(song.duration, '–')}</div>
|
<div className="song-list-row-cell song-list-row-duration">{formatTrackTime(song.duration, '–')}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Column header with the same grid as <SongRow>. Optional — pages can render it above the list. */
|
/** Column header with the same grid as <SongRow>. Optional — pages can render it above the list. */
|
||||||
export function SongListHeader() {
|
export function SongListHeader({ showBpm }: { showBpm?: boolean } = {}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="song-list-row song-list-row--header" role="row">
|
<div
|
||||||
|
className={`song-list-row song-list-row--header${showBpm ? ' song-list-row--with-bpm' : ''}`}
|
||||||
|
role="row"
|
||||||
|
>
|
||||||
<div className="song-list-row-cell song-list-row-actions" />
|
<div className="song-list-row-cell song-list-row-actions" />
|
||||||
<div className="song-list-row-cell">{t('albumDetail.trackTitle')}</div>
|
<div className="song-list-row-cell">{t('albumDetail.trackTitle')}</div>
|
||||||
<div className="song-list-row-cell">{t('albumDetail.trackArtist')}</div>
|
<div className="song-list-row-cell">{t('albumDetail.trackArtist')}</div>
|
||||||
<div className="song-list-row-cell">{t('albumDetail.trackAlbum')}</div>
|
<div className="song-list-row-cell">{t('albumDetail.trackAlbum')}</div>
|
||||||
<div className="song-list-row-cell">{t('randomMix.trackGenre')}</div>
|
<div className="song-list-row-cell">{t('randomMix.trackGenre')}</div>
|
||||||
|
{showBpm && (
|
||||||
|
<div className="song-list-row-cell song-list-row-bpm">{t('albumDetail.trackBpm')}</div>
|
||||||
|
)}
|
||||||
<div className="song-list-row-cell song-list-row-duration">{t('albumDetail.trackDuration')}</div>
|
<div className="song-list-row-cell song-list-row-duration">{t('albumDetail.trackDuration')}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
} from '../../utils/componentHelpers/queuePanelHelpers';
|
} from '../../utils/componentHelpers/queuePanelHelpers';
|
||||||
import { loudnessGainPlaceholderUntilCacheDb } from '../../utils/audio/loudnessPlaceholder';
|
import { loudnessGainPlaceholderUntilCacheDb } from '../../utils/audio/loudnessPlaceholder';
|
||||||
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../../utils/audio/loudnessPreAnalysisSlider';
|
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../../utils/audio/loudnessPreAnalysisSlider';
|
||||||
|
import { formatQueueBpmTech, formatQueueMoodLabels } from '../../utils/library/trackEnrichment';
|
||||||
|
import { useQueueTrackEnrichment } from '../../hooks/useQueueTrackEnrichment';
|
||||||
import { QueueLufsTargetMenu } from './QueueLufsTargetMenu';
|
import { QueueLufsTargetMenu } from './QueueLufsTargetMenu';
|
||||||
import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
|
import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
@@ -48,6 +50,9 @@ export function QueueCurrentTrack({
|
|||||||
lufsTgtBtnRef, lufsTgtMenuRef, lufsTgtPopStyle, t,
|
lufsTgtBtnRef, lufsTgtMenuRef, lufsTgtPopStyle, t,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
||||||
|
const enrichment = useQueueTrackEnrichment(currentTrack.id);
|
||||||
|
const bpmTech = formatQueueBpmTech(enrichment, t);
|
||||||
|
const moodLine = formatQueueMoodLabels(enrichment.moodLabels, t);
|
||||||
return (
|
return (
|
||||||
<div className="queue-current-track">
|
<div className="queue-current-track">
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -62,6 +67,7 @@ export function QueueCurrentTrack({
|
|||||||
if (sr) return sr;
|
if (sr) return sr;
|
||||||
return undefined;
|
return undefined;
|
||||||
})(),
|
})(),
|
||||||
|
bpmTech ?? undefined,
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
const rgParts = formatQueueReplayGainParts(currentTrack, t);
|
const rgParts = formatQueueReplayGainParts(currentTrack, t);
|
||||||
const baseLine = baseParts.join(' · ');
|
const baseLine = baseParts.join(' · ');
|
||||||
@@ -86,7 +92,7 @@ export function QueueCurrentTrack({
|
|||||||
})();
|
})();
|
||||||
const tgtNum = normalizationTargetLufs ?? authLoudnessTargetLufs;
|
const tgtNum = normalizationTargetLufs ?? authLoudnessTargetLufs;
|
||||||
const targetLabel = `${tgtNum} LUFS`;
|
const targetLabel = `${tgtNum} LUFS`;
|
||||||
if (!baseLine && !rgLine && !playbackSource) return null;
|
if (!baseLine && !rgLine && !playbackSource && !bpmTech) return null;
|
||||||
const showRgLine = !isLoudnessActive && expandReplayGain && !!rgLine;
|
const showRgLine = !isLoudnessActive && expandReplayGain && !!rgLine;
|
||||||
const showLufsLine = isLoudnessActive && expandReplayGain;
|
const showLufsLine = isLoudnessActive && expandReplayGain;
|
||||||
return (
|
return (
|
||||||
@@ -215,6 +221,9 @@ export function QueueCurrentTrack({
|
|||||||
{currentTrack.year && (
|
{currentTrack.year && (
|
||||||
<div className="queue-current-sub">{currentTrack.year}</div>
|
<div className="queue-current-sub">{currentTrack.year}</div>
|
||||||
)}
|
)}
|
||||||
|
{moodLine && (
|
||||||
|
<div className="queue-current-sub queue-current-enrichment">{moodLine}</div>
|
||||||
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
const label = orbitAttributionLabel(currentTrack.id);
|
const label = orbitAttributionLabel(currentTrack.id);
|
||||||
return label ? <div className="queue-current-sub queue-current-attribution">{label}</div> : null;
|
return label ? <div className="queue-current-sub queue-current-attribution">{label}</div> : null;
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
MOOD_GROUP_IDS,
|
||||||
|
MOOD_GROUPS,
|
||||||
|
OXIMEDIA_MOOD_TAG_IDS,
|
||||||
|
TOP_OXIMEDIA_MOOD_TAG_TEST_SCORES,
|
||||||
|
moodScoresFromValenceArousal,
|
||||||
|
topDistinctOximediaMoodTagIds,
|
||||||
|
topDistinctOximediaMoodTagIdsFromValenceArousal,
|
||||||
|
topOximediaMoodTagIds,
|
||||||
|
} from './moodGroups';
|
||||||
|
|
||||||
|
/** Keep in sync with `psysonic_library::mood_groups` invariant tests. */
|
||||||
|
describe('moodGroups catalog invariants', () => {
|
||||||
|
it('group ids match Rust MOOD_GROUP_IDS', () => {
|
||||||
|
expect([...MOOD_GROUP_IDS]).toEqual(['joy', 'sadness', 'dance', 'work', 'romance', 'anger']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every oximedia tag appears in at least one group', () => {
|
||||||
|
for (const tag of OXIMEDIA_MOOD_TAG_IDS) {
|
||||||
|
expect(MOOD_GROUPS.some(g => g.tags.includes(tag))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joy group tags match Rust expand_mood_groups', () => {
|
||||||
|
expect(MOOD_GROUPS.find(g => g.id === 'joy')?.tags).toEqual(['happy', 'excited']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('work and romance groups overlap on calm/peaceful', () => {
|
||||||
|
const work = MOOD_GROUPS.find(g => g.id === 'work')!.tags;
|
||||||
|
const romance = MOOD_GROUPS.find(g => g.id === 'romance')!.tags;
|
||||||
|
expect(work.some(t => romance.includes(t))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anger group tags match Rust expand_mood_groups', () => {
|
||||||
|
expect(MOOD_GROUPS.find(g => g.id === 'anger')?.tags).toEqual(['angry', 'tense']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('moodScoresFromValenceArousal', () => {
|
||||||
|
it('never shows both happy and excited for typical oximedia pop', () => {
|
||||||
|
const labels = topDistinctOximediaMoodTagIdsFromValenceArousal(0.4, 0.75);
|
||||||
|
expect(labels.includes('happy') && labels.includes('excited')).toBe(false);
|
||||||
|
expect(labels.length).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers calm or peaceful for low arousal', () => {
|
||||||
|
const labels = topDistinctOximediaMoodTagIdsFromValenceArousal(0.55, 0.42);
|
||||||
|
expect(labels.some(id => id === 'calm' || id === 'peaceful')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('topOximediaMoodTagIds', () => {
|
||||||
|
it('matches Rust top_oximedia_mood_tag_ids_from_moods_json test vector', () => {
|
||||||
|
expect(topOximediaMoodTagIds(TOP_OXIMEDIA_MOOD_TAG_TEST_SCORES)).toEqual([
|
||||||
|
'happy',
|
||||||
|
'excited',
|
||||||
|
'calm',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by score descending with id tie-break', () => {
|
||||||
|
expect(topOximediaMoodTagIds({ calm: 0.2, happy: 0.9, excited: 0.5 })).toEqual([
|
||||||
|
'happy',
|
||||||
|
'excited',
|
||||||
|
'calm',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/** Oximedia mood label ids — keep in sync with `psysonic_library::mood_groups` (see `moodGroups.test.ts`). */
|
||||||
|
export const OXIMEDIA_MOOD_TAG_IDS = [
|
||||||
|
'happy',
|
||||||
|
'excited',
|
||||||
|
'calm',
|
||||||
|
'peaceful',
|
||||||
|
'angry',
|
||||||
|
'tense',
|
||||||
|
'sad',
|
||||||
|
'melancholic',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type OximediaMoodTagId = (typeof OXIMEDIA_MOOD_TAG_IDS)[number];
|
||||||
|
|
||||||
|
export type MoodGroupId = 'joy' | 'sadness' | 'dance' | 'work' | 'romance' | 'anger';
|
||||||
|
|
||||||
|
/** Virtual mood groups for Advanced Search — overlaps are intentional. */
|
||||||
|
export const MOOD_GROUPS: ReadonlyArray<{
|
||||||
|
readonly id: MoodGroupId;
|
||||||
|
readonly tags: readonly string[];
|
||||||
|
}> = [
|
||||||
|
{ id: 'joy', tags: ['happy', 'excited'] },
|
||||||
|
{ id: 'sadness', tags: ['sad', 'melancholic'] },
|
||||||
|
{ id: 'dance', tags: ['excited', 'happy', 'tense', 'angry'] },
|
||||||
|
{ id: 'work', tags: ['calm', 'peaceful'] },
|
||||||
|
{ id: 'romance', tags: ['peaceful', 'calm', 'melancholic'] },
|
||||||
|
{ id: 'anger', tags: ['angry', 'tense'] },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const MOOD_GROUP_IDS: readonly MoodGroupId[] = MOOD_GROUPS.map(g => g.id);
|
||||||
|
|
||||||
|
/** Valence/arousal anchor — keep in sync with Rust `mood_groups::MOOD_VA_ANCHORS`. */
|
||||||
|
const MOOD_VA_ANCHORS: ReadonlyArray<{ readonly id: OximediaMoodTagId; readonly v: number; readonly a: number }> = [
|
||||||
|
{ id: 'happy', v: 0.75, a: 0.72 },
|
||||||
|
{ id: 'excited', v: 0.55, a: 0.88 },
|
||||||
|
{ id: 'calm', v: 0.65, a: 0.22 },
|
||||||
|
{ id: 'peaceful', v: 0.78, a: 0.12 },
|
||||||
|
{ id: 'angry', v: -0.72, a: 0.82 },
|
||||||
|
{ id: 'tense', v: -0.35, a: 0.68 },
|
||||||
|
{ id: 'sad', v: -0.75, a: 0.28 },
|
||||||
|
{ id: 'melancholic', v: -0.55, a: 0.18 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const MOOD_VA_MAX_DIST = 1.35;
|
||||||
|
const MOOD_VA_VALENCE_BIAS = 0.12;
|
||||||
|
const MOOD_VA_VALENCE_SCALE = 1.4;
|
||||||
|
const MOOD_VA_AROUSAL_OFFSET = 0.48;
|
||||||
|
const MOOD_VA_AROUSAL_SCALE = 0.40;
|
||||||
|
const MOOD_DISPLAY_MIN_RELATIVE = 0.55;
|
||||||
|
const MOOD_DISPLAY_MIN_ABSOLUTE = 0.28;
|
||||||
|
|
||||||
|
/** One label per cluster in UI — mirrors Rust `MOOD_DISPLAY_CLUSTERS`. */
|
||||||
|
const MOOD_DISPLAY_CLUSTERS: readonly (readonly OximediaMoodTagId[])[] = [
|
||||||
|
['happy', 'excited'],
|
||||||
|
['calm', 'peaceful'],
|
||||||
|
['angry', 'tense'],
|
||||||
|
['sad', 'melancholic'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function moodDisplayCluster(tag: string): number | null {
|
||||||
|
const idx = MOOD_DISPLAY_CLUSTERS.findIndex(cluster => cluster.includes(tag as OximediaMoodTagId));
|
||||||
|
return idx >= 0 ? idx : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft scores for all oximedia mood tags from raw valence/arousal.
|
||||||
|
* Oximedia's built-in mapper returns only two labels (often happy/excited);
|
||||||
|
* we recalibrate V/A and score every catalog tag by distance to anchors.
|
||||||
|
*/
|
||||||
|
export function moodScoresFromValenceArousal(valence: number, arousal: number): Record<string, number> {
|
||||||
|
const v = Math.max(-1, Math.min(1, (valence - MOOD_VA_VALENCE_BIAS) * MOOD_VA_VALENCE_SCALE));
|
||||||
|
const a = Math.max(0, Math.min(1, (arousal - MOOD_VA_AROUSAL_OFFSET) / MOOD_VA_AROUSAL_SCALE));
|
||||||
|
const scores: Record<string, number> = {};
|
||||||
|
for (const anchor of MOOD_VA_ANCHORS) {
|
||||||
|
const dv = v - anchor.v;
|
||||||
|
const da = a - anchor.a;
|
||||||
|
const dist = Math.sqrt(dv * dv + da * da);
|
||||||
|
scores[anchor.id] = Math.max(0, 1 - dist / MOOD_VA_MAX_DIST);
|
||||||
|
}
|
||||||
|
return scores;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared test vector with Rust `mood_groups::top_oximedia_mood_tag_ids_from_moods_json`. */
|
||||||
|
export const TOP_OXIMEDIA_MOOD_TAG_TEST_SCORES = {
|
||||||
|
noise: 0.99,
|
||||||
|
calm: 0.2,
|
||||||
|
happy: 0.9,
|
||||||
|
excited: 0.5,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Top oximedia mood tag ids by score — mirrors Rust `top_oximedia_mood_tag_ids_from_scores`. */
|
||||||
|
export function topOximediaMoodTagIds(
|
||||||
|
scores: Record<string, number> | null | undefined,
|
||||||
|
limit = 3,
|
||||||
|
): string[] {
|
||||||
|
if (!scores) return [];
|
||||||
|
const allowed = new Set<string>(OXIMEDIA_MOOD_TAG_IDS);
|
||||||
|
return Object.entries(scores)
|
||||||
|
.filter(([id]) => allowed.has(id))
|
||||||
|
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(([id]) => id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One tag per display cluster (never happy+excited together). Mirrors Rust distinct picker. */
|
||||||
|
export function topDistinctOximediaMoodTagIds(
|
||||||
|
scores: Record<string, number> | null | undefined,
|
||||||
|
limit = 2,
|
||||||
|
): string[] {
|
||||||
|
if (!scores) return [];
|
||||||
|
const ranked = topOximediaMoodTagIds(scores, OXIMEDIA_MOOD_TAG_IDS.length);
|
||||||
|
if (ranked.length === 0) return [];
|
||||||
|
const topScore = scores[ranked[0]] ?? 0;
|
||||||
|
const usedClusters = new Set<number>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const id of ranked) {
|
||||||
|
const score = scores[id] ?? 0;
|
||||||
|
if (score < MOOD_DISPLAY_MIN_ABSOLUTE || score < topScore * MOOD_DISPLAY_MIN_RELATIVE) continue;
|
||||||
|
const cluster = moodDisplayCluster(id);
|
||||||
|
if (cluster != null) {
|
||||||
|
if (usedClusters.has(cluster)) continue;
|
||||||
|
usedClusters.add(cluster);
|
||||||
|
}
|
||||||
|
out.push(id);
|
||||||
|
if (out.length >= limit) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function topDistinctOximediaMoodTagIdsFromValenceArousal(
|
||||||
|
valence: number,
|
||||||
|
arousal: number,
|
||||||
|
limit = 2,
|
||||||
|
): string[] {
|
||||||
|
return topDistinctOximediaMoodTagIds(moodScoresFromValenceArousal(valence, arousal), limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dedupe a stored tag list to one label per cluster (preserves rank order). */
|
||||||
|
export function distinctOximediaMoodTagIds(tags: readonly string[], limit = 2): string[] {
|
||||||
|
const scores = Object.fromEntries(tags.map((id, index) => [id, tags.length - index]));
|
||||||
|
return topDistinctOximediaMoodTagIds(scores, limit);
|
||||||
|
}
|
||||||
@@ -127,6 +127,7 @@ const CONTRIBUTOR_ENTRIES = [
|
|||||||
'Player stats: local listening history tab with heatmap, year summary, recent days, and day drill-down (PR #849)',
|
'Player stats: local listening history tab with heatmap, year summary, recent days, and day drill-down (PR #849)',
|
||||||
'Playback speed: global 0.5–2.0× with Speed / Varispeed / Pitch shift strategies, player bar popover, Orbit passthrough (PR #852)',
|
'Playback speed: global 0.5–2.0× with Speed / Varispeed / Pitch shift strategies, player bar popover, Orbit passthrough (PR #852)',
|
||||||
'Local library index: full resync orphan sweep (IS-7) — remove server-deleted tracks after successful re-sync (PR #861)',
|
'Local library index: full resync orphan sweep (IS-7) — remove server-deleted tracks after successful re-sync (PR #861)',
|
||||||
|
'Track enrichment: oximedia BPM/mood analysis, mood-group Advanced Search, queue display, unified playback analysis dispatch (PR #863)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,100 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import { libraryGetFacts, libraryGetTrack } from '../api/library';
|
||||||
|
import { usePlaybackServerId } from './usePlaybackServerId';
|
||||||
|
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||||
|
import {
|
||||||
|
enrichmentDisplayComplete,
|
||||||
|
OXIMEDIA_MOOD_UI_ENABLED,
|
||||||
|
parseTrackEnrichmentFacts,
|
||||||
|
type ParsedTrackEnrichment,
|
||||||
|
} from '../utils/library/trackEnrichment';
|
||||||
|
import { libraryIsReady } from '../utils/library/libraryReady';
|
||||||
|
import { normalizeAnalysisTrackId } from '../utils/playback/queueIdentity';
|
||||||
|
|
||||||
|
const EMPTY: ParsedTrackEnrichment = {
|
||||||
|
serverBpm: null,
|
||||||
|
measuredBpm: null,
|
||||||
|
moodLabels: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Enrichment may finish several seconds after CPU seed / playback start. */
|
||||||
|
const REFETCH_MS = [3_000, 8_000, 15_000, 30_000, 60_000] as const;
|
||||||
|
|
||||||
|
const ENRICHMENT_FACT_KINDS = OXIMEDIA_MOOD_UI_ENABLED
|
||||||
|
? (['bpm', 'moods', 'mood_tag', 'mood_labels', 'valence', 'arousal'] as const)
|
||||||
|
: (['bpm'] as const);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads server BPM + oximedia mood facts for the queue "now playing" block.
|
||||||
|
* Uses the playback server id (queue scope), not the browsed server.
|
||||||
|
*/
|
||||||
|
export function useQueueTrackEnrichment(trackId: string | undefined): ParsedTrackEnrichment {
|
||||||
|
const serverId = usePlaybackServerId();
|
||||||
|
const indexEnabled = useLibraryIndexStore(s =>
|
||||||
|
serverId ? s.isIndexEnabled(serverId) : false,
|
||||||
|
);
|
||||||
|
const [data, setData] = useState<ParsedTrackEnrichment>(EMPTY);
|
||||||
|
const [refreshNonce, setRefreshNonce] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!serverId || !trackId || !indexEnabled) {
|
||||||
|
setData(EMPTY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!(await libraryIsReady(serverId))) return;
|
||||||
|
try {
|
||||||
|
const [track, facts] = await Promise.all([
|
||||||
|
libraryGetTrack(serverId, trackId),
|
||||||
|
libraryGetFacts(serverId, trackId, [...ENRICHMENT_FACT_KINDS]),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
const parsed = parseTrackEnrichmentFacts(facts, track?.bpm ?? null);
|
||||||
|
setData(parsed);
|
||||||
|
if (enrichmentDisplayComplete(parsed)) {
|
||||||
|
for (const id of timers) clearTimeout(id);
|
||||||
|
timers.length = 0;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setData(EMPTY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void load();
|
||||||
|
for (const ms of REFETCH_MS) {
|
||||||
|
timers.push(setTimeout(() => { void load(); }, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
for (const id of timers) clearTimeout(id);
|
||||||
|
};
|
||||||
|
}, [serverId, trackId, indexEnabled, refreshNonce]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!serverId || !trackId || !indexEnabled) return;
|
||||||
|
|
||||||
|
let unlisten: (() => void) | undefined;
|
||||||
|
void listen<{ trackId: string; serverId: string }>('analysis:enrichment-updated', ({ payload }) => {
|
||||||
|
if (!payload?.trackId) return;
|
||||||
|
const eventTrackId = normalizeAnalysisTrackId(payload.trackId);
|
||||||
|
const currentId = normalizeAnalysisTrackId(trackId);
|
||||||
|
if (!eventTrackId || eventTrackId !== currentId) return;
|
||||||
|
if (payload.serverId && payload.serverId !== serverId) return;
|
||||||
|
setRefreshNonce(n => n + 1);
|
||||||
|
}).then(fn => {
|
||||||
|
unlisten = fn;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
|
}, [serverId, trackId, indexEnabled]);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -43,4 +43,15 @@ export const queue = {
|
|||||||
sourceStream: 'Wiedergabe aus dem Netzwerkstream',
|
sourceStream: 'Wiedergabe aus dem Netzwerkstream',
|
||||||
clearCachedLoudnessWaveform: 'Clear cached loudness and waveform, then re-analyze this track',
|
clearCachedLoudnessWaveform: 'Clear cached loudness and waveform, then re-analyze this track',
|
||||||
recalculatingLoudnessWaveform: 'Recalculating loudness and waveform for this track…',
|
recalculatingLoudnessWaveform: 'Recalculating loudness and waveform for this track…',
|
||||||
|
bpm: '{{bpm}} BPM',
|
||||||
|
moods: {
|
||||||
|
happy: 'Happy',
|
||||||
|
excited: 'Excited',
|
||||||
|
calm: 'Calm',
|
||||||
|
peaceful: 'Peaceful',
|
||||||
|
angry: 'Angry',
|
||||||
|
tense: 'Tense',
|
||||||
|
sad: 'Sad',
|
||||||
|
melancholic: 'Melancholic',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export const search = {
|
|||||||
advancedYearFrom: 'von',
|
advancedYearFrom: 'von',
|
||||||
advancedYearTo: 'bis',
|
advancedYearTo: 'bis',
|
||||||
advancedBpm: 'BPM',
|
advancedBpm: 'BPM',
|
||||||
|
advancedBpmLocalNote: 'Uses tag BPM and measured analysis when the local index is enabled',
|
||||||
|
advancedBpmClear: 'BPM-Filter zurücksetzen',
|
||||||
|
bpmSourceTag: 'BPM from file tag',
|
||||||
|
bpmSourceAnalysis: 'BPM from audio analysis',
|
||||||
|
advancedMoodGroup: 'Mood',
|
||||||
|
advancedAllMoods: 'All moods',
|
||||||
|
advancedMoodLocalNote: 'Requires local library index and track analysis',
|
||||||
advancedAll: 'Alle',
|
advancedAll: 'Alle',
|
||||||
advancedSearch: 'Suchen',
|
advancedSearch: 'Suchen',
|
||||||
advancedEmpty: 'Suchbegriff eingeben oder Filter wählen, um zu beginnen.',
|
advancedEmpty: 'Suchbegriff eingeben oder Filter wählen, um zu beginnen.',
|
||||||
@@ -48,4 +55,12 @@ export const search = {
|
|||||||
shareQueuePreviewLoading: 'Loading tracks…',
|
shareQueuePreviewLoading: 'Loading tracks…',
|
||||||
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
||||||
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
||||||
|
moodGroups: {
|
||||||
|
joy: 'Joy',
|
||||||
|
sadness: 'Sadness',
|
||||||
|
dance: 'Dance',
|
||||||
|
work: 'Work',
|
||||||
|
romance: 'Romance',
|
||||||
|
anger: 'Anger',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const songInfo = {
|
|||||||
duration: 'Länge',
|
duration: 'Länge',
|
||||||
track: 'Track',
|
track: 'Track',
|
||||||
bpm: 'BPM',
|
bpm: 'BPM',
|
||||||
|
mood: 'Stimmung',
|
||||||
playCount: 'Wiedergaben',
|
playCount: 'Wiedergaben',
|
||||||
lastPlayed: 'Zuletzt gespielt',
|
lastPlayed: 'Zuletzt gespielt',
|
||||||
format: 'Format',
|
format: 'Format',
|
||||||
|
|||||||
@@ -43,4 +43,15 @@ export const queue = {
|
|||||||
sourceStream: 'Playing from network stream',
|
sourceStream: 'Playing from network stream',
|
||||||
clearCachedLoudnessWaveform: 'Clear cached loudness and waveform, then re-analyze this track',
|
clearCachedLoudnessWaveform: 'Clear cached loudness and waveform, then re-analyze this track',
|
||||||
recalculatingLoudnessWaveform: 'Recalculating loudness and waveform for this track…',
|
recalculatingLoudnessWaveform: 'Recalculating loudness and waveform for this track…',
|
||||||
|
bpm: '{{bpm}} BPM',
|
||||||
|
moods: {
|
||||||
|
happy: 'Happy',
|
||||||
|
excited: 'Excited',
|
||||||
|
calm: 'Calm',
|
||||||
|
peaceful: 'Peaceful',
|
||||||
|
angry: 'Angry',
|
||||||
|
tense: 'Tense',
|
||||||
|
sad: 'Sad',
|
||||||
|
melancholic: 'Melancholic',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export const search = {
|
|||||||
advancedYearFrom: 'from',
|
advancedYearFrom: 'from',
|
||||||
advancedYearTo: 'to',
|
advancedYearTo: 'to',
|
||||||
advancedBpm: 'BPM',
|
advancedBpm: 'BPM',
|
||||||
|
advancedBpmLocalNote: 'Measured analysis BPM takes priority over file tags when the local index is enabled',
|
||||||
|
advancedBpmClear: 'Clear BPM filter',
|
||||||
|
bpmSourceTag: 'BPM from file tag',
|
||||||
|
bpmSourceAnalysis: 'BPM from audio analysis',
|
||||||
|
advancedMoodGroup: 'Mood',
|
||||||
|
advancedAllMoods: 'All moods',
|
||||||
|
advancedMoodLocalNote: 'Requires local library index and track analysis',
|
||||||
advancedAll: 'All',
|
advancedAll: 'All',
|
||||||
advancedSearch: 'Search',
|
advancedSearch: 'Search',
|
||||||
advancedEmpty: 'Enter a search term or select a filter to begin.',
|
advancedEmpty: 'Enter a search term or select a filter to begin.',
|
||||||
@@ -48,4 +55,12 @@ export const search = {
|
|||||||
shareQueuePreviewLoading: 'Loading tracks…',
|
shareQueuePreviewLoading: 'Loading tracks…',
|
||||||
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
||||||
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
||||||
|
moodGroups: {
|
||||||
|
joy: 'Joy',
|
||||||
|
sadness: 'Sadness',
|
||||||
|
dance: 'Dance',
|
||||||
|
work: 'Work',
|
||||||
|
romance: 'Romance',
|
||||||
|
anger: 'Anger',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const songInfo = {
|
|||||||
duration: 'Duration',
|
duration: 'Duration',
|
||||||
track: 'Track',
|
track: 'Track',
|
||||||
bpm: 'BPM',
|
bpm: 'BPM',
|
||||||
|
mood: 'Mood',
|
||||||
playCount: 'Play count',
|
playCount: 'Play count',
|
||||||
lastPlayed: 'Last played',
|
lastPlayed: 'Last played',
|
||||||
format: 'Format',
|
format: 'Format',
|
||||||
|
|||||||
@@ -43,4 +43,15 @@ export const queue = {
|
|||||||
sourceStream: 'Reproducción desde la transmisión en red',
|
sourceStream: 'Reproducción desde la transmisión en red',
|
||||||
clearCachedLoudnessWaveform: 'Clear cached loudness and waveform, then re-analyze this track',
|
clearCachedLoudnessWaveform: 'Clear cached loudness and waveform, then re-analyze this track',
|
||||||
recalculatingLoudnessWaveform: 'Recalculating loudness and waveform for this track…',
|
recalculatingLoudnessWaveform: 'Recalculating loudness and waveform for this track…',
|
||||||
|
bpm: '{{bpm}} BPM',
|
||||||
|
moods: {
|
||||||
|
happy: 'Happy',
|
||||||
|
excited: 'Excited',
|
||||||
|
calm: 'Calm',
|
||||||
|
peaceful: 'Peaceful',
|
||||||
|
angry: 'Angry',
|
||||||
|
tense: 'Tense',
|
||||||
|
sad: 'Sad',
|
||||||
|
melancholic: 'Melancholic',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export const search = {
|
|||||||
advancedYearFrom: 'desde',
|
advancedYearFrom: 'desde',
|
||||||
advancedYearTo: 'hasta',
|
advancedYearTo: 'hasta',
|
||||||
advancedBpm: 'BPM',
|
advancedBpm: 'BPM',
|
||||||
|
advancedBpmLocalNote: 'Uses tag BPM and measured analysis when the local index is enabled',
|
||||||
|
advancedBpmClear: 'Limpiar filtro BPM',
|
||||||
|
bpmSourceTag: 'BPM from file tag',
|
||||||
|
bpmSourceAnalysis: 'BPM from audio analysis',
|
||||||
|
advancedMoodGroup: 'Mood',
|
||||||
|
advancedAllMoods: 'All moods',
|
||||||
|
advancedMoodLocalNote: 'Requires local library index and track analysis',
|
||||||
advancedAll: 'Todos',
|
advancedAll: 'Todos',
|
||||||
advancedSearch: 'Buscar',
|
advancedSearch: 'Buscar',
|
||||||
advancedEmpty: 'Ingresa un término de búsqueda o selecciona un filtro para comenzar.',
|
advancedEmpty: 'Ingresa un término de búsqueda o selecciona un filtro para comenzar.',
|
||||||
@@ -48,4 +55,12 @@ export const search = {
|
|||||||
shareQueuePreviewLoading: 'Loading tracks…',
|
shareQueuePreviewLoading: 'Loading tracks…',
|
||||||
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
||||||
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
||||||
|
moodGroups: {
|
||||||
|
joy: 'Joy',
|
||||||
|
sadness: 'Sadness',
|
||||||
|
dance: 'Dance',
|
||||||
|
work: 'Work',
|
||||||
|
romance: 'Romance',
|
||||||
|
anger: 'Anger',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const songInfo = {
|
|||||||
duration: 'Duración',
|
duration: 'Duración',
|
||||||
track: 'Pista',
|
track: 'Pista',
|
||||||
bpm: 'BPM',
|
bpm: 'BPM',
|
||||||
|
mood: 'Estado de ánimo',
|
||||||
playCount: 'Reproducciones',
|
playCount: 'Reproducciones',
|
||||||
lastPlayed: 'Última reproducción',
|
lastPlayed: 'Última reproducción',
|
||||||
format: 'Formato',
|
format: 'Formato',
|
||||||
|
|||||||
@@ -43,4 +43,15 @@ export const queue = {
|
|||||||
sourceStream: 'Lecture depuis le flux réseau',
|
sourceStream: 'Lecture depuis le flux réseau',
|
||||||
clearCachedLoudnessWaveform: 'Clear cached loudness and waveform, then re-analyze this track',
|
clearCachedLoudnessWaveform: 'Clear cached loudness and waveform, then re-analyze this track',
|
||||||
recalculatingLoudnessWaveform: 'Recalculating loudness and waveform for this track…',
|
recalculatingLoudnessWaveform: 'Recalculating loudness and waveform for this track…',
|
||||||
|
bpm: '{{bpm}} BPM',
|
||||||
|
moods: {
|
||||||
|
happy: 'Happy',
|
||||||
|
excited: 'Excited',
|
||||||
|
calm: 'Calm',
|
||||||
|
peaceful: 'Peaceful',
|
||||||
|
angry: 'Angry',
|
||||||
|
tense: 'Tense',
|
||||||
|
sad: 'Sad',
|
||||||
|
melancholic: 'Melancholic',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export const search = {
|
|||||||
advancedYearFrom: 'de',
|
advancedYearFrom: 'de',
|
||||||
advancedYearTo: 'à',
|
advancedYearTo: 'à',
|
||||||
advancedBpm: 'BPM',
|
advancedBpm: 'BPM',
|
||||||
|
advancedBpmLocalNote: 'Uses tag BPM and measured analysis when the local index is enabled',
|
||||||
|
advancedBpmClear: 'Effacer le filtre BPM',
|
||||||
|
bpmSourceTag: 'BPM from file tag',
|
||||||
|
bpmSourceAnalysis: 'BPM from audio analysis',
|
||||||
|
advancedMoodGroup: 'Mood',
|
||||||
|
advancedAllMoods: 'All moods',
|
||||||
|
advancedMoodLocalNote: 'Requires local library index and track analysis',
|
||||||
advancedAll: 'Tous',
|
advancedAll: 'Tous',
|
||||||
advancedSearch: 'Rechercher',
|
advancedSearch: 'Rechercher',
|
||||||
advancedEmpty: 'Entrez un terme de recherche ou sélectionnez un filtre.',
|
advancedEmpty: 'Entrez un terme de recherche ou sélectionnez un filtre.',
|
||||||
@@ -48,4 +55,12 @@ export const search = {
|
|||||||
shareQueuePreviewLoading: 'Loading tracks…',
|
shareQueuePreviewLoading: 'Loading tracks…',
|
||||||
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
||||||
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
||||||
|
moodGroups: {
|
||||||
|
joy: 'Joy',
|
||||||
|
sadness: 'Sadness',
|
||||||
|
dance: 'Dance',
|
||||||
|
work: 'Work',
|
||||||
|
romance: 'Romance',
|
||||||
|
anger: 'Anger',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const songInfo = {
|
|||||||
duration: 'Durée',
|
duration: 'Durée',
|
||||||
track: 'Piste',
|
track: 'Piste',
|
||||||
bpm: 'BPM',
|
bpm: 'BPM',
|
||||||
|
mood: 'Ambiance',
|
||||||
playCount: 'Nombre de lectures',
|
playCount: 'Nombre de lectures',
|
||||||
lastPlayed: 'Dernière lecture',
|
lastPlayed: 'Dernière lecture',
|
||||||
format: 'Format',
|
format: 'Format',
|
||||||
|
|||||||
@@ -43,4 +43,15 @@ export const queue = {
|
|||||||
sourceStream: 'Spiller fra nettverksstrøm',
|
sourceStream: 'Spiller fra nettverksstrøm',
|
||||||
clearCachedLoudnessWaveform: 'Clear cached loudness and waveform, then re-analyze this track',
|
clearCachedLoudnessWaveform: 'Clear cached loudness and waveform, then re-analyze this track',
|
||||||
recalculatingLoudnessWaveform: 'Recalculating loudness and waveform for this track…',
|
recalculatingLoudnessWaveform: 'Recalculating loudness and waveform for this track…',
|
||||||
|
bpm: '{{bpm}} BPM',
|
||||||
|
moods: {
|
||||||
|
happy: 'Happy',
|
||||||
|
excited: 'Excited',
|
||||||
|
calm: 'Calm',
|
||||||
|
peaceful: 'Peaceful',
|
||||||
|
angry: 'Angry',
|
||||||
|
tense: 'Tense',
|
||||||
|
sad: 'Sad',
|
||||||
|
melancholic: 'Melancholic',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export const search = {
|
|||||||
advancedYearFrom: 'fra',
|
advancedYearFrom: 'fra',
|
||||||
advancedYearTo: 'til',
|
advancedYearTo: 'til',
|
||||||
advancedBpm: 'BPM',
|
advancedBpm: 'BPM',
|
||||||
|
advancedBpmLocalNote: 'Uses tag BPM and measured analysis when the local index is enabled',
|
||||||
|
advancedBpmClear: 'Tøm BPM-filter',
|
||||||
|
bpmSourceTag: 'BPM from file tag',
|
||||||
|
bpmSourceAnalysis: 'BPM from audio analysis',
|
||||||
|
advancedMoodGroup: 'Mood',
|
||||||
|
advancedAllMoods: 'All moods',
|
||||||
|
advancedMoodLocalNote: 'Requires local library index and track analysis',
|
||||||
advancedAll: 'Alle',
|
advancedAll: 'Alle',
|
||||||
advancedSearch: 'Søk',
|
advancedSearch: 'Søk',
|
||||||
advancedEmpty: 'Skriv inn et søkeord eller velg ett filter for å begynne.',
|
advancedEmpty: 'Skriv inn et søkeord eller velg ett filter for å begynne.',
|
||||||
@@ -48,4 +55,12 @@ export const search = {
|
|||||||
shareQueuePreviewLoading: 'Loading tracks…',
|
shareQueuePreviewLoading: 'Loading tracks…',
|
||||||
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
||||||
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
||||||
|
moodGroups: {
|
||||||
|
joy: 'Joy',
|
||||||
|
sadness: 'Sadness',
|
||||||
|
dance: 'Dance',
|
||||||
|
work: 'Work',
|
||||||
|
romance: 'Romance',
|
||||||
|
anger: 'Anger',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const songInfo = {
|
|||||||
duration: 'Varighet',
|
duration: 'Varighet',
|
||||||
track: 'Spor',
|
track: 'Spor',
|
||||||
bpm: 'BPM',
|
bpm: 'BPM',
|
||||||
|
mood: 'Stemning',
|
||||||
playCount: 'Avspillinger',
|
playCount: 'Avspillinger',
|
||||||
lastPlayed: 'Sist spilt',
|
lastPlayed: 'Sist spilt',
|
||||||
format: 'Format',
|
format: 'Format',
|
||||||
|
|||||||
@@ -43,4 +43,15 @@ export const queue = {
|
|||||||
sourceStream: 'Afspelen vanuit netwerkstream',
|
sourceStream: 'Afspelen vanuit netwerkstream',
|
||||||
clearCachedLoudnessWaveform: 'Clear cached loudness and waveform, then re-analyze this track',
|
clearCachedLoudnessWaveform: 'Clear cached loudness and waveform, then re-analyze this track',
|
||||||
recalculatingLoudnessWaveform: 'Recalculating loudness and waveform for this track…',
|
recalculatingLoudnessWaveform: 'Recalculating loudness and waveform for this track…',
|
||||||
|
bpm: '{{bpm}} BPM',
|
||||||
|
moods: {
|
||||||
|
happy: 'Happy',
|
||||||
|
excited: 'Excited',
|
||||||
|
calm: 'Calm',
|
||||||
|
peaceful: 'Peaceful',
|
||||||
|
angry: 'Angry',
|
||||||
|
tense: 'Tense',
|
||||||
|
sad: 'Sad',
|
||||||
|
melancholic: 'Melancholic',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export const search = {
|
|||||||
advancedYearFrom: 'van',
|
advancedYearFrom: 'van',
|
||||||
advancedYearTo: 'tot',
|
advancedYearTo: 'tot',
|
||||||
advancedBpm: 'BPM',
|
advancedBpm: 'BPM',
|
||||||
|
advancedBpmLocalNote: 'Uses tag BPM and measured analysis when the local index is enabled',
|
||||||
|
advancedBpmClear: 'BPM-filter wissen',
|
||||||
|
bpmSourceTag: 'BPM from file tag',
|
||||||
|
bpmSourceAnalysis: 'BPM from audio analysis',
|
||||||
|
advancedMoodGroup: 'Mood',
|
||||||
|
advancedAllMoods: 'All moods',
|
||||||
|
advancedMoodLocalNote: 'Requires local library index and track analysis',
|
||||||
advancedAll: 'Alle',
|
advancedAll: 'Alle',
|
||||||
advancedSearch: 'Zoeken',
|
advancedSearch: 'Zoeken',
|
||||||
advancedEmpty: 'Voer een zoekterm in of selecteer een filter.',
|
advancedEmpty: 'Voer een zoekterm in of selecteer een filter.',
|
||||||
@@ -48,4 +55,12 @@ export const search = {
|
|||||||
shareQueuePreviewLoading: 'Loading tracks…',
|
shareQueuePreviewLoading: 'Loading tracks…',
|
||||||
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
||||||
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
||||||
|
moodGroups: {
|
||||||
|
joy: 'Joy',
|
||||||
|
sadness: 'Sadness',
|
||||||
|
dance: 'Dance',
|
||||||
|
work: 'Work',
|
||||||
|
romance: 'Romance',
|
||||||
|
anger: 'Anger',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const songInfo = {
|
|||||||
duration: 'Duur',
|
duration: 'Duur',
|
||||||
track: 'Track',
|
track: 'Track',
|
||||||
bpm: 'BPM',
|
bpm: 'BPM',
|
||||||
|
mood: 'Stemming',
|
||||||
playCount: 'Aantal weergaven',
|
playCount: 'Aantal weergaven',
|
||||||
lastPlayed: 'Laatst afgespeeld',
|
lastPlayed: 'Laatst afgespeeld',
|
||||||
format: 'Formaat',
|
format: 'Formaat',
|
||||||
|
|||||||
@@ -43,4 +43,15 @@ export const queue = {
|
|||||||
sourceStream: 'Se redă din stream-ul de rețea',
|
sourceStream: 'Se redă din stream-ul de rețea',
|
||||||
clearCachedLoudnessWaveform: 'Golește zgomotul si formele de undă din cache, apoi reanalizează piesa',
|
clearCachedLoudnessWaveform: 'Golește zgomotul si formele de undă din cache, apoi reanalizează piesa',
|
||||||
recalculatingLoudnessWaveform: 'Se recalculează zgomotul și formele de undă pentru această piesă…',
|
recalculatingLoudnessWaveform: 'Se recalculează zgomotul și formele de undă pentru această piesă…',
|
||||||
|
bpm: '{{bpm}} BPM',
|
||||||
|
moods: {
|
||||||
|
happy: 'Fericit',
|
||||||
|
excited: 'Entuziasmat',
|
||||||
|
calm: 'Calm',
|
||||||
|
peaceful: 'Pașnic',
|
||||||
|
angry: 'Furios',
|
||||||
|
tense: 'Tensionat',
|
||||||
|
sad: 'Trist',
|
||||||
|
melancholic: 'Melancolic',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export const search = {
|
|||||||
advancedYearFrom: 'de la',
|
advancedYearFrom: 'de la',
|
||||||
advancedYearTo: 'până la',
|
advancedYearTo: 'până la',
|
||||||
advancedBpm: 'BPM',
|
advancedBpm: 'BPM',
|
||||||
|
advancedBpmLocalNote: 'Uses tag BPM and measured analysis when the local index is enabled',
|
||||||
|
advancedBpmClear: 'Golește filtrul BPM',
|
||||||
|
bpmSourceTag: 'BPM from file tag',
|
||||||
|
bpmSourceAnalysis: 'BPM from audio analysis',
|
||||||
|
advancedMoodGroup: 'Mood',
|
||||||
|
advancedAllMoods: 'All moods',
|
||||||
|
advancedMoodLocalNote: 'Requires local library index and track analysis',
|
||||||
advancedAll: 'Toate',
|
advancedAll: 'Toate',
|
||||||
advancedSearch: 'Căutare',
|
advancedSearch: 'Căutare',
|
||||||
advancedEmpty: 'Introdu un termen de căutare sau alege un filtru pentru a începe',
|
advancedEmpty: 'Introdu un termen de căutare sau alege un filtru pentru a începe',
|
||||||
@@ -48,4 +55,12 @@ export const search = {
|
|||||||
shareQueuePreviewLoading: 'Loading tracks…',
|
shareQueuePreviewLoading: 'Loading tracks…',
|
||||||
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
||||||
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
||||||
|
moodGroups: {
|
||||||
|
joy: 'Bucurie',
|
||||||
|
sadness: 'Tristețe',
|
||||||
|
dance: 'Dans',
|
||||||
|
work: 'Muncă',
|
||||||
|
romance: 'Romantic',
|
||||||
|
anger: 'Furie',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const songInfo = {
|
|||||||
duration: 'Durată',
|
duration: 'Durată',
|
||||||
track: 'Piesă',
|
track: 'Piesă',
|
||||||
bpm: 'BPM',
|
bpm: 'BPM',
|
||||||
|
mood: 'Stare de spirit',
|
||||||
playCount: 'Număr de redări',
|
playCount: 'Număr de redări',
|
||||||
lastPlayed: 'Ultima redare',
|
lastPlayed: 'Ultima redare',
|
||||||
format: 'Format',
|
format: 'Format',
|
||||||
|
|||||||
@@ -43,4 +43,15 @@ export const queue = {
|
|||||||
sourceStream: 'Играет из сетевого потока',
|
sourceStream: 'Играет из сетевого потока',
|
||||||
clearCachedLoudnessWaveform: 'Сбросить кэш громкости (LUFS) и формы волны и заново проанализировать трек',
|
clearCachedLoudnessWaveform: 'Сбросить кэш громкости (LUFS) и формы волны и заново проанализировать трек',
|
||||||
recalculatingLoudnessWaveform: 'Пересчёт громкости и формы волны для этого трека…',
|
recalculatingLoudnessWaveform: 'Пересчёт громкости и формы волны для этого трека…',
|
||||||
|
bpm: '{{bpm}} BPM',
|
||||||
|
moods: {
|
||||||
|
happy: 'Радостный',
|
||||||
|
excited: 'Возбуждённый',
|
||||||
|
calm: 'Спокойный',
|
||||||
|
peaceful: 'Мирный',
|
||||||
|
angry: 'Злой',
|
||||||
|
tense: 'Напряжённый',
|
||||||
|
sad: 'Грустный',
|
||||||
|
melancholic: 'Меланхоличный',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export const search = {
|
|||||||
advancedYearFrom: 'от',
|
advancedYearFrom: 'от',
|
||||||
advancedYearTo: 'до',
|
advancedYearTo: 'до',
|
||||||
advancedBpm: 'BPM',
|
advancedBpm: 'BPM',
|
||||||
|
advancedBpmLocalNote: 'Тег BPM и измеренный анализ — при включённом локальном индексе; анализ приоритетнее тега',
|
||||||
|
advancedBpmClear: 'Сбросить BPM',
|
||||||
|
bpmSourceTag: 'BPM из тега файла',
|
||||||
|
bpmSourceAnalysis: 'BPM из анализа аудио',
|
||||||
|
advancedMoodGroup: 'Настроение',
|
||||||
|
advancedAllMoods: 'Все настроения',
|
||||||
|
advancedMoodLocalNote: 'Только локальный индекс и анализ треков',
|
||||||
advancedAll: 'Все',
|
advancedAll: 'Все',
|
||||||
advancedSearch: 'Найти',
|
advancedSearch: 'Найти',
|
||||||
advancedEmpty: 'Введите запрос или выберите фильтр.',
|
advancedEmpty: 'Введите запрос или выберите фильтр.',
|
||||||
@@ -52,4 +59,12 @@ export const search = {
|
|||||||
shareQueuePreviewLoading: 'Загрузка треков…',
|
shareQueuePreviewLoading: 'Загрузка треков…',
|
||||||
shareQueuePreviewEmpty: 'Треки из этой ссылки не найдены на сервере.',
|
shareQueuePreviewEmpty: 'Треки из этой ссылки не найдены на сервере.',
|
||||||
shareQueuePreviewSkipped: '{{skipped}} из {{total}} треков не найдено на этом сервере.',
|
shareQueuePreviewSkipped: '{{skipped}} из {{total}} треков не найдено на этом сервере.',
|
||||||
|
moodGroups: {
|
||||||
|
joy: 'Радость',
|
||||||
|
sadness: 'Грусть',
|
||||||
|
dance: 'Танцы',
|
||||||
|
work: 'Работа',
|
||||||
|
romance: 'Романтика',
|
||||||
|
anger: 'Злость',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const songInfo = {
|
|||||||
duration: 'Длительность',
|
duration: 'Длительность',
|
||||||
track: 'Номер',
|
track: 'Номер',
|
||||||
bpm: 'BPM',
|
bpm: 'BPM',
|
||||||
|
mood: 'Настроение',
|
||||||
playCount: 'Количество прослушиваний',
|
playCount: 'Количество прослушиваний',
|
||||||
lastPlayed: 'Последнее воспроизведение',
|
lastPlayed: 'Последнее воспроизведение',
|
||||||
format: 'Формат',
|
format: 'Формат',
|
||||||
|
|||||||
@@ -43,4 +43,15 @@ export const queue = {
|
|||||||
sourceStream: '正在从网络流播放',
|
sourceStream: '正在从网络流播放',
|
||||||
clearCachedLoudnessWaveform: 'Clear cached loudness and waveform, then re-analyze this track',
|
clearCachedLoudnessWaveform: 'Clear cached loudness and waveform, then re-analyze this track',
|
||||||
recalculatingLoudnessWaveform: 'Recalculating loudness and waveform for this track…',
|
recalculatingLoudnessWaveform: 'Recalculating loudness and waveform for this track…',
|
||||||
|
bpm: '{{bpm}} BPM',
|
||||||
|
moods: {
|
||||||
|
happy: 'Happy',
|
||||||
|
excited: 'Excited',
|
||||||
|
calm: 'Calm',
|
||||||
|
peaceful: 'Peaceful',
|
||||||
|
angry: 'Angry',
|
||||||
|
tense: 'Tense',
|
||||||
|
sad: 'Sad',
|
||||||
|
melancholic: 'Melancholic',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export const search = {
|
|||||||
advancedYearFrom: '从',
|
advancedYearFrom: '从',
|
||||||
advancedYearTo: '至',
|
advancedYearTo: '至',
|
||||||
advancedBpm: 'BPM',
|
advancedBpm: 'BPM',
|
||||||
|
advancedBpmLocalNote: 'Uses tag BPM and measured analysis when the local index is enabled',
|
||||||
|
advancedBpmClear: '清除 BPM 筛选',
|
||||||
|
bpmSourceTag: 'BPM from file tag',
|
||||||
|
bpmSourceAnalysis: 'BPM from audio analysis',
|
||||||
|
advancedMoodGroup: 'Mood',
|
||||||
|
advancedAllMoods: 'All moods',
|
||||||
|
advancedMoodLocalNote: 'Requires local library index and track analysis',
|
||||||
advancedAll: '全部',
|
advancedAll: '全部',
|
||||||
advancedSearch: '搜索',
|
advancedSearch: '搜索',
|
||||||
advancedEmpty: '请输入搜索词或选择过滤器。',
|
advancedEmpty: '请输入搜索词或选择过滤器。',
|
||||||
@@ -48,4 +55,12 @@ export const search = {
|
|||||||
shareQueuePreviewLoading: 'Loading tracks…',
|
shareQueuePreviewLoading: 'Loading tracks…',
|
||||||
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.',
|
||||||
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.',
|
||||||
|
moodGroups: {
|
||||||
|
joy: 'Joy',
|
||||||
|
sadness: 'Sadness',
|
||||||
|
dance: 'Dance',
|
||||||
|
work: 'Work',
|
||||||
|
romance: 'Romance',
|
||||||
|
anger: 'Anger',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const songInfo = {
|
|||||||
duration: '时长',
|
duration: '时长',
|
||||||
track: '曲目',
|
track: '曲目',
|
||||||
bpm: 'BPM',
|
bpm: 'BPM',
|
||||||
|
mood: '情绪',
|
||||||
playCount: '播放次数',
|
playCount: '播放次数',
|
||||||
lastPlayed: '上次播放',
|
lastPlayed: '上次播放',
|
||||||
format: '格式',
|
format: '格式',
|
||||||
|
|||||||
+176
-10
@@ -4,7 +4,7 @@ import { getAlbumList, getRandomSongs } from '../api/subsonicLibrary';
|
|||||||
import type { SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes';
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { SlidersVertical } from 'lucide-react';
|
import { SlidersVertical, X } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import AlbumRow from '../components/AlbumRow';
|
import AlbumRow from '../components/AlbumRow';
|
||||||
import ArtistRow from '../components/ArtistRow';
|
import ArtistRow from '../components/ArtistRow';
|
||||||
@@ -14,9 +14,13 @@ import StarFilterButton from '../components/StarFilterButton';
|
|||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { runLocalAdvancedSearch, loadMoreLocalSongs, runNetworkAdvancedTextSearch } from '../utils/library/advancedSearchLocal';
|
import { runLocalAdvancedSearch, loadMoreLocalSongs, runNetworkAdvancedTextSearch } from '../utils/library/advancedSearchLocal';
|
||||||
|
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from '../utils/library/trackEnrichment';
|
||||||
import { raceSearchSources } from '../utils/library/searchRace';
|
import { raceSearchSources } from '../utils/library/searchRace';
|
||||||
import { logLibrarySearch } from '../utils/library/libraryDevLog';
|
import { logLibrarySearch } from '../utils/library/libraryDevLog';
|
||||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||||
|
import { MOOD_GROUP_IDS } from '../config/moodGroups';
|
||||||
|
|
||||||
|
const MOOD_UI_ENABLED = OXIMEDIA_MOOD_SEARCH_ENABLED;
|
||||||
|
|
||||||
type ResultType = 'all' | 'artists' | 'albums' | 'songs';
|
type ResultType = 'all' | 'artists' | 'albums' | 'songs';
|
||||||
|
|
||||||
@@ -25,6 +29,9 @@ interface SearchOpts {
|
|||||||
genre: string;
|
genre: string;
|
||||||
yearFrom: string;
|
yearFrom: string;
|
||||||
yearTo: string;
|
yearTo: string;
|
||||||
|
bpmFrom: string;
|
||||||
|
bpmTo: string;
|
||||||
|
moodGroup: string;
|
||||||
resultType: ResultType;
|
resultType: ResultType;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +41,13 @@ interface Results {
|
|||||||
songs: SubsonicSong[];
|
songs: SubsonicSong[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseBpmInput(raw: string): number | null {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const n = parseInt(trimmed, 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdvancedSearch() {
|
export default function AdvancedSearch() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
@@ -42,6 +56,9 @@ export default function AdvancedSearch() {
|
|||||||
const [genre, setGenre] = useState('');
|
const [genre, setGenre] = useState('');
|
||||||
const [yearFrom, setYearFrom] = useState('');
|
const [yearFrom, setYearFrom] = useState('');
|
||||||
const [yearTo, setYearTo] = useState('');
|
const [yearTo, setYearTo] = useState('');
|
||||||
|
const [bpmFrom, setBpmFrom] = useState('');
|
||||||
|
const [bpmTo, setBpmTo] = useState('');
|
||||||
|
const [moodGroup, setMoodGroup] = useState('');
|
||||||
const [resultType, setResultType] = useState<ResultType>('all');
|
const [resultType, setResultType] = useState<ResultType>('all');
|
||||||
const [starredOnly, setStarredOnly] = useState(false);
|
const [starredOnly, setStarredOnly] = useState(false);
|
||||||
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||||||
@@ -85,11 +102,15 @@ export default function AdvancedSearch() {
|
|||||||
g: string,
|
g: string,
|
||||||
from: number | null,
|
from: number | null,
|
||||||
to: number | null,
|
to: number | null,
|
||||||
|
bpmLo: number | null,
|
||||||
|
bpmHi: number | null,
|
||||||
): SubsonicSong[] => {
|
): SubsonicSong[] => {
|
||||||
let r = list;
|
let r = list;
|
||||||
if (g) r = r.filter(s => s.genre?.toLowerCase() === g.toLowerCase());
|
if (g) r = r.filter(s => s.genre?.toLowerCase() === g.toLowerCase());
|
||||||
if (from !== null) r = r.filter(s => !s.year || s.year >= from);
|
if (from !== null) r = r.filter(s => !s.year || s.year >= from);
|
||||||
if (to !== null) r = r.filter(s => !s.year || s.year <= to);
|
if (to !== null) r = r.filter(s => !s.year || s.year <= to);
|
||||||
|
if (bpmLo !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm >= bpmLo);
|
||||||
|
if (bpmHi !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm <= bpmHi);
|
||||||
return r;
|
return r;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,8 +127,12 @@ export default function AdvancedSearch() {
|
|||||||
|
|
||||||
const q = opts.query.trim();
|
const q = opts.query.trim();
|
||||||
const searchT0 = performance.now();
|
const searchT0 = performance.now();
|
||||||
|
const moodFilterActive = MOOD_UI_ENABLED && !!opts.moodGroup;
|
||||||
|
const bpmFilterActive = !!(opts.bpmFrom || opts.bpmTo);
|
||||||
|
const trackOnlyFilterActive = moodFilterActive || bpmFilterActive;
|
||||||
|
|
||||||
if (q && serverId && indexEnabled) {
|
// Track-only filters (BPM dual-storage, mood) need the local index for full coverage.
|
||||||
|
if (q && serverId && indexEnabled && !trackOnlyFilterActive) {
|
||||||
try {
|
try {
|
||||||
const winner = await raceSearchSources(
|
const winner = await raceSearchSources(
|
||||||
[
|
[
|
||||||
@@ -155,7 +180,7 @@ export default function AdvancedSearch() {
|
|||||||
if (isStale()) return;
|
if (isStale()) return;
|
||||||
}
|
}
|
||||||
setLocalMode(false);
|
setLocalMode(false);
|
||||||
} else {
|
} else if (serverId && indexEnabled) {
|
||||||
const localPage = await runLocalAdvancedSearch(serverId, opts, SONGS_INITIAL);
|
const localPage = await runLocalAdvancedSearch(serverId, opts, SONGS_INITIAL);
|
||||||
if (isStale()) return;
|
if (isStale()) return;
|
||||||
if (localPage) {
|
if (localPage) {
|
||||||
@@ -170,12 +195,27 @@ export default function AdvancedSearch() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (trackOnlyFilterActive) {
|
||||||
|
setResults({ artists: [], albums: [], songs: [] });
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocalMode(false);
|
||||||
|
} else {
|
||||||
setLocalMode(false);
|
setLocalMode(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { genre: g, yearFrom: yf, yearTo: yt, resultType: rt } = opts;
|
if (trackOnlyFilterActive && !indexEnabled) {
|
||||||
|
setResults({ artists: [], albums: [], songs: [] });
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { genre: g, yearFrom: yf, yearTo: yt, bpmFrom: bf, bpmTo: bt, resultType: rt } = opts;
|
||||||
const from = yf ? parseInt(yf) : null;
|
const from = yf ? parseInt(yf) : null;
|
||||||
const to = yt ? parseInt(yt) : null;
|
const to = yt ? parseInt(yt) : null;
|
||||||
|
const bpmLo = bf ? parseInt(bf) : null;
|
||||||
|
const bpmHi = bt ? parseInt(bt) : null;
|
||||||
|
|
||||||
let artists: SubsonicArtist[] = [];
|
let artists: SubsonicArtist[] = [];
|
||||||
let albums: SubsonicAlbum[] = [];
|
let albums: SubsonicAlbum[] = [];
|
||||||
@@ -186,7 +226,7 @@ export default function AdvancedSearch() {
|
|||||||
const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: SONGS_INITIAL });
|
const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: SONGS_INITIAL });
|
||||||
artists = r.artists;
|
artists = r.artists;
|
||||||
albums = r.albums;
|
albums = r.albums;
|
||||||
songs = applySongFilters(r.songs, g, from, to);
|
songs = applySongFilters(r.songs, g, from, to, bpmLo, bpmHi);
|
||||||
|
|
||||||
if (g) {
|
if (g) {
|
||||||
albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase());
|
albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase());
|
||||||
@@ -209,6 +249,7 @@ export default function AdvancedSearch() {
|
|||||||
]);
|
]);
|
||||||
albums = albumRes as SubsonicAlbum[];
|
albums = albumRes as SubsonicAlbum[];
|
||||||
songs = songRes as SubsonicSong[];
|
songs = songRes as SubsonicSong[];
|
||||||
|
songs = applySongFilters(songs, g, from, to, bpmLo, bpmHi);
|
||||||
if (from !== null) albums = albums.filter(a => !a.year || a.year >= from);
|
if (from !== null) albums = albums.filter(a => !a.year || a.year >= from);
|
||||||
if (to !== null) albums = albums.filter(a => !a.year || a.year <= to);
|
if (to !== null) albums = albums.filter(a => !a.year || a.year <= to);
|
||||||
if (songs.length > 0) setGenreNote(true);
|
if (songs.length > 0) setGenreNote(true);
|
||||||
@@ -250,7 +291,18 @@ export default function AdvancedSearch() {
|
|||||||
getGenres().then(data =>
|
getGenres().then(data =>
|
||||||
setGenres(data.sort((a, b) => a.value.localeCompare(b.value)))
|
setGenres(data.sort((a, b) => a.value.localeCompare(b.value)))
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
if (qFromUrl) runSearch({ query: qFromUrl, genre: '', yearFrom: '', yearTo: '', resultType: 'all' });
|
if (qFromUrl) {
|
||||||
|
runSearch({
|
||||||
|
query: qFromUrl,
|
||||||
|
genre: '',
|
||||||
|
yearFrom: '',
|
||||||
|
yearTo: '',
|
||||||
|
bpmFrom: '',
|
||||||
|
bpmTo: '',
|
||||||
|
moodGroup: '',
|
||||||
|
resultType: 'all',
|
||||||
|
});
|
||||||
|
}
|
||||||
}, [musicLibraryFilterVersion, qFromUrl]);
|
}, [musicLibraryFilterVersion, qFromUrl]);
|
||||||
|
|
||||||
const loadMoreSongs = useCallback(async () => {
|
const loadMoreSongs = useCallback(async () => {
|
||||||
@@ -280,8 +332,10 @@ export default function AdvancedSearch() {
|
|||||||
const g = activeSearch.genre;
|
const g = activeSearch.genre;
|
||||||
const from = activeSearch.yearFrom ? parseInt(activeSearch.yearFrom) : null;
|
const from = activeSearch.yearFrom ? parseInt(activeSearch.yearFrom) : null;
|
||||||
const to = activeSearch.yearTo ? parseInt(activeSearch.yearTo) : null;
|
const to = activeSearch.yearTo ? parseInt(activeSearch.yearTo) : null;
|
||||||
|
const bpmLo = activeSearch.bpmFrom ? parseInt(activeSearch.bpmFrom) : null;
|
||||||
|
const bpmHi = activeSearch.bpmTo ? parseInt(activeSearch.bpmTo) : null;
|
||||||
const page = await searchSongsPaged(q, SONGS_PAGE_SIZE, songsServerOffset);
|
const page = await searchSongsPaged(q, SONGS_PAGE_SIZE, songsServerOffset);
|
||||||
const filtered = applySongFilters(page, g, from, to);
|
const filtered = applySongFilters(page, g, from, to, bpmLo, bpmHi);
|
||||||
setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...filtered] } : prev);
|
setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...filtered] } : prev);
|
||||||
setSongsServerOffset(o => o + page.length);
|
setSongsServerOffset(o => o + page.length);
|
||||||
// No more pages when the server returned a non-full page (regardless of how many survived filtering).
|
// No more pages when the server returned a non-full page (regardless of how many survived filtering).
|
||||||
@@ -293,9 +347,29 @@ export default function AdvancedSearch() {
|
|||||||
}
|
}
|
||||||
}, [loadingMoreSongs, songsHasMore, activeSearch, songsServerOffset, localMode, serverId]);
|
}, [loadingMoreSongs, songsHasMore, activeSearch, songsServerOffset, localMode, serverId]);
|
||||||
|
|
||||||
|
const trackFilterActive =
|
||||||
|
(MOOD_UI_ENABLED && !!moodGroup) || !!(bpmFrom || bpmTo);
|
||||||
|
|
||||||
|
const bpmFilterDraftActive = !!(bpmFrom || bpmTo);
|
||||||
|
|
||||||
|
const clearBpmFilter = () => {
|
||||||
|
setBpmFrom('');
|
||||||
|
setBpmTo('');
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = (e?: React.FormEvent) => {
|
const handleSubmit = (e?: React.FormEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
runSearch({ query, genre, yearFrom, yearTo, resultType });
|
const effectiveType = trackFilterActive ? 'songs' : resultType;
|
||||||
|
runSearch({
|
||||||
|
query,
|
||||||
|
genre,
|
||||||
|
yearFrom,
|
||||||
|
yearTo,
|
||||||
|
bpmFrom,
|
||||||
|
bpmTo,
|
||||||
|
moodGroup,
|
||||||
|
resultType: effectiveType,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeOptions: { id: ResultType; label: string }[] = [
|
const typeOptions: { id: ResultType; label: string }[] = [
|
||||||
@@ -310,6 +384,17 @@ export default function AdvancedSearch() {
|
|||||||
...genres.map(g => ({ value: g.value, label: g.value })),
|
...genres.map(g => ({ value: g.value, label: g.value })),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const moodSelectOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: '', label: t('search.advancedAllMoods') },
|
||||||
|
...MOOD_GROUP_IDS.map(id => ({
|
||||||
|
value: id,
|
||||||
|
label: t(`search.moodGroups.${id}`),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-body animate-fade-in">
|
<div className="content-body animate-fade-in">
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
@@ -379,10 +464,90 @@ export default function AdvancedSearch() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 3: Result type + Search button */}
|
{/* Row 3: BPM (tag + measured enrichment via local index) */}
|
||||||
|
{indexEnabled && (
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
|
||||||
|
{t('search.advancedBpm')}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min={20}
|
||||||
|
max={999}
|
||||||
|
value={bpmFrom}
|
||||||
|
onChange={e => setBpmFrom(e.target.value)}
|
||||||
|
onBlur={e => {
|
||||||
|
const from = parseBpmInput(e.target.value);
|
||||||
|
const to = parseBpmInput(bpmTo);
|
||||||
|
if (from != null && to != null && from > to) setBpmTo('');
|
||||||
|
}}
|
||||||
|
placeholder={t('search.advancedYearFrom')}
|
||||||
|
style={{ width: 96 }}
|
||||||
|
/>
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}>–</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min={20}
|
||||||
|
max={999}
|
||||||
|
value={bpmTo}
|
||||||
|
onChange={e => setBpmTo(e.target.value)}
|
||||||
|
onBlur={e => {
|
||||||
|
const to = parseBpmInput(e.target.value);
|
||||||
|
const from = parseBpmInput(bpmFrom);
|
||||||
|
if (from != null && to != null && to < from) setBpmFrom('');
|
||||||
|
}}
|
||||||
|
placeholder={t('search.advancedYearTo')}
|
||||||
|
style={{ width: 96 }}
|
||||||
|
/>
|
||||||
|
{bpmFilterDraftActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={clearBpmFilter}
|
||||||
|
style={{
|
||||||
|
padding: '0.3rem 0.55rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.3rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
{t('search.advancedBpmClear')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
{t('search.advancedBpmLocalNote')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mood — hidden while oximedia mood analysis is disabled */}
|
||||||
|
{indexEnabled && MOOD_UI_ENABLED && (
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
|
||||||
|
{t('search.advancedMoodGroup')}
|
||||||
|
</span>
|
||||||
|
<div style={{ minWidth: 240, flex: '1 1 240px', maxWidth: 360 }}>
|
||||||
|
<CustomSelect
|
||||||
|
value={moodGroup}
|
||||||
|
options={moodSelectOptions}
|
||||||
|
onChange={setMoodGroup}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
{t('search.advancedMoodLocalNote')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 4: Result type + Search button */}
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||||
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
{typeOptions.map(opt => (
|
{!trackFilterActive && typeOptions.map(opt => (
|
||||||
<button
|
<button
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -454,6 +619,7 @@ export default function AdvancedSearch() {
|
|||||||
hasMore={songsHasMore}
|
hasMore={songsHasMore}
|
||||||
loadingMore={loadingMoreSongs}
|
loadingMore={loadingMoreSongs}
|
||||||
onLoadMore={loadMoreSongs}
|
onLoadMore={loadMoreSongs}
|
||||||
|
showBpm={!!(activeSearch?.bpmFrom || activeSearch?.bpmTo)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -232,8 +232,16 @@ export function handleAudioProgress(
|
|||||||
shouldBytePreloadFromMode ||
|
shouldBytePreloadFromMode ||
|
||||||
shouldBytePreloadForCrossfade
|
shouldBytePreloadForCrossfade
|
||||||
);
|
);
|
||||||
|
// Hot/offline cache: seed enrichment from disk (playback also uses psysonic-local://).
|
||||||
|
const shouldPreloadLocalFileAnalysis = preloadMode !== 'off' && (
|
||||||
|
preloadMode === 'early'
|
||||||
|
? current_time >= 5
|
||||||
|
: preloadMode === 'custom'
|
||||||
|
? remaining < preloadCustomSeconds && remaining > 0
|
||||||
|
: remaining < 30 && remaining > 0
|
||||||
|
);
|
||||||
|
|
||||||
if (shouldChainGapless || shouldBytePreload || gaplessEnabled) {
|
if (shouldChainGapless || shouldBytePreload || shouldPreloadLocalFileAnalysis || gaplessEnabled) {
|
||||||
const { queue, queueIndex, repeatMode } = store;
|
const { queue, queueIndex, repeatMode } = store;
|
||||||
const nextIdx = queueIndex + 1;
|
const nextIdx = queueIndex + 1;
|
||||||
const nextTrack = repeatMode === 'one'
|
const nextTrack = repeatMode === 'one'
|
||||||
@@ -260,9 +268,13 @@ export function handleAudioProgress(
|
|||||||
|
|
||||||
const serverId = getPlaybackServerId();
|
const serverId = getPlaybackServerId();
|
||||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||||
|
const nextIsLocalFile = nextUrl.startsWith('psysonic-local://');
|
||||||
|
|
||||||
// Byte pre-download — runs early so bytes are cached by chain time.
|
// Byte pre-download — runs early so bytes are cached by chain time.
|
||||||
if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== getBytePreloadingId()) {
|
if (
|
||||||
|
(shouldBytePreload || shouldBytePreloadForGaplessBackup || (shouldPreloadLocalFileAnalysis && nextIsLocalFile))
|
||||||
|
&& nextTrack.id !== getBytePreloadingId()
|
||||||
|
) {
|
||||||
setBytePreloadingId(nextTrack.id);
|
setBytePreloadingId(nextTrack.id);
|
||||||
// Loudness cache only — do not call refreshWaveformForTrack(next): it writes global
|
// Loudness cache only — do not call refreshWaveformForTrack(next): it writes global
|
||||||
// waveformBins and would replace the current track's seekbar while still playing it.
|
// waveformBins and would replace the current track's seekbar while still playing it.
|
||||||
@@ -273,6 +285,8 @@ export function handleAudioProgress(
|
|||||||
nextUrl,
|
nextUrl,
|
||||||
shouldBytePreload,
|
shouldBytePreload,
|
||||||
shouldBytePreloadForGaplessBackup,
|
shouldBytePreloadForGaplessBackup,
|
||||||
|
shouldPreloadLocalFileAnalysis,
|
||||||
|
nextIsLocalFile,
|
||||||
remaining,
|
remaining,
|
||||||
gaplessEnabled,
|
gaplessEnabled,
|
||||||
});
|
});
|
||||||
@@ -314,6 +328,7 @@ export function handleAudioProgress(
|
|||||||
fallbackDb: authState.replayGainFallbackDb,
|
fallbackDb: authState.replayGainFallbackDb,
|
||||||
hiResEnabled: authState.enableHiRes,
|
hiResEnabled: authState.enableHiRes,
|
||||||
analysisTrackId: nextTrack.id,
|
analysisTrackId: nextTrack.id,
|
||||||
|
serverId: serverId || null,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ import {
|
|||||||
import { usePlayerStore } from '../playerStore';
|
import { usePlayerStore } from '../playerStore';
|
||||||
import { refreshWaveformForTrack } from '../waveformRefresh';
|
import { refreshWaveformForTrack } from '../waveformRefresh';
|
||||||
import { bumpWaveformRefreshGen } from '../waveformRefreshGen';
|
import { bumpWaveformRefreshGen } from '../waveformRefreshGen';
|
||||||
|
import { setBytePreloadingId } from '../gaplessPreloadState';
|
||||||
|
|
||||||
|
type PreloadEventPayload = {
|
||||||
|
url: string;
|
||||||
|
trackId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tauri event listeners for the Rust audio engine + analysis pipeline. Returns
|
* Tauri event listeners for the Rust audio engine + analysis pipeline. Returns
|
||||||
@@ -95,6 +101,13 @@ export function setupAudioEngineListeners(): () => void {
|
|||||||
void refreshLoudnessForTrack(payloadTrackId, { syncPlayingEngine: false });
|
void refreshLoudnessForTrack(payloadTrackId, { syncPlayingEngine: false });
|
||||||
emitNormalizationDebug('backfill:applied', { trackId: payloadTrackId });
|
emitNormalizationDebug('backfill:applied', { trackId: payloadTrackId });
|
||||||
}),
|
}),
|
||||||
|
listen<{ trackId: string; serverId: string }>('analysis:enrichment-updated', ({ payload }) => {
|
||||||
|
if (!payload?.trackId) return;
|
||||||
|
emitNormalizationDebug('enrichment:applied', {
|
||||||
|
trackId: normalizeAnalysisTrackId(payload.trackId) ?? payload.trackId,
|
||||||
|
serverId: payload.serverId,
|
||||||
|
});
|
||||||
|
}),
|
||||||
listen<NormalizationStatePayload>('audio:normalization-state', ({ payload }) => {
|
listen<NormalizationStatePayload>('audio:normalization-state', ({ payload }) => {
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
const engine =
|
const engine =
|
||||||
@@ -140,8 +153,8 @@ export function setupAudioEngineListeners(): () => void {
|
|||||||
normalizationDbgLastEventAt: Date.now(),
|
normalizationDbgLastEventAt: Date.now(),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
listen<string>('audio:preload-ready', ({ payload }) => {
|
listen<PreloadEventPayload>('audio:preload-ready', ({ payload }) => {
|
||||||
const tid = streamUrlTrackId(payload);
|
const tid = payload.trackId ?? streamUrlTrackId(payload.url);
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.info('[psysonic][preload-ready]', {
|
console.info('[psysonic][preload-ready]', {
|
||||||
payload,
|
payload,
|
||||||
@@ -154,6 +167,12 @@ export function setupAudioEngineListeners(): () => void {
|
|||||||
console.warn('[psysonic][preload-ready] could not parse track id from payload URL');
|
console.warn('[psysonic][preload-ready] could not parse track id from payload URL');
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
listen<PreloadEventPayload>('audio:preload-cancelled', ({ payload }) => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.info('[psysonic][preload-cancelled]', payload);
|
||||||
|
}
|
||||||
|
setBytePreloadingId(null);
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -209,6 +209,12 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.queue-current-enrichment {
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.queue-current-tech {
|
.queue-current-tech {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
|
|||||||
@@ -21,6 +21,10 @@
|
|||||||
grid-template-columns: 64px minmax(0, 1.5fr) minmax(0, 1fr) 56px;
|
grid-template-columns: 64px minmax(0, 1.5fr) minmax(0, 1fr) 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.song-list-row--with-bpm {
|
||||||
|
grid-template-columns: 64px minmax(0, 1.5fr) minmax(0, 1fr) 48px 56px;
|
||||||
|
}
|
||||||
|
|
||||||
.song-list-row-cell:nth-child(4), /* album */
|
.song-list-row-cell:nth-child(4), /* album */
|
||||||
.song-list-row-cell:nth-child(5) { /* genre */
|
.song-list-row-cell:nth-child(5) { /* genre */
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -74,6 +74,17 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.song-list-row--with-bpm {
|
||||||
|
grid-template-columns: 64px minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, 1fr) 110px 48px 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-list-row-bpm {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.song-list-row-btn {
|
.song-list-row-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
|||||||
import { onInvoke } from '@/test/mocks/tauri';
|
import { onInvoke } from '@/test/mocks/tauri';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||||
import { runLocalAdvancedSearch, runLocalSongBrowse } from './advancedSearchLocal';
|
import { runLocalAdvancedSearch, runLocalSongBrowse, trackToSong } from './advancedSearchLocal';
|
||||||
|
|
||||||
const opts = (over: Partial<Parameters<typeof runLocalAdvancedSearch>[1]> = {}) => ({
|
const opts = (over: Partial<Parameters<typeof runLocalAdvancedSearch>[1]> = {}) => ({
|
||||||
query: '',
|
query: '',
|
||||||
genre: '',
|
genre: '',
|
||||||
yearFrom: '',
|
yearFrom: '',
|
||||||
yearTo: '',
|
yearTo: '',
|
||||||
|
bpmFrom: '',
|
||||||
|
bpmTo: '',
|
||||||
|
moodGroup: '',
|
||||||
resultType: 'all' as const,
|
resultType: 'all' as const,
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
@@ -58,6 +61,41 @@ describe('runLocalAdvancedSearch', () => {
|
|||||||
expect(captured).toMatchObject({ request: { libraryScope: 'lib7' } });
|
expect(captured).toMatchObject({ request: { libraryScope: 'lib7' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes bpm between filter to library_advanced_search', async () => {
|
||||||
|
ready();
|
||||||
|
let captured: unknown;
|
||||||
|
onInvoke('library_advanced_search', (args) => {
|
||||||
|
captured = args;
|
||||||
|
return {
|
||||||
|
artists: [],
|
||||||
|
albums: [],
|
||||||
|
tracks: [],
|
||||||
|
totals: { artists: 0, albums: 0, tracks: 0 },
|
||||||
|
source: 'local',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await runLocalAdvancedSearch('s1', opts({ bpmFrom: '120', bpmTo: '130' }), 100);
|
||||||
|
expect(captured).toMatchObject({
|
||||||
|
request: { filters: [{ field: 'bpm', op: 'between', value: 120, valueTo: 130 }] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trackToSong keeps resolved bpm and source over rawJson tag', () => {
|
||||||
|
const song = trackToSong({
|
||||||
|
serverId: 's1',
|
||||||
|
id: 't1',
|
||||||
|
title: 'T',
|
||||||
|
album: 'Alb',
|
||||||
|
durationSec: 100,
|
||||||
|
syncedAt: 0,
|
||||||
|
bpm: 128,
|
||||||
|
bpmSource: 'analysis',
|
||||||
|
rawJson: { id: 't1', title: 'T', artist: 'A', album: 'Alb', albumId: 'al1', duration: 100, bpm: 90 },
|
||||||
|
});
|
||||||
|
expect(song.bpm).toBe(128);
|
||||||
|
expect(song.localBpmSource).toBe('analysis');
|
||||||
|
});
|
||||||
|
|
||||||
it('prefers rawJson, falls back to hot columns, and reports the full total', async () => {
|
it('prefers rawJson, falls back to hot columns, and reports the full total', async () => {
|
||||||
ready();
|
ready();
|
||||||
onInvoke('library_advanced_search', () => ({
|
onInvoke('library_advanced_search', () => ({
|
||||||
|
|||||||
@@ -25,15 +25,19 @@ import { search } from '../../api/subsonicSearch';
|
|||||||
import { libraryScopeForServer } from '../../api/subsonicClient';
|
import { libraryScopeForServer } from '../../api/subsonicClient';
|
||||||
import { libraryIsReady } from './libraryReady';
|
import { libraryIsReady } from './libraryReady';
|
||||||
import { logLibrarySearch, timed } from './libraryDevLog';
|
import { logLibrarySearch, timed } from './libraryDevLog';
|
||||||
|
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from './trackEnrichment';
|
||||||
|
|
||||||
export type AdvancedResultType = 'all' | 'artists' | 'albums' | 'songs';
|
export type AdvancedResultType = 'all' | 'artists' | 'albums' | 'songs';
|
||||||
|
|
||||||
/** UI opts for Advanced Search — BPM filter hidden until enrichment ships. */
|
/** UI opts for Advanced Search — BPM/mood filters require local index. */
|
||||||
export interface LocalSearchOpts {
|
export interface LocalSearchOpts {
|
||||||
query: string;
|
query: string;
|
||||||
genre: string;
|
genre: string;
|
||||||
yearFrom: string;
|
yearFrom: string;
|
||||||
yearTo: string;
|
yearTo: string;
|
||||||
|
bpmFrom: string;
|
||||||
|
bpmTo: string;
|
||||||
|
moodGroup: string;
|
||||||
resultType: AdvancedResultType;
|
resultType: AdvancedResultType;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +77,39 @@ function buildFilters(opts: LocalSearchOpts): LibraryFilterClause[] {
|
|||||||
} else if (to !== null) {
|
} else if (to !== null) {
|
||||||
filters.push({ field: 'year', op: 'lte', value: to });
|
filters.push({ field: 'year', op: 'lte', value: to });
|
||||||
}
|
}
|
||||||
|
const bpmFrom = opts.bpmFrom ? parseInt(opts.bpmFrom, 10) : null;
|
||||||
|
const bpmTo = opts.bpmTo ? parseInt(opts.bpmTo, 10) : null;
|
||||||
|
if (bpmFrom !== null && bpmTo !== null) {
|
||||||
|
filters.push({ field: 'bpm', op: 'between', value: bpmFrom, valueTo: bpmTo });
|
||||||
|
} else if (bpmFrom !== null) {
|
||||||
|
filters.push({ field: 'bpm', op: 'gte', value: bpmFrom });
|
||||||
|
} else if (bpmTo !== null) {
|
||||||
|
filters.push({ field: 'bpm', op: 'lte', value: bpmTo });
|
||||||
|
}
|
||||||
|
if (OXIMEDIA_MOOD_SEARCH_ENABLED && opts.moodGroup) {
|
||||||
|
filters.push({ field: 'mood_group', op: 'eq', value: opts.moodGroup });
|
||||||
|
}
|
||||||
return filters;
|
return filters;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyClientSongFilters(
|
||||||
|
list: SubsonicSong[],
|
||||||
|
opts: LocalSearchOpts,
|
||||||
|
): SubsonicSong[] {
|
||||||
|
let r = list;
|
||||||
|
const g = opts.genre;
|
||||||
|
const from = opts.yearFrom ? parseInt(opts.yearFrom, 10) : null;
|
||||||
|
const to = opts.yearTo ? parseInt(opts.yearTo, 10) : null;
|
||||||
|
const bpmFrom = opts.bpmFrom ? parseInt(opts.bpmFrom, 10) : null;
|
||||||
|
const bpmTo = opts.bpmTo ? parseInt(opts.bpmTo, 10) : null;
|
||||||
|
if (g) r = r.filter(s => s.genre?.toLowerCase() === g.toLowerCase());
|
||||||
|
if (from !== null) r = r.filter(s => !s.year || s.year >= from);
|
||||||
|
if (to !== null) r = r.filter(s => !s.year || s.year <= to);
|
||||||
|
if (bpmFrom !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm >= bpmFrom);
|
||||||
|
if (bpmTo !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm <= bpmTo);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
function buildRequest(
|
function buildRequest(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
opts: LocalSearchOpts,
|
opts: LocalSearchOpts,
|
||||||
@@ -100,6 +134,7 @@ function buildRequest(
|
|||||||
|
|
||||||
export function trackToSong(t: LibraryTrackDto): SubsonicSong {
|
export function trackToSong(t: LibraryTrackDto): SubsonicSong {
|
||||||
const raw = isObject(t.rawJson) ? t.rawJson : {};
|
const raw = isObject(t.rawJson) ? t.rawJson : {};
|
||||||
|
const resolvedBpm = t.bpm != null && t.bpm > 0 ? t.bpm : undefined;
|
||||||
const base: SubsonicSong = {
|
const base: SubsonicSong = {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
@@ -119,13 +154,18 @@ export function trackToSong(t: LibraryTrackDto): SubsonicSong {
|
|||||||
starred: t.starredAt != null ? new Date(t.starredAt).toISOString() : undefined,
|
starred: t.starredAt != null ? new Date(t.starredAt).toISOString() : undefined,
|
||||||
userRating: t.userRating ?? undefined,
|
userRating: t.userRating ?? undefined,
|
||||||
playCount: t.playCount ?? undefined,
|
playCount: t.playCount ?? undefined,
|
||||||
bpm: t.bpm ?? undefined,
|
bpm: resolvedBpm,
|
||||||
isrc: t.isrc ?? undefined,
|
isrc: t.isrc ?? undefined,
|
||||||
albumArtist: t.albumArtist ?? undefined,
|
albumArtist: t.albumArtist ?? undefined,
|
||||||
};
|
};
|
||||||
// `rawJson` is the authoritative original song — let it override the
|
// `rawJson` is the authoritative original song — let it override the
|
||||||
// hot-column fallbacks (it carries OpenSubsonic extras too).
|
// hot-column fallbacks (it carries OpenSubsonic extras too).
|
||||||
return { ...base, ...(raw as Partial<SubsonicSong>) };
|
const merged: SubsonicSong = { ...base, ...(raw as Partial<SubsonicSong>) };
|
||||||
|
if (resolvedBpm != null) merged.bpm = resolvedBpm;
|
||||||
|
if (t.bpmSource === 'analysis' || t.bpmSource === 'tag') {
|
||||||
|
merged.localBpmSource = t.bpmSource;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function albumToAlbum(a: LibraryAlbumDto): SubsonicAlbum {
|
export function albumToAlbum(a: LibraryAlbumDto): SubsonicAlbum {
|
||||||
@@ -165,9 +205,6 @@ export async function runNetworkAdvancedTextSearch(
|
|||||||
): Promise<LocalAdvancedSearchPage | null> {
|
): Promise<LocalAdvancedSearchPage | null> {
|
||||||
const q = opts.query.trim();
|
const q = opts.query.trim();
|
||||||
if (!q) return null;
|
if (!q) return null;
|
||||||
const g = opts.genre;
|
|
||||||
const from = opts.yearFrom ? parseInt(opts.yearFrom, 10) : null;
|
|
||||||
const to = opts.yearTo ? parseInt(opts.yearTo, 10) : null;
|
|
||||||
const rt = opts.resultType;
|
const rt = opts.resultType;
|
||||||
|
|
||||||
const r = await search(q, {
|
const r = await search(q, {
|
||||||
@@ -178,12 +215,11 @@ export async function runNetworkAdvancedTextSearch(
|
|||||||
|
|
||||||
let artists = r.artists;
|
let artists = r.artists;
|
||||||
let albums = r.albums;
|
let albums = r.albums;
|
||||||
let songs = r.songs;
|
let songs = applyClientSongFilters(r.songs, opts);
|
||||||
|
|
||||||
if (g) songs = songs.filter(s => s.genre?.toLowerCase() === g.toLowerCase());
|
|
||||||
if (from !== null) songs = songs.filter(s => !s.year || s.year >= from);
|
|
||||||
if (to !== null) songs = songs.filter(s => !s.year || s.year <= to);
|
|
||||||
|
|
||||||
|
const g = opts.genre;
|
||||||
|
const from = opts.yearFrom ? parseInt(opts.yearFrom, 10) : null;
|
||||||
|
const to = opts.yearTo ? parseInt(opts.yearTo, 10) : null;
|
||||||
if (g) albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase());
|
if (g) albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase());
|
||||||
if (from !== null) albums = albums.filter(a => !a.year || a.year >= from);
|
if (from !== null) albums = albums.filter(a => !a.year || a.year >= from);
|
||||||
if (to !== null) albums = albums.filter(a => !a.year || a.year <= to);
|
if (to !== null) albums = albums.filter(a => !a.year || a.year <= to);
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ const emptyBrowseOpts = (query: string): LocalSearchOpts => ({
|
|||||||
genre: '',
|
genre: '',
|
||||||
yearFrom: '',
|
yearFrom: '',
|
||||||
yearTo: '',
|
yearTo: '',
|
||||||
|
bpmFrom: '',
|
||||||
|
bpmTo: '',
|
||||||
|
moodGroup: '',
|
||||||
resultType: 'artists',
|
resultType: 'artists',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,6 +187,9 @@ const songBrowseOpts = (query: string): LocalSearchOpts => ({
|
|||||||
genre: '',
|
genre: '',
|
||||||
yearFrom: '',
|
yearFrom: '',
|
||||||
yearTo: '',
|
yearTo: '',
|
||||||
|
bpmFrom: '',
|
||||||
|
bpmTo: '',
|
||||||
|
moodGroup: '',
|
||||||
resultType: 'songs',
|
resultType: 'songs',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,6 +198,9 @@ const fullSearchOpts = (query: string): LocalSearchOpts => ({
|
|||||||
genre: '',
|
genre: '',
|
||||||
yearFrom: '',
|
yearFrom: '',
|
||||||
yearTo: '',
|
yearTo: '',
|
||||||
|
bpmFrom: '',
|
||||||
|
bpmTo: '',
|
||||||
|
moodGroup: '',
|
||||||
resultType: 'all',
|
resultType: 'all',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import {
|
||||||
|
deriveMoodScores,
|
||||||
|
formatQueueBpmTech,
|
||||||
|
formatQueueMoodLabels,
|
||||||
|
parseTrackEnrichmentFacts,
|
||||||
|
resolveQueueBpm,
|
||||||
|
resolveDisplayBpm,
|
||||||
|
topMoodLabelIds,
|
||||||
|
} from './trackEnrichment';
|
||||||
|
import { topDistinctOximediaMoodTagIds, topOximediaMoodTagIds } from '../../config/moodGroups';
|
||||||
|
|
||||||
|
const t = ((key: string, opts?: Record<string, unknown>) => {
|
||||||
|
if (key === 'queue.bpm') return `${opts?.bpm} BPM`;
|
||||||
|
if (key === 'queue.moods.calm') return 'Calm';
|
||||||
|
if (key === 'queue.moods.peaceful') return 'Peaceful';
|
||||||
|
return key;
|
||||||
|
}) as TFunction;
|
||||||
|
|
||||||
|
describe('parseTrackEnrichmentFacts', () => {
|
||||||
|
it('does not surface oximedia mood labels in UI while detector is disabled', () => {
|
||||||
|
const parsed = parseTrackEnrichmentFacts(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
serverId: 's1',
|
||||||
|
trackId: 't1',
|
||||||
|
factKind: 'moods',
|
||||||
|
sourceKind: 'analysis',
|
||||||
|
sourceId: 'oximedia-60s-center',
|
||||||
|
valueText: '{"calm":0.6,"peaceful":0.4}',
|
||||||
|
confidence: 0.9,
|
||||||
|
fetchedAt: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: 's1',
|
||||||
|
trackId: 't1',
|
||||||
|
factKind: 'valence',
|
||||||
|
sourceKind: 'analysis',
|
||||||
|
sourceId: 'oximedia-60s-center',
|
||||||
|
valueReal: 0.55,
|
||||||
|
confidence: 0.9,
|
||||||
|
fetchedAt: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: 's1',
|
||||||
|
trackId: 't1',
|
||||||
|
factKind: 'arousal',
|
||||||
|
sourceKind: 'analysis',
|
||||||
|
sourceId: 'oximedia-60s-center',
|
||||||
|
valueReal: 0.42,
|
||||||
|
confidence: 0.9,
|
||||||
|
fetchedAt: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(parsed.moodLabels).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads analysis bpm fact with highest confidence', () => {
|
||||||
|
const parsed = parseTrackEnrichmentFacts(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
serverId: 's1',
|
||||||
|
trackId: 't1',
|
||||||
|
factKind: 'bpm',
|
||||||
|
sourceKind: 'analysis',
|
||||||
|
sourceId: 'oximedia-60s-center',
|
||||||
|
valueInt: 128,
|
||||||
|
confidence: 0.9,
|
||||||
|
fetchedAt: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: 's1',
|
||||||
|
trackId: 't1',
|
||||||
|
factKind: 'bpm',
|
||||||
|
sourceKind: 'analysis',
|
||||||
|
sourceId: 'other',
|
||||||
|
valueInt: 110,
|
||||||
|
confidence: 0.5,
|
||||||
|
fetchedAt: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
120,
|
||||||
|
);
|
||||||
|
expect(parsed.measuredBpm).toBe(128);
|
||||||
|
expect(parsed.serverBpm).toBe(120);
|
||||||
|
expect(resolveQueueBpm(parsed)).toBe(128);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveQueueBpm', () => {
|
||||||
|
it('prefers measured analysis bpm over tag', () => {
|
||||||
|
expect(resolveQueueBpm({ serverBpm: 120, measuredBpm: 128, moodLabels: [] })).toBe(128);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to tag bpm when no analysis fact', () => {
|
||||||
|
expect(resolveQueueBpm({ serverBpm: 120, measuredBpm: null, moodLabels: [] })).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to measured when tag bpm missing', () => {
|
||||||
|
expect(resolveQueueBpm({ serverBpm: null, measuredBpm: 128, moodLabels: [] })).toBe(128);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveDisplayBpm', () => {
|
||||||
|
it('ignores zero tag bpm and uses measured', () => {
|
||||||
|
expect(resolveDisplayBpm(0, 132)).toBe(132);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatters', () => {
|
||||||
|
it('formats bpm for tech row', () => {
|
||||||
|
expect(formatQueueBpmTech({ serverBpm: 120, measuredBpm: 128, moodLabels: [] }, t)).toBe('128 BPM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('localizes mood labels without weights', () => {
|
||||||
|
expect(formatQueueMoodLabels(['calm', 'peaceful'], t)).toBe('Calm · Peaceful');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('topMoodLabelIds', () => {
|
||||||
|
it('returns at most two distinct cluster labels from valence/arousal', () => {
|
||||||
|
const labels = topMoodLabelIds(0.4, 0.75);
|
||||||
|
expect(labels.includes('happy') && labels.includes('excited')).toBe(false);
|
||||||
|
expect(labels.length).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('distinct mood tag picking', () => {
|
||||||
|
it('never keeps both happy and excited from raw scores', () => {
|
||||||
|
expect(topDistinctOximediaMoodTagIds({ calm: 0.52, happy: 0.9, excited: 0.5 })).toEqual(['happy', 'calm']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by score descending with id tie-break', () => {
|
||||||
|
expect(topOximediaMoodTagIds({ calm: 0.2, happy: 0.9, excited: 0.5 })).toEqual([
|
||||||
|
'happy',
|
||||||
|
'excited',
|
||||||
|
'calm',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deriveMoodScores', () => {
|
||||||
|
it('delegates to soft valence/arousal scoring', () => {
|
||||||
|
const scores = deriveMoodScores(0.55, 0.42);
|
||||||
|
expect(topDistinctOximediaMoodTagIds(scores, 2).some(id => id === 'calm' || id === 'peaceful')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { TrackFactDto } from '../../api/library';
|
||||||
|
import {
|
||||||
|
distinctOximediaMoodTagIds,
|
||||||
|
topDistinctOximediaMoodTagIds,
|
||||||
|
topDistinctOximediaMoodTagIdsFromValenceArousal,
|
||||||
|
moodScoresFromValenceArousal,
|
||||||
|
} from '../../config/moodGroups';
|
||||||
|
|
||||||
|
/** Oximedia mood labels in queue/Song Info — off until a reliable model ships. */
|
||||||
|
export const OXIMEDIA_MOOD_UI_ENABLED = false;
|
||||||
|
/** Mood group filter in Advanced Search — off while oximedia mood is disabled. */
|
||||||
|
export const OXIMEDIA_MOOD_SEARCH_ENABLED = false;
|
||||||
|
export const OXIMEDIA_ENRICHMENT_SOURCE_KIND = 'analysis';
|
||||||
|
export const OXIMEDIA_ENRICHMENT_SOURCE_ID = 'oximedia-60s-center';
|
||||||
|
|
||||||
|
/** Oximedia mood label ids — see `src/config/moodGroups.ts` and Rust `mood_groups`. */
|
||||||
|
export type { OximediaMoodTagId } from '../../config/moodGroups';
|
||||||
|
|
||||||
|
export interface ParsedTrackEnrichment {
|
||||||
|
serverBpm: number | null;
|
||||||
|
measuredBpm: number | null;
|
||||||
|
moodLabels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOximediaFact(f: TrackFactDto): boolean {
|
||||||
|
return (
|
||||||
|
f.sourceKind === OXIMEDIA_ENRICHMENT_SOURCE_KIND
|
||||||
|
&& (f.sourceId === OXIMEDIA_ENRICHMENT_SOURCE_ID
|
||||||
|
|| f.sourceId.startsWith(`${OXIMEDIA_ENRICHMENT_SOURCE_ID}:`))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBestAnalysisBpmFact(facts: readonly TrackFactDto[]): TrackFactDto | undefined {
|
||||||
|
let best: TrackFactDto | undefined;
|
||||||
|
for (const f of facts) {
|
||||||
|
if (f.factKind !== 'bpm') continue;
|
||||||
|
if (f.sourceKind !== 'analysis') continue;
|
||||||
|
if (f.valueInt == null || f.valueInt <= 0) continue;
|
||||||
|
if (!best || (f.confidence ?? 0) > (best.confidence ?? 0)) best = f;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTrackEnrichmentFacts(
|
||||||
|
facts: readonly TrackFactDto[],
|
||||||
|
serverBpm: number | null | undefined,
|
||||||
|
): ParsedTrackEnrichment {
|
||||||
|
const oximedia = facts.filter(isOximediaFact);
|
||||||
|
const measured = pickBestAnalysisBpmFact(facts);
|
||||||
|
const moodsFact = oximedia.find(f => f.factKind === 'moods' && f.valueText);
|
||||||
|
const legacyLabelsFact = oximedia.find(f => f.factKind === 'mood_labels' && f.valueText);
|
||||||
|
const moodTagFacts = facts.filter(
|
||||||
|
f => isOximediaFact(f) && f.factKind === 'mood_tag' && f.valueText,
|
||||||
|
);
|
||||||
|
const valence = oximedia.find(f => f.factKind === 'valence')?.valueReal ?? null;
|
||||||
|
const arousal = oximedia.find(f => f.factKind === 'arousal')?.valueReal ?? null;
|
||||||
|
|
||||||
|
const hotBpm = serverBpm != null && serverBpm > 0 ? serverBpm : null;
|
||||||
|
|
||||||
|
const fromMoodsJson = topDistinctOximediaMoodTagIds(parseMoodsScoresJson(moodsFact?.valueText));
|
||||||
|
const fromLegacy = parseMoodLabelsArray(legacyLabelsFact?.valueText);
|
||||||
|
const fromMoodTags = distinctOximediaMoodTagIds(
|
||||||
|
moodTagFacts.map(f => f.valueText!).filter(Boolean),
|
||||||
|
);
|
||||||
|
const fromValenceArousal =
|
||||||
|
valence != null && arousal != null
|
||||||
|
? topDistinctOximediaMoodTagIdsFromValenceArousal(valence, arousal)
|
||||||
|
: [];
|
||||||
|
const rawMoodLabels =
|
||||||
|
(fromValenceArousal.length > 0 ? fromValenceArousal : null)
|
||||||
|
?? (fromMoodTags.length > 0 ? fromMoodTags : null)
|
||||||
|
?? (fromMoodsJson.length > 0 ? fromMoodsJson : null)
|
||||||
|
?? (fromLegacy && fromLegacy.length > 0 ? fromLegacy : null)
|
||||||
|
?? [];
|
||||||
|
const moodLabels = OXIMEDIA_MOOD_UI_ENABLED ? rawMoodLabels : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverBpm: hotBpm,
|
||||||
|
measuredBpm: measured?.valueInt ?? null,
|
||||||
|
moodLabels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMoodsScoresJson(raw: string | null | undefined): Record<string, number> | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (typeof parsed !== 'object' || parsed == null || Array.isArray(parsed)) return null;
|
||||||
|
const out: Record<string, number> = {};
|
||||||
|
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) out[key] = value;
|
||||||
|
}
|
||||||
|
return Object.keys(out).length > 0 ? out : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMoodLabelsArray(raw: string | null | undefined): string[] | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return null;
|
||||||
|
const labels = parsed.filter((v): v is string => typeof v === 'string' && v.length > 0);
|
||||||
|
return labels.length > 0 ? labels.slice(0, 3) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use `moodScoresFromValenceArousal` — oximedia quadrant copy. */
|
||||||
|
export function deriveMoodScores(valence: number, arousal: number): Record<string, number> {
|
||||||
|
return moodScoresFromValenceArousal(valence, arousal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use `topDistinctOximediaMoodTagIdsFromValenceArousal`. */
|
||||||
|
export { topDistinctOximediaMoodTagIdsFromValenceArousal as topMoodLabelIds } from '../../config/moodGroups';
|
||||||
|
|
||||||
|
/** Analysis/measured BPM when present; otherwise file tag BPM. */
|
||||||
|
export function resolveDisplayBpm(
|
||||||
|
tagBpm: number | null | undefined,
|
||||||
|
measuredBpm: number | null | undefined,
|
||||||
|
): number | null {
|
||||||
|
if (measuredBpm != null && measuredBpm > 0) return measuredBpm;
|
||||||
|
if (tagBpm != null && tagBpm > 0) return tagBpm;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Analysis fact wins; tag BPM is shown until a fact is stored. */
|
||||||
|
export function resolveQueueBpm(data: ParsedTrackEnrichment): number | null {
|
||||||
|
return resolveDisplayBpm(data.serverBpm, data.measuredBpm);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatQueueBpmTech(data: ParsedTrackEnrichment, t: TFunction): string | null {
|
||||||
|
const bpm = resolveQueueBpm(data);
|
||||||
|
if (bpm == null) return null;
|
||||||
|
return t('queue.bpm', { bpm });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatQueueMoodLabels(labels: readonly string[], t: TFunction): string | null {
|
||||||
|
const names = labels
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(id => {
|
||||||
|
const key = `queue.moods.${id}`;
|
||||||
|
const translated = t(key);
|
||||||
|
return translated === key ? id : translated;
|
||||||
|
});
|
||||||
|
return names.length > 0 ? names.join(' · ') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichmentHasMoodLabels(data: ParsedTrackEnrichment): boolean {
|
||||||
|
return data.moodLabels.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichmentHasBpm(data: ParsedTrackEnrichment): boolean {
|
||||||
|
return (data.measuredBpm != null && data.measuredBpm > 0)
|
||||||
|
|| (data.serverBpm != null && data.serverBpm > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enrichmentDisplayComplete(data: ParsedTrackEnrichment): boolean {
|
||||||
|
return enrichmentHasMoodLabels(data) || enrichmentHasBpm(data);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user