diff --git a/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/3.json b/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/3.json new file mode 100644 index 00000000..6cd39993 --- /dev/null +++ b/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/3.json @@ -0,0 +1,2072 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "5c1cfe97b2878071f055defdcab0cba7", + "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_artist_id` INTEGER NOT NULL DEFAULT 0, `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, `artists_json` TEXT, `source_type` INTEGER NOT NULL DEFAULT 0, `media_store_date_added` INTEGER NOT NULL DEFAULT 0, `media_store_date_modified` INTEGER NOT NULL DEFAULT 0, `title_user_edited` INTEGER NOT NULL DEFAULT 0, `artist_user_edited` INTEGER NOT NULL DEFAULT 0, `album_user_edited` INTEGER NOT NULL DEFAULT 0, `genre_user_edited` 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": "albumArtistId", + "columnName": "album_artist_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "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": "artistsJson", + "columnName": "artists_json", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceType", + "columnName": "source_type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "mediaStoreDateAdded", + "columnName": "media_store_date_added", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "mediaStoreDateModified", + "columnName": "media_store_date_modified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "titleUserEdited", + "columnName": "title_user_edited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "artistUserEdited", + "columnName": "artist_user_edited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "albumUserEdited", + "columnName": "album_user_edited", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "genreUserEdited", + "columnName": "genre_user_edited", + "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_album_artist_id", + "unique": false, + "columnNames": [ + "album_artist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_album_artist_id` ON `${TABLE_NAME}` (`album_artist_id`)" + }, + { + "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": "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": "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_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": "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": "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_artist` 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": "albumArtist", + "columnName": "album_artist", + "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": "navidrome_pending_favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`navidromeSongId` TEXT NOT NULL, `isStar` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `attempts` INTEGER NOT NULL, PRIMARY KEY(`navidromeSongId`))", + "fields": [ + { + "fieldPath": "navidromeSongId", + "columnName": "navidromeSongId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isStar", + "columnName": "isStar", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attempts", + "columnName": "attempts", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "navidromeSongId" + ] + } + }, + { + "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_artist` 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": "albumArtist", + "columnName": "album_artist", + "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" + ] + } + } + ], + "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, '5c1cfe97b2878071f055defdcab0cba7')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/FavoritesDao.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/FavoritesDao.kt index 3322e144..90a77ef8 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/FavoritesDao.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/FavoritesDao.kt @@ -33,6 +33,12 @@ interface FavoritesDao { @Query("SELECT * FROM favorites") suspend fun getAllFavoritesOnce(): List + @Query( + "SELECT f.songId FROM favorites f INNER JOIN songs s ON f.songId = s.id " + + "WHERE s.source_type = :sourceType AND f.isFavorite = 1" + ) + suspend fun getFavoriteSongIdsBySourceOnce(sourceType: Int): List + @Query("DELETE FROM favorites") suspend fun clearAll() diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/Migrations.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/Migrations.kt index ea18809f..63dc95b8 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/Migrations.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/Migrations.kt @@ -52,3 +52,24 @@ val MIGRATION_1_2 = object : Migration(1, 2) { db.execSQL("CREATE INDEX IF NOT EXISTS `index_songs_album_artist_id` ON `songs` (`album_artist_id`)") } } + +/** + * v2 -> v3: outbox table for two-way Navidrome favorites sync. + * The DDL must match the Room-generated schema exactly (no SQL DEFAULTs: + * Kotlin default values do not produce SQL defaults). + */ +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `navidrome_pending_favorites` ( + `navidromeSongId` TEXT NOT NULL, + `isStar` INTEGER NOT NULL, + `createdAt` INTEGER NOT NULL, + `attempts` INTEGER NOT NULL, + PRIMARY KEY(`navidromeSongId`) + ) + """.trimIndent() + ) + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt index b2c52a37..067a2467 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt @@ -1871,6 +1871,18 @@ interface MusicDao { refreshArtistTrackCounts() } + @Query("SELECT id FROM songs WHERE id IN (:ids)") + suspend fun getExistingSongIds(ids: List): List + + @Query( + "SELECT s.content_uri_string FROM songs s INNER JOIN favorites f ON s.id = f.songId " + + "WHERE s.source_type = :sourceType AND f.isFavorite = 1" + ) + suspend fun getFavoriteContentUrisBySource(sourceType: Int): List + + @Query("SELECT content_uri_string FROM songs WHERE id = :id") + suspend fun getContentUriStringOnce(id: Long): String? + companion object { /** * SQLite has a limit on the number of variables per statement (default 999, higher in newer versions). diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromeDao.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromeDao.kt index 41b39290..b41b80f4 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromeDao.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromeDao.kt @@ -94,4 +94,21 @@ interface NavidromeDao { @Query("DELETE FROM navidrome_playlists") suspend fun clearAllPlaylists() + + // ─── Pending favorite ops (outbox) ─────────────────────────────────── + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertPendingFavorite(op: NavidromePendingFavoriteEntity) + + @Query("SELECT * FROM navidrome_pending_favorites ORDER BY createdAt") + suspend fun getPendingFavoritesOnce(): List + + @Query("DELETE FROM navidrome_pending_favorites WHERE navidromeSongId = :id") + suspend fun deletePendingFavorite(id: String) + + @Query("UPDATE navidrome_pending_favorites SET attempts = attempts + 1 WHERE navidromeSongId = :id") + suspend fun incrementPendingFavoriteAttempts(id: String) + + @Query("DELETE FROM navidrome_pending_favorites") + suspend fun clearPendingFavorites() } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromePendingFavoriteEntity.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromePendingFavoriteEntity.kt new file mode 100644 index 00000000..035ba88c --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromePendingFavoriteEntity.kt @@ -0,0 +1,16 @@ +package com.lostf1sh.pixelplayeross.data.database + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Outbox of favorite changes on Navidrome songs awaiting push to the server. + * One row per song: a later toggle replaces the pending op (latest intent wins). + */ +@Entity(tableName = "navidrome_pending_favorites") +data class NavidromePendingFavoriteEntity( + @PrimaryKey val navidromeSongId: String, + val isStar: Boolean, + val createdAt: Long = System.currentTimeMillis(), + val attempts: Int = 0 +) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/PixelPlayerDatabase.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/PixelPlayerDatabase.kt index 21871c66..aee20ee0 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/PixelPlayerDatabase.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/PixelPlayerDatabase.kt @@ -21,10 +21,11 @@ import androidx.sqlite.db.SupportSQLiteDatabase PlaylistSongEntity::class, NavidromeSongEntity::class, NavidromePlaylistEntity::class, + NavidromePendingFavoriteEntity::class, JellyfinSongEntity::class, JellyfinPlaylistEntity::class ], - version = 2, + version = 3, exportSchema = true ) abstract class PixelPlayerDatabase : RoomDatabase() { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeFavoritesReconciliation.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeFavoritesReconciliation.kt new file mode 100644 index 00000000..2430b22e --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeFavoritesReconciliation.kt @@ -0,0 +1,31 @@ +package com.lostf1sh.pixelplayeross.data.navidrome + +import com.lostf1sh.pixelplayeross.data.database.NavidromePendingFavoriteEntity + +data class NavidromeFavoritesReconciliation( + val toFavorite: List, + val toUnfavorite: List +) + +/** + * Computes local favorites changes for Navidrome-sourced songs. + * Server starred state wins, except songs with a pending un-pushed local op, + * where the pending action wins. + */ +internal fun reconcileNavidromeFavorites( + serverStarredIds: Set, + pendingOps: List, + localFavoriteIds: Set, + toUnifiedSongId: (String) -> Long +): NavidromeFavoritesReconciliation { + val desired = serverStarredIds.mapTo(mutableSetOf(), toUnifiedSongId) + pendingOps.forEach { op -> + val unifiedId = toUnifiedSongId(op.navidromeSongId) + if (op.isStar) desired.add(unifiedId) else desired.remove(unifiedId) + } + + return NavidromeFavoritesReconciliation( + toFavorite = (desired - localFavoriteIds).toList(), + toUnfavorite = (localFavoriteIds - desired).toList() + ) +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeFavoritesSyncManager.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeFavoritesSyncManager.kt new file mode 100644 index 00000000..b3556b75 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeFavoritesSyncManager.kt @@ -0,0 +1,97 @@ +package com.lostf1sh.pixelplayeross.data.navidrome + +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.lostf1sh.pixelplayeross.data.database.NavidromeDao +import com.lostf1sh.pixelplayeross.data.database.NavidromePendingFavoriteEntity +import com.lostf1sh.pixelplayeross.data.network.navidrome.NavidromeApiService +import com.lostf1sh.pixelplayeross.data.worker.NavidromeFavoritesPushWorker +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CancellationException +import timber.log.Timber + +/** + * Outbox for favorite changes on Navidrome songs: records pending star/unstar + * ops and pushes them to the server when connectivity allows. + */ +@Singleton +class NavidromeFavoritesSyncManager @Inject constructor( + private val api: NavidromeApiService, + private val navidromeDao: NavidromeDao, + private val workManager: WorkManager +) { + companion object { + private const val TAG = "NavidromeFavSync" + const val PUSH_WORK_NAME = "navidrome_favorites_push" + const val MAX_ATTEMPTS = 8 + } + + suspend fun onFavoriteToggled(navidromeSongId: String, favorite: Boolean) { + // Toggles only happen on Navidrome songs while their rows exist in the unified + // library, which requires being logged in, so we enqueue unconditionally here. + // (Gating on api.hasCredentials() previously silently dropped ops on a cold + // start where credentials had not yet been restored into the in-memory API + // client. Logout already clears the outbox via dao.clearPendingFavorites().) + navidromeDao.upsertPendingFavorite( + NavidromePendingFavoriteEntity( + navidromeSongId = navidromeSongId, + isStar = favorite + ) + ) + schedulePush() + } + + fun schedulePush() { + val request = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .build() + workManager.enqueueUniqueWork(PUSH_WORK_NAME, ExistingWorkPolicy.REPLACE, request) + } + + /** + * Pushes all pending ops. Returns true when the queue is fully drained. + * Ops are deleted on success and dropped after MAX_ATTEMPTS failures so a + * permanently failing song (e.g. deleted server-side) cannot block the queue. + */ + suspend fun drainPendingFavorites(): Boolean { + if (!api.hasCredentials()) { + // Do NOT clear the outbox here: on a WorkManager cold start this worker's + // Hilt graph never constructs NavidromeRepository, so credentials may simply + // not have been restored into the shared NavidromeApiService yet even though + // the user is logged in. Clearing would silently drop pending ops and report + // success. Stale-op cleanup after an actual logout is already handled by + // NavidromeRepository.logout() calling dao.clearPendingFavorites(). + return true + } + var queueDrained = true + navidromeDao.getPendingFavoritesOnce().forEach { op -> + val result = if (op.isStar) api.star(id = op.navidromeSongId) else api.unstar(id = op.navidromeSongId) + result.fold( + onSuccess = { navidromeDao.deletePendingFavorite(op.navidromeSongId) }, + onFailure = { error -> + // A REPLACE-cancelled in-flight run must not consume this op's retry budget. + if (error is CancellationException) throw error + Timber.w("$TAG: push failed for ${op.navidromeSongId} (attempt ${op.attempts + 1}): ${error.message}") + if (op.attempts + 1 >= MAX_ATTEMPTS) { + navidromeDao.deletePendingFavorite(op.navidromeSongId) + } else { + navidromeDao.incrementPendingFavoriteAttempts(op.navidromeSongId) + queueDrained = false + } + } + ) + } + return queueDrained + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepository.kt index 74058213..6da04f93 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepository.kt @@ -8,8 +8,11 @@ import androidx.security.crypto.MasterKey import com.lostf1sh.pixelplayeross.R import com.lostf1sh.pixelplayeross.data.database.AlbumEntity import com.lostf1sh.pixelplayeross.data.database.ArtistEntity +import com.lostf1sh.pixelplayeross.data.database.FavoritesDao +import com.lostf1sh.pixelplayeross.data.database.FavoritesEntity import com.lostf1sh.pixelplayeross.data.database.MusicDao import com.lostf1sh.pixelplayeross.data.database.NavidromeDao +import com.lostf1sh.pixelplayeross.data.database.NavidromePendingFavoriteEntity import com.lostf1sh.pixelplayeross.data.database.NavidromePlaylistEntity import com.lostf1sh.pixelplayeross.data.database.NavidromeSongEntity import com.lostf1sh.pixelplayeross.data.database.toEntity @@ -62,6 +65,8 @@ class NavidromeRepository @Inject constructor( private val api: NavidromeApiService, private val dao: NavidromeDao, private val musicDao: MusicDao, + private val favoritesDao: FavoritesDao, + private val favoritesSyncManager: NavidromeFavoritesSyncManager, private val playlistPreferencesRepository: PlaylistPreferencesRepository, private val userPreferencesRepository: UserPreferencesRepository, @ApplicationContext private val context: Context @@ -74,6 +79,8 @@ class NavidromeRepository @Inject constructor( private const val KEY_USERNAME = "username" private const val KEY_PASSWORD = "password" private const val KEY_LAST_FULL_SYNC = "last_full_sync" + private const val KEY_FAVORITES_IMPORTED = "favorites_imported" + private const val STARRED_REFRESH_MIN_INTERVAL_MS = 30_000L // Negative offsets prevent collisions with MediaStore IDs. private const val NAVIDROME_SONG_ID_OFFSET = 9_000_000_000_000L @@ -241,6 +248,7 @@ class NavidromeRepository @Inject constructor( musicDao.clearAllNavidromeSongs() dao.clearAllPlaylists() + dao.clearPendingFavorites() userPreferencesRepository.clearNavidromeSelectedMusicFolderIds() _isLoggedInFlow.value = false } @@ -633,6 +641,14 @@ class NavidromeRepository @Inject constructor( Timber.e(e, "$TAG: Failed to sync unified library") } + try { + syncStarredSongs() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "$TAG: favorites sync failed, continuing") + } + onProgress?.invoke(1f, context.getString(R.string.dash_status_sync_complete)) if (failedPlaylistCount == 0) { @@ -796,6 +812,7 @@ class NavidromeRepository @Inject constructor( // When on, "Group by Album Artist" makes the album's display artist the album artist; // either way the effective album artist is captured on the song for the Artists tab. val groupByAlbumArtist = userPreferencesRepository.groupByAlbumArtistFlow.first() + val favoriteUnifiedIds = favoritesDao.getFavoriteSongIdsOnce().toSet() val songs = ArrayList(navidromeSongs.size) val artists = LinkedHashMap() @@ -882,7 +899,7 @@ class NavidromeRepository @Inject constructor( genre = navidromeSong.genre ?: NAVIDROME_GENRE, filePath = navidromeSong.path, parentDirectoryPath = NAVIDROME_PARENT_DIRECTORY, - isFavorite = false, + isFavorite = songId in favoriteUnifiedIds, lyrics = null, trackNumber = navidromeSong.trackNumber, year = navidromeSong.year, @@ -913,6 +930,103 @@ class NavidromeRepository @Inject constructor( ) } + /** + * Pulls server starred songs and reconciles them with local favorites for + * Navidrome-sourced songs. Pending un-pushed local ops win over server state. + */ + suspend fun syncStarredSongs() { + if (!api.hasCredentials()) return + + // Snapshot pending ops BEFORE the network round-trip so ops present at snapshot + // time always win, even if the push worker drains and deletes them while + // getStarred2() is in flight (worst case: a harmless re-favorite). + var pendingOps = dao.getPendingFavoritesOnce() + + val serverStarredIds = api.getStarred2() + .getOrElse { error -> + Timber.w("$TAG: getStarred2 failed, skipping favorites sync: ${error.message}") + return + } + .mapNotNull { song -> song.optString("id").takeIf { it.isNotBlank() } } + .toSet() + + val imported = importLocalFavoritesAsPendingOnFirstRun(serverStarredIds) + if (imported) { + pendingOps = dao.getPendingFavoritesOnce() + } + + val localFavoriteIds = favoritesDao + .getFavoriteSongIdsBySourceOnce(SourceType.NAVIDROME) + .toSet() + + val reconciliation = reconcileNavidromeFavorites( + serverStarredIds = serverStarredIds, + pendingOps = pendingOps, + localFavoriteIds = localFavoriteIds, + toUnifiedSongId = ::toUnifiedSongId + ) + + // Only favorite songs that exist in the unified library (starred songs + // outside the selected music folders would otherwise create orphan rows). + val existingIds = reconciliation.toFavorite + .chunked(900) + .flatMap { musicDao.getExistingSongIds(it) } + if (existingIds.isNotEmpty()) { + favoritesDao.insertAll(existingIds.map { FavoritesEntity(songId = it, isFavorite = true) }) + } + reconciliation.toUnfavorite.forEach { favoritesDao.removeFavorite(it) } + + if (pendingOps.isNotEmpty()) { + favoritesSyncManager.schedulePush() + } + Timber.d("$TAG: favorites sync done (server=${serverStarredIds.size}, added=${existingIds.size}, removed=${reconciliation.toUnfavorite.size})") + } + + @Volatile + private var lastStarredRefreshMs = 0L + + /** + * Lightweight favorites-only refresh for UI triggers (Liked tab shown or + * pull-to-refresh). Skips when logged out; non-forced calls are rate + * limited so tab switching does not spam the server. + */ + suspend fun refreshStarredSongs(force: Boolean = false) { + if (!isLoggedIn) return + val now = System.currentTimeMillis() + if (!shouldRefreshCloudFavorites(force, now, lastStarredRefreshMs, STARRED_REFRESH_MIN_INTERVAL_MS)) return + lastStarredRefreshMs = now + try { + syncStarredSongs() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "$TAG: favorites refresh failed") + } + } + + /** + * One-time per login: local favorites on Navidrome songs that predate this + * feature are converted into pending star ops so they are pushed to the + * server instead of being deleted by reconciliation. + */ + /** + * @return true if this run performed the import (i.e. it was the first run), + * false if it was a no-op because the import already happened previously. + */ + private suspend fun importLocalFavoritesAsPendingOnFirstRun(serverStarredIds: Set): Boolean { + if (prefs.getBoolean(KEY_FAVORITES_IMPORTED, false)) return false + musicDao.getFavoriteContentUrisBySource(SourceType.NAVIDROME) + .map { it.removePrefix("navidrome://") } + .filter { it !in serverStarredIds } + .forEach { navidromeId -> + dao.upsertPendingFavorite( + NavidromePendingFavoriteEntity(navidromeSongId = navidromeId, isStar = true) + ) + } + prefs.edit { putBoolean(KEY_FAVORITES_IMPORTED, true) } + return true + } + // ─── Utility Methods ─────────────────────────────────────────────────── private fun parseArtistNames(rawArtist: String): List = @@ -1041,6 +1155,13 @@ class NavidromeRepository @Inject constructor( } } +internal fun shouldRefreshCloudFavorites( + force: Boolean, + nowMs: Long, + lastRefreshMs: Long, + minIntervalMs: Long +): Boolean = force || nowMs - lastRefreshMs >= minIntervalMs + internal fun navidromePlaylistsWithLibraryFallback( playlists: List, librarySongCount: Int, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeApiService.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeApiService.kt index 90b56bb7..95529d11 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeApiService.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeApiService.kt @@ -565,4 +565,16 @@ class NavidromeApiService @Inject constructor( return requestAndParse("unstar", params).map { true } } + + /** + * Get starred (favorited) songs from the server. + * Uses getStarred2 (ID3 tags) to match the rest of the client. + */ + suspend fun getStarred2(): Result> { + return requestAndParse("getStarred2").map { response -> + val starred = response.optJSONObject("starred2") + val songs = starred?.optJSONArray("song") + (0 until (songs?.length() ?: 0)).mapNotNull { songs?.optJSONObject(it) } + } + } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImpl.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImpl.kt index 5b08a605..188f7fe1 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImpl.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImpl.kt @@ -39,6 +39,7 @@ import com.lostf1sh.pixelplayeross.data.model.SearchResultItem import com.lostf1sh.pixelplayeross.data.model.SortOption import com.lostf1sh.pixelplayeross.data.model.FolderSource import com.lostf1sh.pixelplayeross.data.model.StorageFilter +import com.lostf1sh.pixelplayeross.data.navidrome.NavidromeFavoritesSyncManager import com.lostf1sh.pixelplayeross.data.preferences.PlaylistPreferencesRepository import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository import com.lostf1sh.pixelplayeross.ui.theme.GenreThemeUtils @@ -87,6 +88,7 @@ class MusicRepositoryImpl @Inject constructor( private val lyricsRepository: LyricsRepository, private val songRepository: SongRepository, private val favoritesDao: FavoritesDao, + private val navidromeFavoritesSyncManager: NavidromeFavoritesSyncManager, private val artistImageRepository: ArtistImageRepository, private val folderTreeBuilder: FolderTreeBuilder ) : MusicRepository { @@ -760,6 +762,13 @@ class MusicRepositoryImpl @Inject constructor( } else { favoritesDao.removeFavorite(id) } + val contentUri = musicDao.getContentUriStringOnce(id) + if (contentUri?.startsWith("navidrome://") == true) { + navidromeFavoritesSyncManager.onFavoriteToggled( + navidromeSongId = contentUri.removePrefix("navidrome://"), + favorite = isFavorite + ) + } } override suspend fun getFavoriteSongIdsOnce(): Set = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/NavidromeFavoritesPushWorker.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/NavidromeFavoritesPushWorker.kt new file mode 100644 index 00000000..ab70d8dc --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/NavidromeFavoritesPushWorker.kt @@ -0,0 +1,38 @@ +package com.lostf1sh.pixelplayeross.data.worker + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.lostf1sh.pixelplayeross.data.navidrome.NavidromeFavoritesSyncManager +import com.lostf1sh.pixelplayeross.data.navidrome.NavidromeRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException +import timber.log.Timber + +@HiltWorker +class NavidromeFavoritesPushWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val favoritesSyncManager: NavidromeFavoritesSyncManager, + private val repository: NavidromeRepository +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + // Injecting NavidromeRepository forces its init block to run on this worker's + // (previously repository-less) Hilt graph, restoring saved credentials into the + // shared NavidromeApiService before we drain the outbox. Without it, a + // WorkManager cold start could see api.hasCredentials() == false for a + // logged-in user. + if (!repository.isLoggedIn) return Result.success() + return try { + if (favoritesSyncManager.drainPendingFavorites()) Result.success() else Result.retry() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "NavidromeFavoritesPushWorker: push failed") + Result.retry() + } + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt index ba4cc352..390ea997 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt @@ -23,6 +23,7 @@ import com.lostf1sh.pixelplayeross.data.database.FavoritesDao import com.lostf1sh.pixelplayeross.data.database.LyricsDao import com.lostf1sh.pixelplayeross.data.database.LocalPlaylistDao import com.lostf1sh.pixelplayeross.data.database.MIGRATION_1_2 +import com.lostf1sh.pixelplayeross.data.database.MIGRATION_2_3 import com.lostf1sh.pixelplayeross.data.database.MusicDao import com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase import com.lostf1sh.pixelplayeross.data.database.SearchHistoryDao @@ -31,6 +32,7 @@ import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository import com.lostf1sh.pixelplayeross.data.preferences.PlaylistPreferencesRepository import com.lostf1sh.pixelplayeross.data.preferences.dataStore import com.lostf1sh.pixelplayeross.data.media.SongMetadataEditor +import com.lostf1sh.pixelplayeross.data.navidrome.NavidromeFavoritesSyncManager import com.lostf1sh.pixelplayeross.data.network.deezer.DeezerApiService import com.lostf1sh.pixelplayeross.data.network.lyrics.LrcLibApiService import com.lostf1sh.pixelplayeross.data.repository.ArtistImageRepository @@ -124,7 +126,7 @@ object AppModule { "pixelplayer_database" ) .addCallback(PixelPlayerDatabase.createRuntimeArtifactsCallback()) - .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) // P2-4: Only allow destructive recreation in debug builds. @@ -287,6 +289,7 @@ object AppModule { lyricsRepository: LyricsRepository, songRepository: SongRepository, favoritesDao: FavoritesDao, + navidromeFavoritesSyncManager: NavidromeFavoritesSyncManager, artistImageRepository: ArtistImageRepository, folderTreeBuilder: FolderTreeBuilder ): MusicRepository { @@ -299,6 +302,7 @@ object AppModule { lyricsRepository = lyricsRepository, songRepository = songRepository, favoritesDao = favoritesDao, + navidromeFavoritesSyncManager = navidromeFavoritesSyncManager, artistImageRepository = artistImageRepository, folderTreeBuilder = folderTreeBuilder ) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt index 25d76a1e..298407a6 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/LibraryScreen.kt @@ -1309,6 +1309,7 @@ fun LibraryScreen( onMoreOptionsClick = stableOnMoreOptionsClick, isRefreshing = isRefreshing, onRefresh = { + playerViewModel.refreshCloudFavorites(force = true) onRefresh() favoritePagingItems.refresh() }, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt index bc3297aa..4f3fcd1e 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModel.kt @@ -39,6 +39,7 @@ import com.lostf1sh.pixelplayeross.R import com.lostf1sh.pixelplayeross.data.EotStateHolder import com.lostf1sh.pixelplayeross.data.database.AlbumArtThemeDao import com.lostf1sh.pixelplayeross.data.media.CoverArtUpdate +import com.lostf1sh.pixelplayeross.data.navidrome.NavidromeRepository import com.lostf1sh.pixelplayeross.data.model.Album import com.lostf1sh.pixelplayeross.data.model.Artist import com.lostf1sh.pixelplayeross.data.model.FolderSource @@ -247,6 +248,7 @@ private data class ResolvedAlbumSelection( class PlayerViewModel @Inject constructor( @param:ApplicationContext private val context: Context, private val musicRepository: MusicRepository, + private val navidromeRepository: NavidromeRepository, private val userPreferencesRepository: UserPreferencesRepository, private val themePreferencesRepository: ThemePreferencesRepository, private val albumArtThemeDao: AlbumArtThemeDao, @@ -4180,6 +4182,15 @@ class PlayerViewModel @Inject constructor( loadArtists = { loadArtistsIfNeeded() }, loadFolders = { loadFoldersFromRepository() } ) + if (_currentLibraryTabId.value == LibraryTabId.LIKED) { + refreshCloudFavorites(force = false) + } + } + + fun refreshCloudFavorites(force: Boolean = false) { + viewModelScope.launch { + navidromeRepository.refreshStarredSongs(force) + } } fun saveLibraryTabsOrder(tabs: List) { diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeFavoritesReconciliationTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeFavoritesReconciliationTest.kt new file mode 100644 index 00000000..477d08dc --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeFavoritesReconciliationTest.kt @@ -0,0 +1,104 @@ +package com.lostf1sh.pixelplayeross.data.navidrome + +import com.google.common.truth.Truth.assertThat +import com.lostf1sh.pixelplayeross.data.database.NavidromePendingFavoriteEntity +import org.junit.jupiter.api.Test + +class NavidromeFavoritesReconciliationTest { + + private val toUnifiedSongId: (String) -> Long = { id -> -(9_000_000_000_000L + id.hashCode().toLong()) } + + private fun pendingOp(navidromeSongId: String, isStar: Boolean) = + NavidromePendingFavoriteEntity( + navidromeSongId = navidromeSongId, + isStar = isStar, + createdAt = 1L, + attempts = 0 + ) + + @Test + fun `server starred song not favorited locally gets favorited`() { + val result = reconcileNavidromeFavorites( + serverStarredIds = setOf("nd-1"), + pendingOps = emptyList(), + localFavoriteIds = emptySet(), + toUnifiedSongId = toUnifiedSongId + ) + + assertThat(result.toFavorite).containsExactly(toUnifiedSongId("nd-1")) + assertThat(result.toUnfavorite).isEmpty() + } + + @Test + fun `local favorite absent from server with no pending op gets removed`() { + val localId = toUnifiedSongId("nd-2") + + val result = reconcileNavidromeFavorites( + serverStarredIds = emptySet(), + pendingOps = emptyList(), + localFavoriteIds = setOf(localId), + toUnifiedSongId = toUnifiedSongId + ) + + assertThat(result.toFavorite).isEmpty() + assertThat(result.toUnfavorite).containsExactly(localId) + } + + @Test + fun `pending star op protects local favorite not yet on server`() { + val localId = toUnifiedSongId("nd-3") + + val result = reconcileNavidromeFavorites( + serverStarredIds = emptySet(), + pendingOps = listOf(pendingOp("nd-3", isStar = true)), + localFavoriteIds = setOf(localId), + toUnifiedSongId = toUnifiedSongId + ) + + assertThat(result.toFavorite).isEmpty() + assertThat(result.toUnfavorite).isEmpty() + } + + @Test + fun `pending unstar op overrides server starred state`() { + val localId = toUnifiedSongId("nd-4") + + val result = reconcileNavidromeFavorites( + serverStarredIds = setOf("nd-4"), + pendingOps = listOf(pendingOp("nd-4", isStar = false)), + localFavoriteIds = setOf(localId), + toUnifiedSongId = toUnifiedSongId + ) + + assertThat(result.toFavorite).isEmpty() + assertThat(result.toUnfavorite).containsExactly(localId) + } + + @Test + fun `pending unstar op prevents import of server starred song`() { + val result = reconcileNavidromeFavorites( + serverStarredIds = setOf("nd-5"), + pendingOps = listOf(pendingOp("nd-5", isStar = false)), + localFavoriteIds = emptySet(), + toUnifiedSongId = toUnifiedSongId + ) + + assertThat(result.toFavorite).isEmpty() + assertThat(result.toUnfavorite).isEmpty() + } + + @Test + fun `already consistent state produces no changes`() { + val localId = toUnifiedSongId("nd-6") + + val result = reconcileNavidromeFavorites( + serverStarredIds = setOf("nd-6"), + pendingOps = emptyList(), + localFavoriteIds = setOf(localId), + toUnifiedSongId = toUnifiedSongId + ) + + assertThat(result.toFavorite).isEmpty() + assertThat(result.toUnfavorite).isEmpty() + } +} diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepositoryTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepositoryTest.kt index 2666692c..34d107c3 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepositoryTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/navidrome/NavidromeRepositoryTest.kt @@ -101,4 +101,24 @@ class NavidromeRepositoryTest { assertThat(selectedIds).isEmpty() } + + @Test + fun `forced favorites refresh always allowed`() { + assertThat(shouldRefreshCloudFavorites(force = true, nowMs = 1_000L, lastRefreshMs = 999L, minIntervalMs = 30_000L)).isTrue() + } + + @Test + fun `favorites refresh blocked when interval has not elapsed since epoch zero`() { + assertThat(shouldRefreshCloudFavorites(force = false, nowMs = 5L, lastRefreshMs = 0L, minIntervalMs = 30_000L)).isFalse() + } + + @Test + fun `favorites refresh blocked within cooldown`() { + assertThat(shouldRefreshCloudFavorites(force = false, nowMs = 40_000L, lastRefreshMs = 20_000L, minIntervalMs = 30_000L)).isFalse() + } + + @Test + fun `favorites refresh allowed after cooldown`() { + assertThat(shouldRefreshCloudFavorites(force = false, nowMs = 60_000L, lastRefreshMs = 20_000L, minIntervalMs = 30_000L)).isTrue() + } } diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImplTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImplTest.kt index 81f3e00e..6cf13432 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImplTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImplTest.kt @@ -10,6 +10,7 @@ import com.lostf1sh.pixelplayeross.data.model.Song // Para verificar el mapeo import com.lostf1sh.pixelplayeross.data.preferences.PlaylistPreferencesRepository import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository import com.lostf1sh.pixelplayeross.data.database.FavoritesDao +import com.lostf1sh.pixelplayeross.data.navidrome.NavidromeFavoritesSyncManager import io.mockk.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -39,6 +40,7 @@ class MusicRepositoryImplTest { private val mockSongRepository: SongRepository = mockk(relaxed = true) private val mockFavoritesDao: FavoritesDao = mockk(relaxed = true) private val mockArtistImageRepository: ArtistImageRepository = mockk(relaxed = true) + private val mockNavidromeFavoritesSyncManager: NavidromeFavoritesSyncManager = mockk(relaxed = true) private val testDispatcher = StandardTestDispatcher() @@ -99,6 +101,7 @@ class MusicRepositoryImplTest { songRepository = mockSongRepository, favoritesDao = mockFavoritesDao, + navidromeFavoritesSyncManager = mockNavidromeFavoritesSyncManager, artistImageRepository = mockArtistImageRepository, folderTreeBuilder = mockk(relaxed = true) ) @@ -303,6 +306,30 @@ class MusicRepositoryImplTest { assertEquals(1, result.count { it.id == "unknown" }) } + @Nested + @DisplayName("setFavoriteStatus Navidrome push wiring") + inner class SetFavoriteStatusNavidromePush { + @Test + fun `setFavoriteStatus on a Navidrome song enqueues a push`() = runTest(testDispatcher) { + coEvery { mockFavoritesDao.setFavorite(any()) } just runs + coEvery { mockMusicDao.getContentUriStringOnce(99L) } returns "navidrome://abc123" + + musicRepository.setFavoriteStatus("99", true) + + coVerify { mockNavidromeFavoritesSyncManager.onFavoriteToggled("abc123", true) } + } + + @Test + fun `setFavoriteStatus on a local MediaStore song does not enqueue a push`() = runTest(testDispatcher) { + coEvery { mockFavoritesDao.setFavorite(any()) } just runs + coEvery { mockMusicDao.getContentUriStringOnce(42L) } returns "content://media/external/audio/media/42" + + musicRepository.setFavoriteStatus("42", true) + + coVerify(exactly = 0) { mockNavidromeFavoritesSyncManager.onFavoriteToggled(any(), any()) } + } + } + @Nested @DisplayName("Search History Functions") inner class SearchHistoryFunctions { diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModelTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModelTest.kt index ce945529..5bed757c 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModelTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/PlayerViewModelTest.kt @@ -12,6 +12,7 @@ import com.lostf1sh.pixelplayeross.data.model.Artist import com.lostf1sh.pixelplayeross.data.model.Song import com.lostf1sh.pixelplayeross.data.model.SortOption import com.lostf1sh.pixelplayeross.data.model.StorageFilter +import com.lostf1sh.pixelplayeross.data.navidrome.NavidromeRepository import com.lostf1sh.pixelplayeross.data.preferences.ThemePreferencesRepository import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository import com.lostf1sh.pixelplayeross.data.repository.MusicRepository @@ -60,6 +61,7 @@ class PlayerViewModelTest { private lateinit var playerViewModel: PlayerViewModel private val mockMusicRepository: MusicRepository = mockk() + private val mockNavidromeRepository: NavidromeRepository = mockk(relaxed = true) private val mockUserPreferencesRepository: UserPreferencesRepository = mockk(relaxed = true) private val mockThemePreferencesRepository: ThemePreferencesRepository = mockk(relaxed = true) private val mockAlbumArtThemeDao: AlbumArtThemeDao = mockk(relaxed = true) @@ -215,6 +217,7 @@ class PlayerViewModelTest { playerViewModel = PlayerViewModel( mockContext, mockMusicRepository, + mockNavidromeRepository, mockUserPreferencesRepository, mockThemePreferencesRepository, mockAlbumArtThemeDao,