diff --git a/.gitignore b/.gitignore index 61a584d..3b37297 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ vendor/kotlinaudio/kotlin-audio/.cxx/ HANDOFF.md DESIGN.md docs/release/android.md +Untitled-1.md diff --git a/modules/astra-car/android/build.gradle b/modules/astra-car/android/build.gradle index 7dfcf41..0e4a96b 100644 --- a/modules/astra-car/android/build.gradle +++ b/modules/astra-car/android/build.gradle @@ -19,5 +19,8 @@ android { dependencies { implementation "androidx.media:media:1.6.0" + implementation "androidx.room:room-runtime:2.8.4" + implementation "androidx.room:room-ktx:2.8.4" implementation "com.facebook.react:react-android" + implementation project(':astra-library-scanner') } diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarArtworkProvider.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarArtworkProvider.kt index fd7fddf..af2cdaf 100644 --- a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarArtworkProvider.kt +++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarArtworkProvider.kt @@ -9,13 +9,8 @@ import android.os.ParcelFileDescriptor import android.util.Log import java.io.File import java.io.FileNotFoundException -import java.io.FileOutputStream -import java.net.HttpURLConnection -import java.net.URL -import java.security.MessageDigest private const val TAG = "AstraCarArt" -private const val DOWNLOAD_TIMEOUT_MS = 8_000 /** * Serves artwork to Android Auto as `content://` URIs (the only scheme Auto accepts). @@ -59,58 +54,12 @@ class AstraCarArtworkProvider : ContentProvider() { } private fun remoteFile(ctx: Context, sourceId: Long?, artworkSourceId: String?): File? { - if (sourceId == null || artworkSourceId.isNullOrBlank()) return null - val cacheDir = File(ctx.cacheDir, "astra-car-art").apply { mkdirs() } - val cacheFile = File(cacheDir, sha1("$sourceId:$artworkSourceId") + ".img") - if (cacheFile.exists() && cacheFile.length() > 0) return cacheFile - - // art_auth is a full cover-art URL template with an id placeholder, built by JS from - // the (well-tested) Subsonic/Jellyfin URL builders so the provider stays auth-agnostic. - val template = artAuthTemplate(ctx, sourceId) ?: return null - val url = template.replace(AstraCarArtwork.ART_ID_PLACEHOLDER, Uri.encode(artworkSourceId)) - return runCatching { - download(url, cacheFile) - cacheFile.takeIf { it.length() > 0 } - }.onFailure { Log.w(TAG, "remote art download failed for $sourceId/$artworkSourceId", it) } - .getOrNull() + // Credential-bearing remote artwork URLs live only in SecureStore. Remote + // covers are fetched by the app and become available here once cached as + // normal local artwork; the provider never reads or persists credentials. + return null } - private fun artAuthTemplate(ctx: Context, sourceId: Long): String? = - AstraCarDb.openReadable(ctx)?.use { db -> - db.rawQuery( - "SELECT art_auth FROM remote_sources WHERE id = ? LIMIT 1", - arrayOf(sourceId.toString()), - ).use { cursor -> - if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getString(0) else null - } - }?.takeIf { it.isNotBlank() } - - private fun download(url: String, dest: File) { - val connection = (URL(url).openConnection() as HttpURLConnection).apply { - connectTimeout = DOWNLOAD_TIMEOUT_MS - readTimeout = DOWNLOAD_TIMEOUT_MS - instanceFollowRedirects = true - } - try { - if (connection.responseCode !in 200..299) { - throw FileNotFoundException("HTTP ${connection.responseCode}") - } - val tmp = File(dest.absolutePath + ".tmp") - connection.inputStream.use { input -> FileOutputStream(tmp).use(input::copyTo) } - if (!tmp.renameTo(dest)) { - tmp.copyTo(dest, overwrite = true) - tmp.delete() - } - } finally { - connection.disconnect() - } - } - - private fun sha1(value: String): String = - MessageDigest.getInstance("SHA-1") - .digest(value.toByteArray()) - .joinToString("") { "%02x".format(it) } - override fun getType(uri: Uri): String = "image/*" override fun query( diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCatalog.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCatalog.kt index 86a4cde..032d454 100644 --- a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCatalog.kt +++ b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarCatalog.kt @@ -1,8 +1,6 @@ package expo.modules.astracar import android.content.Context -import android.database.Cursor -import android.database.sqlite.SQLiteDatabase import android.net.Uri import android.os.Bundle import android.support.v4.media.MediaBrowserCompat @@ -10,78 +8,189 @@ import android.support.v4.media.MediaBrowserCompat.MediaItem import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat import android.util.Log +import expo.modules.astralibraryscanner.data.ActiveTrackView +import expo.modules.astralibraryscanner.data.AlbumSummaryEntity +import expo.modules.astralibraryscanner.data.ArtistSummaryEntity +import expo.modules.astralibraryscanner.data.AstraLibraryRepository +import expo.modules.astralibraryscanner.data.DynamicPlaylistCompiler import java.io.File import java.util.Locale -import org.json.JSONObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking private const val TAG = "AstraCarCatalog" - -// Cap a node's children when the client doesn't paginate, so a large list (e.g. all albums) -// can't exceed the ~1MB Binder transaction limit — which silently drops the result and hangs -// the browser on a spinner. Auto pages large lists itself; this only guards the unpaged path. private const val MAX_UNPAGED_CHILDREN = 500 +private const val ROOM_PAGE_SIZE = 200 -// Desktop track order (nulls first, path tiebreak) — keep in sync with the JS -// TRACK_ORDER in src/db/queries.ts and compareTracksByDiscTrackTitle. -private const val TRACK_ORDER = - "COALESCE(disc_number, 0), COALESCE(track_number, 0), title COLLATE NOCASE, path" - -// Fully alias-qualified (every column `t.`): these columns are SELECTed from joins where -// the other table (favorites / playlist_tracks) also has an `added_at`, so an unqualified -// `added_at` is ambiguous and SQLite throws. Single-table queries alias `tracks t` too. -private const val TRACK_COLUMNS = - "t.id, t.path, t.title, t.artist, t.album, t.album_artist, t.album_identity_key, t.duration, " + - "t.track_number, t.disc_number, t.year, t.artwork_hash, t.source_type, t.source_id, " + - "t.source_track_id, t.artwork_source_id, t.added_at, t.modified_at" - +/** + * Android Auto reads through the same Room repository as React Native and the + * scanner. It never opens a SQLite file independently and never observes a + * staging catalog generation. + */ class AstraCarCatalog(private val context: Context) { + private val repository by lazy { AstraLibraryRepository.get(context) } + fun loadChildren(parentId: String, options: Bundle? = null): List { val media = AstraCarMediaIds.decode(parentId) ?: AstraCarMediaId(kind = "root") - val items = openReadableDb()?.use { db -> childrenFor(db, media) } - ?: if (media.kind == "root") rootItems() else emptyList() + val items = runCatching { + room { childrenFor(media) } + }.onFailure { + Log.w(TAG, "Room browse failed for $parentId", it) + }.getOrElse { + if (media.kind == "root") rootItems() else emptyList() + } return paginate(items, options) } fun loadItem(mediaId: String): MediaItem? { val media = AstraCarMediaIds.decode(mediaId) ?: return null - return openReadableDb()?.use { db -> - when (media.kind) { - "section" -> sectionItem(media.section ?: return@use null) - "album" -> getAlbums(db).firstOrNull { it.identityKey == media.key }?.let(::albumItem) - "artist" -> getArtists(db).firstOrNull { it.artist == media.key }?.let(::artistItem) - "playlist" -> media.id?.let { id -> getPlaylists(db).firstOrNull { it.id == id } }?.let(::playlistItem) - "track" -> media.path?.let { path -> getTrackByPath(db, path) }?.let { - trackItem(it, contextFromTrackMedia(media)) + return runCatching { + room { + val catalog = repository.catalogDb().catalogDao() + when (media.kind) { + "section" -> sectionItem(media.section ?: return@room null) + "album" -> media.key + ?.let { catalog.getAlbumSummary(catalog.getRevision(), it) } + ?.let(::albumItem) + "artist" -> media.key + ?.let { + catalog.getArtistSummary( + catalog.getRevision(), + artistGroupingMode(), + it, + ) + } + ?.let(::artistItem) + "playlist" -> media.id + ?.let { id -> playlists().firstOrNull { it.id == id } } + ?.let(::playlistItem) + "track" -> { + val path = media.path ?: return@room null + val track = catalog.getActiveTrack(path) ?: return@room null + trackItem(track, contextFromTrackMedia(media)) + } + "root" -> rootItem() + else -> null } - "root" -> rootItem() - else -> null } + }.getOrNull() + } + + private suspend fun childrenFor(media: AstraCarMediaId): List { + val catalog = repository.catalogDb().catalogDao() + return when (media.kind) { + "root" -> rootItems() + "section" -> when (media.section) { + "recent" -> tracksForPaths( + repository.userDb().userDao().getPlaybackHistory().take(24).map { it.trackPath }, + ).map { trackItem(it, AstraCarMediaId(kind = "section", section = "recent")) } + "favorites" -> tracksForPaths( + repository.userDb().userDao().getFavorites().map { it.trackPath }, + ).map { trackItem(it, AstraCarMediaId(kind = "section", section = "favorites")) } + "playlists" -> playlists().map(::playlistItem) + "albums" -> catalog.getAllAlbumSummaries(catalog.getRevision()).map(::albumItem) + "artists" -> catalog + .getAllArtistSummaries(catalog.getRevision(), artistGroupingMode()) + .map(::artistItem) + else -> emptyList() + } + "playlist" -> { + val id = media.id + if (id == null) emptyList() + else playlistTracks(id).map { + trackItem(it, AstraCarMediaId(kind = "playlist", id = id)) + } + } + "album" -> { + val key = media.key + if (key == null) emptyList() + else catalog.getAlbumTracks(key).map { + trackItem(it, AstraCarMediaId(kind = "album", key = key)) + } + } + "artist" -> media.key + ?.let { + catalog.getAllArtistTracks( + catalog.getRevision(), + artistGroupingMode(), + it, + ) + } + ?.map { trackItem(it, AstraCarMediaId(kind = "artist", key = media.key)) } + .orEmpty() + else -> emptyList() } } - private fun openReadableDb(): SQLiteDatabase? = AstraCarDb.openReadable(context) + private suspend fun playlistTracks(playlistId: Long): List { + val userDao = repository.userDb().userDao() + val catalogDao = repository.catalogDb().catalogDao() + val playlist = userDao.getPlaylist(playlistId) ?: return emptyList() + if (playlist.kind != "dynamic") { + return tracksForPaths(userDao.getPlaylistTracks(playlistId).map { it.trackPath }) + } + val total = DynamicPlaylistCompiler + .compile(playlist.dynamicRulesJson, 0, 1) + .let { catalogDao.runDynamicCountQuery(it.count).toInt() } + val tracks = ArrayList(total) + for (offset in 0 until total step ROOM_PAGE_SIZE) { + val query = DynamicPlaylistCompiler.compile( + playlist.dynamicRulesJson, + offset, + minOf(ROOM_PAGE_SIZE, total - offset), + ) + tracks += catalogDao.runDynamicTrackQuery(query.tracks) + } + return tracks + } - private fun childrenFor(db: SQLiteDatabase, media: AstraCarMediaId): List = - when (media.kind) { - "root" -> rootItems() - "section" -> when (media.section) { - "recent" -> getRecentlyPlayed(db).map { trackItem(it, AstraCarMediaId(kind = "section", section = "recent")) } - "favorites" -> getFavorites(db).map { trackItem(it, AstraCarMediaId(kind = "section", section = "favorites")) } - "playlists" -> getPlaylists(db).map(::playlistItem) - "albums" -> getAlbums(db).map(::albumItem) - "artists" -> getArtists(db).map(::artistItem) - else -> emptyList() + private suspend fun playlists(): List { + val userDao = repository.userDb().userDao() + val catalogDao = repository.catalogDb().catalogDao() + return userDao.getPlaylists().map { playlist -> + val tracks = if (playlist.kind == "dynamic") { + val query = DynamicPlaylistCompiler.compile(playlist.dynamicRulesJson, 0, 1) + catalogDao.runDynamicTrackQuery(query.tracks) + } else { + val firstPath = userDao.getPlaylistTrackPage(playlist.id, 1, 0).firstOrNull()?.trackPath + val first = if (firstPath == null) null else catalogDao.getActiveTrack(firstPath) + if (first == null) emptyList() else listOf(first) } - "playlist" -> media.id?.let { id -> - getPlaylistTracks(db, id).map { trackItem(it, AstraCarMediaId(kind = "playlist", id = id)) } - } ?: emptyList() - "album" -> media.key?.let { key -> - getAlbumTracks(db, key).map { trackItem(it, AstraCarMediaId(kind = "album", key = key)) } - } ?: emptyList() - "artist" -> media.key?.let { name -> - getArtistTracks(db, name).map { trackItem(it, AstraCarMediaId(kind = "artist", key = name)) } - } ?: emptyList() - else -> emptyList() + val count = if (playlist.kind == "dynamic") { + val query = DynamicPlaylistCompiler.compile(playlist.dynamicRulesJson, 0, 1) + catalogDao.runDynamicCountQuery(query.count) + } else { + userDao.countPlaylistTracks(playlist.id) + } + val cover = tracks.firstOrNull { + it.artworkHash != null || (it.sourceId != null && !it.artworkSourceId.isNullOrBlank()) + } + PlaylistRow( + id = playlist.id, + name = playlist.name, + artworkHash = cover?.artworkHash, + sourceId = cover?.sourceId, + artworkSourceId = cover?.artworkSourceId, + trackCount = count, + ) + } + } + + private suspend fun tracksForPaths(paths: List): List { + if (paths.isEmpty()) return emptyList() + val catalog = repository.catalogDb().catalogDao() + val rows = LinkedHashMap() + for (chunk in paths.distinct().chunked(ROOM_PAGE_SIZE)) { + catalog.getActiveTracks(chunk).forEach { rows[it.path] = it } + } + return paths.mapNotNull(rows::get) + } + + private suspend fun artistGroupingMode(): String = + if (repository.userDb().userDao().getSetting("artist_grouping_mode") == "fileTags") { + "fileTags" + } else { + "astra" } private fun rootItem(): MediaItem = @@ -94,13 +203,7 @@ class AstraCarCatalog(private val context: Context) { ) private fun rootItems(): List = - listOf( - sectionItem("recent"), - sectionItem("favorites"), - sectionItem("playlists"), - sectionItem("albums"), - sectionItem("artists"), - ) + listOf("recent", "favorites", "playlists", "albums", "artists").map(::sectionItem) private fun sectionItem(section: String): MediaItem { val title = when (section) { @@ -114,7 +217,7 @@ class AstraCarCatalog(private val context: Context) { return browsable(AstraCarMediaIds.section(section), title, null) } - private fun albumItem(album: AlbumRow): MediaItem = + private fun albumItem(album: AlbumSummaryEntity): MediaItem = browsable( AstraCarMediaIds.album(album.identityKey), album.album, @@ -123,9 +226,9 @@ class AstraCarCatalog(private val context: Context) { "${album.trackCount} ${if (album.trackCount == 1L) "track" else "tracks"}", ) - private fun artistItem(artist: ArtistRow): MediaItem = + private fun artistItem(artist: ArtistSummaryEntity): MediaItem = browsable( - AstraCarMediaIds.artist(artist.artist), + AstraCarMediaIds.artist(artist.artistKey), artist.artist, "${artist.trackCount} ${if (artist.trackCount == 1L) "track" else "tracks"}", artworkIconUri(artist.artworkHash, artist.sourceId, artist.artworkSourceId), @@ -139,19 +242,21 @@ class AstraCarCatalog(private val context: Context) { artworkIconUri(playlist.artworkHash, playlist.sourceId, playlist.artworkSourceId), ) - private fun trackItem(track: TrackRow, contextMedia: AstraCarMediaId): MediaItem { + private fun trackItem(track: ActiveTrackView, contextMedia: AstraCarMediaId): MediaItem { val extras = Bundle().apply { putLong(MediaMetadataCompat.METADATA_KEY_DURATION, (track.duration * 1000).toLong()) } - val description = MediaDescriptionCompat.Builder() - .setMediaId(AstraCarMediaIds.track(track.path, contextMedia)) - .setTitle(track.title) - .setSubtitle(track.artist) - .setDescription(track.album) - .setIconUri(artworkIconUri(track.artworkHash, track.sourceId, track.artworkSourceId)) - .setExtras(extras) - .build() - return MediaItem(description, MediaItem.FLAG_PLAYABLE) + return MediaItem( + MediaDescriptionCompat.Builder() + .setMediaId(AstraCarMediaIds.track(track.path, contextMedia)) + .setTitle(track.title) + .setSubtitle(track.artist) + .setDescription(track.album) + .setIconUri(artworkIconUri(track.artworkHash, track.sourceId, track.artworkSourceId)) + .setExtras(extras) + .build(), + MediaItem.FLAG_PLAYABLE, + ) } private fun browsable( @@ -160,29 +265,20 @@ class AstraCarCatalog(private val context: Context) { subtitle: String?, iconUri: Uri? = null, description: String? = null, - ): MediaItem { - val mediaDescription = MediaDescriptionCompat.Builder() - .setMediaId(mediaId) - .setTitle(title) - .setSubtitle(subtitle) - .setDescription(description) - .setIconUri(iconUri) - .build() - return MediaItem(mediaDescription, MediaItem.FLAG_BROWSABLE) - } + ): MediaItem = + MediaItem( + MediaDescriptionCompat.Builder() + .setMediaId(mediaId) + .setTitle(title) + .setSubtitle(subtitle) + .setDescription(description) + .setIconUri(iconUri) + .build(), + MediaItem.FLAG_BROWSABLE, + ) - /** - * Browse-list icon URI: a local content:// URI when the scanner has cached art for - * `hash`, else a remote content:// URI for a remote track with server art, else null. - * Android Auto loads art only from content:// (never file:// / http), so everything - * routes through [AstraCarArtworkProvider]. - */ private fun artworkIconUri(hash: String?, sourceId: Long?, artworkSourceId: String?): Uri? { - localArtworkUri(hash)?.let { return it } - if (sourceId != null && !artworkSourceId.isNullOrBlank()) { - return AstraCarArtwork.remoteUri(context, sourceId, artworkSourceId) - } - return null + return localArtworkUri(hash) } private fun localArtworkUri(hash: String?): Uri? { @@ -200,15 +296,14 @@ class AstraCarCatalog(private val context: Context) { val pageSize = options?.getInt(MediaBrowserCompat.EXTRA_PAGE_SIZE, -1) ?: -1 if (page < 0 || pageSize <= 0) { if (items.size > MAX_UNPAGED_CHILDREN) { - Log.w(TAG, "capping ${items.size} children to $MAX_UNPAGED_CHILDREN (client did not paginate)") + Log.w(TAG, "capping ${items.size} children to $MAX_UNPAGED_CHILDREN") return items.take(MAX_UNPAGED_CHILDREN) } return items } val from = page * pageSize if (from >= items.size) return emptyList() - val to = minOf(from + pageSize, items.size) - return items.subList(from, to) + return items.subList(from, minOf(from + pageSize, items.size)) } private fun contextFromTrackMedia(media: AstraCarMediaId): AstraCarMediaId = @@ -219,639 +314,18 @@ class AstraCarCatalog(private val context: Context) { id = media.contextId, ) - private fun queryTracks( - db: SQLiteDatabase, - sql: String, - args: Array = emptyArray(), - ): List = - db.rawQuery(sql, args).use { cursor -> - buildList { - while (cursor.moveToNext()) add(cursor.toTrackRow()) - } + private fun room(block: suspend () -> T): T = + runBlocking(Dispatchers.IO) { + repository.initialize() + block() } - - private fun getRecentlyPlayed(db: SQLiteDatabase): List = - queryTracks( - db, - "SELECT $TRACK_COLUMNS FROM playback_history h JOIN tracks t ON t.path = h.track_path " + - "ORDER BY h.last_played_at DESC LIMIT 24", - ) - - private fun getFavorites(db: SQLiteDatabase): List = - queryTracks( - db, - "SELECT $TRACK_COLUMNS FROM favorites f JOIN tracks t ON t.path = f.track_path " + - "ORDER BY f.added_at DESC", - ) - - private fun getPlaylistTracks(db: SQLiteDatabase, playlistId: Long): List = - getPlaylistRuleRow(db, playlistId)?.let { row -> - if (row.kind == "dynamic") getDynamicPlaylistTracks(db, row.dynamicRulesJson) - else queryTracks( - db, - "SELECT $TRACK_COLUMNS FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path " + - "WHERE pt.playlist_id = ? ORDER BY pt.position, pt.id", - arrayOf(playlistId.toString()), - ) - } ?: emptyList() - - private fun getAlbumTracks(db: SQLiteDatabase, identityKey: String): List = - queryTracks( - db, - "SELECT $TRACK_COLUMNS FROM tracks t WHERE t.album_identity_key = ? ORDER BY $TRACK_ORDER", - arrayOf(identityKey), - ) - - private fun getTrackByPath(db: SQLiteDatabase, path: String): TrackRow? = - queryTracks(db, "SELECT $TRACK_COLUMNS FROM tracks t WHERE t.path = ? LIMIT 1", arrayOf(path)).firstOrNull() - - private fun getAllTracks(db: SQLiteDatabase): List = - queryTracks( - db, - "SELECT $TRACK_COLUMNS FROM tracks t ORDER BY t.artist COLLATE NOCASE, t.album COLLATE NOCASE, $TRACK_ORDER", - ) - - private fun getArtistTracks(db: SQLiteDatabase, artist: String): List { - val mode = getArtistGroupingMode(db) - return getAllTracks(db).filter { trackMatchesBrowseArtist(it, normalizeKey(artist), mode) } - } - - private fun getAlbums(db: SQLiteDatabase): List { - // album_display_artist is the settled group artist ("Various Artists" for - // compilations), uniform per group so MAX() is exact. Guarded: this headless - // service can open a pre-v15 DB before the JS app ever runs its migration. - val artistExpr = - if (hasColumn(db, "tracks", "album_display_artist")) { - "MAX(COALESCE(album_display_artist, album_artist, artist))" - } else { - "MAX(COALESCE(album_artist, artist))" - } - return db.rawQuery( - """ - SELECT album_identity_key AS identity_key, - MAX(album) AS album, - $artistExpr AS artist, - MAX(year) AS year, - MAX(artwork_hash) AS artwork_hash, - MAX(source_id) AS source_id, - MAX(artwork_source_id) AS artwork_source_id, - COUNT(*) AS track_count - FROM tracks - GROUP BY album_identity_key - ORDER BY 3 COLLATE NOCASE, 2 COLLATE NOCASE - """.trimIndent(), - emptyArray(), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - add( - AlbumRow( - identityKey = cursor.string("identity_key"), - album = cursor.string("album"), - artist = cursor.string("artist"), - artworkHash = cursor.nullableString("artwork_hash"), - sourceId = cursor.nullableLong("source_id"), - artworkSourceId = cursor.nullableString("artwork_source_id"), - trackCount = cursor.long("track_count"), - ), - ) - } - } - } - } - - private fun getPlaylistRuleRow(db: SQLiteDatabase, playlistId: Long): PlaylistRuleRow? { - if (!hasColumn(db, "playlists", "kind")) return PlaylistRuleRow("normal", null) - return db.rawQuery( - "SELECT kind, dynamic_rules_json FROM playlists WHERE id = ? LIMIT 1", - arrayOf(playlistId.toString()), - ).use { cursor -> - if (!cursor.moveToFirst()) return@use null - PlaylistRuleRow( - kind = if (cursor.string("kind") == "dynamic") "dynamic" else "normal", - dynamicRulesJson = cursor.nullableString("dynamic_rules_json"), - ) - } - } - - private fun getPlaylists(db: SQLiteDatabase): List { - val supportsDynamic = hasColumn(db, "playlists", "kind") && hasColumn(db, "playlists", "dynamic_rules_json") - val kindColumns = - if (supportsDynamic) "p.kind, p.dynamic_rules_json," - else "'normal' AS kind, NULL AS dynamic_rules_json," - - return db.rawQuery( - """ - SELECT p.id, p.name, $kindColumns - (SELECT t.artwork_hash - FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path - WHERE pt.playlist_id = p.id AND t.artwork_hash IS NOT NULL - ORDER BY pt.position, pt.id LIMIT 1) AS artwork_hash, - (SELECT t.source_id - FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path - WHERE pt.playlist_id = p.id AND t.artwork_source_id IS NOT NULL - ORDER BY pt.position, pt.id LIMIT 1) AS source_id, - (SELECT t.artwork_source_id - FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path - WHERE pt.playlist_id = p.id AND t.artwork_source_id IS NOT NULL - ORDER BY pt.position, pt.id LIMIT 1) AS artwork_source_id, - (SELECT COUNT(*) - FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path - WHERE pt.playlist_id = p.id) AS track_count - FROM playlists p - ORDER BY (p.last_played_at IS NULL), p.last_played_at DESC, p.updated_at DESC - """.trimIndent(), - emptyArray(), - ).use { cursor -> - buildList { - while (cursor.moveToNext()) { - val kind = if (cursor.string("kind") == "dynamic") "dynamic" else "normal" - val rulesJson = cursor.nullableString("dynamic_rules_json") - val dynamicTracks = if (kind == "dynamic") getDynamicPlaylistTracks(db, rulesJson) else null - val firstDynamicCover = dynamicTracks?.firstOrNull { - it.artworkHash != null || (it.sourceId != null && !it.artworkSourceId.isNullOrBlank()) - } - add( - PlaylistRow( - id = cursor.long("id"), - name = cursor.string("name"), - kind = kind, - artworkHash = firstDynamicCover?.artworkHash ?: cursor.nullableString("artwork_hash"), - sourceId = firstDynamicCover?.sourceId ?: cursor.nullableLong("source_id"), - artworkSourceId = firstDynamicCover?.artworkSourceId ?: cursor.nullableString("artwork_source_id"), - trackCount = dynamicTracks?.size?.toLong() ?: cursor.long("track_count"), - ), - ) - } - } - } - } - - private fun hasColumn(db: SQLiteDatabase, table: String, column: String): Boolean = - db.rawQuery("PRAGMA table_info($table)", emptyArray()).use { cursor -> - while (cursor.moveToNext()) { - if (cursor.string("name") == column) return@use true - } - false - } - - private fun getDynamicPlaylistTracks(db: SQLiteDatabase, rawRules: String?): List { - val rules = parseDynamicRules(rawRules) - val conditions = rules.optJSONArray("conditions") - val joins = StringBuilder() - val where = mutableListOf() - val args = mutableListOf() - var needsFavoriteJoin = false - - if (conditions != null) { - for (i in 0 until conditions.length()) { - val condition = conditions.optJSONObject(i) ?: continue - if (condition.optString("kind") == "exact" && condition.optString("field") == "favorite") { - needsFavoriteJoin = true - } - } - if (needsFavoriteJoin) joins.append("LEFT JOIN favorites f ON f.track_path = t.path") - - for (i in 0 until conditions.length()) { - appendDynamicCondition(conditions.optJSONObject(i) ?: continue, where, args) - } - } - - val sort = rules.optJSONObject("sort") - val orderBy = dynamicOrderBy( - field = sort?.optString("field") ?: "title", - direction = sort?.optString("direction") ?: "asc", - ) - val limit = rules.opt("limit").let { value -> - when (value) { - is Number -> value.toInt().coerceIn(1, 5000) - else -> null - } - } - val limitSql = limit?.let { " LIMIT $it" }.orEmpty() - val whereSql = if (where.isEmpty()) "1 = 1" else where.joinToString("\n AND ") - - return queryTracks( - db, - "SELECT $TRACK_COLUMNS FROM tracks t $joins WHERE $whereSql ORDER BY $orderBy$limitSql", - args.toTypedArray(), - ) - } - - private fun parseDynamicRules(rawRules: String?): JSONObject { - if (rawRules.isNullOrBlank()) return JSONObject("""{"version":1,"conditions":[],"sort":{"field":"title","direction":"asc"},"limit":null}""") - return try { - JSONObject(rawRules) - } catch (_: Throwable) { - JSONObject("""{"version":1,"conditions":[],"sort":{"field":"title","direction":"asc"},"limit":null}""") - } - } - - private fun appendDynamicCondition( - condition: JSONObject, - where: MutableList, - args: MutableList, - ) { - when (condition.optString("kind")) { - "text" -> appendDynamicTextCondition(condition, where, args) - "exact" -> appendDynamicExactCondition(condition, where, args) - "numeric" -> appendDynamicNumericCondition(condition, where, args) - "date" -> appendDynamicDateCondition(condition, where, args) - } - } - - private fun appendDynamicTextCondition( - condition: JSONObject, - where: MutableList, - args: MutableList, - ) { - val expression = when (condition.optString("field")) { - "title" -> "t.title" - "artist" -> "t.artist" - "album" -> "t.album" - "album_artist" -> "t.album_artist" - "genre" -> "t.genre" - "format" -> "t.format" - "musical_key" -> "t.musical_key" - else -> return - } - val value = condition.optString("value").trim().lowercase(Locale.ROOT) - if (value.isEmpty()) return - when (condition.optString("operator")) { - "contains" -> { - where.add("LOWER(COALESCE($expression, '')) LIKE ?") - args.add("%$value%") - } - "is_not" -> { - where.add("LOWER(COALESCE($expression, '')) <> ?") - args.add(value) - } - else -> { - where.add("LOWER(COALESCE($expression, '')) = ?") - args.add(value) - } - } - } - - private fun appendDynamicExactCondition( - condition: JSONObject, - where: MutableList, - args: MutableList, - ) { - if (condition.optString("field") == "source_type") { - val operator = if (condition.optString("operator") == "is_not") "<>" else "=" - where.add("t.source_type $operator ?") - args.add(condition.optString("value")) - return - } - if (condition.optString("field") != "favorite") return - - val value = condition.optBoolean("value", false) - val expectsFavorite = if (condition.optString("operator") == "is_not") !value else value - where.add("f.track_path IS ${if (expectsFavorite) "NOT NULL" else "NULL"}") - } - - private fun appendDynamicNumericCondition( - condition: JSONObject, - where: MutableList, - args: MutableList, - ) { - val expression = when (condition.optString("field")) { - "play_count" -> "COALESCE(t.play_count, 0)" - "year" -> "t.year" - "duration_seconds" -> "t.duration" - "bpm" -> "t.bpm" - else -> return - } - val value = condition.optDouble("value", Double.NaN) - if (value.isNaN() || value.isInfinite()) return - val operator = when (condition.optString("operator")) { - "lte" -> "<=" - "gte" -> ">=" - else -> "=" - } - where.add("$expression $operator ?") - args.add(value.toString()) - } - - private fun appendDynamicDateCondition( - condition: JSONObject, - where: MutableList, - args: MutableList, - ) { - val field = condition.optString("field") - val expression = when (field) { - "last_played_at" -> "t.last_played_at" - "added_at" -> "t.added_at" - else -> return - } - val operator = condition.optString("operator") - if (field == "last_played_at" && operator == "never") { - where.add("$expression IS NULL") - return - } - - val days = condition.optInt("value", 1).coerceAtLeast(1) - val cutoff = System.currentTimeMillis() - days * 24L * 60L * 60L * 1000L - if (field == "last_played_at") { - if (operator == "within_days") { - where.add("$expression >= ?") - } else { - where.add("($expression IS NULL OR $expression < ?)") - } - args.add(cutoff.toString()) - return - } - - where.add("$expression ${if (operator == "older_than_days") "<" else ">="} ?") - args.add(cutoff.toString()) - } - - private fun dynamicOrderBy(field: String, direction: String): String { - val sort = when (field) { - "artist" -> DynamicSort("t.artist", nullable = false, text = true) - "album" -> DynamicSort("t.album", nullable = false, text = true) - "added_at" -> DynamicSort("t.added_at", nullable = false) - "last_played_at" -> DynamicSort("t.last_played_at", nullable = true) - "play_count" -> DynamicSort("COALESCE(t.play_count, 0)", nullable = false) - "year" -> DynamicSort("t.year", nullable = true) - "duration_seconds" -> DynamicSort("t.duration", nullable = false) - "bpm" -> DynamicSort("t.bpm", nullable = true) - else -> DynamicSort("t.title", nullable = false, text = true) - } - val dir = if (direction == "desc") "DESC" else "ASC" - val expression = if (sort.text) "${sort.expression} COLLATE NOCASE" else sort.expression - val nullablePrefix = if (sort.nullable) "CASE WHEN ${sort.expression} IS NULL THEN 1 ELSE 0 END ASC, " else "" - return "$nullablePrefix$expression $dir, t.path COLLATE NOCASE ASC" - } - - private fun getArtistGroupingMode(db: SQLiteDatabase): String = - db.rawQuery( - "SELECT value FROM settings WHERE key = ? LIMIT 1", - arrayOf("artist_grouping_mode"), - ).use { cursor -> - if (cursor.moveToFirst() && cursor.string(0) == "fileTags") "fileTags" else "astra" - } - - private fun getArtists(db: SQLiteDatabase): List = - buildArtistList(getAllTracks(db), getArtistGroupingMode(db)) - - private fun buildArtistList(tracks: List, mode: String): List { - val byKey = linkedMapOf() - - for (track in tracks) { - val names = - if (mode == "fileTags") listOf(resolveStrictBrowseArtist(track)) - else getCanonicalArtistIndexNames(track) - val seen = mutableSetOf() - - for (name in names) { - val key = normalizeKey(name) - if (key.isEmpty() || !seen.add(key)) continue - val aggregate = byKey.getOrPut(key) { - ArtistAggregate( - artist = name, - trackCount = 0, - artworkHash = null, - artworkYear = -1, - artworkAddedAt = -1, - artworkModifiedAt = -1, - ) - } - aggregate.trackCount += 1 - - // Remote tracks carry no local artwork_hash; keep the first server cover ref as a - // fallback so remote-only artists still get a tile (local hash wins when present). - if (aggregate.remoteArtworkSourceId == null && - track.sourceId != null && !track.artworkSourceId.isNullOrBlank() - ) { - aggregate.remoteSourceId = track.sourceId - aggregate.remoteArtworkSourceId = track.artworkSourceId - } - - val artworkHash = track.artworkHash ?: continue - val candidateYear = track.year ?: -1 - val better = - aggregate.artworkHash == null || - candidateYear > aggregate.artworkYear || - (candidateYear == aggregate.artworkYear && - (track.addedAt > aggregate.artworkAddedAt || - (track.addedAt == aggregate.artworkAddedAt && track.modifiedAt > aggregate.artworkModifiedAt))) - if (!better) continue - aggregate.artworkHash = artworkHash - aggregate.artworkYear = candidateYear - aggregate.artworkAddedAt = track.addedAt - aggregate.artworkModifiedAt = track.modifiedAt - } - } - - return byKey.values - .map { - ArtistRow( - it.artist, - it.trackCount.toLong(), - it.artworkHash, - it.remoteSourceId, - it.remoteArtworkSourceId, - ) - } - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.artist }) - } - - private fun normalizeDisplay(value: String?): String = - value.orEmpty().replace(Regex("\\s+"), " ").trim() - - private fun normalizeKey(value: String?): String = - normalizeDisplay(value).lowercase(Locale.ROOT) - - private fun splitCollaborators(rawArtist: String?): List { - val normalized = normalizeDisplay(rawArtist) - if (normalized.isEmpty()) return emptyList() - val unified = normalized - .replace(Regex("\\s*;\\s*"), ",") - .replace(Regex("\\s+&\\s+"), ",") - .replace(Regex("\\s+[x×]\\s+", RegexOption.IGNORE_CASE), ",") - .replace(Regex("\\s+(feat\\.?|ft\\.?|featuring|with)\\s+", RegexOption.IGNORE_CASE), ",") - return dedupeByKey(unified.split(",")) - } - - private fun splitAlbumArtistCollaborators(rawAlbumArtist: String?): List { - val normalized = normalizeDisplay(rawAlbumArtist) - if (normalized.isEmpty()) return emptyList() - val unified = normalized - .replace(Regex("\\s*;\\s*"), ",") - .replace(Regex("\\s+[x×]\\s+", RegexOption.IGNORE_CASE), ",") - .replace(Regex("\\s+(feat\\.?|ft\\.?|featuring|with)\\s+", RegexOption.IGNORE_CASE), ",") - return dedupeByKey(unified.split(",")) - } - - private fun dedupeByKey(parts: List): List { - val unique = linkedMapOf() - for (part in parts) { - val display = normalizeDisplay(part) - val key = normalizeKey(display) - if (key.isEmpty() || unique.containsKey(key)) continue - unique[key] = display - } - return unique.values.toList() - } - - private fun resolveStrictBrowseArtist(track: TrackRow): String { - val albumArtist = normalizeDisplay(track.albumArtist) - return albumArtist.ifEmpty { normalizeDisplay(track.artist).ifEmpty { "Unknown Artist" } } - } - - private fun resolveCanonicalBrowseArtist(track: TrackRow): String { - val albumArtist = normalizeDisplay(track.albumArtist) - if (albumArtist.isNotEmpty()) { - return splitAlbumArtistCollaborators(albumArtist).firstOrNull() ?: albumArtist - } - return splitCollaborators(track.artist).firstOrNull() ?: "Unknown Artist" - } - - private fun getCanonicalArtistIndexNames(track: TrackRow): List { - val unique = linkedMapOf() - fun add(name: String?) { - val display = normalizeDisplay(name) - val key = normalizeKey(display) - if (key.isEmpty() || unique.containsKey(key)) return - unique[key] = display - } - - add(resolveCanonicalBrowseArtist(track)) - val trackArtists = splitCollaborators(track.artist) - for (name in trackArtists) add(name) - if (trackArtists.isEmpty()) { - for (name in splitAlbumArtistCollaborators(track.albumArtist)) add(name) - } - return unique.values.toList() - } - - private fun trackMatchesBrowseArtist(track: TrackRow, targetArtistKey: String, mode: String): Boolean { - val browseKey = normalizeKey( - if (mode == "fileTags") resolveStrictBrowseArtist(track) else resolveCanonicalBrowseArtist(track), - ) - if (browseKey == targetArtistKey) return true - if (mode == "fileTags") return false - - val albumArtistKey = normalizeKey(track.albumArtist) - if (albumArtistKey.isNotEmpty() && albumArtistKey == targetArtistKey) return true - - val trackArtistKey = normalizeKey(track.artist) - if (trackArtistKey.isNotEmpty() && trackArtistKey == targetArtistKey) return true - - if (splitAlbumArtistCollaborators(track.albumArtist).any { normalizeKey(it) == targetArtistKey }) return true - return splitCollaborators(track.artist).any { normalizeKey(it) == targetArtistKey } - } } -private data class TrackRow( - val id: Long, - val path: String, - val title: String, - val artist: String, - val album: String, - val albumArtist: String?, - val albumIdentityKey: String, - val duration: Double, - val trackNumber: Long?, - val discNumber: Long?, - val year: Long?, - val artworkHash: String?, - val sourceType: String, - val sourceId: Long?, - val sourceTrackId: String?, - val artworkSourceId: String?, - val addedAt: Long, - val modifiedAt: Long, -) - -private data class AlbumRow( - val identityKey: String, - val album: String, - val artist: String, - val artworkHash: String?, - val sourceId: Long?, - val artworkSourceId: String?, - val trackCount: Long, -) - private data class PlaylistRow( val id: Long, val name: String, - val kind: String, val artworkHash: String?, val sourceId: Long?, val artworkSourceId: String?, val trackCount: Long, ) - -private data class PlaylistRuleRow( - val kind: String, - val dynamicRulesJson: String?, -) - -private data class DynamicSort( - val expression: String, - val nullable: Boolean, - val text: Boolean = false, -) - -private data class ArtistRow( - val artist: String, - val trackCount: Long, - val artworkHash: String?, - val sourceId: Long?, - val artworkSourceId: String?, -) - -private data class ArtistAggregate( - val artist: String, - var trackCount: Int, - var artworkHash: String?, - var artworkYear: Long, - var artworkAddedAt: Long, - var artworkModifiedAt: Long, - var remoteSourceId: Long? = null, - var remoteArtworkSourceId: String? = null, -) - -private fun Cursor.toTrackRow(): TrackRow = - TrackRow( - id = long("id"), - path = string("path"), - title = string("title"), - artist = string("artist"), - album = string("album"), - albumArtist = nullableString("album_artist"), - albumIdentityKey = string("album_identity_key"), - duration = double("duration"), - trackNumber = nullableLong("track_number"), - discNumber = nullableLong("disc_number"), - year = nullableLong("year"), - artworkHash = nullableString("artwork_hash"), - sourceType = string("source_type"), - sourceId = nullableLong("source_id"), - sourceTrackId = nullableString("source_track_id"), - artworkSourceId = nullableString("artwork_source_id"), - addedAt = long("added_at"), - modifiedAt = long("modified_at"), - ) - -private fun Cursor.string(name: String): String = string(getColumnIndexOrThrow(name)) - -private fun Cursor.string(index: Int): String = getString(index) ?: "" - -private fun Cursor.nullableString(name: String): String? { - val index = getColumnIndexOrThrow(name) - return if (isNull(index)) null else getString(index) -} - -private fun Cursor.long(name: String): Long = getLong(getColumnIndexOrThrow(name)) - -private fun Cursor.nullableLong(name: String): Long? { - val index = getColumnIndexOrThrow(name) - return if (isNull(index)) null else getLong(index) -} - -private fun Cursor.double(name: String): Double = getDouble(getColumnIndexOrThrow(name)) diff --git a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarDb.kt b/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarDb.kt deleted file mode 100644 index 0f42f42..0000000 --- a/modules/astra-car/android/src/main/java/expo/modules/astracar/AstraCarDb.kt +++ /dev/null @@ -1,30 +0,0 @@ -package expo.modules.astracar - -import android.content.Context -import android.database.sqlite.SQLiteDatabase - -/** - * Shared read access to the op-sqlite library database (`astra-library.db`), used by - * both the browse catalog and the artwork ContentProvider. op-sqlite stores the file at - * [Context.getDatabasePath] (the same dir we read here). - */ -object AstraCarDb { - const val DB_NAME = "astra-library.db" - - /** - * Opens the library DB for reading. The JS side runs it in WAL mode - * (`PRAGMA journal_mode = WAL`), and a strict `OPEN_READONLY` open of a live WAL - * database can throw ("could not open in read/write mode" — read-only WAL needs a - * writable `-shm`/`-wal`). Try read-only first, then fall back to read/write (we only - * ever run `SELECT`s). Returns null if the DB is missing or can't be opened. - */ - fun openReadable(context: Context): SQLiteDatabase? { - val file = context.getDatabasePath(DB_NAME) - if (!file.exists()) return null - return runCatching { - SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READONLY) - }.recoverCatching { - SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READWRITE) - }.getOrNull() - } -} diff --git a/modules/astra-library-scanner/android/build.gradle b/modules/astra-library-scanner/android/build.gradle index 6326f60..fdb49e7 100644 --- a/modules/astra-library-scanner/android/build.gradle +++ b/modules/astra-library-scanner/android/build.gradle @@ -1,8 +1,16 @@ +buildscript { + dependencies { + classpath "com.google.devtools.ksp:symbol-processing-gradle-plugin:${rootProject["kspVersion"]}" + } +} + plugins { id 'com.android.library' id 'expo-module-gradle-plugin' } +apply plugin: 'com.google.devtools.ksp' + group = 'expo.modules.astralibraryscanner' version = '0.1.0' @@ -11,6 +19,10 @@ android { defaultConfig { versionCode 1 versionName "0.1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + sourceSets { + androidTest.assets.srcDirs += files("$projectDir/schemas") } lintOptions { abortOnError false @@ -22,5 +34,19 @@ dependencies { // MetadataRetriever — parses ID3/Vorbis/MP4 container tags (ReplayGain) without // decoding audio. Transitively brings exoplayer-extractor (the frame classes). implementation 'com.google.android.exoplayer:exoplayer-core:2.19.0' + implementation 'androidx.room:room-runtime:2.8.4' + implementation 'androidx.room:room-ktx:2.8.4' + ksp 'androidx.room:room-compiler:2.8.4' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.room:room-testing:2.8.4' + androidTestImplementation 'androidx.test:core:1.6.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test:runner:1.6.2' +} + +ksp { + arg("room.schemaLocation", "$projectDir/schemas") + arg("room.generateKotlin", "true") } diff --git a/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraCatalogDatabase/1.json b/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraCatalogDatabase/1.json new file mode 100644 index 0000000..0394177 --- /dev/null +++ b/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraCatalogDatabase/1.json @@ -0,0 +1,1149 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "cc1e0fc052cde984629b8c430c720f20", + "entities": [ + { + "tableName": "catalog_meta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `collation_version` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collationVersion", + "columnName": "collation_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "catalog_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source_key` TEXT NOT NULL, `source_type` TEXT NOT NULL, `source_id` INTEGER NOT NULL, `active_generation_id` TEXT, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`source_key`))", + "fields": [ + { + "fieldPath": "sourceKey", + "columnName": "source_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceType", + "columnName": "source_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "source_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activeGenerationId", + "columnName": "active_generation_id", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "source_key" + ] + } + }, + { + "tableName": "scan_generations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `source_key` TEXT NOT NULL, `state` TEXT NOT NULL, `started_at` INTEGER NOT NULL, `finished_at` INTEGER, `error_message` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceKey", + "columnName": "source_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "finishedAt", + "columnName": "finished_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "errorMessage", + "columnName": "error_message", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_scan_generations_source_key_state", + "unique": false, + "columnNames": [ + "source_key", + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scan_generations_source_key_state` ON `${TABLE_NAME}` (`source_key`, `state`)" + } + ] + }, + { + "tableName": "tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `generation_id` TEXT NOT NULL, `source_key` TEXT NOT NULL, `path` TEXT NOT NULL, `folder_id` INTEGER, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_artist` TEXT, `album_identity_key` TEXT NOT NULL, `album_display_artist` TEXT, `duration` REAL NOT NULL, `track_number` INTEGER, `disc_number` INTEGER, `year` INTEGER, `genre` TEXT, `artwork_hash` TEXT, `format` TEXT NOT NULL, `sample_rate` INTEGER, `bit_depth` INTEGER, `bitrate` INTEGER, `channels` INTEGER, `codec` TEXT, `source_type` TEXT NOT NULL, `source_id` INTEGER, `source_track_id` TEXT, `source_path` TEXT, `artwork_source_id` TEXT, `file_name` TEXT NOT NULL, `parent_uri` TEXT, `size` INTEGER, `mtime` INTEGER NOT NULL, `added_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `loudness_lufs` REAL, `sample_peak` REAL, `replay_gain_track_db` REAL, `replay_gain_album_db` REAL, `replay_gain_track_peak` REAL, `replay_gain_album_peak` REAL, `rg_scanned` INTEGER NOT NULL, `bpm` REAL, `musical_key` TEXT, `title_sort_key` TEXT NOT NULL, `artist_sort_key` TEXT NOT NULL, `album_sort_key` TEXT NOT NULL, `file_name_sort_key` TEXT NOT NULL, `disc_sort` INTEGER NOT NULL, `track_sort` INTEGER NOT NULL, `section_label` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "generationId", + "columnName": "generation_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceKey", + "columnName": "source_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "folderId", + "columnName": "folder_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "album_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "albumIdentityKey", + "columnName": "album_identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumDisplayArtist", + "columnName": "album_display_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "trackNumber", + "columnName": "track_number", + "affinity": "INTEGER" + }, + { + "fieldPath": "discNumber", + "columnName": "disc_number", + "affinity": "INTEGER" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "artworkHash", + "columnName": "artwork_hash", + "affinity": "TEXT" + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sample_rate", + "affinity": "INTEGER" + }, + { + "fieldPath": "bitDepth", + "columnName": "bit_depth", + "affinity": "INTEGER" + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER" + }, + { + "fieldPath": "channels", + "columnName": "channels", + "affinity": "INTEGER" + }, + { + "fieldPath": "codec", + "columnName": "codec", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceType", + "columnName": "source_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "source_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "sourceTrackId", + "columnName": "source_track_id", + "affinity": "TEXT" + }, + { + "fieldPath": "sourcePath", + "columnName": "source_path", + "affinity": "TEXT" + }, + { + "fieldPath": "artworkSourceId", + "columnName": "artwork_source_id", + "affinity": "TEXT" + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentUri", + "columnName": "parent_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER" + }, + { + "fieldPath": "mtime", + "columnName": "mtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessLufs", + "columnName": "loudness_lufs", + "affinity": "REAL" + }, + { + "fieldPath": "samplePeak", + "columnName": "sample_peak", + "affinity": "REAL" + }, + { + "fieldPath": "replayGainTrackDb", + "columnName": "replay_gain_track_db", + "affinity": "REAL" + }, + { + "fieldPath": "replayGainAlbumDb", + "columnName": "replay_gain_album_db", + "affinity": "REAL" + }, + { + "fieldPath": "replayGainTrackPeak", + "columnName": "replay_gain_track_peak", + "affinity": "REAL" + }, + { + "fieldPath": "replayGainAlbumPeak", + "columnName": "replay_gain_album_peak", + "affinity": "REAL" + }, + { + "fieldPath": "replayGainScanned", + "columnName": "rg_scanned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "REAL" + }, + { + "fieldPath": "musicalKey", + "columnName": "musical_key", + "affinity": "TEXT" + }, + { + "fieldPath": "titleSortKey", + "columnName": "title_sort_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistSortKey", + "columnName": "artist_sort_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumSortKey", + "columnName": "album_sort_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileNameSortKey", + "columnName": "file_name_sort_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "discSort", + "columnName": "disc_sort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackSort", + "columnName": "track_sort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionLabel", + "columnName": "section_label", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tracks_generation_id_path", + "unique": true, + "columnNames": [ + "generation_id", + "path" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tracks_generation_id_path` ON `${TABLE_NAME}` (`generation_id`, `path`)" + }, + { + "name": "index_tracks_source_key_generation_id", + "unique": false, + "columnNames": [ + "source_key", + "generation_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_source_key_generation_id` ON `${TABLE_NAME}` (`source_key`, `generation_id`)" + }, + { + "name": "index_tracks_album_identity_key", + "unique": false, + "columnNames": [ + "album_identity_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_album_identity_key` ON `${TABLE_NAME}` (`album_identity_key`)" + }, + { + "name": "index_tracks_artist_sort_key_album_sort_key_disc_sort_track_sort_title_sort_key_path", + "unique": false, + "columnNames": [ + "artist_sort_key", + "album_sort_key", + "disc_sort", + "track_sort", + "title_sort_key", + "path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_artist_sort_key_album_sort_key_disc_sort_track_sort_title_sort_key_path` ON `${TABLE_NAME}` (`artist_sort_key`, `album_sort_key`, `disc_sort`, `track_sort`, `title_sort_key`, `path`)" + }, + { + "name": "index_tracks_title_sort_key_path", + "unique": false, + "columnNames": [ + "title_sort_key", + "path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_title_sort_key_path` ON `${TABLE_NAME}` (`title_sort_key`, `path`)" + }, + { + "name": "index_tracks_added_at_path", + "unique": false, + "columnNames": [ + "added_at", + "path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_added_at_path` ON `${TABLE_NAME}` (`added_at`, `path`)" + }, + { + "name": "index_tracks_duration_path", + "unique": false, + "columnNames": [ + "duration", + "path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tracks_duration_path` ON `${TABLE_NAME}` (`duration`, `path`)" + } + ] + }, + { + "tableName": "album_summaries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`revision` INTEGER NOT NULL, `identity_key` TEXT NOT NULL, `album` TEXT NOT NULL, `artist` TEXT NOT NULL, `year` INTEGER, `artwork_hash` TEXT, `source_type` TEXT, `source_id` INTEGER, `artwork_source_id` TEXT, `track_count` INTEGER NOT NULL, `total_duration` REAL NOT NULL, `latest_added_at` INTEGER NOT NULL, `name_sort_key` TEXT NOT NULL, `artist_sort_key` TEXT NOT NULL, `section_label` TEXT NOT NULL, `is_single` INTEGER NOT NULL, PRIMARY KEY(`revision`, `identity_key`))", + "fields": [ + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityKey", + "columnName": "identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "artworkHash", + "columnName": "artwork_hash", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceType", + "columnName": "source_type", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceId", + "columnName": "source_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "artworkSourceId", + "columnName": "artwork_source_id", + "affinity": "TEXT" + }, + { + "fieldPath": "trackCount", + "columnName": "track_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalDuration", + "columnName": "total_duration", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "latestAddedAt", + "columnName": "latest_added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nameSortKey", + "columnName": "name_sort_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistSortKey", + "columnName": "artist_sort_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sectionLabel", + "columnName": "section_label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSingle", + "columnName": "is_single", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "revision", + "identity_key" + ] + }, + "indices": [ + { + "name": "index_album_summaries_revision_name_sort_key_identity_key", + "unique": false, + "columnNames": [ + "revision", + "name_sort_key", + "identity_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_summaries_revision_name_sort_key_identity_key` ON `${TABLE_NAME}` (`revision`, `name_sort_key`, `identity_key`)" + }, + { + "name": "index_album_summaries_revision_artist_sort_key_name_sort_key_identity_key", + "unique": false, + "columnNames": [ + "revision", + "artist_sort_key", + "name_sort_key", + "identity_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_summaries_revision_artist_sort_key_name_sort_key_identity_key` ON `${TABLE_NAME}` (`revision`, `artist_sort_key`, `name_sort_key`, `identity_key`)" + }, + { + "name": "index_album_summaries_revision_latest_added_at_identity_key", + "unique": false, + "columnNames": [ + "revision", + "latest_added_at", + "identity_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_summaries_revision_latest_added_at_identity_key` ON `${TABLE_NAME}` (`revision`, `latest_added_at`, `identity_key`)" + }, + { + "name": "index_album_summaries_revision_year_name_sort_key_identity_key", + "unique": false, + "columnNames": [ + "revision", + "year", + "name_sort_key", + "identity_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_summaries_revision_year_name_sort_key_identity_key` ON `${TABLE_NAME}` (`revision`, `year`, `name_sort_key`, `identity_key`)" + } + ] + }, + { + "tableName": "artist_summaries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`revision` INTEGER NOT NULL, `artist_key` TEXT NOT NULL, `artist` TEXT NOT NULL, `grouping_mode` TEXT NOT NULL, `track_count` INTEGER NOT NULL, `primary_track_count` INTEGER NOT NULL, `album_count` INTEGER NOT NULL, `artwork_hash` TEXT, `source_type` TEXT, `source_id` INTEGER, `artwork_source_id` TEXT, `name_sort_key` TEXT NOT NULL, `section_label` TEXT NOT NULL, `is_collaboration` INTEGER NOT NULL, `artwork_hashes_json` TEXT NOT NULL, PRIMARY KEY(`revision`, `artist_key`, `grouping_mode`))", + "fields": [ + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistKey", + "columnName": "artist_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupingMode", + "columnName": "grouping_mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackCount", + "columnName": "track_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primaryTrackCount", + "columnName": "primary_track_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumCount", + "columnName": "album_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artworkHash", + "columnName": "artwork_hash", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceType", + "columnName": "source_type", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceId", + "columnName": "source_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "artworkSourceId", + "columnName": "artwork_source_id", + "affinity": "TEXT" + }, + { + "fieldPath": "nameSortKey", + "columnName": "name_sort_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sectionLabel", + "columnName": "section_label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isCollaboration", + "columnName": "is_collaboration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artworkHashesJson", + "columnName": "artwork_hashes_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "revision", + "artist_key", + "grouping_mode" + ] + }, + "indices": [ + { + "name": "index_artist_summaries_revision_grouping_mode_name_sort_key_artist_key", + "unique": false, + "columnNames": [ + "revision", + "grouping_mode", + "name_sort_key", + "artist_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artist_summaries_revision_grouping_mode_name_sort_key_artist_key` ON `${TABLE_NAME}` (`revision`, `grouping_mode`, `name_sort_key`, `artist_key`)" + }, + { + "name": "index_artist_summaries_revision_grouping_mode_track_count_name_sort_key_artist_key", + "unique": false, + "columnNames": [ + "revision", + "grouping_mode", + "track_count", + "name_sort_key", + "artist_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artist_summaries_revision_grouping_mode_track_count_name_sort_key_artist_key` ON `${TABLE_NAME}` (`revision`, `grouping_mode`, `track_count`, `name_sort_key`, `artist_key`)" + } + ] + }, + { + "tableName": "artist_track_index", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`revision` INTEGER NOT NULL, `grouping_mode` TEXT NOT NULL, `artist_key` TEXT NOT NULL, `track_id` INTEGER NOT NULL, `relationship` TEXT NOT NULL, PRIMARY KEY(`revision`, `grouping_mode`, `artist_key`, `track_id`))", + "fields": [ + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupingMode", + "columnName": "grouping_mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistKey", + "columnName": "artist_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackId", + "columnName": "track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relationship", + "columnName": "relationship", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "revision", + "grouping_mode", + "artist_key", + "track_id" + ] + }, + "indices": [ + { + "name": "index_artist_track_index_revision_grouping_mode_artist_key_relationship_track_id", + "unique": false, + "columnNames": [ + "revision", + "grouping_mode", + "artist_key", + "relationship", + "track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artist_track_index_revision_grouping_mode_artist_key_relationship_track_id` ON `${TABLE_NAME}` (`revision`, `grouping_mode`, `artist_key`, `relationship`, `track_id`)" + }, + { + "name": "index_artist_track_index_track_id", + "unique": false, + "columnNames": [ + "track_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artist_track_index_track_id` ON `${TABLE_NAME}` (`track_id`)" + } + ] + }, + { + "tableName": "directory_summaries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`revision` INTEGER NOT NULL, `node_id` TEXT NOT NULL, `folder_id` INTEGER NOT NULL, `parent_node_id` TEXT, `name` TEXT NOT NULL, `depth` INTEGER NOT NULL, `directory_path` TEXT NOT NULL, `document_uri` TEXT, `direct_track_count` INTEGER NOT NULL, `total_track_count` INTEGER NOT NULL, `name_sort_key` TEXT NOT NULL, PRIMARY KEY(`revision`, `node_id`))", + "fields": [ + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeId", + "columnName": "node_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "folderId", + "columnName": "folder_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentNodeId", + "columnName": "parent_node_id", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "depth", + "columnName": "depth", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directoryPath", + "columnName": "directory_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentUri", + "columnName": "document_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "directTrackCount", + "columnName": "direct_track_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalTrackCount", + "columnName": "total_track_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nameSortKey", + "columnName": "name_sort_key", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "revision", + "node_id" + ] + }, + "indices": [ + { + "name": "index_directory_summaries_revision_folder_id_parent_node_id_name_sort_key", + "unique": false, + "columnNames": [ + "revision", + "folder_id", + "parent_node_id", + "name_sort_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_directory_summaries_revision_folder_id_parent_node_id_name_sort_key` ON `${TABLE_NAME}` (`revision`, `folder_id`, `parent_node_id`, `name_sort_key`)" + } + ] + }, + { + "tableName": "track_user_facts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`path` TEXT NOT NULL, `is_favorite` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, `last_played_at` INTEGER, PRIMARY KEY(`path`))", + "fields": [ + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "path" + ] + } + }, + { + "tableName": "waveform_peaks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_path` TEXT NOT NULL, `bins` INTEGER NOT NULL, `peaks` BLOB NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`track_path`))", + "fields": [ + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bins", + "columnName": "bins", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "peaks", + "columnName": "peaks", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_path" + ] + } + }, + { + "tableName": "lyrics_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_path` TEXT NOT NULL, `metadata_signature` TEXT, `status` TEXT NOT NULL, `source` TEXT, `provider` TEXT, `format` TEXT, `plain_lyrics` TEXT, `synced_lyrics` TEXT, `synced_lines_json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`track_path`))", + "fields": [ + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "metadataSignature", + "columnName": "metadata_signature", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT" + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT" + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT" + }, + { + "fieldPath": "plainLyrics", + "columnName": "plain_lyrics", + "affinity": "TEXT" + }, + { + "fieldPath": "syncedLyrics", + "columnName": "synced_lyrics", + "affinity": "TEXT" + }, + { + "fieldPath": "syncedLinesJson", + "columnName": "synced_lines_json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_path" + ] + }, + "indices": [ + { + "name": "index_lyrics_cache_updated_at", + "unique": false, + "columnNames": [ + "updated_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_lyrics_cache_updated_at` ON `${TABLE_NAME}` (`updated_at`)" + } + ] + }, + { + "tableName": "track_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `file_name` TEXT NOT NULL, tokenize=unicode61)", + "fields": [ + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "unicode61", + "tokenizerArgs": [], + "contentTable": "", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [] + } + ], + "views": [ + { + "viewName": "active_tracks", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT t.*\n FROM tracks t\n INNER JOIN catalog_sources s\n ON s.source_key = t.source_key\n AND s.active_generation_id = t.generation_id" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cc1e0fc052cde984629b8c430c720f20')" + ] + } +} \ No newline at end of file diff --git a/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraUserDatabase/1.json b/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraUserDatabase/1.json new file mode 100644 index 0000000..613ccc1 --- /dev/null +++ b/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraUserDatabase/1.json @@ -0,0 +1,755 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "5d8a5f2a676148b233470aedd5d05cee", + "entities": [ + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tree_uri` TEXT NOT NULL, `display_name` TEXT NOT NULL, `added_at` INTEGER NOT NULL, `last_scanned_at` INTEGER, `last_scan_status` TEXT NOT NULL, `last_scan_error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "treeUri", + "columnName": "tree_uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastScannedAt", + "columnName": "last_scanned_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastScanStatus", + "columnName": "last_scan_status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastScanError", + "columnName": "last_scan_error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_folders_tree_uri", + "unique": true, + "columnNames": [ + "tree_uri" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_folders_tree_uri` ON `${TABLE_NAME}` (`tree_uri`)" + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `last_played_at` INTEGER, `kind` TEXT NOT NULL, `dynamic_rules_json` TEXT, `remote_source_id` INTEGER, `remote_playlist_id` TEXT, `sync_uid` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dynamicRulesJson", + "columnName": "dynamic_rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteSourceId", + "columnName": "remote_source_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "remotePlaylistId", + "columnName": "remote_playlist_id", + "affinity": "TEXT" + }, + { + "fieldPath": "syncUid", + "columnName": "sync_uid", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlists_sync_uid", + "unique": true, + "columnNames": [ + "sync_uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlists_sync_uid` ON `${TABLE_NAME}` (`sync_uid`)" + }, + { + "name": "index_playlists_remote_source_id_remote_playlist_id", + "unique": true, + "columnNames": [ + "remote_source_id", + "remote_playlist_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlists_remote_source_id_remote_playlist_id` ON `${TABLE_NAME}` (`remote_source_id`, `remote_playlist_id`)" + }, + { + "name": "index_playlists_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_kind` ON `${TABLE_NAME}` (`kind`)" + } + ] + }, + { + "tableName": "playlist_tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlist_id` INTEGER NOT NULL, `track_path` TEXT NOT NULL, `position` INTEGER NOT NULL, `added_at` INTEGER NOT NULL, `fallback_title` TEXT, `fallback_artist` TEXT, `fallback_album` TEXT, FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fallbackTitle", + "columnName": "fallback_title", + "affinity": "TEXT" + }, + { + "fieldPath": "fallbackArtist", + "columnName": "fallback_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "fallbackAlbum", + "columnName": "fallback_album", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_tracks_playlist_id_position", + "unique": false, + "columnNames": [ + "playlist_id", + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_tracks_playlist_id_position` ON `${TABLE_NAME}` (`playlist_id`, `position`)" + }, + { + "name": "index_playlist_tracks_playlist_id_track_path", + "unique": true, + "columnNames": [ + "playlist_id", + "track_path" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_tracks_playlist_id_track_path` ON `${TABLE_NAME}` (`playlist_id`, `track_path`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_path` TEXT NOT NULL, `added_at` INTEGER NOT NULL, PRIMARY KEY(`track_path`))", + "fields": [ + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_path" + ] + } + }, + { + "tableName": "playback_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_path` TEXT NOT NULL, `last_played_at` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, PRIMARY KEY(`track_path`))", + "fields": [ + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_path" + ] + }, + "indices": [ + { + "name": "index_playback_history_last_played_at", + "unique": false, + "columnNames": [ + "last_played_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_history_last_played_at` ON `${TABLE_NAME}` (`last_played_at`)" + } + ] + }, + { + "tableName": "remote_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `name` TEXT NOT NULL, `base_url` TEXT NOT NULL, `username` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `last_status` TEXT NOT NULL, `last_error` TEXT, `last_sync_at` INTEGER, `last_checked_at` INTEGER, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "base_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus", + "columnName": "last_status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "last_error", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncAt", + "columnName": "last_sync_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastCheckedAt", + "columnName": "last_checked_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_remote_sources_type_name", + "unique": false, + "columnNames": [ + "type", + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_sources_type_name` ON `${TABLE_NAME}` (`type`, `name`)" + } + ] + }, + { + "tableName": "favorite_tombstones", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_key` TEXT NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`sync_key`))", + "fields": [ + { + "fieldPath": "syncKey", + "columnName": "sync_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deleted_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sync_key" + ] + } + }, + { + "tableName": "favorite_sync_pending", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_key` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `added_at` INTEGER NOT NULL, PRIMARY KEY(`sync_key`))", + "fields": [ + { + "fieldPath": "syncKey", + "columnName": "sync_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sync_key" + ] + } + }, + { + "tableName": "playlist_tombstones", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_uid` TEXT NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`sync_uid`))", + "fields": [ + { + "fieldPath": "syncUid", + "columnName": "sync_uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deleted_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sync_uid" + ] + } + }, + { + "tableName": "playlist_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_uid` TEXT NOT NULL, `local_updated_at` INTEGER NOT NULL, `remote_updated_at` INTEGER NOT NULL, PRIMARY KEY(`sync_uid`))", + "fields": [ + { + "fieldPath": "syncUid", + "columnName": "sync_uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "local_updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteUpdatedAt", + "columnName": "remote_updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sync_uid" + ] + } + }, + { + "tableName": "playback_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `context_json` TEXT NOT NULL, `anchor_path` TEXT, `shuffle_seed` INTEGER, `active_position` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextJson", + "columnName": "context_json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "anchorPath", + "columnName": "anchor_path", + "affinity": "TEXT" + }, + { + "fieldPath": "shuffleSeed", + "columnName": "shuffle_seed", + "affinity": "INTEGER" + }, + { + "fieldPath": "activePosition", + "columnName": "active_position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "playback_queue_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `position` INTEGER NOT NULL, `track_path` TEXT NOT NULL, PRIMARY KEY(`session_id`, `position`), FOREIGN KEY(`session_id`) REFERENCES `playback_sessions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id", + "position" + ] + }, + "indices": [ + { + "name": "index_playback_queue_entries_session_id_track_path", + "unique": false, + "columnNames": [ + "session_id", + "track_path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_queue_entries_session_id_track_path` ON `${TABLE_NAME}` (`session_id`, `track_path`)" + } + ], + "foreignKeys": [ + { + "table": "playback_sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playback_original_queue_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `position` INTEGER NOT NULL, `track_path` TEXT NOT NULL, PRIMARY KEY(`session_id`, `position`), FOREIGN KEY(`session_id`) REFERENCES `playback_sessions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id", + "position" + ] + }, + "indices": [ + { + "name": "index_playback_original_queue_entries_session_id_track_path", + "unique": false, + "columnNames": [ + "session_id", + "track_path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_original_queue_entries_session_id_track_path` ON `${TABLE_NAME}` (`session_id`, `track_path`)" + } + ], + "foreignKeys": [ + { + "table": "playback_sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "snapshot_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `last_snapshot_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSnapshotAt", + "columnName": "last_snapshot_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5d8a5f2a676148b233470aedd5d05cee')" + ] + } +} \ No newline at end of file diff --git a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt new file mode 100644 index 0000000..403780c --- /dev/null +++ b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt @@ -0,0 +1,368 @@ +package expo.modules.astralibraryscanner.data + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import java.io.File +import java.text.Normalizer +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoomLibraryRepositoryTest { + private lateinit var catalog: AstraCatalogDatabase + private lateinit var user: AstraUserDatabase + + @Before + fun openDatabases() { + val context = ApplicationProvider.getApplicationContext() + catalog = Room.inMemoryDatabaseBuilder(context, AstraCatalogDatabase::class.java) + .allowMainThreadQueries() + .build() + user = Room.inMemoryDatabaseBuilder(context, AstraUserDatabase::class.java) + .allowMainThreadQueries() + .build() + } + + @After + fun closeDatabases() { + catalog.close() + user.close() + } + + @Test + fun unicodeRoundTripsSortsAndSearchesWithoutTranslation() = runBlocking { + val titles = listOf( + "東京の夜", + "Привет мир", + "emoji 🚀 song", + Normalizer.normalize("Café", Normalizer.Form.NFC), + Normalizer.normalize("Café", Normalizer.Form.NFD), + "O'Brien 100%_Mix & Friends", + ) + publish("g1", titles.mapIndexed { index, title -> + track( + generation = "g1", + index = index, + title = title, + path = "content://com.android.externalstorage.documents/document/primary%3AMusic%2F${title}%25_${index}.flac", + ) + }) + + val dao = catalog.catalogDao() + val paths = dao.getAllPathsByTitle() + assertEquals(titles.size, paths.size) + val rows = dao.getActiveTracks(paths) + assertEquals(titles.toSet(), rows.map { it.title }.toSet()) + assertEquals(1, dao.searchTracksLiteral("%東京%", 10).size) + assertEquals(1, dao.searchTracks("\"Привет\"*", 10).size) + assertEquals(1, dao.searchTracksLiteral("%🚀%", 10).size) + assertEquals(1, dao.searchTracksLiteral("%100\\%\\_Mix%", 10).size) + assertTrue(dao.searchTracksLiteral("%&%", 10).isNotEmpty()) + assertTrue(rows.any { "%3A" in it.path && "%2F" in it.path }) + + val firstPage = dao.getTitlePage(null, "", 2) + val secondPage = dao.getTitlePage( + firstPage.last().titleSortKey, + firstPage.last().path, + 20, + ) + assertEquals(titles.size, (firstPage + secondPage).map { it.path }.distinct().size) + val artistFirst = dao.getArtistOrderPage(null, "", 0, 0, "", "", 2) + val artistAnchor = artistFirst.last() + val artistSecond = dao.getArtistOrderPage( + artistAnchor.artistSortKey, + artistAnchor.albumSortKey, + artistAnchor.discSort, + artistAnchor.trackSort, + artistAnchor.titleSortKey, + artistAnchor.path, + 20, + ) + assertEquals(titles.size, (artistFirst + artistSecond).map { it.path }.distinct().size) + val recentFirst = dao.getRecentlyAddedPage(null, "", 2) + val recentSecond = dao.getRecentlyAddedPage( + recentFirst.last().addedAt, + recentFirst.last().path, + 20, + ) + assertEquals(titles.size, (recentFirst + recentSecond).map { it.path }.distinct().size) + val durationFirst = dao.getDurationPage(null, "", 2) + val durationSecond = dao.getDurationPage( + durationFirst.last().duration, + durationFirst.last().path, + 20, + ) + assertEquals(titles.size, (durationFirst + durationSecond).map { it.path }.distinct().size) + assertNotEquals(SortKeys.forText("A"), SortKeys.forText("B")) + } + + @Test + fun stagingGenerationIsInvisibleAndAbandonedWorkIsDiscarded() = runBlocking { + publish("active", listOf(track("active", 1, "Last known good"))) + val dao = catalog.catalogDao() + val revision = dao.getRevision() + + dao.insertGeneration(ScanGenerationEntity("pending", "local:1", "staging", 2)) + dao.putTracks(listOf(track("pending", 2, "Half written scan"))) + assertEquals(listOf("Last known good"), dao.getTitlePage(null, "", 10).map { it.title }) + assertEquals(revision, dao.getRevision()) + + dao.discardAbandonedGenerations() + assertNull(dao.getGeneration("pending")) + assertEquals(listOf("Last known good"), dao.getTitlePage(null, "", 10).map { it.title }) + + publish("replacement", listOf(track("replacement", 3, "Published replacement")), "active") + assertEquals(listOf("Published replacement"), dao.getTitlePage(null, "", 10).map { it.title }) + assertNull(dao.getGeneration("active")) + } + + @Test + fun userMutationsAndVirtualQueueAreAtomicAndDurable() = runBlocking { + val dao = user.userDao() + val playlistId = dao.insertPlaylist( + PlaylistEntity(name = "Unicode 🚀", createdAt = 1, updatedAt = 1), + ) + assertEquals( + 2, + dao.appendPlaylistTracks( + playlistId, + listOf( + PlaylistTrackEntity(playlistId = playlistId, trackPath = "東京.flac", position = 0, addedAt = 0), + PlaylistTrackEntity(playlistId = playlistId, trackPath = "Привет.flac", position = 0, addedAt = 0), + ), + 2, + ), + ) + dao.putFavorite(FavoriteEntity("東京.flac", 3)) + dao.putPlaybackHistory(PlaybackHistoryEntity("東京.flac", 4, 7)) + + val session = PlaybackSessionEntity("active-context", """{"kind":"playlist","playlistId":$playlistId}""", "東京.flac", 42, 0, 5, 5) + dao.replacePlaybackQueue( + session, + listOf( + PlaybackQueueEntryEntity(session.id, 0, "東京.flac"), + PlaybackQueueEntryEntity(session.id, 1, "Привет.flac"), + ), + listOf( + PlaybackOriginalQueueEntryEntity(session.id, 0, "東京.flac"), + PlaybackOriginalQueueEntryEntity(session.id, 1, "Привет.flac"), + ), + ) + + assertEquals(2L, dao.countPlaylistTracks(playlistId)) + assertTrue(dao.isFavorite("東京.flac")) + assertEquals(7L, dao.getPlaybackHistory("東京.flac")?.playCount) + assertEquals(listOf("東京.flac", "Привет.flac"), dao.getAllQueueEntries(session.id).map { it.trackPath }) + assertEquals(2, dao.getOriginalQueueEntries(session.id).size) + + dao.deletePlaylistById(playlistId) + assertEquals(0, dao.countPlaylistTracks(playlistId)) + assertEquals(2L, dao.countQueueEntries(session.id)) + } + + @Test + fun userSnapshotsRotateRejectDamageAndRestoreTheNewestValidCopy() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + val snapshotDirectory = context.filesDir.resolve("astra-user-snapshots") + snapshotDirectory.deleteRecursively() + val snapshots = UserSnapshotStore(context) + val dao = user.userDao() + + dao.putSettings(listOf(SettingEntity("theme", "old"))) + snapshots.write(user) + Thread.sleep(10) + dao.putSettings(listOf(SettingEntity("theme", "new"))) + snapshots.write(user) + + val snapshotFiles = snapshotDirectory.listFiles().orEmpty().filter { it.extension == "json" } + assertEquals(2, snapshotFiles.size) + snapshotFiles.maxBy(File::lastModified).writeText("""{"damaged":true}""") + + val valid = snapshots.newestValid() + assertTrue(valid != null) + val replacement = Room.inMemoryDatabaseBuilder(context, AstraUserDatabase::class.java) + .allowMainThreadQueries() + .build() + try { + snapshots.restore(replacement, requireNotNull(valid)) + assertEquals("old", replacement.userDao().getSettings(listOf("theme")).single().value) + } finally { + replacement.close() + snapshotDirectory.deleteRecursively() + } + } + + @Test + fun dynamicRulesUseBoundArgumentsAndEscapeWildcards() = runBlocking { + publish( + "dynamic", + listOf( + track("dynamic", 1, "100%_Real", genre = "Rock"), + track("dynamic", 2, "100xxReal", genre = "Jazz"), + ), + ) + val rules = """ + { + "conditions": [ + {"kind":"text","field":"title","operator":"contains","value":"%_"}, + {"kind":"exact","field":"favorite","operator":"is","value":true} + ], + "sort":{"field":"title","direction":"asc"} + } + """.trimIndent() + catalog.catalogDao().putTrackUserFacts( + listOf(TrackUserFactEntity(path = "content://track/1.flac", isFavorite = true)), + ) + val queries = DynamicPlaylistCompiler.compile(rules, 0, 100) + val rows = catalog.catalogDao().runDynamicTrackQuery(queries.tracks) + assertEquals(listOf("100%_Real"), rows.map { it.title }) + assertFalse(queries.tracks.sql.contains("100%_Real")) + } + + @Test + fun albumAndCollaborativeArtistReadModelsMatchEstablishedRules() = runBlocking { + val first = track("groups", 1, "One").copy( + artist = "Alpha & Guest", + album = "Shared Album", + artworkHash = "same-cover", + artistSortKey = SortKeys.forText("Alpha & Guest"), + albumSortKey = SortKeys.forText("Shared Album"), + ) + val second = track("groups", 2, "Two").copy( + artist = "Beta", + album = "Shared Album", + artworkHash = "same-cover", + artistSortKey = SortKeys.forText("Beta"), + albumSortKey = SortKeys.forText("Shared Album"), + ) + publish("groups", listOf(first, second)) + val dao = catalog.catalogDao() + val revision = dao.getRevision() + + val albums = dao.getAllAlbumSummaries(revision) + assertEquals(1, albums.size) + assertEquals("Various Artists", albums.single().artist) + assertEquals(2L, albums.single().trackCount) + + val artists = dao.getAllArtistSummaries(revision, "astra").associateBy { it.artist } + assertTrue(artists.containsKey("Alpha")) + assertTrue(artists.containsKey("Guest")) + assertTrue(artists.containsKey("Beta")) + assertEquals(0L, artists.getValue("Guest").primaryTrackCount) + assertTrue(artists.getValue("Guest").isCollaboration) + assertEquals( + "One", + dao.getArtistTrackPage( + revision, + "astra", + "guest", + "appearance", + null, + 0, + 0, + "", + "", + 10, + ).single().title, + ) + } + + @LargeTest + @Test + fun oneHundredThousandTrackKeysetPagingRemainsBounded() = runBlocking { + val dao = catalog.catalogDao() + dao.insertMeta(CatalogMetaEntity(collationVersion = COLLATION_VERSION, updatedAt = 0)) + dao.putSource(CatalogSourceEntity("local:1", "local", 1, null, 0)) + dao.insertGeneration(ScanGenerationEntity("stress", "local:1", "staging", 0)) + for (start in 0 until 100_000 step 1_000) { + dao.putTracks( + (start until start + 1_000).map { index -> + track("stress", index, "Track ${index.toString().padStart(6, '0')}") + }, + ) + } + dao.setActiveGeneration("local:1", "stress", 1) + dao.setGenerationState("stress", "active", 1, null) + dao.incrementRevision(1) + + assertEquals(100_000L, dao.countActiveTracks()) + val first = dao.getTitlePage(null, "", 100) + val second = dao.getTitlePage(first.last().titleSortKey, first.last().path, 100) + assertEquals(100, first.size) + assertEquals(100, second.size) + assertTrue(first.map { it.path }.intersect(second.map { it.path }.toSet()).isEmpty()) + } + + private suspend fun publish( + generation: String, + tracks: List, + previous: String? = null, + ) { + val dao = catalog.catalogDao() + if (dao.getMeta() == null) { + dao.insertMeta(CatalogMetaEntity(collationVersion = COLLATION_VERSION, updatedAt = 0)) + dao.putSource(CatalogSourceEntity("local:1", "local", 1, null, 0)) + } + dao.insertGeneration(ScanGenerationEntity(generation, "local:1", "staging", 1)) + dao.putTracks(tracks) + val prospective = dao.getProspectiveTracks("local:1", generation) + val revision = dao.getRevision() + 1 + val models = CatalogReadModelBuilder.build(prospective, revision) + dao.publishGeneration( + sourceKey = "local:1", + generationId = generation, + previousGenerationId = previous, + now = revision, + albumIdentityUpdates = models.identityUpdates, + albums = models.albums, + artists = models.artists, + artistTrackIndex = models.artistTrackIndex, + directories = models.directories, + ftsRows = models.ftsRows, + ) + } + + private fun track( + generation: String, + index: Int, + title: String, + path: String = "content://track/$index.flac", + genre: String? = null, + ): TrackEntity = TrackEntity( + generationId = generation, + sourceKey = "local:1", + path = path, + folderId = 1, + title = title, + artist = if (index % 2 == 0) "Björk & Rosalía" else "Кино", + album = "Album %_${index / 2}", + albumArtist = null, + albumIdentityKey = "pending", + duration = 180.0 + index, + genre = genre, + format = "FLAC", + fileName = "$title.flac", + parentUri = "content://com.android.externalstorage.documents/document/primary%3AMusic", + mtime = index.toLong(), + addedAt = index.toLong(), + modifiedAt = index.toLong(), + titleSortKey = SortKeys.forText(title), + artistSortKey = SortKeys.forText("Artist"), + albumSortKey = SortKeys.forText("Album"), + fileNameSortKey = SortKeys.forText("$title.flac"), + discSort = 0, + trackSort = index, + sectionLabel = SortKeys.sectionLabel(title), + ) +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryDataModule.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryDataModule.kt new file mode 100644 index 0000000..6217266 --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryDataModule.kt @@ -0,0 +1,472 @@ +package expo.modules.astralibraryscanner + +import android.content.Context +import expo.modules.astralibraryscanner.data.AstraLibraryRepository +import expo.modules.astralibraryscanner.data.LibraryStatusSnapshot +import expo.modules.astralibraryscanner.data.StaleRevisionException +import expo.modules.kotlin.exception.Exceptions +import expo.modules.kotlin.functions.Coroutine +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class AstraLibraryDataModule : Module() { + private var repository: AstraLibraryRepository? = null + private val statusListener: (LibraryStatusSnapshot) -> Unit = { status -> + sendEvent("onLibraryStatus", status.toMap()) + } + private val catalogListener: (Long) -> Unit = { revision -> + sendEvent("onCatalogChanged", mapOf("catalogRevision" to revision.toString())) + } + + override fun definition() = ModuleDefinition { + Name("AstraLibraryData") + + Events( + "onLibraryStatus", + "onScanProgress", + "onCatalogChanged", + ) + + OnCreate { + val instance = repository() + instance.addStatusListener(statusListener) + instance.addCatalogListener(catalogListener) + } + + OnDestroy { + repository?.removeStatusListener(statusListener) + repository?.removeCatalogListener(catalogListener) + } + + AsyncFunction("initialize").Coroutine> { + repository().initialize().toMap() + } + + Function("getCurrentStatus") { + repository().status().toMap() + } + + AsyncFunction("getSettings") Coroutine { keys: List -> + repositoryCall { getSettings(keys) } + } + + AsyncFunction("setSettings") Coroutine { values: Map -> + repositoryCall { setSettings(values) } + } + + AsyncFunction("listFolders").Coroutine>> { + repositoryCall { listFolders() } + } + + AsyncFunction("getFolderNodes") Coroutine { parentNodeId: String? -> + repositoryCall { getFolderNodes(parentNodeId) } + } + + AsyncFunction("getFolderTracks") Coroutine { + nodeId: String, + offset: Int, + limit: Int, + -> + repositoryCall { getFolderTracks(nodeId, offset, limit) } + } + + AsyncFunction("registerFolder") Coroutine { treeUri: String, displayName: String -> + repositoryCall { registerFolder(treeUri, displayName) } + } + + AsyncFunction("removeFolder") Coroutine { folderId: Double -> + repositoryCall { removeFolder(folderId.toLong()) } + } + + AsyncFunction("getTrackPage") Coroutine { + sort: String, + cursor: String?, + limit: Int, + -> + try { + repository().getTrackPage(sort, cursor, limit) + } catch (_: StaleRevisionException) { + mapOf("error" to "STALE_REVISION") + } + } + + AsyncFunction("getTrack") Coroutine { path: String -> + repository().getTrack(path) + } + + AsyncFunction("getTrackLoudness") Coroutine { paths: List -> + repository().getTrackLoudness(paths) + } + + AsyncFunction("setTrackLoudness") Coroutine { path: String, lufs: Double?, samplePeak: Double? -> + repository().setTrackLoudness(path, lufs, samplePeak) + } + + AsyncFunction("setTrackReplayGain") Coroutine { + path: String, + trackGainDb: Double?, + albumGainDb: Double?, + trackPeak: Double?, + albumPeak: Double?, + -> + repository().setTrackReplayGain(path, trackGainDb, albumGainDb, trackPeak, albumPeak) + } + + AsyncFunction("getLibraryLoudnessStats").Coroutine> { + repository().getLibraryLoudnessStats() + } + + AsyncFunction("getWaveform") Coroutine { path: String -> + repository().getWaveform(path) + } + + AsyncFunction("putWaveform") Coroutine { path: String, peaks: List -> + repository().putWaveform(path, peaks) + } + + AsyncFunction("countWaveforms").Coroutine { + repository().countWaveforms().toDouble() + } + + AsyncFunction("clearWaveforms").Coroutine { + repository().clearWaveforms() + } + + AsyncFunction("getLyrics") Coroutine { path: String, metadataSignature: String -> + repository().getLyrics(path, metadataSignature) + } + + AsyncFunction("putLyrics") Coroutine { path: String, values: Map -> + repository().putLyrics(path, values) + } + + AsyncFunction("deleteLyrics") Coroutine { path: String -> + repository().deleteLyrics(path) + } + + AsyncFunction("countLyrics").Coroutine { + repository().countLyrics().toDouble() + } + + AsyncFunction("clearLyrics").Coroutine { + repository().clearLyrics() + } + + AsyncFunction("readMobileSession").Coroutine { + repositoryCall { readMobileSession() } + } + + AsyncFunction("writeMobileSession") Coroutine { snapshotJson: String -> + repositoryCall { writeMobileSession(snapshotJson) } + } + + AsyncFunction("createPlaybackContext") Coroutine { + context: Map, + anchorPath: String?, + shuffle: Boolean, + seed: Double?, + -> + repositoryCall { createPlaybackContext(context, anchorPath, shuffle, seed?.toLong()) } + } + + AsyncFunction("getPlaybackWindow") Coroutine { sessionId: String, start: Double, limit: Int -> + repositoryCall { getPlaybackWindow(sessionId, start.toLong(), limit) } + } + + AsyncFunction("updatePlaybackPosition") Coroutine { sessionId: String, activePosition: Double -> + repositoryCall { updatePlaybackPosition(sessionId, activePosition.toLong()) } + } + + AsyncFunction("restorePlaybackContext").Coroutine?> { + repositoryCall { restorePlaybackContext() } + } + + AsyncFunction("mutatePlaybackContext") Coroutine { + operation: String, + values: Map, + -> + repositoryCall { mutatePlaybackContext(operation, values) } + } + + AsyncFunction("recordTrackPlayed") Coroutine { path: String -> + repositoryCall { recordTrackPlayed(path) } + } + + AsyncFunction("getRecentlyPlayed") Coroutine { limit: Int -> + repositoryCall { getRecentlyPlayed(limit) } + } + + AsyncFunction("listRemoteSources").Coroutine>> { + repositoryCall { listRemoteSources() } + } + + AsyncFunction("getRemoteSource") Coroutine { sourceId: Double -> + repositoryCall { getRemoteSource(sourceId.toLong()) } + } + + AsyncFunction("createRemoteSource") Coroutine { + type: String, + name: String, + baseUrl: String, + username: String, + enabled: Boolean, + -> + repositoryCall { createRemoteSource(type, name, baseUrl, username, enabled) } + } + + AsyncFunction("updateRemoteSource") Coroutine { sourceId: Double, fields: Map -> + repositoryCall { updateRemoteSource(sourceId.toLong(), fields) } + } + + AsyncFunction("setRemoteSourceStatus") Coroutine { + sourceId: Double, + status: String, + error: String?, + -> + repositoryCall { setRemoteSourceStatus(sourceId.toLong(), status, error) } + } + + AsyncFunction("deleteRemoteSource") Coroutine { sourceId: Double, purgeCatalog: Boolean -> + repositoryCall { deleteRemoteSource(sourceId.toLong(), purgeCatalog) } + } + + AsyncFunction("replaceRemoteUserState") Coroutine { + sourceId: Double, + sourceType: String, + favoritePaths: List, + playlists: List>, + -> + repositoryCall { replaceRemoteUserState( + sourceId.toLong(), + sourceType, + favoritePaths, + playlists, + ) } + } + + AsyncFunction("beginRemoteSync") Coroutine { sourceId: Double, sourceType: String -> + repository().beginRemoteSync(sourceId.toLong(), sourceType) + } + + AsyncFunction("appendRemoteTracks") Coroutine { + syncId: String, + rows: List>, + -> + repository().appendRemoteTracks(syncId, rows) + } + + AsyncFunction("commitRemoteSync") Coroutine { syncId: String -> + repository().commitRemoteSync(syncId) + } + + AsyncFunction("abortRemoteSync") Coroutine { syncId: String -> + repository().abortRemoteSync(syncId) + } + + AsyncFunction("listPlaylists").Coroutine>> { + repositoryCall { listPlaylists() } + } + + AsyncFunction("createPlaylist") Coroutine { name: String, kind: String, rulesJson: String? -> + repositoryCall { createPlaylist(name, kind, rulesJson) } + } + + AsyncFunction("getDynamicPlaylistRules") Coroutine { playlistId: Double -> + repositoryCall { getDynamicPlaylistRules(playlistId.toLong()) } + } + + AsyncFunction("updateDynamicPlaylistRules") Coroutine { playlistId: Double, rulesJson: String -> + repositoryCall { updateDynamicPlaylistRules(playlistId.toLong(), rulesJson) } + } + + AsyncFunction("previewDynamicPlaylist") Coroutine { rulesJson: String -> + repository().previewDynamicPlaylist(rulesJson) + } + + AsyncFunction("renamePlaylist") Coroutine { playlistId: Double, name: String -> + repositoryCall { renamePlaylist(playlistId.toLong(), name) } + } + + AsyncFunction("deletePlaylist") Coroutine { playlistId: Double -> + repositoryCall { deletePlaylist(playlistId.toLong()) } + } + + AsyncFunction("markPlaylistPlayed") Coroutine { playlistId: Double -> + repositoryCall { markPlaylistPlayed(playlistId.toLong()) } + } + + AsyncFunction("addPlaylistEntries") Coroutine { + playlistId: Double, + entries: List>, + -> + repositoryCall { addPlaylistEntries(playlistId.toLong(), entries) } + } + + AsyncFunction("removePlaylistEntry") Coroutine { playlistId: Double, path: String -> + repositoryCall { removePlaylistEntry(playlistId.toLong(), path) } + } + + AsyncFunction("movePlaylistEntry") Coroutine { + playlistId: Double, + path: String, + direction: Int, + -> + repositoryCall { movePlaylistEntry(playlistId.toLong(), path, direction) } + } + + AsyncFunction("getPlaylistEntries") Coroutine { + playlistId: Double, + offset: Int, + limit: Int, + -> + repositoryCall { getPlaylistEntries(playlistId.toLong(), offset, limit) } + } + + AsyncFunction("getFavoritePaths").Coroutine> { + repositoryCall { getFavoritePaths() } + } + + AsyncFunction("getFavoriteTracks") Coroutine { limit: Int -> + repositoryCall { getFavoriteTracks(limit) } + } + + AsyncFunction("setFavorite") Coroutine { path: String, favorite: Boolean -> + repositoryCall { setFavorite(path, favorite) } + } + + AsyncFunction("getDesktopSyncState").Coroutine> { + repositoryCall { getDesktopSyncState() } + } + + AsyncFunction("applyDesktopSyncPlan") Coroutine { plan: Map -> + repositoryCall { applyDesktopSyncPlan(plan) } + } + + AsyncFunction("resolveDesktopSyncConflict") Coroutine { + conflict: Map, + resolution: String, + mergedPlaylist: Map?, + -> + repositoryCall { resolveDesktopSyncConflict(conflict, resolution, mergedPlaylist) } + } + + AsyncFunction("clearDesktopSyncBaselines").Coroutine { + repositoryCall { clearDesktopSyncBaselines() } + } + + AsyncFunction("getAlbumPage") Coroutine { + sort: String, + includeSingles: Boolean, + cursor: String?, + limit: Int, + -> + try { + repository().getAlbumPage(sort, includeSingles, cursor, limit) + } catch (_: StaleRevisionException) { + mapOf("error" to "STALE_REVISION") + } + } + + AsyncFunction("getArtistPage") Coroutine { + sort: String, + groupingMode: String, + includeCollaborations: Boolean, + cursor: String?, + limit: Int, + -> + try { + repository().getArtistPage(sort, groupingMode, includeCollaborations, cursor, limit) + } catch (_: StaleRevisionException) { + mapOf("error" to "STALE_REVISION") + } + } + + AsyncFunction("getAlbumDetail") Coroutine { albumKey: String, cursor: String?, limit: Int -> + try { + repository().getAlbumDetail(albumKey, cursor, limit) + } catch (_: StaleRevisionException) { + mapOf("error" to "STALE_REVISION") + } + } + + AsyncFunction("getArtistDetail") Coroutine { + artistKey: String, + groupingMode: String, + section: String, + cursor: String?, + limit: Int, + -> + try { + repository().getArtistDetail(artistKey, groupingMode, section, cursor, limit) + } catch (_: StaleRevisionException) { + mapOf("error" to "STALE_REVISION") + } + } + + AsyncFunction("getArtistAlbums") Coroutine { + artistKey: String, + groupingMode: String, + offset: Int, + limit: Int, + -> + repository().getArtistAlbums(artistKey, groupingMode, offset, limit) + } + + AsyncFunction("searchTracks") Coroutine { query: String, limit: Int -> + repository().searchTracks(query, limit) + } + + AsyncFunction("searchLibrary") Coroutine { + query: String, + limit: Int, + includeSingles: Boolean, + groupingMode: String, + includeCollaborations: Boolean, + -> + repository().searchLibrary( + query, + limit, + includeSingles, + groupingMode, + includeCollaborations, + ) + } + + AsyncFunction("matchSignal") Coroutine { title: String, artist: String, durationSeconds: Double? -> + repository().matchSignal(title, artist, durationSeconds) + } + + AsyncFunction("getSectionAnchors") Coroutine { + kind: String, + sort: String, + includeSingles: Boolean, + groupingMode: String, + includeCollaborations: Boolean, + -> + repository().getSectionAnchors( + kind, + sort, + includeSingles, + groupingMode, + includeCollaborations, + ) + } + + AsyncFunction("flushUserSnapshot").Coroutine { + repositoryCall { flushSnapshot() } + } + } + + private fun repository(): AstraLibraryRepository { + val existing = repository + if (existing != null) return existing + return AstraLibraryRepository.get(requireContext()).also { repository = it } + } + + private suspend fun repositoryCall( + block: suspend AstraLibraryRepository.() -> T, + ): T = repository().withUserRecovery(block) + + private fun requireContext(): Context = + appContext.reactContext ?: throw Exceptions.ReactContextLost() +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt index 8f3d9d7..6e9505b 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt @@ -32,6 +32,9 @@ import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.records.Field import expo.modules.kotlin.records.Record +import expo.modules.astralibraryscanner.data.AstraLibraryRepository +import expo.modules.astralibraryscanner.data.LocalAudioFile +import expo.modules.astralibraryscanner.data.LocalAudioMetadata import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -93,6 +96,54 @@ class AstraLibraryScannerModule : Module() { } } + AsyncFunction("scanFolderNative") Coroutine { + folderId: Double, + mode: String, + extensions: List, + -> + withContext(Dispatchers.IO) { + val repository = AstraLibraryRepository.get(requireContext()) + repository.withUserRecovery { scanLocalFolder( + folderId = folderId.toLong(), + full = mode == "full", + discover = { treeUri -> + val listing = listAudioFiles(treeUri, extensions) + @Suppress("UNCHECKED_CAST") + val files = listing["files"] as? List> ?: emptyList() + @Suppress("UNCHECKED_CAST") + val covers = listing["covers"] as? Map ?: emptyMap() + files.mapNotNull { file -> + val uri = file["uri"] as? String ?: return@mapNotNull null + val parentUri = file["parentUri"] as? String ?: "" + LocalAudioFile( + uri = uri, + name = file["name"] as? String ?: uri.substringAfterLast('/'), + size = (file["size"] as? Number)?.toLong(), + lastModified = (file["lastModified"] as? Number)?.toLong() ?: 0L, + mimeType = file["mimeType"] as? String, + parentUri = parentUri, + coverUri = covers[parentUri], + ) + } + }, + extract = { file -> + extractOne(file.uri, file.coverUri).toLocalAudioMetadata() + }, + onProgress = { phase, processed, total, folderName -> + sendEvent( + "onScanProgress", + mapOf( + "phase" to phase, + "processed" to processed, + "total" to total, + "folderName" to folderName, + ), + ) + }, + ).toMap() } + } + } + // Offline waveform peaks for the seek bar: full PCM decode -> RMS per bin, // normalized to [0,1]. Heavy (whole-file decode), so cap concurrency and // run lazily per track on the JS side; results are cached in SQLite there. @@ -483,9 +534,13 @@ class AstraLibraryScannerModule : Module() { // --------------------------------------------------------------------------- private fun extractOne(request: FileRequest): Map { + return extractOne(request.uri, request.coverUri) + } + + private fun extractOne(uriString: String, coverUri: String?): Map { val context = requireContext() - val uri = Uri.parse(request.uri) - val result = mutableMapOf("uri" to request.uri, "ok" to true) + val uri = Uri.parse(uriString) + val result = mutableMapOf("uri" to uriString, "ok" to true) var embeddedPicture: ByteArray? = null val retriever = MediaMetadataRetriever() @@ -519,7 +574,7 @@ class AstraLibraryScannerModule : Module() { embeddedPicture = retriever.embeddedPicture } catch (t: Throwable) { return mapOf( - "uri" to request.uri, + "uri" to uriString, "ok" to false, "error" to (t.message ?: t.javaClass.simpleName) ) @@ -558,7 +613,7 @@ class AstraLibraryScannerModule : Module() { } try { - result["artworkHash"] = resolveArtwork(embeddedPicture, request.coverUri) + result["artworkHash"] = resolveArtwork(embeddedPicture, coverUri) } catch (_: Throwable) { // Artwork failure never fails the track. } @@ -566,6 +621,28 @@ class AstraLibraryScannerModule : Module() { return result } + private fun Map.toLocalAudioMetadata(): LocalAudioMetadata = + LocalAudioMetadata( + ok = this["ok"] as? Boolean ?: false, + title = this["title"] as? String, + artist = this["artist"] as? String, + album = this["album"] as? String, + albumArtist = this["albumArtist"] as? String, + genre = this["genre"] as? String, + mimeType = this["mimeType"] as? String, + durationMs = (this["durationMs"] as? Number)?.toLong(), + bitrate = (this["bitrate"] as? Number)?.toInt(), + trackNumber = (this["trackNumber"] as? Number)?.toInt(), + discNumber = (this["discNumber"] as? Number)?.toInt(), + year = (this["year"] as? Number)?.toInt(), + sampleRate = (this["sampleRate"] as? Number)?.toInt(), + channels = (this["channels"] as? Number)?.toInt(), + bitsPerSample = (this["bitsPerSample"] as? Number)?.toInt(), + codecMime = this["codecMime"] as? String, + artworkHash = this["artworkHash"] as? String, + error = this["error"] as? String, + ) + // --------------------------------------------------------------------------- // Waveform peaks (offline RMS bins) // --------------------------------------------------------------------------- diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt new file mode 100644 index 0000000..6345f37 --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt @@ -0,0 +1,2689 @@ +package expo.modules.astralibraryscanner.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabaseCorruptException +import android.database.sqlite.SQLiteException +import androidx.room.Room +import androidx.room.RoomDatabase +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.text.Normalizer +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArraySet +import kotlin.random.Random +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +private const val USER_DB_NAME = "astra-user.db" +private const val CATALOG_DB_NAME = "astra-catalog.db" +private const val LEGACY_DB_NAME = "astra-library.db" +private const val CUTOVER_PREFS = "astra-room-cutover" +private const val CUTOVER_COMPLETE = "room-cutover-v1-complete" +private const val SNAPSHOT_DEBOUNCE_MS = 2_000L +private const val MOBILE_SESSION_ID = "mobile" +private const val ACTIVE_PLAYBACK_CONTEXT_ID = "active-context" + +class StaleRevisionException : IllegalStateException("STALE_REVISION") + +private data class RemoteSyncHandle( + val syncId: String, + val sourceKey: String, + val sourceId: Long, + val sourceType: String, + val generationId: String, + val previousGenerationId: String?, + val startedAt: Long, + val existingByPath: Map, + val seenPaths: MutableSet = ConcurrentHashMap.newKeySet(), +) + +/** + * Single owner for both Room files. Every app surface—including Android Auto— + * reaches SQLite through this repository so connection and recovery policy + * cannot diverge between JavaScript and native callers. + */ +class AstraLibraryRepository private constructor( + private val context: Context, +) { + private val applicationContext = context.applicationContext + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val bootstrapMutex = Mutex() + private val catalogRecoveryMutex = Mutex() + private val userRecoveryMutex = Mutex() + private val catalogWriterMutex = Mutex() + private val snapshotMutex = Mutex() + private val listeners = CopyOnWriteArraySet<(LibraryStatusSnapshot) -> Unit>() + private val catalogListeners = CopyOnWriteArraySet<(Long) -> Unit>() + private val snapshots = UserSnapshotStore(applicationContext) + private val remoteSyncs = ConcurrentHashMap() + + @Volatile + private var initialized = false + + @Volatile + private var userDatabase: AstraUserDatabase? = null + + @Volatile + private var catalogDatabase: AstraCatalogDatabase? = null + + @Volatile + private var currentStatus = LibraryStatusSnapshot( + status = LibraryStatus.INITIALIZING, + catalogRevision = 0, + trackCount = 0, + ) + + private var pendingSnapshot: Job? = null + private var catalogRecoveredAtBootstrap = false + + suspend fun initialize(): LibraryStatusSnapshot { + if (initialized) return currentStatus + return bootstrapMutex.withLock { + if (initialized) return@withLock currentStatus + + updateStatus(LibraryStatus.INITIALIZING, 0, 0) + val recoveryNotice = performLegacyCutoverIfNeeded() + val userOpen = openUserDatabaseWithRecovery() + if (userOpen == null) { + updateStatus( + LibraryStatus.FATAL_USER_DATA, + 0, + 0, + message = "Astra could not restore its user database.", + recoveryNotice = recoveryNotice, + ) + initialized = true + return@withLock currentStatus + } + userDatabase = userOpen + + val catalogOpen = openCatalogDatabaseWithRecovery() + catalogDatabase = catalogOpen + val dao = catalogOpen.catalogDao() + dao.insertMeta( + CatalogMetaEntity( + revision = 0, + collationVersion = COLLATION_VERSION, + updatedAt = System.currentTimeMillis(), + ), + ) + dao.discardAbandonedGenerations() + reconcileUserFacts() + + val revision = dao.getRevision() + val count = dao.countActiveTracks() + val canRebuild = catalogRecoveredAtBootstrap && + userOpen.userDao().getFolders().any { folder -> + applicationContext.contentResolver.persistedUriPermissions.any { + it.uri.toString() == folder.treeUri + } + } + updateStatus( + when { + canRebuild -> LibraryStatus.REBUILDING + count == 0L -> LibraryStatus.EMPTY + else -> LibraryStatus.READY + }, + revision, + count, + message = if (canRebuild) "The catalog was damaged and is being rebuilt." else null, + recoveryNotice = recoveryNotice, + ) + initialized = true + currentStatus + } + } + + fun addStatusListener(listener: (LibraryStatusSnapshot) -> Unit) { + listeners.add(listener) + } + + fun removeStatusListener(listener: (LibraryStatusSnapshot) -> Unit) { + listeners.remove(listener) + } + + fun addCatalogListener(listener: (Long) -> Unit) { + catalogListeners.add(listener) + } + + fun removeCatalogListener(listener: (Long) -> Unit) { + catalogListeners.remove(listener) + } + + fun status(): LibraryStatusSnapshot = currentStatus + + /** + * Runs a bridge operation against the current user database and retries it + * once after restoring the newest valid rotating snapshot if SQLite reports + * runtime corruption. The damaged handle is never reused. + */ + suspend fun withUserRecovery( + block: suspend AstraLibraryRepository.() -> T, + ): T { + initialize() + val database = requireUser() + return try { + block() + } catch (error: Throwable) { + if (!isCorruption(error)) throw error + userRecoveryMutex.withLock { + if (userDatabase === database) { + pendingSnapshot?.cancel() + database.close() + userDatabase = null + quarantineDatabase(USER_DB_NAME, "user") + val snapshot = snapshots.newestValid() + if (snapshot == null) { + updateStatus( + LibraryStatus.FATAL_USER_DATA, + currentStatus.catalogRevision, + currentStatus.trackCount, + message = "Astra could not restore its user database.", + recoveryNotice = currentStatus.recoveryNotice, + ) + throw error + } + val replacement = buildUserDatabase() + try { + forceOpen(replacement) + snapshots.restore(replacement, snapshot) + userDatabase = replacement + reconcileUserFacts() + refreshReadyStatus() + } catch (restoreError: Throwable) { + replacement.close() + userDatabase = null + updateStatus( + LibraryStatus.FATAL_USER_DATA, + currentStatus.catalogRevision, + currentStatus.trackCount, + message = "Astra could not restore its user database.", + recoveryNotice = currentStatus.recoveryNotice, + ) + throw restoreError + } + } + } + block() + } + } + + suspend fun getSettings(keys: List): Map { + initialize() + val rows = requireUser().userDao().getSettings(keys) + val byKey = rows.associate { it.key to it.value } + return keys.associateWith { byKey[it] } + } + + suspend fun setSettings(values: Map) { + initialize() + val dao = requireUser().userDao() + val deletes = values.filterValues { it == null }.keys.toList() + val writes = values.mapNotNull { (key, value) -> value?.let { SettingEntity(key, it) } } + if (deletes.isNotEmpty()) dao.deleteSettings(deletes) + if (writes.isNotEmpty()) dao.putSettings(writes) + scheduleSnapshot() + } + + suspend fun listFolders(): List> { + initialize() + val persisted = applicationContext.contentResolver.persistedUriPermissions + .mapTo(hashSetOf()) { it.uri.toString() } + val catalogDao = requireCatalog().catalogDao() + return requireUser().userDao().getFolders().map { folder -> + mapOf( + "id" to folder.id.toDouble(), + "tree_uri" to folder.treeUri, + "display_name" to folder.displayName, + "added_at" to folder.addedAt.toDouble(), + "last_scanned_at" to folder.lastScannedAt?.toDouble(), + "available" to persisted.contains(folder.treeUri), + "scan_status" to folder.lastScanStatus, + "scan_error" to folder.lastScanError, + "track_count" to catalogDao.countActiveTracksForFolder(folder.id).toDouble(), + ) + } + } + + suspend fun getFolderNodes(parentNodeId: String?): List> { + initialize() + val catalogDao = requireCatalog().catalogDao() + val revision = catalogDao.getRevision() + val availability = applicationContext.contentResolver.persistedUriPermissions + .mapTo(hashSetOf()) { it.uri.toString() } + val folders = requireUser().userDao().getFolders().associateBy(FolderEntity::id) + return catalogDao.getDirectoryChildren(revision, parentNodeId).map { node -> + val folder = folders[node.folderId] + mapOf( + "id" to node.nodeId, + "folderId" to node.folderId.toDouble(), + "parentNodeId" to node.parentNodeId, + "name" to (if (node.depth == 0) folder?.displayName ?: node.name else node.name), + "depth" to node.depth, + "directTrackCount" to node.directTrackCount.toDouble(), + "totalTrackCount" to node.totalTrackCount.toDouble(), + "available" to (folder != null && availability.contains(folder.treeUri)), + "catalogRevision" to revision.toString(), + ) + } + } + + suspend fun getFolderTracks( + nodeId: String, + offset: Int, + requestedLimit: Int, + ): Map { + initialize() + val dao = requireCatalog().catalogDao() + val revision = dao.getRevision() + val node = dao.getDirectoryNode(revision, nodeId) + ?: return mapOf( + "items" to emptyList>(), + "nextOffset" to null, + "totalCount" to 0, + "catalogRevision" to revision.toString(), + ) + val safeOffset = offset.coerceAtLeast(0) + val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) + val items = node.documentUri?.let { + dao.getDirectoryTrackPage(it, safeOffset, limit).map(ActiveTrackView::toBridgeMap) + }.orEmpty() + return mapOf( + "items" to items, + "nextOffset" to if (safeOffset + items.size < node.directTrackCount) { + safeOffset + items.size + } else { + null + }, + "totalCount" to node.directTrackCount.toDouble(), + "catalogRevision" to revision.toString(), + ) + } + + suspend fun registerFolder(treeUri: String, displayName: String): Map { + initialize() + val dao = requireUser().userDao() + val existing = dao.getFolderByTreeUri(treeUri) + val row = if (existing != null) { + dao.updateFolderName(treeUri, displayName) + existing.copy(displayName = displayName) + } else { + val created = FolderEntity( + treeUri = treeUri, + displayName = displayName, + addedAt = System.currentTimeMillis(), + ) + created.copy(id = dao.insertFolder(created)) + } + flushSnapshot() + return mapOf( + "id" to row.id.toDouble(), + "tree_uri" to row.treeUri, + "display_name" to row.displayName, + "added_at" to row.addedAt.toDouble(), + "last_scanned_at" to row.lastScannedAt?.toDouble(), + "available" to true, + "scan_status" to row.lastScanStatus, + "scan_error" to row.lastScanError, + ) + } + + suspend fun scanLocalFolder( + folderId: Long, + full: Boolean, + discover: suspend (String) -> List, + extract: suspend (LocalAudioFile) -> LocalAudioMetadata, + onProgress: (phase: String, processed: Int, total: Int, folderName: String) -> Unit, + ): NativeScanResult { + initialize() + return catalogWriterMutex.withLock { + val userDao = requireUser().userDao() + val folder = userDao.getFolder(folderId) ?: error("Folder $folderId does not exist") + val database = requireCatalog() + val dao = database.catalogDao() + val sourceKey = localSourceKey(folderId) + val previousSource = dao.getSource(sourceKey) + val generationId = UUID.randomUUID().toString() + val startedAt = System.currentTimeMillis() + + dao.putSource( + previousSource ?: CatalogSourceEntity( + sourceKey = sourceKey, + sourceType = "local", + sourceId = folderId, + updatedAt = startedAt, + ), + ) + dao.insertGeneration( + ScanGenerationEntity( + id = generationId, + sourceKey = sourceKey, + state = "staging", + startedAt = startedAt, + ), + ) + userDao.updateFolderScanState(folderId, folder.lastScannedAt, "scanning", null) + updateOperationalStatus(LibraryStatus.SCANNING) + + try { + onProgress("discovering", 0, 0, folder.displayName) + val files = discover(folder.treeUri) + onProgress("discovering", files.size, files.size, folder.displayName) + + val existing = dao.getActiveTrackEntitiesForSource(sourceKey) + val existingByPath = existing.associateBy(TrackEntity::path) + val seenPaths = files.mapTo(hashSetOf(), LocalAudioFile::uri) + val removed = existing.count { it.path !in seenPaths } + var added = 0 + var updated = 0 + var errors = 0 + var processed = 0 + + for (batch in files.chunked(24)) { + val rows = coroutineScope { + batch.map { file -> + async(Dispatchers.IO) { + val old = existingByPath[file.uri] + val unchanged = !full && + old != null && + old.mtime == file.lastModified && + old.size == file.size + if (unchanged) { + old!!.copy( + id = 0, + generationId = generationId, + sourceKey = sourceKey, + titleSortKey = SortKeys.forText(old.title), + artistSortKey = SortKeys.forText(old.artist), + albumSortKey = SortKeys.forText(old.album), + fileNameSortKey = SortKeys.forText(old.fileName), + sectionLabel = SortKeys.sectionLabel(old.title), + ) to false + } else { + val metadata = extract(file) + if (!metadata.ok) { + if (old != null) { + old.copy(id = 0, generationId = generationId, sourceKey = sourceKey) to true + } else { + null to true + } + } else { + trackFromMetadata( + generationId = generationId, + sourceKey = sourceKey, + folderId = folderId, + file = file, + metadata = metadata, + addedAt = old?.addedAt ?: startedAt, + ) to false + } + } + } + }.awaitAll() + } + val insertRows = ArrayList(rows.size) + for ((row, failed) in rows) { + if (failed) errors += 1 + if (row == null) continue + insertRows += row + if (existingByPath.containsKey(row.path)) updated += if (failed) 0 else 1 else added += 1 + } + if (insertRows.isNotEmpty()) dao.putTracks(insertRows) + processed += batch.size + onProgress("extracting", processed, files.size, folder.displayName) + } + + val prospective = dao.getProspectiveTracks(sourceKey, generationId) + val nextRevision = dao.getRevision() + 1 + onProgress("indexing", prospective.size, prospective.size, folder.displayName) + val readModels = withContext(Dispatchers.Default) { + CatalogReadModelBuilder.build( + prospective, + nextRevision, + userDao.getFolders().associateBy(FolderEntity::id), + ) + } + val revision = dao.publishGeneration( + sourceKey = sourceKey, + generationId = generationId, + previousGenerationId = previousSource?.activeGenerationId, + now = System.currentTimeMillis(), + albumIdentityUpdates = readModels.identityUpdates, + albums = readModels.albums, + artists = readModels.artists, + artistTrackIndex = readModels.artistTrackIndex, + directories = readModels.directories, + ftsRows = readModels.ftsRows, + ) + userDao.updateFolderScanState(folderId, System.currentTimeMillis(), "ready", null) + scheduleSnapshot() + refreshReadyStatus() + for (listener in catalogListeners) listener(revision) + NativeScanResult( + added = added, + updated = updated, + removed = removed, + errors = errors, + total = files.size, + revision = revision, + ) + } catch (error: Throwable) { + runCatching { + dao.deleteGenerationTracks(generationId) + dao.setGenerationState( + generationId, + "failed", + System.currentTimeMillis(), + error.message ?: error.javaClass.simpleName, + ) + } + userDao.updateFolderScanState( + folderId, + folder.lastScannedAt, + "failed", + error.message ?: error.javaClass.simpleName, + ) + val oldCount = runCatching { dao.countActiveTracks() }.getOrDefault(0) + updateStatus( + status = if (oldCount > 0) LibraryStatus.DEGRADED else LibraryStatus.EMPTY, + revision = runCatching { dao.getRevision() }.getOrDefault(0), + count = oldCount, + message = "Scan failed; the previous library is still available.", + recoveryNotice = currentStatus.recoveryNotice, + ) + scheduleSnapshot() + throw error + } + } + } + + suspend fun removeFolder(folderId: Long) { + initialize() + val dao = requireUser().userDao() + val folder = dao.getFolder(folderId) ?: return + val sourceKey = localSourceKey(folderId) + catalogWriterMutex.withLock { + val catalogDao = requireCatalog().catalogDao() + val source = catalogDao.getSource(sourceKey) + val remaining = catalogDao.getActiveTrackEntitiesExcludingSource(sourceKey) + val nextRevision = catalogDao.getRevision() + 1 + val readModels = withContext(Dispatchers.Default) { + CatalogReadModelBuilder.build( + remaining, + nextRevision, + dao.getFolders().filter { it.id != folderId }.associateBy(FolderEntity::id), + ) + } + val revision = catalogDao.removeSourceAndPublish( + sourceKey = sourceKey, + generationId = source?.activeGenerationId, + now = System.currentTimeMillis(), + albumIdentityUpdates = readModels.identityUpdates, + albums = readModels.albums, + artists = readModels.artists, + artistTrackIndex = readModels.artistTrackIndex, + directories = readModels.directories, + ftsRows = readModels.ftsRows, + ) + dao.deleteFolder(folder) + for (listener in catalogListeners) listener(revision) + } + runCatching { + applicationContext.contentResolver.releasePersistableUriPermission( + android.net.Uri.parse(folder.treeUri), + android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or + android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } + flushSnapshot() + refreshReadyStatus() + } + + suspend fun getTrackPage( + sort: String, + cursorRaw: String?, + requestedLimit: Int, + ): Map = withCatalogRecovery { database -> + initialize() + val dao = database.catalogDao() + val revision = dao.getRevision() + val cursor = validateCursor(cursorRaw, revision, "tracks:$sort") + val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) + val rows = when (sort) { + "artist" -> dao.getArtistOrderPage( + afterArtistKey = cursor?.text1, + afterAlbumKey = cursor?.text2.orEmpty(), + afterDisc = cursor?.number1?.toInt() ?: 0, + afterTrack = cursor?.number2?.toInt() ?: 0, + afterTitleKey = cursor?.let(::cursorTitleKey).orEmpty(), + afterPath = cursor?.let { cursorPath(it) }.orEmpty(), + limit = limit, + ) + "recently_added" -> dao.getRecentlyAddedPage( + afterAddedAt = cursor?.number1, + afterPath = cursor?.text1.orEmpty(), + limit = limit, + ) + "duration" -> dao.getDurationPage( + afterDuration = cursor?.decimal1, + afterPath = cursor?.text1.orEmpty(), + limit = limit, + ) + else -> dao.getTitlePage( + afterTitleKey = cursor?.text1, + afterPath = cursor?.text2.orEmpty(), + limit = limit, + ) + } + val next = rows.lastOrNull()?.let { row -> + when (sort) { + "artist" -> TrackPageCursor( + revision = revision, + kind = "tracks:$sort", + text1 = row.artistSortKey, + text2 = row.albumSortKey, + text3 = "${row.titleSortKey}\u0000${row.path}", + number1 = row.discSort.toLong(), + number2 = row.trackSort.toLong(), + // Artist cursor needs one extra string. Encode the path alongside the + // title key with a NUL delimiter; SAF paths cannot contain NUL. + ) + "recently_added" -> TrackPageCursor( + revision = revision, + kind = "tracks:$sort", + text1 = row.path, + number1 = row.addedAt, + ) + "duration" -> TrackPageCursor( + revision = revision, + kind = "tracks:$sort", + text1 = row.path, + decimal1 = row.duration, + ) + else -> TrackPageCursor( + revision = revision, + kind = "tracks:$sort", + text1 = row.titleSortKey, + text2 = row.path, + ) + }.encode() + } + mapOf( + "items" to rows.map(ActiveTrackView::toBridgeMap), + "nextCursor" to next, + "previousCursor" to null, + "totalCount" to dao.countActiveTracks().toDouble(), + "catalogRevision" to revision.toString(), + ) + } + + suspend fun getTrack(path: String): Map? = + withCatalogRecovery { database -> database.catalogDao().getActiveTrack(path)?.toBridgeMap() } + + suspend fun getTrackLoudness(paths: List): List> = + withCatalogRecovery { database -> + if (paths.isEmpty()) return@withCatalogRecovery emptyList() + require(paths.size <= MAX_PAGE_SIZE) { "At most $MAX_PAGE_SIZE paths may be requested" } + database.catalogDao().getActiveTracks(paths.distinct()).map { row -> + mapOf( + "path" to row.path, + "loudness_lufs" to row.loudnessLufs, + "sample_peak" to row.samplePeak, + "replay_gain_track_db" to row.replayGainTrackDb, + "replay_gain_album_db" to row.replayGainAlbumDb, + "replay_gain_track_peak" to row.replayGainTrackPeak, + "replay_gain_album_peak" to row.replayGainAlbumPeak, + "rg_scanned" to if (row.replayGainScanned) 1 else 0, + ) + } + } + + suspend fun setTrackLoudness(path: String, lufs: Double?, samplePeak: Double?) { + withCatalogRecovery { database -> + database.catalogDao().updateActiveTrackLoudness(path, lufs, samplePeak) + } + } + + suspend fun setTrackReplayGain( + path: String, + trackGainDb: Double?, + albumGainDb: Double?, + trackPeak: Double?, + albumPeak: Double?, + ) { + withCatalogRecovery { database -> + database.catalogDao().updateActiveTrackReplayGain( + path, + trackGainDb, + albumGainDb, + trackPeak, + albumPeak, + ) + } + } + + suspend fun getLibraryLoudnessStats(): Map = + withCatalogRecovery { database -> + val row = database.catalogDao().getLibraryLoudnessStats() + mapOf( + "lufsCount" to row.lufsCount.toDouble(), + "medianLufs" to row.medianLufs, + "rgCount" to row.rgCount.toDouble(), + "medianRgTrackDb" to row.medianRgTrackDb, + ) + } + + suspend fun getWaveform(path: String): List? = + withCatalogRecovery { database -> + val row = database.catalogDao().getWaveform(path) ?: return@withCatalogRecovery null + if (row.peaks.size != row.bins * Float.SIZE_BYTES) return@withCatalogRecovery null + val buffer = ByteBuffer.wrap(row.peaks).order(ByteOrder.LITTLE_ENDIAN) + List(row.bins) { buffer.float.toDouble() } + } + + suspend fun putWaveform(path: String, peaks: List) { + require(peaks.size in 1..4_096) { "Waveform must contain between 1 and 4096 peaks" } + val bytes = ByteBuffer + .allocate(peaks.size * Float.SIZE_BYTES) + .order(ByteOrder.LITTLE_ENDIAN) + for (peak in peaks) bytes.putFloat(peak.toFloat().coerceIn(0f, 1f)) + withCatalogRecovery { database -> + database.catalogDao().putWaveform( + WaveformPeaksEntity( + trackPath = path, + bins = peaks.size, + peaks = bytes.array(), + createdAt = System.currentTimeMillis(), + ), + ) + } + } + + suspend fun countWaveforms(): Long = + withCatalogRecovery { database -> database.catalogDao().countWaveforms() } + + suspend fun clearWaveforms() { + withCatalogRecovery { database -> database.catalogDao().clearWaveforms() } + } + + suspend fun getLyrics(path: String, metadataSignature: String): Map? = + withCatalogRecovery { database -> + val row = database.catalogDao().getLyrics(path) ?: return@withCatalogRecovery null + if (row.metadataSignature != metadataSignature) return@withCatalogRecovery null + mapOf( + "status" to row.status, + "source" to row.source, + "provider" to row.provider, + "format" to row.format, + "plainLyrics" to row.plainLyrics, + "syncedLyrics" to row.syncedLyrics, + "syncedLinesJson" to row.syncedLinesJson, + ) + } + + suspend fun putLyrics(path: String, values: Map) { + withCatalogRecovery { database -> + database.catalogDao().putLyrics( + LyricsCacheEntity( + trackPath = path, + metadataSignature = values["metadataSignature"] as? String, + status = values["status"] as? String ?: "not_found", + source = values["source"] as? String, + provider = values["provider"] as? String, + format = values["format"] as? String, + plainLyrics = values["plainLyrics"] as? String, + syncedLyrics = values["syncedLyrics"] as? String, + syncedLinesJson = values["syncedLinesJson"] as? String ?: "[]", + updatedAt = System.currentTimeMillis(), + ), + ) + } + } + + suspend fun deleteLyrics(path: String) { + withCatalogRecovery { database -> database.catalogDao().deleteLyrics(path) } + } + + suspend fun countLyrics(): Long = + withCatalogRecovery { database -> database.catalogDao().countLyrics() } + + suspend fun clearLyrics() { + withCatalogRecovery { database -> database.catalogDao().clearLyrics() } + } + + suspend fun readMobileSession(): String? { + initialize() + return requireUser().userDao().getPlaybackSession(MOBILE_SESSION_ID)?.contextJson + } + + suspend fun writeMobileSession(snapshotJson: String) { + initialize() + val dao = requireUser().userDao() + val previous = dao.getPlaybackSession(MOBILE_SESSION_ID) + val now = System.currentTimeMillis() + val playback = runCatching { + org.json.JSONObject(snapshotJson).optJSONObject("playback") + }.getOrNull() + val activePosition = playback?.optLong("activeIndex", 0L) ?: 0L + val queue = playback?.optJSONArray("queuePaths") + val anchorPath = if (queue != null && activePosition in 0 until queue.length().toLong()) { + queue.optString(activePosition.toInt()).takeIf(String::isNotBlank) + } else { + null + } + dao.putPlaybackSession( + PlaybackSessionEntity( + id = MOBILE_SESSION_ID, + contextJson = snapshotJson, + anchorPath = anchorPath, + shuffleSeed = previous?.shuffleSeed, + activePosition = activePosition, + createdAt = previous?.createdAt ?: now, + updatedAt = now, + ), + ) + scheduleSnapshot() + } + + suspend fun createPlaybackContext( + context: Map, + anchorPath: String?, + shuffle: Boolean, + requestedSeed: Long?, + ): Map { + initialize() + val catalogDao = requireCatalog().catalogDao() + val userDao = requireUser().userDao() + val paths = resolvePlaybackPaths(context, catalogDao, userDao) + val availablePaths = filterAvailablePaths(paths, catalogDao) + val seed = requestedSeed ?: System.currentTimeMillis() + val ordered = availablePaths.toMutableList() + var activePosition = anchorPath?.let(ordered::indexOf)?.takeIf { it >= 0 } ?: 0 + if (shuffle && ordered.size > 1) { + val anchor = ordered.getOrNull(activePosition) + if (anchor != null) ordered.removeAt(activePosition) + ordered.shuffle(Random(seed)) + if (anchor != null) ordered.add(0, anchor) + activePosition = 0 + } + val now = System.currentTimeMillis() + userDao.replacePlaybackQueue( + PlaybackSessionEntity( + id = ACTIVE_PLAYBACK_CONTEXT_ID, + contextJson = org.json.JSONObject(context).toString(), + anchorPath = ordered.getOrNull(activePosition), + shuffleSeed = if (shuffle) seed else null, + activePosition = activePosition.toLong(), + createdAt = now, + updatedAt = now, + ), + ordered.mapIndexed { index, path -> + PlaybackQueueEntryEntity( + sessionId = ACTIVE_PLAYBACK_CONTEXT_ID, + position = index.toLong(), + trackPath = path, + ) + }, + availablePaths.mapIndexed { index, path -> + PlaybackOriginalQueueEntryEntity( + sessionId = ACTIVE_PLAYBACK_CONTEXT_ID, + position = index.toLong(), + trackPath = path, + ) + }, + ) + scheduleSnapshot() + return playbackWindow( + ACTIVE_PLAYBACK_CONTEXT_ID, + (activePosition - 25).coerceAtLeast(0).toLong(), + 226, + ) + } + + suspend fun getPlaybackWindow( + sessionId: String, + start: Long, + requestedLimit: Int, + ): Map { + initialize() + return playbackWindow(sessionId, start.coerceAtLeast(0), requestedLimit.coerceIn(1, 250)) + } + + suspend fun updatePlaybackPosition(sessionId: String, activePosition: Long) { + initialize() + val dao = requireUser().userDao() + val total = dao.countQueueEntries(sessionId) + if (total == 0L) return + val bounded = activePosition.coerceIn(0, total - 1) + val anchor = dao.getQueueWindow(sessionId, bounded, 1).firstOrNull()?.trackPath + dao.updatePlaybackPosition(sessionId, bounded, anchor, System.currentTimeMillis()) + scheduleSnapshot() + } + + suspend fun restorePlaybackContext(): Map? { + initialize() + val database = requireUser() + val dao = database.userDao() + val session = dao.getPlaybackSession(ACTIVE_PLAYBACK_CONTEXT_ID) ?: return null + val existing = dao.getAllQueueEntries(session.id) + if (existing.isEmpty()) return null + val available = filterAvailablePaths( + existing.map(PlaybackQueueEntryEntity::trackPath), + requireCatalog().catalogDao(), + ).toHashSet() + val retained = existing.filter { it.trackPath in available } + if (retained.isEmpty()) { + dao.deletePlaybackSession(session.id) + scheduleSnapshot() + return null + } + val oldActive = session.activePosition + val activePath = session.anchorPath + val activeIndex = activePath?.let { path -> + retained.indexOfFirst { it.trackPath == path }.takeIf { it >= 0 } + } ?: retained.indexOfLast { it.position <= oldActive }.coerceAtLeast(0) + val normalized = retained.mapIndexed { index, row -> + row.copy(position = index.toLong()) + } + val original = dao.getOriginalQueueEntries(session.id) + .filter { it.trackPath in available } + .mapIndexed { index, row -> row.copy(position = index.toLong()) } + database.userDao().replacePlaybackQueue( + session.copy( + anchorPath = normalized[activeIndex].trackPath, + activePosition = activeIndex.toLong(), + updatedAt = System.currentTimeMillis(), + ), + normalized, + original, + ) + val start = (activeIndex - 25).coerceAtLeast(0).toLong() + return playbackWindow(session.id, start, 226) + } + + suspend fun mutatePlaybackContext( + operation: String, + values: Map, + ): Map? { + initialize() + val database = requireUser() + val dao = database.userDao() + val session = dao.getPlaybackSession(ACTIVE_PLAYBACK_CONTEXT_ID) ?: return null + val current = dao.getAllQueueEntries(session.id).map(PlaybackQueueEntryEntity::trackPath).toMutableList() + if (current.isEmpty()) return null + val originalRows = dao.getOriginalQueueEntries(session.id) + val original = (if (originalRows.isEmpty()) current else originalRows.map(PlaybackOriginalQueueEntryEntity::trackPath)) + .toMutableList() + var active = session.activePosition.toInt().coerceIn(current.indices) + val activePath = current[active] + + fun move(paths: MutableList, from: Int, to: Int) { + if (from !in paths.indices || to !in paths.indices || from == to) return + val item = paths.removeAt(from) + paths.add(to.coerceIn(0, paths.size), item) + } + + when (operation) { + "insertAfterActive", "append", "insertQueryAfterActive", "appendQuery" -> { + @Suppress("UNCHECKED_CAST") + val context = values["context"] as? Map + val requested = if (context == null) { + (values["paths"] as? List<*>)?.mapNotNull { it as? String }.orEmpty() + } else { + resolvePlaybackPaths(context, requireCatalog().catalogDao(), dao) + } + val paths = filterAvailablePaths(requested, requireCatalog().catalogDao()) + if (paths.isNotEmpty()) { + val append = operation == "append" || operation == "appendQuery" + val insertAt = if (append) current.size else active + 1 + current.addAll(insertAt, paths) + val originalAnchor = original.indexOf(activePath) + val originalInsert = if (append || originalAnchor < 0) { + original.size + } else { + originalAnchor + 1 + } + original.addAll(originalInsert, paths) + } + } + "remove" -> { + @Suppress("UNCHECKED_CAST") + val positions = (values["positions"] as? List<*>) + ?.mapNotNull { (it as? Number)?.toInt() } + ?.distinct() + ?.sortedDescending() + .orEmpty() + for (position in positions) { + if (position !in current.indices || position == active) continue + val removed = current.removeAt(position) + original.indexOf(removed).takeIf { it >= 0 }?.let(original::removeAt) + if (position < active) active -= 1 + } + } + "move" -> { + val from = (values["from"] as? Number)?.toInt() ?: -1 + val to = (values["to"] as? Number)?.toInt() ?: -1 + if (from in current.indices && to in current.indices && from != active && to != active) { + val movedPath = current[from] + val targetPath = current[to] + move(current, from, to) + val originalFrom = original.indexOf(movedPath) + val originalTo = original.indexOf(targetPath) + if (originalFrom >= 0 && originalTo >= 0) move(original, originalFrom, originalTo) + active = current.indexOf(activePath).coerceAtLeast(0) + } + } + "moveManyAfterActive" -> { + @Suppress("UNCHECKED_CAST") + val positions = (values["positions"] as? List<*>) + ?.mapNotNull { (it as? Number)?.toInt() } + ?.distinct() + ?.filter { it in current.indices && it != active } + ?.sorted() + .orEmpty() + if (positions.isNotEmpty()) { + val selected = positions.map(current::get) + positions.asReversed().forEach { current.removeAt(it) } + active = current.indexOf(activePath).coerceAtLeast(0) + current.addAll(active + 1, selected) + + val selectedCounts = selected.groupingBy { it }.eachCount().toMutableMap() + val remainingOriginal = original.filter { path -> + val count = selectedCounts[path] ?: 0 + if (count <= 0) true else { + if (count == 1) selectedCounts.remove(path) else selectedCounts[path] = count - 1 + false + } + }.toMutableList() + val originalActive = remainingOriginal.indexOf(activePath) + remainingOriginal.addAll( + if (originalActive >= 0) originalActive + 1 else 0, + selected, + ) + original.clear() + original.addAll(remainingOriginal) + } + } + "shuffle" -> { + val enabled = values["enabled"] == true + if (enabled) { + val seed = (values["seed"] as? Number)?.toLong() ?: System.currentTimeMillis() + val prefix = current.take(active + 1) + val upcoming = current.drop(active + 1).toMutableList().apply { shuffle(Random(seed)) } + current.clear() + current.addAll(prefix) + current.addAll(upcoming) + } else { + current.clear() + current.addAll(original) + active = current.indexOf(activePath).coerceAtLeast(0) + } + } + else -> error("Unknown playback context mutation.") + } + + val now = System.currentTimeMillis() + val shuffleEnabled = operation == "shuffle" && values["enabled"] == true + val nextSeed = when { + operation != "shuffle" -> session.shuffleSeed + shuffleEnabled -> (values["seed"] as? Number)?.toLong() ?: now + else -> null + } + dao.replacePlaybackQueue( + session.copy( + anchorPath = current.getOrNull(active), + activePosition = active.toLong(), + shuffleSeed = nextSeed, + updatedAt = now, + ), + current.mapIndexed { index, path -> + PlaybackQueueEntryEntity(session.id, index.toLong(), path) + }, + original.mapIndexed { index, path -> + PlaybackOriginalQueueEntryEntity(session.id, index.toLong(), path) + }, + ) + scheduleSnapshot() + return playbackWindow(session.id, (active - 25).coerceAtLeast(0).toLong(), 226) + } + + suspend fun recordTrackPlayed(path: String): Boolean { + initialize() + val userDao = requireUser().userDao() + val existing = userDao.getPlaybackHistory(path) + val now = System.currentTimeMillis() + userDao.putPlaybackHistory( + PlaybackHistoryEntity( + trackPath = path, + lastPlayedAt = now, + playCount = (existing?.playCount ?: 0) + 1, + ), + ) + requireCatalog().catalogDao().putTrackUserFacts( + listOf( + TrackUserFactEntity( + path = path, + isFavorite = userDao.isFavorite(path), + playCount = (existing?.playCount ?: 0) + 1, + lastPlayedAt = now, + ), + ), + ) + scheduleSnapshot() + return true + } + + suspend fun listRemoteSources(): List> { + initialize() + return requireUser().userDao().getRemoteSources().map(RemoteSourceEntity::toBridgeMap) + } + + suspend fun getRemoteSource(sourceId: Long): Map? { + initialize() + return requireUser().userDao().getRemoteSource(sourceId)?.toBridgeMap() + } + + suspend fun createRemoteSource( + type: String, + name: String, + baseUrl: String, + username: String, + enabled: Boolean, + ): Map { + initialize() + val now = System.currentTimeMillis() + val dao = requireUser().userDao() + val entity = RemoteSourceEntity( + type = type, + name = name, + baseUrl = baseUrl, + username = username, + enabled = enabled, + createdAt = now, + updatedAt = now, + ) + val row = entity.copy(id = dao.insertRemoteSource(entity)) + flushSnapshot() + return row.toBridgeMap() + } + + suspend fun updateRemoteSource(sourceId: Long, fields: Map) { + initialize() + val dao = requireUser().userDao() + val row = dao.getRemoteSource(sourceId) ?: return + dao.putRemoteSource( + row.copy( + name = fields["name"] as? String ?: row.name, + baseUrl = fields["base_url"] as? String ?: row.baseUrl, + username = fields["username"] as? String ?: row.username, + enabled = fields["enabled"] as? Boolean ?: row.enabled, + updatedAt = System.currentTimeMillis(), + ), + ) + flushSnapshot() + } + + suspend fun setRemoteSourceStatus(sourceId: Long, status: String, error: String?) { + initialize() + val dao = requireUser().userDao() + val row = dao.getRemoteSource(sourceId) ?: return + val now = System.currentTimeMillis() + dao.putRemoteSource( + row.copy( + lastStatus = status, + lastError = error, + lastCheckedAt = now, + lastSyncAt = if (status == "ok") now else row.lastSyncAt, + updatedAt = now, + ), + ) + scheduleSnapshot() + } + + suspend fun deleteRemoteSource(sourceId: Long, purgeCatalog: Boolean) { + initialize() + val userDao = requireUser().userDao() + val source = userDao.getRemoteSource(sourceId) ?: return + if (purgeCatalog) removeCatalogSource("${source.type}:$sourceId") + userDao.deleteRemotePlaylists(sourceId) + userDao.deleteFavoritesByPrefix("${source.type}://$sourceId/track/") + userDao.deleteRemoteSource(sourceId) + reconcileUserFacts() + flushSnapshot() + } + + suspend fun replaceRemoteUserState( + sourceId: Long, + sourceType: String, + favoritePaths: List, + playlists: List>, + ) { + initialize() + require(favoritePaths.size <= 100_000) { "Remote favorite batch is too large" } + require(playlists.size <= 10_000) { "Remote playlist batch is too large" } + val now = System.currentTimeMillis() + val plans = playlists.mapNotNull { row -> + val remoteId = row["source_playlist_id"] as? String ?: return@mapNotNull null + val name = (row["name"] as? String)?.trim()?.takeIf(String::isNotEmpty) + ?: "Remote playlist" + val tracks = (row["tracks"] as? List<*>) + .orEmpty() + .mapNotNull { it as? Map<*, *> } + .mapNotNull { track -> + val path = track["path"] as? String ?: return@mapNotNull null + PlaylistTrackEntity( + playlistId = 0, + trackPath = path, + position = 0, + addedAt = now, + fallbackTitle = track["title"] as? String, + fallbackArtist = track["artist"] as? String, + fallbackAlbum = track["album"] as? String, + ) + } + .distinctBy(PlaylistTrackEntity::trackPath) + .mapIndexed { index, entry -> entry.copy(position = index) } + RemotePlaylistSyncPlan( + playlist = PlaylistEntity( + name = name, + createdAt = now, + updatedAt = now, + kind = "normal", + remoteSourceId = sourceId, + remotePlaylistId = remoteId, + syncUid = "remote:$sourceType:$sourceId:$remoteId", + ), + entries = tracks, + ) + } + requireUser().userDao().replaceRemoteUserState( + sourceId = sourceId, + favoritePrefix = "$sourceType://$sourceId/track/", + favorites = favoritePaths.distinct().map { FavoriteEntity(it, now) }, + playlists = plans, + ) + reconcileUserFacts() + scheduleSnapshot() + } + + suspend fun beginRemoteSync(sourceId: Long, sourceType: String): String = + catalogWriterMutex.withLock { + initialize() + val dao = requireCatalog().catalogDao() + val sourceKey = "$sourceType:$sourceId" + val previous = dao.getSource(sourceKey) + val generationId = UUID.randomUUID().toString() + val syncId = UUID.randomUUID().toString() + val now = System.currentTimeMillis() + dao.putSource( + previous ?: CatalogSourceEntity( + sourceKey = sourceKey, + sourceType = sourceType, + sourceId = sourceId, + updatedAt = now, + ), + ) + dao.insertGeneration( + ScanGenerationEntity( + id = generationId, + sourceKey = sourceKey, + state = "staging", + startedAt = now, + ), + ) + remoteSyncs[syncId] = RemoteSyncHandle( + syncId = syncId, + sourceKey = sourceKey, + sourceId = sourceId, + sourceType = sourceType, + generationId = generationId, + previousGenerationId = previous?.activeGenerationId, + startedAt = now, + existingByPath = dao.getActiveTrackEntitiesForSource(sourceKey).associateBy(TrackEntity::path), + ) + syncId + } + + suspend fun appendRemoteTracks(syncId: String, rows: List>): Int = + catalogWriterMutex.withLock { + val handle = remoteSyncs[syncId] ?: error("Remote sync is not active") + val tracks = rows.mapNotNull { row -> + val path = row["path"] as? String ?: return@mapNotNull null + handle.seenPaths += path + remoteTrackFromMap(handle, row, handle.existingByPath[path]?.addedAt ?: handle.startedAt) + } + if (tracks.isNotEmpty()) requireCatalog().catalogDao().putTracks(tracks) + tracks.size + } + + suspend fun commitRemoteSync(syncId: String): Map = + catalogWriterMutex.withLock { + val handle = remoteSyncs.remove(syncId) ?: error("Remote sync is not active") + val dao = requireCatalog().catalogDao() + try { + val prospective = dao.getProspectiveTracks(handle.sourceKey, handle.generationId) + val nextRevision = dao.getRevision() + 1 + val readModels = withContext(Dispatchers.Default) { + CatalogReadModelBuilder.build( + prospective, + nextRevision, + requireUser().userDao().getFolders().associateBy(FolderEntity::id), + ) + } + val revision = dao.publishGeneration( + sourceKey = handle.sourceKey, + generationId = handle.generationId, + previousGenerationId = handle.previousGenerationId, + now = System.currentTimeMillis(), + albumIdentityUpdates = readModels.identityUpdates, + albums = readModels.albums, + artists = readModels.artists, + artistTrackIndex = readModels.artistTrackIndex, + directories = readModels.directories, + ftsRows = readModels.ftsRows, + ) + refreshReadyStatus() + for (listener in catalogListeners) listener(revision) + mapOf( + "tracksScanned" to handle.seenPaths.size, + "removed" to handle.existingByPath.keys.count { it !in handle.seenPaths }, + "catalogRevision" to revision.toString(), + ) + } catch (error: Throwable) { + dao.deleteGenerationTracks(handle.generationId) + dao.setGenerationState( + handle.generationId, + "failed", + System.currentTimeMillis(), + error.message ?: error.javaClass.simpleName, + ) + throw error + } + } + + suspend fun abortRemoteSync(syncId: String) { + catalogWriterMutex.withLock { + val handle = remoteSyncs.remove(syncId) ?: return@withLock + val dao = requireCatalog().catalogDao() + dao.deleteGenerationTracks(handle.generationId) + dao.setGenerationState(handle.generationId, "failed", System.currentTimeMillis(), "aborted") + } + } + + suspend fun getRecentlyPlayed(limit: Int): List> { + initialize() + val history = requireUser().userDao().getPlaybackHistory().take(limit.coerceIn(1, 100)) + if (history.isEmpty()) return emptyList() + val tracks = requireCatalog().catalogDao().getActiveTracks(history.map { it.trackPath }).associateBy { it.path } + return history.mapNotNull { item -> + tracks[item.trackPath]?.toBridgeMap()?.toMutableMap()?.apply { + this["play_count"] = item.playCount.toDouble() + this["last_played_at"] = item.lastPlayedAt.toDouble() + } + } + } + + suspend fun listPlaylists(): List> { + initialize() + val userDao = requireUser().userDao() + val catalogDao = requireCatalog().catalogDao() + return userDao.getPlaylists().map { playlist -> + if (playlist.kind == "dynamic") { + val queries = DynamicPlaylistCompiler.compile(playlist.dynamicRulesJson, 0, 1) + val count = catalogDao.runDynamicCountQuery(queries.count) + val first = catalogDao.runDynamicTrackQuery(queries.tracks).firstOrNull() + playlist.toBridgeMap( + trackCount = count, + missingCount = 0, + artworkHash = first?.artworkHash, + ) + } else { + val entries = userDao.getPlaylistTracks(playlist.id) + val active = entries.chunked(400).flatMap { chunk -> + catalogDao.getActiveTracks(chunk.map(PlaylistTrackEntity::trackPath)) + } + val activeByPath = active.associateBy(ActiveTrackView::path) + val activePaths = activeByPath.keys + playlist.toBridgeMap( + trackCount = activePaths.size.toLong(), + missingCount = entries.count { it.trackPath !in activePaths }.toLong(), + artworkHash = entries.asSequence() + .mapNotNull { entry -> activeByPath[entry.trackPath]?.artworkHash } + .firstOrNull(), + ) + } + } + } + + suspend fun createPlaylist(name: String, kind: String, rulesJson: String?): Map { + initialize() + val trimmed = name.trim() + require(trimmed.isNotEmpty()) { "Playlist name is required." } + val now = System.currentTimeMillis() + val entity = PlaylistEntity( + name = trimmed, + createdAt = now, + updatedAt = now, + kind = if (kind == "dynamic") "dynamic" else "normal", + dynamicRulesJson = if (kind == "dynamic") rulesJson else null, + ) + val row = entity.copy(id = requireUser().userDao().insertPlaylist(entity)) + flushSnapshot() + return row.toBridgeMap(0, 0, null) + } + + suspend fun getDynamicPlaylistRules(playlistId: Long): String { + initialize() + val playlist = requireUser().userDao().getPlaylist(playlistId) + ?: error("Playlist not found.") + require(playlist.kind == "dynamic") { "Playlist is not dynamic." } + return playlist.dynamicRulesJson ?: """{"version":1,"conditions":[],"sort":{"field":"title","direction":"asc"},"limit":null}""" + } + + suspend fun updateDynamicPlaylistRules(playlistId: Long, rulesJson: String) { + initialize() + val dao = requireUser().userDao() + val playlist = dao.getPlaylist(playlistId) ?: error("Playlist not found.") + require(playlist.kind == "dynamic") { "Playlist is not dynamic." } + dao.putPlaylist( + playlist.copy( + dynamicRulesJson = rulesJson, + updatedAt = System.currentTimeMillis(), + ), + ) + scheduleSnapshot() + } + + suspend fun previewDynamicPlaylist(rulesJson: String): Map { + initialize() + val dao = requireCatalog().catalogDao() + val queries = DynamicPlaylistCompiler.compile(rulesJson, 0, 25) + return mapOf( + "track_count" to dao.runDynamicCountQuery(queries.count).toDouble(), + "tracks" to dao.runDynamicTrackQuery(queries.tracks).map { track -> + mapOf( + "path" to track.path, + "title" to track.title, + "artist" to track.artist, + "album" to track.album, + ) + }, + ) + } + + suspend fun renamePlaylist(playlistId: Long, name: String) { + initialize() + val dao = requireUser().userDao() + val playlist = dao.getPlaylist(playlistId) ?: return + dao.putPlaylist(playlist.copy(name = name.trim(), updatedAt = System.currentTimeMillis())) + scheduleSnapshot() + } + + suspend fun deletePlaylist(playlistId: Long) { + initialize() + requireUser().userDao().deletePlaylistById(playlistId) + flushSnapshot() + } + + suspend fun markPlaylistPlayed(playlistId: Long) { + initialize() + val dao = requireUser().userDao() + val playlist = dao.getPlaylist(playlistId) ?: return + dao.putPlaylist(playlist.copy(lastPlayedAt = System.currentTimeMillis())) + scheduleSnapshot() + } + + suspend fun addPlaylistEntries( + playlistId: Long, + entries: List>, + ): Int { + initialize() + val dao = requireUser().userDao() + val playlist = dao.getPlaylist(playlistId) ?: error("Playlist not found.") + require(playlist.kind != "dynamic") { "Dynamic playlists cannot accept manual tracks." } + val rows = entries.mapNotNull { entry -> + val path = entry["trackPath"] as? String ?: return@mapNotNull null + PlaylistTrackEntity( + playlistId = playlistId, + trackPath = path, + position = 0, + addedAt = 0, + fallbackTitle = entry["fallbackTitle"] as? String, + fallbackArtist = entry["fallbackArtist"] as? String, + fallbackAlbum = entry["fallbackAlbum"] as? String, + ) + } + val inserted = dao.appendPlaylistTracks(playlistId, rows, System.currentTimeMillis()) + scheduleSnapshot() + return inserted + } + + suspend fun removePlaylistEntry(playlistId: Long, path: String) { + initialize() + val dao = requireUser().userDao() + val playlist = dao.getPlaylist(playlistId) ?: return + require(playlist.kind != "dynamic") { "Dynamic playlists cannot remove tracks manually." } + dao.removePlaylistTrackByPath(playlistId, path, System.currentTimeMillis()) + scheduleSnapshot() + } + + suspend fun movePlaylistEntry(playlistId: Long, path: String, direction: Int) { + initialize() + val dao = requireUser().userDao() + val playlist = dao.getPlaylist(playlistId) ?: return + require(playlist.kind != "dynamic") { "Dynamic playlists cannot reorder tracks manually." } + dao.movePlaylistTrackByPath( + playlistId, + path, + direction.coerceIn(-1, 1), + System.currentTimeMillis(), + ) + scheduleSnapshot() + } + + suspend fun getPlaylistEntries( + playlistId: Long, + offset: Int, + requestedLimit: Int, + ): Map { + initialize() + val userDao = requireUser().userDao() + val catalogDao = requireCatalog().catalogDao() + val playlist = userDao.getPlaylist(playlistId) ?: error("Playlist not found.") + val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) + if (playlist.kind == "dynamic") { + val queries = DynamicPlaylistCompiler.compile(playlist.dynamicRulesJson, offset, limit) + val rows = catalogDao.runDynamicTrackQuery(queries.tracks) + val total = catalogDao.runDynamicCountQuery(queries.count) + return mapOf( + "items" to rows.mapIndexed { index, track -> + mapOf( + "id" to (-(offset + index) - 1).toDouble(), + "track_path" to track.path, + "position" to offset + index, + "added_at" to track.addedAt.toDouble(), + "missing" to false, + "fallback_title" to null, + "fallback_artist" to null, + "fallback_album" to null, + "track" to track.toBridgeMap(), + ) + }, + "nextOffset" to if (offset + rows.size < total) offset + rows.size else null, + "totalCount" to total.toDouble(), + ) + } + val entries = userDao.getPlaylistTrackPage(playlistId, limit, offset) + val tracks = catalogDao.getActiveTracks(entries.map(PlaylistTrackEntity::trackPath)) + .associateBy(ActiveTrackView::path) + val total = userDao.countPlaylistTracks(playlistId) + return mapOf( + "items" to entries.map { entry -> + val track = tracks[entry.trackPath] + mapOf( + "id" to entry.id.toDouble(), + "track_path" to entry.trackPath, + "position" to entry.position, + "added_at" to entry.addedAt.toDouble(), + "missing" to (track == null), + "fallback_title" to entry.fallbackTitle, + "fallback_artist" to entry.fallbackArtist, + "fallback_album" to entry.fallbackAlbum, + "track" to track?.toBridgeMap(), + ) + }, + "nextOffset" to if (offset + entries.size < total) offset + entries.size else null, + "totalCount" to total.toDouble(), + ) + } + + suspend fun getFavoritePaths(): List { + initialize() + return requireUser().userDao().getFavorites().map(FavoriteEntity::trackPath) + } + + suspend fun getFavoriteTracks(limit: Int): List> { + initialize() + val favorites = requireUser().userDao().getFavorites().take(limit.coerceIn(1, 500)) + val tracks = requireCatalog().catalogDao().getActiveTracks(favorites.map(FavoriteEntity::trackPath)) + .associateBy(ActiveTrackView::path) + return favorites.mapNotNull { tracks[it.trackPath]?.toBridgeMap() } + } + + suspend fun setFavorite(path: String, favorite: Boolean) { + initialize() + val userDao = requireUser().userDao() + if (favorite) userDao.putFavorite(FavoriteEntity(path, System.currentTimeMillis())) + else userDao.deleteFavorite(path) + val history = userDao.getPlaybackHistory(path) + requireCatalog().catalogDao().putTrackUserFacts( + listOf( + TrackUserFactEntity( + path = path, + isFavorite = favorite, + playCount = history?.playCount ?: 0, + lastPlayedAt = history?.lastPlayedAt, + ), + ), + ) + scheduleSnapshot() + } + + suspend fun getDesktopSyncState(): Map { + initialize() + val result = NativeDesktopSync.getState(requireUser(), requireCatalog()) + if (result["mutated"] == true) { + reconcileUserFacts() + scheduleSnapshot() + } + return result + } + + suspend fun applyDesktopSyncPlan(plan: Map): Map { + initialize() + val result = NativeDesktopSync.applyPlan(requireUser(), requireCatalog(), plan) + reconcileUserFacts() + scheduleSnapshot() + return result + } + + suspend fun resolveDesktopSyncConflict( + conflict: Map, + resolution: String, + mergedPlaylist: Map?, + ) { + initialize() + NativeDesktopSync.resolveConflict( + requireUser(), + requireCatalog(), + conflict, + resolution, + mergedPlaylist, + ) + reconcileUserFacts() + flushSnapshot() + } + + suspend fun clearDesktopSyncBaselines() { + initialize() + requireUser().userDao().clearPlaylistSyncStates() + scheduleSnapshot() + } + + suspend fun getAlbumPage( + sort: String, + includeSingles: Boolean, + cursorRaw: String?, + requestedLimit: Int, + ): Map = withCatalogRecovery { database -> + val dao = database.catalogDao() + val revision = dao.getRevision() + val kind = "albums:$sort:${if (includeSingles) 1 else 0}" + val cursor = validateCursor(cursorRaw, revision, kind) + val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) + val rows = when (sort) { + "artist" -> dao.getAlbumArtistPage( + revision, + includeSingles, + cursor?.text1, + cursor?.text2.orEmpty(), + cursor?.text3.orEmpty(), + limit, + ) + "recently_added" -> dao.getAlbumRecentPage( + revision, + includeSingles, + cursor?.number1, + cursor?.text1.orEmpty(), + limit, + ) + "year" -> dao.getAlbumYearPage( + revision, + includeSingles, + cursor?.number1?.toInt(), + cursor?.text1.orEmpty(), + cursor?.text2.orEmpty(), + limit, + ) + else -> dao.getAlbumNamePage( + revision, + includeSingles, + cursor?.text1, + cursor?.text2.orEmpty(), + limit, + ) + } + val next = rows.lastOrNull()?.let { row -> + when (sort) { + "artist" -> TrackPageCursor( + revision, + kind, + text1 = row.artistSortKey, + text2 = row.nameSortKey, + text3 = row.identityKey, + ) + "recently_added" -> TrackPageCursor( + revision, + kind, + text1 = row.identityKey, + number1 = row.latestAddedAt, + ) + "year" -> TrackPageCursor( + revision, + kind, + text1 = row.nameSortKey, + text2 = row.identityKey, + number1 = (row.year ?: 0).toLong(), + ) + else -> TrackPageCursor( + revision, + kind, + text1 = row.nameSortKey, + text2 = row.identityKey, + ) + }.encode() + } + mapOf( + "items" to rows.map(AlbumSummaryEntity::toBridgeMap), + "nextCursor" to next, + "previousCursor" to null, + "totalCount" to dao.countAlbums(revision, includeSingles).toDouble(), + "catalogRevision" to revision.toString(), + ) + } + + suspend fun getArtistPage( + sort: String, + groupingMode: String, + includeCollaborations: Boolean, + cursorRaw: String?, + requestedLimit: Int, + ): Map = withCatalogRecovery { database -> + val dao = database.catalogDao() + val revision = dao.getRevision() + val mode = if (groupingMode == "fileTags") "fileTags" else "astra" + val kind = "artists:$sort:$mode:${if (includeCollaborations) 1 else 0}" + val cursor = validateCursor(cursorRaw, revision, kind) + val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) + val rows = if (sort == "track_count") { + dao.getArtistCountPage( + revision, + mode, + includeCollaborations, + cursor?.number1, + cursor?.text1.orEmpty(), + cursor?.text2.orEmpty(), + limit, + ) + } else { + dao.getArtistNamePage( + revision, + mode, + includeCollaborations, + cursor?.text1, + cursor?.text2.orEmpty(), + limit, + ) + } + val next = rows.lastOrNull()?.let { row -> + TrackPageCursor( + revision, + kind, + text1 = row.nameSortKey, + text2 = row.artistKey, + number1 = if (sort == "track_count") row.trackCount else null, + ).encode() + } + mapOf( + "items" to rows.map(ArtistSummaryEntity::toBridgeMap), + "nextCursor" to next, + "previousCursor" to null, + "totalCount" to dao.countArtists(revision, mode, includeCollaborations).toDouble(), + "catalogRevision" to revision.toString(), + ) + } + + suspend fun getAlbumDetail( + albumKey: String, + cursorRaw: String?, + requestedLimit: Int, + ): Map = withCatalogRecovery { database -> + val dao = database.catalogDao() + val revision = dao.getRevision() + val kind = "album:$albumKey" + val cursor = validateCursor(cursorRaw, revision, kind) + val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) + val rows = dao.getAlbumTrackPage( + albumKey, + cursor?.number1?.toInt(), + cursor?.number2?.toInt() ?: 0, + cursor?.let(::cursorTitleKey).orEmpty(), + cursor?.let(::cursorPath).orEmpty(), + limit, + ) + val next = rows.lastOrNull()?.let { row -> + TrackPageCursor( + revision, + kind, + text3 = "${row.titleSortKey}\u0000${row.path}", + number1 = row.discSort.toLong(), + number2 = row.trackSort.toLong(), + ).encode() + } + mapOf( + "summary" to dao.getAlbumSummary(revision, albumKey)?.toBridgeMap(), + "items" to rows.map(ActiveTrackView::toBridgeMap), + "nextCursor" to next, + "previousCursor" to null, + "totalCount" to dao.countAlbumTracks(albumKey).toDouble(), + "catalogRevision" to revision.toString(), + ) + } + + suspend fun getArtistDetail( + artistKey: String, + groupingMode: String, + section: String, + cursorRaw: String?, + requestedLimit: Int, + ): Map = withCatalogRecovery { database -> + val dao = database.catalogDao() + val revision = dao.getRevision() + val mode = if (groupingMode == "fileTags") "fileTags" else "astra" + val normalizedArtistKey = normalizeArtistKey(artistKey) + val normalizedSection = when (section) { + "songs" -> "song" + "appearances" -> "appearance" + else -> "all" + } + val kind = "artist:$mode:$normalizedArtistKey:$normalizedSection" + val cursor = validateCursor(cursorRaw, revision, kind) + val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) + val rows = dao.getArtistTrackPage( + revision, + mode, + normalizedArtistKey, + normalizedSection, + cursor?.text1, + cursor?.number1?.toInt() ?: 0, + cursor?.number2?.toInt() ?: 0, + cursor?.let(::cursorTitleKey).orEmpty(), + cursor?.let(::cursorPath).orEmpty(), + limit, + ) + val next = rows.lastOrNull()?.let { row -> + TrackPageCursor( + revision, + kind, + text1 = row.albumSortKey, + text3 = "${row.titleSortKey}\u0000${row.path}", + number1 = row.discSort.toLong(), + number2 = row.trackSort.toLong(), + ).encode() + } + mapOf( + "summary" to dao.getArtistSummary(revision, mode, normalizedArtistKey)?.toBridgeMap(), + "items" to rows.map(ActiveTrackView::toBridgeMap), + "nextCursor" to next, + "previousCursor" to null, + "totalCount" to dao.countArtistTracks(revision, mode, normalizedArtistKey, normalizedSection).toDouble(), + "catalogRevision" to revision.toString(), + ) + } + + suspend fun getArtistAlbums( + artistKey: String, + groupingMode: String, + offset: Int, + requestedLimit: Int, + ): Map = withCatalogRecovery { database -> + val dao = database.catalogDao() + val revision = dao.getRevision() + val mode = if (groupingMode == "fileTags") "fileTags" else "astra" + val normalized = normalizeArtistKey(artistKey) + val safeOffset = offset.coerceAtLeast(0) + val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) + val rows = dao.getArtistAlbumPage(revision, mode, normalized, safeOffset, limit) + val total = dao.countArtistAlbums(revision, mode, normalized) + mapOf( + "items" to rows.map(AlbumSummaryEntity::toBridgeMap), + "nextOffset" to if (safeOffset + rows.size < total) safeOffset + rows.size else null, + "totalCount" to total.toDouble(), + "catalogRevision" to revision.toString(), + ) + } + + suspend fun searchTracks(query: String, requestedLimit: Int): List> = + withCatalogRecovery { database -> + val fts = compileFtsQuery(query) + if (fts.isBlank()) emptyList() + else { + val dao = database.catalogDao() + val limit = requestedLimit.coerceIn(1, 100) + val rows = searchTrackRows(dao, query, fts, limit) + rows.map(ActiveTrackView::toBridgeMap) + } + } + + suspend fun searchLibrary( + query: String, + requestedLimit: Int, + includeSingles: Boolean, + groupingMode: String, + includeCollaborations: Boolean, + ): Map = withCatalogRecovery { database -> + val fts = compileFtsQuery(query) + if (fts.isBlank()) { + return@withCatalogRecovery mapOf( + "tracks" to emptyList>(), + "albums" to emptyList>(), + "artists" to emptyList>(), + ) + } + val dao = database.catalogDao() + val revision = dao.getRevision() + val limit = requestedLimit.coerceIn(1, 100) + val mode = if (groupingMode == "fileTags") "fileTags" else "astra" + val literal = useLiteralSearch(query) + val pattern = literalSearchPattern(query) + val tracks = searchTrackRows(dao, query, fts, limit) + val albums = if (literal) { + dao.searchAlbumsLiteral(revision, includeSingles, pattern, limit) + } else { + dao.searchAlbums(revision, includeSingles, fts, limit).ifEmpty { + dao.searchAlbumsLiteral(revision, includeSingles, pattern, limit) + } + } + val artists = if (literal) { + dao.searchArtistsLiteral(revision, mode, includeCollaborations, pattern, limit) + } else { + dao.searchArtists(revision, mode, includeCollaborations, fts, limit).ifEmpty { + dao.searchArtistsLiteral(revision, mode, includeCollaborations, pattern, limit) + } + } + mapOf( + "tracks" to tracks.map(ActiveTrackView::toBridgeMap), + "albums" to albums.map(AlbumSummaryEntity::toBridgeMap), + "artists" to artists.map(ArtistSummaryEntity::toBridgeMap), + ) + } + + suspend fun matchSignal( + title: String, + artist: String, + durationSeconds: Double?, + ): Map = withCatalogRecovery { database -> + data class Candidate( + val track: ActiveTrackView, + val match: String, + val delta: Double?, + ) + + fun exact(value: String): String = + Normalizer.normalize(value, Normalizer.Form.NFKC) + .replace(Regex("\\s+"), " ") + .trim() + .lowercase(java.util.Locale.ROOT) + + fun relaxed(value: String): String = + Normalizer.normalize(value, Normalizer.Form.NFKD) + .replace(Regex("\\p{M}+"), "") + .lowercase(java.util.Locale.ROOT) + .replace(Regex("[^\\p{L}\\p{N}]+"), " ") + .replace(Regex("\\s+"), " ") + .trim() + + val wantedTitle = exact(title) + val wantedArtist = exact(artist) + if (wantedTitle.isBlank() || wantedArtist.isBlank()) { + return@withCatalogRecovery mapOf("kind" to "none", "candidates" to emptyList()) + } + val wantedDuration = durationSeconds?.takeIf { it.isFinite() && it > 0 } + val exactMatches = mutableListOf() + val relaxedMatches = mutableListOf() + for (track in database.catalogDao().getAllActiveTracksForNativeMatching()) { + val trackDuration = track.duration.takeIf { it.isFinite() && it > 0 } + val delta = if (wantedDuration != null && trackDuration != null) { + kotlin.math.abs(wantedDuration - trackDuration) + } else { + null + } + if (exact(track.title) == wantedTitle && exact(track.artist) == wantedArtist) { + if (wantedDuration != null && trackDuration == null) continue + if (delta != null && delta > 3.0) continue + exactMatches += Candidate(track, "exact", delta) + } else if ( + wantedDuration != null && + trackDuration != null && + delta != null && + delta <= 2.0 && + relaxed(track.title) == relaxed(title) && + relaxed(track.artist) == relaxed(artist) + ) { + relaxedMatches += Candidate(track, "normalized", delta) + } + } + val matches = (if (exactMatches.isNotEmpty()) exactMatches else relaxedMatches) + .sortedWith(compareBy({ it.delta ?: Double.POSITIVE_INFINITY }, { it.track.path })) + .take(20) + mapOf( + "kind" to when (matches.size) { + 0 -> "none" + 1 -> "match" + else -> "ambiguous" + }, + "candidates" to matches.map { candidate -> + mapOf( + "track" to candidate.track.toBridgeMap(), + "match" to candidate.match, + "durationDeltaSec" to candidate.delta, + ) + }, + ) + } + + private fun compileFtsQuery(query: String): String = + query + .trim() + .split(Regex("\\s+")) + .filter(String::isNotBlank) + .joinToString(" AND ") { "\"${it.replace("\"", "\"\"")}\"*" } + + private fun useLiteralSearch(query: String): Boolean = + query.isNotBlank() && query.none(Character::isLetterOrDigit) + + private fun literalSearchPattern(query: String): String = + "%${query.trim().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")}%" + + /** + * unicode61 tokenization is fast for word-oriented scripts, but a prefix query + * can legitimately miss a CJK substring or punctuation-heavy title. Keep FTS + * as the primary path and fall back to an escaped, bounded literal LIKE query + * when token search has no match, without ever hydrating the catalog. + */ + private suspend fun searchTrackRows( + dao: CatalogDao, + query: String, + fts: String, + limit: Int, + ): List { + val pattern = literalSearchPattern(query) + if (useLiteralSearch(query)) return dao.searchTracksLiteral(pattern, limit) + return dao.searchTracks(fts, limit).ifEmpty { + dao.searchTracksLiteral(pattern, limit) + } + } + + private fun normalizeArtistKey(value: String): String = + value.replace(Regex("\\s+"), " ").trim().lowercase(java.util.Locale.ROOT) + + suspend fun getSectionAnchors( + kind: String, + sort: String, + includeSingles: Boolean, + groupingMode: String, + includeCollaborations: Boolean, + ): List> = + withCatalogRecovery { database -> + val dao = database.catalogDao() + val revision = dao.getRevision() + val anchors: List> = when (kind) { + "albums" -> { + val rows = dao.getAllAlbumSummaries(revision).filter { includeSingles || !it.isSingle } + rows.groupBy { row -> + if (sort == "artist") SortKeys.sectionLabel(row.artist) else row.sectionLabel + }.map { (label, section) -> + if (sort == "artist") { + val first = section.minWith(compareBy({ it.artistSortKey }, { it.nameSortKey }, { it.identityKey })) + label to TrackPageCursor( + revision, + "albums:artist:${if (includeSingles) 1 else 0}", + text1 = first.artistSortKey, + ) + } else { + val first = section.minWith(compareBy({ it.nameSortKey }, { it.identityKey })) + label to TrackPageCursor( + revision, + "albums:name:${if (includeSingles) 1 else 0}", + text1 = first.nameSortKey, + ) + } + } + } + "artists" -> { + val mode = if (groupingMode == "fileTags") "fileTags" else "astra" + dao.getAllArtistSummaries(revision, mode) + .filter { includeCollaborations || !it.isCollaboration } + .groupBy(ArtistSummaryEntity::sectionLabel) + .map { (label, section) -> + val first = section.minWith(compareBy({ it.nameSortKey }, { it.artistKey })) + label to TrackPageCursor( + revision, + "artists:name:$mode:${if (includeCollaborations) 1 else 0}", + text1 = first.nameSortKey, + ) + } + } + else -> { + dao.getAllActiveTracksForNativeMatching() + .groupBy { row -> + if (sort == "artist") SortKeys.sectionLabel(row.artist) else row.sectionLabel + } + .map { (label, section) -> + val first = if (sort == "artist") { + section.minWith( + compareBy( + { it.artistSortKey }, + { it.albumSortKey }, + { it.discSort }, + { it.trackSort }, + { it.titleSortKey }, + { it.path }, + ), + ) + } else { + section.minWith(compareBy({ it.titleSortKey }, { it.path })) + } + label to if (sort == "artist") { + TrackPageCursor(revision, "tracks:artist", text1 = first.artistSortKey) + } else { + TrackPageCursor(revision, "tracks:title", text1 = first.titleSortKey) + } + } + } + } + anchors.sortedWith(compareBy> { it.second.text1 }.thenBy { it.first }) + .map { (label, cursor) -> + mapOf( + "label" to label, + "cursor" to cursor.encode(), + ) + } + } + + private suspend fun filterAvailablePaths( + paths: List, + catalogDao: CatalogDao, + ): List { + if (paths.isEmpty()) return emptyList() + val available = HashSet(paths.size) + for (chunk in paths.distinct().chunked(MAX_PAGE_SIZE)) { + catalogDao.getActiveTracks(chunk).mapTo(available, ActiveTrackView::path) + } + return paths.filter(available::contains) + } + + private suspend fun resolvePlaybackPaths( + context: Map, + catalogDao: CatalogDao, + userDao: UserDao, + ): List = when (context["kind"] as? String ?: "library") { + "album" -> (context["albumKey"] as? String)?.let { catalogDao.getAlbumPaths(it) }.orEmpty() + "artist" -> { + val artistKey = (context["artistKey"] as? String)?.let(::normalizeArtistKey) + if (artistKey == null) { + emptyList() + } else { + catalogDao.getArtistPaths( + revision = catalogDao.getRevision(), + groupingMode = if (context["groupingMode"] == "fileTags") "fileTags" else "astra", + artistKey = artistKey, + section = when (context["section"]) { + "songs" -> "song" + "appearances" -> "appearance" + else -> "all" + }, + ) + } + } + "folder" -> when (val folderId = context["folderId"] as? Number) { + null -> (context["folderNodeId"] as? String) + ?.let { folderSubtreePaths(catalogDao, it) } + .orEmpty() + else -> catalogDao.getFolderPaths(folderId.toLong()) + } + "playlist", "dynamicPlaylist" -> { + val playlistId = (context["playlistId"] as? Number)?.toLong() + val playlist = playlistId?.let { userDao.getPlaylist(it) } + when { + playlist == null -> emptyList() + playlist.kind == "dynamic" -> { + val total = DynamicPlaylistCompiler + .compile(playlist.dynamicRulesJson, 0, 1) + .let { catalogDao.runDynamicCountQuery(it.count).toInt() } + val result = ArrayList(total) + for (offset in 0 until total step MAX_PAGE_SIZE) { + val query = DynamicPlaylistCompiler.compile( + playlist.dynamicRulesJson, + offset, + minOf(MAX_PAGE_SIZE, total - offset), + ) + result += catalogDao.runDynamicTrackQuery(query.tracks).map(ActiveTrackView::path) + } + result + } + else -> userDao.getPlaylistTracks(playlist.id).map(PlaylistTrackEntity::trackPath) + } + } + "favorites" -> userDao.getFavorites().map(FavoriteEntity::trackPath) + "recent" -> userDao.getPlaybackHistory().map(PlaybackHistoryEntity::trackPath) + "search" -> { + val search = context["query"] as? String ?: "" + val fts = compileFtsQuery(search) + when { + fts.isBlank() -> emptyList() + useLiteralSearch(search) -> catalogDao.searchTrackPathsLiteral(literalSearchPattern(search)) + else -> catalogDao.searchTrackPaths(fts) + } + } + "manual" -> (context["paths"] as? List<*>) + ?.mapNotNull { it as? String } + .orEmpty() + else -> when (context["sort"] as? String) { + "artist" -> catalogDao.getAllPathsByArtist() + "recently_added" -> catalogDao.getAllPathsByRecentlyAdded() + "duration" -> catalogDao.getAllPathsByDuration() + else -> catalogDao.getAllPathsByTitle() + } + } + + private suspend fun playbackWindow( + sessionId: String, + start: Long, + limit: Int, + ): Map { + val userDao = requireUser().userDao() + val session = userDao.getPlaybackSession(sessionId) + ?: error("Playback context $sessionId does not exist") + val total = userDao.countQueueEntries(sessionId) + val boundedStart = if (total == 0L) 0L else start.coerceIn(0, total - 1) + val entries = userDao.getQueueWindow(sessionId, boundedStart, limit) + val tracks = LinkedHashMap() + for (chunk in entries.map(PlaybackQueueEntryEntity::trackPath).distinct().chunked(MAX_PAGE_SIZE)) { + requireCatalog().catalogDao().getActiveTracks(chunk).forEach { tracks[it.path] = it } + } + val items = entries.mapNotNull { entry -> + tracks[entry.trackPath]?.toBridgeMap()?.toMutableMap()?.apply { + this["queuePosition"] = entry.position.toDouble() + } + } + return mapOf( + "sessionId" to session.id, + "items" to items, + "windowStart" to boundedStart.toDouble(), + "activePosition" to session.activePosition.toDouble(), + "totalCount" to total.toDouble(), + "contextJson" to session.contextJson, + "shuffleSeed" to session.shuffleSeed?.toDouble(), + "catalogRevision" to requireCatalog().catalogDao().getRevision().toString(), + ) + } + + suspend fun flushSnapshot() { + initialize() + val database = userDatabase ?: return + snapshotMutex.withLock { + pendingSnapshot?.cancel() + pendingSnapshot = null + snapshots.write(database) + database.userDao().putSnapshotMetadata( + SnapshotMetadataEntity(lastSnapshotAt = System.currentTimeMillis()), + ) + } + } + + suspend fun userDb(): AstraUserDatabase { + initialize() + return requireUser() + } + + suspend fun catalogDb(): AstraCatalogDatabase { + initialize() + return requireCatalog() + } + + internal fun updateOperationalStatus( + status: LibraryStatus, + message: String? = null, + ) { + val previous = currentStatus + updateStatus( + status = status, + revision = previous.catalogRevision, + count = previous.trackCount, + message = message, + recoveryNotice = previous.recoveryNotice, + ) + } + + internal suspend fun refreshReadyStatus(recoveryNotice: String? = currentStatus.recoveryNotice) { + val dao = requireCatalog().catalogDao() + val count = dao.countActiveTracks() + updateStatus( + if (count == 0L) LibraryStatus.EMPTY else LibraryStatus.READY, + dao.getRevision(), + count, + recoveryNotice = recoveryNotice, + ) + } + + private fun validateCursor( + raw: String?, + revision: Long, + kind: String, + ): TrackPageCursor? { + if (raw.isNullOrBlank()) return null + val cursor = TrackPageCursor.decode(raw) ?: throw IllegalArgumentException("INVALID_CURSOR") + if (cursor.revision != revision || cursor.kind != kind) throw StaleRevisionException() + return cursor + } + + private fun cursorPath(cursor: TrackPageCursor): String = + cursor.text3?.substringAfter('\u0000', "") ?: "" + + private fun cursorTitleKey(cursor: TrackPageCursor): String = + cursor.text3?.substringBefore('\u0000') ?: "" + + private fun trackFromMetadata( + generationId: String, + sourceKey: String, + folderId: Long, + file: LocalAudioFile, + metadata: LocalAudioMetadata, + addedAt: Long, + ): TrackEntity { + fun clean(value: String?): String? = MediaTagCleanup.clean(value) + val extension = file.name.substringAfterLast('.', "") + val title = clean(metadata.title) + ?: file.name.removeSuffix(if (extension.isEmpty()) "" else ".$extension") + val artist = clean(metadata.artist) ?: "Unknown Artist" + val album = clean(metadata.album) ?: "Unknown Album" + val albumArtist = clean(metadata.albumArtist) + val provisional = CatalogReadModelBuilder.provisionalIdentity(album, artist, albumArtist) + val now = System.currentTimeMillis() + return TrackEntity( + generationId = generationId, + sourceKey = sourceKey, + path = file.uri, + folderId = folderId, + title = title, + artist = artist, + album = album, + albumArtist = albumArtist, + albumIdentityKey = provisional.first, + albumDisplayArtist = provisional.second, + duration = (metadata.durationMs ?: 0L) / 1_000.0, + trackNumber = metadata.trackNumber, + discNumber = metadata.discNumber, + year = metadata.year, + genre = clean(metadata.genre), + artworkHash = metadata.artworkHash, + format = extension.ifEmpty { "UNKNOWN" }.uppercase(java.util.Locale.ROOT), + sampleRate = metadata.sampleRate, + bitDepth = metadata.bitsPerSample, + bitrate = metadata.bitrate, + channels = metadata.channels, + codec = codecFromMime(metadata.codecMime, metadata.mimeType), + fileName = file.name, + parentUri = file.parentUri, + size = file.size, + mtime = file.lastModified, + addedAt = addedAt, + modifiedAt = now, + titleSortKey = SortKeys.forText(title), + artistSortKey = SortKeys.forText(artist), + albumSortKey = SortKeys.forText(album), + fileNameSortKey = SortKeys.forText(file.name), + discSort = metadata.discNumber ?: 0, + trackSort = metadata.trackNumber ?: 0, + sectionLabel = SortKeys.sectionLabel(title), + ) + } + + private fun codecFromMime(trackMime: String?, containerMime: String?): String? { + val mime = if (trackMime == "audio/raw" && containerMime != null) containerMime else trackMime + return when (mime) { + null -> null + "audio/flac" -> "flac" + "audio/mpeg" -> "mp3" + "audio/mpeg-l2" -> "mp2" + "audio/mp4a-latm", "audio/aac" -> "aac" + "audio/alac" -> "alac" + "audio/opus" -> "opus" + "audio/vorbis" -> "vorbis" + "audio/raw" -> "pcm" + "audio/ac3" -> "ac3" + "audio/eac3" -> "eac3" + else -> mime.removePrefix("audio/") + } + } + + private suspend fun folderSubtreePaths( + dao: CatalogDao, + nodeId: String, + ): List { + val revision = dao.getRevision() + val node = dao.getDirectoryNode(revision, nodeId) ?: return emptyList() + return dao.getActiveTracksForFolder(node.folderId) + .asSequence() + .filter { track -> + val parentPath = track.parentUri?.let(::decodedSafDocumentPath) ?: return@filter false + parentPath == node.directoryPath || parentPath.startsWith("${node.directoryPath}/") + } + .sortedWith(compareBy({ it.fileNameSortKey }, { it.path })) + .map(ActiveTrackView::path) + .toList() + } + + private fun decodedSafDocumentPath(uri: String): String? = runCatching { + android.net.Uri.decode(uri.substringAfter("/document/")).substringAfter(':') + }.getOrNull() + + private fun remoteTrackFromMap( + handle: RemoteSyncHandle, + row: Map, + addedAt: Long, + ): TrackEntity { + fun string(key: String): String? = (row[key] as? String)?.trim()?.takeIf(String::isNotEmpty) + fun int(key: String): Int? = (row[key] as? Number)?.toInt() + fun double(key: String): Double? = (row[key] as? Number)?.toDouble() + val path = requireNotNull(string("path")) + val title = string("title") ?: path.substringAfterLast('/') + val artist = string("artist") ?: "Unknown Artist" + val album = string("album") ?: "Unknown Album" + val albumArtist = string("album_artist") + val provisional = CatalogReadModelBuilder.provisionalIdentity(album, artist, albumArtist) + val now = System.currentTimeMillis() + return TrackEntity( + generationId = handle.generationId, + sourceKey = handle.sourceKey, + path = path, + title = title, + artist = artist, + album = album, + albumArtist = albumArtist, + albumIdentityKey = provisional.first, + albumDisplayArtist = provisional.second, + duration = double("duration") ?: 0.0, + trackNumber = int("track_number"), + discNumber = int("disc_number"), + year = int("year"), + genre = string("genre"), + artworkHash = string("artwork_hash"), + format = string("format") ?: "UNKNOWN", + sampleRate = int("sample_rate"), + bitDepth = int("bit_depth"), + bitrate = int("bitrate"), + channels = int("channels"), + codec = string("codec"), + sourceType = handle.sourceType, + sourceId = handle.sourceId, + sourceTrackId = string("source_track_id"), + sourcePath = string("source_path"), + artworkSourceId = string("artwork_source_id"), + fileName = string("source_path")?.substringAfterLast('/') ?: title, + addedAt = addedAt, + modifiedAt = now, + replayGainTrackDb = double("replaygain_track_gain_db"), + replayGainAlbumDb = double("replaygain_album_gain_db"), + bpm = double("bpm"), + musicalKey = string("musical_key"), + titleSortKey = SortKeys.forText(title), + artistSortKey = SortKeys.forText(artist), + albumSortKey = SortKeys.forText(album), + fileNameSortKey = SortKeys.forText(string("source_path") ?: title), + discSort = int("disc_number") ?: 0, + trackSort = int("track_number") ?: 0, + sectionLabel = SortKeys.sectionLabel(title), + ) + } + + private suspend fun removeCatalogSource(sourceKey: String) { + catalogWriterMutex.withLock { + val catalogDao = requireCatalog().catalogDao() + val source = catalogDao.getSource(sourceKey) ?: return@withLock + val remaining = catalogDao.getActiveTrackEntitiesExcludingSource(sourceKey) + val nextRevision = catalogDao.getRevision() + 1 + val readModels = withContext(Dispatchers.Default) { + CatalogReadModelBuilder.build( + remaining, + nextRevision, + requireUser().userDao().getFolders().associateBy(FolderEntity::id), + ) + } + val revision = catalogDao.removeSourceAndPublish( + sourceKey = sourceKey, + generationId = source.activeGenerationId, + now = System.currentTimeMillis(), + albumIdentityUpdates = readModels.identityUpdates, + albums = readModels.albums, + artists = readModels.artists, + artistTrackIndex = readModels.artistTrackIndex, + directories = readModels.directories, + ftsRows = readModels.ftsRows, + ) + for (listener in catalogListeners) listener(revision) + } + refreshReadyStatus() + } + + private suspend fun performLegacyCutoverIfNeeded(): String? { + val preferences = applicationContext.getSharedPreferences(CUTOVER_PREFS, Context.MODE_PRIVATE) + if (preferences.getBoolean(CUTOVER_COMPLETE, false)) return null + + val hadLegacyDatabase = applicationContext.getDatabasePath(LEGACY_DB_NAME).exists() + applicationContext.deleteDatabase(LEGACY_DB_NAME) + applicationContext.deleteDatabase(USER_DB_NAME) + applicationContext.deleteDatabase(CATALOG_DB_NAME) + for (permission in applicationContext.contentResolver.persistedUriPermissions) { + runCatching { + var flags = 0 + if (permission.isReadPermission) { + flags = flags or android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION + } + if (permission.isWritePermission) { + flags = flags or android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION + } + applicationContext.contentResolver.releasePersistableUriPermission( + permission.uri, + flags, + ) + } + } + preferences.edit().putBoolean(CUTOVER_COMPLETE, true).commit() + return if (hadLegacyDatabase) { + "Astra's library engine was upgraded. Select your music folders to build the new library." + } else { + null + } + } + + private suspend fun openUserDatabaseWithRecovery(): AstraUserDatabase? { + return try { + buildUserDatabase().also(::forceOpen) + } catch (error: Throwable) { + if (!isCorruption(error)) throw error + quarantineDatabase(USER_DB_NAME, "user") + val snapshot = snapshots.newestValid() ?: return null + runCatching { + buildUserDatabase().also { database -> + forceOpen(database) + snapshots.restore(database, snapshot) + } + }.getOrNull() + } + } + + private fun openCatalogDatabaseWithRecovery(): AstraCatalogDatabase { + return try { + buildCatalogDatabase().also(::forceOpen) + } catch (error: Throwable) { + if (!isCorruption(error)) throw error + quarantineDatabase(CATALOG_DB_NAME, "catalog") + catalogRecoveredAtBootstrap = true + buildCatalogDatabase().also(::forceOpen) + } + } + + private fun buildUserDatabase(): AstraUserDatabase = + Room.databaseBuilder(applicationContext, AstraUserDatabase::class.java, USER_DB_NAME) + .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) + .build() + + private fun buildCatalogDatabase(): AstraCatalogDatabase = + Room.databaseBuilder(applicationContext, AstraCatalogDatabase::class.java, CATALOG_DB_NAME) + .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) + .fallbackToDestructiveMigration(true) + .build() + + private fun forceOpen(database: RoomDatabase) { + database.openHelper.writableDatabase + } + + private suspend fun withCatalogRecovery( + block: suspend (AstraCatalogDatabase) -> T, + ): T { + initialize() + val database = requireCatalog() + return try { + block(database) + } catch (error: Throwable) { + if (!isCorruption(error)) throw error + catalogRecoveryMutex.withLock { + if (catalogDatabase === database) { + database.close() + quarantineDatabase(CATALOG_DB_NAME, "catalog") + val replacement = buildCatalogDatabase() + forceOpen(replacement) + replacement.catalogDao().insertMeta( + CatalogMetaEntity( + collationVersion = COLLATION_VERSION, + updatedAt = System.currentTimeMillis(), + ), + ) + catalogDatabase = replacement + updateStatus( + LibraryStatus.REBUILDING, + 0, + 0, + message = "The catalog was damaged and is being rebuilt.", + recoveryNotice = currentStatus.recoveryNotice, + ) + } + } + block(requireCatalog()) + } + } + + private suspend fun reconcileUserFacts() { + val userDao = requireUser().userDao() + val facts = LinkedHashMap() + for (favorite in userDao.getFavorites()) { + facts[favorite.trackPath] = TrackUserFactEntity( + path = favorite.trackPath, + isFavorite = true, + ) + } + for (history in userDao.getPlaybackHistory()) { + val current = facts[history.trackPath] + facts[history.trackPath] = TrackUserFactEntity( + path = history.trackPath, + isFavorite = current?.isFavorite ?: false, + playCount = history.playCount, + lastPlayedAt = history.lastPlayedAt, + ) + } + val dao = requireCatalog().catalogDao() + dao.clearTrackUserFacts() + if (facts.isNotEmpty()) dao.putTrackUserFacts(facts.values.toList()) + } + + private fun scheduleSnapshot() { + pendingSnapshot?.cancel() + pendingSnapshot = scope.launch { + delay(SNAPSHOT_DEBOUNCE_MS) + runCatching { flushSnapshot() } + } + } + + private fun updateStatus( + status: LibraryStatus, + revision: Long, + count: Long, + message: String? = null, + recoveryNotice: String? = null, + ) { + val snapshot = LibraryStatusSnapshot( + status = status, + catalogRevision = revision, + trackCount = count, + message = message, + recoveryNotice = recoveryNotice, + ) + currentStatus = snapshot + for (listener in listeners) listener(snapshot) + } + + private fun requireUser(): AstraUserDatabase = + userDatabase ?: error("Astra user database is unavailable") + + private fun requireCatalog(): AstraCatalogDatabase = + catalogDatabase ?: error("Astra catalog database is unavailable") + + private fun quarantineDatabase(name: String, kind: String) { + val quarantine = File( + applicationContext.filesDir, + "database-quarantine/${System.currentTimeMillis()}-${kind}-${UUID.randomUUID()}", + ) + quarantine.mkdirs() + for (suffix in listOf("", "-wal", "-shm")) { + val source = File(applicationContext.getDatabasePath(name).path + suffix) + if (!source.exists()) continue + val target = File(quarantine, source.name) + if (!source.renameTo(target)) { + runCatching { + source.copyTo(target, overwrite = true) + source.delete() + } + } + } + } + + private fun isCorruption(error: Throwable): Boolean { + var current: Throwable? = error + while (current != null) { + if (current is SQLiteDatabaseCorruptException) return true + if (current is SQLiteException && current.message?.contains("malformed", ignoreCase = true) == true) { + return true + } + current = current.cause + } + return false + } + + companion object { + @Volatile + private var instance: AstraLibraryRepository? = null + + fun get(context: Context): AstraLibraryRepository = + instance ?: synchronized(this) { + instance ?: AstraLibraryRepository(context).also { instance = it } + } + + fun localSourceKey(folderId: Long): String = "local:$folderId" + + fun remoteSourceKey(type: String, sourceId: Long): String = "$type:$sourceId" + } +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogDatabase.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogDatabase.kt new file mode 100644 index 0000000..d69ed66 --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogDatabase.kt @@ -0,0 +1,1124 @@ +package expo.modules.astralibraryscanner.data + +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.RawQuery +import androidx.room.RoomDatabase +import androidx.room.Transaction +import androidx.room.Upsert +import androidx.sqlite.db.SupportSQLiteQuery + +data class TrackSyncRow( + val path: String, + val size: Long?, + val mtime: Long, +) + +data class SectionAnchorRow( + @androidx.room.ColumnInfo(name = "section_label") val sectionLabel: String, + @androidx.room.ColumnInfo(name = "sort_key") val sortKey: String, +) + +data class LibraryLoudnessStatsRow( + val lufsCount: Long, + val medianLufs: Double?, + val rgCount: Long, + val medianRgTrackDb: Double?, +) + +@Dao +interface CatalogDao { + @Query("SELECT * FROM catalog_meta WHERE id = 1") + suspend fun getMeta(): CatalogMetaEntity? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertMeta(meta: CatalogMetaEntity): Long + + @Query( + """ + UPDATE catalog_meta + SET revision = revision + 1, + updated_at = :updatedAt + WHERE id = 1 + """, + ) + suspend fun incrementRevision(updatedAt: Long) + + @Query("SELECT revision FROM catalog_meta WHERE id = 1") + suspend fun getRevision(): Long + + @Query("SELECT * FROM catalog_sources WHERE source_key = :sourceKey") + suspend fun getSource(sourceKey: String): CatalogSourceEntity? + + @Query("SELECT * FROM catalog_sources ORDER BY source_key") + suspend fun getSources(): List + + @Query("DELETE FROM catalog_sources WHERE source_key = :sourceKey") + suspend fun deleteSource(sourceKey: String) + + @Upsert + suspend fun putSource(source: CatalogSourceEntity) + + @Query( + """ + UPDATE catalog_sources + SET active_generation_id = :generationId, + updated_at = :updatedAt + WHERE source_key = :sourceKey + """, + ) + suspend fun setActiveGeneration( + sourceKey: String, + generationId: String, + updatedAt: Long, + ) + + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insertGeneration(generation: ScanGenerationEntity) + + @Query("SELECT * FROM scan_generations WHERE id = :id") + suspend fun getGeneration(id: String): ScanGenerationEntity? + + @Query( + """ + UPDATE scan_generations + SET state = :state, + finished_at = :finishedAt, + error_message = :errorMessage + WHERE id = :id + """, + ) + suspend fun setGenerationState( + id: String, + state: String, + finishedAt: Long?, + errorMessage: String?, + ) + + @Query("DELETE FROM scan_generations WHERE state = 'staging'") + suspend fun deleteAbandonedGenerationRecords() + + @Query( + """ + DELETE FROM tracks + WHERE generation_id IN ( + SELECT id FROM scan_generations WHERE state = 'staging' + ) + """, + ) + suspend fun deleteAbandonedGenerationTracks() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun putTracks(tracks: List): List + + @Query( + """ + SELECT t.* + FROM tracks t + INNER JOIN catalog_sources s ON s.source_key = t.source_key + WHERE t.source_key = :sourceKey + AND t.generation_id = s.active_generation_id + """, + ) + suspend fun getActiveTrackEntitiesForSource(sourceKey: String): List + + @Query( + """ + SELECT t.* + FROM tracks t + INNER JOIN catalog_sources s ON s.source_key = t.source_key + WHERE (t.source_key = :sourceKey AND t.generation_id = :pendingGenerationId) + OR (t.source_key <> :sourceKey AND t.generation_id = s.active_generation_id) + """, + ) + suspend fun getProspectiveTracks( + sourceKey: String, + pendingGenerationId: String, + ): List + + @Query( + """ + SELECT t.* + FROM tracks t + INNER JOIN catalog_sources s ON s.source_key = t.source_key + WHERE t.source_key <> :excludedSourceKey + AND t.generation_id = s.active_generation_id + """, + ) + suspend fun getActiveTrackEntitiesExcludingSource(excludedSourceKey: String): List + + @Query( + """ + UPDATE tracks + SET album_identity_key = :identityKey, + album_display_artist = :displayArtist + WHERE id = :trackId + """, + ) + suspend fun updateAlbumIdentity( + trackId: Long, + identityKey: String, + displayArtist: String, + ) + + @Query("DELETE FROM tracks WHERE generation_id = :generationId") + suspend fun deleteGenerationTracks(generationId: String) + + @Query("DELETE FROM scan_generations WHERE id = :generationId") + suspend fun deleteGeneration(generationId: String) + + @Query("SELECT COUNT(*) FROM active_tracks") + suspend fun countActiveTracks(): Long + + @Query("SELECT COUNT(*) FROM active_tracks WHERE folder_id = :folderId") + suspend fun countActiveTracksForFolder(folderId: Long): Long + + @Query("SELECT * FROM active_tracks WHERE path = :path LIMIT 1") + suspend fun getActiveTrack(path: String): ActiveTrackView? + + @Query("SELECT * FROM active_tracks WHERE path IN (:paths)") + suspend fun getActiveTracks(paths: List): List + + @Query("SELECT * FROM active_tracks ORDER BY path") + suspend fun getAllActiveTracksForNativeMatching(): List + + @Query("SELECT path FROM active_tracks ORDER BY title_sort_key, path") + suspend fun getAllPathsByTitle(): List + + @Query( + """ + SELECT path FROM active_tracks + ORDER BY artist_sort_key, album_sort_key, disc_sort, track_sort, title_sort_key, path + """, + ) + suspend fun getAllPathsByArtist(): List + + @Query("SELECT path FROM active_tracks ORDER BY added_at DESC, path") + suspend fun getAllPathsByRecentlyAdded(): List + + @Query("SELECT path FROM active_tracks ORDER BY duration DESC, path") + suspend fun getAllPathsByDuration(): List + + @Query( + """ + SELECT path FROM active_tracks + WHERE album_identity_key = :albumKey + ORDER BY disc_sort, track_sort, title_sort_key, path + """, + ) + suspend fun getAlbumPaths(albumKey: String): List + + @Query( + """ + SELECT t.path + FROM active_tracks t + INNER JOIN artist_track_index i ON i.track_id = t.id + WHERE i.revision = :revision + AND i.grouping_mode = :groupingMode + AND i.artist_key = :artistKey + AND (:section = 'all' OR i.relationship = :section) + ORDER BY t.album_sort_key, t.disc_sort, t.track_sort, t.title_sort_key, t.path + """, + ) + suspend fun getArtistPaths( + revision: Long, + groupingMode: String, + artistKey: String, + section: String, + ): List + + @Query( + """ + SELECT path FROM active_tracks + WHERE folder_id = :folderId + ORDER BY parent_uri, file_name_sort_key, path + """, + ) + suspend fun getFolderPaths(folderId: Long): List + + @Query("SELECT * FROM active_tracks WHERE folder_id = :folderId") + suspend fun getActiveTracksForFolder(folderId: Long): List + + @Query( + """ + UPDATE tracks + SET loudness_lufs = :lufs, + sample_peak = :samplePeak + WHERE path = :path + AND generation_id = ( + SELECT active_generation_id + FROM catalog_sources + WHERE source_key = tracks.source_key + ) + """, + ) + suspend fun updateActiveTrackLoudness( + path: String, + lufs: Double?, + samplePeak: Double?, + ) + + @Query( + """ + UPDATE tracks + SET replay_gain_track_db = :trackGainDb, + replay_gain_album_db = :albumGainDb, + replay_gain_track_peak = :trackPeak, + replay_gain_album_peak = :albumPeak, + rg_scanned = 1 + WHERE path = :path + AND generation_id = ( + SELECT active_generation_id + FROM catalog_sources + WHERE source_key = tracks.source_key + ) + """, + ) + suspend fun updateActiveTrackReplayGain( + path: String, + trackGainDb: Double?, + albumGainDb: Double?, + trackPeak: Double?, + albumPeak: Double?, + ) + + @Query( + """ + SELECT + (SELECT COUNT(*) FROM active_tracks WHERE loudness_lufs IS NOT NULL) AS lufsCount, + (SELECT loudness_lufs + FROM active_tracks + WHERE loudness_lufs IS NOT NULL + ORDER BY loudness_lufs + LIMIT 1 + OFFSET ( + SELECT MAX((COUNT(*) - 1) / 2, 0) + FROM active_tracks + WHERE loudness_lufs IS NOT NULL + )) AS medianLufs, + (SELECT COUNT(*) FROM active_tracks WHERE replay_gain_track_db IS NOT NULL) AS rgCount, + (SELECT replay_gain_track_db + FROM active_tracks + WHERE replay_gain_track_db IS NOT NULL + ORDER BY replay_gain_track_db + LIMIT 1 + OFFSET ( + SELECT MAX((COUNT(*) - 1) / 2, 0) + FROM active_tracks + WHERE replay_gain_track_db IS NOT NULL + )) AS medianRgTrackDb + """, + ) + suspend fun getLibraryLoudnessStats(): LibraryLoudnessStatsRow + + @Query( + """ + SELECT path, size, mtime + FROM active_tracks + WHERE folder_id = :folderId + """, + ) + suspend fun getFolderSyncRows(folderId: Long): List + + @Query( + """ + SELECT * FROM active_tracks + WHERE (:afterTitleKey IS NULL + OR title_sort_key > :afterTitleKey + OR (title_sort_key = :afterTitleKey AND path > :afterPath)) + ORDER BY title_sort_key, path + LIMIT :limit + """, + ) + suspend fun getTitlePage( + afterTitleKey: String?, + afterPath: String, + limit: Int, + ): List + + @Query( + """ + SELECT * FROM active_tracks + WHERE (:afterArtistKey IS NULL + OR artist_sort_key > :afterArtistKey + OR (artist_sort_key = :afterArtistKey AND album_sort_key > :afterAlbumKey) + OR (artist_sort_key = :afterArtistKey AND album_sort_key = :afterAlbumKey AND disc_sort > :afterDisc) + OR (artist_sort_key = :afterArtistKey AND album_sort_key = :afterAlbumKey AND disc_sort = :afterDisc + AND track_sort > :afterTrack) + OR (artist_sort_key = :afterArtistKey AND album_sort_key = :afterAlbumKey AND disc_sort = :afterDisc + AND track_sort = :afterTrack AND title_sort_key > :afterTitleKey) + OR (artist_sort_key = :afterArtistKey AND album_sort_key = :afterAlbumKey AND disc_sort = :afterDisc + AND track_sort = :afterTrack AND title_sort_key = :afterTitleKey AND path > :afterPath)) + ORDER BY artist_sort_key, album_sort_key, disc_sort, track_sort, title_sort_key, path + LIMIT :limit + """, + ) + suspend fun getArtistOrderPage( + afterArtistKey: String?, + afterAlbumKey: String, + afterDisc: Int, + afterTrack: Int, + afterTitleKey: String, + afterPath: String, + limit: Int, + ): List + + @Query( + """ + SELECT * FROM active_tracks + WHERE (:afterAddedAt IS NULL + OR added_at < :afterAddedAt + OR (added_at = :afterAddedAt AND path > :afterPath)) + ORDER BY added_at DESC, path + LIMIT :limit + """, + ) + suspend fun getRecentlyAddedPage( + afterAddedAt: Long?, + afterPath: String, + limit: Int, + ): List + + @Query( + """ + SELECT * FROM active_tracks + WHERE (:afterDuration IS NULL + OR duration < :afterDuration + OR (duration = :afterDuration AND path > :afterPath)) + ORDER BY duration DESC, path + LIMIT :limit + """, + ) + suspend fun getDurationPage( + afterDuration: Double?, + afterPath: String, + limit: Int, + ): List + + @Query( + """ + SELECT * FROM active_tracks + WHERE album_identity_key = :albumKey + AND (:afterDisc IS NULL + OR disc_sort > :afterDisc + OR (disc_sort = :afterDisc AND track_sort > :afterTrack) + OR (disc_sort = :afterDisc AND track_sort = :afterTrack AND title_sort_key > :afterTitleKey) + OR (disc_sort = :afterDisc AND track_sort = :afterTrack AND title_sort_key = :afterTitleKey + AND path > :afterPath)) + ORDER BY disc_sort, track_sort, title_sort_key, path + LIMIT :limit + """, + ) + suspend fun getAlbumTrackPage( + albumKey: String, + afterDisc: Int?, + afterTrack: Int, + afterTitleKey: String, + afterPath: String, + limit: Int, + ): List + + @Query("SELECT COUNT(*) FROM active_tracks WHERE album_identity_key = :albumKey") + suspend fun countAlbumTracks(albumKey: String): Long + + @Query( + """ + SELECT * FROM active_tracks + WHERE album_identity_key = :albumKey + ORDER BY disc_sort, track_sort, title_sort_key, path + """, + ) + suspend fun getAlbumTracks(albumKey: String): List + + @Query( + """ + SELECT * FROM active_tracks + WHERE artist = :artist + AND (:afterAlbumKey IS NULL + OR album_sort_key > :afterAlbumKey + OR (album_sort_key = :afterAlbumKey AND disc_sort > :afterDisc) + OR (album_sort_key = :afterAlbumKey AND disc_sort = :afterDisc AND track_sort > :afterTrack) + OR (album_sort_key = :afterAlbumKey AND disc_sort = :afterDisc AND track_sort = :afterTrack + AND title_sort_key > :afterTitleKey) + OR (album_sort_key = :afterAlbumKey AND disc_sort = :afterDisc AND track_sort = :afterTrack + AND title_sort_key = :afterTitleKey AND path > :afterPath)) + ORDER BY album_sort_key, disc_sort, track_sort, title_sort_key, path + LIMIT :limit + """, + ) + suspend fun getExactArtistTrackPage( + artist: String, + afterAlbumKey: String?, + afterDisc: Int, + afterTrack: Int, + afterTitleKey: String, + afterPath: String, + limit: Int, + ): List + + @Query( + """ + SELECT section_label, MIN(title_sort_key) AS sort_key + FROM active_tracks + GROUP BY section_label + ORDER BY sort_key + """, + ) + suspend fun getTitleSectionAnchors(): List + + @Query( + """ + SELECT section_label, MIN(artist_sort_key) AS sort_key + FROM active_tracks + GROUP BY section_label + ORDER BY sort_key + """, + ) + suspend fun getArtistSectionAnchors(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun putAlbumSummaries(rows: List) + + @Query("DELETE FROM album_summaries WHERE revision <> :revision") + suspend fun deleteOldAlbumSummaries(revision: Long) + + @Query( + """ + SELECT * FROM album_summaries + WHERE revision = :revision + AND (:includeSingles OR is_single = 0) + AND (:afterKey IS NULL + OR name_sort_key > :afterKey + OR (name_sort_key = :afterKey AND identity_key > :afterId)) + ORDER BY name_sort_key, identity_key + LIMIT :limit + """, + ) + suspend fun getAlbumNamePage( + revision: Long, + includeSingles: Boolean, + afterKey: String?, + afterId: String, + limit: Int, + ): List + + @Query( + """ + SELECT * FROM album_summaries + WHERE revision = :revision + AND (:includeSingles OR is_single = 0) + AND (:afterArtistKey IS NULL + OR artist_sort_key > :afterArtistKey + OR (artist_sort_key = :afterArtistKey AND name_sort_key > :afterNameKey) + OR (artist_sort_key = :afterArtistKey AND name_sort_key = :afterNameKey + AND identity_key > :afterId)) + ORDER BY artist_sort_key, name_sort_key, identity_key + LIMIT :limit + """, + ) + suspend fun getAlbumArtistPage( + revision: Long, + includeSingles: Boolean, + afterArtistKey: String?, + afterNameKey: String, + afterId: String, + limit: Int, + ): List + + @Query( + """ + SELECT * FROM album_summaries + WHERE revision = :revision + AND (:includeSingles OR is_single = 0) + AND (:afterAddedAt IS NULL + OR latest_added_at < :afterAddedAt + OR (latest_added_at = :afterAddedAt AND identity_key > :afterId)) + ORDER BY latest_added_at DESC, identity_key + LIMIT :limit + """, + ) + suspend fun getAlbumRecentPage( + revision: Long, + includeSingles: Boolean, + afterAddedAt: Long?, + afterId: String, + limit: Int, + ): List + + @Query( + """ + SELECT * FROM album_summaries + WHERE revision = :revision + AND (:includeSingles OR is_single = 0) + AND (:afterYear IS NULL + OR COALESCE(year, 0) < :afterYear + OR (COALESCE(year, 0) = :afterYear AND name_sort_key > :afterNameKey) + OR (COALESCE(year, 0) = :afterYear AND name_sort_key = :afterNameKey + AND identity_key > :afterId)) + ORDER BY COALESCE(year, 0) DESC, name_sort_key, identity_key + LIMIT :limit + """, + ) + suspend fun getAlbumYearPage( + revision: Long, + includeSingles: Boolean, + afterYear: Int?, + afterNameKey: String, + afterId: String, + limit: Int, + ): List + + @Query("SELECT * FROM album_summaries WHERE revision = :revision AND identity_key = :identityKey") + suspend fun getAlbumSummary(revision: Long, identityKey: String): AlbumSummaryEntity? + + @Query("SELECT COUNT(*) FROM album_summaries WHERE revision = :revision AND (:includeSingles OR is_single = 0)") + suspend fun countAlbums(revision: Long, includeSingles: Boolean): Long + + @Query( + """ + SELECT * FROM album_summaries + WHERE revision = :revision + ORDER BY artist_sort_key, name_sort_key, identity_key + """, + ) + suspend fun getAllAlbumSummaries(revision: Long): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun putArtistSummaries(rows: List) + + @Query("DELETE FROM artist_summaries WHERE revision <> :revision") + suspend fun deleteOldArtistSummaries(revision: Long) + + @Query( + """ + SELECT * FROM artist_summaries + WHERE revision = :revision + AND grouping_mode = :groupingMode + AND (:includeCollaborations OR is_collaboration = 0) + AND (:afterKey IS NULL + OR name_sort_key > :afterKey + OR (name_sort_key = :afterKey AND artist_key > :afterId)) + ORDER BY name_sort_key, artist_key + LIMIT :limit + """, + ) + suspend fun getArtistNamePage( + revision: Long, + groupingMode: String, + includeCollaborations: Boolean, + afterKey: String?, + afterId: String, + limit: Int, + ): List + + @Query( + """ + SELECT * FROM artist_summaries + WHERE revision = :revision + AND grouping_mode = :groupingMode + AND (:includeCollaborations OR is_collaboration = 0) + AND (:afterCount IS NULL + OR track_count < :afterCount + OR (track_count = :afterCount AND name_sort_key > :afterNameKey) + OR (track_count = :afterCount AND name_sort_key = :afterNameKey AND artist_key > :afterId)) + ORDER BY track_count DESC, name_sort_key, artist_key + LIMIT :limit + """, + ) + suspend fun getArtistCountPage( + revision: Long, + groupingMode: String, + includeCollaborations: Boolean, + afterCount: Long?, + afterNameKey: String, + afterId: String, + limit: Int, + ): List + + @Query( + """ + SELECT * FROM artist_summaries + WHERE revision = :revision + AND grouping_mode = :groupingMode + AND artist_key = :artistKey + """, + ) + suspend fun getArtistSummary( + revision: Long, + groupingMode: String, + artistKey: String, + ): ArtistSummaryEntity? + + @Query( + """ + SELECT COUNT(*) FROM artist_summaries + WHERE revision = :revision + AND grouping_mode = :groupingMode + AND (:includeCollaborations OR is_collaboration = 0) + """, + ) + suspend fun countArtists( + revision: Long, + groupingMode: String, + includeCollaborations: Boolean, + ): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun putArtistTrackIndex(rows: List) + + @Query("DELETE FROM artist_track_index WHERE revision <> :revision") + suspend fun deleteOldArtistTrackIndex(revision: Long) + + @Query( + """ + SELECT t.* FROM active_tracks t + INNER JOIN artist_track_index i ON i.track_id = t.id + WHERE i.revision = :revision + AND i.grouping_mode = :groupingMode + AND i.artist_key = :artistKey + AND (:section = 'all' OR i.relationship = :section) + AND (:afterAlbumKey IS NULL + OR t.album_sort_key > :afterAlbumKey + OR (t.album_sort_key = :afterAlbumKey AND t.disc_sort > :afterDisc) + OR (t.album_sort_key = :afterAlbumKey AND t.disc_sort = :afterDisc AND t.track_sort > :afterTrack) + OR (t.album_sort_key = :afterAlbumKey AND t.disc_sort = :afterDisc AND t.track_sort = :afterTrack + AND t.title_sort_key > :afterTitleKey) + OR (t.album_sort_key = :afterAlbumKey AND t.disc_sort = :afterDisc AND t.track_sort = :afterTrack + AND t.title_sort_key = :afterTitleKey AND t.path > :afterPath)) + ORDER BY t.album_sort_key, t.disc_sort, t.track_sort, t.title_sort_key, t.path + LIMIT :limit + """, + ) + suspend fun getArtistTrackPage( + revision: Long, + groupingMode: String, + artistKey: String, + section: String, + afterAlbumKey: String?, + afterDisc: Int, + afterTrack: Int, + afterTitleKey: String, + afterPath: String, + limit: Int, + ): List + + @Query( + """ + SELECT COUNT(*) FROM artist_track_index + WHERE revision = :revision + AND grouping_mode = :groupingMode + AND artist_key = :artistKey + AND (:section = 'all' OR relationship = :section) + """, + ) + suspend fun countArtistTracks( + revision: Long, + groupingMode: String, + artistKey: String, + section: String, + ): Long + + @Query( + """ + SELECT t.* + FROM active_tracks t + INNER JOIN artist_track_index i ON i.track_id = t.id + WHERE i.revision = :revision + AND i.grouping_mode = :groupingMode + AND i.artist_key = :artistKey + ORDER BY t.album_sort_key, t.disc_sort, t.track_sort, t.title_sort_key, t.path + """, + ) + suspend fun getAllArtistTracks( + revision: Long, + groupingMode: String, + artistKey: String, + ): List + + @Query( + """ + SELECT * FROM artist_summaries + WHERE revision = :revision + AND grouping_mode = :groupingMode + ORDER BY name_sort_key, artist_key + """, + ) + suspend fun getAllArtistSummaries( + revision: Long, + groupingMode: String, + ): List + + @Query( + """ + SELECT DISTINCT a.* + FROM album_summaries a + INNER JOIN active_tracks t ON t.album_identity_key = a.identity_key + INNER JOIN artist_track_index i ON i.track_id = t.id + WHERE a.revision = :revision + AND i.revision = :revision + AND i.grouping_mode = :groupingMode + AND i.artist_key = :artistKey + ORDER BY a.latest_added_at DESC, a.name_sort_key, a.identity_key + LIMIT :limit OFFSET :offset + """, + ) + suspend fun getArtistAlbumPage( + revision: Long, + groupingMode: String, + artistKey: String, + offset: Int, + limit: Int, + ): List + + @Query( + """ + SELECT COUNT(DISTINCT t.album_identity_key) + FROM active_tracks t + INNER JOIN artist_track_index i ON i.track_id = t.id + WHERE i.revision = :revision + AND i.grouping_mode = :groupingMode + AND i.artist_key = :artistKey + """, + ) + suspend fun countArtistAlbums( + revision: Long, + groupingMode: String, + artistKey: String, + ): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun putDirectorySummaries(rows: List) + + @Query("DELETE FROM directory_summaries WHERE revision <> :revision") + suspend fun deleteOldDirectorySummaries(revision: Long) + + @Query( + """ + SELECT * FROM directory_summaries + WHERE revision = :revision + AND ((:parentNodeId IS NULL AND parent_node_id IS NULL) OR parent_node_id = :parentNodeId) + ORDER BY name_sort_key, node_id + """, + ) + suspend fun getDirectoryChildren( + revision: Long, + parentNodeId: String?, + ): List + + @Query( + """ + SELECT * FROM directory_summaries + WHERE revision = :revision AND node_id = :nodeId + LIMIT 1 + """, + ) + suspend fun getDirectoryNode(revision: Long, nodeId: String): DirectorySummaryEntity? + + @Query( + """ + SELECT * FROM active_tracks + WHERE parent_uri = :documentUri + ORDER BY file_name_sort_key, path + LIMIT :limit OFFSET :offset + """, + ) + suspend fun getDirectoryTrackPage( + documentUri: String, + offset: Int, + limit: Int, + ): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun putFtsRows(rows: List) + + @Query("DELETE FROM track_fts") + suspend fun clearFts() + + @Query( + """ + SELECT t.* FROM active_tracks t + INNER JOIN track_fts f ON f.rowid = t.id + WHERE track_fts MATCH :query + ORDER BY t.title_sort_key, t.path + LIMIT :limit + """, + ) + suspend fun searchTracks(query: String, limit: Int): List + + @Query( + """ + SELECT * FROM active_tracks + WHERE title LIKE :pattern ESCAPE '\' + OR artist LIKE :pattern ESCAPE '\' + OR album LIKE :pattern ESCAPE '\' + OR file_name LIKE :pattern ESCAPE '\' + ORDER BY title_sort_key, path + LIMIT :limit + """, + ) + suspend fun searchTracksLiteral(pattern: String, limit: Int): List + + @Query( + """ + SELECT DISTINCT a.* + FROM album_summaries a + INNER JOIN active_tracks t ON t.album_identity_key = a.identity_key + INNER JOIN track_fts f ON f.rowid = t.id + WHERE a.revision = :revision + AND (:includeSingles OR a.is_single = 0) + AND track_fts MATCH :query + ORDER BY a.name_sort_key, a.identity_key + LIMIT :limit + """, + ) + suspend fun searchAlbums( + revision: Long, + includeSingles: Boolean, + query: String, + limit: Int, + ): List + + @Query( + """ + SELECT DISTINCT a.* + FROM album_summaries a + INNER JOIN active_tracks t ON t.album_identity_key = a.identity_key + WHERE a.revision = :revision + AND (:includeSingles OR a.is_single = 0) + AND ( + a.album LIKE :pattern ESCAPE '\' + OR a.artist LIKE :pattern ESCAPE '\' + OR t.title LIKE :pattern ESCAPE '\' + OR t.file_name LIKE :pattern ESCAPE '\' + ) + ORDER BY a.name_sort_key, a.identity_key + LIMIT :limit + """, + ) + suspend fun searchAlbumsLiteral( + revision: Long, + includeSingles: Boolean, + pattern: String, + limit: Int, + ): List + + @Query( + """ + SELECT DISTINCT a.* + FROM artist_summaries a + INNER JOIN artist_track_index i + ON i.revision = a.revision + AND i.grouping_mode = a.grouping_mode + AND i.artist_key = a.artist_key + INNER JOIN track_fts f ON f.rowid = i.track_id + WHERE a.revision = :revision + AND a.grouping_mode = :groupingMode + AND (:includeCollaborations OR a.is_collaboration = 0) + AND track_fts MATCH :query + ORDER BY a.name_sort_key, a.artist_key + LIMIT :limit + """, + ) + suspend fun searchArtists( + revision: Long, + groupingMode: String, + includeCollaborations: Boolean, + query: String, + limit: Int, + ): List + + @Query( + """ + SELECT DISTINCT a.* + FROM artist_summaries a + INNER JOIN artist_track_index i + ON i.revision = a.revision + AND i.grouping_mode = a.grouping_mode + AND i.artist_key = a.artist_key + INNER JOIN active_tracks t ON t.id = i.track_id + WHERE a.revision = :revision + AND a.grouping_mode = :groupingMode + AND (:includeCollaborations OR a.is_collaboration = 0) + AND ( + a.artist LIKE :pattern ESCAPE '\' + OR t.title LIKE :pattern ESCAPE '\' + OR t.album LIKE :pattern ESCAPE '\' + OR t.file_name LIKE :pattern ESCAPE '\' + ) + ORDER BY a.name_sort_key, a.artist_key + LIMIT :limit + """, + ) + suspend fun searchArtistsLiteral( + revision: Long, + groupingMode: String, + includeCollaborations: Boolean, + pattern: String, + limit: Int, + ): List + + @Query( + """ + SELECT t.path FROM active_tracks t + INNER JOIN track_fts f ON f.rowid = t.id + WHERE track_fts MATCH :query + ORDER BY t.title_sort_key, t.path + """, + ) + suspend fun searchTrackPaths(query: String): List + + @Query( + """ + SELECT path FROM active_tracks + WHERE title LIKE :pattern ESCAPE '\' + OR artist LIKE :pattern ESCAPE '\' + OR album LIKE :pattern ESCAPE '\' + OR file_name LIKE :pattern ESCAPE '\' + ORDER BY title_sort_key, path + """, + ) + suspend fun searchTrackPathsLiteral(pattern: String): List + + @RawQuery + suspend fun runDynamicTrackQuery(query: SupportSQLiteQuery): List + + @RawQuery + suspend fun runDynamicCountQuery(query: SupportSQLiteQuery): Long + + @Upsert + suspend fun putTrackUserFacts(rows: List) + + @Query("DELETE FROM track_user_facts") + suspend fun clearTrackUserFacts() + + @Query("SELECT * FROM waveform_peaks WHERE track_path = :path") + suspend fun getWaveform(path: String): WaveformPeaksEntity? + + @Upsert + suspend fun putWaveform(row: WaveformPeaksEntity) + + @Query("SELECT COUNT(*) FROM waveform_peaks") + suspend fun countWaveforms(): Long + + @Query("DELETE FROM waveform_peaks") + suspend fun clearWaveforms() + + @Query("SELECT * FROM lyrics_cache WHERE track_path = :path") + suspend fun getLyrics(path: String): LyricsCacheEntity? + + @Upsert + suspend fun putLyrics(row: LyricsCacheEntity) + + @Query("DELETE FROM lyrics_cache WHERE track_path = :path") + suspend fun deleteLyrics(path: String) + + @Query("SELECT COUNT(*) FROM lyrics_cache") + suspend fun countLyrics(): Long + + @Query("DELETE FROM lyrics_cache") + suspend fun clearLyrics() + + @Transaction + suspend fun discardAbandonedGenerations() { + deleteAbandonedGenerationTracks() + deleteAbandonedGenerationRecords() + } + + @Transaction + suspend fun publishGeneration( + sourceKey: String, + generationId: String, + previousGenerationId: String?, + now: Long, + albumIdentityUpdates: List, + albums: List, + artists: List, + artistTrackIndex: List, + directories: List, + ftsRows: List, + ): Long { + for (update in albumIdentityUpdates) { + updateAlbumIdentity(update.trackId, update.identityKey, update.displayArtist) + } + setActiveGeneration(sourceKey, generationId, now) + setGenerationState(generationId, "active", now, null) + incrementRevision(now) + val revision = getRevision() + if (albums.isNotEmpty()) putAlbumSummaries(albums) + if (artists.isNotEmpty()) putArtistSummaries(artists) + if (artistTrackIndex.isNotEmpty()) putArtistTrackIndex(artistTrackIndex) + if (directories.isNotEmpty()) putDirectorySummaries(directories) + clearFts() + if (ftsRows.isNotEmpty()) putFtsRows(ftsRows) + deleteOldAlbumSummaries(revision) + deleteOldArtistSummaries(revision) + deleteOldArtistTrackIndex(revision) + deleteOldDirectorySummaries(revision) + if (previousGenerationId != null && previousGenerationId != generationId) { + deleteGenerationTracks(previousGenerationId) + deleteGeneration(previousGenerationId) + } + return revision + } + + @Transaction + suspend fun removeSourceAndPublish( + sourceKey: String, + generationId: String?, + now: Long, + albumIdentityUpdates: List, + albums: List, + artists: List, + artistTrackIndex: List, + directories: List, + ftsRows: List, + ): Long { + for (update in albumIdentityUpdates) { + updateAlbumIdentity(update.trackId, update.identityKey, update.displayArtist) + } + deleteSource(sourceKey) + if (generationId != null) { + deleteGenerationTracks(generationId) + deleteGeneration(generationId) + } + incrementRevision(now) + val revision = getRevision() + if (albums.isNotEmpty()) putAlbumSummaries(albums) + if (artists.isNotEmpty()) putArtistSummaries(artists) + if (artistTrackIndex.isNotEmpty()) putArtistTrackIndex(artistTrackIndex) + if (directories.isNotEmpty()) putDirectorySummaries(directories) + clearFts() + if (ftsRows.isNotEmpty()) putFtsRows(ftsRows) + deleteOldAlbumSummaries(revision) + deleteOldArtistSummaries(revision) + deleteOldArtistTrackIndex(revision) + deleteOldDirectorySummaries(revision) + return revision + } +} + +@Database( + entities = [ + CatalogMetaEntity::class, + CatalogSourceEntity::class, + ScanGenerationEntity::class, + TrackEntity::class, + AlbumSummaryEntity::class, + ArtistSummaryEntity::class, + ArtistTrackIndexEntity::class, + DirectorySummaryEntity::class, + TrackUserFactEntity::class, + WaveformPeaksEntity::class, + LyricsCacheEntity::class, + TrackFtsEntity::class, + ], + views = [ActiveTrackView::class], + version = 1, + exportSchema = true, +) +abstract class AstraCatalogDatabase : RoomDatabase() { + abstract fun catalogDao(): CatalogDao +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogEntities.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogEntities.kt new file mode 100644 index 0000000..dc7901d --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogEntities.kt @@ -0,0 +1,308 @@ +package expo.modules.astralibraryscanner.data + +import androidx.room.ColumnInfo +import androidx.room.DatabaseView +import androidx.room.Entity +import androidx.room.Fts4 +import androidx.room.FtsOptions +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity(tableName = "catalog_meta") +data class CatalogMetaEntity( + @PrimaryKey val id: Int = 1, + val revision: Long = 0, + @ColumnInfo(name = "collation_version") val collationVersion: Int, + @ColumnInfo(name = "updated_at") val updatedAt: Long, +) + +@Entity(tableName = "catalog_sources") +data class CatalogSourceEntity( + @PrimaryKey + @ColumnInfo(name = "source_key") + val sourceKey: String, + @ColumnInfo(name = "source_type") val sourceType: String, + @ColumnInfo(name = "source_id") val sourceId: Long, + @ColumnInfo(name = "active_generation_id") val activeGenerationId: String? = null, + @ColumnInfo(name = "updated_at") val updatedAt: Long, +) + +@Entity( + tableName = "scan_generations", + indices = [Index(value = ["source_key", "state"])], +) +data class ScanGenerationEntity( + @PrimaryKey val id: String, + @ColumnInfo(name = "source_key") val sourceKey: String, + val state: String, + @ColumnInfo(name = "started_at") val startedAt: Long, + @ColumnInfo(name = "finished_at") val finishedAt: Long? = null, + @ColumnInfo(name = "error_message") val errorMessage: String? = null, +) + +@Entity( + tableName = "tracks", + indices = [ + Index(value = ["generation_id", "path"], unique = true), + Index(value = ["source_key", "generation_id"]), + Index(value = ["album_identity_key"]), + Index(value = ["artist_sort_key", "album_sort_key", "disc_sort", "track_sort", "title_sort_key", "path"]), + Index(value = ["title_sort_key", "path"]), + Index(value = ["added_at", "path"]), + Index(value = ["duration", "path"]), + ], +) +data class TrackEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(name = "generation_id") val generationId: String, + @ColumnInfo(name = "source_key") val sourceKey: String, + val path: String, + @ColumnInfo(name = "folder_id") val folderId: Long? = null, + val title: String, + val artist: String, + val album: String, + @ColumnInfo(name = "album_artist") val albumArtist: String? = null, + @ColumnInfo(name = "album_identity_key") val albumIdentityKey: String, + @ColumnInfo(name = "album_display_artist") val albumDisplayArtist: String? = null, + val duration: Double = 0.0, + @ColumnInfo(name = "track_number") val trackNumber: Int? = null, + @ColumnInfo(name = "disc_number") val discNumber: Int? = null, + val year: Int? = null, + val genre: String? = null, + @ColumnInfo(name = "artwork_hash") val artworkHash: String? = null, + val format: String, + @ColumnInfo(name = "sample_rate") val sampleRate: Int? = null, + @ColumnInfo(name = "bit_depth") val bitDepth: Int? = null, + val bitrate: Int? = null, + val channels: Int? = null, + val codec: String? = null, + @ColumnInfo(name = "source_type") val sourceType: String = "local", + @ColumnInfo(name = "source_id") val sourceId: Long? = null, + @ColumnInfo(name = "source_track_id") val sourceTrackId: String? = null, + @ColumnInfo(name = "source_path") val sourcePath: String? = null, + @ColumnInfo(name = "artwork_source_id") val artworkSourceId: String? = null, + @ColumnInfo(name = "file_name") val fileName: String, + @ColumnInfo(name = "parent_uri") val parentUri: String? = null, + val size: Long? = null, + val mtime: Long = 0, + @ColumnInfo(name = "added_at") val addedAt: Long, + @ColumnInfo(name = "modified_at") val modifiedAt: Long, + @ColumnInfo(name = "loudness_lufs") val loudnessLufs: Double? = null, + @ColumnInfo(name = "sample_peak") val samplePeak: Double? = null, + @ColumnInfo(name = "replay_gain_track_db") val replayGainTrackDb: Double? = null, + @ColumnInfo(name = "replay_gain_album_db") val replayGainAlbumDb: Double? = null, + @ColumnInfo(name = "replay_gain_track_peak") val replayGainTrackPeak: Double? = null, + @ColumnInfo(name = "replay_gain_album_peak") val replayGainAlbumPeak: Double? = null, + @ColumnInfo(name = "rg_scanned") val replayGainScanned: Boolean = false, + val bpm: Double? = null, + @ColumnInfo(name = "musical_key") val musicalKey: String? = null, + @ColumnInfo(name = "title_sort_key") val titleSortKey: String, + @ColumnInfo(name = "artist_sort_key") val artistSortKey: String, + @ColumnInfo(name = "album_sort_key") val albumSortKey: String, + @ColumnInfo(name = "file_name_sort_key") val fileNameSortKey: String, + @ColumnInfo(name = "disc_sort") val discSort: Int, + @ColumnInfo(name = "track_sort") val trackSort: Int, + @ColumnInfo(name = "section_label") val sectionLabel: String, +) + +@DatabaseView( + viewName = "active_tracks", + value = """ + SELECT t.* + FROM tracks t + INNER JOIN catalog_sources s + ON s.source_key = t.source_key + AND s.active_generation_id = t.generation_id + """, +) +data class ActiveTrackView( + val id: Long, + @ColumnInfo(name = "generation_id") val generationId: String, + @ColumnInfo(name = "source_key") val sourceKey: String, + val path: String, + @ColumnInfo(name = "folder_id") val folderId: Long?, + val title: String, + val artist: String, + val album: String, + @ColumnInfo(name = "album_artist") val albumArtist: String?, + @ColumnInfo(name = "album_identity_key") val albumIdentityKey: String, + @ColumnInfo(name = "album_display_artist") val albumDisplayArtist: String?, + val duration: Double, + @ColumnInfo(name = "track_number") val trackNumber: Int?, + @ColumnInfo(name = "disc_number") val discNumber: Int?, + val year: Int?, + val genre: String?, + @ColumnInfo(name = "artwork_hash") val artworkHash: String?, + val format: String, + @ColumnInfo(name = "sample_rate") val sampleRate: Int?, + @ColumnInfo(name = "bit_depth") val bitDepth: Int?, + val bitrate: Int?, + val channels: Int?, + val codec: String?, + @ColumnInfo(name = "source_type") val sourceType: String, + @ColumnInfo(name = "source_id") val sourceId: Long?, + @ColumnInfo(name = "source_track_id") val sourceTrackId: String?, + @ColumnInfo(name = "source_path") val sourcePath: String?, + @ColumnInfo(name = "artwork_source_id") val artworkSourceId: String?, + @ColumnInfo(name = "file_name") val fileName: String, + @ColumnInfo(name = "parent_uri") val parentUri: String?, + val size: Long?, + val mtime: Long, + @ColumnInfo(name = "added_at") val addedAt: Long, + @ColumnInfo(name = "modified_at") val modifiedAt: Long, + @ColumnInfo(name = "loudness_lufs") val loudnessLufs: Double?, + @ColumnInfo(name = "sample_peak") val samplePeak: Double?, + @ColumnInfo(name = "replay_gain_track_db") val replayGainTrackDb: Double?, + @ColumnInfo(name = "replay_gain_album_db") val replayGainAlbumDb: Double?, + @ColumnInfo(name = "replay_gain_track_peak") val replayGainTrackPeak: Double?, + @ColumnInfo(name = "replay_gain_album_peak") val replayGainAlbumPeak: Double?, + @ColumnInfo(name = "rg_scanned") val replayGainScanned: Boolean, + val bpm: Double?, + @ColumnInfo(name = "musical_key") val musicalKey: String?, + @ColumnInfo(name = "title_sort_key") val titleSortKey: String, + @ColumnInfo(name = "artist_sort_key") val artistSortKey: String, + @ColumnInfo(name = "album_sort_key") val albumSortKey: String, + @ColumnInfo(name = "file_name_sort_key") val fileNameSortKey: String, + @ColumnInfo(name = "disc_sort") val discSort: Int, + @ColumnInfo(name = "track_sort") val trackSort: Int, + @ColumnInfo(name = "section_label") val sectionLabel: String, +) + +@Entity( + tableName = "album_summaries", + primaryKeys = ["revision", "identity_key"], + indices = [ + Index(value = ["revision", "name_sort_key", "identity_key"]), + Index(value = ["revision", "artist_sort_key", "name_sort_key", "identity_key"]), + Index(value = ["revision", "latest_added_at", "identity_key"]), + Index(value = ["revision", "year", "name_sort_key", "identity_key"]), + ], +) +data class AlbumSummaryEntity( + val revision: Long, + @ColumnInfo(name = "identity_key") val identityKey: String, + val album: String, + val artist: String, + val year: Int? = null, + @ColumnInfo(name = "artwork_hash") val artworkHash: String? = null, + @ColumnInfo(name = "source_type") val sourceType: String? = null, + @ColumnInfo(name = "source_id") val sourceId: Long? = null, + @ColumnInfo(name = "artwork_source_id") val artworkSourceId: String? = null, + @ColumnInfo(name = "track_count") val trackCount: Long, + @ColumnInfo(name = "total_duration") val totalDuration: Double, + @ColumnInfo(name = "latest_added_at") val latestAddedAt: Long, + @ColumnInfo(name = "name_sort_key") val nameSortKey: String, + @ColumnInfo(name = "artist_sort_key") val artistSortKey: String, + @ColumnInfo(name = "section_label") val sectionLabel: String, + @ColumnInfo(name = "is_single") val isSingle: Boolean, +) + +@Entity( + tableName = "artist_summaries", + primaryKeys = ["revision", "artist_key", "grouping_mode"], + indices = [ + Index(value = ["revision", "grouping_mode", "name_sort_key", "artist_key"]), + Index(value = ["revision", "grouping_mode", "track_count", "name_sort_key", "artist_key"]), + ], +) +data class ArtistSummaryEntity( + val revision: Long, + @ColumnInfo(name = "artist_key") val artistKey: String, + val artist: String, + @ColumnInfo(name = "grouping_mode") val groupingMode: String, + @ColumnInfo(name = "track_count") val trackCount: Long, + @ColumnInfo(name = "primary_track_count") val primaryTrackCount: Long, + @ColumnInfo(name = "album_count") val albumCount: Long, + @ColumnInfo(name = "artwork_hash") val artworkHash: String? = null, + @ColumnInfo(name = "source_type") val sourceType: String? = null, + @ColumnInfo(name = "source_id") val sourceId: Long? = null, + @ColumnInfo(name = "artwork_source_id") val artworkSourceId: String? = null, + @ColumnInfo(name = "name_sort_key") val nameSortKey: String, + @ColumnInfo(name = "section_label") val sectionLabel: String, + @ColumnInfo(name = "is_collaboration") val isCollaboration: Boolean, + @ColumnInfo(name = "artwork_hashes_json") val artworkHashesJson: String, +) + +@Entity( + tableName = "artist_track_index", + primaryKeys = ["revision", "grouping_mode", "artist_key", "track_id"], + indices = [ + Index(value = ["revision", "grouping_mode", "artist_key", "relationship", "track_id"]), + Index(value = ["track_id"]), + ], +) +data class ArtistTrackIndexEntity( + val revision: Long, + @ColumnInfo(name = "grouping_mode") val groupingMode: String, + @ColumnInfo(name = "artist_key") val artistKey: String, + @ColumnInfo(name = "track_id") val trackId: Long, + val relationship: String, +) + +@Entity( + tableName = "directory_summaries", + primaryKeys = ["revision", "node_id"], + indices = [Index(value = ["revision", "folder_id", "parent_node_id", "name_sort_key"])], +) +data class DirectorySummaryEntity( + val revision: Long, + @ColumnInfo(name = "node_id") val nodeId: String, + @ColumnInfo(name = "folder_id") val folderId: Long, + @ColumnInfo(name = "parent_node_id") val parentNodeId: String? = null, + val name: String, + val depth: Int, + @ColumnInfo(name = "directory_path") val directoryPath: String, + @ColumnInfo(name = "document_uri") val documentUri: String? = null, + @ColumnInfo(name = "direct_track_count") val directTrackCount: Long, + @ColumnInfo(name = "total_track_count") val totalTrackCount: Long, + @ColumnInfo(name = "name_sort_key") val nameSortKey: String, +) + +@Entity(tableName = "track_user_facts") +data class TrackUserFactEntity( + @PrimaryKey val path: String, + @ColumnInfo(name = "is_favorite") val isFavorite: Boolean = false, + @ColumnInfo(name = "play_count") val playCount: Long = 0, + @ColumnInfo(name = "last_played_at") val lastPlayedAt: Long? = null, +) + +@Entity(tableName = "waveform_peaks") +data class WaveformPeaksEntity( + @PrimaryKey + @ColumnInfo(name = "track_path") + val trackPath: String, + val bins: Int, + val peaks: ByteArray, + @ColumnInfo(name = "created_at") val createdAt: Long, +) + +@Entity( + tableName = "lyrics_cache", + indices = [Index(value = ["updated_at"])], +) +data class LyricsCacheEntity( + @PrimaryKey + @ColumnInfo(name = "track_path") + val trackPath: String, + @ColumnInfo(name = "metadata_signature") val metadataSignature: String? = null, + val status: String, + val source: String? = null, + val provider: String? = null, + val format: String? = null, + @ColumnInfo(name = "plain_lyrics") val plainLyrics: String? = null, + @ColumnInfo(name = "synced_lyrics") val syncedLyrics: String? = null, + @ColumnInfo(name = "synced_lines_json") val syncedLinesJson: String, + @ColumnInfo(name = "updated_at") val updatedAt: Long, +) + +@Fts4(tokenizer = FtsOptions.TOKENIZER_UNICODE61) +@Entity(tableName = "track_fts") +data class TrackFtsEntity( + @PrimaryKey + @ColumnInfo(name = "rowid") + val rowId: Long, + val title: String, + val artist: String, + val album: String, + @ColumnInfo(name = "file_name") val fileName: String, +) diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogReadModelBuilder.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogReadModelBuilder.kt new file mode 100644 index 0000000..02bd9ad --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/CatalogReadModelBuilder.kt @@ -0,0 +1,486 @@ +package expo.modules.astralibraryscanner.data + +import android.net.Uri +import java.util.Locale +import org.json.JSONArray + +data class AlbumIdentityUpdate( + val trackId: Long, + val identityKey: String, + val displayArtist: String, +) + +data class CatalogReadModels( + val identityUpdates: List, + val albums: List, + val artists: List, + val artistTrackIndex: List, + val directories: List, + val ftsRows: List, +) + +data class LocalAudioFile( + val uri: String, + val name: String, + val size: Long?, + val lastModified: Long, + val mimeType: String?, + val parentUri: String, + val coverUri: String?, +) + +data class LocalAudioMetadata( + val ok: Boolean, + val title: String? = null, + val artist: String? = null, + val album: String? = null, + val albumArtist: String? = null, + val genre: String? = null, + val mimeType: String? = null, + val durationMs: Long? = null, + val bitrate: Int? = null, + val trackNumber: Int? = null, + val discNumber: Int? = null, + val year: Int? = null, + val sampleRate: Int? = null, + val channels: Int? = null, + val bitsPerSample: Int? = null, + val codecMime: String? = null, + val artworkHash: String? = null, + val error: String? = null, +) + +data class NativeScanResult( + val added: Int, + val updated: Int, + val removed: Int, + val errors: Int, + val total: Int, + val revision: Long, +) { + fun toMap(): Map = mapOf( + "added" to added, + "updated" to updated, + "removed" to removed, + "errors" to errors, + "total" to total, + "catalogRevision" to revision.toString(), + ) +} + +private data class PreparedAlbumTrack( + val track: TrackEntity, + val albumKey: String, + val primaryArtist: String, + val primaryArtistKey: String, + val normalizedAlbumArtist: String, + val artworkIdentityHash: String?, +) + +private data class SettledTrack( + val track: TrackEntity, + val identityKey: String, + val displayArtist: String, +) + +object CatalogReadModelBuilder { + private const val UNKNOWN_ARTIST = "Unknown Artist" + private const val UNKNOWN_ALBUM = "Unknown Album" + private const val VARIOUS_ARTISTS = "Various Artists" + + fun build( + tracks: List, + revision: Long, + folders: Map = emptyMap(), + ): CatalogReadModels { + val settled = settleAlbums(tracks) + val artistModels = buildArtists(settled, revision) + return CatalogReadModels( + identityUpdates = settled.mapNotNull { row -> + if ( + row.track.albumIdentityKey == row.identityKey && + row.track.albumDisplayArtist == row.displayArtist + ) { + null + } else { + AlbumIdentityUpdate(row.track.id, row.identityKey, row.displayArtist) + } + }, + albums = buildAlbums(settled, revision), + artists = artistModels.first, + artistTrackIndex = artistModels.second, + directories = buildDirectories(settled, revision, folders), + ftsRows = settled.map { row -> + TrackFtsEntity( + rowId = row.track.id, + title = row.track.title, + artist = row.track.artist, + album = row.track.album, + fileName = row.track.fileName, + ) + }, + ) + } + + fun provisionalIdentity( + album: String, + artist: String, + albumArtist: String?, + ): Pair { + val albumKey = normalizeKey(normalizeAlbum(album)) + val explicit = normalizeDisplay(albumArtist.orEmpty()) + if (explicit.isNotEmpty()) { + return identity(albumKey, "aa:${normalizeKey(explicit).ifEmpty { normalizeKey(UNKNOWN_ARTIST) }}") to explicit + } + val primary = primaryArtist(artist) + return identity(albumKey, "ta:${normalizeKey(primary)}") to primary + } + + private fun settleAlbums(tracks: List): List { + val settled = ArrayList(tracks.size) + val missingAlbumArtist = LinkedHashMap>() + + for (track in tracks) { + val albumKey = normalizeKey(normalizeAlbum(track.album)) + val explicit = normalizeDisplay(track.albumArtist.orEmpty()) + val primary = primaryArtist(track.artist) + val prepared = PreparedAlbumTrack( + track = track, + albumKey = albumKey, + primaryArtist = primary, + primaryArtistKey = normalizeKey(primary), + normalizedAlbumArtist = explicit, + artworkIdentityHash = normalizeKey( + track.artworkHash ?: if (track.sourceType != "local") track.artworkSourceId.orEmpty() else "", + ).ifEmpty { null }, + ) + if (explicit.isNotEmpty()) { + settled += SettledTrack( + track, + identity(albumKey, "aa:${normalizeKey(explicit).ifEmpty { normalizeKey(UNKNOWN_ARTIST) }}"), + explicit, + ) + } else { + missingAlbumArtist.getOrPut(albumKey) { mutableListOf() } += prepared + } + } + + for ((albumKey, bucket) in missingAlbumArtist) { + val artistKeys = bucket.mapTo(linkedSetOf()) { it.primaryArtistKey } + val firstArtwork = bucket.firstOrNull()?.artworkIdentityHash + val sharedArtwork = if ( + artistKeys.size > 1 && + firstArtwork != null && + bucket.all { it.artworkIdentityHash == firstArtwork } + ) { + firstArtwork + } else { + null + } + if (sharedArtwork != null) { + val key = identity(albumKey, "ah:$sharedArtwork") + bucket.forEach { settled += SettledTrack(it.track, key, VARIOUS_ARTISTS) } + } else { + bucket.forEach { + settled += SettledTrack( + it.track, + identity(albumKey, "ta:${it.primaryArtistKey}"), + it.primaryArtist, + ) + } + } + } + return settled + } + + private fun buildAlbums( + tracks: List, + revision: Long, + ): List { + return tracks.groupBy(SettledTrack::identityKey).map { (identityKey, rows) -> + val album = mostFrequent(rows.map { normalizeAlbum(it.track.album) }, UNKNOWN_ALBUM) + val artist = rows.firstNotNullOfOrNull { it.displayArtist.takeIf(String::isNotBlank) } + ?: rows.first().track.albumArtist + ?: rows.first().track.artist + val artwork = mostFrequentNullable(rows.map { it.track.artworkHash }) + val remote = rows.firstOrNull { it.track.sourceType != "local" }?.track + AlbumSummaryEntity( + revision = revision, + identityKey = identityKey, + album = album, + artist = artist, + year = rows.mapNotNull { it.track.year }.maxOrNull(), + artworkHash = artwork, + sourceType = remote?.sourceType ?: "local", + sourceId = remote?.sourceId, + artworkSourceId = remote?.artworkSourceId, + trackCount = rows.size.toLong(), + totalDuration = rows.sumOf { it.track.duration }, + latestAddedAt = rows.maxOf { it.track.addedAt }, + nameSortKey = SortKeys.forText(album), + artistSortKey = SortKeys.forText(artist), + sectionLabel = SortKeys.sectionLabel(album), + isSingle = rows.size < 2, + ) + } + } + + private fun buildArtists( + tracks: List, + revision: Long, + ): Pair, List> { + val result = mutableListOf() + val index = mutableListOf() + for (mode in listOf("astra", "fileTags")) { + data class Aggregate( + val name: String, + var trackCount: Long = 0, + var primaryCount: Long = 0, + val albumKeys: MutableSet = linkedSetOf(), + var artworkTrack: TrackEntity? = null, + val artworkHashes: MutableSet = linkedSetOf(), + ) + val aggregates = LinkedHashMap() + for (row in tracks) { + val track = row.track + val primary = if (mode == "fileTags") { + normalizeDisplay(track.albumArtist.orEmpty()).ifEmpty { + normalizeDisplay(track.artist).ifEmpty { UNKNOWN_ARTIST } + } + } else { + canonicalPrimary(track) + } + val names = if (mode == "fileTags") { + listOf(primary) + } else { + canonicalArtistNames(track) + } + for (name in names) { + val key = normalizeKey(name) + if (key.isEmpty()) continue + val aggregate = aggregates.getOrPut(key) { Aggregate(name) } + aggregate.trackCount += 1 + if (key == normalizeKey(primary)) aggregate.primaryCount += 1 + aggregate.albumKeys += row.identityKey + val current = aggregate.artworkTrack + if (track.artworkHash != null && (current == null || newerArtwork(track, current))) { + aggregate.artworkTrack = track + } + track.artworkHash?.let(aggregate.artworkHashes::add) + index += ArtistTrackIndexEntity( + revision = revision, + groupingMode = mode, + artistKey = key, + trackId = track.id, + relationship = if (key == normalizeKey(primary)) "song" else "appearance", + ) + } + } + result += aggregates.map { (key, aggregate) -> + val art = aggregate.artworkTrack + val artworkHashes = buildList { + art?.artworkHash?.let(::add) + for (hash in aggregate.artworkHashes) { + if (hash !in this) add(hash) + if (size == 4) break + } + } + ArtistSummaryEntity( + revision = revision, + artistKey = key, + artist = aggregate.name, + groupingMode = mode, + trackCount = aggregate.trackCount, + primaryTrackCount = aggregate.primaryCount, + albumCount = aggregate.albumKeys.size.toLong(), + artworkHash = art?.artworkHash, + sourceType = art?.sourceType, + sourceId = art?.sourceId, + artworkSourceId = art?.artworkSourceId, + nameSortKey = SortKeys.forText(aggregate.name), + sectionLabel = SortKeys.sectionLabel(aggregate.name), + isCollaboration = aggregate.primaryCount == 0L, + artworkHashesJson = JSONArray(artworkHashes).toString(), + ) + } + } + return result to index + } + + private fun buildDirectories( + tracks: List, + revision: Long, + folders: Map, + ): List { + data class MutableDirectory( + val nodeId: String, + val folderId: Long, + val parentNodeId: String?, + val name: String, + val depth: Int, + val directoryPath: String, + var documentUri: String? = null, + var directTrackCount: Long = 0, + var totalTrackCount: Long = 0, + ) + + fun decodedPath(uri: String, marker: String): String? = runCatching { + val encoded = uri.substringAfter(marker) + Uri.decode(encoded).substringAfter(':') + }.getOrNull() + + val nodes = linkedMapOf() + for ((folderId, folder) in folders) { + val rootPath = decodedPath(folder.treeUri, "/tree/") ?: folder.displayName + val rootId = "folder:$folderId" + nodes[rootId] = MutableDirectory( + nodeId = rootId, + folderId = folderId, + parentNodeId = null, + name = folder.displayName, + depth = 0, + directoryPath = rootPath, + ) + } + + for (row in tracks.map(SettledTrack::track)) { + val folderId = row.folderId ?: continue + val parentUri = row.parentUri ?: continue + val root = nodes["folder:$folderId"] ?: continue + val parentPath = decodedPath(parentUri, "/document/") ?: continue + val relative = when { + parentPath == root.directoryPath -> "" + parentPath.startsWith("${root.directoryPath}/") -> + parentPath.removePrefix("${root.directoryPath}/") + else -> "" + } + root.totalTrackCount += 1 + if (relative.isEmpty()) { + root.directTrackCount += 1 + root.documentUri = parentUri + continue + } + var parentId = root.nodeId + var path = root.directoryPath + for ((index, segment) in relative.split('/').filter(String::isNotBlank).withIndex()) { + path = "$path/$segment" + val nodeId = "folder:$folderId:${path.removePrefix("${root.directoryPath}/")}" + val node = nodes.getOrPut(nodeId) { + MutableDirectory( + nodeId = nodeId, + folderId = folderId, + parentNodeId = parentId, + name = segment, + depth = index + 1, + directoryPath = path, + ) + } + node.totalTrackCount += 1 + parentId = nodeId + if (index == relative.split('/').filter(String::isNotBlank).lastIndex) { + node.directTrackCount += 1 + node.documentUri = parentUri + } + } + } + + return nodes.values + .filter { it.totalTrackCount > 0 } + .map { + DirectorySummaryEntity( + revision = revision, + nodeId = it.nodeId, + folderId = it.folderId, + parentNodeId = it.parentNodeId, + name = it.name, + depth = it.depth, + directoryPath = it.directoryPath, + documentUri = it.documentUri, + directTrackCount = it.directTrackCount, + totalTrackCount = it.totalTrackCount, + nameSortKey = SortKeys.forText(it.name), + ) + } + } + + private fun canonicalPrimary(track: TrackEntity): String { + val albumArtist = normalizeDisplay(track.albumArtist.orEmpty()) + if (albumArtist.isNotEmpty()) { + return splitAlbumArtists(albumArtist).firstOrNull() ?: albumArtist + } + return splitTrackArtists(track.artist).firstOrNull() ?: UNKNOWN_ARTIST + } + + private fun canonicalArtistNames(track: TrackEntity): List { + val result = LinkedHashMap() + fun add(value: String) { + val display = normalizeDisplay(value) + val key = normalizeKey(display) + if (key.isNotEmpty()) result.putIfAbsent(key, display) + } + add(canonicalPrimary(track)) + val trackArtists = splitTrackArtists(track.artist) + trackArtists.forEach(::add) + if (trackArtists.isEmpty()) splitAlbumArtists(track.albumArtist.orEmpty()).forEach(::add) + return result.values.toList() + } + + private fun splitTrackArtists(raw: String): List = + splitArtists(raw, splitAmpersand = true) + + private fun splitAlbumArtists(raw: String): List = + splitArtists(raw, splitAmpersand = false) + + private fun splitArtists(raw: String, splitAmpersand: Boolean): List { + var unified = normalizeDisplay(raw) + .replace(Regex("\\s*;\\s*"), ",") + .replace(Regex("\\s+[x×]\\s+", RegexOption.IGNORE_CASE), ",") + .replace(Regex("\\s+(?:feat\\.?|ft\\.?|featuring|with)\\s+", RegexOption.IGNORE_CASE), ",") + if (splitAmpersand) unified = unified.replace(Regex("\\s+&\\s+"), ",") + val result = LinkedHashMap() + unified.split(',').forEach { + val display = normalizeDisplay(it) + val key = normalizeKey(display) + if (key.isNotEmpty()) result.putIfAbsent(key, display) + } + return result.values.toList() + } + + private fun newerArtwork(candidate: TrackEntity, current: TrackEntity): Boolean { + val candidateYear = candidate.year ?: -1 + val currentYear = current.year ?: -1 + return candidateYear > currentYear || + (candidateYear == currentYear && ( + candidate.addedAt > current.addedAt || + (candidate.addedAt == current.addedAt && candidate.modifiedAt > current.modifiedAt) + )) + } + + private fun mostFrequent(values: List, fallback: String): String = + values.groupingBy(::normalizeKey).eachCount().entries + .sortedWith(compareByDescending> { it.value }.thenBy { it.key }) + .firstOrNull() + ?.key + ?.let { key -> values.filter { normalizeKey(it) == key }.minByOrNull(SortKeys::forText) } + ?: fallback + + private fun mostFrequentNullable(values: List): String? = + values.filterNotNull().filter(String::isNotBlank).groupingBy { it }.eachCount().entries + .sortedWith(compareByDescending> { it.value }.thenBy { it.key }) + .firstOrNull()?.key + + private fun normalizeDisplay(value: String): String = + value.replace(Regex("\\s+"), " ").trim() + + private fun normalizeKey(value: String): String = + normalizeDisplay(value).lowercase(Locale.ROOT) + + private fun normalizeAlbum(value: String): String = + normalizeDisplay(value).ifEmpty { UNKNOWN_ALBUM } + + private fun primaryArtist(value: String): String = + splitTrackArtists(value).firstOrNull() ?: UNKNOWN_ARTIST + + private fun identity(albumKey: String, discriminator: String): String = + "album:$albumKey::$discriminator" +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/DesktopSyncNative.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/DesktopSyncNative.kt new file mode 100644 index 0000000..c723f1f --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/DesktopSyncNative.kt @@ -0,0 +1,527 @@ +package expo.modules.astralibraryscanner.data + +import android.net.Uri +import android.provider.DocumentsContract +import androidx.room.withTransaction +import java.util.Locale +import java.util.UUID + +private val SYNC_WHITESPACE = Regex("\\s+") +private const val SYNC_SEPARATOR = "\u001f" + +private fun normalizeSyncPart(value: String): String = + value.replace(SYNC_WHITESPACE, " ").trim().lowercase(Locale.ROOT) + +private fun syncKey(title: String, artist: String, album: String): String = + listOf(title, artist, album).joinToString(SYNC_SEPARATOR, transform = ::normalizeSyncPart) + +private fun decodedDocumentPath(raw: String): String? = runCatching { + val uri = Uri.parse(raw) + val id = DocumentsContract.getDocumentId(uri) + Uri.decode(id.substringAfter(':', id)).replace('\\', '/') +}.getOrNull() + +private fun normalizedForeignPath(raw: String): String { + var value = raw.trim().replace('\\', '/') + if (value.startsWith("file://", ignoreCase = true)) value = value.drop(7) + return Uri.decode(value).lowercase(Locale.ROOT) +} + +private data class MatchCandidate( + val track: ActiveTrackView, + val decodedPath: String?, +) + +private class NativeTrackMatcher(tracks: List) { + private val byFileName = HashMap>() + private val byTitleArtistAlbum = HashMap() + private val byTitleArtist = HashMap() + private val byTitle = HashMap() + + init { + for (track in tracks) { + val decoded = decodedDocumentPath(track.path)?.lowercase(Locale.ROOT) + byFileName.getOrPut(track.fileName.lowercase(Locale.ROOT)) { mutableListOf() } + .add(MatchCandidate(track, decoded)) + putUnique( + byTitleArtistAlbum, + listOf(track.title, track.artist, track.album) + .joinToString("\n", transform = ::normalizeSyncPart), + track, + ) + putUnique( + byTitleArtist, + "${track.title.trim().lowercase(Locale.ROOT)}\n${track.artist.trim().lowercase(Locale.ROOT)}", + track, + ) + putUnique(byTitle, track.title.trim().lowercase(Locale.ROOT), track) + } + } + + private fun putUnique( + map: MutableMap, + key: String, + track: ActiveTrackView, + ) { + if (key.isBlank()) return + if (map.containsKey(key)) map[key] = null else map[key] = track + } + + fun match( + title: String, + artist: String, + album: String, + sourcePath: String? = null, + ): ActiveTrackView? { + if (!sourcePath.isNullOrBlank()) { + val normalized = normalizedForeignPath(sourcePath) + val segments = normalized.split('/') + val candidates = byFileName[segments.lastOrNull().orEmpty()].orEmpty() + if (candidates.size == 1) return candidates.first().track + if (candidates.size > 1) { + var best: ActiveTrackView? = null + var bestScore = 0 + var tied = false + for (candidate in candidates) { + val score = suffixOverlap(segments, candidate.decodedPath) + if (score > bestScore) { + best = candidate.track + bestScore = score + tied = false + } else if (score == bestScore) { + tied = true + } + } + if (best != null && !tied) return best + } + } + + val normalizedTitle = normalizeSyncPart(title) + if (normalizedTitle.isBlank()) return null + val normalizedArtist = normalizeSyncPart(artist) + val normalizedAlbum = normalizeSyncPart(album) + if (normalizedArtist.isNotBlank() && normalizedAlbum.isNotBlank()) { + val key = "$normalizedTitle\n$normalizedArtist\n$normalizedAlbum" + if (byTitleArtistAlbum.containsKey(key)) return byTitleArtistAlbum[key] + } + val simpleTitle = title.trim().lowercase(Locale.ROOT) + val simpleArtist = artist.trim().lowercase(Locale.ROOT) + if (simpleArtist.isNotBlank()) { + val key = "$simpleTitle\n$simpleArtist" + if (byTitleArtist.containsKey(key)) return byTitleArtist[key] + } + return if (byTitle.containsKey(simpleTitle)) byTitle[simpleTitle] else null + } + + private fun suffixOverlap(entrySegments: List, decodedPath: String?): Int { + if (decodedPath == null) return 1 + val trackSegments = decodedPath.split('/') + var overlap = 0 + while ( + overlap < entrySegments.size && + overlap < trackSegments.size && + entrySegments[entrySegments.lastIndex - overlap] == + trackSegments[trackSegments.lastIndex - overlap] + ) { + overlap += 1 + } + return overlap + } +} + +private fun Any?.asLong(default: Long = 0): Long = when (this) { + is Number -> toLong() + is String -> toLongOrNull() ?: default + else -> default +} + +private fun Any?.asString(): String = this as? String ?: "" + +private fun Map.mapList(key: String): List> = + (this[key] as? List<*>)?.mapNotNull { it as? Map }.orEmpty() + +private fun Map.stringList(key: String): List = + (this[key] as? List<*>)?.mapNotNull { it as? String }.orEmpty() + +private data class PlaylistApplyResult( + val syncUid: String, + val status: String, + val entriesMatched: Int, + val entriesFallback: Int, +) { + fun toBridgeMap(): Map = mapOf( + "syncUid" to syncUid, + "status" to status, + "entriesMatched" to entriesMatched, + "entriesFallback" to entriesFallback, + ) +} + +internal object NativeDesktopSync { + suspend fun getState( + userDatabase: AstraUserDatabase, + catalogDatabase: AstraCatalogDatabase, + ): Map { + val userDao = userDatabase.userDao() + val catalogDao = catalogDatabase.catalogDao() + val matcher = NativeTrackMatcher(catalogDao.getAllActiveTracksForNativeMatching()) + + var mutated = false + userDatabase.withTransaction { + for (playlist in userDao.getLocalPlaylists()) { + if (playlist.syncUid != null) continue + userDao.updatePlaylistSyncUid( + playlist.id, + UUID.randomUUID().toString().replace("-", ""), + ) + mutated = true + } + for (pending in userDao.getPendingFavorites()) { + val match = matcher.match(pending.title, pending.artist, pending.album) ?: continue + userDao.putFavorite(FavoriteEntity(match.path, pending.addedAt)) + userDao.deletePendingFavorites(listOf(pending.syncKey)) + mutated = true + } + } + + val favorites = linkedMapOf>() + val favoriteRows = userDao.getFavorites() + val favoriteTracks = favoriteRows.chunked(400).flatMap { chunk -> + catalogDao.getActiveTracks(chunk.map(FavoriteEntity::trackPath)) + }.associateBy(ActiveTrackView::path) + for (favorite in favoriteRows) { + val track = favoriteTracks[favorite.trackPath] ?: continue + val key = syncKey(track.title, track.artist, track.album) + val existing = favorites[key] + if (existing == null) { + favorites[key] = mutableMapOf( + "key" to key, + "title" to track.title, + "artist" to track.artist, + "album" to track.album, + "addedAt" to favorite.addedAt.toDouble(), + "trackPaths" to mutableListOf(favorite.trackPath), + "pending" to false, + ) + } else { + @Suppress("UNCHECKED_CAST") + (existing["trackPaths"] as MutableList).add(favorite.trackPath) + if (favorite.addedAt > existing["addedAt"].asLong()) { + existing["addedAt"] = favorite.addedAt.toDouble() + } + } + } + for (pending in userDao.getPendingFavorites()) { + if (favorites.containsKey(pending.syncKey)) continue + favorites[pending.syncKey] = mutableMapOf( + "key" to pending.syncKey, + "title" to pending.title, + "artist" to pending.artist, + "album" to pending.album, + "addedAt" to pending.addedAt.toDouble(), + "trackPaths" to emptyList(), + "pending" to true, + ) + } + + val playlists = userDao.getLocalPlaylists().mapNotNull { playlist -> + val uid = playlist.syncUid ?: return@mapNotNull null + val entries = if (playlist.kind == "dynamic") { + null + } else { + val rows = userDao.getPlaylistTracks(playlist.id) + val tracks = rows.chunked(400).flatMap { chunk -> + catalogDao.getActiveTracks(chunk.map(PlaylistTrackEntity::trackPath)) + }.associateBy(ActiveTrackView::path) + rows.map { row -> + val track = tracks[row.trackPath] + mapOf( + "title" to (track?.title ?: row.fallbackTitle.orEmpty()), + "artist" to (track?.artist ?: row.fallbackArtist.orEmpty()), + "album" to (track?.album ?: row.fallbackAlbum.orEmpty()), + "durationSeconds" to track?.duration, + "position" to row.position, + "addedAt" to row.addedAt.toDouble(), + "sourcePath" to if (track != null) { + decodedDocumentPath(row.trackPath) ?: track.fileName + } else { + row.trackPath.takeIf(String::isNotBlank) + }, + ) + } + } + mapOf( + "id" to playlist.id.toDouble(), + "syncUid" to uid, + "name" to playlist.name, + "kind" to playlist.kind, + "dynamicRules" to playlist.dynamicRulesJson, + "createdAt" to playlist.createdAt.toDouble(), + "updatedAt" to playlist.updatedAt.toDouble(), + "entries" to entries, + ) + } + + return mapOf( + "favorites" to favorites.values.toList(), + "favoriteTombstones" to userDao.getFavoriteTombstones().map { + mapOf("key" to it.syncKey, "deletedAt" to it.deletedAt.toDouble()) + }, + "playlists" to playlists, + "playlistTombstones" to userDao.getPlaylistTombstones().map { + mapOf("syncUid" to it.syncUid, "deletedAt" to it.deletedAt.toDouble()) + }, + "baselines" to userDao.getPlaylistSyncStates().map { + mapOf( + "syncUid" to it.syncUid, + "localUpdatedAt" to it.localUpdatedAt.toDouble(), + "remoteUpdatedAt" to it.remoteUpdatedAt.toDouble(), + ) + }, + "mutated" to mutated, + ) + } + + suspend fun applyPlan( + userDatabase: AstraUserDatabase, + catalogDatabase: AstraCatalogDatabase, + plan: Map, + ): Map { + val userDao = userDatabase.userDao() + val matcher = NativeTrackMatcher( + catalogDatabase.catalogDao().getAllActiveTracksForNativeMatching(), + ) + val playlistResults = mutableListOf() + var favoritesAdded = 0 + var favoritesPending = 0 + var favoritesRemoved = 0 + + userDatabase.withTransaction { + @Suppress("UNCHECKED_CAST") + val settings = plan["settings"] as? Map ?: emptyMap() + val settingRows = settings.mapNotNull { (key, value) -> + (value as? String)?.let { SettingEntity(key, it) } + } + if (settingRows.isNotEmpty()) userDao.putSettings(settingRows) + + val favoriteTombstoneRemovals = plan.stringList("favoriteTombstoneRemovals") + if (favoriteTombstoneRemovals.isNotEmpty()) { + userDao.deleteFavoriteTombstones(favoriteTombstoneRemovals) + } + for (item in plan.mapList("favoriteAdds")) { + val key = item["key"].asString() + val title = item["title"].asString() + val artist = item["artist"].asString() + val album = item["album"].asString() + val addedAt = item["addedAt"].asLong() + val match = matcher.match(title, artist, album, item["sourcePath"] as? String) + if (match != null) { + userDao.putFavorite(FavoriteEntity(match.path, addedAt)) + userDao.deletePendingFavorites(listOf(key)) + userDao.deleteFavoriteTombstones(listOf(key)) + favoritesAdded += 1 + } else { + userDao.putPendingFavorites( + listOf(PendingFavoriteEntity(key, title, artist, album, addedAt)), + ) + favoritesPending += 1 + } + } + for (item in plan.mapList("favoriteRemoves")) { + for (path in item.stringList("trackPaths")) userDao.deleteFavorite(path) + val key = item["key"].asString() + userDao.deletePendingFavorites(listOf(key)) + userDao.putFavoriteTombstones( + listOf(FavoriteTombstoneEntity(key, item["deletedAt"].asLong())), + ) + favoritesRemoved += 1 + } + + val playlistTombstoneRemovals = plan.stringList("playlistTombstoneRemovals") + if (playlistTombstoneRemovals.isNotEmpty()) { + userDao.deletePlaylistTombstones(playlistTombstoneRemovals) + } + for (item in plan.mapList("playlistAdoptions")) { + userDao.updatePlaylistSyncUid(item["playlistId"].asLong(), item["syncUid"].asString()) + } + for (item in plan.mapList("playlistDeletes")) { + val uid = item["syncUid"].asString() + userDao.deletePlaylistBySyncUid(uid) + userDao.putPlaylistTombstones( + listOf(PlaylistTombstoneEntity(uid, item["deletedAt"].asLong())), + ) + userDao.deletePlaylistSyncStates(listOf(uid)) + playlistResults += PlaylistApplyResult(uid, "deleted", 0, 0) + } + for (item in plan.mapList("playlistUpserts")) { + playlistResults += replacePlaylist(userDao, matcher, item) + } + + val baselineDeletes = plan.stringList("baselineDeletes") + if (baselineDeletes.isNotEmpty()) userDao.deletePlaylistSyncStates(baselineDeletes) + val baselines = plan.mapList("baselineUpserts").map { + PlaylistSyncStateEntity( + syncUid = it["syncUid"].asString(), + localUpdatedAt = it["localUpdatedAt"].asLong(), + remoteUpdatedAt = it["remoteUpdatedAt"].asLong(), + ) + } + if (baselines.isNotEmpty()) userDao.putPlaylistSyncStates(baselines) + } + + return mapOf( + "favoritesAdded" to favoritesAdded, + "favoritesPending" to favoritesPending, + "favoritesRemoved" to favoritesRemoved, + "playlistResults" to playlistResults.map(PlaylistApplyResult::toBridgeMap), + ) + } + + suspend fun resolveConflict( + userDatabase: AstraUserDatabase, + catalogDatabase: AstraCatalogDatabase, + conflict: Map, + resolution: String, + mergedPlaylist: Map?, + ) { + val userDao = userDatabase.userDao() + val playlistId = conflict["localPlaylistId"].asLong() + val syncUid = conflict["syncUid"].asString() + val conflictKind = conflict["kind"].asString() + val remoteUpdatedAt = conflict["remoteUpdatedAt"].asLong() + val matcher = NativeTrackMatcher( + catalogDatabase.catalogDao().getAllActiveTracksForNativeMatching(), + ) + userDatabase.withTransaction { + val current = userDao.getPlaylist(playlistId) ?: return@withTransaction + when (resolution) { + "desktop" -> { + if (conflictKind == "first-pairing") userDao.updatePlaylistSyncUid(playlistId, syncUid) + userDao.putPlaylistSyncStates( + listOf(PlaylistSyncStateEntity(syncUid, current.updatedAt, 0)), + ) + } + "phone" -> { + if (conflictKind == "first-pairing") userDao.updatePlaylistSyncUid(playlistId, syncUid) + userDao.putPlaylistSyncStates( + listOf(PlaylistSyncStateEntity(syncUid, 0, remoteUpdatedAt)), + ) + } + "both" -> { + val copyName = "${current.name} (Phone)" + if (conflictKind == "first-pairing") { + userDao.putPlaylist( + current.copy(name = copyName, updatedAt = System.currentTimeMillis()), + ) + } else { + val now = System.currentTimeMillis() + val cloneId = userDao.insertPlaylist( + current.copy( + id = 0, + name = copyName, + createdAt = now, + updatedAt = now, + lastPlayedAt = null, + syncUid = UUID.randomUUID().toString().replace("-", ""), + ), + ) + userDao.putPlaylistTracks( + userDao.getPlaylistTracks(playlistId).map { + it.copy(id = 0, playlistId = cloneId) + }, + ) + userDao.putPlaylistSyncStates( + listOf(PlaylistSyncStateEntity(syncUid, current.updatedAt, 0)), + ) + } + } + "merge" -> { + require(mergedPlaylist != null) { "Merged playlist is required." } + if (conflictKind == "first-pairing") userDao.updatePlaylistSyncUid(playlistId, syncUid) + replacePlaylist(userDao, matcher, mergedPlaylist) + userDao.putPlaylistSyncStates( + listOf(PlaylistSyncStateEntity(syncUid, 0, remoteUpdatedAt)), + ) + } + else -> error("Unknown desktop sync conflict resolution.") + } + } + } + + private suspend fun replacePlaylist( + userDao: UserDao, + matcher: NativeTrackMatcher, + input: Map, + ): PlaylistApplyResult { + val uid = input["syncUid"].asString() + val kind = if (input["kind"] == "dynamic") "dynamic" else "normal" + val rules = if (kind == "dynamic") input["dynamicRules"] as? String else null + if (kind == "dynamic") { + try { + DynamicPlaylistCompiler.compile(rules, 0, 1) + } catch (_: Throwable) { + return PlaylistApplyResult(uid, "skipped-incompatible", 0, 0) + } + } + val existing = userDao.getPlaylistBySyncUid(uid) + val playlistId = if (existing == null) { + userDao.insertPlaylist( + PlaylistEntity( + name = input["name"].asString(), + kind = kind, + dynamicRulesJson = rules, + createdAt = input["createdAt"].asLong(), + updatedAt = input["updatedAt"].asLong(), + syncUid = uid, + ), + ) + } else { + userDao.putPlaylist( + existing.copy( + name = input["name"].asString(), + kind = kind, + dynamicRulesJson = rules, + updatedAt = input["updatedAt"].asLong(), + ), + ) + existing.id + } + userDao.deletePlaylistTombstones(listOf(uid)) + + var matchedCount = 0 + var fallbackCount = 0 + val rows = mutableListOf() + val seen = hashSetOf() + if (kind == "normal") { + val ordered = input.mapList("entries").sortedBy { it["position"].asLong() } + for (entry in ordered) { + val title = entry["title"].asString() + val artist = entry["artist"].asString() + val album = entry["album"].asString() + val match = matcher.match(title, artist, album, entry["sourcePath"] as? String) + val path = match?.path + ?: (entry["sourcePath"] as? String)?.trim()?.takeIf(String::isNotEmpty) + ?: "astra-sync://unmatched/${syncKey(title, artist, album)}" + if (!seen.add(path)) continue + if (match != null) matchedCount += 1 else fallbackCount += 1 + rows += PlaylistTrackEntity( + playlistId = playlistId, + trackPath = path, + position = rows.size, + addedAt = entry["addedAt"].asLong(input["updatedAt"].asLong()) + .takeIf { it > 0 } ?: input["updatedAt"].asLong(), + fallbackTitle = if (match == null) title.takeIf(String::isNotEmpty) else null, + fallbackArtist = if (match == null) artist.takeIf(String::isNotEmpty) else null, + fallbackAlbum = if (match == null) album.takeIf(String::isNotEmpty) else null, + ) + } + } + userDao.replacePlaylistEntries(playlistId, rows) + return PlaylistApplyResult( + uid, + if (existing == null) "created" else "replaced", + matchedCount, + fallbackCount, + ) + } +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/DynamicPlaylistCompiler.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/DynamicPlaylistCompiler.kt new file mode 100644 index 0000000..5b97748 --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/DynamicPlaylistCompiler.kt @@ -0,0 +1,149 @@ +package expo.modules.astralibraryscanner.data + +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.sqlite.db.SupportSQLiteQuery +import java.util.Locale +import org.json.JSONObject + +data class DynamicQueries( + val tracks: SupportSQLiteQuery, + val count: SupportSQLiteQuery, +) + +/** Allow-list compiler: rules become bound parameters; no user text becomes SQL. */ +object DynamicPlaylistCompiler { + private val textFields = mapOf( + "title" to "t.title", + "artist" to "t.artist", + "album" to "t.album", + "album_artist" to "t.album_artist", + "genre" to "t.genre", + "format" to "t.format", + "musical_key" to "t.musical_key", + ) + private val numericFields = mapOf( + "play_count" to "COALESCE(f.play_count, 0)", + "year" to "t.year", + "duration_seconds" to "t.duration", + "bpm" to "t.bpm", + ) + private val sortFields = mapOf( + "title" to "t.title_sort_key", + "artist" to "t.artist_sort_key", + "album" to "t.album_sort_key", + "added_at" to "t.added_at", + "last_played_at" to "f.last_played_at", + "play_count" to "COALESCE(f.play_count, 0)", + "year" to "t.year", + "duration_seconds" to "t.duration", + "bpm" to "t.bpm", + ) + + fun compile(rawRules: String?, offset: Int, requestedLimit: Int): DynamicQueries { + val json = runCatching { JSONObject(rawRules ?: "{}") }.getOrElse { JSONObject() } + val clauses = mutableListOf() + val args = mutableListOf() + json.optJSONArray("conditions")?.let { conditions -> + for (index in 0 until conditions.length()) { + val condition = conditions.optJSONObject(index) ?: continue + when (condition.optString("kind")) { + "text" -> appendText(condition, clauses, args) + "exact" -> appendExact(condition, clauses, args) + "numeric" -> appendNumeric(condition, clauses, args) + "date" -> appendDate(condition, clauses, args) + } + } + } + val where = clauses.ifEmpty { listOf("1 = 1") }.joinToString(" AND ") + val sort = json.optJSONObject("sort") + val sortExpression = sortFields[sort?.optString("field")] ?: "t.title_sort_key" + val direction = if (sort?.optString("direction") == "desc") "DESC" else "ASC" + val ruleLimit = if (json.has("limit") && !json.isNull("limit")) json.optInt("limit", 0) else 0 + val pageLimit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE) + val limit = if (ruleLimit > 0) minOf(pageLimit, (ruleLimit - offset).coerceAtLeast(0)) else pageLimit + val base = """ + FROM active_tracks t + LEFT JOIN track_user_facts f ON f.path = t.path + WHERE $where + """.trimIndent() + return DynamicQueries( + tracks = SimpleSQLiteQuery( + "SELECT t.* $base ORDER BY $sortExpression $direction, t.path ASC LIMIT ? OFFSET ?", + (args + listOf(limit, offset)).toTypedArray(), + ), + count = SimpleSQLiteQuery( + "SELECT ${if (ruleLimit > 0) "MIN(COUNT(*), $ruleLimit)" else "COUNT(*)"} $base", + args.toTypedArray(), + ), + ) + } + + private fun appendText(condition: JSONObject, clauses: MutableList, args: MutableList) { + val expression = textFields[condition.optString("field")] ?: return + val value = condition.optString("value").trim().lowercase(Locale.ROOT) + if (value.isEmpty()) return + when (condition.optString("operator")) { + "contains" -> { + clauses += "LOWER(COALESCE($expression, '')) LIKE ? ESCAPE '\\'" + args += "%${escapeLike(value)}%" + } + "is_not" -> { + clauses += "LOWER(COALESCE($expression, '')) <> ?" + args += value + } + else -> { + clauses += "LOWER(COALESCE($expression, '')) = ?" + args += value + } + } + } + + private fun appendExact(condition: JSONObject, clauses: MutableList, args: MutableList) { + val negate = condition.optString("operator") == "is_not" + when (condition.optString("field")) { + "source_type" -> { + clauses += "t.source_type ${if (negate) "<>" else "="} ?" + args += condition.optString("value") + } + "favorite" -> { + val wantsFavorite = condition.optBoolean("value") xor negate + clauses += "COALESCE(f.is_favorite, 0) = ${if (wantsFavorite) 1 else 0}" + } + } + } + + private fun appendNumeric(condition: JSONObject, clauses: MutableList, args: MutableList) { + val expression = numericFields[condition.optString("field")] ?: return + val operator = when (condition.optString("operator")) { + "gte" -> ">=" + "lte" -> "<=" + else -> "=" + } + clauses += "$expression $operator ?" + args += condition.optDouble("value") + } + + private fun appendDate(condition: JSONObject, clauses: MutableList, args: MutableList) { + val field = condition.optString("field") + val operator = condition.optString("operator") + if (field == "last_played_at" && operator == "never") { + clauses += "f.last_played_at IS NULL" + return + } + val cutoff = System.currentTimeMillis() - + condition.optInt("value", 1).coerceAtLeast(1) * 86_400_000L + when (field) { + "last_played_at" -> clauses += if (operator == "within_days") { + "f.last_played_at >= ?" + } else { + "(f.last_played_at IS NULL OR f.last_played_at < ?)" + } + "added_at" -> clauses += "t.added_at ${if (operator == "within_days") ">=" else "<"} ?" + else -> return + } + args += cutoff + } + + private fun escapeLike(value: String): String = + value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/LibraryModels.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/LibraryModels.kt new file mode 100644 index 0000000..fd7ea23 --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/LibraryModels.kt @@ -0,0 +1,239 @@ +package expo.modules.astralibraryscanner.data + +import android.icu.text.Collator +import android.util.Base64 +import java.text.Normalizer +import java.util.Locale +import org.json.JSONObject +import org.json.JSONArray + +const val COLLATION_VERSION = 1 +const val DEFAULT_PAGE_SIZE = 100 +const val MAX_PAGE_SIZE = 200 + +enum class LibraryStatus(val wireValue: String) { + INITIALIZING("initializing"), + EMPTY("empty"), + READY("ready"), + SCANNING("scanning"), + REBUILDING("rebuilding"), + DEGRADED("degraded"), + FATAL_USER_DATA("fatalUserData"), +} + +data class LibraryStatusSnapshot( + val status: LibraryStatus, + val catalogRevision: Long, + val trackCount: Long, + val message: String? = null, + val recoveryNotice: String? = null, +) { + fun toMap(): Map = mapOf( + "status" to status.wireValue, + "catalogRevision" to catalogRevision.toString(), + "trackCount" to trackCount.toDouble(), + "message" to message, + "recoveryNotice" to recoveryNotice, + ) +} + +object SortKeys { + private val collator = ThreadLocal.withInitial { + Collator.getInstance(Locale.ROOT).apply { + strength = Collator.SECONDARY + decomposition = Collator.CANONICAL_DECOMPOSITION + } + } + + fun forText(value: String): String { + val bytes = collator.get()!!.getCollationKey(value.trim()).toByteArray() + val chars = CharArray(bytes.size * 2) + val hex = "0123456789ABCDEF" + for (index in bytes.indices) { + val unsigned = bytes[index].toInt() and 0xff + chars[index * 2] = hex[unsigned ushr 4] + chars[index * 2 + 1] = hex[unsigned and 0x0f] + } + return String(chars) + } + + fun sectionLabel(value: String): String { + val normalized = Normalizer.normalize(value.trim(), Normalizer.Form.NFD) + val first = normalized.firstOrNull { Character.isLetterOrDigit(it) } ?: return "#" + val upper = first.uppercaseChar() + return if (upper in 'A'..'Z' || upper in '0'..'9') upper.toString() else "#" + } +} + +data class TrackPageCursor( + val revision: Long, + val kind: String, + val text1: String? = null, + val text2: String? = null, + val text3: String? = null, + val number1: Long? = null, + val number2: Long? = null, + val decimal1: Double? = null, +) { + fun encode(): String { + val json = JSONObject() + .put("v", 1) + .put("revision", revision) + .put("kind", kind) + .put("text1", text1) + .put("text2", text2) + .put("text3", text3) + .put("number1", number1) + .put("number2", number2) + .put("decimal1", decimal1) + return Base64.encodeToString( + json.toString().toByteArray(Charsets.UTF_8), + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING, + ) + } + + companion object { + fun decode(raw: String?): TrackPageCursor? { + if (raw.isNullOrBlank()) return null + return runCatching { + val json = JSONObject( + String( + Base64.decode(raw, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING), + Charsets.UTF_8, + ), + ) + require(json.optInt("v") == 1) + TrackPageCursor( + revision = json.getLong("revision"), + kind = json.getString("kind"), + text1 = json.optNullableString("text1"), + text2 = json.optNullableString("text2"), + text3 = json.optNullableString("text3"), + number1 = json.optNullableLong("number1"), + number2 = json.optNullableLong("number2"), + decimal1 = json.optNullableDouble("decimal1"), + ) + }.getOrNull() + } + } +} + +private fun JSONObject.optNullableString(key: String): String? = + if (isNull(key) || !has(key)) null else getString(key) + +private fun JSONObject.optNullableLong(key: String): Long? = + if (isNull(key) || !has(key)) null else getLong(key) + +private fun JSONObject.optNullableDouble(key: String): Double? = + if (isNull(key) || !has(key)) null else getDouble(key) + +fun ActiveTrackView.toBridgeMap(): Map = mapOf( + "id" to id.toDouble(), + "path" to path, + "folder_id" to folderId?.toDouble(), + "title" to title, + "artist" to artist, + "album" to album, + "album_artist" to albumArtist, + "album_identity_key" to albumIdentityKey, + "album_display_artist" to albumDisplayArtist, + "duration" to duration, + "track_number" to trackNumber, + "disc_number" to discNumber, + "year" to year, + "genre" to genre, + "artwork_hash" to artworkHash, + "format" to format, + "sample_rate" to sampleRate, + "bit_depth" to bitDepth, + "bitrate" to bitrate, + "channels" to channels, + "codec" to codec, + "source_type" to sourceType, + "source_id" to sourceId?.toDouble(), + "source_track_id" to sourceTrackId, + "source_path" to sourcePath, + "artwork_source_id" to artworkSourceId, + "file_name" to fileName, + "size" to size?.toDouble(), + "mtime" to mtime.toDouble(), + "added_at" to addedAt.toDouble(), + "modified_at" to modifiedAt.toDouble(), + "loudness_lufs" to loudnessLufs, + "sample_peak" to samplePeak, + "replay_gain_track_db" to replayGainTrackDb, + "replay_gain_album_db" to replayGainAlbumDb, + "replay_gain_track_peak" to replayGainTrackPeak, + "replay_gain_album_peak" to replayGainAlbumPeak, + "rg_scanned" to if (replayGainScanned) 1 else 0, + "play_count" to 0, + "last_played_at" to null, + "bpm" to bpm, + "musical_key" to musicalKey, +) + +fun AlbumSummaryEntity.toBridgeMap(): Map = mapOf( + "identity_key" to identityKey, + "album" to album, + "artist" to artist, + "year" to year, + "artwork_hash" to artworkHash, + "source_type" to sourceType, + "source_id" to sourceId?.toDouble(), + "artwork_source_id" to artworkSourceId, + "track_count" to trackCount.toDouble(), + "total_duration" to totalDuration, + "latest_added_at" to latestAddedAt.toDouble(), +) + +fun ArtistSummaryEntity.toBridgeMap(): Map = mapOf( + "artist" to artist, + "track_count" to trackCount.toDouble(), + "primary_track_count" to primaryTrackCount.toDouble(), + "album_count" to albumCount.toDouble(), + "artwork_hash" to artworkHash, + "source_type" to sourceType, + "source_id" to sourceId?.toDouble(), + "artwork_source_id" to artworkSourceId, + "is_collaboration" to isCollaboration, + "artwork_hashes" to runCatching { + val array = JSONArray(artworkHashesJson) + List(array.length()) { index -> array.getString(index) } + }.getOrDefault(emptyList()), +) + +fun RemoteSourceEntity.toBridgeMap(): Map = mapOf( + "id" to id.toDouble(), + "type" to type, + "name" to name, + "base_url" to baseUrl, + "username" to username, + "enabled" to if (enabled) 1 else 0, + "last_status" to lastStatus, + "last_error" to lastError, + "last_sync_at" to lastSyncAt?.toDouble(), + "last_checked_at" to lastCheckedAt?.toDouble(), + "access_token" to null, + "user_id" to null, + "device_id" to null, + "art_auth" to null, + "created_at" to createdAt.toDouble(), + "updated_at" to updatedAt.toDouble(), +) + +fun PlaylistEntity.toBridgeMap( + trackCount: Long, + missingCount: Long, + artworkHash: String?, +): Map = mapOf( + "id" to id.toDouble(), + "name" to name, + "kind" to kind, + "created_at" to createdAt.toDouble(), + "updated_at" to updatedAt.toDouble(), + "last_played_at" to lastPlayedAt?.toDouble(), + "auto_cover_hash" to artworkHash, + "track_count" to trackCount.toDouble(), + "missing_track_count" to missingCount.toDouble(), + "remote_source_id" to remoteSourceId?.toDouble(), +) diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/MediaTagCleanup.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/MediaTagCleanup.kt new file mode 100644 index 0000000..1c82887 --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/MediaTagCleanup.kt @@ -0,0 +1,39 @@ +package expo.modules.astralibraryscanner.data + +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction +import java.nio.charset.Charset + +/** + * Repairs the narrow legacy-ID3 failure where Android exposed Shift-JIS or + * EUC-JP bytes as Latin-1 code points. This is deliberately separate from + * Room binding: correctly decoded Unicode is returned untouched. + */ +internal object MediaTagCleanup { + private val cjk = Regex("[\\u3040-\\u30ff\\u3400-\\u9fff\\uac00-\\ud7af\\uf900-\\ufaff\\uff00-\\uffef]") + + fun clean(value: String?): String? { + val trimmed = value?.trim()?.takeIf(String::isNotEmpty) ?: return null + return repairLegacyJapaneseMojibake(trimmed) + } + + private fun repairLegacyJapaneseMojibake(value: String): String { + if (value.none { it.code >= 0x80 }) return value + if (value.any { it.code > 0xff }) return value + val bytes = ByteArray(value.length) { value[it].code.toByte() } + val candidates = listOf("Shift_JIS", "EUC-JP").mapNotNull { charsetName -> + runCatching { + val charset = Charset.forName(charsetName) + val decoder = charset.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + val decoded = decoder.decode(ByteBuffer.wrap(bytes)).toString() + val roundTrip = decoded.toByteArray(charset) + decoded.takeIf { roundTrip.contentEquals(bytes) && cjk.containsMatchIn(decoded) } + }.getOrNull() + } + return candidates.maxByOrNull { candidate -> + cjk.findAll(candidate).count() + } ?: value + } +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt new file mode 100644 index 0000000..122bc05 --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt @@ -0,0 +1,441 @@ +package expo.modules.astralibraryscanner.data + +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.RoomDatabase +import androidx.room.Transaction +import androidx.room.Upsert + +data class RemotePlaylistSyncPlan( + val playlist: PlaylistEntity, + val entries: List, +) + +@Dao +interface UserDao { + @Query("SELECT * FROM settings WHERE key IN (:keys)") + suspend fun getSettings(keys: List): List + + @Query("SELECT value FROM settings WHERE key = :key") + suspend fun getSetting(key: String): String? + + @Upsert + suspend fun putSettings(settings: List) + + @Query("DELETE FROM settings WHERE key IN (:keys)") + suspend fun deleteSettings(keys: List) + + @Query("SELECT * FROM settings ORDER BY key") + suspend fun snapshotSettings(): List + + @Query("SELECT * FROM folders ORDER BY added_at, id") + suspend fun getFolders(): List + + @Query("SELECT * FROM folders WHERE id = :id") + suspend fun getFolder(id: Long): FolderEntity? + + @Query("SELECT * FROM folders WHERE tree_uri = :treeUri") + suspend fun getFolderByTreeUri(treeUri: String): FolderEntity? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertFolder(folder: FolderEntity): Long + + @Upsert + suspend fun putFolders(folders: List) + + @Query( + """ + UPDATE folders + SET display_name = :displayName + WHERE tree_uri = :treeUri + """, + ) + suspend fun updateFolderName(treeUri: String, displayName: String) + + @Query( + """ + UPDATE folders + SET last_scanned_at = :scannedAt, + last_scan_status = :status, + last_scan_error = :error + WHERE id = :folderId + """, + ) + suspend fun updateFolderScanState( + folderId: Long, + scannedAt: Long?, + status: String, + error: String?, + ) + + @Delete + suspend fun deleteFolder(folder: FolderEntity) + + @Query("SELECT * FROM playlists ORDER BY COALESCE(last_played_at, 0) DESC, updated_at DESC, id") + suspend fun getPlaylists(): List + + @Query("SELECT * FROM playlists WHERE id = :id") + suspend fun getPlaylist(id: Long): PlaylistEntity? + + @Query("SELECT * FROM playlists WHERE sync_uid = :syncUid LIMIT 1") + suspend fun getPlaylistBySyncUid(syncUid: String): PlaylistEntity? + + @Query("SELECT * FROM playlists WHERE remote_source_id IS NULL ORDER BY id") + suspend fun getLocalPlaylists(): List + + @Insert + suspend fun insertPlaylist(playlist: PlaylistEntity): Long + + @Upsert + suspend fun putPlaylist(playlist: PlaylistEntity) + + @Upsert + suspend fun putPlaylists(playlists: List) + + @Query("DELETE FROM playlists WHERE id = :id") + suspend fun deletePlaylistById(id: Long) + + @Query("DELETE FROM playlists WHERE sync_uid = :syncUid") + suspend fun deletePlaylistBySyncUid(syncUid: String) + + @Query("UPDATE playlists SET sync_uid = :syncUid WHERE id = :playlistId") + suspend fun updatePlaylistSyncUid(playlistId: Long, syncUid: String) + + @Query("SELECT * FROM playlist_tracks WHERE playlist_id = :playlistId ORDER BY position, id") + suspend fun getPlaylistTracks(playlistId: Long): List + + @Query("SELECT * FROM playlist_tracks WHERE playlist_id = :playlistId ORDER BY position, id LIMIT :limit OFFSET :offset") + suspend fun getPlaylistTrackPage(playlistId: Long, limit: Int, offset: Int): List + + @Query("SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = :playlistId") + suspend fun countPlaylistTracks(playlistId: Long): Long + + @Query("SELECT COALESCE(MAX(position), -1) FROM playlist_tracks WHERE playlist_id = :playlistId") + suspend fun maxPlaylistPosition(playlistId: Long): Int + + @Query("SELECT * FROM playlist_tracks WHERE playlist_id = :playlistId AND track_path = :path") + suspend fun getPlaylistTrackByPath(playlistId: Long, path: String): PlaylistTrackEntity? + + @Query("SELECT * FROM playlist_tracks ORDER BY playlist_id, position, id") + suspend fun snapshotPlaylistTracks(): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertPlaylistTracks(entries: List): List + + @Upsert + suspend fun putPlaylistTracks(entries: List) + + @Query("DELETE FROM playlist_tracks WHERE id = :entryId") + suspend fun deletePlaylistTrack(entryId: Long) + + @Query("DELETE FROM playlist_tracks WHERE playlist_id = :playlistId") + suspend fun clearPlaylistTracks(playlistId: Long) + + @Query("UPDATE playlist_tracks SET position = :position WHERE id = :entryId") + suspend fun updatePlaylistTrackPosition(entryId: Long, position: Int) + + @Query("UPDATE playlists SET updated_at = :updatedAt WHERE id = :playlistId") + suspend fun touchPlaylist(playlistId: Long, updatedAt: Long) + + @Query("SELECT * FROM favorites ORDER BY added_at DESC") + suspend fun getFavorites(): List + + @Query("SELECT EXISTS(SELECT 1 FROM favorites WHERE track_path = :path)") + suspend fun isFavorite(path: String): Boolean + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun putFavorite(favorite: FavoriteEntity) + + @Upsert + suspend fun putFavorites(favorites: List) + + @Query("DELETE FROM favorites WHERE track_path = :path") + suspend fun deleteFavorite(path: String) + + @Query("DELETE FROM favorites WHERE track_path LIKE :prefix || '%'") + suspend fun deleteFavoritesByPrefix(prefix: String) + + @Query("DELETE FROM playlists WHERE remote_source_id = :sourceId") + suspend fun deleteRemotePlaylists(sourceId: Long) + + @Query("SELECT * FROM playlists WHERE remote_source_id = :sourceId") + suspend fun getRemotePlaylists(sourceId: Long): List + + @Query("SELECT * FROM playback_history ORDER BY last_played_at DESC") + suspend fun getPlaybackHistory(): List + + @Query("SELECT * FROM playback_history WHERE track_path = :path") + suspend fun getPlaybackHistory(path: String): PlaybackHistoryEntity? + + @Upsert + suspend fun putPlaybackHistory(history: PlaybackHistoryEntity) + + @Upsert + suspend fun putPlaybackHistories(history: List) + + @Query("SELECT * FROM remote_sources ORDER BY created_at, id") + suspend fun getRemoteSources(): List + + @Query("SELECT * FROM remote_sources WHERE id = :id") + suspend fun getRemoteSource(id: Long): RemoteSourceEntity? + + @Insert + suspend fun insertRemoteSource(source: RemoteSourceEntity): Long + + @Upsert + suspend fun putRemoteSource(source: RemoteSourceEntity) + + @Upsert + suspend fun putRemoteSources(sources: List) + + @Query("DELETE FROM remote_sources WHERE id = :id") + suspend fun deleteRemoteSource(id: Long) + + @Query("SELECT * FROM favorite_tombstones ORDER BY sync_key") + suspend fun getFavoriteTombstones(): List + + @Upsert + suspend fun putFavoriteTombstones(rows: List) + + @Query("DELETE FROM favorite_tombstones WHERE sync_key IN (:syncKeys)") + suspend fun deleteFavoriteTombstones(syncKeys: List) + + @Query("SELECT * FROM favorite_sync_pending ORDER BY sync_key") + suspend fun getPendingFavorites(): List + + @Upsert + suspend fun putPendingFavorites(rows: List) + + @Query("DELETE FROM favorite_sync_pending WHERE sync_key IN (:syncKeys)") + suspend fun deletePendingFavorites(syncKeys: List) + + @Query("SELECT * FROM playlist_tombstones ORDER BY sync_uid") + suspend fun getPlaylistTombstones(): List + + @Upsert + suspend fun putPlaylistTombstones(rows: List) + + @Query("DELETE FROM playlist_tombstones WHERE sync_uid IN (:syncUids)") + suspend fun deletePlaylistTombstones(syncUids: List) + + @Query("SELECT * FROM playlist_sync_state ORDER BY sync_uid") + suspend fun getPlaylistSyncStates(): List + + @Upsert + suspend fun putPlaylistSyncStates(rows: List) + + @Query("DELETE FROM playlist_sync_state WHERE sync_uid IN (:syncUids)") + suspend fun deletePlaylistSyncStates(syncUids: List) + + @Query("DELETE FROM playlist_sync_state") + suspend fun clearPlaylistSyncStates() + + @Query("SELECT * FROM playback_sessions WHERE id = :id") + suspend fun getPlaybackSession(id: String): PlaybackSessionEntity? + + @Query("SELECT * FROM playback_sessions ORDER BY updated_at DESC LIMIT 1") + suspend fun getLatestPlaybackSession(): PlaybackSessionEntity? + + @Query("SELECT * FROM playback_sessions ORDER BY id") + suspend fun getPlaybackSessions(): List + + @Upsert + suspend fun putPlaybackSession(session: PlaybackSessionEntity) + + @Query( + """ + UPDATE playback_sessions + SET active_position = :activePosition, + anchor_path = :anchorPath, + updated_at = :updatedAt + WHERE id = :sessionId + """, + ) + suspend fun updatePlaybackPosition( + sessionId: String, + activePosition: Long, + anchorPath: String?, + updatedAt: Long, + ) + + @Query("DELETE FROM playback_sessions WHERE id = :id") + suspend fun deletePlaybackSession(id: String) + + @Query( + """ + SELECT * FROM playback_queue_entries + WHERE session_id = :sessionId + AND position >= :start + ORDER BY position + LIMIT :limit + """, + ) + suspend fun getQueueWindow( + sessionId: String, + start: Long, + limit: Int, + ): List + + @Query("SELECT * FROM playback_queue_entries WHERE session_id = :sessionId ORDER BY position") + suspend fun getAllQueueEntries(sessionId: String): List + + @Upsert + suspend fun putQueueEntries(entries: List) + + @Query("DELETE FROM playback_queue_entries WHERE session_id = :sessionId") + suspend fun clearQueueEntries(sessionId: String) + + @Query("SELECT COUNT(*) FROM playback_queue_entries WHERE session_id = :sessionId") + suspend fun countQueueEntries(sessionId: String): Long + + @Query("SELECT * FROM playback_original_queue_entries WHERE session_id = :sessionId ORDER BY position") + suspend fun getOriginalQueueEntries(sessionId: String): List + + @Upsert + suspend fun putOriginalQueueEntries(entries: List) + + @Query("DELETE FROM playback_original_queue_entries WHERE session_id = :sessionId") + suspend fun clearOriginalQueueEntries(sessionId: String) + + @Query("SELECT * FROM snapshot_metadata WHERE id = 1") + suspend fun getSnapshotMetadata(): SnapshotMetadataEntity? + + @Upsert + suspend fun putSnapshotMetadata(metadata: SnapshotMetadataEntity) + + @Transaction + suspend fun replacePlaybackQueue( + session: PlaybackSessionEntity, + entries: List, + originalEntries: List = emptyList(), + ) { + putPlaybackSession(session) + clearQueueEntries(session.id) + if (entries.isNotEmpty()) putQueueEntries(entries) + clearOriginalQueueEntries(session.id) + if (originalEntries.isNotEmpty()) putOriginalQueueEntries(originalEntries) + } + + @Transaction + suspend fun replaceRemoteUserState( + sourceId: Long, + favoritePrefix: String, + favorites: List, + playlists: List, + ) { + deleteFavoritesByPrefix(favoritePrefix) + if (favorites.isNotEmpty()) putFavorites(favorites) + + val existing = getRemotePlaylists(sourceId).associateBy { it.remotePlaylistId } + val incomingIds = playlists.mapNotNullTo(hashSetOf()) { it.playlist.remotePlaylistId } + for (stale in existing.values) { + if (stale.remotePlaylistId !in incomingIds) deletePlaylistById(stale.id) + } + for (plan in playlists) { + val remoteId = plan.playlist.remotePlaylistId ?: continue + val old = existing[remoteId] + val playlistId = if (old == null) { + insertPlaylist(plan.playlist) + } else { + putPlaylist( + plan.playlist.copy( + id = old.id, + createdAt = old.createdAt, + lastPlayedAt = old.lastPlayedAt, + ), + ) + old.id + } + replacePlaylistEntries( + playlistId, + plan.entries.map { it.copy(playlistId = playlistId) }, + ) + } + } + + @Transaction + suspend fun replacePlaylistEntries( + playlistId: Long, + entries: List, + ) { + clearPlaylistTracks(playlistId) + if (entries.isNotEmpty()) putPlaylistTracks(entries) + } + + @Transaction + suspend fun appendPlaylistTracks( + playlistId: Long, + entries: List, + updatedAt: Long, + ): Int { + val existing = getPlaylistTracks(playlistId).mapTo(hashSetOf()) { it.trackPath } + var position = maxPlaylistPosition(playlistId) + var inserted = 0 + for (entry in entries) { + if (!existing.add(entry.trackPath)) continue + position += 1 + val result = insertPlaylistTracks(listOf(entry.copy(position = position, addedAt = updatedAt))) + if (result.firstOrNull() != -1L) inserted += 1 + } + if (inserted > 0) touchPlaylist(playlistId, updatedAt) + return inserted + } + + @Transaction + suspend fun removePlaylistTrackByPath(playlistId: Long, path: String, updatedAt: Long) { + val row = getPlaylistTrackByPath(playlistId, path) ?: return + deletePlaylistTrack(row.id) + getPlaylistTracks(playlistId).forEachIndexed { index, entry -> + if (entry.position != index) updatePlaylistTrackPosition(entry.id, index) + } + touchPlaylist(playlistId, updatedAt) + } + + @Transaction + suspend fun movePlaylistTrackByPath( + playlistId: Long, + path: String, + direction: Int, + updatedAt: Long, + ) { + val rows = getPlaylistTracks(playlistId) + val index = rows.indexOfFirst { it.trackPath == path } + if (index < 0) return + val neighborIndex = index + direction + if (neighborIndex !in rows.indices) return + val row = rows[index] + val neighbor = rows[neighborIndex] + updatePlaylistTrackPosition(row.id, neighbor.position) + updatePlaylistTrackPosition(neighbor.id, row.position) + touchPlaylist(playlistId, updatedAt) + } +} + +@Database( + entities = [ + SettingEntity::class, + FolderEntity::class, + PlaylistEntity::class, + PlaylistTrackEntity::class, + FavoriteEntity::class, + PlaybackHistoryEntity::class, + RemoteSourceEntity::class, + FavoriteTombstoneEntity::class, + PendingFavoriteEntity::class, + PlaylistTombstoneEntity::class, + PlaylistSyncStateEntity::class, + PlaybackSessionEntity::class, + PlaybackQueueEntryEntity::class, + PlaybackOriginalQueueEntryEntity::class, + SnapshotMetadataEntity::class, + ], + version = 1, + exportSchema = true, +) +abstract class AstraUserDatabase : RoomDatabase() { + abstract fun userDao(): UserDao +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserEntities.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserEntities.kt new file mode 100644 index 0000000..30b853a --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserEntities.kt @@ -0,0 +1,210 @@ +package expo.modules.astralibraryscanner.data + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity(tableName = "settings") +data class SettingEntity( + @PrimaryKey val key: String, + val value: String, +) + +@Entity( + tableName = "folders", + indices = [Index(value = ["tree_uri"], unique = true)], +) +data class FolderEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(name = "tree_uri") val treeUri: String, + @ColumnInfo(name = "display_name") val displayName: String, + @ColumnInfo(name = "added_at") val addedAt: Long, + @ColumnInfo(name = "last_scanned_at") val lastScannedAt: Long? = null, + @ColumnInfo(name = "last_scan_status") val lastScanStatus: String = "never", + @ColumnInfo(name = "last_scan_error") val lastScanError: String? = null, +) + +@Entity( + tableName = "playlists", + indices = [ + Index(value = ["sync_uid"], unique = true), + Index(value = ["remote_source_id", "remote_playlist_id"], unique = true), + Index(value = ["kind"]), + ], +) +data class PlaylistEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + @ColumnInfo(name = "created_at") val createdAt: Long, + @ColumnInfo(name = "updated_at") val updatedAt: Long, + @ColumnInfo(name = "last_played_at") val lastPlayedAt: Long? = null, + val kind: String = "normal", + @ColumnInfo(name = "dynamic_rules_json") val dynamicRulesJson: String? = null, + @ColumnInfo(name = "remote_source_id") val remoteSourceId: Long? = null, + @ColumnInfo(name = "remote_playlist_id") val remotePlaylistId: String? = null, + @ColumnInfo(name = "sync_uid") val syncUid: String? = null, +) + +@Entity( + tableName = "playlist_tracks", + foreignKeys = [ + ForeignKey( + entity = PlaylistEntity::class, + parentColumns = ["id"], + childColumns = ["playlist_id"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index(value = ["playlist_id", "position"]), + Index(value = ["playlist_id", "track_path"], unique = true), + ], +) +data class PlaylistTrackEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(name = "playlist_id") val playlistId: Long, + @ColumnInfo(name = "track_path") val trackPath: String, + val position: Int, + @ColumnInfo(name = "added_at") val addedAt: Long, + @ColumnInfo(name = "fallback_title") val fallbackTitle: String? = null, + @ColumnInfo(name = "fallback_artist") val fallbackArtist: String? = null, + @ColumnInfo(name = "fallback_album") val fallbackAlbum: String? = null, +) + +@Entity(tableName = "favorites") +data class FavoriteEntity( + @PrimaryKey + @ColumnInfo(name = "track_path") + val trackPath: String, + @ColumnInfo(name = "added_at") val addedAt: Long, +) + +@Entity( + tableName = "playback_history", + indices = [Index(value = ["last_played_at"])], +) +data class PlaybackHistoryEntity( + @PrimaryKey + @ColumnInfo(name = "track_path") + val trackPath: String, + @ColumnInfo(name = "last_played_at") val lastPlayedAt: Long, + @ColumnInfo(name = "play_count") val playCount: Long = 1, +) + +@Entity( + tableName = "remote_sources", + indices = [Index(value = ["type", "name"])], +) +data class RemoteSourceEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val type: String, + val name: String, + @ColumnInfo(name = "base_url") val baseUrl: String, + val username: String, + val enabled: Boolean = true, + @ColumnInfo(name = "last_status") val lastStatus: String = "unknown", + @ColumnInfo(name = "last_error") val lastError: String? = null, + @ColumnInfo(name = "last_sync_at") val lastSyncAt: Long? = null, + @ColumnInfo(name = "last_checked_at") val lastCheckedAt: Long? = null, + @ColumnInfo(name = "created_at") val createdAt: Long, + @ColumnInfo(name = "updated_at") val updatedAt: Long, +) + +@Entity(tableName = "favorite_tombstones") +data class FavoriteTombstoneEntity( + @PrimaryKey + @ColumnInfo(name = "sync_key") + val syncKey: String, + @ColumnInfo(name = "deleted_at") val deletedAt: Long, +) + +@Entity(tableName = "favorite_sync_pending") +data class PendingFavoriteEntity( + @PrimaryKey + @ColumnInfo(name = "sync_key") + val syncKey: String, + val title: String, + val artist: String, + val album: String, + @ColumnInfo(name = "added_at") val addedAt: Long, +) + +@Entity(tableName = "playlist_tombstones") +data class PlaylistTombstoneEntity( + @PrimaryKey + @ColumnInfo(name = "sync_uid") + val syncUid: String, + @ColumnInfo(name = "deleted_at") val deletedAt: Long, +) + +@Entity(tableName = "playlist_sync_state") +data class PlaylistSyncStateEntity( + @PrimaryKey + @ColumnInfo(name = "sync_uid") + val syncUid: String, + @ColumnInfo(name = "local_updated_at") val localUpdatedAt: Long, + @ColumnInfo(name = "remote_updated_at") val remoteUpdatedAt: Long, +) + +/** + * Compact, durable descriptor for a virtual playback context. Track rows remain + * in the rebuildable catalog and are resolved lazily when a window is requested. + */ +@Entity(tableName = "playback_sessions") +data class PlaybackSessionEntity( + @PrimaryKey val id: String, + @ColumnInfo(name = "context_json") val contextJson: String, + @ColumnInfo(name = "anchor_path") val anchorPath: String?, + @ColumnInfo(name = "shuffle_seed") val shuffleSeed: Long?, + @ColumnInfo(name = "active_position") val activePosition: Long, + @ColumnInfo(name = "created_at") val createdAt: Long, + @ColumnInfo(name = "updated_at") val updatedAt: Long, +) + +@Entity( + tableName = "playback_queue_entries", + primaryKeys = ["session_id", "position"], + foreignKeys = [ + ForeignKey( + entity = PlaybackSessionEntity::class, + parentColumns = ["id"], + childColumns = ["session_id"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index(value = ["session_id", "track_path"]), + ], +) +data class PlaybackQueueEntryEntity( + @ColumnInfo(name = "session_id") val sessionId: String, + val position: Long, + @ColumnInfo(name = "track_path") val trackPath: String, +) + +@Entity( + tableName = "playback_original_queue_entries", + primaryKeys = ["session_id", "position"], + foreignKeys = [ + ForeignKey( + entity = PlaybackSessionEntity::class, + parentColumns = ["id"], + childColumns = ["session_id"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index(value = ["session_id", "track_path"])], +) +data class PlaybackOriginalQueueEntryEntity( + @ColumnInfo(name = "session_id") val sessionId: String, + val position: Long, + @ColumnInfo(name = "track_path") val trackPath: String, +) + +@Entity(tableName = "snapshot_metadata") +data class SnapshotMetadataEntity( + @PrimaryKey val id: Int = 1, + @ColumnInfo(name = "last_snapshot_at") val lastSnapshotAt: Long, +) diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserSnapshotStore.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserSnapshotStore.kt new file mode 100644 index 0000000..24200dd --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserSnapshotStore.kt @@ -0,0 +1,370 @@ +package expo.modules.astralibraryscanner.data + +import android.content.Context +import androidx.room.withTransaction +import java.io.File +import java.io.FileOutputStream +import java.security.MessageDigest +import org.json.JSONArray +import org.json.JSONObject + +private const val SNAPSHOT_SCHEMA_VERSION = 1 + +/** + * Rotating, checksummed snapshots are deliberately independent of SQLite. They + * are small enough to restore user-created state even if the Room file and its + * WAL are unreadable. Rebuildable catalog data and secrets are never included. + */ +class UserSnapshotStore( + context: Context, +) { + private val directory = File(context.filesDir, "astra-user-snapshots") + private val files = listOf( + File(directory, "user-a.json"), + File(directory, "user-b.json"), + ) + + suspend fun write(database: AstraUserDatabase) { + val payload = database.withTransaction { + val dao = database.userDao() + val json = JSONObject() + json.put("settings", dao.snapshotSettings().toJsonArray { it.toJson() }) + json.put("folders", dao.getFolders().toJsonArray { it.toJson() }) + json.put("playlists", dao.getPlaylists().toJsonArray { it.toJson() }) + json.put("playlistTracks", dao.snapshotPlaylistTracks().toJsonArray { it.toJson() }) + json.put("favorites", dao.getFavorites().toJsonArray { it.toJson() }) + json.put("playbackHistory", dao.getPlaybackHistory().toJsonArray { it.toJson() }) + json.put("remoteSources", dao.getRemoteSources().toJsonArray { it.toJson() }) + json.put("favoriteTombstones", dao.getFavoriteTombstones().toJsonArray { it.toJson() }) + json.put("pendingFavorites", dao.getPendingFavorites().toJsonArray { it.toJson() }) + json.put("playlistTombstones", dao.getPlaylistTombstones().toJsonArray { it.toJson() }) + json.put("playlistSyncStates", dao.getPlaylistSyncStates().toJsonArray { it.toJson() }) + val sessions = dao.getPlaybackSessions() + json.put("playbackSessions", sessions.toJsonArray { it.toJson() }) + json.put( + "playbackQueues", + sessions + .flatMap { dao.getAllQueueEntries(it.id) } + .toJsonArray { it.toJson() }, + ) + json.put( + "playbackOriginalQueues", + sessions + .flatMap { dao.getOriginalQueueEntries(it.id) } + .toJsonArray { it.toJson() }, + ) + json + } + + val payloadString = payload.toString() + val envelope = JSONObject() + .put("schemaVersion", SNAPSHOT_SCHEMA_VERSION) + .put("createdAt", System.currentTimeMillis()) + .put("checksum", sha256(payloadString)) + .put("payload", payloadString) + + directory.mkdirs() + val target = files.minByOrNull { if (it.exists()) it.lastModified() else Long.MIN_VALUE } ?: files.first() + val temporary = File(directory, "${target.name}.tmp") + FileOutputStream(temporary).use { output -> + output.write(envelope.toString().toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + if (target.exists() && !target.delete()) { + temporary.delete() + error("Could not rotate Astra user snapshot") + } + if (!temporary.renameTo(target)) { + temporary.delete() + error("Could not publish Astra user snapshot") + } + } + + fun newestValid(): UserSnapshot? = + files + .filter(File::isFile) + .sortedByDescending(File::lastModified) + .firstNotNullOfOrNull { readValid(it) } + + suspend fun restore(database: AstraUserDatabase, snapshot: UserSnapshot) { + val payload = snapshot.payload + database.clearAllTables() + database.withTransaction { + val dao = database.userDao() + dao.putSettings(payload.array("settings").mapObjects(::settingFromJson)) + dao.putFolders(payload.array("folders").mapObjects(::folderFromJson)) + dao.putPlaylists(payload.array("playlists").mapObjects(::playlistFromJson)) + dao.putPlaylistTracks(payload.array("playlistTracks").mapObjects(::playlistTrackFromJson)) + dao.putFavorites(payload.array("favorites").mapObjects(::favoriteFromJson)) + dao.putPlaybackHistories(payload.array("playbackHistory").mapObjects(::historyFromJson)) + dao.putRemoteSources(payload.array("remoteSources").mapObjects(::remoteSourceFromJson)) + dao.putFavoriteTombstones(payload.array("favoriteTombstones").mapObjects(::favoriteTombstoneFromJson)) + dao.putPendingFavorites(payload.array("pendingFavorites").mapObjects(::pendingFavoriteFromJson)) + dao.putPlaylistTombstones(payload.array("playlistTombstones").mapObjects(::playlistTombstoneFromJson)) + dao.putPlaylistSyncStates(payload.array("playlistSyncStates").mapObjects(::playlistSyncStateFromJson)) + val sessions = payload.array("playbackSessions").mapObjects(::playbackSessionFromJson) + if (sessions.isNotEmpty()) { + sessions.forEach { dao.putPlaybackSession(it) } + } else { + // Backwards-compatible with the first Room snapshot format. + payload.optJSONObject("playbackSession")?.let { + dao.putPlaybackSession(playbackSessionFromJson(it)) + } + } + dao.putQueueEntries(payload.array("playbackQueues").mapObjects(::playbackQueueFromJson)) + dao.putOriginalQueueEntries( + payload.array("playbackOriginalQueues").mapObjects(::playbackOriginalQueueFromJson), + ) + dao.putSnapshotMetadata(SnapshotMetadataEntity(lastSnapshotAt = snapshot.createdAt)) + } + } + + private fun readValid(file: File): UserSnapshot? = runCatching { + val envelope = JSONObject(file.readText()) + require(envelope.getInt("schemaVersion") == SNAPSHOT_SCHEMA_VERSION) + val payloadString = envelope.getString("payload") + val expected = envelope.getString("checksum") + require(MessageDigest.isEqual(expected.toByteArray(), sha256(payloadString).toByteArray())) + UserSnapshot( + createdAt = envelope.getLong("createdAt"), + payload = JSONObject(payloadString), + ) + }.getOrNull() +} + +data class UserSnapshot( + val createdAt: Long, + val payload: JSONObject, +) + +private fun sha256(value: String): String = + MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + +private fun List.toJsonArray(transform: (T) -> JSONObject): JSONArray = + JSONArray().also { array -> forEach { array.put(transform(it)) } } + +private fun JSONObject.array(key: String): JSONArray = optJSONArray(key) ?: JSONArray() + +private fun JSONArray.mapObjects(transform: (JSONObject) -> T): List = + buildList(length()) { + for (index in 0 until length()) add(transform(getJSONObject(index))) + } + +private fun JSONObject.putNullable(key: String, value: Any?): JSONObject = + put(key, value ?: JSONObject.NULL) + +private fun JSONObject.nullableString(key: String): String? = + if (!has(key) || isNull(key)) null else getString(key) + +private fun JSONObject.nullableLong(key: String): Long? = + if (!has(key) || isNull(key)) null else getLong(key) + +private fun SettingEntity.toJson() = JSONObject() + .put("key", key) + .put("value", value) + +private fun settingFromJson(json: JSONObject) = SettingEntity( + key = json.getString("key"), + value = json.getString("value"), +) + +private fun FolderEntity.toJson() = JSONObject() + .put("id", id) + .put("treeUri", treeUri) + .put("displayName", displayName) + .put("addedAt", addedAt) + .putNullable("lastScannedAt", lastScannedAt) + .put("lastScanStatus", lastScanStatus) + .putNullable("lastScanError", lastScanError) + +private fun folderFromJson(json: JSONObject) = FolderEntity( + id = json.getLong("id"), + treeUri = json.getString("treeUri"), + displayName = json.getString("displayName"), + addedAt = json.getLong("addedAt"), + lastScannedAt = json.nullableLong("lastScannedAt"), + lastScanStatus = json.getString("lastScanStatus"), + lastScanError = json.nullableString("lastScanError"), +) + +private fun PlaylistEntity.toJson() = JSONObject() + .put("id", id) + .put("name", name) + .put("createdAt", createdAt) + .put("updatedAt", updatedAt) + .putNullable("lastPlayedAt", lastPlayedAt) + .put("kind", kind) + .putNullable("dynamicRulesJson", dynamicRulesJson) + .putNullable("remoteSourceId", remoteSourceId) + .putNullable("remotePlaylistId", remotePlaylistId) + .putNullable("syncUid", syncUid) + +private fun playlistFromJson(json: JSONObject) = PlaylistEntity( + id = json.getLong("id"), + name = json.getString("name"), + createdAt = json.getLong("createdAt"), + updatedAt = json.getLong("updatedAt"), + lastPlayedAt = json.nullableLong("lastPlayedAt"), + kind = json.getString("kind"), + dynamicRulesJson = json.nullableString("dynamicRulesJson"), + remoteSourceId = json.nullableLong("remoteSourceId"), + remotePlaylistId = json.nullableString("remotePlaylistId"), + syncUid = json.nullableString("syncUid"), +) + +private fun PlaylistTrackEntity.toJson() = JSONObject() + .put("id", id) + .put("playlistId", playlistId) + .put("trackPath", trackPath) + .put("position", position) + .put("addedAt", addedAt) + .putNullable("fallbackTitle", fallbackTitle) + .putNullable("fallbackArtist", fallbackArtist) + .putNullable("fallbackAlbum", fallbackAlbum) + +private fun playlistTrackFromJson(json: JSONObject) = PlaylistTrackEntity( + id = json.getLong("id"), + playlistId = json.getLong("playlistId"), + trackPath = json.getString("trackPath"), + position = json.getInt("position"), + addedAt = json.getLong("addedAt"), + fallbackTitle = json.nullableString("fallbackTitle"), + fallbackArtist = json.nullableString("fallbackArtist"), + fallbackAlbum = json.nullableString("fallbackAlbum"), +) + +private fun FavoriteEntity.toJson() = JSONObject() + .put("trackPath", trackPath) + .put("addedAt", addedAt) + +private fun favoriteFromJson(json: JSONObject) = FavoriteEntity( + trackPath = json.getString("trackPath"), + addedAt = json.getLong("addedAt"), +) + +private fun PlaybackHistoryEntity.toJson() = JSONObject() + .put("trackPath", trackPath) + .put("lastPlayedAt", lastPlayedAt) + .put("playCount", playCount) + +private fun historyFromJson(json: JSONObject) = PlaybackHistoryEntity( + trackPath = json.getString("trackPath"), + lastPlayedAt = json.getLong("lastPlayedAt"), + playCount = json.getLong("playCount"), +) + +private fun RemoteSourceEntity.toJson() = JSONObject() + .put("id", id) + .put("type", type) + .put("name", name) + .put("baseUrl", baseUrl) + .put("username", username) + .put("enabled", enabled) + .put("lastStatus", lastStatus) + .putNullable("lastError", lastError) + .putNullable("lastSyncAt", lastSyncAt) + .putNullable("lastCheckedAt", lastCheckedAt) + .put("createdAt", createdAt) + .put("updatedAt", updatedAt) + +private fun remoteSourceFromJson(json: JSONObject) = RemoteSourceEntity( + id = json.getLong("id"), + type = json.getString("type"), + name = json.getString("name"), + baseUrl = json.getString("baseUrl"), + username = json.getString("username"), + enabled = json.getBoolean("enabled"), + lastStatus = json.getString("lastStatus"), + lastError = json.nullableString("lastError"), + lastSyncAt = json.nullableLong("lastSyncAt"), + lastCheckedAt = json.nullableLong("lastCheckedAt"), + createdAt = json.getLong("createdAt"), + updatedAt = json.getLong("updatedAt"), +) + +private fun FavoriteTombstoneEntity.toJson() = JSONObject() + .put("syncKey", syncKey) + .put("deletedAt", deletedAt) + +private fun favoriteTombstoneFromJson(json: JSONObject) = FavoriteTombstoneEntity( + syncKey = json.getString("syncKey"), + deletedAt = json.getLong("deletedAt"), +) + +private fun PendingFavoriteEntity.toJson() = JSONObject() + .put("syncKey", syncKey) + .put("title", title) + .put("artist", artist) + .put("album", album) + .put("addedAt", addedAt) + +private fun pendingFavoriteFromJson(json: JSONObject) = PendingFavoriteEntity( + syncKey = json.getString("syncKey"), + title = json.getString("title"), + artist = json.getString("artist"), + album = json.getString("album"), + addedAt = json.getLong("addedAt"), +) + +private fun PlaylistTombstoneEntity.toJson() = JSONObject() + .put("syncUid", syncUid) + .put("deletedAt", deletedAt) + +private fun playlistTombstoneFromJson(json: JSONObject) = PlaylistTombstoneEntity( + syncUid = json.getString("syncUid"), + deletedAt = json.getLong("deletedAt"), +) + +private fun PlaylistSyncStateEntity.toJson() = JSONObject() + .put("syncUid", syncUid) + .put("localUpdatedAt", localUpdatedAt) + .put("remoteUpdatedAt", remoteUpdatedAt) + +private fun playlistSyncStateFromJson(json: JSONObject) = PlaylistSyncStateEntity( + syncUid = json.getString("syncUid"), + localUpdatedAt = json.getLong("localUpdatedAt"), + remoteUpdatedAt = json.getLong("remoteUpdatedAt"), +) + +private fun PlaybackSessionEntity.toJson() = JSONObject() + .put("id", id) + .put("contextJson", contextJson) + .putNullable("anchorPath", anchorPath) + .putNullable("shuffleSeed", shuffleSeed) + .put("activePosition", activePosition) + .put("createdAt", createdAt) + .put("updatedAt", updatedAt) + +private fun playbackSessionFromJson(json: JSONObject) = PlaybackSessionEntity( + id = json.getString("id"), + contextJson = json.getString("contextJson"), + anchorPath = json.nullableString("anchorPath"), + shuffleSeed = json.nullableLong("shuffleSeed"), + activePosition = json.getLong("activePosition"), + createdAt = json.getLong("createdAt"), + updatedAt = json.getLong("updatedAt"), +) + +private fun PlaybackQueueEntryEntity.toJson() = JSONObject() + .put("sessionId", sessionId) + .put("position", position) + .put("trackPath", trackPath) + +private fun playbackQueueFromJson(json: JSONObject) = PlaybackQueueEntryEntity( + sessionId = json.getString("sessionId"), + position = json.getLong("position"), + trackPath = json.getString("trackPath"), +) + +private fun PlaybackOriginalQueueEntryEntity.toJson() = JSONObject() + .put("sessionId", sessionId) + .put("position", position) + .put("trackPath", trackPath) + +private fun playbackOriginalQueueFromJson(json: JSONObject) = PlaybackOriginalQueueEntryEntity( + sessionId = json.getString("sessionId"), + position = json.getLong("position"), + trackPath = json.getString("trackPath"), +) diff --git a/modules/astra-library-scanner/expo-module.config.json b/modules/astra-library-scanner/expo-module.config.json index a058789..26da79e 100644 --- a/modules/astra-library-scanner/expo-module.config.json +++ b/modules/astra-library-scanner/expo-module.config.json @@ -1,6 +1,9 @@ { "platforms": ["android"], "android": { - "modules": ["expo.modules.astralibraryscanner.AstraLibraryScannerModule"] + "modules": [ + "expo.modules.astralibraryscanner.AstraLibraryScannerModule", + "expo.modules.astralibraryscanner.AstraLibraryDataModule" + ] } } diff --git a/modules/astra-library-scanner/index.ts b/modules/astra-library-scanner/index.ts index 378fefa..9a6dad3 100644 --- a/modules/astra-library-scanner/index.ts +++ b/modules/astra-library-scanner/index.ts @@ -73,8 +73,20 @@ export type EmbeddedLyricsReadResult = | { status: 'unavailable' }; export interface ScanProgressEvent { - phase: 'discovering'; - found: number; + phase: 'discovering' | 'extracting' | 'indexing'; + found?: number; + processed?: number; + total?: number; + folderName?: string; +} + +export interface NativeScanResult { + added: number; + updated: number; + removed: number; + errors: number; + total: number; + catalogRevision: string; } type AstraLibraryScannerEvents = { @@ -84,6 +96,11 @@ type AstraLibraryScannerEvents = { declare class AstraLibraryScannerModuleType extends NativeModule { listAudioFiles(treeUri: string, extensions: string[]): Promise; extractMetadata(files: { uri: string; coverUri?: string | null }[]): Promise; + scanFolderNative( + folderId: number, + mode: 'incremental' | 'full', + extensions: string[] + ): Promise; /** * Decode the file's PCM and return `bins` RMS peaks normalized to [0,1] for * the waveform seek bar. Whole-file decode (heavy); returns [] on failure. @@ -145,3 +162,301 @@ declare class AstraLibraryScannerModuleType extends NativeModule('AstraLibraryScanner'); + +export type LibraryStatus = + | 'initializing' + | 'empty' + | 'ready' + | 'scanning' + | 'rebuilding' + | 'degraded' + | 'fatalUserData'; + +export interface LibraryStatusSnapshot { + status: LibraryStatus; + catalogRevision: string; + trackCount: number; + message: string | null; + recoveryNotice: string | null; +} + +export interface NativePage { + items: T[]; + nextCursor: string | null; + previousCursor: string | null; + totalCount: number; + catalogRevision: string; + error?: 'STALE_REVISION'; +} + +export type LibraryQuery = + | { kind: 'library'; sort: 'artist' | 'title' | 'recently_added' | 'duration' } + | { kind: 'album'; albumKey: string } + | { + kind: 'artist'; + artistKey: string; + groupingMode: 'astra' | 'fileTags'; + section: 'songs' | 'appearances' | 'all'; + } + | { kind: 'folder'; folderNodeId?: string; folderId?: number } + | { kind: 'playlist'; playlistId: number } + | { kind: 'favorites' } + | { kind: 'recent' } + | { kind: 'search'; query: string } + | { kind: 'manual'; paths: string[] } + | { kind: 'dynamicPlaylist'; playlistId: number }; + +export interface NativePlaybackWindow { + sessionId: string; + items: (T & { queuePosition: number })[]; + windowStart: number; + activePosition: number; + totalCount: number; + contextJson: string; + shuffleSeed: number | null; + catalogRevision: string; +} + +export interface LibrarySectionAnchor { + label: string; + cursor: string; +} + +export interface NativeFolderNode { + id: string; + folderId: number; + parentNodeId: string | null; + name: string; + depth: number; + directTrackCount: number; + totalTrackCount: number; + available: boolean; + catalogRevision: string; +} + +export interface NativeTrackLoudness { + path: string; + loudness_lufs: number | null; + sample_peak: number | null; + replay_gain_track_db: number | null; + replay_gain_album_db: number | null; + replay_gain_track_peak: number | null; + replay_gain_album_peak: number | null; + rg_scanned: number; +} + +export interface NativeLibraryLoudnessStats { + lufsCount: number; + medianLufs: number | null; + rgCount: number; + medianRgTrackDb: number | null; +} + +type AstraLibraryDataEvents = { + onLibraryStatus: (event: LibraryStatusSnapshot) => void; + onScanProgress: (event: { + scanId: string; + phase: 'discovering' | 'extracting' | 'publishing'; + processed: number; + total: number; + folderName: string; + }) => void; + onCatalogChanged: (event: { catalogRevision: string }) => void; +}; + +declare class AstraLibraryDataModuleType extends NativeModule { + initialize(): Promise; + getCurrentStatus(): LibraryStatusSnapshot; + getSettings(keys: string[]): Promise>; + setSettings(values: Record): Promise; + listFolders(): Promise[]>; + getFolderNodes(parentNodeId: string | null): Promise; + getFolderTracks( + nodeId: string, + offset: number, + limit: number + ): Promise<{ + items: T[]; + nextOffset: number | null; + totalCount: number; + catalogRevision: string; + }>; + registerFolder(treeUri: string, displayName: string): Promise>; + removeFolder(folderId: number): Promise; + getTrackPage( + sort: 'artist' | 'title' | 'recently_added' | 'duration', + cursor: string | null, + limit: number + ): Promise>; + getTrack(path: string): Promise; + getTrackLoudness(paths: string[]): Promise; + setTrackLoudness(path: string, lufs: number | null, samplePeak: number | null): Promise; + setTrackReplayGain( + path: string, + trackGainDb: number | null, + albumGainDb: number | null, + trackPeak: number | null, + albumPeak: number | null + ): Promise; + getLibraryLoudnessStats(): Promise; + getWaveform(path: string): Promise; + putWaveform(path: string, peaks: number[]): Promise; + countWaveforms(): Promise; + clearWaveforms(): Promise; + getLyrics(path: string, metadataSignature: string): Promise; + putLyrics(path: string, values: Record): Promise; + deleteLyrics(path: string): Promise; + countLyrics(): Promise; + clearLyrics(): Promise; + readMobileSession(): Promise; + writeMobileSession(snapshotJson: string): Promise; + createPlaybackContext( + context: LibraryQuery, + anchorPath: string | null, + shuffle: boolean, + seed: number | null + ): Promise>; + getPlaybackWindow( + sessionId: string, + start: number, + limit: number + ): Promise>; + updatePlaybackPosition(sessionId: string, activePosition: number): Promise; + restorePlaybackContext(): Promise | null>; + mutatePlaybackContext( + operation: + | 'insertAfterActive' + | 'append' + | 'insertQueryAfterActive' + | 'appendQuery' + | 'remove' + | 'move' + | 'moveManyAfterActive' + | 'shuffle', + values: Record + ): Promise | null>; + recordTrackPlayed(path: string): Promise; + getRecentlyPlayed(limit: number): Promise; + listRemoteSources(): Promise; + getRemoteSource(sourceId: number): Promise; + createRemoteSource( + type: 'subsonic' | 'jellyfin', + name: string, + baseUrl: string, + username: string, + enabled: boolean + ): Promise; + updateRemoteSource(sourceId: number, fields: Record): Promise; + setRemoteSourceStatus(sourceId: number, status: string, error: string | null): Promise; + deleteRemoteSource(sourceId: number, purgeCatalog: boolean): Promise; + replaceRemoteUserState( + sourceId: number, + sourceType: 'subsonic' | 'jellyfin', + favoritePaths: string[], + playlists: Record[] + ): Promise; + beginRemoteSync(sourceId: number, sourceType: 'subsonic' | 'jellyfin'): Promise; + appendRemoteTracks(syncId: string, rows: Record[]): Promise; + commitRemoteSync( + syncId: string + ): Promise<{ tracksScanned: number; removed: number; catalogRevision: string }>; + abortRemoteSync(syncId: string): Promise; + listPlaylists(): Promise; + createPlaylist(name: string, kind: 'normal' | 'dynamic', rulesJson: string | null): Promise; + getDynamicPlaylistRules(playlistId: number): Promise; + updateDynamicPlaylistRules(playlistId: number, rulesJson: string): Promise; + previewDynamicPlaylist(rulesJson: string): Promise; + renamePlaylist(playlistId: number, name: string): Promise; + deletePlaylist(playlistId: number): Promise; + markPlaylistPlayed(playlistId: number): Promise; + addPlaylistEntries( + playlistId: number, + entries: { + trackPath: string; + fallbackTitle?: string | null; + fallbackArtist?: string | null; + fallbackAlbum?: string | null; + }[] + ): Promise; + removePlaylistEntry(playlistId: number, path: string): Promise; + movePlaylistEntry(playlistId: number, path: string, direction: -1 | 1): Promise; + getPlaylistEntries( + playlistId: number, + offset: number, + limit: number + ): Promise<{ items: T[]; nextOffset: number | null; totalCount: number }>; + getFavoritePaths(): Promise; + getFavoriteTracks(limit: number): Promise; + setFavorite(path: string, favorite: boolean): Promise; + getDesktopSyncState(): Promise; + applyDesktopSyncPlan(plan: Record): Promise; + resolveDesktopSyncConflict( + conflict: Record, + resolution: 'desktop' | 'phone' | 'both' | 'merge', + mergedPlaylist: Record | null + ): Promise; + clearDesktopSyncBaselines(): Promise; + getAlbumPage( + sort: 'artist' | 'name' | 'recently_added' | 'year', + includeSingles: boolean, + cursor: string | null, + limit: number + ): Promise>; + getArtistPage( + sort: 'name' | 'track_count', + groupingMode: 'astra' | 'fileTags', + includeCollaborations: boolean, + cursor: string | null, + limit: number + ): Promise>; + getAlbumDetail>( + albumKey: string, + cursor: string | null, + limit: number + ): Promise & { summary: S | null }>; + getArtistDetail>( + artistKey: string, + groupingMode: 'astra' | 'fileTags', + section: 'songs' | 'appearances' | 'all', + cursor: string | null, + limit: number + ): Promise & { summary: S | null }>; + getArtistAlbums( + artistKey: string, + groupingMode: 'astra' | 'fileTags', + offset: number, + limit: number + ): Promise<{ + items: T[]; + nextOffset: number | null; + totalCount: number; + catalogRevision: string; + }>; + searchTracks(query: string, limit: number): Promise; + searchLibrary( + query: string, + limit: number, + includeSingles: boolean, + groupingMode: 'astra' | 'fileTags', + includeCollaborations: boolean + ): Promise<{ tracks: TTrack[]; albums: TAlbum[]; artists: TArtist[] }>; + matchSignal( + title: string, + artist: string, + durationSeconds: number | null + ): Promise<{ + kind: 'match' | 'ambiguous' | 'none'; + candidates: { track: T; match: 'exact' | 'normalized'; durationDeltaSec: number | null }[]; + }>; + getSectionAnchors( + kind: 'tracks' | 'albums' | 'artists', + sort: 'artist' | 'title' | 'name', + includeSingles: boolean, + groupingMode: 'astra' | 'fileTags', + includeCollaborations: boolean + ): Promise; + flushUserSnapshot(): Promise; +} + +export const AstraLibraryData = + requireNativeModule('AstraLibraryData'); diff --git a/package-lock.json b/package-lock.json index 1e10d9a..0d8b0ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,10 +16,8 @@ "@expo/ui": "~56.0.13", "@expo/vector-icons": "^15.0.2", "@gorhom/bottom-sheet": "^5.2.14", - "@op-engineering/op-sqlite": "^16.2.1", "@shopify/flash-list": "2.0.2", "@shopify/react-native-skia": "2.6.2", - "encoding-japanese": "^2.2.0", "expo": "~56.0.4", "expo-asset": "~56.0.14", "expo-build-properties": "^56.0.19", @@ -58,7 +56,6 @@ "zustand": "^5.0.13" }, "devDependencies": { - "@types/encoding-japanese": "^2.2.1", "@types/react": "~19.2.2", "eslint": "^9.0.0", "eslint-config-expo": "~56.0.4", @@ -2289,26 +2286,6 @@ "node": ">=12.4.0" } }, - "node_modules/@op-engineering/op-sqlite": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/@op-engineering/op-sqlite/-/op-sqlite-16.2.1.tgz", - "integrity": "sha512-mDMdvAJJ97XfmJRKxzldyR9OpADmPVDofOWRgcB+kN1mlKmNkoJMZnX5SDYsYUki215FZkXzevExuhSzfyEWLw==", - "license": "MIT", - "workspaces": [ - "example", - "node" - ], - "peerDependencies": { - "@sqlite.org/sqlite-wasm": "*", - "react": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "@sqlite.org/sqlite-wasm": { - "optional": true - } - } - }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -3243,13 +3220,6 @@ "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", "license": "MIT" }, - "node_modules/@types/encoding-japanese": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@types/encoding-japanese/-/encoding-japanese-2.2.1.tgz", - "integrity": "sha512-6jjepuTusvySxMLP7W6usamlbgf0F4sIDvm7EzYePjLHY7zWUv4yz2PLUnu0vuNVtXOTLu2cRdFcDg40J5Owsw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -5387,15 +5357,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding-japanese": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", - "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", - "license": "MIT", - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", diff --git a/package.json b/package.json index a53fc43..120b1f4 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,8 @@ "@expo/ui": "~56.0.13", "@expo/vector-icons": "^15.0.2", "@gorhom/bottom-sheet": "^5.2.14", - "@op-engineering/op-sqlite": "^16.2.1", "@shopify/flash-list": "2.0.2", "@shopify/react-native-skia": "2.6.2", - "encoding-japanese": "^2.2.0", "expo": "~56.0.4", "expo-asset": "~56.0.14", "expo-build-properties": "^56.0.19", @@ -52,7 +50,6 @@ "zustand": "^5.0.13" }, "devDependencies": { - "@types/encoding-japanese": "^2.2.1", "@types/react": "~19.2.2", "eslint": "^9.0.0", "eslint-config-expo": "~56.0.4", @@ -69,7 +66,7 @@ "lint": "expo lint", "test:queue-actions": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/queue/queueActions.test.mts", "test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts", - "test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts src/db/dynamicPlaylistSql.test.mts", + "test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts", "test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts", "test:artist-grouping": "node --experimental-strip-types --test src/library/artistGrouping.test.mts", "test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/services/desktopSyncPolicy.test.mts src/shared/sync/conflictPreview.test.mts", @@ -80,7 +77,7 @@ "test:seek-bar": "node --experimental-strip-types --test src/audio/playbackClock.test.mts src/components/waveformScrubDetents.test.mts", "test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts src/lyrics/embedded.test.mts src/lyrics/resolver.test.mts", "test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts", - "test:troubleshooting": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/db/libraryMaintenance.test.mts src/lib/cacheInvalidation.test.mts", + "test:troubleshooting": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/cacheInvalidation.test.mts", "test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.test.mts", "test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts src/components/player/nowPlayingDismiss.test.mts src/playback/playbackTargetPresentation.test.mts", "test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs", diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index f46b0e7..6d6e688 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -34,11 +34,8 @@ import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlayerStore } from '@/stores/playerStore'; import { useSearchStore } from '@/stores/searchStore'; import { useSettingsStore } from '@/stores/settingsStore'; -import { playTracks, shuffleTracks } from '@/audio/playbackController'; -import { compareTracksByDiscTrackTitle } from '@/library/albumIdentity'; -import { buildArtistDetail } from '@/library/artistDetail'; +import { playLibraryQuery } from '@/audio/playbackController'; import { filterArtistBrowseList } from '@/library/artistGrouping'; -import { dbTrackToTrack } from '@/library/trackAdapter'; import { albumArtworkSource, artworkUri } from '@/library/artwork'; import { chooseHomeGreeting, @@ -357,14 +354,14 @@ function RecentlyAddedAlbum({ function RandomSpotlightCard({ spotlight, - tracks, + hasTracks, onPlay, onShuffle, onReroll, onOpen, }: { spotlight: { kind: 'album'; album: Album } | { kind: 'artist'; artist: Artist }; - tracks: DbTrack[]; + hasTracks: boolean; onPlay: () => void; onShuffle: () => void; onReroll: () => void; @@ -373,7 +370,7 @@ function RandomSpotlightCard({ const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); - const disabled = tracks.length === 0; + const disabled = !hasTracks; const title = spotlight.kind === 'album' ? spotlight.album.album : spotlight.artist.artist; const label = spotlight.kind === 'album' ? 'RANDOM ALBUM' : 'RANDOM ARTIST'; const meta = spotlight.kind === 'album' @@ -454,21 +451,44 @@ function RandomSpotlightCard({ function EmptyHomeCard({ scanError, + status, onManageFolders, }: { scanError: string | null; + status: 'initializing' | 'empty' | 'ready' | 'scanning' | 'rebuilding' | 'degraded' | 'fatalUserData'; onManageFolders: () => void; }) { const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); + const fatal = status === 'fatalUserData'; + const rebuilding = status === 'rebuilding'; + const degraded = status === 'degraded'; return ( - + - No music yet + + {fatal + ? 'Library data unavailable' + : rebuilding + ? 'Rebuilding your library' + : degraded + ? 'Library temporarily unavailable' + : 'No music yet'} + - Add a local folder to fill Home with albums, history, favorites, and playlists. + {fatal + ? 'Astra could not restore your playlists, favorites, and settings from either safety snapshot. Your music files were not changed.' + : rebuilding + ? 'The damaged catalog was quarantined. Astra is rebuilding from available folders and remote sources.' + : degraded + ? 'Astra cannot currently read the catalog, so it will not treat your library as empty.' + : 'Add a local folder to fill Home with albums, history, favorites, and playlists.'} {scanError ? ( @@ -482,9 +502,9 @@ function EmptyHomeCard({ onPress={onManageFolders} accessibilityRole="button" > - + - Folder settings + {fatal ? 'Troubleshooting' : 'Folder settings'} @@ -494,12 +514,13 @@ function EmptyHomeCard({ export default function HomeScreen() { const styles = useStyles(); const router = useRouter(); - const tracks = useLibraryStore((s) => s.tracks); - const albums = useLibraryStore((s) => s.albums); - const artists = useLibraryStore((s) => s.artists); + const totalTrackCount = useLibraryStore((s) => s.totalTrackCount); + const albums = useLibraryStore((s) => s.homeAlbums); + const artists = useLibraryStore((s) => s.homeArtists); const includeCollabArtists = useLibraryStore((s) => s.includeCollabArtists); const recentlyPlayedTracks = useLibraryStore((s) => s.recentlyPlayedTracks); const scanError = useLibraryStore((s) => s.scanError); + const libraryStatus = useLibraryStore((s) => s.status); const playlists = usePlaylistStore((s) => s.playlists); const favoriteTracks = usePlaylistStore((s) => s.favoriteTracks); const currentPath = usePlayerStore((s) => s.currentTrack?.path); @@ -511,7 +532,7 @@ export default function HomeScreen() { const [randomSeeds] = useState(() => [Math.random(), Math.random()] as const); const [actionTrack, setActionTrack] = useState(null); const scrollTop = useScrollTopGate(); - const hasLibrary = tracks.length > 0; + const hasLibrary = totalTrackCount > 0; const recentlyAddedAlbums = useMemo( () => [...albums].sort((a, b) => b.latest_added_at - a.latest_added_at).slice(0, RECENT_ALBUM_LIMIT), @@ -564,29 +585,6 @@ export default function HomeScreen() { ? ({ kind: 'artist', artist: randomArtist } as const) : null; - const randomAlbumNeedsTracks = hasLibrary && randomAlbum != null; - const tracksByAlbum = useMemo(() => { - if (!randomAlbumNeedsTracks) return null; - const map = new Map(); - for (const track of tracks) { - const list = map.get(track.album_identity_key) ?? []; - list.push(track); - map.set(track.album_identity_key, list); - } - // Store tracks are artist-ordered; a multi-artist compilation would play - // blocked by artist without an explicit album-order sort. - for (const list of map.values()) list.sort(compareTracksByDiscTrackTitle); - return map; - }, [randomAlbumNeedsTracks, tracks]); - const randomTracks = - randomAlbum && tracksByAlbum ? (tracksByAlbum.get(randomAlbum.identity_key) ?? []) : []; - const randomArtistDetail = useMemo( - () => randomArtist - ? buildArtistDetail(tracks, randomArtist.artist, artistGroupingMode) - : null, - [artistGroupingMode, randomArtist, tracks] - ); - const spotlightTracks = randomAlbum ? randomTracks : randomArtistDetail?.playbackTracks ?? []; const recentTracks = recentlyPlayedTracks.slice(0, RECENT_TRACK_LIMIT); const canExpandRecentTracks = recentlyPlayedTracks.length > RECENT_TRACK_LIMIT; @@ -606,22 +604,26 @@ export default function HomeScreen() { const playRecentlyPlayed = (list: DbTrack[], index = 0) => { if (list.length === 0) return; - void playTracks(list.map(dbTrackToTrack), { - startIndex: index, + void playLibraryQuery({ kind: 'recent' }, { + anchorPath: list[index]?.path, source: { kind: 'recently-played', label: 'Recently Played' }, }); }; const playSpotlight = (shuffled = false) => { - if (spotlightTracks.length === 0) return; + if (!spotlightContent) return; const source = spotlightContent?.kind === 'album' ? { kind: 'album' as const, label: spotlightContent.album.album } : { kind: 'artist' as const, label: spotlightContent?.artist.artist ?? 'Artist' }; - if (shuffled) { - void shuffleTracks(spotlightTracks.map(dbTrackToTrack), source); - } else { - void playTracks(spotlightTracks.map(dbTrackToTrack), { source }); - } + const query = spotlightContent.kind === 'album' + ? { kind: 'album' as const, albumKey: spotlightContent.album.identity_key } + : { + kind: 'artist' as const, + artistKey: spotlightContent.artist.artist, + groupingMode: artistGroupingMode, + section: 'all' as const, + }; + void playLibraryQuery(query, { shuffle: shuffled, source }); }; const rerollSpotlight = () => { @@ -652,7 +654,10 @@ export default function HomeScreen() { {!hasLibrary ? ( router.push('/settings')} + status={libraryStatus} + onManageFolders={() => router.push( + libraryStatus === 'fatalUserData' ? '/settings/troubleshooting' : '/settings' + )} /> ) : ( <> @@ -660,7 +665,11 @@ export default function HomeScreen() { 0 + : spotlightContent.artist.track_count > 0 + } onOpen={() => spotlightContent.kind === 'album' ? openAlbum(spotlightContent.album) : openArtist(spotlightContent.artist)} diff --git a/src/app/(tabs)/library/album/[key].tsx b/src/app/(tabs)/library/album/[key].tsx index 8afae7f..5db4d92 100644 --- a/src/app/(tabs)/library/album/[key].tsx +++ b/src/app/(tabs)/library/album/[key].tsx @@ -13,11 +13,9 @@ import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail'; import { spacing } from '@/theme'; import { useColors } from '@/theme/themed'; -import { useLibraryStore } from '@/stores/libraryStore'; import { usePlayerStore } from '@/stores/playerStore'; -import { playTracks, shuffleTracks } from '@/audio/playbackController'; -import { compareTracksByDiscTrackTitle } from '@/library/albumIdentity'; -import { dbTrackToTrack } from '@/library/trackAdapter'; +import { playLibraryQuery } from '@/audio/playbackController'; +import { useNativeAlbumDetail } from '@/library/nativePages'; import { albumArtworkSource, artworkThumbUri, artworkUri } from '@/library/artwork'; import { formatDuration } from '@/lib/format'; import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack'; @@ -40,26 +38,15 @@ function DiscHeader({ disc }: { disc: number }) { export default function AlbumScreen() { const colors = useColors(); const { key } = useLocalSearchParams<{ key: string }>(); - const albums = useLibraryStore((s) => s.albums); - const allTracks = useLibraryStore((s) => s.tracks); + const { items: tracks, summary: album, totalCount, loadMore } = useNativeAlbumDetail(key); const currentPath = usePlayerStore((s) => s.currentTrack?.path); const handleBack = useLibraryDetailBack(); const insets = useSafeAreaInsets(); const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } = useDetailCollapse(); - const album = albums.find((entry) => entry.identity_key === key); - // Store tracks are artist-ordered, so a multi-artist group (Various Artists - // compilation) would come out blocked by artist — re-sort into album order. - const tracks = useMemo( - () => - allTracks - .filter((track) => track.album_identity_key === key) - .sort(compareTracksByDiscTrackTitle), - [allTracks, key] - ); - - const totalDuration = tracks.reduce((sum, track) => sum + track.duration, 0); + const totalDuration = + album?.total_duration ?? tracks.reduce((sum, track) => sum + track.duration, 0); const [actionTrack, setActionTrack] = useState(null); // Interleave "Disc N" headers only when the album spans multiple discs; @@ -84,8 +71,8 @@ export default function AlbumScreen() { }, [tracks]); const playFrom = (index: number) => { - void playTracks(tracks.map(dbTrackToTrack), { - startIndex: index, + void playLibraryQuery({ kind: 'album', albumKey: key }, { + anchorPath: tracks[index]?.path, source: { kind: 'album', label: album?.album ?? tracks[0]?.album ?? 'Album' }, }); }; @@ -109,7 +96,7 @@ export default function AlbumScreen() { const backdropUri = headerArtworkHash ? artworkThumbUri(headerArtworkHash) : artSource; const meta = [ (album?.year ?? fallbackTrack?.year) ? String(album?.year ?? fallbackTrack?.year) : null, - `${tracks.length} ${tracks.length === 1 ? 'track' : 'tracks'}`, + `${totalCount} ${totalCount === 1 ? 'track' : 'tracks'}`, formatDuration(totalDuration), ] .filter(Boolean) @@ -124,6 +111,8 @@ export default function AlbumScreen() { showsVerticalScrollIndicator={false} onScroll={onScroll} scrollEventThrottle={scrollEventThrottle} + onEndReached={() => void loadMore()} + onEndReachedThreshold={0.6} contentContainerStyle={{ paddingTop: insets.top + expandedHeight, paddingHorizontal: spacing.lg, @@ -167,9 +156,12 @@ export default function AlbumScreen() { disabled={tracks.length === 0} onBack={handleBack} onPlay={() => playFrom(0)} - onShuffle={() => void shuffleTracks(tracks.map(dbTrackToTrack), { - kind: 'album', - label: album?.album ?? tracks[0]?.album ?? 'Album', + onShuffle={() => void playLibraryQuery({ kind: 'album', albumKey: key }, { + shuffle: true, + source: { + kind: 'album', + label: album?.album ?? tracks[0]?.album ?? 'Album', + }, })} scrollY={scrollY} heroFaded={heroFaded} diff --git a/src/app/(tabs)/library/artist/[name].tsx b/src/app/(tabs)/library/artist/[name].tsx index 87f20b5..0d5c9d6 100644 --- a/src/app/(tabs)/library/artist/[name].tsx +++ b/src/app/(tabs)/library/artist/[name].tsx @@ -28,17 +28,19 @@ import { import { createThemedStyles, useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import type { Palette } from '@/theme/palettes'; -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 { playLibraryQuery } from '@/audio/playbackController'; import { artworkThumbUri, artworkUri } from '@/library/artwork'; import { buildArtistDetail, type ArtistAlbum, type ArtistDetail } from '@/library/artistDetail'; +import { + useNativeArtistAlbums, + useNativeArtistDetail, +} from '@/library/nativePages'; import { useLibraryDetailBack } from '@/navigation/useLibraryDetailBack'; import type { DbTrack } from '@/types/library'; @@ -73,33 +75,74 @@ export default function ArtistScreen() { const insets = useSafeAreaInsets(); const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } = useDetailCollapse(); - const allTracks = useLibraryStore((s) => s.tracks); const groupingMode = useSettingsStore((s) => s.artistGroupingMode); const detailGroupingMode = credit === '1' ? 'astra' : groupingMode; const currentPath = usePlayerStore((s) => s.currentTrack?.path); const [actionTrack, setActionTrack] = useState(null); - const detail = useMemo( - () => buildArtistDetail(allTracks, name, detailGroupingMode), - [allTracks, name, detailGroupingMode] + const allPage = useNativeArtistDetail(name, detailGroupingMode, 'all'); + const songsPage = useNativeArtistDetail(name, detailGroupingMode, 'songs'); + const appearancesPage = useNativeArtistDetail(name, detailGroupingMode, 'appearances'); + const albumsPage = useNativeArtistAlbums(name, detailGroupingMode); + const detail = useMemo(() => { + const base = buildArtistDetail(allPage.items, name, detailGroupingMode); + return { + ...base, + albums: albumsPage.items.map((album) => ({ + ...album, + duration: album.total_duration ?? 0, + })), + tracks: allPage.items, + playbackTracks: allPage.items, + songTracks: songsPage.items, + appearanceTracks: appearancesPage.items, + showAppearances: appearancesPage.totalCount > 0, + artworkHashes: allPage.summary?.artwork_hashes ?? base.artworkHashes, + }; + }, [ + allPage.items, + allPage.summary, + albumsPage.items, + appearancesPage.items, + appearancesPage.totalCount, + detailGroupingMode, + name, + songsPage.items, + ]); + + const listItems = useMemo( + () => buildListItems(detail, songsPage.totalCount, appearancesPage.totalCount), + [appearancesPage.totalCount, detail, songsPage.totalCount] ); - const listItems = useMemo(() => buildListItems(detail), [detail]); - - const playTrackListFrom = (tracks: readonly DbTrack[], index: number) => { + const playTrackListFrom = ( + tracks: readonly DbTrack[], + index: number, + section: 'songs' | 'appearances' | 'all', + ) => { if (tracks.length === 0) return; - void playTracks(tracks.map(dbTrackToTrack), { - startIndex: index, + void playLibraryQuery({ + kind: 'artist', + artistKey: name, + groupingMode: detailGroupingMode, + section, + }, { + anchorPath: tracks[index]?.path, source: { kind: 'artist', label: name }, }); }; - const playArtist = () => playTrackListFrom(detail.playbackTracks, 0); + const playArtist = () => playTrackListFrom(detail.playbackTracks, 0, 'all'); const shuffleArtist = () => { if (detail.playbackTracks.length === 0) return; - void shuffleTracks(detail.playbackTracks.map(dbTrackToTrack), { + void playLibraryQuery({ kind: 'artist', - label: name, + artistKey: name, + groupingMode: detailGroupingMode, + section: 'all', + }, { + shuffle: true, + source: { kind: 'artist', label: name }, }); }; @@ -142,7 +185,13 @@ export default function ArtistScreen() { track={item.track} subtitle={trackSubtitle(item.track, item.section)} active={item.track.path === currentPath} - onPress={() => playTrackListFrom(sourceTracks, item.index)} + onPress={() => + playTrackListFrom( + sourceTracks, + item.index, + item.section === 'appearances' ? 'appearances' : 'songs', + ) + } onLongPress={() => setActionTrack(item.track)} onOpenActions={() => setActionTrack(item.track)} /> @@ -184,10 +233,13 @@ export default function ArtistScreen() { title={name} heroMeta={ - {detail.albums.length > 0 ? ( - + {(allPage.summary?.album_count ?? detail.albums.length) > 0 ? ( + ) : null} - + {detail.totalDuration > 0 ? ( ) : null} @@ -208,7 +260,11 @@ export default function ArtistScreen() { ); } -function buildListItems(detail: ArtistDetail): ArtistPageItem[] { +function buildListItems( + detail: ArtistDetail, + songCount: number, + appearanceCount: number +): ArtistPageItem[] { const items: ArtistPageItem[] = []; if (detail.tracks.length === 0) { @@ -231,7 +287,7 @@ function buildListItems(detail: ArtistDetail): ArtistPageItem[] { key: 'section-songs', type: 'section', title: 'Songs', - trailing: formatCount(detail.songTracks.length, 'track'), + trailing: formatCount(songCount, 'track'), target: 'songs', }); detail.songTracks.slice(0, SONG_PREVIEW_LIMIT).forEach((track, index) => { @@ -243,7 +299,7 @@ function buildListItems(detail: ArtistDetail): ArtistPageItem[] { key: 'section-appearances', type: 'section', title: 'Appears On', - trailing: formatCount(detail.appearanceTracks.length, 'track'), + trailing: formatCount(appearanceCount, 'track'), target: 'appearances', }); detail.appearanceTracks.slice(0, APPEARANCE_PREVIEW_LIMIT).forEach((track, index) => { diff --git a/src/app/(tabs)/library/artist/[name]/albums.tsx b/src/app/(tabs)/library/artist/[name]/albums.tsx index 2f53942..8b1e775 100644 --- a/src/app/(tabs)/library/artist/[name]/albums.tsx +++ b/src/app/(tabs)/library/artist/[name]/albums.tsx @@ -1,4 +1,3 @@ -import { useMemo } from 'react'; import { Pressable, StyleSheet, @@ -13,9 +12,8 @@ import { AlbumGridItem } from '@/components/library/AlbumGridItem'; import { spacing } from '@/theme'; import { useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; -import { useLibraryStore } from '@/stores/libraryStore'; import { useSettingsStore } from '@/stores/settingsStore'; -import { buildArtistDetail } from '@/library/artistDetail'; +import { useNativeArtistAlbums } from '@/library/nativePages'; export default function ArtistAlbumsScreen() { const colors = useColors(); @@ -25,14 +23,9 @@ export default function ArtistAlbumsScreen() { name: string; credit?: string; }>(); - const allTracks = useLibraryStore((s) => s.tracks); const groupingMode = useSettingsStore((s) => s.artistGroupingMode); const detailGroupingMode = credit === '1' ? 'astra' : groupingMode; - - const detail = useMemo( - () => buildArtistDetail(allTracks, name, detailGroupingMode), - [allTracks, name, detailGroupingMode] - ); + const page = useNativeArtistAlbums(name, detailGroupingMode); return ( @@ -47,14 +40,18 @@ export default function ArtistAlbumsScreen() { Albums - {formatCount(detail.albums.length, 'album')} + + {formatCount(page.totalCount, 'album')} + album.identity_key} showsVerticalScrollIndicator={false} + onEndReached={() => void page.loadMore()} + onEndReachedThreshold={0.6} renderItem={({ item }) => ( (); - const allTracks = useLibraryStore((s) => s.tracks); const groupingMode = useSettingsStore((s) => s.artistGroupingMode); const detailGroupingMode = credit === '1' ? 'astra' : groupingMode; const currentPath = usePlayerStore((s) => s.currentTrack?.path); const [actionTrack, setActionTrack] = useState(null); - const detail = useMemo( - () => buildArtistDetail(allTracks, name, detailGroupingMode), - [allTracks, name, detailGroupingMode] + const { items: tracks, totalCount, loadMore } = useNativeArtistDetail( + name, + detailGroupingMode, + 'appearances' ); - const tracks = detail.appearanceTracks; const playFrom = (index: number) => { if (tracks.length === 0) return; - void playTracks(tracks.map(dbTrackToTrack), { - startIndex: index, + void playLibraryQuery({ + kind: 'artist', + artistKey: name, + groupingMode: detailGroupingMode, + section: 'appearances', + }, { + anchorPath: tracks[index]?.path, source: { kind: 'artist', label: name }, }); }; @@ -63,13 +65,15 @@ export default function ArtistAppearancesScreen() { Appears On - {formatCount(tracks.length, 'track')} + {formatCount(totalCount, 'track')} String(track.id)} showsVerticalScrollIndicator={false} + onEndReached={() => void loadMore()} + onEndReachedThreshold={0.6} renderItem={({ item, index }) => ( (); - const allTracks = useLibraryStore((s) => s.tracks); const groupingMode = useSettingsStore((s) => s.artistGroupingMode); const detailGroupingMode = credit === '1' ? 'astra' : groupingMode; const currentPath = usePlayerStore((s) => s.currentTrack?.path); const [actionTrack, setActionTrack] = useState(null); - const detail = useMemo( - () => buildArtistDetail(allTracks, name, detailGroupingMode), - [allTracks, name, detailGroupingMode] + const { items: tracks, totalCount, loadMore } = useNativeArtistDetail( + name, + detailGroupingMode, + 'songs' ); - const tracks = detail.songTracks; const playFrom = (index: number) => { if (tracks.length === 0) return; - void playTracks(tracks.map(dbTrackToTrack), { - startIndex: index, + void playLibraryQuery({ + kind: 'artist', + artistKey: name, + groupingMode: detailGroupingMode, + section: 'songs', + }, { + anchorPath: tracks[index]?.path, source: { kind: 'artist', label: name }, }); }; @@ -63,13 +65,15 @@ export default function ArtistSongsScreen() { Songs - {formatCount(tracks.length, 'track')} + {formatCount(totalCount, 'track')} String(track.id)} showsVerticalScrollIndicator={false} + onEndReached={() => void loadMore()} + onEndReachedThreshold={0.6} renderItem={({ item, index }) => ( s.albums); const artists = useLibraryStore((s) => s.artists); const tracks = useLibraryStore((s) => s.tracks); - const folders = useLibraryStore((s) => s.folders); const trackSort = useLibraryStore((s) => s.trackSort); const setTrackSort = useLibraryStore((s) => s.setTrackSort); const albumSort = useLibraryStore((s) => s.albumSort); const setAlbumSort = useLibraryStore((s) => s.setAlbumSort); const artistSort = useLibraryStore((s) => s.artistSort); const setArtistSort = useLibraryStore((s) => s.setArtistSort); - const includeCollabArtists = useLibraryStore((s) => s.includeCollabArtists); - const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode); + const loadNextTracks = useLibraryStore((s) => s.loadNextTracks); + const loadNextAlbums = useLibraryStore((s) => s.loadNextAlbums); + const loadNextArtists = useLibraryStore((s) => s.loadNextArtists); + const sectionAnchors = useLibraryStore((s) => s.sectionAnchors); + const jumpToSection = useLibraryStore((s) => s.jumpToSection); const isScanning = useLibraryStore((s) => s.isScanning); const scanError = useLibraryStore((s) => s.scanError); + const libraryStatus = useLibraryStore((s) => s.status); + const totalTrackCount = useLibraryStore((s) => s.totalTrackCount); const currentPath = usePlayerStore((s) => s.currentTrack?.path); const openQuickSearch = useSearchStore((s) => s.openQuickSearch); @@ -112,61 +111,47 @@ export default function LibraryScreen() { const albumsListRef = useRef>(null); const artistsListRef = useRef>(null); - const isEmpty = tracks.length === 0 && folders.length === 0 && !isScanning; + const showLibraryStatus = + totalTrackCount === 0 && + !isScanning && + ( + libraryStatus === 'empty' || + libraryStatus === 'rebuilding' || + libraryStatus === 'degraded' || + libraryStatus === 'fatalUserData' + ); - const sortedTracks = useMemo( - () => (viewMode === 'tracks' ? sortTracks(tracks, trackSort) : []), - [trackSort, tracks, viewMode] - ); - const sortedAlbums = useMemo( - () => (viewMode === 'albums' ? sortAlbums(albums, albumSort) : []), - [albumSort, albums, viewMode] - ); - const visibleArtists = useMemo( - () => filterArtistBrowseList(artists, artistGroupingMode, includeCollabArtists), - [artistGroupingMode, artists, includeCollabArtists] - ); - const sortedArtists = useMemo( - () => (viewMode === 'artists' ? sortArtists(visibleArtists, artistSort) : []), - [artistSort, viewMode, visibleArtists] - ); + const sortedTracks = viewMode === 'tracks' ? tracks : []; + const sortedAlbums = viewMode === 'albums' ? albums : []; + const sortedArtists = viewMode === 'artists' ? artists : []; // Tap index is within sortedTracks so the tapped row is the track that plays. const playAllFrom = (index: number) => { - void playTracks(sortedTracks.map(dbTrackToTrack), { - startIndex: index, + void playLibraryQuery({ kind: 'library', sort: trackSort }, { + anchorPath: sortedTracks[index]?.path, source: { kind: 'library', label: 'Library' }, }); }; const openSearch = () => openQuickSearch(); - // A-Z rail: only for sorts where a letter jump is meaningful. - const letterIndex = useMemo(() => { - if (viewMode === 'tracks' && (trackSort === 'artist' || trackSort === 'title')) { - return buildLetterIndex(sortedTracks, (t) => (trackSort === 'title' ? t.title : t.artist)); - } - if (viewMode === 'albums' && (albumSort === 'artist' || albumSort === 'name')) { - return buildLetterIndex(sortedAlbums, (a) => (albumSort === 'name' ? a.album : a.artist)); - } - if (viewMode === 'artists' && artistSort === 'name') { - return buildLetterIndex(sortedArtists, (a) => a.artist); - } - return []; - }, [albumSort, artistSort, sortedAlbums, sortedArtists, sortedTracks, trackSort, viewMode]); - - const railVisible = letterIndex.length > 1; + const railVisible = sectionAnchors.length > 1; const railLetters = useMemo( - () => new Set(letterIndex.map((entry) => entry.letter)), - [letterIndex] + () => new Set(sectionAnchors.map((entry) => entry.label)), + [sectionAnchors] ); const jumpToLetter = (letter: string) => { - const index = resolveJumpIndex(letterIndex, letter); - if (index == null) return; - // Fire-and-forget: letter-change granularity already throttles the calls. - if (viewMode === 'tracks') void tracksListRef.current?.scrollToIndex({ index, animated: false }); - else if (viewMode === 'albums') void albumsListRef.current?.scrollToIndex({ index, animated: false }); - else if (viewMode === 'artists') void artistsListRef.current?.scrollToIndex({ index, animated: false }); + const requestedIndex = RAIL_LETTERS.indexOf(letter); + const anchor = + sectionAnchors.find((entry) => entry.label === letter) ?? + sectionAnchors.find((entry) => RAIL_LETTERS.indexOf(entry.label) >= requestedIndex) ?? + sectionAnchors.at(-1); + if (!anchor) return; + void jumpToSection(anchor.cursor).then(() => { + if (viewMode === 'tracks') tracksListRef.current?.scrollToOffset({ offset: 0, animated: false }); + else if (viewMode === 'albums') albumsListRef.current?.scrollToOffset({ offset: 0, animated: false }); + else if (viewMode === 'artists') artistsListRef.current?.scrollToOffset({ offset: 0, animated: false }); + }); }; // Multi-select (tracks view): long-press arms it, batch actions live in the @@ -257,7 +242,7 @@ export default function LibraryScreen() { Library - {!isEmpty ? ( + {!showLibraryStatus ? ( openQuickSearch()} @@ -269,7 +254,7 @@ export default function LibraryScreen() { ) : null} - {isEmpty ? ( + {showLibraryStatus ? ( ) : ( <> @@ -325,6 +310,8 @@ export default function LibraryScreen() { renderScrollComponent={PullSearchScrollView} onScroll={scrollTop.onScroll} scrollEventThrottle={scrollTop.scrollEventThrottle} + onEndReached={() => void loadNextAlbums()} + onEndReachedThreshold={0.6} renderItem={({ item }) => ( void loadNextArtists()} + onEndReachedThreshold={0.6} renderItem={({ item }) => ( void loadNextTracks()} + onEndReachedThreshold={0.6} extraData={selectMode ? selectedIds : undefined} renderItem={({ item, index }) => ( s.favoriteTracks); const activeEntries = usePlaylistStore((s) => s.activeEntries); const openPlaylist = usePlaylistStore((s) => s.openPlaylist); + const loadNextEntries = usePlaylistStore((s) => s.loadNextEntries); const closePlaylist = usePlaylistStore((s) => s.closePlaylist); const moveTrack = usePlaylistStore((s) => s.moveTrack); const removeFromPlaylist = usePlaylistStore((s) => s.removeFromPlaylist); @@ -164,8 +164,14 @@ export default function PlaylistScreen() { const startPlayback = (index: number) => { if (playable.length === 0) return; - void playTracks(playable.map(dbTrackToTrack), { - startIndex: index, + const query = isFavorites + ? { kind: 'favorites' as const } + : { + kind: isDynamic ? 'dynamicPlaylist' as const : 'playlist' as const, + playlistId: playlistId!, + }; + void playLibraryQuery(query, { + anchorPath: playable[index]?.path, source: { kind: isFavorites ? 'favorites' : 'playlist', label: name }, }); if (playlistId != null && !Number.isNaN(playlistId)) void markPlayed(playlistId); @@ -173,9 +179,18 @@ export default function PlaylistScreen() { const startShuffle = () => { if (playable.length === 0) return; - void shuffleTracks(playable.map(dbTrackToTrack), { - kind: isFavorites ? 'favorites' : 'playlist', - label: name, + const query = isFavorites + ? { kind: 'favorites' as const } + : { + kind: isDynamic ? 'dynamicPlaylist' as const : 'playlist' as const, + playlistId: playlistId!, + }; + void playLibraryQuery(query, { + shuffle: true, + source: { + kind: isFavorites ? 'favorites' : 'playlist', + label: name, + }, }); if (playlistId != null && !Number.isNaN(playlistId)) void markPlayed(playlistId); }; @@ -266,6 +281,10 @@ export default function PlaylistScreen() { showsVerticalScrollIndicator={false} onScroll={onScroll} scrollEventThrottle={scrollEventThrottle} + onEndReached={() => { + if (!isFavorites) void loadNextEntries(); + }} + onEndReachedThreshold={0.6} contentContainerStyle={{ paddingTop: insets.top + expandedHeight, paddingHorizontal: spacing.lg, diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index f663f69..faade63 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -49,6 +49,7 @@ import { useTheme } from '@/theme/themed'; import { SessionLifecycle } from '@/session/SessionLifecycle'; import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore'; import { useSleepTimerStore } from '@/stores/sleepTimerStore'; +import { Text } from '@/components/Text'; // Anchor the root stack at the tabs so a deep link straight to a top-level route // (the widget's `recently-played`, the notification-click redirect) builds @@ -328,6 +329,8 @@ export default function RootLayout() { const themeLoaded = useThemeStore((s) => s.loaded); const onboardingLoaded = useOnboardingStore((s) => s.loaded); const onboardingComplete = useOnboardingStore((s) => s.onboardingComplete); + const libraryStatus = useLibraryStore((s) => s.status); + const fatalUserData = libraryStatus === 'fatalUserData'; const theme = useTheme(); const renderReady = (fontsLoaded && themeLoaded && onboardingLoaded) || splashTimedOut; const [sessionReady, setSessionReady] = useState(false); @@ -390,7 +393,7 @@ export default function RootLayout() { needs a root navigator), but the playback/sync/desktop side-effects and overlays are gated off during the wizard — no LAN-discovery bursts or scrobbler running mid-onboarding. */} - {onboardingComplete ? ( + {onboardingComplete && !fatalUserData ? ( <> @@ -411,8 +414,33 @@ export default function RootLayout() { > - {onboardingComplete ? : null} - {onboardingComplete ? ( + {onboardingComplete && !fatalUserData ? : null} + {fatalUserData ? ( + + + Library data unavailable + + + Astra could not restore your playlists, favorites, and settings from either safety + snapshot. Nothing was silently reset, and your music files were not changed. + + + ) : onboardingComplete ? ( <> {/* Always-mounted player overlay (store-gated); open/close is a pure UI-thread slide with zero mount cost after the first mount. */} diff --git a/src/app/recently-played.tsx b/src/app/recently-played.tsx index 8dd41aa..5282b46 100644 --- a/src/app/recently-played.tsx +++ b/src/app/recently-played.tsx @@ -16,8 +16,7 @@ import { useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { useLibraryStore } from '@/stores/libraryStore'; import { usePlayerStore } from '@/stores/playerStore'; -import { playTracks } from '@/audio/playbackController'; -import { dbTrackToTrack } from '@/library/trackAdapter'; +import { playLibraryQuery } from '@/audio/playbackController'; import type { DbTrack } from '@/types/library'; function formatCount(count: number, noun: string): string { @@ -46,8 +45,8 @@ export default function RecentlyPlayedScreen() { const playFrom = (index: number) => { if (tracks.length === 0) return; - void playTracks(tracks.map(dbTrackToTrack), { - startIndex: index, + void playLibraryQuery({ kind: 'recent' }, { + anchorPath: tracks[index]?.path, source: { kind: 'recently-played', label: 'Recently Played' }, }); }; diff --git a/src/app/settings/troubleshooting.tsx b/src/app/settings/troubleshooting.tsx index 4f8c825..157f443 100644 --- a/src/app/settings/troubleshooting.tsx +++ b/src/app/settings/troubleshooting.tsx @@ -10,9 +10,8 @@ import { SettingsSectionScreen, type SettingsIconName, } from '@/components/settings/SettingsSectionScaffold'; -import { openLibraryDb } from '@/db/database'; import { getLyricsCacheCount } from '@/db/lyricsQueries'; -import { getWaveformCacheCount } from '@/db/waveformQueries'; +import { AstraLibraryData } from '../../../modules/astra-library-scanner'; import { clearAllLyricsCache } from '@/lyrics/lyrics'; import { clearAllWaveformCache } from '@/scope/waveform'; import { useLyricsStore } from '@/stores/lyricsStore'; @@ -45,10 +44,9 @@ export default function TroubleshootingSettingsScreen() { const disabled = isScanning || runningAction !== null; const refreshCounts = useCallback(async () => { - const db = await openLibraryDb(); const [lyrics, waveforms] = await Promise.all([ - getLyricsCacheCount(db), - getWaveformCacheCount(db), + getLyricsCacheCount(), + AstraLibraryData.countWaveforms(), ]); setCounts({ lyrics, waveforms }); }, []); diff --git a/src/app/signal/scan.tsx b/src/app/signal/scan.tsx index 6260d20..c86498b 100644 --- a/src/app/signal/scan.tsx +++ b/src/app/signal/scan.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Linking, Pressable, StyleSheet, View } from 'react-native'; import { CameraView, useCameraPermissions } from 'expo-camera'; import * as DocumentPicker from 'expo-document-picker'; @@ -15,17 +15,18 @@ import { type SignalScanPhase, } from '@/components/signal/SignalScanTransition'; import { decodeSignalFromUri } from '@/audio/signalDecodeImage'; -import { matchSignalToLibrary } from '@/audio/signalLocalMatch'; +import type { SignalLocalMatchResult } from '@/audio/signalLocalMatch'; import { SIGNAL_SCAN_GUIDE } from '@/audio/signalScanGeometry'; import { encodeSignalWebUrl } from '@/audio/signalShare'; import { enqueueEnd, playTracks } from '@/audio/playbackController'; import { dbTrackToTrack } from '@/library/trackAdapter'; import { playHaptic } from '@/lib/haptics'; -import { useLibraryStore } from '@/stores/libraryStore'; import { radius, spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; import type { SignalPayload } from '@boof2015/astra-signal'; +import type { DbTrack } from '@/types/library'; +import { AstraLibraryData } from '../../../modules/astra-library-scanner'; const MIN_READING_MS = 300; const FAILURE_RETURN_MS = 480; @@ -35,8 +36,6 @@ export default function SignalScanScreen() { const ripple = useRipple(); const colors = useColors(); const router = useRouter(); - const libraryInitialized = useLibraryStore((state) => state.initialized); - const libraryTracks = useLibraryStore((state) => state.tracks); const cameraRef = useRef(null); const [permission, requestPermission] = useCameraPermissions(); const [busy, setBusy] = useState(false); @@ -47,10 +46,28 @@ export default function SignalScanScreen() { const [actionError, setActionError] = useState(null); const [previewSize, setPreviewSize] = useState({ width: 0, height: 0 }); const readingStartedAt = useRef(0); - const resolution = useMemo( - () => result && libraryInitialized ? matchSignalToLibrary(result, libraryTracks) : null, - [libraryInitialized, libraryTracks, result] - ); + const [resolution, setResolution] = useState | null>(null); + + useEffect(() => { + if (!result) return; + let cancelled = false; + void AstraLibraryData.matchSignal( + result.title, + result.artist, + result.durationSec > 0 ? result.durationSec : null + ).then((native) => { + if (cancelled) return; + if (native.kind === 'none') setResolution({ kind: 'none' }); + else if (native.kind === 'match') { + setResolution({ kind: 'match', candidate: native.candidates[0] }); + } else { + setResolution({ kind: 'ambiguous', candidates: native.candidates }); + } + }); + return () => { + cancelled = true; + }; + }, [result]); useEffect(() => { if (phase !== 'failure') return; @@ -118,13 +135,14 @@ export default function SignalScanScreen() { const scanAnother = () => { setResult(null); + setResolution(null); setError(null); setActionState('idle'); setActionError(null); setPhase('idle'); }; - const playMatchedTrack = async (track: (typeof libraryTracks)[number]) => { + const playMatchedTrack = async (track: DbTrack) => { if (actionState === 'playing' || actionState === 'queueing') return; playHaptic('confirm'); setActionState('playing'); @@ -140,7 +158,7 @@ export default function SignalScanScreen() { } }; - const queueMatchedTrack = async (track: (typeof libraryTracks)[number]) => { + const queueMatchedTrack = async (track: DbTrack) => { if (actionState !== 'idle') return; playHaptic('confirm'); setActionState('queueing'); diff --git a/src/audio/audioProcessingStartup.ts b/src/audio/audioProcessingStartup.ts index 4f6561b..ab361ee 100644 --- a/src/audio/audioProcessingStartup.ts +++ b/src/audio/audioProcessingStartup.ts @@ -2,8 +2,7 @@ import TrackPlayer, { type Track as RntpTrack } from 'react-native-track-player' import { DspStartupCoordinator, type DspWarmupInputs } from './dspStartupCoordinator'; import { useAudioSettingsStore } from '@/stores/audioSettingsStore'; import { useEQStore } from '@/stores/eqStore'; -import { openLibraryDb } from '@/db/database'; -import { getTrackLoudnessByPaths } from '@/db/queries'; +import { AstraLibraryData } from '../../modules/astra-library-scanner'; import { factsFromRow } from '@/audio/trackAnalysis'; import type { NormalizationSettings } from '@/audio/normalization'; import { @@ -63,9 +62,8 @@ async function resolveTargetGain( return resolveStartupTargetGain('remote', null, settings, fallback); } - const db = await openLibraryDb(); - const rows = await getTrackLoudnessByPaths(db, [target.url]); - const facts = factsFromRow(rows.get(target.url) ?? null); + const rows = await AstraLibraryData.getTrackLoudness([target.url]); + const facts = factsFromRow(rows[0] ?? null); return resolveStartupTargetGain('local', facts, settings, fallback); } diff --git a/src/audio/gainRegistry.ts b/src/audio/gainRegistry.ts index 2ca0974..e87e4c2 100644 --- a/src/audio/gainRegistry.ts +++ b/src/audio/gainRegistry.ts @@ -20,13 +20,8 @@ import { useQueueStore } from '@/stores/queueStore'; import { useAudioSettingsStore } from '@/stores/audioSettingsStore'; -import { openLibraryDb } from '@/db/database'; -import { - getLibraryLoudnessStats, - getSetting, - getTrackLoudnessByPaths, - setSetting, -} from '@/db/queries'; +import { AstraLibraryData } from '../../modules/astra-library-scanner'; +import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings'; import { dbToLinear, hasUsableReplayGain, @@ -41,8 +36,7 @@ const FALLBACK_DB_KEY = 'normalization_fallback_db'; /** Single-setting cold-start read; no library aggregate or track analysis. */ export async function loadPersistedFallbackGain(): Promise { - const db = await openLibraryDb(); - const raw = await getSetting(db, FALLBACK_DB_KEY); + const raw = await getNativeSetting(FALLBACK_DB_KEY); if (raw === null) return null; const gainDb = Number(raw); if (!Number.isFinite(gainDb)) return null; @@ -132,19 +126,21 @@ async function registerQueueGains(): Promise { } if (settings.enabled && localUrls.length > 0) { - const db = await openLibraryDb(); - const rows = await getTrackLoudnessByPaths(db, localUrls); - if (gen !== generation) return; // a newer registration superseded this one - for (const url of localUrls) { - const row = rows.get(url); - if (!row) continue; // not in the library — leave unregistered (fallback) - const facts = factsFromRow(row); - if (facts.loudnessLufs != null || hasUsableReplayGain(facts, settings)) { - entries[url] = resolveNormalizationGain(facts, settings).linearGain; + for (let offset = 0; offset < localUrls.length; offset += 200) { + const chunk = localUrls.slice(offset, offset + 200); + const rows = await AstraLibraryData.getTrackLoudness(chunk); + if (gen !== generation) return; // a newer registration superseded this one + const byPath = new Map(rows.map((row) => [row.path, row])); + for (const url of chunk) { + const row = byPath.get(url); + if (!row) continue; // not in the library — leave unregistered (fallback) + const facts = factsFromRow(row); + if (facts.loudnessLufs != null || hasUsableReplayGain(facts, settings)) { + entries[url] = resolveNormalizationGain(facts, settings).linearGain; + } } - // No usable facts yet: deliberately NOT registered, so the transition - // activates the fallback gain. (resolveNormalizationGain returns unity for - // fact-less tracks — registering that would reintroduce the loud burst.) + // Fact-less tracks remain unregistered so transitions use the quiet + // fallback until analysis completes. } } @@ -167,16 +163,14 @@ async function refreshFallbackGain(): Promise { return; } - const db = await openLibraryDb(); - // Push the last persisted value first — closes the cold-start window where a // headless (Android Auto) start could hit a transition before the aggregate lands. - const persistedRaw = await getSetting(db, FALLBACK_DB_KEY).catch(() => null); + const persistedRaw = await getNativeSetting(FALLBACK_DB_KEY).catch(() => null); const persistedDb = persistedRaw === null ? NaN : Number(persistedRaw); if (Number.isFinite(persistedDb)) setFallbackGainNative(dbToLinear(persistedDb)); - const stats = await getLibraryLoudnessStats(db); + const stats = await AstraLibraryData.getLibraryLoudnessStats(); const resolved = resolveFallbackGain(stats, settings); setFallbackGainNative(resolved.linearGain); - await setSetting(db, FALLBACK_DB_KEY, String(resolved.gainDb)).catch(() => {}); + await setNativeSetting(FALLBACK_DB_KEY, String(resolved.gainDb)).catch(() => {}); } diff --git a/src/audio/playbackController.ts b/src/audio/playbackController.ts index 423cd7d..ed0d243 100644 --- a/src/audio/playbackController.ts +++ b/src/audio/playbackController.ts @@ -4,6 +4,7 @@ import TrackPlayer, { type Track as RntpTrack, } from 'react-native-track-player'; import type { PlaybackSource, PlaybackState, Track } from '@/types/audio'; +import type { DbTrack } from '@/types/library'; import { usePlayerStore, type RepeatMode as RepeatModeStr } from '@/stores/playerStore'; import { useQueueStore } from '@/stores/queueStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; @@ -27,6 +28,12 @@ import { primePreparedTrackForPlayback, } from './audioProcessingStartup'; import { shouldRestartOnPrevious } from './playbackNavigation'; +import { + AstraLibraryData, + type LibraryQuery, + type NativePlaybackWindow, +} from '../../modules/astra-library-scanner'; +import { dbTrackToTrack } from '@/library/trackAdapter'; // If a background queue fill dies partway, the mirror no longer matches the // native queue — re-read the truth. @@ -45,6 +52,24 @@ setQueueLoadErrorHandler(() => { // autoQueue + shuffledAutoIndices split, but over RNTP's flat native queue). let originalOrder: string[] | null = null; let restoredMaterializationPromise: Promise | null = null; +let virtualContext: { + sessionId: string; + windowStart: number; + loadedEnd: number; + totalCount: number; +} | null = null; +let virtualRefillPromise: Promise | null = null; + +export interface VirtualQueuePageItem { + track: RntpTrack; + queuePosition: number; +} + +export interface VirtualQueuePage { + items: VirtualQueuePageItem[]; + activePosition: number; + totalCount: number; +} export interface PlaybackStartOptions { startIndex?: number; @@ -299,6 +324,45 @@ export function restorePlaybackSession( player.setRestoredSessionPending(true); } +/** + * Hydrates a persisted native virtual context without starting playback. The + * rolling window is materialized only when the user presses Play. + */ +export function restoreVirtualPlaybackContext( + window: NativePlaybackWindow, + session: PlaybackSessionSnapshotV1, +): void { + const tracks = window.items.map(dbTrackToTrack); + if (tracks.length === 0) { + restorePlaybackSession(null); + return; + } + const queueTracks = tracks.map(toRntpTrack); + const activeIndex = Math.max( + 0, + Math.min(queueTracks.length - 1, window.activePosition - window.windowStart), + ); + virtualContext = { + sessionId: window.sessionId, + windowStart: window.windowStart, + loadedEnd: window.items[window.items.length - 1].queuePosition + 1, + totalCount: window.totalCount, + }; + originalOrder = session.shuffle ? null : tracks.map((track) => track.id); + useQueueStore.getState().setSnapshot(queueTracks, activeIndex, { + source: session.source, + }); + const activeTrack = tracks[activeIndex]; + const player = usePlayerStore.getState(); + player.setCurrentTrack(activeTrack); + player.setProgress(session.position, activeTrack.duration); + player.clearPendingSeek(); + player.setShuffle(session.shuffle); + player.setRepeat(session.repeat); + player.setPlaybackState('paused'); + player.setRestoredSessionPending(true); +} + /** A live RNTP session (for example Android Auto) wins over an older disk snapshot. */ export async function hasActiveNativePlaybackSession(): Promise { try { @@ -313,6 +377,7 @@ export async function playTracks( tracks: Track[], options: PlaybackStartOptions ): Promise { + virtualContext = null; return playTracksInternal(tracks, options, { allowBackgroundSetup: false }); } @@ -321,9 +386,254 @@ export async function playTracksForCar( tracks: Track[], options: PlaybackStartOptions ): Promise { + virtualContext = null; return playTracksInternal(tracks, options, { allowBackgroundSetup: true }); } +export interface LibraryPlaybackStartOptions extends PlaybackStartOptions { + anchorPath?: string | null; + shuffle?: boolean; + allowBackgroundSetup?: boolean; +} + +/** + * Starts a native virtual library context. Only 25 previous + 200 upcoming + * tracks cross into JavaScript; the complete ordered path set remains in Room. + */ +export async function playLibraryQuery( + query: LibraryQuery, + options: LibraryPlaybackStartOptions, +): Promise { + selectPhonePlaybackTarget(); + discardPendingRestoredSession(); + await ensurePlayerReady({ + allowBackgroundSetup: options.allowBackgroundSetup ?? false, + materializeRestored: false, + }); + const window = await AstraLibraryData.createPlaybackContext( + query, + options.anchorPath ?? null, + options.shuffle ?? false, + null, + ); + if (window.items.length === 0) return; + await startVirtualWindow(window, options.source, options.shuffle ?? false); +} + +async function startVirtualWindow( + window: NativePlaybackWindow, + source: PlaybackSource, + shuffle: boolean, +): Promise { + const tracks = window.items.map(dbTrackToTrack); + const queueTracks = tracks.map(toRntpTrack); + const startIndex = Math.max( + 0, + Math.min(queueTracks.length - 1, window.activePosition - window.windowStart), + ); + virtualContext = { + sessionId: window.sessionId, + windowStart: window.windowStart, + loadedEnd: window.items.length === 0 + ? window.windowStart + : window.items[window.items.length - 1].queuePosition + 1, + totalCount: window.totalCount, + }; + originalOrder = shuffle ? null : tracks.map((track) => track.id); + usePlayerStore.getState().setShuffle(shuffle); + const playbackTarget = dspTargetFromTrack(queueTracks[startIndex], 'none'); + useQueueStore.getState().setSnapshot(queueTracks, startIndex, { source }); + setOptimisticTrack(queueTracks[startIndex], 'loading'); + try { + await prepareAudioProcessingForPlayback(playbackTarget, 'virtual-queue-play'); + await loadQueueChunked(queueTracks, startIndex); + await primePreparedTrackForPlayback(playbackTarget, 'virtual-queue-play'); + await TrackPlayer.play(); + usePlayerStore.getState().setPlaybackState('playing'); + } catch (error) { + virtualContext = null; + await reconcilePlayerFromNative(); + throw error; + } +} + +/** Keeps the rolling native queue bounded while natural playback advances. */ +export function handleVirtualPlaybackAdvance(_nativeEventIndex?: number): Promise { + if (!virtualContext) return Promise.resolve(); + if (virtualRefillPromise) return virtualRefillPromise; + virtualRefillPromise = replenishVirtualContext().finally(() => { + virtualRefillPromise = null; + }); + return virtualRefillPromise; +} + +async function replenishVirtualContext(): Promise { + const context = virtualContext; + if (!context) return; + await queueLoadSettled(); + if (virtualContext !== context) return; + const nativeIndex = await TrackPlayer.getActiveTrackIndex() ?? -1; + if (nativeIndex < 0) return; + const activePosition = context.windowStart + nativeIndex; + void AstraLibraryData.updatePlaybackPosition(context.sessionId, activePosition).catch(() => {}); + + let localIndex = nativeIndex; + if (localIndex > 25) { + const removeCount = localIndex - 25; + const indices = Array.from({ length: removeCount }, (_, index) => index); + await TrackPlayer.remove(indices); + useQueueStore.getState().removeIndices(indices); + context.windowStart += removeCount; + localIndex -= removeCount; + } + + const currentLength = useQueueStore.getState().tracks.length; + const upcoming = currentLength - localIndex - 1; + if (upcoming >= 50 || context.loadedEnd >= context.totalCount) return; + const next = await AstraLibraryData.getPlaybackWindow( + context.sessionId, + context.loadedEnd, + 100, + ); + if (virtualContext !== context || next.items.length === 0) return; + const additions = next.items.map(dbTrackToTrack).map(toRntpTrack); + const before = useQueueStore.getState(); + await appendUpcomingChunked(additions, before.tracks.length); + useQueueStore.getState().setSnapshot( + [...before.tracks, ...additions], + localIndex, + ); + context.loadedEnd = next.items[next.items.length - 1].queuePosition + 1; +} + +/** Returns a bounded page from the native virtual queue, or null for ordinary queues. */ +export async function getVirtualQueuePage( + start: number, + limit = 100, +): Promise { + const context = virtualContext; + if (!context) return null; + const window = await AstraLibraryData.getPlaybackWindow( + context.sessionId, + Math.max(0, start), + Math.max(1, Math.min(100, limit)), + ); + if (virtualContext !== context) return null; + return { + items: window.items.map((item) => ({ + track: { + ...toRntpTrack(dbTrackToTrack(item)), + astraQueuePosition: item.queuePosition, + }, + queuePosition: item.queuePosition, + })), + activePosition: window.activePosition, + totalCount: window.totalCount, + }; +} + +export function getVirtualQueueState(): { + sessionId: string; + activePosition: number; + totalCount: number; +} | null { + const context = virtualContext; + if (!context) return null; + const localActive = useQueueStore.getState().activeIndex; + return { + sessionId: context.sessionId, + activePosition: context.windowStart + Math.max(0, localActive), + totalCount: context.totalCount, + }; +} + +async function adoptCurrentQueueAsVirtualContext(): Promise { + if (virtualContext) return true; + const snapshot = await getQueueSnapshot(); + if (snapshot.queue.length === 0) return false; + const activeIndex = Math.max(0, snapshot.activeIndex); + const paths = snapshot.queue.map(rntpTrackPath); + const window = await AstraLibraryData.createPlaybackContext( + { kind: 'manual', paths }, + paths[activeIndex] ?? null, + false, + null, + ); + virtualContext = { + sessionId: window.sessionId, + windowStart: window.activePosition - activeIndex, + loadedEnd: Math.min(window.totalCount, snapshot.queue.length), + totalCount: window.totalCount, + }; + originalOrder = null; + return true; +} + +/** + * Applies a native virtual-queue edit without replacing the playing row. Only + * RNTP's bounded upcoming tail is rebuilt; the current audio item keeps playing. + */ +async function mutateVirtualQueue( + operation: + | 'insertAfterActive' + | 'append' + | 'insertQueryAfterActive' + | 'appendQuery' + | 'remove' + | 'move' + | 'moveManyAfterActive' + | 'shuffle', + values: Record, +): Promise | null> { + const context = virtualContext; + if (!context) return null; + await queueLoadSettled(); + if (virtualContext !== context) return null; + const [window, nativeIndex] = await Promise.all([ + AstraLibraryData.mutatePlaybackContext(operation, values), + TrackPlayer.getActiveTrackIndex(), + ]); + if (!window || virtualContext !== context) return window; + + const activeLocal = nativeIndex ?? useQueueStore.getState().activeIndex; + const boundedActive = Math.max(0, activeLocal); + const upcoming = window.items + .filter((item) => item.queuePosition > window.activePosition) + .map(dbTrackToTrack) + .map(toRntpTrack); + const before = useQueueStore.getState(); + const prefix = before.tracks.slice(0, boundedActive + 1); + + await TrackPlayer.removeUpcomingTracks(); + if (upcoming.length > 0) { + await appendUpcomingChunked(upcoming, prefix.length); + } + useQueueStore.getState().setSnapshot( + [...prefix, ...upcoming], + boundedActive, + ); + context.windowStart = window.activePosition - boundedActive; + context.loadedEnd = upcoming.length > 0 + ? window.activePosition + upcoming.length + 1 + : window.activePosition + 1; + context.totalCount = window.totalCount; + return window; +} + +/** Adds an entire native query context without materializing it in JavaScript. */ +export async function enqueueLibraryQuery( + query: LibraryQuery, + placement: 'next' | 'end', +): Promise { + await ensurePlayerReady(); + await queueLoadSettled(); + if (!await adoptCurrentQueueAsVirtualContext()) return; + await mutateVirtualQueue( + placement === 'next' ? 'insertQueryAfterActive' : 'appendQuery', + { context: query }, + ); +} + async function playTracksInternal( tracks: Track[], startOptions: PlaybackStartOptions, @@ -365,6 +675,7 @@ export async function shuffleTracks( source: PlaybackSource ): Promise { if (tracks.length === 0) return; + virtualContext = null; selectPhonePlaybackTarget(); discardPendingRestoredSession(); await ensurePlayerReady({ materializeRestored: false }); @@ -563,6 +874,15 @@ export async function toggleShuffle(): Promise { await ensurePlayerReady(); await queueLoadSettled(); + if (virtualContext) { + await mutateVirtualQueue('shuffle', { + enabled: next, + seed: next ? Date.now() : null, + }); + store.setShuffle(next); + return; + } + const snapshot = await getQueueSnapshot(); const queue = snapshot.queue; const activeIndex = snapshot.activeIndex >= 0 ? snapshot.activeIndex : 0; @@ -598,6 +918,10 @@ export async function toggleShuffle(): Promise { export async function enqueueTop(track: Track): Promise { await ensurePlayerReady(); await queueLoadSettled(); + if (virtualContext) { + await mutateVirtualQueue('insertAfterActive', { paths: [track.path] }); + return; + } const activeIndex = await TrackPlayer.getActiveTrackIndex(); const activeTrack = await TrackPlayer.getActiveTrack(); const insertBefore = activeIndex === undefined ? undefined : activeIndex + 1; @@ -620,6 +944,10 @@ export async function enqueueTop(track: Track): Promise { export async function enqueueEnd(track: Track): Promise { await ensurePlayerReady(); await queueLoadSettled(); + if (virtualContext) { + await mutateVirtualQueue('append', { paths: [track.path] }); + return; + } const queueTrack = toRntpTrack(track); await TrackPlayer.add(queueTrack); if (useQueueStore.getState().hasSnapshot) { @@ -635,6 +963,12 @@ export async function enqueueTopMany(tracks: Track[]): Promise { if (tracks.length === 0) return; await ensurePlayerReady(); await queueLoadSettled(); + if (virtualContext) { + await mutateVirtualQueue('insertAfterActive', { + paths: tracks.map((track) => track.path), + }); + return; + } const activeIndex = await TrackPlayer.getActiveTrackIndex(); const activeTrack = await TrackPlayer.getActiveTrack(); const insertBefore = activeIndex === undefined ? undefined : activeIndex + 1; @@ -655,6 +989,12 @@ export async function enqueueEndMany(tracks: Track[]): Promise { if (tracks.length === 0) return; await ensurePlayerReady(); await queueLoadSettled(); + if (virtualContext) { + await mutateVirtualQueue('append', { + paths: tracks.map((track) => track.path), + }); + return; + } await TrackPlayer.add(tracks.map(toRntpTrack)); await useQueueStore.getState().refreshFromNative(); if (originalOrder) originalOrder.push(...tracks.map((track) => track.id)); @@ -675,6 +1015,11 @@ function moveOriginalOrderIfUnshuffled(fromIndex: number, toIndex: number): void interface QueueRemoveOptions { updateMirror?: boolean; + virtualPosition?: boolean; +} + +interface QueuePositionOptions { + virtualPosition?: boolean; } /** Replace everything after the current track with `upcoming` (in order). */ @@ -691,19 +1036,53 @@ export async function setUpcoming(upcoming: RntpTrack[]): Promise { } /** Move a queued item by absolute RNTP queue index. */ -export async function moveQueueItem(fromAbsoluteIndex: number, toAbsoluteIndex: number): Promise { +export async function moveQueueItem( + fromAbsoluteIndex: number, + toAbsoluteIndex: number, + options: QueuePositionOptions = {}, +): Promise { if (fromAbsoluteIndex === toAbsoluteIndex) return; await ensurePlayerReady(); await queueLoadSettled(); + if (virtualContext) { + const from = options.virtualPosition + ? fromAbsoluteIndex + : virtualContext.windowStart + fromAbsoluteIndex; + const to = options.virtualPosition + ? toAbsoluteIndex + : virtualContext.windowStart + toAbsoluteIndex; + await mutateVirtualQueue('move', { from, to }); + return; + } await TrackPlayer.move(fromAbsoluteIndex, toAbsoluteIndex); useQueueStore.getState().moveItem(fromAbsoluteIndex, toAbsoluteIndex); moveOriginalOrderIfUnshuffled(fromAbsoluteIndex, toAbsoluteIndex); } /** Jump to (and play) an absolute queue index. */ -export async function jumpToQueueIndex(index: number): Promise { +export async function jumpToQueueIndex( + index: number, + options: QueuePositionOptions = {}, +): Promise { selectPhonePlaybackTarget(); await ensurePlayerReady(); + if (virtualContext) { + const context = virtualContext; + const position = options.virtualPosition ? index : context.windowStart + index; + const bounded = Math.max(0, Math.min(context.totalCount - 1, position)); + await AstraLibraryData.updatePlaybackPosition(context.sessionId, bounded); + const window = await AstraLibraryData.getPlaybackWindow( + context.sessionId, + Math.max(0, bounded - 25), + 226, + ); + await startVirtualWindow( + window, + useQueueStore.getState().source ?? { kind: 'library', label: 'Library' }, + usePlayerStore.getState().shuffle, + ); + return; + } // Mid-fill, the tapped row may not be in the native queue yet (or may sit at // a shifted native index while the head is still prepending) — translate, // waiting out the fill only when the target isn't loaded. @@ -736,7 +1115,20 @@ async function getUpcoming(): Promise<{ activeIndex: number; upcoming: RntpTrack } /** Move an upcoming track (absolute index) to the front of the upcoming queue. */ -export async function requeueToTop(absoluteIndex: number): Promise { +export async function requeueToTop( + absoluteIndex: number, + options: QueuePositionOptions = {}, +): Promise { + if (virtualContext) { + const position = options.virtualPosition + ? absoluteIndex + : virtualContext.windowStart + absoluteIndex; + await mutateVirtualQueue('move', { + from: position, + to: (getVirtualQueueState()?.activePosition ?? 0) + 1, + }); + return; + } const { activeIndex, upcoming } = await getUpcoming(); const local = absoluteIndex - (activeIndex + 1); if (local < 0 || local >= upcoming.length) return; @@ -746,7 +1138,18 @@ export async function requeueToTop(absoluteIndex: number): Promise { } /** Move a group of upcoming tracks (absolute indices) to the front, order kept. */ -export async function requeueManyToTop(absoluteIndices: number[]): Promise { +export async function requeueManyToTop( + absoluteIndices: number[], + options: QueuePositionOptions = {}, +): Promise { + if (virtualContext) { + await mutateVirtualQueue('moveManyAfterActive', { + positions: options.virtualPosition + ? absoluteIndices + : absoluteIndices.map((index) => virtualContext!.windowStart + index), + }); + return; + } const { activeIndex, upcoming } = await getUpcoming(); const locals = new Set(absoluteIndices.map((i) => i - (activeIndex + 1))); const moved = upcoming.filter((_, i) => locals.has(i)); @@ -761,6 +1164,13 @@ export async function removeFromQueue( ): Promise { await ensurePlayerReady(); await queueLoadSettled(); + if (virtualContext) { + const position = options.virtualPosition + ? absoluteIndex + : virtualContext.windowStart + absoluteIndex; + await mutateVirtualQueue('remove', { positions: [position] }); + return; + } await TrackPlayer.remove(absoluteIndex); if (options.updateMirror !== false) { useQueueStore.getState().removeIndices([absoluteIndex]); @@ -776,6 +1186,14 @@ export async function removeManyFromQueue( if (absoluteIndices.length === 0) return; await ensurePlayerReady(); await queueLoadSettled(); + if (virtualContext) { + await mutateVirtualQueue('remove', { + positions: options.virtualPosition + ? absoluteIndices + : absoluteIndices.map((index) => virtualContext!.windowStart + index), + }); + return; + } await TrackPlayer.remove(absoluteIndices); if (options.updateMirror !== false) { useQueueStore.getState().removeIndices(absoluteIndices); diff --git a/src/audio/playbackService.ts b/src/audio/playbackService.ts index 2a4318b..366eeda 100644 --- a/src/audio/playbackService.ts +++ b/src/audio/playbackService.ts @@ -3,7 +3,12 @@ import { syncCarNowPlayingFromTrackPlayer } from './carSync'; import { syncWidgetNowPlayingFromTrackPlayer } from './widgetSync'; import { applyNormalizationForActiveTrack } from './applyNormalization'; import { startAudioProcessingWarmup } from './audioProcessingStartup'; -import { playForCar, skipToNext, skipToPrevious } from './playbackController'; +import { + handleVirtualPlaybackAdvance, + playForCar, + skipToNext, + skipToPrevious, +} from './playbackController'; import { nativeIndexToAbsolute } from './queueLoader'; import { useQueueStore } from '@/stores/queueStore'; import { useSleepTimerStore } from '@/stores/sleepTimerStore'; @@ -63,6 +68,9 @@ export async function PlaybackService(): Promise { event.index != null ? nativeIndexToAbsolute(event.index) : -1 ); } + void handleVirtualPlaybackAdvance(event.index).catch((error) => { + console.warn('[playback] virtual queue replenish failed', error); + }); scheduleSync(); // Apply normalization here too (not just in the UI hook) so playback started from // Android Auto / Bluetooth with the app closed is still normalized. diff --git a/src/audio/trackAnalysis.ts b/src/audio/trackAnalysis.ts index 5514648..441b995 100644 --- a/src/audio/trackAnalysis.ts +++ b/src/audio/trackAnalysis.ts @@ -6,15 +6,16 @@ // falls back to the expensive loudness decode when ReplayGain is off or absent — so a // fully tagged library normalizes with no decoding at all. -import { AstraLibraryScanner } from '../../modules/astra-library-scanner'; -import type { LibraryDatabase } from '@/db/database'; -import { openLibraryDb } from '@/db/database'; -import { getTrackLoudness, setTrackLoudness, setTrackReplayGain, type TrackLoudness } from '@/db/queries'; +import { + AstraLibraryData, + AstraLibraryScanner, + type NativeTrackLoudness, +} from '../../modules/astra-library-scanner'; import { hasUsableReplayGain, type LoudnessFacts } from '@/audio/normalization'; import { useAudioSettingsStore } from '@/stores/audioSettingsStore'; /** Map a loudness DB row (or a miss) to the resolver's facts shape. */ -export function factsFromRow(row: TrackLoudness | null): LoudnessFacts { +export function factsFromRow(row: NativeTrackLoudness | null): LoudnessFacts { return { loudnessLufs: row?.loudness_lufs ?? null, samplePeak: row?.sample_peak ?? null, @@ -30,14 +31,13 @@ export function factsFromRow(row: TrackLoudness | null): LoudnessFacts { * re-measures). The decode is the expensive part; failures leave loudness NULL. */ export async function measureAndStoreLoudness( - db: LibraryDatabase, path: string ): Promise<{ lufs: number | null; peak: number | null }> { try { const res = await AstraLibraryScanner.measureLoudness(path); const lufs = res?.lufs ?? null; const peak = res?.peak ?? null; - await setTrackLoudness(db, path, lufs, peak).catch(() => {}); + await AstraLibraryData.setTrackLoudness(path, lufs, peak).catch(() => {}); return { lufs, peak }; } catch { return { lufs: null, peak: null }; @@ -60,8 +60,7 @@ export function ensureTrackLoudness(path: string): Promise { } async function run(path: string): Promise { - const db = await openLibraryDb(); - const row = await getTrackLoudness(db, path); + const row = (await AstraLibraryData.getTrackLoudness([path]))[0] ?? null; let facts = factsFromRow(row); // 1. Read ReplayGain tags once per track (container-only, no decode). Decoupled @@ -70,12 +69,13 @@ async function run(path: string): Promise { if (!row || row.rg_scanned !== 1) { try { const rg = await AstraLibraryScanner.readReplayGain(path); - await setTrackReplayGain(db, path, { - trackGainDb: rg.trackGainDb, - albumGainDb: rg.albumGainDb, - trackPeak: rg.trackPeak, - albumPeak: rg.albumPeak, - }).catch(() => {}); + await AstraLibraryData.setTrackReplayGain( + path, + rg.trackGainDb, + rg.albumGainDb, + rg.trackPeak, + rg.albumPeak + ).catch(() => {}); facts = { ...facts, replayGainTrackDb: rg.trackGainDb, @@ -96,6 +96,6 @@ async function run(path: string): Promise { if (hasUsableReplayGain(facts, settings)) return facts; // 4. Otherwise measure loudness now (decode) and merge it in. - const measured = await measureAndStoreLoudness(db, path); + const measured = await measureAndStoreLoudness(path); return { ...facts, loudnessLufs: measured.lufs, samplePeak: measured.peak }; } diff --git a/src/car/carPlayback.ts b/src/car/carPlayback.ts index 5b31d25..2ff5fbc 100644 --- a/src/car/carPlayback.ts +++ b/src/car/carPlayback.ts @@ -1,28 +1,26 @@ import { - getAllTracks, - getRecentlyPlayedTracks, - getTracksByAlbumKey, -} from '@/db/queries'; -import { - getFavoriteTracks, - getPlaylistEntries, - getPlaylists, - markPlaylistPlayed, -} from '@/db/playlistQueries'; -import { openLibraryDb, type LibraryDatabase } from '@/db/database'; -import { buildAlbumList } from '@/library/albumSummary'; -import { buildArtistList, filterTracksByArtist } from '@/library/artistGrouping'; + AstraLibraryData, + type LibraryQuery, +} from '../../modules/astra-library-scanner'; import { dbTrackToTrack } from '@/library/trackAdapter'; -import { playForCar, playTracksForCar, pause, seekTo, skipToNext, skipToPrevious } from '@/audio/playbackController'; +import { + pause, + playForCar, + playLibraryQuery, + playTracksForCar, + seekTo, + skipToNext, + skipToPrevious, +} from '@/audio/playbackController'; import { syncCarNowPlayingFromTrackPlayer } from '@/audio/carSync'; import { startAudioProcessingWarmup } from '@/audio/audioProcessingStartup'; import TrackPlayer, { type Track as RntpTrack } from 'react-native-track-player'; -import { useLibraryStore } from '@/stores/libraryStore'; import { usePlaylistStore } from '@/stores/playlistStore'; import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore'; import { useSettingsStore } from '@/stores/settingsStore'; import type { PlaybackSource } from '@/types/audio'; import type { DbTrack } from '@/types/library'; +import type { Playlist } from '@/types/playlist'; export interface CarMediaPayload { kind?: string; @@ -53,8 +51,8 @@ let initPromise: Promise | null = null; async function initializeForCar(): Promise { if (!initPromise) { initPromise = (async () => { + await AstraLibraryData.initialize(); await useSettingsStore.getState().load(); - await useLibraryStore.getState().initialize(); await usePlaylistStore.getState().refresh(); await useRemoteSourcesStore.getState().init(); })().catch((err) => { @@ -123,57 +121,49 @@ function rntpTrackPath(track: RntpTrack | null | undefined): string | null { } async function playMedia(media: CarMediaPayload): Promise { - const db = await openLibraryDb(); - const resolved = await resolveMediaTracks(db, media); - if (!resolved || resolved.tracks.length === 0) return; - await playTracksForCar(resolved.tracks.map(dbTrackToTrack), { - startIndex: resolved.startIndex, - source: resolved.source, - }); + const contextMedia = media.kind === 'track' ? contextFromTrack(media) : media; + const query = contextMedia ? queryForMedia(contextMedia) : null; + if (query) { + await playLibraryQuery(query, { + anchorPath: media.kind === 'track' ? media.path : null, + source: await sourceForContext(contextMedia!), + allowBackgroundSetup: true, + }); + } else if (media.path) { + const track = await AstraLibraryData.getTrack(media.path); + if (!track) return; + await playTracksForCar([dbTrackToTrack(track)], { + startIndex: 0, + source: { kind: 'android-auto', label: 'Android Auto' }, + }); + } else { + return; + } if (media.kind === 'playlist' && media.id != null) { - await markPlaylistPlayed(db, media.id); + await AstraLibraryData.markPlaylistPlayed(media.id); } } -async function resolveMediaTracks( - db: LibraryDatabase, - media: CarMediaPayload, -): Promise<{ tracks: DbTrack[]; startIndex: number; source: PlaybackSource } | null> { - if (media.kind === 'track') { - const context = contextFromTrack(media); - const contextTracks = context ? await tracksForContext(db, context) : []; - const startIndex = contextTracks.findIndex((track) => track.path === media.path); - if (context && contextTracks.length > 0 && startIndex >= 0) { - return { - tracks: contextTracks, - startIndex, - source: await sourceForContext(db, context, contextTracks), - }; - } - const track = media.path ? await getTrackByPath(db, media.path) : null; - return track - ? { - tracks: [track], - startIndex: 0, - source: { kind: 'android-auto', label: 'Android Auto' }, - } - : null; +function queryForMedia(media: CarMediaPayload): LibraryQuery | null { + if (media.kind === 'section' && media.section === 'favorites') return { kind: 'favorites' }; + if (media.kind === 'section' && media.section === 'recent') return { kind: 'recent' }; + if (media.kind === 'playlist' && media.id != null) { + return { kind: 'playlist', playlistId: media.id }; } - - const tracks = await tracksForContext(db, media); - return tracks.length > 0 - ? { - tracks, - startIndex: 0, - source: await sourceForContext(db, media, tracks), - } - : null; + if (media.kind === 'album' && media.key) return { kind: 'album', albumKey: media.key }; + if (media.kind === 'artist' && media.key) { + return { + kind: 'artist', + artistKey: media.key, + groupingMode: useSettingsStore.getState().artistGroupingMode, + section: 'all', + }; + } + return null; } async function sourceForContext( - db: LibraryDatabase, media: CarMediaPayload, - tracks: readonly DbTrack[], ): Promise { if (media.kind === 'section' && media.section === 'favorites') { return { kind: 'favorites', label: 'Favorites' }; @@ -184,11 +174,14 @@ async function sourceForContext( if (media.kind === 'playlist') { const playlist = media.id == null ? null - : (await getPlaylists(db)).find((entry) => entry.id === media.id); + : (await AstraLibraryData.listPlaylists()).find((entry) => entry.id === media.id); return { kind: 'playlist', label: playlist?.name ?? 'Playlist' }; } if (media.kind === 'album') { - return { kind: 'album', label: tracks[0]?.album?.trim() || 'Album' }; + const detail = media.key + ? await AstraLibraryData.getAlbumDetail(media.key, null, 1) + : null; + return { kind: 'album', label: detail?.summary?.album?.trim() || 'Album' }; } if (media.kind === 'artist') { return { kind: 'artist', label: media.key?.trim() || 'Artist' }; @@ -206,62 +199,40 @@ function contextFromTrack(media: CarMediaPayload): CarMediaPayload | null { }; } -async function tracksForContext(db: LibraryDatabase, media: CarMediaPayload): Promise { - switch (media.kind) { - case 'section': - if (media.section === 'recent') return getRecentlyPlayedTracks(db, 24); - if (media.section === 'favorites') return getFavoriteTracks(db); - return []; - case 'playlist': - if (media.id == null) return []; - return (await getPlaylistEntries(db, media.id)) - .map((entry) => entry.track) - .filter((track): track is DbTrack => Boolean(track)); - case 'album': - return media.key ? getTracksByAlbumKey(db, media.key) : []; - case 'artist': { - if (!media.key) return []; - const tracks = await getAllTracks(db); - return filterTracksByArtist( - tracks, - media.key, - useSettingsStore.getState().artistGroupingMode, - ); - } - default: - return []; - } -} - -async function getTrackByPath(db: LibraryDatabase, path: string): Promise { - return (await db.get('SELECT * FROM tracks WHERE path = ?', [path])) ?? null; -} - async function playSearch(payload: CarCommandPayload): Promise { - const db = await openLibraryDb(); const playlistTerm = cleanSearchTerm(payload.playlist) || focusedTerm(payload, 'playlist'); if (playlistTerm) { - const playlist = bestMatch(await getPlaylists(db), playlistTerm, (entry) => [entry.name]); + const playlist = bestMatch( + await AstraLibraryData.listPlaylists(), + playlistTerm, + (entry) => [entry.name], + ); if (playlist) return playMedia({ kind: 'playlist', id: playlist.id }); } const albumTerm = cleanSearchTerm(payload.album) || focusedTerm(payload, 'album'); if (albumTerm) { - // Voice search matches everything, including singles the browse grid hides. - const albums = buildAlbumList(await getAllTracks(db), { includeSingles: true }); + const albums = albumsFromTracks(await AstraLibraryData.searchTracks(albumTerm, 100)); const album = bestMatch(albums, albumTerm, (entry) => [entry.album, entry.artist]); - if (album) return playMedia({ kind: 'album', key: album.identity_key }); + if (album) return playMedia({ kind: 'album', key: album.key }); } const artistTerm = cleanSearchTerm(payload.artist) || focusedTerm(payload, 'artist'); if (artistTerm) { - const artistName = await bestArtistName(db, artistTerm); + const artistName = bestArtistName( + await AstraLibraryData.searchTracks(artistTerm, 100), + artistTerm, + ); if (artistName) return playMedia({ kind: 'artist', key: artistName }); } const titleTerm = cleanSearchTerm(payload.title); if (titleTerm) { - const track = bestMatch(await getAllTracks(db), titleTerm, (entry) => [entry.title]); + const track = bestMatch( + await AstraLibraryData.searchTracks(titleTerm, 100), + titleTerm, + (entry) => [entry.title], + ); if (track) return playMedia({ kind: 'track', path: track.path }); } @@ -271,18 +242,20 @@ async function playSearch(payload: CarCommandPayload): Promise { return; } - const candidate = await bestGeneralSearchCandidate(db, query, payload.focus); + const candidate = await bestGeneralSearchCandidate(query, payload.focus); if (candidate) await playMedia(candidate); } async function bestGeneralSearchCandidate( - db: LibraryDatabase, query: string, focus?: string, ): Promise { - const [tracks, playlists] = await Promise.all([getAllTracks(db), getPlaylists(db)]); - const albums = buildAlbumList(tracks, { includeSingles: true }); - const artistName = await bestArtistName(db, query); + const [tracks, playlists] = await Promise.all([ + AstraLibraryData.searchTracks(query, 100), + AstraLibraryData.listPlaylists(), + ]); + const albums = albumsFromTracks(tracks); + const artistName = bestArtistName(tracks, query); const candidates: { media: CarMediaPayload; score: number }[] = []; const focused = cleanSearchTerm(focus); @@ -291,7 +264,7 @@ async function bestGeneralSearchCandidate( if (track) candidates.push({ media: { kind: 'track', path: track.item.path }, score: track.score + categoryPenalty(focused, 'track') }); const album = bestMatchWithScore(albums, query, (entry) => [entry.album, entry.artist]); - if (album) candidates.push({ media: { kind: 'album', key: album.item.identity_key }, score: album.score + categoryPenalty(focused, 'album') }); + if (album) candidates.push({ media: { kind: 'album', key: album.item.key }, score: album.score + categoryPenalty(focused, 'album') }); const playlist = bestMatchWithScore(playlists, query, (entry) => [entry.name]); if (playlist) candidates.push({ media: { kind: 'playlist', id: playlist.item.id }, score: playlist.score + categoryPenalty(focused, 'playlist') }); @@ -317,20 +290,31 @@ function categoryPenalty(focus: string | null, category: string): number { return focus === category ? -10 : 10; } -async function bestArtistName(db: LibraryDatabase, query: string): Promise { - const tracks = await getAllTracks(db); - const mode = useSettingsStore.getState().artistGroupingMode; - const artists = useLibraryStore.getState().artists.length - ? useLibraryStore.getState().artists - : buildArtistNamesFromTracks(tracks, mode); - return bestMatch(artists, query, (entry) => [entry.artist])?.artist ?? null; +function albumsFromTracks( + tracks: readonly DbTrack[], +): { key: string; album: string; artist: string }[] { + const albums = new Map(); + for (const track of tracks) { + if (!albums.has(track.album_identity_key)) { + albums.set(track.album_identity_key, { + key: track.album_identity_key, + album: track.album, + artist: track.album_display_artist ?? track.album_artist ?? track.artist, + }); + } + } + return [...albums.values()]; } -function buildArtistNamesFromTracks( - tracks: DbTrack[], - mode: ReturnType['artistGroupingMode'], -): { artist: string }[] { - return buildArtistList(tracks, mode).map((artist) => ({ artist: artist.artist })); +function bestArtistName(tracks: readonly DbTrack[], query: string): string | null { + const names = new Map(); + for (const track of tracks) { + for (const name of [track.album_artist, track.artist]) { + const trimmed = name?.trim(); + if (trimmed) names.set(normalize(trimmed), { artist: trimmed }); + } + } + return bestMatch([...names.values()], query, (entry) => [entry.artist])?.artist ?? null; } function focusedTerm(payload: CarCommandPayload, focus: string): string | null { diff --git a/src/components/library/EmptyLibrary.tsx b/src/components/library/EmptyLibrary.tsx index 2bdecab..5936e6c 100644 --- a/src/components/library/EmptyLibrary.tsx +++ b/src/components/library/EmptyLibrary.tsx @@ -11,26 +11,54 @@ import { } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; +import { useLibraryStore } from '@/stores/libraryStore'; export function EmptyLibrary() { const styles = useStyles(); const ripple = useRipple(); const colors = useColors(); const router = useRouter(); + const recoveryNotice = useLibraryStore((state) => state.recoveryNotice); + const status = useLibraryStore((state) => state.status); + const fatal = status === 'fatalUserData'; + const rebuilding = status === 'rebuilding'; + const degraded = status === 'degraded'; return ( - + - No music yet + {fatal + ? 'Library data unavailable' + : rebuilding + ? 'Rebuilding your library' + : degraded + ? 'Library temporarily unavailable' + : 'No music yet'} - Pick a folder on this device and Astra will scan it into your library. + {fatal + ? 'Astra could not restore your playlists, favorites, and settings from either safety snapshot. Your music files were not changed.' + : rebuilding + ? 'The catalog was quarantined and Astra is rebuilding it from your available folders.' + : degraded + ? 'The last valid catalog could not be opened. Astra will keep trying to recover without treating it as an empty library.' + : recoveryNotice ?? + 'Pick a folder on this device and Astra will scan it into your library.'} - router.push('/settings')} accessibilityRole="button"> - + router.push(fatal ? '/settings/troubleshooting' : '/settings')} + accessibilityRole="button" + > + - Folder settings + {fatal ? 'Troubleshooting' : 'Folder settings'} diff --git a/src/components/library/FoldersView.tsx b/src/components/library/FoldersView.tsx index d842ed6..a2c15d0 100644 --- a/src/components/library/FoldersView.tsx +++ b/src/components/library/FoldersView.tsx @@ -1,94 +1,98 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Pressable, StyleSheet, View, type GestureResponderEvent, type NativeScrollEvent, - type NativeSyntheticEvent + type NativeSyntheticEvent, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { FlashList } from '@shopify/flash-list'; +import { + AstraLibraryData, + type NativeFolderNode, +} from '../../../modules/astra-library-scanner'; import { Text } from '@/components/Text'; import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; import { AppSheet, AppSheetItem, - AppSheetTitle + AppSheetTitle, } from '@/components/sheets/AppSheet'; import { PullSearchScrollView } from '@/components/search/PullSearchGesture'; import { - playTracks, - shuffleTracks, - enqueueTopMany, - enqueueEndMany + enqueueLibraryQuery, + playLibraryQuery, } from '@/audio/playbackController'; -import { dbTrackToTrack } from '@/library/trackAdapter'; -import { - buildFolderTree, - flattenFolderTree, - type FlattenedFolderTreeRow, - type FolderTreeNode -} from '@/library/folderTree'; import { formatDuration } from '@/lib/format'; import { playHaptic } from '@/lib/haptics'; -import { - radius, - spacing, -} from '@/theme'; +import { spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; -import { useLibraryStore } from '@/stores/libraryStore'; import { usePlayerStore } from '@/stores/playerStore'; import type { DbTrack } from '@/types/library'; +const PAGE_SIZE = 100; + interface FoldersViewProps { onScroll?: (event: NativeSyntheticEvent) => void; scrollEventThrottle?: number; } -function FolderRow({ - row, +interface LoadedNode { + node: NativeFolderNode; + childIds: string[]; + tracks: DbTrack[]; + nextOffset: number | null; + loaded: boolean; + loading: boolean; +} + +type FolderRow = + | { type: 'folder'; id: string; state: LoadedNode; expanded: boolean } + | { type: 'track'; id: string; track: DbTrack; node: NativeFolderNode } + | { type: 'more'; id: string; nodeId: string; depth: number }; + +function FolderNodeRow({ + state, + expanded, onToggle, onPlay, onShuffle, onOpenActions, }: { - row: Extract; - onToggle: (nodeId: string) => void; - onPlay: (node: FolderTreeNode) => void; - onShuffle: (node: FolderTreeNode) => void; - onOpenActions: (node: FolderTreeNode) => void; + state: LoadedNode; + expanded: boolean; + onToggle: () => void; + onPlay: () => void; + onShuffle: () => void; + onOpenActions: () => void; }) { const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); - const { node, depth, isExpanded } = row; - - const play = (event: GestureResponderEvent) => { + const { node } = state; + const stop = (callback: () => void) => (event: GestureResponderEvent) => { event.stopPropagation(); - onPlay(node); + callback(); }; - const shuffle = (event: GestureResponderEvent) => { - event.stopPropagation(); - onShuffle(node); - }; - return ( onToggle(node.id)} + onPress={onToggle} onLongPress={() => { playHaptic('holdAccepted'); - onOpenActions(node); + onOpenActions(); }} accessibilityRole="button" - accessibilityState={{ expanded: isExpanded }} + accessibilityState={{ expanded }} > - + @@ -98,22 +102,17 @@ function FolderRow({ color={node.available ? colors.textSecondary : colors.warning} /> - - {node.name} - + {node.name} {!node.available ? ( - - Access lost - + Access lost ) : null} - - {node.totalTrackCount} - + {node.totalTrackCount} ; + track: DbTrack; + node: NativeFolderNode; active: boolean; onOpenActions: () => void; }) { const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); - const index = row.folderTracks.findIndex((track) => track.path === row.track.path); - - const playFolderTrack = () => { - void playTracks(row.folderTracks.map(dbTrackToTrack), { - startIndex: Math.max(0, index), - source: { kind: 'folder', label: row.folderName }, - }); - }; - const openActions = (event: GestureResponderEvent) => { - event.stopPropagation(); - onOpenActions(); - }; - return ( { + void playLibraryQuery( + { kind: 'folder', folderNodeId: node.id }, + { + anchorPath: track.path, + source: { kind: 'folder', label: node.name }, + } + ); + }} onLongPress={() => { playHaptic('holdAccepted'); onOpenActions(); }} accessibilityRole="button" > - - + + - - {row.track.title} - - - {row.track.artist} + + {track.title} + {track.artist} - - {formatDuration(row.track.duration)} - + {formatDuration(track.duration)} { + event.stopPropagation(); + onOpenActions(); + }} hitSlop={8} accessibilityRole="button" - accessibilityLabel={`More actions for ${row.track.title}`} + accessibilityLabel={`More actions for ${track.title}`} > @@ -200,52 +206,113 @@ function FolderTrackRow({ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps) { const styles = useStyles(); const colors = useColors(); - const folders = useLibraryStore((s) => s.folders); - const tracks = useLibraryStore((s) => s.tracks); - const currentPath = usePlayerStore((s) => s.currentTrack?.path); - const [expandedNodeIds, setExpandedNodeIds] = useState>(() => new Set()); + const currentPath = usePlayerStore((state) => state.currentTrack?.path); + const [nodes, setNodes] = useState>(() => new Map()); + const [rootIds, setRootIds] = useState([]); + const [expanded, setExpanded] = useState>(() => new Set()); const [actionTrack, setActionTrack] = useState(null); - const [actionFolder, setActionFolder] = useState(null); + const [actionFolder, setActionFolder] = useState(null); - const tree = useMemo(() => buildFolderTree(folders, tracks), [folders, tracks]); - const rows = useMemo(() => flattenFolderTree(tree, expandedNodeIds), [expandedNodeIds, tree]); + const replaceRoots = async () => { + const roots = await AstraLibraryData.getFolderNodes(null); + setNodes(new Map(roots.map((node) => [ + node.id, + { node, childIds: [], tracks: [], nextOffset: 0, loaded: false, loading: false }, + ]))); + setRootIds(roots.map((node) => node.id)); + setExpanded(new Set()); + }; - // Folder-level playback runs the whole subtree (subfolders included), in tree order. - const playFolder = (node: FolderTreeNode) => { - if (node.subtreeTracks.length === 0) return; - void playTracks(node.subtreeTracks.map(dbTrackToTrack), { - source: { kind: 'folder', label: node.name }, + useEffect(() => { + queueMicrotask(() => void replaceRoots()); + const subscription = AstraLibraryData.addListener('onCatalogChanged', () => { + void replaceRoots(); }); - }; - const shuffleFolder = (node: FolderTreeNode) => { - if (node.subtreeTracks.length === 0) return; - void shuffleTracks(node.subtreeTracks.map(dbTrackToTrack), { - kind: 'folder', - label: node.name, - }); - }; - const playFolderNext = (node: FolderTreeNode) => { - if (node.subtreeTracks.length === 0) return; - void enqueueTopMany(node.subtreeTracks.map(dbTrackToTrack)); - }; - const queueFolder = (node: FolderTreeNode) => { - if (node.subtreeTracks.length === 0) return; - void enqueueEndMany(node.subtreeTracks.map(dbTrackToTrack)); - }; + return () => subscription.remove(); + }, []); - const toggleFolder = (nodeId: string) => { - setExpandedNodeIds((current) => { - const next = new Set(current); - if (next.has(nodeId)) { - next.delete(nodeId); - } else { - next.add(nodeId); + const loadNode = async (nodeId: string, append = false) => { + const current = nodes.get(nodeId); + if (!current || current.loading || (append && current.nextOffset == null)) return; + setNodes((existing) => { + const next = new Map(existing); + next.set(nodeId, { ...current, loading: true }); + return next; + }); + const offset = append ? current.nextOffset ?? 0 : 0; + const [children, page] = await Promise.all([ + append ? Promise.resolve([]) : AstraLibraryData.getFolderNodes(nodeId), + AstraLibraryData.getFolderTracks(nodeId, offset, PAGE_SIZE), + ]); + setNodes((existing) => { + const next = new Map(existing); + for (const child of children) { + const old = next.get(child.id); + next.set(child.id, old ?? { + node: child, + childIds: [], + tracks: [], + nextOffset: 0, + loaded: false, + loading: false, + }); } + const latest = next.get(nodeId) ?? current; + next.set(nodeId, { + ...latest, + childIds: append ? latest.childIds : children.map((child) => child.id), + tracks: append ? [...latest.tracks, ...page.items] : page.items, + nextOffset: page.nextOffset, + loaded: true, + loading: false, + }); return next; }); }; - if (tree.length === 0) { + const toggleNode = (nodeId: string) => { + const opening = !expanded.has(nodeId); + setExpanded((current) => { + const next = new Set(current); + if (opening) next.add(nodeId); + else next.delete(nodeId); + return next; + }); + if (opening && !nodes.get(nodeId)?.loaded) void loadNode(nodeId); + }; + + const rows = useMemo(() => { + const result: FolderRow[] = []; + const visit = (id: string) => { + const state = nodes.get(id); + if (!state) return; + const isExpanded = expanded.has(id); + result.push({ type: 'folder', id, state, expanded: isExpanded }); + if (!isExpanded) return; + for (const childId of state.childIds) visit(childId); + for (const track of state.tracks) { + result.push({ type: 'track', id: `track:${id}:${track.path}`, track, node: state.node }); + } + if (state.nextOffset != null) { + result.push({ type: 'more', id: `more:${id}:${state.nextOffset}`, nodeId: id, depth: state.node.depth + 1 }); + } + }; + rootIds.forEach(visit); + return result; + }, [expanded, nodes, rootIds]); + + const playFolder = (node: NativeFolderNode, shuffle = false) => { + if (node.totalTrackCount === 0) return; + void playLibraryQuery( + { kind: 'folder', folderNodeId: node.id }, + { + shuffle, + source: { kind: 'folder', label: node.name }, + } + ); + }; + + if (rootIds.length === 0) { return ( @@ -268,23 +335,38 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps) onScroll={onScroll} scrollEventThrottle={scrollEventThrottle} contentContainerStyle={styles.listContent} - renderItem={({ item }) => - item.type === 'folder' ? ( - - ) : ( + renderItem={({ item }) => { + if (item.type === 'folder') { + return ( + toggleNode(item.id)} + onPlay={() => playFolder(item.state.node)} + onShuffle={() => playFolder(item.state.node, true)} + onOpenActions={() => setActionFolder(item.state.node)} + /> + ); + } + if (item.type === 'more') { + return ( + void loadNode(item.nodeId, true)} + > + Load more tracks + + ); + } + return ( setActionTrack(item.track)} /> - ) - } + ); + }} /> setActionTrack(null)} /> {actionFolder ? ( @@ -305,7 +387,7 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps) label="Shuffle" icon="shuffle" onPress={() => { - shuffleFolder(actionFolder); + playFolder(actionFolder, true); setActionFolder(null); }} /> @@ -313,7 +395,10 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps) label="Play next" icon="play-skip-forward" onPress={() => { - playFolderNext(actionFolder); + void enqueueLibraryQuery( + { kind: 'folder', folderNodeId: actionFolder.id }, + 'next', + ); setActionFolder(null); }} /> @@ -321,7 +406,10 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps) label="Add to queue" icon="list-outline" onPress={() => { - queueFolder(actionFolder); + void enqueueLibraryQuery( + { kind: 'folder', folderNodeId: actionFolder.id }, + 'end', + ); setActionFolder(null); }} /> @@ -350,68 +438,62 @@ const useStyles = createThemedStyles((colors) => ({ folderMeta: { flex: 1, minWidth: 0, - gap: 2, }, count: { - minWidth: 34, - textAlign: 'right', color: colors.textTertiary, - fontSize: 12, }, folderButton: { - width: 32, - height: 32, - flexShrink: 0, - borderRadius: radius.pill, + width: 34, + height: 34, alignItems: 'center', justifyContent: 'center', + borderRadius: 17, }, trackRow: { + minHeight: 56, flexDirection: 'row', alignItems: 'center', - minHeight: 46, gap: spacing.sm, borderBottomColor: colors.glassBorder, borderBottomWidth: StyleSheet.hairlineWidth, - paddingVertical: spacing.sm, }, trackRowActive: { - backgroundColor: colors.accentGlow, + backgroundColor: colors.bgSecondary, }, trackMeta: { flex: 1, minWidth: 0, - gap: 2, }, trackTitle: { - fontSize: 15, + color: colors.textPrimary, }, trackTitleActive: { color: colors.accent, }, duration: { - minWidth: 42, - textAlign: 'right', color: colors.textTertiary, - fontSize: 12, }, actionsButton: { - width: 34, - height: 34, - flexShrink: 0, - borderRadius: radius.pill, + width: 36, + height: 36, alignItems: 'center', justifyContent: 'center', + borderRadius: 18, + }, + moreRow: { + minHeight: 44, + justifyContent: 'center', + borderBottomColor: colors.glassBorder, + borderBottomWidth: StyleSheet.hairlineWidth, }, empty: { flex: 1, alignItems: 'center', justifyContent: 'center', - gap: spacing.sm, - paddingBottom: spacing.xxl, + gap: spacing.md, + paddingHorizontal: spacing.xl, }, emptyText: { textAlign: 'center', - maxWidth: 260, }, })); diff --git a/src/components/queue/QueueTray.tsx b/src/components/queue/QueueTray.tsx index 1963c36..3f3b79b 100644 --- a/src/components/queue/QueueTray.tsx +++ b/src/components/queue/QueueTray.tsx @@ -49,6 +49,8 @@ import { artworkThumbFromSource } from '@/library/artwork'; import { playHaptic } from '@/lib/haptics'; import { useQueueStore } from '@/stores/queueStore'; import { + getVirtualQueuePage, + getVirtualQueueState, jumpToQueueIndex, moveQueueItem, removeFromQueue, @@ -74,6 +76,7 @@ interface QueueEntry { key: string; identity: string; track: RntpTrack; + absoluteIndex: number; } function rntpKey(track: RntpTrack): string { @@ -108,7 +111,8 @@ function clampLocal(value: number, len: number): number { function reconcileQueueEntries( tracks: readonly RntpTrack[], previous: readonly QueueEntry[], - nextSerial: { current: number } + nextSerial: { current: number }, + baseOffset: number, ): QueueEntry[] { const available = new Map(); previous.forEach((entry) => { @@ -117,19 +121,23 @@ function reconcileQueueEntries( else available.set(entry.identity, [entry]); }); - return tracks.map((track) => { + return tracks.map((track, index) => { const identity = rntpKey(track); + const nativePosition = track.astraQueuePosition; + const absoluteIndex = typeof nativePosition === 'number' + ? nativePosition + : baseOffset + index; const reused = available.get(identity)?.shift(); if (reused) { // Same track object → same entry object, so memo'd rows bail out when // only other parts of the queue changed (e.g. a track advance). - if (reused.track === track) return reused; - return { ...reused, track, identity }; + if (reused.track === track && reused.absoluteIndex === absoluteIndex) return reused; + return { ...reused, track, identity, absoluteIndex }; } const key = `${identity}:${nextSerial.current}`; nextSerial.current += 1; - return { key, identity, track }; + return { key, identity, track, absoluteIndex }; }); } @@ -189,13 +197,74 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: const { tracks, activeIndex, hasSnapshot, refresh } = useQueue(true); const currentTrack = activeIndex >= 0 ? tracks[activeIndex] : undefined; - const upcomingTracks = useMemo( + const rollingUpcomingTracks = useMemo( () => (activeIndex >= 0 ? tracks.slice(activeIndex + 1) : tracks), [tracks, activeIndex] ); - const upcomingTotal = - activeIndex >= 0 ? Math.max(0, tracks.length - activeIndex - 1) : tracks.length; - const baseOffset = activeIndex >= 0 ? activeIndex + 1 : 0; + const virtualState = getVirtualQueueState(); + const virtualMode = virtualState !== null; + const virtualActivePosition = virtualState?.activePosition ?? -1; + const [virtualTracks, setVirtualTracks] = useState([]); + const virtualTracksRef = useRef([]); + const virtualLoadGeneration = useRef(0); + const virtualLoading = useRef(false); + + const loadVirtualPage = useCallback(async (reset: boolean) => { + const state = getVirtualQueueState(); + if (!state || (!reset && virtualLoading.current)) return; + virtualLoading.current = true; + const generation = reset ? ++virtualLoadGeneration.current : virtualLoadGeneration.current; + const existing = reset ? [] : virtualTracksRef.current; + const lastPosition = existing.length > 0 + ? existing[existing.length - 1].astraQueuePosition + : state.activePosition; + const start = typeof lastPosition === 'number' + ? lastPosition + 1 + : state.activePosition + 1; + try { + const page = await getVirtualQueuePage(start, 100); + if ( + !page || + generation !== virtualLoadGeneration.current || + getVirtualQueueState()?.sessionId !== state.sessionId + ) return; + const next = reset ? page.items.map((item) => item.track) : [ + ...existing, + ...page.items.map((item) => item.track), + ]; + // Keep no more than five tray pages in JS. + const bounded = next.slice(-500); + virtualTracksRef.current = bounded; + setVirtualTracks(bounded); + } finally { + if (generation === virtualLoadGeneration.current) virtualLoading.current = false; + } + }, []); + + useEffect(() => { + if (!virtualMode) { + virtualLoadGeneration.current += 1; + virtualTracksRef.current = []; + setVirtualTracks([]); + return; + } + void loadVirtualPage(true); + }, [loadVirtualPage, virtualActivePosition, virtualMode, virtualState?.sessionId]); + + const upcomingTracks = virtualMode ? virtualTracks : rollingUpcomingTracks; + const upcomingTotal = virtualState + ? Math.max(0, virtualState.totalCount - virtualState.activePosition - 1) + : activeIndex >= 0 + ? Math.max(0, tracks.length - activeIndex - 1) + : tracks.length; + const firstVirtualPosition = virtualTracks[0]?.astraQueuePosition; + const baseOffset = virtualState + ? typeof firstVirtualPosition === 'number' + ? firstVirtualPosition + : virtualState.activePosition + 1 + : activeIndex >= 0 + ? activeIndex + 1 + : 0; // Row callbacks resolve indices at call time from refs so their identities // survive track advances — an index captured at render time would go stale. const baseOffsetRef = useRef(baseOffset); @@ -206,7 +275,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: // Built synchronously so a warm mirror paints on the list's first frame; the // update effect below takes over from there. const [entries, setEntries] = useState(() => - hasSnapshot ? reconcileQueueEntries(upcomingTracks, [], { current: 0 }) : [] + hasSnapshot ? reconcileQueueEntries(upcomingTracks, [], { current: 0 }, baseOffset) : [] ); const entrySerial = useRef(entries.length); const entriesRef = useRef(entries); @@ -289,9 +358,11 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: const setOptimisticEntries = useCallback( (nextEntries: QueueEntry[]) => { setVisibleEntries(nextEntries); - useQueueStore.getState().replaceUpcoming(nextEntries.map((entry) => entry.track)); + if (!virtualMode) { + useQueueStore.getState().replaceUpcoming(nextEntries.map((entry) => entry.track)); + } }, - [setVisibleEntries] + [setVisibleEntries, virtualMode] ); useEffect(() => { @@ -300,7 +371,8 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: ? reconcileQueueEntries( upcomingTracks, entriesRef.current.length > 0 ? entriesRef.current : previous, - entrySerial + entrySerial, + baseOffset, ) : []; // The mount-time reconcile of the synchronous initial state is a no-op; @@ -315,7 +387,7 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: } return resolved; }); - }, [hasSnapshot, upcomingTracks, updateDragIndexMap]); + }, [baseOffset, hasSnapshot, upcomingTracks, updateDragIndexMap]); const visibleSelectedKeys = useMemo(() => { if (selectedKeys.size === 0) return EMPTY_KEY_SET; @@ -325,19 +397,27 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: const retrySetUpcoming = useCallback( (nextTracks: RntpTrack[]) => { + if (virtualMode) { + void loadVirtualPage(true); + return; + } useQueueStore.getState().replaceUpcoming(nextTracks); void setUpcoming(nextTracks).catch(() => refresh()); }, - [refresh] + [loadVirtualPage, refresh, virtualMode] ); const commitNativeMove = useCallback( (fromAbsolute: number, toAbsolute: number, nextTracks: RntpTrack[]) => { - void moveQueueItem(fromAbsolute, toAbsolute).catch(() => { - retrySetUpcoming(nextTracks); - }); + void moveQueueItem(fromAbsolute, toAbsolute, { virtualPosition: virtualMode }) + .then(() => { + if (virtualMode) void loadVirtualPage(true); + }) + .catch(() => { + retrySetUpcoming(nextTracks); + }); }, - [retrySetUpcoming] + [loadVirtualPage, retrySetUpcoming, virtualMode] ); const finishDrag = useCallback( @@ -348,13 +428,18 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: return; } - const nextEntries = moveQueueEntry(snapshot, from, to); + const positions = snapshot.map((entry) => entry.absoluteIndex); + const nextEntries = moveQueueEntry(snapshot, from, to).map((entry, index) => ( + entry.absoluteIndex === positions[index] + ? entry + : { ...entry, absoluteIndex: positions[index] } + )); playHaptic('queueDrop'); setVisibleEntries(nextEntries); clearDragAfterReorderCommit(); commitNativeMove( - baseOffsetRef.current + from, - baseOffsetRef.current + to, + snapshot[from].absoluteIndex, + snapshot[to].absoluteIndex, nextEntries.map((entry) => entry.track) ); }, @@ -492,18 +577,27 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: const runAndRefresh = useCallback( (task: Promise) => { - void task.catch(() => refresh()); + void task.then( + () => { + if (virtualMode) void loadVirtualPage(true); + }, + () => { + if (virtualMode) void loadVirtualPage(true); + else void refresh(); + }, + ); }, - [refresh] + [loadVirtualPage, refresh, virtualMode] ); const jump = useCallback( (key: string) => { const localIndex = entriesRef.current.findIndex((entry) => entry.key === key); if (localIndex < 0) return; - runAndRefresh(jumpToQueueIndex(baseOffsetRef.current + localIndex)); + const entry = entriesRef.current[localIndex]; + runAndRefresh(jumpToQueueIndex(entry.absoluteIndex, { virtualPosition: virtualMode })); }, - [runAndRefresh] + [runAndRefresh, virtualMode] ); const playNext = useCallback( @@ -512,9 +606,11 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: if (localIndex < 0) return; const nextEntries = moveQueueEntry(entriesRef.current, localIndex, 0); setOptimisticEntries(nextEntries); - runAndRefresh(requeueToTop(baseOffsetRef.current + localIndex)); + runAndRefresh(requeueToTop(entriesRef.current[localIndex].absoluteIndex, { + virtualPosition: virtualMode, + })); }, - [runAndRefresh, setOptimisticEntries] + [runAndRefresh, setOptimisticEntries, virtualMode] ); const remove = useCallback( @@ -525,9 +621,12 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: if (!action) return; setOptimisticEntries(action.nextEntries); - runAndRefresh(removeFromQueue(action.absoluteIndex, { updateMirror: false })); + runAndRefresh(removeFromQueue( + entriesRef.current[localIndex].absoluteIndex, + { updateMirror: false, virtualPosition: virtualMode }, + )); }, - [runAndRefresh, setOptimisticEntries] + [runAndRefresh, setOptimisticEntries, virtualMode] ); const toggleSelect = useCallback((key: string) => { @@ -554,27 +653,50 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: const groupPlayNext = useCallback(() => { const action = resolveSelectedQueueAction(entriesRef.current, visibleSelectedKeys, baseOffset); - if (action.absoluteIndices.length === 0) { + const absoluteIndices = entriesRef.current + .filter((entry) => visibleSelectedKeys.has(entry.key)) + .map((entry) => entry.absoluteIndex); + if (absoluteIndices.length === 0) { clearSelection(); return; } setOptimisticEntries(action.entriesWithSelectedFirst); - runAndRefresh(requeueManyToTop(action.absoluteIndices)); + runAndRefresh(requeueManyToTop(absoluteIndices, { virtualPosition: virtualMode })); clearSelection(); - }, [baseOffset, clearSelection, runAndRefresh, setOptimisticEntries, visibleSelectedKeys]); + }, [ + baseOffset, + clearSelection, + runAndRefresh, + setOptimisticEntries, + virtualMode, + visibleSelectedKeys, + ]); const groupRemove = useCallback(() => { const action = resolveSelectedQueueAction(entriesRef.current, visibleSelectedKeys, baseOffset); - if (action.absoluteIndices.length === 0) { + const absoluteIndices = entriesRef.current + .filter((entry) => visibleSelectedKeys.has(entry.key)) + .map((entry) => entry.absoluteIndex); + if (absoluteIndices.length === 0) { clearSelection(); return; } setOptimisticEntries(action.entriesWithoutSelected); - runAndRefresh(removeManyFromQueue(action.absoluteIndices, { updateMirror: false })); + runAndRefresh(removeManyFromQueue(absoluteIndices, { + updateMirror: false, + virtualPosition: virtualMode, + })); clearSelection(); - }, [baseOffset, clearSelection, runAndRefresh, setOptimisticEntries, visibleSelectedKeys]); + }, [ + baseOffset, + clearSelection, + runAndRefresh, + setOptimisticEntries, + virtualMode, + visibleSelectedKeys, + ]); const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( @@ -727,6 +849,8 @@ export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: renderItem={renderItem} extraData={listExtraData} onLoad={onListLoad} + onEndReached={virtualMode ? () => void loadVirtualPage(false) : undefined} + onEndReachedThreshold={0.6} contentContainerStyle={embedded ? styles.embeddedListContent : editMode && selectedCount > 0 diff --git a/src/components/search/QuickSearchOverlay.tsx b/src/components/search/QuickSearchOverlay.tsx index 712ef29..db524bd 100644 --- a/src/components/search/QuickSearchOverlay.tsx +++ b/src/components/search/QuickSearchOverlay.tsx @@ -30,7 +30,7 @@ import { import { createThemedStyles, useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { rgbaFromHex } from '@/theme/colorUtils'; -import { enqueueTop, playTracks } from '@/audio/playbackController'; +import { enqueueTop, playLibraryQuery } from '@/audio/playbackController'; import { dbTrackToTrack } from '@/library/trackAdapter'; import { albumArtworkSource, @@ -51,6 +51,8 @@ import type { } from '@/types/library'; import type { Playlist } from '@/types/playlist'; import { SETTINGS_SEARCH_ROUTES } from '@/components/search/settingsSearchRoutes'; +import { AstraLibraryData } from '../../../modules/astra-library-scanner'; +import { useSettingsStore } from '@/stores/settingsStore'; type IconName = keyof typeof Ionicons.glyphMap; type RouteHref = @@ -572,11 +574,11 @@ function QuickSearchPanel({ const { height } = useWindowDimensions(); const inputRef = useRef(null); - const tracks = useLibraryStore((s) => s.tracks); - const albums = useLibraryStore((s) => s.albums); - const artists = useLibraryStore((s) => s.artists); const recentlyPlayedTracks = useLibraryStore((s) => s.recentlyPlayedTracks); const setViewMode = useLibraryStore((s) => s.setViewMode); + const includeCollabArtists = useLibraryStore((s) => s.includeCollabArtists); + const includeSingles = useSettingsStore((s) => s.includeSingles); + const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode); const playlists = usePlaylistStore((s) => s.playlists); const favoriteTracks = usePlaylistStore((s) => s.favoriteTracks); @@ -584,11 +586,49 @@ function QuickSearchPanel({ const [query, setQuery] = useState(initialQuery); const [showAllLibrary, setShowAllLibrary] = useState(false); + const [tracks, setTracks] = useState([]); + const [albums, setAlbums] = useState([]); + const [artists, setArtists] = useState([]); const [queuedTrackPaths, setQueuedTrackPaths] = useState>(() => new Set()); const queuedFeedbackTimers = useRef(new Map>()); const trimmedQuery = query.trim(); const hasQuery = trimmedQuery.length > 0; + useEffect(() => { + if (!hasQuery) return; + let cancelled = false; + const timer = setTimeout(() => { + void AstraLibraryData.searchLibrary( + trimmedQuery, + showAllLibrary ? 100 : 20, + includeSingles, + artistGroupingMode, + includeCollabArtists + ).then((result) => { + if (cancelled) return; + setTracks(result.tracks); + setAlbums(result.albums); + setArtists(result.artists); + }).catch(() => { + if (cancelled) return; + setTracks([]); + setAlbums([]); + setArtists([]); + }); + }, 120); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [ + artistGroupingMode, + hasQuery, + includeCollabArtists, + includeSingles, + showAllLibrary, + trimmedQuery, + ]); + useEffect(() => { const timer = setTimeout(() => inputRef.current?.focus(), 80); return () => clearTimeout(timer); @@ -933,17 +973,17 @@ function QuickSearchPanel({ close(); if (result.kind === 'track') { - const context = hasQuery ? allTrackResults.map((entry) => entry.track) : recentTrackResults.map((entry) => entry.track); - const index = Math.max( - 0, - context.findIndex((track) => track.path === result.track.path) - ); - void playTracks(context.map(dbTrackToTrack), { - startIndex: index, + void playLibraryQuery( + hasQuery + ? { kind: 'search', query: trimmedQuery } + : { kind: 'recent' }, + { + anchorPath: result.track.path, source: hasQuery ? { kind: 'search', label: `Search: ${trimmedQuery}` } : { kind: 'recently-played', label: 'Recently Played' }, - }); + } + ); return; } diff --git a/src/db/database.ts b/src/db/database.ts deleted file mode 100644 index 8b0fef4..0000000 --- a/src/db/database.ts +++ /dev/null @@ -1,122 +0,0 @@ -// SQLite access layer — ports the desktop `LibrarySqliteDatabase` wrapper -// (astra/src/main/services/library.ts) onto op-sqlite. Same method surface so -// desktop SQL ports verbatim; methods are async because op-sqlite is async. - -import { open, type DB, type QueryResult, type Scalar, type Transaction } from '@op-engineering/op-sqlite'; -import { migrate } from './schema'; - -export type SqlParams = Scalar[]; - -interface Executor { - execute: (query: string, params?: Scalar[]) => Promise; -} - -// op-sqlite (16.2.x under RN 0.85 / Hermes) truncates each UTF-16 code unit of a -// bound *string* parameter to its low byte, corrupting any non-Latin1 text (CJK, -// emoji, accents beyond U+00FF). Work around it by binding the UTF-8 bytes mapped -// 1:1 into a Latin-1 string: the low-byte truncation then yields exactly those -// bytes — i.e. valid UTF-8 in the column. op-sqlite's read path decodes UTF-8 -// correctly, so reads need no change; applying this to every string param (stored -// values and WHERE comparisons alike) keeps writes and lookups consistent. -// Remove if/when op-sqlite binds UTF-8 strings correctly on this RN/Hermes ABI. -function toUtf8Latin1(s: string): string { - let out = ''; - for (let i = 0; i < s.length; i++) { - const c = s.charCodeAt(i); - if (c < 0x80) { - out += String.fromCharCode(c); - } else if (c < 0x800) { - out += String.fromCharCode(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); - } else if (c >= 0xd800 && c <= 0xdbff) { - const lo = s.charCodeAt(++i); // surrogate pair → astral code point (emoji) - const cp = 0x10000 + ((c & 0x3ff) << 10) + (lo & 0x3ff); - out += String.fromCharCode( - 0xf0 | (cp >> 18), - 0x80 | ((cp >> 12) & 0x3f), - 0x80 | ((cp >> 6) & 0x3f), - 0x80 | (cp & 0x3f) - ); - } else { - out += String.fromCharCode(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); - } - } - return out; -} - -function encodeParams(params: SqlParams): SqlParams { - let hasString = false; - for (const p of params) { - if (typeof p === 'string') { - hasString = true; - break; - } - } - if (!hasString) return params; - return params.map((p) => (typeof p === 'string' ? toUtf8Latin1(p) : p)); -} - -export class LibraryDatabase { - constructor( - private readonly executor: Executor, - private readonly db: DB | null = null - ) {} - - async run(sql: string, params: SqlParams = []): Promise<{ changes: number; lastInsertRowid: number }> { - const result = await this.executor.execute(sql, encodeParams(params)); - return { changes: result.rowsAffected, lastInsertRowid: result.insertId ?? 0 }; - } - - async exec(sql: string): Promise { - await this.executor.execute(sql); - } - - async get(sql: string, params: SqlParams = []): Promise { - const result = await this.executor.execute(sql, encodeParams(params)); - return result.rows[0] as T | undefined; - } - - async all(sql: string, params: SqlParams = []): Promise { - const result = await this.executor.execute(sql, encodeParams(params)); - return result.rows as T[]; - } - - /** - * Runs `fn` inside a single transaction; op-sqlite commits on resolve and - * rolls back on throw. The callback receives a LibraryDatabase scoped to the - * transaction — nesting is not supported. - */ - async transaction(fn: (tx: LibraryDatabase) => Promise): Promise { - if (!this.db) { - throw new Error('Nested transactions are not supported'); - } - await this.db.transaction(async (tx: Transaction) => { - await fn(new LibraryDatabase(tx)); - }); - } - - close(): void { - this.db?.close(); - } -} - -let dbPromise: Promise | null = null; - -async function doOpen(): Promise { - const raw = open({ name: 'astra-library.db' }); - const db = new LibraryDatabase(raw, raw); - await db.exec('PRAGMA journal_mode = WAL'); - await db.exec('PRAGMA foreign_keys = ON'); - await migrate(db); - return db; -} - -/** Opens (once) and migrates the library database. */ -export function openLibraryDb(): Promise { - if (!dbPromise) { - dbPromise = doOpen().catch((err) => { - dbPromise = null; // allow retry on genuine failure - throw err; - }); - } - return dbPromise; -} diff --git a/src/db/desktopSyncQueries.ts b/src/db/desktopSyncQueries.ts deleted file mode 100644 index d8efbf9..0000000 --- a/src/db/desktopSyncQueries.ts +++ /dev/null @@ -1,454 +0,0 @@ -// Mobile-side DB surface for the desktop LAN sync (src/services/desktopSync.ts). -// Serializes local favorites/playlists into the shared wire vocabulary and -// applies merged results. Apply-variants deliberately use the caller-supplied -// (source) timestamps and never write tombstones for the rows they touch — -// otherwise an applied change would look like a fresh local edit on the next -// sync and ping-pong between devices. - -import { buildTrackSyncKey, normalizeSyncKeyPart } from '@/shared/sync/identity'; -import { normalizeDynamicPlaylistRules } from '@/shared/playlists/dynamicPlaylist'; -import { randomSaltHex } from '@/lib/hash'; -import type { - SyncFavorite, - SyncPlaylist, - SyncPlaylistEntry, - SyncPlaylistKind, -} from '@/types/desktopSync'; -import type { LibraryDatabase } from './database'; -import { - decodedDocPath, - matchSyncEntry, - type ImportMatchIndex, -} from '@/library/playlistFiles'; - -export interface LocalSyncFavorite extends SyncFavorite { - /** Local favorite rows carrying this identity (empty for pending rows). */ - trackPaths: string[]; - pending: boolean; -} - -export interface LocalSyncPlaylist { - id: number; - syncUid: string; - name: string; - kind: SyncPlaylistKind; - dynamicRules: string | null; - createdAt: number; - updatedAt: number; -} - -export interface LocalSyncState { - favorites: Map; - favoriteTombstones: Map; - playlists: LocalSyncPlaylist[]; - playlistTombstones: Map; -} - -/** Assign a sync identity to every sync-eligible playlist that lacks one. - * Assigning identity is not an edit: updated_at stays untouched. */ -export async function ensurePlaylistSyncUids(db: LibraryDatabase): Promise { - const rows = await db.all<{ id: number }>( - 'SELECT id FROM playlists WHERE sync_uid IS NULL AND remote_source_id IS NULL' - ); - for (const row of rows) { - await db.run('UPDATE playlists SET sync_uid = ? WHERE id = ?', [randomSaltHex(16), row.id]); - } -} - -export async function getLocalSyncState(db: LibraryDatabase): Promise { - const favorites = new Map(); - const favoriteRows = await db.all<{ - track_path: string; - added_at: number; - title: string | null; - artist: string | null; - album: string | null; - }>(` - SELECT f.track_path, f.added_at, t.title, t.artist, t.album - FROM favorites f - LEFT JOIN tracks t ON t.path = f.track_path - `); - for (const row of favoriteRows) { - // Orphaned favorites (no track row) have no metadata identity — skip. - if (row.title == null || !normalizeSyncKeyPart(row.title)) continue; - const key = buildTrackSyncKey(row.title, row.artist ?? '', row.album ?? ''); - const existing = favorites.get(key); - if (existing) { - existing.trackPaths.push(row.track_path); - if (row.added_at > existing.addedAt) existing.addedAt = row.added_at; - } else { - favorites.set(key, { - key, - title: row.title, - artist: row.artist ?? '', - album: row.album ?? '', - addedAt: row.added_at, - trackPaths: [row.track_path], - pending: false, - }); - } - } - - // Pending favorites re-enter sync state so they keep propagating even while - // unresolved locally. - const pendingRows = await db.all<{ - sync_key: string; - title: string; - artist: string; - album: string; - added_at: number; - }>('SELECT sync_key, title, artist, album, added_at FROM favorite_sync_pending'); - for (const row of pendingRows) { - if (favorites.has(row.sync_key)) continue; - favorites.set(row.sync_key, { - key: row.sync_key, - title: row.title, - artist: row.artist, - album: row.album, - addedAt: row.added_at, - trackPaths: [], - pending: true, - }); - } - - const favoriteTombstones = new Map(); - for (const row of await db.all<{ sync_key: string; deleted_at: number }>( - 'SELECT sync_key, deleted_at FROM favorite_tombstones' - )) { - favoriteTombstones.set(row.sync_key, row.deleted_at); - } - - const playlists: LocalSyncPlaylist[] = []; - for (const row of await db.all<{ - id: number; - sync_uid: string; - name: string; - kind: string | null; - dynamic_rules_json: string | null; - created_at: number; - updated_at: number; - }>(` - SELECT id, sync_uid, name, kind, dynamic_rules_json, created_at, updated_at - FROM playlists - WHERE sync_uid IS NOT NULL AND remote_source_id IS NULL - `)) { - const kind: SyncPlaylistKind = row.kind === 'dynamic' ? 'dynamic' : 'normal'; - playlists.push({ - id: row.id, - syncUid: row.sync_uid, - name: row.name, - kind, - dynamicRules: kind === 'dynamic' ? row.dynamic_rules_json : null, - createdAt: row.created_at, - updatedAt: row.updated_at, - }); - } - - const playlistTombstones = new Map(); - for (const row of await db.all<{ sync_uid: string; deleted_at: number }>( - 'SELECT sync_uid, deleted_at FROM playlist_tombstones' - )) { - playlistTombstones.set(row.sync_uid, row.deleted_at); - } - - return { favorites, favoriteTombstones, playlists, playlistTombstones }; -} - -/** Serialize a normal playlist's entries for a push to the desktop. */ -export async function getSyncPlaylistEntries( - db: LibraryDatabase, - playlistId: number -): Promise { - const rows = await db.all<{ - track_path: string; - position: number; - added_at: number; - fallback_title: string | null; - fallback_artist: string | null; - fallback_album: string | null; - title: string | null; - artist: string | null; - album: string | null; - duration: number | null; - file_name: string | null; - }>(` - SELECT pt.track_path, pt.position, pt.added_at, - pt.fallback_title, pt.fallback_artist, pt.fallback_album, - t.title, t.artist, t.album, t.duration, t.file_name - FROM playlist_tracks pt - LEFT JOIN tracks t ON t.path = pt.track_path - WHERE pt.playlist_id = ? - ORDER BY pt.position, pt.id - `, [playlistId]); - - return rows.map((row) => ({ - title: row.title ?? row.fallback_title ?? '', - artist: row.artist ?? row.fallback_artist ?? '', - album: row.album ?? row.fallback_album ?? '', - durationSeconds: typeof row.duration === 'number' && row.duration > 0 ? row.duration : null, - position: row.position, - addedAt: row.added_at, - // The peer can only use trailing path segments; send the decoded SAF path - // when the track exists locally, else pass the stored (foreign) path along. - sourcePath: row.title != null - ? (decodedDocPath(row.track_path) ?? row.file_name ?? null) - : row.track_path || null, - })); -} - -export async function upsertPendingFavorite(db: LibraryDatabase, item: SyncFavorite): Promise { - await db.run( - 'INSERT OR REPLACE INTO favorite_sync_pending (sync_key, title, artist, album, added_at) VALUES (?, ?, ?, ?, ?)', - [item.key, item.title, item.artist, item.album, item.addedAt] - ); -} - -/** Retry pending favorites against the matching ladder; promoted rows keep - * their original added_at. Returns the number promoted. */ -export async function resolvePendingFavorites( - db: LibraryDatabase, - index: ImportMatchIndex -): Promise { - const rows = await db.all<{ - sync_key: string; - title: string; - artist: string; - album: string; - added_at: number; - }>('SELECT sync_key, title, artist, album, added_at FROM favorite_sync_pending'); - let resolved = 0; - for (const row of rows) { - const match = matchSyncEntry({ title: row.title, artist: row.artist, album: row.album }, index); - if (match.kind !== 'matched') continue; - await db.run('INSERT OR IGNORE INTO favorites (track_path, added_at) VALUES (?, ?)', [ - match.track.path, - row.added_at, - ]); - await db.run('DELETE FROM favorite_sync_pending WHERE sync_key = ?', [row.sync_key]); - resolved += 1; - } - return resolved; -} - -export async function applySyncedFavoriteAdd( - db: LibraryDatabase, - trackPath: string, - syncKey: string, - addedAt: number -): Promise { - await db.run('INSERT OR REPLACE INTO favorites (track_path, added_at) VALUES (?, ?)', [ - trackPath, - addedAt, - ]); - await db.run('DELETE FROM favorite_tombstones WHERE sync_key = ?', [syncKey]); - await db.run('DELETE FROM favorite_sync_pending WHERE sync_key = ?', [syncKey]); -} - -export async function applySyncedFavoriteRemove( - db: LibraryDatabase, - trackPaths: readonly string[], - syncKey: string, - deletedAt: number -): Promise { - for (const trackPath of trackPaths) { - await db.run('DELETE FROM favorites WHERE track_path = ?', [trackPath]); - } - await db.run('DELETE FROM favorite_sync_pending WHERE sync_key = ?', [syncKey]); - await db.run('INSERT OR REPLACE INTO favorite_tombstones (sync_key, deleted_at) VALUES (?, ?)', [ - syncKey, - deletedAt, - ]); -} - -export async function removeFavoriteTombstone(db: LibraryDatabase, syncKey: string): Promise { - await db.run('DELETE FROM favorite_tombstones WHERE sync_key = ?', [syncKey]); -} - -/** Link a local playlist to the desktop's identity (first-sync name pairing). - * Must NOT bump updated_at — adopting identity is not an edit. */ -export async function adoptPlaylistSyncUid( - db: LibraryDatabase, - playlistId: number, - syncUid: string -): Promise { - await db.run('UPDATE playlists SET sync_uid = ? WHERE id = ?', [syncUid, playlistId]); -} - -export async function removePlaylistTombstone(db: LibraryDatabase, syncUid: string): Promise { - await db.run('DELETE FROM playlist_tombstones WHERE sync_uid = ?', [syncUid]); -} - -/** Create-or-replace a playlist by sync_uid from desktop state (whole-playlist - * last-writer-wins). Returns per-entry match counts for the sync summary. */ -export async function replaceSyncedPlaylist( - db: LibraryDatabase, - input: SyncPlaylist, - index: ImportMatchIndex -): Promise<{ status: 'created' | 'replaced' | 'skipped-incompatible'; entriesMatched: number; entriesFallback: number }> { - const kind: SyncPlaylistKind = input.kind === 'dynamic' ? 'dynamic' : 'normal'; - let rulesJson: string | null = null; - if (kind === 'dynamic') { - try { - rulesJson = JSON.stringify(normalizeDynamicPlaylistRules(JSON.parse(input.dynamicRules ?? ''))); - } catch { - return { status: 'skipped-incompatible', entriesMatched: 0, entriesFallback: 0 }; - } - } - - const existing = await db.get<{ id: number }>('SELECT id FROM playlists WHERE sync_uid = ?', [ - input.syncUid, - ]); - let playlistId: number; - let created = false; - if (existing) { - playlistId = existing.id; - await db.run( - 'UPDATE playlists SET name = ?, kind = ?, dynamic_rules_json = ?, updated_at = ? WHERE id = ?', - [input.name, kind, rulesJson, input.updatedAt, playlistId] - ); - await db.run('DELETE FROM playlist_tracks WHERE playlist_id = ?', [playlistId]); - } else { - const result = await db.run( - `INSERT INTO playlists (name, kind, dynamic_rules_json, created_at, updated_at, sync_uid) - VALUES (?, ?, ?, ?, ?, ?)`, - [input.name, kind, rulesJson, input.createdAt, input.updatedAt, input.syncUid] - ); - playlistId = result.lastInsertRowid; - created = true; - } - await db.run('DELETE FROM playlist_tombstones WHERE sync_uid = ?', [input.syncUid]); - - let entriesMatched = 0; - let entriesFallback = 0; - if (kind === 'normal' && Array.isArray(input.entries)) { - const orderedEntries = [...input.entries].sort((a, b) => a.position - b.position); - const seenTrackPaths = new Set(); - let position = 0; - for (const entry of orderedEntries) { - const match = matchSyncEntry( - { title: entry.title, artist: entry.artist, album: entry.album, sourcePath: entry.sourcePath }, - index - ); - let trackPath: string; - let matched = false; - if (match.kind === 'matched') { - trackPath = match.track.path; - matched = true; - } else { - const sourcePath = entry.sourcePath?.trim(); - trackPath = sourcePath || `astra-sync://unmatched/${buildTrackSyncKey(entry.title, entry.artist, entry.album)}`; - } - if (seenTrackPaths.has(trackPath)) continue; - seenTrackPaths.add(trackPath); - await db.run( - `INSERT INTO playlist_tracks - (playlist_id, track_path, position, added_at, fallback_title, fallback_artist, fallback_album) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [ - playlistId, - trackPath, - position++, - entry.addedAt > 0 ? entry.addedAt : input.updatedAt, - matched ? null : entry.title || null, - matched ? null : entry.artist || null, - matched ? null : entry.album || null, - ] - ); - if (matched) { - entriesMatched += 1; - } else { - entriesFallback += 1; - } - } - } - - return { status: created ? 'created' : 'replaced', entriesMatched, entriesFallback }; -} - -export async function applySyncedPlaylistDelete( - db: LibraryDatabase, - syncUid: string, - deletedAt: number -): Promise { - // ON DELETE CASCADE removes the entries. - await db.run('DELETE FROM playlists WHERE sync_uid = ?', [syncUid]); - await db.run('INSERT OR REPLACE INTO playlist_tombstones (sync_uid, deleted_at) VALUES (?, ?)', [ - syncUid, - deletedAt, - ]); - await db.run('DELETE FROM playlist_sync_state WHERE sync_uid = ?', [syncUid]); -} - -// --- Conflict-detection baseline (playlist_sync_state) ----------------------- -// The (local, remote) updated_at pair from the last successful sync per -// playlist. With a baseline, sync direction comes from which side changed — -// not the clock — and both-changed becomes a user-facing conflict. - -export interface PlaylistSyncBaseline { - localUpdatedAt: number; - remoteUpdatedAt: number; -} - -export async function getPlaylistSyncBaselines( - db: LibraryDatabase -): Promise> { - const result = new Map(); - for (const row of await db.all<{ - sync_uid: string; - local_updated_at: number; - remote_updated_at: number; - }>('SELECT sync_uid, local_updated_at, remote_updated_at FROM playlist_sync_state')) { - result.set(row.sync_uid, { - localUpdatedAt: row.local_updated_at, - remoteUpdatedAt: row.remote_updated_at, - }); - } - return result; -} - -export async function upsertPlaylistSyncBaseline( - db: LibraryDatabase, - syncUid: string, - localUpdatedAt: number, - remoteUpdatedAt: number -): Promise { - await db.run( - 'INSERT OR REPLACE INTO playlist_sync_state (sync_uid, local_updated_at, remote_updated_at) VALUES (?, ?, ?)', - [syncUid, localUpdatedAt, remoteUpdatedAt] - ); -} - -export async function deletePlaylistSyncBaseline(db: LibraryDatabase, syncUid: string): Promise { - await db.run('DELETE FROM playlist_sync_state WHERE sync_uid = ?', [syncUid]); -} - -/** Baselines are meaningless against a different desktop — cleared on forget. */ -export async function clearPlaylistSyncBaselines(db: LibraryDatabase): Promise { - await db.run('DELETE FROM playlist_sync_state'); -} - -/** "Keep both" for a concurrent edit: duplicate the local playlist (entries - * included) under a new name + fresh sync identity so both versions survive. */ -export async function clonePlaylistAsLocalCopy( - db: LibraryDatabase, - playlistId: number, - newName: string -): Promise { - const source = await db.get<{ kind: string | null; dynamic_rules_json: string | null }>( - 'SELECT kind, dynamic_rules_json FROM playlists WHERE id = ?', - [playlistId] - ); - if (!source) return; - const now = Date.now(); - const result = await db.run( - `INSERT INTO playlists (name, kind, dynamic_rules_json, created_at, updated_at, sync_uid) - VALUES (?, ?, ?, ?, ?, ?)`, - [newName, source.kind === 'dynamic' ? 'dynamic' : 'normal', source.dynamic_rules_json, now, now, randomSaltHex(16)] - ); - await db.run( - `INSERT INTO playlist_tracks (playlist_id, track_path, position, added_at, fallback_title, fallback_artist, fallback_album) - SELECT ?, track_path, position, added_at, fallback_title, fallback_artist, fallback_album - FROM playlist_tracks WHERE playlist_id = ?`, - [result.lastInsertRowid, playlistId] - ); -} diff --git a/src/db/dynamicPlaylistSql.test.mts b/src/db/dynamicPlaylistSql.test.mts deleted file mode 100644 index 234c02f..0000000 --- a/src/db/dynamicPlaylistSql.test.mts +++ /dev/null @@ -1,77 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { - buildDynamicPlaylistOrderByClause, - buildDynamicPlaylistWhereClause, -} from './dynamicPlaylistSql.ts'; -import type { DynamicPlaylistRulesV1 } from '../shared/playlists/dynamicPlaylist.ts'; - -const NOW = 1_800_000_000_000; - -function rules(overrides: Partial): DynamicPlaylistRulesV1 { - return { - version: 1, - conditions: [], - sort: { field: 'title', direction: 'asc' }, - limit: null, - ...overrides, - }; -} - -test('builds text, favorite, source, numeric, and date filters', () => { - const where = buildDynamicPlaylistWhereClause( - rules({ - conditions: [ - { kind: 'text', field: 'artist', operator: 'contains', value: 'Jane' }, - { kind: 'exact', field: 'favorite', operator: 'is', value: true }, - { kind: 'exact', field: 'source_type', operator: 'is_not', value: 'jellyfin' }, - { kind: 'numeric', field: 'play_count', operator: 'gte', value: 2 }, - { kind: 'date', field: 'added_at', operator: 'within_days', value: 7 }, - ], - }), - NOW - ); - - assert.equal(where.joins, 'LEFT JOIN favorites f ON f.track_path = t.path'); - assert.match(where.where, /LOWER\(COALESCE\(t.artist, ''\)\) LIKE \?/); - assert.match(where.where, /f.track_path IS NOT NULL/); - assert.match(where.where, /t.source_type <> \?/); - assert.match(where.where, /COALESCE\(t.play_count, 0\) >= \?/); - assert.match(where.where, /t.added_at >= \?/); - assert.deepEqual(where.params, ['%jane%', 'jellyfin', 2, NOW - 7 * 24 * 60 * 60 * 1000]); -}); - -test('builds last-played never and not-within filters', () => { - const never = buildDynamicPlaylistWhereClause( - rules({ - conditions: [{ kind: 'date', field: 'last_played_at', operator: 'never' }], - }), - NOW - ); - assert.equal(never.where, 't.last_played_at IS NULL'); - assert.deepEqual(never.params, []); - - const stale = buildDynamicPlaylistWhereClause( - rules({ - conditions: [{ kind: 'date', field: 'last_played_at', operator: 'not_within_days', value: 30 }], - }), - NOW - ); - assert.equal(stale.where, '(t.last_played_at IS NULL OR t.last_played_at < ?)'); - assert.deepEqual(stale.params, [NOW - 30 * 24 * 60 * 60 * 1000]); -}); - -test('builds stable sort clauses with null handling', () => { - assert.equal( - buildDynamicPlaylistOrderByClause( - rules({ sort: { field: 'play_count', direction: 'desc' } }) - ), - 'COALESCE(t.play_count, 0) DESC, t.path COLLATE NOCASE ASC' - ); - assert.equal( - buildDynamicPlaylistOrderByClause( - rules({ sort: { field: 'last_played_at', direction: 'asc' } }) - ), - 'CASE WHEN t.last_played_at IS NULL THEN 1 ELSE 0 END ASC, t.last_played_at ASC, t.path COLLATE NOCASE ASC' - ); -}); diff --git a/src/db/dynamicPlaylistSql.ts b/src/db/dynamicPlaylistSql.ts deleted file mode 100644 index 38e2c1f..0000000 --- a/src/db/dynamicPlaylistSql.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { - DynamicPlaylistCondition, - DynamicPlaylistDateField, - DynamicPlaylistNumericField, - DynamicPlaylistRulesV1, - DynamicPlaylistSortField, - DynamicPlaylistTextField, -} from '../shared/playlists/dynamicPlaylist'; - -export interface DynamicPlaylistWhere { - joins: string; - where: string; - params: (string | number | null)[]; -} - -export interface DynamicPlaylistOrderField { - expression: string; - nullable: boolean; - text?: boolean; -} - -const DYNAMIC_TEXT_FIELD_SQL: Record = { - title: 't.title', - artist: 't.artist', - album: 't.album', - album_artist: 't.album_artist', - genre: 't.genre', - format: 't.format', - musical_key: 't.musical_key', -}; - -const DYNAMIC_NUMERIC_FIELD_SQL: Record = { - play_count: 'COALESCE(t.play_count, 0)', - year: 't.year', - duration_seconds: 't.duration', - bpm: 't.bpm', -}; - -const DYNAMIC_DATE_FIELD_SQL: Record = { - last_played_at: 't.last_played_at', - added_at: 't.added_at', -}; - -export const DYNAMIC_SORT_FIELD_SQL: Record = { - title: { expression: 't.title', nullable: false, text: true }, - artist: { expression: 't.artist', nullable: false, text: true }, - album: { expression: 't.album', nullable: false, text: true }, - added_at: { expression: 't.added_at', nullable: false }, - last_played_at: { expression: 't.last_played_at', nullable: true }, - play_count: { expression: 'COALESCE(t.play_count, 0)', nullable: false }, - year: { expression: 't.year', nullable: true }, - duration_seconds: { expression: 't.duration', nullable: false }, - bpm: { expression: 't.bpm', nullable: true }, -}; - -function appendDynamicTextCondition( - condition: Extract, - whereClauses: string[], - params: (string | number | null)[] -): void { - const expression = DYNAMIC_TEXT_FIELD_SQL[condition.field]; - const normalizedValue = condition.value.toLocaleLowerCase(); - if (condition.operator === 'contains') { - whereClauses.push(`LOWER(COALESCE(${expression}, '')) LIKE ?`); - params.push(`%${normalizedValue}%`); - return; - } - - whereClauses.push(`LOWER(COALESCE(${expression}, '')) ${condition.operator === 'is' ? '=' : '<>'} ?`); - params.push(normalizedValue); -} - -function appendDynamicExactCondition( - condition: Extract, - whereClauses: string[], - params: (string | number | null)[] -): void { - if (condition.field === 'source_type') { - whereClauses.push(`t.source_type ${condition.operator === 'is' ? '=' : '<>'} ?`); - params.push(condition.value); - return; - } - - const expectsFavorite = condition.operator === 'is' ? condition.value : !condition.value; - whereClauses.push(`f.track_path IS ${expectsFavorite ? 'NOT NULL' : 'NULL'}`); -} - -function appendDynamicNumericCondition( - condition: Extract, - whereClauses: string[], - params: (string | number | null)[] -): void { - const expression = DYNAMIC_NUMERIC_FIELD_SQL[condition.field]; - const operator = condition.operator === 'eq' ? '=' : condition.operator === 'gte' ? '>=' : '<='; - whereClauses.push(`${expression} ${operator} ?`); - params.push(condition.value); -} - -function appendDynamicDateCondition( - condition: Extract, - whereClauses: string[], - params: (string | number | null)[], - now: number -): void { - const expression = DYNAMIC_DATE_FIELD_SQL[condition.field]; - if (condition.field === 'last_played_at' && condition.operator === 'never') { - whereClauses.push(`${expression} IS NULL`); - return; - } - - const dayValue = typeof condition.value === 'number' ? condition.value : 1; - const cutoff = now - dayValue * 24 * 60 * 60 * 1000; - if (condition.field === 'last_played_at') { - if (condition.operator === 'within_days') { - whereClauses.push(`${expression} >= ?`); - params.push(cutoff); - return; - } - whereClauses.push(`(${expression} IS NULL OR ${expression} < ?)`); - params.push(cutoff); - return; - } - - whereClauses.push(`${expression} ${condition.operator === 'within_days' ? '>=' : '<'} ?`); - params.push(cutoff); -} - -export function buildDynamicPlaylistWhereClause( - rules: DynamicPlaylistRulesV1, - now: number = Date.now() -): DynamicPlaylistWhere { - const whereClauses: string[] = []; - const params: (string | number | null)[] = []; - const needsFavoriteJoin = rules.conditions.some( - (condition) => condition.kind === 'exact' && condition.field === 'favorite' - ); - - for (const condition of rules.conditions) { - if (condition.kind === 'text') { - appendDynamicTextCondition(condition, whereClauses, params); - } else if (condition.kind === 'exact') { - appendDynamicExactCondition(condition, whereClauses, params); - } else if (condition.kind === 'numeric') { - appendDynamicNumericCondition(condition, whereClauses, params); - } else { - appendDynamicDateCondition(condition, whereClauses, params, now); - } - } - - return { - joins: needsFavoriteJoin ? 'LEFT JOIN favorites f ON f.track_path = t.path' : '', - where: whereClauses.length > 0 ? whereClauses.join('\n AND ') : '1 = 1', - params, - }; -} - -export function buildDynamicPlaylistOrderByClause(rules: DynamicPlaylistRulesV1): string { - const sort = DYNAMIC_SORT_FIELD_SQL[rules.sort.field] ?? DYNAMIC_SORT_FIELD_SQL.title; - const direction = rules.sort.direction === 'desc' ? 'DESC' : 'ASC'; - const expression = sort.text ? `${sort.expression} COLLATE NOCASE` : sort.expression; - const nullablePrefix = sort.nullable - ? `CASE WHEN ${sort.expression} IS NULL THEN 1 ELSE 0 END ASC, ` - : ''; - return `${nullablePrefix}${expression} ${direction}, t.path COLLATE NOCASE ASC`; -} diff --git a/src/db/libraryMaintenance.test.mts b/src/db/libraryMaintenance.test.mts deleted file mode 100644 index aa6d619..0000000 --- a/src/db/libraryMaintenance.test.mts +++ /dev/null @@ -1,17 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { REBUILD_LOCAL_LIBRARY_INDEX_SQL, markLocalTracksStaleForRebuild } from './libraryMaintenance.ts'; - -test('library rebuild marks only local tracks stale', async () => { - assert.match(REBUILD_LOCAL_LIBRARY_INDEX_SQL, /source_type\s*=\s*'local'/i); - assert.doesNotMatch(REBUILD_LOCAL_LIBRARY_INDEX_SQL, /DELETE|DROP/i); - let executed = ''; - const changes = await markLocalTracksStaleForRebuild({ - run: async (sql: string) => { - executed = sql; - return { changes: 12, lastInsertRowid: 0 }; - }, - } as never); - assert.equal(executed, REBUILD_LOCAL_LIBRARY_INDEX_SQL); - assert.equal(changes, 12); -}); diff --git a/src/db/libraryMaintenance.ts b/src/db/libraryMaintenance.ts deleted file mode 100644 index c9376dd..0000000 --- a/src/db/libraryMaintenance.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { LibraryDatabase } from './database'; - -export const REBUILD_LOCAL_LIBRARY_INDEX_SQL = - "UPDATE tracks SET mtime = -1 WHERE source_type = 'local'"; - -/** Marks only device-local rows stale so the normal scanner re-extracts them. */ -export async function markLocalTracksStaleForRebuild(db: LibraryDatabase): Promise { - const result = await db.run(REBUILD_LOCAL_LIBRARY_INDEX_SQL); - return result.changes; -} diff --git a/src/db/lyricsQueries.ts b/src/db/lyricsQueries.ts index 91b3dc2..a1697b4 100644 --- a/src/db/lyricsQueries.ts +++ b/src/db/lyricsQueries.ts @@ -4,7 +4,7 @@ // matches the current track tags, so a retag re-fetches. Parsed lines are stored // as JSON and re-sanitized on read. -import type { LibraryDatabase } from './database'; +import { AstraLibraryData } from '../../modules/astra-library-scanner'; import { sanitizeLyricsLines } from '@/lyrics/parsing'; import type { LyricsFormat, LyricsLine, LyricsProvider, LyricsSource } from '@/lyrics/types'; @@ -31,14 +31,13 @@ export interface LyricsCacheWrite { } interface LyricsCacheRow { - metadata_signature: string | null; status: string; source: string | null; provider: string | null; format: string | null; - plain_lyrics: string | null; - synced_lyrics: string | null; - synced_lines_json: string; + plainLyrics: string | null; + syncedLyrics: string | null; + syncedLinesJson: string; } function parseSyncedLines(json: string): LyricsLine[] { @@ -50,71 +49,44 @@ function parseSyncedLines(json: string): LyricsLine[] { } export async function getLyricsCache( - db: LibraryDatabase, trackPath: string, metadataSignature: string ): Promise { - const row = await db.get( - `SELECT metadata_signature, status, source, provider, format, plain_lyrics, synced_lyrics, synced_lines_json - FROM lyrics_cache WHERE track_path = ?`, - [trackPath] - ); + const row = await AstraLibraryData.getLyrics(trackPath, metadataSignature); if (!row) return null; - // A metadata change (retag) invalidates the cached result. - if (row.metadata_signature !== metadataSignature) return null; return { status: row.status === 'hit' ? 'hit' : 'not_found', source: (row.source as LyricsSource | null) ?? 'lrclib', provider: (row.provider as LyricsProvider | null) ?? null, format: (row.format as LyricsFormat | null) ?? 'plain', - plainLyrics: row.plain_lyrics, - syncedLyrics: row.synced_lyrics, - syncedLines: parseSyncedLines(row.synced_lines_json), + plainLyrics: row.plainLyrics, + syncedLyrics: row.syncedLyrics, + syncedLines: parseSyncedLines(row.syncedLinesJson), }; } -export async function putLyricsCache(db: LibraryDatabase, entry: LyricsCacheWrite): Promise { - await db.run( - `INSERT INTO lyrics_cache ( - track_path, metadata_signature, status, source, provider, format, - plain_lyrics, synced_lyrics, synced_lines_json, updated_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(track_path) DO UPDATE SET - metadata_signature = excluded.metadata_signature, - status = excluded.status, - source = excluded.source, - provider = excluded.provider, - format = excluded.format, - plain_lyrics = excluded.plain_lyrics, - synced_lyrics = excluded.synced_lyrics, - synced_lines_json = excluded.synced_lines_json, - updated_at = excluded.updated_at`, - [ - entry.trackPath, - entry.metadataSignature, - entry.status, - entry.source, - entry.provider, - entry.format, - entry.plainLyrics, - entry.syncedLyrics, - JSON.stringify(entry.syncedLines), - Date.now(), - ] - ); +export async function putLyricsCache(entry: LyricsCacheWrite): Promise { + await AstraLibraryData.putLyrics(entry.trackPath, { + metadataSignature: entry.metadataSignature, + status: entry.status, + source: entry.source, + provider: entry.provider, + format: entry.format, + plainLyrics: entry.plainLyrics, + syncedLyrics: entry.syncedLyrics, + syncedLinesJson: JSON.stringify(entry.syncedLines), + }); } -export async function getLyricsCacheCount(db: LibraryDatabase): Promise { - const row = await db.get<{ count: number }>('SELECT COUNT(*) AS count FROM lyrics_cache'); - return row?.count ?? 0; +export async function getLyricsCacheCount(): Promise { + return AstraLibraryData.countLyrics(); } -export async function deleteLyricsCache(db: LibraryDatabase, trackPath: string): Promise { - await db.run('DELETE FROM lyrics_cache WHERE track_path = ?', [trackPath]); +export async function deleteLyricsCache(trackPath: string): Promise { + await AstraLibraryData.deleteLyrics(trackPath); } -export async function clearLyricsCache(db: LibraryDatabase): Promise { - await db.run('DELETE FROM lyrics_cache'); +export async function clearLyricsCache(): Promise { + await AstraLibraryData.clearLyrics(); } diff --git a/src/db/nativeSettings.ts b/src/db/nativeSettings.ts new file mode 100644 index 0000000..a0bdf7e --- /dev/null +++ b/src/db/nativeSettings.ts @@ -0,0 +1,18 @@ +import { AstraLibraryData } from '../../modules/astra-library-scanner'; + +export async function getNativeSetting(key: string): Promise { + await AstraLibraryData.initialize(); + const values = await AstraLibraryData.getSettings([key]); + return values[key] ?? null; +} + +export async function getNativeSettings( + keys: readonly string[] +): Promise> { + await AstraLibraryData.initialize(); + return AstraLibraryData.getSettings([...keys]); +} + +export async function setNativeSetting(key: string, value: string | null): Promise { + await AstraLibraryData.setSettings({ [key]: value }); +} diff --git a/src/db/playlistQueries.ts b/src/db/playlistQueries.ts deleted file mode 100644 index 36eee73..0000000 --- a/src/db/playlistQueries.ts +++ /dev/null @@ -1,633 +0,0 @@ -// Playlist + favorites queries — SQL ported from the desktop library service -// (getPlaylists / addPlaylistEntries / removeFromPlaylist / favorites CRUD). - -import type { DbTrack } from '@/types/library'; -import type { Playlist, PlaylistTrackEntry } from '@/types/playlist'; -import type { RemotePlaylist } from '@/types/remote'; -import type { LibraryDatabase } from './database'; -import { - createDefaultDynamicPlaylistRules, - normalizeDynamicPlaylistRules, - type DynamicPlaylistPreview, - type DynamicPlaylistRulesV1, - type PlaylistKind, -} from '@/shared/playlists/dynamicPlaylist'; -import { - buildDynamicPlaylistOrderByClause, - buildDynamicPlaylistWhereClause, -} from './dynamicPlaylistSql'; -import { buildTrackSyncKey, normalizeSyncKeyPart } from '@/shared/sync/identity'; - -const PLAYLIST_SELECT = ` - SELECT p.id, p.name, p.kind, p.dynamic_rules_json, - p.created_at, p.updated_at, p.last_played_at, p.remote_source_id, - (SELECT t.artwork_hash - FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path - WHERE pt.playlist_id = p.id AND t.artwork_hash IS NOT NULL - ORDER BY pt.position, pt.id LIMIT 1) AS auto_cover_hash, - (SELECT COUNT(*) - FROM playlist_tracks pt JOIN tracks t ON t.path = pt.track_path - WHERE pt.playlist_id = p.id) AS track_count, - (SELECT COUNT(*) - FROM playlist_tracks pt LEFT JOIN tracks t ON t.path = pt.track_path - WHERE pt.playlist_id = p.id AND t.path IS NULL) AS missing_track_count - FROM playlists p -`; - -interface PlaylistSummaryRow extends Playlist { - dynamic_rules_json: string | null; -} - -interface PlaylistRuleRow { - id: number; - kind: PlaylistKind; - dynamic_rules_json: string | null; -} - -const DYNAMIC_PLAYLIST_PREVIEW_TRACK_LIMIT = 25; - -function normalizePlaylistKind(value: unknown): PlaylistKind { - return value === 'dynamic' ? 'dynamic' : 'normal'; -} - -function serializeDynamicPlaylistRules(rules: DynamicPlaylistRulesV1): string { - return JSON.stringify(normalizeDynamicPlaylistRules(rules)); -} - -function parseDynamicPlaylistRules(rawRules: unknown): DynamicPlaylistRulesV1 { - if (typeof rawRules !== 'string' || rawRules.trim().length === 0) { - return createDefaultDynamicPlaylistRules(); - } - - try { - return normalizeDynamicPlaylistRules(JSON.parse(rawRules)); - } catch { - return createDefaultDynamicPlaylistRules(); - } -} - -async function readPlaylistRuleRow( - db: LibraryDatabase, - playlistId: number -): Promise { - if (!Number.isInteger(playlistId) || playlistId <= 0) return null; - const row = await db.get( - 'SELECT id, kind, dynamic_rules_json FROM playlists WHERE id = ? LIMIT 1', - [playlistId] - ); - if (!row) return null; - return { - ...row, - kind: normalizePlaylistKind(row.kind), - }; -} - -async function assertNormalPlaylist( - db: LibraryDatabase, - playlistId: number, - action: string -): Promise { - const row = await readPlaylistRuleRow(db, playlistId); - if (row?.kind === 'dynamic') { - throw new Error(`Dynamic playlists cannot ${action}.`); - } -} - -async function requireDynamicPlaylistRulesForId( - db: LibraryDatabase, - playlistId: number -): Promise { - const row = await readPlaylistRuleRow(db, playlistId); - if (!row) { - throw new Error('Playlist not found.'); - } - if (row.kind !== 'dynamic') { - throw new Error('Playlist is not dynamic.'); - } - return parseDynamicPlaylistRules(row.dynamic_rules_json); -} - -async function getDynamicPlaylistTracksForRules( - db: LibraryDatabase, - rules: DynamicPlaylistRulesV1 -): Promise { - const normalizedRules = normalizeDynamicPlaylistRules(rules); - const { joins, where, params } = buildDynamicPlaylistWhereClause(normalizedRules); - const orderBy = buildDynamicPlaylistOrderByClause(normalizedRules); - const limitSql = normalizedRules.limit === null ? '' : '\n LIMIT ?'; - const limitParams = normalizedRules.limit === null ? [] : [normalizedRules.limit]; - - return db.all( - `SELECT t.* FROM tracks t - ${joins} - WHERE ${where} - ORDER BY ${orderBy}${limitSql}`, - [...params, ...limitParams] - ); -} - -function dynamicTracksToEntries(tracks: DbTrack[]): PlaylistTrackEntry[] { - return tracks.map((track, index) => ({ - id: -index - 1, - track_path: track.path, - position: index, - added_at: track.added_at, - missing: false, - fallback_title: null, - fallback_artist: null, - fallback_album: null, - track, - })); -} - -async function buildDynamicPlaylistSummary( - db: LibraryDatabase, - row: PlaylistSummaryRow -): Promise { - const tracks = await getDynamicPlaylistTracksForRules(db, parseDynamicPlaylistRules(row.dynamic_rules_json)); - return { - id: row.id, - name: row.name, - kind: 'dynamic', - created_at: row.created_at, - updated_at: row.updated_at, - last_played_at: row.last_played_at, - auto_cover_hash: tracks.find((track) => track.artwork_hash)?.artwork_hash ?? null, - track_count: tracks.length, - missing_track_count: 0, - remote_source_id: null, - }; -} - -function buildNormalPlaylistSummary(row: PlaylistSummaryRow): Playlist { - return { - id: row.id, - name: row.name, - kind: 'normal', - created_at: row.created_at, - updated_at: row.updated_at, - last_played_at: row.last_played_at, - auto_cover_hash: row.auto_cover_hash, - track_count: row.track_count, - missing_track_count: row.missing_track_count, - remote_source_id: row.remote_source_id, - }; -} - -export async function getPlaylists(db: LibraryDatabase): Promise { - const rows = await db.all(` - ${PLAYLIST_SELECT} - ORDER BY (p.last_played_at IS NULL), p.last_played_at DESC, p.updated_at DESC - `); - const playlists: Playlist[] = []; - for (const row of rows) { - playlists.push( - normalizePlaylistKind(row.kind) === 'dynamic' - ? await buildDynamicPlaylistSummary(db, row) - : buildNormalPlaylistSummary(row) - ); - } - return playlists; -} - -export async function getPlaylist(db: LibraryDatabase, id: number): Promise { - const row = await db.get(`${PLAYLIST_SELECT} WHERE p.id = ?`, [id]); - if (!row) return undefined; - return normalizePlaylistKind(row.kind) === 'dynamic' - ? buildDynamicPlaylistSummary(db, row) - : buildNormalPlaylistSummary(row); -} - -export async function createPlaylist(db: LibraryDatabase, name: string): Promise { - const now = Date.now(); - const result = await db.run( - 'INSERT INTO playlists (name, created_at, updated_at) VALUES (?, ?, ?)', - [name, now, now] - ); - const row = await getPlaylist(db, result.lastInsertRowid); - if (!row) throw new Error('Playlist insert failed'); - return row; -} - -export async function createDynamicPlaylist( - db: LibraryDatabase, - name: string, - rules: DynamicPlaylistRulesV1 -): Promise { - const trimmedName = name.trim(); - if (!trimmedName) { - throw new Error('Playlist name is required.'); - } - - const now = Date.now(); - const result = await db.run( - `INSERT INTO playlists (name, created_at, updated_at, kind, dynamic_rules_json) - VALUES (?, ?, ?, 'dynamic', ?)`, - [trimmedName, now, now, serializeDynamicPlaylistRules(rules)] - ); - const row = await getPlaylist(db, result.lastInsertRowid); - if (!row) throw new Error('Dynamic playlist insert failed'); - return row; -} - -export function getDynamicPlaylistRules( - db: LibraryDatabase, - playlistId: number -): Promise { - return requireDynamicPlaylistRulesForId(db, playlistId); -} - -export async function updateDynamicPlaylistRules( - db: LibraryDatabase, - playlistId: number, - rules: DynamicPlaylistRulesV1 -): Promise { - await requireDynamicPlaylistRulesForId(db, playlistId); - await db.run('UPDATE playlists SET dynamic_rules_json = ?, updated_at = ? WHERE id = ?', [ - serializeDynamicPlaylistRules(rules), - Date.now(), - playlistId, - ]); -} - -export async function previewDynamicPlaylist( - db: LibraryDatabase, - rules: DynamicPlaylistRulesV1 -): Promise { - const tracks = await getDynamicPlaylistTracksForRules(db, normalizeDynamicPlaylistRules(rules)); - return { - track_count: tracks.length, - tracks: tracks.slice(0, DYNAMIC_PLAYLIST_PREVIEW_TRACK_LIMIT).map((track) => ({ - path: track.path, - title: track.title, - artist: track.artist, - album: track.album, - })), - }; -} - -export async function renamePlaylist(db: LibraryDatabase, id: number, name: string): Promise { - await db.run('UPDATE playlists SET name = ?, updated_at = ? WHERE id = ?', [ - name, - Date.now(), - id, - ]); -} - -export async function deletePlaylist(db: LibraryDatabase, id: number): Promise { - // Tombstone sync-eligible playlists so the desktop LAN sync propagates the - // deletion (server-mirrored playlists are excluded from that sync and are - // deleted via raw SQL elsewhere, never through here). - const row = await db.get<{ sync_uid: string | null; remote_source_id: number | null }>( - 'SELECT sync_uid, remote_source_id FROM playlists WHERE id = ?', - [id] - ); - if (row?.sync_uid && row.remote_source_id == null) { - await db.run('INSERT OR REPLACE INTO playlist_tombstones (sync_uid, deleted_at) VALUES (?, ?)', [ - row.sync_uid, - Date.now(), - ]); - } - // ON DELETE CASCADE removes the entries (foreign_keys is ON per connection). - await db.run('DELETE FROM playlists WHERE id = ?', [id]); -} - -export async function markPlaylistPlayed(db: LibraryDatabase, id: number): Promise { - await db.run('UPDATE playlists SET last_played_at = ? WHERE id = ?', [Date.now(), id]); -} - -// --- Entries ----------------------------------------------------------------- - -interface EntryRow extends Omit { - entry_id: number; - entry_track_path: string; - entry_position: number; - entry_added_at: number; - fallback_title: string | null; - fallback_artist: string | null; - fallback_album: string | null; - id: number | null; - path: string | null; - added_at: number | null; -} - -export async function getPlaylistEntries( - db: LibraryDatabase, - playlistId: number -): Promise { - const ruleRow = await readPlaylistRuleRow(db, playlistId); - if (ruleRow?.kind === 'dynamic') { - return dynamicTracksToEntries(await getDynamicPlaylistTracksForRules( - db, - parseDynamicPlaylistRules(ruleRow.dynamic_rules_json) - )); - } - - const rows = await db.all( - `SELECT pt.id AS entry_id, pt.track_path AS entry_track_path, - pt.position AS entry_position, pt.added_at AS entry_added_at, - pt.fallback_title, pt.fallback_artist, pt.fallback_album, - t.* - FROM playlist_tracks pt - LEFT JOIN tracks t ON t.path = pt.track_path - WHERE pt.playlist_id = ? - ORDER BY pt.position, pt.id`, - [playlistId] - ); - return rows.map((row) => { - const { - entry_id, - entry_track_path, - entry_position, - entry_added_at, - fallback_title, - fallback_artist, - fallback_album, - ...trackColumns - } = row; - const missing = trackColumns.path == null; - return { - id: entry_id, - track_path: entry_track_path, - position: entry_position, - added_at: entry_added_at, - missing, - fallback_title, - fallback_artist, - fallback_album, - track: missing ? null : (trackColumns as DbTrack), - }; - }); -} - -export interface PlaylistEntryInsert { - trackPath: string; - fallbackTitle?: string | null; - fallbackArtist?: string | null; - fallbackAlbum?: string | null; -} - -/** Appends entries (deduped against input and existing membership). Returns inserted count. */ -export async function addPlaylistEntries( - db: LibraryDatabase, - playlistId: number, - entries: PlaylistEntryInsert[] -): Promise { - await assertNormalPlaylist(db, playlistId, 'accept manual tracks'); - if (entries.length === 0) return 0; - let inserted = 0; - await db.transaction(async (tx) => { - const existing = await tx.all<{ track_path: string }>( - 'SELECT track_path FROM playlist_tracks WHERE playlist_id = ?', - [playlistId] - ); - const seen = new Set(existing.map((row) => row.track_path)); - const maxRow = await tx.get<{ max_position: number }>( - 'SELECT COALESCE(MAX(position), -1) AS max_position FROM playlist_tracks WHERE playlist_id = ?', - [playlistId] - ); - let position = maxRow?.max_position ?? -1; - const now = Date.now(); - for (const entry of entries) { - if (seen.has(entry.trackPath)) continue; - seen.add(entry.trackPath); - position += 1; - await tx.run( - `INSERT OR IGNORE INTO playlist_tracks - (playlist_id, track_path, position, added_at, fallback_title, fallback_artist, fallback_album) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [ - playlistId, - entry.trackPath, - position, - now, - entry.fallbackTitle ?? null, - entry.fallbackArtist ?? null, - entry.fallbackAlbum ?? null, - ] - ); - inserted += 1; - } - if (inserted > 0) { - await tx.run('UPDATE playlists SET updated_at = ? WHERE id = ?', [now, playlistId]); - } - }); - return inserted; -} - -async function renormalizePositions(tx: LibraryDatabase, playlistId: number): Promise { - const rows = await tx.all<{ id: number }>( - 'SELECT id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position, id', - [playlistId] - ); - for (let i = 0; i < rows.length; i++) { - await tx.run('UPDATE playlist_tracks SET position = ? WHERE id = ?', [i, rows[i].id]); - } -} - -export async function removeFromPlaylist( - db: LibraryDatabase, - playlistId: number, - trackPath: string -): Promise { - await assertNormalPlaylist(db, playlistId, 'remove tracks manually'); - await db.transaction(async (tx) => { - await tx.run('DELETE FROM playlist_tracks WHERE playlist_id = ? AND track_path = ?', [ - playlistId, - trackPath, - ]); - await renormalizePositions(tx, playlistId); - await tx.run('UPDATE playlists SET updated_at = ? WHERE id = ?', [Date.now(), playlistId]); - }); -} - -/** Swaps the entry with its neighbor above (-1) or below (+1); no-op at list edges. */ -export async function movePlaylistTrack( - db: LibraryDatabase, - playlistId: number, - trackPath: string, - direction: -1 | 1 -): Promise { - await assertNormalPlaylist(db, playlistId, 'reorder tracks manually'); - await db.transaction(async (tx) => { - const row = await tx.get<{ id: number; position: number }>( - 'SELECT id, position FROM playlist_tracks WHERE playlist_id = ? AND track_path = ?', - [playlistId, trackPath] - ); - if (!row) return; - const neighbor = await tx.get<{ id: number; position: number }>( - direction === -1 - ? 'SELECT id, position FROM playlist_tracks WHERE playlist_id = ? AND position < ? ORDER BY position DESC LIMIT 1' - : 'SELECT id, position FROM playlist_tracks WHERE playlist_id = ? AND position > ? ORDER BY position ASC LIMIT 1', - [playlistId, row.position] - ); - if (!neighbor) return; - await tx.run('UPDATE playlist_tracks SET position = ? WHERE id = ?', [neighbor.position, row.id]); - await tx.run('UPDATE playlist_tracks SET position = ? WHERE id = ?', [row.position, neighbor.id]); - await tx.run('UPDATE playlists SET updated_at = ? WHERE id = ?', [Date.now(), playlistId]); - }); -} - -// --- Favorites --------------------------------------------------------------- - -export function getFavoriteTracks(db: LibraryDatabase): Promise { - return db.all(` - SELECT t.* FROM favorites f - JOIN tracks t ON t.path = f.track_path - ORDER BY f.added_at DESC - `); -} - -export async function getFavoritePaths(db: LibraryDatabase): Promise { - const rows = await db.all<{ track_path: string }>('SELECT track_path FROM favorites'); - return rows.map((row) => row.track_path); -} - -/** Metadata identity key for the desktop LAN sync; null when the track is - * unknown or has no usable title. */ -async function trackSyncKeyForPath(db: LibraryDatabase, trackPath: string): Promise { - const row = await db.get<{ title: string; artist: string; album: string }>( - 'SELECT title, artist, album FROM tracks WHERE path = ?', - [trackPath] - ); - if (!row || !normalizeSyncKeyPart(row.title)) return null; - return buildTrackSyncKey(row.title, row.artist, row.album); -} - -export async function addFavorite(db: LibraryDatabase, trackPath: string): Promise { - await db.run('INSERT OR IGNORE INTO favorites (track_path, added_at) VALUES (?, ?)', [ - trackPath, - Date.now(), - ]); - // Re-favoriting must clear any sync deletion tombstone for the same identity. - const syncKey = await trackSyncKeyForPath(db, trackPath); - if (syncKey) { - await db.run('DELETE FROM favorite_tombstones WHERE sync_key = ?', [syncKey]); - await db.run('DELETE FROM favorite_sync_pending WHERE sync_key = ?', [syncKey]); - } -} - -export async function removeFavorite(db: LibraryDatabase, trackPath: string): Promise { - // Record a deletion tombstone so the desktop LAN sync propagates the - // unfavorite instead of resurrecting it from the desktop's copy. - const syncKey = await trackSyncKeyForPath(db, trackPath); - if (syncKey) { - await db.run('INSERT OR REPLACE INTO favorite_tombstones (sync_key, deleted_at) VALUES (?, ?)', [ - syncKey, - Date.now(), - ]); - await db.run('DELETE FROM favorite_sync_pending WHERE sync_key = ?', [syncKey]); - } - await db.run('DELETE FROM favorites WHERE track_path = ?', [trackPath]); -} - -/** Add many favorites at once (insert-or-ignore). Used by remote starred sync. */ -export async function addFavoritePaths(db: LibraryDatabase, paths: string[]): Promise { - if (paths.length === 0) return; - const now = Date.now(); - const insertedPaths: string[] = []; - await db.transaction(async (tx) => { - for (const path of paths) { - const result = await tx.run('INSERT OR IGNORE INTO favorites (track_path, added_at) VALUES (?, ?)', [ - path, - now, - ]); - if (result.changes > 0) insertedPaths.push(path); - } - }); - // Only genuinely new favorites clear tombstones — the remote starred sync - // re-runs its inserts every pass and must not keep resurrecting identities - // the user unfavorited elsewhere. - for (const path of insertedPaths) { - const syncKey = await trackSyncKeyForPath(db, path); - if (syncKey) { - await db.run('DELETE FROM favorite_tombstones WHERE sync_key = ?', [syncKey]); - await db.run('DELETE FROM favorite_sync_pending WHERE sync_key = ?', [syncKey]); - } - } -} - -// --- Remote sync (Subsonic playlists/favorites) ------------------------------ - -/** Remove favorites whose path belongs to a given remote source (on source delete). */ -export async function deleteFavoritesByPathPrefix( - db: LibraryDatabase, - prefix: string -): Promise { - await db.run('DELETE FROM favorites WHERE track_path LIKE ?', [`${prefix}%`]); -} - -/** Remove all synced playlists (and their entries via CASCADE) for a remote source. */ -export async function deleteRemotePlaylistsBySource( - db: LibraryDatabase, - sourceId: number -): Promise { - await db.run('DELETE FROM playlists WHERE remote_source_id = ?', [sourceId]); -} - -/** - * Upsert a source's server playlists by (remote_source_id, remote_playlist_id): - * create/update each + replace its entries, then delete remote playlists for this - * source that vanished upstream. Ports desktop `syncSubsonicRemotePlaylists`. - */ -export async function syncRemotePlaylists( - db: LibraryDatabase, - sourceId: number, - playlists: RemotePlaylist[] -): Promise { - await db.transaction(async (tx) => { - const existing = await tx.all<{ id: number; remote_playlist_id: string }>( - 'SELECT id, remote_playlist_id FROM playlists WHERE remote_source_id = ?', - [sourceId] - ); - const existingByRemoteId = new Map(); - for (const row of existing) { - if (row.remote_playlist_id) existingByRemoteId.set(row.remote_playlist_id, row.id); - } - - const seen = new Set(); - const now = Date.now(); - for (const playlist of playlists) { - const remotePlaylistId = playlist.source_playlist_id.trim(); - if (!remotePlaylistId) continue; - seen.add(remotePlaylistId); - const name = playlist.name.trim() || `Playlist ${remotePlaylistId}`; - - let playlistId = existingByRemoteId.get(remotePlaylistId); - if (playlistId == null) { - const result = await tx.run( - `INSERT INTO playlists (name, created_at, updated_at, remote_source_id, remote_playlist_id) - VALUES (?, ?, ?, ?, ?)`, - [name, now, now, sourceId, remotePlaylistId] - ); - playlistId = result.lastInsertRowid; - } else { - await tx.run('UPDATE playlists SET name = ?, updated_at = ? WHERE id = ?', [ - name, - now, - playlistId, - ]); - await tx.run('DELETE FROM playlist_tracks WHERE playlist_id = ?', [playlistId]); - } - - let position = 0; - const seenPaths = new Set(); - for (const track of playlist.tracks) { - if (seenPaths.has(track.path)) continue; - seenPaths.add(track.path); - await tx.run( - `INSERT OR IGNORE INTO playlist_tracks - (playlist_id, track_path, position, added_at, fallback_title, fallback_artist, fallback_album) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [playlistId, track.path, position++, now, track.title, track.artist, track.album] - ); - } - } - - // Reconcile: drop synced playlists that no longer exist upstream. - for (const [remoteId, playlistId] of existingByRemoteId) { - if (seen.has(remoteId)) continue; - await tx.run('DELETE FROM playlists WHERE id = ?', [playlistId]); - } - }); -} diff --git a/src/db/queries.ts b/src/db/queries.ts deleted file mode 100644 index aea2c42..0000000 --- a/src/db/queries.ts +++ /dev/null @@ -1,516 +0,0 @@ -// Library queries — SQL ported/adapted from the desktop library service. - -import type { 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). */ -export interface TrackUpsert { - path: string; - folder_id: number; - title: string; - artist: string; - album: string; - album_artist: string | null; - album_identity_key: string; - album_display_artist: string | null; - duration: number; - track_number: number | null; - disc_number: number | null; - year: number | null; - genre: string | null; - artwork_hash: string | null; - format: string; - sample_rate: number | null; - bit_depth: number | null; - bitrate: number | null; - channels: number | null; - codec: string | null; - file_name: string; - size: number | null; - mtime: number; -} - -const UPSERT_TRACK_SQL = ` - INSERT INTO tracks ( - path, folder_id, title, artist, album, album_artist, album_identity_key, - album_display_artist, duration, track_number, disc_number, year, genre, - artwork_hash, format, sample_rate, bit_depth, bitrate, channels, codec, - source_type, file_name, size, mtime, added_at, modified_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'local', ?, ?, ?, ?, ?) - ON CONFLICT(path) DO UPDATE SET - folder_id = excluded.folder_id, - title = excluded.title, - artist = excluded.artist, - album = excluded.album, - album_artist = excluded.album_artist, - album_identity_key = excluded.album_identity_key, - album_display_artist = excluded.album_display_artist, - duration = excluded.duration, - track_number = excluded.track_number, - disc_number = excluded.disc_number, - year = excluded.year, - genre = excluded.genre, - artwork_hash = excluded.artwork_hash, - format = excluded.format, - sample_rate = excluded.sample_rate, - bit_depth = excluded.bit_depth, - bitrate = excluded.bitrate, - channels = excluded.channels, - codec = excluded.codec, - file_name = excluded.file_name, - size = excluded.size, - mtime = excluded.mtime, - modified_at = excluded.modified_at -`; - -export async function upsertTracks(db: LibraryDatabase, rows: TrackUpsert[]): Promise { - if (rows.length === 0) return; - const now = Date.now(); - await db.transaction(async (tx) => { - for (const row of rows) { - await tx.run(UPSERT_TRACK_SQL, [ - row.path, - row.folder_id, - row.title, - row.artist, - row.album, - row.album_artist, - row.album_identity_key, - row.album_display_artist, - row.duration, - row.track_number, - row.disc_number, - row.year, - row.genre, - row.artwork_hash, - row.format, - row.sample_rate, - row.bit_depth, - row.bitrate, - row.channels, - row.codec, - row.file_name, - row.size, - row.mtime, - now, - now, - ]); - } - }); -} - -// --- Remote tracks (Subsonic / Jellyfin) ------------------------------------- - -/** Row shape for a synced remote track. folder_id is NULL; file_name/size/mtime unused. */ -export interface RemoteTrackUpsert { - path: string; // subsonic://|jellyfin:// identity URI - source_type: 'subsonic' | 'jellyfin'; - source_id: number; - source_track_id: string; - source_path: string | null; - artwork_source_id: string | null; - title: string; - artist: string; - album: string; - album_artist: string | null; - album_identity_key: string; - album_display_artist: string | null; - duration: number; - track_number: number | null; - disc_number: number | null; - year: number | null; - genre: string | null; - format: string; - sample_rate: number | null; - bit_depth: number | null; - bitrate: number | null; - channels: number | null; - codec: string | null; - bpm: number | null; - musical_key: string | null; -} - -const UPSERT_REMOTE_TRACK_SQL = ` - INSERT INTO tracks ( - path, folder_id, title, artist, album, album_artist, album_identity_key, - album_display_artist, duration, track_number, disc_number, year, genre, - artwork_hash, format, sample_rate, bit_depth, bitrate, channels, codec, - bpm, musical_key, - source_type, source_id, source_track_id, source_path, artwork_source_id, file_name, size, mtime, - added_at, modified_at - ) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', NULL, 0, ?, ?) - ON CONFLICT(path) DO UPDATE SET - title = excluded.title, - artist = excluded.artist, - album = excluded.album, - album_artist = excluded.album_artist, - album_identity_key = excluded.album_identity_key, - album_display_artist = excluded.album_display_artist, - duration = excluded.duration, - track_number = excluded.track_number, - disc_number = excluded.disc_number, - year = excluded.year, - genre = excluded.genre, - format = excluded.format, - sample_rate = excluded.sample_rate, - bit_depth = excluded.bit_depth, - bitrate = excluded.bitrate, - channels = excluded.channels, - codec = excluded.codec, - bpm = excluded.bpm, - musical_key = excluded.musical_key, - source_track_id = excluded.source_track_id, - source_path = excluded.source_path, - artwork_source_id = excluded.artwork_source_id, - modified_at = excluded.modified_at -`; - -export async function upsertRemoteTracks( - db: LibraryDatabase, - rows: RemoteTrackUpsert[] -): Promise { - if (rows.length === 0) return; - const now = Date.now(); - await db.transaction(async (tx) => { - for (const row of rows) { - await tx.run(UPSERT_REMOTE_TRACK_SQL, [ - row.path, - row.title, - row.artist, - row.album, - row.album_artist, - row.album_identity_key, - row.album_display_artist, - row.duration, - row.track_number, - row.disc_number, - row.year, - row.genre, - row.format, - row.sample_rate, - row.bit_depth, - row.bitrate, - row.channels, - row.codec, - row.bpm, - row.musical_key, - row.source_type, - row.source_id, - row.source_track_id, - row.source_path, - row.artwork_source_id, - now, - now, - ]); - } - }); -} - -/** Existing remote-track paths for a source, used to diff/prune removed tracks. */ -export function getRemoteSourcePaths( - db: LibraryDatabase, - sourceType: string, - sourceId: number -): Promise<{ path: string }[]> { - return db.all<{ path: string }>( - 'SELECT path FROM tracks WHERE source_type = ? AND source_id = ?', - [sourceType, sourceId] - ); -} - -export async function deleteRemoteTracksBySource( - db: LibraryDatabase, - sourceType: string, - sourceId: number -): Promise { - const result = await db.run('DELETE FROM tracks WHERE source_type = ? AND source_id = ?', [ - sourceType, - sourceId, - ]); - return result.changes; -} - -// Desktop track order (library.ts compareTracksByDiscTrackTitle): nulls sort -// first (as 0) and path is the final tiebreak. Keep in sync with the JS -// comparator in src/library/albumIdentity.ts and astra-car's Kotlin TRACK_ORDER. -const TRACK_ORDER = - 'COALESCE(disc_number, 0), COALESCE(track_number, 0), title COLLATE NOCASE, path'; - -// NOTE: the album browse list is built in JS (src/library/albumSummary.ts) — the -// desktop-parity display picks (most-frequent name/artwork variants, settled -// display artist, singles eligibility) don't map cleanly onto GROUP BY aggregates. -// The artist browse list is likewise built in JS (src/library/artistGrouping.ts) -// so it can honor the astra-grouping vs file-tags mode. - -export function getAllTracks(db: LibraryDatabase): Promise { - return db.all(` - SELECT * FROM tracks - ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE, ${TRACK_ORDER} - `); -} - -export function getTracksByAlbumKey(db: LibraryDatabase, identityKey: string): Promise { - return db.all( - `SELECT * FROM tracks WHERE album_identity_key = ? ORDER BY ${TRACK_ORDER}`, - [identityKey] - ); -} - -export function getTracksByArtist(db: LibraryDatabase, artist: string): Promise { - return db.all( - `SELECT * FROM tracks WHERE artist = ? ORDER BY album COLLATE NOCASE, ${TRACK_ORDER}`, - [artist] - ); -} - -export async function getTrackCount(db: LibraryDatabase): Promise { - const row = await db.get<{ count: number }>('SELECT COUNT(*) AS count FROM tracks'); - return row?.count ?? 0; -} - -// --- Playback history -------------------------------------------------------- - -/** - * Record a local library play. The INSERT is sourced from `tracks`, so streamed - * samples / external paths are ignored unless they are actually in the library. - */ -export async function markTrackPlayed(db: LibraryDatabase, path: string): Promise { - const playedAt = Date.now(); - let recorded = false; - await db.transaction(async (tx) => { - const trackResult = await tx.run( - 'UPDATE tracks SET play_count = play_count + 1, last_played_at = ? WHERE path = ?', - [playedAt, path] - ); - if (trackResult.changes === 0) return; - await tx.run( - `INSERT INTO playback_history (track_path, last_played_at, play_count) - VALUES (?, ?, 1) - ON CONFLICT(track_path) DO UPDATE SET - last_played_at = excluded.last_played_at, - play_count = playback_history.play_count + 1`, - [path, playedAt] - ); - recorded = true; - }); - return recorded; -} - -export function getRecentlyPlayedTracks( - db: LibraryDatabase, - limit = 24 -): Promise { - return db.all( - `SELECT t.* FROM playback_history h - JOIN tracks t ON t.path = h.track_path - ORDER BY h.last_played_at DESC - LIMIT ?`, - [limit] - ); -} - -// --- Loudness (M4 normalization facts) --------------------------------------- - -export interface TrackLoudness { - loudness_lufs: number | null; - sample_peak: number | null; - replay_gain_track_db: number | null; - replay_gain_album_db: number | null; - replay_gain_track_peak: number | null; - replay_gain_album_peak: number | null; - /** 1 once ReplayGain tags have been read (whether or not any were present). */ - rg_scanned: number | null; -} - -/** Loudness facts for one track path (NULL fields = not yet analyzed). */ -export async function getTrackLoudness( - db: LibraryDatabase, - path: string -): Promise { - return ( - (await db.get( - `SELECT loudness_lufs, sample_peak, - replay_gain_track_db, replay_gain_album_db, - replay_gain_track_peak, replay_gain_album_peak, rg_scanned - FROM tracks WHERE path = ?`, - [path] - )) ?? null - ); -} - -/** Persist measured loudness + sample peak for a track (scan analyze pass). */ -export async function setTrackLoudness( - db: LibraryDatabase, - path: string, - lufs: number | null, - samplePeak: number | null -): Promise { - await db.run('UPDATE tracks SET loudness_lufs = ?, sample_peak = ? WHERE path = ?', [ - lufs, - samplePeak, - path, - ]); -} - -export interface ReplayGainColumns { - trackGainDb: number | null; - albumGainDb: number | null; - trackPeak: number | null; - albumPeak: number | null; -} - -/** - * Persist ReplayGain tags read from the container + mark the track as scanned, so - * we read tags once per track (independent of loudness, which may re-measure). - */ -export async function setTrackReplayGain( - db: LibraryDatabase, - path: string, - rg: ReplayGainColumns -): Promise { - await db.run( - `UPDATE tracks SET - replay_gain_track_db = ?, replay_gain_album_db = ?, - replay_gain_track_peak = ?, replay_gain_album_peak = ?, rg_scanned = 1 - WHERE path = ?`, - [rg.trackGainDb, rg.albumGainDb, rg.trackPeak, rg.albumPeak, path] - ); -} - -/** - * Loudness facts for many track paths in one round trip (chunked IN at 500/chunk, - * same shape as deleteTracksByPaths). Used by the gain registry to register the - * whole queue's gains in a single pass. Keys of the returned map are the selected - * `path` values — string params go through encodeParams/toUtf8Latin1 and the read - * path decodes them back, so they match the input JS strings exactly. - */ -export async function getTrackLoudnessByPaths( - db: LibraryDatabase, - paths: string[] -): Promise> { - const out = new Map(); - for (let i = 0; i < paths.length; i += 500) { - const chunk = paths.slice(i, i + 500); - const placeholders = chunk.map(() => '?').join(', '); - const rows = await db.all( - `SELECT path, loudness_lufs, sample_peak, - replay_gain_track_db, replay_gain_album_db, - replay_gain_track_peak, replay_gain_album_peak, rg_scanned - FROM tracks WHERE path IN (${placeholders})`, - chunk as SqlParams - ); - for (const row of rows) out.set(row.path, row); - } - return out; -} - -/** Library-wide loudness aggregates, feeding the fallback ("temp") gain. */ -export interface LibraryLoudnessStatsRow { - lufsCount: number; - medianLufs: number | null; - rgCount: number; - medianRgTrackDb: number | null; -} - -/** - * Counts + medians of measured LUFS and ReplayGain track gain across the library. - * SQLite has no MEDIAN — ORDER BY + LIMIT 1 OFFSET (COUNT-1)/2 scalar subqueries; - * empty sets yield NULL. - */ -export async function getLibraryLoudnessStats( - db: LibraryDatabase -): Promise { - const row = await db.get( - `SELECT - (SELECT COUNT(*) FROM tracks WHERE loudness_lufs IS NOT NULL) AS lufsCount, - (SELECT loudness_lufs FROM tracks WHERE loudness_lufs IS NOT NULL - ORDER BY loudness_lufs LIMIT 1 - OFFSET (SELECT (COUNT(*) - 1) / 2 FROM tracks WHERE loudness_lufs IS NOT NULL) - ) AS medianLufs, - (SELECT COUNT(*) FROM tracks WHERE replay_gain_track_db IS NOT NULL) AS rgCount, - (SELECT replay_gain_track_db FROM tracks WHERE replay_gain_track_db IS NOT NULL - ORDER BY replay_gain_track_db LIMIT 1 - OFFSET (SELECT (COUNT(*) - 1) / 2 FROM tracks WHERE replay_gain_track_db IS NOT NULL) - ) AS medianRgTrackDb` - ); - return ( - row ?? { lufsCount: 0, medianLufs: null, rgCount: 0, medianRgTrackDb: null } - ); -} - -// --- Settings (key-value preferences) ---------------------------------------- - -export async function getSetting(db: LibraryDatabase, key: string): Promise { - 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 { - await db.run( - `INSERT INTO settings (key, value) VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value`, - [key, value] - ); -} - -// --- Folders ----------------------------------------------------------------- - -type FolderRow = Omit; - -export function getFolders(db: LibraryDatabase): Promise { - return db.all('SELECT * FROM folders ORDER BY added_at'); -} - -export async function getFolderTrackCounts(db: LibraryDatabase): Promise> { - const rows = await db.all<{ folder_id: number; count: number }>( - 'SELECT folder_id, COUNT(*) AS count FROM tracks GROUP BY folder_id' - ); - return new Map(rows.map((row) => [row.folder_id, row.count])); -} - -export async function insertFolder( - db: LibraryDatabase, - treeUri: string, - displayName: string -): Promise { - await db.run( - `INSERT INTO folders (tree_uri, display_name, added_at) VALUES (?, ?, ?) - ON CONFLICT(tree_uri) DO UPDATE SET display_name = excluded.display_name`, - [treeUri, displayName, Date.now()] - ); - const row = await db.get('SELECT * FROM folders WHERE tree_uri = ?', [treeUri]); - if (!row) throw new Error('Folder insert failed'); - return row; -} - -export async function deleteFolder(db: LibraryDatabase, folderId: number): Promise { - // ON DELETE CASCADE removes the folder's tracks (foreign_keys is ON per connection). - await db.run('DELETE FROM folders WHERE id = ?', [folderId]); -} - -export async function markFolderScanned(db: LibraryDatabase, folderId: number): Promise { - await db.run('UPDATE folders SET last_scanned_at = ? WHERE id = ?', [Date.now(), folderId]); -} - -// --- Scan support ------------------------------------------------------------ - -export function getFolderSyncRows( - db: LibraryDatabase, - folderId: number -): Promise<{ path: string; size: number | null; mtime: number }[]> { - return db.all('SELECT path, size, mtime FROM tracks WHERE folder_id = ?', [folderId]); -} - -export async function deleteTracksByPaths(db: LibraryDatabase, paths: string[]): Promise { - let deleted = 0; - for (let i = 0; i < paths.length; i += 500) { - const chunk = paths.slice(i, i + 500); - const placeholders = chunk.map(() => '?').join(', '); - const result = await db.run( - `DELETE FROM tracks WHERE path IN (${placeholders})`, - chunk as SqlParams - ); - deleted += result.changes; - } - return deleted; -} diff --git a/src/db/remoteSourceQueries.ts b/src/db/remoteSourceQueries.ts deleted file mode 100644 index 1dfe32c..0000000 --- a/src/db/remoteSourceQueries.ts +++ /dev/null @@ -1,138 +0,0 @@ -// CRUD for the `remote_sources` table (Subsonic/Jellyfin server config). Passwords -// are NOT stored here — see src/services/remoteCredentials.ts (expo-secure-store). - -import type { LibraryDatabase } from './database'; -import type { - RemoteSourceRow, - RemoteSourceStatus, - RemoteSourceType, -} from '@/types/remote'; - -export function getRemoteSources(db: LibraryDatabase): Promise { - return db.all('SELECT * FROM remote_sources ORDER BY created_at'); -} - -export function getRemoteSource( - db: LibraryDatabase, - id: number -): Promise { - return db.get('SELECT * FROM remote_sources WHERE id = ?', [id]); -} - -export interface InsertRemoteSourceInput { - type: RemoteSourceType; - name: string; - baseUrl: string; - username: string; - enabled: boolean; -} - -export async function insertRemoteSource( - db: LibraryDatabase, - input: InsertRemoteSourceInput -): Promise { - const now = Date.now(); - const result = await db.run( - `INSERT INTO remote_sources (type, name, base_url, username, enabled, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [input.type, input.name, input.baseUrl, input.username, input.enabled ? 1 : 0, now, now] - ); - const row = await getRemoteSource(db, result.lastInsertRowid); - if (!row) throw new Error('Remote source insert failed'); - return row; -} - -export interface UpdateRemoteSourceFields { - name?: string; - base_url?: string; - username?: string; - enabled?: boolean; -} - -export async function updateRemoteSource( - db: LibraryDatabase, - id: number, - fields: UpdateRemoteSourceFields -): Promise { - const sets: string[] = []; - const params: (string | number)[] = []; - if (fields.name !== undefined) { - sets.push('name = ?'); - params.push(fields.name); - } - if (fields.base_url !== undefined) { - sets.push('base_url = ?'); - params.push(fields.base_url); - } - if (fields.username !== undefined) { - sets.push('username = ?'); - params.push(fields.username); - } - if (fields.enabled !== undefined) { - sets.push('enabled = ?'); - params.push(fields.enabled ? 1 : 0); - } - if (sets.length === 0) return; - sets.push('updated_at = ?'); - params.push(Date.now()); - params.push(id); - await db.run(`UPDATE remote_sources SET ${sets.join(', ')} WHERE id = ?`, params); -} - -export async function deleteRemoteSource(db: LibraryDatabase, id: number): Promise { - await db.run('DELETE FROM remote_sources WHERE id = ?', [id]); -} - -export async function setRemoteSourceStatus( - db: LibraryDatabase, - id: number, - status: RemoteSourceStatus, - error: string | null -): Promise { - await db.run( - `UPDATE remote_sources - SET last_status = ?, last_error = ?, last_checked_at = ?, updated_at = ? - WHERE id = ?`, - [status, error, Date.now(), Date.now(), id] - ); -} - -export async function setRemoteSourceSynced(db: LibraryDatabase, id: number): Promise { - const now = Date.now(); - await db.run( - `UPDATE remote_sources - SET last_status = 'ok', last_error = NULL, last_sync_at = ?, last_checked_at = ?, updated_at = ? - WHERE id = ?`, - [now, now, now, id] - ); -} - -/** - * Persist the cover-art URL template (with an `__ASTRA_ART_ID__` placeholder) that the - * native Android Auto artwork provider uses to fetch server art without JS/secret access. - */ -export async function setRemoteSourceArtAuth( - db: LibraryDatabase, - id: number, - artAuth: string | null -): Promise { - await db.run('UPDATE remote_sources SET art_auth = ?, updated_at = ? WHERE id = ?', [ - artAuth, - Date.now(), - id, - ]); -} - -/** Cache Jellyfin auth (Subsonic derives a salted token per request, so it stays NULL). */ -export async function setRemoteSourceAuth( - db: LibraryDatabase, - id: number, - auth: { accessToken: string | null; userId: string | null; deviceId: string | null } -): Promise { - await db.run( - `UPDATE remote_sources - SET access_token = ?, user_id = ?, device_id = ?, updated_at = ? - WHERE id = ?`, - [auth.accessToken, auth.userId, auth.deviceId, Date.now(), id] - ); -} diff --git a/src/db/schema.ts b/src/db/schema.ts deleted file mode 100644 index 72224e4..0000000 --- a/src/db/schema.ts +++ /dev/null @@ -1,367 +0,0 @@ -// 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); -// v4 adds a key-value settings table (artist grouping mode, future prefs); -// v5 caches offline waveform peaks for the M3 waveform seek bar; v6 repairs DBs -// that an abandoned earlier M3 spike left at v5 with a stale `waveform_cache`; -// v7 (M4) adds per-track loudness facts (integrated LUFS + sample peak + ReplayGain -// tags) measured for normalization; v8 clears any loudness measured by the earlier -// ungated whole-file method so it re-measures with the fast gated subset method; -// v9 adds ReplayGain peak columns + an `rg_scanned` sentinel so tag reading runs -// once per track (and is retried if it ever failed), independent of loudness; -// v10 adds lightweight local playback history for Home; v11 (M5) adds remote -// sources (Subsonic/Jellyfin): a `remote_sources` table + remote-linkage columns on -// `tracks`, and makes `folder_id` nullable (remote tracks have no SAF folder); v12 -// marks playlists that mirror a server playlist (remote_source_id/remote_playlist_id) -// so remote playlist sync can upsert + reconcile them; v13 adds `remote_sources.art_auth` -// — a self-contained cover-art URL template the native Android Auto artwork provider uses -// to fetch server art without a JS round-trip; v14 adds desktop-style dynamic -// playlist rules plus fresh aggregate track play stats (no playback_history backfill); -// v15 adds `album_display_artist` — the settled group artist ("Various Artists" for -// shared-artwork compilations) written by the album-identity recompute pass -// (src/library/albumIdentity.ts); the backfill itself runs from libraryStore.initialize -// via the `album_grouping_version` settings sentinel (v3 precedent: SQL marks, store acts); -// v16 adds desktop LAN sync state (src/services/desktopSync.ts): a `sync_uid` playlist -// identity shared with the paired desktop, deletion tombstones for favorites/playlists, -// and a pending table for incoming favorites that haven't matched a local track yet; -// v17 adds `playlist_sync_state` — the per-playlist (local, remote) updated_at baseline -// from the last successful sync, which turns blind last-writer-wins into 3-way change -// detection: only-one-side-changed syncs silently, both-changed surfaces a conflict -// prompt (Steam-Cloud style) instead of silently dropping an edit; v18 adds `lyrics_cache` -// — LRC/XLRC results from online lookup (xlrcdb + lrclib), keyed by track_path with no FK -// (survives folder re-grant like waveform_peaks) and invalidated by a metadata_signature. - -import type { LibraryDatabase } from './database'; - -export const SCHEMA_VERSION = 18; - -// One statement per entry — op-sqlite executes single statements. -const MIGRATIONS: readonly (readonly string[])[] = [ - // v0 -> v1 - [ - `CREATE TABLE IF NOT EXISTS folders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - tree_uri TEXT UNIQUE NOT NULL, - display_name TEXT NOT NULL, - added_at INTEGER NOT NULL, - last_scanned_at INTEGER - )`, - `CREATE TABLE IF NOT EXISTS tracks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - path TEXT UNIQUE NOT NULL, - folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE, - title TEXT NOT NULL, - artist TEXT NOT NULL, - album TEXT NOT NULL, - album_artist TEXT, - album_identity_key TEXT NOT NULL, - duration REAL NOT NULL DEFAULT 0, - track_number INTEGER, - disc_number INTEGER, - year INTEGER, - genre TEXT, - artwork_hash TEXT, - format TEXT NOT NULL, - sample_rate INTEGER, - bit_depth INTEGER, - bitrate INTEGER, - channels INTEGER, - codec TEXT, - source_type TEXT NOT NULL DEFAULT 'local', - file_name TEXT NOT NULL, - size INTEGER, - mtime INTEGER NOT NULL DEFAULT 0, - added_at INTEGER NOT NULL, - modified_at INTEGER NOT NULL - )`, - 'CREATE INDEX IF NOT EXISTS idx_tracks_album_identity ON tracks(album_identity_key)', - 'CREATE INDEX IF NOT EXISTS idx_tracks_artist ON tracks(artist)', - 'CREATE INDEX IF NOT EXISTS idx_tracks_folder ON tracks(folder_id)', - ], - // v1 -> v2 — playlists + favorites (desktop library.ts tables, trimmed). - // track_path deliberately has NO FK to tracks: entries survive folder removal - // and resolve again when the same folder is re-granted (identical SAF URIs). - [ - `CREATE TABLE IF NOT EXISTS playlists ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - last_played_at INTEGER - )`, - `CREATE TABLE IF NOT EXISTS playlist_tracks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, - track_path TEXT NOT NULL, - position INTEGER NOT NULL, - added_at INTEGER NOT NULL, - fallback_title TEXT, - fallback_artist TEXT, - fallback_album TEXT, - UNIQUE(playlist_id, track_path) - )`, - 'CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist ON playlist_tracks(playlist_id, position)', - `CREATE TABLE IF NOT EXISTS favorites ( - track_path TEXT PRIMARY KEY NOT NULL, - added_at INTEGER NOT NULL - )`, - ], - // v2 -> v3 — pre-fix builds stored truncated non-ASCII tags (op-sqlite bind bug, - // 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 - )`, - ], - // v4 -> v5 — cached waveform peaks (offline RMS bins) for the seek bar. - // Keyed by track path (SAF URI), no FK — survives folder removal/re-grant - // like favorites/playlists. `peaks` is a tightly-packed Float32 LE blob. - [ - `CREATE TABLE IF NOT EXISTS waveform_peaks ( - track_path TEXT PRIMARY KEY NOT NULL, - bins INTEGER NOT NULL, - peaks BLOB NOT NULL, - created_at INTEGER NOT NULL - )`, - ], - // v5 -> v6 — repair: an abandoned earlier M3 spike shipped a v5 that created a - // different `waveform_cache` table, leaving such DBs at v5 without the - // `waveform_peaks` table above. Create it if missing and drop the orphan. - [ - `CREATE TABLE IF NOT EXISTS waveform_peaks ( - track_path TEXT PRIMARY KEY NOT NULL, - bins INTEGER NOT NULL, - peaks BLOB NOT NULL, - created_at INTEGER NOT NULL - )`, - `DROP TABLE IF EXISTS waveform_cache`, - ], - // v6 -> v7 — per-track loudness facts for normalization (M4). NULL = not yet - // analyzed (the scan analyze pass / lazy fallback backfills them). loudness_lufs - // is integrated LUFS (negative dB); sample_peak is linear [0,1]; replay_gain_* - // are tag dB values when present. - [ - `ALTER TABLE tracks ADD COLUMN loudness_lufs REAL`, - `ALTER TABLE tracks ADD COLUMN sample_peak REAL`, - `ALTER TABLE tracks ADD COLUMN replay_gain_track_db REAL`, - `ALTER TABLE tracks ADD COLUMN replay_gain_album_db REAL`, - ], - // v7 -> v8 — re-measure loudness with the gated subset method (the earlier values - // were ungated whole-file). NULL forces the background pass to recompute them. - [`UPDATE tracks SET loudness_lufs = NULL, sample_peak = NULL`], - // v8 -> v9 — ReplayGain peaks (linear, for clip-limiting in RG mode) + an - // `rg_scanned` flag (0 = tags not yet read). Tag reading is decoupled from the - // loudness decode so it runs once per track and survives loudness re-measures. - [ - `ALTER TABLE tracks ADD COLUMN replay_gain_track_peak REAL`, - `ALTER TABLE tracks ADD COLUMN replay_gain_album_peak REAL`, - `ALTER TABLE tracks ADD COLUMN rg_scanned INTEGER NOT NULL DEFAULT 0`, - ], - // v9 -> v10 — recently played facts. No FK so rows can survive temporary - // folder removal; Home joins against tracks so missing files stay hidden. - [ - `CREATE TABLE IF NOT EXISTS playback_history ( - track_path TEXT PRIMARY KEY NOT NULL, - last_played_at INTEGER NOT NULL, - play_count INTEGER NOT NULL DEFAULT 1 - )`, - 'CREATE INDEX IF NOT EXISTS idx_playback_history_last_played ON playback_history(last_played_at DESC)', - ], - // v10 -> v11 — remote sources (M5: Subsonic/Jellyfin). One `remote_sources` table - // (type-discriminated) holds server config + cached Jellyfin auth (the password - // lives in expo-secure-store, never here). The `tracks` table gains remote-linkage - // columns and `folder_id` becomes nullable — SQLite can't drop NOT NULL in place, - // so we rebuild `tracks` (the only FK into it is its own folder_id; favorites / - // playlists / waveform_peaks / playback_history key on `path` with no FK, so the - // rebuild is safe). Existing (local) rows copy across; the 4 new columns default NULL. - [ - `CREATE TABLE IF NOT EXISTS remote_sources ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL, - name TEXT NOT NULL, - base_url TEXT NOT NULL, - username TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - last_status TEXT NOT NULL DEFAULT 'unknown', - last_error TEXT, - last_sync_at INTEGER, - last_checked_at INTEGER, - access_token TEXT, - user_id TEXT, - device_id TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - )`, - `CREATE TABLE tracks_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - path TEXT UNIQUE NOT NULL, - folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, - title TEXT NOT NULL, - artist TEXT NOT NULL, - album TEXT NOT NULL, - album_artist TEXT, - album_identity_key TEXT NOT NULL, - duration REAL NOT NULL DEFAULT 0, - track_number INTEGER, - disc_number INTEGER, - year INTEGER, - genre TEXT, - artwork_hash TEXT, - format TEXT NOT NULL, - sample_rate INTEGER, - bit_depth INTEGER, - bitrate INTEGER, - channels INTEGER, - codec TEXT, - source_type TEXT NOT NULL DEFAULT 'local', - file_name TEXT NOT NULL, - size INTEGER, - mtime INTEGER NOT NULL DEFAULT 0, - added_at INTEGER NOT NULL, - modified_at INTEGER NOT NULL, - loudness_lufs REAL, - sample_peak REAL, - replay_gain_track_db REAL, - replay_gain_album_db REAL, - replay_gain_track_peak REAL, - replay_gain_album_peak REAL, - rg_scanned INTEGER NOT NULL DEFAULT 0, - source_id INTEGER, - source_track_id TEXT, - source_path TEXT, - artwork_source_id TEXT - )`, - `INSERT INTO tracks_new ( - id, path, folder_id, title, artist, album, album_artist, album_identity_key, - duration, track_number, disc_number, year, genre, artwork_hash, format, - sample_rate, bit_depth, bitrate, channels, codec, source_type, file_name, size, - mtime, added_at, modified_at, loudness_lufs, sample_peak, replay_gain_track_db, - replay_gain_album_db, replay_gain_track_peak, replay_gain_album_peak, rg_scanned - ) - SELECT - id, path, folder_id, title, artist, album, album_artist, album_identity_key, - duration, track_number, disc_number, year, genre, artwork_hash, format, - sample_rate, bit_depth, bitrate, channels, codec, source_type, file_name, size, - mtime, added_at, modified_at, loudness_lufs, sample_peak, replay_gain_track_db, - replay_gain_album_db, replay_gain_track_peak, replay_gain_album_peak, rg_scanned - FROM tracks`, - `DROP TABLE tracks`, - `ALTER TABLE tracks_new RENAME TO tracks`, - 'CREATE INDEX IF NOT EXISTS idx_tracks_album_identity ON tracks(album_identity_key)', - 'CREATE INDEX IF NOT EXISTS idx_tracks_artist ON tracks(artist)', - 'CREATE INDEX IF NOT EXISTS idx_tracks_folder ON tracks(folder_id)', - 'CREATE INDEX IF NOT EXISTS idx_tracks_source ON tracks(source_type, source_id)', - ], - // v11 -> v12 — mark playlists that mirror a remote server playlist. A non-null - // remote_source_id (-> remote_sources.id) + remote_playlist_id make the row a synced - // remote playlist; the unique index lets sync upsert by that pair. Local playlists - // leave both NULL and are untouched. - [ - `ALTER TABLE playlists ADD COLUMN remote_source_id INTEGER`, - `ALTER TABLE playlists ADD COLUMN remote_playlist_id TEXT`, - `CREATE UNIQUE INDEX IF NOT EXISTS idx_playlists_remote - ON playlists(remote_source_id, remote_playlist_id) - WHERE remote_source_id IS NOT NULL AND remote_playlist_id IS NOT NULL`, - ], - // v12 -> v13 — cover-art URL template per remote source, read by the native Android - // Auto artwork provider (which has no JS/secret access) to fetch + cache server art. - // It embeds a fixed Subsonic salt+token / Jellyfin api_key with an `__ASTRA_ART_ID__` - // placeholder for the cover id; the password itself never leaves expo-secure-store. - [`ALTER TABLE remote_sources ADD COLUMN art_auth TEXT`], - // v13 -> v14 — dynamic playlists and fresh aggregate play stats. Do not seed - // play_count / last_played_at from playback_history: existing recents remain for - // Home, while dynamic play-stat rules start fresh from this version forward. - [ - `ALTER TABLE tracks ADD COLUMN play_count INTEGER NOT NULL DEFAULT 0`, - `ALTER TABLE tracks ADD COLUMN last_played_at INTEGER`, - `ALTER TABLE tracks ADD COLUMN bpm REAL`, - `ALTER TABLE tracks ADD COLUMN musical_key TEXT`, - `ALTER TABLE playlists ADD COLUMN kind TEXT NOT NULL DEFAULT 'normal'`, - `ALTER TABLE playlists ADD COLUMN dynamic_rules_json TEXT`, - `UPDATE playlists SET kind = 'normal' WHERE kind IS NULL OR kind NOT IN ('normal', 'dynamic')`, - `UPDATE playlists SET dynamic_rules_json = NULL WHERE kind <> 'dynamic'`, - `CREATE INDEX IF NOT EXISTS idx_tracks_play_count ON tracks(play_count DESC)`, - `CREATE INDEX IF NOT EXISTS idx_tracks_last_played ON tracks(last_played_at DESC)`, - `CREATE INDEX IF NOT EXISTS idx_playlists_kind ON playlists(kind)`, - ], - // v14 -> v15 — settled album display artist (see header). NULL until the - // startup recompute pass backfills it; readers fall back to album_artist/artist. - [`ALTER TABLE tracks ADD COLUMN album_display_artist TEXT`], - // v15 -> v16 — desktop LAN sync (see header). Tombstones record deletions so a - // two-way merge propagates them instead of resurrecting the row from the peer; - // favorite_sync_pending holds incoming favorites with no local track match yet - // (retried against the metadata ladder at each sync). Table shapes mirror the - // desktop's (astra src/main/services/library.ts). - [ - `ALTER TABLE playlists ADD COLUMN sync_uid TEXT`, - `CREATE UNIQUE INDEX IF NOT EXISTS idx_playlists_sync_uid - ON playlists(sync_uid) - WHERE sync_uid IS NOT NULL`, - `CREATE TABLE IF NOT EXISTS favorite_tombstones ( - sync_key TEXT PRIMARY KEY NOT NULL, - deleted_at INTEGER NOT NULL - )`, - `CREATE TABLE IF NOT EXISTS favorite_sync_pending ( - sync_key TEXT PRIMARY KEY NOT NULL, - title TEXT NOT NULL, - artist TEXT NOT NULL, - album TEXT NOT NULL, - added_at INTEGER NOT NULL - )`, - `CREATE TABLE IF NOT EXISTS playlist_tombstones ( - sync_uid TEXT PRIMARY KEY NOT NULL, - deleted_at INTEGER NOT NULL - )`, - ], - // v16 -> v17 — desktop sync conflict detection baseline (see header). Rows are - // written only for playlists that ended a sync run in-sync on both devices. - [ - `CREATE TABLE IF NOT EXISTS playlist_sync_state ( - sync_uid TEXT PRIMARY KEY NOT NULL, - local_updated_at INTEGER NOT NULL, - remote_updated_at INTEGER NOT NULL - )`, - ], - // v17 -> v18 — cached lyrics (LRC/XLRC) from online lookup (xlrcdb + lrclib). - // Keyed by track_path (SAF URI / remote identity), no FK — survives folder - // removal/re-grant like waveform_peaks. metadata_signature (sha1 of - // title/artist/album/duration) invalidates the row when the track's tags change; - // `status` distinguishes a real hit from a cached "no lyrics found" (so we don't - // re-hit the network every open); synced_lines_json is the parsed LyricsLine[] the - // renderer consumes. Non-ASCII lyric text stores via the op-sqlite UTF-8/Latin1 - // param workaround (see database.ts). Local/embedded sources land in the v2 phase. - [ - `CREATE TABLE IF NOT EXISTS lyrics_cache ( - track_path TEXT PRIMARY KEY NOT NULL, - metadata_signature TEXT, - status TEXT NOT NULL, - source TEXT, - provider TEXT, - format TEXT, - plain_lyrics TEXT, - synced_lyrics TEXT, - synced_lines_json TEXT NOT NULL, - updated_at INTEGER NOT NULL - )`, - 'CREATE INDEX IF NOT EXISTS idx_lyrics_cache_updated ON lyrics_cache(updated_at)', - ], -]; - -export async function migrate(db: LibraryDatabase): Promise { - const row = await db.get<{ user_version: number }>('PRAGMA user_version'); - const current = row?.user_version ?? 0; - - for (let version = current; version < SCHEMA_VERSION; version++) { - await db.transaction(async (tx) => { - for (const statement of MIGRATIONS[version]) { - await tx.exec(statement); - } - await tx.exec(`PRAGMA user_version = ${version + 1}`); - }); - } -} diff --git a/src/db/waveformQueries.ts b/src/db/waveformQueries.ts deleted file mode 100644 index 958c603..0000000 --- a/src/db/waveformQueries.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Waveform peak cache — offline RMS bins for the M3 waveform seek bar. -// Keyed by track path (SAF URI), mirroring favorites/playlists (no FK, so a row -// survives folder removal and resolves again on re-grant). Peaks are normalized -// to [0, 1] and stored as a tightly-packed Float32 little-endian blob. - -import type { LibraryDatabase } from './database'; - -export async function getWaveformPeaks( - db: LibraryDatabase, - trackPath: string -): Promise { - const row = await db.get<{ peaks: ArrayBuffer | ArrayBufferView }>( - 'SELECT peaks FROM waveform_peaks WHERE track_path = ?', - [trackPath] - ); - return row ? toFloat32(row.peaks) : null; -} - -export async function putWaveformPeaks( - db: LibraryDatabase, - trackPath: string, - peaks: Float32Array -): Promise { - // Bind the typed-array view directly (a valid ArrayBufferView Scalar); copy to - // a tight view first if it's a window into a larger buffer. - const tight = - peaks.byteOffset === 0 && peaks.byteLength === peaks.buffer.byteLength - ? peaks - : peaks.slice(); - await db.run( - `INSERT INTO waveform_peaks (track_path, bins, peaks, created_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(track_path) DO UPDATE SET - bins = excluded.bins, peaks = excluded.peaks, created_at = excluded.created_at`, - [trackPath, peaks.length, tight, Date.now()] - ); -} - -export async function getWaveformCacheCount(db: LibraryDatabase): Promise { - const row = await db.get<{ count: number }>('SELECT COUNT(*) AS count FROM waveform_peaks'); - return row?.count ?? 0; -} - -export async function clearWaveformCache(db: LibraryDatabase): Promise { - await db.run('DELETE FROM waveform_peaks'); -} - -function toFloat32(blob: ArrayBuffer | ArrayBufferView): Float32Array { - if (blob instanceof Float32Array) return blob; - if (ArrayBuffer.isView(blob)) { - return new Float32Array(blob.buffer, blob.byteOffset, Math.floor(blob.byteLength / 4)); - } - return new Float32Array(blob); -} diff --git a/src/library/albumIdentity.ts b/src/library/albumIdentity.ts index 6391431..b584551 100644 --- a/src/library/albumIdentity.ts +++ b/src/library/albumIdentity.ts @@ -17,7 +17,6 @@ import { groupTracksByAlbumIdentity, normalizeDisplay, } from '../shared/library/albumGrouping.ts'; -import type { LibraryDatabase, SqlParams } from '../db/database'; export interface ProvisionalAlbumIdentity { key: string; @@ -98,39 +97,6 @@ export function computeAlbumIdentityUpdates( 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 { - const rows = await db.all( - `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 diff --git a/src/library/nativePages.ts b/src/library/nativePages.ts new file mode 100644 index 0000000..3376161 --- /dev/null +++ b/src/library/nativePages.ts @@ -0,0 +1,205 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { AstraLibraryData } from '../../modules/astra-library-scanner'; +import { normalizeKey } from '@/shared/library/albumGrouping'; +import type { ArtistGroupingMode } from '@/library/artistGrouping'; +import type { Album, Artist, DbTrack } from '@/types/library'; + +const DETAIL_PAGE_SIZE = 100; +const MAX_DETAIL_ITEMS = 500; + +export type NativeAlbumSummary = Album & { total_duration?: number }; + +interface PagedDetail { + items: T[]; + summary: S | null; + totalCount: number; + loading: boolean; + loadMore: () => Promise; +} + +function appendTracks(current: DbTrack[], incoming: DbTrack[]): DbTrack[] { + const paths = new Set(current.map((track) => track.path)); + const merged = [...current, ...incoming.filter((track) => !paths.has(track.path))]; + return merged.length > MAX_DETAIL_ITEMS + ? merged.slice(merged.length - MAX_DETAIL_ITEMS) + : merged; +} + +export function useNativeAlbumDetail(albumKey: string): PagedDetail { + const [items, setItems] = useState([]); + const [summary, setSummary] = useState(null); + const [totalCount, setTotalCount] = useState(0); + const [cursor, setCursor] = useState(null); + const [loading, setLoading] = useState(true); + + const reset = useCallback(async () => { + if (!albumKey) return; + setLoading(true); + try { + const page = await AstraLibraryData.getAlbumDetail( + albumKey, + null, + DETAIL_PAGE_SIZE + ); + setItems(page.items ?? []); + setSummary(page.summary ?? null); + setTotalCount(page.totalCount ?? 0); + setCursor(page.nextCursor ?? null); + } finally { + setLoading(false); + } + }, [albumKey]); + + useEffect(() => { + queueMicrotask(() => void reset()); + const subscription = AstraLibraryData.addListener('onCatalogChanged', () => void reset()); + return () => subscription.remove(); + }, [reset]); + + const loadMore = useCallback(async () => { + if (!cursor || loading) return; + setLoading(true); + try { + const page = await AstraLibraryData.getAlbumDetail( + albumKey, + cursor, + DETAIL_PAGE_SIZE + ); + if (page.error === 'STALE_REVISION') return reset(); + setItems((current) => appendTracks(current, page.items)); + setSummary(page.summary ?? null); + setTotalCount(page.totalCount ?? 0); + setCursor(page.nextCursor ?? null); + } finally { + setLoading(false); + } + }, [albumKey, cursor, loading, reset]); + + return { items, summary, totalCount, loading, loadMore }; +} + +export function useNativeArtistDetail( + artistName: string, + groupingMode: ArtistGroupingMode, + section: 'songs' | 'appearances' | 'all' +): PagedDetail { + const artistKey = useMemo(() => normalizeKey(artistName), [artistName]); + const [items, setItems] = useState([]); + const [summary, setSummary] = useState(null); + const [totalCount, setTotalCount] = useState(0); + const [cursor, setCursor] = useState(null); + const [loading, setLoading] = useState(true); + + const reset = useCallback(async () => { + if (!artistKey) return; + setLoading(true); + try { + const page = await AstraLibraryData.getArtistDetail( + artistKey, + groupingMode, + section, + null, + DETAIL_PAGE_SIZE + ); + setItems(page.items ?? []); + setSummary(page.summary ?? null); + setTotalCount(page.totalCount ?? 0); + setCursor(page.nextCursor ?? null); + } finally { + setLoading(false); + } + }, [artistKey, groupingMode, section]); + + useEffect(() => { + queueMicrotask(() => void reset()); + const subscription = AstraLibraryData.addListener('onCatalogChanged', () => void reset()); + return () => subscription.remove(); + }, [reset]); + + const loadMore = useCallback(async () => { + if (!cursor || loading) return; + setLoading(true); + try { + const page = await AstraLibraryData.getArtistDetail( + artistKey, + groupingMode, + section, + cursor, + DETAIL_PAGE_SIZE + ); + if (page.error === 'STALE_REVISION') return reset(); + setItems((current) => appendTracks(current, page.items)); + setSummary(page.summary ?? null); + setTotalCount(page.totalCount ?? 0); + setCursor(page.nextCursor ?? null); + } finally { + setLoading(false); + } + }, [artistKey, cursor, groupingMode, loading, reset, section]); + + return { items, summary, totalCount, loading, loadMore }; +} + +export function useNativeArtistAlbums( + artistName: string, + groupingMode: ArtistGroupingMode, +): { + items: NativeAlbumSummary[]; + totalCount: number; + loading: boolean; + loadMore: () => Promise; +} { + const artistKey = useMemo(() => normalizeKey(artistName), [artistName]); + const [items, setItems] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [nextOffset, setNextOffset] = useState(0); + const [loading, setLoading] = useState(true); + + const reset = useCallback(async () => { + if (!artistKey) return; + setLoading(true); + try { + const page = await AstraLibraryData.getArtistAlbums( + artistKey, + groupingMode, + 0, + DETAIL_PAGE_SIZE, + ); + setItems(page.items); + setTotalCount(page.totalCount); + setNextOffset(page.nextOffset); + } finally { + setLoading(false); + } + }, [artistKey, groupingMode]); + + useEffect(() => { + queueMicrotask(() => void reset()); + const subscription = AstraLibraryData.addListener('onCatalogChanged', () => void reset()); + return () => subscription.remove(); + }, [reset]); + + const loadMore = useCallback(async () => { + if (nextOffset == null || loading) return; + setLoading(true); + try { + const page = await AstraLibraryData.getArtistAlbums( + artistKey, + groupingMode, + nextOffset, + DETAIL_PAGE_SIZE, + ); + setItems((current) => { + const known = new Set(current.map((album) => album.identity_key)); + const merged = [...current, ...page.items.filter((album) => !known.has(album.identity_key))]; + return merged.slice(-MAX_DETAIL_ITEMS); + }); + setTotalCount(page.totalCount); + setNextOffset(page.nextOffset); + } finally { + setLoading(false); + } + }, [artistKey, groupingMode, loading, nextOffset]); + + return { items, totalCount, loading, loadMore }; +} diff --git a/src/library/remoteSync.ts b/src/library/remoteSync.ts index c7e0ab2..8afb05f 100644 --- a/src/library/remoteSync.ts +++ b/src/library/remoteSync.ts @@ -2,15 +2,7 @@ // folders: fetch the server catalog -> upsert into `tracks` -> prune removed tracks. // The caller (remoteSourcesStore) owns status/progress writes and libraryStore.refresh. -import type { LibraryDatabase } from '@/db/database'; -import { - deleteTracksByPaths, - getRemoteSourcePaths, - upsertRemoteTracks, - type RemoteTrackUpsert, -} from '@/db/queries'; -import { addFavoritePaths, syncRemotePlaylists } from '@/db/playlistQueries'; -import { buildProvisionalAlbumIdentity, recomputeAlbumIdentity } from '@/library/albumIdentity'; +import { AstraLibraryData } from '../../modules/astra-library-scanner'; import { buildSubsonicTrackPath, fetchSubsonicStarredTrackIds, @@ -19,13 +11,13 @@ import { } from '@/services/subsonic'; import { syncJellyfinCatalog, type JellyfinAuthContext } from '@/services/jellyfin'; import type { - RemoteCatalogTrack, RemoteConnectionConfig, + RemotePlaylist, RemoteSourceRow, RemoteSyncProgress, } from '@/types/remote'; -const UPSERT_BATCH = 500; +const UPSERT_BATCH = 200; export interface SyncRemoteResult { tracksScanned: number; @@ -39,122 +31,72 @@ export interface SyncRemoteOptions { signal?: AbortSignal; } -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, - source_id: source.id, - source_track_id: track.source_track_id, - source_path: track.source_path, - artwork_source_id: track.artwork_source_id, - title: track.title, - artist: track.artist, - album: track.album, - album_artist: track.album_artist, - album_identity_key: albumIdentity.key, - album_display_artist: albumIdentity.displayArtist, - duration: track.duration, - track_number: track.track_number, - disc_number: track.disc_number, - year: track.year, - genre: track.genre, - format: track.format, - sample_rate: track.sample_rate, - bit_depth: track.bit_depth, - bitrate: track.bitrate, - channels: track.channels, - codec: track.codec, - bpm: track.bpm, - musical_key: track.musical_key, - }; -} - export async function syncRemoteSource( - db: LibraryDatabase, source: RemoteSourceRow, config: RemoteConnectionConfig, options: SyncRemoteOptions = {} ): Promise { options.onProgress?.({ phase: 'connecting', current: 0, total: 0, detail: null }); - let catalogTracks: RemoteCatalogTrack[]; - if (source.type === 'subsonic') { - const result = await syncSubsonicCatalog(source.id, config, { - onProgress: options.onProgress, - signal: options.signal, - }); - catalogTracks = result.tracks; - } else { - const result = await syncJellyfinCatalog(source.id, config, { - onProgress: options.onProgress, - authContext: options.authContext, - signal: options.signal, - }); - catalogTracks = result.tracks; - } + const syncId = await AstraLibraryData.beginRemoteSync(source.id, source.type); + let streamedTracks = 0; + const appendTracks = async (tracks: Record[]) => { + for (let index = 0; index < tracks.length; index += UPSERT_BATCH) { + const batch = tracks.slice(index, index + UPSERT_BATCH); + await AstraLibraryData.appendRemoteTracks(syncId, batch); + streamedTracks += batch.length; + options.onProgress?.({ + phase: 'saving', + current: streamedTracks, + total: streamedTracks, + detail: null, + }); + } + }; - options.onProgress?.({ phase: 'saving', current: 0, total: catalogTracks.length, detail: null }); + try { + let favoritePaths: string[] = []; + let remotePlaylists: RemotePlaylist[] = []; + if (source.type === 'subsonic') { + const requestOptions = { + onProgress: options.onProgress, + signal: options.signal, + }; + const [, starredIds, playlists] = await Promise.all([ + syncSubsonicCatalog(source.id, config, { + ...requestOptions, + collectTracks: false, + onTracksBatch: (tracks) => appendTracks( + tracks as unknown as Record[], + ), + }), + fetchSubsonicStarredTrackIds(config, requestOptions), + syncSubsonicPlaylists(source.id, config, requestOptions), + ]); + favoritePaths = starredIds.map((id) => buildSubsonicTrackPath(source.id, id)); + remotePlaylists = playlists; + } else { + await syncJellyfinCatalog(source.id, config, { + onProgress: options.onProgress, + authContext: options.authContext, + signal: options.signal, + collectTracks: false, + onTracksBatch: (tracks) => appendTracks( + tracks as unknown as Record[], + ), + }); + } - const rows = catalogTracks.map((track) => toUpsertRow(source, track)); - for (let i = 0; i < rows.length; i += UPSERT_BATCH) { - await upsertRemoteTracks(db, rows.slice(i, i + UPSERT_BATCH)); - options.onProgress?.({ - phase: 'saving', - current: Math.min(i + UPSERT_BATCH, rows.length), - total: rows.length, - detail: null, - }); - } - - // Prune tracks that vanished upstream (favorites/playlists keep their path-keyed - // entries; they just resolve as missing until re-added — same as local removal). - const currentPaths = new Set(rows.map((row) => row.path)); - const existing = await getRemoteSourcePaths(db, source.type, source.id); - 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') { - await syncSubsonicFavoritesAndPlaylists(db, source.id, config, options); - } - - return { tracksScanned: rows.length, removed }; -} - -async function syncSubsonicFavoritesAndPlaylists( - db: LibraryDatabase, - sourceId: number, - config: RemoteConnectionConfig, - options: SyncRemoteOptions -): Promise { - const [starred, playlists] = await Promise.allSettled([ - fetchSubsonicStarredTrackIds(config, { signal: options.signal }), - syncSubsonicPlaylists(sourceId, config, { - onProgress: options.onProgress, - signal: options.signal, - }), - ]); - - if (starred.status === 'fulfilled') { - // Starred ids -> deterministic identity paths; insert-or-ignore (additive, like - // desktop — un-starring on the server doesn't drop a local favorite). - const paths = starred.value.map((id) => buildSubsonicTrackPath(sourceId, id)); - await addFavoritePaths(db, paths); - } else { - console.warn('[remoteSync] subsonic starred fetch failed', starred.reason); - } - - if (playlists.status === 'fulfilled') { - await syncRemotePlaylists(db, sourceId, playlists.value); - } else { - console.warn('[remoteSync] subsonic playlist sync failed', playlists.reason); + const committed = await AstraLibraryData.commitRemoteSync(syncId); + await AstraLibraryData.replaceRemoteUserState( + source.id, + source.type, + favoritePaths, + remotePlaylists as unknown as Record[] + ); + return { tracksScanned: committed.tracksScanned, removed: committed.removed }; + } catch (error) { + await AstraLibraryData.abortRemoteSync(syncId).catch(() => {}); + throw error; } } diff --git a/src/library/scanner.ts b/src/library/scanner.ts index 7cb4d14..c8d6187 100644 --- a/src/library/scanner.ts +++ b/src/library/scanner.ts @@ -1,27 +1,11 @@ -// 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'; + AstraLibraryData, + AstraLibraryScanner, + type NativeScanResult, +} from '../../modules/astra-library-scanner'; import type { LibraryFolder } from '@/types/library'; import { AUDIO_EXTENSIONS } from './audioExtensions'; -import { recomputeAlbumIdentity } from './albumIdentity'; -import { metadataToUpsertRow } from './trackAdapter'; - -const EXTRACT_BATCH_SIZE = 24; export interface ScanProgress { phase: 'discovering' | 'extracting' | 'analyzing'; @@ -41,11 +25,12 @@ export interface ScanResult { errors: number; } -function emptyResult(): ScanResult { - return { added: 0, updated: 0, removed: 0, errors: 0 }; -} +type NativeFolder = LibraryFolder & { + track_count: number; + scan_status?: string; + scan_error?: string | null; +}; -/** "content://…/tree/primary%3AMusic%2FAstraTest" -> "AstraTest" */ function displayNameFromTreeUri(treeUri: string): string { const lastSegment = treeUri.split('/').pop() ?? treeUri; const decoded = decodeURIComponent(lastSegment); @@ -53,32 +38,28 @@ function displayNameFromTreeUri(treeUri: string): string { 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, - })); +function scanResult(result: NativeScanResult): ScanResult { + return { + added: result.added, + updated: result.updated, + removed: result.removed, + errors: result.errors, + }; +} + +export async function loadFolders(): Promise { + await AstraLibraryData.initialize(); + return (await AstraLibraryData.listFolders()) as unknown as NativeFolder[]; } -/** - * System folder picker -> persist grant -> folder row -> scan. - * Returns null if the user cancelled the picker. - */ export async function addFolderViaPicker(callbacks?: ScanCallbacks): Promise { 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 }); + const folder = await AstraLibraryData.registerFolder(treeUri, displayNameFromTreeUri(treeUri)); + return scanFolder(folder as unknown as LibraryFolder, { callbacks }); } export async function scanFolder( @@ -86,96 +67,28 @@ export async function scanFolder( opts: { mode?: 'incremental' | 'full'; callbacks?: ScanCallbacks } = {} ): Promise { 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) => { + const total = event.total ?? event.found ?? 0; callbacks?.onProgress?.({ - phase: 'discovering', - processed: 0, - total: event.found, - folderName: folder.display_name, + phase: event.phase === 'indexing' ? 'analyzing' : event.phase, + processed: event.processed ?? (event.phase === 'discovering' ? total : 0), + total, + folderName: event.folderName ?? folder.display_name, }); }); - callbacks?.onProgress?.({ phase: 'discovering', processed: 0, total: 0, folderName: folder.display_name }); - - let files: ScannedFile[]; - let covers: Record; try { - const listing = await AstraLibraryScanner.listAudioFiles(folder.tree_uri, AUDIO_EXTENSIONS); - files = listing.files; - covers = listing.covers; + const result = await AstraLibraryScanner.scanFolderNative(folder.id, mode, AUDIO_EXTENSIONS); + return scanResult(result); } 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); - - // 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. - return result; } -/** Rescans every folder whose permission grant is still alive. */ export async function rescanAll( opts: { mode?: 'incremental' | 'full'; callbacks?: ScanCallbacks } = {} ): Promise { const folders = await loadFolders(); - const total = emptyResult(); + const total: ScanResult = { added: 0, updated: 0, removed: 0, errors: 0 }; for (const folder of folders) { if (!folder.available) continue; const result = await scanFolder(folder, opts); @@ -187,12 +100,6 @@ export async function rescanAll( return total; } -/** Explicit user removal: drop the folder (tracks CASCADE) and release the grant. */ -export async function removeFolder(folder: Pick): Promise { - 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); +export async function removeFolder(folder: Pick): Promise { + await AstraLibraryData.removeFolder(folder.id); } diff --git a/src/library/tagEncoding.ts b/src/library/tagEncoding.ts deleted file mode 100644 index 01c300b..0000000 --- a/src/library/tagEncoding.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Repairs mojibake produced by MediaMetadataRetriever mis-decoding legacy ID3v2 -// text frames — the classic case being Japanese MP3s with Shift-JIS bytes in an -// ISO-8859-1-flagged frame. MMR decodes each raw byte to a Latin-1 char, so the -// original bytes survive in the low byte of every code unit and can be recovered -// and re-decoded here. Desktop avoids this entirely via music-metadata, which -// honours the frame's encoding byte. - -import * as Encoding from 'encoding-japanese'; - -// Characters that prove a recovered string is real Japanese/CJK text (kana, CJK -// unified ideographs, hangul, full/half-width forms). Used as the final gate so a -// mis-detection of accented Latin-1 (e.g. "Beyoncé") can never corrupt good text. -const CJK = /[\u3040-\u30FF\u3400-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF]/; - -/** - * Returns `value` re-decoded from a Japanese multibyte encoding when it is - * recoverable mojibake; otherwise returns `value` unchanged. Safe to call on any - * tag string — it is a no-op for ASCII, accented Latin, and already-correct - * Unicode. - */ -export function repairMojibakeTag(value: string): string { - let hasHighByte = false; - const bytes = new Array(value.length); - for (let i = 0; i < value.length; i++) { - const code = value.charCodeAt(i); - // A code unit above 0xFF means the string already holds real Unicode (e.g. - // MMR decoded a UTF-16 frame correctly) — there is nothing to recover. - if (code > 0xff) return value; - if (code >= 0x80) hasHighByte = true; - bytes[i] = code; - } - if (!hasHighByte) return value; // pure ASCII/Latin — nothing to recover - - const detected = Encoding.detect(bytes); - if (detected !== 'SJIS' && detected !== 'EUCJP') return value; - - const repaired = Encoding.convert(bytes, { to: 'UNICODE', from: detected, type: 'string' }); - // Only accept the conversion when it actually produced CJK text. - return CJK.test(repaired) ? repaired : value; -} diff --git a/src/library/trackAdapter.ts b/src/library/trackAdapter.ts index 07e3d80..177ca9d 100644 --- a/src/library/trackAdapter.ts +++ b/src/library/trackAdapter.ts @@ -1,91 +1,9 @@ -// Adapters between the native scanner output, the SQLite row shape, and the -// app-level Track model the player consumes. +// Adapter between native repository rows and the player model. 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'; import { artworkUrlForTrack } from '@/services/remoteUrls'; -import { buildProvisionalAlbumIdentity } from './albumIdentity'; -import { repairMojibakeTag } from './tagEncoding'; - -const UNKNOWN_ARTIST = 'Unknown Artist'; -const UNKNOWN_ALBUM = 'Unknown Album'; - -const CODEC_BY_MIME: Record = { - '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 ? repairMojibakeTag(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); - const albumIdentity = buildProvisionalAlbumIdentity(albumArtist, artist, album); - - return { - path: file.uri, - folder_id: folderId, - title, - artist, - album, - album_artist: albumArtist, - 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, - 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 { const isRemote = track.source_type !== 'local'; diff --git a/src/lyrics/lyrics.ts b/src/lyrics/lyrics.ts index cea1f86..56519ea 100644 Binary files a/src/lyrics/lyrics.ts and b/src/lyrics/lyrics.ts differ diff --git a/src/scope/waveform.ts b/src/scope/waveform.ts index e006f8c..e654b8c 100644 --- a/src/scope/waveform.ts +++ b/src/scope/waveform.ts @@ -3,9 +3,7 @@ // once per track and persists; extractWaveformPreview gives uncached local // tracks a fast first paint. -import { AstraLibraryScanner } from '../../modules/astra-library-scanner'; -import { openLibraryDb } from '@/db/database'; -import { clearWaveformCache, getWaveformPeaks, putWaveformPeaks } from '@/db/waveformQueries'; +import { AstraLibraryData, AstraLibraryScanner } from '../../modules/astra-library-scanner'; import { CacheInvalidationGate } from '@/lib/cacheInvalidation'; export const WAVEFORM_BINS = 512; @@ -32,9 +30,8 @@ async function loadWaveform( trackPath: string, options: WaveformLoadOptions ): Promise { - const db = await openLibraryDb(); - const cached = await getWaveformPeaks(db, trackPath); - if (cached && cached.length > 0) return cached; + const cached = await AstraLibraryData.getWaveform(trackPath); + if (cached && cached.length > 0) return Float32Array.from(cached); if (options.onPreview) { void getWaveformPreview(trackPath).then((preview) => { @@ -64,9 +61,8 @@ async function decodeAccurateWaveform(trackPath: string, generation: number): Pr const peaks = Float32Array.from(raw); await cacheGate.enqueue(async () => { if (!cacheGate.isCurrent(generation)) return; - const db = await openLibraryDb(); if (!cacheGate.isCurrent(generation)) return; - await putWaveformPeaks(db, trackPath, peaks); + await AstraLibraryData.putWaveform(trackPath, Array.from(peaks)); }).catch(() => { /* cache write failure is non-fatal */ }); @@ -78,8 +74,7 @@ export async function clearAllWaveformCache(): Promise { inflight.clear(); previewInflight.clear(); await cacheGate.invalidate(async () => { - const db = await openLibraryDb(); - await clearWaveformCache(db); + await AstraLibraryData.clearWaveforms(); }); } diff --git a/src/services/desktopSync.ts b/src/services/desktopSync.ts index e4bc1e3..0aeba37 100644 --- a/src/services/desktopSync.ts +++ b/src/services/desktopSync.ts @@ -14,29 +14,7 @@ // DesktopSyncPlaylistConflict for the user to resolve. Timestamp LWW remains // the fallback for pairs without a baseline yet. -import { openLibraryDb } from '@/db/database'; -import { getAllTracks, setSetting } from '@/db/queries'; -import { renamePlaylist } from '@/db/playlistQueries'; -import { - adoptPlaylistSyncUid, - applySyncedFavoriteAdd, - applySyncedFavoriteRemove, - applySyncedPlaylistDelete, - clonePlaylistAsLocalCopy, - deletePlaylistSyncBaseline, - ensurePlaylistSyncUids, - getLocalSyncState, - getPlaylistSyncBaselines, - getSyncPlaylistEntries, - removeFavoriteTombstone, - removePlaylistTombstone, - replaceSyncedPlaylist, - resolvePendingFavorites, - upsertPendingFavorite, - upsertPlaylistSyncBaseline, - type LocalSyncPlaylist, -} from '@/db/desktopSyncQueries'; -import { buildImportIndex, matchSyncEntry } from '@/library/playlistFiles'; +import { AstraLibraryData } from '../../modules/astra-library-scanner'; import { normalizeSyncKeyPart } from '@/shared/sync/identity'; import { syncPlaylistToSnapshot } from '@/shared/sync/conflictPreview'; import { normalizeDynamicPlaylistRules } from '@/shared/playlists/dynamicPlaylist'; @@ -70,6 +48,48 @@ import { ensureDesktopRemoteCredentialsFresh } from './desktopRemoteSession'; const CLOCK_SKEW_WARN_MS = 5 * 60_000; +interface NativeLocalSyncFavorite extends SyncFavorite { + trackPaths: string[]; + pending: boolean; +} + +interface NativeLocalSyncPlaylist extends SyncPlaylist { + id: number; +} + +interface NativeLocalSyncState { + favorites: NativeLocalSyncFavorite[]; + favoriteTombstones: { key: string; deletedAt: number }[]; + playlists: NativeLocalSyncPlaylist[]; + playlistTombstones: { syncUid: string; deletedAt: number }[]; + baselines: { syncUid: string; localUpdatedAt: number; remoteUpdatedAt: number }[]; +} + +interface DesktopSyncMutationPlan { + settings: Record; + favoriteAdds: SyncFavorite[]; + favoriteRemoves: (SyncFavorite & { trackPaths: string[]; deletedAt: number })[]; + favoriteTombstoneRemovals: string[]; + playlistAdoptions: { playlistId: number; syncUid: string }[]; + playlistUpserts: SyncPlaylist[]; + playlistDeletes: { syncUid: string; deletedAt: number }[]; + playlistTombstoneRemovals: string[]; + baselineUpserts: { syncUid: string; localUpdatedAt: number; remoteUpdatedAt: number }[]; + baselineDeletes: string[]; +} + +interface NativeDesktopSyncApplyResult { + favoritesAdded: number; + favoritesPending: number; + favoritesRemoved: number; + playlistResults: { + syncUid: string; + status: 'created' | 'replaced' | 'deleted' | 'skipped-incompatible'; + entriesMatched: number; + entriesFallback: number; + }[]; +} + /** The paired desktop runs a protocol without /v1/sync/* — needs an update. */ export class DesktopSyncUnsupportedError extends Error { constructor() { @@ -214,11 +234,17 @@ async function runDesktopSyncOnce(): Promise<{ throw new DesktopSyncUnsupportedError(); } - const db = await openLibraryDb(); - await ensurePlaylistSyncUids(db); - const index = buildImportIndex(await getAllTracks(db)); - await resolvePendingFavorites(db, index); - const local = await getLocalSyncState(db); + const localState = await AstraLibraryData.getDesktopSyncState(); + const local = { + favorites: new Map(localState.favorites.map((favorite) => [favorite.key, favorite])), + favoriteTombstones: new Map( + localState.favoriteTombstones.map((tombstone) => [tombstone.key, tombstone.deletedAt]) + ), + playlists: localState.playlists, + playlistTombstones: new Map( + localState.playlistTombstones.map((tombstone) => [tombstone.syncUid, tombstone.deletedAt]) + ), + }; const remote = await fetchDesktopSyncState(connection.baseUrl, token, connection.certificateFingerprint); if (remote.syncFormat !== DESKTOP_SYNC_FORMAT) { throw new DesktopSyncUnsupportedError(); @@ -251,7 +277,27 @@ async function runDesktopSyncOnce(): Promise<{ startedAt, finishedAt: startedAt, }; - const baselines = await getPlaylistSyncBaselines(db); + const baselines = new Map( + localState.baselines.map((baseline) => [ + baseline.syncUid, + { + localUpdatedAt: baseline.localUpdatedAt, + remoteUpdatedAt: baseline.remoteUpdatedAt, + }, + ]) + ); + const plan: DesktopSyncMutationPlan = { + settings: {}, + favoriteAdds: [], + favoriteRemoves: [], + favoriteTombstoneRemovals: [], + playlistAdoptions: [], + playlistUpserts: [], + playlistDeletes: [], + playlistTombstoneRemovals: [], + baselineUpserts: [], + baselineDeletes: [], + }; // Baselines are recorded only for playlists that END this run in sync; // push-dependent ones wait for the desktop's per-playlist apply result. const baselinePlans: { uid: string; localUpdatedAt: number; remoteUpdatedAt: number; afterPush: boolean }[] = []; @@ -261,8 +307,7 @@ async function runDesktopSyncOnce(): Promise<{ const remoteByUid = new Map(remote.playlists.map((playlist) => [playlist.syncUid, playlist])); const remoteTombByUid = new Map(remote.playlistTombstones.map((tomb) => [tomb.syncUid, tomb.deletedAt])); - await db.transaction(async (tx) => { - // ── Favorites ──────────────────────────────────────────────────────────── + // ── Favorites ──────────────────────────────────────────────────────────── const favoriteKeys = new Set([ ...local.favorites.keys(), ...local.favoriteTombstones.keys(), @@ -281,25 +326,17 @@ async function runDesktopSyncOnce(): Promise<{ if (present) { if (localTombAt !== null) { - await removeFavoriteTombstone(tx, key); + plan.favoriteTombstoneRemovals.push(key); } if (!localFav || localFav.pending) { - const match = matchSyncEntry( - { title: bestAdd.title, artist: bestAdd.artist, album: bestAdd.album }, - index - ); - if (match.kind === 'matched') { - await applySyncedFavoriteAdd(tx, match.track.path, key, bestAdd.addedAt); - summary.favoritesAdded += 1; - } else if (!localFav || localFav.addedAt < bestAdd.addedAt) { - await upsertPendingFavorite(tx, { + if (!localFav || localFav.addedAt < bestAdd.addedAt || localFav.pending) { + plan.favoriteAdds.push({ key, title: bestAdd.title, artist: bestAdd.artist, album: bestAdd.album, addedAt: bestAdd.addedAt, }); - if (!localFav) summary.favoritesPending += 1; } } if (!remoteFav) { @@ -313,12 +350,27 @@ async function runDesktopSyncOnce(): Promise<{ } } else if (bestDelAt !== null) { if (localFav) { - await applySyncedFavoriteRemove(tx, localFav.trackPaths, key, bestDelAt); - if (!localFav.pending) summary.favoritesRemoved += 1; + plan.favoriteRemoves.push({ + key, + title: localFav.title, + artist: localFav.artist, + album: localFav.album, + addedAt: localFav.addedAt, + trackPaths: localFav.trackPaths, + deletedAt: bestDelAt, + }); } else if (localTombAt === null || localTombAt < bestDelAt) { // Record the peer's tombstone locally so the merge stays // deterministic even if the desktop ever loses its copy. - await applySyncedFavoriteRemove(tx, [], key, bestDelAt); + plan.favoriteRemoves.push({ + key, + title: '', + artist: '', + album: '', + addedAt: 0, + trackPaths: [], + deletedAt: bestDelAt, + }); } if (remoteFav) { payload.favoriteRemoves.push({ key, deletedAt: bestDelAt }); @@ -326,15 +378,15 @@ async function runDesktopSyncOnce(): Promise<{ } } - // ── Playlists ──────────────────────────────────────────────────────────── + // ── Playlists ──────────────────────────────────────────────────────────── const localByUid = new Map(local.playlists.map((playlist) => [playlist.syncUid, playlist])); const skippedConflictUids = new Set(); - const localContentsFor = async (row: LocalSyncPlaylist): Promise => - row.kind === 'normal' ? getSyncPlaylistEntries(tx, row.id) : []; + const localContentsFor = async (row: NativeLocalSyncPlaylist): Promise => + row.kind === 'normal' ? row.entries ?? [] : []; const contentsMatch = async ( - localRow: LocalSyncPlaylist, + localRow: NativeLocalSyncPlaylist, remoteRow: SyncPlaylist, localEntries: SyncPlaylistEntry[] ): Promise => { @@ -347,7 +399,7 @@ async function runDesktopSyncOnce(): Promise<{ const buildConflict = ( kind: DesktopSyncPlaylistConflict['kind'], - localRow: LocalSyncPlaylist, + localRow: NativeLocalSyncPlaylist, remoteRow: SyncPlaylist, localEntries: SyncPlaylistEntry[] ): DesktopSyncPlaylistConflict => { @@ -388,7 +440,7 @@ async function runDesktopSyncOnce(): Promise<{ if (local.playlistTombstones.has(remotePlaylist.syncUid)) continue; const nameKey = normalizeSyncKeyPart(remotePlaylist.name); if (!nameKey) continue; - let paired: LocalSyncPlaylist | null = null; + let paired: NativeLocalSyncPlaylist | null = null; for (const candidate of local.playlists) { if (candidate.syncUid === remotePlaylist.syncUid) continue; if (remoteByUid.has(candidate.syncUid) || remoteTombByUid.has(candidate.syncUid)) continue; @@ -398,7 +450,10 @@ async function runDesktopSyncOnce(): Promise<{ if (!paired) continue; const pairedEntries = await localContentsFor(paired); if (await contentsMatch(paired, remotePlaylist, pairedEntries)) { - await adoptPlaylistSyncUid(tx, paired.id, remotePlaylist.syncUid); + plan.playlistAdoptions.push({ + playlistId: paired.id, + syncUid: remotePlaylist.syncUid, + }); localByUid.delete(paired.syncUid); paired.syncUid = remotePlaylist.syncUid; localByUid.set(remotePlaylist.syncUid, paired); @@ -428,21 +483,20 @@ async function runDesktopSyncOnce(): Promise<{ // Deletion wins only when strictly newer than the newest edit. if (bestTombAt !== null && (bestRowAt === null || bestTombAt > bestRowAt)) { if (localRow) { - await applySyncedPlaylistDelete(tx, uid, bestTombAt); - summary.playlistsDeleted += 1; + plan.playlistDeletes.push({ syncUid: uid, deletedAt: bestTombAt }); } else if (localTombAt === null || localTombAt < bestTombAt) { - await applySyncedPlaylistDelete(tx, uid, bestTombAt); + plan.playlistDeletes.push({ syncUid: uid, deletedAt: bestTombAt }); } - await deletePlaylistSyncBaseline(tx, uid); + plan.baselineDeletes.push(uid); if (remoteRow) { payload.playlistDeletes.push({ syncUid: uid, deletedAt: bestTombAt }); } continue; } - const pushLocal = async (row: LocalSyncPlaylist) => { + const pushLocal = async (row: NativeLocalSyncPlaylist) => { if (localTombAt !== null) { - await removePlaylistTombstone(tx, uid); + plan.playlistTombstoneRemovals.push(uid); } payload.playlistUpserts.push({ syncUid: uid, @@ -451,7 +505,7 @@ async function runDesktopSyncOnce(): Promise<{ dynamicRules: row.dynamicRules, createdAt: row.createdAt, updatedAt: row.updatedAt, - entries: row.kind === 'normal' ? await getSyncPlaylistEntries(tx, row.id) : null, + entries: row.kind === 'normal' ? row.entries ?? [] : null, } satisfies SyncPlaylist); baselinePlans.push({ uid, @@ -462,19 +516,13 @@ async function runDesktopSyncOnce(): Promise<{ }; const applyRemote = async (row: SyncPlaylist) => { - const result = await replaceSyncedPlaylist(tx, row, index); - if (result.status === 'created') summary.playlistsCreated += 1; - else if (result.status === 'replaced') summary.playlistsReplaced += 1; - else summary.playlistsSkipped += 1; - summary.entriesFallback += result.entriesFallback; - if (result.status !== 'skipped-incompatible') { - baselinePlans.push({ - uid, - localUpdatedAt: row.updatedAt, - remoteUpdatedAt: row.updatedAt, - afterPush: false, - }); - } + plan.playlistUpserts.push(row); + baselinePlans.push({ + uid, + localUpdatedAt: row.updatedAt, + remoteUpdatedAt: row.updatedAt, + afterPush: false, + }); }; // With a baseline, sync direction comes from WHICH side changed since @@ -534,23 +582,25 @@ async function runDesktopSyncOnce(): Promise<{ }); } } - }); - // Baselines that don't depend on the push are valid as soon as the local - // transaction committed. - for (const plan of baselinePlans) { - if (plan.afterPush) continue; - const existing = baselines.get(plan.uid); + for (const baselinePlan of baselinePlans) { + if (baselinePlan.afterPush) continue; + const existing = baselines.get(baselinePlan.uid); if ( existing && - existing.localUpdatedAt === plan.localUpdatedAt && - existing.remoteUpdatedAt === plan.remoteUpdatedAt + existing.localUpdatedAt === baselinePlan.localUpdatedAt && + existing.remoteUpdatedAt === baselinePlan.remoteUpdatedAt ) { continue; } - await upsertPlaylistSyncBaseline(db, plan.uid, plan.localUpdatedAt, plan.remoteUpdatedAt); + plan.baselineUpserts.push({ + syncUid: baselinePlan.uid, + localUpdatedAt: baselinePlan.localUpdatedAt, + remoteUpdatedAt: baselinePlan.remoteUpdatedAt, + }); } + let pushStatusByUid = new Map(); const hasDiff = payload.favoriteAdds.length > 0 || payload.favoriteRemoves.length > 0 || @@ -567,7 +617,7 @@ async function runDesktopSyncOnce(): Promise<{ summary.favoritesAdded += result.favorites.added; summary.favoritesPending += result.favorites.pending; summary.favoritesRemoved += result.favorites.removed; - const pushStatusByUid = new Map(result.playlists.map((entry) => [entry.syncUid, entry.status])); + pushStatusByUid = new Map(result.playlists.map((entry) => [entry.syncUid, entry.status])); for (const playlistResult of result.playlists) { if (playlistResult.status === 'created') summary.playlistsCreated += 1; else if (playlistResult.status === 'replaced') summary.playlistsReplaced += 1; @@ -577,15 +627,32 @@ async function runDesktopSyncOnce(): Promise<{ } // Push-dependent baselines only count once the desktop confirmed the // upsert; a failed/skipped push re-syncs naturally next run. - for (const plan of baselinePlans) { - if (!plan.afterPush) continue; - const status = pushStatusByUid.get(plan.uid); + for (const baselinePlan of baselinePlans) { + if (!baselinePlan.afterPush) continue; + const status = pushStatusByUid.get(baselinePlan.uid); if (status !== 'created' && status !== 'replaced') continue; - await upsertPlaylistSyncBaseline(db, plan.uid, plan.localUpdatedAt, plan.remoteUpdatedAt); + plan.baselineUpserts.push({ + syncUid: baselinePlan.uid, + localUpdatedAt: baselinePlan.localUpdatedAt, + remoteUpdatedAt: baselinePlan.remoteUpdatedAt, + }); } } - await setSetting(db, desktopSyncSettingKey(connection), String(Date.now())); + plan.settings[desktopSyncSettingKey(connection)] = String(Date.now()); + const applied = await AstraLibraryData.applyDesktopSyncPlan( + plan as unknown as Record + ); + summary.favoritesAdded += applied.favoritesAdded; + summary.favoritesPending += applied.favoritesPending; + summary.favoritesRemoved += applied.favoritesRemoved; + for (const playlistResult of applied.playlistResults) { + if (playlistResult.status === 'created') summary.playlistsCreated += 1; + else if (playlistResult.status === 'replaced') summary.playlistsReplaced += 1; + else if (playlistResult.status === 'deleted') summary.playlistsDeleted += 1; + else summary.playlistsSkipped += 1; + summary.entriesFallback += playlistResult.entriesFallback; + } await usePlaylistStore.getState().refresh(); summary.finishedAt = Date.now(); @@ -603,82 +670,32 @@ export async function applyDesktopSyncConflictResolution( conflict: DesktopSyncPlaylistConflict, resolution: DesktopSyncConflictResolution ): Promise { - const db = await openLibraryDb(); - const currentRow = await db.get<{ updated_at: number; name: string }>( - 'SELECT updated_at, name FROM playlists WHERE id = ?', - [conflict.localPlaylistId] - ); - if (!currentRow) { - // The local copy vanished since detection — nothing to choose between; - // the next sync settles whatever remains. - return; - } - - switch (resolution) { - case 'desktop': { - if (conflict.kind === 'first-pairing') { - await adoptPlaylistSyncUid(db, conflict.localPlaylistId, conflict.syncUid); - } - // Local reads as unchanged, remote as changed → next run pulls desktop. - await upsertPlaylistSyncBaseline(db, conflict.syncUid, currentRow.updated_at, 0); - break; + let mergedPlaylist: SyncPlaylist | null = null; + if (resolution === 'merge') { + if (conflict.playlistKind !== 'normal') { + throw new Error('Dynamic playlists cannot be merged — keep one side instead.'); } - case 'phone': { - if (conflict.kind === 'first-pairing') { - await adoptPlaylistSyncUid(db, conflict.localPlaylistId, conflict.syncUid); - } - // Remote reads as unchanged (as of detection), local as changed → next - // run pushes the phone copy. - await upsertPlaylistSyncBaseline(db, conflict.syncUid, 0, conflict.remoteUpdatedAt); - break; - } - case 'both': { - const copyName = `${currentRow.name} (Phone)`; - if (conflict.kind === 'first-pairing') { - // Rename the local copy out of the collision; both lists then sync as - // independent playlists. - await renamePlaylist(db, conflict.localPlaylistId, copyName); - } else { - // Duplicate the local version under a fresh identity, then let the - // shared uid take the desktop version. - await clonePlaylistAsLocalCopy(db, conflict.localPlaylistId, copyName); - await upsertPlaylistSyncBaseline(db, conflict.syncUid, currentRow.updated_at, 0); - } - break; - } - case 'merge': { - if (conflict.playlistKind !== 'normal') { - throw new Error('Dynamic playlists cannot be merged — keep one side instead.'); - } - const localEntries = await getSyncPlaylistEntries(db, conflict.localPlaylistId); - const remoteEntries = conflict.remote.entries ?? []; - const localIsNewer = conflict.localUpdatedAt >= conflict.remoteUpdatedAt; - const merged = mergePlaylistEntries( + const localEntries = conflict.local.entries ?? []; + const remoteEntries = conflict.remote.entries ?? []; + const localIsNewer = conflict.localUpdatedAt >= conflict.remoteUpdatedAt; + mergedPlaylist = { + syncUid: conflict.syncUid, + name: localIsNewer ? conflict.local.name : conflict.remote.name, + kind: 'normal', + dynamicRules: null, + createdAt: conflict.remote.createdAt, + updatedAt: Date.now(), + entries: mergePlaylistEntries( localIsNewer ? localEntries : remoteEntries, localIsNewer ? remoteEntries : localEntries - ); - if (conflict.kind === 'first-pairing') { - await adoptPlaylistSyncUid(db, conflict.localPlaylistId, conflict.syncUid); - } - const index = buildImportIndex(await getAllTracks(db)); - await replaceSyncedPlaylist( - db, - { - syncUid: conflict.syncUid, - name: localIsNewer ? currentRow.name : conflict.remote.name, - kind: 'normal', - dynamicRules: null, - createdAt: conflict.remote.createdAt, - updatedAt: Date.now(), - entries: merged, - }, - index - ); - // The merged list is a fresh local edit → next run pushes it. - await upsertPlaylistSyncBaseline(db, conflict.syncUid, 0, conflict.remoteUpdatedAt); - break; - } + ), + }; } + await AstraLibraryData.resolveDesktopSyncConflict( + conflict as unknown as Record, + resolution, + mergedPlaylist as unknown as Record | null + ); await usePlaylistStore.getState().refresh(); } diff --git a/src/services/jellyfin.ts b/src/services/jellyfin.ts index 9b58dc3..86d2013 100644 --- a/src/services/jellyfin.ts +++ b/src/services/jellyfin.ts @@ -34,6 +34,10 @@ export interface JellyfinRequestOptions { export interface JellyfinCatalogSyncOptions extends JellyfinRequestOptions { authContext?: JellyfinAuthContext; onProgress?: (progress: RemoteSyncProgress) => void; + /** Awaited once per server page so callers can stream directly to native storage. */ + onTracksBatch?: (tracks: RemoteCatalogTrack[]) => Promise; + /** Defaults to true for compatibility; sync orchestration disables collection. */ + collectTracks?: boolean; } export interface JellyfinCatalogSyncResult { @@ -454,7 +458,11 @@ export async function syncJellyfinCatalog( options: JellyfinCatalogSyncOptions = {} ): Promise { const authContext = options.authContext ?? (await authenticateJellyfin(config, options)); - const byTrackId = new Map(); + const byTrackId = options.collectTracks === false + ? null + : new Map(); + const seenTrackIds = new Set(); + let tracksScanned = 0; let startIndex = 0; let totalRecordCount: number | null = null; @@ -482,11 +490,15 @@ export async function syncJellyfinCatalog( } const items = asArray(response.Items); + const trackBatch: RemoteCatalogTrack[] = []; for (const item of items) { const mapped = mapJellyfinItemToCatalogTrack(sourceId, item); - if (!mapped) continue; - byTrackId.set(mapped.source_track_id, mapped); + if (!mapped || !seenTrackIds.add(mapped.source_track_id)) continue; + byTrackId?.set(mapped.source_track_id, mapped); + trackBatch.push(mapped); + tracksScanned += 1; } + if (trackBatch.length > 0) await options.onTracksBatch?.(trackBatch); options.onProgress?.({ phase: 'items', @@ -501,10 +513,10 @@ export async function syncJellyfinCatalog( if (items.length < DEFAULT_PAGE_SIZE) break; } - const tracks = Array.from(byTrackId.values()); + const tracks = byTrackId ? Array.from(byTrackId.values()) : []; return { - itemsScanned: totalRecordCount ?? tracks.length, - tracksScanned: tracks.length, + itemsScanned: totalRecordCount ?? tracksScanned, + tracksScanned, tracks, }; } diff --git a/src/services/lastfm/config.ts b/src/services/lastfm/config.ts index ec8f3da..8c0c149 100644 --- a/src/services/lastfm/config.ts +++ b/src/services/lastfm/config.ts @@ -6,8 +6,7 @@ // On load we re-attach the session keys before handing the config to the service, // so the ported service code (which reads `profile.sessionKey`) is unchanged. -import { openLibraryDb } from '@/db/database'; -import { getSetting, setSetting } from '@/db/queries'; +import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings'; import type { LastFmServiceConfig } from '@/types/lastFm'; import { deleteLastFmSessionKey, @@ -32,8 +31,7 @@ function parseStringArray(value: string | null): string[] { /** Load the persisted config (with session keys re-attached), or null if none. */ export async function loadLastFmConfig(): Promise { - const db = await openLibraryDb(); - const json = await getSetting(db, CONFIG_KEY); + const json = await getNativeSetting(CONFIG_KEY); if (!json) return null; let parsed: LastFmServiceConfig; @@ -57,9 +55,7 @@ export async function loadLastFmConfig(): Promise { /** Persist the config: secrets to secure-store, everything else to the settings KV. */ export async function persistLastFmConfig(config: LastFmServiceConfig): Promise { - const db = await openLibraryDb(); - - const previousSecretIds = parseStringArray(await getSetting(db, SECRET_IDS_KEY)); + const previousSecretIds = parseStringArray(await getNativeSetting(SECRET_IDS_KEY)); const currentSecretIds: string[] = []; for (const profile of config.profiles) { @@ -76,7 +72,7 @@ export async function persistLastFmConfig(config: LastFmServiceConfig): Promise< await deleteLastFmSessionKey(id); } } - await setSetting(db, SECRET_IDS_KEY, JSON.stringify(currentSecretIds)); + await setNativeSetting(SECRET_IDS_KEY, JSON.stringify(currentSecretIds)); const sanitized: LastFmServiceConfig = { enabled: config.enabled, @@ -87,5 +83,5 @@ export async function persistLastFmConfig(config: LastFmServiceConfig): Promise< pendingScrobbles: profile.pendingScrobbles.map((item) => ({ ...item })), })), }; - await setSetting(db, CONFIG_KEY, JSON.stringify(sanitized)); + await setNativeSetting(CONFIG_KEY, JSON.stringify(sanitized)); } diff --git a/src/services/lastfm/credentials.ts b/src/services/lastfm/credentials.ts index bfe377c..228988a 100644 --- a/src/services/lastfm/credentials.ts +++ b/src/services/lastfm/credentials.ts @@ -9,7 +9,7 @@ import * as SecureStore from 'expo-secure-store'; function secretKey(profileId: string): string { // SecureStore keys must be alphanumeric + ".-_" — sanitize the profile id. const safe = profileId.replace(/[^a-zA-Z0-9._-]/g, '_'); - return `lastfm_session_${safe}`; + return `astra_room_v1_lastfm_session_${safe}`; } export async function getLastFmSessionKey(profileId: string): Promise { diff --git a/src/services/remoteCredentials.ts b/src/services/remoteCredentials.ts index aedf2ff..e7dac64 100644 --- a/src/services/remoteCredentials.ts +++ b/src/services/remoteCredentials.ts @@ -4,19 +4,68 @@ import * as SecureStore from 'expo-secure-store'; -function secretKey(sourceId: number): string { +function secretKey(sourceId: number, field = 'password'): string { // SecureStore keys must be alphanumeric + ".-_" — this satisfies that. - return `remote_secret_${sourceId}`; + return `astra_room_v1_remote_${field}_${sourceId}`; } export async function getRemoteSecret(sourceId: number): Promise { - return SecureStore.getItemAsync(secretKey(sourceId)); + return SecureStore.getItemAsync(secretKey(sourceId, 'password')); } export async function setRemoteSecret(sourceId: number, password: string): Promise { - await SecureStore.setItemAsync(secretKey(sourceId), password); + await SecureStore.setItemAsync(secretKey(sourceId, 'password'), password); } export async function deleteRemoteSecret(sourceId: number): Promise { - await SecureStore.deleteItemAsync(secretKey(sourceId)); + await Promise.all( + ['password', 'access_token', 'user_id', 'device_id', 'art_auth'].map((field) => + SecureStore.deleteItemAsync(secretKey(sourceId, field)) + ) + ); +} + +export interface RemoteSecureAuth { + accessToken: string | null; + userId: string | null; + deviceId: string | null; + artAuth: string | null; +} + +export async function getRemoteSecureAuth(sourceId: number): Promise { + const [accessToken, userId, deviceId, artAuth] = await Promise.all( + ['access_token', 'user_id', 'device_id', 'art_auth'].map((field) => + SecureStore.getItemAsync(secretKey(sourceId, field)) + ) + ); + return { accessToken, userId, deviceId, artAuth }; +} + +async function setOptionalSecret( + sourceId: number, + field: string, + value: string | null, +): Promise { + const key = secretKey(sourceId, field); + if (value == null) await SecureStore.deleteItemAsync(key); + else await SecureStore.setItemAsync(key, value); +} + +export async function setRemoteSecureAuth( + sourceId: number, + auth: { + accessToken: string | null; + userId: string | null; + deviceId: string | null; + }, +): Promise { + await Promise.all([ + setOptionalSecret(sourceId, 'access_token', auth.accessToken), + setOptionalSecret(sourceId, 'user_id', auth.userId), + setOptionalSecret(sourceId, 'device_id', auth.deviceId), + ]); +} + +export async function setRemoteArtAuth(sourceId: number, value: string | null): Promise { + await setOptionalSecret(sourceId, 'art_auth', value); } diff --git a/src/services/subsonic.ts b/src/services/subsonic.ts index 0e546f6..2849316 100644 --- a/src/services/subsonic.ts +++ b/src/services/subsonic.ts @@ -26,6 +26,10 @@ export interface SubsonicRequestOptions { export interface SubsonicCatalogSyncOptions extends SubsonicRequestOptions { onProgress?: (progress: RemoteSyncProgress) => void; + /** Awaited after each bounded batch so callers can stream directly to native storage. */ + onTracksBatch?: (tracks: RemoteCatalogTrack[]) => Promise; + /** Defaults to true for compatibility; sync orchestration disables collection. */ + collectTracks?: boolean; } export interface SubsonicCatalogSyncResult { @@ -419,13 +423,22 @@ export async function syncSubsonicCatalog( options.onProgress?.({ phase: 'albums', current: 0, total: uniqueAlbumIds.length, detail: null }); let albumsProcessed = 0; - const albumSongLists = await runWithConcurrency( - uniqueAlbumIds, - MAX_SYNC_CONCURRENCY, - async (albumId): Promise => { - const albumResponse = await requestSubsonicJson(config, 'getAlbum', { id: albumId }, options); - const albumContainer = albumResponse.album as Record | undefined; - if (!albumContainer || typeof albumContainer !== 'object') { + const byTrackId = options.collectTracks === false + ? null + : new Map(); + const seenTrackIds = new Set(); + let tracksScanned = 0; + options.onProgress?.({ phase: 'tracks', current: 0, total: uniqueAlbumIds.length, detail: null }); + let trackAlbumProcessed = 0; + const albumBatchSize = MAX_SYNC_CONCURRENCY * 6; + for (let start = 0; start < uniqueAlbumIds.length; start += albumBatchSize) { + const albumIds = uniqueAlbumIds.slice(start, start + albumBatchSize); + const albumSongLists = await runWithConcurrency( + albumIds, + MAX_SYNC_CONCURRENCY, + async (albumId): Promise => { + const albumResponse = await requestSubsonicJson(config, 'getAlbum', { id: albumId }, options); + const albumContainer = albumResponse.album as Record | undefined; albumsProcessed += 1; options.onProgress?.({ phase: 'albums', @@ -433,45 +446,40 @@ export async function syncSubsonicCatalog( total: uniqueAlbumIds.length, detail: albumId, }); - return { coverArtId: null, songs: [] }; + if (!albumContainer || typeof albumContainer !== 'object') { + return { coverArtId: null, songs: [] }; + } + return { + coverArtId: toTrimmedText(albumContainer.coverArt), + songs: asArray(albumContainer.song), + }; } - albumsProcessed += 1; + ); + const trackBatch: RemoteCatalogTrack[] = []; + for (const albumSongs of albumSongLists) { + for (const song of albumSongs.songs) { + const mapped = mapSongToCatalogTrack(sourceId, song, albumSongs.coverArtId); + if (!mapped || !seenTrackIds.add(mapped.source_track_id)) continue; + trackBatch.push(mapped); + byTrackId?.set(mapped.source_track_id, mapped); + tracksScanned += 1; + } + trackAlbumProcessed += 1; options.onProgress?.({ - phase: 'albums', - current: albumsProcessed, + phase: 'tracks', + current: trackAlbumProcessed, total: uniqueAlbumIds.length, - detail: albumId, + detail: null, }); - return { - coverArtId: toTrimmedText(albumContainer.coverArt), - songs: asArray(albumContainer.song), - }; } - ); - - const byTrackId = new Map(); - options.onProgress?.({ phase: 'tracks', current: 0, total: albumSongLists.length, detail: null }); - let trackAlbumProcessed = 0; - for (const albumSongs of albumSongLists) { - for (const song of albumSongs.songs) { - const mapped = mapSongToCatalogTrack(sourceId, song, albumSongs.coverArtId); - if (!mapped) continue; - byTrackId.set(mapped.source_track_id, mapped); - } - trackAlbumProcessed += 1; - options.onProgress?.({ - phase: 'tracks', - current: trackAlbumProcessed, - total: albumSongLists.length, - detail: null, - }); + if (trackBatch.length > 0) await options.onTracksBatch?.(trackBatch); } - const tracks = Array.from(byTrackId.values()); + const tracks = byTrackId ? Array.from(byTrackId.values()) : []; return { artistsScanned: artistRefs.length, albumsScanned: uniqueAlbumIds.length, - tracksScanned: tracks.length, + tracksScanned, tracks, }; } diff --git a/src/session/SessionLifecycle.tsx b/src/session/SessionLifecycle.tsx index 07eef68..f3cb8ff 100644 --- a/src/session/SessionLifecycle.tsx +++ b/src/session/SessionLifecycle.tsx @@ -13,12 +13,14 @@ import { useSettingsStore } from '@/stores/settingsStore'; import { usePlayerUiStore } from '@/stores/playerUiStore'; import { useSearchStore } from '@/stores/searchStore'; import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore'; -import { buildArtistDetail } from '@/library/artistDetail'; import { dbTrackToTrack } from '@/library/trackAdapter'; import { hasActiveNativePlaybackSession, restorePlaybackSession, + restoreVirtualPlaybackContext, } from '@/audio/playbackController'; +import { AstraLibraryData } from '../../modules/astra-library-scanner'; +import type { DbTrack } from '@/types/library'; import { installMobileSessionPersistence, readPersistedMobileSession, @@ -30,23 +32,51 @@ import { resolvePlaybackSession, shouldRestoreSavedRoute, stableHrefForRoute, - validateRestoredHref, } from './sessionState'; interface SessionLifecycleProps { onReady: () => void; } -function validateSavedHref(href: string): string { - const tracks = useLibraryStore.getState().tracks; - return validateRestoredHref(href, { - hasAlbum: (identityKey) => tracks.some((track) => track.album_identity_key === identityKey), - hasArtist: (name, credit) => { - const groupingMode = credit ? 'astra' : useSettingsStore.getState().artistGroupingMode; - return buildArtistDetail(tracks, name, groupingMode).tracks.length > 0; - }, - hasPlaylist: (id) => usePlaylistStore.getState().playlists.some((playlist) => playlist.id === id), - }); +async function validateSavedHref(href: string): Promise { + const normalized = normalizeStableHref(href) ?? '/'; + const [pathname, query = ''] = normalized.split('?', 2); + const albumMatch = pathname.match(/^\/library\/album\/([^/]+)$/); + if (albumMatch) { + const key = decodeURIComponent(albumMatch[1]); + const result = await AstraLibraryData.getAlbumDetail>( + key, + null, + 1 + ); + return result.summary ? normalized : '/library'; + } + const artistMatch = pathname.match( + /^\/library\/artist\/([^/]+)(?:\/(?:albums|songs|appearances))?$/ + ); + if (artistMatch) { + const name = decodeURIComponent(artistMatch[1]); + const groupingMode = new URLSearchParams(query).get('credit') === '1' + ? 'astra' + : useSettingsStore.getState().artistGroupingMode; + const result = await AstraLibraryData.getArtistDetail>( + name, + groupingMode, + 'all', + null, + 1 + ); + return result.summary ? normalized : '/library'; + } + const playlistMatch = pathname.match(/^\/library\/playlist\/(favorites|\d+)$/); + if ( + playlistMatch && + playlistMatch[1] !== 'favorites' && + !usePlaylistStore.getState().playlists.some((playlist) => playlist.id === Number(playlistMatch[1])) + ) { + return '/library'; + } + return normalized; } /** Restores once, then owns stable-route tracking and session autosave. */ @@ -100,19 +130,24 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) { const liveNativeSession = await hasActiveNativePlaybackSession(); if (!cancelled && snapshot?.playback && !liveNativeSession) { - const resolved = resolvePlaybackSession( - snapshot.playback, - useLibraryStore.getState().tracks - ); - restorePlaybackSession( - resolved - ? { ...resolved, tracks: resolved.tracks.map(dbTrackToTrack) } - : null - ); + const nativeContext = await AstraLibraryData.restorePlaybackContext(); + if (nativeContext) { + restoreVirtualPlaybackContext(nativeContext, snapshot.playback); + } else { + const resolved = resolvePlaybackSession( + snapshot.playback, + useLibraryStore.getState().tracks + ); + restorePlaybackSession( + resolved + ? { ...resolved, tracks: resolved.tracks.map(dbTrackToTrack) } + : null + ); + } } if (cancelled) return; - const stableHref = validateSavedHref(snapshot?.lastStableHref ?? '/'); + const stableHref = await validateSavedHref(snapshot?.lastStableHref ?? '/'); setInitialStableHref(stableHref); if (shouldRestoreSavedRoute(initialPathname.current, initialUrl) && stableHref !== '/') { router.replace(stableHref as never); diff --git a/src/session/sessionPersistence.ts b/src/session/sessionPersistence.ts index d1fa379..d7e084e 100644 --- a/src/session/sessionPersistence.ts +++ b/src/session/sessionPersistence.ts @@ -1,6 +1,5 @@ import { AppState } from 'react-native'; -import { openLibraryDb } from '@/db/database'; -import { getSetting, setSetting } from '@/db/queries'; +import { AstraLibraryData } from '../../modules/astra-library-scanner'; import { getPlaybackSessionSnapshot } from '@/audio/playbackController'; import { usePlayerStore } from '@/stores/playerStore'; import { useQueueStore } from '@/stores/queueStore'; @@ -14,7 +13,6 @@ import { type PlaybackSessionSnapshotV1, } from './sessionState'; -const MOBILE_SESSION_SETTING_KEY = 'mobile_session_state_v1'; const STRUCTURAL_SAVE_DEBOUNCE_MS = 250; const POSITION_SAVE_THROTTLE_MS = 2000; @@ -23,13 +21,11 @@ let scheduleStructuralSave: (() => void) | null = null; let writeChain: Promise = Promise.resolve(); export async function readPersistedMobileSession(): Promise { - const db = await openLibraryDb(); - return parseMobileSessionSnapshot(await getSetting(db, MOBILE_SESSION_SETTING_KEY)); + return parseMobileSessionSnapshot(await AstraLibraryData.readMobileSession()); } async function writePersistedMobileSession(snapshot: MobileSessionSnapshotV1): Promise { - const db = await openLibraryDb(); - await setSetting(db, MOBILE_SESSION_SETTING_KEY, stringifyMobileSessionSnapshot(snapshot)); + await AstraLibraryData.writeMobileSession(stringifyMobileSessionSnapshot(snapshot)); } function enqueueSnapshotWrite(snapshot: MobileSessionSnapshotV1): Promise { @@ -153,7 +149,10 @@ export function installMobileSessionPersistence( } }); const appStateSubscription = AppState.addEventListener('change', (state) => { - if (state === 'inactive' || state === 'background') saveNow(); + if (state === 'inactive' || state === 'background') { + saveNow(); + void AstraLibraryData.flushUserSnapshot().catch(() => {}); + } }); // Persist route validation and queue normalization from hydration. The diff --git a/src/stores/audioSettingsStore.ts b/src/stores/audioSettingsStore.ts index 4016c32..567710d 100644 --- a/src/stores/audioSettingsStore.ts +++ b/src/stores/audioSettingsStore.ts @@ -1,6 +1,5 @@ import { create } from 'zustand'; -import { openLibraryDb } from '@/db/database'; -import { getSetting, setSetting } from '@/db/queries'; +import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings'; import { DEFAULT_TARGET_LUFS, type NormalizationSettings, @@ -46,12 +45,11 @@ export const useAudioSettingsStore = create((set, get) => ({ load: async () => { if (get().loaded) return; - const db = await openLibraryDb(); const [enabled, target, rgEnabled, rgMode] = await Promise.all([ - getSetting(db, NORMALIZATION_ENABLED_KEY), - getSetting(db, NORMALIZATION_TARGET_KEY), - getSetting(db, REPLAYGAIN_ENABLED_KEY), - getSetting(db, REPLAYGAIN_MODE_KEY), + getNativeSetting(NORMALIZATION_ENABLED_KEY), + getNativeSetting(NORMALIZATION_TARGET_KEY), + getNativeSetting(REPLAYGAIN_ENABLED_KEY), + getNativeSetting(REPLAYGAIN_MODE_KEY), ]); const targetNum = Number(target); set({ @@ -67,30 +65,26 @@ export const useAudioSettingsStore = create((set, get) => ({ setNormalizationEnabled: async (enabled) => { if (get().normalizationEnabled === enabled) return; set({ normalizationEnabled: enabled }); - const db = await openLibraryDb(); - await setSetting(db, NORMALIZATION_ENABLED_KEY, enabled ? 'true' : 'false'); + await setNativeSetting(NORMALIZATION_ENABLED_KEY, enabled ? 'true' : 'false'); }, setNormalizationTargetLufs: async (lufs) => { const clamped = Math.max(-30, Math.min(-5, lufs)); if (get().normalizationTargetLufs === clamped) return; set({ normalizationTargetLufs: clamped }); - const db = await openLibraryDb(); - await setSetting(db, NORMALIZATION_TARGET_KEY, String(clamped)); + await setNativeSetting(NORMALIZATION_TARGET_KEY, String(clamped)); }, setReplayGainEnabled: async (enabled) => { if (get().replayGainEnabled === enabled) return; set({ replayGainEnabled: enabled }); - const db = await openLibraryDb(); - await setSetting(db, REPLAYGAIN_ENABLED_KEY, enabled ? 'true' : 'false'); + await setNativeSetting(REPLAYGAIN_ENABLED_KEY, enabled ? 'true' : 'false'); }, setReplayGainMode: async (mode) => { if (get().replayGainMode === mode) return; set({ replayGainMode: mode }); - const db = await openLibraryDb(); - await setSetting(db, REPLAYGAIN_MODE_KEY, mode); + await setNativeSetting(REPLAYGAIN_MODE_KEY, mode); }, asNormalizationSettings: () => { diff --git a/src/stores/desktopRemoteStore.ts b/src/stores/desktopRemoteStore.ts index 6f0ed32..409dd15 100644 --- a/src/stores/desktopRemoteStore.ts +++ b/src/stores/desktopRemoteStore.ts @@ -28,8 +28,7 @@ import { setDesktopRemoteConnection, setDesktopRemoteCredentials, } from '@/services/desktopRemoteCredentials'; -import { openLibraryDb } from '@/db/database'; -import { clearPlaylistSyncBaselines } from '@/db/desktopSyncQueries'; +import { AstraLibraryData } from '../../modules/astra-library-scanner'; import { useDesktopSyncStore } from '@/stores/desktopSyncStore'; import { identityMatchesPinnedConnection } from '@/services/desktopSyncPolicy'; import { ensureDesktopRemoteCredentialsFresh } from '@/services/desktopRemoteSession'; @@ -718,9 +717,7 @@ export const useDesktopRemoteStore = create((set, get) => { clearPairingPoll(); await clearDesktopRemotePairing(); // Sync baselines are meaningless against a different desktop. - void openLibraryDb() - .then((db) => clearPlaylistSyncBaselines(db)) - .catch(() => {}); + void AstraLibraryData.clearDesktopSyncBaselines().catch(() => {}); set({ connectionState: 'unpaired', connection: null, diff --git a/src/stores/desktopSyncStore.ts b/src/stores/desktopSyncStore.ts index 81313d9..6aadfed 100644 --- a/src/stores/desktopSyncStore.ts +++ b/src/stores/desktopSyncStore.ts @@ -5,8 +5,7 @@ import { AppState } from 'react-native'; import { create } from 'zustand'; -import { openLibraryDb } from '@/db/database'; -import { getSetting, setSetting } from '@/db/queries'; +import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings'; import { DesktopSyncUnsupportedError, applyDesktopSyncConflictResolution, @@ -80,17 +79,16 @@ export const useDesktopSyncStore = create((set, get) => ({ hydrate: async () => { try { - const db = await openLibraryDb(); - const masterSetting = await getSetting(db, DESKTOP_SYNC_ENABLED_SETTING_KEY); - const legacySetting = await getSetting(db, LEGACY_AUTO_SYNC_SETTING_KEY); + const masterSetting = await getNativeSetting(DESKTOP_SYNC_ENABLED_SETTING_KEY); + const legacySetting = await getNativeSetting(LEGACY_AUTO_SYNC_SETTING_KEY); const enabled = decideDesktopSyncEnabled(masterSetting, legacySetting); set({ desktopSyncEnabled: enabled }); if (masterSetting === null) { - await setSetting(db, DESKTOP_SYNC_ENABLED_SETTING_KEY, enabled ? '1' : '0'); + await setNativeSetting(DESKTOP_SYNC_ENABLED_SETTING_KEY, enabled ? '1' : '0'); } const connection = await getDesktopRemoteConnection(); if (connection) { - const stored = await getSetting(db, desktopSyncSettingKey(connection)); + const stored = await getNativeSetting(desktopSyncSettingKey(connection)); const lastSyncAt = stored ? Number(stored) : NaN; if (Number.isFinite(lastSyncAt) && lastSyncAt > 0) { set({ lastSyncAt }); @@ -110,8 +108,7 @@ export const useDesktopSyncStore = create((set, get) => ({ } set({ desktopSyncEnabled: enabled }); try { - const db = await openLibraryDb(); - await setSetting(db, DESKTOP_SYNC_ENABLED_SETTING_KEY, enabled ? '1' : '0'); + await setNativeSetting(DESKTOP_SYNC_ENABLED_SETTING_KEY, enabled ? '1' : '0'); } catch { // The in-memory value still applies for this session. } diff --git a/src/stores/eqStore.ts b/src/stores/eqStore.ts index 29138b0..14df51f 100644 --- a/src/stores/eqStore.ts +++ b/src/stores/eqStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import type { AudioOutputRoute, EQBand, EQMode, EQPreset } from '@/types/audio'; -import { openLibraryDb } from '@/db/database'; -import { getSetting, setSetting } from '@/db/queries'; +import { AstraLibraryData } from '../../modules/astra-library-scanner'; +import { getNativeSettings, setNativeSetting } from '@/db/nativeSettings'; import { EQ_MAX_BANDS, clampEQFrequency, @@ -188,19 +188,15 @@ export const useEQStore = create((set, get) => { assignments: devicePresetAssignments, }; try { - const db = await openLibraryDb(); - await Promise.all([ - setSetting(db, ENABLED_KEY, enabled ? 'true' : 'false'), - setSetting(db, PREAMP_KEY, String(preamp)), - setSetting(db, BANDS_KEY, JSON.stringify(bands)), - setSetting(db, MODE_KEY, mode), - setSetting(db, GRAPHIC_GAINS_KEY, JSON.stringify(graphicGains)), - setSetting(db, ACTIVE_PRESET_KEY, activePresetId ?? ''), - setSetting(db, DEVICE_PRESETS_KEY, stringifyEQDevicePresetState(devicePresetState)), - setSetting( - db, - CUSTOM_PRESETS_KEY, - JSON.stringify( + await AstraLibraryData.setSettings({ + [ENABLED_KEY]: enabled ? 'true' : 'false', + [PREAMP_KEY]: String(preamp), + [BANDS_KEY]: JSON.stringify(bands), + [MODE_KEY]: mode, + [GRAPHIC_GAINS_KEY]: JSON.stringify(graphicGains), + [ACTIVE_PRESET_KEY]: activePresetId ?? '', + [DEVICE_PRESETS_KEY]: stringifyEQDevicePresetState(devicePresetState), + [CUSTOM_PRESETS_KEY]: JSON.stringify( custom.map((p) => ({ id: p.id, name: p.name, @@ -208,9 +204,8 @@ export const useEQStore = create((set, get) => { bands: p.bands, ...(p.mode === 'graphic' ? { mode: p.mode, graphicGains: p.graphicGains } : {}), })) - ) - ), - ]); + ), + }); } catch { /* persistence failure is non-fatal */ } @@ -239,28 +234,26 @@ export const useEQStore = create((set, get) => { load: async () => { if (get().loaded) return; - const db = await openLibraryDb(); - const [ - enabledRaw, - preampRaw, - bandsRaw, - modeRaw, - gainsRaw, - activeRaw, - customRaw, - devicePresetsRaw, - routeProfilesRaw, - ] = await Promise.all([ - getSetting(db, ENABLED_KEY), - getSetting(db, PREAMP_KEY), - getSetting(db, BANDS_KEY), - getSetting(db, MODE_KEY), - getSetting(db, GRAPHIC_GAINS_KEY), - getSetting(db, ACTIVE_PRESET_KEY), - getSetting(db, CUSTOM_PRESETS_KEY), - getSetting(db, DEVICE_PRESETS_KEY), - getSetting(db, ROUTE_PROFILES_KEY), + const values = await getNativeSettings([ + ENABLED_KEY, + PREAMP_KEY, + BANDS_KEY, + MODE_KEY, + GRAPHIC_GAINS_KEY, + ACTIVE_PRESET_KEY, + CUSTOM_PRESETS_KEY, + DEVICE_PRESETS_KEY, + ROUTE_PROFILES_KEY, ]); + const enabledRaw = values[ENABLED_KEY]; + const preampRaw = values[PREAMP_KEY]; + const bandsRaw = values[BANDS_KEY]; + const modeRaw = values[MODE_KEY]; + const gainsRaw = values[GRAPHIC_GAINS_KEY]; + const activeRaw = values[ACTIVE_PRESET_KEY]; + const customRaw = values[CUSTOM_PRESETS_KEY]; + const devicePresetsRaw = values[DEVICE_PRESETS_KEY]; + const routeProfilesRaw = values[ROUTE_PROFILES_KEY]; const bands = parseBands(bandsRaw) ?? createDefaultBands(); const presets = [...createBuiltInPresets(), ...parseCustomPresets(customRaw)]; @@ -294,7 +287,7 @@ export const useEQStore = create((set, get) => { // snapshots remain unread after this write and can no longer affect EQ. if (devicePresetsRaw === null) { try { - await setSetting(db, DEVICE_PRESETS_KEY, stringifyEQDevicePresetState(deviceState)); + await setNativeSetting(DEVICE_PRESETS_KEY, stringifyEQDevicePresetState(deviceState)); } catch { /* migration persistence failure is non-fatal and safe to retry */ } diff --git a/src/stores/libraryStore.ts b/src/stores/libraryStore.ts index 4a89ae7..e252d23 100644 --- a/src/stores/libraryStore.ts +++ b/src/stores/libraryStore.ts @@ -1,19 +1,10 @@ import { create } from 'zustand'; -import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library'; -import { openLibraryDb } from '@/db/database'; import { - getAllTracks, - getRecentlyPlayedTracks, - getSetting, - getTrackCount, - markTrackPlayed, - setSetting, -} from '@/db/queries'; -import { markLocalTracksStaleForRebuild } from '@/db/libraryMaintenance'; -import { recomputeAlbumIdentity } from '@/library/albumIdentity'; -import { buildAlbumList } from '@/library/albumSummary'; -import { ensureArtworkThumbnails } from '@/library/artwork'; -import { buildArtistList } from '@/library/artistGrouping'; + AstraLibraryData, + type LibrarySectionAnchor, + type LibraryStatus, +} from '../../modules/astra-library-scanner'; +import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library'; import { addFolderViaPicker, loadFolders, @@ -26,13 +17,8 @@ import { endScanService, reportScanProgress } from '@/library/scanService'; import { ALBUM_SORT_LABELS, type AlbumSort } from '@/lib/albumSort'; import { ARTIST_SORT_LABELS, type ArtistSort } from '@/lib/artistSort'; import { TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort'; -import { usePlaylistStore } from './playlistStore'; import { useSettingsStore } from './settingsStore'; -/** - * Library state — SQLite is the source of truth (no persist middleware); - * this store mirrors it in memory for the UI plus scan/UI state. - */ type ViewMode = 'tracks' | 'albums' | 'artists' | 'playlists' | 'folders'; const VIEW_MODE_KEY = 'library_view_mode'; @@ -40,11 +26,8 @@ const TRACK_SORT_KEY = 'library_track_sort'; const ALBUM_SORT_KEY = 'library_album_sort'; const ARTIST_SORT_KEY = 'library_artist_sort'; const INCLUDE_COLLAB_ARTISTS_KEY = 'library_include_collab_artists'; - -// Bump when the album-identity algorithm changes to re-run the whole-library -// recompute at startup. '2' = the desktop three-tier grouping port (v15 schema). -const ALBUM_GROUPING_VERSION_KEY = 'album_grouping_version'; -const ALBUM_GROUPING_VERSION = '2'; +const PAGE_SIZE = 100; +const MAX_WINDOW_ITEMS = PAGE_SIZE * 5; const VIEW_MODES: readonly ViewMode[] = ['tracks', 'albums', 'artists', 'playlists', 'folders']; @@ -64,13 +47,8 @@ function parseArtistSort(value: string | null): ArtistSort | null { return value !== null && value in ARTIST_SORT_LABELS ? (value as ArtistSort) : null; } -/** Fire-and-forget settings write so view/sort switching stays synchronous. */ function persistSetting(key: string, value: string) { - void openLibraryDb() - .then((db) => setSetting(db, key, value)) - .catch(() => { - // Losing a view preference write is harmless; never surface it. - }); + void AstraLibraryData.setSettings({ [key]: value }); } export type FolderWithCount = LibraryFolder & { track_count: number }; @@ -86,10 +64,14 @@ const IDLE_PROGRESS: ScanProgressState = { phase: 'idle', processed: 0, total: 0 interface LibraryStore { initialized: boolean; + status: LibraryStatus; + recoveryNotice: string | null; tracks: DbTrack[]; recentlyPlayedTracks: DbTrack[]; albums: Album[]; artists: Artist[]; + homeAlbums: Album[]; + homeArtists: Artist[]; folders: FolderWithCount[]; totalTrackCount: number; viewMode: ViewMode; @@ -98,11 +80,20 @@ interface LibraryStore { artistSort: ArtistSort; includeCollabArtists: boolean; isScanning: boolean; + isPageLoading: boolean; scanProgress: ScanProgressState; scanError: string | null; + trackNextCursor: string | null; + albumNextCursor: string | null; + artistNextCursor: string | null; + sectionAnchors: LibrarySectionAnchor[]; initialize: () => Promise; refresh: () => Promise; + loadNextTracks: () => Promise; + loadNextAlbums: () => Promise; + loadNextArtists: () => Promise; + jumpToSection: (cursor: string) => Promise; recordTrackPlayed: (path: string) => Promise; recomputeArtists: () => void; recomputeAlbums: () => void; @@ -118,36 +109,117 @@ interface LibraryStore { } let initPromise: Promise | null = null; +let nativeSubscriptionsInstalled = false; + +function appendWindow( + current: T[], + incoming: T[], + key: (item: T) => string +): T[] { + const known = new Set(current.map(key)); + const merged = [...current, ...incoming.filter((item) => !known.has(key(item)))]; + return merged.length > MAX_WINDOW_ITEMS ? merged.slice(merged.length - MAX_WINDOW_ITEMS) : merged; +} export const useLibraryStore = create((set, get) => { const onProgress = (progress: ScanProgress) => { set({ scanProgress: progress }); - // Mirror progress into the foreground-service notification (starts it on the - // first tick) so a big scan keeps running + stays visible when backgrounded. void reportScanProgress(progress); }; - /** Shared scan wrapper: progress/error state + refresh, scans never overlap. */ + const readTrackPage = (cursor: string | null) => + AstraLibraryData.getTrackPage(get().trackSort, cursor, PAGE_SIZE); + + const readAlbumPage = (cursor: string | null) => + AstraLibraryData.getAlbumPage( + get().albumSort, + useSettingsStore.getState().includeSingles, + cursor, + PAGE_SIZE + ); + + const readArtistPage = (cursor: string | null) => + AstraLibraryData.getArtistPage( + get().artistSort, + useSettingsStore.getState().artistGroupingMode, + get().includeCollabArtists, + cursor, + PAGE_SIZE + ); + + const resetTracks = async () => { + const page = await readTrackPage(null); + set({ + tracks: page.items ?? [], + trackNextCursor: page.nextCursor ?? null, + totalTrackCount: page.totalCount ?? 0, + }); + }; + + const resetAlbums = async () => { + const page = await readAlbumPage(null); + set({ albums: page.items ?? [], albumNextCursor: page.nextCursor ?? null }); + }; + + const resetArtists = async () => { + const page = await readArtistPage(null); + set({ artists: page.items ?? [], artistNextCursor: page.nextCursor ?? null }); + }; + + const resetSectionAnchors = async () => { + const state = get(); + const sortable = + (state.viewMode === 'tracks' && (state.trackSort === 'artist' || state.trackSort === 'title')) || + (state.viewMode === 'albums' && (state.albumSort === 'artist' || state.albumSort === 'name')) || + (state.viewMode === 'artists' && state.artistSort === 'name'); + if (!sortable) { + set({ sectionAnchors: [] }); + return; + } + const sort = + state.viewMode === 'tracks' + ? state.trackSort as 'artist' | 'title' + : state.viewMode === 'albums' + ? state.albumSort as 'artist' | 'name' + : 'name'; + set({ + sectionAnchors: await AstraLibraryData.getSectionAnchors( + state.viewMode as 'tracks' | 'albums' | 'artists', + sort, + useSettingsStore.getState().includeSingles, + useSettingsStore.getState().artistGroupingMode, + state.includeCollabArtists + ), + }); + }; + const runScan = async (scan: () => Promise) => { if (get().isScanning) return; set({ isScanning: true, scanError: null, scanProgress: { ...IDLE_PROGRESS } }); try { await scan(); - } catch (err) { - set({ scanError: err instanceof Error ? err.message : String(err) }); + } catch (error) { + set({ scanError: error instanceof Error ? error.message : String(error) }); } finally { - await get().refresh(); - set({ isScanning: false, scanProgress: { ...IDLE_PROGRESS } }); - endScanService(); + try { + await get().refresh(); + } finally { + set({ isScanning: false, scanProgress: { ...IDLE_PROGRESS } }); + endScanService(); + } } }; return { initialized: false, + status: 'initializing', + recoveryNotice: null, tracks: [], recentlyPlayedTracks: [], albums: [], artists: [], + homeAlbums: [], + homeArtists: [], folders: [], totalTrackCount: 0, viewMode: 'albums', @@ -156,140 +228,261 @@ export const useLibraryStore = create((set, get) => { artistSort: 'name', includeCollabArtists: false, isScanning: false, + isPageLoading: false, scanProgress: { ...IDLE_PROGRESS }, scanError: null, + trackNextCursor: null, + albumNextCursor: null, + artistNextCursor: null, + sectionAnchors: [], initialize: () => { 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(); - if (state.includeSingles !== prev.includeSingles) get().recomputeAlbums(); - }); - // One-time backfill when the album-identity algorithm changes (e.g. the - // desktop three-tier grouping port): settle every track's identity key + - // display artist before the first refresh so first paint is grouped right. - if ((await getSetting(db, ALBUM_GROUPING_VERSION_KEY)) !== ALBUM_GROUPING_VERSION) { - await recomputeAlbumIdentity(db); - await setSetting(db, ALBUM_GROUPING_VERSION_KEY, ALBUM_GROUPING_VERSION); - } - // Restore view preferences before the first render of the library screen. - const [ - savedViewMode, - savedTrackSort, - savedAlbumSort, - savedArtistSort, - savedIncludeCollabArtists, - ] = - await Promise.all([ - getSetting(db, VIEW_MODE_KEY), - getSetting(db, TRACK_SORT_KEY), - getSetting(db, ALBUM_SORT_KEY), - getSetting(db, ARTIST_SORT_KEY), - getSetting(db, INCLUDE_COLLAB_ARTISTS_KEY), - ]); - const viewMode = parseViewMode(savedViewMode); - const trackSort = parseTrackSort(savedTrackSort); - const albumSort = parseAlbumSort(savedAlbumSort); - const artistSort = parseArtistSort(savedArtistSort); + const status = await AstraLibraryData.initialize(); set({ - ...(viewMode ? { viewMode } : null), - ...(trackSort ? { trackSort } : null), - ...(albumSort ? { albumSort } : null), - ...(artistSort ? { artistSort } : null), - includeCollabArtists: savedIncludeCollabArtists === 'true', + status: status.status, + recoveryNotice: status.recoveryNotice, + totalTrackCount: status.trackCount, }); + if (status.status === 'fatalUserData') { + set({ initialized: true }); + return; + } + await useSettingsStore.getState().load(); + const values = await AstraLibraryData.getSettings([ + VIEW_MODE_KEY, + TRACK_SORT_KEY, + ALBUM_SORT_KEY, + ARTIST_SORT_KEY, + INCLUDE_COLLAB_ARTISTS_KEY, + ]); + const viewMode = parseViewMode(values[VIEW_MODE_KEY] ?? null); + const trackSort = parseTrackSort(values[TRACK_SORT_KEY] ?? null); + const albumSort = parseAlbumSort(values[ALBUM_SORT_KEY] ?? null); + const artistSort = parseArtistSort(values[ARTIST_SORT_KEY] ?? null); + set({ + ...(viewMode ? { viewMode } : {}), + ...(trackSort ? { trackSort } : {}), + ...(albumSort ? { albumSort } : {}), + ...(artistSort ? { artistSort } : {}), + includeCollabArtists: values[INCLUDE_COLLAB_ARTISTS_KEY] === 'true', + }); + + if (!nativeSubscriptionsInstalled) { + nativeSubscriptionsInstalled = true; + AstraLibraryData.addListener('onLibraryStatus', (next) => { + set({ + status: next.status, + recoveryNotice: next.recoveryNotice, + totalTrackCount: next.trackCount, + }); + if (next.status === 'rebuilding' && !get().isScanning) { + void get().rebuildLocalIndex(); + } + }); + AstraLibraryData.addListener('onCatalogChanged', () => { + void get().refresh(); + }); + useSettingsStore.subscribe((next, previous) => { + if (next.artistGroupingMode !== previous.artistGroupingMode) { + void resetArtists(); + void resetSectionAnchors(); + } + if (next.includeSingles !== previous.includeSingles) { + void resetAlbums(); + void resetSectionAnchors(); + } + }); + } + await get().refresh(); set({ initialized: true }); - // One-time recovery: the v3 migration marks tracks stale (mtime = -1) - // whose non-ASCII tags were truncated by the pre-fix op-sqlite binding. - // Re-extract them now that binding is fixed. Fire-and-forget so startup - // isn't blocked; rescan manages its own progress + refresh. - const stale = await db.get<{ n: number }>('SELECT COUNT(*) AS n FROM tracks WHERE mtime = -1'); - if ((stale?.n ?? 0) > 0) { - void get().rescan(); + if (status.status === 'rebuilding' && !get().isScanning) { + void get().rebuildLocalIndex(); } - })().catch((err) => { - initPromise = null; // allow retry on genuine failure - throw err; + })().catch((error) => { + initPromise = null; + throw error; }); } return initPromise; }, refresh: async () => { - const db = await openLibraryDb(); - const [tracks, folders, totalTrackCount, recentlyPlayedTracks] = await Promise.all([ - getAllTracks(db), + const viewMode = get().viewMode; + const [ + trackPage, + albumPage, + artistPage, + homeAlbumPage, + homeArtistPage, + folders, + recentlyPlayedTracks, + ] = await Promise.all([ + viewMode === 'tracks' ? readTrackPage(null) : Promise.resolve(null), + viewMode === 'albums' ? readAlbumPage(null) : Promise.resolve(null), + viewMode === 'artists' ? readArtistPage(null) : Promise.resolve(null), + AstraLibraryData.getAlbumPage( + 'recently_added', + useSettingsStore.getState().includeSingles, + null, + 20 + ), + AstraLibraryData.getArtistPage( + 'name', + useSettingsStore.getState().artistGroupingMode, + get().includeCollabArtists, + null, + 50 + ), loadFolders(), - getTrackCount(db), - getRecentlyPlayedTracks(db), + AstraLibraryData.getRecentlyPlayed(20), ]); + set({ + ...(trackPage ? { + tracks: trackPage.items ?? [], + trackNextCursor: trackPage.nextCursor ?? null, + totalTrackCount: trackPage.totalCount ?? get().totalTrackCount, + } : {}), + ...(albumPage ? { + albums: albumPage.items ?? [], + albumNextCursor: albumPage.nextCursor ?? null, + } : {}), + ...(artistPage ? { + artists: artistPage.items ?? [], + artistNextCursor: artistPage.nextCursor ?? null, + } : {}), + homeAlbums: homeAlbumPage.items ?? [], + homeArtists: homeArtistPage.items ?? [], + folders, + recentlyPlayedTracks, + }); + await resetSectionAnchors(); + }, + + loadNextTracks: async () => { + const cursor = get().trackNextCursor; + if (!cursor || get().isPageLoading) return; + set({ isPageLoading: true }); try { - await ensureArtworkThumbnails(tracks.map((track) => track.artwork_hash)); - } catch { - // Missing thumbnails should not prevent the library itself from loading. + const page = await readTrackPage(cursor); + if (page.error === 'STALE_REVISION') return resetTracks(); + set((state) => ({ + tracks: appendWindow(state.tracks, page.items, (track) => track.path), + trackNextCursor: page.nextCursor, + })); + } finally { + set({ isPageLoading: false }); + } + }, + + loadNextAlbums: async () => { + const cursor = get().albumNextCursor; + if (!cursor || get().isPageLoading) return; + set({ isPageLoading: true }); + try { + const page = await readAlbumPage(cursor); + if (page.error === 'STALE_REVISION') return resetAlbums(); + set((state) => ({ + albums: appendWindow(state.albums, page.items, (album) => album.identity_key), + albumNextCursor: page.nextCursor, + })); + } finally { + set({ isPageLoading: false }); + } + }, + + loadNextArtists: async () => { + const cursor = get().artistNextCursor; + if (!cursor || get().isPageLoading) return; + set({ isPageLoading: true }); + try { + const page = await readArtistPage(cursor); + if (page.error === 'STALE_REVISION') return resetArtists(); + set((state) => ({ + artists: appendWindow(state.artists, page.items, (artist) => artist.artist), + artistNextCursor: page.nextCursor, + })); + } finally { + set({ isPageLoading: false }); + } + }, + + jumpToSection: async (cursor) => { + const state = get(); + set({ isPageLoading: true }); + try { + if (state.viewMode === 'tracks') { + const page = await readTrackPage(cursor); + if (page.error === 'STALE_REVISION') return resetTracks(); + set({ + tracks: page.items, + trackNextCursor: page.nextCursor, + totalTrackCount: page.totalCount, + }); + } else if (state.viewMode === 'albums') { + const page = await readAlbumPage(cursor); + if (page.error === 'STALE_REVISION') return resetAlbums(); + set({ albums: page.items, albumNextCursor: page.nextCursor }); + } else if (state.viewMode === 'artists') { + const page = await readArtistPage(cursor); + if (page.error === 'STALE_REVISION') return resetArtists(); + set({ artists: page.items, artistNextCursor: page.nextCursor }); + } + } finally { + set({ isPageLoading: false }); } - // Album + artist lists are derived in JS: albums for the desktop-parity - // display picks + singles eligibility, artists to honor the grouping mode. - const settings = useSettingsStore.getState(); - const albums = buildAlbumList(tracks, { includeSingles: settings.includeSingles }); - const artists = buildArtistList(tracks, settings.artistGroupingMode); - set({ tracks, recentlyPlayedTracks, albums, artists, folders, totalTrackCount }); - // Playlist counts/missing states depend on tracks — keep them in step. - await usePlaylistStore.getState().refresh(); }, recordTrackPlayed: async (path) => { - const db = await openLibraryDb(); - const recorded = await markTrackPlayed(db, path); - if (!recorded) return; - const recentlyPlayedTracks = await getRecentlyPlayedTracks(db); - set({ recentlyPlayedTracks }); + await AstraLibraryData.recordTrackPlayed(path); + set({ recentlyPlayedTracks: await AstraLibraryData.getRecentlyPlayed(20) }); }, - // 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), - })), + recomputeArtists: () => { + void resetArtists(); + }, - // Rebuild the album list from in-memory tracks (e.g. on singles-toggle change). - recomputeAlbums: () => - set((state) => ({ - albums: buildAlbumList(state.tracks, { - includeSingles: useSettingsStore.getState().includeSingles, - }), - })), + recomputeAlbums: () => { + void resetAlbums(); + }, setViewMode: (viewMode) => { set({ viewMode }); persistSetting(VIEW_MODE_KEY, viewMode); + if (viewMode === 'tracks' && get().tracks.length === 0) void resetTracks(); + if (viewMode === 'albums' && get().albums.length === 0) void resetAlbums(); + if (viewMode === 'artists' && get().artists.length === 0) void resetArtists(); + void resetSectionAnchors(); }, setTrackSort: (trackSort) => { - set({ trackSort }); + set({ trackSort, tracks: [], trackNextCursor: null }); persistSetting(TRACK_SORT_KEY, trackSort); + void resetTracks(); + void resetSectionAnchors(); }, setAlbumSort: (albumSort) => { - set({ albumSort }); + set({ albumSort, albums: [], albumNextCursor: null }); persistSetting(ALBUM_SORT_KEY, albumSort); + void resetAlbums(); + void resetSectionAnchors(); }, setArtistSort: (artistSort) => { - set({ artistSort }); + set({ artistSort, artists: [], artistNextCursor: null }); persistSetting(ARTIST_SORT_KEY, artistSort); + void resetArtists(); + void resetSectionAnchors(); }, setIncludeCollabArtists: (includeCollabArtists) => { - set({ includeCollabArtists }); + set({ includeCollabArtists, artists: [], artistNextCursor: null }); persistSetting(INCLUDE_COLLAB_ARTISTS_KEY, includeCollabArtists ? 'true' : 'false'); + void resetArtists(); + void resetSectionAnchors(); }, addFolder: () => runScan(() => addFolderViaPicker({ onProgress })), @@ -303,10 +496,6 @@ export const useLibraryStore = create((set, get) => { rescan: () => runScan(() => rescanAll({ callbacks: { onProgress } })), - rebuildLocalIndex: () => runScan(async () => { - const db = await openLibraryDb(); - await markLocalTracksStaleForRebuild(db); - return rescanAll({ callbacks: { onProgress } }); - }), + rebuildLocalIndex: () => runScan(() => rescanAll({ mode: 'full', callbacks: { onProgress } })), }; }); diff --git a/src/stores/lyricsSettingsStore.ts b/src/stores/lyricsSettingsStore.ts index 6a619c0..72b522b 100644 --- a/src/stores/lyricsSettingsStore.ts +++ b/src/stores/lyricsSettingsStore.ts @@ -1,6 +1,5 @@ import { create } from 'zustand'; -import { openLibraryDb } from '@/db/database'; -import { getSetting, setSetting } from '@/db/queries'; +import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings'; import { DEFAULT_LYRICS_DISPLAY_SETTINGS, normalizeLyricsDisplaySettings, @@ -42,8 +41,7 @@ function displaySettingsFromState(state: LyricsSettingsStore): LyricsDisplaySett } async function persistDisplaySettings(settings: LyricsDisplaySettings): Promise { - const db = await openLibraryDb(); - await setSetting(db, DISPLAY_SETTINGS_KEY, JSON.stringify(settings)); + await setNativeSetting(DISPLAY_SETTINGS_KEY, JSON.stringify(settings)); } let loadPromise: Promise | null = null; @@ -57,10 +55,9 @@ export const useLyricsSettingsStore = create((set, get) => if (get().loaded) return; if (loadPromise) return loadPromise; loadPromise = (async () => { - const db = await openLibraryDb(); const [onlineValue, displayValue] = await Promise.all([ - getSetting(db, ONLINE_LOOKUP_KEY), - getSetting(db, DISPLAY_SETTINGS_KEY), + getNativeSetting(ONLINE_LOOKUP_KEY), + getNativeSetting(DISPLAY_SETTINGS_KEY), ]); let parsed: unknown = null; try { @@ -82,8 +79,7 @@ export const useLyricsSettingsStore = create((set, get) => setOnlineLookupEnabled: async (enabled) => { await get().load(); set({ onlineLookupEnabled: enabled }); - const db = await openLibraryDb(); - await setSetting(db, ONLINE_LOOKUP_KEY, enabled ? 'true' : 'false'); + await setNativeSetting(ONLINE_LOOKUP_KEY, enabled ? 'true' : 'false'); }, setWordTimingEnabled: async (enabled) => { diff --git a/src/stores/onboardingStore.ts b/src/stores/onboardingStore.ts index e3c944d..bd907fb 100644 --- a/src/stores/onboardingStore.ts +++ b/src/stores/onboardingStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { openLibraryDb } from '@/db/database'; -import { getFolders, getSetting, setSetting } from '@/db/queries'; +import { AstraLibraryData } from '../../modules/astra-library-scanner'; +import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings'; /** * First-run wizard gate. SQLite (settings table) is the source of truth, mirrored @@ -24,8 +24,7 @@ export const useOnboardingStore = create((set, get) => ({ load: async () => { if (get().loaded) return; - const db = await openLibraryDb(); - const value = await getSetting(db, ONBOARDING_COMPLETE_KEY); + const value = await getNativeSetting(ONBOARDING_COMPLETE_KEY); if (value !== null) { set({ onboardingComplete: value === 'true', loaded: true }); return; @@ -33,21 +32,19 @@ export const useOnboardingStore = create((set, get) => ({ // Flag never set: an install that already has library folders predates this // wizard — treat it as onboarded (and persist) so the wizard never ambushes // an upgrading user. A genuinely fresh install has no folders → show it. - const folders = await getFolders(db); + const folders = await AstraLibraryData.listFolders(); const complete = folders.length > 0; - if (complete) await setSetting(db, ONBOARDING_COMPLETE_KEY, 'true'); + if (complete) await setNativeSetting(ONBOARDING_COMPLETE_KEY, 'true'); set({ onboardingComplete: complete, loaded: true }); }, markComplete: async () => { set({ onboardingComplete: true }); - const db = await openLibraryDb(); - await setSetting(db, ONBOARDING_COMPLETE_KEY, 'true'); + await setNativeSetting(ONBOARDING_COMPLETE_KEY, 'true'); }, reset: async () => { set({ onboardingComplete: false }); - const db = await openLibraryDb(); - await setSetting(db, ONBOARDING_COMPLETE_KEY, 'false'); + await setNativeSetting(ONBOARDING_COMPLETE_KEY, 'false'); }, })); diff --git a/src/stores/playbackTargetStore.ts b/src/stores/playbackTargetStore.ts index 3287c41..3f48243 100644 --- a/src/stores/playbackTargetStore.ts +++ b/src/stores/playbackTargetStore.ts @@ -1,6 +1,5 @@ import { create } from 'zustand'; -import { openLibraryDb } from '@/db/database'; -import { getSetting, setSetting } from '@/db/queries'; +import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings'; const PLAYBACK_TARGET_KEY = 'playback_target'; @@ -23,15 +22,13 @@ export const usePlaybackTargetStore = create((set, get) => load: async () => { if (get().loaded) return; - const db = await openLibraryDb(); - const stored = await getSetting(db, PLAYBACK_TARGET_KEY); + const stored = await getNativeSetting(PLAYBACK_TARGET_KEY); set({ target: parsePlaybackTarget(stored), loaded: true }); }, setTarget: async (target) => { if (get().target === target && get().loaded) return; set({ target, loaded: true }); - const db = await openLibraryDb(); - await setSetting(db, PLAYBACK_TARGET_KEY, target); + await setNativeSetting(PLAYBACK_TARGET_KEY, target); }, })); diff --git a/src/stores/playlistStore.ts b/src/stores/playlistStore.ts index 3a994ea..22498aa 100644 --- a/src/stores/playlistStore.ts +++ b/src/stores/playlistStore.ts @@ -1,13 +1,13 @@ import { create } from 'zustand'; +import { AstraLibraryData } from '../../modules/astra-library-scanner'; import type { DbTrack } from '@/types/library'; import type { Playlist, PlaylistTrackEntry } from '@/types/playlist'; -import type { - DynamicPlaylistPreview, - DynamicPlaylistRulesV1, +import { + createDefaultDynamicPlaylistRules, + normalizeDynamicPlaylistRules, + type DynamicPlaylistPreview, + type DynamicPlaylistRulesV1, } from '@/shared/playlists/dynamicPlaylist'; -import { openLibraryDb, type LibraryDatabase } from '@/db/database'; -import { getAllTracks } from '@/db/queries'; -import * as playlistDb from '@/db/playlistQueries'; import { buildImportIndex, decodedDocPath, @@ -17,7 +17,9 @@ import { pickAndParseM3u, type M3uExportResult, } from '@/library/playlistFiles'; -import type { M3uExportEntry } from '@/lib/m3u'; +import type { M3uExportEntry, M3uEntry } from '@/lib/m3u'; + +const ENTRY_PAGE_SIZE = 100; export interface M3uImportSummary { playlistId: number; @@ -29,20 +31,18 @@ export interface M3uImportSummary { ambiguous: number; } -/** - * Playlists + favorites state — SQLite is the source of truth (no persist); - * every mutation re-queries. libraryStore.refresh() chains into refresh() so - * scans and folder removals update counts/missing states. - */ interface PlaylistStore { playlists: Playlist[]; favoritePaths: Set; favoriteTracks: DbTrack[]; activePlaylistId: number | null; activeEntries: PlaylistTrackEntry[]; + activeEntriesTotal: number; + activeEntriesNextOffset: number | null; refresh: () => Promise; openPlaylist: (id: number) => Promise; + loadNextEntries: () => Promise; closePlaylist: () => void; createPlaylist: (name: string) => Promise; createDynamicPlaylist: (name: string, rules: DynamicPlaylistRulesV1) => Promise; @@ -54,7 +54,6 @@ interface PlaylistStore { addTracksToPlaylist: (id: number, tracks: DbTrack[]) => Promise; removeFromPlaylist: (id: number, trackPath: string) => Promise; moveTrack: (id: number, trackPath: string, direction: -1 | 1) => Promise; - // Only the path is read; accepts a library DbTrack or the now-playing Track. toggleFavorite: (track: { path: string }) => Promise; markPlayed: (id: number) => Promise; importM3u: () => Promise; @@ -80,22 +79,45 @@ function entryToExportEntry(entry: PlaylistTrackEntry): M3uExportEntry { }; } +function parseRules(raw: string): DynamicPlaylistRulesV1 { + try { + return normalizeDynamicPlaylistRules(JSON.parse(raw)); + } catch { + return createDefaultDynamicPlaylistRules(); + } +} + +async function candidatesForImport(entry: M3uEntry): Promise { + const exact = await AstraLibraryData.getTrack(entry.path).catch(() => null); + if (exact) return [exact]; + const term = entry.title?.trim() || entry.path.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') || ''; + return term ? AstraLibraryData.searchTracks(term, 50) : []; +} + export const usePlaylistStore = create((set, get) => { - const reloadActive = async (db: LibraryDatabase) => { + const reloadActive = async () => { const id = get().activePlaylistId; if (id == null) return; - const activeEntries = await playlistDb.getPlaylistEntries(db, id); - set({ activeEntries }); + const page = await AstraLibraryData.getPlaylistEntries( + id, + 0, + ENTRY_PAGE_SIZE + ); + set({ + activeEntries: page.items, + activeEntriesTotal: page.totalCount, + activeEntriesNextOffset: page.nextOffset, + }); }; - const refreshWith = async (db: LibraryDatabase) => { - const [playlists, favoritePathList, favoriteTracks] = await Promise.all([ - playlistDb.getPlaylists(db), - playlistDb.getFavoritePaths(db), - playlistDb.getFavoriteTracks(db), + const refreshAll = async () => { + const [playlists, favoritePaths, favoriteTracks] = await Promise.all([ + AstraLibraryData.listPlaylists(), + AstraLibraryData.getFavoritePaths(), + AstraLibraryData.getFavoriteTracks(500), ]); - set({ playlists, favoritePaths: new Set(favoritePathList), favoriteTracks }); - await reloadActive(db); + set({ playlists, favoritePaths: new Set(favoritePaths), favoriteTracks }); + await reloadActive(); }; return { @@ -104,69 +126,86 @@ export const usePlaylistStore = create((set, get) => { favoriteTracks: [], activePlaylistId: null, activeEntries: [], + activeEntriesTotal: 0, + activeEntriesNextOffset: null, - refresh: async () => { - const db = await openLibraryDb(); - await refreshWith(db); - }, + refresh: refreshAll, openPlaylist: async (id) => { - const db = await openLibraryDb(); - const activeEntries = await playlistDb.getPlaylistEntries(db, id); - set({ activePlaylistId: id, activeEntries }); + set({ activePlaylistId: id, activeEntries: [], activeEntriesNextOffset: 0 }); + await reloadActive(); }, - closePlaylist: () => set({ activePlaylistId: null, activeEntries: [] }), + loadNextEntries: async () => { + const id = get().activePlaylistId; + const offset = get().activeEntriesNextOffset; + if (id == null || offset == null) return; + const page = await AstraLibraryData.getPlaylistEntries( + id, + offset, + ENTRY_PAGE_SIZE + ); + set((state) => ({ + activeEntries: [...state.activeEntries, ...page.items], + activeEntriesTotal: page.totalCount, + activeEntriesNextOffset: page.nextOffset, + })); + }, + + closePlaylist: () => + set({ + activePlaylistId: null, + activeEntries: [], + activeEntriesTotal: 0, + activeEntriesNextOffset: null, + }), createPlaylist: async (name) => { - const db = await openLibraryDb(); - const playlist = await playlistDb.createPlaylist(db, name); - await refreshWith(db); + const playlist = await AstraLibraryData.createPlaylist(name, 'normal', null); + await refreshAll(); return playlist; }, createDynamicPlaylist: async (name, rules) => { - const db = await openLibraryDb(); - const playlist = await playlistDb.createDynamicPlaylist(db, name, rules); - await refreshWith(db); + const normalized = normalizeDynamicPlaylistRules(rules); + const playlist = await AstraLibraryData.createPlaylist( + name, + 'dynamic', + JSON.stringify(normalized) + ); + await refreshAll(); return playlist; }, - getDynamicPlaylistRules: async (id) => { - const db = await openLibraryDb(); - return playlistDb.getDynamicPlaylistRules(db, id); - }, + getDynamicPlaylistRules: async (id) => + parseRules(await AstraLibraryData.getDynamicPlaylistRules(id)), updateDynamicPlaylistRules: async (id, rules) => { - const db = await openLibraryDb(); - await playlistDb.updateDynamicPlaylistRules(db, id, rules); - await refreshWith(db); + await AstraLibraryData.updateDynamicPlaylistRules( + id, + JSON.stringify(normalizeDynamicPlaylistRules(rules)) + ); + await refreshAll(); }, - previewDynamicPlaylist: async (rules) => { - const db = await openLibraryDb(); - return playlistDb.previewDynamicPlaylist(db, rules); - }, + previewDynamicPlaylist: async (rules) => + AstraLibraryData.previewDynamicPlaylist( + JSON.stringify(normalizeDynamicPlaylistRules(rules)) + ), renamePlaylist: async (id, name) => { - const db = await openLibraryDb(); - await playlistDb.renamePlaylist(db, id, name); - await refreshWith(db); + await AstraLibraryData.renamePlaylist(id, name); + await refreshAll(); }, deletePlaylist: async (id) => { - const db = await openLibraryDb(); - await playlistDb.deletePlaylist(db, id); - if (get().activePlaylistId === id) { - set({ activePlaylistId: null, activeEntries: [] }); - } - await refreshWith(db); + await AstraLibraryData.deletePlaylist(id); + if (get().activePlaylistId === id) get().closePlaylist(); + await refreshAll(); }, addTracksToPlaylist: async (id, tracks) => { - const db = await openLibraryDb(); - const inserted = await playlistDb.addPlaylistEntries( - db, + const inserted = await AstraLibraryData.addPlaylistEntries( id, tracks.map((track) => ({ trackPath: track.path, @@ -175,60 +214,42 @@ export const usePlaylistStore = create((set, get) => { fallbackAlbum: track.album, })) ); - await refreshWith(db); + await refreshAll(); return inserted; }, removeFromPlaylist: async (id, trackPath) => { - const db = await openLibraryDb(); - await playlistDb.removeFromPlaylist(db, id, trackPath); - await refreshWith(db); + await AstraLibraryData.removePlaylistEntry(id, trackPath); + await refreshAll(); }, moveTrack: async (id, trackPath, direction) => { - const db = await openLibraryDb(); - await playlistDb.movePlaylistTrack(db, id, trackPath, direction); - await refreshWith(db); + await AstraLibraryData.movePlaylistEntry(id, trackPath, direction); + await refreshAll(); }, toggleFavorite: async (track) => { - const wasFavorite = get().favoritePaths.has(track.path); - // Optimistic Set swap (always a fresh Set — never mutate in place). + const favorite = !get().favoritePaths.has(track.path); const optimistic = new Set(get().favoritePaths); - if (wasFavorite) { - optimistic.delete(track.path); - } else { - optimistic.add(track.path); - } + if (favorite) optimistic.add(track.path); + else optimistic.delete(track.path); set({ favoritePaths: optimistic }); - - const db = await openLibraryDb(); - if (wasFavorite) { - await playlistDb.removeFavorite(db, track.path); - } else { - await playlistDb.addFavorite(db, track.path); - } - const [favoritePathList, favoriteTracks] = await Promise.all([ - playlistDb.getFavoritePaths(db), - playlistDb.getFavoriteTracks(db), + await AstraLibraryData.setFavorite(track.path, favorite); + const [paths, tracks] = await Promise.all([ + AstraLibraryData.getFavoritePaths(), + AstraLibraryData.getFavoriteTracks(500), ]); - set({ favoritePaths: new Set(favoritePathList), favoriteTracks }); + set({ favoritePaths: new Set(paths), favoriteTracks: tracks }); }, markPlayed: async (id) => { - const db = await openLibraryDb(); - await playlistDb.markPlaylistPlayed(db, id); - const playlists = await playlistDb.getPlaylists(db); - set({ playlists }); + await AstraLibraryData.markPlaylistPlayed(id); + set({ playlists: await AstraLibraryData.listPlaylists() }); }, importM3u: async () => { const picked = await pickAndParseM3u(); if (!picked) return null; - - const db = await openLibraryDb(); - const index = buildImportIndex(await getAllTracks(db)); - const summary: Omit = { name: picked.name, total: picked.entries.length, @@ -237,15 +258,13 @@ export const usePlaylistStore = create((set, get) => { missing: 0, ambiguous: 0, }; - const inserts: playlistDb.PlaylistEntryInsert[] = []; + const inserts: Parameters[1] = []; for (const entry of picked.entries) { - const match = matchImportEntry(entry, index); + const candidates = await candidatesForImport(entry); + const match = matchImportEntry(entry, buildImportIndex(candidates)); if (match.kind === 'matched') { - if (match.via === 'path') { - summary.matchedByPath += 1; - } else { - summary.matchedByMetadata += 1; - } + if (match.via === 'path') summary.matchedByPath += 1; + else summary.matchedByMetadata += 1; inserts.push({ trackPath: match.track.path, fallbackTitle: match.track.title, @@ -253,7 +272,6 @@ export const usePlaylistStore = create((set, get) => { fallbackAlbum: match.track.album, }); } else { - // Preserve unmatched entries as "missing" rows (desktop model). summary.missing += 1; if (match.kind === 'ambiguous') summary.ambiguous += 1; inserts.push({ @@ -263,15 +281,13 @@ export const usePlaylistStore = create((set, get) => { }); } } - - const playlist = await playlistDb.createPlaylist(db, picked.name); - await playlistDb.addPlaylistEntries(db, playlist.id, inserts); - await refreshWith(db); + const playlist = await AstraLibraryData.createPlaylist(picked.name, 'normal', null); + await AstraLibraryData.addPlaylistEntries(playlist.id, inserts); + await refreshAll(); return { ...summary, playlistId: playlist.id }; }, exportM3u: async (target) => { - const db = await openLibraryDb(); let name: string; let entries: M3uExportEntry[]; if (target === 'favorites') { @@ -279,8 +295,19 @@ export const usePlaylistStore = create((set, get) => { entries = get().favoriteTracks.map(trackToExportEntry); } else { name = get().playlists.find((playlist) => playlist.id === target)?.name ?? 'Playlist'; - const playlistEntries = await playlistDb.getPlaylistEntries(db, target); - entries = playlistEntries.map(entryToExportEntry); + const all: PlaylistTrackEntry[] = []; + let offset = 0; + while (true) { + const page = await AstraLibraryData.getPlaylistEntries( + target, + offset, + 200 + ); + all.push(...page.items); + if (page.nextOffset == null) break; + offset = page.nextOffset; + } + entries = all.map(entryToExportEntry); } return exportPlaylistM3u(name, entries); }, diff --git a/src/stores/remoteSourcesStore.ts b/src/stores/remoteSourcesStore.ts index dc636f7..0aaf445 100644 --- a/src/stores/remoteSourcesStore.ts +++ b/src/stores/remoteSourcesStore.ts @@ -6,29 +6,15 @@ // registry (services/remoteConfig) so the library UI and playback can build URLs. import { create } from 'zustand'; -import { openLibraryDb } from '@/db/database'; -import { deleteRemoteTracksBySource } from '@/db/queries'; -import { - deleteFavoritesByPathPrefix, - deleteRemotePlaylistsBySource, -} from '@/db/playlistQueries'; -import { recomputeAlbumIdentity } from '@/library/albumIdentity'; -import { - deleteRemoteSource, - getRemoteSource, - getRemoteSources, - insertRemoteSource, - setRemoteSourceArtAuth, - setRemoteSourceAuth, - setRemoteSourceStatus, - setRemoteSourceSynced, - updateRemoteSource, -} from '@/db/remoteSourceQueries'; +import { AstraLibraryData } from '../../modules/astra-library-scanner'; import { buildCoverArtUrlTemplate } from '@/services/remoteUrls'; import { deleteRemoteSecret, getRemoteSecret, + getRemoteSecureAuth, + setRemoteArtAuth, setRemoteSecret, + setRemoteSecureAuth, } from '@/services/remoteCredentials'; import { clearResolvedRemoteConfig, @@ -60,7 +46,10 @@ function errorMessage(error: unknown): string { /** Hydrate the synchronous URL-building registry for one source (loads its secret). */ async function hydrateRegistry(source: RemoteSourceRow): Promise { - const password = await getRemoteSecret(source.id); + const [password, auth] = await Promise.all([ + getRemoteSecret(source.id), + getRemoteSecureAuth(source.id), + ]); if (password == null) return null; setResolvedRemoteConfig({ id: source.id, @@ -68,8 +57,8 @@ async function hydrateRegistry(source: RemoteSourceRow): Promise { - if (source.art_auth) return; + if ((await getRemoteSecureAuth(source.id)).artAuth) return; const template = buildCoverArtUrlTemplate(source.id); if (!template) return; - const db = await openLibraryDb(); - await setRemoteSourceArtAuth(db, source.id, template); + await setRemoteArtAuth(source.id, template); } /** Ensure a usable Jellyfin token, authenticating + persisting it if missing. */ @@ -94,12 +82,12 @@ async function ensureJellyfinAuth( source: RemoteSourceRow, config: RemoteConnectionConfig ): Promise { - if (source.access_token && source.user_id) { - return { accessToken: source.access_token, userId: source.user_id }; + const cached = await getRemoteSecureAuth(source.id); + if (cached.accessToken && cached.userId) { + return { accessToken: cached.accessToken, userId: cached.userId }; } const auth = await authenticateJellyfin(config); - const db = await openLibraryDb(); - await setRemoteSourceAuth(db, source.id, { + await setRemoteSecureAuth(source.id, { accessToken: auth.accessToken, userId: auth.userId, deviceId: buildJellyfinDeviceId(config), @@ -107,7 +95,7 @@ async function ensureJellyfinAuth( updateResolvedRemoteAuth(source.id, auth); // Token (re)issued — refresh the native Auto cover-art template so it isn't stale. const artTemplate = buildCoverArtUrlTemplate(source.id); - if (artTemplate) await setRemoteSourceArtAuth(db, source.id, artTemplate); + if (artTemplate) await setRemoteArtAuth(source.id, artTemplate); return auth; } @@ -128,6 +116,7 @@ interface RemoteSourcesStore { } let initPromise: Promise | null = null; +let recoverySubscriptionInstalled = false; export const useRemoteSourcesStore = create((set, get) => ({ sources: [], @@ -138,11 +127,21 @@ export const useRemoteSourcesStore = create((set, get) => ({ if (get().initialized) return Promise.resolve(); if (!initPromise) { initPromise = (async () => { - const db = await openLibraryDb(); - const sources = await getRemoteSources(db); + const sources = await AstraLibraryData.listRemoteSources(); // Populate the URL registry from cached config/token (no network on launch). await Promise.all(sources.filter((s) => s.enabled).map((s) => hydrateRegistry(s))); set({ sources, initialized: true }); + if (!recoverySubscriptionInstalled) { + recoverySubscriptionInstalled = true; + AstraLibraryData.addListener('onLibraryStatus', (status) => { + if (status.status === 'rebuilding') { + void get().syncAll(); + } + }); + } + if (AstraLibraryData.getCurrentStatus().status === 'rebuilding') { + void get().syncAll(); + } // The library's initial refresh may have run before the registry was hydrated, // leaving remote artwork URLs unresolved — refresh once more now that it's ready. if (sources.length > 0) { @@ -157,8 +156,7 @@ export const useRemoteSourcesStore = create((set, get) => ({ }, refresh: async () => { - const db = await openLibraryDb(); - set({ sources: await getRemoteSources(db) }); + set({ sources: await AstraLibraryData.listRemoteSources() }); }, testSource: async (input) => { @@ -180,7 +178,6 @@ export const useRemoteSourcesStore = create((set, get) => ({ }, createSource: async (input) => { - const db = await openLibraryDb(); const config: RemoteConnectionConfig = { baseUrl: input.baseUrl, username: input.username, @@ -195,17 +192,17 @@ export const useRemoteSourcesStore = create((set, get) => ({ auth = await authenticateJellyfin(config); } - const row = await insertRemoteSource(db, { - type: input.type, - name: input.name, - baseUrl: input.baseUrl, - username: input.username, - enabled: input.enabled, - }); + const row = await AstraLibraryData.createRemoteSource( + input.type, + input.name, + input.baseUrl, + input.username, + input.enabled + ); await setRemoteSecret(row.id, input.password); if (auth) { - await setRemoteSourceAuth(db, row.id, { + await setRemoteSecureAuth(row.id, { accessToken: auth.accessToken, userId: auth.userId, deviceId: buildJellyfinDeviceId(config), @@ -230,11 +227,10 @@ export const useRemoteSourcesStore = create((set, get) => ({ }, updateSource: async (id, input) => { - const db = await openLibraryDb(); - const existing = await getRemoteSource(db, id); + const existing = await AstraLibraryData.getRemoteSource(id); if (!existing) return; - await updateRemoteSource(db, id, { + await AstraLibraryData.updateRemoteSource(id, { name: input.name, base_url: input.baseUrl, username: input.username, @@ -244,33 +240,26 @@ export const useRemoteSourcesStore = create((set, get) => ({ await setRemoteSecret(id, input.password); } - const updated = await getRemoteSource(db, id); + const updated = await AstraLibraryData.getRemoteSource(id); if (updated) { // Connection details may have changed → drop cached token + cover-art template, // re-hydrate registry (which regenerates the template from the new credentials). if (input.baseUrl || input.username || input.password) { - await setRemoteSourceAuth(db, id, { accessToken: null, userId: null, deviceId: null }); - await setRemoteSourceArtAuth(db, id, null); + await setRemoteSecureAuth(id, { + accessToken: null, + userId: null, + deviceId: null, + }); + await setRemoteArtAuth(id, null); } - const fresh = (await getRemoteSource(db, id)) ?? updated; + const fresh = (await AstraLibraryData.getRemoteSource(id)) ?? updated; await hydrateRegistry(fresh); } await get().refresh(); }, deleteSource: async (id, purgeTracks) => { - const db = await openLibraryDb(); - const source = await getRemoteSource(db, id); - if (purgeTracks && source) { - await deleteRemoteTracksBySource(db, source.type, id); - // Drop this source's synced playlists + favorites (favorites key on the - // `${type}://${id}/` path prefix). - await deleteRemotePlaylistsBySource(db, id); - await deleteFavoritesByPathPrefix(db, `${source.type}://${id}/`); - // Removals can regroup albums (compilation heuristic is cross-track). - await recomputeAlbumIdentity(db); - } - await deleteRemoteSource(db, id); + await AstraLibraryData.deleteRemoteSource(id, purgeTracks); await deleteRemoteSecret(id); clearResolvedRemoteConfig(id); await get().refresh(); @@ -280,14 +269,13 @@ export const useRemoteSourcesStore = create((set, get) => ({ }, syncSource: async (id) => { - const db = await openLibraryDb(); - const source = await getRemoteSource(db, id); + const source = await AstraLibraryData.getRemoteSource(id); if (!source) return; if (get().progressById[id]) return; // already syncing const config = await hydrateRegistry(source); if (!config) { - await setRemoteSourceStatus(db, id, 'error', 'Missing stored password.'); + await AstraLibraryData.setRemoteSourceStatus(id, 'error', 'Missing stored password.'); await get().refresh(); return; } @@ -304,11 +292,11 @@ export const useRemoteSourcesStore = create((set, get) => ({ if (source.type === 'jellyfin') { authContext = await ensureJellyfinAuth(source, config); } - await syncRemoteSource(db, source, config, { onProgress, authContext }); - await setRemoteSourceSynced(db, id); + await syncRemoteSource(source, config, { onProgress, authContext }); + await AstraLibraryData.setRemoteSourceStatus(id, 'ok', null); await useLibraryStore.getState().refresh(); } catch (error) { - await setRemoteSourceStatus(db, id, 'error', errorMessage(error)); + await AstraLibraryData.setRemoteSourceStatus(id, 'error', errorMessage(error)); } finally { set((state) => ({ progressById: { ...state.progressById, [id]: null } })); await get().refresh(); diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index d506f78..9fdaad6 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -1,6 +1,5 @@ import { create } from 'zustand'; -import { openLibraryDb } from '@/db/database'; -import { getSetting, setSetting } from '@/db/queries'; +import { AstraLibraryData } from '../../modules/astra-library-scanner'; import type { ArtistGroupingMode } from '@/library/artistGrouping'; import { parseNowPlayingCompanion, @@ -87,26 +86,25 @@ export const useSettingsStore = create((set, get) => ({ load: async () => { if (get().loaded) return; - const db = await openLibraryDb(); - const [ - grouping, - includeSingles, - scope, - scopeStageVisible, - scopeStyle, - lyricsVisible, - nowPlayingCompanion, - homeGreetingTextMode, - ] = await Promise.all([ - getSetting(db, ARTIST_GROUPING_KEY), - getSetting(db, INCLUDE_SINGLES_KEY), - getSetting(db, SCOPE_MODE_KEY), - getSetting(db, SCOPE_STAGE_VISIBLE_KEY), - getSetting(db, SCOPE_STYLE_KEY), - getSetting(db, LYRICS_VISIBLE_KEY), - getSetting(db, NOW_PLAYING_COMPANION_KEY), - getSetting(db, HOME_GREETING_TEXT_MODE_KEY), + await AstraLibraryData.initialize(); + const values = await AstraLibraryData.getSettings([ + ARTIST_GROUPING_KEY, + INCLUDE_SINGLES_KEY, + SCOPE_MODE_KEY, + SCOPE_STAGE_VISIBLE_KEY, + SCOPE_STYLE_KEY, + LYRICS_VISIBLE_KEY, + NOW_PLAYING_COMPANION_KEY, + HOME_GREETING_TEXT_MODE_KEY, ]); + const grouping = values[ARTIST_GROUPING_KEY] ?? null; + const includeSingles = values[INCLUDE_SINGLES_KEY] ?? null; + const scope = values[SCOPE_MODE_KEY] ?? null; + const scopeStageVisible = values[SCOPE_STAGE_VISIBLE_KEY] ?? null; + const scopeStyle = values[SCOPE_STYLE_KEY] ?? null; + const lyricsVisible = values[LYRICS_VISIBLE_KEY] ?? null; + const nowPlayingCompanion = values[NOW_PLAYING_COMPANION_KEY] ?? null; + const homeGreetingTextMode = values[HOME_GREETING_TEXT_MODE_KEY] ?? null; set({ artistGroupingMode: parseGroupingMode(grouping), includeSingles: parseBoolean(includeSingles), @@ -123,57 +121,49 @@ export const useSettingsStore = create((set, get) => ({ setArtistGroupingMode: async (mode) => { if (get().artistGroupingMode === mode) return; set({ artistGroupingMode: mode }); - const db = await openLibraryDb(); - await setSetting(db, ARTIST_GROUPING_KEY, mode); + await AstraLibraryData.setSettings({ [ARTIST_GROUPING_KEY]: mode }); }, setIncludeSingles: async (include) => { if (get().includeSingles === include) return; set({ includeSingles: include }); - const db = await openLibraryDb(); - await setSetting(db, INCLUDE_SINGLES_KEY, include ? 'true' : 'false'); + await AstraLibraryData.setSettings({ [INCLUDE_SINGLES_KEY]: include ? 'true' : 'false' }); }, setScopeMode: async (mode) => { if (get().scopeMode === mode) return; set({ scopeMode: mode }); - const db = await openLibraryDb(); - await setSetting(db, SCOPE_MODE_KEY, mode); + await AstraLibraryData.setSettings({ [SCOPE_MODE_KEY]: mode }); }, setScopeStageVisible: async (visible) => { if (get().scopeStageVisible === visible) return; set({ scopeStageVisible: visible }); - const db = await openLibraryDb(); - await setSetting(db, SCOPE_STAGE_VISIBLE_KEY, visible ? 'true' : 'false'); + await AstraLibraryData.setSettings({ [SCOPE_STAGE_VISIBLE_KEY]: visible ? 'true' : 'false' }); }, setNowPlayingScopeStyle: async (style) => { if (get().nowPlayingScopeStyle === style) return; set({ nowPlayingScopeStyle: style }); - const db = await openLibraryDb(); - await setSetting(db, SCOPE_STYLE_KEY, style); + await AstraLibraryData.setSettings({ [SCOPE_STYLE_KEY]: style }); }, setLyricsVisible: async (visible) => { if (get().lyricsVisible === visible) return; set({ lyricsVisible: visible }); - const db = await openLibraryDb(); - await setSetting(db, LYRICS_VISIBLE_KEY, visible ? 'true' : 'false'); + await AstraLibraryData.setSettings({ [LYRICS_VISIBLE_KEY]: visible ? 'true' : 'false' }); }, setNowPlayingCompanion: async (companion) => { if (get().nowPlayingCompanion === companion) return; set({ nowPlayingCompanion: companion }); - const db = await openLibraryDb(); - await setSetting(db, NOW_PLAYING_COMPANION_KEY, companion); + await AstraLibraryData.setSettings({ [NOW_PLAYING_COMPANION_KEY]: companion }); }, setHomeGreetingTextMode: async (mode) => { const nextMode = parseHomeGreetingTextMode(mode); if (get().homeGreetingTextMode === nextMode) return; set({ homeGreetingTextMode: nextMode }); - const db = await openLibraryDb(); - await setSetting(db, HOME_GREETING_TEXT_MODE_KEY, nextMode); + await AstraLibraryData.setSettings({ [HOME_GREETING_TEXT_MODE_KEY]: nextMode }); }, })); diff --git a/src/stores/sleepTimerStore.ts b/src/stores/sleepTimerStore.ts index a3e4898..172985b 100644 --- a/src/stores/sleepTimerStore.ts +++ b/src/stores/sleepTimerStore.ts @@ -1,7 +1,6 @@ import TrackPlayer from 'react-native-track-player'; import { create } from 'zustand'; -import { openLibraryDb } from '@/db/database'; -import { getSetting, setSetting } from '@/db/queries'; +import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings'; import { setPauseAtEndOfItem } from '@/audio/trackPlayerExtensions'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { @@ -37,8 +36,7 @@ function clearDeadlineTimer(): void { } async function persistTimer(timer: PersistedSleepTimerState | null): Promise { - const db = await openLibraryDb(); - await setSetting(db, SLEEP_TIMER_KEY, timer ? JSON.stringify(timer) : ''); + await setNativeSetting(SLEEP_TIMER_KEY, timer ? JSON.stringify(timer) : ''); } async function hasActivePhoneTrack(): Promise { @@ -77,8 +75,7 @@ export const useSleepTimerStore = create((set, get) => ({ if (get().hydrated) return; if (hydrationPromise) return hydrationPromise; hydrationPromise = (async () => { - const db = await openLibraryDb(); - const raw = await getSetting(db, SLEEP_TIMER_KEY); + const raw = await getNativeSetting(SLEEP_TIMER_KEY); let parsed: unknown = null; try { parsed = raw ? JSON.parse(raw) : null; diff --git a/src/stores/themeStore.ts b/src/stores/themeStore.ts index 1a70bdc..98b1121 100644 --- a/src/stores/themeStore.ts +++ b/src/stores/themeStore.ts @@ -1,8 +1,7 @@ import { Appearance } from 'react-native'; import { create } from 'zustand'; import { AstraSystemColors, type SystemPalette } from '../../modules/astra-system-colors'; -import { openLibraryDb } from '@/db/database'; -import { getSetting, setSetting } from '@/db/queries'; +import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings'; import { parseAccentId, DEFAULT_ACCENT, type AccentId } from '@/theme/accents'; import { parseBaseTheme, @@ -71,11 +70,10 @@ export const useThemeStore = create((set, get) => ({ load: async () => { if (get().loaded) return; - const db = await openLibraryDb(); const [base, dark, accent] = await Promise.all([ - getSetting(db, BASE_THEME_KEY), - getSetting(db, PREFERRED_DARK_KEY), - getSetting(db, ACCENT_KEY), + getNativeSetting(BASE_THEME_KEY), + getNativeSetting(PREFERRED_DARK_KEY), + getNativeSetting(ACCENT_KEY), ]); if (get().materialYouAvailable) { materialYouRamps = AstraSystemColors.getSystemPalette(); @@ -93,24 +91,21 @@ export const useThemeStore = create((set, get) => ({ if (get().baseTheme === id) return; const inputs: ResolutionInputs = { ...get(), baseTheme: id }; set({ baseTheme: id, theme: recompute(inputs) }); - const db = await openLibraryDb(); - await setSetting(db, BASE_THEME_KEY, id); + await setNativeSetting(BASE_THEME_KEY, id); }, setPreferredDark: async (id) => { if (get().preferredDark === id) return; const inputs: ResolutionInputs = { ...get(), preferredDark: id }; set({ preferredDark: id, theme: recompute(inputs) }); - const db = await openLibraryDb(); - await setSetting(db, PREFERRED_DARK_KEY, id); + await setNativeSetting(PREFERRED_DARK_KEY, id); }, setAccent: async (id) => { if (get().accentId === id) return; const inputs: ResolutionInputs = { ...get(), accentId: id }; set({ accentId: id, theme: recompute(inputs) }); - const db = await openLibraryDb(); - await setSetting(db, ACCENT_KEY, id); + await setNativeSetting(ACCENT_KEY, id); }, refreshSystemInputs: () => {