diff --git a/app/schemas/com.theveloper.pixelplay.data.database.PixelPlayDatabase/43.json b/app/schemas/com.theveloper.pixelplay.data.database.PixelPlayDatabase/43.json
new file mode 100644
index 0000000000..973c332265
--- /dev/null
+++ b/app/schemas/com.theveloper.pixelplay.data.database.PixelPlayDatabase/43.json
@@ -0,0 +1,2904 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 43,
+ "identityHash": "07ce45dab5a383b78ea4b21f867758bd",
+ "entities": [
+ {
+ "tableName": "album_art_themes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumArtUriString` TEXT NOT NULL, `paletteStyle` TEXT NOT NULL, `light_primary` TEXT NOT NULL, `light_onPrimary` TEXT NOT NULL, `light_primaryContainer` TEXT NOT NULL, `light_onPrimaryContainer` TEXT NOT NULL, `light_secondary` TEXT NOT NULL, `light_onSecondary` TEXT NOT NULL, `light_secondaryContainer` TEXT NOT NULL, `light_onSecondaryContainer` TEXT NOT NULL, `light_tertiary` TEXT NOT NULL, `light_onTertiary` TEXT NOT NULL, `light_tertiaryContainer` TEXT NOT NULL, `light_onTertiaryContainer` TEXT NOT NULL, `light_background` TEXT NOT NULL, `light_onBackground` TEXT NOT NULL, `light_surface` TEXT NOT NULL, `light_onSurface` TEXT NOT NULL, `light_surfaceVariant` TEXT NOT NULL, `light_onSurfaceVariant` TEXT NOT NULL, `light_error` TEXT NOT NULL, `light_onError` TEXT NOT NULL, `light_outline` TEXT NOT NULL, `light_errorContainer` TEXT NOT NULL, `light_onErrorContainer` TEXT NOT NULL, `light_inversePrimary` TEXT NOT NULL, `light_inverseSurface` TEXT NOT NULL, `light_inverseOnSurface` TEXT NOT NULL, `light_surfaceTint` TEXT NOT NULL, `light_outlineVariant` TEXT NOT NULL, `light_scrim` TEXT NOT NULL, `light_surfaceBright` TEXT NOT NULL, `light_surfaceDim` TEXT NOT NULL, `light_surfaceContainer` TEXT NOT NULL, `light_surfaceContainerHigh` TEXT NOT NULL, `light_surfaceContainerHighest` TEXT NOT NULL, `light_surfaceContainerLow` TEXT NOT NULL, `light_surfaceContainerLowest` TEXT NOT NULL, `light_primaryFixed` TEXT NOT NULL, `light_primaryFixedDim` TEXT NOT NULL, `light_onPrimaryFixed` TEXT NOT NULL, `light_onPrimaryFixedVariant` TEXT NOT NULL, `light_secondaryFixed` TEXT NOT NULL, `light_secondaryFixedDim` TEXT NOT NULL, `light_onSecondaryFixed` TEXT NOT NULL, `light_onSecondaryFixedVariant` TEXT NOT NULL, `light_tertiaryFixed` TEXT NOT NULL, `light_tertiaryFixedDim` TEXT NOT NULL, `light_onTertiaryFixed` TEXT NOT NULL, `light_onTertiaryFixedVariant` TEXT NOT NULL, `dark_primary` TEXT NOT NULL, `dark_onPrimary` TEXT NOT NULL, `dark_primaryContainer` TEXT NOT NULL, `dark_onPrimaryContainer` TEXT NOT NULL, `dark_secondary` TEXT NOT NULL, `dark_onSecondary` TEXT NOT NULL, `dark_secondaryContainer` TEXT NOT NULL, `dark_onSecondaryContainer` TEXT NOT NULL, `dark_tertiary` TEXT NOT NULL, `dark_onTertiary` TEXT NOT NULL, `dark_tertiaryContainer` TEXT NOT NULL, `dark_onTertiaryContainer` TEXT NOT NULL, `dark_background` TEXT NOT NULL, `dark_onBackground` TEXT NOT NULL, `dark_surface` TEXT NOT NULL, `dark_onSurface` TEXT NOT NULL, `dark_surfaceVariant` TEXT NOT NULL, `dark_onSurfaceVariant` TEXT NOT NULL, `dark_error` TEXT NOT NULL, `dark_onError` TEXT NOT NULL, `dark_outline` TEXT NOT NULL, `dark_errorContainer` TEXT NOT NULL, `dark_onErrorContainer` TEXT NOT NULL, `dark_inversePrimary` TEXT NOT NULL, `dark_inverseSurface` TEXT NOT NULL, `dark_inverseOnSurface` TEXT NOT NULL, `dark_surfaceTint` TEXT NOT NULL, `dark_outlineVariant` TEXT NOT NULL, `dark_scrim` TEXT NOT NULL, `dark_surfaceBright` TEXT NOT NULL, `dark_surfaceDim` TEXT NOT NULL, `dark_surfaceContainer` TEXT NOT NULL, `dark_surfaceContainerHigh` TEXT NOT NULL, `dark_surfaceContainerHighest` TEXT NOT NULL, `dark_surfaceContainerLow` TEXT NOT NULL, `dark_surfaceContainerLowest` TEXT NOT NULL, `dark_primaryFixed` TEXT NOT NULL, `dark_primaryFixedDim` TEXT NOT NULL, `dark_onPrimaryFixed` TEXT NOT NULL, `dark_onPrimaryFixedVariant` TEXT NOT NULL, `dark_secondaryFixed` TEXT NOT NULL, `dark_secondaryFixedDim` TEXT NOT NULL, `dark_onSecondaryFixed` TEXT NOT NULL, `dark_onSecondaryFixedVariant` TEXT NOT NULL, `dark_tertiaryFixed` TEXT NOT NULL, `dark_tertiaryFixedDim` TEXT NOT NULL, `dark_onTertiaryFixed` TEXT NOT NULL, `dark_onTertiaryFixedVariant` TEXT NOT NULL, PRIMARY KEY(`albumArtUriString`))",
+ "fields": [
+ {
+ "fieldPath": "albumArtUriString",
+ "columnName": "albumArtUriString",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "paletteStyle",
+ "columnName": "paletteStyle",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.primary",
+ "columnName": "light_primary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onPrimary",
+ "columnName": "light_onPrimary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.primaryContainer",
+ "columnName": "light_primaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onPrimaryContainer",
+ "columnName": "light_onPrimaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.secondary",
+ "columnName": "light_secondary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSecondary",
+ "columnName": "light_onSecondary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.secondaryContainer",
+ "columnName": "light_secondaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSecondaryContainer",
+ "columnName": "light_onSecondaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.tertiary",
+ "columnName": "light_tertiary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onTertiary",
+ "columnName": "light_onTertiary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.tertiaryContainer",
+ "columnName": "light_tertiaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onTertiaryContainer",
+ "columnName": "light_onTertiaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.background",
+ "columnName": "light_background",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onBackground",
+ "columnName": "light_onBackground",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surface",
+ "columnName": "light_surface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSurface",
+ "columnName": "light_onSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceVariant",
+ "columnName": "light_surfaceVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSurfaceVariant",
+ "columnName": "light_onSurfaceVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.error",
+ "columnName": "light_error",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onError",
+ "columnName": "light_onError",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.outline",
+ "columnName": "light_outline",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.errorContainer",
+ "columnName": "light_errorContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onErrorContainer",
+ "columnName": "light_onErrorContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.inversePrimary",
+ "columnName": "light_inversePrimary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.inverseSurface",
+ "columnName": "light_inverseSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.inverseOnSurface",
+ "columnName": "light_inverseOnSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceTint",
+ "columnName": "light_surfaceTint",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.outlineVariant",
+ "columnName": "light_outlineVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.scrim",
+ "columnName": "light_scrim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceBright",
+ "columnName": "light_surfaceBright",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceDim",
+ "columnName": "light_surfaceDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceContainer",
+ "columnName": "light_surfaceContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceContainerHigh",
+ "columnName": "light_surfaceContainerHigh",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceContainerHighest",
+ "columnName": "light_surfaceContainerHighest",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceContainerLow",
+ "columnName": "light_surfaceContainerLow",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceContainerLowest",
+ "columnName": "light_surfaceContainerLowest",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.primaryFixed",
+ "columnName": "light_primaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.primaryFixedDim",
+ "columnName": "light_primaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onPrimaryFixed",
+ "columnName": "light_onPrimaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onPrimaryFixedVariant",
+ "columnName": "light_onPrimaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.secondaryFixed",
+ "columnName": "light_secondaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.secondaryFixedDim",
+ "columnName": "light_secondaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSecondaryFixed",
+ "columnName": "light_onSecondaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSecondaryFixedVariant",
+ "columnName": "light_onSecondaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.tertiaryFixed",
+ "columnName": "light_tertiaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.tertiaryFixedDim",
+ "columnName": "light_tertiaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onTertiaryFixed",
+ "columnName": "light_onTertiaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onTertiaryFixedVariant",
+ "columnName": "light_onTertiaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.primary",
+ "columnName": "dark_primary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onPrimary",
+ "columnName": "dark_onPrimary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.primaryContainer",
+ "columnName": "dark_primaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onPrimaryContainer",
+ "columnName": "dark_onPrimaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.secondary",
+ "columnName": "dark_secondary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSecondary",
+ "columnName": "dark_onSecondary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.secondaryContainer",
+ "columnName": "dark_secondaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSecondaryContainer",
+ "columnName": "dark_onSecondaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.tertiary",
+ "columnName": "dark_tertiary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onTertiary",
+ "columnName": "dark_onTertiary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.tertiaryContainer",
+ "columnName": "dark_tertiaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onTertiaryContainer",
+ "columnName": "dark_onTertiaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.background",
+ "columnName": "dark_background",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onBackground",
+ "columnName": "dark_onBackground",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surface",
+ "columnName": "dark_surface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSurface",
+ "columnName": "dark_onSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceVariant",
+ "columnName": "dark_surfaceVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSurfaceVariant",
+ "columnName": "dark_onSurfaceVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.error",
+ "columnName": "dark_error",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onError",
+ "columnName": "dark_onError",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.outline",
+ "columnName": "dark_outline",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.errorContainer",
+ "columnName": "dark_errorContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onErrorContainer",
+ "columnName": "dark_onErrorContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.inversePrimary",
+ "columnName": "dark_inversePrimary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.inverseSurface",
+ "columnName": "dark_inverseSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.inverseOnSurface",
+ "columnName": "dark_inverseOnSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceTint",
+ "columnName": "dark_surfaceTint",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.outlineVariant",
+ "columnName": "dark_outlineVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.scrim",
+ "columnName": "dark_scrim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceBright",
+ "columnName": "dark_surfaceBright",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceDim",
+ "columnName": "dark_surfaceDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceContainer",
+ "columnName": "dark_surfaceContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceContainerHigh",
+ "columnName": "dark_surfaceContainerHigh",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceContainerHighest",
+ "columnName": "dark_surfaceContainerHighest",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceContainerLow",
+ "columnName": "dark_surfaceContainerLow",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceContainerLowest",
+ "columnName": "dark_surfaceContainerLowest",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.primaryFixed",
+ "columnName": "dark_primaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.primaryFixedDim",
+ "columnName": "dark_primaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onPrimaryFixed",
+ "columnName": "dark_onPrimaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onPrimaryFixedVariant",
+ "columnName": "dark_onPrimaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.secondaryFixed",
+ "columnName": "dark_secondaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.secondaryFixedDim",
+ "columnName": "dark_secondaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSecondaryFixed",
+ "columnName": "dark_onSecondaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSecondaryFixedVariant",
+ "columnName": "dark_onSecondaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.tertiaryFixed",
+ "columnName": "dark_tertiaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.tertiaryFixedDim",
+ "columnName": "dark_tertiaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onTertiaryFixed",
+ "columnName": "dark_onTertiaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onTertiaryFixedVariant",
+ "columnName": "dark_onTertiaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "albumArtUriString"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_album_art_themes_albumArtUriString_paletteStyle",
+ "unique": false,
+ "columnNames": [
+ "albumArtUriString",
+ "paletteStyle"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_album_art_themes_albumArtUriString_paletteStyle` ON `${TABLE_NAME}` (`albumArtUriString`, `paletteStyle`)"
+ }
+ ]
+ },
+ {
+ "tableName": "search_history",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "query",
+ "columnName": "query",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `artist_id` INTEGER NOT NULL, `album_artist` TEXT, `album_name` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `content_uri_string` TEXT NOT NULL, `album_art_uri_string` TEXT, `duration` INTEGER NOT NULL, `genre` TEXT, `file_path` TEXT NOT NULL, `parent_directory_path` TEXT NOT NULL, `is_favorite` INTEGER NOT NULL DEFAULT 0, `lyrics` TEXT DEFAULT null, `track_number` INTEGER NOT NULL DEFAULT 0, `disc_number` INTEGER DEFAULT null, `year` INTEGER NOT NULL DEFAULT 0, `date_added` INTEGER NOT NULL DEFAULT 0, `mime_type` TEXT, `bitrate` INTEGER, `sample_rate` INTEGER, `telegram_chat_id` INTEGER, `telegram_file_id` INTEGER, `artists_json` TEXT, `source_type` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`album_id`) REFERENCES `albums`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistName",
+ "columnName": "artist_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtist",
+ "columnName": "album_artist",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "albumName",
+ "columnName": "album_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "album_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contentUriString",
+ "columnName": "content_uri_string",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUriString",
+ "columnName": "album_art_uri_string",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "filePath",
+ "columnName": "file_path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "parentDirectoryPath",
+ "columnName": "parent_directory_path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "is_favorite",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "lyrics",
+ "columnName": "lyrics",
+ "affinity": "TEXT",
+ "defaultValue": "null"
+ },
+ {
+ "fieldPath": "trackNumber",
+ "columnName": "track_number",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "disc_number",
+ "affinity": "INTEGER",
+ "defaultValue": "null"
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "sampleRate",
+ "columnName": "sample_rate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "telegramChatId",
+ "columnName": "telegram_chat_id",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "telegramFileId",
+ "columnName": "telegram_file_id",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "artistsJson",
+ "columnName": "artists_json",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "sourceType",
+ "columnName": "source_type",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_songs_title",
+ "unique": false,
+ "columnNames": [
+ "title"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_title` ON `${TABLE_NAME}` (`title`)"
+ },
+ {
+ "name": "index_songs_album_id",
+ "unique": false,
+ "columnNames": [
+ "album_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_album_id` ON `${TABLE_NAME}` (`album_id`)"
+ },
+ {
+ "name": "index_songs_artist_id",
+ "unique": false,
+ "columnNames": [
+ "artist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_artist_id` ON `${TABLE_NAME}` (`artist_id`)"
+ },
+ {
+ "name": "index_songs_artist_name",
+ "unique": false,
+ "columnNames": [
+ "artist_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_artist_name` ON `${TABLE_NAME}` (`artist_name`)"
+ },
+ {
+ "name": "index_songs_genre",
+ "unique": false,
+ "columnNames": [
+ "genre"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_genre` ON `${TABLE_NAME}` (`genre`)"
+ },
+ {
+ "name": "index_songs_parent_directory_path",
+ "unique": false,
+ "columnNames": [
+ "parent_directory_path"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path` ON `${TABLE_NAME}` (`parent_directory_path`)"
+ },
+ {
+ "name": "index_songs_file_path",
+ "unique": false,
+ "columnNames": [
+ "file_path"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_file_path` ON `${TABLE_NAME}` (`file_path`)"
+ },
+ {
+ "name": "index_songs_content_uri_string",
+ "unique": false,
+ "columnNames": [
+ "content_uri_string"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_content_uri_string` ON `${TABLE_NAME}` (`content_uri_string`)"
+ },
+ {
+ "name": "index_songs_date_added",
+ "unique": false,
+ "columnNames": [
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_date_added` ON `${TABLE_NAME}` (`date_added`)"
+ },
+ {
+ "name": "index_songs_duration",
+ "unique": false,
+ "columnNames": [
+ "duration"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_duration` ON `${TABLE_NAME}` (`duration`)"
+ },
+ {
+ "name": "index_songs_source_type",
+ "unique": false,
+ "columnNames": [
+ "source_type"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_source_type` ON `${TABLE_NAME}` (`source_type`)"
+ },
+ {
+ "name": "index_songs_parent_directory_path_source_type_album_id",
+ "unique": false,
+ "columnNames": [
+ "parent_directory_path",
+ "source_type",
+ "album_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path_source_type_album_id` ON `${TABLE_NAME}` (`parent_directory_path`, `source_type`, `album_id`)"
+ },
+ {
+ "name": "index_songs_parent_directory_path_source_type_id",
+ "unique": false,
+ "columnNames": [
+ "parent_directory_path",
+ "source_type",
+ "id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path_source_type_id` ON `${TABLE_NAME}` (`parent_directory_path`, `source_type`, `id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "albums",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "album_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "artists",
+ "onDelete": "SET NULL",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "artist_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "songs_fts",
+ "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, tokenize=unicode61)",
+ "fields": [
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistName",
+ "columnName": "artist_name",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "rowid"
+ ]
+ },
+ "ftsVersion": "FTS4",
+ "ftsOptions": {
+ "tokenizer": "unicode61",
+ "tokenizerArgs": [],
+ "contentTable": "",
+ "languageIdColumnName": "",
+ "matchInfo": "FTS4",
+ "notIndexedColumns": [],
+ "prefixSizes": [],
+ "preferredOrder": "ASC"
+ },
+ "contentSyncTriggers": []
+ },
+ {
+ "tableName": "albums",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `artist_id` INTEGER NOT NULL, `album_art_uri_string` TEXT, `song_count` INTEGER NOT NULL, `date_added` INTEGER NOT NULL, `year` INTEGER NOT NULL, `album_artist` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistName",
+ "columnName": "artist_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUriString",
+ "columnName": "album_art_uri_string",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtist",
+ "columnName": "album_artist",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_albums_title",
+ "unique": false,
+ "columnNames": [
+ "title"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_title` ON `${TABLE_NAME}` (`title`)"
+ },
+ {
+ "name": "index_albums_artist_id",
+ "unique": false,
+ "columnNames": [
+ "artist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_id` ON `${TABLE_NAME}` (`artist_id`)"
+ },
+ {
+ "name": "index_albums_artist_name",
+ "unique": false,
+ "columnNames": [
+ "artist_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_name` ON `${TABLE_NAME}` (`artist_name`)"
+ },
+ {
+ "name": "index_albums_album_artist",
+ "unique": false,
+ "columnNames": [
+ "album_artist"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_album_artist` ON `${TABLE_NAME}` (`album_artist`)"
+ }
+ ]
+ },
+ {
+ "tableName": "artists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `track_count` INTEGER NOT NULL, `image_url` TEXT, `custom_image_uri` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackCount",
+ "columnName": "track_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "image_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "customImageUri",
+ "columnName": "custom_image_uri",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_artists_name",
+ "unique": false,
+ "columnNames": [
+ "name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`name`)"
+ }
+ ]
+ },
+ {
+ "tableName": "transition_rules",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `fromTrackId` TEXT, `toTrackId` TEXT, `mode` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `curveIn` TEXT NOT NULL, `curveOut` TEXT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromTrackId",
+ "columnName": "fromTrackId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "toTrackId",
+ "columnName": "toTrackId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "settings.mode",
+ "columnName": "mode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "settings.durationMs",
+ "columnName": "durationMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "settings.curveIn",
+ "columnName": "curveIn",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "settings.curveOut",
+ "columnName": "curveOut",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_transition_rules_playlistId_fromTrackId_toTrackId",
+ "unique": true,
+ "columnNames": [
+ "playlistId",
+ "fromTrackId",
+ "toTrackId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_transition_rules_playlistId_fromTrackId_toTrackId` ON `${TABLE_NAME}` (`playlistId`, `fromTrackId`, `toTrackId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "song_artist_cross_ref",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` INTEGER NOT NULL, `artist_id` INTEGER NOT NULL, `is_primary` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`song_id`, `artist_id`), FOREIGN KEY(`song_id`) REFERENCES `songs`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "song_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isPrimary",
+ "columnName": "is_primary",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "song_id",
+ "artist_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_song_artist_cross_ref_song_id",
+ "unique": false,
+ "columnNames": [
+ "song_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_song_id` ON `${TABLE_NAME}` (`song_id`)"
+ },
+ {
+ "name": "index_song_artist_cross_ref_artist_id",
+ "unique": false,
+ "columnNames": [
+ "artist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_artist_id` ON `${TABLE_NAME}` (`artist_id`)"
+ },
+ {
+ "name": "index_song_artist_cross_ref_is_primary",
+ "unique": false,
+ "columnNames": [
+ "is_primary"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_is_primary` ON `${TABLE_NAME}` (`is_primary`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "songs",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "song_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "artists",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "artist_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "telegram_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chat_id` INTEGER NOT NULL, `message_id` INTEGER NOT NULL, `file_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `duration` INTEGER NOT NULL, `file_path` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `date_added` INTEGER NOT NULL, `album_art_uri` TEXT, `thread_id` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatId",
+ "columnName": "chat_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageId",
+ "columnName": "message_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fileId",
+ "columnName": "file_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "filePath",
+ "columnName": "file_path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUriString",
+ "columnName": "album_art_uri",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "threadId",
+ "columnName": "thread_id",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_telegram_songs_chat_id",
+ "unique": false,
+ "columnNames": [
+ "chat_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_songs_chat_id` ON `${TABLE_NAME}` (`chat_id`)"
+ },
+ {
+ "name": "index_telegram_songs_message_id",
+ "unique": false,
+ "columnNames": [
+ "message_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_songs_message_id` ON `${TABLE_NAME}` (`message_id`)"
+ },
+ {
+ "name": "index_telegram_songs_file_id",
+ "unique": false,
+ "columnNames": [
+ "file_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_songs_file_id` ON `${TABLE_NAME}` (`file_id`)"
+ },
+ {
+ "name": "index_telegram_songs_chat_id_message_id",
+ "unique": false,
+ "columnNames": [
+ "chat_id",
+ "message_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_songs_chat_id_message_id` ON `${TABLE_NAME}` (`chat_id`, `message_id`)"
+ },
+ {
+ "name": "index_telegram_songs_thread_id",
+ "unique": false,
+ "columnNames": [
+ "thread_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_songs_thread_id` ON `${TABLE_NAME}` (`thread_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "telegram_channels",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `username` TEXT, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, `photo_path` TEXT, PRIMARY KEY(`chat_id`))",
+ "fields": [
+ {
+ "fieldPath": "chatId",
+ "columnName": "chat_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "username",
+ "columnName": "username",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "photoPath",
+ "columnName": "photo_path",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "chat_id"
+ ]
+ }
+ },
+ {
+ "tableName": "song_engagements",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `play_count` INTEGER NOT NULL, `total_play_duration_ms` INTEGER NOT NULL, `last_played_timestamp` INTEGER NOT NULL, PRIMARY KEY(`song_id`))",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "song_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playCount",
+ "columnName": "play_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalPlayDurationMs",
+ "columnName": "total_play_duration_ms",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastPlayedTimestamp",
+ "columnName": "last_played_timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "song_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_song_engagements_play_count",
+ "unique": false,
+ "columnNames": [
+ "play_count"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_engagements_play_count` ON `${TABLE_NAME}` (`play_count`)"
+ }
+ ]
+ },
+ {
+ "tableName": "favorites",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`songId`))",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "isFavorite",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "songId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_favorites_timestamp",
+ "unique": false,
+ "columnNames": [
+ "timestamp"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_favorites_timestamp` ON `${TABLE_NAME}` (`timestamp`)"
+ }
+ ]
+ },
+ {
+ "tableName": "lyrics",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` INTEGER NOT NULL, `content` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `source` TEXT, PRIMARY KEY(`songId`))",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isSynced",
+ "columnName": "isSynced",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "songId"
+ ]
+ }
+ },
+ {
+ "tableName": "netease_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `netease_id` INTEGER NOT NULL, `playlist_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `album_art_url` TEXT, `mime_type` TEXT NOT NULL, `bitrate` INTEGER, `date_added` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "neteaseId",
+ "columnName": "netease_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlist_id",
+ "affinity": "INTEGER",
+ "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": "albumId",
+ "columnName": "album_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUrl",
+ "columnName": "album_art_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_netease_songs_netease_id",
+ "unique": false,
+ "columnNames": [
+ "netease_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_netease_songs_netease_id` ON `${TABLE_NAME}` (`netease_id`)"
+ },
+ {
+ "name": "index_netease_songs_playlist_id",
+ "unique": false,
+ "columnNames": [
+ "playlist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_netease_songs_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)"
+ },
+ {
+ "name": "index_netease_songs_playlist_id_date_added",
+ "unique": false,
+ "columnNames": [
+ "playlist_id",
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_netease_songs_playlist_id_date_added` ON `${TABLE_NAME}` (`playlist_id`, `date_added`)"
+ }
+ ]
+ },
+ {
+ "tableName": "netease_playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `cover_url` TEXT, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coverUrl",
+ "columnName": "cover_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "gdrive_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `drive_file_id` TEXT NOT NULL, `folder_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `album_art_url` TEXT, `mime_type` TEXT NOT NULL, `bitrate` INTEGER, `file_size` INTEGER NOT NULL, `date_added` INTEGER NOT NULL, `date_modified` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "driveFileId",
+ "columnName": "drive_file_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "folderId",
+ "columnName": "folder_id",
+ "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": "albumId",
+ "columnName": "album_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUrl",
+ "columnName": "album_art_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "fileSize",
+ "columnName": "file_size",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateModified",
+ "columnName": "date_modified",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_gdrive_songs_drive_file_id",
+ "unique": false,
+ "columnNames": [
+ "drive_file_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_gdrive_songs_drive_file_id` ON `${TABLE_NAME}` (`drive_file_id`)"
+ },
+ {
+ "name": "index_gdrive_songs_folder_id",
+ "unique": false,
+ "columnNames": [
+ "folder_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_gdrive_songs_folder_id` ON `${TABLE_NAME}` (`folder_id`)"
+ },
+ {
+ "name": "index_gdrive_songs_folder_id_date_added",
+ "unique": false,
+ "columnNames": [
+ "folder_id",
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_gdrive_songs_folder_id_date_added` ON `${TABLE_NAME}` (`folder_id`, `date_added`)"
+ }
+ ]
+ },
+ {
+ "tableName": "gdrive_folders",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `last_modified` INTEGER NOT NULL, `is_ai_generated` INTEGER NOT NULL, `is_queue_generated` INTEGER NOT NULL, `cover_image_uri` TEXT, `cover_color_argb` INTEGER, `cover_icon_name` TEXT, `cover_shape_type` TEXT, `cover_shape_detail_1` REAL, `cover_shape_detail_2` REAL, `cover_shape_detail_3` REAL, `cover_shape_detail_4` REAL, `source` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastModified",
+ "columnName": "last_modified",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isAiGenerated",
+ "columnName": "is_ai_generated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isQueueGenerated",
+ "columnName": "is_queue_generated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coverImageUri",
+ "columnName": "cover_image_uri",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "coverColorArgb",
+ "columnName": "cover_color_argb",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "coverIconName",
+ "columnName": "cover_icon_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "coverShapeType",
+ "columnName": "cover_shape_type",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "coverShapeDetail1",
+ "columnName": "cover_shape_detail_1",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "coverShapeDetail2",
+ "columnName": "cover_shape_detail_2",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "coverShapeDetail3",
+ "columnName": "cover_shape_detail_3",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "coverShapeDetail4",
+ "columnName": "cover_shape_detail_4",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_playlists_last_modified",
+ "unique": false,
+ "columnNames": [
+ "last_modified"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_last_modified` ON `${TABLE_NAME}` (`last_modified`)"
+ }
+ ]
+ },
+ {
+ "tableName": "playlist_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` TEXT NOT NULL, `song_id` TEXT NOT NULL, `sort_order` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `song_id`))",
+ "fields": [
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlist_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songId",
+ "columnName": "song_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortOrder",
+ "columnName": "sort_order",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "playlist_id",
+ "song_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_playlist_songs_playlist_id_sort_order",
+ "unique": false,
+ "columnNames": [
+ "playlist_id",
+ "sort_order"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_playlist_id_sort_order` ON `${TABLE_NAME}` (`playlist_id`, `sort_order`)"
+ },
+ {
+ "name": "index_playlist_songs_song_id",
+ "unique": false,
+ "columnNames": [
+ "song_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_song_id` ON `${TABLE_NAME}` (`song_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "qqmusic_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `song_mid` TEXT NOT NULL, `playlist_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_mid` TEXT, `duration` INTEGER NOT NULL, `album_art_url` TEXT, `mime_type` TEXT NOT NULL, `bitrate` INTEGER, `date_added` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songMid",
+ "columnName": "song_mid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlist_id",
+ "affinity": "INTEGER",
+ "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": "albumMid",
+ "columnName": "album_mid",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUrl",
+ "columnName": "album_art_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "qqmusic_playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `cover_url` TEXT, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coverUrl",
+ "columnName": "cover_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "navidrome_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `navidrome_id` TEXT NOT NULL, `playlist_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `artist_id` TEXT, `album` TEXT NOT NULL, `album_id` TEXT, `cover_art_id` TEXT, `duration` INTEGER NOT NULL, `track_number` INTEGER NOT NULL, `disc_number` INTEGER NOT NULL, `year` INTEGER NOT NULL, `genre` TEXT, `bitRate` INTEGER, `mime_type` TEXT, `suffix` TEXT, `path` TEXT NOT NULL, `date_added` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "navidromeId",
+ "columnName": "navidrome_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlist_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "album",
+ "columnName": "album",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "album_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "coverArtId",
+ "columnName": "cover_art_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackNumber",
+ "columnName": "track_number",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "disc_number",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "bitRate",
+ "columnName": "bitRate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "suffix",
+ "columnName": "suffix",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_navidrome_songs_navidrome_id",
+ "unique": false,
+ "columnNames": [
+ "navidrome_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_navidrome_songs_navidrome_id` ON `${TABLE_NAME}` (`navidrome_id`)"
+ },
+ {
+ "name": "index_navidrome_songs_playlist_id",
+ "unique": false,
+ "columnNames": [
+ "playlist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_navidrome_songs_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)"
+ },
+ {
+ "name": "index_navidrome_songs_playlist_id_date_added",
+ "unique": false,
+ "columnNames": [
+ "playlist_id",
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_navidrome_songs_playlist_id_date_added` ON `${TABLE_NAME}` (`playlist_id`, `date_added`)"
+ }
+ ]
+ },
+ {
+ "tableName": "navidrome_playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `comment` TEXT, `owner` TEXT, `cover_art_id` TEXT, `song_count` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `public` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "comment",
+ "columnName": "comment",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "owner",
+ "columnName": "owner",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "coverArtId",
+ "columnName": "cover_art_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "public",
+ "columnName": "public",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "telegram_topics",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chat_id` INTEGER NOT NULL, `thread_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, `icon_emoji` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatId",
+ "columnName": "chat_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "threadId",
+ "columnName": "thread_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "iconEmoji",
+ "columnName": "icon_emoji",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_telegram_topics_chat_id",
+ "unique": false,
+ "columnNames": [
+ "chat_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_topics_chat_id` ON `${TABLE_NAME}` (`chat_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "jellyfin_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `jellyfin_id` TEXT NOT NULL, `playlist_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `artist_id` TEXT, `album` TEXT NOT NULL, `album_id` TEXT, `duration` INTEGER NOT NULL, `track_number` INTEGER NOT NULL, `disc_number` INTEGER NOT NULL, `year` INTEGER NOT NULL, `genre` TEXT, `bitRate` INTEGER, `mime_type` TEXT, `path` TEXT NOT NULL, `date_added` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinId",
+ "columnName": "jellyfin_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlist_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "album",
+ "columnName": "album",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "album_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackNumber",
+ "columnName": "track_number",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "disc_number",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "bitRate",
+ "columnName": "bitRate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_jellyfin_songs_jellyfin_id",
+ "unique": false,
+ "columnNames": [
+ "jellyfin_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_jellyfin_id` ON `${TABLE_NAME}` (`jellyfin_id`)"
+ },
+ {
+ "name": "index_jellyfin_songs_playlist_id",
+ "unique": false,
+ "columnNames": [
+ "playlist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)"
+ },
+ {
+ "name": "index_jellyfin_songs_playlist_id_date_added",
+ "unique": false,
+ "columnNames": [
+ "playlist_id",
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_playlist_id_date_added` ON `${TABLE_NAME}` (`playlist_id`, `date_added`)"
+ }
+ ]
+ },
+ {
+ "tableName": "jellyfin_playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `song_count` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "s3_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `object_key` TEXT NOT NULL, `folder_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `bitrate` INTEGER, `file_size` INTEGER NOT NULL, `date_added` INTEGER NOT NULL, `date_modified` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "objectKey",
+ "columnName": "object_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "folderId",
+ "columnName": "folder_id",
+ "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": "albumId",
+ "columnName": "album_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mimeType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "fileSize",
+ "columnName": "file_size",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateModified",
+ "columnName": "date_modified",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_s3_songs_object_key",
+ "unique": false,
+ "columnNames": [
+ "object_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_s3_songs_object_key` ON `${TABLE_NAME}` (`object_key`)"
+ },
+ {
+ "name": "index_s3_songs_folder_id",
+ "unique": false,
+ "columnNames": [
+ "folder_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_s3_songs_folder_id` ON `${TABLE_NAME}` (`folder_id`)"
+ },
+ {
+ "name": "index_s3_songs_folder_id_date_added",
+ "unique": false,
+ "columnNames": [
+ "folder_id",
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_s3_songs_folder_id_date_added` ON `${TABLE_NAME}` (`folder_id`, `date_added`)"
+ }
+ ]
+ },
+ {
+ "tableName": "s3_folders",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "ai_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`promptHash` TEXT NOT NULL, `responseJson` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`promptHash`))",
+ "fields": [
+ {
+ "fieldPath": "promptHash",
+ "columnName": "promptHash",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "responseJson",
+ "columnName": "responseJson",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "promptHash"
+ ]
+ }
+ },
+ {
+ "tableName": "ai_usage",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `promptType` TEXT NOT NULL, `promptTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `thoughtTokens` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "provider",
+ "columnName": "provider",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "model",
+ "columnName": "model",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "promptType",
+ "columnName": "promptType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "promptTokens",
+ "columnName": "promptTokens",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "outputTokens",
+ "columnName": "outputTokens",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thoughtTokens",
+ "columnName": "thoughtTokens",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "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, '07ce45dab5a383b78ea4b21f867758bd')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/schemas/com.theveloper.pixelplay.data.database.PixelPlayDatabase/44.json b/app/schemas/com.theveloper.pixelplay.data.database.PixelPlayDatabase/44.json
new file mode 100644
index 0000000000..1e961762e5
--- /dev/null
+++ b/app/schemas/com.theveloper.pixelplay.data.database.PixelPlayDatabase/44.json
@@ -0,0 +1,2909 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 44,
+ "identityHash": "502d882063b46e105090ef8f83c5d6ef",
+ "entities": [
+ {
+ "tableName": "album_art_themes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumArtUriString` TEXT NOT NULL, `paletteStyle` TEXT NOT NULL, `light_primary` TEXT NOT NULL, `light_onPrimary` TEXT NOT NULL, `light_primaryContainer` TEXT NOT NULL, `light_onPrimaryContainer` TEXT NOT NULL, `light_secondary` TEXT NOT NULL, `light_onSecondary` TEXT NOT NULL, `light_secondaryContainer` TEXT NOT NULL, `light_onSecondaryContainer` TEXT NOT NULL, `light_tertiary` TEXT NOT NULL, `light_onTertiary` TEXT NOT NULL, `light_tertiaryContainer` TEXT NOT NULL, `light_onTertiaryContainer` TEXT NOT NULL, `light_background` TEXT NOT NULL, `light_onBackground` TEXT NOT NULL, `light_surface` TEXT NOT NULL, `light_onSurface` TEXT NOT NULL, `light_surfaceVariant` TEXT NOT NULL, `light_onSurfaceVariant` TEXT NOT NULL, `light_error` TEXT NOT NULL, `light_onError` TEXT NOT NULL, `light_outline` TEXT NOT NULL, `light_errorContainer` TEXT NOT NULL, `light_onErrorContainer` TEXT NOT NULL, `light_inversePrimary` TEXT NOT NULL, `light_inverseSurface` TEXT NOT NULL, `light_inverseOnSurface` TEXT NOT NULL, `light_surfaceTint` TEXT NOT NULL, `light_outlineVariant` TEXT NOT NULL, `light_scrim` TEXT NOT NULL, `light_surfaceBright` TEXT NOT NULL, `light_surfaceDim` TEXT NOT NULL, `light_surfaceContainer` TEXT NOT NULL, `light_surfaceContainerHigh` TEXT NOT NULL, `light_surfaceContainerHighest` TEXT NOT NULL, `light_surfaceContainerLow` TEXT NOT NULL, `light_surfaceContainerLowest` TEXT NOT NULL, `light_primaryFixed` TEXT NOT NULL, `light_primaryFixedDim` TEXT NOT NULL, `light_onPrimaryFixed` TEXT NOT NULL, `light_onPrimaryFixedVariant` TEXT NOT NULL, `light_secondaryFixed` TEXT NOT NULL, `light_secondaryFixedDim` TEXT NOT NULL, `light_onSecondaryFixed` TEXT NOT NULL, `light_onSecondaryFixedVariant` TEXT NOT NULL, `light_tertiaryFixed` TEXT NOT NULL, `light_tertiaryFixedDim` TEXT NOT NULL, `light_onTertiaryFixed` TEXT NOT NULL, `light_onTertiaryFixedVariant` TEXT NOT NULL, `dark_primary` TEXT NOT NULL, `dark_onPrimary` TEXT NOT NULL, `dark_primaryContainer` TEXT NOT NULL, `dark_onPrimaryContainer` TEXT NOT NULL, `dark_secondary` TEXT NOT NULL, `dark_onSecondary` TEXT NOT NULL, `dark_secondaryContainer` TEXT NOT NULL, `dark_onSecondaryContainer` TEXT NOT NULL, `dark_tertiary` TEXT NOT NULL, `dark_onTertiary` TEXT NOT NULL, `dark_tertiaryContainer` TEXT NOT NULL, `dark_onTertiaryContainer` TEXT NOT NULL, `dark_background` TEXT NOT NULL, `dark_onBackground` TEXT NOT NULL, `dark_surface` TEXT NOT NULL, `dark_onSurface` TEXT NOT NULL, `dark_surfaceVariant` TEXT NOT NULL, `dark_onSurfaceVariant` TEXT NOT NULL, `dark_error` TEXT NOT NULL, `dark_onError` TEXT NOT NULL, `dark_outline` TEXT NOT NULL, `dark_errorContainer` TEXT NOT NULL, `dark_onErrorContainer` TEXT NOT NULL, `dark_inversePrimary` TEXT NOT NULL, `dark_inverseSurface` TEXT NOT NULL, `dark_inverseOnSurface` TEXT NOT NULL, `dark_surfaceTint` TEXT NOT NULL, `dark_outlineVariant` TEXT NOT NULL, `dark_scrim` TEXT NOT NULL, `dark_surfaceBright` TEXT NOT NULL, `dark_surfaceDim` TEXT NOT NULL, `dark_surfaceContainer` TEXT NOT NULL, `dark_surfaceContainerHigh` TEXT NOT NULL, `dark_surfaceContainerHighest` TEXT NOT NULL, `dark_surfaceContainerLow` TEXT NOT NULL, `dark_surfaceContainerLowest` TEXT NOT NULL, `dark_primaryFixed` TEXT NOT NULL, `dark_primaryFixedDim` TEXT NOT NULL, `dark_onPrimaryFixed` TEXT NOT NULL, `dark_onPrimaryFixedVariant` TEXT NOT NULL, `dark_secondaryFixed` TEXT NOT NULL, `dark_secondaryFixedDim` TEXT NOT NULL, `dark_onSecondaryFixed` TEXT NOT NULL, `dark_onSecondaryFixedVariant` TEXT NOT NULL, `dark_tertiaryFixed` TEXT NOT NULL, `dark_tertiaryFixedDim` TEXT NOT NULL, `dark_onTertiaryFixed` TEXT NOT NULL, `dark_onTertiaryFixedVariant` TEXT NOT NULL, PRIMARY KEY(`albumArtUriString`))",
+ "fields": [
+ {
+ "fieldPath": "albumArtUriString",
+ "columnName": "albumArtUriString",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "paletteStyle",
+ "columnName": "paletteStyle",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.primary",
+ "columnName": "light_primary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onPrimary",
+ "columnName": "light_onPrimary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.primaryContainer",
+ "columnName": "light_primaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onPrimaryContainer",
+ "columnName": "light_onPrimaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.secondary",
+ "columnName": "light_secondary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSecondary",
+ "columnName": "light_onSecondary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.secondaryContainer",
+ "columnName": "light_secondaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSecondaryContainer",
+ "columnName": "light_onSecondaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.tertiary",
+ "columnName": "light_tertiary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onTertiary",
+ "columnName": "light_onTertiary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.tertiaryContainer",
+ "columnName": "light_tertiaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onTertiaryContainer",
+ "columnName": "light_onTertiaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.background",
+ "columnName": "light_background",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onBackground",
+ "columnName": "light_onBackground",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surface",
+ "columnName": "light_surface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSurface",
+ "columnName": "light_onSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceVariant",
+ "columnName": "light_surfaceVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSurfaceVariant",
+ "columnName": "light_onSurfaceVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.error",
+ "columnName": "light_error",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onError",
+ "columnName": "light_onError",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.outline",
+ "columnName": "light_outline",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.errorContainer",
+ "columnName": "light_errorContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onErrorContainer",
+ "columnName": "light_onErrorContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.inversePrimary",
+ "columnName": "light_inversePrimary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.inverseSurface",
+ "columnName": "light_inverseSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.inverseOnSurface",
+ "columnName": "light_inverseOnSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceTint",
+ "columnName": "light_surfaceTint",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.outlineVariant",
+ "columnName": "light_outlineVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.scrim",
+ "columnName": "light_scrim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceBright",
+ "columnName": "light_surfaceBright",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceDim",
+ "columnName": "light_surfaceDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceContainer",
+ "columnName": "light_surfaceContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceContainerHigh",
+ "columnName": "light_surfaceContainerHigh",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceContainerHighest",
+ "columnName": "light_surfaceContainerHighest",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceContainerLow",
+ "columnName": "light_surfaceContainerLow",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceContainerLowest",
+ "columnName": "light_surfaceContainerLowest",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.primaryFixed",
+ "columnName": "light_primaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.primaryFixedDim",
+ "columnName": "light_primaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onPrimaryFixed",
+ "columnName": "light_onPrimaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onPrimaryFixedVariant",
+ "columnName": "light_onPrimaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.secondaryFixed",
+ "columnName": "light_secondaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.secondaryFixedDim",
+ "columnName": "light_secondaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSecondaryFixed",
+ "columnName": "light_onSecondaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSecondaryFixedVariant",
+ "columnName": "light_onSecondaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.tertiaryFixed",
+ "columnName": "light_tertiaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.tertiaryFixedDim",
+ "columnName": "light_tertiaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onTertiaryFixed",
+ "columnName": "light_onTertiaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onTertiaryFixedVariant",
+ "columnName": "light_onTertiaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.primary",
+ "columnName": "dark_primary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onPrimary",
+ "columnName": "dark_onPrimary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.primaryContainer",
+ "columnName": "dark_primaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onPrimaryContainer",
+ "columnName": "dark_onPrimaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.secondary",
+ "columnName": "dark_secondary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSecondary",
+ "columnName": "dark_onSecondary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.secondaryContainer",
+ "columnName": "dark_secondaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSecondaryContainer",
+ "columnName": "dark_onSecondaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.tertiary",
+ "columnName": "dark_tertiary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onTertiary",
+ "columnName": "dark_onTertiary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.tertiaryContainer",
+ "columnName": "dark_tertiaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onTertiaryContainer",
+ "columnName": "dark_onTertiaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.background",
+ "columnName": "dark_background",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onBackground",
+ "columnName": "dark_onBackground",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surface",
+ "columnName": "dark_surface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSurface",
+ "columnName": "dark_onSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceVariant",
+ "columnName": "dark_surfaceVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSurfaceVariant",
+ "columnName": "dark_onSurfaceVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.error",
+ "columnName": "dark_error",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onError",
+ "columnName": "dark_onError",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.outline",
+ "columnName": "dark_outline",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.errorContainer",
+ "columnName": "dark_errorContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onErrorContainer",
+ "columnName": "dark_onErrorContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.inversePrimary",
+ "columnName": "dark_inversePrimary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.inverseSurface",
+ "columnName": "dark_inverseSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.inverseOnSurface",
+ "columnName": "dark_inverseOnSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceTint",
+ "columnName": "dark_surfaceTint",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.outlineVariant",
+ "columnName": "dark_outlineVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.scrim",
+ "columnName": "dark_scrim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceBright",
+ "columnName": "dark_surfaceBright",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceDim",
+ "columnName": "dark_surfaceDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceContainer",
+ "columnName": "dark_surfaceContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceContainerHigh",
+ "columnName": "dark_surfaceContainerHigh",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceContainerHighest",
+ "columnName": "dark_surfaceContainerHighest",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceContainerLow",
+ "columnName": "dark_surfaceContainerLow",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceContainerLowest",
+ "columnName": "dark_surfaceContainerLowest",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.primaryFixed",
+ "columnName": "dark_primaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.primaryFixedDim",
+ "columnName": "dark_primaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onPrimaryFixed",
+ "columnName": "dark_onPrimaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onPrimaryFixedVariant",
+ "columnName": "dark_onPrimaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.secondaryFixed",
+ "columnName": "dark_secondaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.secondaryFixedDim",
+ "columnName": "dark_secondaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSecondaryFixed",
+ "columnName": "dark_onSecondaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSecondaryFixedVariant",
+ "columnName": "dark_onSecondaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.tertiaryFixed",
+ "columnName": "dark_tertiaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.tertiaryFixedDim",
+ "columnName": "dark_tertiaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onTertiaryFixed",
+ "columnName": "dark_onTertiaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onTertiaryFixedVariant",
+ "columnName": "dark_onTertiaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "albumArtUriString"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_album_art_themes_albumArtUriString_paletteStyle",
+ "unique": false,
+ "columnNames": [
+ "albumArtUriString",
+ "paletteStyle"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_album_art_themes_albumArtUriString_paletteStyle` ON `${TABLE_NAME}` (`albumArtUriString`, `paletteStyle`)"
+ }
+ ]
+ },
+ {
+ "tableName": "search_history",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "query",
+ "columnName": "query",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `artist_id` INTEGER NOT NULL, `album_artist` TEXT, `album_name` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `content_uri_string` TEXT NOT NULL, `album_art_uri_string` TEXT, `duration` INTEGER NOT NULL, `genre` TEXT, `file_path` TEXT NOT NULL, `parent_directory_path` TEXT NOT NULL, `is_favorite` INTEGER NOT NULL DEFAULT 0, `lyrics` TEXT DEFAULT null, `track_number` INTEGER NOT NULL DEFAULT 0, `disc_number` INTEGER DEFAULT null, `year` INTEGER NOT NULL DEFAULT 0, `date_added` INTEGER NOT NULL DEFAULT 0, `mime_type` TEXT, `bitrate` INTEGER, `sample_rate` INTEGER, `telegram_chat_id` INTEGER, `telegram_file_id` INTEGER, `artists_json` TEXT, `source_type` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`album_id`) REFERENCES `albums`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistName",
+ "columnName": "artist_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtist",
+ "columnName": "album_artist",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "albumName",
+ "columnName": "album_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "album_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contentUriString",
+ "columnName": "content_uri_string",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUriString",
+ "columnName": "album_art_uri_string",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "filePath",
+ "columnName": "file_path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "parentDirectoryPath",
+ "columnName": "parent_directory_path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "is_favorite",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "lyrics",
+ "columnName": "lyrics",
+ "affinity": "TEXT",
+ "defaultValue": "null"
+ },
+ {
+ "fieldPath": "trackNumber",
+ "columnName": "track_number",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "disc_number",
+ "affinity": "INTEGER",
+ "defaultValue": "null"
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "sampleRate",
+ "columnName": "sample_rate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "telegramChatId",
+ "columnName": "telegram_chat_id",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "telegramFileId",
+ "columnName": "telegram_file_id",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "artistsJson",
+ "columnName": "artists_json",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "sourceType",
+ "columnName": "source_type",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_songs_title",
+ "unique": false,
+ "columnNames": [
+ "title"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_title` ON `${TABLE_NAME}` (`title`)"
+ },
+ {
+ "name": "index_songs_album_id",
+ "unique": false,
+ "columnNames": [
+ "album_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_album_id` ON `${TABLE_NAME}` (`album_id`)"
+ },
+ {
+ "name": "index_songs_artist_id",
+ "unique": false,
+ "columnNames": [
+ "artist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_artist_id` ON `${TABLE_NAME}` (`artist_id`)"
+ },
+ {
+ "name": "index_songs_artist_name",
+ "unique": false,
+ "columnNames": [
+ "artist_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_artist_name` ON `${TABLE_NAME}` (`artist_name`)"
+ },
+ {
+ "name": "index_songs_genre",
+ "unique": false,
+ "columnNames": [
+ "genre"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_genre` ON `${TABLE_NAME}` (`genre`)"
+ },
+ {
+ "name": "index_songs_parent_directory_path",
+ "unique": false,
+ "columnNames": [
+ "parent_directory_path"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path` ON `${TABLE_NAME}` (`parent_directory_path`)"
+ },
+ {
+ "name": "index_songs_file_path",
+ "unique": false,
+ "columnNames": [
+ "file_path"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_file_path` ON `${TABLE_NAME}` (`file_path`)"
+ },
+ {
+ "name": "index_songs_content_uri_string",
+ "unique": false,
+ "columnNames": [
+ "content_uri_string"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_content_uri_string` ON `${TABLE_NAME}` (`content_uri_string`)"
+ },
+ {
+ "name": "index_songs_date_added",
+ "unique": false,
+ "columnNames": [
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_date_added` ON `${TABLE_NAME}` (`date_added`)"
+ },
+ {
+ "name": "index_songs_duration",
+ "unique": false,
+ "columnNames": [
+ "duration"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_duration` ON `${TABLE_NAME}` (`duration`)"
+ },
+ {
+ "name": "index_songs_source_type",
+ "unique": false,
+ "columnNames": [
+ "source_type"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_source_type` ON `${TABLE_NAME}` (`source_type`)"
+ },
+ {
+ "name": "index_songs_parent_directory_path_source_type_album_id",
+ "unique": false,
+ "columnNames": [
+ "parent_directory_path",
+ "source_type",
+ "album_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path_source_type_album_id` ON `${TABLE_NAME}` (`parent_directory_path`, `source_type`, `album_id`)"
+ },
+ {
+ "name": "index_songs_parent_directory_path_source_type_id",
+ "unique": false,
+ "columnNames": [
+ "parent_directory_path",
+ "source_type",
+ "id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path_source_type_id` ON `${TABLE_NAME}` (`parent_directory_path`, `source_type`, `id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "albums",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "album_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "artists",
+ "onDelete": "SET NULL",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "artist_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "songs_fts",
+ "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, tokenize=unicode61)",
+ "fields": [
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistName",
+ "columnName": "artist_name",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "rowid"
+ ]
+ },
+ "ftsVersion": "FTS4",
+ "ftsOptions": {
+ "tokenizer": "unicode61",
+ "tokenizerArgs": [],
+ "contentTable": "",
+ "languageIdColumnName": "",
+ "matchInfo": "FTS4",
+ "notIndexedColumns": [],
+ "prefixSizes": [],
+ "preferredOrder": "ASC"
+ },
+ "contentSyncTriggers": []
+ },
+ {
+ "tableName": "albums",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `artist_id` INTEGER NOT NULL, `album_art_uri_string` TEXT, `song_count` INTEGER NOT NULL, `date_added` INTEGER NOT NULL, `year` INTEGER NOT NULL, `album_artist` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistName",
+ "columnName": "artist_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUriString",
+ "columnName": "album_art_uri_string",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtist",
+ "columnName": "album_artist",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_albums_title",
+ "unique": false,
+ "columnNames": [
+ "title"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_title` ON `${TABLE_NAME}` (`title`)"
+ },
+ {
+ "name": "index_albums_artist_id",
+ "unique": false,
+ "columnNames": [
+ "artist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_id` ON `${TABLE_NAME}` (`artist_id`)"
+ },
+ {
+ "name": "index_albums_artist_name",
+ "unique": false,
+ "columnNames": [
+ "artist_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_name` ON `${TABLE_NAME}` (`artist_name`)"
+ },
+ {
+ "name": "index_albums_album_artist",
+ "unique": false,
+ "columnNames": [
+ "album_artist"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_album_artist` ON `${TABLE_NAME}` (`album_artist`)"
+ }
+ ]
+ },
+ {
+ "tableName": "artists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `track_count` INTEGER NOT NULL, `image_url` TEXT, `custom_image_uri` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackCount",
+ "columnName": "track_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "image_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "customImageUri",
+ "columnName": "custom_image_uri",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_artists_name",
+ "unique": false,
+ "columnNames": [
+ "name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`name`)"
+ }
+ ]
+ },
+ {
+ "tableName": "transition_rules",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `fromTrackId` TEXT, `toTrackId` TEXT, `mode` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `curveIn` TEXT NOT NULL, `curveOut` TEXT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromTrackId",
+ "columnName": "fromTrackId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "toTrackId",
+ "columnName": "toTrackId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "settings.mode",
+ "columnName": "mode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "settings.durationMs",
+ "columnName": "durationMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "settings.curveIn",
+ "columnName": "curveIn",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "settings.curveOut",
+ "columnName": "curveOut",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_transition_rules_playlistId_fromTrackId_toTrackId",
+ "unique": true,
+ "columnNames": [
+ "playlistId",
+ "fromTrackId",
+ "toTrackId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_transition_rules_playlistId_fromTrackId_toTrackId` ON `${TABLE_NAME}` (`playlistId`, `fromTrackId`, `toTrackId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "song_artist_cross_ref",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` INTEGER NOT NULL, `artist_id` INTEGER NOT NULL, `is_primary` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`song_id`, `artist_id`), FOREIGN KEY(`song_id`) REFERENCES `songs`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "song_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isPrimary",
+ "columnName": "is_primary",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "song_id",
+ "artist_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_song_artist_cross_ref_song_id",
+ "unique": false,
+ "columnNames": [
+ "song_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_song_id` ON `${TABLE_NAME}` (`song_id`)"
+ },
+ {
+ "name": "index_song_artist_cross_ref_artist_id",
+ "unique": false,
+ "columnNames": [
+ "artist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_artist_id` ON `${TABLE_NAME}` (`artist_id`)"
+ },
+ {
+ "name": "index_song_artist_cross_ref_is_primary",
+ "unique": false,
+ "columnNames": [
+ "is_primary"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_is_primary` ON `${TABLE_NAME}` (`is_primary`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "songs",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "song_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "artists",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "artist_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "telegram_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chat_id` INTEGER NOT NULL, `message_id` INTEGER NOT NULL, `file_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `duration` INTEGER NOT NULL, `file_path` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `date_added` INTEGER NOT NULL, `album_art_uri` TEXT, `thread_id` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatId",
+ "columnName": "chat_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageId",
+ "columnName": "message_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fileId",
+ "columnName": "file_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "filePath",
+ "columnName": "file_path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUriString",
+ "columnName": "album_art_uri",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "threadId",
+ "columnName": "thread_id",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_telegram_songs_chat_id",
+ "unique": false,
+ "columnNames": [
+ "chat_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_songs_chat_id` ON `${TABLE_NAME}` (`chat_id`)"
+ },
+ {
+ "name": "index_telegram_songs_message_id",
+ "unique": false,
+ "columnNames": [
+ "message_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_songs_message_id` ON `${TABLE_NAME}` (`message_id`)"
+ },
+ {
+ "name": "index_telegram_songs_file_id",
+ "unique": false,
+ "columnNames": [
+ "file_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_songs_file_id` ON `${TABLE_NAME}` (`file_id`)"
+ },
+ {
+ "name": "index_telegram_songs_chat_id_message_id",
+ "unique": false,
+ "columnNames": [
+ "chat_id",
+ "message_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_songs_chat_id_message_id` ON `${TABLE_NAME}` (`chat_id`, `message_id`)"
+ },
+ {
+ "name": "index_telegram_songs_thread_id",
+ "unique": false,
+ "columnNames": [
+ "thread_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_songs_thread_id` ON `${TABLE_NAME}` (`thread_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "telegram_channels",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `username` TEXT, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, `photo_path` TEXT, PRIMARY KEY(`chat_id`))",
+ "fields": [
+ {
+ "fieldPath": "chatId",
+ "columnName": "chat_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "username",
+ "columnName": "username",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "photoPath",
+ "columnName": "photo_path",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "chat_id"
+ ]
+ }
+ },
+ {
+ "tableName": "song_engagements",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `play_count` INTEGER NOT NULL, `total_play_duration_ms` INTEGER NOT NULL, `last_played_timestamp` INTEGER NOT NULL, PRIMARY KEY(`song_id`))",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "song_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playCount",
+ "columnName": "play_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalPlayDurationMs",
+ "columnName": "total_play_duration_ms",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastPlayedTimestamp",
+ "columnName": "last_played_timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "song_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_song_engagements_play_count",
+ "unique": false,
+ "columnNames": [
+ "play_count"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_engagements_play_count` ON `${TABLE_NAME}` (`play_count`)"
+ }
+ ]
+ },
+ {
+ "tableName": "favorites",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`songId`))",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "isFavorite",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "songId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_favorites_timestamp",
+ "unique": false,
+ "columnNames": [
+ "timestamp"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_favorites_timestamp` ON `${TABLE_NAME}` (`timestamp`)"
+ }
+ ]
+ },
+ {
+ "tableName": "lyrics",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` INTEGER NOT NULL, `content` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `source` TEXT, PRIMARY KEY(`songId`))",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isSynced",
+ "columnName": "isSynced",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "songId"
+ ]
+ }
+ },
+ {
+ "tableName": "netease_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `netease_id` INTEGER NOT NULL, `playlist_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `album_art_url` TEXT, `mime_type` TEXT NOT NULL, `bitrate` INTEGER, `date_added` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "neteaseId",
+ "columnName": "netease_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlist_id",
+ "affinity": "INTEGER",
+ "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": "albumId",
+ "columnName": "album_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUrl",
+ "columnName": "album_art_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_netease_songs_netease_id",
+ "unique": false,
+ "columnNames": [
+ "netease_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_netease_songs_netease_id` ON `${TABLE_NAME}` (`netease_id`)"
+ },
+ {
+ "name": "index_netease_songs_playlist_id",
+ "unique": false,
+ "columnNames": [
+ "playlist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_netease_songs_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)"
+ },
+ {
+ "name": "index_netease_songs_playlist_id_date_added",
+ "unique": false,
+ "columnNames": [
+ "playlist_id",
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_netease_songs_playlist_id_date_added` ON `${TABLE_NAME}` (`playlist_id`, `date_added`)"
+ }
+ ]
+ },
+ {
+ "tableName": "netease_playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `cover_url` TEXT, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coverUrl",
+ "columnName": "cover_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "gdrive_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `drive_file_id` TEXT NOT NULL, `folder_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `album_art_url` TEXT, `mime_type` TEXT NOT NULL, `bitrate` INTEGER, `file_size` INTEGER NOT NULL, `date_added` INTEGER NOT NULL, `date_modified` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "driveFileId",
+ "columnName": "drive_file_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "folderId",
+ "columnName": "folder_id",
+ "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": "albumId",
+ "columnName": "album_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUrl",
+ "columnName": "album_art_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "fileSize",
+ "columnName": "file_size",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateModified",
+ "columnName": "date_modified",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_gdrive_songs_drive_file_id",
+ "unique": false,
+ "columnNames": [
+ "drive_file_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_gdrive_songs_drive_file_id` ON `${TABLE_NAME}` (`drive_file_id`)"
+ },
+ {
+ "name": "index_gdrive_songs_folder_id",
+ "unique": false,
+ "columnNames": [
+ "folder_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_gdrive_songs_folder_id` ON `${TABLE_NAME}` (`folder_id`)"
+ },
+ {
+ "name": "index_gdrive_songs_folder_id_date_added",
+ "unique": false,
+ "columnNames": [
+ "folder_id",
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_gdrive_songs_folder_id_date_added` ON `${TABLE_NAME}` (`folder_id`, `date_added`)"
+ }
+ ]
+ },
+ {
+ "tableName": "gdrive_folders",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `last_modified` INTEGER NOT NULL, `is_ai_generated` INTEGER NOT NULL, `is_queue_generated` INTEGER NOT NULL, `cover_image_uri` TEXT, `cover_color_argb` INTEGER, `cover_icon_name` TEXT, `cover_shape_type` TEXT, `cover_shape_detail_1` REAL, `cover_shape_detail_2` REAL, `cover_shape_detail_3` REAL, `cover_shape_detail_4` REAL, `source` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastModified",
+ "columnName": "last_modified",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isAiGenerated",
+ "columnName": "is_ai_generated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isQueueGenerated",
+ "columnName": "is_queue_generated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coverImageUri",
+ "columnName": "cover_image_uri",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "coverColorArgb",
+ "columnName": "cover_color_argb",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "coverIconName",
+ "columnName": "cover_icon_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "coverShapeType",
+ "columnName": "cover_shape_type",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "coverShapeDetail1",
+ "columnName": "cover_shape_detail_1",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "coverShapeDetail2",
+ "columnName": "cover_shape_detail_2",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "coverShapeDetail3",
+ "columnName": "cover_shape_detail_3",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "coverShapeDetail4",
+ "columnName": "cover_shape_detail_4",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_playlists_last_modified",
+ "unique": false,
+ "columnNames": [
+ "last_modified"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_last_modified` ON `${TABLE_NAME}` (`last_modified`)"
+ }
+ ]
+ },
+ {
+ "tableName": "playlist_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` TEXT NOT NULL, `song_id` TEXT NOT NULL, `sort_order` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `song_id`))",
+ "fields": [
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlist_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songId",
+ "columnName": "song_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortOrder",
+ "columnName": "sort_order",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "playlist_id",
+ "song_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_playlist_songs_playlist_id_sort_order",
+ "unique": false,
+ "columnNames": [
+ "playlist_id",
+ "sort_order"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_playlist_id_sort_order` ON `${TABLE_NAME}` (`playlist_id`, `sort_order`)"
+ },
+ {
+ "name": "index_playlist_songs_song_id",
+ "unique": false,
+ "columnNames": [
+ "song_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_song_id` ON `${TABLE_NAME}` (`song_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "qqmusic_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `song_mid` TEXT NOT NULL, `playlist_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_mid` TEXT, `duration` INTEGER NOT NULL, `album_art_url` TEXT, `mime_type` TEXT NOT NULL, `bitrate` INTEGER, `date_added` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songMid",
+ "columnName": "song_mid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlist_id",
+ "affinity": "INTEGER",
+ "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": "albumMid",
+ "columnName": "album_mid",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUrl",
+ "columnName": "album_art_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "qqmusic_playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `cover_url` TEXT, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coverUrl",
+ "columnName": "cover_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "navidrome_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `navidrome_id` TEXT NOT NULL, `playlist_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `artist_id` TEXT, `album` TEXT NOT NULL, `album_id` TEXT, `cover_art_id` TEXT, `duration` INTEGER NOT NULL, `track_number` INTEGER NOT NULL, `disc_number` INTEGER NOT NULL, `year` INTEGER NOT NULL, `genre` TEXT, `bitRate` INTEGER, `mime_type` TEXT, `suffix` TEXT, `path` TEXT NOT NULL, `date_added` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "navidromeId",
+ "columnName": "navidrome_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlist_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "album",
+ "columnName": "album",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "album_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "coverArtId",
+ "columnName": "cover_art_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackNumber",
+ "columnName": "track_number",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "disc_number",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "bitRate",
+ "columnName": "bitRate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "suffix",
+ "columnName": "suffix",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_navidrome_songs_navidrome_id",
+ "unique": false,
+ "columnNames": [
+ "navidrome_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_navidrome_songs_navidrome_id` ON `${TABLE_NAME}` (`navidrome_id`)"
+ },
+ {
+ "name": "index_navidrome_songs_playlist_id",
+ "unique": false,
+ "columnNames": [
+ "playlist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_navidrome_songs_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)"
+ },
+ {
+ "name": "index_navidrome_songs_playlist_id_date_added",
+ "unique": false,
+ "columnNames": [
+ "playlist_id",
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_navidrome_songs_playlist_id_date_added` ON `${TABLE_NAME}` (`playlist_id`, `date_added`)"
+ }
+ ]
+ },
+ {
+ "tableName": "navidrome_playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `comment` TEXT, `owner` TEXT, `cover_art_id` TEXT, `song_count` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `public` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "comment",
+ "columnName": "comment",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "owner",
+ "columnName": "owner",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "coverArtId",
+ "columnName": "cover_art_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "public",
+ "columnName": "public",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "telegram_topics",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chat_id` INTEGER NOT NULL, `thread_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, `icon_emoji` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatId",
+ "columnName": "chat_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "threadId",
+ "columnName": "thread_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "iconEmoji",
+ "columnName": "icon_emoji",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_telegram_topics_chat_id",
+ "unique": false,
+ "columnNames": [
+ "chat_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_topics_chat_id` ON `${TABLE_NAME}` (`chat_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "jellyfin_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `jellyfin_id` TEXT NOT NULL, `playlist_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `artist_id` TEXT, `album` TEXT NOT NULL, `album_id` TEXT, `duration` INTEGER NOT NULL, `track_number` INTEGER NOT NULL, `disc_number` INTEGER NOT NULL, `year` INTEGER NOT NULL, `genre` TEXT, `bitRate` INTEGER, `mime_type` TEXT, `path` TEXT NOT NULL, `date_added` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinId",
+ "columnName": "jellyfin_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlist_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "album",
+ "columnName": "album",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "album_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackNumber",
+ "columnName": "track_number",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "disc_number",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "bitRate",
+ "columnName": "bitRate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_jellyfin_songs_jellyfin_id",
+ "unique": false,
+ "columnNames": [
+ "jellyfin_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_jellyfin_id` ON `${TABLE_NAME}` (`jellyfin_id`)"
+ },
+ {
+ "name": "index_jellyfin_songs_playlist_id",
+ "unique": false,
+ "columnNames": [
+ "playlist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)"
+ },
+ {
+ "name": "index_jellyfin_songs_playlist_id_date_added",
+ "unique": false,
+ "columnNames": [
+ "playlist_id",
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_playlist_id_date_added` ON `${TABLE_NAME}` (`playlist_id`, `date_added`)"
+ }
+ ]
+ },
+ {
+ "tableName": "jellyfin_playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `song_count` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "s3_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `object_key` TEXT NOT NULL, `folder_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `bitrate` INTEGER, `file_size` INTEGER NOT NULL, `date_added` INTEGER NOT NULL, `date_modified` INTEGER NOT NULL, `album_art_uri` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "objectKey",
+ "columnName": "object_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "folderId",
+ "columnName": "folder_id",
+ "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": "albumId",
+ "columnName": "album_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mimeType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "fileSize",
+ "columnName": "file_size",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateModified",
+ "columnName": "date_modified",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUri",
+ "columnName": "album_art_uri",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_s3_songs_object_key",
+ "unique": false,
+ "columnNames": [
+ "object_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_s3_songs_object_key` ON `${TABLE_NAME}` (`object_key`)"
+ },
+ {
+ "name": "index_s3_songs_folder_id",
+ "unique": false,
+ "columnNames": [
+ "folder_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_s3_songs_folder_id` ON `${TABLE_NAME}` (`folder_id`)"
+ },
+ {
+ "name": "index_s3_songs_folder_id_date_added",
+ "unique": false,
+ "columnNames": [
+ "folder_id",
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_s3_songs_folder_id_date_added` ON `${TABLE_NAME}` (`folder_id`, `date_added`)"
+ }
+ ]
+ },
+ {
+ "tableName": "s3_folders",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "ai_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`promptHash` TEXT NOT NULL, `responseJson` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`promptHash`))",
+ "fields": [
+ {
+ "fieldPath": "promptHash",
+ "columnName": "promptHash",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "responseJson",
+ "columnName": "responseJson",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "promptHash"
+ ]
+ }
+ },
+ {
+ "tableName": "ai_usage",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `promptType` TEXT NOT NULL, `promptTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `thoughtTokens` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "provider",
+ "columnName": "provider",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "model",
+ "columnName": "model",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "promptType",
+ "columnName": "promptType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "promptTokens",
+ "columnName": "promptTokens",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "outputTokens",
+ "columnName": "outputTokens",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thoughtTokens",
+ "columnName": "thoughtTokens",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "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, '502d882063b46e105090ef8f83c5d6ef')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/schemas/com.theveloper.pixelplay.data.database.PixelPlayDatabase/45.json b/app/schemas/com.theveloper.pixelplay.data.database.PixelPlayDatabase/45.json
new file mode 100644
index 0000000000..0c847cea19
--- /dev/null
+++ b/app/schemas/com.theveloper.pixelplay.data.database.PixelPlayDatabase/45.json
@@ -0,0 +1,2916 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 45,
+ "identityHash": "f313ce810095cfaa93da8320af98ddb2",
+ "entities": [
+ {
+ "tableName": "album_art_themes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumArtUriString` TEXT NOT NULL, `paletteStyle` TEXT NOT NULL, `light_primary` TEXT NOT NULL, `light_onPrimary` TEXT NOT NULL, `light_primaryContainer` TEXT NOT NULL, `light_onPrimaryContainer` TEXT NOT NULL, `light_secondary` TEXT NOT NULL, `light_onSecondary` TEXT NOT NULL, `light_secondaryContainer` TEXT NOT NULL, `light_onSecondaryContainer` TEXT NOT NULL, `light_tertiary` TEXT NOT NULL, `light_onTertiary` TEXT NOT NULL, `light_tertiaryContainer` TEXT NOT NULL, `light_onTertiaryContainer` TEXT NOT NULL, `light_background` TEXT NOT NULL, `light_onBackground` TEXT NOT NULL, `light_surface` TEXT NOT NULL, `light_onSurface` TEXT NOT NULL, `light_surfaceVariant` TEXT NOT NULL, `light_onSurfaceVariant` TEXT NOT NULL, `light_error` TEXT NOT NULL, `light_onError` TEXT NOT NULL, `light_outline` TEXT NOT NULL, `light_errorContainer` TEXT NOT NULL, `light_onErrorContainer` TEXT NOT NULL, `light_inversePrimary` TEXT NOT NULL, `light_inverseSurface` TEXT NOT NULL, `light_inverseOnSurface` TEXT NOT NULL, `light_surfaceTint` TEXT NOT NULL, `light_outlineVariant` TEXT NOT NULL, `light_scrim` TEXT NOT NULL, `light_surfaceBright` TEXT NOT NULL, `light_surfaceDim` TEXT NOT NULL, `light_surfaceContainer` TEXT NOT NULL, `light_surfaceContainerHigh` TEXT NOT NULL, `light_surfaceContainerHighest` TEXT NOT NULL, `light_surfaceContainerLow` TEXT NOT NULL, `light_surfaceContainerLowest` TEXT NOT NULL, `light_primaryFixed` TEXT NOT NULL, `light_primaryFixedDim` TEXT NOT NULL, `light_onPrimaryFixed` TEXT NOT NULL, `light_onPrimaryFixedVariant` TEXT NOT NULL, `light_secondaryFixed` TEXT NOT NULL, `light_secondaryFixedDim` TEXT NOT NULL, `light_onSecondaryFixed` TEXT NOT NULL, `light_onSecondaryFixedVariant` TEXT NOT NULL, `light_tertiaryFixed` TEXT NOT NULL, `light_tertiaryFixedDim` TEXT NOT NULL, `light_onTertiaryFixed` TEXT NOT NULL, `light_onTertiaryFixedVariant` TEXT NOT NULL, `dark_primary` TEXT NOT NULL, `dark_onPrimary` TEXT NOT NULL, `dark_primaryContainer` TEXT NOT NULL, `dark_onPrimaryContainer` TEXT NOT NULL, `dark_secondary` TEXT NOT NULL, `dark_onSecondary` TEXT NOT NULL, `dark_secondaryContainer` TEXT NOT NULL, `dark_onSecondaryContainer` TEXT NOT NULL, `dark_tertiary` TEXT NOT NULL, `dark_onTertiary` TEXT NOT NULL, `dark_tertiaryContainer` TEXT NOT NULL, `dark_onTertiaryContainer` TEXT NOT NULL, `dark_background` TEXT NOT NULL, `dark_onBackground` TEXT NOT NULL, `dark_surface` TEXT NOT NULL, `dark_onSurface` TEXT NOT NULL, `dark_surfaceVariant` TEXT NOT NULL, `dark_onSurfaceVariant` TEXT NOT NULL, `dark_error` TEXT NOT NULL, `dark_onError` TEXT NOT NULL, `dark_outline` TEXT NOT NULL, `dark_errorContainer` TEXT NOT NULL, `dark_onErrorContainer` TEXT NOT NULL, `dark_inversePrimary` TEXT NOT NULL, `dark_inverseSurface` TEXT NOT NULL, `dark_inverseOnSurface` TEXT NOT NULL, `dark_surfaceTint` TEXT NOT NULL, `dark_outlineVariant` TEXT NOT NULL, `dark_scrim` TEXT NOT NULL, `dark_surfaceBright` TEXT NOT NULL, `dark_surfaceDim` TEXT NOT NULL, `dark_surfaceContainer` TEXT NOT NULL, `dark_surfaceContainerHigh` TEXT NOT NULL, `dark_surfaceContainerHighest` TEXT NOT NULL, `dark_surfaceContainerLow` TEXT NOT NULL, `dark_surfaceContainerLowest` TEXT NOT NULL, `dark_primaryFixed` TEXT NOT NULL, `dark_primaryFixedDim` TEXT NOT NULL, `dark_onPrimaryFixed` TEXT NOT NULL, `dark_onPrimaryFixedVariant` TEXT NOT NULL, `dark_secondaryFixed` TEXT NOT NULL, `dark_secondaryFixedDim` TEXT NOT NULL, `dark_onSecondaryFixed` TEXT NOT NULL, `dark_onSecondaryFixedVariant` TEXT NOT NULL, `dark_tertiaryFixed` TEXT NOT NULL, `dark_tertiaryFixedDim` TEXT NOT NULL, `dark_onTertiaryFixed` TEXT NOT NULL, `dark_onTertiaryFixedVariant` TEXT NOT NULL, PRIMARY KEY(`albumArtUriString`))",
+ "fields": [
+ {
+ "fieldPath": "albumArtUriString",
+ "columnName": "albumArtUriString",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "paletteStyle",
+ "columnName": "paletteStyle",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.primary",
+ "columnName": "light_primary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onPrimary",
+ "columnName": "light_onPrimary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.primaryContainer",
+ "columnName": "light_primaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onPrimaryContainer",
+ "columnName": "light_onPrimaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.secondary",
+ "columnName": "light_secondary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSecondary",
+ "columnName": "light_onSecondary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.secondaryContainer",
+ "columnName": "light_secondaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSecondaryContainer",
+ "columnName": "light_onSecondaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.tertiary",
+ "columnName": "light_tertiary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onTertiary",
+ "columnName": "light_onTertiary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.tertiaryContainer",
+ "columnName": "light_tertiaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onTertiaryContainer",
+ "columnName": "light_onTertiaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.background",
+ "columnName": "light_background",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onBackground",
+ "columnName": "light_onBackground",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surface",
+ "columnName": "light_surface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSurface",
+ "columnName": "light_onSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceVariant",
+ "columnName": "light_surfaceVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSurfaceVariant",
+ "columnName": "light_onSurfaceVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.error",
+ "columnName": "light_error",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onError",
+ "columnName": "light_onError",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.outline",
+ "columnName": "light_outline",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.errorContainer",
+ "columnName": "light_errorContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onErrorContainer",
+ "columnName": "light_onErrorContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.inversePrimary",
+ "columnName": "light_inversePrimary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.inverseSurface",
+ "columnName": "light_inverseSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.inverseOnSurface",
+ "columnName": "light_inverseOnSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceTint",
+ "columnName": "light_surfaceTint",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.outlineVariant",
+ "columnName": "light_outlineVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.scrim",
+ "columnName": "light_scrim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceBright",
+ "columnName": "light_surfaceBright",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceDim",
+ "columnName": "light_surfaceDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceContainer",
+ "columnName": "light_surfaceContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceContainerHigh",
+ "columnName": "light_surfaceContainerHigh",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceContainerHighest",
+ "columnName": "light_surfaceContainerHighest",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceContainerLow",
+ "columnName": "light_surfaceContainerLow",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.surfaceContainerLowest",
+ "columnName": "light_surfaceContainerLowest",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.primaryFixed",
+ "columnName": "light_primaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.primaryFixedDim",
+ "columnName": "light_primaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onPrimaryFixed",
+ "columnName": "light_onPrimaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onPrimaryFixedVariant",
+ "columnName": "light_onPrimaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.secondaryFixed",
+ "columnName": "light_secondaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.secondaryFixedDim",
+ "columnName": "light_secondaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSecondaryFixed",
+ "columnName": "light_onSecondaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onSecondaryFixedVariant",
+ "columnName": "light_onSecondaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.tertiaryFixed",
+ "columnName": "light_tertiaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.tertiaryFixedDim",
+ "columnName": "light_tertiaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onTertiaryFixed",
+ "columnName": "light_onTertiaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lightThemeValues.onTertiaryFixedVariant",
+ "columnName": "light_onTertiaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.primary",
+ "columnName": "dark_primary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onPrimary",
+ "columnName": "dark_onPrimary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.primaryContainer",
+ "columnName": "dark_primaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onPrimaryContainer",
+ "columnName": "dark_onPrimaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.secondary",
+ "columnName": "dark_secondary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSecondary",
+ "columnName": "dark_onSecondary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.secondaryContainer",
+ "columnName": "dark_secondaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSecondaryContainer",
+ "columnName": "dark_onSecondaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.tertiary",
+ "columnName": "dark_tertiary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onTertiary",
+ "columnName": "dark_onTertiary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.tertiaryContainer",
+ "columnName": "dark_tertiaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onTertiaryContainer",
+ "columnName": "dark_onTertiaryContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.background",
+ "columnName": "dark_background",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onBackground",
+ "columnName": "dark_onBackground",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surface",
+ "columnName": "dark_surface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSurface",
+ "columnName": "dark_onSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceVariant",
+ "columnName": "dark_surfaceVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSurfaceVariant",
+ "columnName": "dark_onSurfaceVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.error",
+ "columnName": "dark_error",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onError",
+ "columnName": "dark_onError",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.outline",
+ "columnName": "dark_outline",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.errorContainer",
+ "columnName": "dark_errorContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onErrorContainer",
+ "columnName": "dark_onErrorContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.inversePrimary",
+ "columnName": "dark_inversePrimary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.inverseSurface",
+ "columnName": "dark_inverseSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.inverseOnSurface",
+ "columnName": "dark_inverseOnSurface",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceTint",
+ "columnName": "dark_surfaceTint",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.outlineVariant",
+ "columnName": "dark_outlineVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.scrim",
+ "columnName": "dark_scrim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceBright",
+ "columnName": "dark_surfaceBright",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceDim",
+ "columnName": "dark_surfaceDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceContainer",
+ "columnName": "dark_surfaceContainer",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceContainerHigh",
+ "columnName": "dark_surfaceContainerHigh",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceContainerHighest",
+ "columnName": "dark_surfaceContainerHighest",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceContainerLow",
+ "columnName": "dark_surfaceContainerLow",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.surfaceContainerLowest",
+ "columnName": "dark_surfaceContainerLowest",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.primaryFixed",
+ "columnName": "dark_primaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.primaryFixedDim",
+ "columnName": "dark_primaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onPrimaryFixed",
+ "columnName": "dark_onPrimaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onPrimaryFixedVariant",
+ "columnName": "dark_onPrimaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.secondaryFixed",
+ "columnName": "dark_secondaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.secondaryFixedDim",
+ "columnName": "dark_secondaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSecondaryFixed",
+ "columnName": "dark_onSecondaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onSecondaryFixedVariant",
+ "columnName": "dark_onSecondaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.tertiaryFixed",
+ "columnName": "dark_tertiaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.tertiaryFixedDim",
+ "columnName": "dark_tertiaryFixedDim",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onTertiaryFixed",
+ "columnName": "dark_onTertiaryFixed",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "darkThemeValues.onTertiaryFixedVariant",
+ "columnName": "dark_onTertiaryFixedVariant",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "albumArtUriString"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_album_art_themes_albumArtUriString_paletteStyle",
+ "unique": false,
+ "columnNames": [
+ "albumArtUriString",
+ "paletteStyle"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_album_art_themes_albumArtUriString_paletteStyle` ON `${TABLE_NAME}` (`albumArtUriString`, `paletteStyle`)"
+ }
+ ]
+ },
+ {
+ "tableName": "search_history",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "query",
+ "columnName": "query",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `artist_id` INTEGER NOT NULL, `album_artist` TEXT, `album_name` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `content_uri_string` TEXT NOT NULL, `album_art_uri_string` TEXT, `duration` INTEGER NOT NULL, `genre` TEXT, `file_path` TEXT NOT NULL, `parent_directory_path` TEXT NOT NULL, `is_favorite` INTEGER NOT NULL DEFAULT 0, `lyrics` TEXT DEFAULT null, `track_number` INTEGER NOT NULL DEFAULT 0, `disc_number` INTEGER DEFAULT null, `year` INTEGER NOT NULL DEFAULT 0, `date_added` INTEGER NOT NULL DEFAULT 0, `mime_type` TEXT, `bitrate` INTEGER, `sample_rate` INTEGER, `telegram_chat_id` INTEGER, `telegram_file_id` INTEGER, `artists_json` TEXT, `source_type` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`album_id`) REFERENCES `albums`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistName",
+ "columnName": "artist_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtist",
+ "columnName": "album_artist",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "albumName",
+ "columnName": "album_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "album_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contentUriString",
+ "columnName": "content_uri_string",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUriString",
+ "columnName": "album_art_uri_string",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "filePath",
+ "columnName": "file_path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "parentDirectoryPath",
+ "columnName": "parent_directory_path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "is_favorite",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "lyrics",
+ "columnName": "lyrics",
+ "affinity": "TEXT",
+ "defaultValue": "null"
+ },
+ {
+ "fieldPath": "trackNumber",
+ "columnName": "track_number",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "disc_number",
+ "affinity": "INTEGER",
+ "defaultValue": "null"
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "sampleRate",
+ "columnName": "sample_rate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "telegramChatId",
+ "columnName": "telegram_chat_id",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "telegramFileId",
+ "columnName": "telegram_file_id",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "artistsJson",
+ "columnName": "artists_json",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "sourceType",
+ "columnName": "source_type",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_songs_title",
+ "unique": false,
+ "columnNames": [
+ "title"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_title` ON `${TABLE_NAME}` (`title`)"
+ },
+ {
+ "name": "index_songs_album_id",
+ "unique": false,
+ "columnNames": [
+ "album_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_album_id` ON `${TABLE_NAME}` (`album_id`)"
+ },
+ {
+ "name": "index_songs_artist_id",
+ "unique": false,
+ "columnNames": [
+ "artist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_artist_id` ON `${TABLE_NAME}` (`artist_id`)"
+ },
+ {
+ "name": "index_songs_artist_name",
+ "unique": false,
+ "columnNames": [
+ "artist_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_artist_name` ON `${TABLE_NAME}` (`artist_name`)"
+ },
+ {
+ "name": "index_songs_genre",
+ "unique": false,
+ "columnNames": [
+ "genre"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_genre` ON `${TABLE_NAME}` (`genre`)"
+ },
+ {
+ "name": "index_songs_parent_directory_path",
+ "unique": false,
+ "columnNames": [
+ "parent_directory_path"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path` ON `${TABLE_NAME}` (`parent_directory_path`)"
+ },
+ {
+ "name": "index_songs_file_path",
+ "unique": false,
+ "columnNames": [
+ "file_path"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_file_path` ON `${TABLE_NAME}` (`file_path`)"
+ },
+ {
+ "name": "index_songs_content_uri_string",
+ "unique": false,
+ "columnNames": [
+ "content_uri_string"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_content_uri_string` ON `${TABLE_NAME}` (`content_uri_string`)"
+ },
+ {
+ "name": "index_songs_date_added",
+ "unique": false,
+ "columnNames": [
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_date_added` ON `${TABLE_NAME}` (`date_added`)"
+ },
+ {
+ "name": "index_songs_duration",
+ "unique": false,
+ "columnNames": [
+ "duration"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_duration` ON `${TABLE_NAME}` (`duration`)"
+ },
+ {
+ "name": "index_songs_source_type",
+ "unique": false,
+ "columnNames": [
+ "source_type"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_source_type` ON `${TABLE_NAME}` (`source_type`)"
+ },
+ {
+ "name": "index_songs_parent_directory_path_source_type_album_id",
+ "unique": false,
+ "columnNames": [
+ "parent_directory_path",
+ "source_type",
+ "album_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path_source_type_album_id` ON `${TABLE_NAME}` (`parent_directory_path`, `source_type`, `album_id`)"
+ },
+ {
+ "name": "index_songs_parent_directory_path_source_type_id",
+ "unique": false,
+ "columnNames": [
+ "parent_directory_path",
+ "source_type",
+ "id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_parent_directory_path_source_type_id` ON `${TABLE_NAME}` (`parent_directory_path`, `source_type`, `id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "albums",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "album_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "artists",
+ "onDelete": "SET NULL",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "artist_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "songs_fts",
+ "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, tokenize=unicode61)",
+ "fields": [
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistName",
+ "columnName": "artist_name",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "rowid"
+ ]
+ },
+ "ftsVersion": "FTS4",
+ "ftsOptions": {
+ "tokenizer": "unicode61",
+ "tokenizerArgs": [],
+ "contentTable": "",
+ "languageIdColumnName": "",
+ "matchInfo": "FTS4",
+ "notIndexedColumns": [],
+ "prefixSizes": [],
+ "preferredOrder": "ASC"
+ },
+ "contentSyncTriggers": []
+ },
+ {
+ "tableName": "albums",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist_name` TEXT NOT NULL, `artist_id` INTEGER NOT NULL, `album_art_uri_string` TEXT, `song_count` INTEGER NOT NULL, `date_added` INTEGER NOT NULL, `year` INTEGER NOT NULL, `album_artist` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistName",
+ "columnName": "artist_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUriString",
+ "columnName": "album_art_uri_string",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtist",
+ "columnName": "album_artist",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_albums_title",
+ "unique": false,
+ "columnNames": [
+ "title"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_title` ON `${TABLE_NAME}` (`title`)"
+ },
+ {
+ "name": "index_albums_artist_id",
+ "unique": false,
+ "columnNames": [
+ "artist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_id` ON `${TABLE_NAME}` (`artist_id`)"
+ },
+ {
+ "name": "index_albums_artist_name",
+ "unique": false,
+ "columnNames": [
+ "artist_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist_name` ON `${TABLE_NAME}` (`artist_name`)"
+ },
+ {
+ "name": "index_albums_album_artist",
+ "unique": false,
+ "columnNames": [
+ "album_artist"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_album_artist` ON `${TABLE_NAME}` (`album_artist`)"
+ }
+ ]
+ },
+ {
+ "tableName": "artists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `track_count` INTEGER NOT NULL, `image_url` TEXT, `custom_image_uri` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackCount",
+ "columnName": "track_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "image_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "customImageUri",
+ "columnName": "custom_image_uri",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_artists_name",
+ "unique": false,
+ "columnNames": [
+ "name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`name`)"
+ }
+ ]
+ },
+ {
+ "tableName": "transition_rules",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `fromTrackId` TEXT, `toTrackId` TEXT, `mode` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `curveIn` TEXT NOT NULL, `curveOut` TEXT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromTrackId",
+ "columnName": "fromTrackId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "toTrackId",
+ "columnName": "toTrackId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "settings.mode",
+ "columnName": "mode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "settings.durationMs",
+ "columnName": "durationMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "settings.curveIn",
+ "columnName": "curveIn",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "settings.curveOut",
+ "columnName": "curveOut",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_transition_rules_playlistId_fromTrackId_toTrackId",
+ "unique": true,
+ "columnNames": [
+ "playlistId",
+ "fromTrackId",
+ "toTrackId"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_transition_rules_playlistId_fromTrackId_toTrackId` ON `${TABLE_NAME}` (`playlistId`, `fromTrackId`, `toTrackId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "song_artist_cross_ref",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` INTEGER NOT NULL, `artist_id` INTEGER NOT NULL, `is_primary` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`song_id`, `artist_id`), FOREIGN KEY(`song_id`) REFERENCES `songs`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artist_id`) REFERENCES `artists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "song_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isPrimary",
+ "columnName": "is_primary",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "song_id",
+ "artist_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_song_artist_cross_ref_song_id",
+ "unique": false,
+ "columnNames": [
+ "song_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_song_id` ON `${TABLE_NAME}` (`song_id`)"
+ },
+ {
+ "name": "index_song_artist_cross_ref_artist_id",
+ "unique": false,
+ "columnNames": [
+ "artist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_artist_id` ON `${TABLE_NAME}` (`artist_id`)"
+ },
+ {
+ "name": "index_song_artist_cross_ref_is_primary",
+ "unique": false,
+ "columnNames": [
+ "is_primary"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_cross_ref_is_primary` ON `${TABLE_NAME}` (`is_primary`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "songs",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "song_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "artists",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "artist_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "telegram_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chat_id` INTEGER NOT NULL, `message_id` INTEGER NOT NULL, `file_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `duration` INTEGER NOT NULL, `file_path` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `date_added` INTEGER NOT NULL, `album_art_uri` TEXT, `thread_id` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatId",
+ "columnName": "chat_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageId",
+ "columnName": "message_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fileId",
+ "columnName": "file_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "filePath",
+ "columnName": "file_path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUriString",
+ "columnName": "album_art_uri",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "threadId",
+ "columnName": "thread_id",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_telegram_songs_chat_id",
+ "unique": false,
+ "columnNames": [
+ "chat_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_songs_chat_id` ON `${TABLE_NAME}` (`chat_id`)"
+ },
+ {
+ "name": "index_telegram_songs_message_id",
+ "unique": false,
+ "columnNames": [
+ "message_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_songs_message_id` ON `${TABLE_NAME}` (`message_id`)"
+ },
+ {
+ "name": "index_telegram_songs_file_id",
+ "unique": false,
+ "columnNames": [
+ "file_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_songs_file_id` ON `${TABLE_NAME}` (`file_id`)"
+ },
+ {
+ "name": "index_telegram_songs_chat_id_message_id",
+ "unique": false,
+ "columnNames": [
+ "chat_id",
+ "message_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_songs_chat_id_message_id` ON `${TABLE_NAME}` (`chat_id`, `message_id`)"
+ },
+ {
+ "name": "index_telegram_songs_thread_id",
+ "unique": false,
+ "columnNames": [
+ "thread_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_songs_thread_id` ON `${TABLE_NAME}` (`thread_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "telegram_channels",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `username` TEXT, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, `photo_path` TEXT, PRIMARY KEY(`chat_id`))",
+ "fields": [
+ {
+ "fieldPath": "chatId",
+ "columnName": "chat_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "username",
+ "columnName": "username",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "photoPath",
+ "columnName": "photo_path",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "chat_id"
+ ]
+ }
+ },
+ {
+ "tableName": "song_engagements",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `play_count` INTEGER NOT NULL, `total_play_duration_ms` INTEGER NOT NULL, `last_played_timestamp` INTEGER NOT NULL, PRIMARY KEY(`song_id`))",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "song_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playCount",
+ "columnName": "play_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalPlayDurationMs",
+ "columnName": "total_play_duration_ms",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastPlayedTimestamp",
+ "columnName": "last_played_timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "song_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_song_engagements_play_count",
+ "unique": false,
+ "columnNames": [
+ "play_count"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_song_engagements_play_count` ON `${TABLE_NAME}` (`play_count`)"
+ }
+ ]
+ },
+ {
+ "tableName": "favorites",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`songId`))",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "isFavorite",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "songId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_favorites_timestamp",
+ "unique": false,
+ "columnNames": [
+ "timestamp"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_favorites_timestamp` ON `${TABLE_NAME}` (`timestamp`)"
+ }
+ ]
+ },
+ {
+ "tableName": "lyrics",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` INTEGER NOT NULL, `content` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `source` TEXT, PRIMARY KEY(`songId`))",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isSynced",
+ "columnName": "isSynced",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "songId"
+ ]
+ }
+ },
+ {
+ "tableName": "netease_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `netease_id` INTEGER NOT NULL, `playlist_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `album_art_url` TEXT, `mime_type` TEXT NOT NULL, `bitrate` INTEGER, `date_added` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "neteaseId",
+ "columnName": "netease_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlist_id",
+ "affinity": "INTEGER",
+ "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": "albumId",
+ "columnName": "album_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUrl",
+ "columnName": "album_art_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_netease_songs_netease_id",
+ "unique": false,
+ "columnNames": [
+ "netease_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_netease_songs_netease_id` ON `${TABLE_NAME}` (`netease_id`)"
+ },
+ {
+ "name": "index_netease_songs_playlist_id",
+ "unique": false,
+ "columnNames": [
+ "playlist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_netease_songs_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)"
+ },
+ {
+ "name": "index_netease_songs_playlist_id_date_added",
+ "unique": false,
+ "columnNames": [
+ "playlist_id",
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_netease_songs_playlist_id_date_added` ON `${TABLE_NAME}` (`playlist_id`, `date_added`)"
+ }
+ ]
+ },
+ {
+ "tableName": "netease_playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `cover_url` TEXT, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coverUrl",
+ "columnName": "cover_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "gdrive_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `drive_file_id` TEXT NOT NULL, `folder_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `album_art_url` TEXT, `mime_type` TEXT NOT NULL, `bitrate` INTEGER, `file_size` INTEGER NOT NULL, `date_added` INTEGER NOT NULL, `date_modified` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "driveFileId",
+ "columnName": "drive_file_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "folderId",
+ "columnName": "folder_id",
+ "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": "albumId",
+ "columnName": "album_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUrl",
+ "columnName": "album_art_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "fileSize",
+ "columnName": "file_size",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateModified",
+ "columnName": "date_modified",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_gdrive_songs_drive_file_id",
+ "unique": false,
+ "columnNames": [
+ "drive_file_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_gdrive_songs_drive_file_id` ON `${TABLE_NAME}` (`drive_file_id`)"
+ },
+ {
+ "name": "index_gdrive_songs_folder_id",
+ "unique": false,
+ "columnNames": [
+ "folder_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_gdrive_songs_folder_id` ON `${TABLE_NAME}` (`folder_id`)"
+ },
+ {
+ "name": "index_gdrive_songs_folder_id_date_added",
+ "unique": false,
+ "columnNames": [
+ "folder_id",
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_gdrive_songs_folder_id_date_added` ON `${TABLE_NAME}` (`folder_id`, `date_added`)"
+ }
+ ]
+ },
+ {
+ "tableName": "gdrive_folders",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `last_modified` INTEGER NOT NULL, `is_ai_generated` INTEGER NOT NULL, `is_queue_generated` INTEGER NOT NULL, `cover_image_uri` TEXT, `cover_color_argb` INTEGER, `cover_icon_name` TEXT, `cover_shape_type` TEXT, `cover_shape_detail_1` REAL, `cover_shape_detail_2` REAL, `cover_shape_detail_3` REAL, `cover_shape_detail_4` REAL, `source` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastModified",
+ "columnName": "last_modified",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isAiGenerated",
+ "columnName": "is_ai_generated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isQueueGenerated",
+ "columnName": "is_queue_generated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coverImageUri",
+ "columnName": "cover_image_uri",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "coverColorArgb",
+ "columnName": "cover_color_argb",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "coverIconName",
+ "columnName": "cover_icon_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "coverShapeType",
+ "columnName": "cover_shape_type",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "coverShapeDetail1",
+ "columnName": "cover_shape_detail_1",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "coverShapeDetail2",
+ "columnName": "cover_shape_detail_2",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "coverShapeDetail3",
+ "columnName": "cover_shape_detail_3",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "coverShapeDetail4",
+ "columnName": "cover_shape_detail_4",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_playlists_last_modified",
+ "unique": false,
+ "columnNames": [
+ "last_modified"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_last_modified` ON `${TABLE_NAME}` (`last_modified`)"
+ }
+ ]
+ },
+ {
+ "tableName": "playlist_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` TEXT NOT NULL, `song_id` TEXT NOT NULL, `sort_order` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `song_id`))",
+ "fields": [
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlist_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songId",
+ "columnName": "song_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortOrder",
+ "columnName": "sort_order",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "playlist_id",
+ "song_id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_playlist_songs_playlist_id_sort_order",
+ "unique": false,
+ "columnNames": [
+ "playlist_id",
+ "sort_order"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_playlist_id_sort_order` ON `${TABLE_NAME}` (`playlist_id`, `sort_order`)"
+ },
+ {
+ "name": "index_playlist_songs_song_id",
+ "unique": false,
+ "columnNames": [
+ "song_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_song_id` ON `${TABLE_NAME}` (`song_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "qqmusic_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `song_mid` TEXT NOT NULL, `playlist_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_mid` TEXT, `duration` INTEGER NOT NULL, `album_art_url` TEXT, `mime_type` TEXT NOT NULL, `bitrate` INTEGER, `date_added` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songMid",
+ "columnName": "song_mid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlist_id",
+ "affinity": "INTEGER",
+ "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": "albumMid",
+ "columnName": "album_mid",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUrl",
+ "columnName": "album_art_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "qqmusic_playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `cover_url` TEXT, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coverUrl",
+ "columnName": "cover_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "navidrome_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `navidrome_id` TEXT NOT NULL, `playlist_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `artist_id` TEXT, `album` TEXT NOT NULL, `album_id` TEXT, `cover_art_id` TEXT, `duration` INTEGER NOT NULL, `track_number` INTEGER NOT NULL, `disc_number` INTEGER NOT NULL, `year` INTEGER NOT NULL, `genre` TEXT, `bitRate` INTEGER, `mime_type` TEXT, `suffix` TEXT, `path` TEXT NOT NULL, `date_added` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "navidromeId",
+ "columnName": "navidrome_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlist_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "album",
+ "columnName": "album",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "album_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "coverArtId",
+ "columnName": "cover_art_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackNumber",
+ "columnName": "track_number",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "disc_number",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "bitRate",
+ "columnName": "bitRate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "suffix",
+ "columnName": "suffix",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_navidrome_songs_navidrome_id",
+ "unique": false,
+ "columnNames": [
+ "navidrome_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_navidrome_songs_navidrome_id` ON `${TABLE_NAME}` (`navidrome_id`)"
+ },
+ {
+ "name": "index_navidrome_songs_playlist_id",
+ "unique": false,
+ "columnNames": [
+ "playlist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_navidrome_songs_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)"
+ },
+ {
+ "name": "index_navidrome_songs_playlist_id_date_added",
+ "unique": false,
+ "columnNames": [
+ "playlist_id",
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_navidrome_songs_playlist_id_date_added` ON `${TABLE_NAME}` (`playlist_id`, `date_added`)"
+ }
+ ]
+ },
+ {
+ "tableName": "navidrome_playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `comment` TEXT, `owner` TEXT, `cover_art_id` TEXT, `song_count` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `public` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "comment",
+ "columnName": "comment",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "owner",
+ "columnName": "owner",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "coverArtId",
+ "columnName": "cover_art_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "public",
+ "columnName": "public",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "telegram_topics",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chat_id` INTEGER NOT NULL, `thread_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, `icon_emoji` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatId",
+ "columnName": "chat_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "threadId",
+ "columnName": "thread_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "iconEmoji",
+ "columnName": "icon_emoji",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_telegram_topics_chat_id",
+ "unique": false,
+ "columnNames": [
+ "chat_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_telegram_topics_chat_id` ON `${TABLE_NAME}` (`chat_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "jellyfin_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `jellyfin_id` TEXT NOT NULL, `playlist_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `artist_id` TEXT, `album` TEXT NOT NULL, `album_id` TEXT, `duration` INTEGER NOT NULL, `track_number` INTEGER NOT NULL, `disc_number` INTEGER NOT NULL, `year` INTEGER NOT NULL, `genre` TEXT, `bitRate` INTEGER, `mime_type` TEXT, `path` TEXT NOT NULL, `date_added` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "jellyfinId",
+ "columnName": "jellyfin_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlist_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "album",
+ "columnName": "album",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "album_id",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackNumber",
+ "columnName": "track_number",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "disc_number",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "bitRate",
+ "columnName": "bitRate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mime_type",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_jellyfin_songs_jellyfin_id",
+ "unique": false,
+ "columnNames": [
+ "jellyfin_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_jellyfin_id` ON `${TABLE_NAME}` (`jellyfin_id`)"
+ },
+ {
+ "name": "index_jellyfin_songs_playlist_id",
+ "unique": false,
+ "columnNames": [
+ "playlist_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)"
+ },
+ {
+ "name": "index_jellyfin_songs_playlist_id_date_added",
+ "unique": false,
+ "columnNames": [
+ "playlist_id",
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_jellyfin_songs_playlist_id_date_added` ON `${TABLE_NAME}` (`playlist_id`, `date_added`)"
+ }
+ ]
+ },
+ {
+ "tableName": "jellyfin_playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `song_count` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "s3_songs",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `object_key` TEXT NOT NULL, `folder_id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `bitrate` INTEGER, `file_size` INTEGER NOT NULL, `date_added` INTEGER NOT NULL, `date_modified` INTEGER NOT NULL, `album_art_uri` TEXT, `metadata_probed` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "objectKey",
+ "columnName": "object_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "folderId",
+ "columnName": "folder_id",
+ "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": "albumId",
+ "columnName": "album_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mimeType",
+ "columnName": "mimeType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "fileSize",
+ "columnName": "file_size",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateAdded",
+ "columnName": "date_added",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dateModified",
+ "columnName": "date_modified",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumArtUri",
+ "columnName": "album_art_uri",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "metadataProbed",
+ "columnName": "metadata_probed",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_s3_songs_object_key",
+ "unique": false,
+ "columnNames": [
+ "object_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_s3_songs_object_key` ON `${TABLE_NAME}` (`object_key`)"
+ },
+ {
+ "name": "index_s3_songs_folder_id",
+ "unique": false,
+ "columnNames": [
+ "folder_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_s3_songs_folder_id` ON `${TABLE_NAME}` (`folder_id`)"
+ },
+ {
+ "name": "index_s3_songs_folder_id_date_added",
+ "unique": false,
+ "columnNames": [
+ "folder_id",
+ "date_added"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_s3_songs_folder_id_date_added` ON `${TABLE_NAME}` (`folder_id`, `date_added`)"
+ }
+ ]
+ },
+ {
+ "tableName": "s3_folders",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `song_count` INTEGER NOT NULL, `last_sync_time` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songCount",
+ "columnName": "song_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncTime",
+ "columnName": "last_sync_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "ai_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`promptHash` TEXT NOT NULL, `responseJson` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`promptHash`))",
+ "fields": [
+ {
+ "fieldPath": "promptHash",
+ "columnName": "promptHash",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "responseJson",
+ "columnName": "responseJson",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "promptHash"
+ ]
+ }
+ },
+ {
+ "tableName": "ai_usage",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `promptType` TEXT NOT NULL, `promptTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `thoughtTokens` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "provider",
+ "columnName": "provider",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "model",
+ "columnName": "model",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "promptType",
+ "columnName": "promptType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "promptTokens",
+ "columnName": "promptTokens",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "outputTokens",
+ "columnName": "outputTokens",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "thoughtTokens",
+ "columnName": "thoughtTokens",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "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, 'f313ce810095cfaa93da8320af98ddb2')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1afb6f4044..ca698d623f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -120,6 +120,13 @@
android:theme="@style/Theme.PixelPlay"
android:windowSoftInputMode="adjustResize" />
+
+
+ @Inject
+ lateinit var s3Repository: dagger.Lazy
+
private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// AÑADE EL COMPANION OBJECT
@@ -121,6 +124,8 @@ class PixelPlayApplication : Application(), ImageLoaderFactory, Configuration.Pr
ProcessLifecycleOwner.get().lifecycle.addObserver(appLifecycleObserver)
advancedPerformanceDiagnosticsController.get().start(startupScope)
+ startupScope.launch { s3Repository.get().hideTracksWithLocalCopies() }
+
startupScope.launch {
AlbumArtUtils.migrateLegacyCacheLocation(this@PixelPlayApplication)
val savedLimit = runCatching {
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt
index 8a141e350e..af398f1550 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt
@@ -1,6 +1,7 @@
package com.theveloper.pixelplay.data.database
import androidx.paging.PagingSource
+import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
@@ -273,6 +274,16 @@ interface MusicDao {
@Query("SELECT id FROM songs WHERE source_type = 6")
suspend fun getAllJellyfinSongIds(): List
+ @Query("SELECT id FROM songs WHERE source_type = 7")
+ suspend fun getAllS3SongIds(): List
+
+ /** Title/artist/duration of on-device songs, used to hide cloud copies of the same track. */
+ @Query("SELECT COUNT(*) FROM songs WHERE source_type = 0")
+ fun observeLocalSongCount(): Flow
+
+ @Query("SELECT title, artist_name, duration, file_path FROM songs WHERE source_type = 0")
+ suspend fun getLocalSongMatchKeys(): List
+
@Transaction
suspend fun deleteSongsAndRelatedData(songIds: List) {
if (songIds.isEmpty()) return
@@ -321,6 +332,13 @@ interface MusicDao {
deleteSongsAndRelatedData(jellyfinSongIds)
}
+ @Transaction
+ suspend fun clearAllS3Songs() {
+ val s3SongIds = getAllS3SongIds()
+ if (s3SongIds.isEmpty()) return
+ deleteSongsAndRelatedData(s3SongIds)
+ }
+
@Transaction
suspend fun clearAllTelegramSongs() {
val telegramSongIds = getAllTelegramSongIds()
@@ -1950,3 +1968,10 @@ interface MusicDao {
const val SONG_BATCH_SIZE = 500
}
}
+
+data class SongMatchKey(
+ @ColumnInfo(name = "title") val title: String,
+ @ColumnInfo(name = "artist_name") val artistName: String,
+ @ColumnInfo(name = "duration") val duration: Long,
+ @ColumnInfo(name = "file_path") val filePath: String = "",
+)
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt
index 094929dd9f..ffc3b0b731 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt
@@ -33,10 +33,12 @@ import androidx.sqlite.db.SupportSQLiteDatabase
TelegramTopicEntity::class,
JellyfinSongEntity::class,
JellyfinPlaylistEntity::class,
+ S3SongEntity::class,
+ S3FolderEntity::class,
AiCacheEntity::class,
AiUsageEntity::class
],
- version = 42,
+ version = 45,
exportSchema = true
)
abstract class PixelPlayDatabase : RoomDatabase() {
@@ -54,6 +56,7 @@ abstract class PixelPlayDatabase : RoomDatabase() {
abstract fun qqmusicDao(): QqMusicDao
abstract fun navidromeDao(): NavidromeDao
abstract fun jellyfinDao(): JellyfinDao
+ abstract fun s3Dao(): S3Dao
abstract fun aiCacheDao(): AiCacheDao
abstract fun aiUsageDao(): AiUsageDao
@@ -747,6 +750,62 @@ abstract class PixelPlayDatabase : RoomDatabase() {
}
}
+ /**
+ * Add S3-compatible storage tables (s3_songs, s3_folders).
+ */
+ val MIGRATION_42_43 = object : Migration(42, 43) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("""
+ CREATE TABLE IF NOT EXISTS s3_songs (
+ id TEXT NOT NULL PRIMARY KEY,
+ object_key TEXT NOT NULL,
+ folder_id TEXT NOT NULL,
+ title TEXT NOT NULL,
+ artist TEXT NOT NULL,
+ album TEXT NOT NULL,
+ album_id INTEGER NOT NULL,
+ duration INTEGER NOT NULL,
+ mime_type TEXT NOT NULL,
+ bitrate INTEGER,
+ file_size INTEGER NOT NULL,
+ date_added INTEGER NOT NULL,
+ date_modified INTEGER NOT NULL
+ )
+ """.trimIndent())
+
+ db.execSQL("""
+ CREATE TABLE IF NOT EXISTS s3_folders (
+ id TEXT NOT NULL PRIMARY KEY,
+ name TEXT NOT NULL,
+ song_count INTEGER NOT NULL DEFAULT 0,
+ last_sync_time INTEGER NOT NULL DEFAULT 0
+ )
+ """.trimIndent())
+
+ db.execSQL("CREATE INDEX IF NOT EXISTS index_s3_songs_object_key ON s3_songs(object_key)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS index_s3_songs_folder_id ON s3_songs(folder_id)")
+ db.execSQL("CREATE INDEX IF NOT EXISTS index_s3_songs_folder_id_date_added ON s3_songs(folder_id, date_added)")
+ }
+ }
+
+ /**
+ * Add embedded artwork column to s3_songs.
+ */
+ val MIGRATION_43_44 = object : Migration(43, 44) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE s3_songs ADD COLUMN album_art_uri TEXT")
+ }
+ }
+
+ /**
+ * Add tag-probe flag to s3_songs (background metadata queue).
+ */
+ val MIGRATION_44_45 = object : Migration(44, 45) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE s3_songs ADD COLUMN metadata_probed INTEGER NOT NULL DEFAULT 0")
+ }
+ }
+
private fun ensureSongsTableHasDateAdded(db: SupportSQLiteDatabase) {
if (!tableExists(db, "songs")) {
recreateSongsTable(db)
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/S3Dao.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/S3Dao.kt
new file mode 100644
index 0000000000..4cc67f4de3
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/data/database/S3Dao.kt
@@ -0,0 +1,68 @@
+package com.theveloper.pixelplay.data.database
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface S3Dao {
+
+ // --- Songs ---
+
+ @Query("SELECT * FROM s3_songs ORDER BY date_added DESC")
+ fun getAllS3Songs(): Flow>
+
+ @Query("SELECT * FROM s3_songs ORDER BY date_added DESC")
+ suspend fun getAllS3SongsList(): List
+
+ @Query("SELECT * FROM s3_songs WHERE folder_id = :folderId ORDER BY title ASC")
+ fun getSongsByFolder(folderId: String): Flow>
+
+ @Query("SELECT * FROM s3_songs WHERE metadata_probed = 0 LIMIT :limit")
+ suspend fun getUnprobedBatch(limit: Int = 8): List
+
+ @Query("SELECT COUNT(*) FROM s3_songs WHERE metadata_probed = 0")
+ fun countUnprobedFlow(): Flow
+
+ @Query(
+ """UPDATE s3_songs SET title = :title, artist = :artist, album = :album,
+ duration = :duration, album_art_uri = :albumArtUri, metadata_probed = 1 WHERE id = :id"""
+ )
+ suspend fun applyProbeResult(
+ id: String, title: String, artist: String, album: String,
+ duration: Long, albumArtUri: String?
+ )
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertSongs(songs: List)
+
+ @Query("DELETE FROM s3_songs WHERE id IN (:ids)")
+ suspend fun deleteSongsByIds(ids: List)
+
+ @Query("DELETE FROM s3_songs WHERE folder_id = :folderId")
+ suspend fun deleteSongsByFolder(folderId: String)
+
+ // --- Folders ---
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertFolder(folder: S3FolderEntity)
+
+ @Query("SELECT * FROM s3_folders ORDER BY name ASC")
+ fun getAllFolders(): Flow>
+
+ @Query("SELECT * FROM s3_folders")
+ suspend fun getAllFoldersList(): List
+
+ @Query("DELETE FROM s3_folders WHERE id = :folderId")
+ suspend fun deleteFolder(folderId: String)
+
+ // --- Clear All ---
+
+ @Query("DELETE FROM s3_songs")
+ suspend fun clearAllSongs()
+
+ @Query("DELETE FROM s3_folders")
+ suspend fun clearAllFolders()
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/S3FolderEntity.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/S3FolderEntity.kt
new file mode 100644
index 0000000000..7cb2582696
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/data/database/S3FolderEntity.kt
@@ -0,0 +1,13 @@
+package com.theveloper.pixelplay.data.database
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "s3_folders")
+data class S3FolderEntity(
+ @PrimaryKey val id: String, // S3 prefix, e.g. "Music/"
+ val name: String, // display name (last segment)
+ @ColumnInfo(name = "song_count") val songCount: Int = 0,
+ @ColumnInfo(name = "last_sync_time") val lastSyncTime: Long = 0
+)
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/S3SongEntity.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/S3SongEntity.kt
new file mode 100644
index 0000000000..2094e5d31f
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/data/database/S3SongEntity.kt
@@ -0,0 +1,83 @@
+package com.theveloper.pixelplay.data.database
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.theveloper.pixelplay.data.model.Song
+import com.theveloper.pixelplay.data.s3.S3Repository
+
+@Entity(
+ tableName = "s3_songs",
+ indices = [
+ Index(value = ["object_key"]),
+ Index(value = ["folder_id"]),
+ Index(value = ["folder_id", "date_added"])
+ ]
+)
+data class S3SongEntity(
+ @PrimaryKey val id: String, // "{folderId}_{objectKey}"
+ @ColumnInfo(name = "object_key") val objectKey: String, // full S3 object key
+ @ColumnInfo(name = "folder_id") val folderId: String,
+ val title: String,
+ val artist: String,
+ val album: String,
+ @ColumnInfo(name = "album_id") val albumId: Long,
+ val duration: Long, // milliseconds (0 = unknown)
+ val mimeType: String,
+ val bitrate: Int?,
+ @ColumnInfo(name = "file_size") val fileSize: Long,
+ @ColumnInfo(name = "date_added") val dateAdded: Long,
+ @ColumnInfo(name = "date_modified") val dateModified: Long,
+ @ColumnInfo(name = "album_art_uri") val albumArtUri: String? = null,
+ @ColumnInfo(name = "metadata_probed", defaultValue = "0") val metadataProbed: Boolean = false
+)
+
+fun S3SongEntity.toSong(): Song {
+ return Song(
+ id = "s3_$objectKey",
+ title = title,
+ artist = artist,
+ artistId = artistIdFor(artist),
+ album = album,
+ albumId = albumIdFor(album),
+ path = "",
+ contentUriString = "s3://$objectKey",
+ albumArtUriString = albumArtUri,
+ duration = duration,
+ genre = S3Repository.S3_GENRE,
+ mimeType = mimeType,
+ bitrate = bitrate,
+ sampleRate = 0,
+ year = 0,
+ trackNumber = 0,
+ dateAdded = dateAdded,
+ isFavorite = false,
+ s3ObjectKey = objectKey
+ )
+}
+
+fun Song.toS3SongEntity(folderId: String): S3SongEntity {
+ val resolvedKey = s3ObjectKey ?: ""
+ return S3SongEntity(
+ id = "${folderId}_${resolvedKey}",
+ objectKey = resolvedKey,
+ folderId = folderId,
+ title = title,
+ artist = artist,
+ album = album,
+ albumId = albumId,
+ duration = duration,
+ mimeType = mimeType ?: "audio/mpeg",
+ bitrate = bitrate,
+ fileSize = 0L,
+ dateAdded = dateAdded,
+ dateModified = dateAdded
+ )
+}
+
+internal fun artistIdFor(artist: String): Long =
+ -(S3Repository.S3_ARTIST_ID_OFFSET + artist.lowercase().hashCode().toLong().let { if (it < 0) -it else it })
+
+internal fun albumIdFor(album: String): Long =
+ -(S3Repository.S3_ALBUM_ID_OFFSET + album.lowercase().hashCode().toLong().let { if (it < 0) -it else it })
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/SongEntity.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/SongEntity.kt
index 19f948b792..decb4f6a0f 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/database/SongEntity.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/database/SongEntity.kt
@@ -22,6 +22,7 @@ object SourceType {
const val QQMUSIC = 4
const val NAVIDROME = 5
const val JELLYFIN = 6
+ const val S3 = 7
/** Derive source type from a content URI string (fallback for migration / conversion). */
fun fromContentUri(uri: String): Int = when {
@@ -31,6 +32,7 @@ object SourceType {
uri.startsWith("qqmusic://") -> QQMUSIC
uri.startsWith("navidrome://") -> NAVIDROME
uri.startsWith("jellyfin://") -> JELLYFIN
+ uri.startsWith("s3://") -> S3
else -> LOCAL
}
}
@@ -145,6 +147,9 @@ private fun SongEntity.toSongInternal(artists: List): Song {
jellyfinId = if (this.contentUriString.startsWith("jellyfin://")) {
this.contentUriString.removePrefix("jellyfin://")
} else null,
+ s3ObjectKey = if (this.contentUriString.startsWith("s3://")) {
+ this.contentUriString.removePrefix("s3://")
+ } else null,
mimeType = this.mimeType,
bitrate = this.bitrate,
sampleRate = this.sampleRate
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/model/Song.kt b/app/src/main/java/com/theveloper/pixelplay/data/model/Song.kt
index 951db81179..9d5b3ab7c2 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/model/Song.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/model/Song.kt
@@ -4,6 +4,10 @@ import android.os.Parcelable
import androidx.compose.runtime.Immutable
import kotlinx.parcelize.Parcelize
+private val CLOUD_URI_SCHEMES = listOf(
+ "telegram://", "netease://", "qqmusic://", "navidrome://", "jellyfin://", "gdrive://", "s3://",
+)
+
@Immutable
@Parcelize
data class Song(
@@ -42,7 +46,12 @@ data class Song(
val qqMusicMid: String? = null, // QQ Music song MID
val navidromeId: String? = null, // Navidrome song ID
val jellyfinId: String? = null, // Jellyfin item ID
+ val s3ObjectKey: String? = null // S3-compatible object key
) : Parcelable {
+ /** True when the song streams from a cloud source rather than a file on this device. */
+ val isCloud: Boolean
+ get() = CLOUD_URI_SCHEMES.any { contentUriString.startsWith(it, ignoreCase = true) }
+
/**
* Returns the display string for artists.
* If multiple artists exist (populated during sync), joins them with ", ".
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/s3/LocalTrackIndex.kt b/app/src/main/java/com/theveloper/pixelplay/data/s3/LocalTrackIndex.kt
new file mode 100644
index 0000000000..e0ea7c72a5
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/data/s3/LocalTrackIndex.kt
@@ -0,0 +1,56 @@
+package com.theveloper.pixelplay.data.s3
+
+import com.theveloper.pixelplay.data.database.SongMatchKey
+import com.theveloper.pixelplay.data.stream.CloudMusicUtils
+import kotlin.math.abs
+
+/**
+ * Index of on-device tracks. The S3 mirror skips objects that have a local copy, so the
+ * library shows the local file instead of a duplicate stream.
+ */
+class LocalTrackIndex(localSongs: List) {
+
+ private class Local(val artist: String, val duration: Long)
+
+ private val byTitle: Map> =
+ localSongs.groupBy({ norm(it.title) }, { Local(primaryArtist(it.artistName), it.duration) })
+
+ private val fileNames: Set =
+ localSongs.mapNotNullTo(HashSet()) { baseName(it.filePath).takeIf(String::isNotEmpty) }
+
+ /**
+ * A local copy is the same file name, or the same title with either the same primary artist
+ * or (when one side's artist is missing or spelled differently) the same length.
+ */
+ fun hasLocalCopy(title: String, artist: String, durationMs: Long, objectKey: String = ""): Boolean {
+ if (objectKey.isNotEmpty() && baseName(objectKey) in fileNames) return true
+ val candidates = byTitle[norm(title).ifEmpty { return false }] ?: return false
+ val s3Artist = primaryArtist(artist)
+ return candidates.any { local ->
+ val closeLength = local.duration > 0 && durationMs > 0 &&
+ abs(local.duration - durationMs) <= DURATION_TOLERANCE_MS
+ val sameArtist = local.artist.isNotEmpty() && local.artist == s3Artist
+ // Unknown durations (S3 tags not probed yet) match on title + artist alone.
+ closeLength || (sameArtist && (local.duration <= 0 || durationMs <= 0))
+ }
+ }
+
+ companion object {
+ const val DURATION_TOLERANCE_MS = 3_000L
+ private val ARTIST_SEPARATORS = Regex("[,;/&]")
+ private val BRACKETED_SUFFIX = Regex("""\s*[(\[][^)\]]*[)\]]""")
+
+ /** Lowercased, without "(Remastered)"-style suffixes or punctuation. */
+ private fun norm(value: String) = value.lowercase()
+ .replace(BRACKETED_SUFFIX, "")
+ .filter { it.isLetterOrDigit() }
+
+ // Tags differ in how they join artists ("A, B" vs "A; B"), so also cut at common separators.
+ private fun primaryArtist(artist: String) = norm(
+ CloudMusicUtils.parseArtistNames(artist).firstOrNull().orEmpty().split(ARTIST_SEPARATORS).first()
+ )
+
+ /** "Music/Artist - Song.flac" -> "artistsong". */
+ private fun baseName(path: String) = norm(path.substringAfterLast('/').substringBeforeLast('.'))
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/s3/S3ApiService.kt b/app/src/main/java/com/theveloper/pixelplay/data/s3/S3ApiService.kt
new file mode 100644
index 0000000000..85e996b798
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/data/s3/S3ApiService.kt
@@ -0,0 +1,173 @@
+package com.theveloper.pixelplay.data.s3
+
+import android.util.Xml
+import com.theveloper.pixelplay.data.stream.CloudStreamSecurity
+import java.io.IOException
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.xmlpull.v1.XmlPullParser
+
+/** One "folder" = an S3 common prefix (e.g. `Music/`). */
+data class S3FolderEntry(
+ val prefix: String,
+ val songCount: Int
+)
+
+data class S3ObjectEntry(
+ val key: String,
+ val sizeBytes: Long,
+ val lastModified: String
+)
+
+data class S3ListPage(
+ val folders: List,
+ val objects: List,
+ val isTruncated: Boolean,
+ val continuationToken: String?
+)
+
+@Singleton
+class S3ApiService @Inject constructor(
+ private val okHttpClient: OkHttpClient
+) {
+
+ /**
+ * Lists one level of folders and (optionally all) objects under [prefix].
+ * With [delimiter] = "/" returns immediate sub-folders only.
+ */
+ suspend fun listObjects(
+ endpoint: String,
+ bucket: String,
+ accessKey: String,
+ secretKey: String,
+ region: String,
+ prefix: String = "",
+ delimiter: String? = "/",
+ continuationToken: String? = null,
+ maxKeys: Int = 1000
+ ): S3ListPage = withContext(Dispatchers.IO) {
+ val params = sortedMapOf("list-type" to "2")
+ if (prefix.isNotEmpty()) params["prefix"] = prefix
+ if (delimiter != null) params["delimiter"] = delimiter
+ params["max-keys"] = maxKeys.toString()
+ continuationToken?.let { params["continuation-token"] = it }
+
+ val request = S3SigV4.signedHeaders(
+ builder = Request.Builder(),
+ endpoint = endpoint,
+ bucket = bucket,
+ query = params,
+ accessKey = accessKey,
+ secretKey = secretKey,
+ region = region
+ ).build()
+
+ okHttpClient.newCall(request).execute().use { response ->
+ val body = response.body?.string().orEmpty()
+ if (!response.isSuccessful) {
+ throw IOException("S3 list failed (${response.code}): $body")
+ }
+ parseListBucketResult(body)
+ }
+ }
+
+ /** Lists every object under [prefix], following continuation tokens. */
+ suspend fun listAllObjects(
+ endpoint: String,
+ bucket: String,
+ accessKey: String,
+ secretKey: String,
+ region: String,
+ prefix: String,
+ maxKeys: Int = 1000
+ ): List {
+ val all = mutableListOf()
+ var token: String? = null
+ do {
+ val page = listObjects(
+ endpoint, bucket, accessKey, secretKey, region,
+ prefix = prefix, delimiter = null,
+ continuationToken = token, maxKeys = maxKeys
+ )
+ all += page.objects
+ token = if (page.isTruncated) page.continuationToken else null
+ } while (token != null)
+ return all
+ }
+
+ /** Cheap connectivity + credential check: a single-key listing. */
+ suspend fun ping(
+ endpoint: String,
+ bucket: String,
+ accessKey: String,
+ secretKey: String,
+ region: String
+ ) {
+ listObjects(endpoint, bucket, accessKey, secretKey, region, maxKeys = 1)
+ }
+
+ suspend fun presignGetUrl(
+ endpoint: String,
+ bucket: String,
+ objectKey: String,
+ accessKey: String,
+ secretKey: String,
+ region: String,
+ expiresInSeconds: Long = 3600
+ ): String {
+ CloudStreamSecurity.validateS3ObjectKey(objectKey)
+ return S3SigV4.presignGetUrl(
+ endpoint, bucket, objectKey, accessKey, secretKey, region, expiresInSeconds
+ )
+ }
+
+ internal fun parseListBucketResult(xml: String): S3ListPage {
+ val parser = Xml.newPullParser()
+ parser.setInput(xml.reader())
+ val folders = mutableListOf()
+ val objects = mutableListOf()
+ var isTruncated = false
+ var continuationToken: String? = null
+
+ var insideContents = false
+ var insideCommonPrefixes = false
+ var currentKey: String? = null
+ var currentSize = 0L
+
+ var eventType = parser.eventType
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ when (eventType) {
+ XmlPullParser.START_TAG -> when (parser.name) {
+ "Contents" -> {
+ insideContents = true
+ currentKey = null
+ currentSize = 0L
+ }
+ "CommonPrefixes" -> insideCommonPrefixes = true
+ "Key" -> if (insideContents) currentKey = parser.nextText()
+ "Size" -> if (insideContents) currentSize = parser.nextText()?.toLongOrNull() ?: 0L
+ "Prefix" -> if (insideCommonPrefixes) folders.add(
+ S3FolderEntry(prefix = parser.nextText().orEmpty(), songCount = 0)
+ )
+ "IsTruncated" -> isTruncated = parser.nextText().equals("true", ignoreCase = true)
+ "NextContinuationToken" -> continuationToken = parser.nextText()
+ }
+ XmlPullParser.END_TAG -> when (parser.name) {
+ "Contents" -> {
+ insideContents = false
+ currentKey?.takeIf { it.isNotEmpty() }?.let {
+ objects.add(S3ObjectEntry(key = it, sizeBytes = currentSize, lastModified = ""))
+ }
+ }
+ "CommonPrefixes" -> insideCommonPrefixes = false
+ }
+ }
+ eventType = parser.next()
+ }
+ return S3ListPage(folders, objects, isTruncated, continuationToken)
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/s3/S3Repository.kt b/app/src/main/java/com/theveloper/pixelplay/data/s3/S3Repository.kt
new file mode 100644
index 0000000000..13b4ce69c5
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/data/s3/S3Repository.kt
@@ -0,0 +1,564 @@
+package com.theveloper.pixelplay.data.s3
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.media.MediaMetadataRetriever
+import android.net.Uri
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import androidx.work.Constraints
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import com.theveloper.pixelplay.data.database.AlbumEntity
+import com.theveloper.pixelplay.data.database.ArtistEntity
+import com.theveloper.pixelplay.data.database.MusicDao
+import com.theveloper.pixelplay.data.database.S3Dao
+import com.theveloper.pixelplay.data.database.S3FolderEntity
+import com.theveloper.pixelplay.data.database.S3SongEntity
+import com.theveloper.pixelplay.data.database.SongArtistCrossRef
+import com.theveloper.pixelplay.data.database.SongEntity
+import com.theveloper.pixelplay.data.database.SourceType
+import com.theveloper.pixelplay.data.stream.CloudMusicUtils
+import com.theveloper.pixelplay.data.worker.S3MetadataWorker
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.io.File
+import java.io.IOException
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.sync.withPermit
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import kotlin.math.absoluteValue
+
+/** Data for one configured S3-compatible endpoint. */
+data class S3Connection(
+ val endpoint: String,
+ val region: String,
+ val bucket: String,
+ val accessKey: String,
+ val secretKey: String,
+ val prefix: String = ""
+) {
+ val endpointHost: String get() = endpoint.removePrefix("https://").removePrefix("http://").trimEnd('/')
+}
+
+@Singleton
+class S3Repository @Inject constructor(
+ private val api: S3ApiService,
+ private val dao: S3Dao,
+ private val musicDao: MusicDao,
+ @ApplicationContext private val context: Context
+) {
+ data class BulkSyncResult(
+ val folderCount: Int,
+ val syncedSongCount: Int,
+ val failedFolderCount: Int
+ )
+
+ private val prefs: SharedPreferences = createPrefs(context)
+ private val syncMutex = Mutex()
+
+ private val _isLoggedInFlow = MutableStateFlow(false)
+ val isLoggedInFlow: StateFlow = _isLoggedInFlow.asStateFlow()
+
+ init {
+ _isLoggedInFlow.value = getConnection() != null
+ }
+
+ private fun createPrefs(context: Context): SharedPreferences {
+ val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+ return EncryptedSharedPreferences.create(
+ context,
+ "s3_prefs",
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ }
+
+ fun getConnection(): S3Connection? {
+ val endpoint = prefs.getString(KEY_ENDPOINT, null)?.takeIf { it.isNotBlank() } ?: return null
+ return S3Connection(
+ endpoint = endpoint.trimEnd('/'),
+ region = prefs.getString(KEY_REGION, null).orEmpty().ifBlank { "us-east-1" },
+ bucket = prefs.getString(KEY_BUCKET, null).orEmpty(),
+ accessKey = prefs.getString(KEY_ACCESS_KEY, null).orEmpty(),
+ secretKey = prefs.getString(KEY_SECRET_KEY, null).orEmpty(),
+ prefix = prefs.getString(KEY_PREFIX, null).orEmpty()
+ )
+ }
+
+ suspend fun connect(
+ endpoint: String,
+ region: String,
+ bucket: String,
+ accessKey: String,
+ secretKey: String,
+ prefix: String = ""
+ ): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ val normalizedEndpoint = endpoint.trim().trimEnd('/')
+ require(normalizedEndpoint.startsWith("https://")) {
+ "Endpoint must use https:// (http is only reachable on plain-text-allowed networks)"
+ }
+ require(bucket.isNotBlank()) { "Bucket is required" }
+ require(accessKey.isNotBlank() && secretKey.isNotBlank()) { "Access keys are required" }
+
+ val connection = S3Connection(
+ endpoint = normalizedEndpoint,
+ region = region.trim().ifBlank { "us-east-1" },
+ bucket = bucket.trim(),
+ accessKey = accessKey.trim(),
+ secretKey = secretKey.trim(),
+ prefix = prefix.trim().trim('/').let { if (it.isEmpty()) "" else "$it/" }
+ )
+ // Fail fast on bad credentials / wrong bucket before persisting anything.
+ api.ping(
+ endpoint = connection.endpoint,
+ bucket = connection.bucket,
+ accessKey = connection.accessKey,
+ secretKey = connection.secretKey,
+ region = connection.region
+ )
+ prefs.edit()
+ .putString(KEY_ENDPOINT, connection.endpoint)
+ .putString(KEY_REGION, connection.region)
+ .putString(KEY_BUCKET, connection.bucket)
+ .putString(KEY_ACCESS_KEY, connection.accessKey)
+ .putString(KEY_SECRET_KEY, connection.secretKey)
+ .putString(KEY_PREFIX, connection.prefix)
+ .apply()
+ _isLoggedInFlow.value = true
+ }.onFailure { Timber.e(it, "S3 connect failed") }
+ }
+
+ suspend fun logout() = withContext(Dispatchers.IO) {
+ prefs.edit().clear().apply()
+ _isLoggedInFlow.value = false
+ dao.clearAllSongs()
+ dao.clearAllFolders()
+ musicDao.clearAllS3Songs()
+ }
+
+ fun getFolders(): Flow> = dao.getAllFolders()
+
+ fun userBucketLabel(): String? = getConnection()?.let { "${it.bucket}/${it.prefix.trimEnd('/')}".trimEnd('/') }
+
+ /** Lists immediate sub-folders of [prefix] (plus the objects at that level). */
+ suspend fun browseFolders(prefix: String): Result> =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ val conn = requireNotNull(getConnection()) { "Not connected" }
+ val page = api.listObjects(
+ endpoint = conn.endpoint, bucket = conn.bucket,
+ accessKey = conn.accessKey, secretKey = conn.secretKey, region = conn.region,
+ prefix = prefix, delimiter = "/"
+ )
+ page.folders
+ }.onFailure { Timber.e(it, "S3 browse failed") }
+ }
+
+ /** Picks a folder (by prefix) and syncs every audio object under it. */
+ suspend fun addFolder(prefix: String): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ val conn = requireNotNull(getConnection()) { "Not connected" }
+ val normalized = prefix.trim('/').let { if (it.isEmpty()) "" else "$it/" }
+ syncFolderInternal(conn, normalized)
+ countSongsInFolder(normalized)
+ }.onFailure { Timber.e(it, "S3 addFolder failed") }
+ }
+
+ suspend fun syncFolder(folderId: String): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ val conn = requireNotNull(getConnection()) { "Not connected" }
+ syncFolderInternal(conn, folderId)
+ countSongsInFolder(folderId)
+ }.onFailure { Timber.e(it, "S3 syncFolder failed") }
+ }
+
+ suspend fun syncAllFoldersAndSongs(): BulkSyncResult = withContext(Dispatchers.IO) {
+ val conn = getConnection() ?: return@withContext BulkSyncResult(0, 0, 0)
+ syncMutex.withLock {
+ val folders = dao.getAllFoldersList()
+ var synced = 0
+ var failed = 0
+ folders.forEach { folder ->
+ runCatching {
+ syncFolderInternal(conn, folder.id)
+ synced += 1
+ }.onFailure {
+ failed += 1
+ Timber.e(it, "S3 sync failed for folder ${folder.id}")
+ }
+ }
+ BulkSyncResult(folders.size, synced, failed)
+ }
+ }
+
+ suspend fun removeFolder(folderId: String) = withContext(Dispatchers.IO) {
+ dao.deleteSongsByFolder(folderId)
+ dao.deleteFolder(folderId)
+ resyncUnifiedLibrary()
+ }
+
+ suspend fun presignGetUrl(objectKey: String, expiresInSeconds: Long = 3600): String =
+ withContext(Dispatchers.IO) {
+ val conn = requireNotNull(getConnection()) { "Not connected" }
+ api.presignGetUrl(
+ endpoint = conn.endpoint, bucket = conn.bucket, objectKey = objectKey,
+ accessKey = conn.accessKey, secretKey = conn.secretKey,
+ region = conn.region, expiresInSeconds = expiresInSeconds
+ )
+ }
+
+ // --- Internals ---
+
+ private suspend fun countSongsInFolder(folderId: String): Int {
+ val existing = dao.getAllS3SongsList().filter { it.folderId == folderId }
+ return existing.size
+ }
+
+ /** Lists every audio object under [folderId], replaces the folder's song set, refreshes folder row. */
+ private suspend fun syncFolderInternal(conn: S3Connection, folderId: String) {
+ val objects = api.listAllObjects(
+ endpoint = conn.endpoint, bucket = conn.bucket,
+ accessKey = conn.accessKey, secretKey = conn.secretKey, region = conn.region,
+ prefix = folderId
+ ).filter { it.key.isAudioFile() }
+
+ val now = System.currentTimeMillis()
+ // Tags are probed later in the background (S3MetadataWorker) — sync stays fast.
+ val entities = objects.mapNotNull { obj -> parseFileToEntity(obj, folderId) }
+
+ val oldSongs = dao.getAllS3SongsList().filter { it.folderId == folderId }
+ val newKeys = entities.map { it.objectKey }.toSet()
+ val removedIds = oldSongs.filter { it.objectKey !in newKeys }.map { it.id }
+
+ dao.insertSongs(entities)
+ scheduleMetadataProbe()
+ if (removedIds.isNotEmpty()) dao.deleteSongsByIds(removedIds)
+ // Removed objects disappear from the unified library via the ID diff in resyncUnifiedLibrary().
+
+ val folderName = folderId.trimEnd('/').substringAfterLast('/').ifBlank { conn.bucket }
+ dao.insertFolder(
+ S3FolderEntity(
+ id = folderId,
+ name = folderName,
+ songCount = entities.size,
+ lastSyncTime = now
+ )
+ )
+ resyncUnifiedLibrary()
+ }
+
+ /** Live state of the background tag-probe queue. */
+ data class ProbeQueueState(val isRunning: Boolean, val remaining: Int)
+
+ val probeQueueFlow: Flow = combine(
+ WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow("s3_metadata_probe"),
+ dao.countUnprobedFlow()
+ ) { infos, remaining ->
+ ProbeQueueState(
+ isRunning = infos.any {
+ it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
+ },
+ remaining = remaining
+ )
+ }
+
+ /** Enqueues the background tag-probe queue (unique, network-constrained). */
+ fun scheduleMetadataProbe() {
+ val request = OneTimeWorkRequestBuilder()
+ .setConstraints(
+ Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+ )
+ .build()
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ "s3_metadata_probe",
+ ExistingWorkPolicy.KEEP,
+ request
+ )
+ }
+
+ /** Mirrors all s3_songs into the unified `songs` table. */
+ suspend fun refreshUnifiedLibrary() = resyncUnifiedLibrary()
+
+ /** Re-mirrors S3 whenever on-device songs come or go, so local copies keep hiding their S3 twins. */
+ @OptIn(FlowPreview::class)
+ suspend fun hideTracksWithLocalCopies() {
+ musicDao.observeLocalSongCount().distinctUntilChanged().debounce(2_000).collect {
+ if (_isLoggedInFlow.value) runCatching { resyncUnifiedLibrary() }.onFailure { Timber.e(it, "S3 resync failed") }
+ }
+ }
+
+ private suspend fun resyncUnifiedLibrary() {
+ // A track that is also on the device stays local-only: its S3 copy is left out of the
+ // library (and removed if it was mirrored before) until the local file goes away.
+ val localTracks = LocalTrackIndex(musicDao.getLocalSongMatchKeys())
+ val s3Songs = dao.getAllS3SongsList().filterNot { localTracks.hasLocalCopy(it.title, it.artist, it.duration, it.objectKey) }
+ val existingUnifiedIds = musicDao.getAllS3SongIds()
+
+ if (s3Songs.isEmpty()) {
+ if (existingUnifiedIds.isNotEmpty()) musicDao.clearAllS3Songs()
+ return
+ }
+
+ val songs = ArrayList(s3Songs.size)
+ val artists = LinkedHashMap()
+ val albums = LinkedHashMap()
+ val crossRefs = mutableListOf()
+
+ s3Songs.forEach { s3Song ->
+ val songId = s3Song.objectKey.toUnifiedSongId()
+ val artistNames = CloudMusicUtils.parseArtistNames(s3Song.artist)
+ val primaryArtistName = artistNames.firstOrNull() ?: "Unknown Artist"
+ val primaryArtistId = primaryArtistName.toUnifiedArtistId()
+
+ artistNames.forEachIndexed { index, artistName ->
+ val artistId = artistName.toUnifiedArtistId()
+ artists.putIfAbsent(
+ artistId,
+ ArtistEntity(id = artistId, name = artistName, trackCount = 0, imageUrl = null)
+ )
+ crossRefs.add(SongArtistCrossRef(songId = songId, artistId = artistId, isPrimary = index == 0))
+ }
+
+ val albumName = s3Song.album.ifBlank { "Unknown Album" }
+ val albumId = albumName.toUnifiedAlbumId()
+ albums.putIfAbsent(
+ albumId,
+ AlbumEntity(
+ id = albumId,
+ title = albumName,
+ artistName = primaryArtistName,
+ artistId = primaryArtistId,
+ songCount = 0,
+ dateAdded = s3Song.dateAdded,
+ year = 0,
+ albumArtUriString = s3Song.albumArtUri
+ )
+ )
+
+ songs.add(
+ SongEntity(
+ id = songId,
+ title = s3Song.title,
+ artistName = s3Song.artist.ifBlank { primaryArtistName },
+ artistId = primaryArtistId,
+ albumArtist = null,
+ albumName = albumName,
+ albumId = albumId,
+ contentUriString = "s3://${s3Song.objectKey}",
+ albumArtUriString = s3Song.albumArtUri,
+ duration = s3Song.duration,
+ genre = S3_GENRE,
+ filePath = "",
+ parentDirectoryPath = S3_PARENT_DIRECTORY,
+ isFavorite = false,
+ lyrics = null,
+ trackNumber = 0,
+ year = 0,
+ dateAdded = s3Song.dateAdded.takeIf { it > 0 } ?: System.currentTimeMillis(),
+ mimeType = s3Song.mimeType,
+ bitrate = s3Song.bitrate,
+ sampleRate = null,
+ telegramChatId = null,
+ telegramFileId = null,
+ sourceType = SourceType.S3
+ )
+ )
+ }
+
+ val albumCounts = songs.groupingBy { it.albumId }.eachCount()
+ val finalAlbums = albums.values.map { it.copy(songCount = albumCounts[it.id] ?: 0) }
+
+ val currentIds = songs.map { it.id }.toSet()
+ val deletedIds = existingUnifiedIds.filter { it !in currentIds }
+
+ musicDao.incrementalSyncMusicData(
+ songs = songs,
+ albums = finalAlbums,
+ artists = artists.values.toList(),
+ crossRefs = crossRefs,
+ deletedSongIds = deletedIds
+ )
+ }
+
+ private class ProbedMeta(
+ val title: String?,
+ val artist: String?,
+ val album: String?,
+ val durationMs: Long?,
+ val albumArtUri: String?
+ )
+
+ /** Probes one batch of un-probed songs; returns true when more work likely remains. */
+ suspend fun probeUnprobedBatch(batchSize: Int = 8): Boolean = withContext(Dispatchers.IO) {
+ val batch = dao.getUnprobedBatch(batchSize)
+ if (batch.isEmpty()) return@withContext false
+ val limiter = Semaphore(permits = 4)
+ coroutineScope {
+ batch.map { entity ->
+ async {
+ limiter.withPermit {
+ val meta = probeMetadata(entity.objectKey)
+ dao.applyProbeResult(
+ id = entity.id,
+ title = meta?.title ?: entity.title,
+ artist = meta?.artist ?: entity.artist,
+ album = meta?.album ?: entity.album,
+ duration = meta?.durationMs ?: entity.duration,
+ albumArtUri = meta?.albumArtUri
+ )
+ }
+ }
+ }.awaitAll()
+ }
+ true
+ }
+
+ /** Reads embedded tags/artwork over a presigned partial fetch. Null on any failure. */
+ private suspend fun probeMetadata(objectKey: String): ProbedMeta? = withContext(Dispatchers.IO) {
+ runCatching {
+ val url = presignGetUrl(objectKey, expiresInSeconds = 600L)
+ val mmr = MediaMetadataRetriever()
+ try {
+ mmr.setDataSource(url, emptyMap())
+ fun meta(key: Int) = mmr.extractMetadata(key)?.trim()?.takeIf { it.isNotEmpty() }
+ val title = meta(MediaMetadataRetriever.METADATA_KEY_TITLE)
+ val artist = meta(MediaMetadataRetriever.METADATA_KEY_ARTIST)
+ ?: meta(MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST)
+ val album = meta(MediaMetadataRetriever.METADATA_KEY_ALBUM)
+ val duration = meta(MediaMetadataRetriever.METADATA_KEY_DURATION)
+ ?.toLongOrNull()?.takeIf { it > 0L }
+ val artUri = mmr.embeddedPicture
+ ?.takeIf { it.isNotEmpty() }
+ ?.let { bytes -> saveAlbumArt(album, bytes) }
+ ProbedMeta(title, artist, album, duration, artUri)
+ } finally {
+ mmr.release()
+ }
+ }.onFailure { Timber.tag("S3Repo").d(it, "Tag probe failed for %s", objectKey) }
+ .getOrNull()
+ }
+
+ /** One artwork file per album (hash-named) under cacheDir/s3_artwork. */
+ private fun saveAlbumArt(album: String?, bytes: ByteArray): String {
+ val dir = File(context.cacheDir, "s3_artwork").apply { mkdirs() }
+ val name = (album ?: "").hashCode().absoluteValue.toString(16)
+ val file = File(dir, "$name.img")
+ if (!file.exists() || file.length() == 0L) {
+ file.writeBytes(bytes)
+ }
+ return Uri.fromFile(file).toString()
+ }
+
+ private fun parseFileToEntity(obj: S3ObjectEntry, folderId: String): S3SongEntity? {
+ val fileName = obj.key.substringAfterLast('/')
+ if (fileName.isBlank()) return null
+ val ext = fileName.substringAfterLast('.', "").lowercase()
+ val nameWithoutExt = fileName.substringBeforeLast('.')
+
+ // "Artist - Title.ext" or just "Title.ext"
+ val parts = nameWithoutExt.split(" - ", limit = 2)
+ val (artist, title) = if (parts.size == 2) {
+ parts[0].trim() to parts[1].trim()
+ } else {
+ "Unknown Artist" to nameWithoutExt.trim()
+ }
+
+ // Album = first path segment under the synced folder, else "S3".
+ val relativeDir = obj.key.removePrefix(folderId).substringBeforeLast('/')
+ val album = relativeDir.substringBefore('/').ifBlank { "S3" }
+
+ return S3SongEntity(
+ id = "${folderId}_${obj.key}",
+ objectKey = obj.key,
+ folderId = folderId,
+ title = title,
+ artist = artist,
+ album = album,
+ albumId = album.toUnifiedAlbumId(),
+ duration = 0L, // Not available from object metadata
+ mimeType = mimeTypeForExtension(ext),
+ bitrate = null,
+ fileSize = obj.sizeBytes,
+ dateAdded = System.currentTimeMillis(),
+ dateModified = System.currentTimeMillis()
+ )
+ }
+
+ private fun mimeTypeForExtension(ext: String): String = when (ext) {
+ "mp3" -> "audio/mpeg"
+ "flac" -> "audio/flac"
+ "wav" -> "audio/wav"
+ "ogg", "oga" -> "audio/ogg"
+ "opus" -> "audio/opus"
+ "m4a", "m4b" -> "audio/mp4"
+ "aac" -> "audio/aac"
+ "wma" -> "audio/x-ms-wma"
+ "aiff", "aif" -> "audio/aiff"
+ "ape" -> "audio/x-ape"
+ "dsf" -> "audio/dsf"
+ "wv" -> "audio/x-wavpack"
+ else -> "audio/mpeg"
+ }
+
+ private fun String.isAudioFile(): Boolean {
+ val ext = substringAfterLast('.', "").lowercase()
+ return ext in AUDIO_EXTENSIONS
+ }
+
+ private fun String.toUnifiedSongId(): Long =
+ -(S3_SONG_ID_OFFSET + hashCode().toLong().absoluteValue)
+
+ private fun String.toUnifiedAlbumId(): Long =
+ -(S3_ALBUM_ID_OFFSET + lowercase().hashCode().toLong().absoluteValue)
+
+ private fun String.toUnifiedArtistId(): Long =
+ -(S3_ARTIST_ID_OFFSET + lowercase().hashCode().toLong().absoluteValue)
+
+ companion object {
+ const val S3_SONG_ID_OFFSET = 15_000_000_000_000L
+ const val S3_ALBUM_ID_OFFSET = 16_000_000_000_000L
+ const val S3_ARTIST_ID_OFFSET = 17_000_000_000_000L
+ const val S3_PARENT_DIRECTORY = "/Cloud/S3"
+ const val S3_GENRE = "S3"
+
+ private val AUDIO_EXTENSIONS = setOf(
+ "mp3", "flac", "wav", "ogg", "oga", "opus", "m4a", "m4b",
+ "aac", "wma", "aiff", "aif", "ape", "dsf", "wv"
+ )
+
+ private const val KEY_ENDPOINT = "s3_endpoint"
+ private const val KEY_REGION = "s3_region"
+ private const val KEY_BUCKET = "s3_bucket"
+ private const val KEY_ACCESS_KEY = "s3_access_key"
+ private const val KEY_SECRET_KEY = "s3_secret_key"
+ private const val KEY_PREFIX = "s3_prefix"
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/s3/S3SigV4.kt b/app/src/main/java/com/theveloper/pixelplay/data/s3/S3SigV4.kt
new file mode 100644
index 0000000000..9187e2f4b6
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/data/s3/S3SigV4.kt
@@ -0,0 +1,203 @@
+package com.theveloper.pixelplay.data.s3
+
+import android.util.Base64
+import java.net.URI
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * Minimal AWS Signature V4 signer for S3-compatible object storage
+ * (AWS S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi...).
+ *
+ * Supports the two request styles the app needs:
+ * - [signedHeaders] : header-based auth for ListObjectsV2
+ * - [presignUrl] : query-string presigned GET for streaming (no headers needed)
+ */
+object S3SigV4 {
+
+ private const val ALGORITHM = "AWS4-HMAC-SHA256"
+ private const val SERVICE = "s3"
+
+ /** Amazon-style timestamp, e.g. 20130524T000000Z. */
+ data class AmzDate(val amzDate: String, val dateStamp: String)
+
+ fun amzDate(epochMillis: Long): AmzDate {
+ val utc = java.time.Instant.ofEpochMilli(epochMillis)
+ .atZone(java.time.ZoneOffset.UTC)
+ val dateStamp = String.format("%04d%02d%02d", utc.year, utc.monthValue, utc.dayOfMonth)
+ val amzDate = dateStamp + String.format(
+ "T%02d%02d%02dZ", utc.hour, utc.minute, utc.second
+ )
+ return AmzDate(amzDate, dateStamp)
+ }
+
+ /** Builds a full request URL with a presigned GET (default expiry: 1 hour). */
+ suspend fun presignGetUrl(
+ endpoint: String,
+ bucket: String,
+ objectKey: String,
+ accessKey: String,
+ secretKey: String,
+ region: String,
+ expiresInSeconds: Long = 3600,
+ nowMillis: Long = System.currentTimeMillis()
+ ): String = withContext(Dispatchers.Default) {
+ val host = hostOf(endpoint)
+ val date = amzDate(nowMillis)
+ val scope = "${date.dateStamp}/$region/$SERVICE/aws4_request"
+
+ val query = sortedMapOf(
+ "X-Amz-Algorithm" to ALGORITHM,
+ "X-Amz-Credential" to "$accessKey/$scope",
+ "X-Amz-Date" to date.amzDate,
+ "X-Amz-Expires" to expiresInSeconds.toString(),
+ "X-Amz-SignedHeaders" to "host"
+ )
+ val canonicalQuery = canonicalQueryString(query)
+ val canonicalUri = if (bucket.isBlank()) canonicalUri(objectKey)
+ else "/$bucket" + canonicalUri(objectKey) // path-style: bucket is part of the signed path
+ val canonicalRequest = listOf(
+ "GET",
+ canonicalUri,
+ canonicalQuery,
+ "host:$host\n",
+ "host",
+ "UNSIGNED-PAYLOAD"
+ ).joinToString("\n")
+
+ val signature = sign(secretKey, date.amzDate, date.dateStamp, region, canonicalRequest)
+
+ // canonicalUri already includes "/$bucket" (path-style) — don't prepend it again.
+ "$endpoint$canonicalUri?$canonicalQuery&X-Amz-Signature=$signature"
+ }
+
+ /** Adds header-based SigV4 auth (including the signed x-amz-* headers) to a builder.
+ * [query] entries are URI-encoded and sorted per the SigV4 canonical form.
+ * Request URL becomes "/?". */
+ suspend fun signedHeaders(
+ builder: okhttp3.Request.Builder,
+ endpoint: String,
+ bucket: String,
+ query: Map,
+ accessKey: String,
+ secretKey: String,
+ region: String,
+ nowMillis: Long = System.currentTimeMillis()
+ ): okhttp3.Request.Builder = withContext(Dispatchers.Default) {
+ val host = hostOf(endpoint)
+ val canonicalUri = if (bucket.isBlank()) "/" else "/$bucket"
+ val canonicalQuery = canonicalQueryString(query)
+ val date = amzDate(nowMillis)
+ val payloadHash = sha256Hex("")
+ val amzHeaders = sortedMapOf(
+ "x-amz-content-sha256" to payloadHash,
+ "x-amz-date" to date.amzDate
+ )
+
+ val canonicalRequest = listOf(
+ "GET",
+ canonicalUri,
+ canonicalQuery,
+ "host:$host\n" + amzHeaders.entries.joinToString("") { "${it.key}:${it.value}\n" },
+ "host;" + amzHeaders.keys.joinToString(";"),
+ payloadHash
+ ).joinToString("\n")
+
+ val signature = sign(secretKey, date.amzDate, date.dateStamp, region, canonicalRequest)
+ val credential = "$accessKey/${date.dateStamp}/$region/$SERVICE/aws4_request"
+
+ val url = buildString {
+ append(endpoint).append(canonicalUri)
+ if (canonicalQuery.isNotEmpty()) append('?').append(canonicalQuery)
+ }
+ builder.url(url)
+ builder.header("Host", host)
+ builder.header("x-amz-content-sha256", payloadHash)
+ builder.header("x-amz-date", date.amzDate)
+ builder.header(
+ "Authorization",
+ "$ALGORITHM Credential=$credential, SignedHeaders=host;${amzHeaders.keys.joinToString(";")}, Signature=$signature"
+ )
+ builder
+ }
+
+ private fun sign(
+ secretKey: String,
+ amzDate: String,
+ dateStamp: String,
+ region: String,
+ canonicalRequest: String
+ ): String {
+ val kDate = hmac("AWS4$secretKey".toByteArray(), dateStamp)
+ val kRegion = hmac(kDate, region)
+ val kService = hmac(kRegion, SERVICE)
+ val kSigning = hmac(kService, "aws4_request")
+ val stringToSign = listOf(
+ ALGORITHM,
+ amzDate,
+ "$dateStamp/$region/$SERVICE/aws4_request",
+ sha256Hex(canonicalRequest)
+ ).joinToString("\n")
+ return hex(hmac(kSigning, stringToSign))
+ }
+
+ internal fun signForTest(
+ secretKey: String,
+ dateStamp: String,
+ region: String,
+ canonicalRequest: String,
+ amzDate: String = "${dateStamp}T000000Z"
+ ): String = sign(secretKey, amzDate, dateStamp, region, canonicalRequest)
+
+ private fun hmac(key: ByteArray, data: String): ByteArray {
+ val mac = Mac.getInstance("HmacSHA256")
+ mac.init(SecretKeySpec(key, "HmacSHA256"))
+ return mac.doFinal(data.toByteArray())
+ }
+
+ fun sha256Hex(data: String): String {
+ val digest = java.security.MessageDigest.getInstance("SHA-256")
+ .digest(data.toByteArray())
+ return hex(digest)
+ }
+
+ private fun hex(bytes: ByteArray): String =
+ bytes.joinToString("") { "%02x".format(it) }
+
+ /** URI-encode each path segment, preserving '/'. */
+ fun canonicalUri(objectKey: String): String {
+ val path = objectKey.trim('/')
+ if (path.isEmpty()) return "/"
+ return "/" + path.split("/").joinToString("/") { uriEncode(it, encodeSlash = false) }
+ }
+
+ private fun canonicalQueryString(query: Map): String =
+ query.entries
+ .sortedBy { uriEncode(it.key) }
+ .joinToString("&") { (k, v) ->
+ "${uriEncode(k)}=${uriEncode(v)}"
+ }
+
+ /**
+ * Strict AWS URI-encoding: unreserved chars kept, everything else percent-encoded
+ * (space becomes %20). Base64 flag NO_WRAP | NO_PADDING for hex-safe use.
+ */
+ fun uriEncode(value: String, encodeSlash: Boolean = true): String {
+ val encoded = StringBuilder()
+ for (byte in value.toByteArray(Charsets.UTF_8)) {
+ val c = byte.toInt().toChar()
+ when {
+ c in 'A'..'Z' || c in 'a'..'z' || c in '0'..'9' ||
+ c == '-' || c == '.' || c == '_' || c == '~' -> encoded.append(c)
+ c == '/' && !encodeSlash -> encoded.append('/')
+ else -> encoded.append('%').append("%02X".format(byte.toInt() and 0xFF))
+ }
+ }
+ return encoded.toString()
+ }
+
+ // java.net.URI (bukan android.net.Uri) agar signer testable di plain JVM.
+ private fun hostOf(endpoint: String): String = runCatching { URI(endpoint).host }.getOrNull() ?: ""
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/s3/S3StreamProxy.kt b/app/src/main/java/com/theveloper/pixelplay/data/s3/S3StreamProxy.kt
new file mode 100644
index 0000000000..688f606753
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/data/s3/S3StreamProxy.kt
@@ -0,0 +1,51 @@
+package com.theveloper.pixelplay.data.s3
+
+import android.net.Uri
+import com.theveloper.pixelplay.data.stream.CloudStreamProxy
+import com.theveloper.pixelplay.data.stream.CloudStreamSecurity
+import okhttp3.OkHttpClient
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Local HTTP proxy for S3-compatible object storage streaming.
+ *
+ * Resolves `s3://{objectKey}` URIs by presigning a GET URL (SigV4, 1 h)
+ * and proxying it through the shared [CloudStreamProxy] loopback server.
+ * Object keys contain '/', so route params are URL-encoded; Ktor decodes
+ * them back into [parseRouteParam].
+ */
+@Singleton
+class S3StreamProxy @Inject constructor(
+ private val repository: S3Repository,
+ okHttpClient: OkHttpClient
+) : CloudStreamProxy(okHttpClient) {
+
+ override val allowedHostSuffixes: Set
+ get() = repository.getConnection()?.endpointHost?.let { setOf(it) } ?: emptySet()
+
+ override val cacheExpirationMs = 55L * 60 * 1000 // presign TTL is 1 h
+ override val proxyTag = "S3StreamProxy"
+ override val routePath = "/s3/{key}"
+ override val routeParamName = "key"
+ override val uriScheme = "s3"
+ override val routePrefix = "/s3"
+
+ override fun parseRouteParam(value: String): String? =
+ value.takeIf { it.isNotBlank() }
+
+ override fun validateId(id: String): Boolean =
+ CloudStreamSecurity.validateS3ObjectKey(id)
+
+ override fun formatIdForUrl(id: String): String =
+ Uri.encode(id, "") // encode everything incl '/' and ' ' (URLEncoder's '+' would break)
+
+ override suspend fun resolveStreamUrl(id: String): String? =
+ repository.presignGetUrl(id)
+
+ // "s3://bucket-prefix/path/song.mp3" — Uri splits host/path at the first '/'.
+ override fun extractIdFromUri(uri: Uri): String? =
+ ((uri.host ?: "") + (uri.path ?: "")).removePrefix("/").takeIf { it.isNotEmpty() }
+
+ fun resolveS3Uri(uriString: String): String? = resolveUri(uriString)
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt
index adab461ca8..64e9d2b8d6 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt
@@ -1669,7 +1669,10 @@ class MusicService : MediaLibraryService() {
for (index in 0 until mediaItemCount) {
val mediaItem = player.getMediaItemAt(index)
val metadata = mediaItem.mediaMetadata
- val uri = mediaItem.localConfiguration?.uri?.toString()
+ // Prefer the original cloud URI (stored at resolve time) — the live
+ // localConfiguration URI may be a loopback proxy URL whose port dies with the process.
+ val uri = metadata.extras?.getString(MediaItemBuilder.ORIGINAL_CONTENT_URI)
+ ?: mediaItem.localConfiguration?.uri?.toString()
?: metadata.extras?.getString(MediaItemBuilder.EXTERNAL_EXTRA_CONTENT_URI)
if (mediaItem.mediaId.isBlank() || uri.isNullOrBlank()) {
@@ -1817,6 +1820,11 @@ class MusicService : MediaLibraryService() {
if (snapshotItem.mediaId.isBlank() || snapshotItem.uri.isBlank()) {
return null
}
+ // Stale loopback proxy URL from an older snapshot — the proxy port died with the
+ // old process. Drop the item instead of queueing a guaranteed-connect-fail URI.
+ if (Uri.parse(snapshotItem.uri).host == "127.0.0.1") {
+ return null
+ }
val metadataBuilder = MediaMetadata.Builder()
snapshotItem.title?.takeIf { it.isNotBlank() }?.let { metadataBuilder.setTitle(it) }
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt
index c8f05dc0a2..018656239e 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt
@@ -6,6 +6,7 @@ import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.net.Uri
+import android.os.Bundle
import android.os.Build
import android.os.SystemClock
import android.util.LruCache
@@ -39,6 +40,7 @@ import androidx.media3.extractor.flac.FlacExtractor
import com.theveloper.pixelplay.data.diagnostics.PerformanceMetrics
import com.theveloper.pixelplay.data.model.TransitionSettings
import com.theveloper.pixelplay.data.telegram.TelegramRepository
+import com.theveloper.pixelplay.utils.MediaItemBuilder
import com.theveloper.pixelplay.utils.envelope
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
@@ -220,6 +222,7 @@ class DualPlayerEngine @Inject constructor(
private val navidromeStreamProxy: NavidromeStreamProxy,
private val jellyfinStreamProxy: com.theveloper.pixelplay.data.jellyfin.JellyfinStreamProxy,
private val gdriveStreamProxy: com.theveloper.pixelplay.data.gdrive.GDriveStreamProxy,
+ private val s3StreamProxy: com.theveloper.pixelplay.data.s3.S3StreamProxy,
private val telegramCacheManager: com.theveloper.pixelplay.data.telegram.TelegramCacheManager,
private val connectivityStateHolder: com.theveloper.pixelplay.presentation.viewmodel.ConnectivityStateHolder
) {
@@ -234,10 +237,10 @@ class DualPlayerEngine @Inject constructor(
private const val POST_TRANSITION_OFFLOAD_GUARD_MS = 2_000L
private const val MAX_AUXILIARY_TIMELINE_ITEMS = 200
private val LOCAL_MEDIA_SCHEMES = setOf("content", "file", "android.resource")
- private val REMOTE_MEDIA_SCHEMES = setOf("http", "https", "telegram", "netease", "qqmusic", "navidrome", "jellyfin", "gdrive")
+ private val REMOTE_MEDIA_SCHEMES = setOf("http", "https", "telegram", "netease", "qqmusic", "navidrome", "jellyfin", "gdrive", "s3")
// Subset of REMOTE_MEDIA_SCHEMES: schemes that need proxy resolution.
// http/https resolve directly and must NOT enter the resolvedUriCache lookup path.
- private val CLOUD_PROXY_SCHEMES = setOf("telegram", "netease", "qqmusic", "navidrome", "jellyfin", "gdrive")
+ private val CLOUD_PROXY_SCHEMES = setOf("telegram", "netease", "qqmusic", "navidrome", "jellyfin", "gdrive", "s3")
}
data class TransitionTarget(
@@ -1197,16 +1200,26 @@ class DualPlayerEngine @Inject constructor(
val uriString = uri.toString()
resolvedUriCache.get(uriString)?.let { return@withContext it }
- val resolved: Uri? = when (uri.scheme) {
+ suspend fun tryResolve(): Uri? = when (uri.scheme) {
"telegram" -> resolveTelegramUriAsync(uri, uriString)
"netease" -> resolveNeteaseUriAsync(uriString)
"qqmusic" -> resolveQqMusicUriAsync(uriString)
"navidrome" -> resolveNavidromeUriAsync(uriString)
"jellyfin" -> resolveJellyfinUriAsync(uriString)
"gdrive" -> resolveGDriveUriAsync(uriString)
+ "s3" -> resolveS3UriAsync(uriString)
else -> null
}
+ var resolved = tryResolve()
+ if (resolved == null) {
+ // Transient failures (proxy warm-up race, network blip) resolve on retry;
+ // a raw cloud URI would hit ExoPlayer as "unknown protocol" and error out.
+ Timber.tag("DualPlayerEngine").w("Cloud resolve failed for %s — retrying once", uri.scheme)
+ delay(400)
+ resolved = tryResolve()
+ }
+
if (resolved != null) {
resolvedUriCache.put(uriString, resolved)
return@withContext resolved
@@ -1269,6 +1282,21 @@ class DualPlayerEngine @Inject constructor(
gdriveStreamProxy.resolveGDriveUri(uriString)?.toUri()
}
+ private suspend fun resolveS3UriAsync(uriString: String): Uri? = withContext(Dispatchers.IO) {
+ if (!connectivityStateHolder.isOnline.value) {
+ Timber.tag("DualPlayerEngine").w("S3 resolve blocked: offline")
+ connectivityStateHolder.triggerOfflineBlockedEvent()
+ return@withContext null
+ }
+ if (!s3StreamProxy.ensureReady(5_000L)) {
+ Timber.tag("DualPlayerEngine").w("S3 resolve failed: proxy not ready within 5s")
+ return@withContext null
+ }
+ s3StreamProxy.resolveS3Uri(uriString)?.toUri().also {
+ if (it == null) Timber.tag("DualPlayerEngine").w("S3 resolve returned null for %s", uriString)
+ }
+ }
+
suspend fun resolveMediaItem(mediaItem: MediaItem): MediaItem {
val uri = mediaItem.localConfiguration?.uri ?: return mediaItem
val scheme = uri.scheme
@@ -1276,7 +1304,15 @@ class DualPlayerEngine @Inject constructor(
// reach resolveCloudUri, so checking them wastes an IO dispatch.
if (scheme !in CLOUD_PROXY_SCHEMES) return mediaItem
val resolvedUri = resolveCloudUri(uri)
- return if (resolvedUri == uri) mediaItem else mediaItem.buildUpon().setUri(resolvedUri).build()
+ if (resolvedUri == uri) return mediaItem
+ // Keep the original cloud URI in extras so queue persistence never stores a
+ // stale loopback proxy URL (its port dies with the process).
+ val metadata = mediaItem.mediaMetadata.buildUpon()
+ .setExtras((mediaItem.mediaMetadata.extras ?: Bundle()).apply {
+ putString(MediaItemBuilder.ORIGINAL_CONTENT_URI, uri.toString())
+ })
+ .build()
+ return mediaItem.buildUpon().setUri(resolvedUri).setMediaMetadata(metadata).build()
}
suspend fun prepareNext(target: TransitionTarget, startPositionMs: Long = 0L) {
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/stream/CloudStreamProxy.kt b/app/src/main/java/com/theveloper/pixelplay/data/stream/CloudStreamProxy.kt
index f8fb67866c..e383e451d8 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/stream/CloudStreamProxy.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/stream/CloudStreamProxy.kt
@@ -38,9 +38,15 @@ import java.util.concurrent.ConcurrentHashMap
*
* @param K The song identifier type (e.g. [String] for QQ Music songMid, [Long] for Netease songId)
*/
-abstract class CloudStreamProxy(
- private val okHttpClient: OkHttpClient
-) {
+abstract class CloudStreamProxy(okHttpClient: OkHttpClient) {
+
+ /** Upstream fetches need more patience than the shared 8s client — first byte
+ * from slow clouds (Cloudeka etc.) routinely exceeds it, esp. under load. */
+ private val upstreamClient = okHttpClient.newBuilder()
+ .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
+ .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
+ .build()
+
// ─── Subclass Configuration ────────────────────────────────────────
protected abstract val allowedHostSuffixes: Set
@@ -212,7 +218,7 @@ abstract class CloudStreamProxy(
}
val response = withContext(Dispatchers.IO) {
- okHttpClient.newCall(requestBuilder.build()).execute()
+ upstreamClient.newCall(requestBuilder.build()).execute()
}
response.use { upstream ->
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/stream/CloudStreamSecurity.kt b/app/src/main/java/com/theveloper/pixelplay/data/stream/CloudStreamSecurity.kt
index 411346f09b..5e7aa92faf 100644
--- a/app/src/main/java/com/theveloper/pixelplay/data/stream/CloudStreamSecurity.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/data/stream/CloudStreamSecurity.kt
@@ -19,6 +19,7 @@ object CloudStreamSecurity {
private val QQMUSIC_SONG_MID_REGEX = Regex("^[A-Za-z0-9_-]{6,50}$")
private val NAVIDROME_SONG_ID_REGEX = Regex("^[A-Za-z0-9_-]{1,100}$")
private val JELLYFIN_ITEM_ID_REGEX = Regex("^[A-Za-z0-9]{1,100}$")
+ private val S3_OBJECT_KEY_REGEX = Regex("^[A-Za-z0-9][A-Za-z0-9/_. ()&'!,+\\-]{0,1023}$")
private val FORBIDDEN_HOSTS = setOf("localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]")
// DNS suffixes that only resolve on a local network: mDNS (.local), common
@@ -52,6 +53,12 @@ object CloudStreamSecurity {
fun validateJellyfinItemId(itemId: String): Boolean = JELLYFIN_ITEM_ID_REGEX.matches(itemId)
+ /** S3 object keys: printable, no leading '/', no '..' path traversal segments. */
+ fun validateS3ObjectKey(objectKey: String): Boolean {
+ if (!S3_OBJECT_KEY_REGEX.matches(objectKey)) return false
+ return objectKey.split('/').none { it == ".." }
+ }
+
fun validateRangeHeader(rawHeader: String?): RangeHeaderValidation {
if (rawHeader.isNullOrBlank()) {
return RangeHeaderValidation(isValid = true)
@@ -133,11 +140,13 @@ object CloudStreamSecurity {
val httpUrl = url.toHttpUrlOrNull() ?: return false
val host = httpUrl.host.lowercase()
- // Allow private IPs and .local for Subsonic/Navidrome/Jellyfin servers which are often self-hosted
+ // Allow private IPs and .local for Subsonic/Navidrome/Jellyfin servers and
+ // S3-compatible endpoints (MinIO, NAS gateways) which are often self-hosted
val isNavidromeStream = httpUrl.pathSegments.contains("stream.view")
val isJellyfinStream = httpUrl.pathSegments.contains("Audio") && httpUrl.pathSegments.contains("universal")
+ val isS3PresignedStream = httpUrl.queryParameter("X-Amz-Signature") != null
- if (!isNavidromeStream && !isJellyfinStream) {
+ if (!isNavidromeStream && !isJellyfinStream && !isS3PresignedStream) {
if (host in FORBIDDEN_HOSTS) return false
if (host.endsWith(".local")) return false
if (isPrivateIpv4Literal(host)) return false
diff --git a/app/src/main/java/com/theveloper/pixelplay/data/worker/S3MetadataWorker.kt b/app/src/main/java/com/theveloper/pixelplay/data/worker/S3MetadataWorker.kt
new file mode 100644
index 0000000000..51c92f1af6
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/data/worker/S3MetadataWorker.kt
@@ -0,0 +1,41 @@
+package com.theveloper.pixelplay.data.worker
+
+import android.content.Context
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import com.theveloper.pixelplay.data.s3.S3Repository
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import timber.log.Timber
+
+/**
+ * Background queue that reads embedded tags (title/artist/album/duration/artwork)
+ * for S3 songs whose metadata still comes from the filename. Processes batches
+ * until the `metadata_probed = 0` backlog is empty, then refreshes the unified library.
+ */
+@HiltWorker
+class S3MetadataWorker @AssistedInject constructor(
+ @Assisted appContext: Context,
+ @Assisted workerParams: WorkerParameters,
+ private val repository: S3Repository
+) : CoroutineWorker(appContext, workerParams) {
+
+ override suspend fun doWork(): Result = try {
+ var probed = 0
+ while (repository.probeUnprobedBatch()) {
+ probed += 8
+ setProgress(androidx.work.workDataOf(PROGRESS_PROBED to probed))
+ }
+ if (probed > 0) repository.refreshUnifiedLibrary()
+ Timber.tag("S3MetadataWorker").d("Tag probe finished (%d songs)", probed)
+ Result.success()
+ } catch (e: Exception) {
+ Timber.tag("S3MetadataWorker").w(e, "Tag probe failed")
+ Result.retry()
+ }
+
+ companion object {
+ const val PROGRESS_PROBED = "probed"
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt
index 7f1d935994..3721e81a35 100644
--- a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt
@@ -165,7 +165,10 @@ object AppModule {
PixelPlayDatabase.MIGRATION_38_39,
PixelPlayDatabase.MIGRATION_39_40,
PixelPlayDatabase.MIGRATION_40_41,
- PixelPlayDatabase.MIGRATION_41_42
+ PixelPlayDatabase.MIGRATION_41_42,
+ PixelPlayDatabase.MIGRATION_42_43,
+ PixelPlayDatabase.MIGRATION_43_44,
+ PixelPlayDatabase.MIGRATION_44_45
)
.addCallback(PixelPlayDatabase.createRuntimeArtifactsCallback())
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
@@ -263,6 +266,12 @@ object AppModule {
return database.jellyfinDao()
}
+ @Singleton
+ @Provides
+ fun provideS3Dao(database: PixelPlayDatabase): com.theveloper.pixelplay.data.database.S3Dao {
+ return database.s3Dao()
+ }
+
@Provides
@Singleton
fun provideImageLoader(
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/StreamingProviderSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/StreamingProviderSheet.kt
index a75eeefa9c..90f17bd8b4 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/StreamingProviderSheet.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/StreamingProviderSheet.kt
@@ -29,6 +29,7 @@ import com.theveloper.pixelplay.presentation.jellyfin.auth.JellyfinLoginActivity
import com.theveloper.pixelplay.presentation.navidrome.auth.NavidromeLoginActivity
import com.theveloper.pixelplay.presentation.netease.auth.NeteaseLoginActivity
import com.theveloper.pixelplay.presentation.qqmusic.auth.QqMusicLoginActivity
+import com.theveloper.pixelplay.presentation.s3.auth.S3LoginActivity
import com.theveloper.pixelplay.presentation.telegram.auth.TelegramLoginActivity
import com.theveloper.pixelplay.ui.theme.GoogleSansRounded
@@ -49,6 +50,8 @@ fun StreamingProviderSheet(
onNavigateToNavidromeDashboard: () -> Unit = {},
isJellyfinLoggedIn: Boolean = false,
onNavigateToJellyfinDashboard: () -> Unit = {},
+ isS3LoggedIn: Boolean = false,
+ onNavigateToS3Dashboard: () -> Unit = {},
sheetState: SheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
@@ -160,6 +163,23 @@ fun StreamingProviderSheet(
}
)
+ ProviderRow(
+ iconPainter = painterResource(R.drawable.rounded_storage_24),
+ iconTint = MaterialTheme.colorScheme.tertiary,
+ title = "S3 Storage",
+ subtitle = if (isS3LoggedIn) "Connected" else "AWS S3 · R2 · B2 · MinIO · Wasabi",
+ shape = providerSegmentItemShape,
+ isConnected = isS3LoggedIn,
+ onClick = {
+ if (isS3LoggedIn) {
+ onNavigateToS3Dashboard()
+ } else {
+ context.startActivity(Intent(context, S3LoginActivity::class.java))
+ }
+ onDismissRequest()
+ }
+ )
+
ProviderRow(
iconPainter = painterResource(R.drawable.netease_cloud_music_logo_icon_206716__1_),
iconTint = Color(0xFFE85959),
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/EnhancedSongListItem.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/EnhancedSongListItem.kt
index 30abec5879..41ca0a1198 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/EnhancedSongListItem.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/EnhancedSongListItem.kt
@@ -22,6 +22,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CheckCircle
+import androidx.compose.material.icons.rounded.Cloud
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
@@ -349,13 +350,23 @@ fun EnhancedSongListItem(
)
}
Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = song.displayArtist,
- style = MaterialTheme.typography.bodyMedium,
- color = contentColor.copy(alpha = 0.7f),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (song.isCloud) {
+ Icon(
+ imageVector = Icons.Rounded.Cloud,
+ contentDescription = null,
+ tint = contentColor.copy(alpha = 0.7f),
+ modifier = Modifier.padding(end = 4.dp).size(14.dp)
+ )
+ }
+ Text(
+ text = song.displayArtist,
+ style = MaterialTheme.typography.bodyMedium,
+ color = contentColor.copy(alpha = 0.7f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
}
val showPlayingIndicator = isCurrentSong && !isSelectionMode
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt
index 7e55cceb8d..9e200503db 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/AppNavigation.kt
@@ -235,6 +235,9 @@ fun AppNavigation(
},
onOpenJellyfinDashboard = {
navController.navigateSafely(Screen.JellyfinDashboard.route)
+ },
+ onOpenS3Dashboard = {
+ navController.navigateSafely(Screen.S3Dashboard.route)
}
)
}
@@ -504,6 +507,15 @@ fun AppNavigation(
)
}
}
+ composable(
+ Screen.S3Dashboard.route,
+ ) {
+ ScreenWrapper(navController = navController, playerViewModel = playerViewModel, animatedVisibilityScope = this) {
+ com.theveloper.pixelplay.presentation.s3.dashboard.S3DashboardScreen(
+ onBack = { navController.popBackStack() }
+ )
+ }
+ }
}
}
}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt
index e079695893..9886249650 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/navigation/Screen.kt
@@ -55,5 +55,6 @@ sealed class Screen(val route: String) {
object QqMusicDashboard : Screen("qqmusic_dashboard")
object NavidromeDashboard : Screen("navidrome_dashboard")
object JellyfinDashboard : Screen("jellyfin_dashboard")
+ object S3Dashboard : Screen("s3_dashboard")
}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/s3/auth/S3LoginActivity.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/s3/auth/S3LoginActivity.kt
new file mode 100644
index 0000000000..974bcc5cc4
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/s3/auth/S3LoginActivity.kt
@@ -0,0 +1,657 @@
+package com.theveloper.pixelplay.presentation.s3.auth
+
+import android.widget.Toast
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.rounded.CloudQueue
+import androidx.compose.material.icons.rounded.Folder
+import androidx.compose.material.icons.rounded.Lock
+import androidx.compose.material.icons.rounded.Person
+import androidx.compose.material.icons.rounded.Visibility
+import androidx.compose.material.icons.rounded.VisibilityOff
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.theveloper.pixelplay.R
+import com.theveloper.pixelplay.ui.theme.GoogleSansRounded
+import com.theveloper.pixelplay.ui.theme.PixelPlayTheme
+import dagger.hilt.android.AndroidEntryPoint
+import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape
+
+@AndroidEntryPoint
+class S3LoginActivity : ComponentActivity() {
+
+ companion object {
+ const val EXTRA_EDIT_MODE = "edit_mode"
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ val editMode = intent.getBooleanExtra(EXTRA_EDIT_MODE, false)
+ setContent {
+ PixelPlayTheme {
+ S3LoginScreen(editMode = editMode, onClose = { finish() })
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun S3LoginScreen(
+ viewModel: S3LoginViewModel = hiltViewModel(),
+ editMode: Boolean = false,
+ onClose: () -> Unit
+) {
+ val context = LocalContext.current
+ val loginState by viewModel.state.collectAsStateWithLifecycle()
+ val snackbarHostState = remember { SnackbarHostState() }
+ val focusManager = LocalFocusManager.current
+
+ var endpoint by remember { mutableStateOf("") }
+ var region by remember { mutableStateOf("") }
+ var bucket by remember { mutableStateOf("") }
+ var accessKey by remember { mutableStateOf("") }
+ var secretKey by remember { mutableStateOf("") }
+ var prefix by remember { mutableStateOf("") }
+ var secretVisible by remember { mutableStateOf(false) }
+
+ LaunchedEffect(editMode) {
+ if (editMode) {
+ viewModel.getExistingConnection()?.let { c ->
+ endpoint = c.endpoint
+ region = c.region
+ bucket = c.bucket
+ accessKey = c.accessKey
+ secretKey = c.secretKey
+ prefix = c.prefix.trimEnd('/')
+ }
+ }
+ }
+
+ LaunchedEffect(loginState) {
+ when (val state = loginState) {
+ is S3LoginState.Success -> {
+ Toast.makeText(
+ context,
+ context.getString(R.string.auth_s3_connected_toast),
+ Toast.LENGTH_SHORT
+ ).show()
+ onClose()
+ }
+ is S3LoginState.Error -> {
+ snackbarHostState.showSnackbar(state.message)
+ viewModel.clearError()
+ }
+ else -> Unit
+ }
+ }
+
+ val isLoading = loginState is S3LoginState.Connecting
+ val isFolderSetup = loginState is S3LoginState.FolderSetup
+ val inputShape = AbsoluteSmoothCornerShape(18.dp, 60)
+
+ Scaffold(
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = stringResource(R.string.auth_s3_title),
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.SemiBold
+ )
+ },
+ navigationIcon = {
+ FilledIconButton(
+ modifier = Modifier.padding(start = 6.dp),
+ onClick = onClose,
+ enabled = !isLoading,
+ colors = IconButtonDefaults.filledIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
+ contentColor = MaterialTheme.colorScheme.onSurface
+ )
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = stringResource(R.string.common_back)
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+ )
+ )
+ }
+ ) { paddingValues ->
+ if (isFolderSetup) {
+ val setup = loginState as S3LoginState.FolderSetup
+ S3FolderPickerContent(
+ setup = setup,
+ paddingValues = paddingValues,
+ isLoading = isLoading,
+ onOpenFolder = { viewModel.browse(it) },
+ onSelectFolder = { viewModel.selectFolder(it) },
+ onSelectCurrent = { viewModel.selectCurrentFolder() }
+ )
+ return@Scaffold
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .imePadding()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Surface(
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ modifier = Modifier.size(64.dp)
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ imageVector = Icons.Rounded.CloudQueue,
+ contentDescription = stringResource(R.string.auth_s3_title),
+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.size(36.dp)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(R.string.auth_s3_title),
+ style = MaterialTheme.typography.headlineSmall,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Text(
+ text = stringResource(R.string.auth_s3_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Card(
+ shape = AbsoluteSmoothCornerShape(20.dp, 60),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = stringResource(R.string.auth_s3_info_msg),
+ modifier = Modifier.padding(16.dp),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontFamily = GoogleSansRounded
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = AbsoluteSmoothCornerShape(28.dp, 60),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(18.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.auth_details_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(18.dp))
+
+ S3LoginField(
+ value = endpoint,
+ onValueChange = { endpoint = it },
+ label = stringResource(R.string.auth_s3_endpoint_label),
+ placeholder = stringResource(R.string.auth_s3_endpoint_placeholder),
+ supportingText = stringResource(R.string.auth_s3_endpoint_hint),
+ leadingIcon = Icons.Rounded.CloudQueue,
+ enabled = !isLoading,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Next
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = { focusManager.moveFocus(FocusDirection.Down) }
+ ),
+ shape = inputShape
+ )
+
+ Spacer(modifier = Modifier.height(14.dp))
+
+ S3LoginField(
+ value = region,
+ onValueChange = { region = it },
+ label = stringResource(R.string.auth_s3_region_label),
+ placeholder = stringResource(R.string.auth_s3_region_placeholder),
+ supportingText = stringResource(R.string.auth_s3_region_hint),
+ leadingIcon = Icons.Rounded.CloudQueue,
+ enabled = !isLoading,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Text,
+ imeAction = ImeAction.Next
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = { focusManager.moveFocus(FocusDirection.Down) }
+ ),
+ shape = inputShape
+ )
+
+ Spacer(modifier = Modifier.height(14.dp))
+
+ S3LoginField(
+ value = bucket,
+ onValueChange = { bucket = it },
+ label = stringResource(R.string.auth_s3_bucket_label),
+ placeholder = stringResource(R.string.auth_s3_bucket_placeholder),
+ supportingText = stringResource(R.string.auth_s3_bucket_hint),
+ leadingIcon = Icons.Rounded.Folder,
+ enabled = !isLoading,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Text,
+ imeAction = ImeAction.Next
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = { focusManager.moveFocus(FocusDirection.Down) }
+ ),
+ shape = inputShape
+ )
+
+ Spacer(modifier = Modifier.height(14.dp))
+
+ S3LoginField(
+ value = accessKey,
+ onValueChange = { accessKey = it },
+ label = stringResource(R.string.auth_s3_access_key_label),
+ placeholder = stringResource(R.string.auth_s3_access_key_placeholder),
+ supportingText = stringResource(R.string.auth_s3_access_key_hint),
+ leadingIcon = Icons.Rounded.Person,
+ enabled = !isLoading,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Text,
+ imeAction = ImeAction.Next
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = { focusManager.moveFocus(FocusDirection.Down) }
+ ),
+ shape = inputShape
+ )
+
+ Spacer(modifier = Modifier.height(14.dp))
+
+ S3LoginField(
+ value = secretKey,
+ onValueChange = { secretKey = it },
+ label = stringResource(R.string.auth_s3_secret_key_label),
+ placeholder = stringResource(R.string.auth_s3_secret_key_placeholder),
+ supportingText = stringResource(R.string.auth_s3_secret_key_hint),
+ leadingIcon = Icons.Rounded.Lock,
+ enabled = !isLoading,
+ visualTransformation = if (secretVisible) {
+ VisualTransformation.None
+ } else {
+ PasswordVisualTransformation()
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Next
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = { focusManager.moveFocus(FocusDirection.Down) }
+ ),
+ trailingContent = {
+ IconButton(
+ onClick = { secretVisible = !secretVisible },
+ enabled = !isLoading
+ ) {
+ Icon(
+ imageVector = if (secretVisible) Icons.Rounded.VisibilityOff else Icons.Rounded.Visibility,
+ contentDescription = stringResource(
+ if (secretVisible) R.string.auth_hide_pwd_action else R.string.auth_show_pwd_action
+ )
+ )
+ }
+ },
+ shape = inputShape
+ )
+
+ Spacer(modifier = Modifier.height(14.dp))
+
+ S3LoginField(
+ value = prefix,
+ onValueChange = { prefix = it },
+ label = stringResource(R.string.auth_s3_prefix_label),
+ placeholder = stringResource(R.string.auth_s3_prefix_placeholder),
+ supportingText = stringResource(R.string.auth_s3_prefix_hint),
+ leadingIcon = Icons.Rounded.Folder,
+ enabled = !isLoading,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Text,
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = { focusManager.clearFocus() }
+ ),
+ shape = inputShape
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ val canConnect = endpoint.isNotBlank() && bucket.isNotBlank() &&
+ accessKey.isNotBlank() && secretKey.isNotBlank()
+
+ Button(
+ onClick = {
+ focusManager.clearFocus()
+ viewModel.connect(
+ endpoint = endpoint,
+ region = region,
+ bucket = bucket,
+ accessKey = accessKey,
+ secretKey = secretKey,
+ prefix = prefix
+ )
+ },
+ enabled = !isLoading && canConnect,
+ shape = AbsoluteSmoothCornerShape(18.dp, 60),
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp)
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(stringResource(R.string.auth_connecting_msg), fontFamily = GoogleSansRounded)
+ } else {
+ Text(stringResource(R.string.auth_connect_action), fontFamily = GoogleSansRounded, fontWeight = FontWeight.SemiBold)
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(R.string.auth_s3_footer_msg),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontFamily = GoogleSansRounded
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+ }
+}
+
+@Composable
+private fun S3FolderPickerContent(
+ setup: S3LoginState.FolderSetup,
+ paddingValues: PaddingValues,
+ isLoading: Boolean,
+ onOpenFolder: (String) -> Unit,
+ onSelectFolder: (String) -> Unit,
+ onSelectCurrent: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(horizontal = 20.dp)
+ ) {
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.auth_s3_pick_folder_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.weight(1f)
+ )
+ if (!isLoading) {
+ TextButton(onClick = onSelectCurrent) {
+ Text(
+ text = stringResource(R.string.auth_s3_add_current_action),
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+ }
+ if (setup.folders.isEmpty() && !isLoading) {
+ Text(
+ text = stringResource(R.string.auth_s3_pick_folder_empty),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontFamily = GoogleSansRounded
+ )
+ }
+
+ if (isLoading) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(32.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 2.dp)
+ }
+ } else {
+ Spacer(modifier = Modifier.height(12.dp))
+
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(bottom = 24.dp)
+ ) {
+ items(items = setup.folders, key = { it.prefix }) { folder ->
+ Card(
+ onClick = { onOpenFolder(folder.prefix) },
+ shape = AbsoluteSmoothCornerShape(20.dp, 60),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(12.dp)
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier.size(44.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Rounded.Folder,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.secondary
+ )
+ }
+ Spacer(Modifier.width(12.dp))
+ Text(
+ text = folder.name,
+ style = MaterialTheme.typography.titleSmall,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ modifier = Modifier.weight(1f)
+ )
+ FilledTonalButton(
+ onClick = { onSelectFolder(folder.prefix) },
+ enabled = true,
+ shape = AbsoluteSmoothCornerShape(14.dp, 60),
+ contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.auth_s3_add_action),
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun S3LoginField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ label: String,
+ placeholder: String,
+ supportingText: String,
+ leadingIcon: ImageVector,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ visualTransformation: VisualTransformation = VisualTransformation.None,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+ trailingContent: @Composable (() -> Unit)? = null,
+ shape: AbsoluteSmoothCornerShape
+) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ modifier = modifier.fillMaxWidth(),
+ enabled = enabled,
+ singleLine = true,
+ shape = shape,
+ textStyle = MaterialTheme.typography.bodyLarge.copy(
+ fontFamily = GoogleSansRounded
+ ),
+ label = {
+ Text(
+ text = label,
+ fontFamily = GoogleSansRounded
+ )
+ },
+ placeholder = {
+ Text(
+ text = placeholder,
+ fontFamily = GoogleSansRounded
+ )
+ },
+ supportingText = {
+ Text(
+ text = supportingText,
+ fontFamily = GoogleSansRounded
+ )
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = leadingIcon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ trailingIcon = trailingContent,
+ visualTransformation = visualTransformation,
+ keyboardOptions = keyboardOptions,
+ keyboardActions = keyboardActions,
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ disabledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
+ focusedBorderColor = MaterialTheme.colorScheme.primary,
+ unfocusedBorderColor = Color.Transparent,
+ disabledBorderColor = Color.Transparent
+ )
+ )
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/s3/auth/S3LoginViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/s3/auth/S3LoginViewModel.kt
new file mode 100644
index 0000000000..6c51dea293
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/s3/auth/S3LoginViewModel.kt
@@ -0,0 +1,111 @@
+package com.theveloper.pixelplay.presentation.s3.auth
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.theveloper.pixelplay.data.s3.S3Repository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+data class S3FolderItem(val prefix: String, val name: String)
+
+sealed interface S3LoginState {
+ data object Idle : S3LoginState
+ data object Connecting : S3LoginState
+ /** Credentials accepted; user picks a folder to sync. */
+ data class FolderSetup(
+ val path: String,
+ val folders: List
+ ) : S3LoginState
+ data object Success : S3LoginState
+ data class Error(val message: String) : S3LoginState
+}
+
+@HiltViewModel
+class S3LoginViewModel @Inject constructor(
+ private val repository: S3Repository
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(S3LoginState.Idle)
+ val state: StateFlow = _state.asStateFlow()
+
+ private var rootPrefix: String = ""
+ private var currentRelativePath: String = ""
+
+ fun getExistingConnection() = repository.getConnection()
+
+ fun connect(
+ endpoint: String,
+ region: String,
+ bucket: String,
+ accessKey: String,
+ secretKey: String,
+ prefix: String
+ ) {
+ if (_state.value is S3LoginState.Connecting) return
+
+ viewModelScope.launch {
+ _state.value = S3LoginState.Connecting
+ val result = repository.connect(endpoint, region, bucket, accessKey, secretKey, prefix)
+ result.fold(
+ onSuccess = {
+ rootPrefix = repository.getConnection()?.prefix.orEmpty()
+ browse(rootPrefix)
+ },
+ onFailure = {
+ _state.value = S3LoginState.Error(it.message ?: "Connection failed")
+ }
+ )
+ }
+ }
+
+ /** Navigates the folder browser to a sub-path relative to the configured root prefix. */
+ fun browse(relativePath: String) {
+ currentRelativePath = relativePath
+ viewModelScope.launch {
+ repository.browseFolders(rootPrefix + relativePath).fold(
+ onSuccess = { entries ->
+ _state.value = S3LoginState.FolderSetup(
+ path = relativePath,
+ folders = entries
+ .filter { it.prefix != rootPrefix + relativePath }
+ .map { entry ->
+ S3FolderItem(
+ prefix = entry.prefix,
+ name = entry.prefix.trimEnd('/').substringAfterLast('/').ifBlank { entry.prefix }
+ )
+ }
+ )
+ },
+ onFailure = {
+ _state.value = S3LoginState.Error(it.message ?: "Failed to list folders")
+ }
+ )
+ }
+ }
+
+ /** Adds the folder currently being browsed (root prefix + relative path). */
+ fun selectCurrentFolder() {
+ selectFolder(rootPrefix + currentRelativePath)
+ }
+
+ fun selectFolder(prefix: String) {
+ if (_state.value is S3LoginState.Connecting) return
+ viewModelScope.launch {
+ _state.value = S3LoginState.Connecting
+ repository.addFolder(prefix).fold(
+ onSuccess = { _state.value = S3LoginState.Success },
+ onFailure = { _state.value = S3LoginState.Error(it.message ?: "Sync failed") }
+ )
+ }
+ }
+
+ fun clearError() {
+ if (_state.value is S3LoginState.Error) {
+ _state.value = S3LoginState.Idle
+ }
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/s3/dashboard/S3DashboardScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/s3/dashboard/S3DashboardScreen.kt
new file mode 100644
index 0000000000..54677cc789
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/s3/dashboard/S3DashboardScreen.kt
@@ -0,0 +1,435 @@
+package com.theveloper.pixelplay.presentation.s3.dashboard
+
+import android.content.Intent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.automirrored.rounded.Logout
+import androidx.compose.material.icons.rounded.Add
+import androidx.compose.material.icons.rounded.CloudQueue
+import androidx.compose.material.icons.rounded.CloudSync
+import androidx.compose.material.icons.rounded.Delete
+import androidx.compose.material.icons.rounded.Edit
+import androidx.compose.material.icons.rounded.Folder
+import androidx.compose.material.icons.rounded.Sync
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.theveloper.pixelplay.R
+import com.theveloper.pixelplay.data.database.S3FolderEntity
+import com.theveloper.pixelplay.presentation.s3.auth.S3LoginActivity
+import com.theveloper.pixelplay.ui.theme.GoogleSansRounded
+import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun S3DashboardScreen(
+ viewModel: S3DashboardViewModel = hiltViewModel(),
+ onBack: () -> Unit
+) {
+ val context = LocalContext.current
+ val folders by viewModel.folders.collectAsStateWithLifecycle()
+ val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
+ val syncMessage by viewModel.syncMessage.collectAsStateWithLifecycle()
+ val probeQueue by viewModel.probeQueue.collectAsStateWithLifecycle()
+
+ val cardShape = AbsoluteSmoothCornerShape(
+ cornerRadiusTR = 20.dp, cornerRadiusTL = 20.dp,
+ cornerRadiusBR = 20.dp, cornerRadiusBL = 20.dp,
+ smoothnessAsPercentTR = 60, smoothnessAsPercentTL = 60,
+ smoothnessAsPercentBR = 60, smoothnessAsPercentBL = 60
+ )
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ stringResource(R.string.auth_s3_title),
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Bold
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.common_back))
+ }
+ },
+ actions = {
+ // Edit connection button
+ IconButton(onClick = {
+ context.startActivity(
+ Intent(context, S3LoginActivity::class.java)
+ .putExtra(S3LoginActivity.EXTRA_EDIT_MODE, true)
+ )
+ }) {
+ Icon(
+ Icons.Rounded.Edit,
+ contentDescription = stringResource(R.string.s3_cd_edit_connection),
+ tint = MaterialTheme.colorScheme.secondary
+ )
+ }
+ // Add folder button
+ IconButton(onClick = {
+ context.startActivity(Intent(context, S3LoginActivity::class.java))
+ }) {
+ Icon(
+ Icons.Rounded.Add,
+ contentDescription = stringResource(R.string.s3_cd_add_folder),
+ tint = MaterialTheme.colorScheme.secondary
+ )
+ }
+ // Sync button
+ IconButton(
+ onClick = { viewModel.syncAllFoldersAndSongs() },
+ enabled = !isSyncing
+ ) {
+ Icon(
+ Icons.Rounded.CloudSync,
+ contentDescription = stringResource(R.string.cloud_cd_sync_all),
+ tint = MaterialTheme.colorScheme.secondary
+ )
+ }
+ // Logout button
+ IconButton(onClick = {
+ viewModel.logout()
+ onBack()
+ }) {
+ Icon(
+ Icons.AutoMirrored.Rounded.Logout,
+ contentDescription = stringResource(R.string.cloud_cd_logout)
+ )
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ // Sync status banner
+ AnimatedVisibility(
+ visible = syncMessage != null,
+ enter = slideInVertically(
+ animationSpec = spring(stiffness = Spring.StiffnessMedium)
+ ) + fadeIn(),
+ exit = fadeOut()
+ ) {
+ syncMessage?.let { message ->
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ shape = cardShape,
+ colors = CardDefaults.cardColors(
+ containerColor = if (message.contains("failed"))
+ MaterialTheme.colorScheme.errorContainer
+ else
+ MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (isSyncing) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.secondary
+ )
+ Spacer(Modifier.width(12.dp))
+ }
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = GoogleSansRounded
+ )
+ }
+ }
+ }
+ }
+
+ // Metadata probe queue banner
+ AnimatedVisibility(
+ visible = probeQueue.isRunning,
+ enter = slideInVertically(
+ animationSpec = spring(stiffness = Spring.StiffnessMedium)
+ ) + fadeIn(),
+ exit = fadeOut()
+ ) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ shape = cardShape,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer
+ )
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Rounded.CloudSync,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ tint = MaterialTheme.colorScheme.onTertiaryContainer
+ )
+ Spacer(Modifier.width(12.dp))
+ Text(
+ text = if (probeQueue.remaining > 0)
+ stringResource(R.string.s3_probe_queue_running, probeQueue.remaining)
+ else
+ stringResource(R.string.s3_probe_queue_finishing),
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = GoogleSansRounded,
+ color = MaterialTheme.colorScheme.onTertiaryContainer
+ )
+ }
+ Spacer(Modifier.height(8.dp))
+ LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.tertiary,
+ trackColor = MaterialTheme.colorScheme.tertiaryContainer
+ )
+ }
+ }
+ }
+
+ // Connection info header
+ viewModel.bucketLabel?.let { label ->
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ shape = cardShape,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.secondary),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Rounded.CloudQueue,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.onSecondary
+ )
+ }
+ Spacer(Modifier.width(16.dp))
+ Column {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.titleMedium,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = stringResource(R.string.s3_dashboard_folders_synced_count, folders.size),
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = GoogleSansRounded,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Folders header
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.s3_dashboard_music_folders),
+ style = MaterialTheme.typography.titleMedium,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.Bold
+ )
+ if (folders.isEmpty()) {
+ TextButton(onClick = { viewModel.syncAllFoldersAndSongs() }) {
+ Icon(
+ Icons.Rounded.Sync,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(Modifier.width(4.dp))
+ Text(stringResource(R.string.cloud_dashboard_action_sync), fontFamily = GoogleSansRounded, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ }
+ }
+
+ // Folder list
+ if (folders.isEmpty() && !isSyncing) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(32.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ Icons.Rounded.Folder,
+ contentDescription = null,
+ modifier = Modifier.size(64.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
+ )
+ Spacer(Modifier.height(16.dp))
+ Text(
+ text = stringResource(R.string.s3_dashboard_no_folders_yet),
+ style = MaterialTheme.typography.bodyLarge,
+ fontFamily = GoogleSansRounded,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.s3_dashboard_add_folder_hint),
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = GoogleSansRounded,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+ )
+ }
+ }
+ } else {
+ LazyColumn(
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(
+ items = folders,
+ key = { it.id }
+ ) { folder ->
+ S3FolderCard(
+ folder = folder,
+ onSyncClick = { viewModel.syncFolder(folder.id) },
+ onDeleteClick = { viewModel.removeFolder(folder.id) },
+ cardShape = cardShape,
+ isSyncing = isSyncing
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun S3FolderCard(
+ folder: S3FolderEntity,
+ onSyncClick: () -> Unit,
+ onDeleteClick: () -> Unit,
+ cardShape: AbsoluteSmoothCornerShape,
+ isSyncing: Boolean
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = cardShape,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(12.dp)
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(MaterialTheme.colorScheme.secondaryContainer),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Rounded.Folder,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ }
+
+ Spacer(Modifier.width(12.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = folder.name,
+ style = MaterialTheme.typography.titleSmall,
+ fontFamily = GoogleSansRounded,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = stringResource(R.string.cloud_dashboard_song_count, folder.songCount),
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = GoogleSansRounded,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ // Sync button
+ IconButton(
+ onClick = onSyncClick,
+ enabled = !isSyncing
+ ) {
+ Icon(
+ Icons.Rounded.Sync,
+ contentDescription = stringResource(R.string.cloud_cd_sync),
+ tint = MaterialTheme.colorScheme.secondary,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+
+ // Delete button
+ IconButton(onClick = onDeleteClick) {
+ Icon(
+ Icons.Rounded.Delete,
+ contentDescription = stringResource(R.string.common_remove),
+ tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/s3/dashboard/S3DashboardViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/s3/dashboard/S3DashboardViewModel.kt
new file mode 100644
index 0000000000..beeb86f7fe
--- /dev/null
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/s3/dashboard/S3DashboardViewModel.kt
@@ -0,0 +1,81 @@
+package com.theveloper.pixelplay.presentation.s3.dashboard
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.theveloper.pixelplay.data.database.S3FolderEntity
+import com.theveloper.pixelplay.data.s3.S3Repository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+@HiltViewModel
+class S3DashboardViewModel @Inject constructor(
+ private val repository: S3Repository
+) : ViewModel() {
+
+ val folders: StateFlow> = repository.getFolders()
+ .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
+
+ private val _isSyncing = MutableStateFlow(false)
+ val isSyncing: StateFlow = _isSyncing.asStateFlow()
+
+ private val _syncMessage = MutableStateFlow(null)
+ val syncMessage: StateFlow = _syncMessage.asStateFlow()
+
+ val probeQueue: StateFlow = repository.probeQueueFlow
+ .stateIn(viewModelScope, SharingStarted.Lazily, S3Repository.ProbeQueueState(isRunning = false, remaining = 0))
+
+ val bucketLabel: String? get() = repository.userBucketLabel()
+ val isLoggedIn: StateFlow = repository.isLoggedInFlow
+
+ init {
+ // Auto-sync folders when dashboard opens
+ syncAllFoldersAndSongs()
+ }
+
+ fun syncAllFoldersAndSongs() {
+ if (_isSyncing.value) return
+ viewModelScope.launch {
+ _isSyncing.value = true
+ _syncMessage.value = "Syncing all folders..."
+ val result = repository.syncAllFoldersAndSongs()
+ _syncMessage.value = when {
+ result.folderCount == 0 -> "No folders to sync"
+ result.failedFolderCount > 0 ->
+ "Synced ${result.folderCount - result.failedFolderCount}/${result.folderCount} folders (some failed)"
+ else -> "Synced ${result.folderCount} folders"
+ }
+ _isSyncing.value = false
+ }
+ }
+
+ fun syncFolder(folderId: String) {
+ if (_isSyncing.value) return
+ viewModelScope.launch {
+ _isSyncing.value = true
+ _syncMessage.value = "Syncing folder..."
+ repository.syncFolder(folderId).fold(
+ onSuccess = { count -> _syncMessage.value = "Synced $count songs" },
+ onFailure = { _syncMessage.value = "Sync failed: ${it.message}" }
+ )
+ _isSyncing.value = false
+ }
+ }
+
+ fun removeFolder(folderId: String) {
+ viewModelScope.launch {
+ repository.removeFolder(folderId)
+ }
+ }
+
+ fun logout() {
+ viewModelScope.launch {
+ repository.logout()
+ }
+ }
+}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AccountsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AccountsScreen.kt
index b5aeee48aa..302473cd1a 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AccountsScreen.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/AccountsScreen.kt
@@ -98,6 +98,7 @@ fun AccountsScreen(
onOpenQqMusicDashboard: () -> Unit = {},
onOpenNavidromeDashboard: () -> Unit = {},
onOpenJellyfinDashboard: () -> Unit = {},
+ onOpenS3Dashboard: () -> Unit = {},
viewModel: AccountsViewModel = hiltViewModel()
) {
val context = LocalContext.current
@@ -213,6 +214,7 @@ fun AccountsScreen(
onOpenQqMusicDashboard = onOpenQqMusicDashboard,
onOpenNavidromeDashboard = onOpenNavidromeDashboard,
onOpenJellyfinDashboard = onOpenJellyfinDashboard,
+ onOpenS3Dashboard = onOpenS3Dashboard,
preferNeteaseDashboard = true
)
},
@@ -242,6 +244,7 @@ fun AccountsScreen(
onOpenQqMusicDashboard = onOpenQqMusicDashboard,
onOpenNavidromeDashboard = onOpenNavidromeDashboard,
onOpenJellyfinDashboard = onOpenJellyfinDashboard,
+ onOpenS3Dashboard = onOpenS3Dashboard,
preferNeteaseDashboard = false
)
}
@@ -563,6 +566,7 @@ private fun EmptyAccountsCard(
ExternalServiceAccount.GOOGLE_DRIVE -> painterResource(R.drawable.rounded_drive_export_24)
ExternalServiceAccount.JELLYFIN -> painterResource(R.drawable.ic_jellyfin)
ExternalServiceAccount.NAVIDROME -> painterResource(R.drawable.ic_navidrome_md3)
+ ExternalServiceAccount.S3 -> painterResource(R.drawable.rounded_storage_24)
}
FilledTonalButton(
onClick = { if (!isComingSoon) onConnect(service) },
@@ -655,6 +659,14 @@ private fun servicePalette(service: ExternalServiceAccount): ServicePalette {
primaryActionContainer = Color(0xFFE3F2FD),
primaryActionTint = Color(0xFF1565C0)
)
+ ExternalServiceAccount.S3 -> ServicePalette(
+ iconContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ iconTint = MaterialTheme.colorScheme.onTertiaryContainer,
+ statusContainer = Color(0xFFE8F5E9),
+ statusTint = Color(0xFF2E7D32),
+ primaryActionContainer = MaterialTheme.colorScheme.tertiaryContainer,
+ primaryActionTint = MaterialTheme.colorScheme.onTertiaryContainer
+ )
}
}
@@ -666,6 +678,7 @@ private fun accountIcon(service: ExternalServiceAccount): ImageVector {
ExternalServiceAccount.QQ_MUSIC -> Icons.Rounded.MusicNote
ExternalServiceAccount.NAVIDROME -> Icons.Rounded.CloudQueue
ExternalServiceAccount.JELLYFIN -> Icons.Rounded.CloudQueue
+ ExternalServiceAccount.S3 -> Icons.Rounded.CloudQueue
}
}
@@ -721,6 +734,7 @@ private fun serviceDisplayName(service: ExternalServiceAccount): String {
ExternalServiceAccount.QQ_MUSIC -> stringResource(R.string.auth_qq_title)
ExternalServiceAccount.NAVIDROME -> stringResource(R.string.auth_subsonic_title)
ExternalServiceAccount.JELLYFIN -> stringResource(R.string.auth_jellyfin_title)
+ ExternalServiceAccount.S3 -> stringResource(R.string.auth_s3_title)
}
}
@@ -731,6 +745,7 @@ private fun openService(
onOpenQqMusicDashboard: () -> Unit,
onOpenNavidromeDashboard: () -> Unit,
onOpenJellyfinDashboard: () -> Unit,
+ onOpenS3Dashboard: () -> Unit,
preferNeteaseDashboard: Boolean
) {
when (service) {
@@ -783,6 +798,16 @@ private fun openService(
)
}
}
+ ExternalServiceAccount.S3 -> {
+ if (preferNeteaseDashboard) {
+ onOpenS3Dashboard()
+ } else {
+ safeStartActivity(
+ context = context,
+ intent = Intent(context, com.theveloper.pixelplay.presentation.s3.auth.S3LoginActivity::class.java)
+ )
+ }
+ }
}
}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt
index ba622628f7..77f7151eff 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt
@@ -135,6 +135,7 @@ fun HomeScreen(
qqMusicViewModel: QqMusicDashboardViewModel = hiltViewModel(),
navidromeViewModel: NavidromeDashboardViewModel = hiltViewModel(),
jellyfinViewModel: JellyfinDashboardViewModel = hiltViewModel(),
+ s3ViewModel: com.theveloper.pixelplay.presentation.s3.dashboard.S3DashboardViewModel = hiltViewModel(),
onOpenSidebar: () -> Unit
) {
val context = LocalContext.current
@@ -553,6 +554,7 @@ fun HomeScreen(
val isQqMusicLoggedIn by qqMusicViewModel.isLoggedIn.collectAsStateWithLifecycle()
val isNavidromeLoggedIn by navidromeViewModel.isLoggedIn.collectAsStateWithLifecycle()
val isJellyfinLoggedIn by jellyfinViewModel.isLoggedIn.collectAsStateWithLifecycle()
+ val isS3LoggedIn by s3ViewModel.isLoggedIn.collectAsStateWithLifecycle()
StreamingProviderSheet(
onDismissRequest = { showStreamingProviderSheet = false },
isNeteaseLoggedIn = isNeteaseLoggedIn,
@@ -570,6 +572,10 @@ fun HomeScreen(
isJellyfinLoggedIn = isJellyfinLoggedIn,
onNavigateToJellyfinDashboard = {
navController.navigateSafely(Screen.JellyfinDashboard.route)
+ },
+ isS3LoggedIn = isS3LoggedIn,
+ onNavigateToS3Dashboard = {
+ navController.navigateSafely(Screen.S3Dashboard.route)
}
)
}
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt
index 354d929875..b6605b5be2 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt
@@ -28,7 +28,8 @@ enum class ExternalServiceAccount {
NETEASE,
QQ_MUSIC,
NAVIDROME,
- JELLYFIN
+ JELLYFIN,
+ S3
}
data class ExternalAccountUiModel(
@@ -52,7 +53,8 @@ class AccountsViewModel @Inject constructor(
private val neteaseRepository: NeteaseRepository,
private val qqMusicRepository: QqMusicRepository,
private val navidromeRepository: NavidromeRepository,
- private val jellyfinRepository: JellyfinRepository
+ private val jellyfinRepository: JellyfinRepository,
+ private val s3Repository: com.theveloper.pixelplay.data.s3.S3Repository
) : ViewModel() {
private val loggingOutServices = MutableStateFlow>(emptySet())
@@ -101,6 +103,13 @@ class AccountsViewModel @Inject constructor(
connected to playlistCount
}
+ private val s3StateFlow = combine(
+ s3Repository.isLoggedInFlow,
+ s3Repository.getFolders().map { it.size }
+ ) { connected, folderCount ->
+ connected to folderCount
+ }
+
val uiState: StateFlow = combine(
combine(
listOf(
@@ -109,7 +118,8 @@ class AccountsViewModel @Inject constructor(
neteaseStateFlow,
qqMusicStateFlow,
navidromeStateFlow,
- jellyfinStateFlow
+ jellyfinStateFlow,
+ s3StateFlow
)
) { it.toList() },
loggingOutServices
@@ -120,6 +130,7 @@ class AccountsViewModel @Inject constructor(
val (qqConnected, qqPlaylistCount) = states[3] as Pair
val (navidromeConnected, navidromePlaylistCount) = states[4] as Pair
val (jellyfinConnected, jellyfinPlaylistCount) = states[5] as Pair
+ val (s3Connected, s3FolderCount) = states[6] as Pair
val connectedAccounts = buildList {
if (telegramConnected) {
@@ -224,6 +235,23 @@ class AccountsViewModel @Inject constructor(
)
)
}
+ if (s3Connected) {
+ add(
+ ExternalAccountUiModel(
+ service = ExternalServiceAccount.S3,
+ title = "S3 Storage",
+ accountLabel = s3Repository.userBucketLabel()
+ ?.takeIf { it.isNotBlank() }
+ ?: "S3 bucket connected",
+ syncedContentLabel = formatCount(
+ count = s3FolderCount,
+ singular = "synced folder",
+ plural = "synced folders"
+ ),
+ isLoggingOut = ExternalServiceAccount.S3 in activeLogouts
+ )
+ )
+ }
}
val disconnectedServices = buildList {
@@ -233,6 +261,7 @@ class AccountsViewModel @Inject constructor(
if (!qqConnected) add(ExternalServiceAccount.QQ_MUSIC)
if (!navidromeConnected) add(ExternalServiceAccount.NAVIDROME)
if (!jellyfinConnected) add(ExternalServiceAccount.JELLYFIN)
+ if (!s3Connected) add(ExternalServiceAccount.S3)
}
AccountsUiState(
@@ -259,6 +288,7 @@ class AccountsViewModel @Inject constructor(
ExternalServiceAccount.QQ_MUSIC -> qqMusicRepository.logout()
ExternalServiceAccount.NAVIDROME -> navidromeRepository.logout()
ExternalServiceAccount.JELLYFIN -> jellyfinRepository.logout()
+ ExternalServiceAccount.S3 -> s3Repository.logout()
}
}
} finally {
diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackDispatchStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackDispatchStateHolder.kt
index 8e346ebc43..7ca8800335 100644
--- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackDispatchStateHolder.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackDispatchStateHolder.kt
@@ -916,7 +916,8 @@ class PlaybackDispatchStateHolder @Inject constructor(
scheme != "qqmusic" &&
scheme != "navidrome" &&
scheme != "jellyfin" &&
- scheme != "gdrive"
+ scheme != "gdrive" &&
+ scheme != "s3"
) {
return mediaItem
}
diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt b/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt
index 48a8e1bcea..1c3e058b20 100644
--- a/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt
+++ b/app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt
@@ -87,6 +87,8 @@ object MediaItemBuilder {
const val EXTERNAL_EXTRA_ALBUM = EXTERNAL_EXTRA_PREFIX + "ALBUM"
const val EXTERNAL_EXTRA_DURATION = EXTERNAL_EXTRA_PREFIX + "DURATION"
const val EXTERNAL_EXTRA_CONTENT_URI = EXTERNAL_EXTRA_PREFIX + "CONTENT_URI"
+ /** Original cloud URI (e.g. s3://, gdrive://) of a resolved item — never the loopback proxy URL. */
+ const val ORIGINAL_CONTENT_URI = EXTERNAL_EXTRA_PREFIX + "ORIGINAL_URI"
const val EXTERNAL_EXTRA_ALBUM_ART = EXTERNAL_EXTRA_PREFIX + "ALBUM_ART"
const val EXTERNAL_EXTRA_GENRE = EXTERNAL_EXTRA_PREFIX + "GENRE"
const val EXTERNAL_EXTRA_TRACK = EXTERNAL_EXTRA_PREFIX + "TRACK"
diff --git a/app/src/main/res/drawable/rounded_storage_24.xml b/app/src/main/res/drawable/rounded_storage_24.xml
new file mode 100644
index 0000000000..894c29c6d5
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_storage_24.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/res/values-in/strings_cloud_services.xml b/app/src/main/res/values-in/strings_cloud_services.xml
index b71bab87e9..5a651db363 100644
--- a/app/src/main/res/values-in/strings_cloud_services.xml
+++ b/app/src/main/res/values-in/strings_cloud_services.xml
@@ -225,4 +225,6 @@
Login ke QQ Music
Catatan keamanan: kata sandi Anda hanya dimasukkan di halaman web QQ Music. PixelPlayer menyimpan cookie sesi untuk menyinkronkan pustaka Anda.
QQ Music
+ Membaca tag… %1$d lagu tersisa
+ Menyelesaikan…
\ No newline at end of file
diff --git a/app/src/main/res/values/strings_cloud_services.xml b/app/src/main/res/values/strings_cloud_services.xml
index df633dab6a..e192aef747 100644
--- a/app/src/main/res/values/strings_cloud_services.xml
+++ b/app/src/main/res/values/strings_cloud_services.xml
@@ -225,4 +225,41 @@
Login to QQ Music
Security note: your password is entered only in QQ Music web pages. PixelPlayer stores session cookies to sync your library.
QQ Music
+
+
+ S3 Storage
+ Stream music from any S3-compatible bucket: AWS S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi…
+ Music files are streamed directly from the bucket. Your access keys are stored encrypted on this device and never leave it.
+ Endpoint
+ https://s3.us-east-1.amazonaws.com
+ HTTPS endpoint of the S3-compatible service
+ Region
+ us-east-1
+ Region of the bucket (e.g. us-east-1, auto for R2)
+ Bucket
+ my-music
+ Name of the bucket that holds your music
+ Access Key ID
+ AKIA…
+ Access key from the provider console
+ Secret Access Key
+ Secret access key
+ Kept encrypted on this device
+ Folder prefix (optional)
+ music/
+ Restrict browsing to this key prefix
+ Tip: files named \u0022Artist - Title.ext\u0022 get artist and title parsed automatically. HTTPS is required.
+ S3 connected!
+ Pick a music folder
+ No sub-folders here
+ Add
+ Add this folder
+ Music folders
+ Tap + to add a bucket folder
+ No folders added yet
+ %1$d folders synced
+ Add folder
+ Edit connection
+ Reading tags… %1$d songs remaining
+ Finishing up…
\ No newline at end of file
diff --git a/app/src/test/java/com/theveloper/pixelplay/data/s3/LocalTrackIndexTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/s3/LocalTrackIndexTest.kt
new file mode 100644
index 0000000000..261478af63
--- /dev/null
+++ b/app/src/test/java/com/theveloper/pixelplay/data/s3/LocalTrackIndexTest.kt
@@ -0,0 +1,60 @@
+package com.theveloper.pixelplay.data.s3
+
+import com.theveloper.pixelplay.data.database.SongMatchKey
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class LocalTrackIndexTest {
+
+ private val index = LocalTrackIndex(
+ listOf(
+ SongMatchKey(title = "Gymnopédie No. 1", artistName = "Kevin MacLeod", duration = 182_000),
+ SongMatchKey(title = "Local Forecast", artistName = "Kevin MacLeod, Someone", duration = 0),
+ SongMatchKey(
+ title = "Sneaky Snitch", artistName = "", duration = 136_000,
+ filePath = "/storage/emulated/0/Music/Kevin MacLeod - Sneaky Snitch.mp3",
+ ),
+ )
+ )
+
+ @Test
+ fun `same title and artist within tolerance is a local copy`() {
+ assertTrue(index.hasLocalCopy("Gymnopédie No. 1", "Kevin MacLeod", 183_500))
+ }
+
+ @Test
+ fun `case, punctuation and bracketed suffixes are ignored`() {
+ assertTrue(index.hasLocalCopy("GYMNOPÉDIE NO. 1 (Remastered)", "kevin macleod", 182_000))
+ assertTrue(index.hasLocalCopy("GYMNOPÉDIE NO. 1 [Live]", "kevin macleod", 182_000))
+ }
+
+ @Test
+ fun `duration far apart is a different recording`() {
+ assertFalse(index.hasLocalCopy("Gymnopédie No. 1", "Kevin MacLeod", 240_000))
+ }
+
+ @Test
+ fun `unknown durations match on title and primary artist`() {
+ assertTrue(index.hasLocalCopy("Gymnopédie No. 1", "Kevin MacLeod", 0))
+ assertTrue(index.hasLocalCopy("Local Forecast", "Kevin MacLeod", 150_000))
+ }
+
+ @Test
+ fun `different artist or title is not a copy`() {
+ assertFalse(index.hasLocalCopy("Gymnopédie No. 1", "Erik Satie", 240_000))
+ assertFalse(index.hasLocalCopy("Gymnopédie No. 2", "Kevin MacLeod", 182_000))
+ assertFalse(index.hasLocalCopy("", "Kevin MacLeod", 182_000))
+ }
+
+ @Test
+ fun `same title and length match even when the artist differs`() {
+ assertTrue(index.hasLocalCopy("Sneaky Snitch", "Kevin MacLeod", 137_000))
+ }
+
+ @Test
+ fun `same file name is a local copy`() {
+ assertTrue(index.hasLocalCopy("01 sneaky", "Unknown Artist", 0, "music/Kevin MacLeod - Sneaky Snitch.flac"))
+ assertFalse(index.hasLocalCopy("Other", "Unknown Artist", 0, "music/Other.flac"))
+ }
+}
diff --git a/app/src/test/java/com/theveloper/pixelplay/data/s3/S3SigV4Test.kt b/app/src/test/java/com/theveloper/pixelplay/data/s3/S3SigV4Test.kt
new file mode 100644
index 0000000000..c16c1a9a9a
--- /dev/null
+++ b/app/src/test/java/com/theveloper/pixelplay/data/s3/S3SigV4Test.kt
@@ -0,0 +1,101 @@
+package com.theveloper.pixelplay.data.s3
+
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.runBlocking
+import org.junit.Test
+
+/**
+ * Verifies the SigV4 signer against the official AWS "Authenticating Requests
+ * (AWS Signature Version 4)" documentation example (examplebucket / test.txt).
+ */
+class S3SigV4Test {
+
+ // Values from the AWS docs example.
+ private val accessKey = "AKIAIOSFODNN7EXAMPLE"
+ private val secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
+ private val region = "us-east-1"
+ private val dateStamp = "20130524"
+
+ @Test
+ fun `signature matches AWS documentation example`() {
+ // Canonical request exactly as documented for the GET Object header example.
+ val canonicalRequest = listOf(
+ "GET",
+ "/test.txt",
+ "",
+ "host:examplebucket.s3.amazonaws.com\n" +
+ "range:bytes=0-9\n" +
+ "x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n" +
+ "x-amz-date:20130524T000000Z\n",
+ "host;range;x-amz-content-sha256;x-amz-date",
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ ).joinToString("\n")
+
+ val signature = S3SigV4.signForTest(secretKey, dateStamp, region, canonicalRequest)
+
+ assertThat(signature)
+ .isEqualTo("f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41")
+ }
+
+ @Test
+ fun `presigned URL signature matches AWS query-string example`() {
+ // Same canonical request shape as the "Using Query Parameters" docs example.
+ val canonicalRequest = listOf(
+ "GET",
+ "/test.txt",
+ "X-Amz-Algorithm=AWS4-HMAC-SHA256" +
+ "&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request" +
+ "&X-Amz-Date=20130524T000000Z" +
+ "&X-Amz-Expires=86400" +
+ "&X-Amz-SignedHeaders=host",
+ "host:examplebucket.s3.amazonaws.com\n",
+ "host",
+ "UNSIGNED-PAYLOAD"
+ ).joinToString("\n")
+
+ val signature = S3SigV4.signForTest(secretKey, dateStamp, region, canonicalRequest)
+
+ assertThat(signature)
+ .isEqualTo("aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404")
+ }
+
+ @Test
+ fun `uriEncode follows strict AWS rules`() {
+ assertThat(S3SigV4.uriEncode("music/my song+1.mp3")).isEqualTo("music%2Fmy%20song%2B1.mp3")
+ assertThat(S3SigV4.uriEncode("music/my song+1.mp3", encodeSlash = false))
+ .isEqualTo("music/my%20song%2B1.mp3")
+ assertThat(S3SigV4.uriEncode("a~b-c_d.e")).isEqualTo("a~b-c_d.e")
+ assertThat(S3SigV4.uriEncode("café")).isEqualTo("caf%C3%A9")
+ }
+
+ @Test
+ fun `canonicalUri preserves slashes and trims edges`() {
+ assertThat(S3SigV4.canonicalUri("/Music/01 song.mp3")).isEqualTo("/Music/01%20song.mp3")
+ assertThat(S3SigV4.canonicalUri("")).isEqualTo("/")
+ // Empty path segments are legal object keys and must be preserved verbatim.
+ assertThat(S3SigV4.canonicalUri("a//b")).isEqualTo("/a//b")
+ }
+
+ @Test
+ fun `amzDate formats correctly`() {
+ val date = S3SigV4.amzDate(1_369_975_200_000L) // 2013-05-31T04:40:00Z
+ assertThat(date.dateStamp).isEqualTo("20130531")
+ assertThat(date.amzDate).isEqualTo("20130531T044000Z")
+ }
+
+ @Test
+ fun `presigned URL is path-style with exactly one bucket segment`() = runBlocking {
+ val url = S3SigV4.presignGetUrl(
+ endpoint = "https://s3.example.com",
+ bucket = "mybucket",
+ objectKey = "u1/33x - Perunggu.flac",
+ accessKey = accessKey,
+ secretKey = secretKey,
+ region = region,
+ expiresInSeconds = 3600,
+ nowMillis = 0L
+ )
+ assertThat(url).startsWith("https://s3.example.com/mybucket/u1/33x%20-%20Perunggu.flac?")
+ assertThat(url).doesNotContain("mybucket/mybucket")
+ }
+}
diff --git a/docs/superpowers/specs/s3-storage-source.md b/docs/superpowers/specs/s3-storage-source.md
new file mode 100644
index 0000000000..b12def52b7
--- /dev/null
+++ b/docs/superpowers/specs/s3-storage-source.md
@@ -0,0 +1,34 @@
+# S3-Compatible Storage Source
+
+## Summary
+New cloud music source: any S3-compatible object storage (AWS S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi). Pure REST over existing OkHttp — no new dependencies. Follows the existing cloud-source pattern (GDrive/Navidrome/Jellyfin).
+
+## Auth & API
+- **SigV4** (`data/s3/S3SigV4.kt`): header-signed `ListObjectsV2` + query-string presigned GET (`UNSIGNED-PAYLOAD`, 1h TTL). Verified against official AWS doc vectors (`S3SigV4Test`).
+- **API** (`data/s3/S3ApiService.kt`): `listObjects` (delimiter=`/`, continuation token), `listAllObjects`, `ping` (maxKeys=1), `presignGetUrl` (validates object key via `CloudStreamSecurity.validateS3ObjectKey`).
+- Endpoint must be `https://` (cleartext blocked by network config). Region optional (defaults `us-east-1`); R2 uses `auto`.
+- Credentials in `EncryptedSharedPreferences("s3_prefs")`. Single connection per app.
+
+## Sync model (GDrive-style)
+User connects (ping) → picks folder(s) in bucket via browser → folders stored in Room (`s3_folders`), songs in `s3_songs` + unified `songs` table. Re-sync = diff object listing vs DB, add/remove songs; unified library updated via ID diff. Per-folder sync + sync-all (`Mutex`). Failed folders don't abort the batch.
+
+## IDs & identity
+- `SourceType.S3 = 7`; URI scheme `s3://{objectKey}`.
+- ID offsets: songs `15e12`, albums `16e12`, artists `17e12` (3–14e12 taken by Netease/QQ/Navidrome/Jellyfin).
+- Artist/title parsed from `Artist - Title.ext`; album = first path segment under folder prefix, else `S3`; genre `S3`; duration 0 (unknown).
+
+## Streaming
+- `S3StreamProxy` extends `CloudStreamProxy`; route `/s3/{key}`, key URL-encoded with `Uri.encode(id, "")` (URLEncoder breaks spaces→`+`).
+- Presigned URL cached 55 min (inside 1h TTL). `DualPlayerEngine` resolves `s3://` when online.
+- Presigned URLs exempt from private-IP block in `isSafeRemoteStreamUrl` (self-hosted MinIO/NAS); `X-Amz-Signature` in query is the marker.
+
+## UI
+- `S3LoginActivity`: endpoint/region/bucket/accessKey/secretKey/prefix form → folder picker (browse common prefixes, "Add this folder" for current level).
+- `S3DashboardScreen`: folder list w/ per-folder sync/delete, sync-all, logout, bucket label.
+- Wiring: `AccountsScreen` (S3 palette/icon/disconnect), `Screen.S3Dashboard`, `AppNavigation`, manifest.
+
+## DB
+Version 42→43, `MIGRATION_42_43` creates `s3_folders` + `s3_songs` (+3 indices). Schema JSON exported at `app/schemas/.../43.json`.
+
+## Out of scope (deliberate)
+Multi-account, multipart upload, offline downloads, per-file browse UI, XML `EncodingType=url` handling.