initial db rewrite

This commit is contained in:
Boof2015
2026-07-24 02:18:37 -04:00
parent 26fba836f4
commit a10ecf4a0f
90 changed files with 12669 additions and 5173 deletions
@@ -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")
}
@@ -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')"
]
}
}
@@ -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<Context>()
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<Context>()
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<TrackEntity>,
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),
)
}
@@ -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<Map<String, Any?>> {
repository().initialize().toMap()
}
Function("getCurrentStatus") {
repository().status().toMap()
}
AsyncFunction("getSettings") Coroutine { keys: List<String> ->
repositoryCall { getSettings(keys) }
}
AsyncFunction("setSettings") Coroutine { values: Map<String, String?> ->
repositoryCall { setSettings(values) }
}
AsyncFunction("listFolders").Coroutine<List<Map<String, Any?>>> {
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<String> ->
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<Map<String, Any?>> {
repository().getLibraryLoudnessStats()
}
AsyncFunction("getWaveform") Coroutine { path: String ->
repository().getWaveform(path)
}
AsyncFunction("putWaveform") Coroutine { path: String, peaks: List<Double> ->
repository().putWaveform(path, peaks)
}
AsyncFunction("countWaveforms").Coroutine<Double> {
repository().countWaveforms().toDouble()
}
AsyncFunction("clearWaveforms").Coroutine<Unit> {
repository().clearWaveforms()
}
AsyncFunction("getLyrics") Coroutine { path: String, metadataSignature: String ->
repository().getLyrics(path, metadataSignature)
}
AsyncFunction("putLyrics") Coroutine { path: String, values: Map<String, Any?> ->
repository().putLyrics(path, values)
}
AsyncFunction("deleteLyrics") Coroutine { path: String ->
repository().deleteLyrics(path)
}
AsyncFunction("countLyrics").Coroutine<Double> {
repository().countLyrics().toDouble()
}
AsyncFunction("clearLyrics").Coroutine<Unit> {
repository().clearLyrics()
}
AsyncFunction("readMobileSession").Coroutine<String?> {
repositoryCall { readMobileSession() }
}
AsyncFunction("writeMobileSession") Coroutine { snapshotJson: String ->
repositoryCall { writeMobileSession(snapshotJson) }
}
AsyncFunction("createPlaybackContext") Coroutine {
context: Map<String, Any?>,
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<Map<String, Any?>?> {
repositoryCall { restorePlaybackContext() }
}
AsyncFunction("mutatePlaybackContext") Coroutine {
operation: String,
values: Map<String, Any?>,
->
repositoryCall { mutatePlaybackContext(operation, values) }
}
AsyncFunction("recordTrackPlayed") Coroutine { path: String ->
repositoryCall { recordTrackPlayed(path) }
}
AsyncFunction("getRecentlyPlayed") Coroutine { limit: Int ->
repositoryCall { getRecentlyPlayed(limit) }
}
AsyncFunction("listRemoteSources").Coroutine<List<Map<String, Any?>>> {
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<String, Any?> ->
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<String>,
playlists: List<Map<String, Any?>>,
->
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<Map<String, Any?>>,
->
repository().appendRemoteTracks(syncId, rows)
}
AsyncFunction("commitRemoteSync") Coroutine { syncId: String ->
repository().commitRemoteSync(syncId)
}
AsyncFunction("abortRemoteSync") Coroutine { syncId: String ->
repository().abortRemoteSync(syncId)
}
AsyncFunction("listPlaylists").Coroutine<List<Map<String, Any?>>> {
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<Map<String, Any?>>,
->
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<List<String>> {
repositoryCall { getFavoritePaths() }
}
AsyncFunction("getFavoriteTracks") Coroutine { limit: Int ->
repositoryCall { getFavoriteTracks(limit) }
}
AsyncFunction("setFavorite") Coroutine { path: String, favorite: Boolean ->
repositoryCall { setFavorite(path, favorite) }
}
AsyncFunction("getDesktopSyncState").Coroutine<Map<String, Any?>> {
repositoryCall { getDesktopSyncState() }
}
AsyncFunction("applyDesktopSyncPlan") Coroutine { plan: Map<String, Any?> ->
repositoryCall { applyDesktopSyncPlan(plan) }
}
AsyncFunction("resolveDesktopSyncConflict") Coroutine {
conflict: Map<String, Any?>,
resolution: String,
mergedPlaylist: Map<String, Any?>?,
->
repositoryCall { resolveDesktopSyncConflict(conflict, resolution, mergedPlaylist) }
}
AsyncFunction("clearDesktopSyncBaselines").Coroutine<Unit> {
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<Unit> {
repositoryCall { flushSnapshot() }
}
}
private fun repository(): AstraLibraryRepository {
val existing = repository
if (existing != null) return existing
return AstraLibraryRepository.get(requireContext()).also { repository = it }
}
private suspend fun <T> repositoryCall(
block: suspend AstraLibraryRepository.() -> T,
): T = repository().withUserRecovery(block)
private fun requireContext(): Context =
appContext.reactContext ?: throw Exceptions.ReactContextLost()
}
@@ -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<String>,
->
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<Map<String, Any?>> ?: emptyList()
@Suppress("UNCHECKED_CAST")
val covers = listing["covers"] as? Map<String, String> ?: 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<String, Any?> {
return extractOne(request.uri, request.coverUri)
}
private fun extractOne(uriString: String, coverUri: String?): Map<String, Any?> {
val context = requireContext()
val uri = Uri.parse(request.uri)
val result = mutableMapOf<String, Any?>("uri" to request.uri, "ok" to true)
val uri = Uri.parse(uriString)
val result = mutableMapOf<String, Any?>("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<String, Any?>.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)
// ---------------------------------------------------------------------------
@@ -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,
)
@@ -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<AlbumIdentityUpdate>,
val albums: List<AlbumSummaryEntity>,
val artists: List<ArtistSummaryEntity>,
val artistTrackIndex: List<ArtistTrackIndexEntity>,
val directories: List<DirectorySummaryEntity>,
val ftsRows: List<TrackFtsEntity>,
)
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<String, Any> = 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<TrackEntity>,
revision: Long,
folders: Map<Long, FolderEntity> = 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<String, String> {
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<TrackEntity>): List<SettledTrack> {
val settled = ArrayList<SettledTrack>(tracks.size)
val missingAlbumArtist = LinkedHashMap<String, MutableList<PreparedAlbumTrack>>()
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<SettledTrack>,
revision: Long,
): List<AlbumSummaryEntity> {
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<SettledTrack>,
revision: Long,
): Pair<List<ArtistSummaryEntity>, List<ArtistTrackIndexEntity>> {
val result = mutableListOf<ArtistSummaryEntity>()
val index = mutableListOf<ArtistTrackIndexEntity>()
for (mode in listOf("astra", "fileTags")) {
data class Aggregate(
val name: String,
var trackCount: Long = 0,
var primaryCount: Long = 0,
val albumKeys: MutableSet<String> = linkedSetOf(),
var artworkTrack: TrackEntity? = null,
val artworkHashes: MutableSet<String> = linkedSetOf(),
)
val aggregates = LinkedHashMap<String, Aggregate>()
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<SettledTrack>,
revision: Long,
folders: Map<Long, FolderEntity>,
): List<DirectorySummaryEntity> {
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<String, MutableDirectory>()
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<String> {
val result = LinkedHashMap<String, String>()
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<String> =
splitArtists(raw, splitAmpersand = true)
private fun splitAlbumArtists(raw: String): List<String> =
splitArtists(raw, splitAmpersand = false)
private fun splitArtists(raw: String, splitAmpersand: Boolean): List<String> {
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<String, String>()
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<String>, fallback: String): String =
values.groupingBy(::normalizeKey).eachCount().entries
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
.firstOrNull()
?.key
?.let { key -> values.filter { normalizeKey(it) == key }.minByOrNull(SortKeys::forText) }
?: fallback
private fun mostFrequentNullable(values: List<String?>): String? =
values.filterNotNull().filter(String::isNotBlank).groupingBy { it }.eachCount().entries
.sortedWith(compareByDescending<Map.Entry<String, Int>> { 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"
}
@@ -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<ActiveTrackView>) {
private val byFileName = HashMap<String, MutableList<MatchCandidate>>()
private val byTitleArtistAlbum = HashMap<String, ActiveTrackView?>()
private val byTitleArtist = HashMap<String, ActiveTrackView?>()
private val byTitle = HashMap<String, ActiveTrackView?>()
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<String, ActiveTrackView?>,
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<String>, 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<String, Any?>.mapList(key: String): List<Map<String, Any?>> =
(this[key] as? List<*>)?.mapNotNull { it as? Map<String, Any?> }.orEmpty()
private fun Map<String, Any?>.stringList(key: String): List<String> =
(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<String, Any?> = mapOf(
"syncUid" to syncUid,
"status" to status,
"entriesMatched" to entriesMatched,
"entriesFallback" to entriesFallback,
)
}
internal object NativeDesktopSync {
suspend fun getState(
userDatabase: AstraUserDatabase,
catalogDatabase: AstraCatalogDatabase,
): Map<String, Any?> {
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<String, MutableMap<String, Any?>>()
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<String>).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<String>(),
"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<String, Any?>,
): Map<String, Any?> {
val userDao = userDatabase.userDao()
val matcher = NativeTrackMatcher(
catalogDatabase.catalogDao().getAllActiveTracksForNativeMatching(),
)
val playlistResults = mutableListOf<PlaylistApplyResult>()
var favoritesAdded = 0
var favoritesPending = 0
var favoritesRemoved = 0
userDatabase.withTransaction {
@Suppress("UNCHECKED_CAST")
val settings = plan["settings"] as? Map<String, Any?> ?: 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<String, Any?>,
resolution: String,
mergedPlaylist: Map<String, Any?>?,
) {
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<String, Any?>,
): 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<PlaylistTrackEntity>()
val seen = hashSetOf<String>()
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,
)
}
}
@@ -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<String>()
val args = mutableListOf<Any?>()
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<String>, args: MutableList<Any?>) {
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<String>, args: MutableList<Any?>) {
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<String>, args: MutableList<Any?>) {
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<String>, args: MutableList<Any?>) {
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("_", "\\_")
}
@@ -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<String, Any?> = 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<String, Any?> = 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<String, Any?> = 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<String, Any?> = 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<String>()),
)
fun RemoteSourceEntity.toBridgeMap(): Map<String, Any?> = 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<String, Any?> = 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(),
)
@@ -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
}
}
@@ -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<PlaylistTrackEntity>,
)
@Dao
interface UserDao {
@Query("SELECT * FROM settings WHERE key IN (:keys)")
suspend fun getSettings(keys: List<String>): List<SettingEntity>
@Query("SELECT value FROM settings WHERE key = :key")
suspend fun getSetting(key: String): String?
@Upsert
suspend fun putSettings(settings: List<SettingEntity>)
@Query("DELETE FROM settings WHERE key IN (:keys)")
suspend fun deleteSettings(keys: List<String>)
@Query("SELECT * FROM settings ORDER BY key")
suspend fun snapshotSettings(): List<SettingEntity>
@Query("SELECT * FROM folders ORDER BY added_at, id")
suspend fun getFolders(): List<FolderEntity>
@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<FolderEntity>)
@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<PlaylistEntity>
@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<PlaylistEntity>
@Insert
suspend fun insertPlaylist(playlist: PlaylistEntity): Long
@Upsert
suspend fun putPlaylist(playlist: PlaylistEntity)
@Upsert
suspend fun putPlaylists(playlists: List<PlaylistEntity>)
@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<PlaylistTrackEntity>
@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<PlaylistTrackEntity>
@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<PlaylistTrackEntity>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertPlaylistTracks(entries: List<PlaylistTrackEntity>): List<Long>
@Upsert
suspend fun putPlaylistTracks(entries: List<PlaylistTrackEntity>)
@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<FavoriteEntity>
@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<FavoriteEntity>)
@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<PlaylistEntity>
@Query("SELECT * FROM playback_history ORDER BY last_played_at DESC")
suspend fun getPlaybackHistory(): List<PlaybackHistoryEntity>
@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<PlaybackHistoryEntity>)
@Query("SELECT * FROM remote_sources ORDER BY created_at, id")
suspend fun getRemoteSources(): List<RemoteSourceEntity>
@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<RemoteSourceEntity>)
@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<FavoriteTombstoneEntity>
@Upsert
suspend fun putFavoriteTombstones(rows: List<FavoriteTombstoneEntity>)
@Query("DELETE FROM favorite_tombstones WHERE sync_key IN (:syncKeys)")
suspend fun deleteFavoriteTombstones(syncKeys: List<String>)
@Query("SELECT * FROM favorite_sync_pending ORDER BY sync_key")
suspend fun getPendingFavorites(): List<PendingFavoriteEntity>
@Upsert
suspend fun putPendingFavorites(rows: List<PendingFavoriteEntity>)
@Query("DELETE FROM favorite_sync_pending WHERE sync_key IN (:syncKeys)")
suspend fun deletePendingFavorites(syncKeys: List<String>)
@Query("SELECT * FROM playlist_tombstones ORDER BY sync_uid")
suspend fun getPlaylistTombstones(): List<PlaylistTombstoneEntity>
@Upsert
suspend fun putPlaylistTombstones(rows: List<PlaylistTombstoneEntity>)
@Query("DELETE FROM playlist_tombstones WHERE sync_uid IN (:syncUids)")
suspend fun deletePlaylistTombstones(syncUids: List<String>)
@Query("SELECT * FROM playlist_sync_state ORDER BY sync_uid")
suspend fun getPlaylistSyncStates(): List<PlaylistSyncStateEntity>
@Upsert
suspend fun putPlaylistSyncStates(rows: List<PlaylistSyncStateEntity>)
@Query("DELETE FROM playlist_sync_state WHERE sync_uid IN (:syncUids)")
suspend fun deletePlaylistSyncStates(syncUids: List<String>)
@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<PlaybackSessionEntity>
@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<PlaybackQueueEntryEntity>
@Query("SELECT * FROM playback_queue_entries WHERE session_id = :sessionId ORDER BY position")
suspend fun getAllQueueEntries(sessionId: String): List<PlaybackQueueEntryEntity>
@Upsert
suspend fun putQueueEntries(entries: List<PlaybackQueueEntryEntity>)
@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<PlaybackOriginalQueueEntryEntity>
@Upsert
suspend fun putOriginalQueueEntries(entries: List<PlaybackOriginalQueueEntryEntity>)
@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<PlaybackQueueEntryEntity>,
originalEntries: List<PlaybackOriginalQueueEntryEntity> = 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<FavoriteEntity>,
playlists: List<RemotePlaylistSyncPlan>,
) {
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<PlaylistTrackEntity>,
) {
clearPlaylistTracks(playlistId)
if (entries.isNotEmpty()) putPlaylistTracks(entries)
}
@Transaction
suspend fun appendPlaylistTracks(
playlistId: Long,
entries: List<PlaylistTrackEntity>,
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
}
@@ -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,
)
@@ -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 <T> List<T>.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 <T> JSONArray.mapObjects(transform: (JSONObject) -> T): List<T> =
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"),
)
@@ -1,6 +1,9 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.astralibraryscanner.AstraLibraryScannerModule"]
"modules": [
"expo.modules.astralibraryscanner.AstraLibraryScannerModule",
"expo.modules.astralibraryscanner.AstraLibraryDataModule"
]
}
}
+317 -2
View File
@@ -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<AstraLibraryScannerEvents> {
listAudioFiles(treeUri: string, extensions: string[]): Promise<ListResult>;
extractMetadata(files: { uri: string; coverUri?: string | null }[]): Promise<ExtractedMetadata[]>;
scanFolderNative(
folderId: number,
mode: 'incremental' | 'full',
extensions: string[]
): Promise<NativeScanResult>;
/**
* 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<AstraLibrarySca
export const AstraLibraryScanner =
requireNativeModule<AstraLibraryScannerModuleType>('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<T> {
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<T> {
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<AstraLibraryDataEvents> {
initialize(): Promise<LibraryStatusSnapshot>;
getCurrentStatus(): LibraryStatusSnapshot;
getSettings(keys: string[]): Promise<Record<string, string | null>>;
setSettings(values: Record<string, string | null>): Promise<void>;
listFolders(): Promise<Record<string, unknown>[]>;
getFolderNodes(parentNodeId: string | null): Promise<NativeFolderNode[]>;
getFolderTracks<T>(
nodeId: string,
offset: number,
limit: number
): Promise<{
items: T[];
nextOffset: number | null;
totalCount: number;
catalogRevision: string;
}>;
registerFolder(treeUri: string, displayName: string): Promise<Record<string, unknown>>;
removeFolder(folderId: number): Promise<void>;
getTrackPage<T>(
sort: 'artist' | 'title' | 'recently_added' | 'duration',
cursor: string | null,
limit: number
): Promise<NativePage<T>>;
getTrack<T>(path: string): Promise<T | null>;
getTrackLoudness(paths: string[]): Promise<NativeTrackLoudness[]>;
setTrackLoudness(path: string, lufs: number | null, samplePeak: number | null): Promise<void>;
setTrackReplayGain(
path: string,
trackGainDb: number | null,
albumGainDb: number | null,
trackPeak: number | null,
albumPeak: number | null
): Promise<void>;
getLibraryLoudnessStats(): Promise<NativeLibraryLoudnessStats>;
getWaveform(path: string): Promise<number[] | null>;
putWaveform(path: string, peaks: number[]): Promise<void>;
countWaveforms(): Promise<number>;
clearWaveforms(): Promise<void>;
getLyrics<T>(path: string, metadataSignature: string): Promise<T | null>;
putLyrics(path: string, values: Record<string, unknown>): Promise<void>;
deleteLyrics(path: string): Promise<void>;
countLyrics(): Promise<number>;
clearLyrics(): Promise<void>;
readMobileSession(): Promise<string | null>;
writeMobileSession(snapshotJson: string): Promise<void>;
createPlaybackContext<T>(
context: LibraryQuery,
anchorPath: string | null,
shuffle: boolean,
seed: number | null
): Promise<NativePlaybackWindow<T>>;
getPlaybackWindow<T>(
sessionId: string,
start: number,
limit: number
): Promise<NativePlaybackWindow<T>>;
updatePlaybackPosition(sessionId: string, activePosition: number): Promise<void>;
restorePlaybackContext<T>(): Promise<NativePlaybackWindow<T> | null>;
mutatePlaybackContext<T>(
operation:
| 'insertAfterActive'
| 'append'
| 'insertQueryAfterActive'
| 'appendQuery'
| 'remove'
| 'move'
| 'moveManyAfterActive'
| 'shuffle',
values: Record<string, unknown>
): Promise<NativePlaybackWindow<T> | null>;
recordTrackPlayed(path: string): Promise<boolean>;
getRecentlyPlayed<T>(limit: number): Promise<T[]>;
listRemoteSources<T>(): Promise<T[]>;
getRemoteSource<T>(sourceId: number): Promise<T | null>;
createRemoteSource<T>(
type: 'subsonic' | 'jellyfin',
name: string,
baseUrl: string,
username: string,
enabled: boolean
): Promise<T>;
updateRemoteSource(sourceId: number, fields: Record<string, unknown>): Promise<void>;
setRemoteSourceStatus(sourceId: number, status: string, error: string | null): Promise<void>;
deleteRemoteSource(sourceId: number, purgeCatalog: boolean): Promise<void>;
replaceRemoteUserState(
sourceId: number,
sourceType: 'subsonic' | 'jellyfin',
favoritePaths: string[],
playlists: Record<string, unknown>[]
): Promise<void>;
beginRemoteSync(sourceId: number, sourceType: 'subsonic' | 'jellyfin'): Promise<string>;
appendRemoteTracks(syncId: string, rows: Record<string, unknown>[]): Promise<number>;
commitRemoteSync(
syncId: string
): Promise<{ tracksScanned: number; removed: number; catalogRevision: string }>;
abortRemoteSync(syncId: string): Promise<void>;
listPlaylists<T>(): Promise<T[]>;
createPlaylist<T>(name: string, kind: 'normal' | 'dynamic', rulesJson: string | null): Promise<T>;
getDynamicPlaylistRules(playlistId: number): Promise<string>;
updateDynamicPlaylistRules(playlistId: number, rulesJson: string): Promise<void>;
previewDynamicPlaylist<T>(rulesJson: string): Promise<T>;
renamePlaylist(playlistId: number, name: string): Promise<void>;
deletePlaylist(playlistId: number): Promise<void>;
markPlaylistPlayed(playlistId: number): Promise<void>;
addPlaylistEntries(
playlistId: number,
entries: {
trackPath: string;
fallbackTitle?: string | null;
fallbackArtist?: string | null;
fallbackAlbum?: string | null;
}[]
): Promise<number>;
removePlaylistEntry(playlistId: number, path: string): Promise<void>;
movePlaylistEntry(playlistId: number, path: string, direction: -1 | 1): Promise<void>;
getPlaylistEntries<T>(
playlistId: number,
offset: number,
limit: number
): Promise<{ items: T[]; nextOffset: number | null; totalCount: number }>;
getFavoritePaths(): Promise<string[]>;
getFavoriteTracks<T>(limit: number): Promise<T[]>;
setFavorite(path: string, favorite: boolean): Promise<void>;
getDesktopSyncState<T>(): Promise<T>;
applyDesktopSyncPlan<T>(plan: Record<string, unknown>): Promise<T>;
resolveDesktopSyncConflict(
conflict: Record<string, unknown>,
resolution: 'desktop' | 'phone' | 'both' | 'merge',
mergedPlaylist: Record<string, unknown> | null
): Promise<void>;
clearDesktopSyncBaselines(): Promise<void>;
getAlbumPage<T>(
sort: 'artist' | 'name' | 'recently_added' | 'year',
includeSingles: boolean,
cursor: string | null,
limit: number
): Promise<NativePage<T>>;
getArtistPage<T>(
sort: 'name' | 'track_count',
groupingMode: 'astra' | 'fileTags',
includeCollaborations: boolean,
cursor: string | null,
limit: number
): Promise<NativePage<T>>;
getAlbumDetail<T, S = Record<string, unknown>>(
albumKey: string,
cursor: string | null,
limit: number
): Promise<NativePage<T> & { summary: S | null }>;
getArtistDetail<T, S = Record<string, unknown>>(
artistKey: string,
groupingMode: 'astra' | 'fileTags',
section: 'songs' | 'appearances' | 'all',
cursor: string | null,
limit: number
): Promise<NativePage<T> & { summary: S | null }>;
getArtistAlbums<T>(
artistKey: string,
groupingMode: 'astra' | 'fileTags',
offset: number,
limit: number
): Promise<{
items: T[];
nextOffset: number | null;
totalCount: number;
catalogRevision: string;
}>;
searchTracks<T>(query: string, limit: number): Promise<T[]>;
searchLibrary<TTrack, TAlbum, TArtist>(
query: string,
limit: number,
includeSingles: boolean,
groupingMode: 'astra' | 'fileTags',
includeCollaborations: boolean
): Promise<{ tracks: TTrack[]; albums: TAlbum[]; artists: TArtist[] }>;
matchSignal<T>(
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<LibrarySectionAnchor[]>;
flushUserSnapshot(): Promise<void>;
}
export const AstraLibraryData =
requireNativeModule<AstraLibraryDataModuleType>('AstraLibraryData');