Merge feature/progress-sync: reading and playback progress sync

This commit is contained in:
Atte149 2026-06-16 12:19:45 +03:00
commit 37dfd74fc9
24 changed files with 982 additions and 3 deletions

View file

@ -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')"
]
}
}

View file

@ -9,8 +9,10 @@ package org.dueattendant149.bookshelf
import android.app.Application import android.app.Application
import androidx.hilt.work.HiltWorkerFactory import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration import androidx.work.Configuration
import androidx.work.WorkManager
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import org.dueattendant149.bookshelf.core.crash.CrashHandler import org.dueattendant149.bookshelf.core.crash.CrashHandler
import org.dueattendant149.bookshelf.data.worker.ProgressSyncWorker
import javax.inject.Inject import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
@ -19,13 +21,17 @@ class Application : Application(), Configuration.Provider {
@Inject @Inject
lateinit var workerFactory: HiltWorkerFactory lateinit var workerFactory: HiltWorkerFactory
@Inject
lateinit var workManager: WorkManager
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this)) Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this))
ProgressSyncWorker.schedule(workManager)
} }
override val workManagerConfiguration: Configuration override val workManagerConfiguration: Configuration
get() = Configuration.Builder() get() = Configuration.Builder()
.setWorkerFactory(workerFactory) .setWorkerFactory(workerFactory)
.build() .build()
} }

View file

