Feature/update dependencies (#202)

This commit is contained in:
Max Grakov
2025-05-04 23:01:06 +02:00
committed by GitHub
parent 594652e8a3
commit 38888e902a
211 changed files with 9767 additions and 9229 deletions

View File

@@ -1,84 +1,12 @@
root = true
[{*.kts,*.kt}]
ij_kotlin_align_in_columns_case_branch = false
ij_kotlin_align_multiline_binary_operation = false
ij_kotlin_align_multiline_extends_list = false
ij_kotlin_align_multiline_method_parentheses = false
ij_kotlin_align_multiline_parameters = true
ij_kotlin_align_multiline_parameters_in_calls = false
ij_kotlin_assignment_wrap = off
ij_kotlin_blank_lines_after_class_header = 1
ij_kotlin_blank_lines_around_block_when_branches = 0
ij_kotlin_block_comment_at_first_column = true
ij_kotlin_call_parameters_new_line_after_left_paren = false
ij_kotlin_call_parameters_right_paren_on_new_line = false
ij_kotlin_call_parameters_wrap = off
ij_kotlin_catch_on_new_line = false
ij_kotlin_class_annotation_wrap = split_into_lines
ij_kotlin_continuation_indent_for_chained_calls = true
ij_kotlin_continuation_indent_for_expression_bodies = true
ij_kotlin_continuation_indent_in_argument_lists = true
ij_kotlin_continuation_indent_in_elvis = true
ij_kotlin_continuation_indent_in_if_conditions = true
ij_kotlin_continuation_indent_in_parameter_lists = true
ij_kotlin_continuation_indent_in_supertype_lists = true
ij_kotlin_else_on_new_line = false
ij_kotlin_enum_constants_wrap = off
ij_kotlin_extends_list_wrap = off
ij_kotlin_field_annotation_wrap = split_into_lines
ij_kotlin_finally_on_new_line = false
ij_kotlin_if_rparen_on_new_line = false
ij_kotlin_import_nested_classes = false
ij_kotlin_insert_whitespaces_in_simple_one_line_method = true
ij_kotlin_keep_blank_lines_before_right_brace = 2
ij_kotlin_keep_blank_lines_in_code = 2
ij_kotlin_keep_blank_lines_in_declarations = 2
ij_kotlin_keep_first_column_comment = true
ij_kotlin_keep_indents_on_empty_lines = false
ij_kotlin_keep_line_breaks = true
ij_kotlin_lbrace_on_next_line = false
ij_kotlin_line_comment_add_space = false
ij_kotlin_line_comment_at_first_column = true
ij_kotlin_method_annotation_wrap = split_into_lines
ij_kotlin_method_call_chain_wrap = off
ij_kotlin_method_parameters_new_line_after_left_paren = false
ij_kotlin_method_parameters_right_paren_on_new_line = false
ij_kotlin_method_parameters_wrap = off
ij_kotlin_name_count_to_use_star_import = 100
ij_kotlin_name_count_to_use_star_import_for_members = 100
ij_kotlin_parameter_annotation_wrap = off
ij_kotlin_space_after_comma = true
ij_kotlin_space_after_extend_colon = true
ij_kotlin_space_after_type_colon = true
ij_kotlin_space_before_catch_parentheses = true
ij_kotlin_space_before_comma = false
ij_kotlin_space_before_extend_colon = true
ij_kotlin_space_before_for_parentheses = true
ij_kotlin_space_before_if_parentheses = true
ij_kotlin_space_before_lambda_arrow = true
ij_kotlin_space_before_type_colon = false
ij_kotlin_space_before_when_parentheses = true
ij_kotlin_space_before_while_parentheses = true
ij_kotlin_spaces_around_additive_operators = true
ij_kotlin_spaces_around_assignment_operators = true
ij_kotlin_spaces_around_equality_operators = true
ij_kotlin_spaces_around_function_type_arrow = true
ij_kotlin_spaces_around_logical_operators = true
ij_kotlin_spaces_around_multiplicative_operators = true
ij_kotlin_spaces_around_range = false
ij_kotlin_spaces_around_relational_operators = true
ij_kotlin_spaces_around_unary_operator = false
ij_kotlin_spaces_around_when_arrow = true
ij_kotlin_variable_annotation_wrap = off
ij_kotlin_while_on_new_line = false
ij_kotlin_wrap_elvis_expressions = 1
ij_kotlin_wrap_expression_body_functions = 0
ij_kotlin_wrap_first_method_in_call_chain = false
[*.{kt,kts}]
indent_style = space
indent_size = 2
max_line_length = 140
insert_final_newline = true
ktlint_standard_function-naming = disabled
ktlint_standard_property-naming = disabled
ktlint_standard_backing-property-naming = disabled
ij_xml_block_comment_at_first_column = true
ij_xml_keep_indents_on_empty_lines = false
ij_xml_line_comment_at_first_column = true
ij_properties_align_group_field_declarations = false

View File

@@ -6,14 +6,17 @@ plugins {
alias(libs.plugins.compose.compiler)
id("com.google.dagger.hilt.android")
id("org.jmailen.kotlinter") version "3.15.0"
id("org.jmailen.kotlinter") version "5.0.2"
id("com.google.devtools.ksp")
}
kotlinter {
reporters = arrayOf("checkstyle", "plain")
ignoreFormatFailures = false
ignoreLintFailures = false
}
tasks.lintKotlinMain {
dependsOn(tasks.formatKotlinMain)
}

View File

@@ -14,51 +14,50 @@ import javax.inject.Inject
@HiltAndroidApp
class LissenApplication : Application() {
@Inject
lateinit var runningComponents: Set<@JvmSuppressWildcards RunningComponent>
@Inject
lateinit var runningComponents: Set<@JvmSuppressWildcards RunningComponent>
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
initCrashReporting()
}
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
initCrashReporting()
override fun onCreate() {
super.onCreate()
appContext = applicationContext
runningComponents.forEach { it.onCreate() }
}
private fun initCrashReporting() {
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
httpSender {
uri = "https://acrarium.grakovne.org/report"
basicAuthLogin = BuildConfig.ACRA_REPORT_LOGIN
basicAuthPassword = BuildConfig.ACRA_REPORT_PASSWORD
httpMethod = HttpSender.Method.POST
dropReportsOnTimeout = false
}
toast {
text = getString(R.string.app_crach_toast)
}
reportContent =
listOf(
ReportField.APP_VERSION_NAME,
ReportField.APP_VERSION_CODE,
ReportField.ANDROID_VERSION,
ReportField.PHONE_MODEL,
ReportField.STACK_TRACE,
)
}
}
override fun onCreate() {
super.onCreate()
appContext = applicationContext
runningComponents.forEach { it.onCreate() }
}
private fun initCrashReporting() {
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
httpSender {
uri = "https://acrarium.grakovne.org/report"
basicAuthLogin = BuildConfig.ACRA_REPORT_LOGIN
basicAuthPassword = BuildConfig.ACRA_REPORT_PASSWORD
httpMethod = HttpSender.Method.POST
dropReportsOnTimeout = false
}
toast {
text = getString(R.string.app_crach_toast)
}
reportContent = listOf(
ReportField.APP_VERSION_NAME,
ReportField.APP_VERSION_CODE,
ReportField.ANDROID_VERSION,
ReportField.PHONE_MODEL,
ReportField.STACK_TRACE,
)
}
}
companion object {
lateinit var appContext: Context
private set
}
companion object {
lateinit var appContext: Context
private set
}
}

View File

@@ -14,28 +14,30 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AudiobookshelfChannelProvider @Inject constructor(
class AudiobookshelfChannelProvider
@Inject
constructor(
private val podcastAudiobookshelfChannel: PodcastAudiobookshelfChannel,
private val libraryAudiobookshelfChannel: LibraryAudiobookshelfChannel,
private val unknownAudiobookshelfChannel: UnknownAudiobookshelfChannel,
private val audiobookshelfAuthService: AudiobookshelfAuthService,
private val sharedPreferences: LissenSharedPreferences,
) : ChannelProvider {
) : ChannelProvider {
override fun provideMediaChannel(): MediaChannel {
val libraryType = sharedPreferences
.getPreferredLibrary()
?.type
?: LibraryType.UNKNOWN
val libraryType =
sharedPreferences
.getPreferredLibrary()
?.type
?: LibraryType.UNKNOWN
return when (libraryType) {
LibraryType.LIBRARY -> libraryAudiobookshelfChannel
LibraryType.PODCAST -> podcastAudiobookshelfChannel
LibraryType.UNKNOWN -> unknownAudiobookshelfChannel
}
return when (libraryType) {
LibraryType.LIBRARY -> libraryAudiobookshelfChannel
LibraryType.PODCAST -> podcastAudiobookshelfChannel
LibraryType.UNKNOWN -> unknownAudiobookshelfChannel
}
}
override fun provideChannelAuth(): ChannelAuthService = audiobookshelfAuthService
override fun getChannelCode(): ChannelCode = ChannelCode.AUDIOBOOKSHELF
}
}

View File

@@ -20,70 +20,71 @@ import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences
import java.io.InputStream
abstract class AudiobookshelfChannel(
protected val dataRepository: AudioBookshelfDataRepository,
protected val sessionResponseConverter: PlaybackSessionResponseConverter,
protected val preferences: LissenSharedPreferences,
private val syncService: AudioBookshelfSyncService,
private val libraryResponseConverter: LibraryResponseConverter,
private val mediaRepository: AudioBookshelfMediaRepository,
private val recentBookResponseConverter: RecentListeningResponseConverter,
private val connectionInfoResponseConverter: ConnectionInfoResponseConverter,
protected val dataRepository: AudioBookshelfDataRepository,
protected val sessionResponseConverter: PlaybackSessionResponseConverter,
protected val preferences: LissenSharedPreferences,
private val syncService: AudioBookshelfSyncService,
private val libraryResponseConverter: LibraryResponseConverter,
private val mediaRepository: AudioBookshelfMediaRepository,
private val recentBookResponseConverter: RecentListeningResponseConverter,
private val connectionInfoResponseConverter: ConnectionInfoResponseConverter,
) : MediaChannel {
override fun provideFileUri(
libraryItemId: String,
fileId: String,
): Uri {
val host = preferences.getHost() ?: error("Host is null")
override fun provideFileUri(
libraryItemId: String,
fileId: String,
): Uri {
val host = preferences.getHost() ?: error("Host is null")
return host
.toUri()
.buildUpon()
.appendPath("api")
.appendPath("items")
.appendPath(libraryItemId)
.appendPath("file")
.appendPath(fileId)
.appendQueryParameter("token", preferences.getToken())
.build()
}
return host.toUri()
.buildUpon()
.appendPath("api")
.appendPath("items")
.appendPath(libraryItemId)
.appendPath("file")
.appendPath(fileId)
.appendQueryParameter("token", preferences.getToken())
.build()
}
override suspend fun syncProgress(
sessionId: String,
progress: PlaybackProgress,
): ApiResult<Unit> = syncService.syncProgress(sessionId, progress)
override suspend fun syncProgress(
sessionId: String,
progress: PlaybackProgress,
): ApiResult<Unit> = syncService.syncProgress(sessionId, progress)
override suspend fun fetchBookCover(bookId: String): ApiResult<InputStream> = mediaRepository.fetchBookCover(bookId)
override suspend fun fetchBookCover(
bookId: String,
): ApiResult<InputStream> = mediaRepository.fetchBookCover(bookId)
override suspend fun fetchLibraries(): ApiResult<List<Library>> =
dataRepository
.fetchLibraries()
.map { libraryResponseConverter.apply(it) }
override suspend fun fetchLibraries(): ApiResult<List<Library>> = dataRepository
.fetchLibraries()
.map { libraryResponseConverter.apply(it) }
override suspend fun fetchRecentListenedBooks(libraryId: String): ApiResult<List<RecentBook>> {
val progress: Map<String, Pair<Long, Double>> =
dataRepository
.fetchUserInfoResponse()
.fold(
onSuccess = {
it
.user
.mediaProgress
?.groupBy { item -> item.libraryItemId }
?.map { (item, value) -> item to value.maxBy { progress -> progress.lastUpdate } }
?.associate { (item, progress) -> item to (progress.lastUpdate to progress.progress) }
?: emptyMap()
},
onFailure = { emptyMap() },
)
override suspend fun fetchRecentListenedBooks(libraryId: String): ApiResult<List<RecentBook>> {
val progress: Map<String, Pair<Long, Double>> = dataRepository
.fetchUserInfoResponse()
.fold(
onSuccess = {
it
.user
.mediaProgress
?.groupBy { item -> item.libraryItemId }
?.map { (item, value) -> item to value.maxBy { progress -> progress.lastUpdate } }
?.associate { (item, progress) -> item to (progress.lastUpdate to progress.progress) }
?: emptyMap()
},
onFailure = { emptyMap() },
)
return dataRepository
.fetchPersonalizedFeed(libraryId)
.map { recentBookResponseConverter.apply(it, progress) }
}
return dataRepository
.fetchPersonalizedFeed(libraryId)
.map { recentBookResponseConverter.apply(it, progress) }
}
override suspend fun fetchConnectionInfo(): ApiResult<ConnectionInfo> =
dataRepository
.fetchConnectionInfo()
.map { connectionInfoResponseConverter.apply(it) }
override suspend fun fetchConnectionInfo(): ApiResult<ConnectionInfo> = dataRepository
.fetchConnectionInfo()
.map { connectionInfoResponseConverter.apply(it) }
protected fun getClientName() = "Lissen App ${BuildConfig.VERSION_NAME}"
protected fun getClientName() = "Lissen App ${BuildConfig.VERSION_NAME}"
}

View File

@@ -19,7 +19,9 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UnknownAudiobookshelfChannel @Inject constructor(
class UnknownAudiobookshelfChannel
@Inject
constructor(
dataRepository: AudioBookshelfDataRepository,
mediaRepository: AudioBookshelfMediaRepository,
recentListeningResponseConverter: RecentListeningResponseConverter,
@@ -28,39 +30,36 @@ class UnknownAudiobookshelfChannel @Inject constructor(
sessionResponseConverter: PlaybackSessionResponseConverter,
libraryResponseConverter: LibraryResponseConverter,
connectionInfoResponseConverter: ConnectionInfoResponseConverter,
) : AudiobookshelfChannel(
dataRepository = dataRepository,
mediaRepository = mediaRepository,
recentBookResponseConverter = recentListeningResponseConverter,
sessionResponseConverter = sessionResponseConverter,
preferences = preferences,
syncService = syncService,
libraryResponseConverter = libraryResponseConverter,
connectionInfoResponseConverter = connectionInfoResponseConverter,
) {
) : AudiobookshelfChannel(
dataRepository = dataRepository,
mediaRepository = mediaRepository,
recentBookResponseConverter = recentListeningResponseConverter,
sessionResponseConverter = sessionResponseConverter,
preferences = preferences,
syncService = syncService,
libraryResponseConverter = libraryResponseConverter,
connectionInfoResponseConverter = connectionInfoResponseConverter,
) {
override fun getLibraryType(): LibraryType = LibraryType.UNKNOWN
override suspend fun fetchBooks(
libraryId: String,
pageSize: Int,
pageNumber: Int,
libraryId: String,
pageSize: Int,
pageNumber: Int,
): ApiResult<PagedItems<Book>> = ApiResult.Error(ApiError.UnsupportedError)
override suspend fun searchBooks(
libraryId: String,
query: String,
limit: Int,
libraryId: String,
query: String,
limit: Int,
): ApiResult<List<Book>> = ApiResult.Error(ApiError.UnsupportedError)
override suspend fun startPlayback(
bookId: String,
episodeId: String,
supportedMimeTypes: List<String>,
deviceId: String,
bookId: String,
episodeId: String,
supportedMimeTypes: List<String>,
deviceId: String,
): ApiResult<PlaybackSession> = ApiResult.Error(ApiError.UnsupportedError)
override suspend fun fetchBook(
bookId: String,
): ApiResult<DetailedItem> = ApiResult.Error(ApiError.UnsupportedError)
}
override suspend fun fetchBook(bookId: String): ApiResult<DetailedItem> = ApiResult.Error(ApiError.UnsupportedError)
}

View File

@@ -5,7 +5,7 @@ import org.grakovne.lissen.domain.connection.ServerRequestHeader
@Keep
data class ApiClientConfig(
val host: String?,
val token: String?,
val customHeaders: List<ServerRequestHeader>?,
val host: String?,
val token: String?,
val customHeaders: List<ServerRequestHeader>?,
)

View File

@@ -23,168 +23,174 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AudioBookshelfDataRepository @Inject constructor(
class AudioBookshelfDataRepository
@Inject
constructor(
private val preferences: LissenSharedPreferences,
private val requestHeadersProvider: RequestHeadersProvider,
) {
) {
private var configCache: ApiClientConfig? = null
private var clientCache: AudiobookshelfApiClient? = null
suspend fun fetchLibraries(): ApiResult<LibraryResponse> =
safeApiCall { getClientInstance().fetchLibraries() }
suspend fun fetchLibraries(): ApiResult<LibraryResponse> = safeApiCall { getClientInstance().fetchLibraries() }
suspend fun fetchAuthorItems(
authorId: String,
): ApiResult<AuthorItemsResponse> = safeApiCall {
suspend fun fetchAuthorItems(authorId: String): ApiResult<AuthorItemsResponse> =
safeApiCall {
getClientInstance()
.fetchAuthorLibraryItems(
authorId = authorId,
)
}
.fetchAuthorLibraryItems(
authorId = authorId,
)
}
suspend fun searchPodcasts(
libraryId: String,
query: String,
limit: Int,
): ApiResult<PodcastSearchResponse> = safeApiCall {
libraryId: String,
query: String,
limit: Int,
): ApiResult<PodcastSearchResponse> =
safeApiCall {
getClientInstance()
.searchPodcasts(
libraryId = libraryId,
request = query,
limit = limit,
)
}
.searchPodcasts(
libraryId = libraryId,
request = query,
limit = limit,
)
}
suspend fun searchBooks(
libraryId: String,
query: String,
limit: Int,
): ApiResult<LibrarySearchResponse> = safeApiCall {
libraryId: String,
query: String,
limit: Int,
): ApiResult<LibrarySearchResponse> =
safeApiCall {
getClientInstance()
.searchLibraryItems(
libraryId = libraryId,
request = query,
limit = limit,
)
}
.searchLibraryItems(
libraryId = libraryId,
request = query,
limit = limit,
)
}
suspend fun fetchLibraryItems(
libraryId: String,
pageSize: Int,
pageNumber: Int,
sort: String,
direction: String,
libraryId: String,
pageSize: Int,
pageNumber: Int,
sort: String,
direction: String,
): ApiResult<LibraryItemsResponse> =
safeApiCall {
getClientInstance()
.fetchLibraryItems(
libraryId = libraryId,
pageSize = pageSize,
pageNumber = pageNumber,
sort = sort,
desc = direction,
)
}
safeApiCall {
getClientInstance()
.fetchLibraryItems(
libraryId = libraryId,
pageSize = pageSize,
pageNumber = pageNumber,
sort = sort,
desc = direction,
)
}
suspend fun fetchPodcastItems(
libraryId: String,
pageSize: Int,
pageNumber: Int,
sort: String,
direction: String,
libraryId: String,
pageSize: Int,
pageNumber: Int,
sort: String,
direction: String,
): ApiResult<PodcastItemsResponse> =
safeApiCall {
getClientInstance()
.fetchPodcastItems(
libraryId = libraryId,
pageSize = pageSize,
pageNumber = pageNumber,
sort = sort,
desc = direction,
)
}
safeApiCall {
getClientInstance()
.fetchPodcastItems(
libraryId = libraryId,
pageSize = pageSize,
pageNumber = pageNumber,
sort = sort,
desc = direction,
)
}
suspend fun fetchBook(itemId: String): ApiResult<BookResponse> =
safeApiCall { getClientInstance().fetchLibraryItem(itemId) }
safeApiCall {
getClientInstance().fetchLibraryItem(itemId)
}
suspend fun fetchPodcastItem(itemId: String): ApiResult<PodcastResponse> =
safeApiCall { getClientInstance().fetchPodcastEpisode(itemId) }
safeApiCall { getClientInstance().fetchPodcastEpisode(itemId) }
suspend fun fetchConnectionInfo(): ApiResult<ConnectionInfoResponse> =
safeApiCall { getClientInstance().fetchConnectionInfo() }
safeApiCall {
getClientInstance().fetchConnectionInfo()
}
suspend fun fetchPersonalizedFeed(libraryId: String): ApiResult<List<PersonalizedFeedResponse>> =
safeApiCall { getClientInstance().fetchPersonalizedFeed(libraryId) }
safeApiCall { getClientInstance().fetchPersonalizedFeed(libraryId) }
suspend fun fetchLibraryItemProgress(itemId: String): ApiResult<MediaProgressResponse> =
safeApiCall { getClientInstance().fetchLibraryItemProgress(itemId) }
safeApiCall { getClientInstance().fetchLibraryItemProgress(itemId) }
suspend fun fetchUserInfoResponse(): ApiResult<UserInfoResponse> =
safeApiCall { getClientInstance().fetchUserInfo() }
suspend fun fetchUserInfoResponse(): ApiResult<UserInfoResponse> = safeApiCall { getClientInstance().fetchUserInfo() }
suspend fun startPlayback(
itemId: String,
request: PlaybackStartRequest,
): ApiResult<PlaybackSessionResponse> =
safeApiCall { getClientInstance().startLibraryPlayback(itemId, request) }
itemId: String,
request: PlaybackStartRequest,
): ApiResult<PlaybackSessionResponse> = safeApiCall { getClientInstance().startLibraryPlayback(itemId, request) }
suspend fun startPodcastPlayback(
itemId: String,
episodeId: String,
request: PlaybackStartRequest,
itemId: String,
episodeId: String,
request: PlaybackStartRequest,
): ApiResult<PlaybackSessionResponse> =
safeApiCall { getClientInstance().startPodcastPlayback(itemId, episodeId, request) }
safeApiCall {
getClientInstance().startPodcastPlayback(itemId, episodeId, request)
}
suspend fun publishLibraryItemProgress(
itemId: String,
progress: ProgressSyncRequest,
): ApiResult<Unit> =
safeApiCall { getClientInstance().publishLibraryItemProgress(itemId, progress) }
itemId: String,
progress: ProgressSyncRequest,
): ApiResult<Unit> = safeApiCall { getClientInstance().publishLibraryItemProgress(itemId, progress) }
private fun getClientInstance(): AudiobookshelfApiClient {
val host = preferences.getHost()
val token = preferences.getToken()
val host = preferences.getHost()
val token = preferences.getToken()
val cache = ApiClientConfig(
host = host,
token = token,
customHeaders = requestHeadersProvider.fetchRequestHeaders(),
val cache =
ApiClientConfig(
host = host,
token = token,
customHeaders = requestHeadersProvider.fetchRequestHeaders(),
)
val currentClientCache = clientCache
val currentClientCache = clientCache
return when (currentClientCache == null || cache != configCache) {
true -> {
val instance = createClientInstance()
configCache = cache
clientCache = instance
instance
}
else -> currentClientCache
return when (currentClientCache == null || cache != configCache) {
true -> {
val instance = createClientInstance()
configCache = cache
clientCache = instance
instance
}
else -> currentClientCache
}
}
private fun createClientInstance(): AudiobookshelfApiClient {
val host = preferences.getHost()
val token = preferences.getToken()
val host = preferences.getHost()
val token = preferences.getToken()
if (host.isNullOrBlank() || token.isNullOrBlank()) {
throw IllegalStateException("Host or token is missing")
}
if (host.isNullOrBlank() || token.isNullOrBlank()) {
throw IllegalStateException("Host or token is missing")
}
return apiClient(host, token)
.retrofit
.create(AudiobookshelfApiClient::class.java)
return apiClient(host, token)
.retrofit
.create(AudiobookshelfApiClient::class.java)
}
private fun apiClient(
host: String,
token: String?,
): ApiClient = ApiClient(
host: String,
token: String?,
): ApiClient =
ApiClient(
host = host,
token = token,
requestHeaders = requestHeadersProvider.fetchRequestHeaders(),
)
}
)
}

View File

