port over astra desktop tag algos

This commit is contained in:
Boof2015
2026-06-14 14:57:25 -04:00
parent 51b733a293
commit be740c2d60
9 changed files with 415 additions and 20 deletions
+1
View File
@@ -29,6 +29,7 @@ export default function TabsLayout() {
<Tabs.Screen name="index" />
<Tabs.Screen name="library" />
<Tabs.Screen name="eq" />
<Tabs.Screen name="settings" />
</Tabs>
);
}
+7 -4
View File
@@ -10,22 +10,25 @@ import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, radius, spacing } from '@/theme';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { filterTracksByArtist } from '@/library/artistGrouping';
import type { DbTrack } from '@/types/library';
export default function ArtistScreen() {
const router = useRouter();
const { name } = useLocalSearchParams<{ name: string }>();
const allTracks = useLibraryStore((s) => s.tracks);
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const [actionTrack, setActionTrack] = useState<DbTrack | null>(null);
// Store tracks are ordered artist/album/disc/track, so the filtered slice
// keeps album grouping and track order.
// Match the artist list's grouping mode; store tracks are ordered
// artist/album/disc/track, so the filtered slice keeps album/track order.
const tracks = useMemo(
() => allTracks.filter((track) => track.artist === name),
[allTracks, name]
() => filterTracksByArtist(allTracks, name, groupingMode),
[allTracks, name, groupingMode]
);
const playFrom = (index: number) => {
+113
View File
@@ -0,0 +1,113 @@
import { View, Pressable, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { useSettingsStore } from '@/stores/settingsStore';
import type { ArtistGroupingMode } from '@/library/artistGrouping';
const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; description: string }[] = [
{
mode: 'astra',
title: 'Astra grouping',
description: 'Parse collaborators ("feat.", "&", "x") — featured artists get their own entry.',
},
{
mode: 'fileTags',
title: 'File tags',
description: 'Group by the album artist / artist tag exactly as written.',
},
];
export default function SettingsScreen() {
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
return (
<Screen>
<Text variant="title" style={styles.heading}>
Settings
</Text>
<Text variant="label" color={colors.textTertiary} style={styles.sectionLabel}>
LIBRARY
</Text>
<Text variant="body" style={styles.settingTitle}>
Artist grouping
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
How tracks are organized into artists in the library.
</Text>
<View style={styles.options}>
{ARTIST_GROUPING_OPTIONS.map((option) => {
const selected = option.mode === groupingMode;
return (
<Pressable
key={option.mode}
style={[styles.option, selected && styles.optionSelected]}
onPress={() => void setArtistGroupingMode(option.mode)}
accessibilityRole="radio"
accessibilityState={{ selected }}
>
<View style={styles.optionText}>
<Text variant="body" color={selected ? colors.accentTextStrong : colors.textPrimary}>
{option.title}
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{option.description}
</Text>
</View>
{selected ? (
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
) : (
<Ionicons name="ellipse-outline" size={20} color={colors.textTertiary} />
)}
</Pressable>
);
})}
</View>
</Screen>
);
}
const styles = StyleSheet.create({
heading: {
marginTop: spacing.xl,
marginBottom: spacing.xxl,
},
sectionLabel: {
letterSpacing: 1,
marginBottom: spacing.sm,
},
settingTitle: {
marginBottom: spacing.xs,
},
settingNote: {
marginBottom: spacing.md,
},
options: {
gap: spacing.sm,
},
option: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
padding: spacing.lg,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
optionSelected: {
borderColor: colors.accent,
backgroundColor: colors.glassHighlight,
},
optionText: {
flex: 1,
gap: 2,
},
optionDescription: {
lineHeight: 16,
},
});
+1
View File
@@ -11,6 +11,7 @@ export const TAB_META: Record<string, { label: string; icon: IconName }> = {
index: { label: 'Home', icon: 'home' },
library: { label: 'Library', icon: 'musical-notes' },
eq: { label: 'EQ', icon: 'options' },
settings: { label: 'Settings', icon: 'settings' },
};
export interface TabItem {
+18 -11
View File
@@ -1,6 +1,6 @@
// Library queries — SQL ported/adapted from the desktop library service.
import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library';
import type { Album, 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). */
@@ -112,16 +112,8 @@ export function getAlbums(db: LibraryDatabase): Promise<Album[]> {
`);
}
export function getArtists(db: LibraryDatabase): Promise<Artist[]> {
return db.all<Artist>(`
SELECT artist,
COUNT(*) AS track_count,
MAX(artwork_hash) AS artwork_hash
FROM tracks
GROUP BY artist
ORDER BY artist 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.
export function getAllTracks(db: LibraryDatabase): Promise<DbTrack[]> {
return db.all<DbTrack>(`
@@ -149,6 +141,21 @@ export async function getTrackCount(db: LibraryDatabase): Promise<number> {
return row?.count ?? 0;
}
// --- Settings (key-value preferences) ----------------------------------------
export async function getSetting(db: LibraryDatabase, key: string): Promise<string | null> {
const row = await db.get<{ value: string }>('SELECT value FROM settings WHERE key = ?', [key]);
return row?.value ?? null;
}
export async function setSetting(db: LibraryDatabase, key: string, value: string): Promise<void> {
await db.run(
`INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
[key, value]
);
}
// --- Folders -----------------------------------------------------------------
type FolderRow = Omit<LibraryFolder, 'available'>;
+10 -2
View File
@@ -1,11 +1,12 @@
// Library schema — a trimmed port of the desktop schema (astra
// src/main/services/library.ts). v1 covers M1 (local scan + browse);
// v2 adds playlists + favorites (M2); v3 forces re-extraction of tracks whose
// non-ASCII tags were truncated by the pre-fix op-sqlite binding (see database.ts).
// non-ASCII tags were truncated by the pre-fix op-sqlite binding (see database.ts);
// v4 adds a key-value settings table (artist grouping mode, future prefs).
import type { LibraryDatabase } from './database';
export const SCHEMA_VERSION = 3;
export const SCHEMA_VERSION = 4;
// One statement per entry — op-sqlite executes single statements.
const MIGRATIONS: readonly (readonly string[])[] = [
@@ -82,6 +83,13 @@ const MIGRATIONS: readonly (readonly string[])[] = [
// see database.ts). The damage is irreversible in place, so mark every track
// stale; libraryStore re-extracts them on next launch now that binding is fixed.
[`UPDATE tracks SET mtime = -1`],
// v3 -> v4 — persisted app preferences as a simple key-value store.
[
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
)`,
],
];
export async function migrate(db: LibraryDatabase): Promise<void> {
+204
View File
@@ -0,0 +1,204 @@
// Artist browse grouping — ported from desktop (astra src/main/services/library.ts
// and src/shared/library/artistCredits.ts). Two modes:
// 'astra' (desktop "canonical"): parse the artist string into collaborators,
// file each track under its primary artist, and also index it under
// every collaborator so featured artists are browsable.
// 'fileTags' (desktop "strict"): use the tag verbatim (album_artist || artist).
//
// Mobile has no parsed `artist_names_json` columns (MMR yields one artist string),
// so desktop's parsed-array paths collapse to splitCollaborators(artist) — which is
// the parsing heuristic. Everything here is derivable from artist + album_artist.
import type { Artist, DbTrack } from '@/types/library';
export type ArtistGroupingMode = 'astra' | 'fileTags';
const UNKNOWN_ARTIST = 'Unknown Artist';
/** Track fields the grouping logic reads (subset of DbTrack, for testability). */
type ArtistTrackLike = Pick<
DbTrack,
'artist' | 'album_artist' | 'artwork_hash' | 'year' | 'added_at' | 'modified_at'
>;
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);
if (!normalized) return [];
const unified = normalized
.replace(/\s*;\s*/g, ',')
.replace(/\s+[x×]\s+/gi, ',')
.replace(/\s+(?:feat\.?|ft\.?|featuring|with)\s+/gi, ',');
return dedupeByKey(unified.split(','));
}
function dedupeByKey(parts: string[]): string[] {
const unique = new Map<string, string>();
for (const part of parts) {
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());
}
/** File-tags artist: album_artist if present, else the raw track artist. */
export function resolveStrictBrowseArtist(track: Pick<DbTrack, 'artist' | 'album_artist'>): string {
const albumArtist = normalizeDisplay(track.album_artist ?? '');
if (albumArtist) return albumArtist;
return normalizeDisplay(track.artist) || UNKNOWN_ARTIST;
}
/** Astra-grouping primary: album_artist's first collaborator, else artist's first. */
export function resolveCanonicalBrowseArtist(track: Pick<DbTrack, 'artist' | 'album_artist'>): string {
const albumArtist = normalizeDisplay(track.album_artist ?? '');
if (albumArtist) {
return splitAlbumArtistCollaborators(albumArtist)[0] ?? albumArtist;
}
return splitCollaborators(track.artist)[0] ?? UNKNOWN_ARTIST;
}
/** Every artist a track is indexed under in astra mode: primary + all collaborators. */
export function getCanonicalArtistIndexNames(track: Pick<DbTrack, 'artist' | 'album_artist'>): string[] {
const unique = new Map<string, string>();
const add = (name: string) => {
const display = normalizeDisplay(name);
const key = normalizeKey(display);
if (!key || unique.has(key)) return;
unique.set(key, display);
};
add(resolveCanonicalBrowseArtist(track));
const trackArtists = splitCollaborators(track.artist);
for (const name of trackArtists) add(name);
if (trackArtists.length === 0) {
for (const name of splitAlbumArtistCollaborators(track.album_artist ?? '')) add(name);
}
return Array.from(unique.values());
}
/** Whether a track belongs to the given artist key under the active browse mode. */
export function trackMatchesBrowseArtist(
track: Pick<DbTrack, 'artist' | 'album_artist'>,
targetArtistKey: string,
mode: ArtistGroupingMode
): boolean {
const browseKey = normalizeKey(
mode === 'fileTags' ? resolveStrictBrowseArtist(track) : resolveCanonicalBrowseArtist(track)
);
if (browseKey === targetArtistKey) return true;
if (mode === 'fileTags') return false;
const albumArtistKey = normalizeKey(track.album_artist ?? '');
if (albumArtistKey && albumArtistKey === targetArtistKey) return true;
const trackArtistKey = normalizeKey(track.artist);
if (trackArtistKey && trackArtistKey === targetArtistKey) return true;
if (splitAlbumArtistCollaborators(track.album_artist ?? '').some((n) => normalizeKey(n) === targetArtistKey)) {
return true;
}
return splitCollaborators(track.artist).some((n) => normalizeKey(n) === targetArtistKey);
}
interface ArtistAggregate {
artist: string;
track_count: number;
artwork_hash: string | null;
artworkYear: number;
artworkAddedAt: number;
artworkModifiedAt: number;
}
/**
* Aggregate the artist browse list from in-memory tracks (replaces SQL getArtists).
* Artwork is the cover of the artist's newest track (year, then added/modified) —
* matches desktop getArtists.
*/
export function buildArtistList(tracks: readonly ArtistTrackLike[], mode: ArtistGroupingMode): Artist[] {
const byKey = new Map<string, ArtistAggregate>();
for (const track of tracks) {
const indexNames = mode === 'fileTags'
? [resolveStrictBrowseArtist(track)]
: getCanonicalArtistIndexNames(track);
const seen = new Set<string>();
for (const name of indexNames) {
const key = normalizeKey(name);
if (!key || seen.has(key)) continue;
seen.add(key);
let aggregate = byKey.get(key);
if (!aggregate) {
aggregate = {
artist: name,
track_count: 0,
artwork_hash: null,
artworkYear: -1,
artworkAddedAt: -1,
artworkModifiedAt: -1,
};
byKey.set(key, aggregate);
}
aggregate.track_count += 1;
if (!track.artwork_hash) continue;
const candidateYear = track.year ?? -1;
const better =
aggregate.artwork_hash == null ||
candidateYear > aggregate.artworkYear ||
(candidateYear === aggregate.artworkYear &&
(track.added_at > aggregate.artworkAddedAt ||
(track.added_at === aggregate.artworkAddedAt && track.modified_at > aggregate.artworkModifiedAt)));
if (!better) continue;
aggregate.artwork_hash = track.artwork_hash;
aggregate.artworkYear = candidateYear;
aggregate.artworkAddedAt = track.added_at;
aggregate.artworkModifiedAt = track.modified_at;
}
}
return Array.from(byKey.values())
.map(({ artist, track_count, artwork_hash }) => ({ artist, track_count, artwork_hash }))
.sort((a, b) => a.artist.localeCompare(b.artist, undefined, { sensitivity: 'base' }));
}
/** Tracks belonging to one artist under the active mode (preserves input order). */
export function filterTracksByArtist(
tracks: readonly DbTrack[],
artistName: string,
mode: ArtistGroupingMode
): DbTrack[] {
const key = normalizeKey(artistName);
if (!key) return [];
return tracks.filter((track) => trackMatchesBrowseArtist(track, key, mode));
}
+20 -3
View File
@@ -1,7 +1,8 @@
import { create } from 'zustand';
import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library';
import { openLibraryDb } from '@/db/database';
import { getAlbums, getAllTracks, getArtists, getTrackCount } from '@/db/queries';
import { getAlbums, getAllTracks, getTrackCount } from '@/db/queries';
import { buildArtistList } from '@/library/artistGrouping';
import {
addFolderViaPicker,
loadFolders,
@@ -12,6 +13,7 @@ import {
} from '@/library/scanner';
import type { TrackSort } from '@/lib/trackSort';
import { usePlaylistStore } from './playlistStore';
import { useSettingsStore } from './settingsStore';
/**
* Library state SQLite is the source of truth (no persist middleware);
@@ -45,6 +47,7 @@ interface LibraryStore {
initialize: () => Promise<void>;
refresh: () => Promise<void>;
recomputeArtists: () => void;
setViewMode: (mode: ViewMode) => void;
setTrackSort: (sort: TrackSort) => void;
addFolder: () => Promise<void>;
@@ -88,6 +91,12 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
if (!initPromise) {
initPromise = (async () => {
const db = await openLibraryDb();
// Load the persisted grouping mode before the first refresh so the artist
// list is built correctly; recompute it whenever the mode changes later.
await useSettingsStore.getState().load();
useSettingsStore.subscribe((state, prev) => {
if (state.artistGroupingMode !== prev.artistGroupingMode) get().recomputeArtists();
});
await get().refresh();
set({ initialized: true });
// One-time recovery: the v3 migration marks tracks stale (mtime = -1)
@@ -108,18 +117,26 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
refresh: async () => {
const db = await openLibraryDb();
const [tracks, albums, artists, folders, totalTrackCount] = await Promise.all([
const [tracks, albums, folders, totalTrackCount] = await Promise.all([
getAllTracks(db),
getAlbums(db),
getArtists(db),
loadFolders(),
getTrackCount(db),
]);
// The artist list is derived in JS so it can honor the grouping mode.
const artists = buildArtistList(tracks, useSettingsStore.getState().artistGroupingMode);
set({ tracks, albums, artists, folders, totalTrackCount });
// Playlist counts/missing states depend on tracks — keep them in step.
await usePlaylistStore.getState().refresh();
},
// Rebuild the artist list from in-memory tracks (e.g. on grouping-mode change),
// without re-querying SQLite.
recomputeArtists: () =>
set((state) => ({
artists: buildArtistList(state.tracks, useSettingsStore.getState().artistGroupingMode),
})),
setViewMode: (viewMode) => set({ viewMode }),
setTrackSort: (trackSort) => set({ trackSort }),
+41
View File
@@ -0,0 +1,41 @@
import { create } from 'zustand';
import { openLibraryDb } from '@/db/database';
import { getSetting, setSetting } from '@/db/queries';
import type { ArtistGroupingMode } from '@/library/artistGrouping';
/**
* Persisted app preferences. SQLite (settings table) is the source of truth this
* store mirrors it in memory. Kept free of cross-store imports; libraryStore
* subscribes here to recompute the artist list when the grouping mode changes.
*/
const ARTIST_GROUPING_KEY = 'artist_grouping_mode';
function parseGroupingMode(value: string | null): ArtistGroupingMode {
return value === 'fileTags' ? 'fileTags' : 'astra';
}
interface SettingsStore {
artistGroupingMode: ArtistGroupingMode;
loaded: boolean;
load: () => Promise<void>;
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
}
export const useSettingsStore = create<SettingsStore>((set, get) => ({
artistGroupingMode: 'astra',
loaded: false,
load: async () => {
if (get().loaded) return;
const db = await openLibraryDb();
const stored = await getSetting(db, ARTIST_GROUPING_KEY);
set({ artistGroupingMode: parseGroupingMode(stored), loaded: true });
},
setArtistGroupingMode: async (mode) => {
if (get().artistGroupingMode === mode) return;
set({ artistGroupingMode: mode });
const db = await openLibraryDb();
await setSetting(db, ARTIST_GROUPING_KEY, mode);
},
}));