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:
cucadmuh
2026-05-23 18:54:04 +03:00
committed by GitHub
parent 67b1dc790b
commit 003b280a77
92 changed files with 4566 additions and 492 deletions
@@ -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::repos;
use crate::search::{
aliased_track_columns, fts_album_prefix_match_query, fts_column_prefix_query,
fts_query_meets_min_len, fts_track_prefix_match_query, library_scope_equals_sql,
like_contains, PAGE_LIMIT_MAX,
aliased_track_columns, aliased_track_columns_resolved_bpm, bpm_resolved_expr,
fts_album_prefix_match_query, fts_column_prefix_query, fts_query_meets_min_len,
fts_track_prefix_match_query, library_scope_equals_sql, like_contains, PAGE_LIMIT_MAX,
};
use crate::store::LibraryStore;
/// `bpm` dual-storage resolution (§5.13.4): prefer the hot `track.bpm`
/// column, fall back to the highest-priority `track_fact(bpm)` value. The
/// spec's `not_found = 0` guard is dropped — the live `track_fact` schema
/// has no such column (that lives on `track_artifact`).
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))";
/// `bpm` dual-storage resolution (§5.13.4): prefer analysis `track_fact(bpm)`,
/// then hot `track.bpm` tag, then other fact sources.
fn bpm_resolved_sql() -> String {
bpm_resolved_expr("t")
}
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, \
@@ -183,7 +180,17 @@ fn build_track(
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) {
applied.insert("text".to_string());
let pool = fts_candidate_pool_size(limit, offset);
@@ -205,7 +212,7 @@ fn build_track(
limit,
offset,
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,
offset,
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)]
fn build_album(
store: &LibraryStore,
@@ -235,9 +250,11 @@ fn build_album(
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
let table = build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?;
if !table.0.is_empty() || table.1 > 0 {
return Ok(table);
if !scalar_requires_track_derived_entities(scalar) {
let table = build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?;
if !table.0.is_empty() || table.1 > 0 {
return Ok(table);
}
}
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);
@@ -360,9 +377,11 @@ fn build_artist(
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryArtistDto>, u32), String> {
let table = build_artist_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?;
if !table.0.is_empty() || table.1 > 0 {
return Ok(table);
if !scalar_requires_track_derived_entities(scalar) {
let table = build_artist_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?;
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)) {
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 ──────────────────────────────────────────────────
/// 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)`
/// means the field is known but doesn't route to this entity (§5.13.3 skip).
fn resolve_clause(
@@ -668,6 +695,14 @@ fn resolve_clause(
if !applies {
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) {
("genre", EntityKind::Track) => "t.genre",
("genre", EntityKind::Album) => "a.genre",
@@ -678,7 +713,9 @@ fn resolve_clause(
// `starred` routes to artist in the registry, but the `artist`
// table has no `starred_at` column — skip rather than error.
("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", _) => return Ok(None),
// 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)))];
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[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 ────────────────────────────────────────
@@ -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 mbid_recording: Option<String>,
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_album_db: Option<f64>,
@@ -149,6 +152,7 @@ impl LibraryTrackDto {
isrc: row.isrc.clone(),
mbid_recording: row.mbid_recording.clone(),
bpm: row.bpm,
bpm_source: None,
replay_gain_track_db: row.replay_gain_track_db,
replay_gain_album_db: row.replay_gain_album_db,
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",
entities: &[EntityKind::Track],
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,
},
];
@@ -290,10 +302,16 @@ mod tests {
}
#[test]
fn bpm_is_schema_v1_but_ui_later() {
// §5.13.3: bpm has a hot column + index from day one, but is hidden
// from the v1 UI until §5.13.4 dual-storage resolution lands.
assert_eq!(lookup("bpm").unwrap().status, FilterStatus::SchemaV1UiLater);
fn bpm_is_v1_with_dual_storage_resolution() {
assert_eq!(lookup("bpm").unwrap().status, FilterStatus::V1);
}
#[test]
fn mood_group_is_schema_v1_ui_later_while_oximedia_mood_disabled() {
assert_eq!(
lookup("mood_group").unwrap().status,
FilterStatus::SchemaV1UiLater
);
}
#[test]
@@ -9,11 +9,14 @@
pub(crate) mod bulk_ingest;
pub mod advanced_search;
mod advanced_search_mood;
pub mod canonical;
pub mod commands;
pub mod cross_server;
pub mod dto;
pub mod enrichment;
pub mod filter;
pub mod mood_groups;
pub mod live_search;
pub mod payload;
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)?,
play_count: row.get(offset + 19)?,
bpm: row.get(offset + 20)?,
bpm_source: None,
played_at: None,
server_path: 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(", ")
}
/// 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
/// `\` escape char escaped, for use with `LIKE ? ESCAPE '\'`. Shared by the
/// 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
/// `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
/// 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");
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_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
/// 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),
(6, MIGRATION_006_PLAY_SESSION),
(7, MIGRATION_007_RESYNC_GEN),
(8, MIGRATION_008_MOOD_TAG_INDEX),
(9, MIGRATION_009_PURGE_MOOD_FACTS),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]