fix improperly ported metadata logic

This commit is contained in:
Boof2015
2026-07-05 02:10:25 -04:00
parent 34ffda7190
commit 3a25966dde
29 changed files with 1349 additions and 110 deletions
@@ -21,8 +21,10 @@ private const val TAG = "AstraCarCatalog"
// the browser on a spinner. Auto pages large lists itself; this only guards the unpaged path.
private const val MAX_UNPAGED_CHILDREN = 500
// Desktop track order (nulls first, path tiebreak) — keep in sync with the JS
// TRACK_ORDER in src/db/queries.ts and compareTracksByDiscTrackTitle.
private const val TRACK_ORDER =
"COALESCE(disc_number, 9999), COALESCE(track_number, 9999), title COLLATE NOCASE"
"COALESCE(disc_number, 0), COALESCE(track_number, 0), title COLLATE NOCASE, path"
// Fully alias-qualified (every column `t.`): these columns are SELECTed from joins where
// the other table (favorites / playlist_tracks) also has an `added_at`, so an unqualified
@@ -274,12 +276,21 @@ class AstraCarCatalog(private val context: Context) {
return getAllTracks(db).filter { trackMatchesBrowseArtist(it, normalizeKey(artist), mode) }
}
private fun getAlbums(db: SQLiteDatabase): List<AlbumRow> =
db.rawQuery(
private fun getAlbums(db: SQLiteDatabase): List<AlbumRow> {
// album_display_artist is the settled group artist ("Various Artists" for
// compilations), uniform per group so MAX() is exact. Guarded: this headless
// service can open a pre-v15 DB before the JS app ever runs its migration.
val artistExpr =
if (hasColumn(db, "tracks", "album_display_artist")) {
"MAX(COALESCE(album_display_artist, album_artist, artist))"
} else {
"MAX(COALESCE(album_artist, artist))"
}
return db.rawQuery(
"""
SELECT album_identity_key AS identity_key,
MAX(album) AS album,
MAX(COALESCE(album_artist, artist)) AS artist,
$artistExpr AS artist,
MAX(year) AS year,
MAX(artwork_hash) AS artwork_hash,
MAX(source_id) AS source_id,
@@ -307,6 +318,7 @@ class AstraCarCatalog(private val context: Context) {
}
}
}
}
private fun getPlaylistRuleRow(db: SQLiteDatabase, playlistId: Long): PlaylistRuleRow? {
if (!hasColumn(db, "playlists", "kind")) return PlaylistRuleRow("normal", null)
+1
View File
@@ -64,6 +64,7 @@
"lint": "expo lint",
"test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts",
"test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts src/db/dynamicPlaylistSql.test.mts",
"test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts",
"typecheck": "tsc --noEmit",
"postinstall": "patch-package"
},
+4
View File
@@ -39,6 +39,7 @@ import {
skipToPrevious,
togglePlay
} from '@/audio/playbackController';
import { compareTracksByDiscTrackTitle } from '@/library/albumIdentity';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { albumArtworkSource } from '@/library/artwork';
import { formatDuration } from '@/lib/format';
@@ -407,6 +408,9 @@ export default function HomeScreen() {
list.push(track);
map.set(track.album_identity_key, list);
}
// Store tracks are artist-ordered; a multi-artist compilation would play
// blocked by artist without an explicit album-order sort.
for (const list of map.values()) list.sort(compareTracksByDiscTrackTitle);
return map;
}, [randomAlbumNeedsTracks, tracks]);
const randomTracks =
+27 -10
View File
@@ -15,8 +15,9 @@ import { colors, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
import { compareTracksByDiscTrackTitle } from '@/library/albumIdentity';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { albumArtworkSource, artworkThumbUri } from '@/library/artwork';
import { albumArtworkSource, artworkThumbUri, artworkUri } from '@/library/artwork';
import { formatDuration } from '@/lib/format';
import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack';
import type { DbTrack } from '@/types/library';
@@ -45,10 +46,13 @@ export default function AlbumScreen() {
useDetailCollapse();
const album = albums.find((entry) => entry.identity_key === key);
// Store tracks are ordered artist/album/disc/track, so the filtered slice
// keeps disc/track order within one album.
// Store tracks are artist-ordered, so a multi-artist group (Various Artists
// compilation) would come out blocked by artist — re-sort into album order.
const tracks = useMemo(
() => allTracks.filter((track) => track.album_identity_key === key),
() =>
allTracks
.filter((track) => track.album_identity_key === key)
.sort(compareTracksByDiscTrackTitle),
[allTracks, key]
);
@@ -80,12 +84,25 @@ export default function AlbumScreen() {
void playTracks(tracks.map(dbTrackToTrack), index);
};
const artSource = album ? albumArtworkSource(album) : null;
// Eligibility can filter an album out of the store list (a single reached via
// "go to album") — fall back to the filtered tracks' own metadata.
const fallbackTrack = tracks[0];
const headerArtist =
album?.artist ??
fallbackTrack?.album_display_artist ??
fallbackTrack?.album_artist ??
fallbackTrack?.artist;
const headerArtworkHash = album?.artwork_hash ?? fallbackTrack?.artwork_hash ?? null;
const artSource = album
? albumArtworkSource(album)
: headerArtworkHash
? artworkUri(headerArtworkHash)
: null;
// Blur the cached thumbnail, not the full-res cover (remote albums have no
// local thumb — their server URL is already sized reasonably).
const backdropUri = album?.artwork_hash ? artworkThumbUri(album.artwork_hash) : artSource;
const backdropUri = headerArtworkHash ? artworkThumbUri(headerArtworkHash) : artSource;
const meta = [
album?.year ? String(album.year) : null,
(album?.year ?? fallbackTrack?.year) ? String(album?.year ?? fallbackTrack?.year) : null,
`${tracks.length} ${tracks.length === 1 ? 'track' : 'tracks'}`,
formatDuration(totalDuration),
]
@@ -130,12 +147,12 @@ export default function AlbumScreen() {
)
}
backdropUri={backdropUri}
title={album?.album ?? 'Album'}
title={album?.album ?? tracks[0]?.album ?? 'Album'}
heroMeta={
<>
{album?.artist ? (
{headerArtist ? (
<Text variant="body" color={colors.textSecondary} numberOfLines={1}>
{album.artist}
{headerArtist}
</Text>
) : null}
<Text variant="label">{meta}</Text>
+9
View File
@@ -234,6 +234,8 @@ export default function SettingsScreen() {
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
const includeSingles = useSettingsStore((s) => s.includeSingles);
const setIncludeSingles = useSettingsStore((s) => s.setIncludeSingles);
const normalizationEnabled = useAudioSettingsStore((s) => s.normalizationEnabled);
const normalizationTargetLufs = useAudioSettingsStore((s) => s.normalizationTargetLufs);
@@ -357,6 +359,13 @@ export default function SettingsScreen() {
})}
</View>
<ToggleRow
title="Show singles in Albums"
description="Include 1-track albums in the Albums view. Off matches desktop."
value={includeSingles}
onValueChange={(v) => void setIncludeSingles(v)}
/>
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
REMOTE SOURCES
</Text>
+5 -4
View File
@@ -34,7 +34,7 @@ import {
} from '@/theme';
import { WIDE_MIN_WIDTH, isWideWindow } from '@/theme/adaptive';
import { motion } from '@/theme/motion';
import { resolveCanonicalBrowseArtist, resolveStrictBrowseArtist } from '@/library/artistGrouping';
import { resolveNavigationArtist } from '@/library/artistGrouping';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { usePlaylistStore } from '@/stores/playlistStore';
@@ -288,9 +288,10 @@ export default function NowPlayingScreen() {
[libraryTracks, track]
);
const artistName = track
? artistGroupingMode === 'fileTags'
? resolveStrictBrowseArtist(libraryTrack ?? { artist: track.artist, album_artist: track.albumArtist ?? null })
: resolveCanonicalBrowseArtist(libraryTrack ?? { artist: track.artist, album_artist: track.albumArtist ?? null })
? resolveNavigationArtist(
libraryTrack ?? { artist: track.artist, album_artist: track.albumArtist ?? null },
artistGroupingMode
)
: '';
const albumKey = track?.albumIdentityKey ?? libraryTrack?.album_identity_key;
+6 -7
View File
@@ -1,5 +1,4 @@
import {
getAlbums,
getAllTracks,
getRecentlyPlayedTracks,
getTracksByAlbumKey,
@@ -11,6 +10,7 @@ import {
markPlaylistPlayed,
} from '@/db/playlistQueries';
import { openLibraryDb, type LibraryDatabase } from '@/db/database';
import { buildAlbumList } from '@/library/albumSummary';
import { buildArtistList, filterTracksByArtist } from '@/library/artistGrouping';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { playForCar, playTracksForCar, pause, seekTo, skipToNext, skipToPrevious } from '@/audio/playbackController';
@@ -201,7 +201,9 @@ async function playSearch(payload: CarCommandPayload): Promise<void> {
const albumTerm = cleanSearchTerm(payload.album) || focusedTerm(payload, 'album');
if (albumTerm) {
const album = bestMatch(await getAlbums(db), albumTerm, (entry) => [entry.album, entry.artist]);
// Voice search matches everything, including singles the browse grid hides.
const albums = buildAlbumList(await getAllTracks(db), { includeSingles: true });
const album = bestMatch(albums, albumTerm, (entry) => [entry.album, entry.artist]);
if (album) return playMedia({ kind: 'album', key: album.identity_key });
}
@@ -232,11 +234,8 @@ async function bestGeneralSearchCandidate(
query: string,
focus?: string,
): Promise<CarMediaPayload | null> {
const [tracks, albums, playlists] = await Promise.all([
getAllTracks(db),
getAlbums(db),
getPlaylists(db),
]);
const [tracks, playlists] = await Promise.all([getAllTracks(db), getPlaylists(db)]);
const albums = buildAlbumList(tracks, { includeSingles: true });
const artistName = await bestArtistName(db, query);
const candidates: { media: CarMediaPayload; score: number }[] = [];
+2 -3
View File
@@ -10,7 +10,7 @@ import {
import { PlaylistPickerSheet } from '@/components/sheets/PlaylistPickerSheet';
import { enqueueEnd, enqueueTop } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { resolveCanonicalBrowseArtist, resolveStrictBrowseArtist } from '@/library/artistGrouping';
import { resolveNavigationArtist } from '@/library/artistGrouping';
import { usePlaylistStore } from '@/stores/playlistStore';
import { useSettingsStore } from '@/stores/settingsStore';
import type { DbTrack } from '@/types/library';
@@ -48,8 +48,7 @@ function TrackActionsSheetInner({
const isFavorite = usePlaylistStore((s) => s.favoritePaths.has(track.path));
const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite);
const artistName =
groupingMode === 'fileTags' ? resolveStrictBrowseArtist(track) : resolveCanonicalBrowseArtist(track);
const artistName = resolveNavigationArtist(track, groupingMode);
const closeAndRun = (run: () => void) => {
onClose();
+25 -29
View File
@@ -1,6 +1,6 @@
// Library queries — SQL ported/adapted from the desktop library service.
import type { Album, DbTrack, LibraryFolder } from '@/types/library';
import type { DbTrack, LibraryFolder } from '@/types/library';
import type { LibraryDatabase, SqlParams } from './database';
/** Row shape the scanner produces for insert/update (id and timestamps are db-managed). */
@@ -12,6 +12,7 @@ export interface TrackUpsert {
album: string;
album_artist: string | null;
album_identity_key: string;
album_display_artist: string | null;
duration: number;
track_number: number | null;
disc_number: number | null;
@@ -32,10 +33,10 @@ export interface TrackUpsert {
const UPSERT_TRACK_SQL = `
INSERT INTO tracks (
path, folder_id, title, artist, album, album_artist, album_identity_key,
duration, track_number, disc_number, year, genre, artwork_hash, format,
sample_rate, bit_depth, bitrate, channels, codec, source_type,
file_name, size, mtime, added_at, modified_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'local', ?, ?, ?, ?, ?)
album_display_artist, duration, track_number, disc_number, year, genre,
artwork_hash, format, sample_rate, bit_depth, bitrate, channels, codec,
source_type, file_name, size, mtime, added_at, modified_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'local', ?, ?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
folder_id = excluded.folder_id,
title = excluded.title,
@@ -43,6 +44,7 @@ const UPSERT_TRACK_SQL = `
album = excluded.album,
album_artist = excluded.album_artist,
album_identity_key = excluded.album_identity_key,
album_display_artist = excluded.album_display_artist,
duration = excluded.duration,
track_number = excluded.track_number,
disc_number = excluded.disc_number,
@@ -74,6 +76,7 @@ export async function upsertTracks(db: LibraryDatabase, rows: TrackUpsert[]): Pr
row.album,
row.album_artist,
row.album_identity_key,
row.album_display_artist,
row.duration,
row.track_number,
row.disc_number,
@@ -111,6 +114,7 @@ export interface RemoteTrackUpsert {
album: string;
album_artist: string | null;
album_identity_key: string;
album_display_artist: string | null;
duration: number;
track_number: number | null;
disc_number: number | null;
@@ -129,17 +133,19 @@ export interface RemoteTrackUpsert {
const UPSERT_REMOTE_TRACK_SQL = `
INSERT INTO tracks (
path, folder_id, title, artist, album, album_artist, album_identity_key,
duration, track_number, disc_number, year, genre, artwork_hash, format,
sample_rate, bit_depth, bitrate, channels, codec, bpm, musical_key,
album_display_artist, duration, track_number, disc_number, year, genre,
artwork_hash, format, sample_rate, bit_depth, bitrate, channels, codec,
bpm, musical_key,
source_type, source_id, source_track_id, source_path, artwork_source_id, file_name, size, mtime,
added_at, modified_at
) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', NULL, 0, ?, ?)
) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', NULL, 0, ?, ?)
ON CONFLICT(path) DO UPDATE SET
title = excluded.title,
artist = excluded.artist,
album = excluded.album,
album_artist = excluded.album_artist,
album_identity_key = excluded.album_identity_key,
album_display_artist = excluded.album_display_artist,
duration = excluded.duration,
track_number = excluded.track_number,
disc_number = excluded.disc_number,
@@ -174,6 +180,7 @@ export async function upsertRemoteTracks(
row.album,
row.album_artist,
row.album_identity_key,
row.album_display_artist,
row.duration,
row.track_number,
row.disc_number,
@@ -223,28 +230,17 @@ export async function deleteRemoteTracksBySource(
return result.changes;
}
const TRACK_ORDER = 'COALESCE(disc_number, 9999), COALESCE(track_number, 9999), title COLLATE NOCASE';
// Desktop track order (library.ts compareTracksByDiscTrackTitle): nulls sort
// first (as 0) and path is the final tiebreak. Keep in sync with the JS
// comparator in src/library/albumIdentity.ts and astra-car's Kotlin TRACK_ORDER.
const TRACK_ORDER =
'COALESCE(disc_number, 0), COALESCE(track_number, 0), title COLLATE NOCASE, path';
export function getAlbums(db: LibraryDatabase): Promise<Album[]> {
return db.all<Album>(`
SELECT album_identity_key AS identity_key,
MAX(album) AS album,
MAX(COALESCE(album_artist, artist)) AS artist,
MAX(year) AS year,
MAX(artwork_hash) AS artwork_hash,
COUNT(*) AS track_count,
MAX(added_at) AS latest_added_at,
MAX(source_type) AS source_type,
MAX(source_id) AS source_id,
MAX(artwork_source_id) AS artwork_source_id
FROM tracks
GROUP BY album_identity_key
ORDER BY 3 COLLATE NOCASE, 2 COLLATE NOCASE
`);
}
// NOTE: the artist browse list is built in JS (src/library/artistGrouping.ts) so it
// can honor the astra-grouping vs file-tags mode; there is no SQL getArtists anymore.
// NOTE: the album browse list is built in JS (src/library/albumSummary.ts) — the
// desktop-parity display picks (most-frequent name/artwork variants, settled
// display artist, singles eligibility) don't map cleanly onto GROUP BY aggregates.
// The artist browse list is likewise built in JS (src/library/artistGrouping.ts)
// so it can honor the astra-grouping vs file-tags mode.
export function getAllTracks(db: LibraryDatabase): Promise<DbTrack[]> {
return db.all<DbTrack>(`
+9 -2
View File
@@ -17,11 +17,15 @@
// so remote playlist sync can upsert + reconcile them; v13 adds `remote_sources.art_auth`
// — a self-contained cover-art URL template the native Android Auto artwork provider uses
// to fetch server art without a JS round-trip; v14 adds desktop-style dynamic
// playlist rules plus fresh aggregate track play stats (no playback_history backfill).
// playlist rules plus fresh aggregate track play stats (no playback_history backfill);
// v15 adds `album_display_artist` — the settled group artist ("Various Artists" for
// shared-artwork compilations) written by the album-identity recompute pass
// (src/library/albumIdentity.ts); the backfill itself runs from libraryStore.initialize
// via the `album_grouping_version` settings sentinel (v3 precedent: SQL marks, store acts).
import type { LibraryDatabase } from './database';
export const SCHEMA_VERSION = 14;
export const SCHEMA_VERSION = 15;
// One statement per entry — op-sqlite executes single statements.
const MIGRATIONS: readonly (readonly string[])[] = [
@@ -276,6 +280,9 @@ const MIGRATIONS: readonly (readonly string[])[] = [
`CREATE INDEX IF NOT EXISTS idx_tracks_last_played ON tracks(last_played_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_playlists_kind ON playlists(kind)`,
],
// v14 -> v15 — settled album display artist (see header). NULL until the
// startup recompute pass backfills it; readers fall back to album_artist/artist.
[`ALTER TABLE tracks ADD COLUMN album_display_artist TEXT`],
];
export async function migrate(db: LibraryDatabase): Promise<void> {
+1 -1
View File
@@ -9,7 +9,7 @@ export const ALBUM_SORT_LABELS: Record<AlbumSort, string> = {
year: 'Year',
};
/** 'artist' is the DB's native order (getAlbums); others sort a copy. */
/** 'artist' is buildAlbumList's native order (artist → album); others sort a copy. */
export function sortAlbums(albums: Album[], sort: AlbumSort): Album[] {
switch (sort) {
case 'artist':
+138
View File
@@ -0,0 +1,138 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildProvisionalAlbumIdentity,
compareTracksByDiscTrackTitle,
computeAlbumIdentityUpdates,
type AlbumIdentityRow,
} from './albumIdentity.ts';
let nextId = 1;
function createRow(
overrides: Partial<AlbumIdentityRow> & Pick<AlbumIdentityRow, 'album' | 'artist'>
): AlbumIdentityRow {
return {
id: overrides.id ?? nextId++,
album: overrides.album,
artist: overrides.artist,
album_artist: overrides.album_artist ?? null,
artwork_hash: overrides.artwork_hash ?? null,
source_type: overrides.source_type ?? 'local',
artwork_source_id: overrides.artwork_source_id ?? null,
album_identity_key: overrides.album_identity_key ?? 'stale|key',
album_display_artist: overrides.album_display_artist ?? null,
};
}
test('provisional identity matches the grouped identity for explicit album artists', () => {
const provisional = buildProvisionalAlbumIdentity('Curator', 'Artist A', 'Mixtape');
const [update] = computeAlbumIdentityUpdates([
createRow({ album: 'Mixtape', artist: 'Artist A', album_artist: 'Curator' }),
]);
assert.equal(provisional.key, update.identityKey);
assert.equal(provisional.displayArtist, 'Curator');
assert.equal(update.displayArtist, 'Curator');
});
test('provisional identity uses the primary collaborator when album artist is missing', () => {
const provisional = buildProvisionalAlbumIdentity(null, 'Jane Remover feat. Venturing', 'teen week');
assert.equal(provisional.key, 'album:teen week::ta:jane remover');
assert.equal(provisional.displayArtist, 'Jane Remover');
});
test('recompute merges shared-cover multi-artist albums into a Various Artists group', () => {
const rows = [
createRow({ id: 1, album: 'Split Release', artist: 'Artist A', artwork_hash: 'shared' }),
createRow({ id: 2, album: 'Split Release', artist: 'Artist B', artwork_hash: 'shared' }),
];
const updates = computeAlbumIdentityUpdates(rows);
assert.equal(updates.length, 1);
assert.equal(updates[0].identityKey, 'album:split release::ah:shared');
assert.equal(updates[0].displayArtist, 'Various Artists');
assert.deepEqual(updates[0].ids.sort(), [1, 2]);
});
test('recompute returns only rows whose key or display artist changed', () => {
const rows = [
createRow({
id: 1,
album: 'Mixtape',
artist: 'Artist A',
album_artist: 'Curator',
album_identity_key: 'album:mixtape::aa:curator',
album_display_artist: 'Curator',
}),
createRow({
id: 2,
album: 'Mixtape',
artist: 'Artist B',
album_artist: 'Curator',
album_identity_key: 'album:mixtape::aa:curator',
album_display_artist: null, // display artist not yet settled
}),
];
const updates = computeAlbumIdentityUpdates(rows);
assert.equal(updates.length, 1);
assert.deepEqual(updates[0].ids, [2]);
assert.equal(updates[0].displayArtist, 'Curator');
});
test('remote rows use the album-scoped cover-art id as the shared-artwork signal', () => {
const rows = [
createRow({
id: 1,
album: 'Server Comp',
artist: 'Artist A',
source_type: 'subsonic',
artwork_source_id: 'al-77',
}),
createRow({
id: 2,
album: 'Server Comp',
artist: 'Artist B',
source_type: 'subsonic',
artwork_source_id: 'al-77',
}),
];
const updates = computeAlbumIdentityUpdates(rows);
assert.equal(updates.length, 1);
assert.equal(updates[0].displayArtist, 'Various Artists');
assert.equal(updates[0].identityKey, 'album:server comp::ah:al-77');
});
test('local rows without artwork stay split per artist (desktop parity)', () => {
const rows = [
createRow({ id: 1, album: 'Split Release', artist: 'Artist A' }),
createRow({ id: 2, album: 'Split Release', artist: 'Artist B' }),
];
const updates = computeAlbumIdentityUpdates(rows);
assert.equal(updates.length, 2);
const artists = updates.map((update) => update.displayArtist).sort();
assert.deepEqual(artists, ['Artist A', 'Artist B']);
});
test('track comparator orders disc/track nulls first with title and path tiebreaks', () => {
const tracks = [
{ disc_number: 2, track_number: 1, title: 'D2T1', path: 'e' },
{ disc_number: null, track_number: 2, title: 'NoDisc2', path: 'd' },
{ disc_number: 1, track_number: null, title: 'B', path: 'c' },
{ disc_number: 1, track_number: null, title: 'a', path: 'b' },
{ disc_number: 1, track_number: null, title: 'a', path: 'a' },
];
const sorted = [...tracks].sort(compareTracksByDiscTrackTitle);
assert.deepEqual(
sorted.map((track) => track.path),
['d', 'a', 'b', 'c', 'e']
);
});
+166
View File
@@ -0,0 +1,166 @@
// Album identity orchestration around the shared desktop-parity grouping
// algorithm (src/shared/library/albumGrouping.ts).
//
// The identity key is a *derived, stored* column: Android Auto (astra-car)
// groups albums in Kotlin SQL, so the truth has to live in the tracks table.
// Upserts write a provisional per-track key (tier 1/3 — correct for anything
// with an ALBUMARTIST or a single-artist album); the whole-library recompute
// pass afterwards applies the cross-track compilation heuristic (tier 2) and
// settles the group display artist.
//
// Runtime imports are relative (not '@/') so this module runs under plain
// `node --test` like the shared modules it wraps.
import {
buildAlbumIdentityKeyFromTrack,
getPrimaryArtistFromTrackArtist,
groupTracksByAlbumIdentity,
normalizeDisplay,
} from '../shared/library/albumGrouping.ts';
import type { LibraryDatabase, SqlParams } from '../db/database';
export interface ProvisionalAlbumIdentity {
key: string;
displayArtist: string;
}
/**
* Per-track identity for upsert time, before the whole-library pass runs.
* Matches the group the recompute would assign for tier-1 (explicit album
* artist) and tier-3 (single-artist bucket) tracks; tier-2 compilations are
* only discoverable across tracks and get corrected by the recompute.
*/
export function buildProvisionalAlbumIdentity(
albumArtist: string | null,
artist: string,
album: string
): ProvisionalAlbumIdentity {
const key = buildAlbumIdentityKeyFromTrack({ album, artist, album_artist: albumArtist });
const normalizedAlbumArtist = normalizeDisplay(albumArtist ?? '');
const displayArtist = normalizedAlbumArtist || getPrimaryArtistFromTrackArtist(artist);
return { key, displayArtist };
}
/** Minimal row shape the recompute reads from the tracks table. */
export interface AlbumIdentityRow {
id: number;
album: string;
artist: string;
album_artist: string | null;
artwork_hash: string | null;
source_type: string;
artwork_source_id: string | null;
album_identity_key: string;
album_display_artist: string | null;
}
export interface AlbumIdentityUpdate {
identityKey: string;
displayArtist: string;
ids: number[];
}
/**
* Pure diff: run the shared grouping over all rows and return only the groups
* whose stored key or display artist changed. Remote rows have no cached
* artwork_hash; their server cover-art id is album-scoped on both Subsonic and
* Jellyfin, so it serves as the same shared-artwork signal for tier 2.
*/
export function computeAlbumIdentityUpdates(
rows: readonly AlbumIdentityRow[]
): AlbumIdentityUpdate[] {
const adapted = rows.map((row) => ({
row,
album: row.album,
artist: row.artist,
album_artist: row.album_artist,
base_artwork_hash:
row.artwork_hash ?? (row.source_type !== 'local' ? row.artwork_source_id : null),
}));
const groups = groupTracksByAlbumIdentity(adapted, (track) => String(track.row.id));
const updates: AlbumIdentityUpdate[] = [];
for (const group of groups.values()) {
const ids: number[] = [];
for (const track of group.tracks) {
if (
track.row.album_identity_key !== group.identityKey ||
track.row.album_display_artist !== group.displayArtist
) {
ids.push(track.row.id);
}
}
if (ids.length > 0) {
updates.push({ identityKey: group.identityKey, displayArtist: group.displayArtist, ids });
}
}
return updates;
}
/**
* Whole-library recompute: settle every track's album_identity_key and
* album_display_artist. Runs after scans, folder/source removals, remote
* syncs, and once at startup when the grouping algorithm version changes.
* Returns the number of updated rows.
*/
export async function recomputeAlbumIdentity(db: LibraryDatabase): Promise<number> {
const rows = await db.all<AlbumIdentityRow>(
`SELECT id, album, artist, album_artist, artwork_hash, source_type,
artwork_source_id, album_identity_key, album_display_artist
FROM tracks`
);
const updates = computeAlbumIdentityUpdates(rows);
if (updates.length === 0) return 0;
let changed = 0;
await db.transaction(async (tx) => {
for (const update of updates) {
for (let i = 0; i < update.ids.length; i += 500) {
const chunk = update.ids.slice(i, i + 500);
const placeholders = chunk.map(() => '?').join(', ');
await tx.run(
`UPDATE tracks SET album_identity_key = ?, album_display_artist = ?
WHERE id IN (${placeholders})`,
[update.identityKey, update.displayArtist, ...chunk] as SqlParams
);
changed += chunk.length;
}
}
});
return changed;
}
/**
* Desktop track order within an album (library.ts compareTracksByDiscTrackTitle):
* disc (null=0) → track (null=0) → title (base sensitivity) → path. Store-level
* track lists are artist-ordered, so album screens must sort their filtered
* slice with this — a compilation's tracks would otherwise come out grouped by
* artist instead of running disc/track order.
*/
export function compareTracksByDiscTrackTitle(
a: Pick<AlbumTrackOrderLike, 'disc_number' | 'track_number' | 'title' | 'path'>,
b: Pick<AlbumTrackOrderLike, 'disc_number' | 'track_number' | 'title' | 'path'>
): number {
const discA = a.disc_number ?? 0;
const discB = b.disc_number ?? 0;
if (discA !== discB) return discA - discB;
const trackA = a.track_number ?? 0;
const trackB = b.track_number ?? 0;
if (trackA !== trackB) return trackA - trackB;
const titleCompare = normalizeDisplay(a.title).localeCompare(normalizeDisplay(b.title), undefined, {
sensitivity: 'base',
});
if (titleCompare !== 0) return titleCompare;
return a.path.localeCompare(b.path);
}
export interface AlbumTrackOrderLike {
disc_number: number | null;
track_number: number | null;
title: string;
path: string;
}
+131
View File
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildAlbumList, type AlbumSummaryTrackLike } from './albumSummary.ts';
let nextAddedAt = 1_000;
function createTrack(
overrides: Partial<AlbumSummaryTrackLike> &
Pick<AlbumSummaryTrackLike, 'album_identity_key' | 'album' | 'artist'>
): AlbumSummaryTrackLike {
return {
album_identity_key: overrides.album_identity_key,
album: overrides.album,
artist: overrides.artist,
album_artist: overrides.album_artist ?? null,
album_display_artist: overrides.album_display_artist ?? null,
year: overrides.year ?? null,
artwork_hash: overrides.artwork_hash ?? null,
added_at: overrides.added_at ?? nextAddedAt++,
source_type: overrides.source_type ?? 'local',
source_id: overrides.source_id ?? null,
artwork_source_id: overrides.artwork_source_id ?? null,
};
}
test('groups by stored identity key and uses the settled display artist', () => {
const key = 'album:split release::ah:shared';
const albums = buildAlbumList([
createTrack({ album_identity_key: key, album: 'Split Release', artist: 'Artist A', album_display_artist: 'Various Artists' }),
createTrack({ album_identity_key: key, album: 'Split Release', artist: 'Artist B', album_display_artist: 'Various Artists' }),
]);
assert.equal(albums.length, 1);
assert.equal(albums[0].identity_key, key);
assert.equal(albums[0].artist, 'Various Artists');
assert.equal(albums[0].track_count, 2);
});
test('picks the most frequent album-name variant with lexicographic tiebreak', () => {
const key = 'album:ok computer::aa:radiohead';
const albums = buildAlbumList([
createTrack({ album_identity_key: key, album: 'OK Computer', artist: 'Radiohead', album_display_artist: 'Radiohead' }),
createTrack({ album_identity_key: key, album: 'OK COMPUTER', artist: 'Radiohead', album_display_artist: 'Radiohead' }),
createTrack({ album_identity_key: key, album: 'OK Computer', artist: 'Radiohead', album_display_artist: 'Radiohead' }),
]);
assert.equal(albums[0].album, 'OK Computer');
});
test('picks the most frequent artwork hash, max year, and latest added_at', () => {
const key = 'album:x::aa:y';
const albums = buildAlbumList([
createTrack({ album_identity_key: key, album: 'X', artist: 'Y', album_display_artist: 'Y', artwork_hash: 'h1', year: 2001, added_at: 10 }),
createTrack({ album_identity_key: key, album: 'X', artist: 'Y', album_display_artist: 'Y', artwork_hash: 'h2', year: 2003, added_at: 30 }),
createTrack({ album_identity_key: key, album: 'X', artist: 'Y', album_display_artist: 'Y', artwork_hash: 'h2', year: 2002, added_at: 20 }),
]);
assert.equal(albums[0].artwork_hash, 'h2');
assert.equal(albums[0].year, 2003);
assert.equal(albums[0].latest_added_at, 30);
});
test('excludes singles by default and includes them when enabled', () => {
const tracks = [
createTrack({ album_identity_key: 'album:solo::ta:a', album: 'Solo', artist: 'A' }),
createTrack({ album_identity_key: 'album:full::ta:b', album: 'Full', artist: 'B' }),
createTrack({ album_identity_key: 'album:full::ta:b', album: 'Full', artist: 'B' }),
];
const defaults = buildAlbumList(tracks);
assert.deepEqual(defaults.map((album) => album.album), ['Full']);
const withSingles = buildAlbumList(tracks, { includeSingles: true });
assert.deepEqual(withSingles.map((album) => album.album).sort(), ['Full', 'Solo']);
});
test('always excludes Unknown Album, even with singles enabled', () => {
const tracks = [
createTrack({ album_identity_key: 'album:unknown album::ta:a', album: '', artist: 'A' }),
createTrack({ album_identity_key: 'album:unknown album::ta:a', album: '', artist: 'A' }),
];
assert.equal(buildAlbumList(tracks).length, 0);
assert.equal(buildAlbumList(tracks, { includeSingles: true }).length, 0);
});
test('falls back to tag artist when display artist is not yet settled', () => {
const key = 'album:x::aa:curator';
const albums = buildAlbumList([
createTrack({ album_identity_key: key, album: 'X', artist: 'A', album_artist: 'Curator' }),
createTrack({ album_identity_key: key, album: 'X', artist: 'B', album_artist: 'Curator' }),
]);
assert.equal(albums[0].artist, 'Curator');
});
test('carries representative remote-source linkage from the first remote track', () => {
const key = 'album:remote::aa:z';
const albums = buildAlbumList([
createTrack({ album_identity_key: key, album: 'Remote', artist: 'Z', album_display_artist: 'Z' }),
createTrack({
album_identity_key: key,
album: 'Remote',
artist: 'Z',
album_display_artist: 'Z',
source_type: 'subsonic',
source_id: 3,
artwork_source_id: 'al-9',
}),
]);
assert.equal(albums[0].source_type, 'subsonic');
assert.equal(albums[0].source_id, 3);
assert.equal(albums[0].artwork_source_id, 'al-9');
});
test('sorts by artist then album, base sensitivity', () => {
const albums = buildAlbumList([
createTrack({ album_identity_key: 'k1', album: 'Beta', artist: 'zeta', album_display_artist: 'zeta' }),
createTrack({ album_identity_key: 'k1', album: 'Beta', artist: 'zeta', album_display_artist: 'zeta' }),
createTrack({ album_identity_key: 'k2', album: 'Alpha', artist: 'Alpha Artist', album_display_artist: 'Alpha Artist' }),
createTrack({ album_identity_key: 'k2', album: 'Alpha', artist: 'Alpha Artist', album_display_artist: 'Alpha Artist' }),
createTrack({ album_identity_key: 'k3', album: 'Alpha', artist: 'zeta', album_display_artist: 'zeta' }),
createTrack({ album_identity_key: 'k3', album: 'Alpha', artist: 'zeta', album_display_artist: 'zeta' }),
]);
assert.deepEqual(
albums.map((album) => `${album.artist}/${album.album}`),
['Alpha Artist/Alpha', 'zeta/Alpha', 'zeta/Beta']
);
});
+194
View File
@@ -0,0 +1,194 @@
// Album browse list built in JS from in-memory tracks (replaces SQL getAlbums),
// mirroring desktop getAlbums summaries: most-frequent display variants and a
// deterministic artwork pick instead of arbitrary MAX() aggregates, plus the
// desktop eligibility rules (no "Unknown Album"; singles behind a toggle).
//
// Groups by the *stored* album_identity_key — never re-runs the tier logic and
// never parses the key (album names can themselves contain "::"); the stored
// column is the single source of truth shared with Android Auto.
//
// Runtime imports are relative (not '@/') so this module runs under `node --test`.
import { normalizeAlbumName, normalizeKey } from '../shared/library/albumGrouping.ts';
import { isAlbumGroupEligible } from '../shared/library/albumEligibility.ts';
import type { Album, DbTrack } from '../types/library';
export interface AlbumSummaryOptions {
includeSingles?: boolean;
}
/** Track fields the summary reads (subset of DbTrack, for testability). */
export type AlbumSummaryTrackLike = Pick<
DbTrack,
| 'album_identity_key'
| 'album'
| 'artist'
| 'album_artist'
| 'album_display_artist'
| 'year'
| 'artwork_hash'
| 'added_at'
| 'source_type'
| 'source_id'
| 'artwork_source_id'
>;
interface CountedDisplayVariant {
display: string;
count: number;
}
function incrementDisplayVariant(map: Map<string, CountedDisplayVariant>, display: string): void {
const key = normalizeKey(display);
if (!key) return;
const existing = map.get(key);
if (existing) {
existing.count += 1;
return;
}
map.set(key, { display, count: 1 });
}
// Desktop library.ts pickMostFrequentDisplayVariant: highest count, ties go to
// the lexicographically smallest display string (deterministic across scans).
function pickMostFrequentDisplayVariant(
map: Map<string, CountedDisplayVariant>,
fallback: string
): string {
let best: CountedDisplayVariant | null = null;
for (const variant of map.values()) {
if (!best || variant.count > best.count) {
best = variant;
continue;
}
if (
variant.count === best.count &&
variant.display.localeCompare(best.display, undefined, { sensitivity: 'base' }) < 0
) {
best = variant;
}
}
return best?.display ?? fallback;
}
// Desktop library.ts pickMostFrequentArtworkHash.
function pickMostFrequentArtworkHash(
artworkCounts: Map<string, number>,
fallback: string | null
): string | null {
let bestHash: string | null = null;
let bestCount = -1;
for (const [hash, count] of artworkCounts.entries()) {
if (count > bestCount) {
bestHash = hash;
bestCount = count;
continue;
}
if (count === bestCount && bestHash && hash.localeCompare(bestHash) < 0) {
bestHash = hash;
}
}
return bestHash ?? fallback;
}
interface AlbumAggregate {
albumKey: string;
albumVariants: Map<string, CountedDisplayVariant>;
displayArtist: string | null;
fallbackArtist: string;
year: number | null;
artworkCounts: Map<string, number>;
firstArtworkHash: string | null;
trackCount: number;
latestAddedAt: number;
sourceType: DbTrack['source_type'];
sourceId: number | null;
artworkSourceId: string | null;
}
export function buildAlbumList(
tracks: readonly AlbumSummaryTrackLike[],
options: AlbumSummaryOptions = {}
): Album[] {
const byKey = new Map<string, AlbumAggregate>();
for (const track of tracks) {
let aggregate = byKey.get(track.album_identity_key);
if (!aggregate) {
aggregate = {
// Uniform across the group by construction: every grouping tier keys
// on the normalized album name, so the first track's key is the key.
albumKey: normalizeKey(normalizeAlbumName(track.album)),
albumVariants: new Map(),
displayArtist: null,
fallbackArtist: track.album_artist ?? track.artist,
year: null,
artworkCounts: new Map(),
firstArtworkHash: null,
trackCount: 0,
latestAddedAt: track.added_at,
sourceType: 'local',
sourceId: null,
artworkSourceId: null,
};
byKey.set(track.album_identity_key, aggregate);
}
aggregate.trackCount += 1;
incrementDisplayVariant(aggregate.albumVariants, normalizeAlbumName(track.album));
// Uniform within a group after the recompute pass; provisional rows may
// still be null, hence the first-non-null pick + tag fallback.
if (aggregate.displayArtist == null && track.album_display_artist) {
aggregate.displayArtist = track.album_display_artist;
}
if (track.year != null && (aggregate.year == null || track.year > aggregate.year)) {
aggregate.year = track.year;
}
if (track.artwork_hash) {
if (!aggregate.firstArtworkHash) aggregate.firstArtworkHash = track.artwork_hash;
aggregate.artworkCounts.set(
track.artwork_hash,
(aggregate.artworkCounts.get(track.artwork_hash) ?? 0) + 1
);
}
if (track.added_at > aggregate.latestAddedAt) aggregate.latestAddedAt = track.added_at;
// Representative remote-source linkage: first remote track carries it.
if (aggregate.sourceType === 'local' && track.source_type !== 'local') {
aggregate.sourceType = track.source_type;
aggregate.sourceId = track.source_id;
aggregate.artworkSourceId = track.artwork_source_id;
}
}
const albums: Album[] = [];
for (const [identityKey, aggregate] of byKey.entries()) {
if (!isAlbumGroupEligible(
{ albumKey: aggregate.albumKey, trackCount: aggregate.trackCount },
{ includeSingles: options.includeSingles }
)) {
continue;
}
albums.push({
identity_key: identityKey,
album: pickMostFrequentDisplayVariant(aggregate.albumVariants, 'Unknown Album'),
artist: aggregate.displayArtist ?? aggregate.fallbackArtist,
year: aggregate.year,
artwork_hash: pickMostFrequentArtworkHash(aggregate.artworkCounts, aggregate.firstArtworkHash),
track_count: aggregate.trackCount,
latest_added_at: aggregate.latestAddedAt,
source_type: aggregate.sourceType,
source_id: aggregate.sourceId,
artwork_source_id: aggregate.artworkSourceId,
});
}
// Artist → album, matching the old SQL getAlbums ORDER BY: the albums view's
// 'artist' sort mode uses this native order (src/lib/albumSort.ts).
return albums.sort((a, b) => {
const artistCompare = a.artist.localeCompare(b.artist, undefined, { sensitivity: 'base' });
if (artistCompare !== 0) return artistCompare;
return a.album.localeCompare(b.album, undefined, { sensitivity: 'base' });
});
}
+23 -22
View File
@@ -9,11 +9,17 @@
// so desktop's parsed-array paths collapse to splitCollaborators(artist) — which is
// the parsing heuristic. Everything here is derivable from artist + album_artist.
import { normalizeDisplay, normalizeKey, splitCollaborators } from '@/shared/library/albumGrouping';
import type { Artist, DbTrack } from '@/types/library';
// Shared with the album-identity port so artist and album grouping can never
// drift apart on normalization or collaborator splitting.
export { normalizeDisplay, normalizeKey, splitCollaborators };
export type ArtistGroupingMode = 'astra' | 'fileTags';
const UNKNOWN_ARTIST = 'Unknown Artist';
const VARIOUS_ARTISTS_KEY = 'various artists';
/** Track fields the grouping logic reads (subset of DbTrack, for testability). */
type ArtistTrackLike = Pick<
@@ -21,28 +27,6 @@ type ArtistTrackLike = Pick<
'artist' | 'album_artist' | 'artwork_hash' | 'year' | 'added_at' | 'modified_at' | 'album_identity_key'
>;
export function normalizeDisplay(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
export function normalizeKey(value: string): string {
return normalizeDisplay(value).toLocaleLowerCase();
}
/** Split "A & B feat. C; D" into ["A","B","C","D"], deduped by normalized key. */
export function splitCollaborators(rawArtist: string): string[] {
const normalized = normalizeDisplay(rawArtist);
if (!normalized) return [];
const unified = normalized
.replace(/\s*;\s*/g, ',')
.replace(/\s+&\s+/g, ',')
.replace(/\s+[x×]\s+/gi, ',')
.replace(/\s+(?:feat\.?|ft\.?|featuring|with)\s+/gi, ',');
return dedupeByKey(unified.split(','));
}
/** Like splitCollaborators but keeps "&" (e.g. "Earth, Wind & Fire" stays whole). */
export function splitAlbumArtistCollaborators(rawAlbumArtist: string): string[] {
const normalized = normalizeDisplay(rawAlbumArtist);
@@ -105,6 +89,23 @@ export function getCanonicalArtistIndexNames(track: Pick<DbTrack, 'artist' | 'al
return Array.from(unique.values());
}
/**
* Per-track "View artist" destination. "Various Artists" is an album-level
* placeholder, not a person in astra mode fall through to the track's own
* primary artist (desktop parity: album artist links are nulled when they
* normalize to Various Artists). File-tags mode keeps the tag verbatim: the VA
* bucket is the only artist page that lists those tracks in that mode.
*/
export function resolveNavigationArtist(
track: Pick<DbTrack, 'artist' | 'album_artist'>,
mode: ArtistGroupingMode
): string {
if (mode === 'fileTags') return resolveStrictBrowseArtist(track);
const canonical = resolveCanonicalBrowseArtist(track);
if (normalizeKey(canonical) !== VARIOUS_ARTISTS_KEY) return canonical;
return splitCollaborators(track.artist)[0] ?? canonical;
}
/** Whether a track belongs to the given artist key under the active browse mode. */
export function trackMatchesBrowseArtist(
track: Pick<DbTrack, 'artist' | 'album_artist'>,
+10 -3
View File
@@ -10,7 +10,7 @@ import {
type RemoteTrackUpsert,
} from '@/db/queries';
import { addFavoritePaths, syncRemotePlaylists } from '@/db/playlistQueries';
import { buildAlbumIdentityKey } from '@/library/trackAdapter';
import { buildProvisionalAlbumIdentity, recomputeAlbumIdentity } from '@/library/albumIdentity';
import {
buildSubsonicTrackPath,
fetchSubsonicStarredTrackIds,
@@ -40,6 +40,9 @@ export interface SyncRemoteOptions {
}
function toUpsertRow(source: RemoteSourceRow, track: RemoteCatalogTrack): RemoteTrackUpsert {
// Same album identity rule as local tracks so remote/local albums group consistently;
// the post-sync recompute settles cross-track compilations.
const albumIdentity = buildProvisionalAlbumIdentity(track.album_artist, track.artist, track.album);
return {
path: track.path,
source_type: source.type,
@@ -51,8 +54,8 @@ function toUpsertRow(source: RemoteSourceRow, track: RemoteCatalogTrack): Remote
artist: track.artist,
album: track.album,
album_artist: track.album_artist,
// Same album identity rule as local tracks so remote/local albums group consistently.
album_identity_key: buildAlbumIdentityKey(track.album_artist, track.artist, track.album),
album_identity_key: albumIdentity.key,
album_display_artist: albumIdentity.displayArtist,
duration: track.duration,
track_number: track.track_number,
disc_number: track.disc_number,
@@ -113,6 +116,10 @@ export async function syncRemoteSource(
const toDelete = existing.map((row) => row.path).filter((path) => !currentPaths.has(path));
const removed = toDelete.length > 0 ? await deleteTracksByPaths(db, toDelete) : 0;
// Settle album identities across the whole library (compilation heuristic is
// cross-track; additions AND removals can change grouping).
await recomputeAlbumIdentity(db);
// Subsonic also exposes server favorites + playlists; mirror them into the local
// favorites/playlists tables (must run after the track upsert so paths resolve).
if (source.type === 'subsonic') {
+9
View File
@@ -18,6 +18,7 @@ import {
} from '@/db/queries';
import type { LibraryFolder } from '@/types/library';
import { AUDIO_EXTENSIONS } from './audioExtensions';
import { recomputeAlbumIdentity } from './albumIdentity';
import { metadataToUpsertRow } from './trackAdapter';
const EXTRACT_BATCH_SIZE = 24;
@@ -158,6 +159,11 @@ export async function scanFolder(
await markFolderScanned(db, folder.id);
// Settle album identities: the compilation heuristic is cross-track, so adds,
// re-extractions AND removals can regroup albums. Upserts only wrote
// provisional per-track keys.
await recomputeAlbumIdentity(db);
// Loudness + waveform are measured on the fly: the first time a track is played,
// useNormalizationSync (loudness) and the seek bar (waveform) decode + cache it.
// No bulk background decoding — gentle on low-end devices.
@@ -185,5 +191,8 @@ export async function rescanAll(
export async function removeFolder(folder: Pick<LibraryFolder, 'id' | 'tree_uri'>): Promise<void> {
const db = await openLibraryDb();
await deleteFolder(db, folder.id);
// Removals can dissolve compilations (e.g. a Various Artists group reduced to
// one artist's tracks must fall back to a track-artist group).
await recomputeAlbumIdentity(db);
await AstraLibraryScanner.releasePersistedUriPermission(folder.tree_uri);
}
+4 -19
View File
@@ -7,29 +7,12 @@ import type { TrackUpsert } from '@/db/queries';
import type { ExtractedMetadata, ScannedFile } from '../../modules/astra-library-scanner';
import { artworkUri } from './artwork';
import { artworkUrlForTrack } from '@/services/remoteUrls';
import { buildProvisionalAlbumIdentity } from './albumIdentity';
import { repairMojibakeTag } from './tagEncoding';
const UNKNOWN_ARTIST = 'Unknown Artist';
const UNKNOWN_ALBUM = 'Unknown Album';
// Ports desktop normalizeDisplay/normalizeKey (library.ts:1023).
function normalizeKey(value: string): string {
return value.replace(/\s+/g, ' ').trim().toLocaleLowerCase();
}
// M1-simple album identity: normalized "<album artist or artist>|<album>".
// Stored per track so getAlbums is a plain GROUP BY; the desktop compilation
// heuristic can land later by recomputing this column.
export function buildAlbumIdentityKey(
albumArtist: string | null,
artist: string,
album: string
): string {
const artistKey = normalizeKey(albumArtist || artist) || 'unknown artist';
const albumKey = normalizeKey(album) || 'unknown album';
return `${artistKey}|${albumKey}`;
}
const CODEC_BY_MIME: Record<string, string> = {
'audio/flac': 'flac',
'audio/mpeg': 'mp3',
@@ -75,6 +58,7 @@ export function metadataToUpsertRow(
const artist = cleanTag(meta.artist) ?? UNKNOWN_ARTIST;
const album = cleanTag(meta.album) ?? UNKNOWN_ALBUM;
const albumArtist = cleanTag(meta.albumArtist);
const albumIdentity = buildProvisionalAlbumIdentity(albumArtist, artist, album);
return {
path: file.uri,
@@ -83,7 +67,8 @@ export function metadataToUpsertRow(
artist,
album,
album_artist: albumArtist,
album_identity_key: buildAlbumIdentityKey(albumArtist, artist, album),
album_identity_key: albumIdentity.key,
album_display_artist: albumIdentity.displayArtist,
duration: meta.durationMs != null ? meta.durationMs / 1000 : 0,
track_number: meta.trackNumber ?? null,
disc_number: meta.discNumber ?? null,
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isAlbumGroupEligible, type AlbumEligibilityGroupLike } from './albumEligibility.ts';
function createGroup(overrides: Partial<AlbumEligibilityGroupLike> = {}): AlbumEligibilityGroupLike {
return {
albumKey: 'test album',
trackCount: 2,
...overrides,
};
}
test('excludes one-track named albums by default', () => {
assert.equal(isAlbumGroupEligible(createGroup({ trackCount: 1 })), false);
});
test('includes one-track named albums when singles are enabled', () => {
assert.equal(isAlbumGroupEligible(createGroup({ trackCount: 1 }), { includeSingles: true }), true);
});
test('excludes unknown albums even when singles are enabled', () => {
assert.equal(
isAlbumGroupEligible(createGroup({ albumKey: 'unknown album', trackCount: 1 }), { includeSingles: true }),
false
);
assert.equal(isAlbumGroupEligible(createGroup({ albumKey: 'unknown album', trackCount: 3 })), false);
});
test('includes multi-track named albums', () => {
assert.equal(isAlbumGroupEligible(createGroup({ trackCount: 2 })), true);
});
+22
View File
@@ -0,0 +1,22 @@
// Port of desktop astra/src/shared/library/albumEligibility.ts — keep semantically identical.
export interface AlbumEligibilityGroupLike {
albumKey: string;
trackCount: number;
}
export interface AlbumEligibilityOptions {
includeSingles?: boolean;
}
const UNKNOWN_ALBUM_KEY = 'unknown album';
const MIN_TRACKS_FOR_ALBUM = 2;
export function isAlbumGroupEligible(
group: AlbumEligibilityGroupLike,
options: AlbumEligibilityOptions = {}
): boolean {
if (group.albumKey === UNKNOWN_ALBUM_KEY) return false;
if (!options.includeSingles && group.trackCount < MIN_TRACKS_FOR_ALBUM) return false;
return true;
}
+150
View File
@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildAlbumIdentityKeyByTrackId,
groupTracksByAlbumIdentity,
type AlbumIdentityTrackLike,
} from './albumGrouping.ts';
interface TestTrack extends AlbumIdentityTrackLike {
id: string;
}
function createTrack(overrides: Partial<TestTrack> & Pick<TestTrack, 'id' | 'album' | 'artist'>): TestTrack {
return {
id: overrides.id,
album: overrides.album,
artist: overrides.artist,
artist_names: overrides.artist_names ?? null,
album_artist: overrides.album_artist ?? null,
album_artist_names: overrides.album_artist_names ?? null,
artwork_hash: overrides.artwork_hash ?? null,
base_artwork_hash: overrides.base_artwork_hash ?? null,
};
}
function groupTracks(tracks: TestTrack[]) {
return groupTracksByAlbumIdentity(tracks, (track) => track.id);
}
test('groups missing-albumartist tracks by primary artist even when artwork differs', () => {
const tracks = [
createTrack({ id: '1', album: 'teen week', artist: 'Jane Remover', base_artwork_hash: 'cover-a' }),
createTrack({ id: '2', album: 'teen week', artist: 'Jane Remover feat. Venturing', base_artwork_hash: 'cover-b' }),
];
const groups = groupTracks(tracks);
assert.equal(groups.size, 1);
const [group] = Array.from(groups.values());
assert.equal(group.groupingMode, 'track-artist');
assert.equal(group.displayArtist, 'Jane Remover');
assert.deepEqual(group.tracks.map((track) => track.id), ['1', '2']);
});
test('uses parsed multi-value artist credits for primary artist grouping', () => {
const tracks = [
createTrack({
id: '1',
album: 'duets',
artist: 'Earth, Wind & Fire & The Emotions',
artist_names: ['Earth, Wind & Fire', 'The Emotions'],
base_artwork_hash: 'cover-a',
}),
createTrack({
id: '2',
album: 'duets',
artist: 'Earth, Wind & Fire & The Emotions',
artist_names: ['Earth, Wind & Fire', 'The Emotions'],
base_artwork_hash: 'cover-b',
}),
];
const groups = groupTracks(tracks);
assert.equal(groups.size, 1);
const [group] = Array.from(groups.values());
assert.equal(group.displayArtist, 'Earth, Wind & Fire');
assert.deepEqual(group.tracks.map((track) => track.id), ['1', '2']);
});
test('collapses shared-cover multi-artist albums into a single compilation group when albumartist is missing', () => {
const tracks = [
createTrack({ id: '1', album: 'split release', artist: 'Artist A', base_artwork_hash: 'shared-cover' }),
createTrack({ id: '2', album: 'split release', artist: 'Artist B', base_artwork_hash: 'shared-cover' }),
];
const groups = groupTracks(tracks);
assert.equal(groups.size, 1);
const [group] = Array.from(groups.values());
assert.equal(group.groupingMode, 'shared-artwork-compilation');
assert.equal(group.displayArtist, 'Various Artists');
assert.deepEqual(group.tracks.map((track) => track.id), ['1', '2']);
});
test('keeps missing-albumartist multi-artist albums separate when artwork differs', () => {
const tracks = [
createTrack({ id: '1', album: 'split release', artist: 'Artist A', base_artwork_hash: 'cover-a' }),
createTrack({ id: '2', album: 'split release', artist: 'Artist B', base_artwork_hash: 'cover-b' }),
];
const groups = groupTracks(tracks);
assert.equal(groups.size, 2);
const groupedArtists = Array.from(groups.values()).map((group) => group.displayArtist).sort();
assert.deepEqual(groupedArtists, ['Artist A', 'Artist B']);
assert.ok(Array.from(groups.values()).every((group) => group.groupingMode === 'track-artist'));
});
test('respects explicit albumartist even when per-track artwork differs', () => {
const tracks = [
createTrack({
id: '1',
album: 'mixtape',
artist: 'Artist A',
album_artist: 'Curator',
base_artwork_hash: 'cover-a',
}),
createTrack({
id: '2',
album: 'mixtape',
artist: 'Artist B',
album_artist: 'Curator',
base_artwork_hash: 'cover-b',
}),
];
const groups = groupTracks(tracks);
assert.equal(groups.size, 1);
const [group] = Array.from(groups.values());
assert.equal(group.groupingMode, 'explicit-album-artist');
assert.equal(group.displayArtist, 'Curator');
assert.deepEqual(group.tracks.map((track) => track.id), ['1', '2']);
});
test('produces per-track canonical identity keys that match the grouped album identities', () => {
const tracks = [
createTrack({ id: '1', album: 'teen week', artist: 'Jane Remover', base_artwork_hash: 'cover-a' }),
createTrack({ id: '2', album: 'teen week', artist: 'Jane Remover feat. Venturing', base_artwork_hash: 'cover-b' }),
createTrack({ id: '3', album: 'split release', artist: 'Artist A', base_artwork_hash: 'shared-cover' }),
createTrack({ id: '4', album: 'split release', artist: 'Artist B', base_artwork_hash: 'shared-cover' }),
createTrack({ id: '5', album: 'mixtape', artist: 'Artist C', album_artist: 'Curator', base_artwork_hash: 'cover-c' }),
];
const groups = groupTracks(tracks);
const keysByTrackId = buildAlbumIdentityKeyByTrackId(tracks, (track) => track.id);
const expectedKeysByTrackId = new Map<string, string>();
for (const [identityKey, group] of groups.entries()) {
for (const track of group.tracks) {
expectedKeysByTrackId.set(track.id, identityKey);
}
}
assert.deepEqual(
Object.fromEntries(Array.from(keysByTrackId.entries()).sort(([a], [b]) => a.localeCompare(b))),
Object.fromEntries(Array.from(expectedKeysByTrackId.entries()).sort(([a], [b]) => a.localeCompare(b)))
);
});
+249
View File
@@ -0,0 +1,249 @@
// Port of desktop astra/src/shared/library/albumGrouping.ts — keep semantically
// identical so album identities match the desktop app. Mobile has no
// artist_names/*_names_json columns; those inputs stay undefined and the module
// falls through to the string-splitting paths, exactly like desktop does for
// files without multi-value tags.
// Explicit .ts extension so the module resolves under plain `node --test`
// (Metro and tsc accept it via allowImportingTsExtensions).
import { deserializeArtistNames, formatArtistNames, normalizeArtistNames } from './artistCredits.ts';
export interface AlbumIdentityArtistTrackLike {
artist: string;
artist_names?: readonly string[] | null;
artist_names_json?: string | null;
album_artist?: string | null;
album_artist_names?: readonly string[] | null;
album_artist_names_json?: string | null;
}
export interface AlbumIdentityTrackLike extends AlbumIdentityArtistTrackLike {
album: string;
artwork_hash?: string | null;
base_artwork_hash?: string | null;
}
export type AlbumGroupingMode = 'explicit-album-artist' | 'shared-artwork-compilation' | 'track-artist';
export interface AlbumIdentityGroup<T> {
identityKey: string;
albumKey: string;
groupingMode: AlbumGroupingMode;
displayArtist: string;
tracks: T[];
}
const UNKNOWN_ALBUM_NAME = 'Unknown Album';
const UNKNOWN_ARTIST_NAME = 'Unknown Artist';
const VARIOUS_ARTISTS_NAME = 'Various Artists';
interface PreparedTrack<T> {
track: T;
trackId: string;
albumKey: string;
normalizedAlbumArtist: string;
primaryArtist: string;
primaryArtistKey: string;
artworkIdentityHash: string | null;
}
export function normalizeDisplay(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
export function normalizeKey(value: string): string {
return normalizeDisplay(value).toLocaleLowerCase();
}
export function normalizeAlbumName(album: string): string {
const normalized = normalizeDisplay(album);
return normalized || UNKNOWN_ALBUM_NAME;
}
export function normalizeArtworkHash(hash: string | null | undefined): string | null {
const normalized = normalizeDisplay(hash ?? '');
return normalized ? normalized.toLocaleLowerCase() : null;
}
export function splitCollaborators(rawArtist: string): string[] {
const normalized = normalizeDisplay(rawArtist);
if (!normalized) return [];
const unified = normalized
.replace(/\s*;\s*/g, ',')
.replace(/\s+&\s+/g, ',')
.replace(/\s+[x×]\s+/gi, ',')
.replace(/\s+(?:feat\.?|ft\.?|featuring|with)\s+/gi, ',');
const unique = new Map<string, string>();
for (const part of unified.split(',')) {
const display = normalizeDisplay(part);
if (!display) continue;
const key = normalizeKey(display);
if (!key || unique.has(key)) continue;
unique.set(key, display);
}
return Array.from(unique.values());
}
export function getPrimaryArtistFromTrackArtist(trackArtist: string): string {
const contributors = splitCollaborators(trackArtist);
return contributors[0] ?? UNKNOWN_ARTIST_NAME;
}
function getPrimaryArtistFromTrack<T extends AlbumIdentityArtistTrackLike>(track: T): string {
const parsedArtists = normalizeArtistNames(track.artist_names);
if (parsedArtists.length > 0) return parsedArtists[0];
const parsedArtistsJson = deserializeArtistNames(track.artist_names_json);
if (parsedArtistsJson.length > 0) return parsedArtistsJson[0];
return getPrimaryArtistFromTrackArtist(track.artist);
}
function getNormalizedAlbumArtist<T extends AlbumIdentityArtistTrackLike>(track: T): string {
const normalizedAlbumArtist = normalizeDisplay(track.album_artist ?? '');
if (normalizedAlbumArtist) return normalizedAlbumArtist;
const parsedAlbumArtists = normalizeArtistNames(track.album_artist_names);
if (parsedAlbumArtists.length > 0) return formatArtistNames(parsedAlbumArtists);
const parsedAlbumArtistsJson = deserializeArtistNames(track.album_artist_names_json);
if (parsedAlbumArtistsJson.length > 0) return formatArtistNames(parsedAlbumArtistsJson);
return '';
}
export function buildCanonicalAlbumIdentityKey(albumKey: string, discriminator: string): string {
return `album:${albumKey}::${discriminator}`;
}
export function buildAlbumIdentityKeyFromTrack(track: AlbumIdentityTrackLike): string {
const albumKey = normalizeKey(normalizeAlbumName(track.album));
const normalizedAlbumArtist = getNormalizedAlbumArtist(track);
if (normalizedAlbumArtist) {
const albumArtistKey = normalizeKey(normalizedAlbumArtist) || normalizeKey(UNKNOWN_ARTIST_NAME);
return buildCanonicalAlbumIdentityKey(albumKey, `aa:${albumArtistKey}`);
}
const primaryArtist = normalizeDisplay(getPrimaryArtistFromTrack(track)) || UNKNOWN_ARTIST_NAME;
const primaryArtistKey = normalizeKey(primaryArtist) || normalizeKey(UNKNOWN_ARTIST_NAME);
return buildCanonicalAlbumIdentityKey(albumKey, `ta:${primaryArtistKey}`);
}
function createAlbumIdentityGroup<T>(
identityKey: string,
albumKey: string,
groupingMode: AlbumGroupingMode,
displayArtist: string
): AlbumIdentityGroup<T> {
return {
identityKey,
albumKey,
groupingMode,
displayArtist,
tracks: [],
};
}
function resolveSharedArtworkHash<T>(tracks: readonly PreparedTrack<T>[]): string | null {
const firstHash = tracks[0]?.artworkIdentityHash ?? null;
if (!firstHash) return null;
for (const track of tracks) {
if (track.artworkIdentityHash !== firstHash) {
return null;
}
}
return firstHash;
}
export function groupTracksByAlbumIdentity<T extends AlbumIdentityTrackLike>(
tracks: readonly T[],
getTrackId: (track: T) => string
): Map<string, AlbumIdentityGroup<T>> {
const groups = new Map<string, AlbumIdentityGroup<T>>();
const missingAlbumArtistBuckets = new Map<string, PreparedTrack<T>[]>();
for (const track of tracks) {
const trackId = getTrackId(track);
const albumKey = normalizeKey(normalizeAlbumName(track.album));
const normalizedAlbumArtist = getNormalizedAlbumArtist(track);
const primaryArtist = normalizeDisplay(getPrimaryArtistFromTrack(track)) || UNKNOWN_ARTIST_NAME;
const primaryArtistKey = normalizeKey(primaryArtist) || normalizeKey(UNKNOWN_ARTIST_NAME);
const artworkIdentityHash = normalizeArtworkHash(track.base_artwork_hash);
if (normalizedAlbumArtist) {
const albumArtistKey = normalizeKey(normalizedAlbumArtist) || normalizeKey(UNKNOWN_ARTIST_NAME);
const identityKey = buildCanonicalAlbumIdentityKey(albumKey, `aa:${albumArtistKey}`);
let group = groups.get(identityKey);
if (!group) {
group = createAlbumIdentityGroup(identityKey, albumKey, 'explicit-album-artist', normalizedAlbumArtist);
groups.set(identityKey, group);
}
group.tracks.push(track);
continue;
}
const bucket = missingAlbumArtistBuckets.get(albumKey);
const preparedTrack: PreparedTrack<T> = {
track,
trackId,
albumKey,
normalizedAlbumArtist,
primaryArtist,
primaryArtistKey,
artworkIdentityHash,
};
if (bucket) {
bucket.push(preparedTrack);
} else {
missingAlbumArtistBuckets.set(albumKey, [preparedTrack]);
}
}
for (const bucket of missingAlbumArtistBuckets.values()) {
const primaryArtistKeys = new Set(bucket.map((track) => track.primaryArtistKey));
const sharedArtworkHash = primaryArtistKeys.size > 1 ? resolveSharedArtworkHash(bucket) : null;
if (sharedArtworkHash) {
const identityKey = buildCanonicalAlbumIdentityKey(bucket[0].albumKey, `ah:${sharedArtworkHash}`);
let group = groups.get(identityKey);
if (!group) {
group = createAlbumIdentityGroup(identityKey, bucket[0].albumKey, 'shared-artwork-compilation', VARIOUS_ARTISTS_NAME);
groups.set(identityKey, group);
}
for (const track of bucket) {
group.tracks.push(track.track);
}
continue;
}
for (const track of bucket) {
const identityKey = buildCanonicalAlbumIdentityKey(track.albumKey, `ta:${track.primaryArtistKey}`);
let group = groups.get(identityKey);
if (!group) {
group = createAlbumIdentityGroup(identityKey, track.albumKey, 'track-artist', track.primaryArtist);
groups.set(identityKey, group);
}
group.tracks.push(track.track);
}
}
return groups;
}
export function buildAlbumIdentityKeyByTrackId<T extends AlbumIdentityTrackLike>(
tracks: readonly T[],
getTrackId: (track: T) => string
): Map<string, string> {
const keysByTrackId = new Map<string, string>();
const groups = groupTracksByAlbumIdentity(tracks, getTrackId);
for (const [identityKey, group] of groups.entries()) {
for (const track of group.tracks) {
keysByTrackId.set(getTrackId(track), identityKey);
}
}
return keysByTrackId;
}
+65
View File
@@ -0,0 +1,65 @@
// Port of desktop astra/src/shared/library/artistCredits.ts — keep semantically
// identical so the album-grouping algorithm stays in sync across apps.
export interface ArtistNameToken {
artist: string;
separator: string | null;
}
export function normalizeArtistName(value: unknown): string {
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
}
export function normalizeArtistNames(values: readonly unknown[] | null | undefined): string[] {
if (!values) return [];
const unique = new Map<string, string>();
for (const value of values) {
const display = normalizeArtistName(value);
if (!display) continue;
const key = display.toLocaleLowerCase();
if (!key || unique.has(key)) continue;
unique.set(key, display);
}
return Array.from(unique.values());
}
export function serializeArtistNames(names: readonly unknown[] | null | undefined): string | null {
const normalized = normalizeArtistNames(names);
return normalized.length > 0 ? JSON.stringify(normalized) : null;
}
export function deserializeArtistNames(value: unknown): string[] {
if (typeof value !== 'string' || value.trim().length === 0) {
return [];
}
try {
const parsed = JSON.parse(value) as unknown;
return Array.isArray(parsed) ? normalizeArtistNames(parsed) : [];
} catch {
return [];
}
}
export function formatArtistNames(names: readonly unknown[] | null | undefined): string {
const normalized = normalizeArtistNames(names);
if (normalized.length === 0) return '';
if (normalized.length === 1) return normalized[0];
if (normalized.length === 2) return `${normalized[0]} & ${normalized[1]}`;
return `${normalized.slice(0, -1).join(', ')} & ${normalized[normalized.length - 1]}`;
}
export function buildArtistNameTokens(names: readonly unknown[] | null | undefined): ArtistNameToken[] {
const normalized = normalizeArtistNames(names);
return normalized.map((artist, index) => {
let separator: string | null = null;
if (index < normalized.length - 2) {
separator = ', ';
} else if (index === normalized.length - 2) {
separator = ' & ';
}
return { artist, separator };
});
}
+30 -5
View File
@@ -2,7 +2,6 @@ import { create } from 'zustand';
import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library';
import { openLibraryDb } from '@/db/database';
import {
getAlbums,
getAllTracks,
getRecentlyPlayedTracks,
getSetting,
@@ -10,6 +9,8 @@ import {
markTrackPlayed,
setSetting,
} from '@/db/queries';
import { recomputeAlbumIdentity } from '@/library/albumIdentity';
import { buildAlbumList } from '@/library/albumSummary';
import { ensureArtworkThumbnails } from '@/library/artwork';
import { buildArtistList } from '@/library/artistGrouping';
import {
@@ -37,6 +38,11 @@ const TRACK_SORT_KEY = 'library_track_sort';
const ALBUM_SORT_KEY = 'library_album_sort';
const ARTIST_SORT_KEY = 'library_artist_sort';
// Bump when the album-identity algorithm changes to re-run the whole-library
// recompute at startup. '2' = the desktop three-tier grouping port (v15 schema).
const ALBUM_GROUPING_VERSION_KEY = 'album_grouping_version';
const ALBUM_GROUPING_VERSION = '2';
const VIEW_MODES: readonly ViewMode[] = ['tracks', 'albums', 'artists', 'playlists', 'folders'];
function parseViewMode(value: string | null): ViewMode | null {
@@ -95,6 +101,7 @@ interface LibraryStore {
refresh: () => Promise<void>;
recordTrackPlayed: (path: string) => Promise<void>;
recomputeArtists: () => void;
recomputeAlbums: () => void;
setViewMode: (mode: ViewMode) => void;
setTrackSort: (sort: TrackSort) => void;
setAlbumSort: (sort: AlbumSort) => void;
@@ -148,7 +155,15 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
await useSettingsStore.getState().load();
useSettingsStore.subscribe((state, prev) => {
if (state.artistGroupingMode !== prev.artistGroupingMode) get().recomputeArtists();
if (state.includeSingles !== prev.includeSingles) get().recomputeAlbums();
});
// One-time backfill when the album-identity algorithm changes (e.g. the
// desktop three-tier grouping port): settle every track's identity key +
// display artist before the first refresh so first paint is grouped right.
if ((await getSetting(db, ALBUM_GROUPING_VERSION_KEY)) !== ALBUM_GROUPING_VERSION) {
await recomputeAlbumIdentity(db);
await setSetting(db, ALBUM_GROUPING_VERSION_KEY, ALBUM_GROUPING_VERSION);
}
// Restore view preferences before the first render of the library screen.
const [savedViewMode, savedTrackSort, savedAlbumSort, savedArtistSort] =
await Promise.all([
@@ -189,9 +204,8 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
refresh: async () => {
const db = await openLibraryDb();
const [tracks, albums, folders, totalTrackCount, recentlyPlayedTracks] = await Promise.all([
const [tracks, folders, totalTrackCount, recentlyPlayedTracks] = await Promise.all([
getAllTracks(db),
getAlbums(db),
loadFolders(),
getTrackCount(db),
getRecentlyPlayedTracks(db),
@@ -201,8 +215,11 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
} catch {
// Missing thumbnails should not prevent the library itself from loading.
}
// The artist list is derived in JS so it can honor the grouping mode.
const artists = buildArtistList(tracks, useSettingsStore.getState().artistGroupingMode);
// Album + artist lists are derived in JS: albums for the desktop-parity
// display picks + singles eligibility, artists to honor the grouping mode.
const settings = useSettingsStore.getState();
const albums = buildAlbumList(tracks, { includeSingles: settings.includeSingles });
const artists = buildArtistList(tracks, settings.artistGroupingMode);
set({ tracks, recentlyPlayedTracks, albums, artists, folders, totalTrackCount });
// Playlist counts/missing states depend on tracks — keep them in step.
await usePlaylistStore.getState().refresh();
@@ -223,6 +240,14 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
artists: buildArtistList(state.tracks, useSettingsStore.getState().artistGroupingMode),
})),
// Rebuild the album list from in-memory tracks (e.g. on singles-toggle change).
recomputeAlbums: () =>
set((state) => ({
albums: buildAlbumList(state.tracks, {
includeSingles: useSettingsStore.getState().includeSingles,
}),
})),
setViewMode: (viewMode) => {
set({ viewMode });
persistSetting(VIEW_MODE_KEY, viewMode);
+3
View File
@@ -12,6 +12,7 @@ import {
deleteFavoritesByPathPrefix,
deleteRemotePlaylistsBySource,
} from '@/db/playlistQueries';
import { recomputeAlbumIdentity } from '@/library/albumIdentity';
import {
deleteRemoteSource,
getRemoteSource,
@@ -256,6 +257,8 @@ export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
// `${type}://${id}/` path prefix).
await deleteRemotePlaylistsBySource(db, id);
await deleteFavoritesByPathPrefix(db, `${source.type}://${id}/`);
// Removals can regroup albums (compilation heuristic is cross-track).
await recomputeAlbumIdentity(db);
}
await deleteRemoteSource(db, id);
await deleteRemoteSecret(id);
+15 -1
View File
@@ -9,6 +9,7 @@ import type { ArtistGroupingMode } from '@/library/artistGrouping';
* subscribes here to recompute the artist list when the grouping mode changes.
*/
const ARTIST_GROUPING_KEY = 'artist_grouping_mode';
const INCLUDE_SINGLES_KEY = 'album_include_singles';
const SCOPE_MODE_KEY = 'scope_mode';
const SCOPE_STAGE_VISIBLE_KEY = 'scope_stage_visible';
@@ -29,17 +30,21 @@ function parseBoolean(value: string | null): boolean {
interface SettingsStore {
artistGroupingMode: ArtistGroupingMode;
/** Show 1-track albums in the Albums view (desktop parity default: hidden). */
includeSingles: boolean;
scopeMode: ScopeMode;
scopeStageVisible: boolean;
loaded: boolean;
load: () => Promise<void>;
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
setIncludeSingles: (include: boolean) => Promise<void>;
setScopeMode: (mode: ScopeMode) => Promise<void>;
setScopeStageVisible: (visible: boolean) => Promise<void>;
}
export const useSettingsStore = create<SettingsStore>((set, get) => ({
artistGroupingMode: 'astra',
includeSingles: false,
scopeMode: 'spectrum',
scopeStageVisible: false,
loaded: false,
@@ -47,13 +52,15 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
load: async () => {
if (get().loaded) return;
const db = await openLibraryDb();
const [grouping, scope, scopeStageVisible] = await Promise.all([
const [grouping, includeSingles, scope, scopeStageVisible] = await Promise.all([
getSetting(db, ARTIST_GROUPING_KEY),
getSetting(db, INCLUDE_SINGLES_KEY),
getSetting(db, SCOPE_MODE_KEY),
getSetting(db, SCOPE_STAGE_VISIBLE_KEY),
]);
set({
artistGroupingMode: parseGroupingMode(grouping),
includeSingles: parseBoolean(includeSingles),
scopeMode: parseScopeMode(scope),
scopeStageVisible: parseBoolean(scopeStageVisible),
loaded: true,
@@ -67,6 +74,13 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
await setSetting(db, ARTIST_GROUPING_KEY, mode);
},
setIncludeSingles: async (include) => {
if (get().includeSingles === include) return;
set({ includeSingles: include });
const db = await openLibraryDb();
await setSetting(db, INCLUDE_SINGLES_KEY, include ? 'true' : 'false');
},
setScopeMode: async (mode) => {
if (get().scopeMode === mode) return;
set({ scopeMode: mode });
+3
View File
@@ -12,6 +12,9 @@ export interface DbTrack {
album: string;
album_artist: string | null;
album_identity_key: string;
/** Settled group artist ("Various Artists" for shared-artwork compilations);
* written by the album-identity recompute pass, NULL until it first runs. */
album_display_artist: string | null;
duration: number;
track_number: number | null;
disc_number: number | null;
+1
View File
@@ -2,6 +2,7 @@
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"allowImportingTsExtensions": true,
"paths": {
"@/*": [
"./src/*"