@ -66,6 +66,7 @@ object AppModule {
DatabaseHelper.MANUAL_MIGRATION_14_15, // remove author nullability from BookEntity DatabaseHelper.MANUAL_MIGRATION_14_15, // remove author nullability from BookEntity
DatabaseHelper.MANUAL_MIGRATION_15_16, // merge CategoryEntity and CategorySortEntity 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_16_17, // add remote bookshelf fields + audio/ebook/cache tables
DatabaseHelper.MANUAL_MIGRATION_17_18, // add audio progress fields
).allowMainThreadQueries().build().also { database -> ).allowMainThreadQueries().build().also { database ->
// Additional Migrations // Additional Migrations
DatabaseHelper.AUTO_MIGRATION_7_8.removeBooksDir(app) DatabaseHelper.AUTO_MIGRATION_7_8.removeBooksDir(app)

View file

@ -36,6 +36,7 @@ import org.dueattendant149.bookshelf.data.repository.ColorPresetRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl 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.RemoteLibraryRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.RemoteTtsRepositoryImpl import org.dueattendant149.bookshelf.data.repository.RemoteTtsRepositoryImpl
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository 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.FileSystemRepository
import org.dueattendant149.bookshelf.domain.repository.HistoryRepository import org.dueattendant149.bookshelf.domain.repository.HistoryRepository
import org.dueattendant149.bookshelf.domain.repository.PermissionRepository 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.RemoteLibraryRepository
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
import javax.inject.Singleton import javax.inject.Singleton
@ -119,6 +121,12 @@ abstract class RepositoryModule {
remoteTtsRepositoryImpl: RemoteTtsRepositoryImpl remoteTtsRepositoryImpl: RemoteTtsRepositoryImpl
): RemoteTtsRepository ): RemoteTtsRepository
@Binds
@Singleton
abstract fun bindProgressRepository(
progressRepositoryImpl: ProgressRepositoryImpl
): ProgressRepository
@Binds @Binds
@Singleton @Singleton
abstract fun bindBookMapper( abstract fun bindBookMapper(

View file

@ -39,6 +39,10 @@ data class BookEntity(
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
val audioDuration: Long = 0L, val audioDuration: Long = 0L,
@ColumnInfo(defaultValue = "") @ColumnInfo(defaultValue = "")
val audioCurrentFile: String = "",
@ColumnInfo(defaultValue = "0")
val audioCurrentPosition: Long = 0L,
@ColumnInfo(defaultValue = "")
val coverUrl: String = "", val coverUrl: String = "",
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
val lastSyncedAt: Long = 0L, val lastSyncedAt: Long = 0L,

View file

@ -38,6 +38,9 @@ interface BookDao {
@Query("SELECT * FROM bookentity WHERE libraryId=:libraryId") @Query("SELECT * FROM bookentity WHERE libraryId=:libraryId")
suspend fun findBooksByLibraryId(libraryId: String): List<BookEntity> suspend fun findBooksByLibraryId(libraryId: String): List<BookEntity>
@Query("SELECT * FROM bookentity WHERE remoteId != ''")
suspend fun findBooksWithRemoteId(): List<BookEntity>
@Delete @Delete
suspend fun deleteBook(book: BookEntity): Int suspend fun deleteBook(book: BookEntity): Int

View file

@ -34,7 +34,7 @@ import java.io.File
EbookFileEntity::class, EbookFileEntity::class,
CachedFileEntity::class, CachedFileEntity::class,
], ],
version = 17, version = 18,
autoMigrations = [ autoMigrations = [
AutoMigration(1, 2), AutoMigration(1, 2),
AutoMigration(2, 3), AutoMigration(2, 3),
@ -203,6 +203,17 @@ object DatabaseHelper {
@DeleteTable("CategorySortEntity") @DeleteTable("CategorySortEntity")
class AUTO_MIGRATION_15_16 : AutoMigrationSpec 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) { val MANUAL_MIGRATION_16_17 = object : Migration(16, 17) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
// Add remote / bookshelf columns to BookEntity // Add remote / bookshelf columns to BookEntity

View file

@ -32,6 +32,8 @@ class BookMapperImpl @Inject constructor() : BookMapper {
hasAudio = book.hasAudio, hasAudio = book.hasAudio,
hasEbook = book.hasEbook, hasEbook = book.hasEbook,
audioDuration = book.audioDuration, audioDuration = book.audioDuration,
audioCurrentFile = book.audioCurrentFile,
audioCurrentPosition = book.audioCurrentPosition,
coverUrl = book.coverUrl, coverUrl = book.coverUrl,
lastSyncedAt = book.lastSyncedAt, lastSyncedAt = book.lastSyncedAt,
) )
@ -59,6 +61,8 @@ class BookMapperImpl @Inject constructor() : BookMapper {
hasAudio = bookEntity.hasAudio, hasAudio = bookEntity.hasAudio,
hasEbook = bookEntity.hasEbook, hasEbook = bookEntity.hasEbook,
audioDuration = bookEntity.audioDuration, audioDuration = bookEntity.audioDuration,
audioCurrentFile = bookEntity.audioCurrentFile,
audioCurrentPosition = bookEntity.audioCurrentPosition,
coverUrl = bookEntity.coverUrl, coverUrl = bookEntity.coverUrl,
lastSyncedAt = bookEntity.lastSyncedAt, lastSyncedAt = bookEntity.lastSyncedAt,
) )

View file

@ -1,9 +1,12 @@
package org.dueattendant149.bookshelf.data.remote.audiobookshelf package org.dueattendant149.bookshelf.data.remote.audiobookshelf
import okhttp3.ResponseBody import okhttp3.ResponseBody
import org.dueattendant149.bookshelf.data.remote.audiobookshelf.PlaybackProgressUpdateRequest
import org.dueattendant149.bookshelf.data.remote.audiobookshelf.model.AbsItemResponse import org.dueattendant149.bookshelf.data.remote.audiobookshelf.model.AbsItemResponse
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Streaming import retrofit2.http.Streaming
@ -40,4 +43,10 @@ interface AudiobookshelfApiService {
suspend fun getItem( suspend fun getItem(
@Path("itemId") itemId: String, @Path("itemId") itemId: String,
): Response<AbsItemResponse> ): Response<AbsItemResponse>
@POST("api/me/progress/{itemId}")
suspend fun updateProgress(
@Path("itemId") itemId: String,
@Body request: PlaybackProgressUpdateRequest,
): Response<String>
} }

View file

@ -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,
)

View file

@ -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.DownloadResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse 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.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.PodcastRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastResponse 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.SearchRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest
@ -44,6 +46,18 @@ interface BookshelfApiService {
@Path("itemId") itemId: String, @Path("itemId") itemId: String,
): Response<String> ): Response<String>
@POST("api/v1/books/{itemId}/progress")
suspend fun updateReadingProgress(
@Path("itemId") itemId: String,
@Body request: ProgressUpdateRequest,
): Response<String>
@POST("api/v1/books/{itemId}/playback-progress")
suspend fun updatePlaybackProgress(
@Path("itemId") itemId: String,
@Body request: PlaybackProgressUpdateRequest,
): Response<String>
@GET("api/v1/books/library/{libraryId}/search") @GET("api/v1/books/library/{libraryId}/search")
suspend fun searchLibrary( suspend fun searchLibrary(
@Path("libraryId") libraryId: String, @Path("libraryId") libraryId: String,

View file

@ -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,
)

View file

@ -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,
)

View file

@ -92,4 +92,10 @@ class BookRepositoryImpl @Inject constructor(
} }
} }
} }
override suspend fun getBooksWithRemoteId(): Result<List<Book>> = runCatching {
withContext(Dispatchers.IO) {
database.bookDao.findBooksWithRemoteId().map { bookMapper.toBook(it) }
}
}
} }

View file

@ -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<Unit> = 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<Unit> = 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<List<Book>> = 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"
}
}

View file

