From ec98fcc4ff947769dc78ae7bc4d11bc011901718 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:07:22 +0300 Subject: [PATCH] fix(genres): hide empty genres after library resync (#1176) --- CHANGELOG.md | 6 ++ .../psysonic-library/src/browse_support.rs | 73 ++++++++++++++----- .../src/genre_album_browse.rs | 10 +-- src/pages/Genres.tsx | 28 ++++++- src/utils/library/genreBrowsePlayback.test.ts | 24 ++++++ src/utils/library/genreBrowsePlayback.ts | 11 ++- 6 files changed, 120 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af39bf27..7603e494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Fixed +### Genres page kept empty genres after tag changes + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1176](https://github.com/Psychotoxical/psysonic/pull/1176)**, closes [#1162](https://github.com/Psychotoxical/psysonic/issues/1162) + +* After retagging a track and resyncing the library, genres with no remaining albums could still appear on the Genres page until restart. The local genre catalog now counts only live indexed tracks, filters zero-count genres, and the Genres page refreshes when library sync finishes. + ### Playlists header buttons clipped at narrow widths **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1153](https://github.com/Psychotoxical/psysonic/pull/1153)** diff --git a/src-tauri/crates/psysonic-library/src/browse_support.rs b/src-tauri/crates/psysonic-library/src/browse_support.rs index 442762c1..e28cce15 100644 --- a/src-tauri/crates/psysonic-library/src/browse_support.rs +++ b/src-tauri/crates/psysonic-library/src/browse_support.rs @@ -113,26 +113,15 @@ pub(crate) fn genre_album_counts_for_server( ) -> Result, String> { store .with_read_conn(|conn| { - let scoped = library_scope.is_some_and(|s| !s.trim().is_empty()); - let mut sql = if scoped { - String::from( - "SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \ - COUNT(DISTINCT tg.track_id) AS song_count \ - FROM track_genre tg \ - INNER JOIN track t \ - ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \ - WHERE tg.server_id = ?1 \ - AND tg.album_id IS NOT NULL AND tg.album_id != ''", - ) - } else { - String::from( - "SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \ - COUNT(DISTINCT tg.track_id) AS song_count \ - FROM track_genre tg \ - WHERE tg.server_id = ?1 \ - AND tg.album_id IS NOT NULL AND tg.album_id != ''", - ) - }; + let mut sql = String::from( + "SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \ + COUNT(DISTINCT tg.track_id) AS song_count \ + FROM track_genre tg \ + INNER JOIN track t \ + ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \ + WHERE tg.server_id = ?1 \ + AND tg.album_id IS NOT NULL AND tg.album_id != ''", + ); let mut params: Vec = vec![rusqlite::types::Value::Text(server_id.to_string())]; if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) { @@ -141,6 +130,7 @@ pub(crate) fn genre_album_counts_for_server( } sql.push_str( " GROUP BY tg.genre COLLATE NOCASE \ + HAVING album_count > 0 \ ORDER BY album_count DESC, tg.genre COLLATE NOCASE ASC", ); let mut stmt = conn.prepare(&sql)?; @@ -370,6 +360,49 @@ mod tests { assert_eq!(counts[0].album_count, 1); } + #[test] + fn genre_album_counts_drop_genre_after_track_retag() { + let store = Arc::new(LibraryStore::open_in_memory()); + let mut track = make_row("s1", "t1", "al1", 1); + track.genre = Some("ruspop".into()); + TrackRepository::new(&store) + .upsert_batch(&[track.clone()]) + .unwrap(); + let counts = genre_album_counts_for_server(&store, "s1", None).unwrap(); + assert_eq!(counts.len(), 1); + assert_eq!(counts[0].value, "ruspop"); + + track.genre = Some("Pop".into()); + TrackRepository::new(&store).upsert_batch(&[track]).unwrap(); + let counts = genre_album_counts_for_server(&store, "s1", None).unwrap(); + assert_eq!(counts.len(), 1); + assert_eq!(counts[0].value, "Pop"); + } + + #[test] + fn genre_album_counts_ignore_orphan_track_genre_rows() { + let store = Arc::new(LibraryStore::open_in_memory()); + let mut live = make_row("s1", "live", "al1", 1); + live.genre = Some("Rock".into()); + let mut stale = make_row("s1", "gone", "al_stale", 1); + stale.genre = Some("ruspop".into()); + TrackRepository::new(&store) + .upsert_batch(&[live, stale]) + .unwrap(); + store + .with_conn("test", |conn| { + conn.execute( + "UPDATE track SET deleted = 1 WHERE server_id = 's1' AND id = 'gone'", + [], + ) + }) + .unwrap(); + + let counts = genre_album_counts_for_server(&store, "s1", None).unwrap(); + assert_eq!(counts.len(), 1); + assert_eq!(counts[0].value, "Rock"); + } + #[test] fn reconcile_album_stars_clears_all_when_server_list_empty() { let store = Arc::new(LibraryStore::open_in_memory()); diff --git a/src-tauri/crates/psysonic-library/src/genre_album_browse.rs b/src-tauri/crates/psysonic-library/src/genre_album_browse.rs index 4fc02626..12687e8f 100644 --- a/src-tauri/crates/psysonic-library/src/genre_album_browse.rs +++ b/src-tauri/crates/psysonic-library/src/genre_album_browse.rs @@ -45,15 +45,11 @@ fn count_genre_albums( conn: &rusqlite::Connection, where_sql: &str, params: &[SqlValue], - library_scoped: bool, + _library_scoped: bool, ) -> Result { - let from = if library_scoped { - "FROM track_genre tg \ + let from = "FROM track_genre tg \ INNER JOIN track t \ - ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0" - } else { - "FROM track_genre tg" - }; + ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0"; let count_sql = format!("SELECT COUNT(DISTINCT tg.album_id) {from} WHERE {where_sql}"); let n: i64 = conn.query_row( &count_sql, diff --git a/src/pages/Genres.tsx b/src/pages/Genres.tsx index 5bd166cc..903cbc4d 100644 --- a/src/pages/Genres.tsx +++ b/src/pages/Genres.tsx @@ -4,11 +4,13 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Tags } from 'lucide-react'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; +import { subscribeLibrarySyncIdle } from '../api/library'; import { useAuthStore } from '../store/authStore'; import { useLibraryIndexStore } from '../store/libraryIndexStore'; -import { fetchGenreCatalog } from '../utils/library/genreBrowsePlayback'; +import { fetchGenreCatalog, filterGenresWithContent } from '../utils/library/genreBrowsePlayback'; import { libraryScopeForServer } from '../api/subsonicClient'; import { peekGenreCatalogCache } from '../utils/library/genreCatalogCountsCache'; +import { resolveIndexKey } from '../utils/server/serverIndexKey'; const CTP_COLORS = [ 'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)', @@ -63,10 +65,32 @@ export default function Genres() { }, [serverId, indexEnabled, musicLibraryFilterVersion]); const genres = useMemo( - () => [...rawGenres].sort((a, b) => b.albumCount - a.albumCount), + () => filterGenresWithContent([...rawGenres]).sort((a, b) => b.albumCount - a.albumCount), [rawGenres], ); + // After library resync the in-memory catalog cache is cleared, but this page + // can still hold pre-sync genres until we refetch (issue #1162). + useEffect(() => { + if (!serverId || !indexEnabled) return; + let cancelled = false; + const indexKey = resolveIndexKey(serverId); + let unlisten: (() => void) | undefined; + void subscribeLibrarySyncIdle(payload => { + if (!payload.ok) return; + if (payload.serverId !== indexKey && payload.serverId !== serverId) return; + void fetchGenreCatalog(serverId, indexEnabled).then(data => { + if (!cancelled) setRawGenres(data); + }); + }).then(fn => { + unlisten = fn; + }); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [serverId, indexEnabled]); + // Log-scale font sizing — flattens the long tail (a 1000-album genre and a // 50-album genre look distinct, but a 1-album genre still has a readable size). const maxLog = useMemo(() => { diff --git a/src/utils/library/genreBrowsePlayback.test.ts b/src/utils/library/genreBrowsePlayback.test.ts index 31454325..1bc37602 100644 --- a/src/utils/library/genreBrowsePlayback.test.ts +++ b/src/utils/library/genreBrowsePlayback.test.ts @@ -4,6 +4,7 @@ import { fetchGenreCatalog, fetchGenreTracksForPlayback, fetchLocalGenreTracksForPlayback, + filterGenresWithContent, GENRE_PLAYBACK_QUEUE_CAP, } from './genreBrowsePlayback'; @@ -135,6 +136,29 @@ describe('genreBrowsePlayback', () => { expect(getGenres).not.toHaveBeenCalled(); }); + it('drops empty genres from server fallback catalog', async () => { + vi.mocked(libraryIsReady).mockResolvedValue(false); + vi.mocked(getGenres).mockResolvedValue([ + { value: 'ruspop', songCount: 0, albumCount: 0 }, + { value: 'Rock', songCount: 10, albumCount: 3 }, + ]); + + await expect(fetchGenreCatalog('srv-1', true)).resolves.toEqual([ + { value: 'Rock', albumCount: 3, songCount: 10 }, + ]); + }); + + it('filterGenresWithContent drops zero-count rows', () => { + expect(filterGenresWithContent([ + { value: 'Empty', albumCount: 0, songCount: 0 }, + { value: 'SongsOnly', albumCount: 0, songCount: 2 }, + { value: 'AlbumsOnly', albumCount: 1, songCount: 0 }, + ])).toEqual([ + { value: 'SongsOnly', albumCount: 0, songCount: 2 }, + { value: 'AlbumsOnly', albumCount: 1, songCount: 0 }, + ]); + }); + it('reuses cached genre catalog without repeating SQL', async () => { vi.mocked(libraryIsReady).mockResolvedValue(true); vi.mocked(libraryGetGenreAlbumCounts).mockResolvedValue([ diff --git a/src/utils/library/genreBrowsePlayback.ts b/src/utils/library/genreBrowsePlayback.ts index 7ffc89a5..8954fedb 100644 --- a/src/utils/library/genreBrowsePlayback.ts +++ b/src/utils/library/genreBrowsePlayback.ts @@ -21,6 +21,11 @@ import { import { fetchGenreAlbumTotal } from './genreAlbumBrowse'; import { libraryIsReady } from './libraryReady'; +/** Drop genres with no indexed albums/tracks (stale server list or orphan rows). */ +export function filterGenresWithContent(genres: SubsonicGenre[]): SubsonicGenre[] { + return genres.filter(g => (g.albumCount ?? 0) > 0 || (g.songCount ?? 0) > 0); +} + async function loadLocalGenreCatalogRows( serverId: string, libraryScope: string | undefined, @@ -29,11 +34,11 @@ async function loadLocalGenreCatalogRows( serverId, libraryScope, }); - return rows.map(row => ({ + return filterGenresWithContent(rows.map(row => ({ value: row.value, albumCount: row.albumCount, songCount: row.songCount, - })); + }))); } async function fetchLocalGenreCatalog( @@ -153,7 +158,7 @@ export async function fetchGenreCatalog( /* network fallback */ } } - const genres = await getGenres(); + const genres = filterGenresWithContent(await getGenres()); writeGenreCatalogCache(serverId, scope, genres); return genres; };