m1, add file scan, library, browse, play, seek

This commit is contained in:
Boof2015
2026-06-12 19:18:42 -04:00
parent 9761975af7
commit 23be9875e8
202 changed files with 6270 additions and 115 deletions
+13
View File
@@ -0,0 +1,13 @@
// Artwork cache lives in the app's files dir; the native scanner writes
// md5-named files (desktop convention) and tracks store the file name.
import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
let artworkDir: string | null = null;
export function artworkUri(hash: string): string {
if (!artworkDir) {
artworkDir = AstraLibraryScanner.getArtworkDirPath();
}
return `file://${artworkDir}/${hash}`;
}
+17
View File
@@ -0,0 +1,17 @@
// Ported from desktop AUDIO_EXTENSIONS (astra src/main/services/library.ts).
// Extensions are passed to the native walk without leading dots.
export const AUDIO_EXTENSIONS = [
'mp3',
'flac',
'wav',
'ogg',
'aac',
'm4a',
'opus',
'wma',
'aiff',
'alac',
'ape',
'wv',
];
+185
View File
@@ -0,0 +1,185 @@
// Scan orchestration: SAF folder pick -> native walk -> diff against DB ->
// native metadata extraction in batches -> batched upserts. Concepts (mtime
// skip, batching, never-abort-on-file-errors) ported from desktop scanFolder.
import { StorageAccessFramework } from 'expo-file-system/legacy';
import { AstraLibraryScanner, type ScannedFile } from '../../modules/astra-library-scanner';
import { openLibraryDb } from '@/db/database';
import {
deleteFolder,
deleteTracksByPaths,
getFolders,
getFolderSyncRows,
getFolderTrackCounts,
insertFolder,
markFolderScanned,
upsertTracks,
type TrackUpsert,
} from '@/db/queries';
import type { LibraryFolder } from '@/types/library';
import { AUDIO_EXTENSIONS } from './audioExtensions';
import { metadataToUpsertRow } from './trackAdapter';
const EXTRACT_BATCH_SIZE = 24;
export interface ScanProgress {
phase: 'discovering' | 'extracting';
processed: number;
total: number;
folderName: string;
}
export interface ScanCallbacks {
onProgress?: (progress: ScanProgress) => void;
}
export interface ScanResult {
added: number;
updated: number;
removed: number;
errors: number;
}
function emptyResult(): ScanResult {
return { added: 0, updated: 0, removed: 0, errors: 0 };
}
/** "content://…/tree/primary%3AMusic%2FAstraTest" -> "AstraTest" */
function displayNameFromTreeUri(treeUri: string): string {
const lastSegment = treeUri.split('/').pop() ?? treeUri;
const decoded = decodeURIComponent(lastSegment);
const name = decoded.split(/[/:]/).pop()?.trim();
return name || 'Music folder';
}
/** Folder rows joined with current permission state and track counts. */
export async function loadFolders(): Promise<(LibraryFolder & { track_count: number })[]> {
const db = await openLibraryDb();
const [rows, counts] = await Promise.all([getFolders(db), getFolderTrackCounts(db)]);
const persisted = new Set(AstraLibraryScanner.getPersistedTreeUris());
return rows.map((row) => ({
...row,
available: persisted.has(row.tree_uri),
track_count: counts.get(row.id) ?? 0,
}));
}
/**
* System folder picker -> persist grant -> folder row -> scan.
* Returns null if the user cancelled the picker.
*/
export async function addFolderViaPicker(callbacks?: ScanCallbacks): Promise<ScanResult | null> {
const permission = await StorageAccessFramework.requestDirectoryPermissionsAsync();
if (!permission.granted) return null;
const treeUri = permission.directoryUri;
await AstraLibraryScanner.takePersistableUriPermission(treeUri);
const db = await openLibraryDb();
const row = await insertFolder(db, treeUri, displayNameFromTreeUri(treeUri));
return scanFolder({ ...row, available: true }, { callbacks });
}
export async function scanFolder(
folder: Omit<LibraryFolder, 'available'> & { available?: boolean },
opts: { mode?: 'incremental' | 'full'; callbacks?: ScanCallbacks } = {}
): Promise<ScanResult> {
const { mode = 'incremental', callbacks } = opts;
const db = await openLibraryDb();
const result = emptyResult();
// Native discovery runs as one promise; forward its progress events.
const subscription = AstraLibraryScanner.addListener('onScanProgress', (event) => {
callbacks?.onProgress?.({
phase: 'discovering',
processed: 0,
total: event.found,
folderName: folder.display_name,
});
});
callbacks?.onProgress?.({ phase: 'discovering', processed: 0, total: 0, folderName: folder.display_name });
let files: ScannedFile[];
let covers: Record<string, string>;
try {
const listing = await AstraLibraryScanner.listAudioFiles(folder.tree_uri, AUDIO_EXTENSIONS);
files = listing.files;
covers = listing.covers;
} finally {
subscription.remove();
}
// Diff against what the DB knows about this folder.
const existingRows = await getFolderSyncRows(db, folder.id);
const existingByPath = new Map(existingRows.map((row) => [row.path, row]));
const seenPaths = new Set(files.map((file) => file.uri));
const toDelete = existingRows.filter((row) => !seenPaths.has(row.path)).map((row) => row.path);
const toExtract = files.filter((file) => {
const existing = existingByPath.get(file.uri);
if (!existing || mode === 'full') return true;
return existing.mtime !== file.lastModified || existing.size !== file.size;
});
result.removed = await deleteTracksByPaths(db, toDelete);
let processed = 0;
for (let i = 0; i < toExtract.length; i += EXTRACT_BATCH_SIZE) {
const batch = toExtract.slice(i, i + EXTRACT_BATCH_SIZE);
const extracted = await AstraLibraryScanner.extractMetadata(
batch.map((file) => ({ uri: file.uri, coverUri: covers[file.parentUri] ?? null }))
);
const metaByUri = new Map(extracted.map((meta) => [meta.uri, meta]));
const rows: TrackUpsert[] = [];
for (const file of batch) {
const meta = metaByUri.get(file.uri);
if (!meta?.ok) {
result.errors += 1;
continue;
}
rows.push(metadataToUpsertRow(meta, file, folder.id));
if (existingByPath.has(file.uri)) {
result.updated += 1;
} else {
result.added += 1;
}
}
await upsertTracks(db, rows);
processed += batch.length;
callbacks?.onProgress?.({
phase: 'extracting',
processed,
total: toExtract.length,
folderName: folder.display_name,
});
}
await markFolderScanned(db, folder.id);
return result;
}
/** Rescans every folder whose permission grant is still alive. */
export async function rescanAll(
opts: { mode?: 'incremental' | 'full'; callbacks?: ScanCallbacks } = {}
): Promise<ScanResult> {
const folders = await loadFolders();
const total = emptyResult();
for (const folder of folders) {
if (!folder.available) continue;
const result = await scanFolder(folder, opts);
total.added += result.added;
total.updated += result.updated;
total.removed += result.removed;
total.errors += result.errors;
}
return total;
}
/** Explicit user removal: drop the folder (tracks CASCADE) and release the grant. */
export async function removeFolder(folder: Pick<LibraryFolder, 'id' | 'tree_uri'>): Promise<void> {
const db = await openLibraryDb();
await deleteFolder(db, folder.id);
await AstraLibraryScanner.releasePersistedUriPermission(folder.tree_uri);
}
+128
View File
@@ -0,0 +1,128 @@
// Adapters between the native scanner output, the SQLite row shape, and the
// app-level Track model the player consumes.
import type { Track } from '@/types/audio';
import type { DbTrack } from '@/types/library';
import type { TrackUpsert } from '@/db/queries';
import type { ExtractedMetadata, ScannedFile } from '../../modules/astra-library-scanner';
import { artworkUri } from './artwork';
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',
'audio/mpeg-l2': 'mp2',
'audio/mp4a-latm': 'aac',
'audio/aac': 'aac',
'audio/alac': 'alac',
'audio/opus': 'opus',
'audio/vorbis': 'vorbis',
'audio/raw': 'pcm',
'audio/ac3': 'ac3',
'audio/eac3': 'eac3',
};
function codecFromMime(
trackMime: string | null | undefined,
containerMime: string | null | undefined
): string | null {
// Some framework extractors (e.g. FLAC) expose the decoded track as
// audio/raw; the container mime identifies the real codec there.
const mime = trackMime === 'audio/raw' && containerMime ? containerMime : trackMime;
if (!mime) return null;
return CODEC_BY_MIME[mime] ?? mime.replace(/^audio\//, '');
}
function fileExtension(name: string): string {
const dot = name.lastIndexOf('.');
return dot >= 0 ? name.slice(dot + 1) : '';
}
function cleanTag(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
export function metadataToUpsertRow(
meta: ExtractedMetadata,
file: ScannedFile,
folderId: number
): TrackUpsert {
const extension = fileExtension(file.name);
const title = cleanTag(meta.title) ?? file.name.slice(0, file.name.length - (extension ? extension.length + 1 : 0));
const artist = cleanTag(meta.artist) ?? UNKNOWN_ARTIST;
const album = cleanTag(meta.album) ?? UNKNOWN_ALBUM;
const albumArtist = cleanTag(meta.albumArtist);
return {
path: file.uri,
folder_id: folderId,
title,
artist,
album,
album_artist: albumArtist,
album_identity_key: buildAlbumIdentityKey(albumArtist, artist, album),
duration: meta.durationMs != null ? meta.durationMs / 1000 : 0,
track_number: meta.trackNumber ?? null,
disc_number: meta.discNumber ?? null,
year: meta.year ?? null,
genre: cleanTag(meta.genre),
artwork_hash: meta.artworkHash ?? null,
format: extension ? extension.toUpperCase() : 'UNKNOWN',
sample_rate: meta.sampleRate ?? null,
bit_depth: meta.bitsPerSample ?? null,
bitrate: meta.bitrate ?? null,
channels: meta.channels ?? null,
codec: codecFromMime(meta.codecMime, meta.mimeType),
file_name: file.name,
size: file.size,
mtime: file.lastModified,
};
}
export function dbTrackToTrack(track: DbTrack): Track {
return {
id: String(track.id),
path: track.path,
origin: 'library',
title: track.title,
artist: track.artist,
album: track.album,
albumArtist: track.album_artist ?? undefined,
albumIdentityKey: track.album_identity_key,
duration: track.duration,
trackNumber: track.track_number ?? undefined,
discNumber: track.disc_number ?? undefined,
year: track.year ?? undefined,
genre: track.genre ?? undefined,
artworkData: track.artwork_hash ? artworkUri(track.artwork_hash) : undefined,
artworkHash: track.artwork_hash ?? undefined,
format: track.format,
sampleRate: track.sample_rate ?? undefined,
bitDepth: track.bit_depth ?? undefined,
bitrate: track.bitrate ?? undefined,
channels: track.channels ?? undefined,
codec: track.codec ?? undefined,
sourceType: track.source_type,
};
}