fix(genres): hide empty genres after library resync (#1176)

This commit is contained in:
cucadmuh
2026-06-24 23:07:22 +03:00
committed by GitHub
parent 1031590742
commit ec98fcc4ff
6 changed files with 120 additions and 32 deletions
+6
View File
@@ -127,6 +127,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Fixed ## 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 ### Playlists header buttons clipped at narrow widths
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1153](https://github.com/Psychotoxical/psysonic/pull/1153)** **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1153](https://github.com/Psychotoxical/psysonic/pull/1153)**
@@ -113,26 +113,15 @@ pub(crate) fn genre_album_counts_for_server(
) -> Result<Vec<GenreAlbumCountDto>, String> { ) -> Result<Vec<GenreAlbumCountDto>, String> {
store store
.with_read_conn(|conn| { .with_read_conn(|conn| {
let scoped = library_scope.is_some_and(|s| !s.trim().is_empty()); let mut sql = String::from(
let mut sql = if scoped { "SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \
String::from( COUNT(DISTINCT tg.track_id) AS song_count \
"SELECT tg.genre, COUNT(DISTINCT tg.album_id) AS album_count, \ FROM track_genre tg \
COUNT(DISTINCT tg.track_id) AS song_count \ INNER JOIN track t \
FROM track_genre tg \ ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \
INNER JOIN track t \ WHERE tg.server_id = ?1 \
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0 \ AND tg.album_id IS NOT NULL AND tg.album_id != ''",
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 params: Vec<rusqlite::types::Value> = let mut params: Vec<rusqlite::types::Value> =
vec![rusqlite::types::Value::Text(server_id.to_string())]; vec![rusqlite::types::Value::Text(server_id.to_string())];
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) { 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( sql.push_str(
" GROUP BY tg.genre COLLATE NOCASE \ " GROUP BY tg.genre COLLATE NOCASE \
HAVING album_count > 0 \
ORDER BY album_count DESC, tg.genre COLLATE NOCASE ASC", ORDER BY album_count DESC, tg.genre COLLATE NOCASE ASC",
); );
let mut stmt = conn.prepare(&sql)?; let mut stmt = conn.prepare(&sql)?;
@@ -370,6 +360,49 @@ mod tests {
assert_eq!(counts[0].album_count, 1); 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] #[test]
fn reconcile_album_stars_clears_all_when_server_list_empty() { fn reconcile_album_stars_clears_all_when_server_list_empty() {
let store = Arc::new(LibraryStore::open_in_memory()); let store = Arc::new(LibraryStore::open_in_memory());
@@ -45,15 +45,11 @@ fn count_genre_albums(
conn: &rusqlite::Connection, conn: &rusqlite::Connection,
where_sql: &str, where_sql: &str,
params: &[SqlValue], params: &[SqlValue],
library_scoped: bool, _library_scoped: bool,
) -> Result<u32, rusqlite::Error> { ) -> Result<u32, rusqlite::Error> {
let from = if library_scoped { let from = "FROM track_genre tg \
"FROM track_genre tg \
INNER JOIN track t \ INNER JOIN track t \
ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0" ON t.server_id = tg.server_id AND t.id = tg.track_id AND t.deleted = 0";
} else {
"FROM track_genre tg"
};
let count_sql = format!("SELECT COUNT(DISTINCT tg.album_id) {from} WHERE {where_sql}"); let count_sql = format!("SELECT COUNT(DISTINCT tg.album_id) {from} WHERE {where_sql}");
let n: i64 = conn.query_row( let n: i64 = conn.query_row(
&count_sql, &count_sql,
+26 -2
View File
@@ -4,11 +4,13 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Tags } from 'lucide-react'; import { Tags } from 'lucide-react';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { subscribeLibrarySyncIdle } from '../api/library';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { fetchGenreCatalog } from '../utils/library/genreBrowsePlayback'; import { fetchGenreCatalog, filterGenresWithContent } from '../utils/library/genreBrowsePlayback';
import { libraryScopeForServer } from '../api/subsonicClient'; import { libraryScopeForServer } from '../api/subsonicClient';
import { peekGenreCatalogCache } from '../utils/library/genreCatalogCountsCache'; import { peekGenreCatalogCache } from '../utils/library/genreCatalogCountsCache';
import { resolveIndexKey } from '../utils/server/serverIndexKey';
const CTP_COLORS = [ const CTP_COLORS = [
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)', 'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
@@ -63,10 +65,32 @@ export default function Genres() {
}, [serverId, indexEnabled, musicLibraryFilterVersion]); }, [serverId, indexEnabled, musicLibraryFilterVersion]);
const genres = useMemo( const genres = useMemo(
() => [...rawGenres].sort((a, b) => b.albumCount - a.albumCount), () => filterGenresWithContent([...rawGenres]).sort((a, b) => b.albumCount - a.albumCount),
[rawGenres], [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 // 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). // 50-album genre look distinct, but a 1-album genre still has a readable size).
const maxLog = useMemo(() => { const maxLog = useMemo(() => {
@@ -4,6 +4,7 @@ import {
fetchGenreCatalog, fetchGenreCatalog,
fetchGenreTracksForPlayback, fetchGenreTracksForPlayback,
fetchLocalGenreTracksForPlayback, fetchLocalGenreTracksForPlayback,
filterGenresWithContent,
GENRE_PLAYBACK_QUEUE_CAP, GENRE_PLAYBACK_QUEUE_CAP,
} from './genreBrowsePlayback'; } from './genreBrowsePlayback';
@@ -135,6 +136,29 @@ describe('genreBrowsePlayback', () => {
expect(getGenres).not.toHaveBeenCalled(); 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 () => { it('reuses cached genre catalog without repeating SQL', async () => {
vi.mocked(libraryIsReady).mockResolvedValue(true); vi.mocked(libraryIsReady).mockResolvedValue(true);
vi.mocked(libraryGetGenreAlbumCounts).mockResolvedValue([ vi.mocked(libraryGetGenreAlbumCounts).mockResolvedValue([
+8 -3
View File
@@ -21,6 +21,11 @@ import {
import { fetchGenreAlbumTotal } from './genreAlbumBrowse'; import { fetchGenreAlbumTotal } from './genreAlbumBrowse';
import { libraryIsReady } from './libraryReady'; 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( async function loadLocalGenreCatalogRows(
serverId: string, serverId: string,
libraryScope: string | undefined, libraryScope: string | undefined,
@@ -29,11 +34,11 @@ async function loadLocalGenreCatalogRows(
serverId, serverId,
libraryScope, libraryScope,
}); });
return rows.map(row => ({ return filterGenresWithContent(rows.map(row => ({
value: row.value, value: row.value,
albumCount: row.albumCount, albumCount: row.albumCount,
songCount: row.songCount, songCount: row.songCount,
})); })));
} }
async function fetchLocalGenreCatalog( async function fetchLocalGenreCatalog(
@@ -153,7 +158,7 @@ export async function fetchGenreCatalog(
/* network fallback */ /* network fallback */
} }
} }
const genres = await getGenres(); const genres = filterGenresWithContent(await getGenres());
writeGenreCatalogCache(serverId, scope, genres); writeGenreCatalogCache(serverId, scope, genres);
return genres; return genres;
}; };