mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
m1, add file scan, library, browse, play, seek
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user