From 3cbb75bd21dc4dfd37b43d1fb1bcec3b7c3922a7 Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Thu, 18 Jun 2026 01:54:51 +0300 Subject: [PATCH 1/2] Trust user-installed CAs for self-signed cloud servers Self-hosted Navidrome/Subsonic/Jellyfin servers commonly use a private or self-signed CA. Release builds previously trusted only system CAs, so a root certificate the user imported via Android Settings was ignored and the connection was refused. Add to the release network security config so the app honors user-installed CAs. This is a declarative, user-controlled trust decision (the user must deliberately install the cert), not a validation bypass, and keeps F-Droid compliance intact. Fixes #7 --- app/src/main/res/xml/network_security_config.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index e94e6a0..a943465 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,9 +1,17 @@ + + From 726c7a8921d513cd69f1956aee25ff22e8e422d4 Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Thu, 18 Jun 2026 01:55:01 +0300 Subject: [PATCH 2/2] Group Artists tab and cloud albums by album artist "Group by Album Artist" was effectively ignored for Subsonic/Navidrome and Jellyfin libraries, and never decluttered the Artists tab for any source. Two problems: - Cloud sync hardcoded albumArtist = null, so the album-artist tag the Subsonic (albumArtist) and Jellyfin (AlbumArtist) APIs return was dropped and the setting could not work for cloud albums. - The preference only relabeled album entities; the Artists tab always listed every track-level artist, so featured/compilation artists cluttered it even for local libraries. Changes: - Capture the album-artist tag end to end for Navidrome and Jellyfin (API parser -> DTO -> cache entity -> unified song). - Add songs.album_artist_id, the id of the effective album artist (album_artist when present, else the primary track artist), populated at sync for local and both cloud sources. Album-artist-only names (e.g. "Various Artists") get a real artist row and are preserved by deleteOrphanedArtists. - When the toggle is on, the Artists tab and artist detail collapse onto album_artist_id. The column is preference-independent, so toggling switches queries at runtime with no re-sync. Default (toggle off) behavior is unchanged. - MIGRATION_1_2 (v1 -> v2) adds the columns with defensive PRAGMA guards and backfills album_artist_id from the existing primary artist. Closes #8 --- .../2.json | 2036 +++++++++++++++++ .../data/database/JellyfinSongEntity.kt | 2 + .../data/database/Migrations.kt | 54 + .../pixelplayeross/data/database/MusicDao.kt | 67 +- .../data/database/NavidromeSongEntity.kt | 2 + .../data/database/PixelPlayerDatabase.kt | 2 +- .../data/database/SongEntity.kt | 5 + .../data/jellyfin/JellyfinRepository.kt | 35 +- .../data/jellyfin/model/JellyfinSong.kt | 2 + .../data/navidrome/NavidromeRepository.kt | 35 +- .../data/navidrome/model/NavidromeSong.kt | 2 + .../jellyfin/JellyfinResponseParser.kt | 4 +- .../navidrome/NavidromeResponseParser.kt | 1 + .../data/repository/MusicRepositoryImpl.kt | 43 +- .../pixelplayeross/data/worker/SyncWorker.kt | 15 + .../lostf1sh/pixelplayeross/di/AppModule.kt | 2 + .../values/strings_presentation_batch_g.xml | 2 +- 17 files changed, 2282 insertions(+), 27 deletions(-) create mode 100644 app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/2.json create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/data/database/Migrations.kt diff --git a/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/2.json b/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/2.json new file mode 100644 index 0000000..eafbe54 --- /dev/null +++ b/app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/2.json @@ -0,0 +1,2036 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "45d157a424596f5eac77b9f085a88bbd", + "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": "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, '45d157a424596f5eac77b9f085a88bbd')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/JellyfinSongEntity.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/JellyfinSongEntity.kt index 48dff68..bacbc1c 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/JellyfinSongEntity.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/JellyfinSongEntity.kt @@ -22,6 +22,7 @@ data class JellyfinSongEntity( val title: String, val artist: String, @ColumnInfo(name = "artist_id") val artistId: String?, + @ColumnInfo(name = "album_artist") val albumArtist: String? = null, val album: String, @ColumnInfo(name = "album_id") val albumId: String?, val duration: Long, @@ -66,6 +67,7 @@ fun JellyfinSong.toEntity(playlistId: String): JellyfinSongEntity { title = title, artist = artist, artistId = artistId, + albumArtist = albumArtist, album = album, albumId = albumId, duration = duration, 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 new file mode 100644 index 0000000..ea18809 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/Migrations.kt @@ -0,0 +1,54 @@ +package com.lostf1sh.pixelplayeross.data.database + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Returns true if [table] already has a column named [column]. + * + * Platform Auto Backup can restore a database that reports the right schema version but whose + * columns have drifted, so migrations guard each `ALTER` with this check instead of assuming a + * bare `ADD COLUMN` is safe (see the migration notes in CLAUDE.md / CONTRIBUTING). + */ +private fun SupportSQLiteDatabase.hasColumn(table: String, column: String): Boolean { + query("PRAGMA table_info(`$table`)").use { cursor -> + val nameIndex = cursor.getColumnIndex("name") + if (nameIndex < 0) return false + while (cursor.moveToNext()) { + if (cursor.getString(nameIndex) == column) return true + } + } + return false +} + +private fun SupportSQLiteDatabase.addColumnIfMissing(table: String, column: String, ddl: String) { + if (!hasColumn(table, column)) { + execSQL("ALTER TABLE `$table` ADD COLUMN $ddl") + } +} + +/** + * v1 -> v2: album-artist support for the unified library (issue #8). + * + * - `songs.album_artist_id`: id of the *effective* album artist (the song's `album_artist` when + * present, otherwise its primary track artist). Source-independent, so the "Group by Album + * Artist" Artists tab can collapse on it at runtime without forcing a re-sync. + * - `navidrome_songs.album_artist` / `jellyfin_songs.album_artist`: carry the server's + * album-artist tag through the cloud cache so the unified projection can populate the above. + * + * Additive and idempotent — each column is added only when missing. `album_artist_id` is then + * backfilled to the existing primary artist so the collapsed Artists tab is populated before the + * next library sync recomputes precise values (e.g. collapsing compilations under one artist). + */ +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.addColumnIfMissing("songs", "album_artist_id", "`album_artist_id` INTEGER NOT NULL DEFAULT 0") + db.addColumnIfMissing("navidrome_songs", "album_artist", "`album_artist` TEXT") + db.addColumnIfMissing("jellyfin_songs", "album_artist", "`album_artist` TEXT") + + // Seed from the existing primary artist so the collapsed tab is non-empty immediately. + db.execSQL("UPDATE songs SET album_artist_id = artist_id WHERE album_artist_id = 0") + + db.execSQL("CREATE INDEX IF NOT EXISTS `index_songs_album_artist_id` ON `songs` (`album_artist_id`)") + } +} 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 19f6d09..b2c52a3 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 @@ -47,6 +47,7 @@ private const val SONG_DETAIL_PROJECTION = """ songs.artist_name AS artist_name, songs.artist_id AS artist_id, songs.album_artist AS album_artist, + songs.album_artist_id AS album_artist_id, songs.album_name AS album_name, songs.album_id AS album_id, songs.content_uri_string AS content_uri_string, @@ -77,7 +78,7 @@ private const val SONG_DETAIL_PROJECTION = """ // Projection for list queries: excludes lyrics to prevent CursorWindow overflow (2MB limit) // when loading large libraries. Lyrics are only needed for the Now Playing screen (getSongById). private const val SONG_LIST_PROJECTION = """ - id, title, artist_name, artist_id, album_artist, album_name, album_id, + id, title, artist_name, artist_id, album_artist, album_artist_id, album_name, album_id, content_uri_string, album_art_uri_string, duration, genre, file_path, parent_directory_path, is_favorite, NULL AS lyrics, track_number, disc_number, year, date_added, mime_type, bitrate, sample_rate, artists_json, source_type, @@ -1324,6 +1325,46 @@ interface MusicDao { sortOrder: String ): PagingSource + /** + * Album-artist variant of [getArtistsPaginated], used when "Group by Album Artist" is on. + * Collapses the Artists tab onto each song's effective album artist (songs.album_artist_id) + * instead of every track-level artist, so featured/compilation artists stop cluttering the + * list. Counts are per-album-artist; sorting and source/directory filtering mirror the + * track-artist query so toggling the preference only swaps which query backs the tab. + */ + @Query(""" + SELECT artists.id, artists.name, artists.image_url, artists.custom_image_uri, + COUNT(DISTINCT songs.id) AS track_count + FROM songs + INNER JOIN artists ON artists.id = songs.album_artist_id + WHERE (:applyDirectoryFilter = 0 OR songs.id < 0 OR songs.parent_directory_path IN (:allowedParentDirs)) + AND ( + :filterMode = 0 + OR ( + :filterMode = 1 + AND songs.source_type = 0 + ) + OR ( + :filterMode = 2 + AND songs.source_type != 0 + ) + ) + GROUP BY artists.id + ORDER BY + CASE WHEN :sortOrder = 'artist_name_az' THEN artists.name END COLLATE NOCASE ASC, + CASE WHEN :sortOrder = 'artist_name_za' THEN artists.name END COLLATE NOCASE DESC, + CASE WHEN :sortOrder = 'artist_num_songs_desc' THEN track_count END DESC, + CASE WHEN :sortOrder = 'artist_num_songs_asc' THEN track_count END ASC, + artists.name COLLATE NOCASE ASC, + artists.id ASC + """) + fun getArtistsPaginatedByAlbumArtist( + allowedParentDirs: List, + applyDirectoryFilter: Boolean, + filterMode: Int, + sortOrder: String + ): PagingSource + @Query(""" SELECT artists.id, artists.name, artists.image_url, artists.custom_image_uri, COUNT(DISTINCT songs.id) AS track_count @@ -1498,15 +1539,18 @@ interface MusicDao { suspend fun deleteOrphanedAlbums() /** - * An artist is only orphaned when nothing references it: neither the cross-ref table - * nor songs.artist_id. The songs.artist_id check is load-bearing — the songs FK is - * declared ON DELETE SET NULL but the column is NOT NULL, so deleting an artist that - * a song still points at would abort with a constraint error instead of nulling. + * An artist is only orphaned when nothing references it: not the cross-ref table, not + * songs.artist_id, and not songs.album_artist_id. The songs.artist_id check is load-bearing + * — the songs FK is declared ON DELETE SET NULL but the column is NOT NULL, so deleting an + * artist a song still points at would abort with a constraint error instead of nulling. The + * album_artist_id check keeps album-artist-only rows (e.g. "Various Artists", which never + * appear as a track artist and so have no cross-ref) alive for the "Group by Album Artist" tab. */ @Query(""" DELETE FROM artists WHERE NOT EXISTS (SELECT 1 FROM song_artist_cross_ref WHERE song_artist_cross_ref.artist_id = artists.id) AND NOT EXISTS (SELECT 1 FROM songs WHERE songs.artist_id = artists.id) + AND NOT EXISTS (SELECT 1 FROM songs WHERE songs.album_artist_id = artists.id) """) suspend fun deleteOrphanedArtists() @@ -1681,6 +1725,19 @@ interface MusicDao { """) fun getSongsForArtist(artistId: Long): Flow> + /** + * Album-artist variant of [getSongsForArtist]: every song whose effective album artist is + * [artistId]. Used by the artist detail screen when "Group by Album Artist" is on so the + * detail view stays consistent with the collapsed Artists tab (tapping "Various Artists" + * shows the compilation tracks rather than an empty screen). + */ + @Query(""" + SELECT * FROM songs + WHERE album_artist_id = :artistId + ORDER BY title ASC + """) + fun getSongsForArtistByAlbumArtist(artistId: Long): Flow> + /** * Get all songs for a specific artist (one-shot). */ diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromeSongEntity.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromeSongEntity.kt index eee3cab..797c3d9 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromeSongEntity.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/NavidromeSongEntity.kt @@ -45,6 +45,7 @@ data class NavidromeSongEntity( val title: String, val artist: String, @ColumnInfo(name = "artist_id") val artistId: String?, + @ColumnInfo(name = "album_artist") val albumArtist: String? = null, val album: String, @ColumnInfo(name = "album_id") val albumId: String?, @ColumnInfo(name = "cover_art_id") val coverArtId: String?, @@ -98,6 +99,7 @@ fun NavidromeSong.toEntity(playlistId: String): NavidromeSongEntity { title = title, artist = artist, artistId = artistId, + albumArtist = albumArtist, album = album, albumId = albumId, coverArtId = coverArt, 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 59a125a..21871c6 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 @@ -24,7 +24,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase JellyfinSongEntity::class, JellyfinPlaylistEntity::class ], - version = 1, + version = 2, exportSchema = true ) abstract class PixelPlayerDatabase : RoomDatabase() { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/SongEntity.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/SongEntity.kt index 48f5957..9302fa5 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/SongEntity.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/SongEntity.kt @@ -41,6 +41,7 @@ object SourceType { Index(value = ["date_added"], unique = false), Index(value = ["duration"], unique = false), Index(value = ["source_type"], unique = false), + Index(value = ["album_artist_id"], unique = false), Index(value = ["parent_directory_path", "source_type", "album_id"], unique = false), Index(value = ["parent_directory_path", "source_type", "id"], unique = false) ], @@ -67,6 +68,10 @@ data class SongEntity( @ColumnInfo(name = "artist_name") val artistName: String, // Display string (combined or primary) @ColumnInfo(name = "artist_id") val artistId: Long, // Primary artist ID for backward compatibility @ColumnInfo(name = "album_artist") val albumArtist: String? = null, // Album artist from metadata + // Id of the *effective* album artist (album_artist when present, else the primary track + // artist). Source-independent and computed at sync time so the "Group by Album Artist" + // Artists tab can collapse on it at runtime without a re-sync. 0 = unresolved. + @ColumnInfo(name = "album_artist_id", defaultValue = "0") val albumArtistId: Long = 0L, @ColumnInfo(name = "album_name") val albumName: String, @ColumnInfo(name = "album_id") val albumId: Long, // index = true removed @ColumnInfo(name = "content_uri_string") val contentUriString: String, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/JellyfinRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/JellyfinRepository.kt index 89bbfb1..94a7e37 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/JellyfinRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/JellyfinRepository.kt @@ -22,6 +22,7 @@ import com.lostf1sh.pixelplayeross.data.model.Song import com.lostf1sh.pixelplayeross.data.network.jellyfin.JellyfinApiService import com.lostf1sh.pixelplayeross.data.network.jellyfin.JellyfinResponseParser import com.lostf1sh.pixelplayeross.data.preferences.PlaylistPreferencesRepository +import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository import com.lostf1sh.pixelplayeross.data.stream.BulkSyncResult import com.lostf1sh.pixelplayeross.data.stream.CloudMusicUtils import dagger.hilt.android.qualifiers.ApplicationContext @@ -45,6 +46,7 @@ class JellyfinRepository @Inject constructor( private val dao: JellyfinDao, private val musicDao: MusicDao, private val playlistPreferencesRepository: PlaylistPreferencesRepository, + private val userPreferencesRepository: UserPreferencesRepository, @ApplicationContext private val context: Context ) { private companion object { @@ -496,6 +498,10 @@ class JellyfinRepository @Inject constructor( return } + // 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 songs = ArrayList(jellyfinSongs.size) val artists = LinkedHashMap() val albums = LinkedHashMap() @@ -507,6 +513,25 @@ class JellyfinRepository @Inject constructor( val primaryArtistName = artistNames.firstOrNull() ?: "Unknown Artist" val primaryArtistId = toUnifiedArtistId(primaryArtistName) + // Effective album artist (Jellyfin AlbumArtist, else primary track artist), registered + // as a real artist row so songs.album_artist_id can join to it. + val effectiveAlbumArtistName = jellyfinSong.albumArtist + ?.trim() + ?.takeIf { it.isNotBlank() } + ?: primaryArtistName + val albumArtistId = toUnifiedArtistId(effectiveAlbumArtistName) + artists.putIfAbsent( + albumArtistId, + ArtistEntity( + id = albumArtistId, + name = effectiveAlbumArtistName, + trackCount = 0, + imageUrl = null + ) + ) + val albumDisplayArtistName = if (groupByAlbumArtist) effectiveAlbumArtistName else primaryArtistName + val albumDisplayArtistId = if (groupByAlbumArtist) albumArtistId else primaryArtistId + artistNames.forEachIndexed { index, artistName -> val artistId = toUnifiedArtistId(artistName) artists.putIfAbsent( @@ -534,12 +559,13 @@ class JellyfinRepository @Inject constructor( AlbumEntity( id = albumId, title = albumName, - artistName = primaryArtistName, - artistId = primaryArtistId, + artistName = albumDisplayArtistName, + artistId = albumDisplayArtistId, songCount = 0, dateAdded = jellyfinSong.dateAdded, year = jellyfinSong.year, - albumArtUriString = "jellyfin_cover://${jellyfinSong.jellyfinId}" + albumArtUriString = "jellyfin_cover://${jellyfinSong.jellyfinId}", + albumArtist = jellyfinSong.albumArtist?.takeIf { it.isNotBlank() } ) ) @@ -549,7 +575,8 @@ class JellyfinRepository @Inject constructor( title = jellyfinSong.title, artistName = jellyfinSong.artist.ifBlank { primaryArtistName }, artistId = primaryArtistId, - albumArtist = null, + albumArtist = jellyfinSong.albumArtist?.takeIf { it.isNotBlank() }, + albumArtistId = albumArtistId, albumName = albumName, albumId = albumId, contentUriString = "jellyfin://${jellyfinSong.jellyfinId}", diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/model/JellyfinSong.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/model/JellyfinSong.kt index be96837..5322a88 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/model/JellyfinSong.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/model/JellyfinSong.kt @@ -11,6 +11,7 @@ data class JellyfinSong( val title: String, val artist: String, val artistId: String? = null, + val albumArtist: String? = null, val album: String, val albumId: String? = null, val duration: Long, // milliseconds @@ -30,6 +31,7 @@ data class JellyfinSong( title = "", artist = "", artistId = null, + albumArtist = null, album = "", albumId = null, duration = 0L, 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 b311cc1..109e629 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 @@ -23,6 +23,7 @@ import com.lostf1sh.pixelplayeross.data.navidrome.model.NavidromeSong import com.lostf1sh.pixelplayeross.data.network.navidrome.NavidromeApiService import com.lostf1sh.pixelplayeross.data.network.navidrome.NavidromeResponseParser import com.lostf1sh.pixelplayeross.data.preferences.PlaylistPreferencesRepository +import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository import com.lostf1sh.pixelplayeross.data.stream.BulkSyncResult import com.lostf1sh.pixelplayeross.data.stream.CloudMusicUtils import dagger.hilt.android.qualifiers.ApplicationContext @@ -60,6 +61,7 @@ class NavidromeRepository @Inject constructor( private val dao: NavidromeDao, private val musicDao: MusicDao, private val playlistPreferencesRepository: PlaylistPreferencesRepository, + private val userPreferencesRepository: UserPreferencesRepository, @ApplicationContext private val context: Context ) { companion object { @@ -735,6 +737,10 @@ class NavidromeRepository @Inject constructor( return } + // 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 songs = ArrayList(navidromeSongs.size) val artists = LinkedHashMap() val albums = LinkedHashMap() @@ -746,6 +752,25 @@ class NavidromeRepository @Inject constructor( val primaryArtistName = artistNames.firstOrNull() ?: "Unknown Artist" val primaryArtistId = toUnifiedArtistId(primaryArtistName) + // Effective album artist (Subsonic albumArtist tag, else primary track artist), + // registered as a real artist row so songs.album_artist_id can join to it. + val effectiveAlbumArtistName = navidromeSong.albumArtist + ?.trim() + ?.takeIf { it.isNotBlank() } + ?: primaryArtistName + val albumArtistId = toUnifiedArtistId(effectiveAlbumArtistName) + artists.putIfAbsent( + albumArtistId, + ArtistEntity( + id = albumArtistId, + name = effectiveAlbumArtistName, + trackCount = 0, + imageUrl = null + ) + ) + val albumDisplayArtistName = if (groupByAlbumArtist) effectiveAlbumArtistName else primaryArtistName + val albumDisplayArtistId = if (groupByAlbumArtist) albumArtistId else primaryArtistId + artistNames.forEachIndexed { index, artistName -> val artistId = toUnifiedArtistId(artistName) artists.putIfAbsent( @@ -773,13 +798,14 @@ class NavidromeRepository @Inject constructor( AlbumEntity( id = albumId, title = albumName, - artistName = primaryArtistName, - artistId = primaryArtistId, + artistName = albumDisplayArtistName, + artistId = albumDisplayArtistId, songCount = 0, dateAdded = navidromeSong.dateAdded, year = navidromeSong.year, albumArtUriString = navidromeSong.coverArtId?.takeIf { it.isNotBlank() } - ?.let { "navidrome_cover://$it" } + ?.let { "navidrome_cover://$it" }, + albumArtist = navidromeSong.albumArtist?.takeIf { it.isNotBlank() } ) ) @@ -789,7 +815,8 @@ class NavidromeRepository @Inject constructor( title = navidromeSong.title, artistName = navidromeSong.artist.ifBlank { primaryArtistName }, artistId = primaryArtistId, - albumArtist = null, + albumArtist = navidromeSong.albumArtist?.takeIf { it.isNotBlank() }, + albumArtistId = albumArtistId, albumName = albumName, albumId = albumId, contentUriString = "navidrome://${navidromeSong.navidromeId}", diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/model/NavidromeSong.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/model/NavidromeSong.kt index 837af7a..cf5cf74 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/model/NavidromeSong.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/navidrome/model/NavidromeSong.kt @@ -35,6 +35,7 @@ data class NavidromeSong( val title: String, val artist: String, val artistId: String? = null, + val albumArtist: String? = null, val album: String, val albumId: String? = null, val coverArt: String? = null, @@ -56,6 +57,7 @@ data class NavidromeSong( title = "", artist = "", artistId = null, + albumArtist = null, album = "", albumId = null, coverArt = null, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParser.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParser.kt index 89d16a4..2c6ad21 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParser.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/jellyfin/JellyfinResponseParser.kt @@ -23,8 +23,9 @@ object JellyfinResponseParser { } } } + val albumArtist = json.optString("AlbumArtist").takeIf { it.isNotBlank() } val artist = artistNames.joinToString(", ").ifBlank { - json.optString("AlbumArtist", "Unknown Artist") + albumArtist ?: "Unknown Artist" } val artistIds = buildList { @@ -51,6 +52,7 @@ object JellyfinResponseParser { title = json.optString("Name", "Unknown Title"), artist = artist, artistId = artistIds.firstOrNull(), + albumArtist = albumArtist, album = json.optString("Album", "Unknown Album"), albumId = json.optString("AlbumId").takeIf { it.isNotBlank() }, duration = (json.optLong("RunTimeTicks", 0L) / 10_000), // Ticks to milliseconds diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeResponseParser.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeResponseParser.kt index abedd7b..567e6b4 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeResponseParser.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/network/navidrome/NavidromeResponseParser.kt @@ -88,6 +88,7 @@ object NavidromeResponseParser { title = json.optString("title", json.optString("name", "Unknown Title")), artist = json.optString("artist", "Unknown Artist"), artistId = json.optString("artistId").takeIf { it.isNotEmpty() }, + albumArtist = json.optString("albumArtist").takeIf { it.isNotEmpty() }, album = json.optString("album", "Unknown Album"), albumId = json.optString("albumId").takeIf { it.isNotEmpty() }, coverArt = json.optString("coverArt").takeIf { it.isNotEmpty() }, 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 d50364a..eb00b1d 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 @@ -208,10 +208,11 @@ class MusicRepositoryImpl @Inject constructor( ): Flow> { return combine( userPreferencesRepository.allowedDirectoriesFlow, - userPreferencesRepository.blockedDirectoriesFlow - ) { allowedDirs, blockedDirs -> - allowedDirs to blockedDirs - }.flatMapLatest { (allowedDirs, blockedDirs) -> + userPreferencesRepository.blockedDirectoriesFlow, + userPreferencesRepository.groupByAlbumArtistFlow + ) { allowedDirs, blockedDirs, groupByAlbumArtist -> + Triple(allowedDirs, blockedDirs, groupByAlbumArtist) + }.flatMapLatest { (allowedDirs, blockedDirs, groupByAlbumArtist) -> flow { val (allowedParentDirs, applyDirectoryFilter) = computeAllowedDirs(allowedDirs, blockedDirs) @@ -219,12 +220,23 @@ class MusicRepositoryImpl @Inject constructor( Pager( config = defaultLibraryPagingConfig, pagingSourceFactory = { - musicDao.getArtistsPaginated( - allowedParentDirs = allowedParentDirs, - applyDirectoryFilter = applyDirectoryFilter, - filterMode = storageFilter.toFilterMode(), - sortOrder = sortOption.storageKey - ) + // "Group by Album Artist" collapses the tab onto each song's effective + // album artist; otherwise it lists every track-level artist as before. + if (groupByAlbumArtist) { + musicDao.getArtistsPaginatedByAlbumArtist( + allowedParentDirs = allowedParentDirs, + applyDirectoryFilter = applyDirectoryFilter, + filterMode = storageFilter.toFilterMode(), + sortOrder = sortOption.storageKey + ) + } else { + musicDao.getArtistsPaginated( + allowedParentDirs = allowedParentDirs, + applyDirectoryFilter = applyDirectoryFilter, + filterMode = storageFilter.toFilterMode(), + sortOrder = sortOption.storageKey + ) + } } ).flow ) @@ -454,8 +466,17 @@ class MusicRepositoryImpl @Inject constructor( .flowOn(Dispatchers.IO) } + @OptIn(ExperimentalCoroutinesApi::class) override fun getSongsForArtist(artistId: Long): Flow> { - return musicDao.getSongsForArtist(artistId).map { entities -> + return userPreferencesRepository.groupByAlbumArtistFlow.flatMapLatest { groupByAlbumArtist -> + // Mirror the Artists tab: in album-artist mode the detail shows the songs whose + // effective album artist is this id, keeping tab and detail consistent. + if (groupByAlbumArtist) { + musicDao.getSongsForArtistByAlbumArtist(artistId) + } else { + musicDao.getSongsForArtist(artistId) + } + }.map { entities -> entities.map { it.toSong() } }.flowOn(Dispatchers.IO) } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt index 0117a5d..6c4fae9 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt @@ -519,6 +519,20 @@ constructor( ?: songArtistNameTrimmed val primaryArtistId = artistNameToId[primaryArtistName] ?: song.artistId + // Effective album artist = the album_artist tag when usable, else the primary track + // artist. Registered as a first-class artist row (even for compilation-only names like + // "Various Artists" that never appear as a track artist) so the "Group by Album Artist" + // tab can collapse onto songs.album_artist_id. Preference-independent: the toggle only + // chooses whether the tab/detail query reads this column, so no re-sync is needed. + val effectiveAlbumArtistName = song.albumArtist + ?.trim() + ?.takeIf { it.isNotEmpty() && !it.equals("", ignoreCase = true) } + ?: primaryArtistName + if (effectiveAlbumArtistName.isNotEmpty() && !artistNameToId.containsKey(effectiveAlbumArtistName)) { + artistNameToId[effectiveAlbumArtistName] = nextArtistId.getAndIncrement() + } + val albumArtistId = artistNameToId[effectiveAlbumArtistName] ?: primaryArtistId + allArtistsForSong.forEachIndexed { index, artistName -> val normalizedName = artistName.trim() val artistId = artistNameToId[normalizedName] @@ -550,6 +564,7 @@ constructor( song.copy( artistId = primaryArtistId, artistName = rawArtistName, // Preserving full artist string for display + albumArtistId = albumArtistId, albumId = finalAlbumId, artistsJson = serializeArtistRefs(artistRefsForJson) ) 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 c2de2f7..796f1d2 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt @@ -22,6 +22,7 @@ import com.lostf1sh.pixelplayeross.data.database.EngagementDao 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.MusicDao import com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase import com.lostf1sh.pixelplayeross.data.database.SearchHistoryDao @@ -123,6 +124,7 @@ object AppModule { "pixelplayer_database" ) .addCallback(PixelPlayerDatabase.createRuntimeArtifactsCallback()) + .addMigrations(MIGRATION_1_2) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) // P2-4: Only allow destructive recreation in debug builds. diff --git a/app/src/main/res/values/strings_presentation_batch_g.xml b/app/src/main/res/values/strings_presentation_batch_g.xml index 6bbc797..4e2fcd6 100644 --- a/app/src/main/res/values/strings_presentation_batch_g.xml +++ b/app/src/main/res/values/strings_presentation_batch_g.xml @@ -175,7 +175,7 @@ Detect feat., ft., with in song titles Library Organization Group by Album Artist - Show collaboration albums under main artist + Group the Artists tab and albums by album artist, so featured and compilation artists don\'t clutter the list About Multi-Artist Parsing PixelPlayerOSS splits artist tags using character delimiters (/, ;, &) and word delimiters (feat., ft., vs., x). Word delimiters are matched case-insensitively.