mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
fix improperly ported metadata logic
This commit is contained in:
@@ -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']
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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']
|
||||
);
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
}
|
||||
@@ -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,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') {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user