@@ -14,93 +14,96 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AudioBookshelfMediaRepository @Inject constructor(
class AudioBookshelfMediaRepository
@Inject
constructor(
private val preferences: LissenSharedPreferences,
private val requestHeadersProvider: RequestHeadersProvider,
) {
) {
private var configCache: ApiClientConfig? = null
private var clientCache: AudiobookshelfMediaClient? = null
suspend fun fetchBookCover(itemId: String): ApiResult<InputStream> =
safeCall { getClientInstance().getItemCover(itemId) }
safeCall {
getClientInstance().getItemCover(itemId)
}
private suspend fun safeCall(
apiCall: suspend () -> Response<ResponseBody>,
): ApiResult<InputStream> {
return try {
val response = apiCall.invoke()
private suspend fun safeCall(apiCall: suspend () -> Response<ResponseBody>): ApiResult<InputStream> {
return try {
val response = apiCall.invoke()
return when (response.code()) {
200 -> when (val body = response.body()) {
null -> ApiResult.Error(ApiError.InternalError)
else -> ApiResult.Success(body.byteStream())
}
400 -> ApiResult.Error(ApiError.InternalError)
401 -> ApiResult.Error(ApiError.Unauthorized)
403 -> ApiResult.Error(ApiError.Unauthorized)
404 -> ApiResult.Error(ApiError.InternalError)
500 -> ApiResult.Error(ApiError.InternalError)
else -> ApiResult.Error(ApiError.InternalError)
return when (response.code()) {
200 ->
when (val body = response.body()) {
null -> ApiResult.Error(ApiError.InternalError)
else -> ApiResult.Success(body.byteStream())
}
} catch (e: IOException) {
Log.e(TAG, "Unable to make network api call $apiCall due to: $e")
ApiResult.Error(ApiError.NetworkError)
} catch (e: Exception) {
Log.e(TAG, "Unable to make network api call $apiCall due to: $e")
ApiResult.Error(ApiError.InternalError)
400 -> ApiResult.Error(ApiError.InternalError)
401 -> ApiResult.Error(ApiError.Unauthorized)
403 -> ApiResult.Error(ApiError.Unauthorized)
404 -> ApiResult.Error(ApiError.InternalError)
500 -> ApiResult.Error(ApiError.InternalError)
else -> ApiResult.Error(ApiError.InternalError)
}
} catch (e: IOException) {
Log.e(TAG, "Unable to make network api call $apiCall due to: $e")
ApiResult.Error(ApiError.NetworkError)
} catch (e: Exception) {
Log.e(TAG, "Unable to make network api call $apiCall due to: $e")
ApiResult.Error(ApiError.InternalError)
}
}
private fun getClientInstance(): AudiobookshelfMediaClient {
val host = preferences.getHost()
val token = preferences.getToken()
val host = preferences.getHost()
val token = preferences.getToken()
val cache = ApiClientConfig(
host = host,
token = token,
customHeaders = requestHeadersProvider.fetchRequestHeaders(),
val cache =
ApiClientConfig(
host = host,
token = token,
customHeaders = requestHeadersProvider.fetchRequestHeaders(),
)
val currentClientCache = clientCache
val currentClientCache = clientCache
return when (currentClientCache == null || cache != configCache) {
true -> {
val instance = createClientInstance()
configCache = cache
clientCache = instance
instance
}
else -> currentClientCache
return when (currentClientCache == null || cache != configCache) {
true -> {
val instance = createClientInstance()
configCache = cache
clientCache = instance
instance
}
else -> currentClientCache
}
}
private fun createClientInstance(): AudiobookshelfMediaClient {
val host = preferences.getHost()
val token = preferences.getToken()
val host = preferences.getHost()
val token = preferences.getToken()
if (host.isNullOrBlank() || token.isNullOrBlank()) {
throw IllegalStateException("Host or token is missing")
}
if (host.isNullOrBlank() || token.isNullOrBlank()) {
throw IllegalStateException("Host or token is missing")
}
return apiClient(host, token)
.retrofit
.create(AudiobookshelfMediaClient::class.java)
return apiClient(host, token)
.retrofit
.create(AudiobookshelfMediaClient::class.java)
}
private fun apiClient(
host: String,
token: String,
): BinaryApiClient = BinaryApiClient(
host: String,
token: String,
): BinaryApiClient =
BinaryApiClient(
host = host,
token = token,
requestHeaders = requestHeadersProvider.fetchRequestHeaders(),
)
)
companion object {
private const val TAG: String = "AudioBookshelfMediaRepository"
private const val TAG: String = "AudioBookshelfMediaRepository"
}
}
}

View File

@@ -4,9 +4,8 @@ import org.grakovne.lissen.channel.common.ApiResult
import org.grakovne.lissen.domain.PlaybackProgress
interface AudioBookshelfSyncService {
suspend fun syncProgress(
itemId: String,
progress: PlaybackProgress,
): ApiResult<Unit>
suspend fun syncProgress(
itemId: String,
progress: PlaybackProgress,
): ApiResult<Unit>
}

View File

@@ -39,224 +39,260 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AudiobookshelfAuthService @Inject constructor(
class AudiobookshelfAuthService
@Inject
constructor(
@ApplicationContext private val context: Context,
private val loginResponseConverter: LoginResponseConverter,
private val requestHeadersProvider: RequestHeadersProvider,
private val preferences: LissenSharedPreferences,
private val contextCache: OAuthContextCache,
private val authMethodResponseConverter: AuthMethodResponseConverter,
) : ChannelAuthService(preferences) {
private val client = createOkHttpClient()
) : ChannelAuthService(preferences) {
private val client =
createOkHttpClient()
.newBuilder()
.followRedirects(false)
.build()
override suspend fun authorize(
host: String,
username: String,
password: String,
onSuccess: suspend (UserAccount) -> Unit,
host: String,
username: String,
password: String,
onSuccess: suspend (UserAccount) -> Unit,
): ApiResult<UserAccount> {
if (host.isBlank() || !urlPattern.matches(host)) {
return ApiResult.Error(ApiError.InvalidCredentialsHost)
}
if (host.isBlank() || !urlPattern.matches(host)) {
return ApiResult.Error(ApiError.InvalidCredentialsHost)
}
lateinit var apiService: AudiobookshelfApiClient
lateinit var apiService: AudiobookshelfApiClient
try {
val apiClient = ApiClient(
host = host,
requestHeaders = requestHeadersProvider.fetchRequestHeaders(),
)
try {
val apiClient =
ApiClient(
host = host,
requestHeaders = requestHeadersProvider.fetchRequestHeaders(),
)
apiService = apiClient.retrofit.create(AudiobookshelfApiClient::class.java)
} catch (e: Exception) {
return ApiResult.Error(ApiError.InternalError)
}
apiService = apiClient.retrofit.create(AudiobookshelfApiClient::class.java)
} catch (e: Exception) {
return ApiResult.Error(ApiError.InternalError)
}
val response: ApiResult<LoggedUserResponse> =
safeApiCall { apiService.login(CredentialsLoginRequest(username, password)) }
val response: ApiResult<LoggedUserResponse> =
safeApiCall { apiService.login(CredentialsLoginRequest(username, password)) }
return response
.foldAsync(
onSuccess = {
loginResponseConverter
.apply(it)
.also { onSuccess(it) }
.let { ApiResult.Success(it) }
},
onFailure = { ApiResult.Error(it.code) },
)
return response
.foldAsync(
onSuccess = {
loginResponseConverter
.apply(it)
.also { onSuccess(it) }
.let { ApiResult.Success(it) }
},
onFailure = { ApiResult.Error(it.code) },
)
}
override suspend fun fetchAuthMethods(host: String): ApiResult<List<AuthMethod>> {
return withContext(Dispatchers.IO) {
try {
val url = host
.toUri()
.buildUpon()
.appendEncodedPath("status")
.build()
return withContext(Dispatchers.IO) {
try {
val url =
host
.toUri()
.buildUpon()
.appendEncodedPath("status")
.build()
val client = createOkHttpClient()
val request = Request.Builder().url(url.toString()).get().build()
val response = client.newCall(request).execute()
val client = createOkHttpClient()
val request =
Request
.Builder()
.url(url.toString())
.get()
.build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
return@withContext ApiResult.Success(emptyList())
}
if (!response.isSuccessful) {
return@withContext ApiResult.Success(emptyList())
}
val body = response.body?.string()
?: return@withContext ApiResult.Success(emptyList())
val body =
response.body?.string()
?: return@withContext ApiResult.Success(emptyList())
val gson = Gson()
val authMethod = gson.fromJson(body, AuthMethodResponse::class.java)
val gson = Gson()
val authMethod = gson.fromJson(body, AuthMethodResponse::class.java)
val converted = authMethodResponseConverter.apply(authMethod)
ApiResult.Success(converted)
} catch (e: Exception) {
ApiResult.Success(emptyList())
}
val converted = authMethodResponseConverter.apply(authMethod)
ApiResult.Success(converted)
} catch (e: Exception) {
ApiResult.Success(emptyList())
}
}
}
override suspend fun startOAuth(
host: String,
onSuccess: () -> Unit,
onFailure: (ApiError) -> Unit,
host: String,
onSuccess: () -> Unit,
onFailure: (ApiError) -> Unit,
) {
Log.d(TAG, "Starting OAuth flow for $host")
Log.d(TAG, "Starting OAuth flow for $host")
preferences.saveHost(host)
preferences.saveHost(host)
val pkce = randomPkce()
contextCache.storePkce(pkce)
val pkce = randomPkce()
contextCache.storePkce(pkce)
val url = host
.toUri()
.buildUpon()
.appendEncodedPath("auth/openid/")
.appendQueryParameter("code_challenge", pkce.challenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("redirect_uri", "$AuthScheme://$AuthHost")
.appendQueryParameter("client_id", AuthClient)
.appendQueryParameter("response_type", "code")
.appendQueryParameter("state", pkce.state)
.build()
val url =
host
.toUri()
.buildUpon()
.appendEncodedPath("auth/openid/")
.appendQueryParameter("code_challenge", pkce.challenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("redirect_uri", "$AuthScheme://$AuthHost")
.appendQueryParameter("client_id", AuthClient)
.appendQueryParameter("response_type", "code")
.appendQueryParameter("state", pkce.state)
.build()
val request = Request
.Builder()
.url(url.toString())
.get()
.build()
val request =
Request
.Builder()
.url(url.toString())
.get()
.build()
client
.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.e(TAG, "Failed OAuth flow due to: $e")
onFailure(examineError(e.message ?: ""))
}
client
.newCall(request)
.enqueue(
object : Callback {
override fun onFailure(
call: Call,
e: IOException,
) {
Log.e(TAG, "Failed OAuth flow due to: $e")
onFailure(examineError(e.message ?: ""))
}
override fun onResponse(call: Call, response: Response) {
Log.d(TAG, "Got Redirect from ABS")
override fun onResponse(
call: Call,
response: Response,
) {
Log.d(TAG, "Got Redirect from ABS")
if (response.code != 302) {
onFailure(examineError(response.body?.string() ?: ""))
return
}
if (response.code != 302) {
onFailure(examineError(response.body?.string() ?: ""))
return
}
val location = response
.header("Location")
?: kotlin.run {
onFailure(examineError("invalid_redirect"))
return
}
val location =
response
.header("Location")
?: kotlin.run {
onFailure(examineError("invalid_redirect"))
return
}
try {
val cookieHeaders: List<String> = response.headers("Set-Cookie")
contextCache.storeCookies(cookieHeaders)
try {
val cookieHeaders: List<String> = response.headers("Set-Cookie")
contextCache.storeCookies(cookieHeaders)
onSuccess()
forwardAuthRequest(context, location)
} catch (ex: Exception) {
onFailure(examineError(ex.message ?: ""))
}
}
})
onSuccess()
forwardAuthRequest(context, location)
} catch (ex: Exception) {
onFailure(examineError(ex.message ?: ""))
}
}
},
)
}
fun forwardAuthRequest(context: Context, url: String) {
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY or Intent.FLAG_ACTIVITY_NEW_TASK)
customTabsIntent.launchUrl(context, url.toUri())
fun forwardAuthRequest(
context: Context,
url: String,
) {
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY or Intent.FLAG_ACTIVITY_NEW_TASK)
customTabsIntent.launchUrl(context, url.toUri())
}
override suspend fun exchangeToken(
host: String,
code: String,
onSuccess: suspend (UserAccount) -> Unit,
onFailure: (String) -> Unit,
host: String,
code: String,
onSuccess: suspend (UserAccount) -> Unit,
onFailure: (String) -> Unit,
) {
val pkce = contextCache.readPkce()
val cookie = contextCache.readCookies()
val pkce = contextCache.readPkce()
val cookie = contextCache.readCookies()
contextCache.clearPkce()
contextCache.clearCookies()
contextCache.clearPkce()
contextCache.clearCookies()
val callbackUrl = host
.toUri()
.buildUpon()
.appendEncodedPath("auth/openid/callback")
.appendQueryParameter("state", pkce.state)
.appendQueryParameter("code", code)
.appendQueryParameter("code_verifier", pkce.verifier)
.build()
val callbackUrl =
host
.toUri()
.buildUpon()
.appendEncodedPath("auth/openid/callback")
.appendQueryParameter("state", pkce.state)
.appendQueryParameter("code", code)
.appendQueryParameter("code_verifier", pkce.verifier)
.build()
val client = createOkHttpClient()
val client = createOkHttpClient()
val request = Request
.Builder()
.url(callbackUrl.toString())
.addHeader("Cookie", cookie)
.build()
val request =
Request
.Builder()
.url(callbackUrl.toString())
.addHeader("Cookie", cookie)
.build()
client
.newCall(request)
.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.e(TAG, "Callback request failed: $e")
onFailure(e.message ?: "")
client
.newCall(request)
.enqueue(
object : Callback {
override fun onFailure(
call: Call,
e: IOException,
) {
Log.e(TAG, "Callback request failed: $e")
onFailure(e.message ?: "")
}
override fun onResponse(
call: Call,
response: Response,
) {
val raw =
response
.body
?.string()
?: run {
onFailure(response.body?.string() ?: "")
return
}
val user =
try {
Gson()
.fromJson(raw, LoggedUserResponse::class.java)
.let { loginResponseConverter.apply(it) }
} catch (ex: Exception) {
Log.e(TAG, "Unable to get User data from response: $ex")
onFailure(ex.message ?: "")
return
}
override fun onResponse(call: Call, response: Response) {
val raw = response
.body
?.string()
?: run {
onFailure(response.body?.string() ?: "")
return
}
val user = try {
Gson()
.fromJson(raw, LoggedUserResponse::class.java)
.let { loginResponseConverter.apply(it) }
} catch (ex: Exception) {
Log.e(TAG, "Unable to get User data from response: $ex")
onFailure(ex.message ?: "")
return
}
CoroutineScope(Dispatchers.IO).launch { onSuccess(user) }
}
})
CoroutineScope(Dispatchers.IO).launch { onSuccess(user) }
}
},
)
}
private companion object {
private val TAG = "AudiobookshelfAuthService"
val urlPattern = Regex("^(http|https)://.*\$")
private val TAG = "AudiobookshelfAuthService"
val urlPattern = Regex("^(http|https)://.*\$")
}
}
}

View File

@@ -7,14 +7,15 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RequestHeadersProvider @Inject constructor(
class RequestHeadersProvider
@Inject
constructor(
private val preferences: LissenSharedPreferences,
) {
) {
fun fetchRequestHeaders(): List<ServerRequestHeader> {
val usersHeaders = preferences.getCustomHeaders()
val usersHeaders = preferences.getCustomHeaders()
val userAgent = ServerRequestHeader("User-Agent", USER_AGENT)
return usersHeaders + userAgent
val userAgent = ServerRequestHeader("User-Agent", USER_AGENT)
return usersHeaders + userAgent
}
}
}

View File

