diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/21.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/21.json new file mode 100644 index 00000000..edeb4196 --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/21.json @@ -0,0 +1,1439 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "7ca3af61c6b27df074cc831cbc76e120", + "entities": [ + { + "tableName": "sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "subsonic_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `useTokenAuth` INTEGER NOT NULL, PRIMARY KEY(`sourceId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "useTokenAuth", + "columnName": "useTokenAuth", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId" + ] + }, + "indices": [ + { + "name": "index_subsonic_sources_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_subsonic_sources_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL COLLATE NOCASE, `albumCount` INTEGER NOT NULL, `starred` INTEGER, `coverArt` TEXT, `playCount` INTEGER NOT NULL DEFAULT 0, `played` INTEGER, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumCount", + "columnName": "albumCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER" + }, + { + "fieldPath": "coverArt", + "columnName": "coverArt", + "affinity": "TEXT" + }, + { + "fieldPath": "playCount", + "columnName": "playCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "played", + "columnName": "played", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_artists_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_artists_name", + "unique": false, + "columnNames": [ + "sourceId", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_name` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_artists_albumCount", + "unique": false, + "columnNames": [ + "sourceId", + "albumCount", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `${TABLE_NAME}` (`sourceId` ASC, `albumCount` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_artists_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_artists_frequent", + "unique": false, + "columnNames": [ + "sourceId", + "playCount", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_frequent` ON `${TABLE_NAME}` (`sourceId` ASC, `playCount` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_artists_recent", + "unique": false, + "columnNames": [ + "sourceId", + "played", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_recent` ON `${TABLE_NAME}` (`sourceId` ASC, `played` DESC, `name` ASC, `id` ASC)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "artist_search", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`name`, tokenize=`trigram`, content=`artists`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "trigram", + "tokenizerArgs": [], + "contentTable": "artists", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE BEFORE UPDATE ON `artists` BEGIN DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_DELETE BEFORE DELETE ON `artists` BEGIN DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE AFTER UPDATE ON `artists` BEGIN INSERT INTO `artist_search`(`rowid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_INSERT AFTER INSERT ON `artists` BEGIN INSERT INTO `artist_search`(`rowid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END" + ] + }, + { + "tableName": "albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `artistId` TEXT, `name` TEXT NOT NULL COLLATE NOCASE, `albumArtist` TEXT COLLATE NOCASE, `created` INTEGER NOT NULL, `coverArt` TEXT, `genre` TEXT, `year` INTEGER, `starred` INTEGER, `songCount` INTEGER NOT NULL, `playCount` INTEGER NOT NULL DEFAULT 0, `played` INTEGER, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT" + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverArt", + "columnName": "coverArt", + "affinity": "TEXT" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER" + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "playCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "played", + "columnName": "played", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_albums_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_albums_sourceId_artistId", + "unique": false, + "columnNames": [ + "sourceId", + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" + }, + { + "name": "index_albums_name", + "unique": false, + "columnNames": [ + "sourceId", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_name` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_albums_artist", + "unique": false, + "columnNames": [ + "sourceId", + "albumArtist", + "year", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `${TABLE_NAME}` (`sourceId`, `albumArtist`, `year`, `name`, `id`)" + }, + { + "name": "index_albums_year", + "unique": false, + "columnNames": [ + "sourceId", + "year", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_year` ON `${TABLE_NAME}` (`sourceId` ASC, `year` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_albums_added", + "unique": false, + "columnNames": [ + "sourceId", + "created", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_added` ON `${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_albums_starred", + "unique": false, + "columnNames": [ + "sourceId", + "starred", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_albums_frequent", + "unique": false, + "columnNames": [ + "sourceId", + "playCount", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_frequent` ON `${TABLE_NAME}` (`sourceId` ASC, `playCount` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_albums_recent", + "unique": false, + "columnNames": [ + "sourceId", + "played", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_recent` ON `${TABLE_NAME}` (`sourceId` ASC, `played` DESC, `name` ASC, `id` ASC)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "album_search", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`name`, `albumArtist`, tokenize=`trigram`, content=`albums`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "trigram", + "tokenizerArgs": [], + "contentTable": "albums", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_BEFORE_UPDATE BEFORE UPDATE ON `albums` BEGIN DELETE FROM `album_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_BEFORE_DELETE BEFORE DELETE ON `albums` BEGIN DELETE FROM `album_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_AFTER_UPDATE AFTER UPDATE ON `albums` BEGIN INSERT INTO `album_search`(`rowid`, `name`, `albumArtist`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`albumArtist`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_album_search_AFTER_INSERT AFTER INSERT ON `albums` BEGIN INSERT INTO `album_search`(`rowid`, `name`, `albumArtist`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`albumArtist`); END" + ] + }, + { + "tableName": "discs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `disc` INTEGER NOT NULL, `title` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `albumId`, `disc`), FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disc", + "columnName": "disc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "albumId", + "disc" + ] + }, + "indices": [ + { + "name": "index_discs_sourceId_albumId", + "unique": false, + "columnNames": [ + "sourceId", + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discs_sourceId_albumId` ON `${TABLE_NAME}` (`sourceId`, `albumId`)" + } + ], + "foreignKeys": [ + { + "table": "albums", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId", + "albumId" + ], + "referencedColumns": [ + "sourceId", + "id" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL COLLATE NOCASE, `comment` TEXT, `coverArt` TEXT, `songCount` INTEGER NOT NULL, `created` INTEGER NOT NULL, `changed` INTEGER NOT NULL DEFAULT 0, `duration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT" + }, + { + "fieldPath": "coverArt", + "columnName": "coverArt", + "affinity": "TEXT" + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "changed", + "columnName": "changed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_playlists_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_playlists_name", + "unique": false, + "columnNames": [ + "sourceId", + "name", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`sourceId`, `name`, `id`)" + }, + { + "name": "index_playlists_added", + "unique": false, + "columnNames": [ + "sourceId", + "created", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_added` ON `${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `name` ASC, `id` ASC)" + }, + { + "name": "index_playlists_updated", + "unique": false, + "columnNames": [ + "sourceId", + "changed", + "name", + "id" + ], + "orders": [ + "ASC", + "DESC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_updated` ON `${TABLE_NAME}` (`sourceId` ASC, `changed` DESC, `name` ASC, `id` ASC)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_search", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`name`, tokenize=`trigram`, content=`playlists`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowid" + ] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "trigram", + "tokenizerArgs": [], + "contentTable": "playlists", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_BEFORE_UPDATE BEFORE UPDATE ON `playlists` BEGIN DELETE FROM `playlist_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_BEFORE_DELETE BEFORE DELETE ON `playlists` BEGIN DELETE FROM `playlist_search` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_AFTER_UPDATE AFTER UPDATE ON `playlists` BEGIN INSERT INTO `playlist_search`(`rowid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_playlist_search_AFTER_INSERT AFTER INSERT ON `playlists` BEGIN INSERT INTO `playlist_search`(`rowid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END" + ] + }, + { + "tableName": "playlist_songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`sourceId`, `playlistId`, `position`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "playlistId", + "position" + ] + }, + "indices": [ + { + "name": "index_playlist_songs_sourceId_playlistId", + "unique": false, + "columnNames": [ + "sourceId", + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_playlistId` ON `${TABLE_NAME}` (`sourceId`, `playlistId`)" + }, + { + "name": "index_playlist_songs_sourceId_songId", + "unique": false, + "columnNames": [ + "sourceId", + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_songId` ON `${TABLE_NAME}` (`sourceId`, `songId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `albumId` TEXT, `artistId` TEXT, `title` TEXT NOT NULL, `album` TEXT, `artist` TEXT, `duration` INTEGER, `track` INTEGER, `disc` INTEGER, `starred` INTEGER, `genre` TEXT, `created` INTEGER NOT NULL DEFAULT 0, `playCount` INTEGER NOT NULL DEFAULT 0, `played` INTEGER, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT" + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT" + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER" + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER" + }, + { + "fieldPath": "disc", + "columnName": "disc", + "affinity": "INTEGER" + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "playCount", + "columnName": "playCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "played", + "columnName": "played", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_songs_sourceId_artistId", + "unique": false, + "columnNames": [ + "sourceId", + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" + }, + { + "name": "index_songs_sourceId_albumId_order", + "unique": false, + "columnNames": [ + "sourceId", + "albumId", + "disc", + "track", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_albumId_order` ON `${TABLE_NAME}` (`sourceId`, `albumId`, `disc`, `track`, `id`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "queue_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `position` INTEGER NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `refId` TEXT NOT NULL, `rangeStart` INTEGER, `rangeEnd` INTEGER, FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refId", + "columnName": "refId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rangeStart", + "columnName": "rangeStart", + "affinity": "INTEGER" + }, + { + "fieldPath": "rangeEnd", + "columnName": "rangeEnd", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_queue_entries_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_queue_entries_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_position` ON `${TABLE_NAME}` (`position`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "up_next_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `position` INTEGER NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `refId` TEXT NOT NULL, `rangeStart` INTEGER, `rangeEnd` INTEGER, FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refId", + "columnName": "refId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rangeStart", + "columnName": "rangeStart", + "affinity": "INTEGER" + }, + { + "fieldPath": "rangeEnd", + "columnName": "rangeEnd", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_up_next_entries_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_up_next_entries_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_up_next_entries_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_up_next_entries_position` ON `${TABLE_NAME}` (`position`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playback_cursor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `queuePosition` INTEGER NOT NULL, `shuffleEnabled` INTEGER NOT NULL DEFAULT 0, `repeatMode` INTEGER NOT NULL DEFAULT 0, `shuffleSeed` INTEGER NOT NULL DEFAULT 0, `shuffleSize` INTEGER NOT NULL DEFAULT 0, `positionMs` INTEGER NOT NULL DEFAULT 0, `upNextAnchor` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "queuePosition", + "columnName": "queuePosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shuffleEnabled", + "columnName": "shuffleEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "repeatMode", + "columnName": "repeatMode", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "shuffleSeed", + "columnName": "shuffleSeed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "shuffleSize", + "columnName": "shuffleSize", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "positionMs", + "columnName": "positionMs", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "upNextAnchor", + "columnName": "upNextAnchor", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "artwork_seeds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `primary` INTEGER NOT NULL, `secondary` INTEGER, `nameBusy` INTEGER, `sourceId` INTEGER NOT NULL, `storedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`cacheKey`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primary", + "columnName": "primary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "secondary", + "columnName": "secondary", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameBusy", + "columnName": "nameBusy", + "affinity": "INTEGER" + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "storedAt", + "columnName": "storedAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "cacheKey" + ] + }, + "indices": [ + { + "name": "index_artwork_seeds_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artwork_seeds_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "song_downloads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `status` TEXT NOT NULL, `engineId` INTEGER, `bytes` INTEGER NOT NULL DEFAULT 0, `total` INTEGER NOT NULL DEFAULT 0, `error` TEXT, PRIMARY KEY(`sourceId`, `songId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "engineId", + "columnName": "engineId", + "affinity": "INTEGER" + }, + { + "fieldPath": "bytes", + "columnName": "bytes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "total", + "columnName": "total", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "songId" + ] + }, + "indices": [ + { + "name": "index_song_downloads_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_downloads_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_song_downloads_engineId", + "unique": false, + "columnNames": [ + "engineId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_downloads_engineId` ON `${TABLE_NAME}` (`engineId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "songs", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId", + "songId" + ], + "referencedColumns": [ + "sourceId", + "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, '7ca3af61c6b27df074cc831cbc76e120')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index e5da63a9..db21b055 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -4,6 +4,7 @@ import androidx.paging.PagingSource import androidx.room3.Dao import androidx.room3.DaoReturnTypeConverters import androidx.room3.Query +import androidx.room3.Transaction import androidx.room3.Upsert import androidx.room3.paging.PagingSourceDaoReturnTypeConverter import com.subtracks.data.model.Album @@ -56,6 +57,11 @@ internal const val ALBUM_ORDER_BY_ADDED_REVERSED = "created ASC, name COLLATE NO internal const val ALBUM_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id" internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC" +internal const val ALBUM_ORDER_BY_FREQUENT = "playCount DESC, name COLLATE NOCASE, id" +internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, name COLLATE NOCASE DESC, id DESC" +internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, name COLLATE NOCASE, id" +internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC" + internal const val ARTISTS_FILTER = "FROM artists WHERE sourceId = :sourceId " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + @@ -78,7 +84,7 @@ internal const val ARTISTS_SELECT = "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + "WHERE songs.sourceId = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed' " + ") ELSE artists.albumCount END AS albumCount, " + - "artists.starred, artists.coverArt " + "artists.starred, artists.coverArt, artists.playCount, artists.played " internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id" internal const val ARTIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC" @@ -86,6 +92,10 @@ internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "artists.albumCount DESC, name internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "artists.albumCount ASC, name COLLATE NOCASE DESC, id DESC" internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id" internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC" +internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, name COLLATE NOCASE, id" +internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, name COLLATE NOCASE DESC, id DESC" +internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, name COLLATE NOCASE, id" +internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC" internal const val PLAYLISTS_FILTER = "FROM playlists WHERE sourceId = :sourceId " + @@ -133,6 +143,50 @@ interface LibraryDao { @Upsert suspend fun upsertPlaylistSongs(items: List) + @Query( + "UPDATE albums SET " + + "playCount = COALESCE((SELECT SUM(s.playCount) FROM songs s " + + "WHERE s.sourceId = albums.sourceId AND s.albumId = albums.id), 0), " + + "played = (SELECT MAX(s.played) FROM songs s " + + "WHERE s.sourceId = albums.sourceId AND s.albumId = albums.id) " + + "WHERE sourceId = :sourceId AND (" + + "playCount IS NOT COALESCE((SELECT SUM(s.playCount) FROM songs s " + + "WHERE s.sourceId = albums.sourceId AND s.albumId = albums.id), 0) " + + "OR played IS NOT (SELECT MAX(s.played) FROM songs s " + + "WHERE s.sourceId = albums.sourceId AND s.albumId = albums.id))", + ) + suspend fun recomputeAlbumPlayData(sourceId: Long) + + // INDEXED BY is required: the planner otherwise answers MAX(played) from index_albums_recent + // (sourceId, played), which cannot apply the artistId equality. + @Query( + "UPDATE artists SET " + + "playCount = COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " + + "WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0), " + + "played = (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " + + "WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id) " + + "WHERE sourceId = :sourceId AND (" + + "playCount IS NOT COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " + + "WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0) " + + "OR played IS NOT (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " + + "WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id))", + ) + suspend fun recomputeArtistPlayData(sourceId: Long) + + @Transaction + suspend fun recomputePlayData(sourceId: Long) { + recomputeAlbumPlayData(sourceId) + recomputeArtistPlayData(sourceId) + } + + // Albums, not songs: these flows re-run on every write to their table, and songs are written + // one batch at a time during a sync. + @Query("SELECT EXISTS(SELECT 1 FROM albums WHERE sourceId = :sourceId AND playCount > 0)") + fun hasAlbumPlayCount(sourceId: Long): Flow + + @Query("SELECT EXISTS(SELECT 1 FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL)") + fun hasAlbumPlayed(sourceId: Long): Flow + @Query("UPDATE artists SET starred = :starred WHERE sourceId = :sourceId AND id = :id") suspend fun setArtistStar( sourceId: Long, @@ -327,6 +381,38 @@ interface LibraryDao { downloadedFilter: Int = 0, ): PagingSource + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_FREQUENT") + fun albumsByFrequent( + sourceId: Long, + starredFilter: Int, + search: String, + downloadedFilter: Int = 0, + ): PagingSource + + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_FREQUENT_REVERSED") + fun albumsByFrequentReversed( + sourceId: Long, + starredFilter: Int, + search: String, + downloadedFilter: Int = 0, + ): PagingSource + + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_RECENT") + fun albumsByRecent( + sourceId: Long, + starredFilter: Int, + search: String, + downloadedFilter: Int = 0, + ): PagingSource + + @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_RECENT_REVERSED") + fun albumsByRecentReversed( + sourceId: Long, + starredFilter: Int, + search: String, + downloadedFilter: Int = 0, + ): PagingSource + @Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME") fun artistsByName( sourceId: Long, @@ -375,6 +461,38 @@ interface LibraryDao { downloadedFilter: Int = 0, ): PagingSource + @Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_FREQUENT") + fun artistsByFrequent( + sourceId: Long, + starredFilter: Int, + search: String, + downloadedFilter: Int = 0, + ): PagingSource + + @Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_FREQUENT_REVERSED") + fun artistsByFrequentReversed( + sourceId: Long, + starredFilter: Int, + search: String, + downloadedFilter: Int = 0, + ): PagingSource + + @Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_RECENT") + fun artistsByRecent( + sourceId: Long, + starredFilter: Int, + search: String, + downloadedFilter: Int = 0, + ): PagingSource + + @Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_RECENT_REVERSED") + fun artistsByRecentReversed( + sourceId: Long, + starredFilter: Int, + search: String, + downloadedFilter: Int = 0, + ): PagingSource + @Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME") fun playlistsByName( sourceId: Long, diff --git a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt index 43107429..d92fc703 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -280,6 +280,34 @@ val MIGRATION_19_20 = } } +val MIGRATION_20_21 = + object : Migration(20, 21) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE `songs` ADD COLUMN `playCount` INTEGER NOT NULL DEFAULT 0") + connection.execSQL("ALTER TABLE `songs` ADD COLUMN `played` INTEGER") + connection.execSQL("ALTER TABLE `albums` ADD COLUMN `playCount` INTEGER NOT NULL DEFAULT 0") + connection.execSQL("ALTER TABLE `albums` ADD COLUMN `played` INTEGER") + connection.execSQL("ALTER TABLE `artists` ADD COLUMN `playCount` INTEGER NOT NULL DEFAULT 0") + connection.execSQL("ALTER TABLE `artists` ADD COLUMN `played` INTEGER") + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_albums_frequent` ON `albums` " + + "(`sourceId` ASC, `playCount` DESC, `name` ASC, `id` ASC)", + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_albums_recent` ON `albums` " + + "(`sourceId` ASC, `played` DESC, `name` ASC, `id` ASC)", + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_artists_frequent` ON `artists` " + + "(`sourceId` ASC, `playCount` DESC, `name` ASC, `id` ASC)", + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_artists_recent` ON `artists` " + + "(`sourceId` ASC, `played` DESC, `name` ASC, `id` ASC)", + ) + } + } + // Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities. private suspend fun createSearchIndex( connection: SQLiteConnection, @@ -333,4 +361,5 @@ val MIGRATIONS: Array = MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, + MIGRATION_20_21, ) diff --git a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt index 7b74be0d..a1aaa1e1 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -41,7 +41,7 @@ import com.subtracks.data.model.UpNextEntry ArtworkSeed::class, SongDownload::class, ], - version = 20, + version = 21, exportSchema = true, ) @ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class) diff --git a/app/src/main/kotlin/com/subtracks/data/model/Models.kt b/app/src/main/kotlin/com/subtracks/data/model/Models.kt index 9aa31276..4a549242 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -63,6 +63,16 @@ data class SubsonicSource( value = ["sourceId", "starred", "name", "id"], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], ), + Index( + name = "index_artists_frequent", + value = ["sourceId", "playCount", "name", "id"], + orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], + ), + Index( + name = "index_artists_recent", + value = ["sourceId", "played", "name", "id"], + orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], + ), ], ) data class Artist( @@ -72,6 +82,8 @@ data class Artist( val albumCount: Long, val starred: Long?, val coverArt: String? = null, + @ColumnInfo(defaultValue = "0") val playCount: Long = 0, + val played: Long? = null, ) @Entity( @@ -105,6 +117,16 @@ data class Artist( value = ["sourceId", "starred", "name", "id"], orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], ), + Index( + name = "index_albums_frequent", + value = ["sourceId", "playCount", "name", "id"], + orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], + ), + Index( + name = "index_albums_recent", + value = ["sourceId", "played", "name", "id"], + orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC], + ), ], ) data class Album( @@ -120,6 +142,8 @@ data class Album( val starred: Long?, val songCount: Long, @Ignore val discTitles: Map = emptyMap(), + @ColumnInfo(defaultValue = "0") val playCount: Long = 0, + val played: Long? = null, ) @Entity( @@ -233,6 +257,8 @@ data class Song( val starred: Long?, val genre: String?, @ColumnInfo(defaultValue = "0") val created: Long = 0, + @ColumnInfo(defaultValue = "0") val playCount: Long = 0, + val played: Long? = null, ) sealed interface SongItem { diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index 5667106a..5893064d 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -14,9 +14,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -enum class AlbumSort { Name, Artist, Year, Added, Starred } +enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent } -enum class ArtistSort { Name, AlbumCount, Starred } +enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent } enum class PlaylistSort { Name, Added, Updated } diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index 47d6e982..c1fcb6ed 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -37,6 +37,10 @@ class LibraryRepository( ) { val activeSourceId: Flow = sourceRepository.activeSourceId() + fun hasAlbumPlayCount(sourceId: Long): Flow = db.libraryDao().hasAlbumPlayCount(sourceId) + + fun hasAlbumPlayed(sourceId: Long): Flow = db.libraryDao().hasAlbumPlayed(sourceId) + fun albums( sourceId: Long, sort: AlbumSort, @@ -88,6 +92,22 @@ class LibraryRepository( dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter) } } + + AlbumSort.Frequent -> { + if (descending) { + dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter) + } + } + + AlbumSort.Recent -> { + if (descending) { + dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter) + } + } } } @@ -126,6 +146,22 @@ class LibraryRepository( dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter) } } + + ArtistSort.Frequent -> { + if (descending) { + dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter) + } + } + + ArtistSort.Recent -> { + if (descending) { + dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter) + } else { + dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter) + } + } } } diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt index c8eed8d7..61014924 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt @@ -67,6 +67,8 @@ object SubsonicXml { starred = attrs.dateAttr("starred"), genre = attrs.attr("genre").ifEmpty { null }, created = attrs.dateAttr("created") ?: 0L, + playCount = attrs.longAttr("playCount") ?: 0L, + played = attrs.dateAttr("played"), ) internal fun playlist( diff --git a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt index ed04086d..949630fd 100644 --- a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt @@ -23,6 +23,7 @@ class SyncService( syncArtists() syncAlbums() syncSongs() + aggregatePlayData() syncPlaylists() syncPlaylistSongs() } @@ -69,6 +70,10 @@ class SyncService( pruneStaleIds(seen, { library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteSongs(source.id, it) }) } + private suspend fun aggregatePlayData() { + db.libraryDao().recomputePlayData(source.id) + } + private suspend fun syncPlaylists() { val library = db.libraryDao() val seen = HashedIds() @@ -249,6 +254,8 @@ private val songColumns = "starred", "genre", "created", + "playCount", + "played", ) private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration") @@ -264,7 +271,23 @@ private fun Album.values() = listOf(sourceId, id, artistId, name, albumArt private fun Disc.values() = listOf(sourceId, albumId, disc, title) private fun Song.values() = - listOf(sourceId, id, albumId, artistId, title, album, artist, duration, track, disc, starred, genre, created) + listOf( + sourceId, + id, + albumId, + artistId, + title, + album, + artist, + duration, + track, + disc, + starred, + genre, + created, + playCount, + played, + ) private fun Playlist.values() = listOf(sourceId, id, name, comment, coverArt, songCount, created, changed, duration) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index d784f5f2..e28d21d2 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -154,24 +154,31 @@ fun LibraryTab.listTab(): LibraryListTab = LibraryListTab.valueOf(name) internal fun libraryTabFor(name: String?): LibraryTab = LibraryTab.entries.firstOrNull { it.name == name } ?: LibraryTab.Albums -fun sortOptionsFor(tab: LibraryTab): List = +fun sortOptionsFor( + tab: LibraryTab, + play: PlaySortAvailability = PlaySortAvailability(frequent = false, recent = false), +): List = when (tab) { LibraryTab.Albums -> { - listOf( - SortOption(AlbumSort.Name.name, "Name"), - SortOption(AlbumSort.Artist.name, "Artist"), - SortOption(AlbumSort.Year.name, "Year", descendingByDefault = true), - SortOption(AlbumSort.Added.name, "Added", descendingByDefault = true), - SortOption(AlbumSort.Starred.name, "Starred", descendingByDefault = true), - ) + buildList { + add(SortOption(AlbumSort.Name.name, "Name")) + add(SortOption(AlbumSort.Artist.name, "Artist")) + add(SortOption(AlbumSort.Year.name, "Year", descendingByDefault = true)) + add(SortOption(AlbumSort.Added.name, "Added", descendingByDefault = true)) + add(SortOption(AlbumSort.Starred.name, "Starred", descendingByDefault = true)) + if (play.frequent) add(SortOption(AlbumSort.Frequent.name, "Frequently played", descendingByDefault = true)) + if (play.recent) add(SortOption(AlbumSort.Recent.name, "Recently played", descendingByDefault = true)) + } } LibraryTab.Artists -> { - listOf( - SortOption(ArtistSort.Name.name, "Name"), - SortOption(ArtistSort.AlbumCount.name, "Albums", descendingByDefault = true), - SortOption(ArtistSort.Starred.name, "Starred", descendingByDefault = true), - ) + buildList { + add(SortOption(ArtistSort.Name.name, "Name")) + add(SortOption(ArtistSort.AlbumCount.name, "Albums", descendingByDefault = true)) + add(SortOption(ArtistSort.Starred.name, "Starred", descendingByDefault = true)) + if (play.frequent) add(SortOption(ArtistSort.Frequent.name, "Frequently played", descendingByDefault = true)) + if (play.recent) add(SortOption(ArtistSort.Recent.name, "Recently played", descendingByDefault = true)) + } } LibraryTab.Playlists -> { @@ -213,8 +220,14 @@ fun LibraryRoute( } val listQuery by viewModel.listQuery(listTab).collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle() + val playSort by viewModel.playSortAvailability.collectAsStateWithLifecycle() val displayQuery = if (offline) listQuery.copy(downloaded = true) else listQuery val search by viewModel.search(listTab).collectAsStateWithLifecycle() + LaunchedEffect(listTab, playSort, listQuery.sort) { + if (sortOptionsFor(selectedTab, playSort).none { it.value == listQuery.sort }) { + viewModel.setListQuery(listTab, listQuery.copy(sort = listTab.defaultSort)) + } + } val resetKeys = LibraryTab.entries.associateWith { tab -> val query by viewModel.listQuery(tab.listTab()).collectAsStateWithLifecycle() @@ -266,7 +279,7 @@ fun LibraryRoute( resetKeys = resetKeys, offline = offline, onExitOffline = { viewModel.setOffline(false) }, - sortOptions = sortOptionsFor(selectedTab), + sortOptions = sortOptionsFor(selectedTab, playSort), starredSupported = listTab.supportsStarred, onSortChange = { viewModel.setListQuery(listTab, listQuery.copy(sort = it)) }, onToggleSortDirection = { viewModel.setListQuery(listTab, listQuery.copy(descending = !listQuery.descending)) }, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt index 5e94bb3f..7e723bce 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt @@ -122,6 +122,19 @@ class LibraryViewModel( private val activeSourceId = libraryRepository.activeSourceId.filterNotNull() + val playSortAvailability: StateFlow = + activeSourceId + .flatMapLatest { sourceId -> + combine( + libraryRepository.hasAlbumPlayCount(sourceId), + libraryRepository.hasAlbumPlayed(sourceId), + ) { frequent, recent -> PlaySortAvailability(frequent, recent) } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + PlaySortAvailability(frequent = false, recent = false), + ) + private val albumDownloads: StateFlow> = activeSourceId .flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) } @@ -201,3 +214,8 @@ internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { internal fun ListQuery.artistSort(): ArtistSort = ArtistSort.entries.firstOrNull { it.name == sort } ?: ArtistSort.Name internal fun ListQuery.playlistSort(): PlaylistSort = PlaylistSort.entries.firstOrNull { it.name == sort } ?: PlaylistSort.Name + +data class PlaySortAvailability( + val frequent: Boolean, + val recent: Boolean, +) diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt index 253105f2..e1cf5382 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryDaoTest.kt @@ -156,6 +156,63 @@ class LibraryDaoTest { ) } + @Test + fun playRanksAreAggregatedFromSongsIntoAlbumsAndArtists() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertArtists( + listOf( + artist(sourceId, "ar-1", "Few", albumCount = 2), + artist(sourceId, "ar-2", "Many", albumCount = 1), + ), + ) + dao.upsertAlbums( + listOf( + album(sourceId, "al-1", "One", year = null, starred = null), + album(sourceId, "al-2", "Two", year = null, starred = null).copy(artistId = "ar-2"), + album(sourceId, "al-3", "Three", year = null, starred = null), + ), + ) + dao.upsertSongs( + listOf( + song(sourceId, "s1", "One", starred = null).copy(albumId = "al-1", playCount = 3, played = 100), + song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2", playCount = 4, played = 200), + song(sourceId, "s3", "Three", starred = null).copy(albumId = "al-2", playCount = 5, played = 400), + ), + ) + + dao.recomputeAlbumPlayData(sourceId) + dao.recomputeArtistPlayData(sourceId) + + assertEquals(3L, dao.albumOnce(sourceId, "al-1")!!.playCount) + assertEquals(100L, dao.albumOnce(sourceId, "al-1")!!.played) + assertEquals(9L, dao.albumOnce(sourceId, "al-2")!!.playCount) + assertEquals(400L, dao.albumOnce(sourceId, "al-2")!!.played) + assertEquals(0L, dao.albumOnce(sourceId, "al-3")!!.playCount) + assertEquals(null, dao.albumOnce(sourceId, "al-3")!!.played) + assertEquals(3L, dao.artistOnce(sourceId, "ar-1")!!.playCount) + assertEquals(9L, dao.artistOnce(sourceId, "ar-2")!!.playCount) + assertEquals(400L, dao.artistOnce(sourceId, "ar-2")!!.played) + + assertEquals( + listOf("Two", "One", "Three"), + dao.albumsByFrequent(sourceId, 0, "").page().map { it.name }, + ) + assertEquals( + listOf("Two", "One", "Three"), + dao.albumsByRecent(sourceId, 0, "").page().map { it.name }, + ) + assertEquals( + listOf("Many", "Few"), + dao.artistsByFrequent(sourceId, 0, "").page().map { it.name }, + ) + assertEquals( + listOf("Many", "Few"), + dao.artistsByRecent(sourceId, 0, "").page().map { it.name }, + ) + } + @Test fun reversedAlbumSortsReverseEveryTiebreaker() = runTest { @@ -163,10 +220,14 @@ class LibraryDaoTest { val dao = db.libraryDao() dao.upsertAlbums( listOf( - album(sourceId, "a1", "Same", year = 2000, starred = null, created = 100).copy(albumArtist = "Zed"), - album(sourceId, "a2", "Same", year = 2000, starred = null, created = 100).copy(albumArtist = "Amy"), - album(sourceId, "a3", "Same", year = 1990, starred = null, created = 200).copy(albumArtist = "Amy"), - album(sourceId, "a4", "Other", year = 2000, starred = null, created = 100).copy(albumArtist = "Amy"), + album(sourceId, "a1", "Same", year = 2000, starred = null, created = 100) + .copy(albumArtist = "Zed", playCount = 1, played = 100), + album(sourceId, "a2", "Same", year = 2000, starred = null, created = 100) + .copy(albumArtist = "Amy", playCount = 2, played = 200), + album(sourceId, "a3", "Same", year = 1990, starred = null, created = 200) + .copy(albumArtist = "Amy", playCount = 1, played = 300), + album(sourceId, "a4", "Other", year = 2000, starred = null, created = 100) + .copy(albumArtist = "Amy", playCount = 3, played = 100), ), ) @@ -176,6 +237,8 @@ class LibraryDaoTest { dao.albumsByArtist(sourceId, 0, "") to dao.albumsByArtistReversed(sourceId, 0, ""), dao.albumsByYear(sourceId, 0, "") to dao.albumsByYearReversed(sourceId, 0, ""), dao.albumsByRecentlyAdded(sourceId, 0, "") to dao.albumsByRecentlyAddedReversed(sourceId, 0, ""), + dao.albumsByFrequent(sourceId, 0, "") to dao.albumsByFrequentReversed(sourceId, 0, ""), + dao.albumsByRecent(sourceId, 0, "") to dao.albumsByRecentReversed(sourceId, 0, ""), ) for ((base, reversed) in pairs) { val ids = base.page().map { it.id } @@ -190,9 +253,9 @@ class LibraryDaoTest { val dao = db.libraryDao() dao.upsertArtists( listOf( - artist(sourceId, "r1", "Same", albumCount = 2), - artist(sourceId, "r2", "Other", albumCount = 1), - artist(sourceId, "r3", "Same", albumCount = 1), + artist(sourceId, "r1", "Same", albumCount = 2).copy(playCount = 1, played = 100), + artist(sourceId, "r2", "Other", albumCount = 1).copy(playCount = 2, played = 200), + artist(sourceId, "r3", "Same", albumCount = 1).copy(playCount = 1, played = 300), ), ) dao.upsertPlaylists( @@ -209,6 +272,10 @@ class LibraryDaoTest { dao.artistsByNameReversed(sourceId, 0, "").page().map { it.id }, dao.artistsByAlbumCount(sourceId, 0, "").page().map { it.id } to dao.artistsByAlbumCountReversed(sourceId, 0, "").page().map { it.id }, + dao.artistsByFrequent(sourceId, 0, "").page().map { it.id } to + dao.artistsByFrequentReversed(sourceId, 0, "").page().map { it.id }, + dao.artistsByRecent(sourceId, 0, "").page().map { it.id } to + dao.artistsByRecentReversed(sourceId, 0, "").page().map { it.id }, ) val playlistPairs = listOf( @@ -241,6 +308,10 @@ class LibraryDaoTest { ALBUM_ORDER_BY_ADDED_REVERSED to "index_albums_added", ALBUM_ORDER_BY_STARRED to "index_albums_starred", ALBUM_ORDER_BY_STARRED_REVERSED to "index_albums_starred", + ALBUM_ORDER_BY_FREQUENT to "index_albums_frequent", + ALBUM_ORDER_BY_FREQUENT_REVERSED to "index_albums_frequent", + ALBUM_ORDER_BY_RECENT to "index_albums_recent", + ALBUM_ORDER_BY_RECENT_REVERSED to "index_albums_recent", ) for ((order, index) in orders) { val plan = plan("SELECT * $ALBUMS_FILTER ORDER BY $order LIMIT 20 OFFSET 0") @@ -260,6 +331,10 @@ class LibraryDaoTest { ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED to "index_artists_albumCount", ARTIST_ORDER_BY_STARRED to "index_artists_starred", ARTIST_ORDER_BY_STARRED_REVERSED to "index_artists_starred", + ARTIST_ORDER_BY_FREQUENT to "index_artists_frequent", + ARTIST_ORDER_BY_FREQUENT_REVERSED to "index_artists_frequent", + ARTIST_ORDER_BY_RECENT to "index_artists_recent", + ARTIST_ORDER_BY_RECENT_REVERSED to "index_artists_recent", ) for ((order, index) in orders) { val plan = plan("SELECT * $ARTISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0") diff --git a/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt b/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt index 05b8597e..6778daa0 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt @@ -62,9 +62,13 @@ class MigrationsTest { assertEquals(0, indexColumn(migrated, "index_albums_year", "name").descending) assertEquals(1, indexColumn(migrated, "index_albums_added", "created").descending) assertEquals(1, indexColumn(migrated, "index_albums_starred", "starred").descending) + assertEquals(1, indexColumn(migrated, "index_albums_frequent", "playCount").descending) + assertEquals(1, indexColumn(migrated, "index_albums_recent", "played").descending) assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "name").collation) assertEquals(1, indexColumn(migrated, "index_artists_albumCount", "albumCount").descending) assertEquals(1, indexColumn(migrated, "index_artists_starred", "starred").descending) + assertEquals(1, indexColumn(migrated, "index_artists_frequent", "playCount").descending) + assertEquals(1, indexColumn(migrated, "index_artists_recent", "played").descending) assertEquals("NOCASE", indexColumn(migrated, "index_playlists_name", "name").collation) assertEquals(1, indexColumn(migrated, "index_playlists_added", "created").descending) assertEquals(1, indexColumn(migrated, "index_playlists_updated", "changed").descending) diff --git a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt index 7e021654..11c45519 100644 --- a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt @@ -134,6 +134,26 @@ class SubsonicXmlTest { assertEquals("sg1", entries[0].songId) } + @Test + fun mapsSongPlayData() { + val xml = + """ + + + + + + + """.trimIndent() + + val songs = parse(xml, tag = "song", create = { SubsonicXml.song(1, it) }) + + assertEquals(12L, songs[0].playCount) + assertEquals(1679869666L, songs[0].played) + assertEquals(0L, songs[1].playCount) + assertEquals(null, songs[1].played) + } + @Test fun statusOnlyReadRejectsAnHtmlLoginPage() { val failure = diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt index 4e89b896..c56b904c 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt @@ -611,6 +611,59 @@ class SyncServiceTest { assertEquals(survivors, storedDiscKeys().map { it.albumId }.toSet()) } + @Test + fun albumAndArtistPlayRanksAreDerivedFromTheSongRows() = + runTest { + insertSource() + val source = + FakeMusicSource( + artists = listOf(artist("a1")), + albums = listOf(album("al1")), + songs = + listOf( + song("s1").copy(playCount = 5, played = 100), + song("s2").copy(playCount = 2, played = 300), + ), + ) + + SyncService(db, source).sync() + + assertEquals(7L, db.libraryDao().albumOnce(1, "al1")!!.playCount) + assertEquals(300L, db.libraryDao().albumOnce(1, "al1")!!.played) + assertEquals(7L, db.libraryDao().artistOnce(1, "a1")!!.playCount) + assertEquals(300L, db.libraryDao().artistOnce(1, "a1")!!.played) + + source.songs = listOf(song("s1").copy(playCount = 0, played = null)) + + SyncService(db, source).sync() + + assertEquals(0L, db.libraryDao().albumOnce(1, "al1")!!.playCount) + assertEquals(null, db.libraryDao().albumOnce(1, "al1")!!.played) + assertEquals(0L, db.libraryDao().artistOnce(1, "a1")!!.playCount) + assertEquals(null, db.libraryDao().artistOnce(1, "a1")!!.played) + } + + @Test + fun anUnchangedSyncWithPlayDataDoesNotRewriteTheRanks() = + runTest { + insertSource() + val source = + FakeMusicSource( + artists = listOf(artist("a1")), + albums = listOf(album("al1")), + songs = listOf(song("s1").copy(playCount = 5, played = 100)), + ) + + SyncService(db, source).sync() + val before = totalChanges() + + SyncService(db, source).sync() + + assertEquals(before, totalChanges()) + assertEquals(5L, db.libraryDao().albumOnce(1, "al1")!!.playCount) + assertEquals(5L, db.libraryDao().artistOnce(1, "a1")!!.playCount) + } + @Test fun anIdenticalSyncDoesNotRewriteRows() = runTest { diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt index aa84eaf0..81d62e30 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryOptionsTest.kt @@ -22,6 +22,7 @@ import com.subtracks.data.prefs.StarredFilter import com.subtracks.ui.library.ALBUM_COVER_TAG import com.subtracks.ui.library.LibraryScreen import com.subtracks.ui.library.LibraryTab +import com.subtracks.ui.library.PlaySortAvailability import com.subtracks.ui.library.sortOptionsFor import com.subtracks.ui.theme.SubtracksTheme import kotlinx.coroutines.flow.flowOf @@ -96,6 +97,26 @@ class LibraryOptionsTest { assertEquals(StarredFilter.Any, query.starred) } + @Test + fun playSortsAreHiddenUntilTheServerReturnsPlayData() { + assertEquals( + listOf("Name", "Artist", "Year", "Added", "Starred"), + sortOptionsFor(LibraryTab.Albums).map { it.label }, + ) + assertEquals( + listOf("Name", "Artist", "Year", "Added", "Starred", "Frequently played", "Recently played"), + sortOptionsFor(LibraryTab.Albums, PlaySortAvailability(frequent = true, recent = true)).map { it.label }, + ) + assertEquals( + listOf("Name", "Albums", "Starred"), + sortOptionsFor(LibraryTab.Artists).map { it.label }, + ) + assertEquals( + listOf("Name", "Albums", "Starred", "Frequently played", "Recently played"), + sortOptionsFor(LibraryTab.Artists, PlaySortAvailability(frequent = true, recent = true)).map { it.label }, + ) + } + private fun albums() = (1..3).map { index -> Album( diff --git a/docs/architecture.md b/docs/architecture.md index 03952233..6d2cfcda 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -102,13 +102,13 @@ The media notification carries a star button for the playing track. `PlaybackSer `sources` and `subsonic_sources` hold the configured servers. Library tables are keyed by `(sourceId, id)` and cascade from `sources`: `artists`, `albums`, `playlists`, `playlist_songs`, `songs`. -The library is mirrored wholesale at sync and must work offline, so metadata is never fetched on demand: everything used to browse, filter or sort a local list has to be present locally. The only on-demand network is media bytes (streams and downloads) and images. A sort whose key is server-derived — say play frequency — therefore has to be synced into a local column, not requested lazily when the sort is selected. +The library is mirrored wholesale at sync and must work offline, so metadata is never fetched on demand: everything used to browse, filter or sort a local list has to be present locally. The only on-demand network is media bytes (streams and downloads) and images. A sort whose key is server-derived — say play frequency — therefore has to be synced into a local column, not requested lazily when the sort is selected. Play frequency and last-played are read from the synced song rows and aggregated into the album and artist rows by the same sync (`SUM(playCount)`, `MAX(played)`): servers disagree on what they put on album objects (Airsonic returns no album play count at all), while the song fields are consistent, and the aggregate only writes rows whose value changed. The artist roll-up attributes plays to the album's artist (the same `artistId` the artist list is built from), so a track artist credited only on a compilation can appear unplayed; that is deliberate, since it keeps the rank consistent with the album rows rather than the server's per-track artists. UI and playback preferences live in DataStore (`user_prefs`), not in SQLite: they are not relational, nothing joins against them, and keeping them out of Room avoids a schema migration per preference. Room is for the library only. Every schema version step ships a hand-written `Migration` (`data/db/Migrations.kt`), and there is no destructive fallback, so an upgrade never silently wipes the local mirror. The exported schemas under `app/schemas/` are the source of truth for the migration SQL, and `MigrationsTest` runs each step against a real SQLite connection and asserts the resulting tables, columns and indices. -Library screens page over Room with `PagingSource` queries, ordering by a sort chosen in preferences (name, artist, year, recently added). Every album, artist and playlist order has an index on its own table, and the sortable text columns carry `COLLATE NOCASE`, so a page seeks the index in order instead of re-sorting the table for each `LIMIT`/`OFFSET`. Starred orders pin unstarred rows last in both directions, matching the list grouping, so a starred "reversed" order is not a literal mirror of the base order. Paging is local, but it keeps memory bounded on large libraries and lets a huge playlist render incrementally. Room 3 requires `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)` on the DAO for this. +Library screens page over Room with `PagingSource` queries, ordering by a sort chosen in preferences (name, artist, year, recently added, frequently/recently played). Every album, artist and playlist order has an index on its own table, and the sortable text columns carry `COLLATE NOCASE`, so a page seeks the index in order instead of re-sorting the table for each `LIMIT`/`OFFSET`. Starred orders pin unstarred rows last in both directions, matching the list grouping, so a starred "reversed" order is not a literal mirror of the base order. Paging is local, but it keeps memory bounded on large libraries and lets a huge playlist render incrementally. Room 3 requires `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)` on the DAO for this. Song rows show the album's cover art, fetched by joining `songs` to `albums` into a `SongListItem` rather than storing cover art on the song: some servers return a distinct cover ID per track, and keeping those would fragment the image cache for no visual gain. Artists show their image cropped to a circle.