mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
167 lines
5.8 KiB
TypeScript
167 lines
5.8 KiB
TypeScript
// 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;
|
|
}
|