Compare commits

..
Author SHA1 Message Date
advplyr 04c70f54aa iOS version bump 0.10.0 2025-07-19 17:17:27 -05:00
advplyr 03e2feddb3 Version bump v0.10.0-beta 2025-07-19 16:55:54 -05:00
advplyrandGitHub fc69909dd0 Merge pull request #1625 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-07-19 16:47:20 -05:00
FiendFEARingandHosted Weblate 15fa42986a Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (356 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-07-19 15:03:05 +02:00
SunSpringandHosted Weblate d8b4cf98dd Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (356 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-07-19 15:03:04 +02:00
Максим ГорпинічandHosted Weblate ce62f3b273 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (356 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2025-07-19 15:03:03 +02:00
biuklijaandHosted Weblate 8be2e3af0c Translated using Weblate (Croatian)
Currently translated at 100.0% (356 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hr/
2025-07-19 15:03:01 +02:00
GernomalyandHosted Weblate e44c574068 Translated using Weblate (German)
Currently translated at 99.4% (354 of 356 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-07-19 15:03:01 +02:00
SunSpringandHosted Weblate c31aa17ce2 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-07-17 22:45:49 +00:00
Максим ГорпинічandHosted Weblate decac596d0 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2025-07-17 22:45:48 +00:00
Grzegorz OrlowskiandHosted Weblate 2d9604aa07 Translated using Weblate (Polish)
Currently translated at 98.8% (349 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/pl/
2025-07-17 22:45:47 +00:00
Fredrik LindqvistandHosted Weblate 79c4ffa6c5 Translated using Weblate (Swedish)
Currently translated at 99.7% (352 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sv/
2025-07-17 22:45:47 +00:00
JannikandHosted Weblate 571b3a2c86 Translated using Weblate (German)
Currently translated at 99.7% (352 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-07-17 22:45:46 +00:00
FiendFEARingandHosted Weblate fa31fba586 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-07-17 22:45:45 +00:00
Simple16andHosted Weblate 2f3b261392 Translated using Weblate (Russian)
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/ru/
2025-07-17 22:45:45 +00:00
biuklijaandHosted Weblate afd3cd6716 Translated using Weblate (Croatian)
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hr/
2025-07-17 22:45:44 +00:00
FiendFEARingandHosted Weblate be99ef87ad Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-07-17 22:45:44 +00:00
Kabika82andHosted Weblate 572fc8545f Translated using Weblate (Hungarian)
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/hu/
2025-07-17 22:45:43 +00:00
advplyrandGitHub 239a943172 Merge pull request #1618 from advplyr/new_jwt_auth
Update auth to handle refresh tokens
2025-07-17 17:45:33 -05:00
advplyr 87614bc78a Update auth message, update force re-login to pull auth methods to support oidc 2025-07-17 17:37:41 -05:00
advplyr 224d75fac5 Update old auth alert messages, add link to github discussion 2025-07-15 17:41:30 -05:00
advplyr f4e0a6121f Update readers to handle token refresh 2025-07-15 17:22:45 -05:00
advplyr 80ee88b488 Merge branch 'master' into new_jwt_auth 2025-07-11 16:58:50 -05:00
advplyrandGitHub beb654825f Merge pull request #1623 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-07-11 16:58:32 -05:00
advplyr 79d8ccbf52 Update login query param to x-return-tokens header 2025-07-11 16:00:48 -05:00
thehijackerandHosted Weblate b48c74eca0 Translated using Weblate (Slovenian)
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sl/
2025-07-11 00:18:44 +02:00
FiendFEARingandHosted Weblate 19e3dee706 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-07-11 00:18:44 +02:00
SunSpringandHosted Weblate e1be86c3ce Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-07-11 00:18:44 +02:00
SunSpringandHosted Weblate 11f7eddd7b Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-07-11 00:18:44 +02:00
RajandHosted Weblate 7f28d2ea70 Translated using Weblate (Gujarati)
Currently translated at 9.3% (33 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/gu/
2025-07-11 00:18:44 +02:00
Richard PožgayandHosted Weblate 4607a8e274 Translated using Weblate (Czech)
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/cs/
2025-07-11 00:18:44 +02:00
MichalandHosted Weblate 26c559104a Translated using Weblate (Slovak)
Currently translated at 99.7% (352 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/sk/
2025-07-11 00:18:44 +02:00
Максим ГорпинічandHosted Weblate e15e884e65 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (353 of 353 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2025-07-11 00:18:43 +02:00
advplyr b29401909f Fix comic extracted metadata icon 2025-07-10 17:18:38 -05:00
advplyr beb5e1a56c OIDC to support new access tokens 2025-07-07 17:20:22 -05:00
advplyr be08efeca3 iOS update retry request handler to handle string bodies 2025-07-07 06:15:51 -05:00
advplyr 4534ffaead Handle re-authenticating socket 2025-07-06 11:32:21 -05:00
advplyr d35dd2df1a Merge branch 'master' into new_jwt_auth 2025-07-06 09:07:17 -05:00
advplyr 2f3a9a5d96 Update play button on list book to check if player is starting 2025-07-05 18:06:24 -05:00
advplyr fab94cd363 Handle native app token refresh failure notification 2025-07-05 17:40:57 -05:00
advplyr b06274866d iOS update to use new track endpoint and remove token from cover url depending on version 2025-07-05 17:31:25 -05:00
advplyr 5766c49f61 Update iOS ApiClient to handle token refresh 2025-07-05 16:46:37 -05:00
advplyr bc927d4c35 Add SecureStorage to iOS and implement refresh token methods 2025-07-05 14:52:41 -05:00
advplyr 5804c54656 Add version to ServerConnectionConfig on iOS 2025-07-05 14:28:02 -05:00
advplyr 52f86cbce9 Fix plugin listener handlers, add message for configs using old server auth, show server version on account page 2025-07-05 12:20:27 -05:00
advplyr f6e2e4010f Update to not logout from server when switching servers, force users to re-login if using old auth 2025-07-05 11:21:20 -05:00
advplyr 44613e12f1 Update serverConnectionConfig to include server version, update server track URL and server cover image URL based on server version 2025-07-05 09:28:40 -05:00
advplyr b99e0b112b Merge branch 'master' into new_jwt_auth 2025-07-05 08:06:40 -05:00
advplyrandGitHub 80b565c23f Merge pull request #1622 from advplyr/play_from_library_list_rows
Add play button to book library list view
2025-07-04 18:22:30 -05:00
advplyr bee88c43e5 Add play button to book library list view 2025-07-04 18:07:05 -05:00
advplyr 467fedbfe7 Update to use x-refresh-token header, update logout to clear refresh token, add AbsLogger logs for android refresh 2025-07-04 17:41:19 -05:00
advplyr ba4e9ab7e3 Add explicit library filters 2025-07-03 17:40:02 -05:00
advplyrandGitHub 1b4fa5e44a Merge pull request #1610 from weblate/weblate-audiobookshelf-abs-mobile-app
Translations update from Hosted Weblate
2025-07-03 17:32:02 -05:00
FiendFEARingandHosted Weblate e92c9b2990 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-07-02 20:16:41 +02:00
FiendFEARingandHosted Weblate f23f8e9ade Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/zh_Hans/
2025-07-02 20:16:39 +02:00
DavevanIerselandHosted Weblate dc00aeece4 Translated using Weblate (Dutch)
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/nl/
2025-07-02 20:16:37 +02:00
advplyr d8cdb7073e Update auth to handle refresh tokens 2025-07-01 11:33:51 -05:00
Vito0912andHosted Weblate 4fdea9b0ec Translated using Weblate (German)
Currently translated at 99.7% (351 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-06-30 00:32:39 +02:00
Vito0912andHosted Weblate 3ded7687f7 Translated using Weblate (German)
Currently translated at 99.7% (351 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-06-30 00:32:38 +02:00
Eigen_artandHosted Weblate 42119425af Translated using Weblate (Ukrainian)
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/uk/
2025-06-30 00:32:38 +02:00
Dan JohansenandHosted Weblate 93c7e1c44a Translated using Weblate (Danish)
Currently translated at 100.0% (352 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/da/
2025-06-30 00:32:37 +02:00
Michael FörsterandHosted Weblate 381fd84839 Translated using Weblate (German)
Currently translated at 99.7% (351 of 352 strings)

Translation: Audiobookshelf/Abs Mobile App
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-mobile-app/de/
2025-06-30 00:32:36 +02:00
advplyr 67bab72783 Update podcast library latest page number of episodes to 50 #1615 2025-06-29 17:32:29 -05:00
advplyrandGitHub b9c084a442 Merge pull request #1612 from advplyr/create_bookmarks_focus
Update create bookmark to not autofocus input and use placeholder
2025-06-22 17:51:26 -05:00
63 changed files with 2293 additions and 409 deletions
+2 -2
View File
@@ -36,8 +36,8 @@ android {
applicationId "com.audiobookshelf.app" applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 112 versionCode 113
versionName "0.9.81-beta" versionName "0.10.0-beta"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [ manifestPlaceholders = [
"appAuthRedirectScheme": "com.audiobookshelf.app" "appAuthRedirectScheme": "com.audiobookshelf.app"
@@ -14,6 +14,7 @@ data class AudioTrack(
var metadata: FileMetadata?, var metadata: FileMetadata?,
var isLocal: Boolean, var isLocal: Boolean,
var localFileId: String?, var localFileId: String?,
// TODO: This should no longer be necessary
var serverIndex: Int? // Need to know if server track index is different var serverIndex: Int? // Need to know if server track index is different
) { ) {
@@ -32,11 +32,14 @@ enum class AndroidAutoBrowseSeriesSequenceOrderSetting {
ASC, DESC ASC, DESC
} }
@JsonIgnoreProperties(ignoreUnknown = true)
data class ServerConnectionConfig( data class ServerConnectionConfig(
var id:String, var id:String,
var index:Int, var index:Int,
var name:String, var name:String,
var address:String, var address:String,
// version added after 0.9.81-beta
var version:String?,
var userId:String, var userId:String,
var username:String, var username:String,
var token:String, var token:String,
@@ -53,6 +53,11 @@ class LibraryItem(
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
} }
// As of v2.17.0 token is not needed with cover image requests
if (DeviceManager.isServerVersionGreaterThanOrEqualTo("2.17.0")) {
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover")
}
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}") return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
} }
@@ -149,6 +149,16 @@ class PlaybackSession(
return total return total
} }
@JsonIgnore
fun checkIsServerVersionGte(compareVersion: String): Boolean {
// Safety check this playback session is the same one currently connected (should always be)
if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) {
return false
}
return DeviceManager.isServerVersionGreaterThanOrEqualTo(compareVersion)
}
@JsonIgnore @JsonIgnore
fun getCoverUri(ctx: Context): Uri { fun getCoverUri(ctx: Context): Uri {
if (localLibraryItem?.coverContentUrl != null) { if (localLibraryItem?.coverContentUrl != null) {
@@ -168,12 +178,22 @@ class PlaybackSession(
if (coverPath == null) if (coverPath == null)
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
// As of v2.17.0 token is not needed with cover image requests
if (checkIsServerVersionGte("2.17.0")) {
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover")
}
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}") return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
} }
@JsonIgnore @JsonIgnore
fun getContentUri(audioTrack: AudioTrack): Uri { fun getContentUri(audioTrack: AudioTrack): Uri {
if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url
// As of v2.22.0 tracks use a different endpoint
// See: https://github.com/advplyr/audiobookshelf/pull/4263
if (checkIsServerVersionGte("2.22.0")) {
return Uri.parse("$serverAddress/public/session/$id/track/${audioTrack.index}")
}
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}") return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
} }
@@ -264,14 +284,16 @@ class PlaybackSession(
com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER
) )
// As of v2.17.0 token is not needed with cover image requests
val coverUri = if (checkIsServerVersionGte("2.17.0")) {
Uri.parse("$serverAddress/api/items/$libraryItemId/cover")
} else {
Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
}
// Cast always uses server cover uri
coverPath?.let { coverPath?.let {
castMetadata.addImage( castMetadata.addImage(WebImage(coverUri))
WebImage(
Uri.parse(
"$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}"
)
)
)
} }
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "") castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
@@ -41,6 +41,7 @@ object DeviceManager {
get() = serverConnectionConfig?.userId ?: "" get() = serverConnectionConfig?.userId ?: ""
val token val token
get() = serverConnectionConfig?.token ?: "" get() = serverConnectionConfig?.token ?: ""
val serverVersion get() = serverConnectionConfig?.version ?: ""
val isConnectedToServer val isConnectedToServer
get() = serverConnectionConfig != null get() = serverConnectionConfig != null
@@ -111,6 +112,41 @@ object DeviceManager {
return id?.let { deviceData.serverConnectionConfigs.find { it.id == id } } return id?.let { deviceData.serverConnectionConfigs.find { it.id == id } }
} }
/**
* Check if the currently connected server version is >= compareVersion
* Abs server only uses major.minor.patch
* Note: Version is returned in Abs auth payloads starting v2.6.0
* Note: Version is saved with the server connection config starting after v0.9.81
*
* @example
* serverVersion=2.25.1
* isServerVersionGreaterThanOrEqualTo("2.26.0") = false
*
* serverVersion=2.26.1
* isServerVersionGreaterThanOrEqualTo("2.26.0") = true
*/
fun isServerVersionGreaterThanOrEqualTo(compareVersion:String):Boolean {
if (serverVersion == "") return false
if (compareVersion == "") return true
val serverVersionParts = serverVersion.split(".").map { it.toIntOrNull() ?: 0 }
val compareVersionParts = compareVersion.split(".").map { it.toIntOrNull() ?: 0 }
// Compare major, minor, and patch components
for (i in 0 until maxOf(serverVersionParts.size, compareVersionParts.size)) {
val serverVersionComponent = serverVersionParts.getOrElse(i) { 0 }
val compareVersionComponent = compareVersionParts.getOrElse(i) { 0 }
if (serverVersionComponent < compareVersionComponent) {
return false // Server version is less than compareVersion
} else if (serverVersionComponent > compareVersionComponent) {
return true // Server version is greater than compareVersion
}
}
return true // versions are equal in major, minor, and patch
}
/** /**
* Checks the network connectivity status. * Checks the network connectivity status.
* @param ctx The context to use for checking connectivity. * @param ctx The context to use for checking connectivity.
@@ -0,0 +1,124 @@
package com.audiobookshelf.app.managers
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import android.util.Log
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class SecureStorage(private val context: Context) {
companion object {
private const val TAG = "SecureStorage"
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val KEY_ALIAS = "AudiobookshelfRefreshTokens"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val IV_LENGTH = 12
private const val TAG_LENGTH = 128
}
private val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply {
load(null)
}
/**
* Encrypts and stores a refresh token for a specific server connection
*/
fun storeRefreshToken(serverConnectionId: String, refreshToken: String): Boolean {
return try {
val key = getOrCreateKey()
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, key)
val encryptedBytes = cipher.doFinal(refreshToken.toByteArray(Charsets.UTF_8))
val combined = cipher.iv + encryptedBytes
val encoded = Base64.encodeToString(combined, Base64.DEFAULT)
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
sharedPrefs.edit().putString("refresh_token_$serverConnectionId", encoded).apply()
Log.d(TAG, "Successfully stored encrypted refresh token for server: $serverConnectionId")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to store refresh token for server: $serverConnectionId", e)
false
}
}
/**
* Retrieves and decrypts a refresh token for a specific server connection
*/
fun getRefreshToken(serverConnectionId: String): String? {
return try {
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
val encoded = sharedPrefs.getString("refresh_token_$serverConnectionId", null) ?: return null
val combined = Base64.decode(encoded, Base64.DEFAULT)
val iv = combined.copyOfRange(0, IV_LENGTH)
val encryptedBytes = combined.copyOfRange(IV_LENGTH, combined.size)
val key = getOrCreateKey()
val cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(TAG_LENGTH, iv)
cipher.init(Cipher.DECRYPT_MODE, key, spec)
val decryptedBytes = cipher.doFinal(encryptedBytes)
String(decryptedBytes, Charsets.UTF_8)
} catch (e: Exception) {
Log.e(TAG, "Failed to retrieve refresh token for server: $serverConnectionId", e)
null
}
}
/**
* Removes a refresh token for a specific server connection
*/
fun removeRefreshToken(serverConnectionId: String): Boolean {
return try {
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
sharedPrefs.edit().remove("refresh_token_$serverConnectionId").apply()
Log.d(TAG, "Successfully removed refresh token for server: $serverConnectionId")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to remove refresh token for server: $serverConnectionId", e)
false
}
}
/**
* Checks if a refresh token exists for a specific server connection
*/
fun hasRefreshToken(serverConnectionId: String): Boolean {
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
return sharedPrefs.contains("refresh_token_$serverConnectionId")
}
private fun getOrCreateKey(): SecretKey {
return if (keyStore.containsAlias(KEY_ALIAS)) {
keyStore.getKey(KEY_ALIAS, null) as SecretKey
} else {
createKey()
}
}
private fun createKey(): SecretKey {
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
val keyGenSpec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(false)
.setRandomizedEncryptionRequired(true)
.build()
keyGenerator.init(keyGenSpec)
return keyGenerator.generateKey()
}
}
@@ -8,6 +8,7 @@ import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaEventManager import com.audiobookshelf.app.media.MediaEventManager
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
import com.audiobookshelf.app.managers.SecureStorage
import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
@@ -22,17 +23,21 @@ class AbsDatabase : Plugin() {
val tag = "AbsDatabase" val tag = "AbsDatabase"
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
lateinit var mainActivity: MainActivity private lateinit var mainActivity: MainActivity
lateinit var apiHandler: ApiHandler private lateinit var apiHandler: ApiHandler
private lateinit var secureStorage: SecureStorage
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>) data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>) data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
data class LocalFoldersPayload(val value:List<LocalFolder>) data class LocalFoldersPayload(val value:List<LocalFolder>)
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val address:String?, val customHeaders:Map<String,String>?) data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, var version:String, val token:String, val refreshToken:String?, val address:String?, val customHeaders:Map<String,String>?)
override fun load() { override fun load() {
mainActivity = (activity as MainActivity) mainActivity = (activity as MainActivity)
apiHandler = ApiHandler(mainActivity) apiHandler = ApiHandler(mainActivity)
ApiHandler.absDatabaseNotifyListeners = ::notifyListeners
secureStorage = SecureStorage(mainActivity)
DeviceManager.dbManager.cleanLocalMediaProgress() DeviceManager.dbManager.cleanLocalMediaProgress()
DeviceManager.dbManager.cleanLocalLibraryItems() DeviceManager.dbManager.cleanLocalLibraryItems()
@@ -120,7 +125,9 @@ class AbsDatabase : Plugin() {
val userId = serverConfigPayload.userId val userId = serverConfigPayload.userId
val username = serverConfigPayload.username val username = serverConfigPayload.username
val token = serverConfigPayload.token val serverVersion = serverConfigPayload.version
val accessToken = serverConfigPayload.token
val refreshToken = serverConfigPayload.refreshToken // Refresh only sent after login or refresh
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
if (serverConnectionConfig == null) { // New Server Connection if (serverConnectionConfig == null) { // New Server Connection
@@ -129,7 +136,16 @@ class AbsDatabase : Plugin() {
// Create new server connection config // Create new server connection config
val sscId = DeviceManager.getBase64Id("$serverAddress@$username") val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token, serverConfigPayload.customHeaders)
// Store refresh token securely if provided
val hasRefreshToken = if (!refreshToken.isNullOrEmpty()) {
secureStorage.storeRefreshToken(sscId, refreshToken)
} else {
false
}
Log.d(tag, "Refresh token secured = $hasRefreshToken")
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, serverVersion, userId, username, accessToken, serverConfigPayload.customHeaders)
// Add and save // Add and save
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!) DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
@@ -137,14 +153,21 @@ class AbsDatabase : Plugin() {
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
} else { } else {
var shouldSave = false var shouldSave = false
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != token) { if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != accessToken || serverConnectionConfig?.version != serverVersion) {
serverConnectionConfig?.userId = userId serverConnectionConfig?.userId = userId
serverConnectionConfig?.username = username serverConnectionConfig?.username = username
serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})" serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})"
serverConnectionConfig?.token = token serverConnectionConfig?.version = serverVersion
serverConnectionConfig?.token = accessToken
shouldSave = true shouldSave = true
} }
// Update refresh token if provided
if (!refreshToken.isNullOrEmpty()) {
val stored = secureStorage.storeRefreshToken(serverConnectionConfig!!.id, refreshToken)
Log.d(tag, "Refresh token secured = $stored")
}
// Set last connection config // Set last connection config
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) { if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
@@ -163,6 +186,10 @@ class AbsDatabase : Plugin() {
fun removeServerConnectionConfig(call:PluginCall) { fun removeServerConnectionConfig(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString() val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
// Remove refresh token if it exists
secureStorage.removeRefreshToken(serverConnectionConfigId)
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig> DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) { if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
DeviceManager.deviceData.lastServerConnectionConfigId = null DeviceManager.deviceData.lastServerConnectionConfigId = null
@@ -175,6 +202,42 @@ class AbsDatabase : Plugin() {
} }
} }
@PluginMethod
fun getRefreshToken(call:PluginCall) {
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
GlobalScope.launch(Dispatchers.IO) {
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
if (refreshToken != null) {
val result = JSObject()
result.put("refreshToken", refreshToken)
call.resolve(result)
} else {
call.resolve()
}
}
}
@PluginMethod
fun clearRefreshToken(call:PluginCall) {
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
val refreshToken = secureStorage.removeRefreshToken(serverConnectionConfigId)
val result = JSObject()
result.put("success", refreshToken)
call.resolve(result)
}
@PluginMethod
fun getAccessToken(call:PluginCall) {
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
val serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId }
val token = serverConnectionConfig?.token ?: ""
val ret = JSObject()
ret.put("token", token)
call.resolve(ret)
}
@PluginMethod @PluginMethod
fun logout(call:PluginCall) { fun logout(call:PluginCall) {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
@@ -14,6 +14,7 @@ import com.audiobookshelf.app.media.SyncResult
import com.audiobookshelf.app.models.User import com.audiobookshelf.app.models.User
import com.audiobookshelf.app.BuildConfig import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.plugins.AbsLogger import com.audiobookshelf.app.plugins.AbsLogger
import com.audiobookshelf.app.managers.SecureStorage
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
@@ -33,9 +34,19 @@ import java.util.concurrent.TimeUnit
class ApiHandler(var ctx:Context) { class ApiHandler(var ctx:Context) {
val tag = "ApiHandler" val tag = "ApiHandler"
companion object {
// For sending data back to the Webview frontend
lateinit var absDatabaseNotifyListeners:(String, JSObject) -> Unit
fun checkAbsDatabaseNotifyListenersInitted():Boolean {
return ::absDatabaseNotifyListeners.isInitialized
}
}
private var defaultClient = OkHttpClient() private var defaultClient = OkHttpClient()
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build() private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
private var secureStorage = SecureStorage(ctx)
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo) data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@@ -110,6 +121,13 @@ class ApiHandler(var ctx:Context) {
override fun onResponse(call: Call, response: Response) { override fun onResponse(call: Call, response: Response) {
response.use { response.use {
if (it.code == 401) {
// Handle 401 Unauthorized by attempting token refresh
AbsLogger.info(tag, "makeRequest: 401 Unauthorized for request to \"${request.url}\" - attempt token refresh")
handleTokenRefresh(request, httpClient, cb)
return
}
if (!it.isSuccessful) { if (!it.isSuccessful) {
val jsobj = JSObject() val jsobj = JSObject()
jsobj.put("error", "Unexpected code $response") jsobj.put("error", "Unexpected code $response")
@@ -142,6 +160,266 @@ class ApiHandler(var ctx:Context) {
}) })
} }
/**
* Handles token refresh when a 401 Unauthorized response is received
* This function will:
* 1. Get the refresh token from secure storage for the current server connection
* 2. Make a request to /auth/refresh endpoint with the refresh token
* 3. Update the stored tokens with the new access token
* 4. Retry the original request with the new access token
* 5. If refresh fails, handle logout
*
* @param originalRequest The original request that failed with 401
* @param httpClient The HTTP client to use for the request
* @param callback The callback to return the response
*/
private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
try {
AbsLogger.info(tag, "handleTokenRefresh: Attempting to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString}")
// Get current server connection config ID
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
if (serverConnectionConfigId.isEmpty()) {
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No server connection config ID")
val errorObj = JSObject()
errorObj.put("error", "No server connection available")
callback(errorObj)
return
}
// Get refresh token from secure storage
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
if (refreshToken.isNullOrEmpty()) {
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No refresh token available for server ${DeviceManager.serverConnectionConfigString}")
val errorObj = JSObject()
errorObj.put("error", "No refresh token available")
callback(errorObj)
return
}
Log.d(tag, "handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
// Create refresh token request
val refreshEndpoint = "${DeviceManager.serverAddress}/auth/refresh"
val refreshRequest = Request.Builder()
.url(refreshEndpoint)
.addHeader("x-refresh-token", refreshToken)
.addHeader("Content-Type", "application/json")
.post(EMPTY_REQUEST)
.build()
// Make the refresh request
val client = httpClient ?: defaultClient
client.newCall(refreshRequest).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.e(tag, "handleTokenRefresh: Failed to connect to refresh endpoint", e)
AbsLogger.error(tag, "handleTokenRefresh: Failed to connect to refresh endpoint for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
handleRefreshFailure(callback)
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!it.isSuccessful) {
AbsLogger.error(tag, "handleTokenRefresh: Refresh request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}")
handleRefreshFailure(callback)
return
}
val bodyString = it.body!!.string()
try {
val responseJson = JSONObject(bodyString)
val userObj = responseJson.optJSONObject("user")
if (userObj == null) {
AbsLogger.error(tag, "handleTokenRefresh: No user object in refresh response for server ${DeviceManager.serverConnectionConfigString}")
handleRefreshFailure(callback)
return
}
val newAccessToken = userObj.optString("accessToken")
val newRefreshToken = userObj.optString("refreshToken")
if (newAccessToken.isEmpty()) {
AbsLogger.error(tag, "handleTokenRefresh: No access token in refresh response for server ${DeviceManager.serverConnectionConfigString}")
handleRefreshFailure(callback)
return
}
Log.d(tag, "handleTokenRefresh: Successfully obtained new access token")
// Update tokens in secure storage and device manager
updateTokens(newAccessToken, newRefreshToken.ifEmpty { refreshToken }, serverConnectionConfigId)
// Retry the original request with the new access token
Log.d(tag, "handleTokenRefresh: Retrying original request with new token")
retryOriginalRequest(originalRequest, newAccessToken, httpClient, callback)
} catch (e: Exception) {
Log.e(tag, "handleTokenRefresh: Failed to parse refresh response", e)
AbsLogger.error(tag, "handleTokenRefresh: Failed to parse refresh response for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
handleRefreshFailure(callback)
}
}
}
})
} catch (e: Exception) {
Log.e(tag, "handleTokenRefresh: Unexpected error during token refresh", e)
handleRefreshFailure(callback)
}
}
/**
* Updates the stored tokens with new access and refresh tokens
*
* @param newAccessToken The new access token
* @param newRefreshToken The new refresh token (or existing one if not provided)
*/
private fun updateTokens(newAccessToken: String, newRefreshToken: String, serverConnectionConfigId: String) {
try {
// Update the refresh token in secure storage if it's new
if (newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId)) {
secureStorage.storeRefreshToken(serverConnectionConfigId, newRefreshToken)
Log.d(tag, "updateTokens: Updated refresh token in secure storage")
}
// Update the access token in the current server connection config
DeviceManager.serverConnectionConfig?.let { config ->
config.token = newAccessToken
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
Log.d(tag, "updateTokens: Updated access token in server connection config")
}
// Send access token to Webview frontend
if (checkAbsDatabaseNotifyListenersInitted()) {
val tokenJsObject = JSObject()
tokenJsObject.put("accessToken", newAccessToken)
absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject)
} else {
// Can happen if Webview is never run
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send new access token")
}
AbsLogger.info(tag, "updateTokens: Successfully refreshed auth tokens for server ${DeviceManager.serverConnectionConfigString}")
} catch (e: Exception) {
Log.e(tag, "updateTokens: Failed to update tokens", e)
AbsLogger.error(tag, "updateTokens: Failed to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
}
}
/**
* Retries the original request with the new access token
*
* @param originalRequest The original request to retry
* @param newAccessToken The new access token to use
* @param httpClient The HTTP client to use
* @param callback The callback to return the response
*/
private fun retryOriginalRequest(originalRequest: Request, newAccessToken: String, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
try {
// Create a new request with the updated authorization header
val newRequest = originalRequest.newBuilder()
.removeHeader("Authorization")
.addHeader("Authorization", "Bearer $newAccessToken")
.build()
Log.d(tag, "retryOriginalRequest: Retrying request to ${newRequest.url}")
// Make the retry request
val client = httpClient ?: defaultClient
client.newCall(newRequest).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.e(tag, "retryOriginalRequest: Failed to retry request", e)
AbsLogger.error(tag, "retryOriginalRequest: Failed to retry request after token refresh for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
val errorObj = JSObject()
errorObj.put("error", "Failed to retry request after token refresh")
callback(errorObj)
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!it.isSuccessful) {
Log.e(tag, "retryOriginalRequest: Retry request failed with status ${it.code}")
AbsLogger.error(tag, "retryOriginalRequest: Retry request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}")
val errorObj = JSObject()
errorObj.put("error", "Retry request failed with status ${it.code}")
callback(errorObj)
return
}
val bodyString = it.body!!.string()
if (bodyString == "OK") {
callback(JSObject())
} else {
try {
var jsonObj = JSObject()
if (bodyString.startsWith("[")) {
val array = JSArray(bodyString)
jsonObj.put("value", array)
} else {
jsonObj = JSObject(bodyString)
}
callback(jsonObj)
} catch(je:JSONException) {
Log.e(tag, "retryOriginalRequest: Invalid JSON response ${je.localizedMessage} from body $bodyString")
val errorObj = JSObject()
errorObj.put("error", "Invalid response body")
callback(errorObj)
}
}
}
}
})
} catch (e: Exception) {
Log.e(tag, "retryOriginalRequest: Unexpected error during retry", e)
AbsLogger.error(tag, "retryOriginalRequest: Unexpected error during retry for server ${DeviceManager.serverConnectionConfigString}")
val errorObj = JSObject()
errorObj.put("error", "Failed to retry request")
callback(errorObj)
}
}
/**
* Handles the case when token refresh fails
* This will clear the current session and notify the callback
*
* @param callback The callback to return the error
*/
private fun handleRefreshFailure(callback: (JSObject) -> Unit) {
try {
Log.d(tag, "handleRefreshFailure: Token refresh failed, clearing session")
// Clear the current server connection
DeviceManager.serverConnectionConfig = null
DeviceManager.deviceData.lastServerConnectionConfigId = null
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
// Remove refresh token from secure storage
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
if (serverConnectionConfigId.isNotEmpty()) {
secureStorage.removeRefreshToken(serverConnectionConfigId)
}
val errorObj = JSObject()
errorObj.put("error", "Authentication failed - please login again")
callback(errorObj)
if (checkAbsDatabaseNotifyListenersInitted()) {
val tokenJsObject = JSObject()
tokenJsObject.put("error", "Token refresh failed")
absDatabaseNotifyListeners("onTokenRefreshFailure", tokenJsObject)
} else {
// Can happen if Webview is never run
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send token refresh failure notification")
}
} catch (e: Exception) {
Log.e(tag, "handleRefreshFailure: Error during failure handling", e)
val errorObj = JSObject()
errorObj.put("error", "Authentication failed")
callback(errorObj)
}
}
fun getCurrentUser(cb: (User?) -> Unit) { fun getCurrentUser(cb: (User?) -> Unit) {
getRequest("/api/me", null, null) { getRequest("/api/me", null, null) {
if (it.has("error")) { if (it.has("error")) {
+3 -3
View File
@@ -100,14 +100,14 @@ export default {
this.isCastAvailable = data && data.value this.isCastAvailable = data && data.value
} }
}, },
mounted() { async mounted() {
AbsAudioPlayer.getIsCastAvailable().then((data) => { AbsAudioPlayer.getIsCastAvailable().then((data) => {
this.isCastAvailable = data && data.value this.isCastAvailable = data && data.value
}) })
this.onCastAvailableUpdateListener = AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate) this.onCastAvailableUpdateListener = await AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate)
}, },
beforeDestroy() { beforeDestroy() {
if (this.onCastAvailableUpdateListener) this.onCastAvailableUpdateListener.remove() this.onCastAvailableUpdateListener?.remove()
} }
} }
</script> </script>
+11 -23
View File
@@ -140,13 +140,6 @@ export default {
readyTrackWidth: 0, readyTrackWidth: 0,
seekedTime: 0, seekedTime: 0,
seekLoading: false, seekLoading: false,
onPlaybackSessionListener: null,
onPlaybackClosedListener: null,
onPlayingUpdateListener: null,
onMetadataListener: null,
onProgressSyncFailing: null,
onProgressSyncSuccess: null,
onPlaybackSpeedChangedListener: null,
touchStartY: 0, touchStartY: 0,
touchStartTime: 0, touchStartTime: 0,
playerSettings: { playerSettings: {
@@ -883,14 +876,14 @@ export default {
async init() { async init() {
await this.loadPlayerSettings() await this.loadPlayerSettings()
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession) AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed) AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
this.onPlaybackFailedListener = AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed) AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed)
this.onPlayingUpdateListener = AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate) AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate)
this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata) AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing) AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess) AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged) AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
}, },
async screenOrientationChange() { async screenOrientationChange() {
if (this.isRefreshingUI) return if (this.isRefreshingUI) return
@@ -984,14 +977,9 @@ export default {
document.body.removeEventListener('touchend', this.touchend) document.body.removeEventListener('touchend', this.touchend)
document.body.removeEventListener('touchmove', this.touchmove) document.body.removeEventListener('touchmove', this.touchmove)
if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove() if (AbsAudioPlayer.removeAllListeners) {
if (this.onMetadataListener) this.onMetadataListener.remove() AbsAudioPlayer.removeAllListeners()
if (this.onPlaybackSessionListener) this.onPlaybackSessionListener.remove() }
if (this.onPlaybackClosedListener) this.onPlaybackClosedListener.remove()
if (this.onPlaybackFailedListener) this.onPlaybackFailedListener.remove()
if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove()
if (this.onProgressSyncSuccess) this.onProgressSyncSuccess.remove()
if (this.onPlaybackSpeedChangedListener) this.onPlaybackSpeedChangedListener.remove()
clearInterval(this.playInterval) clearInterval(this.playInterval)
} }
} }
+9 -9
View File
@@ -351,11 +351,11 @@ export default {
} }
} }
}, },
mounted() { async mounted() {
this.onLocalMediaProgressUpdateListener = AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate) this.onLocalMediaProgressUpdateListener = await AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate)
this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded) this.onSleepTimerEndedListener = await AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet) this.onSleepTimerSetListener = await AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet)
this.onMediaPlayerChangedListener = AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged) this.onMediaPlayerChangedListener = await AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged)
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate') this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`) console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
@@ -370,10 +370,10 @@ export default {
this.$eventBus.$on('device-focus-update', this.deviceFocused) this.$eventBus.$on('device-focus-update', this.deviceFocused)
}, },
beforeDestroy() { beforeDestroy() {
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove() this.onLocalMediaProgressUpdateListener?.remove()
if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove() this.onSleepTimerEndedListener?.remove()
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove() this.onSleepTimerSetListener?.remove()
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove() this.onMediaPlayerChangedListener?.remove()
this.$eventBus.$off('abs-ui-ready', this.onReady) this.$eventBus.$off('abs-ui-ready', this.onReady)
this.$eventBus.$off('play-item', this.playLibraryItem) this.$eventBus.$off('play-item', this.playLibraryItem)
+1 -15
View File
@@ -179,21 +179,7 @@ export default {
this.show = false this.show = false
}, },
async logout() { async logout() {
if (this.user) { await this.$store.dispatch('user/logout')
if (this.$store.getters['getIsPlayerOpen']) {
this.$eventBus.$emit('close-stream')
}
await this.$nativeHttp.post('/logout').catch((error) => {
console.error('Failed to logout', error)
})
}
this.$socket.logout()
await this.$db.logout()
this.$localStore.removeLastLibraryId()
this.$store.commit('user/logout')
this.$store.commit('libraries/setCurrentLibrary', null)
}, },
async disconnect() { async disconnect() {
await this.$hapticsImpact() await this.$hapticsImpact()
+60 -6
View File
@@ -14,7 +14,7 @@
<!-- No progress shown for collapsed series or podcasts in library --> <!-- No progress shown for collapsed series or podcasts in library -->
<div v-if="!isPodcast && !collapsedSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div> <div v-if="!isPodcast && !collapsedSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
</div> </div>
<div class="flex-grow px-2"> <div class="flex-grow pl-2" :class="showPlayButton ? 'pr-12' : 'pr-2'">
<p class="whitespace-normal line-clamp-2" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }"> <p class="whitespace-normal line-clamp-2" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
<span v-if="seriesSequence">#{{ seriesSequence }}&nbsp;</span>{{ displayTitle }} <span v-if="seriesSequence">#{{ seriesSequence }}&nbsp;</span>{{ displayTitle }}
</p> </p>
@@ -29,6 +29,16 @@
{{ $getString('LabelNumEpisodes', [numEpisodes]) }} {{ $getString('LabelNumEpisodes', [numEpisodes]) }}
</p> </p>
</div> </div>
<div v-if="showPlayButton" class="absolute top-0 bottom-0 right-0 h-full flex items-center justify-center z-20 pr-1">
<button type="button" class="relative rounded-full bg-fg-muted/50" :class="{ 'p-2': !playerIsStartingForThisMedia }" @click.stop.prevent="play">
<span v-if="!playerIsStartingForThisMedia" class="material-symbols text-2xl fill text-white">{{ playerIsPlaying ? 'pause' : 'play_arrow' }}</span>
<div v-else class="p-2 text-fg w-10 h-10 flex items-center justify-center bg-fg-muted/80 rounded-full overflow-hidden">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</button>
</div>
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }"> <div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<span class="material-symbols text-2xl text-success">download_done</span> <span class="material-symbols text-2xl text-success">download_done</span>
@@ -129,6 +139,9 @@ export default {
libraryItemId() { libraryItemId() {
return this._libraryItem.id return this._libraryItem.id
}, },
localLibraryItemId() {
return this.localLibraryItem?.id
},
series() { series() {
return this.mediaMetadata.series return this.mediaMetadata.series
}, },
@@ -217,13 +230,31 @@ export default {
return this.isMissing || this.isInvalid return this.isMissing || this.isInvalid
}, },
isStreaming() { isStreaming() {
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId return this.isPlaying && !this.store.getters['getIsCurrentSessionLocal']
},
isPlaying() {
if (this.localLibraryItemId && this.store.getters['getIsMediaStreaming'](this.localLibraryItemId)) return true
return this.store.getters['getIsMediaStreaming'](this.libraryItemId)
},
playerIsPlaying() {
return this.store.state.playerIsPlaying && (this.isStreaming || this.isPlaying)
},
playerIsStartingPlayback() {
// Play has been pressed and waiting for native play response
return this.store.state.playerIsStartingPlayback
},
playerIsStartingForThisMedia() {
const mediaId = this.store.state.playerStartingPlaybackMediaId
return mediaId === this.libraryItemId
},
isCasting() {
return this.store.state.isCasting
}, },
showReadButton() { showReadButton() {
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook return !this.isSelectionMode && !this.showPlayButton && this.hasEbook
}, },
showPlayButton() { showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isPodcast
}, },
showSmallEBookIcon() { showSmallEBookIcon() {
return !this.isSelectionMode && this.hasEbook return !this.isSelectionMode && this.hasEbook
@@ -293,9 +324,32 @@ export default {
this.selected = !this.selected this.selected = !this.selected
this.$emit('select', this.libraryItem) this.$emit('select', this.libraryItem)
}, },
play() { async play() {
var eventBus = this.$eventBus || this.$nuxt.$eventBus if (this.playerIsStartingPlayback) return
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
const hapticsImpact = this.$hapticsImpact || this.$nuxt.$hapticsImpact
if (hapticsImpact) {
await hapticsImpact()
}
const eventBus = this.$eventBus || this.$nuxt.$eventBus
if (this.playerIsPlaying) {
eventBus.$emit('pause-item')
} else {
// Audiobook
let libraryItemId = this.libraryItemId
// When casting use server library item
if (this.localLibraryItem && !this.isCasting) {
libraryItemId = this.localLibraryItem.id
} else if (this.hasLocal) {
libraryItemId = this.localLibraryItem.id
}
this.store.commit('setPlayerIsStartingPlayback', this.libraryItemId)
eventBus.$emit('play-item', { libraryItemId, serverLibraryItemId: this.libraryItemId })
}
}, },
destroy() { destroy() {
// destroy the vue listeners, etc // destroy the vue listeners, etc
+103 -40
View File
@@ -17,6 +17,11 @@
<p class="text-xs text-warning">{{ $strings.MessageOldServerConnectionWarning }}</p> <p class="text-xs text-warning">{{ $strings.MessageOldServerConnectionWarning }}</p>
<ui-btn class="text-xs whitespace-nowrap" :padding-x="2" :padding-y="1" @click="showOldUserIdWarningDialog">{{ $strings.LabelMoreInfo }}</ui-btn> <ui-btn class="text-xs whitespace-nowrap" :padding-x="2" :padding-y="1" @click="showOldUserIdWarningDialog">{{ $strings.LabelMoreInfo }}</ui-btn>
</div> </div>
<!-- warning message if server connection config is using an old auth method -->
<div v-if="config.version && checkIsUsingOldAuth(config)" class="flex flex-nowrap justify-between items-center space-x-4 pt-4">
<p class="text-xs text-warning">{{ $strings.MessageOldServerAuthWarning }}</p>
<ui-btn class="text-xs whitespace-nowrap" :padding-x="2" :padding-y="1" @click="showOldAuthWarningDialog">{{ $strings.LabelMoreInfo }}</ui-btn>
</div>
</div> </div>
<div class="my-1 py-4 w-full"> <div class="my-1 py-4 w-full">
<ui-btn class="w-full" @click="newServerConfigClick">{{ $strings.ButtonAddNewServer }}</ui-btn> <ui-btn class="w-full" @click="newServerConfigClick">{{ $strings.ButtonAddNewServer }}</ui-btn>
@@ -155,9 +160,23 @@ export default {
cancelText: this.$strings.ButtonOk cancelText: this.$strings.ButtonOk
}) })
}, },
async showOldAuthWarningDialog() {
const confirmResult = await Dialog.confirm({
title: 'Old Server Auth Warning',
message: this.$strings.MessageOldServerAuthWarningHelp,
cancelButtonTitle: this.$strings.ButtonReadMore
})
if (!confirmResult.value) {
window.open('https://github.com/advplyr/audiobookshelf/discussions/4460', '_blank')
}
},
checkIdUuid(userId) { checkIdUuid(userId) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId) return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId)
}, },
checkIsUsingOldAuth(config) {
if (!config.version) return true
return !this.$isValidVersion(config.version, '2.26.0')
},
/** /**
* Initiates the login process using OpenID via OAuth2.0. * Initiates the login process using OpenID via OAuth2.0.
* 1. Verifying the server's address * 1. Verifying the server's address
@@ -374,15 +393,19 @@ export default {
} catch (error) {} // No Error handling needed } catch (error) {} // No Error handling needed
try { try {
// Returns the same user response payload as /login
const response = await CapacitorHttp.get({ const response = await CapacitorHttp.get({
url: backendEndpoint url: backendEndpoint
}) })
// v2.26.0+ returns accessToken and refreshToken on user object
if (!response.data || !response.data.user || !response.data.user.token) { if (!response.data?.user?.token && !response.data?.user?.accessToken) {
throw new Error('Token data is missing in the response.') throw new Error('Token is missing in response.')
} }
this.serverConfig.token = response.data.user.token const user = response.data.user
this.serverConfig.token = user.accessToken || user.token
// TODO: Is it necessary to authenticate again?
const payload = await this.authenticateToken() const payload = await this.authenticateToken()
if (!payload) { if (!payload) {
@@ -394,6 +417,12 @@ export default {
throw new Error('Config already exists for this address and username.') throw new Error('Config already exists for this address and username.')
} }
// For v2.26.0+ re-attach accessToken and refreshToken to user object because /authorize does not return them
if (user.accessToken) {
payload.user.accessToken = user.accessToken
payload.user.refreshToken = user.refreshToken
}
this.setUserAndConnection(payload) this.setUserAndConnection(payload)
} catch (error) { } catch (error) {
console.error('[SSO] Error in exchangeCodeForToken: ', error) console.error('[SSO] Error in exchangeCodeForToken: ', error)
@@ -436,9 +465,10 @@ export default {
} }
this.error = null this.error = null
var payload = await this.authenticateToken() const payload = await this.authenticateToken()
if (payload) { if (payload) {
// Will NOT include access token and refresh token
this.setUserAndConnection(payload) this.setUserAndConnection(payload)
} else { } else {
this.showAuth = true this.showAuth = true
@@ -504,7 +534,7 @@ export default {
try { try {
var urlObject = new URL(url) var urlObject = new URL(url)
if (protocolOverride) urlObject.protocol = protocolOverride if (protocolOverride) urlObject.protocol = protocolOverride
return urlObject.href return urlObject.href.replace(/\/$/, '') // Remove trailing slash
} catch (error) { } catch (error) {
console.error('Invalid URL', error) console.error('Invalid URL', error)
return null return null
@@ -597,7 +627,12 @@ export default {
}) })
}, },
requestServerLogin() { requestServerLogin() {
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, this.serverConfig.customHeaders, 20000) const headers = {
// Tells the Abs server to return the refresh token
'x-return-tokens': 'true',
...(this.serverConfig.customHeaders || {})
}
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, headers, 20000)
.then((data) => { .then((data) => {
if (!data.user) { if (!data.user) {
console.error(data.error) console.error(data.error)
@@ -642,9 +677,13 @@ export default {
if (statusData.data.authFormData?.authOpenIDAutoLaunch) { if (statusData.data.authFormData?.authOpenIDAutoLaunch) {
this.clickLoginWithOpenId() this.clickLoginWithOpenId()
} }
return true
} else {
return false
} }
} catch (error) { } catch (error) {
this.handleLoginFormError(error) this.handleLoginFormError(error)
return false
} finally { } finally {
this.processing = false this.processing = false
} }
@@ -770,26 +809,6 @@ export default {
prependProtocolIfNeeded(address) { prependProtocolIfNeeded(address) {
return address.startsWith('http://') || address.startsWith('https://') ? address : `https://${address}` return address.startsWith('http://') || address.startsWith('https://') ? address : `https://${address}`
}, },
/**
* Compares two semantic versioning strings to determine if the current version meets
* or exceeds the minimum version requirement.
*
* @param {string} currentVersion - The current version string to compare, e.g., "1.2.3".
* @param {string} minVersion - The minimum version string required, e.g., "1.0.0".
* @returns {boolean} - Returns true if the current version is greater than or equal
* to the minimum version, false otherwise.
*/
isValidVersion(currentVersion, minVersion) {
const currentParts = currentVersion.split('.').map(Number)
const minParts = minVersion.split('.').map(Number)
for (let i = 0; i < minParts.length; i++) {
if (currentParts[i] > minParts[i]) return true
if (currentParts[i] < minParts[i]) return false
}
return true
},
async submitAuth() { async submitAuth() {
if (!this.networkConnected) return if (!this.networkConnected) return
if (!this.serverConfig.username) { if (!this.serverConfig.username) {
@@ -806,9 +825,10 @@ export default {
this.error = null this.error = null
this.processing = true this.processing = true
var payload = await this.requestServerLogin() const payload = await this.requestServerLogin()
this.processing = false this.processing = false
if (payload) { if (payload) {
// Will include access token and refresh token
this.setUserAndConnection(payload) this.setUserAndConnection(payload)
} }
}, },
@@ -821,18 +841,30 @@ export default {
this.$store.commit('libraries/setEReaderDevices', ereaderDevices) this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
this.$setServerLanguageCode(serverSettings.language) this.$setServerLanguageCode(serverSettings.language)
// Set library - Use last library if set and available fallback to default user library this.serverConfig.userId = user.id
var lastLibraryId = await this.$localStore.getLastLibraryId() this.serverConfig.username = user.username
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
this.$store.commit('libraries/setCurrentLibrary', lastLibraryId) if (this.$isValidVersion(serverSettings.version, '2.26.0')) {
} else if (userDefaultLibraryId) { // Tokens only returned from /login endpoint
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) if (user.accessToken) {
this.serverConfig.token = user.accessToken
this.serverConfig.refreshToken = user.refreshToken
} else {
// Detect if the connection config is using the old token. If so, force re-login
if (this.serverConfig.token === user.token || user.isOldToken) {
this.setForceReloginForNewAuth()
return
}
// If the token was updated during a refresh (in nativeHttp.js) it gets updated in the store, so refetch
this.serverConfig.token = this.$store.getters['user/getToken'] || this.serverConfig.token
}
} else {
// Server version before new JWT auth, use old user.token
this.serverConfig.token = user.token
} }
this.serverConfig.userId = user.id this.serverConfig.version = serverSettings.version
this.serverConfig.token = user.token
this.serverConfig.username = user.username
delete this.serverConfig.version
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig) var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
@@ -849,7 +881,16 @@ export default {
} }
} }
// Set library - Use last library if set and available fallback to default user library
const lastLibraryId = await this.$localStore.getLastLibraryId()
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
this.$store.commit('libraries/setCurrentLibrary', lastLibraryId)
} else if (userDefaultLibraryId) {
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
}
this.$store.commit('user/setUser', user) this.$store.commit('user/setUser', user)
this.$store.commit('user/setAccessToken', serverConnectionConfig.token)
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig) this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
this.$socket.connect(this.serverConfig.address, this.serverConfig.token) this.$socket.connect(this.serverConfig.address, this.serverConfig.token)
@@ -865,7 +906,13 @@ export default {
this.error = null this.error = null
this.processing = true this.processing = true
const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => { const nativeHttpOptions = {
headers: {
Authorization: `Bearer ${this.serverConfig.token}`
},
serverConnectionConfig: this.serverConfig
}
const authRes = await this.$nativeHttp.post(`${this.serverConfig.address}/api/authorize`, null, nativeHttpOptions).catch((error) => {
console.error('[ServerConnectForm] Server auth failed', error) console.error('[ServerConnectForm] Server auth failed', error)
const errorMsg = error.message || error const errorMsg = error.message || error
this.error = 'Failed to authorize' this.error = 'Failed to authorize'
@@ -874,14 +921,30 @@ export default {
} }
return false return false
}) })
console.log('[ServerConnectForm] authRes=', authRes) console.log('[ServerConnectForm] authRes=', authRes)
this.processing = false this.processing = false
return authRes return authRes
}, },
async setForceReloginForNewAuth() {
// This calls /status on the server and sets the auth methods
const result = await this.submit()
if (result) {
this.error = this.$strings.MessageOldServerAuthReLoginRequired
}
},
init() { init() {
// Handle force re-login for servers using new JWT auth but still using an old token in the server config
if (this.$route.query.error === 'oldAuthToken' && this.$route.query.serverConnectionConfigId) {
this.serverConfig = this.serverConnectionConfigs.find((scc) => scc.id === this.$route.query.serverConnectionConfigId)
if (this.serverConfig) {
this.setForceReloginForNewAuth()
return
}
}
if (this.lastServerConnectionConfig) { if (this.lastServerConnectionConfig) {
console.log('[ServerConnectForm] init with lastServerConnectionConfig', this.lastServerConnectionConfig)
this.connectToServer(this.lastServerConnectionConfig) this.connectToServer(this.lastServerConnectionConfig)
} else { } else {
this.showForm = !this.serverConnectionConfigs.length this.showForm = !this.serverConnectionConfigs.length
+56 -29
View File
@@ -54,8 +54,39 @@ export default {
}, },
data() { data() {
return { return {
sublist: null, sublist: null
bookItems: [ }
},
watch: {
show(newVal) {
if (!newVal) {
if (this.sublist && !this.selectedItemSublist) this.sublist = null
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selected: {
get() {
return this.filterBy
},
set(val) {
this.$emit('update:filterBy', val)
}
},
userCanAccessExplicitContent() {
return this.$store.getters['user/getUserCanAccessExplicitContent']
},
bookItems() {
const items = [
{ {
text: this.$strings.LabelAll, text: this.$strings.LabelAll,
value: 'all' value: 'all'
@@ -110,8 +141,20 @@ export default {
value: 'feed-open', value: 'feed-open',
sublist: false sublist: false
} }
], ]
podcastItems: [
if (this.userCanAccessExplicitContent) {
items.push({
text: this.$strings.LabelExplicit,
value: 'explicit',
sublist: false
})
}
return items
},
podcastItems() {
const items = [
{ {
text: this.$strings.LabelAll, text: this.$strings.LabelAll,
value: 'all' value: 'all'
@@ -132,32 +175,16 @@ export default {
sublist: false sublist: false
} }
] ]
}
}, if (this.userCanAccessExplicitContent) {
watch: { items.push({
show(newVal) { text: this.$strings.LabelExplicit,
if (!newVal) { value: 'explicit',
if (this.sublist && !this.selectedItemSublist) this.sublist = null sublist: false
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist })
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
selected: {
get() {
return this.filterBy
},
set(val) {
this.$emit('update:filterBy', val)
} }
return items
}, },
isPodcast() { isPodcast() {
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast' return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
+2 -7
View File
@@ -77,9 +77,6 @@ export default {
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() { libraryItemId() {
return this.libraryItem?.id return this.libraryItem?.id
}, },
@@ -245,11 +242,9 @@ export default {
async extract() { async extract() {
this.loading = true this.loading = true
// TODO: Handle JWT auth refresh
const buff = await this.$axios.$get(this.url, { const buff = await this.$axios.$get(this.url, {
responseType: 'blob', responseType: 'blob'
headers: {
Authorization: `Bearer ${this.userToken}`
}
}) })
const archive = await Archive.open(buff) const archive = await Archive.open(buff)
+14 -6
View File
@@ -49,9 +49,6 @@ export default {
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
/** @returns {string} */ /** @returns {string} */
libraryItemId() { libraryItemId() {
return this.libraryItem?.id return this.libraryItem?.id
@@ -298,15 +295,26 @@ export default {
/** @type {EpubReader} */ /** @type {EpubReader} */
const reader = this const reader = this
// Use axios to make request because we have token refresh logic in interceptor
const customRequest = async (url) => {
try {
return this.$axios.$get(url, {
responseType: 'arraybuffer'
})
} catch (error) {
console.error('EpubReader.initEpub customRequest failed:', error)
throw error
}
}
console.log('[EpubReader] initEpub', reader.url) console.log('[EpubReader] initEpub', reader.url)
/** @type {ePub.Book} */ /** @type {ePub.Book} */
reader.book = new ePub(reader.url, { reader.book = new ePub(reader.url, {
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight - this.readerHeightOffset, height: window.innerHeight - this.readerHeightOffset,
openAs: 'epub', openAs: 'epub',
requestHeaders: { requestMethod: this.isLocal ? null : customRequest
Authorization: `Bearer ${this.userToken}`
}
}) })
/** @type {ePub.Rendition} */ /** @type {ePub.Rendition} */
+4 -10
View File
@@ -22,11 +22,7 @@ export default {
data() { data() {
return {} return {}
}, },
computed: { computed: {},
userToken() {
return this.$store.getters['user/getToken']
}
},
methods: { methods: {
addHtmlCss() { addHtmlCss() {
let iframe = document.getElementsByTagName('iframe')[0] let iframe = document.getElementsByTagName('iframe')[0]
@@ -84,11 +80,9 @@ export default {
}, },
async initMobi() { async initMobi() {
// Fetch mobi file as blob // Fetch mobi file as blob
// TODO: Handle JWT auth refresh
var buff = await this.$axios.$get(this.url, { var buff = await this.$axios.$get(this.url, {
responseType: 'blob', responseType: 'blob'
headers: {
Authorization: `Bearer ${this.userToken}`
}
}) })
var reader = new FileReader() var reader = new FileReader()
reader.onload = async (event) => { reader.onload = async (event) => {
@@ -133,4 +127,4 @@ export default {
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
} }
</style> </style>
+55 -3
View File
@@ -49,7 +49,8 @@ export default {
numPages: 0, numPages: 0,
windowWidth: 0, windowWidth: 0,
windowHeight: 0, windowHeight: 0,
pdfDocInitParams: null pdfDocInitParams: null,
isRefreshing: false
} }
}, },
computed: { computed: {
@@ -109,6 +110,10 @@ export default {
}, },
isPlayerOpen() { isPlayerOpen() {
return this.$store.getters['getIsPlayerOpen'] return this.$store.getters['getIsPlayerOpen']
},
ebookUrl() {
const serverAddress = this.$store.getters['user/getServerAddress']
return this.isLocal ? this.url : `${serverAddress}${this.url}`
} }
}, },
methods: { methods: {
@@ -164,7 +169,54 @@ export default {
this.page++ this.page++
this.updateProgress() this.updateProgress()
}, },
error(err) { async handleRefreshFailure() {
try {
console.log('[PdfReader] Handling refresh failure - logging out user')
// Clear store
await this.$store.dispatch('user/logout')
if (this.$store.getters['user/getServerConnectionConfigId']) {
// Clear refresh token for server connection config
await this.$db.clearRefreshToken(this.$store.getters['user/getServerConnectionConfigId'])
}
if (window.location.pathname !== '/connect') {
window.location.href = '/connect'
}
} catch (error) {
console.error('[PdfReader] Failed to handle refresh failure:', error)
}
},
async refreshToken() {
if (this.isRefreshing) return
this.isRefreshing = true
// Cannot use axios with this pdf reader so we need to handle the refresh separately
// Should work on migrating to a different pdf reader in the future
const newAccessToken = await this.$store.dispatch('user/refreshToken').catch((error) => {
console.error('Failed to refresh token', error)
return null
})
if (!newAccessToken) {
this.handleRefreshFailure()
return
}
// Force Vue to re-render the PDF component by creating a new object
this.pdfDocInitParams = {
url: this.ebookUrl,
httpHeaders: {
Authorization: `Bearer ${newAccessToken}`
}
}
this.isRefreshing = false
},
async error(err) {
if (err && err.status === 401) {
console.log('Received 401 error, refreshing token')
await this.refreshToken()
return
}
console.error(err) console.error(err)
}, },
screenOrientationChange() { screenOrientationChange() {
@@ -173,7 +225,7 @@ export default {
}, },
init() { init() {
this.pdfDocInitParams = { this.pdfDocInitParams = {
url: this.url, url: this.ebookUrl,
httpHeaders: { httpHeaders: {
Authorization: `Bearer ${this.userToken}` Authorization: `Bearer ${this.userToken}`
} }
+5 -4
View File
@@ -14,7 +14,7 @@
<span class="material-symbols text-2xl text-fg">settings</span> <span class="material-symbols text-2xl text-fg">settings</span>
</button> </button>
<button v-if="comicHasMetadata" type="button" class="inline-flex mx-2" @click.stop="clickMetadataBtn"> <button v-if="comicHasMetadata" type="button" class="inline-flex mx-2" @click.stop="clickMetadataBtn">
<span class="material-symbols text-2xl text-fg">more-vert</span> <span class="material-symbols text-2xl text-fg">more</span>
</button> </button>
</div> </div>
@@ -260,6 +260,7 @@ export default {
return null return null
}, },
ebookFile() { ebookFile() {
if (!this.media) return null
// ebook file id is passed when reading a supplementary ebook // ebook file id is passed when reading a supplementary ebook
if (this.ebookFileId) { if (this.ebookFileId) {
return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId) return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)
@@ -304,11 +305,11 @@ export default {
if (this.localContentUrl) { if (this.localContentUrl) {
return Capacitor.convertFileSrc(this.localContentUrl) return Capacitor.convertFileSrc(this.localContentUrl)
} }
const serverAddress = this.$store.getters['user/getServerAddress']
if (this.ebookFileId) { if (this.ebookFileId) {
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}` return `/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}`
} }
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook` return `/api/items/${this.selectedLibraryItem.id}/ebook`
}, },
isPlayerOpen() { isPlayerOpen() {
return this.$store.getters['getIsPlayerOpen'] return this.$store.getters['getIsPlayerOpen']
@@ -78,15 +78,15 @@ export default {
this.$store.commit('globals/updateDownloadItemPart', itemPart) this.$store.commit('globals/updateDownloadItemPart', itemPart)
} }
}, },
mounted() { async mounted() {
this.downloadItemListener = AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data)) this.downloadItemListener = await AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data))
this.itemPartUpdateListener = AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data)) this.itemPartUpdateListener = await AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data))
this.completeListener = AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data)) this.completeListener = await AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data))
}, },
beforeDestroy() { beforeDestroy() {
if (this.downloadItemListener) this.downloadItemListener.remove() this.downloadItemListener?.remove()
if (this.completeListener) this.completeListener.remove() this.completeListener?.remove()
if (this.itemPartUpdateListener) this.itemPartUpdateListener.remove() this.itemPartUpdateListener?.remove()
} }
} }
</script> </script>
+8 -4
View File
@@ -25,6 +25,7 @@
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; }; 4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; };
4D91EEC62A40F28D004807ED /* EBookFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91EEC52A40F28D004807ED /* EBookFile.swift */; }; 4D91EEC62A40F28D004807ED /* EBookFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91EEC52A40F28D004807ED /* EBookFile.swift */; };
4DABC04F2B0139CA000F6264 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DABC04E2B0139CA000F6264 /* User.swift */; }; 4DABC04F2B0139CA000F6264 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DABC04E2B0139CA000F6264 /* User.swift */; };
4DB441E12E19B8BF0056C8F1 /* SecureStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */; };
4DF6C7172DB58ABF004059F1 /* AbsLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF6C7162DB58ABF004059F1 /* AbsLogger.swift */; }; 4DF6C7172DB58ABF004059F1 /* AbsLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF6C7162DB58ABF004059F1 /* AbsLogger.swift */; };
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF74911287105C600AC7814 /* DeviceSettings.swift */; }; 4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF74911287105C600AC7814 /* DeviceSettings.swift */; };
4DFE2DA32D345C390000B204 /* MyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFE2DA22D345C390000B204 /* MyViewController.swift */; }; 4DFE2DA32D345C390000B204 /* MyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFE2DA22D345C390000B204 /* MyViewController.swift */; };
@@ -98,6 +99,7 @@
4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; }; 4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; };
4D91EEC52A40F28D004807ED /* EBookFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EBookFile.swift; sourceTree = "<group>"; }; 4D91EEC52A40F28D004807ED /* EBookFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EBookFile.swift; sourceTree = "<group>"; };
4DABC04E2B0139CA000F6264 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = "<group>"; }; 4DABC04E2B0139CA000F6264 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = "<group>"; };
4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorage.swift; sourceTree = "<group>"; };
4DF6C7162DB58ABF004059F1 /* AbsLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsLogger.swift; sourceTree = "<group>"; }; 4DF6C7162DB58ABF004059F1 /* AbsLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsLogger.swift; sourceTree = "<group>"; };
4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = "<group>"; }; 4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = "<group>"; };
4DFE2DA22D345C390000B204 /* MyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyViewController.swift; sourceTree = "<group>"; }; 4DFE2DA22D345C390000B204 /* MyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyViewController.swift; sourceTree = "<group>"; };
@@ -239,6 +241,7 @@
3AFCB5E627EA23F700ECCC05 /* util */ = { 3AFCB5E627EA23F700ECCC05 /* util */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */,
E9FA07E228C82848005520B0 /* Logger.swift */, E9FA07E228C82848005520B0 /* Logger.swift */,
3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */, 3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */,
3AD4FCEA280443DD006DB301 /* Database.swift */, 3AD4FCEA280443DD006DB301 /* Database.swift */,
@@ -550,6 +553,7 @@
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */, E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */, 3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */, 3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
4DB441E12E19B8BF0056C8F1 /* SecureStorage.swift in Sources */,
4DABC04F2B0139CA000F6264 /* User.swift in Sources */, 4DABC04F2B0139CA000F6264 /* User.swift in Sources */,
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */, E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */, EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */,
@@ -732,12 +736,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons; ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 38; CURRENT_PROJECT_VERSION = 39;
DEVELOPMENT_TEAM = 7UFJ7D8V6A; DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.81; MARKETING_VERSION = 0.10.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -756,12 +760,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = Icons; ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 38; CURRENT_PROJECT_VERSION = 39;
DEVELOPMENT_TEAM = 7UFJ7D8V6A; DEVELOPMENT_TEAM = 7UFJ7D8V6A;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.9.81; MARKETING_VERSION = 0.10.0;
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+9 -4
View File
@@ -14,7 +14,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// Override point for customization after application launch. // Override point for customization after application launch.
let configuration = Realm.Configuration( let configuration = Realm.Configuration(
schemaVersion: 19, schemaVersion: 20,
migrationBlock: { [weak self] migration, oldSchemaVersion in migrationBlock: { [weak self] migration, oldSchemaVersion in
if (oldSchemaVersion < 1) { if (oldSchemaVersion < 1) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)") self?.logger.log("Realm schema version was \(oldSchemaVersion)")
@@ -65,9 +65,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding disableSleepTimerFadeOut settings") self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding disableSleepTimerFadeOut settings")
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
newObject?["disableSleepTimerFadeOut"] = false newObject?["disableSleepTimerFadeOut"] = false
} }
} }
if (oldSchemaVersion < 20) {
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding version to ServerConnectionConfigs")
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
newObject?["version"] = ""
}
}
} }
) )
Realm.Configuration.defaultConfiguration = configuration Realm.Configuration.defaultConfiguration = configuration
+45 -1
View File
@@ -33,6 +33,8 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
public let pluginMethods: [CAPPluginMethod] = [ public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "setCurrentServerConnectionConfig", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "setCurrentServerConnectionConfig", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "removeServerConnectionConfig", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "removeServerConnectionConfig", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getRefreshToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "clearRefreshToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "logout", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "logout", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getDeviceData", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getDeviceData", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getLocalLibraryItems", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getLocalLibraryItems", returnType: CAPPluginReturnPromise),
@@ -49,15 +51,33 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
] ]
private let logger = AppLogger(category: "AbsDatabase") private let logger = AppLogger(category: "AbsDatabase")
private let secureStorage = SecureStorage()
// Used to notify the webview frontend that the token has been refreshed
static var tokenRefreshCallback: ((String, [String: Any]) -> Void)?
override public func load() {
AbsDatabase.tokenRefreshCallback = { [weak self] eventName, data in
self?.notifyListeners(eventName, data: data)
}
}
@objc func setCurrentServerConnectionConfig(_ call: CAPPluginCall) { @objc func setCurrentServerConnectionConfig(_ call: CAPPluginCall) {
var id = call.getString("id") var id = call.getString("id")
let address = call.getString("address", "") let address = call.getString("address", "")
let version = call.getString("version", "")
let userId = call.getString("userId", "") let userId = call.getString("userId", "")
let username = call.getString("username", "") let username = call.getString("username", "")
let token = call.getString("token", "") let token = call.getString("token", "")
let refreshToken = call.getString("refreshToken", "") // Refresh only sent after login or refresh
let name = "\(address) (\(username))" let name = "\(address) (\(username))"
if (refreshToken != "") {
// Store refresh token securely if provided
let hasRefreshToken = secureStorage.storeRefreshToken(serverConnectionConfigId: id ?? "", refreshToken: refreshToken)
logger.log("Refresh token secured = \(hasRefreshToken)")
}
if id == nil { if id == nil {
id = "\(address)@\(username)".toBase64() id = "\(address)@\(username)".toBase64()
@@ -68,6 +88,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
config.index = 0 config.index = 0
config.name = name config.name = name
config.address = address config.address = address
config.version = version
config.userId = userId config.userId = userId
config.username = username config.username = username
config.token = token config.token = token
@@ -76,12 +97,35 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
let savedConfig = Store.serverConfig // Fetch the latest value let savedConfig = Store.serverConfig // Fetch the latest value
call.resolve(convertServerConnectionConfigToJSON(config: savedConfig!)) call.resolve(convertServerConnectionConfigToJSON(config: savedConfig!))
} }
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) { @objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
let id = call.getString("serverConnectionConfigId", "") let id = call.getString("serverConnectionConfigId", "")
// Remove refresh token if it exists
_ = secureStorage.removeRefreshToken(serverConnectionConfigId: id)
Database.shared.deleteServerConnectionConfig(id: id) Database.shared.deleteServerConnectionConfig(id: id)
call.resolve() call.resolve()
} }
@objc func getRefreshToken(_ call: CAPPluginCall) {
let serverConnectionConfigId = call.getString("serverConnectionConfigId", "")
let refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId: serverConnectionConfigId)
if let refreshToken = refreshToken {
call.resolve(["refreshToken": refreshToken])
} else {
call.resolve()
}
}
@objc func clearRefreshToken(_ call: CAPPluginCall) {
let serverConnectionConfigId = call.getString("serverConnectionConfigId", "")
let success = secureStorage.removeRefreshToken(serverConnectionConfigId: serverConnectionConfigId)
call.resolve(["success": success])
}
@objc func logout(_ call: CAPPluginCall) { @objc func logout(_ call: CAPPluginCall) {
Store.serverConfig = nil Store.serverConfig = nil
call.resolve() call.resolve()
@@ -13,6 +13,7 @@ class ServerConnectionConfig: Object {
@Persisted(indexed: true) var index: Int = 1 @Persisted(indexed: true) var index: Int = 1
@Persisted var name: String = "" @Persisted var name: String = ""
@Persisted var address: String = "" @Persisted var address: String = ""
@Persisted var version: String = ""
@Persisted var userId: String = "" @Persisted var userId: String = ""
@Persisted var username: String = "" @Persisted var username: String = ""
@Persisted var token: String = "" @Persisted var token: String = ""
@@ -29,6 +30,7 @@ func convertServerConnectionConfigToJSON(config: ServerConnectionConfig) -> Dict
"name": config.name, "name": config.name,
"index": config.index, "index": config.index,
"address": config.address, "address": config.address,
"version": config.version,
"userId": config.userId, "userId": config.userId,
"username": config.username, "username": config.username,
"token": config.token, "token": config.token,
+12 -3
View File
@@ -561,8 +561,15 @@ class AudioPlayer: NSObject {
guard let playbackSession = self.getPlaybackSession() else { return nil } guard let playbackSession = self.getPlaybackSession() else { return nil }
if (playbackSession.playMethod == PlayMethod.directplay.rawValue) { if (playbackSession.playMethod == PlayMethod.directplay.rawValue) {
let urlstr = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)" // As of v2.22.0 tracks use a different endpoint
let url = URL(string: urlstr)! // See: https://github.com/advplyr/audiobookshelf/pull/4263
let contentUrl: String
if Store.isServerVersionGreaterThanOrEqualTo("2.22.0") {
contentUrl = "\(Store.serverConfig!.address)/public/session/\(playbackSession.id)/track/\(track.index ?? 1)"
} else {
contentUrl = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)"
}
let url = URL(string: contentUrl)!
return AVURLAsset(url: url) return AVURLAsset(url: url)
} else if (playbackSession.playMethod == PlayMethod.local.rawValue) { } else if (playbackSession.playMethod == PlayMethod.local.rawValue) {
guard let localFile = track.getLocalFile() else { guard let localFile = track.getLocalFile() else {
@@ -577,7 +584,9 @@ class AudioPlayer: NSObject {
let headers: [String: String] = [ let headers: [String: String] = [
"Authorization": "Bearer \(Store.serverConfig!.token)" "Authorization": "Bearer \(Store.serverConfig!.token)"
] ]
return AVURLAsset(url: URL(string: "\(Store.serverConfig!.address)\(track.contentUrl ?? "")")!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
let contentUrl = "\(Store.serverConfig!.address)\(track.contentUrl ?? "")"
return AVURLAsset(url: URL(string: contentUrl)!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
} }
} }
+391 -22
View File
@@ -10,6 +10,7 @@ import Alamofire
class ApiClient { class ApiClient {
private static let logger = AppLogger(category: "ApiClient") private static let logger = AppLogger(category: "ApiClient")
private static let secureStorage = SecureStorage()
public static func getData(from url: URL, completion: @escaping (UIImage?) -> Void) { public static func getData(from url: URL, completion: @escaping (UIImage?) -> Void) {
URLSession.shared.dataTask(with: url, completionHandler: {(data, response, error) in URLSession.shared.dataTask(with: url, completionHandler: {(data, response, error) in
@@ -146,6 +147,318 @@ class ApiClient {
} }
} }
// MARK: - Token Refresh Handling
/**
* Handles token refresh when a 401 Unauthorized response is received
* This function will:
* 1. Get the refresh token from secure storage for the current server connection
* 2. Make a request to /auth/refresh endpoint with the refresh token
* 3. Update the connection config with the new accessToken and put the refreshToken in secure storage
* 4. Retry the original request with the new access token
* 5. If refresh fails, handle logout
*/
private static func handleTokenRefresh<T: Decodable>(originalRequest: DataRequest, endpoint: String, method: HTTPMethod, parameters: Any?, decodable: T.Type, callback: ((_ param: T?) -> Void)?) {
guard let serverConfig = Store.serverConfig else {
logger.error("handleTokenRefresh: No server config available")
callback?(nil)
return
}
logger.log("handleTokenRefresh: Attempting to refresh auth tokens for server \(serverConfig.name)")
// Get refresh token from secure storage
guard let refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId: serverConfig.id) else {
logger.error("handleTokenRefresh: No refresh token available for server \(serverConfig.name)")
handleRefreshFailure()
callback?(nil)
return
}
logger.log("handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
// Create refresh token request
let refreshHeaders: HTTPHeaders = [
"x-refresh-token": refreshToken,
"Content-Type": "application/json"
]
let refreshRequest = AF.request("\(serverConfig.address)/auth/refresh", method: .post, headers: refreshHeaders)
refreshRequest.responseDecodable(of: RefreshResponse.self) { response in
switch response.result {
case .success(let refreshResponse):
guard let user = refreshResponse.user,
!user.accessToken.isEmpty else {
logger.error("handleTokenRefresh: No access token in refresh response for server \(serverConfig.name)")
handleRefreshFailure()
callback?(nil)
return
}
logger.log("handleTokenRefresh: Successfully obtained new access token")
// Update tokens in secure storage and store
updateTokens(newAccessToken: user.accessToken, newRefreshToken: user.refreshToken ?? refreshToken, serverConnectionConfigId: serverConfig.id)
// Retry the original request with the new access token
logger.log("handleTokenRefresh: Retrying original request with new token")
retryOriginalRequest(endpoint: endpoint, method: method, parameters: parameters, decodable: decodable, newAccessToken: user.accessToken, callback: callback)
case .failure(let error):
logger.error("handleTokenRefresh: Refresh request failed for server \(serverConfig.name): \(error)")
handleRefreshFailure()
callback?(nil)
}
}
}
/**
* Updates the stored tokens with new access and refresh tokens
*/
private static func updateTokens(newAccessToken: String, newRefreshToken: String, serverConnectionConfigId: String) {
// Update the refresh token in secure storage if it's new
if newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId: serverConnectionConfigId) {
let hasStored = secureStorage.storeRefreshToken(serverConnectionConfigId: serverConnectionConfigId, refreshToken: newRefreshToken)
logger.log("updateTokens: Updated refresh token in secure storage. Stored=\(hasStored)")
}
// Update access token on server connection config
Database.shared.updateServerConnectionConfigToken(newToken: newAccessToken)
logger.log("updateTokens: Updated access token in server connection config")
logger.log("updateTokens: Successfully refreshed auth tokens for server \(Store.serverConfig?.name ?? "unknown")")
// Notify webview frontend about token refresh
if let callback = AbsDatabase.tokenRefreshCallback {
let tokenData: [String: Any] = ["accessToken": newAccessToken]
callback("onTokenRefresh", tokenData)
}
}
/**
* Retries the original request with the new access token
*/
private static func retryOriginalRequest<T: Decodable>(endpoint: String, method: HTTPMethod, parameters: Any?, decodable: T.Type, newAccessToken: String, callback: ((_ param: T?) -> Void)?) {
guard let serverConfig = Store.serverConfig else {
logger.error("retryOriginalRequest: No server config available")
callback?(nil)
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(newAccessToken)"
]
let retryRequest: DataRequest
switch method {
case .get:
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .get, headers: headers)
case .post:
if let parameters = parameters as? [String: Any] {
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .post, parameters: parameters, encoding: JSONEncoding.default, headers: headers)
} else if let encodableParams = parameters as? Encodable {
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .post, parameters: encodableParams, encoder: JSONParameterEncoder.default, headers: headers)
} else {
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .post, headers: headers)
}
case .patch:
if let encodableParams = parameters as? Encodable {
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .patch, parameters: encodableParams, encoder: JSONParameterEncoder.default, headers: headers)
} else {
retryRequest = AF.request("\(serverConfig.address)/\(endpoint)", method: .patch, headers: headers)
}
default:
logger.error("retryOriginalRequest: Unsupported method \(method)")
callback?(nil)
return
}
// Handle the response
retryRequest.response { response in
if let statusCode = response.response?.statusCode, (200...299).contains(statusCode) {
// Check if response has data
if let data = response.data, !data.isEmpty {
// If it is a string return nil (e.g. express returns OK for 200 status codes)
if let responseString = String(data: data, encoding: .utf8) {
logger.log("retryOriginalRequest: Got string response '\(responseString)'")
callback?(nil)
return
}
// If not a string, try JSON
do {
let decodedObject = try JSONDecoder().decode(decodable, from: data)
callback?(decodedObject)
} catch {
logger.error("retryOriginalRequest: JSON decode failed: \(error)")
callback?(nil)
}
} else {
// Empty response
logger.log("retryOriginalRequest: Empty response with success status \(statusCode)")
callback?(nil)
}
} else {
logger.error("retryOriginalRequest: Request failed with status \(response.response?.statusCode ?? 0)")
callback?(nil)
}
}
}
/**
* Handles the case when token refresh fails
* This will clear the current server connection and notify webview
*/
private static func handleRefreshFailure() {
logger.log("handleRefreshFailure: Token refresh failed, clearing session")
// Clear the current server connection
Store.serverConfig = nil
// Remove refresh token from secure storage
if let serverConfig = Store.serverConfig {
_ = secureStorage.removeRefreshToken(serverConnectionConfigId: serverConfig.id)
}
// Notify webview frontend about token refresh failure
if let callback = AbsDatabase.tokenRefreshCallback {
callback("onTokenRefreshFailure", ["error": "Token refresh failed"])
}
}
// MARK: - Enhanced API Methods with Token Refresh
public static func getResourceWithTokenRefresh<T: Decodable>(endpoint: String, decodable: T.Type = T.self, callback: ((_ param: T?) -> Void)?) {
if (Store.serverConfig == nil) {
logger.error("Server config not set")
callback?(nil)
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .get, headers: headers)
request.responseDecodable(of: decodable) { response in
if let statusCode = response.response?.statusCode, statusCode == 401 {
logger.log("getResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .get, parameters: nil, decodable: decodable, callback: callback)
} else {
switch response.result {
case .success(let obj):
callback?(obj)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
print(error)
callback?(nil)
}
}
}
}
public static func postResourceWithTokenRefresh<T: Encodable, U: Decodable>(endpoint: String, parameters: T, decodable: U.Type = U.self, callback: ((_ param: U?) -> Void)?) {
if (Store.serverConfig == nil) {
logger.error("Server config not set")
callback?(nil)
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers)
request.responseDecodable(of: decodable) { response in
if let statusCode = response.response?.statusCode, statusCode == 401 {
logger.log("postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: decodable, callback: callback)
} else {
switch response.result {
case .success(let obj):
callback?(obj)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
print(error)
callback?(nil)
}
}
}
}
/**
* POST request for endpoints that only return success/failure
*/
public static func postResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) {
logger.error("Server config not set")
callback?(false)
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .post, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers)
request.response { response in
if let statusCode = response.response?.statusCode, statusCode == 401 {
logger.log("postResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .post, parameters: parameters, decodable: EmptyResponse.self) { result in
callback?(result != nil)
}
} else {
switch response.result {
case .success(_):
callback?(true)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
print(error)
callback?(false)
}
}
}
}
public static func patchResourceWithTokenRefresh<T: Encodable>(endpoint: String, parameters: T, callback: ((_ success: Bool) -> Void)?) {
if (Store.serverConfig == nil) {
logger.error("Server config not set")
callback?(false)
return
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(Store.serverConfig!.token)"
]
let request = AF.request("\(Store.serverConfig!.address)/\(endpoint)", method: .patch, parameters: parameters, encoder: JSONParameterEncoder.default, headers: headers)
request.response { response in
if let statusCode = response.response?.statusCode, statusCode == 401 {
logger.log("patchResourceWithTokenRefresh: 401 Unauthorized for request to \(endpoint) - attempting token refresh")
handleTokenRefresh(originalRequest: request, endpoint: endpoint, method: .patch, parameters: parameters, decodable: EmptyResponse.self) { result in
callback?(result != nil)
}
} else {
switch response.result {
case .success(_):
callback?(true)
case .failure(let error):
logger.error("api request to \(endpoint) failed")
print(error)
callback?(false)
}
}
}
}
// MARK: - API Functions
public static func startPlaybackSession(libraryItemId: String, episodeId: String?, forceTranscode:Bool, callback: @escaping (_ param: PlaybackSession) -> Void) { public static func startPlaybackSession(libraryItemId: String, episodeId: String?, forceTranscode:Bool, callback: @escaping (_ param: PlaybackSession) -> Void) {
var endpoint = "api/items/\(libraryItemId)/play" var endpoint = "api/items/\(libraryItemId)/play"
if episodeId != nil { if episodeId != nil {
@@ -160,20 +473,28 @@ class ApiClient {
} }
} }
let parameters: [String: Any] = [ // Create an Encodable struct for the parameters
"forceDirectPlay": !forceTranscode ? "1" : "", let parameters = PlaybackSessionRequest(
"forceTranscode": forceTranscode ? "1" : "", forceDirectPlay: !forceTranscode ? "1" : "",
"mediaPlayer": "AVPlayer", forceTranscode: forceTranscode ? "1" : "",
"deviceInfo": [ mediaPlayer: "AVPlayer",
"deviceId": UIDevice.current.identifierForVendor?.uuidString, deviceInfo: DeviceInfo(
"manufacturer": "Apple", deviceId: UIDevice.current.identifierForVendor?.uuidString,
"model": modelCode, manufacturer: "Apple",
"clientVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String model: modelCode,
] clientVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
] )
ApiClient.postResource(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { obj in )
let session = obj
// Use the new token refresh-enabled method
postResourceWithTokenRefresh(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { session in
guard let session = session else {
logger.error("startPlaybackSession: Failed to create playback session")
callback(PlaybackSession()) // Return empty session on failure
return
}
// Set server connection info on the session
session.serverConnectionConfigId = Store.serverConfig!.id session.serverConnectionConfigId = Store.serverConfig!.id
session.serverAddress = Store.serverConfig!.address session.serverAddress = Store.serverConfig!.address
@@ -182,15 +503,28 @@ class ApiClient {
} }
public static func reportPlaybackProgress(report: PlaybackReport, sessionId: String) async -> Bool { public static func reportPlaybackProgress(report: PlaybackReport, sessionId: String) async -> Bool {
return await postResource(endpoint: "api/session/\(sessionId)/sync", parameters: report) return await withCheckedContinuation { continuation in
postResourceWithTokenRefresh(endpoint: "api/session/\(sessionId)/sync", parameters: report) { success in
continuation.resume(returning: success)
}
}
} }
public static func reportLocalPlaybackProgress(_ session: PlaybackSession) async -> Bool { public static func reportLocalPlaybackProgress(_ session: PlaybackSession) async -> Bool {
return await postResource(endpoint: "api/session/local", parameters: session) return await withCheckedContinuation { continuation in
postResourceWithTokenRefresh(endpoint: "api/session/local", parameters: session) { success in
continuation.resume(returning: success)
}
}
} }
public static func reportAllLocalPlaybackSessions(_ sessions: [PlaybackSession]) async -> Bool { public static func reportAllLocalPlaybackSessions(_ sessions: [PlaybackSession]) async -> Bool {
return await postResource(endpoint: "api/session/local-all", parameters: LocalPlaybackSessionSyncAllPayload(sessions: sessions, deviceInfo: sessions.first?.deviceInfo)) return await withCheckedContinuation { continuation in
let payload = LocalPlaybackSessionSyncAllPayload(sessions: sessions, deviceInfo: sessions.first?.deviceInfo)
postResourceWithTokenRefresh(endpoint: "api/session/local-all", parameters: payload) { success in
continuation.resume(returning: success)
}
}
} }
public static func syncLocalSessionsWithServer(isFirstSync: Bool) async { public static func syncLocalSessionsWithServer(isFirstSync: Bool) async {
@@ -257,7 +591,7 @@ class ApiClient {
public static func updateMediaProgress<T:Encodable>(libraryItemId: String, episodeId: String?, payload: T, callback: @escaping () -> Void) { public static func updateMediaProgress<T:Encodable>(libraryItemId: String, episodeId: String?, payload: T, callback: @escaping () -> Void) {
logger.log("updateMediaProgress \(libraryItemId) \(episodeId ?? "NIL") \(payload)") logger.log("updateMediaProgress \(libraryItemId) \(episodeId ?? "NIL") \(payload)")
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")" let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
patchResource(endpoint: endpoint, parameters: payload) { success in patchResourceWithTokenRefresh(endpoint: endpoint, parameters: payload) { _ in
callback() callback()
} }
} }
@@ -265,21 +599,29 @@ class ApiClient {
public static func getMediaProgress(libraryItemId: String, episodeId: String?) async -> MediaProgress? { public static func getMediaProgress(libraryItemId: String, episodeId: String?) async -> MediaProgress? {
logger.log("getMediaProgress \(libraryItemId) \(episodeId ?? "NIL")") logger.log("getMediaProgress \(libraryItemId) \(episodeId ?? "NIL")")
let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")" let endpoint = episodeId?.isEmpty ?? true ? "api/me/progress/\(libraryItemId)" : "api/me/progress/\(libraryItemId)/\(episodeId ?? "")"
return await getResource(endpoint: endpoint, decodable: MediaProgress.self) return await withCheckedContinuation { continuation in
getResourceWithTokenRefresh(endpoint: endpoint, decodable: MediaProgress.self) { result in
continuation.resume(returning: result)
}
}
} }
public static func getCurrentUser() async -> User? { public static func getCurrentUser() async -> User? {
logger.log("getCurrentUser") logger.log("getCurrentUser")
return await getResource(endpoint: "api/me", decodable: User.self) return await withCheckedContinuation { continuation in
getResourceWithTokenRefresh(endpoint: "api/me", decodable: User.self) { result in
continuation.resume(returning: result)
}
}
} }
public static func getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, callback: @escaping (_ param: LibraryItem?) -> Void) { public static func getLibraryItemWithProgress(libraryItemId: String, episodeId: String?, callback: @escaping (_ param: LibraryItem?) -> Void) {
var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress" var endpoint = "api/items/\(libraryItemId)?expanded=1&include=progress"
if episodeId != nil { if episodeId != nil {
endpoint += "&episodeId=\(episodeId!)" endpoint += "&episodeId=\(episodeId!)"
} }
ApiClient.getResource(endpoint: endpoint, decodable: LibraryItem.self) { obj in getResourceWithTokenRefresh(endpoint: endpoint, decodable: LibraryItem.self) { obj in
callback(obj) callback(obj)
} }
} }
@@ -338,3 +680,30 @@ struct Connectivity {
return self.sharedInstance.isReachable return self.sharedInstance.isReachable
} }
} }
// MARK: - Response Models
struct RefreshResponse: Decodable {
let user: RefreshUser?
}
struct RefreshUser: Decodable {
let accessToken: String
let refreshToken: String?
}
struct EmptyResponse: Decodable {}
struct PlaybackSessionRequest: Encodable {
let forceDirectPlay: String
let forceTranscode: String
let mediaPlayer: String
let deviceInfo: DeviceInfo
}
struct DeviceInfo: Encodable {
let deviceId: String?
let manufacturer: String
let model: String?
let clientVersion: String?
}
+14
View File
@@ -27,6 +27,7 @@ class Database {
try existing.update { try existing.update {
existing.name = config.name existing.name = config.name
existing.address = config.address existing.address = config.address
existing.version = config.version
existing.userId = config.userId existing.userId = config.userId
existing.username = config.username existing.username = config.username
existing.token = config.token existing.token = config.token
@@ -60,6 +61,19 @@ class Database {
setLastActiveConfigIndex(index: config.index) setLastActiveConfigIndex(index: config.index)
} }
} }
public func updateServerConnectionConfigToken(newToken: String) {
do {
let realm = try Realm()
if let config = realm.objects(ServerConnectionConfig.self).first(where: { $0.index == getLastActiveConfigIndex() }) {
try realm.write {
config.token = newToken
}
}
} catch {
debugPrint("Failed to update server connection config token: \(error)")
}
}
public func deleteServerConnectionConfig(id: String) { public func deleteServerConnectionConfig(id: String) {
let realm = try! Realm() let realm = try! Realm()
+10 -2
View File
@@ -22,8 +22,16 @@ struct NowPlayingMetadata {
return item.coverUrl return item.coverUrl
} else { } else {
guard let config = Store.serverConfig else { return nil } guard let config = Store.serverConfig else { return nil }
guard let url = URL(string: "\(config.address)/api/items/\(itemId)/cover?token=\(config.token)") else { return nil }
return url // As of v2.17.0 token is not needed with cover image requests
let coverUrlString: String
if Store.isServerVersionGreaterThanOrEqualTo("2.17.0") {
coverUrlString = "\(config.address)/api/items/\(itemId)/cover"
} else {
coverUrlString = "\(config.address)/api/items/\(itemId)/cover?token=\(config.token)"
}
return URL(string: coverUrlString)
} }
} }
} }
+116
View File
@@ -0,0 +1,116 @@
//
// SecureStorage.swift
// App
//
// Created by advplyr on 2025-07-05
//
import Foundation
import Security
class SecureStorage {
private static let tag = "SecureStorage"
private static let serviceName = "AudiobookshelfRefreshTokens"
/**
* Encrypts and stores a refresh token for a specific server connection
*/
func storeRefreshToken(serverConnectionConfigId: String, refreshToken: String) -> Bool {
let key = "refresh_token_\(serverConnectionConfigId)"
guard let data = refreshToken.data(using: .utf8) else {
print("\(Self.tag): Failed to convert refresh token to data")
return false
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.serviceName,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
// First, try to delete any existing item
SecItemDelete(query as CFDictionary)
// Then add the new item
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecSuccess {
print("\(Self.tag): Successfully stored encrypted refresh token for server: \(serverConnectionConfigId)")
return true
} else {
print("\(Self.tag): Failed to store refresh token for server: \(serverConnectionConfigId), status: \(status)")
return false
}
}
/**
* Retrieves and decrypts a refresh token for a specific server connection
*/
func getRefreshToken(serverConnectionConfigId: String) -> String? {
let key = "refresh_token_\(serverConnectionConfigId)"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.serviceName,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess,
let data = result as? Data,
let refreshToken = String(data: data, encoding: .utf8) {
return refreshToken
} else {
print("\(Self.tag): Failed to retrieve refresh token for server: \(serverConnectionConfigId), status: \(status)")
return nil
}
}
/**
* Removes a refresh token for a specific server connection
*/
func removeRefreshToken(serverConnectionConfigId: String) -> Bool {
let key = "refresh_token_\(serverConnectionConfigId)"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.serviceName,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
if status == errSecSuccess || status == errSecItemNotFound {
print("\(Self.tag): Successfully removed refresh token for server: \(serverConnectionConfigId)")
return true
} else {
print("\(Self.tag): Failed to remove refresh token for server: \(serverConnectionConfigId), status: \(status)")
return false
}
}
/**
* Checks if a refresh token exists for a specific server connection
*/
func hasRefreshToken(serverConnectionConfigId: String) -> Bool {
let key = "refresh_token_\(serverConnectionConfigId)"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.serviceName,
kSecAttrAccount as String: key,
kSecReturnData as String: false,
kSecMatchLimit as String: kSecMatchLimitOne
]
let status = SecItemCopyMatching(query as CFDictionary, nil)
return status == errSecSuccess
}
}
+42
View File
@@ -29,4 +29,46 @@ class Store {
} }
} }
} }
/**
* Check if the currently connected server version is >= compareVersion
* Abs server only uses major.minor.patch
* Note: Version is returned in Abs auth payloads starting v2.6.0
* Note: Version is saved with the server connection config starting after v0.9.81
*
* @example
* serverVersion=2.25.1
* isServerVersionGreaterThanOrEqualTo("2.26.0") = false
*
* serverVersion=2.26.1
* isServerVersionGreaterThanOrEqualTo("2.26.0") = true
*/
public static func isServerVersionGreaterThanOrEqualTo(_ compareVersion: String) -> Bool {
guard let serverConfig = serverConfig, !serverConfig.version.isEmpty else {
return false
}
if compareVersion.isEmpty {
return true
}
let serverVersionParts = serverConfig.version.split(separator: ".").compactMap { Int($0) }
let compareVersionParts = compareVersion.split(separator: ".").compactMap { Int($0) }
// Compare major, minor, and patch components
let maxLength = max(serverVersionParts.count, compareVersionParts.count)
for i in 0..<maxLength {
let serverVersionComponent = i < serverVersionParts.count ? serverVersionParts[i] : 0
let compareVersionComponent = i < compareVersionParts.count ? compareVersionParts[i] : 0
if serverVersionComponent < compareVersionComponent {
return false // Server version is less than compareVersion
} else if serverVersionComponent > compareVersionComponent {
return true // Server version is greater than compareVersion
}
}
return true // versions are equal in major, minor, and patch
}
} }
+27 -16
View File
@@ -99,21 +99,6 @@ export default {
await this.$store.dispatch('user/loadUserSettings') await this.$store.dispatch('user/loadUserSettings')
}, },
async postRequest(url, data, headers, connectTimeout = 30000) {
const options = {
url,
headers,
data,
connectTimeout
}
const response = await CapacitorHttp.post(options)
console.log('[default] POST request response', response)
if (response.status >= 400) {
throw new Error(response.data)
} else {
return response.data
}
},
async attemptConnection() { async attemptConnection() {
console.warn('[default] attemptConnection') console.warn('[default] attemptConnection')
if (!this.networkConnected) { if (!this.networkConnected) {
@@ -145,10 +130,18 @@ export default {
AbsLogger.info({ tag: 'default', message: `attemptConnection: Got server config, attempt authorize (${serverConfig.name})` }) AbsLogger.info({ tag: 'default', message: `attemptConnection: Got server config, attempt authorize (${serverConfig.name})` })
const authRes = await this.postRequest(`${serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${serverConfig.token}` }, 6000).catch((error) => { const nativeHttpOptions = {
headers: {
Authorization: `Bearer ${serverConfig.token}`
},
connectTimeout: 6000,
serverConnectionConfig: serverConfig
}
const authRes = await this.$nativeHttp.post(`${serverConfig.address}/api/authorize`, null, nativeHttpOptions).catch((error) => {
AbsLogger.error({ tag: 'default', message: `attemptConnection: Server auth failed (${serverConfig.name})` }) AbsLogger.error({ tag: 'default', message: `attemptConnection: Server auth failed (${serverConfig.name})` })
return false return false
}) })
if (!authRes) { if (!authRes) {
this.attemptingConnection = false this.attemptingConnection = false
return return
@@ -158,6 +151,22 @@ export default {
this.$store.commit('setServerSettings', serverSettings) this.$store.commit('setServerSettings', serverSettings)
this.$store.commit('libraries/setEReaderDevices', ereaderDevices) this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
if (this.$isValidVersion(serverSettings.version, '2.26.0')) {
// Check if the server is using the new JWT auth and is still using an old token in the server config
// If so, redirect to /connect and request to re-login
if (serverConfig.token === user.token || user.isOldToken) {
this.attemptingConnection = false
AbsLogger.info({ tag: 'default', message: `attemptConnection: Server is using new JWT auth but config is still using an old token (server version: ${serverSettings.version}) (${serverConfig.name})` })
// Clear last server config
await this.$store.dispatch('user/logout')
this.$router.push(`/connect?error=oldAuthToken&serverConnectionConfigId=${serverConfig.id}`)
return
}
// Token may have been refreshed during the authorize call so refetch from store
serverConfig.token = this.$store.getters['user/getToken'] || serverConfig.token
}
// Set library - Use last library if set and available fallback to default user library // Set library - Use last library if set and available fallback to default user library
const lastLibraryId = await this.$localStore.getLastLibraryId() const lastLibraryId = await this.$localStore.getLastLibraryId()
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) { if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
@@ -165,9 +174,11 @@ export default {
} else if (userDefaultLibraryId) { } else if (userDefaultLibraryId) {
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
} }
serverConfig.version = serverSettings.version
const serverConnectionConfig = await this.$db.setServerConnectionConfig(serverConfig) const serverConnectionConfig = await this.$db.setServerConnectionConfig(serverConfig)
this.$store.commit('user/setUser', user) this.$store.commit('user/setUser', user)
this.$store.commit('user/setAccessToken', serverConnectionConfig.token)
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig) this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
this.$socket.connect(serverConnectionConfig.address, serverConnectionConfig.token) this.$socket.connect(serverConnectionConfig.address, serverConnectionConfig.token)
+1 -1
View File
@@ -30,7 +30,7 @@ export default {
css: ['@/assets/tailwind.css', '@/assets/app.css'], css: ['@/assets/tailwind.css', '@/assets/app.css'],
plugins: ['@/plugins/server.js', '@/plugins/db.js', '@/plugins/localStore.js', '@/plugins/init.client.js', '@/plugins/axios.js', '@/plugins/nativeHttp.js', '@/plugins/capacitor/index.js', '@/plugins/capacitor/AbsAudioPlayer.js', '@/plugins/toast.js', '@/plugins/constants.js', '@/plugins/haptics.js', '@/plugins/i18n.js'], plugins: ['@/plugins/server.js', '@/plugins/db.js', '@/plugins/localStore.js', '@/plugins/init.client.js', '@/plugins/axios.js', '@/plugins/capacitor/index.js', '@/plugins/capacitor/AbsAudioPlayer.js', '@/plugins/nativeHttp.js', '@/plugins/toast.js', '@/plugins/constants.js', '@/plugins/haptics.js', '@/plugins/i18n.js'],
components: true, components: true,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.81-beta", "version": "0.10.0-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.81-beta", "version": "0.10.0-beta",
"dependencies": { "dependencies": {
"@capacitor-community/keep-awake": "^7.0.0", "@capacitor-community/keep-awake": "^7.0.0",
"@capacitor-community/volume-buttons": "^7.0.0", "@capacitor-community/volume-buttons": "^7.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-app", "name": "audiobookshelf-app",
"version": "0.9.81-beta", "version": "0.10.0-beta",
"author": "advplyr", "author": "advplyr",
"scripts": { "scripts": {
"dev": "nuxt --hostname 0.0.0.0 --port 1337", "dev": "nuxt --hostname 0.0.0.0 --port 1337",
+9 -11
View File
@@ -4,6 +4,10 @@
<ui-text-input-with-label :value="username" :label="$strings.LabelUsername" disabled class="my-2" /> <ui-text-input-with-label :value="username" :label="$strings.LabelUsername" disabled class="my-2" />
<div v-if="serverVersion" class="text-sm text-fg">
<p>Server version: v{{ serverVersion }}</p>
</div>
<ui-btn color="primary flex items-center justify-between gap-2 ml-auto text-base mt-8" @click="logout">{{ $strings.ButtonSwitchServerUser }}<span class="material-symbols" style="font-size: 1.1rem">logout</span></ui-btn> <ui-btn color="primary flex items-center justify-between gap-2 ml-auto text-base mt-8" @click="logout">{{ $strings.ButtonSwitchServerUser }}<span class="material-symbols" style="font-size: 1.1rem">logout</span></ui-btn>
<div class="flex justify-center items-center my-4 left-0 right-0 bottom-0 absolute"> <div class="flex justify-center items-center my-4 left-0 right-0 bottom-0 absolute">
@@ -43,22 +47,16 @@ export default {
}, },
serverAddress() { serverAddress() {
return this.serverConnectionConfig.address return this.serverConnectionConfig.address
},
serverVersion() {
// Saved in server connection config after 0.9.81
return this.serverConnectionConfig.version
} }
}, },
methods: { methods: {
async logout() { async logout() {
await this.$hapticsImpact() await this.$hapticsImpact()
if (this.user) { await this.$store.dispatch('user/logout')
await this.$nativeHttp.post('/logout').catch((error) => {
console.error(error)
})
}
this.$socket.logout()
await this.$db.logout()
this.$localStore.removeLastLibraryId()
this.$store.commit('user/logout')
this.$store.commit('libraries/setCurrentLibrary', null)
this.$router.push('/connect') this.$router.push('/connect')
} }
}, },
+1 -1
View File
@@ -62,7 +62,7 @@ export default {
async loadRecentEpisodes(page = 0) { async loadRecentEpisodes(page = 0) {
this.loadedLibraryId = this.currentLibraryId this.loadedLibraryId = this.currentLibraryId
this.processing = true this.processing = true
const episodePayload = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/recent-episodes?limit=25&page=${page}`).catch((error) => { const episodePayload = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/recent-episodes?limit=50&page=${page}`).catch((error) => {
console.error('Failed to get recent episodes', error) console.error('Failed to get recent episodes', error)
this.$toast.error('Failed to get recent episodes') this.$toast.error('Failed to get recent episodes')
return null return null
+1 -1
View File
@@ -200,7 +200,7 @@ export default {
this.onMediaItemHistoryUpdatedListener = await AbsAudioPlayer.addListener('onMediaItemHistoryUpdated', this.onMediaItemHistoryUpdated) this.onMediaItemHistoryUpdatedListener = await AbsAudioPlayer.addListener('onMediaItemHistoryUpdated', this.onMediaItemHistoryUpdated)
}, },
beforeDestroy() { beforeDestroy() {
if (this.onMediaItemHistoryUpdatedListener) this.onMediaItemHistoryUpdatedListener.remove() this.onMediaItemHistoryUpdatedListener?.remove()
} }
} }
</script> </script>
+113 -12
View File
@@ -1,17 +1,50 @@
export default function ({ $axios, store }) { export default function ({ $axios, store, $db }) {
$axios.onRequest(config => { // Track if we're currently refreshing to prevent multiple refresh attempts
let isRefreshing = false
let failedQueue = []
const processQueue = (error, token = null) => {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error)
} else {
resolve(token)
}
})
failedQueue = []
}
/**
* Handles the case when token refresh fails
* @param {string} [serverConnectionConfigId]
* @returns {Promise} - Promise that resolves when logout is complete
*/
const handleRefreshFailure = async (serverConnectionConfigId) => {
try {
console.log('[axios] Handling refresh failure - logging out user')
// Clear store
await store.dispatch('user/logout')
if (serverConnectionConfigId) {
// Clear refresh token for server connection config
await $db.clearRefreshToken(serverConnectionConfigId)
}
if (window.location.pathname !== '/connect') {
window.location.href = '/connect'
}
} catch (error) {
console.error('[axios] Failed to handle refresh failure:', error)
}
}
$axios.onRequest((config) => {
console.log('[Axios] Making request to ' + config.url) console.log('[Axios] Making request to ' + config.url)
if (config.url.startsWith('http:') || config.url.startsWith('https:') || config.url.startsWith('capacitor:')) { if (config.url.startsWith('http:') || config.url.startsWith('https:') || config.url.startsWith('capacitor:')) {
return return
} }
const customHeaders = store.getters['user/getCustomHeaders']
if (customHeaders) {
for (const key in customHeaders) {
config.headers.common[key] = customHeaders[key]
}
}
const bearerToken = store.getters['user/getToken'] const bearerToken = store.getters['user/getToken']
if (bearerToken) { if (bearerToken) {
config.headers.common['Authorization'] = `Bearer ${bearerToken}` config.headers.common['Authorization'] = `Bearer ${bearerToken}`
@@ -26,7 +59,75 @@ export default function ({ $axios, store }) {
console.log('[Axios] Request out', config.url) console.log('[Axios] Request out', config.url)
}) })
$axios.onError(error => { $axios.onError(async (error) => {
console.error('Axios error code', error) const originalRequest = error.config
const code = parseInt(error.response && error.response.status)
const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
console.error('Axios error', code, message)
// Handle 401 Unauthorized (token expired)
if (code === 401 && !originalRequest._retry) {
// Skip refresh for auth endpoints to prevent infinite loops
if (originalRequest.url.endsWith('/auth/refresh') || originalRequest.url.endsWith('/login')) {
await handleRefreshFailure(store.getters['user/getServerConnectionConfigId'])
return Promise.reject(error)
}
if (isRefreshing) {
// If already refreshing, queue this request
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
})
.then((token) => {
if (!originalRequest.headers) {
originalRequest.headers = {}
}
originalRequest.headers['Authorization'] = `Bearer ${token}`
return $axios(originalRequest)
})
.catch((err) => {
return Promise.reject(err)
})
}
originalRequest._retry = true
isRefreshing = true
try {
// Attempt to refresh the token
// Updates store if successful, otherwise clears store and throw error
const newAccessToken = await store.dispatch('user/refreshToken')
if (!newAccessToken) {
console.error('No new access token received')
return Promise.reject(error)
}
// Update the original request with new token
if (!originalRequest.headers) {
originalRequest.headers = {}
}
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`
// Process any queued requests
processQueue(null, newAccessToken)
// Retry the original request
return $axios(originalRequest)
} catch (refreshError) {
console.error('Token refresh failed:', refreshError)
// Process queued requests with error
processQueue(refreshError, null)
await handleRefreshFailure(store.getters['user/getServerConnectionConfigId'])
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
}) })
} }
+15 -2
View File
@@ -70,7 +70,12 @@ class AbsAudioPlayerWeb extends WebPlugin {
const deviceInfo = { const deviceInfo = {
deviceId: this.getDeviceId() deviceId: this.getDeviceId()
} }
const playbackSession = await $axios.$post(route, { deviceInfo, mediaPlayer: 'html5-mobile', forceDirectPlay: true }) const reqBody = {
deviceInfo,
mediaPlayer: 'html5-mobile',
forceDirectPlay: true
}
const playbackSession = await $axios.$post(route, reqBody)
if (playbackSession) { if (playbackSession) {
if (startTime !== undefined && startTime !== null) playbackSession.currentTime = startTime if (startTime !== undefined && startTime !== null) playbackSession.currentTime = startTime
this.setAudioPlayer(playbackSession, playWhenReady) this.setAudioPlayer(playbackSession, playWhenReady)
@@ -245,7 +250,15 @@ class AbsAudioPlayerWeb extends WebPlugin {
this.trackStartTime = Math.max(0, this.startTime - (this.currentTrack.startOffset || 0)) this.trackStartTime = Math.max(0, this.startTime - (this.currentTrack.startOffset || 0))
const serverAddressUrl = new URL(vuexStore.getters['user/getServerAddress']) const serverAddressUrl = new URL(vuexStore.getters['user/getServerAddress'])
const serverHost = `${serverAddressUrl.protocol}//${serverAddressUrl.host}` const serverHost = `${serverAddressUrl.protocol}//${serverAddressUrl.host}`
this.player.src = `${serverHost}${this.currentTrack.contentUrl}?token=${vuexStore.getters['user/getToken']}`
let sessionTrackUrl = null
if (this.currentTrack.contentUrl?.startsWith('/hls')) {
sessionTrackUrl = this.currentTrack.contentUrl
} else {
sessionTrackUrl = `/public/session/${this.playbackSession.id}/track/${this.currentTrack.index}`
}
this.player.src = `${serverHost}${sessionTrackUrl}`
console.log(`[AbsAudioPlayer] Loading track src ${this.player.src}`) console.log(`[AbsAudioPlayer] Loading track src ${this.player.src}`)
this.player.load() this.player.load()
this.player.playbackRate = this.playbackRate this.player.playbackRate = this.playbackRate
+133 -85
View File
@@ -1,4 +1,17 @@
import { registerPlugin, Capacitor, WebPlugin } from '@capacitor/core'; import { registerPlugin, Capacitor, WebPlugin } from '@capacitor/core'
/**
* @typedef {Object} ServerConnectionConfig
* @property {string} id
* @property {number} index
* @property {string} name
* @property {string} address
* @property {string} version
* @property {string} userId
* @property {string} username
* @property {string} token
* @property {string} [refreshToken] - Only passed in when setting config, then stored in secure storage
*/
class AbsDatabaseWeb extends WebPlugin { class AbsDatabaseWeb extends WebPlugin {
constructor() { constructor() {
@@ -19,17 +32,30 @@ class AbsDatabaseWeb extends WebPlugin {
return deviceData return deviceData
} }
/**
*
* @param {ServerConnectionConfig} serverConnectionConfig
* @returns {Promise<ServerConnectionConfig>}
*/
async setCurrentServerConnectionConfig(serverConnectionConfig) { async setCurrentServerConnectionConfig(serverConnectionConfig) {
var deviceData = await this.getDeviceData() var deviceData = await this.getDeviceData()
var ssc = deviceData.serverConnectionConfigs.find(_ssc => _ssc.id == serverConnectionConfig.id) var ssc = deviceData.serverConnectionConfigs.find((_ssc) => _ssc.id == serverConnectionConfig.id)
if (ssc) { if (ssc) {
deviceData.lastServerConnectionConfigId = ssc.id deviceData.lastServerConnectionConfigId = ssc.id
ssc.name = `${ssc.address} (${serverConnectionConfig.username})` ssc.name = `${ssc.address} (${serverConnectionConfig.username})`
ssc.token = serverConnectionConfig.token ssc.token = serverConnectionConfig.token
ssc.userId = serverConnectionConfig.userId ssc.userId = serverConnectionConfig.userId
ssc.username = serverConnectionConfig.username ssc.username = serverConnectionConfig.username
ssc.version = serverConnectionConfig.version
ssc.customHeaders = serverConnectionConfig.customHeaders || {} ssc.customHeaders = serverConnectionConfig.customHeaders || {}
if (serverConnectionConfig.refreshToken) {
console.log('[AbsDatabase] Updating refresh token...', serverConnectionConfig.refreshToken)
// Only using local storage for web version that is only used for testing
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
}
localStorage.setItem('device', JSON.stringify(deviceData)) localStorage.setItem('device', JSON.stringify(deviceData))
} else { } else {
ssc = { ssc = {
@@ -40,8 +66,16 @@ class AbsDatabaseWeb extends WebPlugin {
username: serverConnectionConfig.username, username: serverConnectionConfig.username,
address: serverConnectionConfig.address, address: serverConnectionConfig.address,
token: serverConnectionConfig.token, token: serverConnectionConfig.token,
version: serverConnectionConfig.version,
customHeaders: serverConnectionConfig.customHeaders || {} customHeaders: serverConnectionConfig.customHeaders || {}
} }
if (serverConnectionConfig.refreshToken) {
console.log('[AbsDatabase] Setting refresh token...', serverConnectionConfig.refreshToken)
// Only using local storage for web version that is only used for testing
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
}
deviceData.serverConnectionConfigs.push(ssc) deviceData.serverConnectionConfigs.push(ssc)
deviceData.lastServerConnectionConfigId = ssc.id deviceData.lastServerConnectionConfigId = ssc.id
localStorage.setItem('device', JSON.stringify(deviceData)) localStorage.setItem('device', JSON.stringify(deviceData))
@@ -49,14 +83,26 @@ class AbsDatabaseWeb extends WebPlugin {
return ssc return ssc
} }
async getRefreshToken({ serverConnectionConfigId }) {
console.log('[AbsDatabase] Getting refresh token...', serverConnectionConfigId)
const refreshToken = localStorage.getItem(`refresh_token_${serverConnectionConfigId}`)
return refreshToken ? { refreshToken } : null
}
async clearRefreshToken({ serverConnectionConfigId }) {
console.log('[AbsDatabase] Clearing refresh token...', serverConnectionConfigId)
localStorage.removeItem(`refresh_token_${serverConnectionConfigId}`)
}
async removeServerConnectionConfig(serverConnectionConfigCallObject) { async removeServerConnectionConfig(serverConnectionConfigCallObject) {
var serverConnectionConfigId = serverConnectionConfigCallObject.serverConnectionConfigId var serverConnectionConfigId = serverConnectionConfigCallObject.serverConnectionConfigId
var deviceData = await this.getDeviceData() var deviceData = await this.getDeviceData()
deviceData.serverConnectionConfigs = deviceData.serverConnectionConfigs.filter(ssc => ssc.id != serverConnectionConfigId) deviceData.serverConnectionConfigs = deviceData.serverConnectionConfigs.filter((ssc) => ssc.id != serverConnectionConfigId)
localStorage.setItem('device', JSON.stringify(deviceData)) localStorage.setItem('device', JSON.stringify(deviceData))
} }
async logout() { async logout() {
console.log('[AbsDatabase] Logging out...')
var deviceData = await this.getDeviceData() var deviceData = await this.getDeviceData()
deviceData.lastServerConnectionConfigId = null deviceData.lastServerConnectionConfigId = null
localStorage.setItem('device', JSON.stringify(deviceData)) localStorage.setItem('device', JSON.stringify(deviceData))
@@ -85,79 +131,81 @@ class AbsDatabaseWeb extends WebPlugin {
} }
async getLocalLibraryItems(payload) { async getLocalLibraryItems(payload) {
return { return {
value: [{ value: [
id: 'local_test', {
libraryItemId: 'test34', id: 'local_test',
serverAddress: 'https://abs.test.com', libraryItemId: 'test34',
serverUserId: 'test56', serverAddress: 'https://abs.test.com',
folderId: 'test1', serverUserId: 'test56',
absolutePath: 'a', folderId: 'test1',
contentUrl: 'c', absolutePath: 'a',
isInvalid: false, contentUrl: 'c',
mediaType: 'book', isInvalid: false,
media: { mediaType: 'book',
metadata: { media: {
title: 'Test Book', metadata: {
authorName: 'Test Author Name' title: 'Test Book',
authorName: 'Test Author Name'
},
coverPath: null,
tags: [],
audioFiles: [],
chapters: [],
tracks: [
{
index: 1,
startOffset: 0,
duration: 10000,
title: 'Track Title 1',
contentUrl: 'test',
mimeType: 'audio/mpeg',
metadata: null,
isLocal: true,
localFileId: 'lf1',
audioProbeResult: {}
},
{
index: 2,
startOffset: 0,
duration: 15000,
title: 'Track Title 2',
contentUrl: 'test2',
mimeType: 'audio/mpeg',
metadata: null,
isLocal: true,
localFileId: 'lf2',
audioProbeResult: {}
},
{
index: 3,
startOffset: 0,
duration: 20000,
title: 'Track Title 3',
contentUrl: 'test3',
mimeType: 'audio/mpeg',
metadata: null,
isLocal: true,
localFileId: 'lf3',
audioProbeResult: {}
}
]
}, },
coverPath: null, localFiles: [
tags: [],
audioFiles: [],
chapters: [],
tracks: [
{ {
index: 1, id: 'lf1',
startOffset: 0, filename: 'lf1.mp3',
duration: 10000,
title: 'Track Title 1',
contentUrl: 'test', contentUrl: 'test',
absolutePath: 'test',
simplePath: 'test',
mimeType: 'audio/mpeg', mimeType: 'audio/mpeg',
metadata: null, size: 39048290
isLocal: true,
localFileId: 'lf1',
audioProbeResult: {}
},
{
index: 2,
startOffset: 0,
duration: 15000,
title: 'Track Title 2',
contentUrl: 'test2',
mimeType: 'audio/mpeg',
metadata: null,
isLocal: true,
localFileId: 'lf2',
audioProbeResult: {}
},
{
index: 3,
startOffset: 0,
duration: 20000,
title: 'Track Title 3',
contentUrl: 'test3',
mimeType: 'audio/mpeg',
metadata: null,
isLocal: true,
localFileId: 'lf3',
audioProbeResult: {}
} }
] ],
}, coverContentUrl: null,
localFiles: [ coverAbsolutePath: null,
{ isLocal: true
id: 'lf1', }
filename: 'lf1.mp3', ]
contentUrl: 'test',
absolutePath: 'test',
simplePath: 'test',
mimeType: 'audio/mpeg',
size: 39048290
}
],
coverContentUrl: null,
coverAbsolutePath: null,
isLocal: true
}]
} }
} }
async getLocalLibraryItemsInFolder({ folderId }) { async getLocalLibraryItemsInFolder({ folderId }) {
@@ -167,7 +215,7 @@ class AbsDatabaseWeb extends WebPlugin {
return this.getLocalLibraryItems().then((data) => data.value[0]) return this.getLocalLibraryItems().then((data) => data.value[0])
} }
async getLocalLibraryItemByLId({ libraryItemId }) { async getLocalLibraryItemByLId({ libraryItemId }) {
return this.getLocalLibraryItems().then((data) => data.value.find(lli => lli.libraryItemId == libraryItemId)) return this.getLocalLibraryItems().then((data) => data.value.find((lli) => lli.libraryItemId == libraryItemId))
} }
async getAllLocalMediaProgress() { async getAllLocalMediaProgress() {
return { return {
@@ -182,7 +230,7 @@ class AbsDatabaseWeb extends WebPlugin {
isFinished: false, isFinished: false,
lastUpdate: 394089090, lastUpdate: 394089090,
startedAt: 239048209, startedAt: 239048209,
finishedAt: null, finishedAt: null
// For local lib items from server to support server sync // For local lib items from server to support server sync
// var serverConnectionConfigId:String?, // var serverConnectionConfigId:String?,
// var serverAddress:String?, // var serverAddress:String?,
@@ -240,7 +288,7 @@ class AbsDatabaseWeb extends WebPlugin {
serverSyncAttempted: true, serverSyncAttempted: true,
serverSyncSuccess: true, serverSyncSuccess: true,
serverSyncMessage: null, serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 22) + 13000 // 22 mins ago + 13s timestamp: Date.now() - 1000 * 60 * 22 + 13000 // 22 mins ago + 13s
}, },
{ {
name: 'Play', name: 'Play',
@@ -250,7 +298,7 @@ class AbsDatabaseWeb extends WebPlugin {
serverSyncAttempted: false, serverSyncAttempted: false,
serverSyncSuccess: null, serverSyncSuccess: null,
serverSyncMessage: null, serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 22) // 22 mins ago timestamp: Date.now() - 1000 * 60 * 22 // 22 mins ago
}, },
{ {
name: 'Pause', name: 'Pause',
@@ -260,7 +308,7 @@ class AbsDatabaseWeb extends WebPlugin {
serverSyncAttempted: true, serverSyncAttempted: true,
serverSyncSuccess: false, serverSyncSuccess: false,
serverSyncMessage: null, serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60) + (58000) // 1 hour ago + 58s timestamp: Date.now() - 1000 * 60 * 60 + 58000 // 1 hour ago + 58s
}, },
{ {
name: 'Save', name: 'Save',
@@ -270,7 +318,7 @@ class AbsDatabaseWeb extends WebPlugin {
serverSyncAttempted: true, serverSyncAttempted: true,
serverSyncSuccess: true, serverSyncSuccess: true,
serverSyncMessage: null, serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60) + (45000) // 1 hour ago + 45s timestamp: Date.now() - 1000 * 60 * 60 + 45000 // 1 hour ago + 45s
}, },
{ {
name: 'Save', name: 'Save',
@@ -280,7 +328,7 @@ class AbsDatabaseWeb extends WebPlugin {
serverSyncAttempted: true, serverSyncAttempted: true,
serverSyncSuccess: true, serverSyncSuccess: true,
serverSyncMessage: null, serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60) + (30000) // 1 hour ago + 30s timestamp: Date.now() - 1000 * 60 * 60 + 30000 // 1 hour ago + 30s
}, },
{ {
name: 'Save', name: 'Save',
@@ -290,7 +338,7 @@ class AbsDatabaseWeb extends WebPlugin {
serverSyncAttempted: true, serverSyncAttempted: true,
serverSyncSuccess: true, serverSyncSuccess: true,
serverSyncMessage: null, serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60) + (15000) // 1 hour ago + 15s timestamp: Date.now() - 1000 * 60 * 60 + 15000 // 1 hour ago + 15s
}, },
{ {
name: 'Play', name: 'Play',
@@ -300,7 +348,7 @@ class AbsDatabaseWeb extends WebPlugin {
serverSyncAttempted: false, serverSyncAttempted: false,
serverSyncSuccess: null, serverSyncSuccess: null,
serverSyncMessage: null, serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60) // 1 hour ago timestamp: Date.now() - 1000 * 60 * 60 // 1 hour ago
}, },
{ {
name: 'Stop', name: 'Stop',
@@ -310,7 +358,7 @@ class AbsDatabaseWeb extends WebPlugin {
serverSyncAttempted: true, serverSyncAttempted: true,
serverSyncSuccess: true, serverSyncSuccess: true,
serverSyncMessage: null, serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60 * 25) + 10000 // 25 hours ago + 10s timestamp: Date.now() - 1000 * 60 * 60 * 25 + 10000 // 25 hours ago + 10s
}, },
{ {
name: 'Seek', name: 'Seek',
@@ -320,7 +368,7 @@ class AbsDatabaseWeb extends WebPlugin {
serverSyncAttempted: true, serverSyncAttempted: true,
serverSyncSuccess: true, serverSyncSuccess: true,
serverSyncMessage: null, serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60 * 25) + 2000 // 25 hours ago + 2s timestamp: Date.now() - 1000 * 60 * 60 * 25 + 2000 // 25 hours ago + 2s
}, },
{ {
name: 'Play', name: 'Play',
@@ -330,7 +378,7 @@ class AbsDatabaseWeb extends WebPlugin {
serverSyncAttempted: false, serverSyncAttempted: false,
serverSyncSuccess: null, serverSyncSuccess: null,
serverSyncMessage: null, serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60 * 25) // 25 hours ago timestamp: Date.now() - 1000 * 60 * 60 * 25 // 25 hours ago
}, },
{ {
name: 'Play', name: 'Play',
@@ -340,7 +388,7 @@ class AbsDatabaseWeb extends WebPlugin {
serverSyncAttempted: false, serverSyncAttempted: false,
serverSyncSuccess: null, serverSyncSuccess: null,
serverSyncMessage: null, serverSyncMessage: null,
timestamp: Date.now() - (1000 * 60 * 60 * 50) // 50 hours ago timestamp: Date.now() - 1000 * 60 * 60 * 50 // 50 hours ago
} }
] ]
} }
@@ -351,4 +399,4 @@ const AbsDatabase = registerPlugin('AbsDatabase', {
web: () => new AbsDatabaseWeb() web: () => new AbsDatabaseWeb()
}) })
export { AbsDatabase } export { AbsDatabase }
+2 -1
View File
@@ -2,9 +2,10 @@ import Vue from 'vue'
import { AbsAudioPlayer } from './AbsAudioPlayer' import { AbsAudioPlayer } from './AbsAudioPlayer'
import { AbsDownloader } from './AbsDownloader' import { AbsDownloader } from './AbsDownloader'
import { AbsFileSystem } from './AbsFileSystem' import { AbsFileSystem } from './AbsFileSystem'
import { AbsDatabase } from './AbsDatabase'
import { AbsLogger } from './AbsLogger' import { AbsLogger } from './AbsLogger'
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
Vue.prototype.$platform = Capacitor.getPlatform() Vue.prototype.$platform = Capacitor.getPlatform()
export { AbsAudioPlayer, AbsDownloader, AbsFileSystem, AbsLogger } export { AbsAudioPlayer, AbsDownloader, AbsFileSystem, AbsLogger, AbsDatabase }
+44 -6
View File
@@ -1,7 +1,7 @@
import { AbsDatabase } from './capacitor/AbsDatabase' import { AbsDatabase } from './capacitor/AbsDatabase'
class DbService { class DbService {
constructor() { } constructor() {}
getDeviceData() { getDeviceData() {
return AbsDatabase.getDeviceData().then((data) => { return AbsDatabase.getDeviceData().then((data) => {
@@ -10,6 +10,26 @@ class DbService {
}) })
} }
/**
* Retrieves refresh token from secure storage
* @param {string} serverConnectionConfigId
* @return {Promise<string|null>}
*/
async getRefreshToken(serverConnectionConfigId) {
const refreshTokenData = await AbsDatabase.getRefreshToken({ serverConnectionConfigId })
return refreshTokenData?.refreshToken
}
/**
* Clears refresh token from secure storage
* @param {string} serverConnectionConfigId
* @returns {Promise<boolean>}
*/
async clearRefreshToken(serverConnectionConfigId) {
const result = await AbsDatabase.clearRefreshToken({ serverConnectionConfigId })
return !!result?.success
}
setServerConnectionConfig(serverConnectionConfig) { setServerConnectionConfig(serverConnectionConfig) {
return AbsDatabase.setCurrentServerConnectionConfig(serverConnectionConfig).then((data) => { return AbsDatabase.setCurrentServerConnectionConfig(serverConnectionConfig).then((data) => {
console.log('Set server connection config', JSON.stringify(data)) console.log('Set server connection config', JSON.stringify(data))
@@ -29,10 +49,12 @@ class DbService {
} }
getLocalFolders() { getLocalFolders() {
return AbsDatabase.getLocalFolders().then((data) => data.value).catch((error) => { return AbsDatabase.getLocalFolders()
console.error('Failed to load', error) .then((data) => data.value)
return null .catch((error) => {
}) console.error('Failed to load', error)
return null
})
} }
getLocalFolder(folderId) { getLocalFolder(folderId) {
@@ -103,4 +125,20 @@ class DbService {
export default ({ app, store }, inject) => { export default ({ app, store }, inject) => {
inject('db', new DbService()) inject('db', new DbService())
}
// Listen for token refresh events from native app
AbsDatabase.addListener('onTokenRefresh', (data) => {
console.log('[db] onTokenRefresh', data)
store.commit('user/setAccessToken', data.accessToken)
})
// Listen for token refresh failure events from native app
AbsDatabase.addListener('onTokenRefreshFailure', async (data) => {
console.log('[db] onTokenRefreshFailure', data)
// Clear store and redirect to login page
await store.dispatch('user/logout')
if (window.location.pathname !== '/connect') {
window.location.href = '/connect'
}
})
}
+25
View File
@@ -245,10 +245,35 @@ Vue.prototype.$sanitizeSlug = (str) => {
return str return str
} }
/**
* Compares two semantic versioning strings to determine if the current version meets
* or exceeds the minimum version requirement.
* Only supports 3 part versions, e.g. "1.2.3"
*
* @param {string} currentVersion - The current version string to compare, e.g., "1.2.3".
* @param {string} minVersion - The minimum version string required, e.g., "1.0.0".
* @returns {boolean} - Returns true if the current version is greater than or equal
* to the minimum version, false otherwise.
*/
function isValidVersion(currentVersion, minVersion) {
if (!currentVersion || !minVersion) return false
const currentParts = currentVersion.split('.').map(Number)
const minParts = minVersion.split('.').map(Number)
for (let i = 0; i < minParts.length; i++) {
if (currentParts[i] > minParts[i]) return true
if (currentParts[i] < minParts[i]) return false
}
return true
}
export default ({ store, app }, inject) => { export default ({ store, app }, inject) => {
const eventBus = new Vue() const eventBus = new Vue()
inject('eventBus', eventBus) inject('eventBus', eventBus)
inject('isValidVersion', isValidVersion)
// Set theme // Set theme
app.$localStore?.getTheme()?.then((theme) => { app.$localStore?.getTheme()?.then((theme) => {
if (theme) { if (theme) {
+201 -8
View File
@@ -1,10 +1,14 @@
import { CapacitorHttp } from '@capacitor/core' import { CapacitorHttp } from '@capacitor/core'
export default function ({ store }, inject) { export default function ({ store, $db, $socket }, inject) {
const nativeHttp = { const nativeHttp = {
request(method, _url, data, options = {}) { async request(method, _url, data, options = {}) {
// When authorizing before a config is set, server config gets passed in as an option
let serverConnectionConfig = options.serverConnectionConfig || store.state.user.serverConnectionConfig
delete options.serverConnectionConfig
let url = _url let url = _url
const headers = {} let headers = {}
if (!url.startsWith('http') && !url.startsWith('capacitor')) { if (!url.startsWith('http') && !url.startsWith('capacitor')) {
const bearerToken = store.getters['user/getToken'] const bearerToken = store.getters['user/getToken']
if (bearerToken) { if (bearerToken) {
@@ -12,22 +16,31 @@ export default function ({ store }, inject) {
} else { } else {
console.warn('[nativeHttp] No Bearer Token for request') console.warn('[nativeHttp] No Bearer Token for request')
} }
const serverUrl = store.getters['user/getServerAddress'] if (serverConnectionConfig?.address) {
if (serverUrl) { url = `${serverConnectionConfig.address}${url}`
url = `${serverUrl}${url}`
} }
} }
if (data) { if (data) {
headers['Content-Type'] = 'application/json' headers['Content-Type'] = 'application/json'
} }
if (options.headers) {
headers = { ...headers, ...options.headers }
delete options.headers
}
console.log(`[nativeHttp] Making ${method} request to ${url}`) console.log(`[nativeHttp] Making ${method} request to ${url}`)
return CapacitorHttp.request({ return CapacitorHttp.request({
method, method,
url, url,
data, data,
headers, headers,
...options ...options
}).then(res => { }).then((res) => {
if (res.status === 401) {
console.error(`[nativeHttp] 401 status for url "${url}"`)
// Handle refresh token automatically
return this.handleTokenRefresh(method, url, data, headers, options, serverConnectionConfig)
}
if (res.status >= 400) { if (res.status >= 400) {
console.error(`[nativeHttp] ${res.status} status for url "${url}"`) console.error(`[nativeHttp] ${res.status} status for url "${url}"`)
throw new Error(res.data) throw new Error(res.data)
@@ -35,6 +48,186 @@ export default function ({ store }, inject) {
return res.data return res.data
}) })
}, },
/**
* Handles token refresh when a 401 Unauthorized response is received
* @param {string} method - HTTP method
* @param {string} url - Full URL
* @param {*} data - Request data
* @param {Object} headers - Request headers
* @param {Object} options - Additional options
* @param {{ id: string, address: string, version: string }} serverConnectionConfig
* @returns {Promise} - Promise that resolves with the response data
*/
async handleTokenRefresh(method, url, data, headers, options, serverConnectionConfig) {
try {
console.log('[nativeHttp] Attempting to refresh token...')
if (!serverConnectionConfig?.id) {
console.error('[nativeHttp] No server connection config ID available for token refresh')
throw new Error('No server connection available')
}
// Get refresh token from secure storage
const refreshToken = await $db.getRefreshToken(serverConnectionConfig.id)
if (!refreshToken) {
console.error('[nativeHttp] No refresh token available')
throw new Error('No refresh token available')
}
// Attempt to refresh the token
const newTokens = await this.refreshAccessToken(refreshToken, serverConnectionConfig.address)
if (!newTokens?.accessToken) {
console.error('[nativeHttp] Failed to refresh access token')
throw new Error('Failed to refresh access token')
}
// Update the store with new tokens
await this.updateTokens(newTokens, serverConnectionConfig)
// Retry the original request with the new token
console.log('[nativeHttp] Retrying original request with new token...')
const retryResponse = await CapacitorHttp.request({
method,
url,
data,
headers: {
...headers,
Authorization: `Bearer ${newTokens.accessToken}`
},
...options
})
if (retryResponse.status >= 400) {
console.error(`[nativeHttp] Retry request failed with status ${retryResponse.status}`)
throw new Error(retryResponse.data)
}
return retryResponse.data
} catch (error) {
console.error('[nativeHttp] Token refresh failed:', error)
// If refresh fails, redirect to login
await this.handleRefreshFailure(serverConnectionConfig?.id)
throw error
}
},
/**
* Refreshes the access token using the refresh token
* @param {string} refreshToken - The refresh token
* @param {string} serverAddress - The server address
* @returns {Promise<Object|null>} - Promise that resolves with new tokens or null
*/
async refreshAccessToken(refreshToken, serverAddress) {
try {
if (!serverAddress) {
throw new Error('No server address available')
}
console.log('[nativeHttp] Refreshing access token...')
const response = await CapacitorHttp.post({
url: `${serverAddress}/auth/refresh`,
headers: {
'Content-Type': 'application/json',
'x-refresh-token': refreshToken
},
data: {}
})
if (response.status !== 200) {
console.error('[nativeHttp] Token refresh request failed:', response.status)
return null
}
const userResponseData = response.data
if (!userResponseData.user?.accessToken) {
console.error('[nativeHttp] No access token in refresh response')
return null
}
console.log('[nativeHttp] Successfully refreshed access token')
return {
accessToken: userResponseData.user.accessToken,
// Refresh token gets returned when refresh token is sent in x-refresh-token header
refreshToken: userResponseData.user.refreshToken
}
} catch (error) {
console.error('[nativeHttp] Failed to refresh access token:', error)
return null
}
},
/**
* Updates the store and secure storage with new tokens
* @param {Object} tokens - Object containing accessToken and refreshToken
* @param {{ id: string, address: string, version: string }} serverConnectionConfig
* @returns {Promise} - Promise that resolves when tokens are updated
*/
async updateTokens(tokens, serverConnectionConfig) {
try {
if (!serverConnectionConfig?.id) {
throw new Error('No server connection config ID available')
}
// Update the config with new tokens
const updatedConfig = {
...serverConnectionConfig,
token: tokens.accessToken,
refreshToken: tokens.refreshToken
}
// Save updated config to secure storage, persists refresh token in secure storage
const savedConfig = await $db.setServerConnectionConfig(updatedConfig)
// Update the store
store.commit('user/setAccessToken', tokens.accessToken)
// Re-authenticate socket if necessary
if ($socket?.connected && !$socket.isAuthenticated) {
$socket.sendAuthenticate()
} else if (!$socket) {
console.warn('[nativeHttp] Socket not available, cannot re-authenticate')
}
if (savedConfig) {
store.commit('user/setServerConnectionConfig', savedConfig)
}
console.log('[nativeHttp] Successfully updated tokens in store and secure storage')
} catch (error) {
console.error('[nativeHttp] Failed to update tokens:', error)
throw error
}
},
/**
* Handles the case when token refresh fails
* @param {string} [serverConnectionConfigId]
* @returns {Promise} - Promise that resolves when logout is complete
*/
async handleRefreshFailure(serverConnectionConfigId) {
try {
console.log('[nativeHttp] Handling refresh failure - logging out user')
// Clear store
await store.dispatch('user/logout')
if (serverConnectionConfigId) {
// Clear refresh token for server connection config
await $db.clearRefreshToken(serverConnectionConfigId)
}
// Redirect to login page
if (window.location.pathname !== '/connect') {
window.location.href = '/connect'
}
} catch (error) {
console.error('[nativeHttp] Failed to handle refresh failure:', error)
}
},
get(url, options = {}) { get(url, options = {}) {
return this.request('GET', url, undefined, options) return this.request('GET', url, undefined, options)
}, },
@@ -49,4 +242,4 @@ export default function ({ store }, inject) {
} }
} }
inject('nativeHttp', nativeHttp) inject('nativeHttp', nativeHttp)
} }
+14 -3
View File
@@ -9,7 +9,7 @@ class ServerSocket extends EventEmitter {
this.socket = null this.socket = null
this.connected = false this.connected = false
this.serverAddress = null this.serverAddress = null
this.token = null this.isAuthenticated = false
this.lastReconnectAttemptTime = 0 this.lastReconnectAttemptTime = 0
} }
@@ -26,7 +26,6 @@ class ServerSocket extends EventEmitter {
connect(serverAddress, token) { connect(serverAddress, token) {
this.serverAddress = serverAddress this.serverAddress = serverAddress
this.token = token
const serverUrl = new URL(serverAddress) const serverUrl = new URL(serverAddress)
const serverHost = `${serverUrl.protocol}//${serverUrl.host}` const serverHost = `${serverUrl.protocol}//${serverUrl.host}`
@@ -53,6 +52,7 @@ class ServerSocket extends EventEmitter {
this.socket.on('connect', this.onConnect.bind(this)) this.socket.on('connect', this.onConnect.bind(this))
this.socket.on('disconnect', this.onDisconnect.bind(this)) this.socket.on('disconnect', this.onDisconnect.bind(this))
this.socket.on('init', this.onInit.bind(this)) this.socket.on('init', this.onInit.bind(this))
this.socket.on('auth_failed', this.onAuthFailed.bind(this))
this.socket.on('user_updated', this.onUserUpdated.bind(this)) this.socket.on('user_updated', this.onUserUpdated.bind(this))
this.socket.on('user_item_progress_updated', this.onUserItemProgressUpdated.bind(this)) this.socket.on('user_item_progress_updated', this.onUserItemProgressUpdated.bind(this))
this.socket.on('playlist_added', this.onPlaylistAdded.bind(this)) this.socket.on('playlist_added', this.onPlaylistAdded.bind(this))
@@ -61,6 +61,11 @@ class ServerSocket extends EventEmitter {
this.socket.io.on('reconnect_failed', this.onReconnectFailed.bind(this)) this.socket.io.on('reconnect_failed', this.onReconnectFailed.bind(this))
} }
sendAuthenticate() {
// Required to connect a socket to a user
this.socket.emit('auth', this.$store.getters['user/getToken'])
}
removeListeners() { removeListeners() {
if (!this.socket) return if (!this.socket) return
this.socket.removeAllListeners() this.socket.removeAllListeners()
@@ -74,7 +79,7 @@ class ServerSocket extends EventEmitter {
this.connected = true this.connected = true
this.$store.commit('setSocketConnected', true) this.$store.commit('setSocketConnected', true)
this.emit('connection-update', true) this.emit('connection-update', true)
this.socket.emit('auth', this.token) // Required to connect a user with their socket this.sendAuthenticate()
} }
onReconnectAttempt(attemptNumber) { onReconnectAttempt(attemptNumber) {
@@ -101,6 +106,12 @@ class ServerSocket extends EventEmitter {
onInit(data) { onInit(data) {
console.log('[SOCKET] Initial socket data received', data) console.log('[SOCKET] Initial socket data received', data)
this.emit('initialized', true) this.emit('initialized', true)
this.isAuthenticated = true
}
onAuthFailed(data) {
console.log('[SOCKET] Auth failed', data)
this.isAuthenticated = false
} }
onUserUpdated(data) { onUserUpdated(data) {
+107 -16
View File
@@ -1,7 +1,10 @@
import { Browser } from '@capacitor/browser' import { Browser } from '@capacitor/browser'
import { AbsLogger } from '@/plugins/capacitor'
import { CapacitorHttp } from '@capacitor/core'
export const state = () => ({ export const state = () => ({
user: null, user: null,
accessToken: null,
serverConnectionConfig: null, serverConnectionConfig: null,
settings: { settings: {
mobileOrderBy: 'addedAt', mobileOrderBy: 'addedAt',
@@ -17,7 +20,7 @@ export const getters = {
getIsRoot: (state) => state.user && state.user.type === 'root', getIsRoot: (state) => state.user && state.user.type === 'root',
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'), getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
getToken: (state) => { getToken: (state) => {
return state.user?.token || null return state.accessToken || null
}, },
getServerConnectionConfigId: (state) => { getServerConnectionConfigId: (state) => {
return state.serverConnectionConfig?.id || null return state.serverConnectionConfig?.id || null
@@ -28,19 +31,18 @@ export const getters = {
getServerConfigName: (state) => { getServerConfigName: (state) => {
return state.serverConnectionConfig?.name || null return state.serverConnectionConfig?.name || null
}, },
getCustomHeaders: (state) => { getUserMediaProgress:
return state.serverConnectionConfig?.customHeaders || null (state) =>
}, (libraryItemId, episodeId = null) => {
getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => { if (!state.user?.mediaProgress) return null
if (!state.user?.mediaProgress) return null return state.user.mediaProgress.find((li) => {
return state.user.mediaProgress.find(li => { if (episodeId && li.episodeId !== episodeId) return false
if (episodeId && li.episodeId !== episodeId) return false return li.libraryItemId == libraryItemId
return li.libraryItemId == libraryItemId })
}) },
},
getUserBookmarksForItem: (state) => (libraryItemId) => { getUserBookmarksForItem: (state) => (libraryItemId) => {
if (!state?.user?.bookmarks) return [] if (!state?.user?.bookmarks) return []
return state.user.bookmarks.filter(bm => bm.libraryItemId === libraryItemId) return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)
}, },
getUserSetting: (state) => (key) => { getUserSetting: (state) => (key) => {
return state.settings?.[key] || null return state.settings?.[key] || null
@@ -53,6 +55,9 @@ export const getters = {
}, },
getUserCanDownload: (state) => { getUserCanDownload: (state) => {
return !!state.user?.permissions?.download return !!state.user?.permissions?.download
},
getUserCanAccessExplicitContent: (state) => {
return !!state.user?.permissions?.accessExplicitContent
} }
} }
@@ -132,24 +137,110 @@ export const actions = {
} catch (error) { } catch (error) {
console.error('Error opening browser', error) console.error('Error opening browser', error)
} }
},
async logout({ state, commit }, logoutFromServer = false) {
// Logging out from server deletes the session so the refresh token is no longer valid
// Currently this is not being used to support switching servers without logging back in (assuming refresh token is still valid)
// We may want to make this change in the future
if (state.serverConnectionConfig && logoutFromServer) {
const refreshToken = await this.$db.getRefreshToken(state.serverConnectionConfig.id)
const options = {}
if (refreshToken) {
// Refresh token is used to delete the session on the server
options.headers = {
'x-refresh-token': refreshToken
}
}
// Logout from server
await this.$nativeHttp.post('/logout', null, options).catch((error) => {
console.error('Failed to logout', error)
})
}
await this.$db.logout()
this.$socket.logout()
this.$localStore.removeLastLibraryId()
commit('logout')
commit('libraries/setCurrentLibrary', null, { root: true })
await AbsLogger.info({ tag: 'user', message: `Logged out from server ${state.serverConnectionConfig?.name || 'Not connected'}` })
},
async refreshToken({ getters, commit, state }) {
const refreshToken = await this.$db.getRefreshToken(getters.getServerConnectionConfigId)
if (!refreshToken) {
console.error('No refresh token found')
return null
}
const serverAddress = getters.getServerAddress
const response = await CapacitorHttp.post({
url: `${serverAddress}/auth/refresh`,
headers: {
'Content-Type': 'application/json',
'x-refresh-token': refreshToken
},
data: {}
})
if (response.status !== 200) {
console.error('[user] Token refresh request failed:', response.status)
return null
}
const userResponseData = response.data
if (!userResponseData.user?.accessToken) {
console.error('[user] No access token in refresh response')
return null
}
// Update the config with new tokens
const updatedConfig = {
...state.serverConnectionConfig,
token: userResponseData.user.accessToken,
refreshToken: userResponseData.user.refreshToken
}
// Save updated config to secure storage, persists refresh token in secure storage
const savedConfig = await this.$db.setServerConnectionConfig(updatedConfig)
// Update the store
commit('setAccessToken', userResponseData.user.accessToken)
// Re-authenticate socket if necessary
if (this.$socket?.connected && !this.$socket.isAuthenticated) {
this.$socket.sendAuthenticate()
} else if (!this.$socket) {
console.warn('[user] Socket not available, cannot re-authenticate')
}
if (savedConfig) {
commit('setServerConnectionConfig', savedConfig)
}
return userResponseData.user.accessToken
} }
} }
export const mutations = { export const mutations = {
logout(state) { logout(state) {
state.user = null state.user = null
state.accessToken = null
state.serverConnectionConfig = null state.serverConnectionConfig = null
}, },
setUser(state, user) { setUser(state, user) {
state.user = user state.user = user
}, },
setAccessToken(state, accessToken) {
console.log('[user] setAccessToken', accessToken)
state.accessToken = accessToken
},
removeMediaProgress(state, id) { removeMediaProgress(state, id) {
if (!state.user) return if (!state.user) return
state.user.mediaProgress = state.user.mediaProgress.filter(mp => mp.id != id) state.user.mediaProgress = state.user.mediaProgress.filter((mp) => mp.id != id)
}, },
updateUserMediaProgress(state, data) { updateUserMediaProgress(state, data) {
if (!data || !state.user) return if (!data || !state.user) return
const mediaProgressIndex = state.user.mediaProgress.findIndex(mp => mp.id === data.id) const mediaProgressIndex = state.user.mediaProgress.findIndex((mp) => mp.id === data.id)
if (mediaProgressIndex >= 0) { if (mediaProgressIndex >= 0) {
state.user.mediaProgress.splice(mediaProgressIndex, 1, data) state.user.mediaProgress.splice(mediaProgressIndex, 1, data)
} else { } else {
@@ -174,9 +265,9 @@ export const mutations = {
}, },
deleteBookmark(state, { libraryItemId, time }) { deleteBookmark(state, { libraryItemId, time }) {
if (!state.user?.bookmarks) return if (!state.user?.bookmarks) return
state.user.bookmarks = state.user.bookmarks.filter(bm => { state.user.bookmarks = state.user.bookmarks.filter((bm) => {
if (bm.libraryItemId === libraryItemId && bm.time === time) return false if (bm.libraryItemId === libraryItemId && bm.time === time) return false
return true return true
}) })
} }
} }
+1
View File
@@ -146,6 +146,7 @@
"LabelEndOfChapter": "Konec kapitoly", "LabelEndOfChapter": "Konec kapitoly",
"LabelEndTime": "Do", "LabelEndTime": "Do",
"LabelEpisode": "Epizoda", "LabelEpisode": "Epizoda",
"LabelExplicit": "Explicitně",
"LabelFeedURL": "URL kanálu", "LabelFeedURL": "URL kanálu",
"LabelFile": "Soubor", "LabelFile": "Soubor",
"LabelFileBirthtime": "Čas vytvoření souboru", "LabelFileBirthtime": "Čas vytvoření souboru",
+7
View File
@@ -6,6 +6,7 @@
"ButtonCancel": "Annuller", "ButtonCancel": "Annuller",
"ButtonCancelTimer": "Stop timer", "ButtonCancelTimer": "Stop timer",
"ButtonClearFilter": "Ryd filter", "ButtonClearFilter": "Ryd filter",
"ButtonClearLogs": "Rens Logfiler",
"ButtonCloseFeed": "Luk feed", "ButtonCloseFeed": "Luk feed",
"ButtonCollections": "Samlinger", "ButtonCollections": "Samlinger",
"ButtonConnect": "Forbind", "ButtonConnect": "Forbind",
@@ -26,7 +27,9 @@
"ButtonLatest": "Seneste", "ButtonLatest": "Seneste",
"ButtonLibrary": "Bibliotek", "ButtonLibrary": "Bibliotek",
"ButtonLocalMedia": "Lokalt medie", "ButtonLocalMedia": "Lokalt medie",
"ButtonLogs": "Logfiler",
"ButtonManageLocalFiles": "Administrér Lokale Filer", "ButtonManageLocalFiles": "Administrér Lokale Filer",
"ButtonMaskServerAddress": "Maskér server adresse",
"ButtonNewFolder": "Ny Folder", "ButtonNewFolder": "Ny Folder",
"ButtonNextEpisode": "Næste Afsnit", "ButtonNextEpisode": "Næste Afsnit",
"ButtonOk": "Ok", "ButtonOk": "Ok",
@@ -50,6 +53,7 @@
"ButtonStream": "Stream", "ButtonStream": "Stream",
"ButtonSubmit": "Send", "ButtonSubmit": "Send",
"ButtonSwitchServerUser": "Skift Server/Bruger", "ButtonSwitchServerUser": "Skift Server/Bruger",
"ButtonUnmaskServerAddress": "Afmaskér server adresse",
"ButtonUserStats": "Bruger statitstik", "ButtonUserStats": "Bruger statitstik",
"ButtonYes": "Ja", "ButtonYes": "Ja",
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
@@ -238,6 +242,8 @@
"LabelShowAll": "Vis alle", "LabelShowAll": "Vis alle",
"LabelSize": "Størrelse", "LabelSize": "Størrelse",
"LabelSleepTimer": "Søvntimer", "LabelSleepTimer": "Søvntimer",
"LabelSleepTimerAlmostDoneChime": "Afspil lyd når snart færdig",
"LabelSleepTimerAlmostDoneChimeHelp": "Afspil lyd når sove timeren har 30 sekunder tilbage",
"LabelStart": "Start", "LabelStart": "Start",
"LabelStartTime": "Starttid", "LabelStartTime": "Starttid",
"LabelStatsBestDay": "Bedste dag", "LabelStatsBestDay": "Bedste dag",
@@ -311,6 +317,7 @@
"MessageNoItems": "Ingen elementer", "MessageNoItems": "Ingen elementer",
"MessageNoItemsFound": "Ingen elementer fundet", "MessageNoItemsFound": "Ingen elementer fundet",
"MessageNoListeningSessions": "Ingen lyttesessioner", "MessageNoListeningSessions": "Ingen lyttesessioner",
"MessageNoLogs": "Ingen logfiler",
"MessageNoMediaFolders": "Ingen Mediemapper", "MessageNoMediaFolders": "Ingen Mediemapper",
"MessageNoNetworkConnection": "Ingen Internetforbindelse", "MessageNoNetworkConnection": "Ingen Internetforbindelse",
"MessageNoPodcastsFound": "Ingen podcasts fundet", "MessageNoPodcastsFound": "Ingen podcasts fundet",
+5 -2
View File
@@ -32,7 +32,7 @@
"ButtonMaskServerAddress": "Server Adresse verstecken", "ButtonMaskServerAddress": "Server Adresse verstecken",
"ButtonNewFolder": "Neuer Ordner", "ButtonNewFolder": "Neuer Ordner",
"ButtonNextEpisode": "Nächste Episode", "ButtonNextEpisode": "Nächste Episode",
"ButtonOk": "Einverstanden", "ButtonOk": "Ok",
"ButtonOpenFeed": "Feed öffnen", "ButtonOpenFeed": "Feed öffnen",
"ButtonOverride": "Überschreiben", "ButtonOverride": "Überschreiben",
"ButtonPause": "Pausieren", "ButtonPause": "Pausieren",
@@ -146,6 +146,7 @@
"LabelEndOfChapter": "Ende des Kapitels", "LabelEndOfChapter": "Ende des Kapitels",
"LabelEndTime": "Endzeit", "LabelEndTime": "Endzeit",
"LabelEpisode": "Episode", "LabelEpisode": "Episode",
"LabelExplicit": "Explizit",
"LabelFeedURL": "Feed-URL", "LabelFeedURL": "Feed-URL",
"LabelFile": "Datei", "LabelFile": "Datei",
"LabelFileBirthtime": "Datei erstellt", "LabelFileBirthtime": "Datei erstellt",
@@ -220,7 +221,7 @@
"LabelPublishedDate": "Veröffentlicht {0}", "LabelPublishedDate": "Veröffentlicht {0}",
"LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail", "LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail",
"LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers", "LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers",
"LabelRSSFeedOpen": "RSS Feed öffnen", "LabelRSSFeedOpen": "RSS Feed offen",
"LabelRSSFeedPreventIndexing": "Indizierung verhindern", "LabelRSSFeedPreventIndexing": "Indizierung verhindern",
"LabelRSSFeedSlug": "RSS-Feed-Schlagwort", "LabelRSSFeedSlug": "RSS-Feed-Schlagwort",
"LabelRandomly": "Zufällig", "LabelRandomly": "Zufällig",
@@ -324,6 +325,8 @@
"MessageNoSeries": "Keine Serie", "MessageNoSeries": "Keine Serie",
"MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig", "MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig",
"MessageNoUserPlaylists": "Keine Wiedergabelisten vorhanden", "MessageNoUserPlaylists": "Keine Wiedergabelisten vorhanden",
"MessageOldServerAuthReLoginRequired": "Für die verbesserung der Sicherheit wurde die Authentifizierung in der Version v2.26.0 verbessert. Alle Nutzer müssen sich neu einloggen.",
"MessageOldServerAuthWarning": "Der Server nutzt eine nicht mehr aktuelle Authentifizierung",
"MessageOldServerConnectionWarning": "Diese Serververbindung nutzt eine alte Nutzer-ID (user ID). Bitte entferne diese Serververbindung und füge sie wieder neu hinzu.", "MessageOldServerConnectionWarning": "Diese Serververbindung nutzt eine alte Nutzer-ID (user ID). Bitte entferne diese Serververbindung und füge sie wieder neu hinzu.",
"MessageOldServerConnectionWarningHelp": "Du hast diese Serververbindung vor der Datenbankmigration in 2.3.0, veröffentlicht im Juni 2023, eingerichtet. Ein zukünftiges Serverupdate wird die Möglichkeit entfernen, sich mit dieser Verbindung anzumelden. Bitte lösche die existierende Serververbindung und melde dich neu an (mit der gleichen Serveradresse und Zugangsdaten). Wenn du irgendwelche Medien auf dieses Gerät heruntergeladen hast, musst du sie anschließend erneut herunterladen, um den Status zu synchronisieren.", "MessageOldServerConnectionWarningHelp": "Du hast diese Serververbindung vor der Datenbankmigration in 2.3.0, veröffentlicht im Juni 2023, eingerichtet. Ein zukünftiges Serverupdate wird die Möglichkeit entfernen, sich mit dieser Verbindung anzumelden. Bitte lösche die existierende Serververbindung und melde dich neu an (mit der gleichen Serveradresse und Zugangsdaten). Wenn du irgendwelche Medien auf dieses Gerät heruntergeladen hast, musst du sie anschließend erneut herunterladen, um den Status zu synchronisieren.",
"MessagePodcastSearchField": "Suchbegriff oder RSS-Feed URL eingeben", "MessagePodcastSearchField": "Suchbegriff oder RSS-Feed URL eingeben",
+4
View File
@@ -146,6 +146,7 @@
"LabelEndOfChapter": "End of Chapter", "LabelEndOfChapter": "End of Chapter",
"LabelEndTime": "End time", "LabelEndTime": "End time",
"LabelEpisode": "Episode", "LabelEpisode": "Episode",
"LabelExplicit": "Explicit",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFile": "File", "LabelFile": "File",
"LabelFileBirthtime": "File Birthtime", "LabelFileBirthtime": "File Birthtime",
@@ -324,6 +325,9 @@
"MessageNoSeries": "No series", "MessageNoSeries": "No series",
"MessageNoUpdatesWereNecessary": "No updates were necessary", "MessageNoUpdatesWereNecessary": "No updates were necessary",
"MessageNoUserPlaylists": "You have no playlists", "MessageNoUserPlaylists": "You have no playlists",
"MessageOldServerAuthReLoginRequired": "Authentication has been improved for security in server v2.26.0. All users are required to re-login.",
"MessageOldServerAuthWarning": "Server is using out-dated authentication",
"MessageOldServerAuthWarningHelp": "This server is running a version older than v2.26.0. A more secure authentication system was added in v2.26.0. It is strongly recommended to update the server to the latest version. If you have already updated the server, log in again to use the new authentication.",
"MessageOldServerConnectionWarning": "Server connection config is using an old user ID. Please delete and re-add this server connection.", "MessageOldServerConnectionWarning": "Server connection config is using an old user ID. Please delete and re-add this server connection.",
"MessageOldServerConnectionWarningHelp": "You originally set up the connection to this server prior to the database migration in 2.3.0, released June 2023. A future server update will remove the ability to sign in with this old connection. Please delete the existing server connection and connect again (using the same server address and credentials). If you have any downloaded media on this device, the media will need to be downloaded again to sync with the server.", "MessageOldServerConnectionWarningHelp": "You originally set up the connection to this server prior to the database migration in 2.3.0, released June 2023. A future server update will remove the ability to sign in with this old connection. Please delete the existing server connection and connect again (using the same server address and credentials). If you have any downloaded media on this device, the media will need to be downloaded again to sync with the server.",
"MessagePodcastSearchField": "Enter search term or RSS feed URL", "MessagePodcastSearchField": "Enter search term or RSS feed URL",
+7
View File
@@ -1,8 +1,12 @@
{ {
"ButtonAdd": "ઉમેરો", "ButtonAdd": "ઉમેરો",
"ButtonAddNewServer": "નવી સર્વર ઉમેરો",
"ButtonAuthors": "લેખકો", "ButtonAuthors": "લેખકો",
"ButtonBack": "પાછા",
"ButtonCancel": "રદ કરો", "ButtonCancel": "રદ કરો",
"ButtonCancelTimer": "ટાઈમર રદ કરો",
"ButtonClearFilter": "ફિલ્ટર જતુ કરો", "ButtonClearFilter": "ફિલ્ટર જતુ કરો",
"ButtonClearLogs": "લોગ્સ સાફ કરો",
"ButtonCloseFeed": "ફીડ બંધ કરો", "ButtonCloseFeed": "ફીડ બંધ કરો",
"ButtonCollections": "સંગ્રહ", "ButtonCollections": "સંગ્રહ",
"ButtonCreate": "બનાવો", "ButtonCreate": "બનાવો",
@@ -12,9 +16,12 @@
"ButtonLatest": "નવીનતમ", "ButtonLatest": "નવીનતમ",
"ButtonLibrary": "પુસ્તકાલય", "ButtonLibrary": "પુસ્તકાલય",
"ButtonOpenFeed": "ફીડ ખોલો", "ButtonOpenFeed": "ફીડ ખોલો",
"ButtonPause": "વિરામ",
"ButtonPlay": "ચલાવો", "ButtonPlay": "ચલાવો",
"ButtonPlaylists": "પ્લેલિસ્ટ", "ButtonPlaylists": "પ્લેલિસ્ટ",
"ButtonRead": "વાંચો", "ButtonRead": "વાંચો",
"ButtonReadLess": "ઓછું વાંચો",
"ButtonReadMore": "વધારે વાંચો",
"ButtonRemove": "કાઢી નાખો", "ButtonRemove": "કાઢી નાખો",
"ButtonSave": "સાચવો", "ButtonSave": "સાચવો",
"ButtonSearch": "શોધો", "ButtonSearch": "શોધો",
+4
View File
@@ -146,6 +146,7 @@
"LabelEndOfChapter": "Kraj poglavlja", "LabelEndOfChapter": "Kraj poglavlja",
"LabelEndTime": "Vrijeme završetka", "LabelEndTime": "Vrijeme završetka",
"LabelEpisode": "Nastavak", "LabelEpisode": "Nastavak",
"LabelExplicit": "Eksplicitno",
"LabelFeedURL": "URL izvora", "LabelFeedURL": "URL izvora",
"LabelFile": "Datoteka", "LabelFile": "Datoteka",
"LabelFileBirthtime": "Datoteka stvorena", "LabelFileBirthtime": "Datoteka stvorena",
@@ -324,6 +325,9 @@
"MessageNoSeries": "Nema serijala", "MessageNoSeries": "Nema serijala",
"MessageNoUpdatesWereNecessary": "Ažuriranje nije bilo potrebno", "MessageNoUpdatesWereNecessary": "Ažuriranje nije bilo potrebno",
"MessageNoUserPlaylists": "Nemate popisa za izvođenje", "MessageNoUserPlaylists": "Nemate popisa za izvođenje",
"MessageOldServerAuthReLoginRequired": "Sigurnost provjere autentičnosti poboljšana je u inačici poslužitelja v2.26.0. Svi se korisnici moraju iznova prijaviti.",
"MessageOldServerAuthWarning": "Poslužitelj upotrebljava zastarjeli način provjere autentičnosti",
"MessageOldServerAuthWarningHelp": "Ovaj poslužitelj starija je inačica od v2.26.0. Od inačice v2.26.0 uveden je sigurniji način provjere autentičnosti. Izričito preporučamo nadogradnju poslužitelja na noviju inačicu. Ukoliko ste već nadogradili poslužitelj, iznova se prijavite kako biste upotrebljavali novi način provjere autentičnosti.",
"MessageOldServerConnectionWarning": "Veza s poslužiteljem koristi se starim ID-jem korisnika. Molimo izbrišite i ponovno dodajte ovu vezu s poslužiteljem.", "MessageOldServerConnectionWarning": "Veza s poslužiteljem koristi se starim ID-jem korisnika. Molimo izbrišite i ponovno dodajte ovu vezu s poslužiteljem.",
"MessageOldServerConnectionWarningHelp": "Vezu s ovim poslužiteljem postavili ste prije migracije baze podataka u inačici 2.3.0 objavljenoj u lipnju 2023. Nadolazeće ažuriranje poslužitelja onemogućit će prijavu korištenjem ove stare veze. Molimo izbrišite postojeću vezu s poslužiteljem i ponovno se povežite (korištenjem iste adrese poslužitelja i korisničkih podataka). Ukoliko imate preuzetih medijskih datoteka na ovom uređaju, morat ćete ih iznova preuzeti da bi se sinkronizirali s poslužiteljem.", "MessageOldServerConnectionWarningHelp": "Vezu s ovim poslužiteljem postavili ste prije migracije baze podataka u inačici 2.3.0 objavljenoj u lipnju 2023. Nadolazeće ažuriranje poslužitelja onemogućit će prijavu korištenjem ove stare veze. Molimo izbrišite postojeću vezu s poslužiteljem i ponovno se povežite (korištenjem iste adrese poslužitelja i korisničkih podataka). Ukoliko imate preuzetih medijskih datoteka na ovom uređaju, morat ćete ih iznova preuzeti da bi se sinkronizirali s poslužiteljem.",
"MessagePodcastSearchField": "Upišite izraz za pretraživanje ili URL RSS izvora", "MessagePodcastSearchField": "Upišite izraz za pretraživanje ili URL RSS izvora",
+1
View File
@@ -146,6 +146,7 @@
"LabelEndOfChapter": "Fejezet vége", "LabelEndOfChapter": "Fejezet vége",
"LabelEndTime": "Befejezés ideje", "LabelEndTime": "Befejezés ideje",
"LabelEpisode": "Epizód", "LabelEpisode": "Epizód",
"LabelExplicit": "Szókimondó",
"LabelFeedURL": "Hírcsatorna URL", "LabelFeedURL": "Hírcsatorna URL",
"LabelFile": "Fájl", "LabelFile": "Fájl",
"LabelFileBirthtime": "Fájl létrehozásának ideje", "LabelFileBirthtime": "Fájl létrehozásának ideje",
+6 -6
View File
@@ -6,12 +6,12 @@
"ButtonCancel": "Annuleren", "ButtonCancel": "Annuleren",
"ButtonCancelTimer": "Timer annuleren", "ButtonCancelTimer": "Timer annuleren",
"ButtonClearFilter": "Filter verwijderen", "ButtonClearFilter": "Filter verwijderen",
"ButtonClearLogs": "Log bestanden verwijderen", "ButtonClearLogs": "Logbestanden verwijderen",
"ButtonCloseFeed": "Feed sluiten", "ButtonCloseFeed": "Feed sluiten",
"ButtonCollections": "Collecties", "ButtonCollections": "Collecties",
"ButtonConnect": "Verbinden", "ButtonConnect": "Verbinden",
"ButtonConnectToServer": "Verbind met server", "ButtonConnectToServer": "Verbind met server",
"ButtonCreate": "Creëer", "ButtonCreate": "Aanmaken",
"ButtonCreateBookmark": "Creëer bladwijzer", "ButtonCreateBookmark": "Creëer bladwijzer",
"ButtonCreateNewPlaylist": "Maak nieuwe afspeellijst", "ButtonCreateNewPlaylist": "Maak nieuwe afspeellijst",
"ButtonDelete": "Verwijder", "ButtonDelete": "Verwijder",
@@ -27,9 +27,9 @@
"ButtonLatest": "Meest recent", "ButtonLatest": "Meest recent",
"ButtonLibrary": "Bibliotheek", "ButtonLibrary": "Bibliotheek",
"ButtonLocalMedia": "Lokale Media", "ButtonLocalMedia": "Lokale Media",
"ButtonLogs": "Log bestanden", "ButtonLogs": "Logbestanden",
"ButtonManageLocalFiles": "Beheer Lokale Bestanden", "ButtonManageLocalFiles": "Beheer Lokale Bestanden",
"ButtonMaskServerAddress": "Server adres maskeren", "ButtonMaskServerAddress": "Serveradres maskeren",
"ButtonNewFolder": "Nieuwe map", "ButtonNewFolder": "Nieuwe map",
"ButtonNextEpisode": "Volgende aflevering", "ButtonNextEpisode": "Volgende aflevering",
"ButtonOk": "Akkoord", "ButtonOk": "Akkoord",
@@ -39,13 +39,13 @@
"ButtonPlay": "Afspelen", "ButtonPlay": "Afspelen",
"ButtonPlayEpisode": "Aflevering afspelen", "ButtonPlayEpisode": "Aflevering afspelen",
"ButtonPlaylists": "Afspeellijsten", "ButtonPlaylists": "Afspeellijsten",
"ButtonRead": "Lees", "ButtonRead": "Lezen",
"ButtonReadLess": "Minder lezen", "ButtonReadLess": "Minder lezen",
"ButtonReadMore": "Meer lezen", "ButtonReadMore": "Meer lezen",
"ButtonRemove": "Verwijder", "ButtonRemove": "Verwijder",
"ButtonRemoveFromServer": "Verwijder van server", "ButtonRemoveFromServer": "Verwijder van server",
"ButtonSave": "Opslaan", "ButtonSave": "Opslaan",
"ButtonSaveOrder": "Volgorde bewaren", "ButtonSaveOrder": "Volgorde opslaan",
"ButtonSearch": "Zoeken", "ButtonSearch": "Zoeken",
"ButtonSendEbookToDevice": "Verzend Ebook naar Apparaat", "ButtonSendEbookToDevice": "Verzend Ebook naar Apparaat",
"ButtonSeries": "Series", "ButtonSeries": "Series",
+3 -1
View File
@@ -141,7 +141,7 @@
"LabelEbooks": "Ebooki", "LabelEbooks": "Ebooki",
"LabelEnable": "Włącz", "LabelEnable": "Włącz",
"LabelEnableMp3IndexSeeking": "Włącz wyszukiwanie indeksu mp3", "LabelEnableMp3IndexSeeking": "Włącz wyszukiwanie indeksu mp3",
"LabelEnableMp3IndexSeekingHelp": "To ustawienie powinno być włączone tylko wtedy, gdy masz pliki mp3, które nie są poprawnie wyszukiwane. Niedokładne wyszukiwanie jest najprawdopodobniej spowodowane plikami MP3 o zmiennym birate (VBR). To ustawienie wymusi wyszukiwanie indeksu, w którym mapowanie czasu do bajtu jest kompilowane podczas odczytywania pliku. W niektórych przypadkach w przypadku dużych plików MP3 wystąpi opóźnienie podczas wyszukiwania w kierunku końca pliku.", "LabelEnableMp3IndexSeekingHelp": "To ustawienie powinno być włączone tylko wtedy, gdy masz pliki mp3, które nie są poprawnie wyszukiwane. Niedokładne wyszukiwanie jest najprawdopodobniej spowodowane plikami MP3 o zmiennym bitrate (VBR). To ustawienie wymusi wyszukiwanie indeksu, w którym mapowanie czasu do bajtu jest kompilowane podczas odczytywania pliku. W niektórych przypadkach w przypadku dużych plików MP3 wystąpi opóźnienie podczas wyszukiwania w kierunku końca pliku.",
"LabelEnd": "Zakończ", "LabelEnd": "Zakończ",
"LabelEndOfChapter": "Koniec rozdziału", "LabelEndOfChapter": "Koniec rozdziału",
"LabelEndTime": "Koniec czasu", "LabelEndTime": "Koniec czasu",
@@ -322,7 +322,9 @@
"MessageNoSeries": "Brak serii", "MessageNoSeries": "Brak serii",
"MessageNoUpdatesWereNecessary": "Brak wymaganych aktualizacji", "MessageNoUpdatesWereNecessary": "Brak wymaganych aktualizacji",
"MessageNoUserPlaylists": "Nie masz żadnych list odtwarzania", "MessageNoUserPlaylists": "Nie masz żadnych list odtwarzania",
"MessageOldServerConnectionWarningHelp": "Połączenie z tym serwerem zostało pierwotnie skonfigurowane przed migracją bazy danych w wersji 2.3.0, wydanej w czerwcu 2023 r. Przyszła aktualizacja serwera usunie możliwość logowania się przy użyciu tego starego połączenia. Usuń istniejące połączenie z serwerem i połącz się ponownie (używając tego samego adresu serwera i danych logowania). Jeśli na tym urządzeniu znajdują się pobrane pliki multimedialne, konieczne będzie ich ponowne pobranie w celu synchronizacji z serwerem.",
"MessagePodcastSearchField": "Wprowadź wyszukiwane hasło lub adres URL kanału RSS", "MessagePodcastSearchField": "Wprowadź wyszukiwane hasło lub adres URL kanału RSS",
"MessageProgressSyncFailed": "Ostatnia próba zgłoszenia postępów słuchania do serwera nie powiodła się. Żądania synchronizacji postępu będą wysyłane co 15 sekund do 1 minuty podczas odtwarzania multimediów.",
"MessageReportBugsAndContribute": "Zgłoś błędy, pomysły i pomóż rozwijać aplikację na", "MessageReportBugsAndContribute": "Zgłoś błędy, pomysły i pomóż rozwijać aplikację na",
"MessageSeriesAlreadyDownloaded": "Seria książek już pobrana.", "MessageSeriesAlreadyDownloaded": "Seria książek już pobrana.",
"MessageSeriesDownloadConfirm": "Pobrać serię?", "MessageSeriesDownloadConfirm": "Pobrać serię?",
+1
View File
@@ -146,6 +146,7 @@
"LabelEndOfChapter": "Конец главы", "LabelEndOfChapter": "Конец главы",
"LabelEndTime": "Время конца", "LabelEndTime": "Время конца",
"LabelEpisode": "Эпизод", "LabelEpisode": "Эпизод",
"LabelExplicit": "18+",
"LabelFeedURL": "URL канала", "LabelFeedURL": "URL канала",
"LabelFile": "Файл", "LabelFile": "Файл",
"LabelFileBirthtime": "Дата создания", "LabelFileBirthtime": "Дата создания",
+1
View File
@@ -146,6 +146,7 @@
"LabelEndOfChapter": "Koniec kapitoly", "LabelEndOfChapter": "Koniec kapitoly",
"LabelEndTime": "Čas ukončenia", "LabelEndTime": "Čas ukončenia",
"LabelEpisode": "Epizóda", "LabelEpisode": "Epizóda",
"LabelExplicit": "Explicitný obsah",
"LabelFeedURL": "URL zdroja", "LabelFeedURL": "URL zdroja",
"LabelFile": "Súbor", "LabelFile": "Súbor",
"LabelFileBirthtime": "Čas vytvorenia súboru", "LabelFileBirthtime": "Čas vytvorenia súboru",
+1
View File
@@ -146,6 +146,7 @@
"LabelEndOfChapter": "Konec poglavja", "LabelEndOfChapter": "Konec poglavja",
"LabelEndTime": "Končni čas", "LabelEndTime": "Končni čas",
"LabelEpisode": "Epizoda", "LabelEpisode": "Epizoda",
"LabelExplicit": "Eksplicitno",
"LabelFeedURL": "URL vir", "LabelFeedURL": "URL vir",
"LabelFile": "Datoteka", "LabelFile": "Datoteka",
"LabelFileBirthtime": "Čas ustvarjanja datoteke", "LabelFileBirthtime": "Čas ustvarjanja datoteke",
+1
View File
@@ -146,6 +146,7 @@
"LabelEndOfChapter": "Slut av kapitel", "LabelEndOfChapter": "Slut av kapitel",
"LabelEndTime": "Slut tid", "LabelEndTime": "Slut tid",
"LabelEpisode": "Avsnitt", "LabelEpisode": "Avsnitt",
"LabelExplicit": "Bestämd",
"LabelFeedURL": "URL-adress för flödet", "LabelFeedURL": "URL-adress för flödet",
"LabelFile": "Fil", "LabelFile": "Fil",
"LabelFileBirthtime": "Tidpunkt, fil skapad", "LabelFileBirthtime": "Tidpunkt, fil skapad",
+16 -12
View File
@@ -36,7 +36,7 @@
"ButtonOpenFeed": "Відкрити стрічку", "ButtonOpenFeed": "Відкрити стрічку",
"ButtonOverride": "Перевизначити", "ButtonOverride": "Перевизначити",
"ButtonPause": "Призупинити", "ButtonPause": "Призупинити",
"ButtonPlay": "Слухати", "ButtonPlay": "Відтворити",
"ButtonPlayEpisode": "Слухати епізод", "ButtonPlayEpisode": "Слухати епізод",
"ButtonPlaylists": "Списки відтворення", "ButtonPlaylists": "Списки відтворення",
"ButtonRead": "Читати", "ButtonRead": "Читати",
@@ -112,7 +112,7 @@
"LabelAutoSleepTimerAutoRewind": "Автоперемотування автотаймеру вимкнення", "LabelAutoSleepTimerAutoRewind": "Автоперемотування автотаймеру вимкнення",
"LabelAutoSleepTimerAutoRewindHelp": "Коли сплине автотаймер вимкнення, відтворення знову автоматично перемотає доріжку.", "LabelAutoSleepTimerAutoRewindHelp": "Коли сплине автотаймер вимкнення, відтворення знову автоматично перемотає доріжку.",
"LabelAutoSleepTimerHelp": "Таймер вимкнення автоматично ввімкнеться при відтворенні медіа між вказаним початковим та кінцевим часом.", "LabelAutoSleepTimerHelp": "Таймер вимкнення автоматично ввімкнеться при відтворенні медіа між вказаним початковим та кінцевим часом.",
"LabelBooks": "Книги", "LabelBooks": "Книг",
"LabelChapterTrack": "Прогрес глави", "LabelChapterTrack": "Прогрес глави",
"LabelChapters": "Глави", "LabelChapters": "Глави",
"LabelClosePlayer": "Закрити програвач", "LabelClosePlayer": "Закрити програвач",
@@ -133,7 +133,7 @@
"LabelDisableVibrateOnReset": "Вимкнути вібрацію при скиданні", "LabelDisableVibrateOnReset": "Вимкнути вібрацію при скиданні",
"LabelDisableVibrateOnResetHelp": "Коли таймер вимкнення буде скинуто, ваш пристрій завібрує. Увімкніть цей параметр, щоб не вібрувати при скиданні таймера.", "LabelDisableVibrateOnResetHelp": "Коли таймер вимкнення буде скинуто, ваш пристрій завібрує. Увімкніть цей параметр, щоб не вібрувати при скиданні таймера.",
"LabelDiscover": "Огляд", "LabelDiscover": "Огляд",
"LabelDownload": "Завантажити", "LabelDownload": "Скачати",
"LabelDownloadUsingCellular": "Завантажувати через мобільний інтернет", "LabelDownloadUsingCellular": "Завантажувати через мобільний інтернет",
"LabelDownloaded": "Завантажено", "LabelDownloaded": "Завантажено",
"LabelDuration": "Тривалість", "LabelDuration": "Тривалість",
@@ -146,10 +146,11 @@
"LabelEndOfChapter": "Кінець глави", "LabelEndOfChapter": "Кінець глави",
"LabelEndTime": "Час завершення", "LabelEndTime": "Час завершення",
"LabelEpisode": "Епізод", "LabelEpisode": "Епізод",
"LabelExplicit": "Відвертий",
"LabelFeedURL": "Адреса стрічки", "LabelFeedURL": "Адреса стрічки",
"LabelFile": "Файл", "LabelFile": "Файл",
"LabelFileBirthtime": "Дата створення", "LabelFileBirthtime": "Дата створення файлу",
"LabelFileModified": "Дата змінення", "LabelFileModified": "Дата зміни файлу",
"LabelFilename": "Ім'я файлу", "LabelFilename": "Ім'я файлу",
"LabelFinished": "Завершено", "LabelFinished": "Завершено",
"LabelFolder": "Тека", "LabelFolder": "Тека",
@@ -295,8 +296,8 @@
"MessageDiscardProgress": "Скинути прогрес", "MessageDiscardProgress": "Скинути прогрес",
"MessageDownloadCompleteProcessing": "Завантаження завершено. Обробка...", "MessageDownloadCompleteProcessing": "Завантаження завершено. Обробка...",
"MessageDownloading": "Завантажується...", "MessageDownloading": "Завантажується...",
"MessageDownloadingEpisode": "Завантаження епізоду", "MessageDownloadingEpisode": "Скачування епізоду",
"MessageEpisodesQueuedForDownload": "Епізодів у черзі завантаження: {0}", "MessageEpisodesQueuedForDownload": "{0} епізод(ів) у черзі на завантаження",
"MessageFeedURLWillBe": "URL-адреса каналу буде {0}", "MessageFeedURLWillBe": "URL-адреса каналу буде {0}",
"MessageFetching": "Отримання...", "MessageFetching": "Отримання...",
"MessageFollowTheProjectOnGithub": "Слідкуйте за проєктом на GitHub", "MessageFollowTheProjectOnGithub": "Слідкуйте за проєктом на GitHub",
@@ -305,7 +306,7 @@
"MessageLoading": "Завантаження...", "MessageLoading": "Завантаження...",
"MessageLoadingServerData": "Завантаження даних сервера...", "MessageLoadingServerData": "Завантаження даних сервера...",
"MessageLocalFolderDescription": "«Внутрішня пам’ять програми» доступна лише цій програмі. Ця програма підтримує лише мультимедійні файли, завантажені безпосередньо через програму. Спільні папки для зберігання можна використовувати, щоб надати іншим програмам доступ до медіафайлів, завантажених цією програмою.", "MessageLocalFolderDescription": "«Внутрішня пам’ять програми» доступна лише цій програмі. Ця програма підтримує лише мультимедійні файли, завантажені безпосередньо через програму. Спільні папки для зберігання можна використовувати, щоб надати іншим програмам доступ до медіафайлів, завантажених цією програмою.",
"MessageMarkAsFinished": "Позначити завершеним", "MessageMarkAsFinished": "Позначити як завершене",
"MessageMediaLinkedToADifferentServer": "Медіа пов'язане з Audiobookself-сервером за іншою адресою ({0}). Прогрес буде синхронізовано після підключення до вказаного сервера.", "MessageMediaLinkedToADifferentServer": "Медіа пов'язане з Audiobookself-сервером за іншою адресою ({0}). Прогрес буде синхронізовано після підключення до вказаного сервера.",
"MessageMediaLinkedToADifferentUser": "Медіа пов'язане з цим сервером, але було завантажене іншим користувачем. Прогрес буде синхронізовано лише для користувача, що завантажив медіа.", "MessageMediaLinkedToADifferentUser": "Медіа пов'язане з цим сервером, але було завантажене іншим користувачем. Прогрес буде синхронізовано лише для користувача, що завантажив медіа.",
"MessageMediaLinkedToServer": "Пов'язано з сервером {0}", "MessageMediaLinkedToServer": "Пов'язано з сервером {0}",
@@ -317,13 +318,16 @@
"MessageNoItems": "Елементи відсутні", "MessageNoItems": "Елементи відсутні",
"MessageNoItemsFound": "Елементів не знайдено", "MessageNoItemsFound": "Елементів не знайдено",
"MessageNoListeningSessions": "Сеанси прослуховування відсутні", "MessageNoListeningSessions": "Сеанси прослуховування відсутні",
"MessageNoLogs": "Без журнал", "MessageNoLogs": "Нема журналу",
"MessageNoMediaFolders": "Теки медіа відсутні", "MessageNoMediaFolders": "Теки медіа відсутні",
"MessageNoNetworkConnection": "Немає підключення до мережі", "MessageNoNetworkConnection": "Немає підключення до мережі",
"MessageNoPodcastsFound": "Подкастів не знайдено", "MessageNoPodcastsFound": "Подкастів не знайдено",
"MessageNoSeries": "Серії відсутні", "MessageNoSeries": "Серії відсутні",
"MessageNoUpdatesWereNecessary": "Оновлень не потрібно", "MessageNoUpdatesWereNecessary": "Оновлення не потрібні",
"MessageNoUserPlaylists": "У вас немає списків відтворення", "MessageNoUserPlaylists": "У вас немає списків відтворення",
"MessageOldServerAuthReLoginRequired": "Автентифікацію покращено для безпеки на сервері версії 2.26.0. Усім користувачам потрібно повторно увійти в систему.",
"MessageOldServerAuthWarning": "Сервер використовує застарілу автентифікацію",
"MessageOldServerAuthWarningHelp": "Цей сервер використовує версію, старішу за v2.26.0. У версії 2.26.0 було додано безпечнішу систему автентифікації. Наполегливо рекомендується оновити сервер до останньої версії. Якщо ви вже оновили сервер, увійдіть знову, щоб використовувати нову автентифікацію.",
"MessageOldServerConnectionWarning": "Конфігурація підключення до сервера використовує старий ідентифікатор користувача. Видаліть і додайте це підключення до сервера заново.", "MessageOldServerConnectionWarning": "Конфігурація підключення до сервера використовує старий ідентифікатор користувача. Видаліть і додайте це підключення до сервера заново.",
"MessageOldServerConnectionWarningHelp": "Ви спочатку налаштували з’єднання з цим сервером перед міграцією бази даних у версії 2.3.0, випущеній у червні 2023 року. Майбутнє оновлення сервера скасує можливість входу за допомогою цього старого з’єднання. Будь ласка, видаліть існуюче підключення до сервера та підключіться знову (використовуючи ту саму адресу сервера та облікові дані). Якщо на цьому пристрої є будь-які завантажені медіафайли, їх потрібно буде завантажити ще раз, щоб синхронізувати із сервером.", "MessageOldServerConnectionWarningHelp": "Ви спочатку налаштували з’єднання з цим сервером перед міграцією бази даних у версії 2.3.0, випущеній у червні 2023 року. Майбутнє оновлення сервера скасує можливість входу за допомогою цього старого з’єднання. Будь ласка, видаліть існуюче підключення до сервера та підключіться знову (використовуючи ту саму адресу сервера та облікові дані). Якщо на цьому пристрої є будь-які завантажені медіафайли, їх потрібно буде завантажити ще раз, щоб синхронізувати із сервером.",
"MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS-стрічки", "MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS-стрічки",
@@ -337,8 +341,8 @@
"MessageSocketConnectedOverUnmeteredCellular": "Сокет під'єднано до безлімітної мобільної мережі", "MessageSocketConnectedOverUnmeteredCellular": "Сокет під'єднано до безлімітної мобільної мережі",
"MessageSocketConnectedOverUnmeteredWifi": "Сокет під'єднано до безлімітної мережі Wi-Fi", "MessageSocketConnectedOverUnmeteredWifi": "Сокет під'єднано до безлімітної мережі Wi-Fi",
"MessageSocketNotConnected": "Сокет не під'єднано", "MessageSocketNotConnected": "Сокет не під'єднано",
"NoteRSSFeedPodcastAppsHttps": "Попередження: Більшість додатків подкастів вимагатимуть використання протоколу HTTPS від RSS-каналу", "NoteRSSFeedPodcastAppsHttps": "Попередження: більшість додатків подкастів вимагають використання HTTPS для RSS-каналу",
"NoteRSSFeedPodcastAppsPubDate": "Попередження: 1 або більше ваших епізодів не мають дати публікації. Деякі додатки подкастів вимагають це.", "NoteRSSFeedPodcastAppsPubDate": "Попередження: один або більше ваших епізодів не мають дати публікації. Деякі додатки подкастів цього вимагають.",
"ToastBookmarkCreateFailed": "Не вдалося створити закладку", "ToastBookmarkCreateFailed": "Не вдалося створити закладку",
"ToastBookmarkRemoveFailed": "Не вдалося видалити закладку", "ToastBookmarkRemoveFailed": "Не вдалося видалити закладку",
"ToastBookmarkUpdateFailed": "Не вдалося оновити закладку", "ToastBookmarkUpdateFailed": "Не вдалося оновити закладку",
+12 -8
View File
@@ -29,7 +29,7 @@
"ButtonLocalMedia": "本地媒体", "ButtonLocalMedia": "本地媒体",
"ButtonLogs": "日志", "ButtonLogs": "日志",
"ButtonManageLocalFiles": "管理本地文件", "ButtonManageLocalFiles": "管理本地文件",
"ButtonMaskServerAddress": "屏蔽服务器地址", "ButtonMaskServerAddress": "隐藏服务器地址",
"ButtonNewFolder": "新建文件夹", "ButtonNewFolder": "新建文件夹",
"ButtonNextEpisode": "下一集", "ButtonNextEpisode": "下一集",
"ButtonOk": "确定", "ButtonOk": "确定",
@@ -40,7 +40,7 @@
"ButtonPlayEpisode": "播放剧集", "ButtonPlayEpisode": "播放剧集",
"ButtonPlaylists": "播放列表", "ButtonPlaylists": "播放列表",
"ButtonRead": "读取", "ButtonRead": "读取",
"ButtonReadLess": "阅读较少", "ButtonReadLess": "收起",
"ButtonReadMore": "阅读更多", "ButtonReadMore": "阅读更多",
"ButtonRemove": "移除", "ButtonRemove": "移除",
"ButtonRemoveFromServer": "从服务器中删除", "ButtonRemoveFromServer": "从服务器中删除",
@@ -53,7 +53,7 @@
"ButtonStream": "串流播放", "ButtonStream": "串流播放",
"ButtonSubmit": "提交", "ButtonSubmit": "提交",
"ButtonSwitchServerUser": "切换服务器/用户", "ButtonSwitchServerUser": "切换服务器/用户",
"ButtonUnmaskServerAddress": "取消屏蔽服务器地址", "ButtonUnmaskServerAddress": "显示服务器地址",
"ButtonUserStats": "用户统计信息", "ButtonUserStats": "用户统计信息",
"ButtonYes": "确定", "ButtonYes": "确定",
"HeaderAccount": "帐户", "HeaderAccount": "帐户",
@@ -81,7 +81,7 @@
"HeaderPlaylistItems": "播放列表项目", "HeaderPlaylistItems": "播放列表项目",
"HeaderProgressSyncFailed": "进度同步失败", "HeaderProgressSyncFailed": "进度同步失败",
"HeaderRSSFeed": "RSS 源", "HeaderRSSFeed": "RSS 源",
"HeaderRSSFeedGeneral": "RSS 源概述", "HeaderRSSFeedGeneral": "RSS 详细信息",
"HeaderRSSFeedIsOpen": "RSS 源已打开", "HeaderRSSFeedIsOpen": "RSS 源已打开",
"HeaderSelectDownloadLocation": "选择下载位置", "HeaderSelectDownloadLocation": "选择下载位置",
"HeaderSettings": "设置", "HeaderSettings": "设置",
@@ -146,6 +146,7 @@
"LabelEndOfChapter": "章节结束", "LabelEndOfChapter": "章节结束",
"LabelEndTime": "结束时间", "LabelEndTime": "结束时间",
"LabelEpisode": "剧集", "LabelEpisode": "剧集",
"LabelExplicit": "含成人内容",
"LabelFeedURL": "源 URL", "LabelFeedURL": "源 URL",
"LabelFile": "文件", "LabelFile": "文件",
"LabelFileBirthtime": "文件创建时间", "LabelFileBirthtime": "文件创建时间",
@@ -246,11 +247,11 @@
"LabelSleepTimerAlmostDoneChimeHelp": "当睡眠定时器还剩30秒时播放铃声", "LabelSleepTimerAlmostDoneChimeHelp": "当睡眠定时器还剩30秒时播放铃声",
"LabelStart": "开始", "LabelStart": "开始",
"LabelStartTime": "开始时间", "LabelStartTime": "开始时间",
"LabelStatsBestDay": "最好的一天", "LabelStatsBestDay": "单日最高",
"LabelStatsDailyAverage": "每日平均值", "LabelStatsDailyAverage": "每日平均值",
"LabelStatsDays": "", "LabelStatsDays": "连续",
"LabelStatsDaysListened": "收听天数", "LabelStatsDaysListened": "收听天数",
"LabelStatsInARow": "在一行", "LabelStatsInARow": "",
"LabelStatsItemsFinished": "已完成的项目", "LabelStatsItemsFinished": "已完成的项目",
"LabelStatsMinutes": "分钟", "LabelStatsMinutes": "分钟",
"LabelStatsMinutesListening": "收听分钟数", "LabelStatsMinutesListening": "收听分钟数",
@@ -324,11 +325,14 @@
"MessageNoSeries": "没有系列", "MessageNoSeries": "没有系列",
"MessageNoUpdatesWereNecessary": "无需更新", "MessageNoUpdatesWereNecessary": "无需更新",
"MessageNoUserPlaylists": "你没有播放列表", "MessageNoUserPlaylists": "你没有播放列表",
"MessageOldServerAuthReLoginRequired": "服务器 v2.26.0 中的身份验证已得到改进, 以提高安全性. 所有用户都需要重新登录.",
"MessageOldServerAuthWarning": "服务器正在使用过时的身份验证",
"MessageOldServerAuthWarningHelp": "此服务器运行的版本早于 v2.26.0. 在 v2.26.0 中添加了更安全的身份验证系统. 强烈建议将服务器更新到最新版本. 如果您已经更新了服务器, 请重新登录以使用新的身份验证.",
"MessageOldServerConnectionWarning": "服务器连接配置正在使用旧的用户 ID. 请删除并重新添加此服务器连接.", "MessageOldServerConnectionWarning": "服务器连接配置正在使用旧的用户 ID. 请删除并重新添加此服务器连接.",
"MessageOldServerConnectionWarningHelp": "您最初在 2023 年 6 月发布的 2.3.0 版本中进行数据库迁移之前设置了与此服务器的连接. 未来的服务器更新将取消使用此旧连接登录的功能. 请删除现有的服务器连接并重新连接 (使用相同的服务器地址和凭据). 如果您在此设备上有任何下载的媒体, 则需要再次下载该媒体才能与服务器同步.", "MessageOldServerConnectionWarningHelp": "您最初在 2023 年 6 月发布的 2.3.0 版本中进行数据库迁移之前设置了与此服务器的连接. 未来的服务器更新将取消使用此旧连接登录的功能. 请删除现有的服务器连接并重新连接 (使用相同的服务器地址和凭据). 如果您在此设备上有任何下载的媒体, 则需要再次下载该媒体才能与服务器同步.",
"MessagePodcastSearchField": "输入搜索词或 RSS 源 URL", "MessagePodcastSearchField": "输入搜索词或 RSS 源 URL",
"MessageProgressSyncFailed": "向服务器报告收听进度的最新尝试失败. 播放媒体时, 将继续每 15 秒至 1 分钟尝试一次进度同步请求.", "MessageProgressSyncFailed": "向服务器报告收听进度的最新尝试失败. 播放媒体时, 将继续每 15 秒至 1 分钟尝试一次进度同步请求.",
"MessageReportBugsAndContribute": "报告错误、请求功能和贡献在", "MessageReportBugsAndContribute": "反馈问题, 建议功能或参与贡献, 请访问",
"MessageSeriesAlreadyDownloaded": "你已下载此系列的所有书籍.", "MessageSeriesAlreadyDownloaded": "你已下载此系列的所有书籍.",
"MessageSeriesDownloadConfirm": "将缺失的 {0} 本图书和 {1} 个文件 (共 {2} 个) 下载至文件夹 {3}?", "MessageSeriesDownloadConfirm": "将缺失的 {0} 本图书和 {1} 个文件 (共 {2} 个) 下载至文件夹 {3}?",
"MessageSeriesDownloadConfirmIos": "是否下载缺失的 {0} 本书和 {1} 个文件,总计 {2} 个?", "MessageSeriesDownloadConfirmIos": "是否下载缺失的 {0} 本书和 {1} 个文件,总计 {2} 个?",