@@ -8,30 +8,29 @@ import java.io.IOException
private const val TAG: String = "safeApiCall"
suspend fun <T> safeApiCall(
apiCall: suspend () -> Response<T>,
): ApiResult<T> {
return try {
val response = apiCall.invoke()
suspend fun <T> safeApiCall(apiCall: suspend () -> Response<T>): ApiResult<T> {
return try {
val response = apiCall.invoke()
return when (response.code()) {
200 -> when (val body = response.body()) {
null -> ApiResult.Error(ApiError.InternalError)
else -> ApiResult.Success(body)
}
400 -> ApiResult.Error(ApiError.InternalError)
401 -> ApiResult.Error(ApiError.Unauthorized)
403 -> ApiResult.Error(ApiError.Unauthorized)
404 -> ApiResult.Error(ApiError.InternalError)
500 -> ApiResult.Error(ApiError.InternalError)
else -> ApiResult.Error(ApiError.InternalError)
return when (response.code()) {
200 ->
when (val body = response.body()) {
null -> ApiResult.Error(ApiError.InternalError)
else -> ApiResult.Success(body)
}
} catch (e: IOException) {
Log.e(TAG, "Unable to make network api call $apiCall due to: $e")
ApiResult.Error(ApiError.NetworkError)
} catch (e: Exception) {
Log.e(TAG, "Unable to make network api call $apiCall due to: $e")
ApiResult.Error(ApiError.InternalError)
400 -> ApiResult.Error(ApiError.InternalError)
401 -> ApiResult.Error(ApiError.Unauthorized)
403 -> ApiResult.Error(ApiError.Unauthorized)
404 -> ApiResult.Error(ApiError.InternalError)
500 -> ApiResult.Error(ApiError.InternalError)
else -> ApiResult.Error(ApiError.InternalError)
}
} catch (e: IOException) {
Log.e(TAG, "Unable to make network api call $apiCall due to: $e")
ApiResult.Error(ApiError.NetworkError)
} catch (e: Exception) {
Log.e(TAG, "Unable to make network api call $apiCall due to: $e")
ApiResult.Error(ApiError.InternalError)
}
}

View File

@@ -9,33 +9,36 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AudioBookshelfLibrarySyncService @Inject constructor(
class AudioBookshelfLibrarySyncService
@Inject
constructor(
private val dataRepository: AudioBookshelfDataRepository,
) : AudioBookshelfSyncService {
) : AudioBookshelfSyncService {
private var previousItemId: String? = null
private var previousTrackedTime: Double = 0.0
override suspend fun syncProgress(
itemId: String,
progress: PlaybackProgress,
itemId: String,
progress: PlaybackProgress,
): ApiResult<Unit> {
val trackedTime = previousTrackedTime
.takeIf { itemId == previousItemId }
?.let { progress.currentTotalTime - previousTrackedTime }
?.toInt()
?: 0
val trackedTime =
previousTrackedTime
.takeIf { itemId == previousItemId }
?.let { progress.currentTotalTime - previousTrackedTime }
?.toInt()
?: 0
val request = ProgressSyncRequest(
currentTime = progress.currentTotalTime,
timeListened = trackedTime,
val request =
ProgressSyncRequest(
currentTime = progress.currentTotalTime,
timeListened = trackedTime,
)
return dataRepository
.publishLibraryItemProgress(itemId, request)
.also {
previousTrackedTime = progress.currentTotalTime
previousItemId = itemId
}
return dataRepository
.publishLibraryItemProgress(itemId, request)
.also {
previousTrackedTime = progress.currentTotalTime
previousItemId = itemId
}
}
}
}

View File

@@ -9,33 +9,36 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AudioBookshelfPodcastSyncService @Inject constructor(
class AudioBookshelfPodcastSyncService
@Inject
constructor(
private val dataRepository: AudioBookshelfDataRepository,
) : AudioBookshelfSyncService {
) : AudioBookshelfSyncService {
private var previousItemId: String? = null
private var previousTrackedTime: Double = 0.0
override suspend fun syncProgress(
itemId: String,
progress: PlaybackProgress,
itemId: String,
progress: PlaybackProgress,
): ApiResult<Unit> {
val trackedTime = previousTrackedTime
.takeIf { itemId == previousItemId }
?.let { progress.currentChapterTime - previousTrackedTime }
?.toInt()
?: 0
val trackedTime =
previousTrackedTime
.takeIf { itemId == previousItemId }
?.let { progress.currentChapterTime - previousTrackedTime }
?.toInt()
?: 0
val request = ProgressSyncRequest(
currentTime = progress.currentChapterTime,
timeListened = trackedTime,
val request =
ProgressSyncRequest(
currentTime = progress.currentChapterTime,
timeListened = trackedTime,
)
return dataRepository
.publishLibraryItemProgress(itemId, request)
.also {
previousTrackedTime = progress.currentChapterTime
previousItemId = itemId
}
return dataRepository
.publishLibraryItemProgress(itemId, request)
.also {
previousTrackedTime = progress.currentChapterTime
previousItemId = itemId
}
}
}
}

View File

@@ -25,94 +25,95 @@ import retrofit2.http.Path
import retrofit2.http.Query
interface AudiobookshelfApiClient {
@GET("/api/libraries")
suspend fun fetchLibraries(): Response<LibraryResponse>
@GET("/api/libraries")
suspend fun fetchLibraries(): Response<LibraryResponse>
@GET("/api/libraries/{libraryId}/personalized")
suspend fun fetchPersonalizedFeed(
@Path("libraryId") libraryId: String,
): Response<List<PersonalizedFeedResponse>>
@GET("/api/libraries/{libraryId}/personalized")
suspend fun fetchPersonalizedFeed(
@Path("libraryId") libraryId: String,
): Response<List<PersonalizedFeedResponse>>
@GET("/api/me/progress/{itemId}")
suspend fun fetchLibraryItemProgress(
@Path("itemId") itemId: String,
): Response<MediaProgressResponse>
@GET("/api/me/progress/{itemId}")
suspend fun fetchLibraryItemProgress(
@Path("itemId") itemId: String,
): Response<MediaProgressResponse>
@POST("/api/authorize")
suspend fun fetchConnectionInfo(): Response<ConnectionInfoResponse>
@POST("/api/authorize")
suspend fun fetchConnectionInfo(): Response<ConnectionInfoResponse>
@POST("/api/authorize")
suspend fun fetchUserInfo(): Response<UserInfoResponse>
@POST("/api/authorize")
suspend fun fetchUserInfo(): Response<UserInfoResponse>
@GET("api/libraries/{libraryId}/items")
suspend fun fetchLibraryItems(
@Path("libraryId") libraryId: String,
@Query("limit") pageSize: Int,
@Query("page") pageNumber: Int,
@Query("sort") sort: String,
@Query("desc") desc: String,
@Query("minified") minified: String = "1",
): Response<LibraryItemsResponse>
@GET("api/libraries/{libraryId}/items")
suspend fun fetchLibraryItems(
@Path("libraryId") libraryId: String,
@Query("limit") pageSize: Int,
@Query("page") pageNumber: Int,
@Query("sort") sort: String,
@Query("desc") desc: String,
@Query("minified") minified: String = "1",
): Response<LibraryItemsResponse>
@GET("api/libraries/{libraryId}/items")
suspend fun fetchPodcastItems(
@Path("libraryId") libraryId: String,
@Query("limit") pageSize: Int,
@Query("page") pageNumber: Int,
@Query("sort") sort: String,
@Query("desc") desc: String,
@Query("minified") minified: String = "1",
): Response<PodcastItemsResponse>
@GET("api/libraries/{libraryId}/items")
suspend fun fetchPodcastItems(
@Path("libraryId") libraryId: String,
@Query("limit") pageSize: Int,
@Query("page") pageNumber: Int,
@Query("sort") sort: String,
@Query("desc") desc: String,
@Query("minified") minified: String = "1",
): Response<PodcastItemsResponse>
@GET("api/libraries/{libraryId}/search")
suspend fun searchLibraryItems(
@Path("libraryId") libraryId: String,
@Query("q") request: String,
@Query("limit") limit: Int,
): Response<LibrarySearchResponse>
@GET("api/libraries/{libraryId}/search")
suspend fun searchLibraryItems(
@Path("libraryId") libraryId: String,
@Query("q") request: String,
@Query("limit") limit: Int,
): Response<LibrarySearchResponse>
@GET("api/libraries/{libraryId}/search")
suspend fun searchPodcasts(
@Path("libraryId") libraryId: String,
@Query("q") request: String,
@Query("limit") limit: Int,
): Response<PodcastSearchResponse>
@GET("api/libraries/{libraryId}/search")
suspend fun searchPodcasts(
@Path("libraryId") libraryId: String,
@Query("q") request: String,
@Query("limit") limit: Int,
): Response<PodcastSearchResponse>
@GET("/api/items/{itemId}")
suspend fun fetchLibraryItem(
@Path("itemId") itemId: String,
): Response<BookResponse>
@GET("/api/items/{itemId}")
suspend fun fetchLibraryItem(
@Path("itemId") itemId: String,
): Response<BookResponse>
@GET("/api/items/{itemId}")
suspend fun fetchPodcastEpisode(
@Path("itemId") itemId: String,
): Response<PodcastResponse>
@GET("/api/items/{itemId}")
suspend fun fetchPodcastEpisode(
@Path("itemId") itemId: String,
): Response<PodcastResponse>
@GET("/api/authors/{authorId}?include=items")
suspend fun fetchAuthorLibraryItems(
@Path("authorId") authorId: String,
): Response<AuthorItemsResponse>
@GET("/api/authors/{authorId}?include=items")
suspend fun fetchAuthorLibraryItems(
@Path("authorId") authorId: String,
): Response<AuthorItemsResponse>
@POST("/api/session/{itemId}/sync")
suspend fun publishLibraryItemProgress(
@Path("itemId") itemId: String,
@Body syncProgressRequest: ProgressSyncRequest,
): Response<Unit>
@POST("/api/session/{itemId}/sync")
suspend fun publishLibraryItemProgress(
@Path("itemId") itemId: String,
@Body syncProgressRequest: ProgressSyncRequest,
): Response<Unit>
@POST("/api/items/{itemId}/play/{episodeId}")
suspend fun startPodcastPlayback(
@Path("itemId") itemId: String,
@Path("episodeId") episodeId: String,
@Body syncProgressRequest: PlaybackStartRequest,
): Response<PlaybackSessionResponse>
@POST("/api/items/{itemId}/play/{episodeId}")
suspend fun startPodcastPlayback(
@Path("itemId") itemId: String,
@Path("episodeId") episodeId: String,
@Body syncProgressRequest: PlaybackStartRequest,
): Response<PlaybackSessionResponse>
@POST("/api/items/{itemId}/play")
suspend fun startLibraryPlayback(
@Path("itemId") itemId: String,
@Body syncProgressRequest: PlaybackStartRequest,
): Response<PlaybackSessionResponse>
@POST("/api/items/{itemId}/play")
suspend fun startLibraryPlayback(
@Path("itemId") itemId: String,
@Body syncProgressRequest: PlaybackStartRequest,
): Response<PlaybackSessionResponse>
@POST("login")
suspend fun login(@Body request: CredentialsLoginRequest): Response<LoggedUserResponse>
@POST("login")
suspend fun login(
@Body request: CredentialsLoginRequest,
): Response<LoggedUserResponse>
}

View File

@@ -7,10 +7,9 @@ import retrofit2.http.Path
import retrofit2.http.Streaming
interface AudiobookshelfMediaClient {
@GET("/api/items/{itemId}/cover?raw=1")
@Streaming
suspend fun getItemCover(
@Path("itemId") itemId: String,
): Response<ResponseBody>
@GET("/api/items/{itemId}/cover?raw=1")
@Streaming
suspend fun getItemCover(
@Path("itemId") itemId: String,
): Response<ResponseBody>
}

View File

@@ -6,15 +6,17 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthMethodResponseConverter @Inject constructor() {
fun apply(response: AuthMethodResponse): List<AuthMethod> = response
class AuthMethodResponseConverter
@Inject
constructor() {
fun apply(response: AuthMethodResponse): List<AuthMethod> =
response
.authMethods
.mapNotNull {
when (it) {
"local" -> AuthMethod.CREDENTIALS
"openid" -> AuthMethod.O_AUTH
else -> null
}
when (it) {
"local" -> AuthMethod.CREDENTIALS
"openid" -> AuthMethod.O_AUTH
else -> null
}
}
}
}

View File

@@ -6,11 +6,13 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConnectionInfoResponseConverter @Inject constructor() {
fun apply(response: ConnectionInfoResponse): ConnectionInfo = ConnectionInfo(
class ConnectionInfoResponseConverter
@Inject
constructor() {
fun apply(response: ConnectionInfoResponse): ConnectionInfo =
ConnectionInfo(
username = response.user.username,
serverVersion = response.serverSettings?.version,
buildNumber = response.serverSettings?.buildNumber,
)
}
)
}

View File

@@ -7,26 +7,27 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LibraryPageResponseConverter @Inject constructor() {
fun apply(response: LibraryItemsResponse): PagedItems<Book> = response
class LibraryPageResponseConverter
@Inject
constructor() {
fun apply(response: LibraryItemsResponse): PagedItems<Book> =
response
.results
.mapNotNull {
val title = it.media.metadata.title ?: return@mapNotNull null
val title = it.media.metadata.title ?: return@mapNotNull null
Book(
id = it.id,
title = title,
series = it.media.metadata.seriesName,
subtitle = it.media.metadata.subtitle,
author = it.media.metadata.authorName,
duration = it.media.duration.toInt(),
)
Book(
id = it.id,
title = title,
series = it.media.metadata.seriesName,
subtitle = it.media.metadata.subtitle,
author = it.media.metadata.authorName,
duration = it.media.duration.toInt(),
)
}.let {
PagedItems(
items = it,
currentPage = response.page,
)
}
.let {
PagedItems(
items = it,
currentPage = response.page,
)
}
}
}

View File

@@ -7,20 +7,23 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LibraryResponseConverter @Inject constructor() {
fun apply(response: LibraryResponse): List<Library> = response
class LibraryResponseConverter
@Inject
constructor() {
fun apply(response: LibraryResponse): List<Library> =
response
.libraries
.map {
it
.mediaType
.toLibraryType()
.let { type -> Library(it.id, it.name, type) }
it
.mediaType
.toLibraryType()
.let { type -> Library(it.id, it.name, type) }
}
private fun String.toLibraryType() = when (this) {
private fun String.toLibraryType() =
when (this) {
"podcast" -> LibraryType.PODCAST
"book" -> LibraryType.LIBRARY
else -> LibraryType.UNKNOWN
}
}
}
}

View File

@@ -6,11 +6,13 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LoginResponseConverter @Inject constructor() {
fun apply(response: LoggedUserResponse): UserAccount = UserAccount(
class LoginResponseConverter
@Inject
constructor() {
fun apply(response: LoggedUserResponse): UserAccount =
UserAccount(
token = response.user.token,
username = response.user.username,
preferredLibraryId = response.userDefaultLibraryId,
)
}
)
}

View File

@@ -6,11 +6,12 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PlaybackSessionResponseConverter @Inject constructor() {
class PlaybackSessionResponseConverter
@Inject
constructor() {
fun apply(response: PlaybackSessionResponse): PlaybackSession =
PlaybackSession(
sessionId = response.id,
bookId = response.libraryItemId,
)
}
PlaybackSession(
sessionId = response.id,
bookId = response.libraryItemId,
)
}

View File

@@ -6,28 +6,29 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecentListeningResponseConverter @Inject constructor() {
class RecentListeningResponseConverter
@Inject
constructor() {
fun apply(
response: List<PersonalizedFeedResponse>,
progress: Map<String, Pair<Long, Double>>,
): List<RecentBook> = response
response: List<PersonalizedFeedResponse>,
progress: Map<String, Pair<Long, Double>>,
): List<RecentBook> =
response
.find { it.labelStringKey == LABEL_CONTINUE_LISTENING }
?.entities
?.distinctBy { it.id }
?.map {
RecentBook(
id = it.id,
title = it.media.metadata.title,
subtitle = it.media.metadata.subtitle,
author = it.media.metadata.authorName,
listenedPercentage = progress[it.id]?.second?.let { it * 100 }?.toInt(),
listenedLastUpdate = progress[it.id]?.first,
)
RecentBook(
id = it.id,
title = it.media.metadata.title,
subtitle = it.media.metadata.subtitle,
author = it.media.metadata.authorName,
listenedPercentage = progress[it.id]?.second?.let { it * 100 }?.toInt(),
listenedLastUpdate = progress[it.id]?.first,
)
} ?: emptyList()
companion object {
private const val LABEL_CONTINUE_LISTENING = "LabelContinueListening"
private const val LABEL_CONTINUE_LISTENING = "LabelContinueListening"
}
}
}

View File

@@ -4,10 +4,10 @@ import androidx.annotation.Keep
@Keep
data class MediaProgressResponse(
val libraryItemId: String,
val episodeId: String?,
val currentTime: Double,
val isFinished: Boolean,
val lastUpdate: Long,
val progress: Double,
val libraryItemId: String,
val episodeId: String?,
val currentTime: Double,
val isFinished: Boolean,
val lastUpdate: Long,
val progress: Double,
)

View File

@@ -4,5 +4,5 @@ import androidx.annotation.Keep
@Keep
data class AuthMethodResponse(
val authMethods: List<String> = emptyList(),
val authMethods: List<String> = emptyList(),
)

View File

@@ -4,17 +4,17 @@ import androidx.annotation.Keep
@Keep
data class ConnectionInfoResponse(
val user: ConnectionInfoUserResponse,
val serverSettings: ConnectionInfoServerResponse?,
val user: ConnectionInfoUserResponse,
val serverSettings: ConnectionInfoServerResponse?,
)
@Keep
data class ConnectionInfoUserResponse(
val username: String,
val username: String,
)
@Keep
data class ConnectionInfoServerResponse(
val version: String?,
val buildNumber: String?,
val version: String?,
val buildNumber: String?,
)

View File

@@ -5,5 +5,5 @@ import org.grakovne.lissen.channel.audiobookshelf.library.model.LibraryItem
@Keep
data class AuthorItemsResponse(
val libraryItems: List<LibraryItem>,
val libraryItems: List<LibraryItem>,
)

View File

@@ -4,12 +4,12 @@ import androidx.annotation.Keep
@Keep
data class LibraryResponse(
val libraries: List<LibraryItemResponse>,
val libraries: List<LibraryItemResponse>,
)
@Keep
data class LibraryItemResponse(
val id: String,
val name: String,
val mediaType: String,
val id: String,
val name: String,
val mediaType: String,
)

View File

@@ -4,6 +4,6 @@ import androidx.annotation.Keep
@Keep
data class PlaybackSessionResponse(
val id: String,
val libraryItemId: String,
val id: String,
val libraryItemId: String,
)

View File

@@ -4,16 +4,16 @@ import androidx.annotation.Keep
@Keep
data class PlaybackStartRequest(
val deviceInfo: DeviceInfo,
val supportedMimeTypes: List<String>,
val mediaPlayer: String,
val forceTranscode: Boolean,
val forceDirectPlay: Boolean,
val deviceInfo: DeviceInfo,
val supportedMimeTypes: List<String>,
val mediaPlayer: String,
val forceTranscode: Boolean,
val forceDirectPlay: Boolean,
)
@Keep
data class DeviceInfo(
val clientName: String,
val deviceId: String,
val deviceName: String,
val clientName: String,
val deviceId: String,
val deviceName: String,
)

View File

@@ -4,6 +4,6 @@ import androidx.annotation.Keep
@Keep
data class ProgressSyncRequest(
val timeListened: Int,
val currentTime: Double,
val timeListened: Int,
val currentTime: Double,
)

View File

@@ -4,6 +4,6 @@ import androidx.annotation.Keep
@Keep
data class CredentialsLoginRequest(
val username: String,
val password: String,
val username: String,
val password: String,
)

View File

@@ -4,13 +4,13 @@ import androidx.annotation.Keep
@Keep
data class LoggedUserResponse(
val user: User,
val userDefaultLibraryId: String?,
val user: User,
val userDefaultLibraryId: String?,
)
@Keep
data class User(
val id: String,
val token: String,
val username: String = "username",
val id: String,
val token: String,
val username: String = "username",
)

View File

@@ -4,28 +4,28 @@ import androidx.annotation.Keep
@Keep
data class PersonalizedFeedResponse(
val id: String,
val labelStringKey: String,
val entities: List<PersonalizedFeedItemResponse>,
val id: String,
val labelStringKey: String,
val entities: List<PersonalizedFeedItemResponse>,
)
@Keep
data class PersonalizedFeedItemResponse(
val id: String,
val libraryId: String,
val media: PersonalizedFeedItemMediaResponse,
val updateAt: Long,
val id: String,
val libraryId: String,
val media: PersonalizedFeedItemMediaResponse,
val updateAt: Long,
)
@Keep
data class PersonalizedFeedItemMediaResponse(
val id: String,
val metadata: PersonalizedFeedItemMetadataResponse,
val id: String,
val metadata: PersonalizedFeedItemMetadataResponse,
)
@Keep
data class PersonalizedFeedItemMetadataResponse(
val title: String,
val subtitle: String?,
val authorName: String,
val title: String,
val subtitle: String?,
val authorName: String,
)

View File

@@ -5,10 +5,10 @@ import org.grakovne.lissen.channel.audiobookshelf.common.model.MediaProgressResp
@Keep
data class UserInfoResponse(
val user: UserResponse,
val user: UserResponse,
)
@Keep
data class UserResponse(
val mediaProgress: List<MediaProgressResponse>?,
val mediaProgress: List<MediaProgressResponse>?,
)

View File

@@ -20,74 +20,74 @@ import javax.inject.Inject
@AndroidEntryPoint
class AudiobookshelfOAuthCallbackActivity : ComponentActivity() {
@Inject
lateinit var contextCache: OAuthContextCache
@Inject
lateinit var contextCache: OAuthContextCache
@Inject
lateinit var authService: AudiobookshelfAuthService
@Inject
lateinit var authService: AudiobookshelfAuthService
@Inject
lateinit var mediaProvider: LissenMediaProvider
@Inject
lateinit var mediaProvider: LissenMediaProvider
@Inject
lateinit var preferences: LissenSharedPreferences
@Inject
lateinit var preferences: LissenSharedPreferences
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val data = intent?.data
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val data = intent?.data
if (null == data) {
finish()
return
}
if (intent?.action == Intent.ACTION_VIEW && data.scheme == AuthScheme) {
val code = data.getQueryParameter("code") ?: ""
Log.d(TAG, "Got Exchange code from ABS")
lifecycleScope.launch {
authService.exchangeToken(
host = preferences.getHost() ?: kotlin.run {
onLoginFailed("invalid_host")
return@launch
},
code = code,
onSuccess = { onLogged(it) },
onFailure = { onLoginFailed(it) },
)
}
}
if (null == data) {
finish()
return
}
private suspend fun onLogged(userAccount: UserAccount) {
mediaProvider.onPostLogin(
host = preferences.getHost() ?: return,
account = userAccount,
if (intent?.action == Intent.ACTION_VIEW && data.scheme == AuthScheme) {
val code = data.getQueryParameter("code") ?: ""
Log.d(TAG, "Got Exchange code from ABS")
lifecycleScope.launch {
authService.exchangeToken(
host =
preferences.getHost() ?: kotlin.run {
onLoginFailed("invalid_host")
return@launch
},
code = code,
onSuccess = { onLogged(it) },
onFailure = { onLoginFailed(it) },
)
val intent = Intent(this, AppActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TASK
}
startActivity(intent)
finish()
}
}
}
private fun onLoginFailed(reason: String) {
runOnUiThread {
authService
.examineError(reason)
.makeText(this)
.let { Toast.makeText(this, it, LENGTH_SHORT).show() }
private suspend fun onLogged(userAccount: UserAccount) {
mediaProvider.onPostLogin(
host = preferences.getHost() ?: return,
account = userAccount,
)
finish()
}
val intent =
Intent(this, AppActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TASK
}
startActivity(intent)
finish()
}
private fun onLoginFailed(reason: String) {
runOnUiThread {
authService
.examineError(reason)
.makeText(this)
.let { Toast.makeText(this, it, LENGTH_SHORT).show() }
finish()
}
}
companion object {
private const val TAG = "AudiobookshelfOAuthCallbackActivity"
}
companion object {
private const val TAG = "AudiobookshelfOAuthCallbackActivity"
}
}

View File

@@ -30,7 +30,9 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LibraryAudiobookshelfChannel @Inject constructor(
class LibraryAudiobookshelfChannel
@Inject
constructor(
dataRepository: AudioBookshelfDataRepository,
mediaRepository: AudioBookshelfMediaRepository,
recentListeningResponseConverter: RecentListeningResponseConverter,
@@ -43,156 +45,158 @@ class LibraryAudiobookshelfChannel @Inject constructor(
private val libraryPageResponseConverter: LibraryPageResponseConverter,
private val bookResponseConverter: BookResponseConverter,
private val librarySearchItemsConverter: LibrarySearchItemsConverter,
) : AudiobookshelfChannel(
dataRepository = dataRepository,
mediaRepository = mediaRepository,
recentBookResponseConverter = recentListeningResponseConverter,
sessionResponseConverter = sessionResponseConverter,
preferences = preferences,
syncService = syncService,
libraryResponseConverter = libraryResponseConverter,
connectionInfoResponseConverter = connectionInfoResponseConverter,
) {
) : AudiobookshelfChannel(
dataRepository = dataRepository,
mediaRepository = mediaRepository,
recentBookResponseConverter = recentListeningResponseConverter,
sessionResponseConverter = sessionResponseConverter,
preferences = preferences,
syncService = syncService,
libraryResponseConverter = libraryResponseConverter,
connectionInfoResponseConverter = connectionInfoResponseConverter,
) {
override fun getLibraryType() = LibraryType.LIBRARY
override suspend fun fetchBooks(
libraryId: String,
pageSize: Int,
pageNumber: Int,
libraryId: String,
pageSize: Int,
pageNumber: Int,
): ApiResult<PagedItems<Book>> {
val (option, direction) = libraryOrderingRequestConverter.apply(preferences.getLibraryOrdering())
val (option, direction) = libraryOrderingRequestConverter.apply(preferences.getLibraryOrdering())
return dataRepository
.fetchLibraryItems(
libraryId = libraryId,
pageSize = pageSize,
pageNumber = pageNumber,
sort = option,
direction = direction,
)
.map { libraryPageResponseConverter.apply(it) }
return dataRepository
.fetchLibraryItems(
libraryId = libraryId,
pageSize = pageSize,
pageNumber = pageNumber,
sort = option,
direction = direction,
).map { libraryPageResponseConverter.apply(it) }
}
override suspend fun searchBooks(
libraryId: String,
query: String,
limit: Int,
): ApiResult<List<Book>> = coroutineScope {
libraryId: String,
query: String,
limit: Int,
): ApiResult<List<Book>> =
coroutineScope {
val searchResult = dataRepository.searchBooks(libraryId, query, limit)
val byTitle = async {
val byTitle =
async {
searchResult
.map { it.book }
.map { it.map { response -> response.libraryItem } }
.map { librarySearchItemsConverter.apply(it) }
}
.map { it.book }
.map { it.map { response -> response.libraryItem } }
.map { librarySearchItemsConverter.apply(it) }
}
val byAuthor = async {
val byAuthor =
async {
searchResult
.map { it.authors }
.map { authors -> authors.map { it.id } }
.map { ids -> ids.map { id -> async { dataRepository.fetchAuthorItems(id) } } }
.map { it.awaitAll() }
.map { result ->
result
.flatMap { authorResponse ->
authorResponse
.fold(
onSuccess = { it.libraryItems },
onFailure = { emptyList() },
)
}
}
.map { librarySearchItemsConverter.apply(it) }
}
.map { it.authors }
.map { authors -> authors.map { it.id } }
.map { ids -> ids.map { id -> async { dataRepository.fetchAuthorItems(id) } } }
.map { it.awaitAll() }
.map { result ->
result
.flatMap { authorResponse ->
authorResponse
.fold(
onSuccess = { it.libraryItems },
onFailure = { emptyList() },
)
}
}.map { librarySearchItemsConverter.apply(it) }
}
val bySeries: Deferred<ApiResult<List<Book>>> = async {
val bySeries: Deferred<ApiResult<List<Book>>> =
async {
searchResult
.map { result -> result.series }
.map { result -> result.flatMap { it.books } }
.map { result -> result.mapNotNull { it.media.metadata.title } }
.map { result -> result.map { async { dataRepository.searchBooks(libraryId, it, limit) } } }
.map { result -> result.awaitAll() }
.map { result ->
result.flatMap {
it.fold(
onSuccess = { items -> items.book },
onFailure = { emptyList() },
)
}
.map { result -> result.series }
.map { result -> result.flatMap { it.books } }
.map { result -> result.mapNotNull { it.media.metadata.title } }
.map { result -> result.map { async { dataRepository.searchBooks(libraryId, it, limit) } } }
.map { result -> result.awaitAll() }
.map { result ->
result.flatMap {
it.fold(
onSuccess = { items -> items.book },
onFailure = { emptyList() },
)
}
.map { result -> result.map { it.libraryItem } }
.map { result -> result.let { librarySearchItemsConverter.apply(it) } }
}
}.map { result -> result.map { it.libraryItem } }
.map { result -> result.let { librarySearchItemsConverter.apply(it) } }
}
mergeBooks(byTitle, byAuthor, bySeries)
}
}
private suspend fun mergeBooks(
vararg queries: Deferred<ApiResult<List<Book>>>,
): ApiResult<List<Book>> = coroutineScope {
private suspend fun mergeBooks(vararg queries: Deferred<ApiResult<List<Book>>>): ApiResult<List<Book>> =
coroutineScope {
val results: List<ApiResult<List<Book>>> = awaitAll(*queries)
val merged: ApiResult<List<Book>> = results
val merged: ApiResult<List<Book>> =
results
.fold<ApiResult<List<Book>>, ApiResult<List<Book>>>(Success(emptyList())) { acc, res ->
when {
acc is ApiResult.Error -> acc
res is ApiResult.Error -> res
else -> {
val combined = (acc as Success).data + (res as Success).data
Success(combined)
}
when {
acc is ApiResult.Error -> acc
res is ApiResult.Error -> res
else -> {
val combined = (acc as Success).data + (res as Success).data
Success(combined)
}
}
}
merged.map { list ->
list
.distinctBy { it.id }
.sortedWith(compareBy({ it.series }, { it.author }, { it.title }))
list
.distinctBy { it.id }
.sortedWith(compareBy({ it.series }, { it.author }, { it.title }))
}
}
}
override suspend fun startPlayback(
bookId: String,
episodeId: String,
supportedMimeTypes: List<String>,
deviceId: String,
bookId: String,
episodeId: String,
supportedMimeTypes: List<String>,
deviceId: String,
): ApiResult<PlaybackSession> {
val request = PlaybackStartRequest(
supportedMimeTypes = supportedMimeTypes,
deviceInfo = DeviceInfo(
clientName = getClientName(),
deviceId = deviceId,
deviceName = getClientName(),
val request =
PlaybackStartRequest(
supportedMimeTypes = supportedMimeTypes,
deviceInfo =
DeviceInfo(
clientName = getClientName(),
deviceId = deviceId,
deviceName = getClientName(),
),
forceTranscode = false,
forceDirectPlay = false,
mediaPlayer = getClientName(),
forceTranscode = false,
forceDirectPlay = false,
mediaPlayer = getClientName(),
)
return dataRepository
.startPlayback(
itemId = bookId,
request = request,
)
.map { sessionResponseConverter.apply(it) }
return dataRepository
.startPlayback(
itemId = bookId,
request = request,
).map { sessionResponseConverter.apply(it) }
}
override suspend fun fetchBook(bookId: String): ApiResult<DetailedItem> = coroutineScope {
override suspend fun fetchBook(bookId: String): ApiResult<DetailedItem> =
coroutineScope {
val book = async { dataRepository.fetchBook(bookId) }
val bookProgress = async { dataRepository.fetchLibraryItemProgress(bookId) }
book.await().foldAsync(
onSuccess = { item ->
bookProgress
.await()
.fold(
onSuccess = { Success(bookResponseConverter.apply(item, it)) },
onFailure = { Success(bookResponseConverter.apply(item, null)) },
)
},
onFailure = { ApiResult.Error(it.code) },
onSuccess = { item ->
bookProgress
.await()
.fold(
onSuccess = { Success(bookResponseConverter.apply(item, it)) },
onFailure = { Success(bookResponseConverter.apply(item, null)) },
)
},
onFailure = { ApiResult.Error(it.code) },
)
}
}
}
}

View File

@@ -12,99 +12,109 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BookResponseConverter @Inject constructor() {
class BookResponseConverter
@Inject
constructor() {
fun apply(
item: BookResponse,
progressResponse: MediaProgressResponse? = null,
item: BookResponse,
progressResponse: MediaProgressResponse? = null,
): DetailedItem {
val maybeChapters = item
val maybeChapters =
item
.media
.chapters
?.takeIf { it.isNotEmpty() }
?.map {
PlayingChapter(
start = it.start,
end = it.end,
title = it.title,
available = true,
id = it.id,
duration = it.end - it.start,
podcastEpisodeState = null,
)
}
val filesAsChapters: () -> List<PlayingChapter> = {
item
.media
.audioFiles
?.sortedBy { it.index }
?.fold(0.0 to mutableListOf<PlayingChapter>()) { (accDuration, chapters), file ->
chapters.add(
PlayingChapter(
available = true,
start = accDuration,
end = accDuration + file.duration,
title =
file.metaTags?.tagTitle
?: file.metadata.filename.removeSuffix(file.metadata.ext),
duration = file.duration,
id = file.ino,
podcastEpisodeState = null,
),
)
accDuration + file.duration to chapters
}?.second
?: emptyList()
}
return DetailedItem(
id = item.id,
title = item.media.metadata.title,
subtitle = item.media.metadata.subtitle,
author =
item.media.metadata.authors
?.joinToString(", ", transform = LibraryAuthorResponse::name),
narrator =
item.media.metadata.narrators
?.joinToString(separator = ", "),
files =
item
.media
.chapters
?.takeIf { it.isNotEmpty() }
.audioFiles
?.sortedBy { it.index }
?.map {
PlayingChapter(
start = it.start,
end = it.end,
title = it.title,
available = true,
id = it.id,
duration = it.end - it.start,
podcastEpisodeState = null,
)
BookFile(
id = it.ino,
name =
it.metaTags
?.tagTitle
?: (it.metadata.filename.removeSuffix(it.metadata.ext)),
duration = it.duration,
mimeType = it.mimeType,
)
}
val filesAsChapters: () -> List<PlayingChapter> = {
item
.media
.audioFiles
?.sortedBy { it.index }
?.fold(0.0 to mutableListOf<PlayingChapter>()) { (accDuration, chapters), file ->
chapters.add(
PlayingChapter(
available = true,
start = accDuration,
end = accDuration + file.duration,
title = file.metaTags?.tagTitle
?: file.metadata.filename.removeSuffix(file.metadata.ext),
duration = file.duration,
id = file.ino,
podcastEpisodeState = null,
),
)
accDuration + file.duration to chapters
}
?.second
?: emptyList()
}
return DetailedItem(
id = item.id,
title = item.media.metadata.title,
subtitle = item.media.metadata.subtitle,
author = item.media.metadata.authors?.joinToString(", ", transform = LibraryAuthorResponse::name),
narrator = item.media.metadata.narrators?.joinToString(separator = ", "),
files = item
.media
.audioFiles
?.sortedBy { it.index }
?.map {
BookFile(
id = it.ino,
name = it.metaTags
?.tagTitle
?: (it.metadata.filename.removeSuffix(it.metadata.ext)),
duration = it.duration,
mimeType = it.mimeType,
)
}
?: emptyList(),
chapters = maybeChapters ?: filesAsChapters(),
libraryId = item.libraryId,
localProvided = false,
year = item.media.metadata.publishedYear,
abstract = item.media.metadata.description,
publisher = item.media.metadata.publisher,
series = item
.media
.metadata
.series
?.map {
BookSeries(
name = it.name,
serialNumber = it.sequence,
)
} ?: emptyList(),
createdAt = item.addedAt,
updatedAt = item.ctimeMs,
progress = progressResponse
?.let {
MediaProgress(
currentTime = it.currentTime,
isFinished = it.isFinished,
lastUpdate = it.lastUpdate,
)
},
)
?: emptyList(),
chapters = maybeChapters ?: filesAsChapters(),
libraryId = item.libraryId,
localProvided = false,
year = item.media.metadata.publishedYear,
abstract = item.media.metadata.description,
publisher = item.media.metadata.publisher,
series =
item
.media
.metadata
.series
?.map {
BookSeries(
name = it.name,
serialNumber = it.sequence,
)
} ?: emptyList(),
createdAt = item.addedAt,
updatedAt = item.ctimeMs,
progress =
progressResponse
?.let {
MediaProgress(
currentTime = it.currentTime,
isFinished = it.isFinished,
lastUpdate = it.lastUpdate,
)
},
)
}
}
}

View File

@@ -7,21 +7,24 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LibraryOrderingRequestConverter @Inject constructor() {
class LibraryOrderingRequestConverter
@Inject
constructor() {
fun apply(configuration: LibraryOrderingConfiguration): Pair<String, String> {
val option = when (configuration.option) {
LibraryOrderingOption.TITLE -> "media.metadata.title"
LibraryOrderingOption.AUTHOR -> "media.metadata.authorName"
LibraryOrderingOption.CREATED_AT -> "addedAt"
LibraryOrderingOption.UPDATED_AT -> "mtimeMs"
val option =
when (configuration.option) {
LibraryOrderingOption.TITLE -> "media.metadata.title"
LibraryOrderingOption.AUTHOR -> "media.metadata.authorName"
LibraryOrderingOption.CREATED_AT -> "addedAt"
LibraryOrderingOption.UPDATED_AT -> "mtimeMs"
}
val direction = when (configuration.direction) {
LibraryOrderingDirection.ASCENDING -> "0"
LibraryOrderingDirection.DESCENDING -> "1"
val direction =
when (configuration.direction) {
LibraryOrderingDirection.ASCENDING -> "0"
LibraryOrderingDirection.DESCENDING -> "1"
}
return option to direction
return option to direction
}
}
}

View File

@@ -6,18 +6,21 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LibrarySearchItemsConverter @Inject constructor() {
fun apply(response: List<LibraryItem>) = response
class LibrarySearchItemsConverter
@Inject
constructor() {
fun apply(response: List<LibraryItem>) =
response
.mapNotNull {
val title = it.media.metadata.title ?: return@mapNotNull null
val title = it.media.metadata.title ?: return@mapNotNull null
Book(
id = it.id,
title = title,
series = it.media.metadata.seriesName,
subtitle = it.media.metadata.subtitle,
author = it.media.metadata.authorName,
duration = it.media.duration.toInt(),
)
Book(
id = it.id,
title = title,
series = it.media.metadata.seriesName,
subtitle = it.media.metadata.subtitle,
author = it.media.metadata.authorName,
duration = it.media.duration.toInt(),
)
}
}
}

View File

@@ -4,73 +4,73 @@ import androidx.annotation.Keep
@Keep
data class BookResponse(
val id: String,
val ino: String,
val libraryId: String,
val media: BookMedia,
val addedAt: Long,
val ctimeMs: Long,
val id: String,
val ino: String,
val libraryId: String,
val media: BookMedia,
val addedAt: Long,
val ctimeMs: Long,
)
@Keep
data class BookMedia(
val metadata: LibraryMetadataResponse,
val audioFiles: List<BookAudioFileResponse>?,
val chapters: List<LibraryChapterResponse>?,
val metadata: LibraryMetadataResponse,
val audioFiles: List<BookAudioFileResponse>?,
val chapters: List<LibraryChapterResponse>?,
)
@Keep
data class LibraryMetadataResponse(
val title: String,
val subtitle: String?,
val authors: List<LibraryAuthorResponse>?,
val narrators: List<String>?,
val series: List<LibrarySeriesResponse>?,
val description: String?,
val publisher: String?,
val publishedYear: String?,
val title: String,
val subtitle: String?,
val authors: List<LibraryAuthorResponse>?,
val narrators: List<String>?,
val series: List<LibrarySeriesResponse>?,
val description: String?,
val publisher: String?,
val publishedYear: String?,
)
@Keep
data class LibrarySeriesResponse(
val id: String,
val name: String,
val sequence: String?,
val id: String,
val name: String,
val sequence: String?,
)
@Keep
data class LibraryAuthorResponse(
val id: String,
val name: String,
val id: String,
val name: String,
)
@Keep
data class BookAudioFileResponse(
val index: Int,
val ino: String,
val duration: Double,
val metadata: AudioFileMetadata,
val metaTags: AudioFileTag?,
val mimeType: String,
val index: Int,
val ino: String,
val duration: Double,
val metadata: AudioFileMetadata,
val metaTags: AudioFileTag?,
val mimeType: String,
)
@Keep
data class AudioFileMetadata(
val filename: String,
val ext: String,
val size: Long,
val filename: String,
val ext: String,
val size: Long,
)
@Keep
data class AudioFileTag(
val tagAlbum: String,
val tagTitle: String,
val tagAlbum: String,
val tagTitle: String,
)
@Keep
data class LibraryChapterResponse(
val start: Double,
val end: Double,
val title: String,
val id: String,
val start: Double,
val end: Double,
val title: String,
val id: String,
)

View File

@@ -4,26 +4,26 @@ import androidx.annotation.Keep
@Keep
data class LibraryItemsResponse(
val results: List<LibraryItem>,
val page: Int,
val results: List<LibraryItem>,
val page: Int,
)
@Keep
data class LibraryItem(
val id: String,
val media: Media,
val id: String,
val media: Media,
)
@Keep
data class Media(
val duration: Double,
val metadata: LibraryMetadata,
val duration: Double,
val metadata: LibraryMetadata,
)
@Keep
data class LibraryMetadata(
val title: String?,
val subtitle: String?,
val seriesName: String?,
val authorName: String?,
val title: String?,
val subtitle: String?,
val seriesName: String?,
val authorName: String?,
)

View File

@@ -4,23 +4,23 @@ import androidx.annotation.Keep
@Keep
data class LibrarySearchResponse(
val book: List<LibrarySearchItemResponse>,
val authors: List<LibrarySearchAuthorResponse>,
val series: List<LibrarySearchSeriesResponse>,
val book: List<LibrarySearchItemResponse>,
val authors: List<LibrarySearchAuthorResponse>,
val series: List<LibrarySearchSeriesResponse>,
)
@Keep
data class LibrarySearchItemResponse(
val libraryItem: LibraryItem,
val libraryItem: LibraryItem,
)
@Keep
data class LibrarySearchAuthorResponse(
val id: String,
val name: String,
val id: String,
val name: String,
)
@Keep
data class LibrarySearchSeriesResponse(
val books: List<LibraryItem>,
val books: List<LibraryItem>,
)

View File

@@ -27,7 +27,9 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PodcastAudiobookshelfChannel @Inject constructor(
class PodcastAudiobookshelfChannel
@Inject
constructor(
dataRepository: AudioBookshelfDataRepository,
mediaRepository: AudioBookshelfMediaRepository,
recentListeningResponseConverter: RecentListeningResponseConverter,
@@ -40,102 +42,106 @@ class PodcastAudiobookshelfChannel @Inject constructor(
private val podcastPageResponseConverter: PodcastPageResponseConverter,
private val podcastResponseConverter: PodcastResponseConverter,
private val podcastSearchItemsConverter: PodcastSearchItemsConverter,
) : AudiobookshelfChannel(
dataRepository = dataRepository,
mediaRepository = mediaRepository,
recentBookResponseConverter = recentListeningResponseConverter,
sessionResponseConverter = sessionResponseConverter,
preferences = preferences,
syncService = syncService,
libraryResponseConverter = libraryResponseConverter,
connectionInfoResponseConverter = connectionInfoResponseConverter,
) {
) : AudiobookshelfChannel(
dataRepository = dataRepository,
mediaRepository = mediaRepository,
recentBookResponseConverter = recentListeningResponseConverter,
sessionResponseConverter = sessionResponseConverter,
preferences = preferences,
syncService = syncService,
libraryResponseConverter = libraryResponseConverter,
connectionInfoResponseConverter = connectionInfoResponseConverter,
) {
override fun getLibraryType() = LibraryType.PODCAST
override suspend fun fetchBooks(
libraryId: String,
pageSize: Int,
pageNumber: Int,
libraryId: String,
pageSize: Int,
pageNumber: Int,
): ApiResult<PagedItems<Book>> {
val (option, direction) = podcastOrderingRequestConverter.apply(preferences.getLibraryOrdering())
val (option, direction) = podcastOrderingRequestConverter.apply(preferences.getLibraryOrdering())
return dataRepository
.fetchPodcastItems(
libraryId = libraryId,
pageSize = pageSize,
pageNumber = pageNumber,
sort = option,
direction = direction,
)
.map { podcastPageResponseConverter.apply(it) }
return dataRepository
.fetchPodcastItems(
libraryId = libraryId,
pageSize = pageSize,
pageNumber = pageNumber,
sort = option,
direction = direction,
).map { podcastPageResponseConverter.apply(it) }
}
override suspend fun searchBooks(
libraryId: String,
query: String,
limit: Int,
): ApiResult<List<Book>> = coroutineScope {
val byTitle = async {
libraryId: String,
query: String,
limit: Int,
): ApiResult<List<Book>> =
coroutineScope {
val byTitle =
async {
dataRepository
.searchPodcasts(libraryId, query, limit)
.map { it.podcast }
.map { it.map { response -> response.libraryItem } }
.map { podcastSearchItemsConverter.apply(it) }
}
.searchPodcasts(libraryId, query, limit)
.map { it.podcast }
.map { it.map { response -> response.libraryItem } }
.map { podcastSearchItemsConverter.apply(it) }
}
byTitle.await()
}
}
override suspend fun startPlayback(
bookId: String,
episodeId: String,
supportedMimeTypes: List<String>,
deviceId: String,
bookId: String,
episodeId: String,
supportedMimeTypes: List<String>,
deviceId: String,
): ApiResult<PlaybackSession> {
val request = PlaybackStartRequest(
supportedMimeTypes = supportedMimeTypes,
deviceInfo = DeviceInfo(
clientName = getClientName(),
deviceId = deviceId,
deviceName = getClientName(),
val request =
PlaybackStartRequest(
supportedMimeTypes = supportedMimeTypes,
deviceInfo =
DeviceInfo(
clientName = getClientName(),
deviceId = deviceId,
deviceName = getClientName(),
),
forceTranscode = false,
forceDirectPlay = false,
mediaPlayer = getClientName(),
forceTranscode = false,
forceDirectPlay = false,
mediaPlayer = getClientName(),
)
return dataRepository
.startPodcastPlayback(
itemId = bookId,
episodeId = episodeId,
request = request,
)
.map { sessionResponseConverter.apply(it) }
return dataRepository
.startPodcastPlayback(
itemId = bookId,
episodeId = episodeId,
request = request,
).map { sessionResponseConverter.apply(it) }
}
override suspend fun fetchBook(bookId: String): ApiResult<DetailedItem> = coroutineScope {
val mediaProgress = async {
val progress = dataRepository
override suspend fun fetchBook(bookId: String): ApiResult<DetailedItem> =
coroutineScope {
val mediaProgress =
async {
val progress =
dataRepository
.fetchUserInfoResponse()
.fold(
onSuccess = { it.user.mediaProgress ?: emptyList() },
onFailure = { emptyList() },
onSuccess = { it.user.mediaProgress ?: emptyList() },
onFailure = { emptyList() },
)
if (progress.isEmpty()) {
return@async null
return@async null
}
progress
.filter { it.libraryItemId == bookId }
.filterNot { it.episodeId == null }
.sortedByDescending { it.lastUpdate }
.distinctBy { it.episodeId }
}
.filter { it.libraryItemId == bookId }
.filterNot { it.episodeId == null }
.sortedByDescending { it.lastUpdate }
.distinctBy { it.episodeId }
}
async { dataRepository.fetchPodcastItem(bookId) }
.await()
.map { podcastResponseConverter.apply(it, mediaProgress.await() ?: emptyList()) }
}
}
.await()
.map { podcastResponseConverter.apply(it, mediaProgress.await() ?: emptyList()) }
}
}

View File

@@ -7,21 +7,24 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PodcastOrderingRequestConverter @Inject constructor() {
class PodcastOrderingRequestConverter
@Inject
constructor() {
fun apply(configuration: LibraryOrderingConfiguration): Pair<String, String> {
val option = when (configuration.option) {
LibraryOrderingOption.TITLE -> "media.metadata.title"
LibraryOrderingOption.AUTHOR -> "media.metadata.author"
LibraryOrderingOption.CREATED_AT -> "addedAt"
LibraryOrderingOption.UPDATED_AT -> "mtimeMs"
val option =
when (configuration.option) {
LibraryOrderingOption.TITLE -> "media.metadata.title"
LibraryOrderingOption.AUTHOR -> "media.metadata.author"
LibraryOrderingOption.CREATED_AT -> "addedAt"
LibraryOrderingOption.UPDATED_AT -> "mtimeMs"
}
val direction = when (configuration.direction) {
LibraryOrderingDirection.ASCENDING -> "0"
LibraryOrderingDirection.DESCENDING -> "1"
val direction =
when (configuration.direction) {
LibraryOrderingDirection.ASCENDING -> "0"
LibraryOrderingDirection.DESCENDING -> "1"
}
return option to direction
return option to direction
}
}
}

View File

@@ -7,26 +7,27 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PodcastPageResponseConverter @Inject constructor() {
fun apply(response: PodcastItemsResponse): PagedItems<Book> = response
class PodcastPageResponseConverter
@Inject
constructor() {
fun apply(response: PodcastItemsResponse): PagedItems<Book> =
response
.results
.mapNotNull {
val title = it.media.metadata.title ?: return@mapNotNull null
val title = it.media.metadata.title ?: return@mapNotNull null
Book(
id = it.id,
title = title,
subtitle = null,
series = null,
author = it.media.metadata.author,
duration = it.media.duration.toInt(),
)
Book(
id = it.id,
title = title,
subtitle = null,
series = null,
author = it.media.metadata.author,
duration = it.media.duration.toInt(),
)
}.let {
PagedItems(
items = it,
currentPage = response.page,
)
}
.let {
PagedItems(
items = it,
currentPage = response.page,
)
}
}
}

View File

@@ -14,119 +14,121 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PodcastResponseConverter @Inject constructor() {
class PodcastResponseConverter
@Inject
constructor() {
fun apply(
item: PodcastResponse,
progressResponses: List<MediaProgressResponse> = emptyList(),
item: PodcastResponse,
progressResponses: List<MediaProgressResponse> = emptyList(),
): DetailedItem {
val orderedEpisodes = item
.media
.episodes
?.orderEpisode()
val orderedEpisodes =
item
.media
.episodes
?.orderEpisode()
val totalCurrentTime = progressResponses
.maxByOrNull { it.lastUpdate }
?.let { progress ->
orderedEpisodes
?.takeWhile { it.id != progress.episodeId }
?.sumOf { it.audioFile.duration }
?.plus(progress.currentTime)
}
val latestEpisodeMediaProgress = progressResponses
.maxByOrNull { it.lastUpdate }
?.let {
MediaProgress(
currentTime = totalCurrentTime ?: 0.0,
isFinished = it.isFinished,
lastUpdate = it.lastUpdate,
)
}
val filesAsChapters: List<PlayingChapter> =
val totalCurrentTime =
progressResponses
.maxByOrNull { it.lastUpdate }
?.let { progress ->
orderedEpisodes
?.fold(0.0 to mutableListOf<PlayingChapter>()) { (accDuration, chapters), episode ->
chapters.add(
PlayingChapter(
start = accDuration,
end = accDuration + episode.audioFile.duration,
title = episode.title,
duration = episode.audioFile.duration,
id = episode.id,
available = true,
podcastEpisodeState = progressResponses
.find { it.episodeId == episode.id }
?.let { hasFinished(it) },
),
)
accDuration + episode.audioFile.duration to chapters
}
?.second
?: emptyList()
?.takeWhile { it.id != progress.episodeId }
?.sumOf { it.audioFile.duration }
?.plus(progress.currentTime)
}
return DetailedItem(
id = item.id,
title = item.media.metadata.title,
subtitle = null,
libraryId = item.libraryId,
author = item.media.metadata.author,
narrator = null,
localProvided = false,
files = orderedEpisodes
?.map {
BookFile(
id = it.audioFile.ino,
name = it.title,
duration = it.audioFile.duration,
mimeType = it.audioFile.mimeType,
)
}
?: emptyList(),
chapters = filesAsChapters,
progress = latestEpisodeMediaProgress,
year = null, // we have no "Year" for the ongoing media
abstract = item.media.metadata.description,
publisher = item.media.metadata.publisher,
series = emptyList(), // there is no series for podcast
createdAt = item.addedAt,
updatedAt = item.ctimeMs,
)
val latestEpisodeMediaProgress =
progressResponses
.maxByOrNull { it.lastUpdate }
?.let {
MediaProgress(
currentTime = totalCurrentTime ?: 0.0,
isFinished = it.isFinished,
lastUpdate = it.lastUpdate,
)
}
val filesAsChapters: List<PlayingChapter> =
orderedEpisodes
?.fold(0.0 to mutableListOf<PlayingChapter>()) { (accDuration, chapters), episode ->
chapters.add(
PlayingChapter(
start = accDuration,
end = accDuration + episode.audioFile.duration,
title = episode.title,
duration = episode.audioFile.duration,
id = episode.id,
available = true,
podcastEpisodeState =
progressResponses
.find { it.episodeId == episode.id }
?.let { hasFinished(it) },
),
)
accDuration + episode.audioFile.duration to chapters
}?.second
?: emptyList()
return DetailedItem(
id = item.id,
title = item.media.metadata.title,
subtitle = null,
libraryId = item.libraryId,
author = item.media.metadata.author,
narrator = null,
localProvided = false,
files =
orderedEpisodes
?.map {
BookFile(
id = it.audioFile.ino,
name = it.title,
duration = it.audioFile.duration,
mimeType = it.audioFile.mimeType,
)
}
?: emptyList(),
chapters = filesAsChapters,
progress = latestEpisodeMediaProgress,
year = null, // we have no "Year" for the ongoing media
abstract = item.media.metadata.description,
publisher = item.media.metadata.publisher,
series = emptyList(), // there is no series for podcast
createdAt = item.addedAt,
updatedAt = item.ctimeMs,
)
}
private fun hasFinished(progress: MediaProgressResponse): BookChapterState? {
return when (progress.isFinished || progress.progress > FINISHED_PROGRESS_THRESHOLD) {
true -> BookChapterState.FINISHED
false -> null
}
}
private fun hasFinished(progress: MediaProgressResponse): BookChapterState? =
when (progress.isFinished || progress.progress > FINISHED_PROGRESS_THRESHOLD) {
true -> BookChapterState.FINISHED
false -> null
}
companion object {
private const val FINISHED_PROGRESS_THRESHOLD = 0.9
private val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH)
private const val FINISHED_PROGRESS_THRESHOLD = 0.9
private val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH)
private fun List<PodcastEpisodeResponse>.orderEpisode() =
this.sortedWith(
compareBy<PodcastEpisodeResponse> { item ->
try {
item.pubDate?.let { dateFormat.parse(it)?.time }
} catch (e: Exception) {
null
}
}
.thenBy { it.season.safeToInt() }
.thenBy { it.episode.safeToInt() },
)
private fun String?.safeToInt(): Int? {
val maybeNumber = this?.takeIf { it.isNotBlank() }
return try {
maybeNumber?.toInt()
} catch (ex: Exception) {
null
private fun List<PodcastEpisodeResponse>.orderEpisode() =
this.sortedWith(
compareBy<PodcastEpisodeResponse> { item ->
try {
item.pubDate?.let { dateFormat.parse(it)?.time }
} catch (e: Exception) {
null
}
}.thenBy { it.season.safeToInt() }
.thenBy { it.episode.safeToInt() },
)
private fun String?.safeToInt(): Int? {
val maybeNumber = this?.takeIf { it.isNotBlank() }
return try {
maybeNumber?.toInt()
} catch (ex: Exception) {
null
}
}
}
}
}

View File

@@ -6,20 +6,22 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PodcastSearchItemsConverter @Inject constructor() {
class PodcastSearchItemsConverter
@Inject
constructor() {
fun apply(response: List<PodcastItem>): List<Book> {
return response
.mapNotNull {
val title = it.media.metadata.title ?: return@mapNotNull null
return response
.mapNotNull {
val title = it.media.metadata.title ?: return@mapNotNull null
Book(
id = it.id,
title = title,
subtitle = null,
series = null,
author = it.media.metadata.author,
duration = it.media.duration.toInt(),
)
}
Book(
id = it.id,
title = title,
subtitle = null,
series = null,
author = it.media.metadata.author,
duration = it.media.duration.toInt(),
)
}
}
}
}

View File

@@ -4,24 +4,24 @@ import androidx.annotation.Keep
@Keep
data class PodcastItemsResponse(
val results: List<PodcastItem>,
val page: Int,
val results: List<PodcastItem>,
val page: Int,
)
@Keep
data class PodcastItem(
val id: String,
val media: PodcastItemMedia,
val id: String,
val media: PodcastItemMedia,
)
@Keep
data class PodcastItemMedia(
val duration: Double,
val metadata: PodcastMetadata,
val duration: Double,
val metadata: PodcastMetadata,
)
@Keep
data class PodcastMetadata(
val title: String?,
val author: String?,
val title: String?,
val author: String?,
)

View File

@@ -4,42 +4,42 @@ import androidx.annotation.Keep
@Keep
data class PodcastResponse(
val id: String,
val ino: String,
val libraryId: String,
val media: PodcastMedia,
val addedAt: Long,
val ctimeMs: Long,
val id: String,
val ino: String,
val libraryId: String,
val media: PodcastMedia,
val addedAt: Long,
val ctimeMs: Long,
)
@Keep
data class PodcastMedia(
val metadata: PodcastMediaMetadataResponse,
val episodes: List<PodcastEpisodeResponse>?,
val metadata: PodcastMediaMetadataResponse,
val episodes: List<PodcastEpisodeResponse>?,
)
@Keep
data class PodcastMediaMetadataResponse(
val title: String,
val author: String?,
val description: String?,
val publisher: String?,
val title: String,
val author: String?,
val description: String?,
val publisher: String?,
)
@Keep
data class PodcastEpisodeResponse(
val id: String,
val season: String?,
val episode: String?,
val pubDate: String?,
val title: String,
val audioFile: PodcastAudioFileResponse,
val id: String,
val season: String?,
val episode: String?,
val pubDate: String?,
val title: String,
val audioFile: PodcastAudioFileResponse,
)
@Keep
data class PodcastAudioFileResponse(
val index: Int,
val ino: String,
val duration: Double,
val mimeType: String,
val index: Int,
val ino: String,
val duration: Double,
val mimeType: String,
)

View File

@@ -4,10 +4,10 @@ import androidx.annotation.Keep
@Keep
data class PodcastSearchResponse(
val podcast: List<PodcastSearchItemResponse>,
val podcast: List<PodcastSearchItemResponse>,
)
@Keep
data class PodcastSearchItemResponse(
val libraryItem: PodcastItem,
val libraryItem: PodcastItem,
)

View File

@@ -11,43 +11,42 @@ import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
class ApiClient(
host: String,
requestHeaders: List<ServerRequestHeader>?,
token: String? = null,
host: String,
requestHeaders: List<ServerRequestHeader>?,
token: String? = null,
) {
private val httpClient =
OkHttpClient
.Builder()
.withTrustedCertificates()
.addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.NONE
},
).addInterceptor { chain: Interceptor.Chain ->
val original: Request = chain.request()
val requestBuilder: Request.Builder = original.newBuilder()
private val httpClient = OkHttpClient
.Builder()
.withTrustedCertificates()
.addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.NONE
},
)
.addInterceptor { chain: Interceptor.Chain ->
val original: Request = chain.request()
val requestBuilder: Request.Builder = original.newBuilder()
if (token != null) {
requestBuilder.header("Authorization", "Bearer $token")
}
requestHeaders
?.filter { it.name.isNotEmpty() }
?.filter { it.value.isNotEmpty() }
?.forEach { requestBuilder.header(it.name, it.value) }
val request: Request = requestBuilder.build()
chain.proceed(request)
if (token != null) {
requestBuilder.header("Authorization", "Bearer $token")
}
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.build()
val retrofit: Retrofit =
Retrofit.Builder()
.baseUrl(host)
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
requestHeaders
?.filter { it.name.isNotEmpty() }
?.filter { it.value.isNotEmpty() }
?.forEach { requestBuilder.header(it.name, it.value) }
val request: Request = requestBuilder.build()
chain.proceed(request)
}.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.build()
val retrofit: Retrofit =
Retrofit
.Builder()
.baseUrl(host)
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}

View File

@@ -6,19 +6,29 @@ import org.grakovne.lissen.channel.audiobookshelf.common.oauth.AuthHost
import org.grakovne.lissen.channel.audiobookshelf.common.oauth.AuthScheme
sealed class ApiError {
data object Unauthorized : ApiError()
data object NetworkError : ApiError()
data object InvalidCredentialsHost : ApiError()
data object MissingCredentialsHost : ApiError()
data object MissingCredentialsUsername : ApiError()
data object MissingCredentialsPassword : ApiError()
data object InternalError : ApiError()
data object InvalidRedirectUri : ApiError()
data object OAuthFlowFailed : ApiError()
data object UnsupportedError : ApiError()
data object Unauthorized : ApiError()
data object NetworkError : ApiError()
data object InvalidCredentialsHost : ApiError()
data object MissingCredentialsHost : ApiError()
data object MissingCredentialsUsername : ApiError()
data object MissingCredentialsPassword : ApiError()
data object InternalError : ApiError()
data object InvalidRedirectUri : ApiError()
data object OAuthFlowFailed : ApiError()
data object UnsupportedError : ApiError()
}
fun ApiError.makeText(context: Context) = when (this) {
fun ApiError.makeText(context: Context) =
when (this) {
ApiError.InternalError -> context.getString(R.string.login_error_host_is_down)
ApiError.MissingCredentialsHost -> context.getString(R.string.login_error_host_url_is_missing)
ApiError.MissingCredentialsPassword -> context.getString(R.string.login_error_username_is_missing)
@@ -26,7 +36,12 @@ fun ApiError.makeText(context: Context) = when (this) {
ApiError.Unauthorized -> context.getString(R.string.login_error_credentials_are_invalid)
ApiError.InvalidCredentialsHost -> context.getString(R.string.login_error_host_url_shall_be_https_or_http)
ApiError.NetworkError -> context.getString(R.string.login_error_connection_error)
ApiError.InvalidRedirectUri -> context.getString(R.string.login_error_lissen_auth_scheme_must_be_whitelisted, AuthScheme, AuthHost)
ApiError.InvalidRedirectUri ->
context.getString(
R.string.login_error_lissen_auth_scheme_must_be_whitelisted,
AuthScheme,
AuthHost,
)
ApiError.UnsupportedError -> context.getString(R.string.login_error_connection_error)
ApiError.OAuthFlowFailed -> context.getString(R.string.login_error_lissen_auth_failed)
}
}

View File

@@ -4,40 +4,42 @@ import androidx.annotation.Keep
@Keep
sealed class ApiResult<T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error<T>(val code: ApiError, val message: String? = null) : ApiResult<T>()
data class Success<T>(
val data: T,
) : ApiResult<T>()
fun <R> fold(
onSuccess: (T) -> R,
onFailure: (Error<T>) -> R,
): R {
return when (this) {
is Success -> onSuccess(this.data)
is Error -> onFailure(this)
}
data class Error<T>(
val code: ApiError,
val message: String? = null,
) : ApiResult<T>()
fun <R> fold(
onSuccess: (T) -> R,
onFailure: (Error<T>) -> R,
): R =
when (this) {
is Success -> onSuccess(this.data)
is Error -> onFailure(this)
}
suspend fun <R> foldAsync(
onSuccess: suspend (T) -> R,
onFailure: suspend (Error<T>) -> R,
): R {
return when (this) {
is Success -> onSuccess(this.data)
is Error -> onFailure(this)
}
suspend fun <R> foldAsync(
onSuccess: suspend (T) -> R,
onFailure: suspend (Error<T>) -> R,
): R =
when (this) {
is Success -> onSuccess(this.data)
is Error -> onFailure(this)
}
suspend fun <R> map(transform: suspend (T) -> R): ApiResult<R> {
return when (this) {
is Success -> Success(transform(this.data))
is Error -> Error(this.code, this.message)
}
suspend fun <R> map(transform: suspend (T) -> R): ApiResult<R> =
when (this) {
is Success -> Success(transform(this.data))
is Error -> Error(this.code, this.message)
}
suspend fun <R> flatMap(transform: suspend (T) -> ApiResult<R>): ApiResult<R> {
return when (this) {
is Success -> transform(this.data)
is Error -> Error(this.code, this.message)
}
suspend fun <R> flatMap(transform: suspend (T) -> ApiResult<R>): ApiResult<R> =
when (this) {
is Success -> transform(this.data)
is Error -> Error(this.code, this.message)
}
}

View File

@@ -1,6 +1,6 @@
package org.grakovne.lissen.channel.common
enum class AuthMethod {
CREDENTIALS,
O_AUTH,
CREDENTIALS,
O_AUTH,
}

View File

@@ -9,38 +9,39 @@ import retrofit2.Retrofit
import java.util.concurrent.TimeUnit
class BinaryApiClient(
host: String,
requestHeaders: List<ServerRequestHeader>?,
token: String,
host: String,
requestHeaders: List<ServerRequestHeader>?,
token: String,
) {
private val httpClient =
OkHttpClient
.Builder()
.withTrustedCertificates()
.addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.NONE
},
).addInterceptor { chain: Interceptor.Chain ->
val request =
chain
.request()
.newBuilder()
.header("Authorization", "Bearer $token")
private val httpClient = OkHttpClient
.Builder()
.withTrustedCertificates()
.addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.NONE
},
)
.addInterceptor { chain: Interceptor.Chain ->
val request = chain
.request()
.newBuilder()
.header("Authorization", "Bearer $token")
requestHeaders
?.filter { it.name.isNotEmpty() }
?.filter { it.value.isNotEmpty() }
?.forEach { request.header(it.name, it.value) }
requestHeaders
?.filter { it.name.isNotEmpty() }
?.filter { it.value.isNotEmpty() }
?.forEach { request.header(it.name, it.value) }
chain.proceed(request.build())
}.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.build()
chain.proceed(request.build())
}
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.build()
val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(host)
.client(httpClient)
.build()
val retrofit: Retrofit =
Retrofit
.Builder()
.baseUrl(host)
.client(httpClient)
.build()
}

View File

@@ -4,48 +4,44 @@ import org.grakovne.lissen.domain.UserAccount
import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences
abstract class ChannelAuthService(
private val preferences: LissenSharedPreferences,
private val preferences: LissenSharedPreferences,
) {
abstract suspend fun authorize(
host: String,
username: String,
password: String,
onSuccess: suspend (UserAccount) -> Unit,
): ApiResult<UserAccount>
abstract suspend fun authorize(
host: String,
username: String,
password: String,
onSuccess: suspend (UserAccount) -> Unit,
): ApiResult<UserAccount>
abstract suspend fun startOAuth(
host: String,
onSuccess: () -> Unit,
onFailure: (ApiError) -> Unit,
)
abstract suspend fun startOAuth(
host: String,
onSuccess: () -> Unit,
onFailure: (ApiError) -> Unit,
)
abstract suspend fun exchangeToken(
host: String,
code: String,
onSuccess: suspend (UserAccount) -> Unit,
onFailure: (String) -> Unit,
)
abstract suspend fun exchangeToken(
host: String,
code: String,
onSuccess: suspend (UserAccount) -> Unit,
onFailure: (String) -> Unit,
)
abstract suspend fun fetchAuthMethods(host: String): ApiResult<List<AuthMethod>>
abstract suspend fun fetchAuthMethods(
host: String,
): ApiResult<List<AuthMethod>>
fun persistCredentials(
host: String,
username: String,
token: String,
) {
preferences.saveHost(host)
preferences.saveUsername(username)
preferences.saveToken(token)
}
fun persistCredentials(
host: String,
username: String,
token: String,
) {
preferences.saveHost(host)
preferences.saveUsername(username)
preferences.saveToken(token)
}
fun examineError(raw: String): ApiError {
return when {
raw.contains("Invalid redirect_uri") -> ApiError.InvalidRedirectUri
raw.contains("invalid_host") -> ApiError.MissingCredentialsHost
else -> ApiError.OAuthFlowFailed
}
fun examineError(raw: String): ApiError =
when {
raw.contains("Invalid redirect_uri") -> ApiError.InvalidRedirectUri
raw.contains("invalid_host") -> ApiError.MissingCredentialsHost
else -> ApiError.OAuthFlowFailed
}
}

View File

@@ -1,5 +1,5 @@
package org.grakovne.lissen.channel.common
enum class ChannelCode {
AUDIOBOOKSHELF,
AUDIOBOOKSHELF,
}

View File

@@ -6,6 +6,6 @@ import org.grakovne.lissen.common.LibraryOrderingOption
@Keep
data class ChannelFilteringConfiguration(
val orderingOptions: List<LibraryOrderingOption>,
val defaultOrdering: LibraryOrderingConfiguration,
val orderingOptions: List<LibraryOrderingOption>,
val defaultOrdering: LibraryOrderingConfiguration,
)

View File

@@ -12,15 +12,13 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ChannelModule {
@OptIn(UnstableApi::class)
@Provides
@Singleton
fun getChannelProviders(
audiobookshelfChannelProvider: AudiobookshelfChannelProvider,
): Map<ChannelCode, @JvmSuppressWildcards ChannelProvider> {
return mapOf(
audiobookshelfChannelProvider.getChannelCode() to audiobookshelfChannelProvider,
)
}
@OptIn(UnstableApi::class)
@Provides
@Singleton
fun getChannelProviders(
audiobookshelfChannelProvider: AudiobookshelfChannelProvider,
): Map<ChannelCode, @JvmSuppressWildcards ChannelProvider> =
mapOf(
audiobookshelfChannelProvider.getChannelCode() to audiobookshelfChannelProvider,
)
}

View File

@@ -1,10 +1,9 @@
package org.grakovne.lissen.channel.common
interface ChannelProvider {
fun provideMediaChannel(): MediaChannel
fun provideMediaChannel(): MediaChannel
fun provideChannelAuth(): ChannelAuthService
fun provideChannelAuth(): ChannelAuthService
fun getChannelCode(): ChannelCode
fun getChannelCode(): ChannelCode
}

View File

@@ -4,7 +4,7 @@ import androidx.annotation.Keep
@Keep
data class ConnectionInfo(
val username: String,
val serverVersion: String?,
val buildNumber: String?,
val username: String,
val serverVersion: String?,
val buildNumber: String?,
)

View File

@@ -1,7 +1,7 @@
package org.grakovne.lissen.channel.common
enum class LibraryType {
LIBRARY,
PODCAST,
UNKNOWN,
LIBRARY,
PODCAST,
UNKNOWN,
}

View File

@@ -11,47 +11,44 @@ import org.grakovne.lissen.domain.RecentBook
import java.io.InputStream
interface MediaChannel {
fun getLibraryType(): LibraryType
fun getLibraryType(): LibraryType
fun provideFileUri(
libraryItemId: String,
fileId: String,
): Uri
fun provideFileUri(
libraryItemId: String,
fileId: String,
): Uri
suspend fun syncProgress(
sessionId: String,
progress: PlaybackProgress,
): ApiResult<Unit>
suspend fun syncProgress(
sessionId: String,
progress: PlaybackProgress,
): ApiResult<Unit>
suspend fun fetchBookCover(bookId: String): ApiResult<InputStream>
suspend fun fetchBookCover(
bookId: String,
): ApiResult<InputStream>
suspend fun fetchBooks(
libraryId: String,
pageSize: Int,
pageNumber: Int,
): ApiResult<PagedItems<Book>>
suspend fun fetchBooks(
libraryId: String,
pageSize: Int,
pageNumber: Int,
): ApiResult<PagedItems<Book>>
suspend fun searchBooks(
libraryId: String,
query: String,
limit: Int,
): ApiResult<List<Book>>
suspend fun searchBooks(
libraryId: String,
query: String,
limit: Int,
): ApiResult<List<Book>>
suspend fun fetchLibraries(): ApiResult<List<Library>>
suspend fun fetchLibraries(): ApiResult<List<Library>>
suspend fun startPlayback(
bookId: String,
episodeId: String,
supportedMimeTypes: List<String>,
deviceId: String,
): ApiResult<PlaybackSession>
suspend fun startPlayback(
bookId: String,
episodeId: String,
supportedMimeTypes: List<String>,
deviceId: String,
): ApiResult<PlaybackSession>
suspend fun fetchConnectionInfo(): ApiResult<ConnectionInfo>
suspend fun fetchConnectionInfo(): ApiResult<ConnectionInfo>
suspend fun fetchRecentListenedBooks(libraryId: String): ApiResult<List<RecentBook>>
suspend fun fetchRecentListenedBooks(libraryId: String): ApiResult<List<RecentBook>>
suspend fun fetchBook(bookId: String): ApiResult<DetailedItem>
suspend fun fetchBook(bookId: String): ApiResult<DetailedItem>
}

View File

@@ -4,30 +4,31 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class OAuthContextCache @Inject constructor() {
class OAuthContextCache
@Inject
constructor() {
private var pkce: Pkce = clearPkce()
private var cookies: String = clearCookies()
fun storePkce(pkce: Pkce) {
this.pkce = pkce
this.pkce = pkce
}
fun readPkce() = pkce
fun clearPkce(): Pkce {
pkce = Pkce("", "", "")
return pkce
pkce = Pkce("", "", "")
return pkce
}
fun storeCookies(cookies: List<String>) {
this.cookies = cookies.joinToString("; ") { it.substringBefore(";") }
this.cookies = cookies.joinToString("; ") { it.substringBefore(";") }
}
fun readCookies() = cookies
fun clearCookies(): String {
cookies = ""
return cookies
cookies = ""
return cookies
}
}
}

View File

@@ -6,37 +6,38 @@ import java.security.MessageDigest
import java.util.Base64
fun randomPkce(): Pkce {
val verifier = generateRandomHexString(42)
val challenge = base64UrlEncode(sha256(verifier))
val state = generateRandomHexString(42)
val verifier = generateRandomHexString(42)
val challenge = base64UrlEncode(sha256(verifier))
val state = generateRandomHexString(42)
return Pkce(
verifier = verifier,
challenge = challenge,
state = state,
)
return Pkce(
verifier = verifier,
challenge = challenge,
state = state,
)
}
private fun generateRandomHexString(byteCount: Int = 32): String {
val array = ByteArray(byteCount)
java.security.SecureRandom().nextBytes(array)
val array = ByteArray(byteCount)
java.security.SecureRandom().nextBytes(array)
return array.joinToString("") { "%02x".format(it) }
return array.joinToString("") { "%02x".format(it) }
}
private fun sha256(input: String): ByteArray {
val digest = MessageDigest.getInstance("SHA-256")
return digest.digest(input.toByteArray(StandardCharsets.US_ASCII))
val digest = MessageDigest.getInstance("SHA-256")
return digest.digest(input.toByteArray(StandardCharsets.US_ASCII))
}
private fun base64UrlEncode(bytes: ByteArray) = Base64
private fun base64UrlEncode(bytes: ByteArray) =
Base64
.getUrlEncoder()
.withoutPadding()
.encodeToString(bytes)
@Keep
data class Pkce(
val verifier: String,
val challenge: String,
val state: String,
val verifier: String,
val challenge: String,
val state: String,
)

View File

@@ -2,4 +2,7 @@ package org.grakovne.lissen.channel.common
import android.os.Build
val USER_AGENT = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.106 Mobile Safari/537.36"
val USER_AGENT =
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; K) " +
"AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/130.0.6723.106 Mobile Safari/537.36"

View File

@@ -8,27 +8,26 @@ import javax.net.ssl.TrustManagerFactory.getInstance
import javax.net.ssl.X509TrustManager
private val systemTrustManager: X509TrustManager by lazy {
val keyStore = KeyStore.getInstance("AndroidCAStore")
keyStore.load(null)
val keyStore = KeyStore.getInstance("AndroidCAStore")
keyStore.load(null)
val trustManagerFactory = getInstance(TrustManagerFactory.getDefaultAlgorithm())
trustManagerFactory.init(keyStore)
val trustManagerFactory = getInstance(TrustManagerFactory.getDefaultAlgorithm())
trustManagerFactory.init(keyStore)
trustManagerFactory
.trustManagers
.first { it is X509TrustManager } as X509TrustManager
trustManagerFactory
.trustManagers
.first { it is X509TrustManager } as X509TrustManager
}
private val systemSSLContext: SSLContext by lazy {
SSLContext.getInstance("TLS").apply {
init(null, arrayOf(systemTrustManager), null)
}
SSLContext.getInstance("TLS").apply {
init(null, arrayOf(systemTrustManager), null)
}
}
fun OkHttpClient.Builder.withTrustedCertificates(): OkHttpClient.Builder {
return try {
sslSocketFactory(systemSSLContext.socketFactory, systemTrustManager)
} catch (ex: Exception) {
this
}
}
fun OkHttpClient.Builder.withTrustedCertificates(): OkHttpClient.Builder =
try {
sslSocketFactory(systemSSLContext.socketFactory, systemTrustManager)
} catch (ex: Exception) {
this
}

View File

@@ -1,9 +1,9 @@
package org.grakovne.lissen.common
enum class ColorScheme {
FOLLOW_SYSTEM,
FOLLOW_SYSTEM,
LIGHT,
DARK,
BLACK,
LIGHT,
DARK,
BLACK,
}

View File

@@ -4,9 +4,9 @@ import android.view.HapticFeedbackConstants
import android.view.View
fun hapticAction(
view: View,
action: () -> Unit,
view: View,
action: () -> Unit,
) {
action()
view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
action()
view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
}

View File

@@ -4,17 +4,15 @@ import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import java.util.concurrent.TimeUnit
fun createOkHttpClient(): OkHttpClient {
return OkHttpClient
.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.withTrustedCertificates()
.addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.NONE
},
)
.build()
}
fun createOkHttpClient(): OkHttpClient =
OkHttpClient
.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.withTrustedCertificates()
.addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.NONE
},
).build()

View File

@@ -4,11 +4,12 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
fun String.fromBase64(): Bitmap? = try {
fun String.fromBase64(): Bitmap? =
try {
val bytes = Base64.decode(this, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} catch (ex: Exception) {
} catch (ex: Exception) {
null
}
}
fun ByteArray.toBase64(): String = Base64.encodeToString(this, Base64.DEFAULT)

View File

@@ -5,27 +5,27 @@ import androidx.compose.runtime.saveable.Saver
@Keep
data class LibraryOrderingConfiguration(
val option: LibraryOrderingOption,
val direction: LibraryOrderingDirection,
val option: LibraryOrderingOption,
val direction: LibraryOrderingDirection,
) {
companion object {
val default =
LibraryOrderingConfiguration(
option = LibraryOrderingOption.TITLE,
direction = LibraryOrderingDirection.ASCENDING,
)
companion object {
val default = LibraryOrderingConfiguration(
option = LibraryOrderingOption.TITLE,
direction = LibraryOrderingDirection.ASCENDING,
)
val saver: Saver<LibraryOrderingConfiguration, *> = Saver(
save = {
listOf(it.option.name, it.direction.name)
},
restore = {
LibraryOrderingConfiguration(
option = LibraryOrderingOption.valueOf(it[0]),
direction = LibraryOrderingDirection.valueOf(it[1]),
)
},
)
}
val saver: Saver<LibraryOrderingConfiguration, *> =
Saver(
save = {
listOf(it.option.name, it.direction.name)
},
restore = {
LibraryOrderingConfiguration(
option = LibraryOrderingOption.valueOf(it[0]),
direction = LibraryOrderingDirection.valueOf(it[1]),
)
},
)
}
}

View File

@@ -1,6 +1,6 @@
package org.grakovne.lissen.common
enum class LibraryOrderingDirection {
ASCENDING,
DESCENDING,
ASCENDING,
DESCENDING,
}

View File

@@ -1,8 +1,8 @@
package org.grakovne.lissen.common
enum class LibraryOrderingOption {
TITLE,
AUTHOR,
UPDATED_AT,
CREATED_AT,
TITLE,
AUTHOR,
UPDATED_AT,
CREATED_AT,
}

View File

@@ -9,19 +9,21 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NetworkQualityService @Inject constructor(
class NetworkQualityService
@Inject
constructor(
@ApplicationContext private val context: Context,
) {
) {
private val connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
fun isNetworkAvailable(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val network = connectivityManager.activeNetwork ?: return false
val networkCapabilities = connectivityManager
.getNetworkCapabilities(network)
?: return false
val networkCapabilities =
connectivityManager
.getNetworkCapabilities(network)
?: return false
return networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
return networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}
}

View File

@@ -1,6 +1,5 @@
package org.grakovne.lissen.common
interface RunningComponent {
fun onCreate()
fun onCreate()
}

View File

@@ -24,287 +24,288 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LissenMediaProvider @Inject constructor(
class LissenMediaProvider
@Inject
constructor(
private val preferences: LissenSharedPreferences,
private val channels: Map<ChannelCode, @JvmSuppressWildcards ChannelProvider>,
private val localCacheRepository: LocalCacheRepository,
) {
) {
fun provideFileUri(
libraryItemId: String,
chapterId: String,
libraryItemId: String,
chapterId: String,
): ApiResult<Uri> {
Log.d(TAG, "Fetching File $libraryItemId and $chapterId URI")
Log.d(TAG, "Fetching File $libraryItemId and $chapterId URI")
return when (preferences.isForceCache()) {
true ->
localCacheRepository
.provideFileUri(libraryItemId, chapterId)
?.let { ApiResult.Success(it) }
?: ApiResult.Error(ApiError.InternalError)
return when (preferences.isForceCache()) {
true ->
localCacheRepository
.provideFileUri(libraryItemId, chapterId)
?.let { ApiResult.Success(it) }
?: ApiResult.Error(ApiError.InternalError)
false ->
localCacheRepository
.provideFileUri(libraryItemId, chapterId)
?.let { ApiResult.Success(it) }
?: providePreferredChannel()
.provideFileUri(libraryItemId, chapterId)
.let { ApiResult.Success(it) }
}
false ->
localCacheRepository
.provideFileUri(libraryItemId, chapterId)
?.let { ApiResult.Success(it) }
?: providePreferredChannel()
.provideFileUri(libraryItemId, chapterId)
.let { ApiResult.Success(it) }
}
}
suspend fun syncProgress(
sessionId: String,
bookId: String,
progress: PlaybackProgress,
sessionId: String,
bookId: String,
progress: PlaybackProgress,
): ApiResult<Unit> {
Log.d(TAG, "Syncing Progress for $bookId. $progress")
Log.d(TAG, "Syncing Progress for $bookId. $progress")
providePreferredChannel().syncProgress(sessionId, progress)
localCacheRepository.syncProgress(bookId, progress)
providePreferredChannel().syncProgress(sessionId, progress)
localCacheRepository.syncProgress(bookId, progress)
return ApiResult.Success(Unit)
return ApiResult.Success(Unit)
}
suspend fun fetchBookCover(
bookId: String,
): ApiResult<InputStream> {
Log.d(TAG, "Fetching Cover stream for $bookId")
suspend fun fetchBookCover(bookId: String): ApiResult<InputStream> {
Log.d(TAG, "Fetching Cover stream for $bookId")
return when (preferences.isForceCache()) {
true -> localCacheRepository.fetchBookCover(bookId)
false -> providePreferredChannel().fetchBookCover(bookId)
}
return when (preferences.isForceCache()) {
true -> localCacheRepository.fetchBookCover(bookId)
false -> providePreferredChannel().fetchBookCover(bookId)
}
}
suspend fun searchBooks(
libraryId: String,
query: String,
limit: Int,
libraryId: String,
query: String,
limit: Int,
): ApiResult<List<Book>> {
Log.d(TAG, "Searching books with query $query of library: $libraryId")
Log.d(TAG, "Searching books with query $query of library: $libraryId")
return when (preferences.isForceCache()) {
true -> localCacheRepository.searchBooks(query)
false -> providePreferredChannel()
.searchBooks(
libraryId = libraryId,
query = query,
limit = limit,
)
}
return when (preferences.isForceCache()) {
true -> localCacheRepository.searchBooks(query)
false ->
providePreferredChannel()
.searchBooks(
libraryId = libraryId,
query = query,
limit = limit,
)
}
}
suspend fun fetchBooks(
libraryId: String,
pageSize: Int,
pageNumber: Int,
libraryId: String,
pageSize: Int,
pageNumber: Int,
): ApiResult<PagedItems<Book>> {
Log.d(TAG, "Fetching page $pageNumber of library: $libraryId")
Log.d(TAG, "Fetching page $pageNumber of library: $libraryId")
return when (preferences.isForceCache()) {
true -> localCacheRepository.fetchBooks(pageSize, pageNumber)
false -> providePreferredChannel().fetchBooks(libraryId, pageSize, pageNumber)
}
return when (preferences.isForceCache()) {
true -> localCacheRepository.fetchBooks(pageSize, pageNumber)
false -> providePreferredChannel().fetchBooks(libraryId, pageSize, pageNumber)
}
}
suspend fun fetchLibraries(): ApiResult<List<Library>> {
Log.d(TAG, "Fetching List of libraries")
Log.d(TAG, "Fetching List of libraries")
return when (preferences.isForceCache()) {
true -> localCacheRepository.fetchLibraries()
false -> providePreferredChannel()
.fetchLibraries()
.also {
it.foldAsync(
onSuccess = { libraries -> localCacheRepository.updateLibraries(libraries) },
onFailure = {},
)
}
}
return when (preferences.isForceCache()) {
true -> localCacheRepository.fetchLibraries()
false ->
providePreferredChannel()
.fetchLibraries()
.also {
it.foldAsync(
onSuccess = { libraries -> localCacheRepository.updateLibraries(libraries) },
onFailure = {},
)
}
}
}
suspend fun startPlayback(
bookId: String,
chapterId: String,
supportedMimeTypes: List<String>,
deviceId: String,
bookId: String,
chapterId: String,
supportedMimeTypes: List<String>,
deviceId: String,
): ApiResult<PlaybackSession> {
Log.d(TAG, "Starting Playback for $bookId. $supportedMimeTypes are supported")
Log.d(TAG, "Starting Playback for $bookId. $supportedMimeTypes are supported")
return providePreferredChannel().startPlayback(
bookId = bookId,
episodeId = chapterId,
supportedMimeTypes = supportedMimeTypes,
deviceId = deviceId,
)
return providePreferredChannel().startPlayback(
bookId = bookId,
episodeId = chapterId,
supportedMimeTypes = supportedMimeTypes,
deviceId = deviceId,
)
}
suspend fun fetchRecentListenedBooks(
libraryId: String,
): ApiResult<List<RecentBook>> {
Log.d(TAG, "Fetching Recent books of library $libraryId")
suspend fun fetchRecentListenedBooks(libraryId: String): ApiResult<List<RecentBook>> {
Log.d(TAG, "Fetching Recent books of library $libraryId")
return when (preferences.isForceCache()) {
true -> localCacheRepository.fetchRecentListenedBooks()
false -> providePreferredChannel()
.fetchRecentListenedBooks(libraryId)
.map { items -> syncFromLocalProgress(items) }
}
return when (preferences.isForceCache()) {
true -> localCacheRepository.fetchRecentListenedBooks()
false ->
providePreferredChannel()
.fetchRecentListenedBooks(libraryId)
.map { items -> syncFromLocalProgress(items) }
}
}
suspend fun fetchBook(
bookId: String,
): ApiResult<DetailedItem> {
Log.d(TAG, "Fetching Detailed book info for $bookId")
suspend fun fetchBook(bookId: String): ApiResult<DetailedItem> {
Log.d(TAG, "Fetching Detailed book info for $bookId")
return when (preferences.isForceCache()) {
true ->
localCacheRepository
.fetchBook(bookId)
?.let { ApiResult.Success(it) }
?: ApiResult.Error(ApiError.InternalError)
return when (preferences.isForceCache()) {
true ->
localCacheRepository
.fetchBook(bookId)
?.let { ApiResult.Success(it) }
?: ApiResult.Error(ApiError.InternalError)
false -> providePreferredChannel()
.fetchBook(bookId)
.map { syncFromLocalProgress(it) }
}
false ->
providePreferredChannel()
.fetchBook(bookId)
.map { syncFromLocalProgress(it) }
}
}
suspend fun authorize(
host: String,
username: String,
password: String,
host: String,
username: String,
password: String,
): ApiResult<UserAccount> {
Log.d(TAG, "Authorizing for $username@$host")
return provideAuthService().authorize(host, username, password) { onPostLogin(host, it) }
Log.d(TAG, "Authorizing for $username@$host")
return provideAuthService().authorize(host, username, password) { onPostLogin(host, it) }
}
suspend fun startOAuth(
host: String,
onSuccess: () -> Unit,
onFailure: (ApiError) -> Unit,
host: String,
onSuccess: () -> Unit,
onFailure: (ApiError) -> Unit,
) {
Log.d(TAG, "Starting OAuth for $host")
Log.d(TAG, "Starting OAuth for $host")
return provideAuthService()
.startOAuth(
host = host,
onSuccess = onSuccess,
onFailure = { onFailure(it) },
)
return provideAuthService()
.startOAuth(
host = host,
onSuccess = onSuccess,
onFailure = { onFailure(it) },
)
}
suspend fun onPostLogin(
host: String,
account: UserAccount,
host: String,
account: UserAccount,
) {
provideAuthService()
.persistCredentials(
host = host,
username = account.username,
token = account.token,
)
provideAuthService()
.persistCredentials(
host = host,
username = account.username,
token = account.token,
)
fetchLibraries()
.fold(
onSuccess = {
val preferredLibrary = it
.find { item -> item.id == account.preferredLibraryId }
?: it.firstOrNull()
fetchLibraries()
.fold(
onSuccess = {
val preferredLibrary =
it
.find { item -> item.id == account.preferredLibraryId }
?: it.firstOrNull()
preferredLibrary
?.let { library ->
preferences.savePreferredLibrary(
Library(
id = library.id,
title = library.title,
type = library.type,
),
)
}
},
onFailure = {
account
.preferredLibraryId
?.let { library ->
Library(
id = library,
title = "Default Library",
type = LibraryType.LIBRARY,
)
}
?.let { preferences.savePreferredLibrary(it) }
},
)
preferredLibrary
?.let { library ->
preferences.savePreferredLibrary(
Library(
id = library.id,
title = library.title,
type = library.type,
),
)
}
},
onFailure = {
account
.preferredLibraryId
?.let { library ->
Library(
id = library,
title = "Default Library",
type = LibraryType.LIBRARY,
)
}?.let { preferences.savePreferredLibrary(it) }
},
)
}
private suspend fun syncFromLocalProgress(
detailedItems: List<RecentBook>,
): List<RecentBook> {
val localRecentlyBooks = localCacheRepository
.fetchRecentListenedBooks()
.fold(
onSuccess = { it },
onFailure = { return@fold detailedItems },
)
private suspend fun syncFromLocalProgress(detailedItems: List<RecentBook>): List<RecentBook> {
val localRecentlyBooks =
localCacheRepository
.fetchRecentListenedBooks()
.fold(
onSuccess = { it },
onFailure = { return@fold detailedItems },
)
val syncedRecentlyBooks = detailedItems
.mapNotNull { item -> localRecentlyBooks.find { it.id == item.id }?.let { item to it } }
.map { (remote, local) ->
val localTimestamp = local.listenedLastUpdate ?: return@map remote
val remoteTimestamp = remote.listenedLastUpdate ?: return@map remote
val syncedRecentlyBooks =
detailedItems
.mapNotNull { item -> localRecentlyBooks.find { it.id == item.id }?.let { item to it } }
.map { (remote, local) ->
val localTimestamp = local.listenedLastUpdate ?: return@map remote
val remoteTimestamp = remote.listenedLastUpdate ?: return@map remote
when (remoteTimestamp > localTimestamp) {
true -> remote
false -> local
}
when (remoteTimestamp > localTimestamp) {
true -> remote
false -> local
}
}
return detailedItems
.map { item ->
syncedRecentlyBooks
.find { item.id == it.id }
?.let { local -> item.copy(listenedPercentage = local.listenedPercentage) }
?: item
}
return detailedItems
.map { item ->
syncedRecentlyBooks
.find { item.id == it.id }
?.let { local -> item.copy(listenedPercentage = local.listenedPercentage) }
?: item
}
}
private suspend fun syncFromLocalProgress(detailedItem: DetailedItem): DetailedItem {
val cachedBook = localCacheRepository.fetchBook(detailedItem.id) ?: return detailedItem
val cachedBook = localCacheRepository.fetchBook(detailedItem.id) ?: return detailedItem
val cachedProgress = cachedBook.progress ?: return detailedItem
val channelProgress = detailedItem.progress
val cachedProgress = cachedBook.progress ?: return detailedItem
val channelProgress = detailedItem.progress
val updatedProgress = listOfNotNull(cachedProgress, channelProgress)
.maxByOrNull { it.lastUpdate }
?: return detailedItem
val updatedProgress =
listOfNotNull(cachedProgress, channelProgress)
.maxByOrNull { it.lastUpdate }
?: return detailedItem
Log.d(
TAG,
"""
Merging local playback progress into channel-fetched:
Channel Progress: $channelProgress
Cached Progress: $cachedProgress
Final Progress: $updatedProgress
""".trimIndent(),
)
Log.d(
TAG,
"""
Merging local playback progress into channel-fetched:
Channel Progress: $channelProgress
Cached Progress: $cachedProgress
Final Progress: $updatedProgress
""".trimIndent(),
)
return detailedItem.copy(progress = updatedProgress)
return detailedItem.copy(progress = updatedProgress)
}
suspend fun fetchConnectionInfo() = providePreferredChannel().fetchConnectionInfo()
fun provideAuthService(): ChannelAuthService = channels[preferences.getChannel()]
fun provideAuthService(): ChannelAuthService =
channels[preferences.getChannel()]
?.provideChannelAuth()
?: throw IllegalStateException("Selected auth service has been requested but not selected")
fun providePreferredChannel(): MediaChannel = channels[preferences.getChannel()]
fun providePreferredChannel(): MediaChannel =
channels[preferences.getChannel()]
?.provideMediaChannel()
?: throw IllegalStateException("Selected auth service has been requested but not selected")
companion object {
private const val TAG: String = "LissenMediaProvider"
private const val TAG: String = "LissenMediaProvider"
}
}
}

View File

@@ -7,29 +7,33 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CacheBookStorageProperties @Inject constructor(
class CacheBookStorageProperties
@Inject
constructor(
@ApplicationContext private val context: Context,
) {
fun provideBookCache(bookId: String) = context
) {
fun provideBookCache(bookId: String) =
context
.getExternalFilesDir(MEDIA_CACHE_FOLDER)
?.resolve(bookId)
fun provideMediaCachePatch(bookId: String, fileId: String) = context
fun provideMediaCachePatch(
bookId: String,
fileId: String,
) = context
.getExternalFilesDir(MEDIA_CACHE_FOLDER)
?.resolve(bookId)
?.resolve(fileId)
?: throw IllegalStateException("")
fun provideBookCoverPath(bookId: String): File =
context
.getExternalFilesDir(MEDIA_CACHE_FOLDER)
?.resolve(bookId)
?.resolve(fileId)
?.resolve("cover.img")
?: throw IllegalStateException("")
fun provideBookCoverPath(bookId: String): File {
return context
.getExternalFilesDir(MEDIA_CACHE_FOLDER)
?.resolve(bookId)
?.resolve("cover.img")
?: throw IllegalStateException("")
}
companion object {
const val MEDIA_CACHE_FOLDER = "media_cache"
const val MEDIA_CACHE_FOLDER = "media_cache"
}
}
}

View File

@@ -5,6 +5,6 @@ import org.grakovne.lissen.domain.CacheStatus
@Keep
data class CacheState(
val status: CacheStatus,
val progress: Double = 0.0,
val status: CacheStatus,
val progress: Double = 0.0,
)

View File

@@ -9,18 +9,19 @@ import org.grakovne.lissen.domain.PlayingChapter
import org.grakovne.lissen.playback.service.calculateChapterIndex
fun calculateRequestedChapters(
book: DetailedItem,
option: DownloadOption,
currentTotalPosition: Double,
book: DetailedItem,
option: DownloadOption,
currentTotalPosition: Double,
): List<PlayingChapter> {
val chapterIndex = calculateChapterIndex(book, currentTotalPosition)
val chapterIndex = calculateChapterIndex(book, currentTotalPosition)
return when (option) {
AllItemsDownloadOption -> book.chapters
CurrentItemDownloadOption -> listOfNotNull(book.chapters.getOrNull(chapterIndex))
is NumberItemDownloadOption -> book.chapters.subList(
chapterIndex.coerceAtLeast(0),
(chapterIndex + option.itemsNumber).coerceIn(chapterIndex..book.chapters.size),
)
}
return when (option) {
AllItemsDownloadOption -> book.chapters
CurrentItemDownloadOption -> listOfNotNull(book.chapters.getOrNull(chapterIndex))
is NumberItemDownloadOption ->
book.chapters.subList(
chapterIndex.coerceAtLeast(0),
(chapterIndex + option.itemsNumber).coerceIn(chapterIndex..book.chapters.size),
)
}
}

View File

@@ -6,21 +6,17 @@ import org.grakovne.lissen.domain.DetailedItem
import org.grakovne.lissen.domain.DownloadOption
class ContentCachingExecutor(
private val item: DetailedItem,
private val options: DownloadOption,
private val position: Double,
private val contentCachingManager: ContentCachingManager,
private val item: DetailedItem,
private val options: DownloadOption,
private val position: Double,
private val contentCachingManager: ContentCachingManager,
) {
fun run(
channel: MediaChannel,
): Flow<CacheState> {
return contentCachingManager
.cacheMediaItem(
mediaItem = item,
option = options,
channel = channel,
currentTotalPosition = position,
)
}
fun run(channel: MediaChannel): Flow<CacheState> =
contentCachingManager
.cacheMediaItem(
mediaItem = item,
option = options,
channel = channel,
currentTotalPosition = position,
)
}

View File

@@ -20,162 +20,172 @@ import javax.inject.Singleton
import kotlin.coroutines.coroutineContext
@Singleton
class ContentCachingManager @Inject constructor(
class ContentCachingManager
@Inject
constructor(
private val bookRepository: CachedBookRepository,
private val libraryRepository: CachedLibraryRepository,
private val properties: CacheBookStorageProperties,
private val requestHeadersProvider: RequestHeadersProvider,
) {
) {
fun cacheMediaItem(
mediaItem: DetailedItem,
option: DownloadOption,
channel: MediaChannel,
currentTotalPosition: Double,
mediaItem: DetailedItem,
option: DownloadOption,
channel: MediaChannel,
currentTotalPosition: Double,
) = flow {
val context = coroutineContext
emit(CacheState(CacheStatus.Caching))
val context = coroutineContext
emit(CacheState(CacheStatus.Caching))
val requestedChapters = calculateRequestedChapters(
book = mediaItem,
option = option,
currentTotalPosition = currentTotalPosition,
val requestedChapters =
calculateRequestedChapters(
book = mediaItem,
option = option,
currentTotalPosition = currentTotalPosition,
)
val requestedFiles = findRequestedFiles(mediaItem, requestedChapters)
val requestedFiles = findRequestedFiles(mediaItem, requestedChapters)
val mediaCachingResult = cacheBookMedia(
mediaItem.id,
requestedFiles,
channel,
val mediaCachingResult =
cacheBookMedia(
mediaItem.id,
requestedFiles,
channel,
) { withContext(context) { emit(CacheState(CacheStatus.Caching, it)) } }
val coverCachingResult = cacheBookCover(mediaItem, channel)
val librariesCachingResult = cacheLibraries(channel)
val coverCachingResult = cacheBookCover(mediaItem, channel)
val librariesCachingResult = cacheLibraries(channel)
when {
listOf(
mediaCachingResult,
coverCachingResult,
librariesCachingResult,
)
.all { it.status == CacheStatus.Completed } -> {
cacheBookInfo(mediaItem, requestedChapters)
emit(CacheState(CacheStatus.Completed))
}
else -> emit(CacheState(CacheStatus.Error))
when {
listOf(
mediaCachingResult,
coverCachingResult,
librariesCachingResult,
).all { it.status == CacheStatus.Completed } -> {
cacheBookInfo(mediaItem, requestedChapters)
emit(CacheState(CacheStatus.Completed))
}
else -> emit(CacheState(CacheStatus.Error))
}
}
suspend fun dropCache(itemId: String) {
bookRepository.removeBook(itemId)
bookRepository.removeBook(itemId)
val cachedContent = properties
.provideBookCache(itemId)
?: return
val cachedContent =
properties
.provideBookCache(itemId)
?: return
if (cachedContent.exists()) {
cachedContent.deleteRecursively()
}
if (cachedContent.exists()) {
cachedContent.deleteRecursively()
}
}
fun hasMetadataCached(mediaItemId: String) = bookRepository.provideCacheState(mediaItemId)
private suspend fun cacheBookMedia(
bookId: String,
files: List<BookFile>,
channel: MediaChannel,
onProgress: suspend (Double) -> Unit,
): CacheState = withContext(Dispatchers.IO) {
bookId: String,
files: List<BookFile>,
channel: MediaChannel,
onProgress: suspend (Double) -> Unit,
): CacheState =
withContext(Dispatchers.IO) {
val headers = requestHeadersProvider.fetchRequestHeaders()
val client = createOkHttpClient()
files.mapIndexed { index, file ->
val uri = channel.provideFileUri(bookId, file.id)
val requestBuilder = Request.Builder().url(uri.toString())
headers.forEach { requestBuilder.addHeader(it.name, it.value) }
val uri = channel.provideFileUri(bookId, file.id)
val requestBuilder = Request.Builder().url(uri.toString())
headers.forEach { requestBuilder.addHeader(it.name, it.value) }
val request = requestBuilder.build()
val response = client.newCall(request).execute()
val request = requestBuilder.build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
Log.e(TAG, "Unable to cache media content: $response")
return@withContext CacheState(CacheStatus.Error)
if (!response.isSuccessful) {
Log.e(TAG, "Unable to cache media content: $response")
return@withContext CacheState(CacheStatus.Error)
}
val body = response.body ?: return@withContext CacheState(CacheStatus.Error)
val dest = properties.provideMediaCachePatch(bookId, file.id)
dest.parentFile?.mkdirs()
try {
dest.outputStream().use { output ->
body.byteStream().use { input ->
input.copyTo(output)
}
}
} catch (ex: Exception) {
return@withContext CacheState(CacheStatus.Error)
}
val body = response.body ?: return@withContext CacheState(CacheStatus.Error)
val dest = properties.provideMediaCachePatch(bookId, file.id)
dest.parentFile?.mkdirs()
try {
dest.outputStream().use { output ->
body.byteStream().use { input ->
input.copyTo(output)
}
}
} catch (ex: Exception) {
return@withContext CacheState(CacheStatus.Error)
}
onProgress(files.size.takeIf { it != 0 }?.let { index / it.toDouble() } ?: 0.0)
onProgress(files.size.takeIf { it != 0 }?.let { index / it.toDouble() } ?: 0.0)
}
CacheState(CacheStatus.Completed)
}
}
private suspend fun cacheBookCover(book: DetailedItem, channel: MediaChannel): CacheState {
val file = properties.provideBookCoverPath(book.id)
private suspend fun cacheBookCover(
book: DetailedItem,
channel: MediaChannel,
): CacheState {
val file = properties.provideBookCoverPath(book.id)
return withContext(Dispatchers.IO) {
channel
.fetchBookCover(book.id)
.fold(
onSuccess = { inputStream ->
if (!file.exists()) {
file.parentFile?.mkdirs()
file.createNewFile()
}
return withContext(Dispatchers.IO) {
channel
.fetchBookCover(book.id)
.fold(
onSuccess = { inputStream ->
if (!file.exists()) {
file.parentFile?.mkdirs()
file.createNewFile()
}
file.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
},
onFailure = {
},
)
file.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
},
onFailure = {
},
)
CacheState(CacheStatus.Completed)
}
CacheState(CacheStatus.Completed)
}
}
private suspend fun cacheBookInfo(
book: DetailedItem,
fetchedChapters: List<PlayingChapter>,
): CacheState = bookRepository
book: DetailedItem,
fetchedChapters: List<PlayingChapter>,
): CacheState =
bookRepository
.cacheBook(book, fetchedChapters)
.let { CacheState(CacheStatus.Completed) }
private suspend fun cacheLibraries(channel: MediaChannel): CacheState = channel
private suspend fun cacheLibraries(channel: MediaChannel): CacheState =
channel
.fetchLibraries()
.foldAsync(
onSuccess = {
libraryRepository.cacheLibraries(it)
CacheState(CacheStatus.Completed)
},
onFailure = {
CacheState(CacheStatus.Error)
},
onSuccess = {
libraryRepository.cacheLibraries(it)
CacheState(CacheStatus.Completed)
},
onFailure = {
CacheState(CacheStatus.Error)
},
)
private fun findRequestedFiles(
book: DetailedItem,
requestedChapters: List<PlayingChapter>,
): List<BookFile> = requestedChapters
book: DetailedItem,
requestedChapters: List<PlayingChapter>,
): List<BookFile> =
requestedChapters
.flatMap { findRelatedFiles(it, book.files) }
.distinctBy { it.id }
companion object {
private const val TAG = "ContentCachingManager"
private const val TAG = "ContentCachingManager"
}
}
}

View File

@@ -13,47 +13,49 @@ import javax.inject.Singleton
import kotlin.math.roundToInt
@Singleton
class ContentCachingNotificationService @Inject constructor(
class ContentCachingNotificationService
@Inject
constructor(
@ApplicationContext private val context: Context,
) {
) {
private val service = context.getSystemService(NotificationManager::class.java)
fun cancel() = context
fun cancel() =
context
.getSystemService(NotificationManager::class.java)
.cancel(NOTIFICATION_ID)
fun updateCachingNotification(
items: List<Pair<DetailedItem, CacheState>>,
): Notification {
val cachingItems = items
.filter { (_, state) -> listOf(CacheStatus.Caching, CacheStatus.Completed).contains(state.status) }
fun updateCachingNotification(items: List<Pair<DetailedItem, CacheState>>): Notification {
val cachingItems =
items
.filter { (_, state) -> listOf(CacheStatus.Caching, CacheStatus.Completed).contains(state.status) }
val itemProgress = cachingItems.sumOf { (_, state) ->
when (state.status) {
CacheStatus.Caching -> state.progress
CacheStatus.Completed -> 1.0
else -> 0.0
}
val itemProgress =
cachingItems.sumOf { (_, state) ->
when (state.status) {
CacheStatus.Caching -> state.progress
CacheStatus.Completed -> 1.0
else -> 0.0
}
}
return Notification
.Builder(context, createNotificationChannel())
.setContentText(items.provideCachingTitles())
.setContentTitle(context.getString(R.string.notification_content_caching_title))
.setSmallIcon(R.drawable.ic_downloading)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setProgress(
cachingItems.size * 100,
(itemProgress * 100).roundToInt(),
cachingItems.isEmpty(),
)
.build()
.also { service.notify(NOTIFICATION_ID, it) }
return Notification
.Builder(context, createNotificationChannel())
.setContentText(items.provideCachingTitles())
.setContentTitle(context.getString(R.string.notification_content_caching_title))
.setSmallIcon(R.drawable.ic_downloading)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setProgress(
cachingItems.size * 100,
(itemProgress * 100).roundToInt(),
cachingItems.isEmpty(),
).build()
.also { service.notify(NOTIFICATION_ID, it) }
}
fun updateErrorNotification(): Notification = Notification
fun updateErrorNotification(): Notification =
Notification
.Builder(context, createNotificationChannel())
.setContentTitle(context.getString(R.string.notification_content_caching_error_title))
.setContentText(context.getString(R.string.notification_content_caching_error_description))
@@ -62,24 +64,25 @@ class ContentCachingNotificationService @Inject constructor(
.also { service.notify(NOTIFICATION_ID, it) }
private fun createNotificationChannel(): String {
val channelId = "caching_channel"
val channelId = "caching_channel"
val channel = NotificationChannel(
channelId,
context.getString(R.string.notification_content_caching_channel),
NotificationManager.IMPORTANCE_DEFAULT,
val channel =
NotificationChannel(
channelId,
context.getString(R.string.notification_content_caching_channel),
NotificationManager.IMPORTANCE_DEFAULT,
)
service.createNotificationChannel(channel)
return channelId
service.createNotificationChannel(channel)
return channelId
}
companion object {
private fun List<Pair<DetailedItem, CacheState>>.provideCachingTitles() =
this
.filter { (_, state) -> CacheStatus.Caching == state.status }
.joinToString(", ") { (key, _) -> key.title }
private fun List<Pair<DetailedItem, CacheState>>.provideCachingTitles() = this
.filter { (_, state) -> CacheStatus.Caching == state.status }
.joinToString(", ") { (key, _) -> key.title }
const val NOTIFICATION_ID = 2042025
const val NOTIFICATION_ID = 2042025
}
}
}

View File

@@ -7,11 +7,16 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ContentCachingProgress @Inject constructor() {
class ContentCachingProgress
@Inject
constructor() {
private val _statusFlow = MutableSharedFlow<Pair<DetailedItem, CacheState>>(replay = 1)
val statusFlow = _statusFlow.asSharedFlow()
suspend fun emit(item: DetailedItem, progress: CacheState) {
_statusFlow.emit(item to progress)
suspend fun emit(
item: DetailedItem,
progress: CacheState,
) {
_statusFlow.emit(item to progress)
}
}
}

View File

@@ -17,117 +17,116 @@ import javax.inject.Inject
@AndroidEntryPoint
class ContentCachingService : LifecycleService() {
@Inject
lateinit var contentCachingManager: ContentCachingManager
@Inject
lateinit var contentCachingManager: ContentCachingManager
@Inject
lateinit var mediaProvider: LissenMediaProvider
@Inject
lateinit var mediaProvider: LissenMediaProvider
@Inject
lateinit var cacheProgressBus: ContentCachingProgress
@Inject
lateinit var cacheProgressBus: ContentCachingProgress
@Inject
lateinit var notificationService: ContentCachingNotificationService
@Inject
lateinit var notificationService: ContentCachingNotificationService
private val executionStatuses = mutableMapOf<DetailedItem, CacheState>()
private val executionStatuses = mutableMapOf<DetailedItem, CacheState>()
@Suppress("DEPRECATION")
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
startForeground(
NOTIFICATION_ID,
notificationService.updateCachingNotification(emptyList()),
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
else -> {
startForeground(
NOTIFICATION_ID,
notificationService.updateCachingNotification(emptyList()),
)
}
}
val task = intent
?.getSerializableExtra(CACHING_TASK_EXTRA)
as? ContentCachingTask
?: return START_STICKY
lifecycleScope.launch {
val item = mediaProvider
.providePreferredChannel()
.fetchBook(task.itemId)
.fold(
onSuccess = { it },
onFailure = {
notificationService.updateErrorNotification()
null
},
)
?: return@launch
val executor = ContentCachingExecutor(
item = item,
options = task.options,
position = task.currentPosition,
contentCachingManager = contentCachingManager,
)
executor
.run(mediaProvider.providePreferredChannel())
.collect { progress ->
executionStatuses[item] = progress
cacheProgressBus.emit(item, progress)
Log.d(TAG, "Caching progress updated: $progress")
when (inProgress()) {
true ->
executionStatuses
.entries
.map { (item, status) -> item to status }
.let { notificationService.updateCachingNotification(it) }
false -> finish()
}
}
}
return super.onStartCommand(intent, flags, startId)
@Suppress("DEPRECATION")
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
startForeground(
NOTIFICATION_ID,
notificationService.updateCachingNotification(emptyList()),
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
else -> {
startForeground(
NOTIFICATION_ID,
notificationService.updateCachingNotification(emptyList()),
)
}
}
private fun inProgress(): Boolean =
executionStatuses.values.any { it.status == CacheStatus.Caching }
val task =
intent
?.getSerializableExtra(CACHING_TASK_EXTRA)
as? ContentCachingTask
?: return START_STICKY
private fun hasErrors(): Boolean =
executionStatuses.values.any { it.status == CacheStatus.Error }
lifecycleScope.launch {
val item =
mediaProvider
.providePreferredChannel()
.fetchBook(task.itemId)
.fold(
onSuccess = { it },
onFailure = {
notificationService.updateErrorNotification()
null
},
)
?: return@launch
private fun finish() {
when (hasErrors()) {
true -> {
notificationService.updateErrorNotification()
stopForeground(STOP_FOREGROUND_DETACH)
}
val executor =
ContentCachingExecutor(
item = item,
options = task.options,
position = task.currentPosition,
contentCachingManager = contentCachingManager,
)
false -> {
stopForeground(STOP_FOREGROUND_REMOVE)
notificationService.cancel()
}
executor
.run(mediaProvider.providePreferredChannel())
.collect { progress ->
executionStatuses[item] = progress
cacheProgressBus.emit(item, progress)
Log.d(TAG, "Caching progress updated: $progress")
when (inProgress()) {
true ->
executionStatuses
.entries
.map { (item, status) -> item to status }
.let { notificationService.updateCachingNotification(it) }
false -> finish()
}
}
stopSelf()
Log.d(TAG, "All tasks finished, stopping foreground service")
}
companion object {
return super.onStartCommand(intent, flags, startId)
}
const val CACHING_TASK_EXTRA = "CACHING_TASK_EXTRA"
private const val TAG = "ContentCachingService"
private fun inProgress(): Boolean = executionStatuses.values.any { it.status == CacheStatus.Caching }
private fun hasErrors(): Boolean = executionStatuses.values.any { it.status == CacheStatus.Error }
private fun finish() {
when (hasErrors()) {
true -> {
notificationService.updateErrorNotification()
stopForeground(STOP_FOREGROUND_DETACH)
}
false -> {
stopForeground(STOP_FOREGROUND_REMOVE)
notificationService.cancel()
}
}
stopSelf()
Log.d(TAG, "All tasks finished, stopping foreground service")
}
companion object {
const val CACHING_TASK_EXTRA = "CACHING_TASK_EXTRA"
private const val TAG = "ContentCachingService"
}
}

View File

@@ -4,27 +4,28 @@ import org.grakovne.lissen.domain.BookFile
import org.grakovne.lissen.domain.PlayingChapter
fun findRelatedFiles(
chapter: PlayingChapter,
files: List<BookFile>,
chapter: PlayingChapter,
files: List<BookFile>,
): List<BookFile> {
val chapterStartRounded = chapter.start.round()
val chapterEndRounded = chapter.end.round()
val chapterStartRounded = chapter.start.round()
val chapterEndRounded = chapter.end.round()
val startTimes = files
.runningFold(0.0) { acc, file -> acc + file.duration }
.dropLast(1)
val startTimes =
files
.runningFold(0.0) { acc, file -> acc + file.duration }
.dropLast(1)
val fileStartTimes = files.zip(startTimes)
val fileStartTimes = files.zip(startTimes)
return fileStartTimes
.filter { (file, fileStartTime) ->
val fileStartTimeRounded = fileStartTime.round()
val fileEndTimeRounded = (fileStartTime + file.duration).round()
return fileStartTimes
.filter { (file, fileStartTime) ->
val fileStartTimeRounded = fileStartTime.round()
val fileEndTimeRounded = (fileStartTime + file.duration).round()
fileStartTimeRounded < chapterEndRounded && chapterStartRounded < fileEndTimeRounded
}
.map { it.first }
fileStartTimeRounded < chapterEndRounded && chapterStartRounded < fileEndTimeRounded
}.map { it.first }
}
private const val PRECISION = 0.01
private fun Double.round(): Double = kotlin.math.round(this / PRECISION) * PRECISION

View File

@@ -14,42 +14,42 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object LocalCacheModule {
private const val DATABASE_NAME = "lissen_local_cache_storage"
private const val DATABASE_NAME = "lissen_local_cache_storage"
@Provides
@Singleton
fun provideAppDatabase(
@ApplicationContext context: Context,
): LocalCacheStorage {
val database =
Room.databaseBuilder(
context = context,
klass = LocalCacheStorage::class.java,
name = DATABASE_NAME,
)
@Provides
@Singleton
fun provideAppDatabase(@ApplicationContext context: Context): LocalCacheStorage {
val database = Room.databaseBuilder(
context = context,
klass = LocalCacheStorage::class.java,
name = DATABASE_NAME,
)
return database
.addMigrations(MIGRATION_1_2)
.addMigrations(MIGRATION_2_3)
.addMigrations(MIGRATION_3_4)
.addMigrations(MIGRATION_4_5)
.addMigrations(MIGRATION_5_6)
.addMigrations(MIGRATION_6_7)
.addMigrations(MIGRATION_7_8)
.addMigrations(MIGRATION_8_9)
.addMigrations(MIGRATION_9_10)
.addMigrations(MIGRATION_10_11)
.addMigrations(MIGRATION_11_12)
.addMigrations(MIGRATION_12_13)
.addMigrations(MIGRATION_13_14)
.build()
}
return database
.addMigrations(MIGRATION_1_2)
.addMigrations(MIGRATION_2_3)
.addMigrations(MIGRATION_3_4)
.addMigrations(MIGRATION_4_5)
.addMigrations(MIGRATION_5_6)
.addMigrations(MIGRATION_6_7)
.addMigrations(MIGRATION_7_8)
.addMigrations(MIGRATION_8_9)
.addMigrations(MIGRATION_9_10)
.addMigrations(MIGRATION_10_11)
.addMigrations(MIGRATION_11_12)
.addMigrations(MIGRATION_12_13)
.addMigrations(MIGRATION_13_14)
.build()
}
@Provides
@Singleton
fun provideCachedBookDao(appDatabase: LocalCacheStorage): CachedBookDao = appDatabase.cachedBookDao()
@Provides
@Singleton
fun provideCachedBookDao(appDatabase: LocalCacheStorage): CachedBookDao =
appDatabase.cachedBookDao()
@Provides
@Singleton
fun provideCachedLibraryDao(appDatabase: LocalCacheStorage): CachedLibraryDao =
appDatabase.cachedLibraryDao()
@Provides
@Singleton
fun provideCachedLibraryDao(appDatabase: LocalCacheStorage): CachedLibraryDao = appDatabase.cachedLibraryDao()
}

View File

@@ -19,72 +19,78 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LocalCacheRepository @Inject constructor(
class LocalCacheRepository
@Inject
constructor(
private val cachedBookRepository: CachedBookRepository,
private val cachedLibraryRepository: CachedLibraryRepository,
) {
fun provideFileUri(libraryItemId: String, fileId: String): Uri? =
cachedBookRepository
.provideFileUri(libraryItemId, fileId)
.takeIf { it.toFile().exists() }
) {
fun provideFileUri(
libraryItemId: String,
fileId: String,
): Uri? =
cachedBookRepository
.provideFileUri(libraryItemId, fileId)
.takeIf { it.toFile().exists() }
/**
* For the local cache we avoiding to create intermediary entity like Session and using BookId
* as a Playback Session Key
*/
suspend fun syncProgress(
bookId: String,
progress: PlaybackProgress,
bookId: String,
progress: PlaybackProgress,
): ApiResult<Unit> {
cachedBookRepository.syncProgress(bookId, progress)
return ApiResult.Success(Unit)
cachedBookRepository.syncProgress(bookId, progress)
return ApiResult.Success(Unit)
}
fun fetchBookCover(bookId: String): ApiResult<InputStream> {
val cover = cachedBookRepository
.provideBookCover(bookId)
val cover =
cachedBookRepository
.provideBookCover(bookId)
return when (cover.exists()) {
true -> ApiResult.Success(cover.inputStream())
false -> ApiResult.Error(ApiError.InternalError)
}
return when (cover.exists()) {
true -> ApiResult.Success(cover.inputStream())
false -> ApiResult.Error(ApiError.InternalError)
}
}
suspend fun searchBooks(
query: String,
): ApiResult<List<Book>> = cachedBookRepository
suspend fun searchBooks(query: String): ApiResult<List<Book>> =
cachedBookRepository
.searchBooks(query = query)
.let { ApiResult.Success(it) }
suspend fun fetchBooks(
pageSize: Int,
pageNumber: Int,
pageSize: Int,
pageNumber: Int,
): ApiResult<PagedItems<Book>> {
val books = cachedBookRepository
.fetchBooks(pageNumber = pageNumber, pageSize = pageSize)
val books =
cachedBookRepository
.fetchBooks(pageNumber = pageNumber, pageSize = pageSize)
return ApiResult
.Success(
PagedItems(
items = books,
currentPage = pageNumber,
),
)
return ApiResult
.Success(
PagedItems(
items = books,
currentPage = pageNumber,
),
)
}
suspend fun fetchLibraries(): ApiResult<List<Library>> = cachedLibraryRepository
suspend fun fetchLibraries(): ApiResult<List<Library>> =
cachedLibraryRepository
.fetchLibraries()
.let { ApiResult.Success(it) }
suspend fun updateLibraries(libraries: List<Library>) {
cachedLibraryRepository.cacheLibraries(libraries)
cachedLibraryRepository.cacheLibraries(libraries)
}
suspend fun fetchRecentListenedBooks(): ApiResult<List<RecentBook>> =
cachedBookRepository
.fetchRecentBooks()
.let { ApiResult.Success(it) }
cachedBookRepository
.fetchRecentBooks()
.let { ApiResult.Success(it) }
/**
* Fetches a detailed book item by its ID from the cached repository.
@@ -99,32 +105,36 @@ class LocalCacheRepository @Inject constructor(
* or `null` if the book is not found in the cache.
*/
suspend fun fetchBook(bookId: String): DetailedItem? {
val cachedBook = cachedBookRepository
.fetchBook(bookId)
?: return null
val cachedBook =
cachedBookRepository
.fetchBook(bookId)
?: return null
val cachedPosition = cachedBook
.progress
?.currentTime
?: 0.0
val cachedPosition =
cachedBook
.progress
?.currentTime
?: 0.0
val currentChapter = calculateChapterIndex(cachedBook, cachedPosition)
val currentChapter = calculateChapterIndex(cachedBook, cachedPosition)
return when (currentChapter in cachedBook.chapters.indices && cachedBook.chapters[currentChapter].available) {
true -> cachedBook
return when (currentChapter in cachedBook.chapters.indices && cachedBook.chapters[currentChapter].available) {
true -> cachedBook
false ->
cachedBook
.copy(
progress = MediaProgress(
currentTime = cachedBook.chapters
.firstOrNull { it.available }
?.start
?: return null,
isFinished = false,
lastUpdate = 946728000000, // 2000-01-01T12:00
),
)
}
false ->
cachedBook
.copy(
progress =
MediaProgress(
currentTime =
cachedBook.chapters
.firstOrNull { it.available }
?.start
?: return null,
isFinished = false,
lastUpdate = 946728000000, // 2000-01-01T12:00
),
)
}
}
}
}

View File

@@ -11,19 +11,18 @@ import org.grakovne.lissen.content.cache.entity.CachedLibraryEntity
import org.grakovne.lissen.content.cache.entity.MediaProgressEntity
@Database(
entities = [
BookEntity::class,
BookFileEntity::class,
BookChapterEntity::class,
MediaProgressEntity::class,
CachedLibraryEntity::class,
],
version = 14,
exportSchema = true,
entities = [
BookEntity::class,
BookFileEntity::class,
BookChapterEntity::class,
MediaProgressEntity::class,
CachedLibraryEntity::class,
],
version = 14,
exportSchema = true,
)
abstract class LocalCacheStorage : RoomDatabase() {
abstract fun cachedBookDao(): CachedBookDao
abstract fun cachedBookDao(): CachedBookDao
abstract fun cachedLibraryDao(): CachedLibraryDao
abstract fun cachedLibraryDao(): CachedLibraryDao
}

View File

@@ -4,242 +4,255 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import org.grakovne.lissen.channel.common.LibraryType
val MIGRATION_1_2 = object : Migration(1, 2) {
val MIGRATION_1_2 =
object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE detailed_books RENAME TO detailed_books_old")
db.execSQL("ALTER TABLE detailed_books RENAME TO detailed_books_old")
db.execSQL(
"""
CREATE TABLE detailed_books (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
author TEXT,
duration INTEGER NOT NULL
)
""".trimIndent(),
db.execSQL(
"""
CREATE TABLE detailed_books (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
author TEXT,
duration INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL(
"""
INSERT INTO detailed_books (id, title, author, duration)
SELECT id, title, author, duration FROM detailed_books_old
""".trimIndent(),
)
db.execSQL(
"""
INSERT INTO detailed_books (id, title, author, duration)
SELECT id, title, author, duration FROM detailed_books_old
""".trimIndent(),
)
db.execSQL("DROP TABLE detailed_books_old")
db.execSQL("DROP TABLE detailed_books_old")
}
}
}
val MIGRATION_2_3 = object : Migration(2, 3) {
val MIGRATION_2_3 =
object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE libraries ADD COLUMN type TEXT")
db.execSQL("ALTER TABLE libraries ADD COLUMN type TEXT")
db.execSQL(
"""
UPDATE libraries
SET type = '${LibraryType.LIBRARY.name}'
""".trimIndent(),
db.execSQL(
"""
UPDATE libraries
SET type = '${LibraryType.LIBRARY.name}'
""".trimIndent(),
)
db.execSQL(
"""
CREATE TABLE libraries_new (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL
)
""".trimIndent(),
)
db.execSQL(
"""
CREATE TABLE libraries_new (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL
)
""".trimIndent(),
)
db.execSQL(
"""
INSERT INTO libraries_new (id, title, type)
SELECT id, title, type FROM libraries
""".trimIndent(),
)
db.execSQL(
"""
INSERT INTO libraries_new (id, title, type)
SELECT id, title, type FROM libraries
""".trimIndent(),
)
db.execSQL("DROP TABLE libraries")
db.execSQL("ALTER TABLE libraries_new RENAME TO libraries")
db.execSQL("DROP TABLE libraries")
db.execSQL("ALTER TABLE libraries_new RENAME TO libraries")
}
}
}
val MIGRATION_3_4 = object : Migration(3, 4) {
val MIGRATION_3_4 =
object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE detailed_books ADD COLUMN libraryId TEXT")
db.execSQL("ALTER TABLE detailed_books ADD COLUMN libraryId TEXT")
db.execSQL(
"""
UPDATE detailed_books
SET libraryId = NULL
""".trimIndent(),
db.execSQL(
"""
UPDATE detailed_books
SET libraryId = NULL
""".trimIndent(),
)
}
}
val MIGRATION_4_5 =
object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE book_chapters ADD COLUMN isCached INTEGER NOT NULL DEFAULT 0")
db.execSQL(
"""
UPDATE book_chapters
SET isCached = 1
""".trimIndent(),
)
}
}
val MIGRATION_5_6 =
object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE detailed_books ADD COLUMN year TEXT")
db.execSQL("ALTER TABLE detailed_books ADD COLUMN abstract TEXT")
db.execSQL("ALTER TABLE detailed_books ADD COLUMN publisher TEXT")
}
}
val MIGRATION_6_7 =
object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE detailed_books ADD COLUMN subtitle TEXT")
}
}
val MIGRATION_7_8 =
object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE book_series (
id TEXT NOT NULL PRIMARY KEY,
bookId TEXT NOT NULL,
serialNumber INTEGER NOT NULL,
name TEXT NOT NULL,
FOREIGN KEY (bookId) REFERENCES detailed_books(id) ON DELETE CASCADE
)
""".trimIndent(),
)
db.execSQL("CREATE INDEX index_book_series_bookId ON book_series(bookId)")
}
}
}
val MIGRATION_4_5 = object : Migration(4, 5) {
val MIGRATION_8_9 =
object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE book_chapters ADD COLUMN isCached INTEGER NOT NULL DEFAULT 0")
db.execSQL(
"""
UPDATE book_chapters
SET isCached = 1
""".trimIndent(),
db.execSQL(
"""
CREATE TABLE book_series_new (
id TEXT NOT NULL PRIMARY KEY,
bookId TEXT NOT NULL,
serialNumber TEXT,
name TEXT NOT NULL,
FOREIGN KEY (bookId) REFERENCES detailed_books(id) ON DELETE CASCADE
)
}
}
""".trimIndent(),
)
val MIGRATION_5_6 = object : Migration(5, 6) {
db.execSQL(
"""
INSERT INTO book_series_new (id, bookId, serialNumber, name)
SELECT id, bookId, serialNumber, name FROM book_series
""".trimIndent(),
)
db.execSQL("DROP TABLE book_series")
db.execSQL("ALTER TABLE book_series_new RENAME TO book_series")
db.execSQL("CREATE INDEX index_book_series_bookId ON book_series(bookId)")
}
}
val MIGRATION_9_10 =
object : Migration(9, 10) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE detailed_books ADD COLUMN year TEXT")
db.execSQL("ALTER TABLE detailed_books ADD COLUMN abstract TEXT")
db.execSQL("ALTER TABLE detailed_books ADD COLUMN publisher TEXT")
db.execSQL("DROP TABLE IF EXISTS book_series")
db.execSQL("ALTER TABLE detailed_books ADD COLUMN seriesJson TEXT")
}
}
}
val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE detailed_books ADD COLUMN subtitle TEXT")
}
}
val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE book_series (
id TEXT NOT NULL PRIMARY KEY,
bookId TEXT NOT NULL,
serialNumber INTEGER NOT NULL,
name TEXT NOT NULL,
FOREIGN KEY (bookId) REFERENCES detailed_books(id) ON DELETE CASCADE
)
""".trimIndent(),
)
db.execSQL("CREATE INDEX index_book_series_bookId ON book_series(bookId)")
}
}
val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE book_series_new (
id TEXT NOT NULL PRIMARY KEY,
bookId TEXT NOT NULL,
serialNumber TEXT,
name TEXT NOT NULL,
FOREIGN KEY (bookId) REFERENCES detailed_books(id) ON DELETE CASCADE
)
""".trimIndent(),
)
db.execSQL(
"""
INSERT INTO book_series_new (id, bookId, serialNumber, name)
SELECT id, bookId, serialNumber, name FROM book_series
""".trimIndent(),
)
db.execSQL("DROP TABLE book_series")
db.execSQL("ALTER TABLE book_series_new RENAME TO book_series")
db.execSQL("CREATE INDEX index_book_series_bookId ON book_series(bookId)")
}
}
val MIGRATION_9_10 = object : Migration(9, 10) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("DROP TABLE IF EXISTS book_series")
db.execSQL("ALTER TABLE detailed_books ADD COLUMN seriesJson TEXT")
}
}
val MIGRATION_10_11 = object : Migration(10, 11) {
val MIGRATION_10_11 =
object : Migration(10, 11) {
override fun migrate(database: SupportSQLiteDatabase) {
val now = System.currentTimeMillis() / 1000
val now = System.currentTimeMillis() / 1000
database.execSQL(
"""
CREATE TABLE detailed_books_new (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
author TEXT,
duration INTEGER NOT NULL,
abstract TEXT,
subtitle TEXT,
year TEXT,
libraryId TEXT,
publisher TEXT,
seriesJson TEXT,
createdAt INTEGER NOT NULL
)
""".trimIndent(),
database.execSQL(
"""
CREATE TABLE detailed_books_new (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
author TEXT,
duration INTEGER NOT NULL,
abstract TEXT,
subtitle TEXT,
year TEXT,
libraryId TEXT,
publisher TEXT,
seriesJson TEXT,
createdAt INTEGER NOT NULL
)
""".trimIndent(),
)
database.execSQL(
"""
INSERT INTO detailed_books_new (
id, title, author, duration, abstract, subtitle, year, libraryId, publisher, seriesJson, createdAt
)
SELECT
id, title, author, duration, abstract, subtitle, year, libraryId, publisher, seriesJson, $now
FROM detailed_books
""".trimIndent(),
database.execSQL(
"""
INSERT INTO detailed_books_new (
id, title, author, duration, abstract, subtitle, year, libraryId, publisher, seriesJson, createdAt
)
SELECT
id, title, author, duration, abstract, subtitle, year, libraryId, publisher, seriesJson, $now
FROM detailed_books
""".trimIndent(),
)
database.execSQL("DROP TABLE detailed_books")
database.execSQL("ALTER TABLE detailed_books_new RENAME TO detailed_books")
database.execSQL("DROP TABLE detailed_books")
database.execSQL("ALTER TABLE detailed_books_new RENAME TO detailed_books")
}
}
}
val MIGRATION_11_12 = object : Migration(11, 12) {
val MIGRATION_11_12 =
object : Migration(11, 12) {
override fun migrate(database: SupportSQLiteDatabase) {
val now = System.currentTimeMillis() / 1000
val now = System.currentTimeMillis() / 1000
database.execSQL(
"""
CREATE TABLE detailed_books_new (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
author TEXT,
duration INTEGER NOT NULL,
abstract TEXT,
subtitle TEXT,
year TEXT,
libraryId TEXT,
publisher TEXT,
seriesJson TEXT,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL
)
""".trimIndent(),
database.execSQL(
"""
CREATE TABLE detailed_books_new (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
author TEXT,
duration INTEGER NOT NULL,
abstract TEXT,
subtitle TEXT,
year TEXT,
libraryId TEXT,
publisher TEXT,
seriesJson TEXT,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL
)
""".trimIndent(),
)
database.execSQL(
"""
INSERT INTO detailed_books_new (
id, title, author, duration, abstract, subtitle, year, libraryId, publisher, seriesJson, createdAt, updatedAt
)
SELECT
id, title, author, duration, abstract, subtitle, year, libraryId, publisher, seriesJson, createdAt, $now
FROM detailed_books
""".trimIndent(),
database.execSQL(
"""
INSERT INTO detailed_books_new (
id, title, author, duration, abstract, subtitle, year, libraryId, publisher, seriesJson, createdAt, updatedAt
)
SELECT
id, title, author, duration, abstract, subtitle, year, libraryId, publisher, seriesJson, createdAt, $now
FROM detailed_books
""".trimIndent(),
)
database.execSQL("DROP TABLE detailed_books")
database.execSQL("ALTER TABLE detailed_books_new RENAME TO detailed_books")
database.execSQL("DROP TABLE detailed_books")
database.execSQL("ALTER TABLE detailed_books_new RENAME TO detailed_books")
}
}
}
val MIGRATION_12_13 = object : Migration(12, 13) {
val MIGRATION_12_13 =
object : Migration(12, 13) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE detailed_books ADD COLUMN narrator TEXT")
db.execSQL("ALTER TABLE detailed_books ADD COLUMN narrator TEXT")
}
}
}
val MIGRATION_13_14 = object : Migration(13, 14) {
val MIGRATION_13_14 =
object : Migration(13, 14) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE detailed_books ADD COLUMN seriesNames TEXT")
db.execSQL("ALTER TABLE detailed_books ADD COLUMN seriesNames TEXT")
}
}
}

View File

@@ -22,118 +22,130 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CachedBookRepository @Inject constructor(
class CachedBookRepository
@Inject
constructor(
private val bookDao: CachedBookDao,
private val properties: CacheBookStorageProperties,
private val cachedBookEntityConverter: CachedBookEntityConverter,
private val cachedBookEntityDetailedConverter: CachedBookEntityDetailedConverter,
private val cachedBookEntityRecentConverter: CachedBookEntityRecentConverter,
private val preferences: LissenSharedPreferences,
) {
fun provideFileUri(bookId: String, fileId: String): Uri = properties
) {
fun provideFileUri(
bookId: String,
fileId: String,
): Uri =
properties
.provideMediaCachePatch(bookId, fileId)
.toUri()
fun provideBookCover(bookId: String): File = properties.provideBookCoverPath(bookId)
suspend fun removeBook(bookId: String) {
bookDao
.fetchBook(bookId)
?.let { bookDao.deleteBook(it) }
bookDao
.fetchBook(bookId)
?.let { bookDao.deleteBook(it) }
}
suspend fun cacheBook(
book: DetailedItem,
fetchedChapters: List<PlayingChapter>,
book: DetailedItem,
fetchedChapters: List<PlayingChapter>,
) {
bookDao.upsertCachedBook(book, fetchedChapters)
bookDao.upsertCachedBook(book, fetchedChapters)
}
fun provideCacheState(bookId: String) = bookDao.isBookCached(bookId)
suspend fun fetchBooks(
pageNumber: Int,
pageSize: Int,
pageNumber: Int,
pageSize: Int,
): List<Book> {
val (option, direction) = buildOrdering()
val (option, direction) = buildOrdering()
val request = FetchRequestBuilder()
.libraryId(preferences.getPreferredLibrary()?.id)
.pageNumber(pageNumber)
.pageSize(pageSize)
.orderField(option)
.orderDirection(direction)
.build()
val request =
FetchRequestBuilder()
.libraryId(preferences.getPreferredLibrary()?.id)
.pageNumber(pageNumber)
.pageSize(pageSize)
.orderField(option)
.orderDirection(direction)
.build()
return bookDao
.fetchCachedBooks(request)
.map { cachedBookEntityConverter.apply(it) }
return bookDao
.fetchCachedBooks(request)
.map { cachedBookEntityConverter.apply(it) }
}
suspend fun searchBooks(
query: String,
): List<Book> {
val (option, direction) = buildOrdering()
suspend fun searchBooks(query: String): List<Book> {
val (option, direction) = buildOrdering()
val request = SearchRequestBuilder()
.searchQuery(query)
.libraryId(preferences.getPreferredLibrary()?.id)
.orderField(option)
.orderDirection(direction)
.build()
val request =
SearchRequestBuilder()
.searchQuery(query)
.libraryId(preferences.getPreferredLibrary()?.id)
.orderField(option)
.orderDirection(direction)
.build()
return bookDao
.searchBooks(request)
.map { cachedBookEntityConverter.apply(it) }
return bookDao
.searchBooks(request)
.map { cachedBookEntityConverter.apply(it) }
}
suspend fun fetchRecentBooks(): List<RecentBook> {
val recentBooks = bookDao.fetchRecentlyListenedCachedBooks(
libraryId = preferences.getPreferredLibrary()?.id,
val recentBooks =
bookDao.fetchRecentlyListenedCachedBooks(
libraryId = preferences.getPreferredLibrary()?.id,
)
val progress = recentBooks
.map { it.id }
.mapNotNull { bookDao.fetchMediaProgress(it) }
.associate { it.bookId to (it.lastUpdate to it.currentTime) }
val progress =
recentBooks
.map { it.id }
.mapNotNull { bookDao.fetchMediaProgress(it) }
.associate { it.bookId to (it.lastUpdate to it.currentTime) }
return recentBooks
.map { cachedBookEntityRecentConverter.apply(it, progress[it.id]) }
return recentBooks
.map { cachedBookEntityRecentConverter.apply(it, progress[it.id]) }
}
suspend fun fetchBook(
bookId: String,
): DetailedItem? = bookDao
suspend fun fetchBook(bookId: String): DetailedItem? =
bookDao
.fetchCachedBook(bookId)
?.let { cachedBookEntityDetailedConverter.apply(it) }
suspend fun syncProgress(bookId: String, progress: PlaybackProgress) {
val book = bookDao.fetchCachedBook(bookId) ?: return
suspend fun syncProgress(
bookId: String,
progress: PlaybackProgress,
) {
val book = bookDao.fetchCachedBook(bookId) ?: return
val entity = MediaProgressEntity(
bookId = bookId,
currentTime = progress.currentTotalTime,
isFinished = progress.currentTotalTime == book.chapters.sumOf { it.duration },
lastUpdate = Instant.now().toEpochMilli(),
val entity =
MediaProgressEntity(
bookId = bookId,
currentTime = progress.currentTotalTime,
isFinished = progress.currentTotalTime == book.chapters.sumOf { it.duration },
lastUpdate = Instant.now().toEpochMilli(),
)
bookDao.upsertMediaProgress(entity)
bookDao.upsertMediaProgress(entity)
}
private fun buildOrdering(): Pair<String, String> {
val option = when (preferences.getLibraryOrdering().option) {
LibraryOrderingOption.TITLE -> "title"
LibraryOrderingOption.AUTHOR -> "author"
LibraryOrderingOption.CREATED_AT -> "createdAt"
LibraryOrderingOption.UPDATED_AT -> "updatedAt"
val option =
when (preferences.getLibraryOrdering().option) {
LibraryOrderingOption.TITLE -> "title"
LibraryOrderingOption.AUTHOR -> "author"
LibraryOrderingOption.CREATED_AT -> "createdAt"
LibraryOrderingOption.UPDATED_AT -> "updatedAt"
}
val direction = when (preferences.getLibraryOrdering().direction) {
LibraryOrderingDirection.ASCENDING -> "asc"
LibraryOrderingDirection.DESCENDING -> "desc"
val direction =
when (preferences.getLibraryOrdering().direction) {
LibraryOrderingDirection.ASCENDING -> "asc"
LibraryOrderingDirection.DESCENDING -> "desc"
}
return option to direction
return option to direction
}
}
}

View File

@@ -7,14 +7,16 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CachedLibraryRepository @Inject constructor(
class CachedLibraryRepository
@Inject
constructor(
private val dao: CachedLibraryDao,
private val converter: CachedLibraryEntityConverter,
) {
) {
suspend fun cacheLibraries(libraries: List<Library>) = dao.updateLibraries(libraries)
suspend fun fetchLibraries() = dao
suspend fun fetchLibraries() =
dao
.fetchLibraries()
.map { converter.apply(it) }
}
}

View File

@@ -4,49 +4,57 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
class FetchRequestBuilder {
private var libraryId: String? = null
private var pageNumber: Int = 0
private var pageSize: Int = 20
private var orderField: String = "title"
private var orderDirection: String = "ASC"
private var libraryId: String? = null
private var pageNumber: Int = 0
private var pageSize: Int = 20
private var orderField: String = "title"
private var orderDirection: String = "ASC"
fun libraryId(id: String?) = apply { this.libraryId = id }
fun pageNumber(number: Int) = apply { this.pageNumber = number }
fun pageSize(size: Int) = apply { this.pageSize = size }
fun orderField(field: String) = apply { this.orderField = field }
fun orderDirection(direction: String) = apply { this.orderDirection = direction }
fun libraryId(id: String?) = apply { this.libraryId = id }
fun build(): SupportSQLiteQuery {
val args = mutableListOf<Any>()
fun pageNumber(number: Int) = apply { this.pageNumber = number }
val whereClause = when (val libraryId = libraryId) {
null -> "libraryId IS NULL"
else -> {
args.add(libraryId)
"(libraryId = ? OR libraryId IS NULL)"
}
fun pageSize(size: Int) = apply { this.pageSize = size }
fun orderField(field: String) = apply { this.orderField = field }
fun orderDirection(direction: String) = apply { this.orderDirection = direction }
fun build(): SupportSQLiteQuery {
val args = mutableListOf<Any>()
val whereClause =
when (val libraryId = libraryId) {
null -> "libraryId IS NULL"
else -> {
args.add(libraryId)
"(libraryId = ? OR libraryId IS NULL)"
}
}
val field = when (orderField) {
"title", "author", "duration" -> orderField
else -> "title"
}
val field =
when (orderField) {
"title", "author", "duration" -> orderField
else -> "title"
}
val direction = when (orderDirection.uppercase()) {
"ASC", "DESC" -> orderDirection.uppercase()
else -> "ASC"
}
val direction =
when (orderDirection.uppercase()) {
"ASC", "DESC" -> orderDirection.uppercase()
else -> "ASC"
}
args.add(pageSize)
args.add(pageNumber * pageSize)
args.add(pageSize)
args.add(pageNumber * pageSize)
val sql = """
SELECT * FROM detailed_books
WHERE $whereClause
ORDER BY $field $direction
LIMIT ? OFFSET ?
""".trimIndent()
val sql =
"""
SELECT * FROM detailed_books
WHERE $whereClause
ORDER BY $field $direction
LIMIT ? OFFSET ?
""".trimIndent()
return SimpleSQLiteQuery(sql, args.toTypedArray())
}
return SimpleSQLiteQuery(sql, args.toTypedArray())
}
}

View File

@@ -4,50 +4,57 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
class SearchRequestBuilder {
private var libraryId: String? = null
private var searchQuery: String = ""
private var orderField: String = "title"
private var orderDirection: String = "ASC"
private var libraryId: String? = null
private var searchQuery: String = ""
private var orderField: String = "title"
private var orderDirection: String = "ASC"
fun libraryId(id: String?) = apply { this.libraryId = id }
fun searchQuery(query: String) = apply { this.searchQuery = query }
fun orderField(field: String) = apply { this.orderField = field }
fun orderDirection(direction: String) = apply { this.orderDirection = direction }
fun libraryId(id: String?) = apply { this.libraryId = id }
fun build(): SupportSQLiteQuery {
val args = mutableListOf<Any>()
fun searchQuery(query: String) = apply { this.searchQuery = query }
val whereClause = buildString {
when (val libraryId = libraryId) {
null -> append("(libraryId IS NULL)")
else -> {
append("(libraryId = ? OR libraryId IS NULL)")
args.add(libraryId)
}
}
append(" AND (title LIKE ? OR author LIKE ? OR seriesNames LIKE ?)")
val pattern = "%$searchQuery%"
args.add(pattern)
args.add(pattern)
args.add(pattern)
fun orderField(field: String) = apply { this.orderField = field }
fun orderDirection(direction: String) = apply { this.orderDirection = direction }
fun build(): SupportSQLiteQuery {
val args = mutableListOf<Any>()
val whereClause =
buildString {
when (val libraryId = libraryId) {
null -> append("(libraryId IS NULL)")
else -> {
append("(libraryId = ? OR libraryId IS NULL)")
args.add(libraryId)
}
}
append(" AND (title LIKE ? OR author LIKE ? OR seriesNames LIKE ?)")
val pattern = "%$searchQuery%"
args.add(pattern)
args.add(pattern)
args.add(pattern)
}
val field = when (orderField) {
"title", "author", "duration" -> orderField
else -> "title"
}
val field =
when (orderField) {
"title", "author", "duration" -> orderField
else -> "title"
}
val direction = when (orderDirection.uppercase()) {
"ASC", "DESC" -> orderDirection.uppercase()
else -> "ASC"
}
val direction =
when (orderDirection.uppercase()) {
"ASC", "DESC" -> orderDirection.uppercase()
else -> "ASC"
}
val sql = """
SELECT * FROM detailed_books
WHERE $whereClause
ORDER BY $field $direction
""".trimIndent()
val sql =
"""
SELECT * FROM detailed_books
WHERE $whereClause
ORDER BY $field $direction
""".trimIndent()
return SimpleSQLiteQuery(sql, args.toTypedArray())
}
return SimpleSQLiteQuery(sql, args.toTypedArray())
}
}

View File

@@ -9,27 +9,29 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CachedBookEntityConverter @Inject constructor() {
fun apply(entity: BookEntity): Book = Book(
class CachedBookEntityConverter
@Inject
constructor() {
fun apply(entity: BookEntity): Book =
Book(
id = entity.id,
title = entity.title,
subtitle = entity.subtitle,
author = entity.author,
series = entity
series =
entity
.seriesJson
?.let {
val type = object : TypeToken<List<BookSeriesDto>>() {}.type
gson.fromJson<List<BookSeriesDto>>(it, type)
}
?.joinToString(", ") { series ->
buildString {
append(series.title)
series.sequence
?.takeIf(String::isNotBlank)
?.let { append(" #$it") }
}
val type = object : TypeToken<List<BookSeriesDto>>() {}.type
gson.fromJson<List<BookSeriesDto>>(it, type)
}?.joinToString(", ") { series ->
buildString {
append(series.title)
series.sequence
?.takeIf(String::isNotBlank)
?.let { append(" #$it") }
}
},
duration = entity.duration,
)
}
)
}

View File

@@ -13,9 +13,11 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CachedBookEntityDetailedConverter @Inject constructor() {
fun apply(entity: CachedBookEntity): DetailedItem = DetailedItem(
class CachedBookEntityDetailedConverter
@Inject
constructor() {
fun apply(entity: CachedBookEntity): DetailedItem =
DetailedItem(
id = entity.detailedBook.id,
title = entity.detailedBook.title,
subtitle = entity.detailedBook.subtitle,
@@ -23,54 +25,56 @@ class CachedBookEntityDetailedConverter @Inject constructor() {
narrator = entity.detailedBook.narrator,
libraryId = entity.detailedBook.libraryId,
localProvided = true,
files = entity.files.map { fileEntity ->
files =
entity.files.map { fileEntity ->
BookFile(
id = fileEntity.bookFileId,
name = fileEntity.name,
duration = fileEntity.duration,
mimeType = fileEntity.mimeType,
id = fileEntity.bookFileId,
name = fileEntity.name,
duration = fileEntity.duration,
mimeType = fileEntity.mimeType,
)
},
chapters = entity.chapters.map { chapterEntity ->
},
chapters =
entity.chapters.map { chapterEntity ->
PlayingChapter(
duration = chapterEntity.duration,
start = chapterEntity.start,
end = chapterEntity.end,
title = chapterEntity.title,
available = chapterEntity.isCached,
id = chapterEntity.bookChapterId,
podcastEpisodeState = null, // currently state is not available for local mode
duration = chapterEntity.duration,
start = chapterEntity.start,
end = chapterEntity.end,
title = chapterEntity.title,
available = chapterEntity.isCached,
id = chapterEntity.bookChapterId,
podcastEpisodeState = null, // currently state is not available for local mode
)
},
},
abstract = entity.detailedBook.abstract,
publisher = entity.detailedBook.publisher,
year = entity.detailedBook.year,
createdAt = entity.detailedBook.createdAt,
updatedAt = entity.detailedBook.updatedAt,
series = entity
series =
entity
.detailedBook
.seriesJson
?.let {
val type = object : TypeToken<List<BookSeriesDto>>() {}.type
gson.fromJson<List<BookSeriesDto>>(it, type)
}
?.map {
BookSeries(
name = it.title,
serialNumber = it.sequence,
)
val type = object : TypeToken<List<BookSeriesDto>>() {}.type
gson.fromJson<List<BookSeriesDto>>(it, type)
}?.map {
BookSeries(
name = it.title,
serialNumber = it.sequence,
)
} ?: emptyList(),
progress = entity.progress?.let { progressEntity ->
progress =
entity.progress?.let { progressEntity ->
MediaProgress(
currentTime = progressEntity.currentTime,
isFinished = progressEntity.isFinished,
lastUpdate = progressEntity.lastUpdate,
currentTime = progressEntity.currentTime,
isFinished = progressEntity.isFinished,
lastUpdate = progressEntity.lastUpdate,
)
},
)
},
)
companion object {
val gson = Gson()
val gson = Gson()
}
}
}

View File

@@ -6,18 +6,24 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CachedBookEntityRecentConverter @Inject constructor() {
fun apply(entity: BookEntity, currentTime: Pair<Long, Double>?): RecentBook = RecentBook(
class CachedBookEntityRecentConverter
@Inject
constructor() {
fun apply(
entity: BookEntity,
currentTime: Pair<Long, Double>?,
): RecentBook =
RecentBook(
id = entity.id,
title = entity.title,
subtitle = entity.subtitle,
author = entity.author,
listenedLastUpdate = currentTime?.first ?: 0,
listenedPercentage = currentTime
listenedPercentage =
currentTime
?.second
?.let { it / entity.duration }
?.let { it * 100 }
?.toInt(),
)
}
)
}

View File

@@ -6,11 +6,13 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CachedLibraryEntityConverter @Inject constructor() {
fun apply(entity: CachedLibraryEntity): Library = Library(
class CachedLibraryEntityConverter
@Inject
constructor() {
fun apply(entity: CachedLibraryEntity): Library =
Library(
id = entity.id,
title = entity.title,
type = entity.type,
)
}
)
}

Some files were not shown because too many files have changed in this diff Show More