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
+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,