@ -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<ProgressSyncWorker>(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"

View file

@ -38,6 +38,8 @@ data class Book(
val hasAudio: Boolean = false, val hasAudio: Boolean = false,
val hasEbook: Boolean = false, val hasEbook: Boolean = false,
val audioDuration: Long = 0L, val audioDuration: Long = 0L,
val audioCurrentFile: String = "",
val audioCurrentPosition: Long = 0L,
val coverUrl: String = "", val coverUrl: String = "",
val lastSyncedAt: Long = 0L, val lastSyncedAt: Long = 0L,
) : Parcelable { ) : Parcelable {
@ -60,6 +62,8 @@ data class Book(
hasAudio = false, hasAudio = false,
hasEbook = false, hasEbook = false,
audioDuration = 0L, audioDuration = 0L,
audioCurrentFile = "",
audioCurrentPosition = 0L,
coverUrl = "", coverUrl = "",
lastSyncedAt = 0L, lastSyncedAt = 0L,
) )

View file

@ -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,
)

View file

@ -43,4 +43,6 @@ interface BookRepository {
suspend fun getDefaultCover( suspend fun getDefaultCover(
book: Book book: Book
): Result<CoverImage?> ): Result<CoverImage?>
suspend fun getBooksWithRemoteId(): Result<List<Book>>
} }

View file

@ -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<Unit>
suspend fun syncPlaybackProgress(
book: Book,
currentFile: String,
position: Long,
duration: Long,
): Result<Unit>
suspend fun getBooksWithRemoteId(): Result<List<Book>>
}

View file

@ -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<Unit> {
if (book.remoteId.isBlank()) {
return Result.success(Unit)
}
return progressRepository.syncPlaybackProgress(
book = book,
currentFile = currentFile,
position = position,
duration = duration,
)
}
}

View file

@ -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<Unit> {
if (book.remoteId.isBlank()) {
return Result.success(Unit)
}
return progressRepository.syncReadingProgress(book, lastChapter)
}
}

View file

@ -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.GetTextUseCase
import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase 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.history.GetHistoryForBookUseCase
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
import org.dueattendant149.bookshelf.presentation.library.LibraryScreen import org.dueattendant149.bookshelf.presentation.library.LibraryScreen
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
@ -50,7 +51,8 @@ class ReaderModel @Inject constructor(
private val getTextUseCase: GetTextUseCase, private val getTextUseCase: GetTextUseCase,
private val getBookUseCase: GetBookUseCase, private val getBookUseCase: GetBookUseCase,
private val getHistoryForBookUseCase: GetHistoryForBookUseCase, private val getHistoryForBookUseCase: GetHistoryForBookUseCase,
private val getChapterProgressUseCase: GetChapterProgressUseCase private val getChapterProgressUseCase: GetChapterProgressUseCase,
private val syncReadingProgressUseCase: SyncReadingProgressUseCase,
) : ViewModel() { ) : ViewModel() {
private val mutex = Mutex() private val mutex = Mutex()
@ -177,6 +179,10 @@ class ReaderModel @Inject constructor(
} }
updateBookUseCase(_state.value.book) updateBookUseCase(_state.value.book)
syncReadingProgressUseCase(
_state.value.book,
_state.value.currentChapter?.title,
)
LibraryScreen.refreshListChannel.trySend(300) LibraryScreen.refreshListChannel.trySend(300)
HistoryScreen.refreshListChannel.trySend(300) HistoryScreen.refreshListChannel.trySend(300)
@ -286,6 +292,10 @@ class ReaderModel @Inject constructor(
} }
updateBookUseCase(_state.value.book) updateBookUseCase(_state.value.book)
syncReadingProgressUseCase(
_state.value.book,
_state.value.currentChapter?.title,
)
LibraryScreen.refreshListChannel.trySend(0) LibraryScreen.refreshListChannel.trySend(0)
HistoryScreen.refreshListChannel.trySend(0) HistoryScreen.refreshListChannel.trySend(0)
@ -448,6 +458,10 @@ class ReaderModel @Inject constructor(
} }
updateBookUseCase(_state.value.book) updateBookUseCase(_state.value.book)
syncReadingProgressUseCase(
_state.value.book,
_state.value.currentChapter?.title,
)
LibraryScreen.refreshListChannel.trySend(0) LibraryScreen.refreshListChannel.trySend(0)
HistoryScreen.refreshListChannel.trySend(0) HistoryScreen.refreshListChannel.trySend(0)

View file

@ -538,6 +538,8 @@
<string name="bookshelf_download_action">Download</string> <string name="bookshelf_download_action">Download</string>
<string name="bookshelf_delete_cache_action">Delete</string> <string name="bookshelf_delete_cache_action">Delete</string>
<string name="bookshelf_play_audio_action">Play</string> <string name="bookshelf_play_audio_action">Play</string>
<string name="progress_sync_failed">Failed to sync reading progress</string>
<string name="playback_progress_sync_failed">Failed to sync playback progress</string>
<string name="arrow_content_desc">Arrow</string> <string name="arrow_content_desc">Arrow</string>
<string name="reset_start_content_desc">Reset start guide</string> <string name="reset_start_content_desc">Reset start guide</string>
<string name="open_in_web_content_desc">Open in web</string> <string name="open_in_web_content_desc">Open in web</string>