diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/17.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/17.json deleted file mode 100644 index e4c2501e..00000000 --- a/app/schemas/com.subtracks.data.db.SubtracksDatabase/17.json +++ /dev/null @@ -1,1230 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 17, - "identityHash": "a884a1dacb84300bb86bbb46fca25c9e", - "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, PRIMARY KEY(`sourceId`, `songId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "songId", - "columnName": "songId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "engineId", - "columnName": "engineId", - "affinity": "INTEGER" - }, - { - "fieldPath": "bytes", - "columnName": "bytes", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "total", - "columnName": "total", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "error", - "columnName": "error", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "sourceId", - "songId" - ] - }, - "indices": [ - { - "name": "index_song_downloads_sourceId", - "unique": false, - "columnNames": [ - "sourceId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_song_downloads_sourceId` ON `${TABLE_NAME}` (`sourceId`)" - }, - { - "name": "index_song_downloads_engineId", - "unique": false, - "columnNames": [ - "engineId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_song_downloads_engineId` ON `${TABLE_NAME}` (`engineId`)" - } - ], - "foreignKeys": [ - { - "table": "sources", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "songs", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "sourceId", - "songId" - ], - "referencedColumns": [ - "sourceId", - "id" - ] - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a884a1dacb84300bb86bbb46fca25c9e')" - ] - } -} \ 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 deleted file mode 100644 index 66da7913..00000000 --- a/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt +++ /dev/null @@ -1,34 +0,0 @@ -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 83916dd3..d571c47c 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -369,12 +369,6 @@ 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 10a6e88f..fdab336e 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -235,22 +235,6 @@ 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, " + - "PRIMARY KEY(`sourceId`, `songId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) " + - "ON UPDATE NO ACTION ON DELETE CASCADE, FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) " + - "ON UPDATE NO ACTION ON DELETE CASCADE )", - ) - 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, @@ -268,5 +252,4 @@ 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 01e18ed9..24f71f52 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -7,7 +7,6 @@ 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 @@ -15,7 +14,6 @@ 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 @@ -35,12 +33,11 @@ import com.subtracks.data.model.UpNextEntry PlaybackCursor::class, ShuffleOrder::class, ArtworkSeed::class, - SongDownload::class, ], - version = 17, + version = 16, exportSchema = true, ) -@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class) +@ColumnTypeConverters(QueueKindConverter::class) abstract class SubtracksDatabase : RoomDatabase() { abstract fun libraryDao(): LibraryDao @@ -49,6 +46,4 @@ 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 deleted file mode 100644 index cf66938e..00000000 --- a/app/src/main/kotlin/com/subtracks/data/download/DownloadEngine.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.subtracks.data.download - -data class EngineRequest( - val uri: String, - val path: String, - val title: String, -) - -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 deleted file mode 100644 index 26419e34..00000000 --- a/app/src/main/kotlin/com/subtracks/data/download/SystemDownloadEngine.kt +++ /dev/null @@ -1,67 +0,0 @@ -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 download = - DownloadManager - .Request(Uri.parse(request.uri)) - .setTitle(request.title) - .setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) - // Downloads wait for an unmetered network until the preference for them exists. - .setAllowedOverMetered(false) - return manager().enqueue(download) - } - - 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 } - return EngineDownload( - status = engineStatus(status), - 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) -} - -internal fun engineStatus(status: Int): EngineStatus = - when (status) { - DownloadManager.STATUS_SUCCESSFUL -> EngineStatus.Completed - DownloadManager.STATUS_FAILED -> EngineStatus.Failed - DownloadManager.STATUS_RUNNING -> EngineStatus.Running - else -> EngineStatus.Pending - } - -internal 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 deleted file mode 100644 index d9cb3eba..00000000 --- a/app/src/main/kotlin/com/subtracks/data/model/Download.kt +++ /dev/null @@ -1,49 +0,0 @@ -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, - ), - ForeignKey( - entity = Song::class, - parentColumns = ["sourceId", "id"], - childColumns = ["sourceId", "songId"], - 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, -) { - 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 deleted file mode 100644 index 6e794b2a..00000000 --- a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt +++ /dev/null @@ -1,271 +0,0 @@ -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.StateFlow -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 kotlinx.coroutines.withContext -import java.io.File - -class DownloadRepository( - private val db: SubtracksDatabase, - private val sourceRepository: SourceRepository, - private val engine: DownloadEngine, - private val downloadsDir: File, - private val showMessage: (String) -> Unit = {}, - private val 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 statesFlow = MutableStateFlow>(emptyMap()) - - fun start() { - if (started) return - started = true - downloadsDir.mkdirs() - File(downloadsDir, NO_MEDIA).createNewFile() - scope.launch { - downloadStates().collect { rows -> - statesFlow.value = rows - loaded.complete(Unit) - } - } - scope.launch { - sourceRepository.activeSourceId().distinctUntilChanged().collect { reconcile() } - } - } - - fun close() { - scope.cancel() - } - - fun states(): StateFlow> = statesFlow - - suspend fun awaitLoaded() { - if (started) loaded.await() - } - - fun localUri(songId: String): String? = - statesFlow.value[songId] - ?.takeIf { it.status == DownloadStatus.Completed } - ?.let { file(it.sourceId, it.songId) } - ?.takeIf { it.exists() } - ?.let { Uri.fromFile(it).toString() } - - suspend fun download( - sourceId: Long, - songId: String, - ) = withContext(dispatcher) { - 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(sourceId, songId) - if (url == null) { - showMessage(NO_ADDRESS) - return@withLock - } - enqueue(sourceId, songId, url) - } - } - - suspend fun remove( - sourceId: Long, - songId: String, - ) = withContext(dispatcher) { - 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) = - withContext(dispatcher) { - 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() = - withContext(dispatcher) { - 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 fun downloadStates(): Flow> = - sourceRepository - .activeSourceId() - .flatMapLatest { sourceId -> if (sourceId == null) flowOf(emptyList()) else db.downloadDao().downloads(sourceId) } - .map { rows -> rows.associateBy { it.songId } } - - 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), - ) - db.downloadDao().upsert( - SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued, engineId = engineId), - ) - ensurePolling() - } - - private suspend fun recover(row: SongDownload) { - when (row.status) { - DownloadStatus.Completed -> { - if (!file(row.sourceId, row.songId).exists()) markFailed(row, MISSING_FILE) - } - - DownloadStatus.Queued -> { - if (sourceRepository.activeSourceIdOnce() != row.sourceId) return - val url = sourceRepository.downloadUri(row.sourceId, row.songId) ?: return - enqueue(row.sourceId, row.songId, url) - } - - DownloadStatus.Running -> { - markFailed(row, STOPPED) - } - - DownloadStatus.Failed -> { - Unit - } - } - } - - 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 - } - if (status == DownloadStatus.Failed) file(row.sourceId, row.songId).delete() - 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) - file(row.sourceId, row.songId).delete() - 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 sourceId = sourceRepository.activeSourceIdOnce() - val active = db.downloadDao().all().filter { it.status.isActive && it.sourceId == sourceId } - 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 - const val MISSING_FILE = "The downloaded file is missing" - const val NO_ADDRESS = "Can't download: the server address is unavailable" - const val STOPPED = "The download stopped unexpectedly" - } -} 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 81b5c432..2a7d14c8 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt @@ -38,8 +38,6 @@ class SourceRepository( @Volatile private var active: SubsonicMusicSource? = null - - @Volatile private var activeSourceId: Long? = null private val _quality = MutableStateFlow(StreamQuality()) @@ -96,11 +94,6 @@ class SourceRepository( return uri + streamLengthSuffix(length) } - fun downloadUri( - sourceId: Long, - songId: String, - ): String? = if (activeSourceId == sourceId) active?.downloadUri(songId)?.toString() else null - 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 2395c4ba..bc96c5ea 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -1,17 +1,14 @@ 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 @@ -30,7 +27,6 @@ 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, @@ -48,24 +44,14 @@ fun appModule( single { ArtworkSeedRepository(get()) } single { SyncManager(get(), get(), get()) } single { QueueRepository(get()) } - 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) } + single { MediaSessionConnection(context.applicationContext, get()) } + single { PlaybackController(get(), get(), get(), showMessage = toast) } viewModel { RootViewModel(get()) } viewModel { LibraryViewModel(get(), get(), get(), get(), get()) } - viewModel { SettingsViewModel(get(), get(), get()) } + viewModel { SettingsViewModel(get(), get()) } viewModel { AddSourceViewModel(get(), get()) } - viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) } + viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) } viewModel { params -> ArtistDetailViewModel(get(), get(), get(), params.get()) } - viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), get(), params.get()) } + viewModel { params -> PlaylistDetailViewModel(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 649175ba..35d80062 100644 --- a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt @@ -9,13 +9,11 @@ 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 @@ -129,13 +127,37 @@ 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 - return queueMediaItem( - item = item, - local = downloads.localUri(item.id), - stream = sourceRepository.streamUri(item.id, item.durationMs, quality), - transcode = quality.transcodes, - ) + 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() } private fun toQueueItem(mediaItem: MediaItem): QueueItem = @@ -156,50 +178,11 @@ 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 480d1b84..413d8d55 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -3,7 +3,6 @@ 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 @@ -107,7 +106,6 @@ 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, ) { @@ -144,7 +142,7 @@ class PlaybackController( init { scope.launch { sourceRepository.activeSourceId().collect { sourceId -> - if (queueSourceId != null && sourceId != queueSourceId) stop() + if (queueSourceId != null && sourceId != null && sourceId != queueSourceId) stop() } } scope.launch { @@ -871,7 +869,6 @@ 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 @@ -890,27 +887,19 @@ 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 - reloadWindowLocked() + 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) } - 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 position = (windowStart + player.currentIndex).coerceIn(0, snapshot.size - 1) - val positionMs = player.currentPositionMs - 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/PlaybackService.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt index 1cd8fa73..f25a6f94 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt @@ -1,7 +1,6 @@ package com.subtracks.playback import android.app.PendingIntent -import android.content.Context import android.content.Intent import android.os.SystemClock import android.util.Log @@ -10,8 +9,6 @@ import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.util.ExperimentalApi import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DataSource -import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory @@ -44,7 +41,9 @@ class PlaybackService : MediaSessionService() { .setMediaSourceFactory( DefaultMediaSourceFactory(this) .setEnableClippingInMediaPeriod(true) - .setDataSourceFactory(mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()))), + .setDataSourceFactory( + KnownLengthDataSourceFactory(OkHttpDataSource.Factory(streamingClient())), + ), ).build() val loader = CoverArtBitmapLoader( @@ -89,6 +88,10 @@ class PlaybackService : MediaSessionService() { response }.build() + private companion object { + const val STREAM_TAG = "SubtracksPlayback" + } + override fun onDestroy() { artworkLoader?.shutdown() artworkLoader = null @@ -99,14 +102,4 @@ class PlaybackService : MediaSessionService() { session = null super.onDestroy() } - - private companion object { - const val STREAM_TAG = "SubtracksPlayback" - } } - -@OptIn(UnstableApi::class) -internal fun mediaDataSourceFactory( - context: Context, - upstream: DataSource.Factory, -): DataSource.Factory = DefaultDataSource.Factory(context, KnownLengthDataSourceFactory(upstream)) diff --git a/app/src/main/kotlin/com/subtracks/playback/PlayerConnection.kt b/app/src/main/kotlin/com/subtracks/playback/PlayerConnection.kt index 92b70922..9499386d 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlayerConnection.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlayerConnection.kt @@ -4,7 +4,6 @@ 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 @@ -18,7 +17,6 @@ interface PlayerConnection { class MediaSessionConnection( private val context: Context, private val sourceRepository: SourceRepository, - private val downloads: DownloadRepository, ) : PlayerConnection { override fun connect( onConnected: (PlayerHandle) -> Unit, @@ -36,7 +34,7 @@ class MediaSessionConnection( future.addListener( { val controller = runCatching { future.get() }.getOrNull() - if (controller == null) onDisconnected() else onConnected(Media3PlayerHandle(controller, sourceRepository, downloads)) + if (controller == null) onDisconnected() else onConnected(Media3PlayerHandle(controller, sourceRepository)) }, 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 0a41ef92..df9fcc4e 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt @@ -14,9 +14,6 @@ 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 @@ -46,9 +43,7 @@ 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 @@ -64,7 +59,6 @@ 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 @@ -121,9 +115,6 @@ 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, @@ -193,7 +184,6 @@ 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 -> @@ -271,40 +261,6 @@ 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 22050eee..25a9c204 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -25,7 +25,6 @@ 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 @@ -56,7 +55,6 @@ 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) } } @@ -67,9 +65,6 @@ 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, ) @@ -79,7 +74,6 @@ 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) }, @@ -102,7 +96,6 @@ fun AlbumDetailScreen( discs: List = emptyList(), coverArt: (String?, Boolean) -> CoverArtRef?, artwork: ArtworkColors?, - downloads: Map = emptyMap(), onBack: () -> Unit, onSongClick: (Int) -> Unit, onSongLongClick: (MenuTarget) -> Unit = {}, @@ -172,13 +165,10 @@ 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), downloads[song.id])) - }, + onLongClick = { onSongLongClick(MenuTarget.Song(song, coverArt(album?.coverArt, true))) }, ), ) } 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 a73150c5..a5d089d9 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,11 @@ 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.StateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest @@ -24,7 +21,6 @@ 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() @@ -35,28 +31,11 @@ class AlbumDetailViewModel( val discs: Flow> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) } - val downloads: StateFlow> = downloadRepository.states() - 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 dd2c05fe..5d9ae8da 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt @@ -14,7 +14,6 @@ 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 @@ -44,7 +43,6 @@ 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 = @@ -53,9 +51,6 @@ 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, @@ -65,7 +60,6 @@ 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) }, @@ -82,7 +76,6 @@ fun PlaylistDetailScreen( songs: LazyPagingItems, coverArt: (String?, Boolean) -> CoverArtRef?, artwork: ArtworkColors?, - downloads: Map = emptyMap(), onBack: () -> Unit, onSongClick: (Int) -> Unit, onSongLongClick: (MenuTarget) -> Unit = {}, @@ -121,13 +114,10 @@ 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), downloads[item.song.id])) - }, + onLongClick = { onSongLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true))) }, ), ) } 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 0da0253f..c160d9a0 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt @@ -6,16 +6,12 @@ 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.StateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest @@ -26,7 +22,6 @@ 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() @@ -38,24 +33,6 @@ class PlaylistDetailViewModel( .flatMapLatest { libraryRepository.playlistSongs(it, playlistId) } .cachedIn(viewModelScope) - val downloads: StateFlow> = - downloadRepository.states() - - 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 854b78c1..57647767 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt @@ -7,9 +7,7 @@ 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 @@ -20,15 +18,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics 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 @@ -39,7 +33,6 @@ fun SongRow( coverArt: ((String?, Boolean) -> CoverArtRef?)? = null, isPlaying: Boolean = false, trackNumber: Long? = null, - download: SongDownload? = null, trailingContent: (@Composable () -> Unit)? = null, modifier: Modifier = Modifier, ) { @@ -89,7 +82,6 @@ fun SongRow( modifier = Modifier.weight(1f, fill = false), ) StarredBadge(song.starred != null) - DownloadBadge(download) } }, supportingContent = { @@ -104,40 +96,6 @@ 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).semantics { contentDescription = "Downloading" }, - strokeWidth = 2.dp, - ) - } else { - CircularProgressIndicator( - progress = { progress }, - modifier = Modifier.size(14.dp).semantics { contentDescription = "Downloading" }, - 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 26c0510a..3a792810 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -46,7 +46,6 @@ 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 @@ -67,7 +66,6 @@ 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 @@ -82,7 +80,6 @@ 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" @@ -98,15 +95,11 @@ 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() - val download = songId?.let { downloads[it] } - val scope = rememberCoroutineScope() val song by produceState(null, sourceId, songId) { if (sourceId != null && songId != null) { @@ -116,14 +109,6 @@ 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, @@ -157,7 +142,7 @@ fun NowPlayingRoute( onPrevious = controller::previous, onShuffle = controller::toggleShuffle, onRepeat = controller::cycleRepeat, - onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } }, + onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail), 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 abf68595..cccd0281 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt @@ -47,9 +47,7 @@ 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 @@ -269,10 +267,8 @@ 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() 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. @@ -291,7 +287,6 @@ fun QueueRoute( currentSongId = playback.item?.id, shuffle = playback.shuffle, contextTitle = sourceTitle.value, - downloads = downloads, onClearUpNext = controller::clearUpNext, coverArt = sourceRepository::coverArt, onBack = onBack, @@ -326,7 +321,6 @@ fun QueueScreen( generation: Int = 0, shuffle: Boolean = false, contextTitle: String? = null, - downloads: Map = emptyMap(), onClearUpNext: () -> Unit = {}, ) { val listState = rememberLazyListState() @@ -443,7 +437,6 @@ fun QueueScreen( isPlaying = row.song.song.id == currentSongId, floating = isDragging, coverArt = coverArt, - download = downloads[row.song.song.id], dragHandle = if (!enabled) { null @@ -544,7 +537,6 @@ private fun QueueRowItem( isPlaying: Boolean, floating: Boolean, coverArt: (String?, Boolean) -> CoverArtRef?, - download: SongDownload?, dragHandle: Modifier?, onClick: () -> Unit, onRemove: () -> Unit, @@ -553,7 +545,6 @@ 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 0bbd7977..d36d2bae 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt @@ -6,7 +6,6 @@ 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 @@ -15,7 +14,6 @@ import kotlinx.coroutines.launch class SettingsViewModel( private val sourceRepository: SourceRepository, - private val downloadRepository: DownloadRepository, private val userPreferences: UserPreferences, ) : ViewModel() { val sources: StateFlow> = @@ -39,10 +37,7 @@ class SettingsViewModel( } fun deleteSource(id: Long) { - viewModelScope.launch { - downloadRepository.removeSource(id) - sourceRepository.deleteSource(id) - } + viewModelScope.launch { 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 deleted file mode 100644 index 744d3dbb..00000000 --- a/app/src/test/kotlin/com/subtracks/data/download/FakeDownloadEngine.kt +++ /dev/null @@ -1,45 +0,0 @@ -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/download/SystemDownloadEngineTest.kt b/app/src/test/kotlin/com/subtracks/data/download/SystemDownloadEngineTest.kt deleted file mode 100644 index ba4e90f4..00000000 --- a/app/src/test/kotlin/com/subtracks/data/download/SystemDownloadEngineTest.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.subtracks.data.download - -import android.app.DownloadManager -import org.junit.Assert.assertEquals -import org.junit.Test - -class SystemDownloadEngineTest { - @Test - fun successfulRunningAndFailedStatusesAreMapped() { - assertEquals(EngineStatus.Completed, engineStatus(DownloadManager.STATUS_SUCCESSFUL)) - assertEquals(EngineStatus.Running, engineStatus(DownloadManager.STATUS_RUNNING)) - assertEquals(EngineStatus.Failed, engineStatus(DownloadManager.STATUS_FAILED)) - } - - @Test - fun aWaitingDownloadIsPending() { - assertEquals(EngineStatus.Pending, engineStatus(DownloadManager.STATUS_PENDING)) - assertEquals(EngineStatus.Pending, engineStatus(DownloadManager.STATUS_PAUSED)) - assertEquals(EngineStatus.Pending, engineStatus(Int.MIN_VALUE)) - } - - @Test - fun failureReasonsReadAsSentences() { - assertEquals("Not enough space to download", failureMessage(DownloadManager.ERROR_INSUFFICIENT_SPACE)) - assertEquals("Storage is unavailable", failureMessage(DownloadManager.ERROR_DEVICE_NOT_FOUND)) - assertEquals("Download failed", failureMessage(DownloadManager.ERROR_UNKNOWN)) - } -} diff --git a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt deleted file mode 100644 index 3e9dbb80..00000000 --- a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt +++ /dev/null @@ -1,326 +0,0 @@ -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 aFailedDownloadIsNotRestartedByReconcile() = - runTest { - seedLibrary() - db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Failed, engineId = null, error = "boom")) - - runBlocking { repository.reconcile() } - runBlocking { repository.reconcile() } - - assertTrue(engine.requests.isEmpty()) - assertEquals(DownloadStatus.Failed, row(1, "s1")?.status) - assertEquals("boom", row(1, "s1")?.error) - } - - @Test - fun anEngineFailureIsMirroredWithItsMessageAndLeavesNoPartialFile() = - runTest { - seedLibrary() - runBlocking { repository.download(1, "s1") } - val id = engine.requests.single().first - writeFile(1, "s1", "half") - - engine.fail(id) - runBlocking { repository.reconcile() } - - assertEquals(DownloadStatus.Failed, row(1, "s1")?.status) - assertEquals("Download failed", row(1, "s1")?.error) - assertFalse(file(1, "s1").exists()) - } - - @Test - fun aRunningDownloadWhoseEngineEntryVanishesIsFailedRatherThanRestarted() = - 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 } - - engine.forget(id) - runBlocking { repository.reconcile() } - - assertEquals(DownloadStatus.Failed, row(1, "s1")?.status) - assertEquals(1, engine.requests.size) - } - - @Test - fun aSongRemovedFromTheServerTakesItsDownloadRowWithIt() = - runTest { - seedLibrary() - runBlocking { repository.download(1, "s1") } - - runBlocking { db.libraryDao().deleteSongs(1, listOf("s1")) } - - assertNull(row(1, "s1")) - } - - @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(1, "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/MediaDataSourceTest.kt b/app/src/test/kotlin/com/subtracks/playback/MediaDataSourceTest.kt deleted file mode 100644 index b1f93367..00000000 --- a/app/src/test/kotlin/com/subtracks/playback/MediaDataSourceTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.subtracks.playback - -import android.content.Context -import android.net.Uri -import androidx.media3.datasource.DataSpec -import androidx.media3.datasource.okhttp.OkHttpDataSource -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.subtracks.data.source.streamLengthSuffix -import okhttp3.OkHttpClient -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith -import java.io.File - -@RunWith(AndroidJUnit4::class) -class MediaDataSourceTest { - private val context = ApplicationProvider.getApplicationContext() - - private fun dataSource() = mediaDataSourceFactory(context, OkHttpDataSource.Factory(OkHttpClient())).createDataSource() - - @Test - fun aDownloadedFileIsReadThroughTheStreamingChain() { - val file = File(context.cacheDir, "downloaded-${System.nanoTime()}").apply { writeBytes(byteArrayOf(1, 2, 3, 4)) } - val dataSource = dataSource() - - val length = dataSource.open(DataSpec(Uri.fromFile(file))) - val buffer = ByteArray(4) - val read = dataSource.read(buffer, 0, buffer.size) - dataSource.close() - - assertEquals(4L, length) - assertEquals(4, read) - assertEquals(listOf(1, 2, 3, 4), buffer.toList()) - } - - @Test - fun aStreamWithoutAContentLengthGetsItsDeclaredLength() { - val server = MockWebServer() - server.enqueue(MockResponse().setChunkedBody("0123456789", 1)) - server.start() - val url = "http://${server.hostName}:${server.port}/rest/stream" + streamLengthSuffix(1_000_000) - val dataSource = dataSource() - - val length = dataSource.open(DataSpec(Uri.parse(url))) - dataSource.close() - server.shutdown() - - assertEquals(1_000_000L, length) - } -} diff --git a/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt b/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt deleted file mode 100644 index adaa0959..00000000 --- a/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -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 0c7be4f1..5f2b52fb 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -7,7 +7,6 @@ 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 @@ -17,7 +16,6 @@ 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 @@ -35,7 +33,6 @@ 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 @@ -48,7 +45,6 @@ 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() @@ -65,24 +61,15 @@ 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), - downloads, - showMessage = { messages += it }, - dispatcher = dispatcher, - ) + PlaybackController(sources, queues, FakePlayerConnection(handle), showMessage = { messages += it }, dispatcher = dispatcher) } @After fun tearDown() { controller.close() sources.close() - downloads.close() db.close() dispatcher.close() } @@ -563,7 +550,7 @@ class PlaybackControllerTest { runBlocking { queues.setPosition(12_000L) } val restoredHandle = FakePlayerHandle() - val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), downloads, dispatcher = dispatcher) + val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), dispatcher = dispatcher) restored.connect() await { restored.state.value.item @@ -671,7 +658,7 @@ class PlaybackControllerTest { block: Runnable, ) = block.run() } - val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline) + val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline) eager.playAlbum(1, "al1", 0) await { @@ -718,7 +705,7 @@ class PlaybackControllerTest { block: Runnable, ) = block.run() } - val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline) + val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline) eager.playAlbum(1, "al1", 0) await { @@ -761,7 +748,7 @@ class PlaybackControllerTest { block: Runnable, ) = block.run() } - val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline) + val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline) eager.playAlbum(1, "al1", 0) await { @@ -804,7 +791,7 @@ class PlaybackControllerTest { block: Runnable, ) = block.run() } - val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline) + val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline) eager.playAlbum(1, "al1", 0) await { @@ -1843,7 +1830,7 @@ class PlaybackControllerTest { runBlocking { queues.setPosition(12_000L) } val restoredHandle = FakePlayerHandle() - val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), downloads, dispatcher = dispatcher) + val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), dispatcher = dispatcher) restored.connect() await { restored.state.value.item @@ -1879,21 +1866,6 @@ 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 deleted file mode 100644 index cfb9eed0..00000000 --- a/app/src/test/kotlin/com/subtracks/ui/library/SongRowDownloadTest.kt +++ /dev/null @@ -1,73 +0,0 @@ -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 aRunningDownloadShowsProgressRatherThanTheDownloadedIndicator() { - render(download(DownloadStatus.Running)) - - composeRule.onNodeWithContentDescription("Downloaded").assertDoesNotExist() - composeRule.onNodeWithContentDescription("Downloading").assertIsDisplayed() - } - - @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 169af1a1..ac3a5c31 100644 --- a/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt @@ -6,13 +6,11 @@ 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 @@ -31,7 +29,6 @@ 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) @@ -55,8 +52,7 @@ class QueueViewModelTest { .build() val sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences()) queues = QueueRepository(db) - val downloads = DownloadRepository(db, sources, FakeDownloadEngine(), File(context.cacheDir, "downloads")) - controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), downloads, dispatcher = dispatcher) + controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), dispatcher = dispatcher) viewModel = QueueViewModel(queues, controller) }