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
@@ -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"),
)