mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
fix(library): multi-genre local index with track_genre and backfill (#1059)
* fix(library): multi-genre local index with track_genre and backfill Restore atomic genre browse, filters, and counts via track_genre: OpenSubsonic genres[] first with Navidrome-default split fallback, sync write path, read-path query switches, blocking startup backfill with progress, and v12 repair migration for DBs that recorded legacy 002–011. TS fallback adds genreTagsFor and migration gate i18n across locales. * fix(library): address multi-genre review — robust TS genres and scope join genreTagsFor routes raw genres through parseItemGenres (single-object Subsonic quirk and bare strings). Library-scoped genre browse/counts join track for raw_json library_id fallback. Statistics keeps empty-genre bucket. * docs: CHANGELOG and credits for multi-genre local index (PR #1059) * docs(changelog): credit HiveMind on Discord for multi-genre report (PR #1059)
This commit is contained in:
@@ -892,3 +892,24 @@ export function subscribeLibrarySyncIdle(
|
||||
handler(payload),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Genre tags startup backfill (multi-genre local index) ───────────────
|
||||
|
||||
export interface GenreTagsInspectDto {
|
||||
needed: boolean;
|
||||
totalTracks: number;
|
||||
doneTracks: number;
|
||||
}
|
||||
|
||||
export interface GenreTagsProgressEvent {
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function libraryGenreTagsInspect(): Promise<GenreTagsInspectDto> {
|
||||
return invoke<GenreTagsInspectDto>('library_genre_tags_inspect');
|
||||
}
|
||||
|
||||
export function libraryGenreTagsRun(): Promise<void> {
|
||||
return invoke<void>('library_genre_tags_run');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
|
||||
import { parseItemGenres } from '../utils/library/genreTags';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { ndLogin } from './navidromeAdmin';
|
||||
@@ -54,6 +55,7 @@ function mapNdSong(o: Record<string, unknown>): SubsonicSong {
|
||||
userRating: asNumber(o.rating),
|
||||
starred: o.starred ? asString(o.starredAt) || 'true' : undefined,
|
||||
genre: typeof o.genre === 'string' ? o.genre : undefined,
|
||||
genres: parseItemGenres(o.genres),
|
||||
bitRate: asNumber(o.bitRate),
|
||||
suffix: typeof o.suffix === 'string' ? o.suffix : undefined,
|
||||
contentType: typeof o.contentType === 'string' ? o.contentType : undefined,
|
||||
@@ -166,6 +168,7 @@ function mapNdAlbum(o: Record<string, unknown>): SubsonicAlbum {
|
||||
duration: asNumber(o.duration) ?? 0,
|
||||
year: asNumber(o.maxYear) ?? asNumber(o.year),
|
||||
genre: typeof o.genre === 'string' ? o.genre : undefined,
|
||||
genres: parseItemGenres(o.genres),
|
||||
starred: starredFlag ? (starredAt ?? 'true') : undefined,
|
||||
userRating: asNumber(o.rating),
|
||||
isCompilation: o.compilation === true,
|
||||
@@ -383,6 +386,7 @@ export async function ndListLosslessAlbumsPage(req: NdLosslessPageRequest): Prom
|
||||
duration: 0,
|
||||
year: asNumber(o.year),
|
||||
genre: typeof o.genre === 'string' ? o.genre : undefined,
|
||||
genres: parseItemGenres(o.genres),
|
||||
};
|
||||
pageEntries.push({ album, bitDepth, sampleRate: asNumber(o.sampleRate) ?? 0 });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { genreTagsFor } from '../utils/library/genreTags';
|
||||
import { getArtists } from './subsonicArtists';
|
||||
import { getAlbumList, getRandomSongs } from './subsonicLibrary';
|
||||
import type {
|
||||
@@ -54,14 +55,17 @@ export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibr
|
||||
albumsCounted += 1;
|
||||
const sc = a.songCount ?? 0;
|
||||
songsCounted += sc;
|
||||
const label = (a.genre?.trim()) ? a.genre.trim() : '';
|
||||
let g = genreAgg.get(label);
|
||||
if (!g) {
|
||||
g = { songCount: 0, albumCount: 0 };
|
||||
genreAgg.set(label, g);
|
||||
const tags = genreTagsFor(a);
|
||||
const labels = tags.length > 0 ? tags : [''];
|
||||
for (const label of labels) {
|
||||
let g = genreAgg.get(label);
|
||||
if (!g) {
|
||||
g = { songCount: 0, albumCount: 0 };
|
||||
genreAgg.set(label, g);
|
||||
}
|
||||
g.songCount += sc;
|
||||
g.albumCount += 1;
|
||||
}
|
||||
g.songCount += sc;
|
||||
g.albumCount += 1;
|
||||
}
|
||||
if (albums.length < pageSize) break;
|
||||
offset += pageSize;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { SubsonicServerIdentity } from '../utils/server/subsonicServerIdentity';
|
||||
|
||||
/** OpenSubsonic `ItemGenre` on songs/albums (atomic genres from the server). */
|
||||
export interface SubsonicItemGenre {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SubsonicAlbum {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -11,6 +16,8 @@ export interface SubsonicAlbum {
|
||||
playCount?: number;
|
||||
year?: number;
|
||||
genre?: string;
|
||||
/** OpenSubsonic atomic genres — preferred over splitting `genre`. */
|
||||
genres?: SubsonicItemGenre[];
|
||||
starred?: string;
|
||||
recordLabel?: string;
|
||||
created?: string;
|
||||
@@ -72,6 +79,8 @@ export interface SubsonicSong {
|
||||
channelCount?: number;
|
||||
starred?: string;
|
||||
genre?: string;
|
||||
/** OpenSubsonic atomic genres — preferred over splitting `genre`. */
|
||||
genres?: SubsonicItemGenre[];
|
||||
path?: string;
|
||||
albumArtist?: string;
|
||||
/** OpenSubsonic: single-string album-artist for display (mirrors `albumArtists` joined). */
|
||||
|
||||
Reference in New Issue
Block a user