mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
fix(genres): hide empty genres after library resync (#1176)
This commit is contained in:
@@ -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)**
|
||||
|
||||
@@ -113,26 +113,15 @@ pub(crate) fn genre_album_counts_for_server(
|
||||
) -> Result<Vec<GenreAlbumCountDto>, 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<rusqlite::types::Value> =
|
||||
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());
|
||||
|
||||
@@ -45,15 +45,11 @@ fn count_genre_albums(
|
||||
conn: &rusqlite::Connection,
|
||||
where_sql: &str,
|
||||
params: &[SqlValue],
|
||||
library_scoped: bool,
|
||||
_library_scoped: bool,
|
||||
) -> Result<u32, rusqlite::Error> {
|
||||
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,
|
||||
|
||||
+26
-2
@@ -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(() => {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user