diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/17.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/17.json new file mode 100644 index 00000000..dca45862 --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/17.json @@ -0,0 +1,1224 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "7e6b1ad7a77670dfbc69c55fb11639a7", + "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, 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" + } + ], + "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)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "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, 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 + } + ], + "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)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "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_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, 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" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_songs_sourceId_albumId", + "unique": false, + "columnNames": [ + "sourceId", + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_albumId` ON `${TABLE_NAME}` (`sourceId`, `albumId`)" + }, + { + "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`)" + } + ], + "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, `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": "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": "shuffle_order", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sequence` INTEGER NOT NULL, `flatPosition` INTEGER NOT NULL, PRIMARY KEY(`sequence`))", + "fields": [ + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flatPosition", + "columnName": "flatPosition", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sequence" + ] + } + }, + { + "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, `queuedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `songId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`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" + }, + { + "fieldPath": "queuedAt", + "columnName": "queuedAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "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" + ] + } + ] + } + ], + "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, '7e6b1ad7a77670dfbc69c55fb11639a7')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt b/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt new file mode 100644 index 00000000..66da7913 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt @@ -0,0 +1,34 @@ +package com.subtracks.data.db + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert +import com.subtracks.data.model.SongDownload +import kotlinx.coroutines.flow.Flow + +@Dao +interface DownloadDao { + @Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId") + fun downloads(sourceId: Long): Flow> + + @Query("SELECT * FROM song_downloads") + suspend fun all(): List + + @Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId AND songId = :songId") + suspend fun find( + sourceId: Long, + songId: String, + ): SongDownload? + + @Upsert + suspend fun upsert(download: SongDownload) + + @Query("DELETE FROM song_downloads WHERE sourceId = :sourceId AND songId = :songId") + suspend fun delete( + sourceId: Long, + songId: String, + ) + + @Query("DELETE FROM song_downloads WHERE sourceId = :sourceId") + suspend fun deleteSource(sourceId: Long) +} 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 d571c47c..83916dd3 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -369,6 +369,12 @@ interface LibraryDao { songId: String, ): Flow + @Query("SELECT * FROM songs WHERE sourceId = :sourceId AND id = :songId") + suspend fun songOnce( + sourceId: Long, + songId: String, + ): Song? + @Query("SELECT * FROM albums WHERE sourceId = :sourceId AND artistId = :artistId ORDER BY year DESC, name COLLATE NOCASE, id") fun albumsForArtist( 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 fdab336e..6f0ae4f6 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -235,6 +235,21 @@ private val PLAYLISTS_V14_INDICES = "CREATE INDEX IF NOT EXISTS `index_playlists_updated` ON `\${TABLE_NAME}` (`sourceId` ASC, `changed` DESC, `name` ASC, `id` ASC)", ) +val MIGRATION_16_17 = + object : Migration(16, 17) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `song_downloads` (`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, `queuedAt` INTEGER NOT NULL DEFAULT 0, " + + "PRIMARY KEY(`sourceId`, `songId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )", + ) + connection.execSQL("CREATE INDEX IF NOT EXISTS `index_song_downloads_sourceId` ON `song_downloads` (`sourceId`)") + connection.execSQL("CREATE INDEX IF NOT EXISTS `index_song_downloads_engineId` ON `song_downloads` (`engineId`)") + } + } + val MIGRATIONS: Array = arrayOf( MIGRATION_1_2, @@ -252,4 +267,5 @@ val MIGRATIONS: Array = MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, + MIGRATION_16_17, ) 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 24f71f52..01e18ed9 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -7,6 +7,7 @@ import com.subtracks.data.model.Album import com.subtracks.data.model.Artist import com.subtracks.data.model.ArtworkSeed import com.subtracks.data.model.Disc +import com.subtracks.data.model.DownloadStatusConverter import com.subtracks.data.model.PlaybackCursor import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong @@ -14,6 +15,7 @@ import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueKindConverter import com.subtracks.data.model.ShuffleOrder import com.subtracks.data.model.Song +import com.subtracks.data.model.SongDownload import com.subtracks.data.model.Source import com.subtracks.data.model.SubsonicSource import com.subtracks.data.model.UpNextEntry @@ -33,11 +35,12 @@ import com.subtracks.data.model.UpNextEntry PlaybackCursor::class, ShuffleOrder::class, ArtworkSeed::class, + SongDownload::class, ], - version = 16, + version = 17, exportSchema = true, ) -@ColumnTypeConverters(QueueKindConverter::class) +@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class) abstract class SubtracksDatabase : RoomDatabase() { abstract fun libraryDao(): LibraryDao @@ -46,4 +49,6 @@ abstract class SubtracksDatabase : RoomDatabase() { abstract fun queueDao(): QueueDao abstract fun artworkSeedDao(): ArtworkSeedDao + + abstract fun downloadDao(): DownloadDao } diff --git a/app/src/main/kotlin/com/subtracks/data/download/DownloadEngine.kt b/app/src/main/kotlin/com/subtracks/data/download/DownloadEngine.kt new file mode 100644 index 00000000..0ab65275 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/download/DownloadEngine.kt @@ -0,0 +1,26 @@ +package com.subtracks.data.download + +data class EngineRequest( + val uri: String, + val path: String, + val title: String, + val mimeType: String? = null, + val allowMetered: Boolean = false, +) + +enum class EngineStatus { Pending, Running, Completed, Failed } + +data class EngineDownload( + val status: EngineStatus, + val bytes: Long = 0, + val total: Long = 0, + val error: String? = null, +) + +interface DownloadEngine { + fun enqueue(request: EngineRequest): Long + + fun download(id: Long): EngineDownload? + + fun cancel(id: Long) +} diff --git a/app/src/main/kotlin/com/subtracks/data/download/SystemDownloadEngine.kt b/app/src/main/kotlin/com/subtracks/data/download/SystemDownloadEngine.kt new file mode 100644 index 00000000..809090b3 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/download/SystemDownloadEngine.kt @@ -0,0 +1,66 @@ +package com.subtracks.data.download + +import android.app.DownloadManager +import android.content.Context +import android.net.Uri +import android.os.Environment + +class SystemDownloadEngine( + private val context: Context, +) : DownloadEngine { + override fun enqueue(request: EngineRequest): Long { + val builder = + DownloadManager + .Request(Uri.parse(request.uri)) + .setTitle(request.title) + .setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) + .setAllowedOverMetered(request.allowMetered) + request.mimeType?.let(builder::setMimeType) + return manager().enqueue(builder) + } + + override fun download(id: Long): EngineDownload? { + val query = DownloadManager.Query().setFilterById(id) + manager().query(query).use { cursor -> + if (!cursor.moveToFirst()) return null + val status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) + val bytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) + val total = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) + val reason = + cursor + .getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON)) + .takeIf { status == DownloadManager.STATUS_FAILED } + val mapped = + when (status) { + DownloadManager.STATUS_SUCCESSFUL -> EngineStatus.Completed + DownloadManager.STATUS_FAILED -> EngineStatus.Failed + DownloadManager.STATUS_RUNNING -> EngineStatus.Running + else -> EngineStatus.Pending + } + return EngineDownload( + status = mapped, + bytes = bytes.coerceAtLeast(0), + total = total.coerceAtLeast(0), + error = reason?.let(::failureMessage), + ) + } + } + + override fun cancel(id: Long) { + manager().remove(id) + } + + private fun manager(): DownloadManager = context.getSystemService(DownloadManager::class.java) + + private fun failureMessage(reason: Int): String = + when (reason) { + DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Not enough space to download" + DownloadManager.ERROR_DEVICE_NOT_FOUND -> "Storage is unavailable" + DownloadManager.ERROR_FILE_ERROR, DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "Could not write the download" + DownloadManager.ERROR_HTTP_DATA_ERROR, DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "The server refused to send this track" + DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "The server redirected too many times" + DownloadManager.ERROR_CANNOT_RESUME -> "The download could not be resumed" + else -> "Download failed" + } +} diff --git a/app/src/main/kotlin/com/subtracks/data/model/Download.kt b/app/src/main/kotlin/com/subtracks/data/model/Download.kt new file mode 100644 index 00000000..799586c9 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/model/Download.kt @@ -0,0 +1,44 @@ +package com.subtracks.data.model + +import androidx.room3.ColumnInfo +import androidx.room3.ColumnTypeConverter +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index + +enum class DownloadStatus { Queued, Running, Completed, Failed } + +@Entity( + tableName = "song_downloads", + primaryKeys = ["sourceId", "songId"], + foreignKeys = [ + ForeignKey( + entity = Source::class, + parentColumns = ["id"], + childColumns = ["sourceId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("sourceId"), Index("engineId")], +) +data class SongDownload( + val sourceId: Long, + val songId: String, + val status: DownloadStatus, + val engineId: Long? = null, + @ColumnInfo(defaultValue = "0") val bytes: Long = 0, + @ColumnInfo(defaultValue = "0") val total: Long = 0, + val error: String? = null, + @ColumnInfo(defaultValue = "0") val queuedAt: Long = 0, +) { + val progress: Float? + get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) } +} + +class DownloadStatusConverter { + @ColumnTypeConverter + fun fromDownloadStatus(status: DownloadStatus): String = status.name + + @ColumnTypeConverter + fun toDownloadStatus(value: String): DownloadStatus = DownloadStatus.entries.firstOrNull { it.name == value } ?: DownloadStatus.Failed +} diff --git a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt new file mode 100644 index 00000000..e8208013 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt @@ -0,0 +1,261 @@ +package com.subtracks.data.repo + +import android.net.Uri +import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.download.DownloadEngine +import com.subtracks.data.download.EngineDownload +import com.subtracks.data.download.EngineRequest +import com.subtracks.data.download.EngineStatus +import com.subtracks.data.model.DownloadStatus +import com.subtracks.data.model.SongDownload +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File + +class DownloadRepository( + private val db: SubtracksDatabase, + private val sourceRepository: SourceRepository, + private val engine: DownloadEngine, + private val downloadsDir: File, + private val allowMetered: Boolean = false, + private val showMessage: (String) -> Unit = {}, + dispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + private val mutex = Mutex() + private val loaded = CompletableDeferred() + private var pollJob: Job? = null + + @Volatile + private var started = false + + private val completedFiles = MutableStateFlow>(emptyMap()) + + fun start() { + if (started) return + started = true + downloadsDir.mkdirs() + File(downloadsDir, NO_MEDIA).createNewFile() + scope.launch { + sourceRepository.activeSourceId().distinctUntilChanged().collect { reconcile() } + } + scope.launch { + states().collect { rows -> + completedFiles.value = + rows + .filterValues { it.status == DownloadStatus.Completed } + .mapValues { (_, row) -> file(row.sourceId, row.songId) } + .filterValues { it.exists() } + loaded.complete(Unit) + } + } + } + + fun close() { + scope.cancel() + } + + fun states(): Flow> = + sourceRepository + .activeSourceId() + .flatMapLatest { sourceId -> if (sourceId == null) flowOf(emptyList()) else db.downloadDao().downloads(sourceId) } + .map { rows -> rows.associateBy { it.songId } } + + suspend fun awaitLoaded() { + if (started) loaded.await() + } + + fun localUri(songId: String): String? = completedFiles.value[songId]?.takeIf { it.exists() }?.let { Uri.fromFile(it).toString() } + + suspend fun download( + sourceId: Long, + songId: String, + ) = mutex.withLock { + if (sourceRepository.activeSourceIdOnce() != sourceId) { + showMessage("Can't download from a source that isn't active") + return@withLock + } + val existing = db.downloadDao().find(sourceId, songId) + if (existing != null && existing.status != DownloadStatus.Failed) return@withLock + existing?.engineId?.let(engine::cancel) + val url = sourceRepository.downloadUri(songId) + if (url == null) { + showMessage("Can't download: the server address is unavailable") + return@withLock + } + enqueue(sourceId, songId, url) + } + + suspend fun remove( + sourceId: Long, + songId: String, + ) = mutex.withLock { + val row = db.downloadDao().find(sourceId, songId) ?: return@withLock + row.engineId?.let(engine::cancel) + file(sourceId, songId).delete() + db.downloadDao().delete(sourceId, songId) + } + + suspend fun removeSource(sourceId: Long) = + mutex.withLock { + db + .downloadDao() + .all() + .filter { it.sourceId == sourceId } + .forEach { row -> row.engineId?.let(engine::cancel) } + db.downloadDao().deleteSource(sourceId) + dir(sourceId).deleteRecursively() + } + + suspend fun reconcile() = + mutex.withLock { + db.downloadDao().all().forEach { row -> + val state = row.engineId?.let(engine::download) + if (state == null) recover(row) else apply(row, state) + } + sweep(db.downloadDao().all()) + ensurePolling() + } + + private suspend fun enqueue( + sourceId: Long, + songId: String, + url: String, + ) { + dir(sourceId).mkdirs() + file(sourceId, songId).delete() + val title = db.libraryDao().songOnce(sourceId, songId)?.title ?: songId + val engineId = + engine.enqueue( + EngineRequest(uri = url, path = enginePath(sourceId, songId), title = title, allowMetered = allowMetered), + ) + db.downloadDao().upsert( + SongDownload( + sourceId = sourceId, + songId = songId, + status = DownloadStatus.Queued, + engineId = engineId, + queuedAt = System.currentTimeMillis(), + ), + ) + ensurePolling() + } + + private suspend fun recover(row: SongDownload) { + when { + row.status == DownloadStatus.Completed && file(row.sourceId, row.songId).exists() -> { + Unit + } + + row.status == DownloadStatus.Completed -> { + markFailed(row, "The downloaded file is missing") + } + + sourceRepository.activeSourceIdOnce() != row.sourceId -> { + Unit + } + + else -> { + val url = sourceRepository.downloadUri(row.songId) + when (url) { + null -> markFailed(row, "Can't download: the server address is unavailable") + else -> enqueue(row.sourceId, row.songId, url) + } + } + } + } + + private suspend fun apply( + row: SongDownload, + state: EngineDownload, + ) { + val status = + when (state.status) { + EngineStatus.Pending -> DownloadStatus.Queued + EngineStatus.Running -> DownloadStatus.Running + EngineStatus.Completed -> if (file(row.sourceId, row.songId).exists()) DownloadStatus.Completed else DownloadStatus.Failed + EngineStatus.Failed -> DownloadStatus.Failed + } + db.downloadDao().upsert( + row.copy( + status = status, + bytes = state.bytes, + total = state.total, + error = state.error ?: if (status == DownloadStatus.Failed) "Download failed" else null, + ), + ) + } + + private suspend fun markFailed( + row: SongDownload, + message: String, + ) { + row.engineId?.let(engine::cancel) + db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = message)) + } + + private fun ensurePolling() { + if (pollJob?.isActive == true) return + pollJob = + scope.launch { + while (true) { + delay(POLL_MS) + mutex.withLock { + val active = db.downloadDao().all().filter { it.status.isActive } + if (active.isEmpty()) return@launch + active.forEach { row -> + val state = row.engineId?.let(engine::download) + if (state == null) recover(row) else apply(row, state) + } + } + } + } + } + + private fun sweep(rows: List) { + val known = rows.map { it.sourceId to it.songId }.toHashSet() + // Artwork (a later block) lives in subdirectories and is swept separately. + downloadsDir.listFiles().orEmpty().filter { it.isDirectory }.forEach { sourceDir -> + val sourceId = sourceDir.name.toLongOrNull() ?: return@forEach + sourceDir.listFiles().orEmpty().filter { it.isFile }.forEach { file -> + if ((sourceId to file.name) !in known) file.delete() + } + } + } + + private fun dir(sourceId: Long): File = downloadsDir.resolve(sourceId.toString()) + + private fun file( + sourceId: Long, + songId: String, + ): File = dir(sourceId).resolve(songId) + + private fun enginePath( + sourceId: Long, + songId: String, + ): String = "$ENGINE_DIR/$sourceId/$songId" + + private val DownloadStatus.isActive: Boolean + get() = this == DownloadStatus.Queued || this == DownloadStatus.Running + + private companion object { + const val ENGINE_DIR = "downloads" + const val NO_MEDIA = ".nomedia" + const val POLL_MS = 1_000L + } +} diff --git a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt index 2a7d14c8..0483f9f0 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt @@ -94,6 +94,8 @@ class SourceRepository( return uri + streamLengthSuffix(length) } + fun downloadUri(songId: String): String? = active?.downloadUri(songId)?.toString() + suspend fun addSource( name: String, address: String, diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index bc96c5ea..2395c4ba 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -1,14 +1,17 @@ package com.subtracks.di import android.content.Context +import android.os.Environment import android.os.Handler import android.os.Looper import android.widget.Toast import com.subtracks.data.db.createAndroidDatabase +import com.subtracks.data.download.SystemDownloadEngine import com.subtracks.data.net.networkMode import com.subtracks.data.prefs.createUserPreferences import com.subtracks.data.repo.ArtworkSeedRepository import com.subtracks.data.repo.ArtworkSeedStore +import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.SourceRepository @@ -27,6 +30,7 @@ import com.subtracks.ui.settings.SettingsViewModel import okhttp3.OkHttpClient import org.koin.core.module.dsl.viewModel import org.koin.dsl.module +import java.io.File fun appModule( context: Context, @@ -44,14 +48,24 @@ fun appModule( single { ArtworkSeedRepository(get()) } single { SyncManager(get(), get(), get()) } single { QueueRepository(get()) } - single { MediaSessionConnection(context.applicationContext, get()) } - single { PlaybackController(get(), get(), get(), showMessage = toast) } + single { SystemDownloadEngine(context.applicationContext) } + single { + DownloadRepository( + db = get(), + sourceRepository = get(), + engine = get(), + downloadsDir = File(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC) ?: context.filesDir, "downloads"), + showMessage = toast, + ).also { it.start() } + } + single { MediaSessionConnection(context.applicationContext, get(), get()) } + single { PlaybackController(get(), get(), get(), get(), showMessage = toast) } viewModel { RootViewModel(get()) } viewModel { LibraryViewModel(get(), get(), get(), get(), get()) } - viewModel { SettingsViewModel(get(), get()) } + viewModel { SettingsViewModel(get(), get(), get()) } viewModel { AddSourceViewModel(get(), get()) } - viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) } + viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) } viewModel { params -> ArtistDetailViewModel(get(), get(), get(), params.get()) } - viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), params.get()) } + viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), get(), params.get()) } viewModel { QueueViewModel(get(), get()) } } diff --git a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt index 35d80062..649175ba 100644 --- a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt @@ -9,11 +9,13 @@ import androidx.media3.common.MediaMetadata import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.session.MediaController +import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.SourceRepository class Media3PlayerHandle( private val controller: MediaController, private val sourceRepository: SourceRepository, + private val downloads: DownloadRepository, ) : PlayerHandle { override val itemCount: Int get() = controller.mediaItemCount @@ -127,37 +129,13 @@ class Media3PlayerHandle( } private fun toMediaItem(item: QueueItem): MediaItem { - val metadata = - MediaMetadata - .Builder() - .setTitle(item.title) - .setArtist(item.artist) - .setAlbumTitle(item.album) - .setArtworkUri(item.coverArtId?.takeIf { it.isNotEmpty() }?.let(CoverArtArtwork::uri)) - .setDurationMs(item.durationMs) - .setIsBrowsable(false) - .setIsPlayable(true) - .setExtras( - Bundle().apply { - putString(EXTRA_COVER_ART_ID, item.coverArtId) - item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) } - }, - ).build() val quality = sourceRepository.quality.value - val builder = - MediaItem - .Builder() - .setMediaId(item.id) - .setUri(sourceRepository.streamUri(item.id, item.durationMs, quality)) - .setMediaMetadata(metadata) - if (quality.transcodes) { - item.durationMs?.takeIf { it > 0 }?.let { duration -> - builder.setClippingConfiguration( - ClippingConfiguration.Builder().setEndPositionMs(duration).build(), - ) - } - } - return builder.build() + return queueMediaItem( + item = item, + local = downloads.localUri(item.id), + stream = sourceRepository.streamUri(item.id, item.durationMs, quality), + transcode = quality.transcodes, + ) } private fun toQueueItem(mediaItem: MediaItem): QueueItem = @@ -178,11 +156,50 @@ class Media3PlayerHandle( private companion object { const val TAG = "SubtracksPlayback" - const val EXTRA_COVER_ART_ID = "coverArtId" - const val EXTRA_DURATION_MS = "durationMs" } } +private const val EXTRA_COVER_ART_ID = "coverArtId" +private const val EXTRA_DURATION_MS = "durationMs" + +internal fun queueMediaItem( + item: QueueItem, + local: String?, + stream: String?, + transcode: Boolean, +): MediaItem { + val metadata = + MediaMetadata + .Builder() + .setTitle(item.title) + .setArtist(item.artist) + .setAlbumTitle(item.album) + .setArtworkUri(item.coverArtId?.takeIf { it.isNotEmpty() }?.let(CoverArtArtwork::uri)) + .setDurationMs(item.durationMs) + .setIsBrowsable(false) + .setIsPlayable(true) + .setExtras( + Bundle().apply { + putString(EXTRA_COVER_ART_ID, item.coverArtId) + item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) } + }, + ).build() + val builder = + MediaItem + .Builder() + .setMediaId(item.id) + .setUri(local ?: stream) + .setMediaMetadata(metadata) + if (local == null && transcode) { + item.durationMs?.takeIf { it > 0 }?.let { duration -> + builder.setClippingConfiguration( + ClippingConfiguration.Builder().setEndPositionMs(duration).build(), + ) + } + } + return builder.build() +} + internal fun playbackErrorMessage(error: PlaybackException): String = when (error.errorCode) { PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 413d8d55..2aacf77b 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -3,6 +3,7 @@ package com.subtracks.playback import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueKind +import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueSnapshot import com.subtracks.data.repo.QueueWindowItem @@ -106,6 +107,7 @@ class PlaybackController( private val sourceRepository: SourceRepository, private val queueRepository: QueueRepository, private val connection: PlayerConnection, + private val downloads: DownloadRepository, private val showMessage: (String) -> Unit = {}, dispatcher: CoroutineDispatcher = Dispatchers.Main.immediate, ) { @@ -869,6 +871,7 @@ class PlaybackController( ) { val player = player ?: return val snapshot = snapshot ?: return + downloads.awaitLoaded() val window = queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS) val startIndex = window.indexOfFirst { it.position == position } if (startIndex < 0) return @@ -887,19 +890,29 @@ class PlaybackController( private suspend fun reloadQuality() = startLock.withLock { - val player = player ?: return@withLock - val snapshot = snapshot ?: return@withLock - if (snapshot.size == 0L) return@withLock val sourceId = queueSourceId if (sourceId == null || sourceRepository.activeSourceIdOnce() != sourceId) return@withLock - if (player.isEnded) return@withLock - val index = player.currentIndex - val position = (windowStart + index).coerceIn(0, snapshot.size - 1) - val positionMs = player.currentPositionMs - if (player.currentIndex != index) return@withLock - loadWindow(position, autoplay = player.playWhenReady, startPositionMs = positionMs) + reloadWindowLocked() } + fun refreshMediaItems() { + scope.launch { + startLock.withLock { reloadWindowLocked() } + } + } + + private suspend fun reloadWindowLocked() { + val player = player ?: return + val snapshot = snapshot ?: return + if (snapshot.size == 0L) return + if (player.isEnded) return + val index = player.currentIndex + val position = (windowStart + index).coerceIn(0, snapshot.size - 1) + val positionMs = player.currentPositionMs + if (player.currentIndex != index) return + loadWindow(position, autoplay = player.playWhenReady, startPositionMs = positionMs) + } + private fun jumpTo(target: Long) { scope.launch { startLock.withLock { diff --git a/app/src/main/kotlin/com/subtracks/playback/PlayerConnection.kt b/app/src/main/kotlin/com/subtracks/playback/PlayerConnection.kt index 9499386d..92b70922 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlayerConnection.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlayerConnection.kt @@ -4,6 +4,7 @@ import android.content.ComponentName import android.content.Context import androidx.media3.session.MediaController import androidx.media3.session.SessionToken +import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.SourceRepository import java.util.concurrent.Executor @@ -17,6 +18,7 @@ interface PlayerConnection { class MediaSessionConnection( private val context: Context, private val sourceRepository: SourceRepository, + private val downloads: DownloadRepository, ) : PlayerConnection { override fun connect( onConnected: (PlayerHandle) -> Unit, @@ -34,7 +36,7 @@ class MediaSessionConnection( future.addListener( { val controller = runCatching { future.get() }.getOrNull() - if (controller == null) onDisconnected() else onConnected(Media3PlayerHandle(controller, sourceRepository)) + if (controller == null) onDisconnected() else onConnected(Media3PlayerHandle(controller, sourceRepository, downloads)) }, Executor { it.run() }, ) diff --git a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt index df9fcc4e..0a41ef92 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt @@ -14,6 +14,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.QueueMusic import androidx.compose.material.icons.rounded.Album +import androidx.compose.material.icons.rounded.Cancel +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Shuffle @@ -43,7 +46,9 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.QueueKind +import com.subtracks.data.model.SongDownload import com.subtracks.data.source.StarType import kotlinx.coroutines.launch import com.subtracks.data.model.Album as AlbumModel @@ -59,6 +64,7 @@ sealed interface MenuTarget { data class Song( val song: SongModel, override val coverArt: CoverArtRef? = null, + val download: SongDownload? = null, ) : MenuTarget { override val title: String get() = song.title @@ -115,6 +121,9 @@ class ItemActions( val shufflePlaylist: (PlaylistModel) -> Unit = {}, val playNext: ((QueueRef) -> Unit)? = null, val addToQueue: ((QueueRef) -> Unit)? = null, + val download: ((SongModel) -> Unit)? = null, + val cancelDownload: ((SongModel) -> Unit)? = null, + val deleteDownload: ((SongModel) -> Unit)? = null, val setStar: suspend (StarType, String, Boolean) -> Result = { _, _, _ -> Result.success(Unit) }, val viewAlbum: ((String) -> Unit)? = null, val viewArtist: ((String) -> Unit)? = null, @@ -184,6 +193,7 @@ fun ItemContextMenu( MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { play(target.song) } } } queueItems() + DownloadItem(target.download, target.song, actions, ::dismiss) StarItem(target.song.starred) { starring -> actions.setStar(StarType.Song, target.song.id, starring) } target.song.albumId?.let { albumId -> actions.viewAlbum?.let { view -> @@ -261,6 +271,40 @@ private fun MenuHeader(target: MenuTarget) { } } +@Composable +private fun DownloadItem( + download: SongDownload?, + song: SongModel, + actions: ItemActions, + dismiss: (() -> Unit) -> Unit, +) { + when (download?.status) { + DownloadStatus.Completed -> { + actions.deleteDownload?.let { delete -> + MenuItem(Icons.Rounded.Delete, "Delete download") { dismiss { delete(song) } } + } + } + + DownloadStatus.Queued, DownloadStatus.Running -> { + actions.cancelDownload?.let { cancel -> + MenuItem(Icons.Rounded.Cancel, "Cancel download") { dismiss { cancel(song) } } + } + } + + DownloadStatus.Failed -> { + actions.download?.let { start -> + MenuItem(Icons.Rounded.Download, "Retry download") { dismiss { start(song) } } + } + } + + null -> { + actions.download?.let { start -> + MenuItem(Icons.Rounded.Download, "Download") { dismiss { start(song) } } + } + } + } +} + @Composable private fun StarItem( current: Long?, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index 25a9c204..22050eee 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -25,6 +25,7 @@ import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.Disc import com.subtracks.data.model.QueueKind import com.subtracks.data.model.Song +import com.subtracks.data.model.SongDownload import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController import com.subtracks.ui.components.ContextMenuHost @@ -55,6 +56,7 @@ fun AlbumDetailRoute( val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null) val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList()) val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList()) + val downloads by viewModel.downloads.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle() val context = playback.context val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } @@ -65,6 +67,9 @@ fun AlbumDetailRoute( shuffleAlbum = { viewModel.shuffle() }, playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) }, + download = viewModel::download, + cancelDownload = viewModel::cancelDownload, + deleteDownload = viewModel::deleteDownload, setStar = setStar, viewArtist = onViewArtist, ) @@ -74,6 +79,7 @@ fun AlbumDetailRoute( discs = discs, coverArt = viewModel::coverArt, artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS), + downloads = downloads, onBack = onBack, onSongClick = viewModel::play, onSongLongClick = { contextMenuHost?.show(it, actions) }, @@ -96,6 +102,7 @@ fun AlbumDetailScreen( discs: List = emptyList(), coverArt: (String?, Boolean) -> CoverArtRef?, artwork: ArtworkColors?, + downloads: Map = emptyMap(), onBack: () -> Unit, onSongClick: (Int) -> Unit, onSongLongClick: (MenuTarget) -> Unit = {}, @@ -165,10 +172,13 @@ fun AlbumDetailScreen( song = song, isPlaying = song.id == playingSongId, trackNumber = song.track, + download = downloads[song.id], modifier = rowModifier.combinedClickable( onClick = { onSongClick(index) }, - onLongClick = { onSongLongClick(MenuTarget.Song(song, coverArt(album?.coverArt, true))) }, + onLongClick = { + onSongLongClick(MenuTarget.Song(song, coverArt(album?.coverArt, true), downloads[song.id])) + }, ), ) } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt index a5d089d9..9934a784 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt @@ -6,14 +6,19 @@ import com.subtracks.data.model.Album import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.Disc import com.subtracks.data.model.Song +import com.subtracks.data.model.SongDownload +import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.SourceRepository import com.subtracks.playback.PlaybackController import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) @@ -21,6 +26,7 @@ class AlbumDetailViewModel( private val libraryRepository: LibraryRepository, private val sourceRepository: SourceRepository, private val playbackController: PlaybackController, + private val downloadRepository: DownloadRepository, private val albumId: String, ) : ViewModel() { private val sourceId = libraryRepository.activeSourceId.filterNotNull() @@ -31,11 +37,29 @@ class AlbumDetailViewModel( val discs: Flow> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) } + val downloads: StateFlow> = + downloadRepository.states().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) + fun coverArt( coverArt: String?, thumbnail: Boolean, ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) + fun download(song: Song) { + viewModelScope.launch { downloadRepository.download(song.sourceId, song.id) } + } + + fun cancelDownload(song: Song) { + viewModelScope.launch { downloadRepository.remove(song.sourceId, song.id) } + } + + fun deleteDownload(song: Song) { + viewModelScope.launch { + downloadRepository.remove(song.sourceId, song.id) + playbackController.refreshMediaItems() + } + } + fun play(startIndex: Int) { viewModelScope.launch { playbackController.playAlbum(sourceId.first(), albumId, startIndex.toLong()) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt index 5d9ae8da..dd2c05fe 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt @@ -14,6 +14,7 @@ import androidx.paging.compose.itemKey import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.Playlist import com.subtracks.data.model.QueueKind +import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongListItem import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController @@ -43,6 +44,7 @@ fun PlaylistDetailRoute( playbackController: PlaybackController = koinInject(), ) { val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null) + val downloads by viewModel.downloads.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle() val context = playback.context val actions = @@ -51,6 +53,9 @@ fun PlaylistDetailRoute( shufflePlaylist = { viewModel.shuffle() }, playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) }, + download = viewModel::download, + cancelDownload = viewModel::cancelDownload, + deleteDownload = viewModel::deleteDownload, setStar = setStar, viewAlbum = onViewAlbum, viewArtist = onViewArtist, @@ -60,6 +65,7 @@ fun PlaylistDetailRoute( songs = viewModel.songs.collectAsLazyPagingItems(), coverArt = viewModel::coverArt, artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS), + downloads = downloads, onBack = onBack, onSongClick = viewModel::play, onSongLongClick = { contextMenuHost?.show(it, actions) }, @@ -76,6 +82,7 @@ fun PlaylistDetailScreen( songs: LazyPagingItems, coverArt: (String?, Boolean) -> CoverArtRef?, artwork: ArtworkColors?, + downloads: Map = emptyMap(), onBack: () -> Unit, onSongClick: (Int) -> Unit, onSongLongClick: (MenuTarget) -> Unit = {}, @@ -114,10 +121,13 @@ fun PlaylistDetailScreen( coverArtId = item.coverArt, coverArt = coverArt, isPlaying = item.song.id == playingSongId, + download = downloads[item.song.id], modifier = rowModifier.combinedClickable( onClick = { onSongClick(index) }, - onLongClick = { onSongLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true))) }, + onLongClick = { + onSongLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true), downloads[item.song.id])) + }, ), ) } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt index c160d9a0..e8441400 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt @@ -6,15 +6,21 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.Playlist +import com.subtracks.data.model.Song +import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongListItem +import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.SourceRepository import com.subtracks.playback.PlaybackController import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) @@ -22,6 +28,7 @@ class PlaylistDetailViewModel( private val libraryRepository: LibraryRepository, private val sourceRepository: SourceRepository, private val playbackController: PlaybackController, + private val downloadRepository: DownloadRepository, private val playlistId: String, ) : ViewModel() { private val sourceId = libraryRepository.activeSourceId.filterNotNull() @@ -33,6 +40,24 @@ class PlaylistDetailViewModel( .flatMapLatest { libraryRepository.playlistSongs(it, playlistId) } .cachedIn(viewModelScope) + val downloads: StateFlow> = + downloadRepository.states().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) + + fun download(song: Song) { + viewModelScope.launch { downloadRepository.download(song.sourceId, song.id) } + } + + fun cancelDownload(song: Song) { + viewModelScope.launch { downloadRepository.remove(song.sourceId, song.id) } + } + + fun deleteDownload(song: Song) { + viewModelScope.launch { + downloadRepository.remove(song.sourceId, song.id) + playbackController.refreshMediaItems() + } + } + fun coverArt( coverArt: String?, thumbnail: Boolean, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt index 57647767..bb984354 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt @@ -7,7 +7,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DownloadDone import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults @@ -22,7 +24,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.Song +import com.subtracks.data.model.SongDownload import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.StarredBadge @@ -33,6 +37,7 @@ fun SongRow( coverArt: ((String?, Boolean) -> CoverArtRef?)? = null, isPlaying: Boolean = false, trackNumber: Long? = null, + download: SongDownload? = null, trailingContent: (@Composable () -> Unit)? = null, modifier: Modifier = Modifier, ) { @@ -82,6 +87,7 @@ fun SongRow( modifier = Modifier.weight(1f, fill = false), ) StarredBadge(song.starred != null) + DownloadBadge(download) } }, supportingContent = { @@ -96,6 +102,33 @@ fun SongRow( ) } +@Composable +private fun DownloadBadge(download: SongDownload?) { + when (download?.status) { + DownloadStatus.Completed -> { + Icon( + imageVector = Icons.Rounded.DownloadDone, + contentDescription = "Downloaded", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + + DownloadStatus.Queued, DownloadStatus.Running -> { + val progress = download.progress + if (progress == null) { + CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp) + } else { + CircularProgressIndicator(progress = { progress }, modifier = Modifier.size(14.dp), strokeWidth = 2.dp) + } + } + + else -> { + Unit + } + } +} + @Composable private fun TrackNumber( track: Long, diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt index 3a792810..b6c39bba 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -66,6 +67,7 @@ import coil3.request.ImageRequest import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.QueueKind import com.subtracks.data.model.Song +import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController @@ -80,6 +82,7 @@ import com.subtracks.ui.theme.ArtworkSeedCache import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.HeroGradient import com.subtracks.ui.theme.rememberArtworkColors +import kotlinx.coroutines.launch import org.koin.compose.koinInject const val NOW_PLAYING_COVER_TAG = "now-playing-cover" @@ -95,11 +98,15 @@ fun NowPlayingRoute( modifier: Modifier = Modifier, controller: PlaybackController = koinInject(), libraryRepository: LibraryRepository = koinInject(), + downloadRepository: DownloadRepository = koinInject(), ) { val state by controller.state.collectAsStateWithLifecycle() val positionMs by controller.positionMs.collectAsStateWithLifecycle() val sourceId = state.context?.sourceId val songId = state.item?.id + val downloads by downloadRepository.states().collectAsStateWithLifecycle(initialValue = emptyMap()) + val download = songId?.let { downloads[it] } + val scope = rememberCoroutineScope() val song by produceState(null, sourceId, songId) { if (sourceId != null && songId != null) { @@ -109,6 +116,14 @@ fun NowPlayingRoute( val context = LocalPlatformContext.current val actions = ItemActions( + download = { track -> scope.launch { downloadRepository.download(track.sourceId, track.id) } }, + cancelDownload = { track -> scope.launch { downloadRepository.remove(track.sourceId, track.id) } }, + deleteDownload = { track -> + scope.launch { + downloadRepository.remove(track.sourceId, track.id) + controller.refreshMediaItems() + } + }, setStar = setStar, viewAlbum = onViewAlbum, viewArtist = onViewArtist, @@ -142,7 +157,7 @@ fun NowPlayingRoute( onPrevious = controller::previous, onShuffle = controller::toggleShuffle, onRepeat = controller::cycleRepeat, - onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail), actions) } }, + onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } }, onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } }, onArtistClick = song?.artistId?.let { id -> { onViewArtist(id) } }, onSeek = controller::seekTo, diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt index cccd0281..d76ee6ce 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt @@ -47,7 +47,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongListItem +import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.QUEUE_CHUNK import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueWindowItem @@ -267,8 +269,10 @@ fun QueueRoute( viewModel: QueueViewModel = koinViewModel(), controller: PlaybackController = koinInject(), sourceRepository: SourceRepository = koinInject(), + downloadRepository: DownloadRepository = koinInject(), ) { val playback by controller.state.collectAsStateWithLifecycle() + val downloads by downloadRepository.states().collectAsStateWithLifecycle(initialValue = emptyMap()) val queueContext = playback.context // The item's album keeps the header stable while the async name lookup runs, so it does not // flash the fallback when tapping between context and manually queued tracks. @@ -287,6 +291,7 @@ fun QueueRoute( currentSongId = playback.item?.id, shuffle = playback.shuffle, contextTitle = sourceTitle.value, + downloads = downloads, onClearUpNext = controller::clearUpNext, coverArt = sourceRepository::coverArt, onBack = onBack, @@ -321,6 +326,7 @@ fun QueueScreen( generation: Int = 0, shuffle: Boolean = false, contextTitle: String? = null, + downloads: Map = emptyMap(), onClearUpNext: () -> Unit = {}, ) { val listState = rememberLazyListState() @@ -437,6 +443,7 @@ fun QueueScreen( isPlaying = row.song.song.id == currentSongId, floating = isDragging, coverArt = coverArt, + download = downloads[row.song.song.id], dragHandle = if (!enabled) { null @@ -537,6 +544,7 @@ private fun QueueRowItem( isPlaying: Boolean, floating: Boolean, coverArt: (String?, Boolean) -> CoverArtRef?, + download: SongDownload?, dragHandle: Modifier?, onClick: () -> Unit, onRemove: () -> Unit, @@ -545,6 +553,7 @@ private fun QueueRowItem( song = row.song.song, coverArtId = row.song.coverArt, coverArt = coverArt, + download = download, isPlaying = isPlaying, trailingContent = { Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt index d36d2bae..0bbd7977 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt @@ -6,6 +6,7 @@ import com.subtracks.data.model.Source import com.subtracks.data.net.NetworkMode import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.UserPreferences +import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.SourceRepository import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -14,6 +15,7 @@ import kotlinx.coroutines.launch class SettingsViewModel( private val sourceRepository: SourceRepository, + private val downloadRepository: DownloadRepository, private val userPreferences: UserPreferences, ) : ViewModel() { val sources: StateFlow> = @@ -37,7 +39,10 @@ class SettingsViewModel( } fun deleteSource(id: Long) { - viewModelScope.launch { sourceRepository.deleteSource(id) } + viewModelScope.launch { + downloadRepository.removeSource(id) + sourceRepository.deleteSource(id) + } } fun setWifiQuality(quality: StreamQuality) { diff --git a/app/src/test/kotlin/com/subtracks/data/download/FakeDownloadEngine.kt b/app/src/test/kotlin/com/subtracks/data/download/FakeDownloadEngine.kt new file mode 100644 index 00000000..744d3dbb --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/download/FakeDownloadEngine.kt @@ -0,0 +1,45 @@ +package com.subtracks.data.download + +class FakeDownloadEngine : DownloadEngine { + private val downloads = mutableMapOf() + private var nextId = 1L + + val requests = mutableListOf>() + val cancelled = mutableListOf() + + override fun enqueue(request: EngineRequest): Long { + val id = nextId++ + requests += id to request + downloads[id] = EngineDownload(EngineStatus.Pending) + return id + } + + override fun download(id: Long): EngineDownload? = downloads[id] + + override fun cancel(id: Long) { + cancelled += id + } + + fun running( + id: Long, + bytes: Long, + total: Long, + ) { + downloads[id] = EngineDownload(EngineStatus.Running, bytes, total) + } + + fun complete( + id: Long, + total: Long, + ) { + downloads[id] = EngineDownload(EngineStatus.Completed, total, total) + } + + fun fail(id: Long) { + downloads[id] = EngineDownload(EngineStatus.Failed, error = "Download failed") + } + + fun forget(id: Long) { + downloads.remove(id) + } +} diff --git a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt new file mode 100644 index 00000000..31fee6b8 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt @@ -0,0 +1,268 @@ +package com.subtracks.data.repo + +import android.content.Context +import android.net.Uri +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.download.FakeDownloadEngine +import com.subtracks.data.model.DownloadStatus +import com.subtracks.data.model.Song +import com.subtracks.data.model.SongDownload +import com.subtracks.data.model.Source +import com.subtracks.data.model.SubsonicSource +import com.subtracks.data.prefs.fakeUserPreferences +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.util.concurrent.CopyOnWriteArrayList + +@RunWith(AndroidJUnit4::class) +class DownloadRepositoryTest { + private lateinit var db: SubtracksDatabase + private lateinit var sources: SourceRepository + private lateinit var engine: FakeDownloadEngine + private lateinit var repository: DownloadRepository + private lateinit var dir: File + private val messages = CopyOnWriteArrayList() + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = + Room + .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .build() + sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences()) + engine = FakeDownloadEngine() + dir = File(context.cacheDir, "downloads-${System.nanoTime()}") + repository = DownloadRepository(db, sources, engine, dir, showMessage = { messages += it }) + } + + @After + fun tearDown() { + repository.close() + sources.close() + db.close() + dir.deleteRecursively() + } + + @Test + fun startingADownloadAsksTheEngineForTheOriginalAndRecordsIt() = + runTest { + seedLibrary() + + runBlocking { repository.download(1, "s1") } + + val request = engine.requests.single().second + assertTrue("expected the download endpoint, was ${request.uri}", request.uri.contains("/rest/download")) + assertEquals("downloads/1/s1", request.path) + assertEquals("Song 1", request.title) + assertEquals(DownloadStatus.Queued, row(1, "s1")?.status) + } + + @Test + fun progressAndCompletionAreMirroredAndTheFileBecomesPlayable() = + runTest { + seedLibrary() + repository.start() + runBlocking { repository.download(1, "s1") } + val id = engine.requests.single().first + + engine.running(id, bytes = 40, total = 100) + await { row(1, "s1")?.status == DownloadStatus.Running } + assertEquals(40L, row(1, "s1")?.bytes) + + engine.complete(id, total = 100) + writeFile(1, "s1", "audio") + runBlocking { repository.reconcile() } + + await { repository.localUri("s1") != null } + assertEquals(DownloadStatus.Completed, row(1, "s1")?.status) + assertEquals(Uri.fromFile(file(1, "s1")).toString(), repository.localUri("s1")) + } + + @Test + fun aPartialFileIsNotTreatedAsPlayableWhileTheDownloadRuns() = + runTest { + seedLibrary() + repository.start() + runBlocking { repository.download(1, "s1") } + val id = engine.requests.single().first + engine.running(id, bytes = 40, total = 100) + writeFile(1, "s1", "half") + await { row(1, "s1")?.status == DownloadStatus.Running } + + assertNull(repository.localUri("s1")) + } + + @Test + fun aCompletedRowWhoseFileIsGoneIsFailedRatherThanLeftCompleted() = + runTest { + seedLibrary() + db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = null)) + + runBlocking { repository.reconcile() } + + assertEquals(DownloadStatus.Failed, row(1, "s1")?.status) + } + + @Test + fun aCompletedEngineStateWithoutAFileIsFailed() = + runTest { + seedLibrary() + runBlocking { repository.download(1, "s1") } + val id = engine.requests.single().first + engine.complete(id, total = 100) + + runBlocking { repository.reconcile() } + + assertEquals(DownloadStatus.Failed, row(1, "s1")?.status) + } + + @Test + fun aQueuedRowWhoseEngineEntryIsGoneIsStartedAgain() = + runTest { + seedLibrary() + db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Queued, engineId = 7)) + + runBlocking { repository.reconcile() } + + val request = engine.requests.single() + assertNotEquals(7L, request.first) + assertEquals("downloads/1/s1", request.second.path) + } + + @Test + fun removingADownloadCancelsItAndDeletesTheFile() = + runTest { + seedLibrary() + writeFile(1, "s1", "audio") + runBlocking { repository.download(1, "s1") } + val id = engine.requests.single().first + + runBlocking { repository.remove(1, "s1") } + + assertTrue(engine.cancelled.contains(id)) + assertFalse(file(1, "s1").exists()) + assertNull(row(1, "s1")) + } + + @Test + fun aFailedDownloadCanBeStartedAgain() = + runTest { + seedLibrary() + db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Failed, engineId = 3, error = "boom")) + + runBlocking { repository.download(1, "s1") } + + assertEquals(1, engine.requests.size) + assertEquals(DownloadStatus.Queued, row(1, "s1")?.status) + } + + @Test + fun downloadingFromASourceThatIsNotActiveIsRefused() = + runTest { + seedLibrary() + db.sourcesDao().upsertSource(Source(2, "other", "http://other/", isActive = false, createdAt = 0)) + db.sourcesDao().upsertSubsonicSource(SubsonicSource(2, "user", "pass")) + + runBlocking { repository.download(2, "s1") } + + assertTrue(engine.requests.isEmpty()) + assertEquals(listOf("Can't download from a source that isn't active"), messages.toList()) + } + + @Test + fun removingASourceCancelsItsDownloadsAndDeletesItsFiles() = + runTest { + seedLibrary() + writeFile(1, "s1", "audio") + runBlocking { repository.download(1, "s1") } + val id = engine.requests.single().first + + runBlocking { repository.removeSource(1) } + + assertTrue(engine.cancelled.contains(id)) + assertFalse(file(1, "s1").exists()) + assertTrue(allRows().isEmpty()) + } + + @Test + fun reconcileDeletesFilesThatBelongToNoDownload() = + runTest { + seedLibrary() + writeFile(1, "orphan", "audio") + + runBlocking { repository.reconcile() } + + assertFalse(file(1, "orphan").exists()) + } + + private fun seedLibrary() { + runBlocking { + db.sourcesDao().upsertSource(Source(1, "source", "http://localhost:4533/", isActive = true, createdAt = 0)) + db.sourcesDao().upsertSubsonicSource(SubsonicSource(1, "user", "pass")) + db.libraryDao().upsertSongs( + listOf( + Song( + sourceId = 1, + id = "s1", + albumId = "al1", + artistId = "ar1", + title = "Song 1", + album = "Album", + artist = "Artist", + duration = 100, + track = 1, + disc = 1, + starred = null, + genre = null, + ), + ), + ) + } + await { sources.downloadUri("s1") != null } + } + + private fun file( + sourceId: Long, + songId: String, + ): File = File(File(dir, sourceId.toString()), songId) + + private fun row( + sourceId: Long, + songId: String, + ): SongDownload? = runBlocking { db.downloadDao().find(sourceId, songId) } + + private fun allRows(): List = runBlocking { db.downloadDao().all() } + + private fun writeFile( + sourceId: Long, + songId: String, + contents: String, + ) { + val file = file(sourceId, songId) + file.parentFile?.mkdirs() + file.writeText(contents) + } + + private fun await(predicate: () -> Boolean) { + val deadline = System.nanoTime() + 5_000_000_000L + while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10) + assertTrue("Timed out waiting for the expected download state", predicate()) + } +} diff --git a/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt b/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt new file mode 100644 index 00000000..adaa0959 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt @@ -0,0 +1,57 @@ +package com.subtracks.playback + +import androidx.media3.common.C +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MediaItemTest { + private val song = + QueueItem( + id = "s1", + title = "Song", + artist = "Artist", + album = "Album", + coverArtId = "al1", + durationMs = 100_000L, + ) + + @Test + fun aDownloadedFileIsPreferredOverTheStream() { + val item = queueMediaItem(song, local = "file:///downloads/1/s1", stream = "http://server/stream?s=s1", transcode = false) + + assertEquals("file:///downloads/1/s1", item.localConfiguration?.uri.toString()) + } + + @Test + fun theStreamIsUsedWhenThereIsNoDownload() { + val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false) + + assertEquals("http://server/stream?s=s1", item.localConfiguration?.uri.toString()) + } + + @Test + fun aDownloadedFileIsNotClippedEvenForATranscodingQuality() { + val item = queueMediaItem(song, local = "file:///downloads/1/s1", stream = "http://server/stream?s=s1", transcode = true) + + assertEquals(C.TIME_END_OF_SOURCE, item.clippingConfiguration?.endPositionMs) + } + + @Test + fun aTranscodedStreamIsClippedToItsDeclaredDuration() { + val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = true) + + assertEquals(100_000L, item.clippingConfiguration?.endPositionMs) + } + + @Test + fun theItemKeepsItsIdTitleAndCoverArt() { + val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false) + + assertEquals("s1", item.mediaId) + assertEquals("Song", item.mediaMetadata.title.toString()) + assertEquals(CoverArtArtwork.uri("al1"), item.mediaMetadata.artworkUri) + } +} diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 5f2b52fb..0c7be4f1 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -7,6 +7,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.download.FakeDownloadEngine import com.subtracks.data.model.Album import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueKind @@ -16,6 +17,7 @@ import com.subtracks.data.net.NetworkMode import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.fakeUserPreferences +import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.SourceRepository import kotlinx.coroutines.Dispatchers @@ -33,6 +35,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import java.io.File import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.Executors @@ -45,6 +48,7 @@ class PlaybackControllerTest { private lateinit var networkMode: MutableStateFlow private lateinit var sources: SourceRepository private lateinit var queues: QueueRepository + private lateinit var downloads: DownloadRepository private lateinit var handle: FakePlayerHandle private lateinit var controller: PlaybackController private val messages = CopyOnWriteArrayList() @@ -61,15 +65,24 @@ class PlaybackControllerTest { networkMode = MutableStateFlow(NetworkMode.Wifi) sources = SourceRepository(db, OkHttpClient(), prefs, networkMode = networkMode) queues = QueueRepository(db) + downloads = DownloadRepository(db, sources, FakeDownloadEngine(), File(context.cacheDir, "downloads")) handle = FakePlayerHandle() controller = - PlaybackController(sources, queues, FakePlayerConnection(handle), showMessage = { messages += it }, dispatcher = dispatcher) + PlaybackController( + sources, + queues, + FakePlayerConnection(handle), + downloads, + showMessage = { messages += it }, + dispatcher = dispatcher, + ) } @After fun tearDown() { controller.close() sources.close() + downloads.close() db.close() dispatcher.close() } @@ -550,7 +563,7 @@ class PlaybackControllerTest { runBlocking { queues.setPosition(12_000L) } val restoredHandle = FakePlayerHandle() - val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), dispatcher = dispatcher) + val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), downloads, dispatcher = dispatcher) restored.connect() await { restored.state.value.item @@ -658,7 +671,7 @@ class PlaybackControllerTest { block: Runnable, ) = block.run() } - val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline) + val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline) eager.playAlbum(1, "al1", 0) await { @@ -705,7 +718,7 @@ class PlaybackControllerTest { block: Runnable, ) = block.run() } - val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline) + val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline) eager.playAlbum(1, "al1", 0) await { @@ -748,7 +761,7 @@ class PlaybackControllerTest { block: Runnable, ) = block.run() } - val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline) + val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline) eager.playAlbum(1, "al1", 0) await { @@ -791,7 +804,7 @@ class PlaybackControllerTest { block: Runnable, ) = block.run() } - val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline) + val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline) eager.playAlbum(1, "al1", 0) await { @@ -1830,7 +1843,7 @@ class PlaybackControllerTest { runBlocking { queues.setPosition(12_000L) } val restoredHandle = FakePlayerHandle() - val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), dispatcher = dispatcher) + val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), downloads, dispatcher = dispatcher) restored.connect() await { restored.state.value.item @@ -1866,6 +1879,21 @@ class PlaybackControllerTest { assertEquals(0L, runBlocking { queues.cursorPositionMs() }) } + @Test + fun refreshingMediaItemsRebuildsTheWindowAtTheSamePosition() { + seedAlbum(3, sourceId = 1) + controller.playAlbum(1, "al1", 0) + await { handle.operations.contains("play") } + handle.positionMs = 42_000L + val windows = handle.operations.count { it.startsWith("setWindow") } + + controller.refreshMediaItems() + + await { handle.operations.count { it.startsWith("setWindow") } > windows } + assertEquals("s1", handle.currentItem?.id) + assertEquals(42_000L, handle.positionMs) + } + @Test fun positionTickerAdvancesPositionWithoutEmittingPlaybackState() = runBlocking { diff --git a/app/src/test/kotlin/com/subtracks/ui/library/SongRowDownloadTest.kt b/app/src/test/kotlin/com/subtracks/ui/library/SongRowDownloadTest.kt new file mode 100644 index 00000000..d90482e5 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/library/SongRowDownloadTest.kt @@ -0,0 +1,72 @@ +package com.subtracks.ui.library + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.model.DownloadStatus +import com.subtracks.data.model.Song +import com.subtracks.data.model.SongDownload +import com.subtracks.ui.theme.SubtracksTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SongRowDownloadTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val song = + Song( + sourceId = 1, + id = "s1", + albumId = "al1", + artistId = "ar1", + title = "Song", + album = "Album", + artist = "Artist", + duration = 100, + track = 1, + disc = 1, + starred = null, + genre = null, + ) + + @Test + fun aCompletedDownloadShowsTheDownloadedIndicator() { + render(download(DownloadStatus.Completed)) + + composeRule.onNodeWithContentDescription("Downloaded").assertIsDisplayed() + } + + @Test + fun aRunningDownloadShowsNoDownloadedIndicator() { + render(download(DownloadStatus.Running)) + + composeRule.onNodeWithContentDescription("Downloaded").assertDoesNotExist() + } + + @Test + fun aSongWithoutADownloadShowsNoIndicator() { + render(null) + + composeRule.onNodeWithText("Song").assertIsDisplayed() + composeRule.onNodeWithContentDescription("Downloaded").assertDoesNotExist() + } + + private fun download(status: DownloadStatus) = + SongDownload(sourceId = 1, songId = "s1", status = status, engineId = 7, bytes = 40, total = 100) + + private fun render(download: SongDownload?) { + composeRule.setContent { + SubtracksTheme { + SongRow(song = song, download = download) + } + } + } +} diff --git a/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt index ac3a5c31..169af1a1 100644 --- a/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt @@ -6,11 +6,13 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.download.FakeDownloadEngine import com.subtracks.data.model.QueueKind import com.subtracks.data.model.Song import com.subtracks.data.model.SongListItem import com.subtracks.data.model.Source import com.subtracks.data.prefs.fakeUserPreferences +import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.SourceRepository import com.subtracks.playback.FakePlayerConnection @@ -29,6 +31,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import java.io.File import java.util.concurrent.Executors @OptIn(ExperimentalCoroutinesApi::class) @@ -52,7 +55,8 @@ class QueueViewModelTest { .build() val sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences()) queues = QueueRepository(db) - controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), dispatcher = dispatcher) + val downloads = DownloadRepository(db, sources, FakeDownloadEngine(), File(context.cacheDir, "downloads")) + controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), downloads, dispatcher = dispatcher) viewModel = QueueViewModel(queues, controller) }