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.