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