diff --git a/app/schemas/org.dueattendant149.bookshelf.data.local.room.BookDatabase/18.json b/app/schemas/org.dueattendant149.bookshelf.data.local.room.BookDatabase/18.json new file mode 100644 index 00000000..42583404 --- /dev/null +++ b/app/schemas/org.dueattendant149.bookshelf.data.local.room.BookDatabase/18.json @@ -0,0 +1,500 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "989c23bd1fdbd69a7d65b94ae6cf381b", + "entities": [ + { + "tableName": "BookEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `author` TEXT NOT NULL, `description` TEXT, `filePath` TEXT NOT NULL, `scrollIndex` INTEGER NOT NULL, `scrollOffset` INTEGER NOT NULL, `progress` REAL NOT NULL, `image` TEXT, `categories` TEXT NOT NULL DEFAULT '[]', `remoteId` TEXT NOT NULL DEFAULT '', `libraryId` TEXT NOT NULL DEFAULT '', `mediaType` TEXT NOT NULL DEFAULT '', `hasAudio` INTEGER NOT NULL DEFAULT 0, `hasEbook` INTEGER NOT NULL DEFAULT 0, `audioDuration` INTEGER NOT NULL DEFAULT 0, `audioCurrentFile` TEXT NOT NULL DEFAULT '', `audioCurrentPosition` INTEGER NOT NULL DEFAULT 0, `coverUrl` TEXT NOT NULL DEFAULT '', `lastSyncedAt` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "filePath", + "columnName": "filePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scrollIndex", + "columnName": "scrollIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scrollOffset", + "columnName": "scrollOffset", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "libraryId", + "columnName": "libraryId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "hasAudio", + "columnName": "hasAudio", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hasEbook", + "columnName": "hasEbook", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "audioDuration", + "columnName": "audioDuration", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "audioCurrentFile", + "columnName": "audioCurrentFile", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "audioCurrentPosition", + "columnName": "audioCurrentPosition", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "HistoryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `time` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookId", + "columnName": "bookId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ColorPresetEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `backgroundColor` INTEGER NOT NULL, `fontColor` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `order` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundColor", + "columnName": "backgroundColor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fontColor", + "columnName": "fontColor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "CategoryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `sortOrder` TEXT NOT NULL DEFAULT 'LAST_READ', `sortOrderDescending` INTEGER NOT NULL DEFAULT 1)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'LAST_READ'" + }, + { + "fieldPath": "sortOrderDescending", + "columnName": "sortOrderDescending", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "audio_file", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, `duration` REAL NOT NULL, `order` INTEGER NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookId", + "columnName": "bookId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remotePath", + "columnName": "remotePath", + "affinity": "TEXT" + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_audio_file_bookId", + "unique": false, + "columnNames": [ + "bookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_audio_file_bookId` ON `${TABLE_NAME}` (`bookId`)" + } + ], + "foreignKeys": [ + { + "table": "BookEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ebook_file", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `format` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookId", + "columnName": "bookId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remotePath", + "columnName": "remotePath", + "affinity": "TEXT" + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ebook_file_bookId", + "unique": false, + "columnNames": [ + "bookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ebook_file_bookId` ON `${TABLE_NAME}` (`bookId`)" + } + ], + "foreignKeys": [ + { + "table": "BookEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "cached_file", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `type` TEXT NOT NULL, `remoteUrl` TEXT NOT NULL, `localPath` TEXT, `status` TEXT NOT NULL, `progress` REAL NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookId", + "columnName": "bookId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remoteUrl", + "columnName": "remoteUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_cached_file_bookId", + "unique": false, + "columnNames": [ + "bookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_bookId` ON `${TABLE_NAME}` (`bookId`)" + }, + { + "name": "index_cached_file_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_type` ON `${TABLE_NAME}` (`type`)" + } + ] + } + ], + "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, '989c23bd1fdbd69a7d65b94ae6cf381b')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookshelf/Application.kt b/app/src/main/java/org/dueattendant149/bookshelf/Application.kt index 08b839e1..36beddd6 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/Application.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/Application.kt @@ -9,8 +9,10 @@ package org.dueattendant149.bookshelf import android.app.Application import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration +import androidx.work.WorkManager import dagger.hilt.android.HiltAndroidApp import org.dueattendant149.bookshelf.core.crash.CrashHandler +import org.dueattendant149.bookshelf.data.worker.ProgressSyncWorker import javax.inject.Inject @HiltAndroidApp @@ -19,13 +21,17 @@ class Application : Application(), Configuration.Provider { @Inject lateinit var workerFactory: HiltWorkerFactory + @Inject + lateinit var workManager: WorkManager + override fun onCreate() { super.onCreate() Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this)) + ProgressSyncWorker.schedule(workManager) } override val workManagerConfiguration: Configuration get() = Configuration.Builder() .setWorkerFactory(workerFactory) .build() -} \ No newline at end of file +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/di/AppModule.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/di/AppModule.kt index 6e23b992..19092a5d 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/di/AppModule.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/di/AppModule.kt @@ -66,6 +66,7 @@ object AppModule { DatabaseHelper.MANUAL_MIGRATION_14_15, // remove author nullability from BookEntity DatabaseHelper.MANUAL_MIGRATION_15_16, // merge CategoryEntity and CategorySortEntity DatabaseHelper.MANUAL_MIGRATION_16_17, // add remote bookshelf fields + audio/ebook/cache tables + DatabaseHelper.MANUAL_MIGRATION_17_18, // add audio progress fields ).allowMainThreadQueries().build().also { database -> // Additional Migrations DatabaseHelper.AUTO_MIGRATION_7_8.removeBooksDir(app) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt index 5c77ac01..d3415c1f 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt @@ -36,6 +36,7 @@ import org.dueattendant149.bookshelf.data.repository.ColorPresetRepositoryImpl import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl +import org.dueattendant149.bookshelf.data.repository.ProgressRepositoryImpl import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl import org.dueattendant149.bookshelf.data.repository.RemoteTtsRepositoryImpl import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository @@ -46,6 +47,7 @@ import org.dueattendant149.bookshelf.domain.repository.ColorPresetRepository import org.dueattendant149.bookshelf.domain.repository.FileSystemRepository import org.dueattendant149.bookshelf.domain.repository.HistoryRepository import org.dueattendant149.bookshelf.domain.repository.PermissionRepository +import org.dueattendant149.bookshelf.domain.repository.ProgressRepository import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository import javax.inject.Singleton @@ -119,6 +121,12 @@ abstract class RepositoryModule { remoteTtsRepositoryImpl: RemoteTtsRepositoryImpl ): RemoteTtsRepository + @Binds + @Singleton + abstract fun bindProgressRepository( + progressRepositoryImpl: ProgressRepositoryImpl + ): ProgressRepository + @Binds @Singleton abstract fun bindBookMapper( diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/local/dto/BookEntity.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/local/dto/BookEntity.kt index be6056a9..330d94b0 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/local/dto/BookEntity.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/local/dto/BookEntity.kt @@ -39,6 +39,10 @@ data class BookEntity( @ColumnInfo(defaultValue = "0") val audioDuration: Long = 0L, @ColumnInfo(defaultValue = "") + val audioCurrentFile: String = "", + @ColumnInfo(defaultValue = "0") + val audioCurrentPosition: Long = 0L, + @ColumnInfo(defaultValue = "") val coverUrl: String = "", @ColumnInfo(defaultValue = "0") val lastSyncedAt: Long = 0L, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDao.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDao.kt index 55e71b78..4855ebb8 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDao.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDao.kt @@ -38,6 +38,9 @@ interface BookDao { @Query("SELECT * FROM bookentity WHERE libraryId=:libraryId") suspend fun findBooksByLibraryId(libraryId: String): List + @Query("SELECT * FROM bookentity WHERE remoteId != ''") + suspend fun findBooksWithRemoteId(): List + @Delete suspend fun deleteBook(book: BookEntity): Int diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDatabase.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDatabase.kt index 1495842b..4d89142a 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDatabase.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDatabase.kt @@ -34,7 +34,7 @@ import java.io.File EbookFileEntity::class, CachedFileEntity::class, ], - version = 17, + version = 18, autoMigrations = [ AutoMigration(1, 2), AutoMigration(2, 3), @@ -203,6 +203,17 @@ object DatabaseHelper { @DeleteTable("CategorySortEntity") class AUTO_MIGRATION_15_16 : AutoMigrationSpec + val MANUAL_MIGRATION_17_18 = object : Migration(17, 18) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "ALTER TABLE BookEntity ADD COLUMN audioCurrentFile TEXT NOT NULL DEFAULT ''" + ) + database.execSQL( + "ALTER TABLE BookEntity ADD COLUMN audioCurrentPosition INTEGER NOT NULL DEFAULT 0" + ) + } + } + val MANUAL_MIGRATION_16_17 = object : Migration(16, 17) { override fun migrate(database: SupportSQLiteDatabase) { // Add remote / bookshelf columns to BookEntity diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/mapper/book/BookMapperImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/mapper/book/BookMapperImpl.kt index 395bc2ba..22e5b3a0 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/mapper/book/BookMapperImpl.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/mapper/book/BookMapperImpl.kt @@ -32,6 +32,8 @@ class BookMapperImpl @Inject constructor() : BookMapper { hasAudio = book.hasAudio, hasEbook = book.hasEbook, audioDuration = book.audioDuration, + audioCurrentFile = book.audioCurrentFile, + audioCurrentPosition = book.audioCurrentPosition, coverUrl = book.coverUrl, lastSyncedAt = book.lastSyncedAt, ) @@ -59,6 +61,8 @@ class BookMapperImpl @Inject constructor() : BookMapper { hasAudio = bookEntity.hasAudio, hasEbook = bookEntity.hasEbook, audioDuration = bookEntity.audioDuration, + audioCurrentFile = bookEntity.audioCurrentFile, + audioCurrentPosition = bookEntity.audioCurrentPosition, coverUrl = bookEntity.coverUrl, lastSyncedAt = bookEntity.lastSyncedAt, ) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt index 6dcff1cd..061dfd13 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt @@ -1,9 +1,12 @@ package org.dueattendant149.bookshelf.data.remote.audiobookshelf import okhttp3.ResponseBody +import org.dueattendant149.bookshelf.data.remote.audiobookshelf.PlaybackProgressUpdateRequest import org.dueattendant149.bookshelf.data.remote.audiobookshelf.model.AbsItemResponse import retrofit2.Response +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Streaming @@ -40,4 +43,10 @@ interface AudiobookshelfApiService { suspend fun getItem( @Path("itemId") itemId: String, ): Response + + @POST("api/me/progress/{itemId}") + suspend fun updateProgress( + @Path("itemId") itemId: String, + @Body request: PlaybackProgressUpdateRequest, + ): Response } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt new file mode 100644 index 00000000..39764b3a --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt @@ -0,0 +1,21 @@ +package org.dueattendant149.bookshelf.data.remote.audiobookshelf + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Request body for updating media progress directly on an Audiobookshelf server. + * + * Field names follow the ABS `/api/me/progress/{itemId}` endpoint contract. + */ +@Serializable +data class PlaybackProgressUpdateRequest( + @SerialName("currentTime") + val currentTime: Double, + @SerialName("duration") + val duration: Double, + @SerialName("progress") + val progress: Double, + @SerialName("episodeId") + val episodeId: String? = null, +) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt index 2b10badb..44520196 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt @@ -6,8 +6,10 @@ import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequ import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastResponse +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.ProgressUpdateRequest import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest @@ -44,6 +46,18 @@ interface BookshelfApiService { @Path("itemId") itemId: String, ): Response + @POST("api/v1/books/{itemId}/progress") + suspend fun updateReadingProgress( + @Path("itemId") itemId: String, + @Body request: ProgressUpdateRequest, + ): Response + + @POST("api/v1/books/{itemId}/playback-progress") + suspend fun updatePlaybackProgress( + @Path("itemId") itemId: String, + @Body request: PlaybackProgressUpdateRequest, + ): Response + @GET("api/v1/books/library/{libraryId}/search") suspend fun searchLibrary( @Path("libraryId") libraryId: String, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt new file mode 100644 index 00000000..1735741e --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt @@ -0,0 +1,20 @@ +package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Request body for updating the audio playback progress of a book on the bookshelf-api. + */ +@Serializable +data class PlaybackProgressUpdateRequest( + val itemId: String, + val libraryId: String, + @SerialName("current_file") + val currentFile: String, + @SerialName("current_position") + val currentPosition: Long, + val duration: Long, + @SerialName("updated_at") + val updatedAt: Long, +) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt new file mode 100644 index 00000000..ca71749b --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt @@ -0,0 +1,20 @@ +package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Request body for updating the local reading progress of a book on the bookshelf-api. + */ +@Serializable +data class ProgressUpdateRequest( + val itemId: String, + val libraryId: String, + val scrollIndex: Int, + val scrollOffset: Int, + val progress: Float, + @SerialName("last_chapter") + val lastChapter: String? = null, + @SerialName("updated_at") + val updatedAt: Long, +) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/BookRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/BookRepositoryImpl.kt index e3cc4dce..feee1b91 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/BookRepositoryImpl.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/BookRepositoryImpl.kt @@ -92,4 +92,10 @@ class BookRepositoryImpl @Inject constructor( } } } + + override suspend fun getBooksWithRemoteId(): Result> = runCatching { + withContext(Dispatchers.IO) { + database.bookDao.findBooksWithRemoteId().map { bookMapper.toBook(it) } + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/ProgressRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/ProgressRepositoryImpl.kt new file mode 100644 index 00000000..67b6466d --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/ProgressRepositoryImpl.kt @@ -0,0 +1,110 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.data.repository + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.dueattendant149.bookshelf.data.local.room.BookDatabase +import org.dueattendant149.bookshelf.data.mapper.book.BookMapper +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.ProgressUpdateRequest +import org.dueattendant149.bookshelf.data.settings.ServerSettings +import org.dueattendant149.bookshelf.domain.model.library.Book +import org.dueattendant149.bookshelf.domain.repository.ProgressRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ProgressRepositoryImpl + @Inject + constructor( + private val database: BookDatabase, + private val bookMapper: BookMapper, + private val clientFactory: BookshelfApiClientFactory, + private val serverSettings: ServerSettings, + ) : ProgressRepository { + override suspend fun syncReadingProgress( + book: Book, + lastChapter: String?, + ): Result = runCatching { + withContext(Dispatchers.IO) { + val client = client() ?: return@withContext + val request = + ProgressUpdateRequest( + itemId = book.remoteId, + libraryId = book.libraryId, + scrollIndex = book.scrollIndex, + scrollOffset = book.scrollOffset, + progress = book.progress, + lastChapter = lastChapter, + updatedAt = System.currentTimeMillis(), + ) + + val response = client.updateReadingProgress(book.remoteId, request) + if (!response.isSuccessful) { + throw RuntimeException("Failed to sync reading progress: ${response.code()}") + } + + updateLastSyncedAt(book) + } + }.onFailure { + Log.e(TAG, "syncReadingProgress failed for ${book.remoteId}", it) + } + + override suspend fun syncPlaybackProgress( + book: Book, + currentFile: String, + position: Long, + duration: Long, + ): Result = runCatching { + withContext(Dispatchers.IO) { + val client = client() ?: return@withContext + val request = + PlaybackProgressUpdateRequest( + itemId = book.remoteId, + libraryId = book.libraryId, + currentFile = currentFile, + currentPosition = position, + duration = duration, + updatedAt = System.currentTimeMillis(), + ) + + val response = client.updatePlaybackProgress(book.remoteId, request) + if (!response.isSuccessful) { + throw RuntimeException("Failed to sync playback progress: ${response.code()}") + } + + updateLastSyncedAt(book) + } + }.onFailure { + Log.e(TAG, "syncPlaybackProgress failed for ${book.remoteId}", it) + } + + override suspend fun getBooksWithRemoteId(): Result> = runCatching { + withContext(Dispatchers.IO) { + database.bookDao.findBooksWithRemoteId().map { bookMapper.toBook(it) } + } + }.onFailure { + Log.e(TAG, "getBooksWithRemoteId failed", it) + } + + private suspend fun client() = + serverSettings.getBookshelfUrl()?.let { url -> + clientFactory.provideClient(url) + } + + private suspend fun updateLastSyncedAt(book: Book) { + val entity = bookMapper.toBookEntity(book.copy(lastSyncedAt = System.currentTimeMillis())) + database.bookDao.updateBook(entity) + } + + companion object { + private const val TAG = "ProgressRepository" + } + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/ProgressSyncWorker.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/ProgressSyncWorker.kt new file mode 100644 index 00000000..4faf9903 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/ProgressSyncWorker.kt @@ -0,0 +1,105 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.data.worker + +import android.content.Context +import android.util.Log +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase +import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase +import java.util.concurrent.TimeUnit + +/** + * Periodic worker that syncs reading and playback progress for all books + * that have a remote (bookshelf-api) identifier. + */ +class ProgressSyncWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + companion object { + const val WORK_NAME = "progress-sync-worker" + + fun schedule(workManager: WorkManager) { + val request = + PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints( + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ) + .build() + + workManager.enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } + } + + private val entryPoint: ProgressSyncEntryPoint by lazy { + EntryPointAccessors.fromApplication(applicationContext, ProgressSyncEntryPoint::class.java) + } + + override suspend fun doWork(): Result { + val syncReading = entryPoint.syncReadingProgressUseCase() + val syncPlayback = entryPoint.syncPlaybackProgressUseCase() + + val books = entryPoint.progressRepository().getBooksWithRemoteId().getOrElse { + Log.e(TAG, "Failed to load books with remote IDs", it) + return Result.retry() + } + + var failures = 0 + books.forEach { book -> + syncReading(book, lastChapter = null).onFailure { + Log.w(TAG, "Reading progress sync failed for ${book.remoteId}", it) + failures++ + } + + if (book.hasAudio && book.audioDuration > 0L) { + syncPlayback( + book = book, + currentFile = book.audioCurrentFile, + position = book.audioCurrentPosition, + duration = book.audioDuration, + ).onFailure { + Log.w(TAG, "Playback progress sync failed for ${book.remoteId}", it) + failures++ + } + } + } + + return if (failures == 0 || failures < books.size) { + Result.success() + } else { + Result.retry() + } + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface ProgressSyncEntryPoint { + fun syncReadingProgressUseCase(): SyncReadingProgressUseCase + fun syncPlaybackProgressUseCase(): SyncPlaybackProgressUseCase + fun progressRepository(): org.dueattendant149.bookshelf.domain.repository.ProgressRepository + } +} + +private const val TAG = "ProgressSyncWorker" diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/library/Book.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/library/Book.kt index 0993c05c..aaa25ee4 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/library/Book.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/library/Book.kt @@ -38,6 +38,8 @@ data class Book( val hasAudio: Boolean = false, val hasEbook: Boolean = false, val audioDuration: Long = 0L, + val audioCurrentFile: String = "", + val audioCurrentPosition: Long = 0L, val coverUrl: String = "", val lastSyncedAt: Long = 0L, ) : Parcelable { @@ -60,6 +62,8 @@ data class Book( hasAudio = false, hasEbook = false, audioDuration = 0L, + audioCurrentFile = "", + audioCurrentPosition = 0L, coverUrl = "", lastSyncedAt = 0L, ) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioPlayerProgress.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioPlayerProgress.kt new file mode 100644 index 00000000..2dec6b92 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioPlayerProgress.kt @@ -0,0 +1,29 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.domain.model.player + +/** + * Placeholder for the audio player progress state. + * + * TODO: When implementing the ExoPlayer/MediaSession audio player, + * hook [org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase] + * into the playback position update flow, e.g.: + * + * ``` + * val book = ... + * val currentFile = player.currentMediaItem?.mediaId ?: book.audioCurrentFile + * val position = player.currentPosition.coerceAtLeast(0L) + * val duration = player.duration.coerceAtLeast(book.audioDuration) + * syncPlaybackProgressUseCase(book, currentFile, position, duration) + * ``` + */ +data class AudioPlayerProgress( + val bookId: Int, + val currentFile: String, + val position: Long, + val duration: Long, +) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/BookRepository.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/BookRepository.kt index 35b71394..1c57a972 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/BookRepository.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/BookRepository.kt @@ -43,4 +43,6 @@ interface BookRepository { suspend fun getDefaultCover( book: Book ): Result + + suspend fun getBooksWithRemoteId(): Result> } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/ProgressRepository.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/ProgressRepository.kt new file mode 100644 index 00000000..e9f67284 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/ProgressRepository.kt @@ -0,0 +1,25 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.domain.repository + +import org.dueattendant149.bookshelf.domain.model.library.Book + +interface ProgressRepository { + suspend fun syncReadingProgress( + book: Book, + lastChapter: String? = null, + ): Result + + suspend fun syncPlaybackProgress( + book: Book, + currentFile: String, + position: Long, + duration: Long, + ): Result + + suspend fun getBooksWithRemoteId(): Result> +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/SyncPlaybackProgressUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/SyncPlaybackProgressUseCase.kt new file mode 100644 index 00000000..b246974b --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/SyncPlaybackProgressUseCase.kt @@ -0,0 +1,34 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.domain.use_case.remote + +import org.dueattendant149.bookshelf.domain.model.library.Book +import org.dueattendant149.bookshelf.domain.repository.ProgressRepository +import javax.inject.Inject + +class SyncPlaybackProgressUseCase + @Inject + constructor( + private val progressRepository: ProgressRepository, + ) { + suspend operator fun invoke( + book: Book, + currentFile: String, + position: Long, + duration: Long, + ): Result { + if (book.remoteId.isBlank()) { + return Result.success(Unit) + } + return progressRepository.syncPlaybackProgress( + book = book, + currentFile = currentFile, + position = position, + duration = duration, + ) + } + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/SyncReadingProgressUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/SyncReadingProgressUseCase.kt new file mode 100644 index 00000000..6187815c --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/SyncReadingProgressUseCase.kt @@ -0,0 +1,27 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.domain.use_case.remote + +import org.dueattendant149.bookshelf.domain.model.library.Book +import org.dueattendant149.bookshelf.domain.repository.ProgressRepository +import javax.inject.Inject + +class SyncReadingProgressUseCase + @Inject + constructor( + private val progressRepository: ProgressRepository, + ) { + suspend operator fun invoke( + book: Book, + lastChapter: String? = null, + ): Result { + if (book.remoteId.isBlank()) { + return Result.success(Unit) + } + return progressRepository.syncReadingProgress(book, lastChapter) + } + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderModel.kt index c045f8bd..a3e9f352 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderModel.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderModel.kt @@ -37,6 +37,7 @@ import org.dueattendant149.bookshelf.domain.use_case.book.GetChapterProgressUseC import org.dueattendant149.bookshelf.domain.use_case.book.GetTextUseCase import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase import org.dueattendant149.bookshelf.domain.use_case.history.GetHistoryForBookUseCase +import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase import org.dueattendant149.bookshelf.presentation.history.HistoryScreen import org.dueattendant149.bookshelf.presentation.library.LibraryScreen import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint @@ -50,7 +51,8 @@ class ReaderModel @Inject constructor( private val getTextUseCase: GetTextUseCase, private val getBookUseCase: GetBookUseCase, private val getHistoryForBookUseCase: GetHistoryForBookUseCase, - private val getChapterProgressUseCase: GetChapterProgressUseCase + private val getChapterProgressUseCase: GetChapterProgressUseCase, + private val syncReadingProgressUseCase: SyncReadingProgressUseCase, ) : ViewModel() { private val mutex = Mutex() @@ -177,6 +179,10 @@ class ReaderModel @Inject constructor( } updateBookUseCase(_state.value.book) + syncReadingProgressUseCase( + _state.value.book, + _state.value.currentChapter?.title, + ) LibraryScreen.refreshListChannel.trySend(300) HistoryScreen.refreshListChannel.trySend(300) @@ -286,6 +292,10 @@ class ReaderModel @Inject constructor( } updateBookUseCase(_state.value.book) + syncReadingProgressUseCase( + _state.value.book, + _state.value.currentChapter?.title, + ) LibraryScreen.refreshListChannel.trySend(0) HistoryScreen.refreshListChannel.trySend(0) @@ -448,6 +458,10 @@ class ReaderModel @Inject constructor( } updateBookUseCase(_state.value.book) + syncReadingProgressUseCase( + _state.value.book, + _state.value.currentChapter?.title, + ) LibraryScreen.refreshListChannel.trySend(0) HistoryScreen.refreshListChannel.trySend(0) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 36f37ab8..3342f532 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -538,6 +538,8 @@ Download Delete Play + Failed to sync reading progress + Failed to sync playback progress Arrow Reset start guide Open in web