mirror of
https://github.com/GrakovNe/lissen-android.git
synced 2025-12-23 22:18:09 -05:00
Feature/update dependencies (#202)
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>?,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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)://.*\$")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -4,5 +4,5 @@ import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
data class AuthMethodResponse(
|
||||
val authMethods: List<String> = emptyList(),
|
||||
val authMethods: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,6 @@ import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
data class PlaybackSessionResponse(
|
||||
val id: String,
|
||||
val libraryItemId: String,
|
||||
val id: String,
|
||||
val libraryItemId: String,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,6 @@ import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
data class ProgressSyncRequest(
|
||||
val timeListened: Int,
|
||||
val currentTime: Double,
|
||||
val timeListened: Int,
|
||||
val currentTime: Double,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,6 @@ import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
data class CredentialsLoginRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
val username: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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>?,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package org.grakovne.lissen.channel.common
|
||||
|
||||
enum class AuthMethod {
|
||||
CREDENTIALS,
|
||||
O_AUTH,
|
||||
CREDENTIALS,
|
||||
O_AUTH,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package org.grakovne.lissen.channel.common
|
||||
|
||||
enum class ChannelCode {
|
||||
AUDIOBOOKSHELF,
|
||||
AUDIOBOOKSHELF,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.grakovne.lissen.channel.common
|
||||
|
||||
enum class LibraryType {
|
||||
LIBRARY,
|
||||
PODCAST,
|
||||
UNKNOWN,
|
||||
LIBRARY,
|
||||
PODCAST,
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package org.grakovne.lissen.common
|
||||
|
||||
enum class ColorScheme {
|
||||
FOLLOW_SYSTEM,
|
||||
FOLLOW_SYSTEM,
|
||||
|
||||
LIGHT,
|
||||
DARK,
|
||||
BLACK,
|
||||
LIGHT,
|
||||
DARK,
|
||||
BLACK,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package org.grakovne.lissen.common
|
||||
|
||||
enum class LibraryOrderingDirection {
|
||||
ASCENDING,
|
||||
DESCENDING,
|
||||
ASCENDING,
|
||||
DESCENDING,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package org.grakovne.lissen.common
|
||||
|
||||
enum class LibraryOrderingOption {
|
||||
TITLE,
|
||||
AUTHOR,
|
||||
UPDATED_AT,
|
||||
CREATED_AT,
|
||||
TITLE,
|
||||
AUTHOR,
|
||||
UPDATED_AT,
|
||||
CREATED_AT,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.grakovne.lissen.common
|
||||
|
||||
interface RunningComponent {
|
||||
|
||||
fun onCreate()
|
||||
fun onCreate()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user