mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-17 11:12:33 +02:00
initial db rewrite
This commit is contained in:
@@ -1,8 +1,16 @@
|
||||
buildscript {
|
||||
dependencies {
|
||||
classpath "com.google.devtools.ksp:symbol-processing-gradle-plugin:${rootProject["kspVersion"]}"
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'expo-module-gradle-plugin'
|
||||
}
|
||||
|
||||
apply plugin: 'com.google.devtools.ksp'
|
||||
|
||||
group = 'expo.modules.astralibraryscanner'
|
||||
version = '0.1.0'
|
||||
|
||||
@@ -11,6 +19,10 @@ android {
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
sourceSets {
|
||||
androidTest.assets.srcDirs += files("$projectDir/schemas")
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
@@ -22,5 +34,19 @@ dependencies {
|
||||
// MetadataRetriever — parses ID3/Vorbis/MP4 container tags (ReplayGain) without
|
||||
// decoding audio. Transitively brings exoplayer-extractor (the frame classes).
|
||||
implementation 'com.google.android.exoplayer:exoplayer-core:2.19.0'
|
||||
implementation 'androidx.room:room-runtime:2.8.4'
|
||||
implementation 'androidx.room:room-ktx:2.8.4'
|
||||
ksp 'androidx.room:room-compiler:2.8.4'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.room:room-testing:2.8.4'
|
||||
androidTestImplementation 'androidx.test:core:1.6.1'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||
androidTestImplementation 'androidx.test:runner:1.6.2'
|
||||
}
|
||||
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
arg("room.generateKotlin", "true")
|
||||
}
|
||||
|
||||
+1149
File diff suppressed because it is too large
Load Diff
+755
@@ -0,0 +1,755 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "5d8a5f2a676148b233470aedd5d05cee",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "key",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "value",
|
||||
"columnName": "value",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"key"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tree_uri` TEXT NOT NULL, `display_name` TEXT NOT NULL, `added_at` INTEGER NOT NULL, `last_scanned_at` INTEGER, `last_scan_status` TEXT NOT NULL, `last_scan_error` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "treeUri",
|
||||
"columnName": "tree_uri",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "display_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "addedAt",
|
||||
"columnName": "added_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastScannedAt",
|
||||
"columnName": "last_scanned_at",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastScanStatus",
|
||||
"columnName": "last_scan_status",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastScanError",
|
||||
"columnName": "last_scan_error",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_folders_tree_uri",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"tree_uri"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_folders_tree_uri` ON `${TABLE_NAME}` (`tree_uri`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "playlists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `last_played_at` INTEGER, `kind` TEXT NOT NULL, `dynamic_rules_json` TEXT, `remote_source_id` INTEGER, `remote_playlist_id` TEXT, `sync_uid` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updated_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastPlayedAt",
|
||||
"columnName": "last_played_at",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "kind",
|
||||
"columnName": "kind",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dynamicRulesJson",
|
||||
"columnName": "dynamic_rules_json",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "remoteSourceId",
|
||||
"columnName": "remote_source_id",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "remotePlaylistId",
|
||||
"columnName": "remote_playlist_id",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "syncUid",
|
||||
"columnName": "sync_uid",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playlists_sync_uid",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"sync_uid"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlists_sync_uid` ON `${TABLE_NAME}` (`sync_uid`)"
|
||||
},
|
||||
{
|
||||
"name": "index_playlists_remote_source_id_remote_playlist_id",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"remote_source_id",
|
||||
"remote_playlist_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlists_remote_source_id_remote_playlist_id` ON `${TABLE_NAME}` (`remote_source_id`, `remote_playlist_id`)"
|
||||
},
|
||||
{
|
||||
"name": "index_playlists_kind",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"kind"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_kind` ON `${TABLE_NAME}` (`kind`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "playlist_tracks",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlist_id` INTEGER NOT NULL, `track_path` TEXT NOT NULL, `position` INTEGER NOT NULL, `added_at` INTEGER NOT NULL, `fallback_title` TEXT, `fallback_artist` TEXT, `fallback_album` TEXT, FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "playlistId",
|
||||
"columnName": "playlist_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackPath",
|
||||
"columnName": "track_path",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "addedAt",
|
||||
"columnName": "added_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fallbackTitle",
|
||||
"columnName": "fallback_title",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fallbackArtist",
|
||||
"columnName": "fallback_artist",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fallbackAlbum",
|
||||
"columnName": "fallback_album",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playlist_tracks_playlist_id_position",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"playlist_id",
|
||||
"position"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_tracks_playlist_id_position` ON `${TABLE_NAME}` (`playlist_id`, `position`)"
|
||||
},
|
||||
{
|
||||
"name": "index_playlist_tracks_playlist_id_track_path",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"playlist_id",
|
||||
"track_path"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_tracks_playlist_id_track_path` ON `${TABLE_NAME}` (`playlist_id`, `track_path`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "playlists",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"playlist_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "favorites",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_path` TEXT NOT NULL, `added_at` INTEGER NOT NULL, PRIMARY KEY(`track_path`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "trackPath",
|
||||
"columnName": "track_path",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "addedAt",
|
||||
"columnName": "added_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"track_path"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "playback_history",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_path` TEXT NOT NULL, `last_played_at` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, PRIMARY KEY(`track_path`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "trackPath",
|
||||
"columnName": "track_path",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastPlayedAt",
|
||||
"columnName": "last_played_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "playCount",
|
||||
"columnName": "play_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"track_path"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playback_history_last_played_at",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"last_played_at"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_playback_history_last_played_at` ON `${TABLE_NAME}` (`last_played_at`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "remote_sources",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `name` TEXT NOT NULL, `base_url` TEXT NOT NULL, `username` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `last_status` TEXT NOT NULL, `last_error` TEXT, `last_sync_at` INTEGER, `last_checked_at` INTEGER, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "base_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "enabled",
|
||||
"columnName": "enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus",
|
||||
"columnName": "last_status",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastError",
|
||||
"columnName": "last_error",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSyncAt",
|
||||
"columnName": "last_sync_at",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastCheckedAt",
|
||||
"columnName": "last_checked_at",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updated_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_remote_sources_type_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"type",
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_remote_sources_type_name` ON `${TABLE_NAME}` (`type`, `name`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "favorite_tombstones",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_key` TEXT NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`sync_key`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "syncKey",
|
||||
"columnName": "sync_key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "deletedAt",
|
||||
"columnName": "deleted_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"sync_key"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "favorite_sync_pending",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_key` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `added_at` INTEGER NOT NULL, PRIMARY KEY(`sync_key`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "syncKey",
|
||||
"columnName": "sync_key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artist",
|
||||
"columnName": "artist",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "album",
|
||||
"columnName": "album",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "addedAt",
|
||||
"columnName": "added_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"sync_key"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "playlist_tombstones",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_uid` TEXT NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`sync_uid`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "syncUid",
|
||||
"columnName": "sync_uid",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "deletedAt",
|
||||
"columnName": "deleted_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"sync_uid"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "playlist_sync_state",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_uid` TEXT NOT NULL, `local_updated_at` INTEGER NOT NULL, `remote_updated_at` INTEGER NOT NULL, PRIMARY KEY(`sync_uid`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "syncUid",
|
||||
"columnName": "sync_uid",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "localUpdatedAt",
|
||||
"columnName": "local_updated_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "remoteUpdatedAt",
|
||||
"columnName": "remote_updated_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"sync_uid"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "playback_sessions",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `context_json` TEXT NOT NULL, `anchor_path` TEXT, `shuffle_seed` INTEGER, `active_position` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contextJson",
|
||||
"columnName": "context_json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "anchorPath",
|
||||
"columnName": "anchor_path",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "shuffleSeed",
|
||||
"columnName": "shuffle_seed",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "activePosition",
|
||||
"columnName": "active_position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "created_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updated_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "playback_queue_entries",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `position` INTEGER NOT NULL, `track_path` TEXT NOT NULL, PRIMARY KEY(`session_id`, `position`), FOREIGN KEY(`session_id`) REFERENCES `playback_sessions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "sessionId",
|
||||
"columnName": "session_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackPath",
|
||||
"columnName": "track_path",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"session_id",
|
||||
"position"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playback_queue_entries_session_id_track_path",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"session_id",
|
||||
"track_path"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_playback_queue_entries_session_id_track_path` ON `${TABLE_NAME}` (`session_id`, `track_path`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "playback_sessions",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "playback_original_queue_entries",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `position` INTEGER NOT NULL, `track_path` TEXT NOT NULL, PRIMARY KEY(`session_id`, `position`), FOREIGN KEY(`session_id`) REFERENCES `playback_sessions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "sessionId",
|
||||
"columnName": "session_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackPath",
|
||||
"columnName": "track_path",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"session_id",
|
||||
"position"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playback_original_queue_entries_session_id_track_path",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"session_id",
|
||||
"track_path"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_playback_original_queue_entries_session_id_track_path` ON `${TABLE_NAME}` (`session_id`, `track_path`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "playback_sessions",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "snapshot_metadata",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `last_snapshot_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSnapshotAt",
|
||||
"columnName": "last_snapshot_at",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5d8a5f2a676148b233470aedd5d05cee')"
|
||||
]
|
||||
}
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
package expo.modules.astralibraryscanner.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.LargeTest
|
||||
import java.io.File
|
||||
import java.text.Normalizer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RoomLibraryRepositoryTest {
|
||||
private lateinit var catalog: AstraCatalogDatabase
|
||||
private lateinit var user: AstraUserDatabase
|
||||
|
||||
@Before
|
||||
fun openDatabases() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
catalog = Room.inMemoryDatabaseBuilder(context, AstraCatalogDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
user = Room.inMemoryDatabaseBuilder(context, AstraUserDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun closeDatabases() {
|
||||
catalog.close()
|
||||
user.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unicodeRoundTripsSortsAndSearchesWithoutTranslation() = runBlocking {
|
||||
val titles = listOf(
|
||||
"東京の夜",
|
||||
"Привет мир",
|
||||
"emoji 🚀 song",
|
||||
Normalizer.normalize("Café", Normalizer.Form.NFC),
|
||||
Normalizer.normalize("Café", Normalizer.Form.NFD),
|
||||
"O'Brien 100%_Mix & Friends",
|
||||
)
|
||||
publish("g1", titles.mapIndexed { index, title ->
|
||||
track(
|
||||
generation = "g1",
|
||||
index = index,
|
||||
title = title,
|
||||
path = "content://com.android.externalstorage.documents/document/primary%3AMusic%2F${title}%25_${index}.flac",
|
||||
)
|
||||
})
|
||||
|
||||
val dao = catalog.catalogDao()
|
||||
val paths = dao.getAllPathsByTitle()
|
||||
assertEquals(titles.size, paths.size)
|
||||
val rows = dao.getActiveTracks(paths)
|
||||
assertEquals(titles.toSet(), rows.map { it.title }.toSet())
|
||||
assertEquals(1, dao.searchTracksLiteral("%東京%", 10).size)
|
||||
assertEquals(1, dao.searchTracks("\"Привет\"*", 10).size)
|
||||
assertEquals(1, dao.searchTracksLiteral("%🚀%", 10).size)
|
||||
assertEquals(1, dao.searchTracksLiteral("%100\\%\\_Mix%", 10).size)
|
||||
assertTrue(dao.searchTracksLiteral("%&%", 10).isNotEmpty())
|
||||
assertTrue(rows.any { "%3A" in it.path && "%2F" in it.path })
|
||||
|
||||
val firstPage = dao.getTitlePage(null, "", 2)
|
||||
val secondPage = dao.getTitlePage(
|
||||
firstPage.last().titleSortKey,
|
||||
firstPage.last().path,
|
||||
20,
|
||||
)
|
||||
assertEquals(titles.size, (firstPage + secondPage).map { it.path }.distinct().size)
|
||||
val artistFirst = dao.getArtistOrderPage(null, "", 0, 0, "", "", 2)
|
||||
val artistAnchor = artistFirst.last()
|
||||
val artistSecond = dao.getArtistOrderPage(
|
||||
artistAnchor.artistSortKey,
|
||||
artistAnchor.albumSortKey,
|
||||
artistAnchor.discSort,
|
||||
artistAnchor.trackSort,
|
||||
artistAnchor.titleSortKey,
|
||||
artistAnchor.path,
|
||||
20,
|
||||
)
|
||||
assertEquals(titles.size, (artistFirst + artistSecond).map { it.path }.distinct().size)
|
||||
val recentFirst = dao.getRecentlyAddedPage(null, "", 2)
|
||||
val recentSecond = dao.getRecentlyAddedPage(
|
||||
recentFirst.last().addedAt,
|
||||
recentFirst.last().path,
|
||||
20,
|
||||
)
|
||||
assertEquals(titles.size, (recentFirst + recentSecond).map { it.path }.distinct().size)
|
||||
val durationFirst = dao.getDurationPage(null, "", 2)
|
||||
val durationSecond = dao.getDurationPage(
|
||||
durationFirst.last().duration,
|
||||
durationFirst.last().path,
|
||||
20,
|
||||
)
|
||||
assertEquals(titles.size, (durationFirst + durationSecond).map { it.path }.distinct().size)
|
||||
assertNotEquals(SortKeys.forText("A"), SortKeys.forText("B"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stagingGenerationIsInvisibleAndAbandonedWorkIsDiscarded() = runBlocking {
|
||||
publish("active", listOf(track("active", 1, "Last known good")))
|
||||
val dao = catalog.catalogDao()
|
||||
val revision = dao.getRevision()
|
||||
|
||||
dao.insertGeneration(ScanGenerationEntity("pending", "local:1", "staging", 2))
|
||||
dao.putTracks(listOf(track("pending", 2, "Half written scan")))
|
||||
assertEquals(listOf("Last known good"), dao.getTitlePage(null, "", 10).map { it.title })
|
||||
assertEquals(revision, dao.getRevision())
|
||||
|
||||
dao.discardAbandonedGenerations()
|
||||
assertNull(dao.getGeneration("pending"))
|
||||
assertEquals(listOf("Last known good"), dao.getTitlePage(null, "", 10).map { it.title })
|
||||
|
||||
publish("replacement", listOf(track("replacement", 3, "Published replacement")), "active")
|
||||
assertEquals(listOf("Published replacement"), dao.getTitlePage(null, "", 10).map { it.title })
|
||||
assertNull(dao.getGeneration("active"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun userMutationsAndVirtualQueueAreAtomicAndDurable() = runBlocking {
|
||||
val dao = user.userDao()
|
||||
val playlistId = dao.insertPlaylist(
|
||||
PlaylistEntity(name = "Unicode 🚀", createdAt = 1, updatedAt = 1),
|
||||
)
|
||||
assertEquals(
|
||||
2,
|
||||
dao.appendPlaylistTracks(
|
||||
playlistId,
|
||||
listOf(
|
||||
PlaylistTrackEntity(playlistId = playlistId, trackPath = "東京.flac", position = 0, addedAt = 0),
|
||||
PlaylistTrackEntity(playlistId = playlistId, trackPath = "Привет.flac", position = 0, addedAt = 0),
|
||||
),
|
||||
2,
|
||||
),
|
||||
)
|
||||
dao.putFavorite(FavoriteEntity("東京.flac", 3))
|
||||
dao.putPlaybackHistory(PlaybackHistoryEntity("東京.flac", 4, 7))
|
||||
|
||||
val session = PlaybackSessionEntity("active-context", """{"kind":"playlist","playlistId":$playlistId}""", "東京.flac", 42, 0, 5, 5)
|
||||
dao.replacePlaybackQueue(
|
||||
session,
|
||||
listOf(
|
||||
PlaybackQueueEntryEntity(session.id, 0, "東京.flac"),
|
||||
PlaybackQueueEntryEntity(session.id, 1, "Привет.flac"),
|
||||
),
|
||||
listOf(
|
||||
PlaybackOriginalQueueEntryEntity(session.id, 0, "東京.flac"),
|
||||
PlaybackOriginalQueueEntryEntity(session.id, 1, "Привет.flac"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(2L, dao.countPlaylistTracks(playlistId))
|
||||
assertTrue(dao.isFavorite("東京.flac"))
|
||||
assertEquals(7L, dao.getPlaybackHistory("東京.flac")?.playCount)
|
||||
assertEquals(listOf("東京.flac", "Привет.flac"), dao.getAllQueueEntries(session.id).map { it.trackPath })
|
||||
assertEquals(2, dao.getOriginalQueueEntries(session.id).size)
|
||||
|
||||
dao.deletePlaylistById(playlistId)
|
||||
assertEquals(0, dao.countPlaylistTracks(playlistId))
|
||||
assertEquals(2L, dao.countQueueEntries(session.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun userSnapshotsRotateRejectDamageAndRestoreTheNewestValidCopy() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val snapshotDirectory = context.filesDir.resolve("astra-user-snapshots")
|
||||
snapshotDirectory.deleteRecursively()
|
||||
val snapshots = UserSnapshotStore(context)
|
||||
val dao = user.userDao()
|
||||
|
||||
dao.putSettings(listOf(SettingEntity("theme", "old")))
|
||||
snapshots.write(user)
|
||||
Thread.sleep(10)
|
||||
dao.putSettings(listOf(SettingEntity("theme", "new")))
|
||||
snapshots.write(user)
|
||||
|
||||
val snapshotFiles = snapshotDirectory.listFiles().orEmpty().filter { it.extension == "json" }
|
||||
assertEquals(2, snapshotFiles.size)
|
||||
snapshotFiles.maxBy(File::lastModified).writeText("""{"damaged":true}""")
|
||||
|
||||
val valid = snapshots.newestValid()
|
||||
assertTrue(valid != null)
|
||||
val replacement = Room.inMemoryDatabaseBuilder(context, AstraUserDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
try {
|
||||
snapshots.restore(replacement, requireNotNull(valid))
|
||||
assertEquals("old", replacement.userDao().getSettings(listOf("theme")).single().value)
|
||||
} finally {
|
||||
replacement.close()
|
||||
snapshotDirectory.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dynamicRulesUseBoundArgumentsAndEscapeWildcards() = runBlocking {
|
||||
publish(
|
||||
"dynamic",
|
||||
listOf(
|
||||
track("dynamic", 1, "100%_Real", genre = "Rock"),
|
||||
track("dynamic", 2, "100xxReal", genre = "Jazz"),
|
||||
),
|
||||
)
|
||||
val rules = """
|
||||
{
|
||||
"conditions": [
|
||||
{"kind":"text","field":"title","operator":"contains","value":"%_"},
|
||||
{"kind":"exact","field":"favorite","operator":"is","value":true}
|
||||
],
|
||||
"sort":{"field":"title","direction":"asc"}
|
||||
}
|
||||
""".trimIndent()
|
||||
catalog.catalogDao().putTrackUserFacts(
|
||||
listOf(TrackUserFactEntity(path = "content://track/1.flac", isFavorite = true)),
|
||||
)
|
||||
val queries = DynamicPlaylistCompiler.compile(rules, 0, 100)
|
||||
val rows = catalog.catalogDao().runDynamicTrackQuery(queries.tracks)
|
||||
assertEquals(listOf("100%_Real"), rows.map { it.title })
|
||||
assertFalse(queries.tracks.sql.contains("100%_Real"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumAndCollaborativeArtistReadModelsMatchEstablishedRules() = runBlocking {
|
||||
val first = track("groups", 1, "One").copy(
|
||||
artist = "Alpha & Guest",
|
||||
album = "Shared Album",
|
||||
artworkHash = "same-cover",
|
||||
artistSortKey = SortKeys.forText("Alpha & Guest"),
|
||||
albumSortKey = SortKeys.forText("Shared Album"),
|
||||
)
|
||||
val second = track("groups", 2, "Two").copy(
|
||||
artist = "Beta",
|
||||
album = "Shared Album",
|
||||
artworkHash = "same-cover",
|
||||
artistSortKey = SortKeys.forText("Beta"),
|
||||
albumSortKey = SortKeys.forText("Shared Album"),
|
||||
)
|
||||
publish("groups", listOf(first, second))
|
||||
val dao = catalog.catalogDao()
|
||||
val revision = dao.getRevision()
|
||||
|
||||
val albums = dao.getAllAlbumSummaries(revision)
|
||||
assertEquals(1, albums.size)
|
||||
assertEquals("Various Artists", albums.single().artist)
|
||||
assertEquals(2L, albums.single().trackCount)
|
||||
|
||||
val artists = dao.getAllArtistSummaries(revision, "astra").associateBy { it.artist }
|
||||
assertTrue(artists.containsKey("Alpha"))
|
||||
assertTrue(artists.containsKey("Guest"))
|
||||
assertTrue(artists.containsKey("Beta"))
|
||||
assertEquals(0L, artists.getValue("Guest").primaryTrackCount)
|
||||
assertTrue(artists.getValue("Guest").isCollaboration)
|
||||
assertEquals(
|
||||
"One",
|
||||
dao.getArtistTrackPage(
|
||||
revision,
|
||||
"astra",
|
||||
"guest",
|
||||
"appearance",
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
10,
|
||||
).single().title,
|
||||
)
|
||||
}
|
||||
|
||||
@LargeTest
|
||||
@Test
|
||||
fun oneHundredThousandTrackKeysetPagingRemainsBounded() = runBlocking {
|
||||
val dao = catalog.catalogDao()
|
||||
dao.insertMeta(CatalogMetaEntity(collationVersion = COLLATION_VERSION, updatedAt = 0))
|
||||
dao.putSource(CatalogSourceEntity("local:1", "local", 1, null, 0))
|
||||
dao.insertGeneration(ScanGenerationEntity("stress", "local:1", "staging", 0))
|
||||
for (start in 0 until 100_000 step 1_000) {
|
||||
dao.putTracks(
|
||||
(start until start + 1_000).map { index ->
|
||||
track("stress", index, "Track ${index.toString().padStart(6, '0')}")
|
||||
},
|
||||
)
|
||||
}
|
||||
dao.setActiveGeneration("local:1", "stress", 1)
|
||||
dao.setGenerationState("stress", "active", 1, null)
|
||||
dao.incrementRevision(1)
|
||||
|
||||
assertEquals(100_000L, dao.countActiveTracks())
|
||||
val first = dao.getTitlePage(null, "", 100)
|
||||
val second = dao.getTitlePage(first.last().titleSortKey, first.last().path, 100)
|
||||
assertEquals(100, first.size)
|
||||
assertEquals(100, second.size)
|
||||
assertTrue(first.map { it.path }.intersect(second.map { it.path }.toSet()).isEmpty())
|
||||
}
|
||||
|
||||
private suspend fun publish(
|
||||
generation: String,
|
||||
tracks: List<TrackEntity>,
|
||||
previous: String? = null,
|
||||
) {
|
||||
val dao = catalog.catalogDao()
|
||||
if (dao.getMeta() == null) {
|
||||
dao.insertMeta(CatalogMetaEntity(collationVersion = COLLATION_VERSION, updatedAt = 0))
|
||||
dao.putSource(CatalogSourceEntity("local:1", "local", 1, null, 0))
|
||||
}
|
||||
dao.insertGeneration(ScanGenerationEntity(generation, "local:1", "staging", 1))
|
||||
dao.putTracks(tracks)
|
||||
val prospective = dao.getProspectiveTracks("local:1", generation)
|
||||
val revision = dao.getRevision() + 1
|
||||
val models = CatalogReadModelBuilder.build(prospective, revision)
|
||||
dao.publishGeneration(
|
||||
sourceKey = "local:1",
|
||||
generationId = generation,
|
||||
previousGenerationId = previous,
|
||||
now = revision,
|
||||
albumIdentityUpdates = models.identityUpdates,
|
||||
albums = models.albums,
|
||||
artists = models.artists,
|
||||
artistTrackIndex = models.artistTrackIndex,
|
||||
directories = models.directories,
|
||||
ftsRows = models.ftsRows,
|
||||
)
|
||||
}
|
||||
|
||||
private fun track(
|
||||
generation: String,
|
||||
index: Int,
|
||||
title: String,
|
||||
path: String = "content://track/$index.flac",
|
||||
genre: String? = null,
|
||||
): TrackEntity = TrackEntity(
|
||||
generationId = generation,
|
||||
sourceKey = "local:1",
|
||||
path = path,
|
||||
folderId = 1,
|
||||
title = title,
|
||||
artist = if (index % 2 == 0) "Björk & Rosalía" else "Кино",
|
||||
album = "Album %_${index / 2}",
|
||||
albumArtist = null,
|
||||
albumIdentityKey = "pending",
|
||||
duration = 180.0 + index,
|
||||
genre = genre,
|
||||
format = "FLAC",
|
||||
fileName = "$title.flac",
|
||||
parentUri = "content://com.android.externalstorage.documents/document/primary%3AMusic",
|
||||
mtime = index.toLong(),
|
||||
addedAt = index.toLong(),
|
||||
modifiedAt = index.toLong(),
|
||||
titleSortKey = SortKeys.forText(title),
|
||||
artistSortKey = SortKeys.forText("Artist"),
|
||||
albumSortKey = SortKeys.forText("Album"),
|
||||
fileNameSortKey = SortKeys.forText("$title.flac"),
|
||||
discSort = 0,
|
||||
trackSort = index,
|
||||
sectionLabel = SortKeys.sectionLabel(title),
|
||||
)
|
||||
}
|
||||
+472
@@ -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()
|
||||
}
|
||||
+81
-4
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+2689
File diff suppressed because it is too large
Load Diff
+1124
File diff suppressed because it is too large
Load Diff
+308
@@ -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,
|
||||
)
|
||||
+486
@@ -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"
|
||||
}
|
||||
+527
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+149
@@ -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("_", "\\_")
|
||||
}
|
||||
+239
@@ -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(),
|
||||
)
|
||||
+39
@@ -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
|
||||
}
|
||||
}
|
||||
+441
@@ -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
|
||||
}
|
||||
+210
@@ -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,
|
||||
)
|
||||
+370
@@ -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"),
|
||||
)
|
||||
Reference in New Issue
Block a user