mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
+176
-10
@@ -4,7 +4,7 @@ import { getAlbumList, getRandomSongs } from '../api/subsonicLibrary';
|
||||
import type { SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { SlidersVertical } from 'lucide-react';
|
||||
import { SlidersVertical, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import ArtistRow from '../components/ArtistRow';
|
||||
@@ -14,9 +14,13 @@ import StarFilterButton from '../components/StarFilterButton';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { runLocalAdvancedSearch, loadMoreLocalSongs, runNetworkAdvancedTextSearch } from '../utils/library/advancedSearchLocal';
|
||||
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from '../utils/library/trackEnrichment';
|
||||
import { raceSearchSources } from '../utils/library/searchRace';
|
||||
import { logLibrarySearch } from '../utils/library/libraryDevLog';
|
||||
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';
|
||||
|
||||
@@ -25,6 +29,9 @@ interface SearchOpts {
|
||||
genre: string;
|
||||
yearFrom: string;
|
||||
yearTo: string;
|
||||
bpmFrom: string;
|
||||
bpmTo: string;
|
||||
moodGroup: string;
|
||||
resultType: ResultType;
|
||||
}
|
||||
|
||||
@@ -34,6 +41,13 @@ interface Results {
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
@@ -42,6 +56,9 @@ export default function AdvancedSearch() {
|
||||
const [genre, setGenre] = useState('');
|
||||
const [yearFrom, setYearFrom] = useState('');
|
||||
const [yearTo, setYearTo] = useState('');
|
||||
const [bpmFrom, setBpmFrom] = useState('');
|
||||
const [bpmTo, setBpmTo] = useState('');
|
||||
const [moodGroup, setMoodGroup] = useState('');
|
||||
const [resultType, setResultType] = useState<ResultType>('all');
|
||||
const [starredOnly, setStarredOnly] = useState(false);
|
||||
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
|
||||
@@ -85,11 +102,15 @@ export default function AdvancedSearch() {
|
||||
g: string,
|
||||
from: number | null,
|
||||
to: number | null,
|
||||
bpmLo: number | null,
|
||||
bpmHi: number | null,
|
||||
): SubsonicSong[] => {
|
||||
let r = list;
|
||||
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 (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;
|
||||
};
|
||||
|
||||
@@ -106,8 +127,12 @@ export default function AdvancedSearch() {
|
||||
|
||||
const q = opts.query.trim();
|
||||
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 {
|
||||
const winner = await raceSearchSources(
|
||||
[
|
||||
@@ -155,7 +180,7 @@ export default function AdvancedSearch() {
|
||||
if (isStale()) return;
|
||||
}
|
||||
setLocalMode(false);
|
||||
} else {
|
||||
} else if (serverId && indexEnabled) {
|
||||
const localPage = await runLocalAdvancedSearch(serverId, opts, SONGS_INITIAL);
|
||||
if (isStale()) return;
|
||||
if (localPage) {
|
||||
@@ -170,12 +195,27 @@ export default function AdvancedSearch() {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (trackOnlyFilterActive) {
|
||||
setResults({ artists: [], albums: [], songs: [] });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLocalMode(false);
|
||||
} else {
|
||||
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 to = yt ? parseInt(yt) : null;
|
||||
const bpmLo = bf ? parseInt(bf) : null;
|
||||
const bpmHi = bt ? parseInt(bt) : null;
|
||||
|
||||
let artists: SubsonicArtist[] = [];
|
||||
let albums: SubsonicAlbum[] = [];
|
||||
@@ -186,7 +226,7 @@ export default function AdvancedSearch() {
|
||||
const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: SONGS_INITIAL });
|
||||
artists = r.artists;
|
||||
albums = r.albums;
|
||||
songs = applySongFilters(r.songs, g, from, to);
|
||||
songs = applySongFilters(r.songs, g, from, to, bpmLo, bpmHi);
|
||||
|
||||
if (g) {
|
||||
albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase());
|
||||
@@ -209,6 +249,7 @@ export default function AdvancedSearch() {
|
||||
]);
|
||||
albums = albumRes as SubsonicAlbum[];
|
||||
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 (to !== null) albums = albums.filter(a => !a.year || a.year <= to);
|
||||
if (songs.length > 0) setGenreNote(true);
|
||||
@@ -250,7 +291,18 @@ export default function AdvancedSearch() {
|
||||
getGenres().then(data =>
|
||||
setGenres(data.sort((a, b) => a.value.localeCompare(b.value)))
|
||||
).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]);
|
||||
|
||||
const loadMoreSongs = useCallback(async () => {
|
||||
@@ -280,8 +332,10 @@ export default function AdvancedSearch() {
|
||||
const g = activeSearch.genre;
|
||||
const from = activeSearch.yearFrom ? parseInt(activeSearch.yearFrom) : 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 filtered = applySongFilters(page, g, from, to);
|
||||
const filtered = applySongFilters(page, g, from, to, bpmLo, bpmHi);
|
||||
setResults(prev => prev ? { ...prev, songs: [...prev.songs, ...filtered] } : prev);
|
||||
setSongsServerOffset(o => o + page.length);
|
||||
// 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]);
|
||||
|
||||
const trackFilterActive =
|
||||
(MOOD_UI_ENABLED && !!moodGroup) || !!(bpmFrom || bpmTo);
|
||||
|
||||
const bpmFilterDraftActive = !!(bpmFrom || bpmTo);
|
||||
|
||||
const clearBpmFilter = () => {
|
||||
setBpmFrom('');
|
||||
setBpmTo('');
|
||||
};
|
||||
|
||||
const handleSubmit = (e?: React.FormEvent) => {
|
||||
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 }[] = [
|
||||
@@ -310,6 +384,17 @@ export default function AdvancedSearch() {
|
||||
...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 (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
@@ -379,10 +464,90 @@ export default function AdvancedSearch() {
|
||||
/>
|
||||
</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.3rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{typeOptions.map(opt => (
|
||||
{!trackFilterActive && typeOptions.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
@@ -454,6 +619,7 @@ export default function AdvancedSearch() {
|
||||
hasMore={songsHasMore}
|
||||
loadingMore={loadingMoreSongs}
|
||||
onLoadMore={loadMoreSongs}
|
||||
showBpm={!!(activeSearch?.bpmFrom || activeSearch?.bpmTo)}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user